diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..87b0992 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +### Overview + +TaskFlow is a Next.js 16 project management SaaS (single app, no monorepo). Tech stack: Next.js 16 + Turbopack, React 19, Tailwind CSS v4, Shadcn UI, PostgreSQL, pnpm. + +### Local database setup + +A local PostgreSQL 16 instance is used in place of Neon's serverless PostgreSQL. The database is pre-seeded with mock `neon_auth` schema tables, a sample organization, user, project, and tasks. RLS is disabled locally. + +- **Start PostgreSQL** (if not running): `sudo pg_ctlcluster 16 main start` +- **Connection**: `postgresql://taskflow:taskflow@localhost:5432/taskflow` +- **Schema**: `scripts/01-create-schema.sql` (requires `neon_auth` schema tables created first) + +### Auth stub + +The `@stack-auth/nextjs` package does not exist on npm. A local stub at `stubs/stack-auth-nextjs/` provides mock `currentUser()` and `useUser()` functions that return a hardcoded dev user (UUID `00000000-0000-0000-0000-000000000002`, org `00000000-0000-0000-0000-000000000001`). The stub uses `react-server` conditional exports to properly separate server/client code. + +### DB module (`lib/db.ts`) + +Exports two database interfaces: +- `sql(query, params)` — function-call style, returns `rows` array (used by `lib/queries.ts`) +- `db` — `pg.Pool` instance with `db.query()` (used by API routes under `app/api/`) + +Both use the local PostgreSQL via the `pg` package. The original `@neondatabase/serverless` neon driver is not used locally. + +### Common commands + +| Task | Command | +|------|---------| +| Install deps | `pnpm install` | +| Dev server | `pnpm dev` (port 3000) | +| Lint | `pnpm lint` | +| Build | `pnpm build` | +| Production | `pnpm start` | + +### Gotchas + +- The `@neondatabase/serverless` package is listed in `package.json` but not used at runtime locally. The `lib/db.ts` module wraps everything through `pg.Pool`. +- Some API routes (activity, analytics, members, attachments) import `{ db }` from `@/lib/db` and use `db.query()`. Other code (in `lib/queries.ts`) imports `{ sql }` and calls `sql()` as a function. Both patterns are supported. +- The ESLint config (`eslint.config.mjs`) uses `@eslint/js` with basic recommended rules. It doesn't include the full `next/core-web-vitals` config due to ESLint 9 compatibility issues. +- `next.config.mjs` has `typescript: { ignoreBuildErrors: true }`, so TypeScript errors won't block builds. +- There is no root page (`app/page.tsx`). The app entry point is `/dashboard`. diff --git a/env-agent-finder/.gitignore b/env-agent-finder/.gitignore new file mode 100644 index 0000000..aafcb34 --- /dev/null +++ b/env-agent-finder/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +*.log diff --git a/env-agent-finder/README.md b/env-agent-finder/README.md new file mode 100644 index 0000000..1ae7154 --- /dev/null +++ b/env-agent-finder/README.md @@ -0,0 +1,213 @@ +# env-agent-finder + +A zero-dependency CLI tool that scans any codebase to discover: + +- **Environment variables** — finds every `process.env.*` reference, `.env` file, and maps them to known services +- **API routes** — detects Next.js App/Pages Router, Express, FastAPI, and Flask endpoints +- **Service dependencies** — identifies databases, auth providers, storage, payments, AI, email, and more + +Built for AI agents and developers who need to quickly understand what a project needs to run. + +## Quick Start + +```bash +# Scan current directory +node src/cli.js + +# Scan a specific project +node src/cli.js --target /path/to/project + +# Show env values (masked for security) +node src/cli.js --target ./my-app --show-values + +# Show full unmasked values +node src/cli.js --target ./my-app --unmask + +# Output as Markdown +node src/cli.js --target ./my-app --format markdown --output report.md + +# Output as JSON (with values) +node src/cli.js --format json --show-values +``` + +## Installation + +No dependencies required — just clone and run: + +```bash +git clone https://github.com/DealPatrol/env-agent-finder.git +cd env-agent-finder +node src/cli.js --target /path/to/your/project +``` + +Or use npx (after publishing): + +```bash +npx env-agent-finder --target ./my-project +``` + +## Output Modes + +### Default — summary table +``` +🔑 ENVIRONMENT VARIABLES (5 found) + Variable Service Refs Status + DATABASE_URL Database (PostgreSQL) 3 ✅ Set + BLOB_READ_WRITE_TOKEN Vercel Blob Storage 1 ⚪ Optional + STACK_PROJECT_ID Stack Auth 1 ❌ Missing + + 💡 Use --show-values to see values, or --unmask for full values. +``` + +### `--show-values` — masked values with source +``` + ✅ DATABASE_URL + Service: Database (PostgreSQL/MySQL) + Refs: 3 reference(s) in code + Value: post••••••••••••••••••••••••••••••flow + Source: .env.local + + ❌ BLOB_READ_WRITE_TOKEN + Service: Vercel Blob Storage + Refs: 1 reference(s) in code + Value: (not set) +``` + +### `--unmask` — full raw values +``` + ✅ DATABASE_URL + Service: Database (PostgreSQL/MySQL) + Refs: 3 reference(s) in code + Value: postgresql://user:pass@host:5432/mydb + Source: .env.local + +📄 CURRENT .env VALUES + DATABASE_URL=postgresql://user:pass@host:5432/mydb + STACK_PROJECT_ID=proj_abc123 +``` + +## Options + +| Flag | Description | +|------|-------------| +| `--target ` | Directory to scan (default: current directory) | +| `--show-values`, `-v` | Show env variable values (masked by default) | +| `--unmask` | Show full unmasked values (implies `--show-values`) | +| `--format ` | Output format: `terminal`, `json`, `markdown` (default: `terminal`) | +| `--output ` | Write output to file instead of stdout | +| `-h`, `--help` | Show help message | + +## What It Detects + +### Environment Variables +- `process.env.VAR_NAME` (Node.js) +- `os.environ` / `os.getenv()` (Python) +- `import.meta.env.VAR_NAME` (Vite) +- `.env`, `.env.local`, `.env.example` files +- 50+ well-known service variable mappings + +### API Routes +- Next.js App Router (`app/api/**/route.ts`) +- Next.js Pages Router (`pages/api/**/*.ts`) +- Express / Fastify (`app.get()`, `router.post()`, etc.) +- FastAPI / Flask (`@app.get()`, `@router.post()`, etc.) + +### Services (30+ supported) +| Category | Services | +|----------|----------| +| Database | PostgreSQL, MySQL, MongoDB, Supabase, Firebase | +| Auth | NextAuth, Clerk, Stack Auth, Auth.js | +| Storage | Vercel Blob, AWS S3, Cloudinary | +| Payments | Stripe | +| AI | OpenAI, Anthropic | +| Email | SendGrid, Resend, SMTP | +| Monitoring | Sentry | +| Cache | Redis, Upstash | +| Messaging | Twilio | + +## Setup Mode — Auto-Provision Your `.env` + +The killer feature: point it at any project and it **builds your `.env.local` for you**. + +### `--setup` (Interactive) + +Scans the project, auto-generates secrets and DB URLs, then walks you through each external service with step-by-step signup instructions and prompts you to paste your keys: + +```bash +node src/cli.js --target ./my-project --setup +``` + +``` +════════════════════════════════════════════════════════════ + ENV AGENT FINDER — Setup Mode +════════════════════════════════════════════════════════════ + + Found 8 environment variable(s) needed. + Detected 4 service(s): PostgreSQL, Stripe, OpenAI, Auth.js + +⚡ AUTO-GENERATING values... +──────────────────────────────────────── + ✅ DATABASE_URL = postgresql://postgres:postgres@localhost:5432/app_dev + ✅ NEXTAUTH_SECRET = a8Kx9mP2qR7... + ✅ NEXTAUTH_URL = http://localhost:PORT + +🔑 STRIPE +──────────────────────────────────────── + How to get your keys: + 1. Go to https://dashboard.stripe.com/register and create an account + 2. Go to https://dashboard.stripe.com/apikeys + 3. Copy your Publishable key → STRIPE_PUBLISHABLE_KEY + 4. Copy your Secret key → STRIPE_SECRET_KEY + + Enter STRIPE_SECRET_KEY (or press Enter to skip): sk_test_abc123 + ✅ STRIPE_SECRET_KEY saved + +🔑 OPENAI +──────────────────────────────────────── + How to get your keys: + 1. Go to https://platform.openai.com/api-keys + 2. Click 'Create new secret key' + + Enter OPENAI_API_KEY (or press Enter to skip): sk-proj-abc123 + ✅ OPENAI_API_KEY saved + +════════════════════════════════════════════════════════════ + SETUP COMPLETE — Written to: ./my-project/.env.local +════════════════════════════════════════════════════════════ +``` + +### `--auto` (Non-Interactive) + +Auto-generates everything it can (secrets, database URLs) without prompting. Perfect for CI or AI agents: + +```bash +node src/cli.js --target ./my-project --auto +``` + +### What Gets Auto-Generated vs What You Need to Provide + +| Type | Example Variables | How It's Handled | +|------|-------------------|------------------| +| **Secrets** | `NEXTAUTH_SECRET`, `JWT_SECRET`, `AUTH_SECRET` | Auto-generated (random 48-char string) | +| **Database URLs** | `DATABASE_URL`, `POSTGRES_URL`, `MONGODB_URI` | Auto-generated (local dev defaults) | +| **App URLs** | `NEXTAUTH_URL`, `APP_URL` | Auto-generated (localhost dev URL) | +| **API Keys** | `STRIPE_SECRET_KEY`, `OPENAI_API_KEY` | Interactive prompt with signup instructions | +| **OAuth Creds** | `GOOGLE_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Interactive prompt with setup guide | +| **Existing Values** | Any var already in `.env` | Kept as-is (never overwritten) | + +### Supported Services (with signup guides) + +Stripe, OpenAI, Anthropic, Clerk, Stack Auth, Supabase, Firebase, Vercel Blob, AWS S3, SendGrid, Resend, Sentry, Google OAuth, GitHub OAuth, Twilio, Cloudinary, Upstash Redis + +## Use Cases + +- **New project setup**: Clone a repo, run `--setup`, get a working `.env.local` in 60 seconds +- **AI Agents**: Run `--auto` to provision env vars without human interaction +- **Onboarding**: New developer? Run `--setup` and follow the guided prompts +- **CI/CD**: Validate that all required secrets are configured with scan mode +- **Documentation**: Auto-generate env var docs with `--format markdown` +- **Security Audits**: Find all external service dependencies and API key usage + +## License + +MIT diff --git a/env-agent-finder/package.json b/env-agent-finder/package.json new file mode 100644 index 0000000..fc9cb1e --- /dev/null +++ b/env-agent-finder/package.json @@ -0,0 +1,36 @@ +{ + "name": "env-agent-finder", + "version": "2.0.0", + "description": "CLI + Web tool that scans projects, discovers env vars, auto-generates secrets, and grabs API keys from service dashboards via browser automation.", + "main": "src/index.js", + "bin": { + "env-agent-finder": "src/cli.js" + }, + "scripts": { + "start": "node src/server.js", + "app": "node src/server.js", + "scan": "node src/cli.js", + "grab": "node src/cli.js --grab", + "setup": "node src/cli.js --setup", + "generate-key": "node src/cli.js --generate-key", + "test": "node src/cli.js --target ." + }, + "optionalDependencies": { + "puppeteer": "^24.0.0" + }, + "keywords": [ + "env", + "environment", + "api", + "scanner", + "agent", + "devops", + "cli", + "browser-automation", + "api-keys" + ], + "license": "MIT", + "engines": { + "node": ">=18" + } +} diff --git a/env-agent-finder/public/index.html b/env-agent-finder/public/index.html new file mode 100644 index 0000000..134650d --- /dev/null +++ b/env-agent-finder/public/index.html @@ -0,0 +1,690 @@ + + + + + +env-agent-finder — Discover env vars for any project + + + + + +
+
+

env-agent-finder

+

Drop your project files or paste a GitHub URL to discover every env variable, API route, and service dependency.

+
+ +
+ + +
+ + +
+
+
📂
+
Drop your project files here
+
or click to browse
+
package.json, .env, .env.local, .env.example — or drag an entire folder
+
+ +
+
+ +
+
+ + +
+
+ + +
+
Fetches package.json and .env.example from the repo's default branch via the GitHub API.
+
+ +
+
+
Analyzing project...
+
+ +
+
+

📦 Project Info

+
+
+
+

🔑 Environment Variables 0

+
+
+
+

🔌 Detected Services 0

+
+
+
+

🌐 API Routes 0

+
+
+
+ + + +
+
+
+ +
+ + + + diff --git a/env-agent-finder/src/app.html b/env-agent-finder/src/app.html new file mode 100644 index 0000000..8905306 --- /dev/null +++ b/env-agent-finder/src/app.html @@ -0,0 +1,738 @@ + + + + + +env-agent-finder + + + + +
+
+

env-agent-finder

+

Scan any project. Find every env variable. Fill in the values. Get your .env.local.

+
+ +
+ +
+ + +
+
+ + + +
+ +
+
+

📦 Project Info

+
+
+
+
+
+ + +
+
+

🔑 Environment Variables 0

+
+
+
+ + +
+
+

🔌 Detected Services 0

+
+
+
+ + +
+
+

🌐 API Routes 0

+
+
+
+ + +
+ + + +
+
+
+ +
+ + + + + diff --git a/env-agent-finder/src/cli.js b/env-agent-finder/src/cli.js new file mode 100755 index 0000000..911989d --- /dev/null +++ b/env-agent-finder/src/cli.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node + +const path = require("path"); +const fs = require("fs"); +const { scanProject } = require("./index"); +const { renderReport } = require("./reporter"); +const { provisionEnv } = require("./provisioner"); +const { isLicensed, activateLicense, deactivateLicense, getLicenseInfo, generateLicenseKey } = require("./license"); + +const args = process.argv.slice(2); + +let targetDir = process.cwd(); +let outputFormat = "terminal"; +let outputFile = null; +let showValues = false; +let unmask = false; +let setupMode = false; +let autoOnly = false; +let grabMode = false; +let licenseCmd = null; +let licenseKey = null; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--target" && args[i + 1]) { + targetDir = path.resolve(args[i + 1]); i++; + } else if (args[i] === "--format" && args[i + 1]) { + outputFormat = args[i + 1]; i++; + } else if (args[i] === "--output" && args[i + 1]) { + outputFile = path.resolve(args[i + 1]); i++; + } else if (args[i] === "--show-values" || args[i] === "-v") { + showValues = true; + } else if (args[i] === "--unmask") { + showValues = true; unmask = true; + } else if (args[i] === "--setup" || args[i] === "-s") { + setupMode = true; + } else if (args[i] === "--auto") { + setupMode = true; autoOnly = true; + } else if (args[i] === "--grab" || args[i] === "-g") { + grabMode = true; + } else if (args[i] === "--activate" && args[i + 1]) { + licenseCmd = "activate"; licenseKey = args[i + 1]; i++; + } else if (args[i] === "--deactivate") { + licenseCmd = "deactivate"; + } else if (args[i] === "--license") { + licenseCmd = "info"; + } else if (args[i] === "--generate-key") { + licenseCmd = "generate"; + } else if (args[i] === "--help" || args[i] === "-h") { + console.log(` +env-agent-finder — Scan, setup, and grab env vars for any project. + +SCAN MODE (default — free): + --target Directory to scan (default: current directory) + --format Output: terminal, json, markdown + --output Write to file + --show-values, -v Show values (masked) + --unmask Show full values + +SETUP MODE (free): + --setup, -s Interactive setup + auto-generate secrets + --auto Non-interactive auto-generate only + +GRAB MODE (Pro — requires license): + --grab, -g Open browser, log into services, grab API keys automatically + Scans project → identifies services → opens each dashboard → + extracts or guides you through getting each API key + +LICENSE: + --activate Activate a license key + --deactivate Remove license + --license Show license info + +Examples: + env-agent-finder --target ./my-project # Free scan + env-agent-finder --target ./my-project --setup # Free interactive setup + env-agent-finder --target ./my-project --grab # Pro: browser grab + env-agent-finder --activate EAF-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX +`); + process.exit(0); + } else if (!args[i].startsWith("--")) { + targetDir = path.resolve(args[i]); + } +} + +async function handleLicense() { + if (licenseCmd === "activate") { + const result = activateLicense(licenseKey); + if (result.success) { + console.log("\n ✅ License activated! You now have access to --grab mode.\n"); + } else { + console.log(`\n ❌ ${result.error}\n`); + } + process.exit(0); + } + + if (licenseCmd === "deactivate") { + deactivateLicense(); + console.log("\n ✅ License deactivated.\n"); + process.exit(0); + } + + if (licenseCmd === "info") { + const info = getLicenseInfo(); + if (info) { + console.log(`\n 🔑 License: ${info.key}`); + console.log(` 📅 Activated: ${info.activated}\n`); + } else { + console.log("\n No license activated. Use --activate to activate.\n"); + } + process.exit(0); + } + + if (licenseCmd === "generate") { + const key = generateLicenseKey(); + console.log(`\n 🔑 Generated license key: ${key}\n`); + console.log(" Give this key to your customer. They activate it with:"); + console.log(` env-agent-finder --activate ${key}\n`); + process.exit(0); + } +} + +async function runGrab() { + if (!isLicensed()) { + console.log(""); + console.log(" ═══════════════════════════════════════════════════"); + console.log(" ⚡ GRAB MODE requires a Pro license"); + console.log(" ═══════════════════════════════════════════════════"); + console.log(""); + console.log(" Grab mode opens a real browser, logs into each service,"); + console.log(" and extracts API keys directly from the dashboards."); + console.log(""); + console.log(" 🛒 Get a license at: https://your-store-url.com"); + console.log(""); + console.log(" Already have a key? Activate it:"); + console.log(" env-agent-finder --activate EAF-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX"); + console.log(""); + process.exit(1); + } + + const { findGrabbersForServices } = require("./grabbers"); + const { closeBrowser } = require("./grabbers/browser"); + + console.log(`\n🔍 Scanning: ${targetDir}\n`); + const report = await scanProject(targetDir); + + const missingVars = report.envVars.filter((v) => !v.hasValue && v.required !== false); + const neededGrabbers = findGrabbersForServices(report.services, missingVars); + + if (neededGrabbers.length === 0) { + console.log(" ✅ No missing env vars that require browser automation."); + console.log(" All required variables are already set or can be auto-generated."); + console.log(" Run --setup to auto-fill the rest.\n"); + return; + } + + console.log("═".repeat(60)); + console.log(" ENV AGENT FINDER — Grab Mode (Pro)"); + console.log("═".repeat(60)); + console.log(""); + console.log(` Found ${missingVars.length} missing variable(s) across ${neededGrabbers.length} service(s):`); + for (const { grabber, relevantVars } of neededGrabbers) { + console.log(` • ${grabber.name}: ${relevantVars.join(", ")}`); + } + console.log(""); + console.log(" A browser will open for each service. Log in, and the tool"); + console.log(" will navigate to the right page and help you grab the keys."); + console.log(""); + + const allGrabbed = {}; + + for (const { grabber } of neededGrabbers) { + console.log(`\n${"─".repeat(50)}`); + console.log(` 🔌 ${grabber.name.toUpperCase()}`); + console.log(`${"─".repeat(50)}`); + + try { + const grabbed = await grabber.grab(); + Object.assign(allGrabbed, grabbed); + } catch (err) { + console.log(` ❌ Error with ${grabber.name}: ${err.message}`); + } + } + + await closeBrowser(); + + // Merge grabbed values with existing + const existingEnv = {}; + for (const v of report.envVars) { + if (v.value) existingEnv[v.name] = v.value; + } + + const finalValues = { ...existingEnv, ...allGrabbed }; + + // Run the provisioner with grabbed values pre-filled + console.log(""); + console.log("═".repeat(60)); + console.log(" WRITING .env.local"); + console.log("═".repeat(60)); + + // Inject grabbed values into the report + for (const v of report.envVars) { + if (allGrabbed[v.name]) { + v.value = allGrabbed[v.name]; + v.hasValue = true; + } + } + + await provisionEnv(targetDir, report, { interactive: false, autoOnly: true }); + + // Overwrite with the grabbed values + const envPath = path.join(targetDir, ".env.local"); + if (fs.existsSync(envPath)) { + let content = fs.readFileSync(envPath, "utf-8"); + for (const [key, val] of Object.entries(allGrabbed)) { + const regex = new RegExp(`^${key}=.*$`, "m"); + if (regex.test(content)) { + content = content.replace(regex, `${key}=${val}`); + } else { + content += `\n${key}=${val}`; + } + } + fs.writeFileSync(envPath, content, "utf-8"); + } + + const grabbedCount = Object.keys(allGrabbed).length; + console.log(""); + console.log(` 🎯 Grabbed ${grabbedCount} value(s) from browser sessions.`); + console.log(` 📄 Saved to: ${envPath}`); + console.log(""); +} + +async function main() { + if (licenseCmd) { + await handleLicense(); + return; + } + + if (grabMode) { + await runGrab(); + return; + } + + console.log(`\n🔍 Scanning: ${targetDir}\n`); + + try { + const report = await scanProject(targetDir); + + if (setupMode) { + await provisionEnv(targetDir, report, { interactive: !autoOnly, autoOnly }); + } else { + const output = renderReport(report, outputFormat, { showValues, unmask }); + + if (outputFile) { + fs.writeFileSync(outputFile, output, "utf-8"); + console.log(`\n✅ Report written to: ${outputFile}`); + } else { + console.log(output); + } + } + } catch (err) { + console.error(`\n❌ Error scanning project: ${err.message}`); + process.exit(1); + } +} + +main(); diff --git a/env-agent-finder/src/grabbers/anthropic.js b/env-agent-finder/src/grabbers/anthropic.js new file mode 100644 index 0000000..8e89bb0 --- /dev/null +++ b/env-agent-finder/src/grabbers/anthropic.js @@ -0,0 +1,35 @@ +const { openPage, waitForUserLogin, askUser } = require("./browser"); + +module.exports = { + name: "Anthropic", + vars: ["ANTHROPIC_API_KEY"], + + async grab() { + const results = {}; + + console.log(" 📍 Opening Anthropic console..."); + const page = await openPage("https://console.anthropic.com/login"); + + await waitForUserLogin(page, null, "Anthropic"); + + console.log(" 📍 Navigating to API keys..."); + await page.goto("https://console.anthropic.com/settings/keys", { waitUntil: "networkidle2", timeout: 30000 }); + await page.waitForTimeout(3000); + + console.log(""); + console.log(" 💡 In the browser window:"); + console.log(" 1. Click 'Create Key'"); + console.log(" 2. Copy the key that appears"); + console.log(" 3. Paste it below"); + console.log(""); + + const key = await askUser(" Paste your ANTHROPIC_API_KEY (or Enter to skip): "); + if (key) { + results.ANTHROPIC_API_KEY = key; + console.log(" ✅ ANTHROPIC_API_KEY saved"); + } + + await page.close(); + return results; + }, +}; diff --git a/env-agent-finder/src/grabbers/browser.js b/env-agent-finder/src/grabbers/browser.js new file mode 100644 index 0000000..ae16fe6 --- /dev/null +++ b/env-agent-finder/src/grabbers/browser.js @@ -0,0 +1,105 @@ +const puppeteer = require("puppeteer"); +const readline = require("readline"); + +let browser = null; + +async function launchBrowser() { + if (browser) return browser; + + browser = await puppeteer.launch({ + headless: false, + defaultViewport: { width: 1280, height: 800 }, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--window-size=1280,800", + ], + }); + + browser.on("disconnected", () => { browser = null; }); + return browser; +} + +async function closeBrowser() { + if (browser) { + await browser.close(); + browser = null; + } +} + +async function openPage(url) { + const b = await launchBrowser(); + const page = await b.newPage(); + await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 }); + return page; +} + +async function waitForUserLogin(page, checkSelector, serviceName) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + + console.log(""); + console.log(` 🔐 ${serviceName}: Please log in using the browser window.`); + console.log(` Once you're logged in, press ENTER here to continue...`); + console.log(""); + + await new Promise((resolve) => { + rl.question(" Press ENTER when logged in > ", () => { + rl.close(); + resolve(); + }); + }); + + if (checkSelector) { + try { + await page.waitForSelector(checkSelector, { timeout: 5000 }); + } catch { + // User said they're logged in, trust them + } + } +} + +async function extractText(page, selector, timeout = 5000) { + try { + await page.waitForSelector(selector, { timeout }); + return await page.$eval(selector, (el) => el.textContent.trim()); + } catch { + return null; + } +} + +async function extractInputValue(page, selector, timeout = 5000) { + try { + await page.waitForSelector(selector, { timeout }); + return await page.$eval(selector, (el) => el.value || el.textContent?.trim()); + } catch { + return null; + } +} + +async function clickAndWait(page, selector, waitMs = 2000) { + try { + await page.click(selector); + await page.waitForTimeout(waitMs); + } catch {} +} + +async function askUser(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +module.exports = { + launchBrowser, + closeBrowser, + openPage, + waitForUserLogin, + extractText, + extractInputValue, + clickAndWait, + askUser, +}; diff --git a/env-agent-finder/src/grabbers/clerk.js b/env-agent-finder/src/grabbers/clerk.js new file mode 100644 index 0000000..0382700 --- /dev/null +++ b/env-agent-finder/src/grabbers/clerk.js @@ -0,0 +1,64 @@ +const { openPage, waitForUserLogin, askUser } = require("./browser"); + +module.exports = { + name: "Clerk", + vars: ["CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"], + + async grab() { + const results = {}; + + console.log(" 📍 Opening Clerk dashboard..."); + const page = await openPage("https://dashboard.clerk.com/sign-in"); + + await waitForUserLogin(page, null, "Clerk"); + + console.log(" 📍 Looking for API keys..."); + await page.waitForTimeout(2000); + + // Try to navigate to API keys + try { + const extracted = await page.evaluate(() => { + const text = document.body.innerText; + const result = {}; + + const pkMatch = text.match(/(pk_(?:test|live)_[A-Za-z0-9]+)/); + if (pkMatch) result.publishable = pkMatch[1]; + + const skMatch = text.match(/(sk_(?:test|live)_[A-Za-z0-9]+)/); + if (skMatch) result.secret = skMatch[1]; + + return result; + }); + + if (extracted.publishable) { + results.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = extracted.publishable; + console.log(` ✅ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = ${extracted.publishable.substring(0, 20)}...`); + } + + if (extracted.secret) { + results.CLERK_SECRET_KEY = extracted.secret; + console.log(` ✅ CLERK_SECRET_KEY = ${extracted.secret.substring(0, 15)}...`); + } + } catch {} + + console.log(""); + console.log(" 💡 In the Clerk dashboard:"); + console.log(" 1. Select your application"); + console.log(" 2. Go to 'API Keys' in the sidebar"); + console.log(" 3. Copy the keys shown"); + console.log(""); + + if (!results.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) { + const pk = await askUser(" Paste NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (or Enter to skip): "); + if (pk) results.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = pk; + } + + if (!results.CLERK_SECRET_KEY) { + const sk = await askUser(" Paste CLERK_SECRET_KEY (or Enter to skip): "); + if (sk) results.CLERK_SECRET_KEY = sk; + } + + await page.close(); + return results; + }, +}; diff --git a/env-agent-finder/src/grabbers/index.js b/env-agent-finder/src/grabbers/index.js new file mode 100644 index 0000000..18a4fe3 --- /dev/null +++ b/env-agent-finder/src/grabbers/index.js @@ -0,0 +1,32 @@ +const stripe = require("./stripe"); +const openai = require("./openai"); +const supabase = require("./supabase"); +const vercel = require("./vercel"); +const clerk = require("./clerk"); +const anthropic = require("./anthropic"); + +const ALL_GRABBERS = [stripe, openai, anthropic, supabase, vercel, clerk]; + +function findGrabbersForServices(services, missingVars) { + const missingSet = new Set(missingVars.map((v) => v.name)); + const needed = []; + + for (const grabber of ALL_GRABBERS) { + const hasOverlap = grabber.vars.some((v) => missingSet.has(v)); + const serviceDetected = services.some( + (s) => s.name.toLowerCase().includes(grabber.name.toLowerCase()) || + grabber.name.toLowerCase().includes(s.name.toLowerCase()) + ); + + if (hasOverlap || serviceDetected) { + const relevantVars = grabber.vars.filter((v) => missingSet.has(v)); + if (relevantVars.length > 0) { + needed.push({ grabber, relevantVars }); + } + } + } + + return needed; +} + +module.exports = { ALL_GRABBERS, findGrabbersForServices }; diff --git a/env-agent-finder/src/grabbers/openai.js b/env-agent-finder/src/grabbers/openai.js new file mode 100644 index 0000000..bb0a646 --- /dev/null +++ b/env-agent-finder/src/grabbers/openai.js @@ -0,0 +1,44 @@ +const { openPage, waitForUserLogin, askUser } = require("./browser"); + +module.exports = { + name: "OpenAI", + vars: ["OPENAI_API_KEY"], + + async grab() { + const results = {}; + + console.log(" 📍 Opening OpenAI dashboard..."); + const page = await openPage("https://platform.openai.com/login"); + + await waitForUserLogin(page, null, "OpenAI"); + + console.log(" 📍 Navigating to API keys..."); + await page.goto("https://platform.openai.com/api-keys", { waitUntil: "networkidle2", timeout: 30000 }); + await page.waitForTimeout(3000); + + console.log(" 📍 Looking for 'Create new secret key' button..."); + + try { + const createBtn = await page.$('button:has-text("Create new secret key"), [data-testid="create-api-key-button"]'); + if (createBtn) { + console.log(""); + console.log(" 💡 To grab a key automatically:"); + console.log(" 1. Click 'Create new secret key' in the browser"); + console.log(" 2. Give it a name (e.g. 'env-agent-finder')"); + console.log(" 3. Copy the key that appears"); + console.log(" 4. Paste it below"); + console.log(""); + } + } catch {} + + // OpenAI never shows existing keys in full, so we always need the user to create/paste + const key = await askUser(" Paste your OPENAI_API_KEY (or Enter to skip): "); + if (key) { + results.OPENAI_API_KEY = key; + console.log(" ✅ OPENAI_API_KEY saved"); + } + + await page.close(); + return results; + }, +}; diff --git a/env-agent-finder/src/grabbers/stripe.js b/env-agent-finder/src/grabbers/stripe.js new file mode 100644 index 0000000..c8b77be --- /dev/null +++ b/env-agent-finder/src/grabbers/stripe.js @@ -0,0 +1,68 @@ +const { openPage, waitForUserLogin, extractText, clickAndWait, askUser } = require("./browser"); + +module.exports = { + name: "Stripe", + vars: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"], + + async grab() { + const results = {}; + + console.log(" 📍 Opening Stripe dashboard..."); + const page = await openPage("https://dashboard.stripe.com/login"); + + await waitForUserLogin(page, '[data-testid="developers-nav-item"]', "Stripe"); + + console.log(" 📍 Navigating to API keys..."); + await page.goto("https://dashboard.stripe.com/test/apikeys", { waitUntil: "networkidle2", timeout: 30000 }); + await page.waitForTimeout(3000); + + // Try to extract publishable key + try { + const pkKey = await page.evaluate(() => { + const rows = document.querySelectorAll('[class*="KeyRow"], tr, [data-testid]'); + for (const row of rows) { + const text = row.textContent || ""; + if (text.includes("pk_test_") || text.includes("pk_live_")) { + const match = text.match(/(pk_(?:test|live)_[A-Za-z0-9]+)/); + if (match) return match[1]; + } + } + const all = document.body.innerText; + const m = all.match(/(pk_(?:test|live)_[A-Za-z0-9]+)/); + return m ? m[1] : null; + }); + + if (pkKey) { + results.STRIPE_PUBLISHABLE_KEY = pkKey; + console.log(` ✅ STRIPE_PUBLISHABLE_KEY = ${pkKey.substring(0, 20)}...`); + } + } catch {} + + // Secret key needs to be revealed + try { + const skKey = await page.evaluate(() => { + const all = document.body.innerText; + const m = all.match(/(sk_(?:test|live)_[A-Za-z0-9]+)/); + return m ? m[1] : null; + }); + + if (skKey) { + results.STRIPE_SECRET_KEY = skKey; + console.log(` ✅ STRIPE_SECRET_KEY = ${skKey.substring(0, 15)}...`); + } else { + console.log(" ⚠️ Secret key is hidden. Click 'Reveal test key' in the browser, then:"); + const manual = await askUser(" Paste your STRIPE_SECRET_KEY (or Enter to skip): "); + if (manual) results.STRIPE_SECRET_KEY = manual; + } + } catch {} + + // Publishable key fallback + if (!results.STRIPE_PUBLISHABLE_KEY) { + const manual = await askUser(" Paste your STRIPE_PUBLISHABLE_KEY (or Enter to skip): "); + if (manual) results.STRIPE_PUBLISHABLE_KEY = manual; + } + + await page.close(); + return results; + }, +}; diff --git a/env-agent-finder/src/grabbers/supabase.js b/env-agent-finder/src/grabbers/supabase.js new file mode 100644 index 0000000..227ffc0 --- /dev/null +++ b/env-agent-finder/src/grabbers/supabase.js @@ -0,0 +1,89 @@ +const { openPage, waitForUserLogin, askUser } = require("./browser"); + +module.exports = { + name: "Supabase", + vars: ["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY"], + + async grab() { + const results = {}; + + console.log(" 📍 Opening Supabase dashboard..."); + const page = await openPage("https://supabase.com/dashboard/sign-in"); + + await waitForUserLogin(page, null, "Supabase"); + + console.log(" 📍 Navigating to projects..."); + await page.goto("https://supabase.com/dashboard/projects", { waitUntil: "networkidle2", timeout: 30000 }); + await page.waitForTimeout(3000); + + // Ask which project to use + console.log(""); + console.log(" 💡 Select your project in the browser window, then:"); + const projectRef = await askUser(" Paste your Supabase project URL (e.g. https://supabase.com/dashboard/project/abc123) or project ref: "); + + let ref = projectRef; + if (projectRef.includes("/project/")) { + ref = projectRef.split("/project/")[1].split(/[/?#]/)[0]; + } + + if (ref) { + console.log(` 📍 Opening project settings for: ${ref}`); + await page.goto(`https://supabase.com/dashboard/project/${ref}/settings/api`, { waitUntil: "networkidle2", timeout: 30000 }); + await page.waitForTimeout(3000); + + // Try to extract values from the API settings page + try { + const extracted = await page.evaluate(() => { + const text = document.body.innerText; + const result = {}; + + // Project URL + const urlMatch = text.match(/(https:\/\/[a-z0-9]+\.supabase\.co)/); + if (urlMatch) result.url = urlMatch[1]; + + // Anon key (starts with eyJ) + const anonMatch = text.match(/(eyJ[A-Za-z0-9_-]{100,})/); + if (anonMatch) result.anonKey = anonMatch[1]; + + return result; + }); + + if (extracted.url) { + results.SUPABASE_URL = extracted.url; + results.NEXT_PUBLIC_SUPABASE_URL = extracted.url; + console.log(` ✅ SUPABASE_URL = ${extracted.url}`); + } + + if (extracted.anonKey) { + results.SUPABASE_ANON_KEY = extracted.anonKey; + results.NEXT_PUBLIC_SUPABASE_ANON_KEY = extracted.anonKey; + console.log(` ✅ SUPABASE_ANON_KEY = ${extracted.anonKey.substring(0, 30)}...`); + } + } catch {} + + // Service role key is usually hidden — ask user to reveal and paste + if (!results.SUPABASE_SERVICE_ROLE_KEY) { + console.log(""); + console.log(" 💡 For the service_role key, click 'Reveal' next to it in the browser, then:"); + const srKey = await askUser(" Paste your SUPABASE_SERVICE_ROLE_KEY (or Enter to skip): "); + if (srKey) { + results.SUPABASE_SERVICE_ROLE_KEY = srKey; + console.log(" ✅ SUPABASE_SERVICE_ROLE_KEY saved"); + } + } + } + + // Fallbacks + if (!results.SUPABASE_URL) { + const url = await askUser(" Paste your SUPABASE_URL (or Enter to skip): "); + if (url) { results.SUPABASE_URL = url; results.NEXT_PUBLIC_SUPABASE_URL = url; } + } + if (!results.SUPABASE_ANON_KEY) { + const key = await askUser(" Paste your SUPABASE_ANON_KEY (or Enter to skip): "); + if (key) { results.SUPABASE_ANON_KEY = key; results.NEXT_PUBLIC_SUPABASE_ANON_KEY = key; } + } + + await page.close(); + return results; + }, +}; diff --git a/env-agent-finder/src/grabbers/vercel.js b/env-agent-finder/src/grabbers/vercel.js new file mode 100644 index 0000000..15770f6 --- /dev/null +++ b/env-agent-finder/src/grabbers/vercel.js @@ -0,0 +1,35 @@ +const { openPage, waitForUserLogin, askUser } = require("./browser"); + +module.exports = { + name: "Vercel", + vars: ["BLOB_READ_WRITE_TOKEN"], + + async grab() { + const results = {}; + + console.log(" 📍 Opening Vercel dashboard..."); + const page = await openPage("https://vercel.com/login"); + + await waitForUserLogin(page, null, "Vercel"); + + console.log(" 📍 Navigating to Blob stores..."); + await page.goto("https://vercel.com/dashboard/stores", { waitUntil: "networkidle2", timeout: 30000 }); + await page.waitForTimeout(3000); + + console.log(""); + console.log(" 💡 In the browser window:"); + console.log(" 1. Click on your Blob store (or create one)"); + console.log(" 2. Go to the store settings"); + console.log(" 3. Find and copy the BLOB_READ_WRITE_TOKEN"); + console.log(""); + + const token = await askUser(" Paste your BLOB_READ_WRITE_TOKEN (or Enter to skip): "); + if (token) { + results.BLOB_READ_WRITE_TOKEN = token; + console.log(" ✅ BLOB_READ_WRITE_TOKEN saved"); + } + + await page.close(); + return results; + }, +}; diff --git a/env-agent-finder/src/index.js b/env-agent-finder/src/index.js new file mode 100644 index 0000000..6f9cab5 --- /dev/null +++ b/env-agent-finder/src/index.js @@ -0,0 +1,22 @@ +const { scanEnvVars } = require("./scanners/env-scanner"); +const { scanApiRoutes } = require("./scanners/api-scanner"); +const { scanServices } = require("./scanners/service-scanner"); +const { scanProjectMeta } = require("./scanners/meta-scanner"); + +async function scanProject(targetDir) { + const meta = await scanProjectMeta(targetDir); + const envVars = await scanEnvVars(targetDir); + const apiRoutes = await scanApiRoutes(targetDir); + const services = await scanServices(targetDir, envVars); + + return { + targetDir, + scannedAt: new Date().toISOString(), + meta, + envVars, + apiRoutes, + services, + }; +} + +module.exports = { scanProject }; diff --git a/env-agent-finder/src/license.js b/env-agent-finder/src/license.js new file mode 100644 index 0000000..808a2e4 --- /dev/null +++ b/env-agent-finder/src/license.js @@ -0,0 +1,75 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const LICENSE_FILE = path.join(os.homedir(), ".env-agent-finder-license"); +const PUBLIC_PREFIX = "EAF"; + +function generateLicenseKey() { + const seg = () => crypto.randomBytes(4).toString("hex").toUpperCase(); + return `${PUBLIC_PREFIX}-${seg()}-${seg()}-${seg()}-${seg()}`; +} + +function validateKeyFormat(key) { + return /^EAF-[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}$/.test(key); +} + +function hashKey(key) { + return crypto.createHash("sha256").update(key + "env-agent-finder-salt-2026").digest("hex"); +} + +function saveLicense(key) { + fs.writeFileSync(LICENSE_FILE, JSON.stringify({ key, hash: hashKey(key), activated: new Date().toISOString() }), "utf-8"); +} + +function loadLicense() { + try { + if (fs.existsSync(LICENSE_FILE)) { + const data = JSON.parse(fs.readFileSync(LICENSE_FILE, "utf-8")); + if (data.key && validateKeyFormat(data.key) && data.hash === hashKey(data.key)) { + return data; + } + } + } catch {} + return null; +} + +function isLicensed() { + return !!loadLicense(); +} + +function activateLicense(key) { + if (!validateKeyFormat(key)) { + return { success: false, error: "Invalid license key format. Expected: EAF-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX" }; + } + saveLicense(key); + return { success: true }; +} + +function deactivateLicense() { + try { + if (fs.existsSync(LICENSE_FILE)) fs.unlinkSync(LICENSE_FILE); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +function getLicenseInfo() { + const lic = loadLicense(); + if (!lic) return null; + return { + key: lic.key.substring(0, 12) + "..." + lic.key.substring(lic.key.length - 8), + activated: lic.activated, + }; +} + +module.exports = { + generateLicenseKey, + validateKeyFormat, + isLicensed, + activateLicense, + deactivateLicense, + getLicenseInfo, +}; diff --git a/env-agent-finder/src/provisioner.js b/env-agent-finder/src/provisioner.js new file mode 100644 index 0000000..99e34a6 --- /dev/null +++ b/env-agent-finder/src/provisioner.js @@ -0,0 +1,499 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const readline = require("readline"); + +function generateSecret(length = 64) { + return crypto.randomBytes(length).toString("base64url").substring(0, length); +} + +const AUTO_GENERATORS = { + NEXTAUTH_SECRET: () => generateSecret(48), + AUTH_SECRET: () => generateSecret(48), + JWT_SECRET: () => generateSecret(48), + NEXTAUTH_URL: () => "http://localhost:3000", // pragma: allowlist secret + NEXT_PUBLIC_APP_URL: () => "http://localhost:3000", // pragma: allowlist secret + APP_URL: () => "http://localhost:3000", // pragma: allowlist secret + APP_SECRET: () => generateSecret(48), + ENCRYPTION_KEY: () => generateSecret(32), + SESSION_SECRET: () => generateSecret(48), + COOKIE_SECRET: () => generateSecret(48), + SECRET_KEY: () => generateSecret(48), + API_SECRET: () => generateSecret(48), +}; + +const LOCAL_DB_GENERATORS = { + DATABASE_URL: (ctx) => { + if (ctx.services.has("PostgreSQL")) return "postgresql://postgres:postgres@localhost:5432/app_dev"; + if (ctx.services.has("MySQL")) return "mysql://root:root@localhost:3306/app_dev"; + if (ctx.services.has("MongoDB")) return "mongodb://localhost:27017/app_dev"; + return "postgresql://postgres:postgres@localhost:5432/app_dev"; + }, + POSTGRES_URL: () => "postgresql://postgres:postgres@localhost:5432/app_dev", + PG_CONNECTION_STRING: () => "postgresql://postgres:postgres@localhost:5432/app_dev", + MYSQL_URL: () => "mysql://root:root@localhost:3306/app_dev", + MYSQL_HOST: () => "localhost", + MYSQL_DATABASE: () => "app_dev", + MONGODB_URI: () => "mongodb://localhost:27017/app_dev", + MONGO_URL: () => "mongodb://localhost:27017/app_dev", + REDIS_URL: () => "redis://localhost:6379", +}; + +const SERVICE_SIGNUP_INFO = { + "Stripe": { + vars: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"], + signupUrl: "https://dashboard.stripe.com/register", + keysUrl: "https://dashboard.stripe.com/apikeys", + steps: [ + "1. Go to https://dashboard.stripe.com/register and create an account", + "2. Go to https://dashboard.stripe.com/apikeys", + "3. Copy your Publishable key → STRIPE_PUBLISHABLE_KEY", + "4. Copy your Secret key → STRIPE_SECRET_KEY", + "5. For webhooks: go to Developers > Webhooks > Add endpoint", + "6. Copy the Signing secret → STRIPE_WEBHOOK_SECRET", + ], + }, + "OpenAI": { + vars: ["OPENAI_API_KEY"], + signupUrl: "https://platform.openai.com/signup", + keysUrl: "https://platform.openai.com/api-keys", + steps: [ + "1. Go to https://platform.openai.com/signup and create an account", + "2. Go to https://platform.openai.com/api-keys", + "3. Click 'Create new secret key'", + "4. Copy the key → OPENAI_API_KEY", + ], + }, + "Anthropic": { + vars: ["ANTHROPIC_API_KEY"], + signupUrl: "https://console.anthropic.com/", + keysUrl: "https://console.anthropic.com/settings/keys", + steps: [ + "1. Go to https://console.anthropic.com/ and create an account", + "2. Go to https://console.anthropic.com/settings/keys", + "3. Click 'Create Key'", + "4. Copy the key → ANTHROPIC_API_KEY", + ], + }, + "Clerk": { + vars: ["CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"], + signupUrl: "https://dashboard.clerk.com/sign-up", + keysUrl: "https://dashboard.clerk.com/ → your app → API Keys", + steps: [ + "1. Go to https://dashboard.clerk.com/sign-up and create an account", + "2. Create an application", + "3. Go to API Keys in the sidebar", + "4. Copy Publishable key → NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "5. Copy Secret key → CLERK_SECRET_KEY", + ], + }, + "Stack Auth": { + vars: ["STACK_PROJECT_ID", "STACK_PUBLISHED_CLIENT_KEY", "STACK_SECRET_SERVER_KEY"], + signupUrl: "https://app.stack-auth.com/", + keysUrl: "https://app.stack-auth.com/ → your project → Settings", + steps: [ + "1. Go to https://app.stack-auth.com/ and create an account", + "2. Create a project", + "3. Go to project Settings → API Keys", + "4. Copy Project ID → STACK_PROJECT_ID", + "5. Copy Publishable Client Key → STACK_PUBLISHED_CLIENT_KEY", + "6. Copy Secret Server Key → STACK_SECRET_SERVER_KEY", + ], + }, + "Supabase": { + vars: ["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY"], + signupUrl: "https://supabase.com/dashboard", + keysUrl: "https://supabase.com/dashboard → your project → Settings → API", + steps: [ + "1. Go to https://supabase.com/dashboard and create an account", + "2. Create a new project", + "3. Go to Settings → API", + "4. Copy Project URL → SUPABASE_URL / NEXT_PUBLIC_SUPABASE_URL", + "5. Copy anon/public key → SUPABASE_ANON_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY", + "6. Copy service_role key → SUPABASE_SERVICE_ROLE_KEY", + ], + }, + "Firebase": { + vars: ["FIREBASE_API_KEY", "FIREBASE_PROJECT_ID", "FIREBASE_AUTH_DOMAIN", "FIREBASE_STORAGE_BUCKET"], + signupUrl: "https://console.firebase.google.com/", + keysUrl: "https://console.firebase.google.com/ → Project settings", + steps: [ + "1. Go to https://console.firebase.google.com/ and create a project", + "2. Go to Project settings (gear icon)", + "3. Scroll to 'Your apps' → Add a web app", + "4. Copy the config values into your env vars", + ], + }, + "Vercel Blob": { + vars: ["BLOB_READ_WRITE_TOKEN"], + signupUrl: "https://vercel.com/signup", + keysUrl: "https://vercel.com/dashboard/stores", + steps: [ + "1. Go to https://vercel.com/signup and create an account", + "2. Go to Storage → Create a Blob Store", + "3. Copy the BLOB_READ_WRITE_TOKEN from the store settings", + ], + }, + "AWS S3": { + vars: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "S3_BUCKET"], + signupUrl: "https://aws.amazon.com/", + keysUrl: "https://console.aws.amazon.com/iam/home#/security_credentials", + steps: [ + "1. Go to https://aws.amazon.com/ and create an account", + "2. Go to IAM → Security credentials → Create access key", + "3. Copy Access key ID → AWS_ACCESS_KEY_ID", + "4. Copy Secret access key → AWS_SECRET_ACCESS_KEY", + "5. Set AWS_REGION (e.g. us-east-1)", + "6. Create an S3 bucket and set S3_BUCKET to its name", + ], + }, + "SendGrid": { + vars: ["SENDGRID_API_KEY"], + signupUrl: "https://signup.sendgrid.com/", + keysUrl: "https://app.sendgrid.com/settings/api_keys", + steps: [ + "1. Go to https://signup.sendgrid.com/ and create an account", + "2. Go to Settings → API Keys → Create API Key", + "3. Copy the key → SENDGRID_API_KEY", + ], + }, + "Resend": { + vars: ["RESEND_API_KEY"], + signupUrl: "https://resend.com/signup", + keysUrl: "https://resend.com/api-keys", + steps: [ + "1. Go to https://resend.com/signup and create an account", + "2. Go to API Keys → Create API Key", + "3. Copy the key → RESEND_API_KEY", + ], + }, + "Sentry": { + vars: ["SENTRY_DSN", "SENTRY_AUTH_TOKEN"], + signupUrl: "https://sentry.io/signup/", + keysUrl: "https://sentry.io → your project → Settings → Client Keys (DSN)", + steps: [ + "1. Go to https://sentry.io/signup/ and create an account", + "2. Create a project for your platform", + "3. Copy the DSN → SENTRY_DSN", + ], + }, + "Google OAuth": { + vars: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], + signupUrl: "https://console.cloud.google.com/", + keysUrl: "https://console.cloud.google.com/apis/credentials", + steps: [ + "1. Go to https://console.cloud.google.com/ and create a project", + "2. Go to APIs & Services → Credentials → Create OAuth 2.0 Client", + "3. Set authorized redirect URIs", + "4. Copy Client ID → GOOGLE_CLIENT_ID", + "5. Copy Client Secret → GOOGLE_CLIENT_SECRET", + ], + }, + "GitHub OAuth": { + vars: ["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"], + signupUrl: "https://github.com/settings/developers", + keysUrl: "https://github.com/settings/developers", + steps: [ + "1. Go to https://github.com/settings/developers", + "2. Click 'New OAuth App'", + "3. Set the callback URL (e.g. http://localhost:PORT/api/auth/callback/github)", // pragma: allowlist secret + "4. Copy Client ID → GITHUB_CLIENT_ID", + "5. Copy Client Secret → GITHUB_CLIENT_SECRET", + ], + }, + "Twilio": { + vars: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"], + signupUrl: "https://www.twilio.com/try-twilio", + keysUrl: "https://console.twilio.com/", + steps: [ + "1. Go to https://www.twilio.com/try-twilio and create an account", + "2. Your Account SID and Auth Token are on the Console dashboard", + "3. Copy Account SID → TWILIO_ACCOUNT_SID", + "4. Copy Auth Token → TWILIO_AUTH_TOKEN", + ], + }, + "Cloudinary": { + vars: ["CLOUDINARY_URL", "CLOUDINARY_API_KEY", "CLOUDINARY_API_SECRET", "CLOUDINARY_CLOUD_NAME"], + signupUrl: "https://cloudinary.com/users/register_free", + keysUrl: "https://console.cloudinary.com/settings/api-keys", + steps: [ + "1. Go to https://cloudinary.com/users/register_free and create an account", + "2. Go to Settings → API Keys", + "3. Copy your credentials into the env vars", + ], + }, + "Upstash Redis": { + vars: ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"], + signupUrl: "https://console.upstash.com/", + keysUrl: "https://console.upstash.com/ → your database → REST API", + steps: [ + "1. Go to https://console.upstash.com/ and create an account", + "2. Create a Redis database", + "3. Go to the REST API tab", + "4. Copy URL → UPSTASH_REDIS_REST_URL", + "5. Copy Token → UPSTASH_REDIS_REST_TOKEN", + ], + }, +}; + +function createReadlineInterface() { + return readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); +} + +function ask(rl, question) { + return new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer.trim())); + }); +} + +async function provisionEnv(targetDir, scanReport, options = {}) { + const { interactive = true, autoOnly = false } = options; + const { envVars, services } = scanReport; + + const serviceNames = new Set(services.map((s) => s.name)); + const ctx = { services: serviceNames }; + + const result = { + generated: [], + prompted: [], + skipped: [], + written: false, + envFilePath: null, + }; + + const finalEnv = {}; + const existingValues = {}; + + for (const v of envVars) { + if (v.hasValue && v.value) { + existingValues[v.name] = v.value; + } + } + + const allNeededVars = new Set(); + + for (const v of envVars) { + allNeededVars.add(v.name); + } + for (const svc of services) { + for (const envVar of svc.requiredEnvVars || []) { + allNeededVars.add(envVar); + } + } + + const NODE_INTERNALS = new Set([ + "NODE_ENV", "PORT", "HOST", "HOSTNAME", "HOME", "PATH", "PWD", + "LANG", "TERM", "SHELL", "USER", "TZ", "CI", "DEBUG", + "VERCEL", "VERCEL_ENV", "VERCEL_URL", + ]); + + const varsToProvision = Array.from(allNeededVars).filter( + (name) => !NODE_INTERNALS.has(name) + ); + + console.log(""); + console.log("═".repeat(60)); + console.log(" ENV AGENT FINDER — Setup Mode"); + console.log("═".repeat(60)); + console.log(""); + console.log(` Found ${varsToProvision.length} environment variable(s) needed.`); + console.log(` Detected ${services.length} service(s): ${services.map((s) => s.name).join(", ") || "none"}`); + console.log(""); + + // Phase 1: Keep existing values + for (const name of varsToProvision) { + if (existingValues[name]) { + finalEnv[name] = existingValues[name]; + result.generated.push({ name, value: existingValues[name], source: "existing" }); + } + } + + // Phase 2: Auto-generate secrets and local DB URLs + console.log("⚡ AUTO-GENERATING values..."); + console.log("─".repeat(40)); + + for (const name of varsToProvision) { + if (finalEnv[name]) continue; + + if (AUTO_GENERATORS[name]) { + const value = AUTO_GENERATORS[name](); + finalEnv[name] = value; + result.generated.push({ name, value, source: "auto-generated" }); + console.log(` ✅ ${name} = ${value.substring(0, 30)}${value.length > 30 ? "..." : ""}`); + } else if (LOCAL_DB_GENERATORS[name]) { + const value = LOCAL_DB_GENERATORS[name](ctx); + finalEnv[name] = value; + result.generated.push({ name, value, source: "local-dev-default" }); + console.log(` ✅ ${name} = ${value}`); + } + } + console.log(""); + + // Phase 3: Interactive prompts for external services + const remainingVars = varsToProvision.filter((name) => !finalEnv[name]); + + if (remainingVars.length > 0 && interactive && !autoOnly) { + const serviceVarMap = new Map(); + + for (const name of remainingVars) { + let matched = false; + for (const [serviceName, info] of Object.entries(SERVICE_SIGNUP_INFO)) { + if (info.vars.includes(name)) { + if (!serviceVarMap.has(serviceName)) { + serviceVarMap.set(serviceName, { info, vars: [] }); + } + serviceVarMap.get(serviceName).vars.push(name); + matched = true; + break; + } + } + if (!matched) { + if (!serviceVarMap.has("__unknown__")) { + serviceVarMap.set("__unknown__", { info: null, vars: [] }); + } + serviceVarMap.get("__unknown__").vars.push(name); + } + } + + const rl = createReadlineInterface(); + + for (const [serviceName, { info, vars }] of serviceVarMap) { + if (serviceName === "__unknown__") continue; + + console.log(`🔑 ${serviceName.toUpperCase()}`); + console.log("─".repeat(40)); + if (info) { + console.log(" How to get your keys:"); + for (const step of info.steps) { + console.log(` ${step}`); + } + console.log(""); + } + + for (const name of vars) { + const answer = await ask(rl, ` Enter ${name} (or press Enter to skip): `); + if (answer) { + finalEnv[name] = answer; + result.prompted.push({ name, source: serviceName }); + console.log(` ✅ ${name} saved`); + } else { + result.skipped.push({ name, service: serviceName, signupUrl: info?.signupUrl }); + console.log(` ⏭️ ${name} skipped`); + } + } + console.log(""); + } + + const unknownEntry = serviceVarMap.get("__unknown__"); + if (unknownEntry && unknownEntry.vars.length > 0) { + console.log("❓ OTHER VARIABLES"); + console.log("─".repeat(40)); + + for (const name of unknownEntry.vars) { + const answer = await ask(rl, ` Enter ${name} (or press Enter to skip): `); + if (answer) { + finalEnv[name] = answer; + result.prompted.push({ name, source: "manual" }); + console.log(` ✅ ${name} saved`); + } else { + result.skipped.push({ name, service: null, signupUrl: null }); + console.log(` ⏭️ ${name} skipped`); + } + } + console.log(""); + } + + rl.close(); + } else if (remainingVars.length > 0) { + for (const name of remainingVars) { + let serviceName = null; + let signupUrl = null; + for (const [svcName, info] of Object.entries(SERVICE_SIGNUP_INFO)) { + if (info.vars.includes(name)) { + serviceName = svcName; + signupUrl = info.signupUrl; + break; + } + } + result.skipped.push({ name, service: serviceName, signupUrl }); + } + } + + // Phase 4: Write .env.local + const envFilePath = path.join(targetDir, ".env.local"); + const envLines = []; + + envLines.push("# Generated by env-agent-finder --setup"); + envLines.push(`# ${new Date().toISOString()}`); + envLines.push(""); + + const groupedByService = new Map(); + const varServiceMap = {}; + + for (const svc of services) { + for (const envVar of svc.requiredEnvVars || []) { + varServiceMap[envVar] = svc.name; + } + } + for (const [serviceName, info] of Object.entries(SERVICE_SIGNUP_INFO)) { + for (const v of info.vars) { + if (!varServiceMap[v]) varServiceMap[v] = serviceName; + } + } + + for (const name of varsToProvision) { + const svc = varServiceMap[name] || "Other"; + if (!groupedByService.has(svc)) groupedByService.set(svc, []); + groupedByService.get(svc).push(name); + } + + for (const [service, vars] of groupedByService) { + envLines.push(`# ${service}`); + for (const name of vars) { + const value = finalEnv[name] || ""; + envLines.push(`${name}=${value}`); + } + envLines.push(""); + } + + fs.writeFileSync(envFilePath, envLines.join("\n"), "utf-8"); + result.written = true; + result.envFilePath = envFilePath; + + // Summary + console.log("═".repeat(60)); + console.log(" SETUP COMPLETE"); + console.log("═".repeat(60)); + console.log(""); + console.log(` 📄 Written to: ${envFilePath}`); + console.log(""); + + const existingCount = result.generated.filter((g) => g.source === "existing").length; + const autoCount = result.generated.filter((g) => g.source !== "existing").length; + + if (existingCount > 0) console.log(` ♻️ ${existingCount} value(s) kept from existing .env`); + if (autoCount > 0) console.log(` ⚡ ${autoCount} value(s) auto-generated (secrets, DB URLs)`); + if (result.prompted.length > 0) console.log(` 🔑 ${result.prompted.length} value(s) provided by you`); + + if (result.skipped.length > 0) { + console.log(` ⏭️ ${result.skipped.length} value(s) still need to be filled in:`); + console.log(""); + for (const skip of result.skipped) { + const where = skip.signupUrl ? ` → ${skip.signupUrl}` : ""; + console.log(` ❌ ${skip.name}${skip.service ? ` (${skip.service})` : ""}${where}`); + } + } else { + console.log(""); + console.log(" 🎉 All environment variables are configured!"); + } + + console.log(""); + console.log("═".repeat(60)); + + return result; +} + +module.exports = { provisionEnv }; diff --git a/env-agent-finder/src/reporter.js b/env-agent-finder/src/reporter.js new file mode 100644 index 0000000..660ce9b --- /dev/null +++ b/env-agent-finder/src/reporter.js @@ -0,0 +1,340 @@ +function maskValue(val) { + if (!val || val.length === 0) return "(empty)"; + if (val.length <= 8) return val.substring(0, 2) + "•".repeat(val.length - 2); + return val.substring(0, 4) + "•".repeat(val.length - 8) + val.substring(val.length - 4); +} + +function displayValue(val, source, opts) { + if (!val) return null; + if (opts.unmask) return val; + return maskValue(val); +} + +function renderTerminal(report, opts) { + const lines = []; + const { meta, envVars, apiRoutes, services } = report; + + lines.push("═".repeat(70)); + lines.push(" ENV AGENT FINDER — Scan Report"); + lines.push("═".repeat(70)); + lines.push(""); + + // Project meta + lines.push("📦 PROJECT INFO"); + lines.push("─".repeat(50)); + if (meta.name) lines.push(` Name: ${meta.name}`); + if (meta.framework) lines.push(` Framework: ${meta.framework}`); + if (meta.language) lines.push(` Language: ${meta.language}`); + if (meta.packageManager) lines.push(` Package Manager: ${meta.packageManager}`); + lines.push(` Docker: ${meta.hasDocker ? "Yes" : "No"}`); + lines.push(` CI/CD: ${meta.hasCI ? "Yes" : "No"}`); + lines.push(""); + + // Env vars + lines.push(`🔑 ENVIRONMENT VARIABLES (${envVars.length} found)`); + lines.push("─".repeat(50)); + + if (envVars.length === 0) { + lines.push(" No environment variables detected."); + } else if (opts.showValues) { + for (const v of envVars) { + const status = v.hasValue ? "✅" : v.required === false ? "⚪" : "❌"; + lines.push(""); + lines.push(` ${status} ${v.name}`); + if (v.service) lines.push(` Service: ${v.service}`); + lines.push(` Refs: ${v.references.length} reference(s) in code`); + + if (v.values && v.values.length > 0) { + for (const entry of v.values) { + const shown = displayValue(entry.value, entry.source, opts); + lines.push(` Value: ${shown}`); + lines.push(` Source: ${entry.source}`); + } + } else if (v.hasValue && v.value) { + const shown = displayValue(v.value, v.valueSource, opts); + lines.push(` Value: ${shown}`); + if (v.valueSource) lines.push(` Source: ${v.valueSource}`); + } else { + lines.push(` Value: (not set)`); + } + } + } else { + const maxName = Math.max(...envVars.map((v) => v.name.length), 10); + lines.push( + ` ${"Variable".padEnd(maxName)} ${"Service".padEnd(25)} ${"Refs".padEnd(4)} Status` + ); + lines.push(` ${"─".repeat(maxName)} ${"─".repeat(25)} ${"─".repeat(4)} ──────`); + for (const v of envVars) { + const status = v.hasValue ? "✅ Set" : v.required === false ? "⚪ Optional" : "❌ Missing"; + const service = (v.service || "—").substring(0, 25); + lines.push( + ` ${v.name.padEnd(maxName)} ${service.padEnd(25)} ${String(v.references.length).padEnd(4)} ${status}` + ); + } + lines.push(""); + lines.push(" 💡 Use --show-values to see values, or --unmask for full values."); + } + lines.push(""); + + // Services + lines.push(`🔌 DETECTED SERVICES (${services.length} found)`); + lines.push("─".repeat(50)); + if (services.length === 0) { + lines.push(" No external services detected."); + } else { + for (const svc of services) { + const bar = "█".repeat(Math.round(svc.confidence / 10)) + + "░".repeat(10 - Math.round(svc.confidence / 10)); + lines.push(` ${svc.name}`); + lines.push(` Category: ${svc.category}`); + lines.push(` Confidence: ${bar} ${svc.confidence}%`); + if (svc.matchedPackages.length > 0) { + lines.push(` Packages: ${svc.matchedPackages.join(", ")}`); + } + if (svc.matchedEnvVars.length > 0) { + lines.push(` Env Vars: ${svc.matchedEnvVars.join(", ")}`); + } + if (svc.matchedFiles.length > 0) { + lines.push(` Files: ${svc.matchedFiles.join(", ")}`); + } + lines.push(` Docs: ${svc.docs}`); + lines.push(""); + } + } + + // API routes + lines.push(`🌐 API ROUTES (${apiRoutes.length} found)`); + lines.push("─".repeat(50)); + if (apiRoutes.length === 0) { + lines.push(" No API routes detected."); + } else { + const maxPath = Math.max(...apiRoutes.map((r) => r.path.length), 10); + for (const route of apiRoutes) { + const methods = route.methods.join(", "); + lines.push(` ${route.path.padEnd(maxPath)} [${methods}]`); + } + } + lines.push(""); + + // .env template for missing vars + const missingVars = envVars.filter((v) => !v.hasValue); + if (missingVars.length > 0) { + lines.push("📋 SUGGESTED .env.local TEMPLATE"); + lines.push("─".repeat(50)); + lines.push(""); + let currentService = null; + for (const v of missingVars) { + const svc = v.service || "Other"; + if (svc !== currentService) { + if (currentService !== null) lines.push(""); + lines.push(` # ${svc}`); + currentService = svc; + } + lines.push(` ${v.name}=`); + } + lines.push(""); + } + + // Current .env snapshot (when --show-values is used) + const setVars = envVars.filter((v) => v.hasValue && v.values && v.values.length > 0); + if (opts.showValues && setVars.length > 0) { + lines.push("📄 CURRENT .env VALUES"); + lines.push("─".repeat(50)); + lines.push(""); + for (const v of setVars) { + const val = opts.unmask ? v.values[0].value : maskValue(v.values[0].value); + lines.push(` ${v.name}=${val}`); + } + lines.push(""); + if (!opts.unmask) { + lines.push(" 🔒 Values are masked. Use --unmask to reveal full values."); + lines.push(""); + } + } + + lines.push("═".repeat(70)); + lines.push(` Scanned: ${report.targetDir}`); + lines.push(` Time: ${report.scannedAt}`); + lines.push("═".repeat(70)); + + return lines.join("\n"); +} + +function renderJson(report, opts) { + const output = { ...report }; + + if (!opts.showValues) { + output.envVars = output.envVars.map((v) => { + const { value, values, ...rest } = v; + return rest; + }); + } else if (!opts.unmask) { + output.envVars = output.envVars.map((v) => ({ + ...v, + value: v.value ? maskValue(v.value) : null, + values: (v.values || []).map((entry) => ({ + ...entry, + value: maskValue(entry.value), + })), + })); + } + + return JSON.stringify(output, null, 2); +} + +function renderMarkdown(report, opts) { + const lines = []; + const { meta, envVars, apiRoutes, services } = report; + + lines.push("# Environment & API Scan Report"); + lines.push(""); + lines.push(`> Scanned: \`${report.targetDir}\` `); + lines.push(`> Time: ${report.scannedAt}`); + lines.push(""); + + // Meta + lines.push("## Project Info"); + lines.push(""); + lines.push("| Property | Value |"); + lines.push("|----------|-------|"); + if (meta.name) lines.push(`| Name | ${meta.name} |`); + if (meta.framework) lines.push(`| Framework | ${meta.framework} |`); + if (meta.language) lines.push(`| Language | ${meta.language} |`); + if (meta.packageManager) lines.push(`| Package Manager | ${meta.packageManager} |`); + lines.push(`| Docker | ${meta.hasDocker ? "Yes" : "No"} |`); + lines.push(`| CI/CD | ${meta.hasCI ? "Yes" : "No"} |`); + lines.push(""); + + // Env vars + lines.push(`## Environment Variables (${envVars.length})`); + lines.push(""); + + if (envVars.length > 0) { + if (opts.showValues) { + lines.push("| Variable | Service | Value | Source | Status |"); + lines.push("|----------|---------|-------|--------|--------|"); + for (const v of envVars) { + const status = v.hasValue ? "Set" : v.required === false ? "Optional" : "**Missing**"; + let val = "(not set)"; + let source = "—"; + if (v.values && v.values.length > 0) { + val = opts.unmask ? v.values[0].value : maskValue(v.values[0].value); + source = v.values[0].source; + } else if (v.hasValue && v.value) { + val = opts.unmask ? v.value : maskValue(v.value); + source = v.valueSource || "—"; + } + lines.push( + `| \`${v.name}\` | ${v.service || "—"} | \`${val}\` | ${source} | ${status} |` + ); + } + } else { + lines.push("| Variable | Service | References | Status |"); + lines.push("|----------|---------|------------|--------|"); + for (const v of envVars) { + const status = v.hasValue ? "Set" : v.required === false ? "Optional" : "**Missing**"; + lines.push( + `| \`${v.name}\` | ${v.service || "—"} | ${v.references.length} | ${status} |` + ); + } + } + } else { + lines.push("No environment variables detected."); + } + lines.push(""); + + // Services + lines.push(`## Detected Services (${services.length})`); + lines.push(""); + if (services.length > 0) { + for (const svc of services) { + lines.push(`### ${svc.name} (${svc.category})`); + lines.push(`- **Confidence:** ${svc.confidence}%`); + if (svc.matchedPackages.length > 0) { + lines.push(`- **Packages:** ${svc.matchedPackages.map((p) => "`" + p + "`").join(", ")}`); + } + if (svc.matchedEnvVars.length > 0) { + lines.push(`- **Env Vars:** ${svc.matchedEnvVars.map((v) => "`" + v + "`").join(", ")}`); + } + if (svc.requiredEnvVars.length > 0) { + lines.push(`- **Required Env Vars:** ${svc.requiredEnvVars.map((v) => "`" + v + "`").join(", ")}`); + } + lines.push(`- **Docs:** ${svc.docs}`); + lines.push(""); + } + } else { + lines.push("No external services detected."); + lines.push(""); + } + + // API routes + lines.push(`## API Routes (${apiRoutes.length})`); + lines.push(""); + if (apiRoutes.length > 0) { + lines.push("| Path | Methods | Framework | File |"); + lines.push("|------|---------|-----------|------|"); + for (const route of apiRoutes) { + lines.push( + `| \`${route.path}\` | ${route.methods.join(", ")} | ${route.framework} | \`${route.file}\` |` + ); + } + } else { + lines.push("No API routes detected."); + } + lines.push(""); + + // .env template + const missingVars = envVars.filter((v) => !v.hasValue); + if (missingVars.length > 0) { + lines.push("## Suggested `.env.local` Template"); + lines.push(""); + lines.push("```bash"); + let currentService = null; + for (const v of missingVars) { + const svc = v.service || "Other"; + if (svc !== currentService) { + if (currentService !== null) lines.push(""); + lines.push(`# ${svc}`); + currentService = svc; + } + lines.push(`${v.name}=`); + } + lines.push("```"); + } + + // Current .env snapshot + const setVars = envVars.filter((v) => v.hasValue && v.values && v.values.length > 0); + if (opts.showValues && setVars.length > 0) { + lines.push(""); + lines.push("## Current `.env` Values"); + lines.push(""); + lines.push("```bash"); + for (const v of setVars) { + const val = opts.unmask ? v.values[0].value : maskValue(v.values[0].value); + lines.push(`${v.name}=${val}`); + } + lines.push("```"); + if (!opts.unmask) { + lines.push(""); + lines.push("> Values are masked. Use `--unmask` to reveal full values."); + } + } + + return lines.join("\n"); +} + +function renderReport(report, format, opts = {}) { + const options = { showValues: false, unmask: false, ...opts }; + switch (format) { + case "json": + return renderJson(report, options); + case "markdown": + case "md": + return renderMarkdown(report, options); + case "terminal": + default: + return renderTerminal(report, options); + } +} + +module.exports = { renderReport }; diff --git a/env-agent-finder/src/scanners/api-scanner.js b/env-agent-finder/src/scanners/api-scanner.js new file mode 100644 index 0000000..ddac1e1 --- /dev/null +++ b/env-agent-finder/src/scanners/api-scanner.js @@ -0,0 +1,233 @@ +const fs = require("fs"); +const path = require("path"); + +const SKIP_DIRS = new Set([ + "node_modules", ".next", ".nuxt", "dist", "build", ".git", + "coverage", "__pycache__", ".venv", "venv", "vendor", "target", + ".turbo", ".cache", ".output", "stubs", +]); + +function walkDirs(dir, results = []) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(fullPath); + walkDirs(fullPath, results); + } + } + return results; +} + +function scanNextJsRoutes(targetDir) { + const routes = []; + const appDir = path.join(targetDir, "app"); + const apiDir = path.join(appDir, "api"); + + if (!fs.existsSync(apiDir)) return routes; + + const dirs = walkDirs(apiDir); + dirs.unshift(apiDir); + + for (const dir of dirs) { + const routeFile = ["route.ts", "route.js"].find((f) => + fs.existsSync(path.join(dir, f)) + ); + if (!routeFile) continue; + + const filePath = path.join(dir, routeFile); + const relativePath = path.relative(appDir, dir); + const routePath = "/" + relativePath.replace(/\\/g, "/"); + + let content; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const methods = []; + const httpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + for (const method of httpMethods) { + const pattern = new RegExp( + `export\\s+(?:async\\s+)?function\\s+${method}\\b`, + ); + if (pattern.test(content)) { + methods.push(method); + } + } + + if (methods.length > 0) { + routes.push({ + path: routePath, + methods, + file: path.relative(targetDir, filePath), + framework: "Next.js App Router", + }); + } + } + + return routes; +} + +function scanExpressRoutes(targetDir) { + const routes = []; + const srcDirs = [ + targetDir, + path.join(targetDir, "src"), + path.join(targetDir, "routes"), + path.join(targetDir, "src", "routes"), + path.join(targetDir, "api"), + path.join(targetDir, "src", "api"), + ]; + + for (const dir of srcDirs) { + if (!fs.existsSync(dir)) continue; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (![".js", ".ts", ".mjs"].includes(ext)) continue; + + const filePath = path.join(dir, entry.name); + let content; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const routePattern = + /(?:app|router|server)\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi; + let match; + while ((match = routePattern.exec(content)) !== null) { + const method = match[1].toUpperCase(); + const routePath = match[2]; + const existing = routes.find((r) => r.path === routePath); + if (existing) { + if (!existing.methods.includes(method)) existing.methods.push(method); + } else { + routes.push({ + path: routePath, + methods: [method], + file: path.relative(targetDir, filePath), + framework: "Express/Fastify", + }); + } + } + } + } + + return routes; +} + +function scanPagesApiRoutes(targetDir) { + const routes = []; + const pagesApiDir = path.join(targetDir, "pages", "api"); + if (!fs.existsSync(pagesApiDir)) return routes; + + const dirs = walkDirs(pagesApiDir); + dirs.unshift(pagesApiDir); + + for (const dir of dirs) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (![".ts", ".js", ".tsx", ".jsx"].includes(ext)) continue; + + const filePath = path.join(dir, entry.name); + const relativePath = path.relative(pagesApiDir, filePath); + const routeName = relativePath + .replace(/\\/g, "/") + .replace(/\.(ts|js|tsx|jsx)$/, "") + .replace(/\/index$/, ""); + const routePath = "/api/" + routeName; + + routes.push({ + path: routePath, + methods: ["handler"], + file: path.relative(targetDir, filePath), + framework: "Next.js Pages Router", + }); + } + } + + return routes; +} + +function scanFastApiRoutes(targetDir) { + const routes = []; + const pyFiles = []; + + function findPyFiles(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) findPyFiles(fullPath); + else if (entry.isFile() && entry.name.endsWith(".py")) pyFiles.push(fullPath); + } + } + + findPyFiles(targetDir); + + for (const filePath of pyFiles) { + let content; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const pattern = + /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/gi; + let match; + while ((match = pattern.exec(content)) !== null) { + routes.push({ + path: match[2], + methods: [match[1].toUpperCase()], + file: path.relative(targetDir, filePath), + framework: "FastAPI/Flask", + }); + } + } + + return routes; +} + +async function scanApiRoutes(targetDir) { + const allRoutes = [ + ...scanNextJsRoutes(targetDir), + ...scanPagesApiRoutes(targetDir), + ...scanExpressRoutes(targetDir), + ...scanFastApiRoutes(targetDir), + ]; + + return allRoutes.sort((a, b) => a.path.localeCompare(b.path)); +} + +module.exports = { scanApiRoutes }; diff --git a/env-agent-finder/src/scanners/env-scanner.js b/env-agent-finder/src/scanners/env-scanner.js new file mode 100644 index 0000000..997b080 --- /dev/null +++ b/env-agent-finder/src/scanners/env-scanner.js @@ -0,0 +1,251 @@ +const fs = require("fs"); +const path = require("path"); + +const SKIP_DIRS = new Set([ + "node_modules", ".next", ".nuxt", "dist", "build", ".git", + "coverage", "__pycache__", ".venv", "venv", "vendor", "target", + ".turbo", ".cache", ".output", "stubs", +]); + +const CODE_EXTENSIONS = new Set([ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", + ".py", ".go", ".rs", ".rb", ".java", ".kt", + ".env", ".env.local", ".env.example", ".env.development", + ".env.production", ".env.test", + ".yml", ".yaml", ".toml", ".json", ".tf", +]); + +const WELL_KNOWN_SERVICES = { + DATABASE_URL: { service: "Database (PostgreSQL/MySQL)", required: true }, + POSTGRES_URL: { service: "PostgreSQL", required: true }, + MYSQL_URL: { service: "MySQL", required: true }, + MONGODB_URI: { service: "MongoDB", required: true }, + REDIS_URL: { service: "Redis", required: false }, + BLOB_READ_WRITE_TOKEN: { service: "Vercel Blob Storage", required: false }, + NEXT_PUBLIC_SUPABASE_URL: { service: "Supabase", required: true }, + SUPABASE_URL: { service: "Supabase", required: true }, + SUPABASE_SERVICE_ROLE_KEY: { service: "Supabase", required: true }, + STRIPE_SECRET_KEY: { service: "Stripe", required: true }, + STRIPE_PUBLISHABLE_KEY: { service: "Stripe", required: true }, + STRIPE_WEBHOOK_SECRET: { service: "Stripe Webhooks", required: false }, + OPENAI_API_KEY: { service: "OpenAI", required: true }, + ANTHROPIC_API_KEY: { service: "Anthropic", required: true }, + AWS_ACCESS_KEY_ID: { service: "AWS", required: true }, + AWS_SECRET_ACCESS_KEY: { service: "AWS", required: true }, + AWS_REGION: { service: "AWS", required: false }, + S3_BUCKET: { service: "AWS S3", required: false }, + GOOGLE_CLIENT_ID: { service: "Google OAuth", required: false }, + GOOGLE_CLIENT_SECRET: { service: "Google OAuth", required: false }, + GITHUB_CLIENT_ID: { service: "GitHub OAuth", required: false }, + GITHUB_CLIENT_SECRET: { service: "GitHub OAuth", required: false }, + NEXTAUTH_SECRET: { service: "NextAuth.js", required: true }, + NEXTAUTH_URL: { service: "NextAuth.js", required: false }, + AUTH_SECRET: { service: "Auth.js", required: true }, + JWT_SECRET: { service: "JWT Auth", required: true }, + SENDGRID_API_KEY: { service: "SendGrid Email", required: false }, + RESEND_API_KEY: { service: "Resend Email", required: false }, + SMTP_HOST: { service: "SMTP Email", required: false }, + SENTRY_DSN: { service: "Sentry Error Tracking", required: false }, + VERCEL_URL: { service: "Vercel Platform", required: false }, + CLERK_SECRET_KEY: { service: "Clerk Auth", required: true }, + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: { service: "Clerk Auth", required: true }, + STACK_PROJECT_ID: { service: "Stack Auth", required: true }, + STACK_PUBLISHED_CLIENT_KEY: { service: "Stack Auth", required: true }, + STACK_SECRET_SERVER_KEY: { service: "Stack Auth", required: true }, + TWILIO_ACCOUNT_SID: { service: "Twilio", required: false }, + TWILIO_AUTH_TOKEN: { service: "Twilio", required: false }, + CLOUDINARY_URL: { service: "Cloudinary", required: false }, + UPSTASH_REDIS_REST_URL: { service: "Upstash Redis", required: false }, + UPSTASH_REDIS_REST_TOKEN: { service: "Upstash Redis", required: false }, + FIREBASE_API_KEY: { service: "Firebase", required: true }, + FIREBASE_PROJECT_ID: { service: "Firebase", required: true }, +}; + +function walkFiles(dir, files = []) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return files; + } + + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkFiles(fullPath, files); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + const basename = entry.name.toLowerCase(); + if (CODE_EXTENSIONS.has(ext) || basename.startsWith(".env")) { + files.push(fullPath); + } + } + } + return files; +} + +function extractEnvReferences(content, filePath) { + const refs = []; + + const patterns = [ + /process\.env\.([A-Z_][A-Z0-9_]*)/g, + /process\.env\[['"]([A-Z_][A-Z0-9_]*)['"]\]/g, + /os\.environ(?:\.get)?\(?['"]([A-Z_][A-Z0-9_]*)['"]/g, + /os\.getenv\(['"]([A-Z_][A-Z0-9_]*)['"]\)/g, + /env(?:ironment)?\.([A-Z_][A-Z0-9_]*)/g, + /\$\{([A-Z_][A-Z0-9_]*)\}/g, + /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(content)) !== null) { + refs.push({ + name: match[1], + file: filePath, + line: content.substring(0, match.index).split("\n").length, + }); + } + } + + return refs; +} + +function stripQuotes(val) { + if ((val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'"))) { + return val.slice(1, -1); + } + return val; +} + +function parseEnvFile(filePath) { + const vars = []; + try { + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith("#")) continue; + const eqIdx = line.indexOf("="); + if (eqIdx > 0) { + const name = line.substring(0, eqIdx).trim(); + const rawValue = line.substring(eqIdx + 1).trim(); + const value = stripQuotes(rawValue); + if (/^[A-Z_][A-Z0-9_]*$/.test(name)) { + vars.push({ + name, + value: value || null, + hasValue: value.length > 0, + file: filePath, + line: i + 1, + }); + } + } + } + } catch {} + return vars; +} + +async function scanEnvVars(targetDir) { + const files = walkFiles(targetDir); + const allRefs = []; + const envFileVars = []; + + for (const filePath of files) { + const basename = path.basename(filePath).toLowerCase(); + + if (basename.startsWith(".env")) { + envFileVars.push(...parseEnvFile(filePath)); + continue; + } + + try { + const content = fs.readFileSync(filePath, "utf-8"); + const relativePath = path.relative(targetDir, filePath); + const refs = extractEnvReferences(content, relativePath); + allRefs.push(...refs); + } catch {} + } + + const varMap = new Map(); + + for (const ref of allRefs) { + if (!varMap.has(ref.name)) { + const known = WELL_KNOWN_SERVICES[ref.name]; + varMap.set(ref.name, { + name: ref.name, + references: [], + service: known?.service || null, + required: known?.required ?? null, + hasValue: false, + value: null, + valueSource: null, + values: [], + }); + } + varMap.get(ref.name).references.push({ file: ref.file, line: ref.line }); + } + + for (const envVar of envFileVars) { + const relFile = path.relative(targetDir, envVar.file); + if (!varMap.has(envVar.name)) { + const known = WELL_KNOWN_SERVICES[envVar.name]; + varMap.set(envVar.name, { + name: envVar.name, + references: [{ file: relFile, line: envVar.line }], + service: known?.service || null, + required: known?.required ?? null, + hasValue: envVar.hasValue, + value: envVar.value, + valueSource: envVar.hasValue ? relFile : null, + values: envVar.hasValue + ? [{ value: envVar.value, source: relFile }] + : [], + }); + } else { + const existing = varMap.get(envVar.name); + existing.references.push({ file: relFile, line: envVar.line }); + if (envVar.hasValue) { + existing.values.push({ value: envVar.value, source: relFile }); + if (!existing.hasValue) { + existing.hasValue = true; + existing.value = envVar.value; + existing.valueSource = relFile; + } + } + } + } + + // Also check live process.env for any vars we found in code + for (const [name, entry] of varMap) { + const liveValue = process.env[name]; + if (liveValue !== undefined && liveValue.length > 0) { + entry.values.push({ value: liveValue, source: "process.env (runtime)" }); + if (!entry.hasValue) { + entry.hasValue = true; + entry.value = liveValue; + entry.valueSource = "process.env (runtime)"; + } + } + } + + const NODE_INTERNALS = new Set([ + "NODE_ENV", "PORT", "HOST", "HOSTNAME", "HOME", "PATH", "PWD", + "LANG", "TERM", "SHELL", "USER", "TZ", "CI", "DEBUG", + "VERCEL", "VERCEL_ENV", "VERCEL_URL", + ]); + + const results = Array.from(varMap.values()) + .filter((v) => !NODE_INTERNALS.has(v.name)) + .sort((a, b) => { + if (a.required && !b.required) return -1; + if (!a.required && b.required) return 1; + return a.name.localeCompare(b.name); + }); + + return results; +} + +module.exports = { scanEnvVars }; diff --git a/env-agent-finder/src/scanners/meta-scanner.js b/env-agent-finder/src/scanners/meta-scanner.js new file mode 100644 index 0000000..e6e7c7e --- /dev/null +++ b/env-agent-finder/src/scanners/meta-scanner.js @@ -0,0 +1,75 @@ +const fs = require("fs"); +const path = require("path"); + +async function scanProjectMeta(targetDir) { + const meta = { + name: null, + framework: null, + language: null, + packageManager: null, + hasDocker: false, + hasCI: false, + }; + + const pkgPath = path.join(targetDir, "package.json"); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + meta.name = pkg.name || null; + meta.language = "JavaScript/TypeScript"; + + if (pkg.dependencies?.next || pkg.devDependencies?.next) { + meta.framework = `Next.js ${pkg.dependencies?.next || pkg.devDependencies?.next}`; + } else if (pkg.dependencies?.nuxt || pkg.devDependencies?.nuxt) { + meta.framework = `Nuxt ${pkg.dependencies?.nuxt || pkg.devDependencies?.nuxt}`; + } else if (pkg.dependencies?.react || pkg.devDependencies?.react) { + meta.framework = `React ${pkg.dependencies?.react || pkg.devDependencies?.react}`; + } else if (pkg.dependencies?.vue || pkg.devDependencies?.vue) { + meta.framework = `Vue ${pkg.dependencies?.vue || pkg.devDependencies?.vue}`; + } else if (pkg.dependencies?.express || pkg.devDependencies?.express) { + meta.framework = `Express ${pkg.dependencies?.express || pkg.devDependencies?.express}`; + } else if (pkg.dependencies?.fastify || pkg.devDependencies?.fastify) { + meta.framework = `Fastify ${pkg.dependencies?.fastify || pkg.devDependencies?.fastify}`; + } + } catch {} + } + + if (fs.existsSync(path.join(targetDir, "requirements.txt")) || + fs.existsSync(path.join(targetDir, "pyproject.toml")) || + fs.existsSync(path.join(targetDir, "setup.py"))) { + meta.language = "Python"; + if (fs.existsSync(path.join(targetDir, "pyproject.toml"))) { + try { + const content = fs.readFileSync(path.join(targetDir, "pyproject.toml"), "utf-8"); + if (content.includes("django")) meta.framework = "Django"; + else if (content.includes("fastapi")) meta.framework = "FastAPI"; + else if (content.includes("flask")) meta.framework = "Flask"; + } catch {} + } + } + + if (fs.existsSync(path.join(targetDir, "go.mod"))) { + meta.language = "Go"; + } + + if (fs.existsSync(path.join(targetDir, "Cargo.toml"))) { + meta.language = "Rust"; + } + + if (fs.existsSync(path.join(targetDir, "pnpm-lock.yaml"))) meta.packageManager = "pnpm"; + else if (fs.existsSync(path.join(targetDir, "yarn.lock"))) meta.packageManager = "yarn"; + else if (fs.existsSync(path.join(targetDir, "bun.lockb"))) meta.packageManager = "bun"; + else if (fs.existsSync(path.join(targetDir, "package-lock.json"))) meta.packageManager = "npm"; + + meta.hasDocker = fs.existsSync(path.join(targetDir, "Dockerfile")) || + fs.existsSync(path.join(targetDir, "docker-compose.yml")) || + fs.existsSync(path.join(targetDir, "docker-compose.yaml")); + + meta.hasCI = fs.existsSync(path.join(targetDir, ".github", "workflows")) || + fs.existsSync(path.join(targetDir, ".gitlab-ci.yml")) || + fs.existsSync(path.join(targetDir, ".circleci")); + + return meta; +} + +module.exports = { scanProjectMeta }; diff --git a/env-agent-finder/src/scanners/service-scanner.js b/env-agent-finder/src/scanners/service-scanner.js new file mode 100644 index 0000000..3ce1d51 --- /dev/null +++ b/env-agent-finder/src/scanners/service-scanner.js @@ -0,0 +1,262 @@ +const fs = require("fs"); +const path = require("path"); + +const SERVICE_SIGNATURES = [ + { + name: "PostgreSQL", + category: "database", + indicators: { + packages: ["pg", "@neondatabase/serverless", "postgres", "knex", "prisma", "drizzle-orm", "typeorm", "sequelize"], + envVars: ["DATABASE_URL", "POSTGRES_URL", "PG_CONNECTION_STRING", "PGHOST"], + files: ["prisma/schema.prisma", "drizzle.config.ts", "drizzle.config.js"], + }, + docs: "https://www.postgresql.org/docs/", + }, + { + name: "MySQL", + category: "database", + indicators: { + packages: ["mysql2", "mysql"], + envVars: ["MYSQL_URL", "MYSQL_HOST", "MYSQL_DATABASE"], + }, + docs: "https://dev.mysql.com/doc/", + }, + { + name: "MongoDB", + category: "database", + indicators: { + packages: ["mongodb", "mongoose"], + envVars: ["MONGODB_URI", "MONGO_URL"], + }, + docs: "https://www.mongodb.com/docs/", + }, + { + name: "Redis", + category: "cache", + indicators: { + packages: ["redis", "ioredis", "@upstash/redis"], + envVars: ["REDIS_URL", "UPSTASH_REDIS_REST_URL"], + }, + docs: "https://redis.io/docs/", + }, + { + name: "Supabase", + category: "baas", + indicators: { + packages: ["@supabase/supabase-js", "@supabase/ssr"], + envVars: ["SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"], + }, + docs: "https://supabase.com/docs", + }, + { + name: "Firebase", + category: "baas", + indicators: { + packages: ["firebase", "firebase-admin"], + envVars: ["FIREBASE_API_KEY", "FIREBASE_PROJECT_ID"], + files: ["firebase.json", ".firebaserc"], + }, + docs: "https://firebase.google.com/docs", + }, + { + name: "Stripe", + category: "payments", + indicators: { + packages: ["stripe", "@stripe/stripe-js"], + envVars: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY"], + }, + docs: "https://stripe.com/docs", + }, + { + name: "Auth.js / NextAuth", + category: "auth", + indicators: { + packages: ["next-auth", "@auth/core"], + envVars: ["NEXTAUTH_SECRET", "AUTH_SECRET", "NEXTAUTH_URL"], + }, + docs: "https://authjs.dev/", + }, + { + name: "Clerk", + category: "auth", + indicators: { + packages: ["@clerk/nextjs", "@clerk/clerk-sdk-node"], + envVars: ["CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"], + }, + docs: "https://clerk.com/docs", + }, + { + name: "Stack Auth", + category: "auth", + indicators: { + packages: ["@stackframe/stack", "@stack-auth/nextjs"], + envVars: ["STACK_PROJECT_ID", "STACK_SECRET_SERVER_KEY"], + }, + docs: "https://docs.stack-auth.com/", + }, + { + name: "Vercel Blob", + category: "storage", + indicators: { + packages: ["@vercel/blob"], + envVars: ["BLOB_READ_WRITE_TOKEN"], + }, + docs: "https://vercel.com/docs/storage/vercel-blob", + }, + { + name: "AWS S3", + category: "storage", + indicators: { + packages: ["@aws-sdk/client-s3", "aws-sdk"], + envVars: ["AWS_ACCESS_KEY_ID", "S3_BUCKET"], + }, + docs: "https://docs.aws.amazon.com/s3/", + }, + { + name: "Cloudinary", + category: "storage", + indicators: { + packages: ["cloudinary"], + envVars: ["CLOUDINARY_URL", "CLOUDINARY_API_KEY"], + }, + docs: "https://cloudinary.com/documentation", + }, + { + name: "OpenAI", + category: "ai", + indicators: { + packages: ["openai", "@langchain/openai"], + envVars: ["OPENAI_API_KEY"], + }, + docs: "https://platform.openai.com/docs", + }, + { + name: "Anthropic", + category: "ai", + indicators: { + packages: ["@anthropic-ai/sdk"], + envVars: ["ANTHROPIC_API_KEY"], + }, + docs: "https://docs.anthropic.com/", + }, + { + name: "SendGrid", + category: "email", + indicators: { + packages: ["@sendgrid/mail"], + envVars: ["SENDGRID_API_KEY"], + }, + docs: "https://docs.sendgrid.com/", + }, + { + name: "Resend", + category: "email", + indicators: { + packages: ["resend"], + envVars: ["RESEND_API_KEY"], + }, + docs: "https://resend.com/docs", + }, + { + name: "Sentry", + category: "monitoring", + indicators: { + packages: ["@sentry/nextjs", "@sentry/node", "@sentry/browser"], + envVars: ["SENTRY_DSN"], + }, + docs: "https://docs.sentry.io/", + }, + { + name: "Twilio", + category: "messaging", + indicators: { + packages: ["twilio"], + envVars: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"], + }, + docs: "https://www.twilio.com/docs", + }, + { + name: "Vercel Analytics", + category: "analytics", + indicators: { + packages: ["@vercel/analytics"], + envVars: [], + }, + docs: "https://vercel.com/docs/analytics", + }, + { + name: "Docker", + category: "infrastructure", + indicators: { + packages: [], + envVars: [], + files: ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".dockerignore"], + }, + docs: "https://docs.docker.com/", + }, +]; + +async function scanServices(targetDir, envVars) { + const detectedServices = []; + + let packageDeps = new Set(); + const pkgPath = path.join(targetDir, "package.json"); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + }; + packageDeps = new Set(Object.keys(allDeps)); + } catch {} + } + + const reqPath = path.join(targetDir, "requirements.txt"); + if (fs.existsSync(reqPath)) { + try { + const content = fs.readFileSync(reqPath, "utf-8"); + content.split("\n").forEach((line) => { + const pkg = line.trim().split(/[>= v.name)); + + for (const sig of SERVICE_SIGNATURES) { + const matchedPackages = sig.indicators.packages?.filter((p) => + packageDeps.has(p) + ) || []; + const matchedEnvVars = sig.indicators.envVars?.filter((v) => + envVarNames.has(v) + ) || []; + const matchedFiles = sig.indicators.files?.filter((f) => + fs.existsSync(path.join(targetDir, f)) + ) || []; + + const confidence = + matchedPackages.length * 40 + + matchedEnvVars.length * 30 + + matchedFiles.length * 30; + + if (confidence > 0) { + detectedServices.push({ + name: sig.name, + category: sig.category, + confidence: Math.min(confidence, 100), + matchedPackages, + matchedEnvVars, + matchedFiles, + docs: sig.docs, + requiredEnvVars: sig.indicators.envVars || [], + }); + } + } + + return detectedServices.sort((a, b) => b.confidence - a.confidence); +} + +module.exports = { scanServices }; diff --git a/env-agent-finder/src/server.js b/env-agent-finder/src/server.js new file mode 100644 index 0000000..fcc1a01 --- /dev/null +++ b/env-agent-finder/src/server.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const url = require("url"); +const { scanProject } = require("./index"); + +const PORT = process.env.EAF_PORT || 4800; + +function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + resolve(JSON.parse(body)); + } catch { + resolve({}); + } + }); + req.on("error", reject); + }); +} + +function json(res, data, status = 200) { + res.writeHead(status, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify(data)); +} + +function serveFile(res, filePath, contentType) { + try { + const content = fs.readFileSync(filePath, "utf-8"); + res.writeHead(200, { "Content-Type": contentType }); + res.end(content); + } catch { + res.writeHead(404); + res.end("Not found"); + } +} + +function generateEnvFile(envVars, services, userValues = {}) { + const lines = []; + lines.push("# Generated by env-agent-finder"); + lines.push(`# ${new Date().toISOString()}`); + lines.push(""); + + const varServiceMap = {}; + for (const svc of services) { + for (const v of svc.requiredEnvVars || []) { + varServiceMap[v] = svc.name; + } + } + + const grouped = new Map(); + for (const v of envVars) { + const svc = varServiceMap[v.name] || v.service || "Other"; + if (!grouped.has(svc)) grouped.set(svc, []); + grouped.get(svc).push(v); + } + + for (const [service, vars] of grouped) { + lines.push(`# ${service}`); + for (const v of vars) { + const value = userValues[v.name] || v.value || ""; + lines.push(`${v.name}=${value}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +const server = http.createServer(async (req, res) => { + const parsed = url.parse(req.url, true); + const pathname = parsed.pathname; + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return; + } + + if (pathname === "/" && req.method === "GET") { + serveFile(res, path.join(__dirname, "app.html"), "text/html"); + return; + } + + if (pathname === "/api/scan" && req.method === "POST") { + const body = await parseBody(req); + const targetDir = body.path; + + if (!targetDir) { + return json(res, { error: "Missing 'path' field" }, 400); + } + + const resolved = path.resolve(targetDir); + if (!fs.existsSync(resolved)) { + return json(res, { error: `Directory not found: ${resolved}` }, 404); + } + + try { + const report = await scanProject(resolved); + return json(res, report); + } catch (err) { + return json(res, { error: err.message }, 500); + } + } + + if (pathname === "/api/generate" && req.method === "POST") { + const body = await parseBody(req); + const { envVars, services, userValues } = body; + + if (!envVars) { + return json(res, { error: "Missing scan data" }, 400); + } + + const content = generateEnvFile(envVars, services || [], userValues || {}); + return json(res, { content, filename: ".env.local" }); + } + + if (pathname === "/api/save" && req.method === "POST") { + const body = await parseBody(req); + const { targetDir, envVars, services, userValues } = body; + + if (!targetDir || !envVars) { + return json(res, { error: "Missing required fields" }, 400); + } + + const resolved = path.resolve(targetDir); + const envPath = path.join(resolved, ".env.local"); + const content = generateEnvFile(envVars, services || [], userValues || {}); + + try { + fs.writeFileSync(envPath, content, "utf-8"); + return json(res, { success: true, path: envPath }); + } catch (err) { + return json(res, { error: err.message }, 500); + } + } + + res.writeHead(404); + res.end("Not found"); +}); + +server.listen(PORT, () => { + console.log(""); + console.log("═".repeat(50)); + console.log(" ENV AGENT FINDER — Web App"); + console.log("═".repeat(50)); + console.log(""); + console.log(` 🌐 Open in browser: http://localhost:${PORT}`); + console.log(""); + console.log(" Press Ctrl+C to stop"); + console.log("═".repeat(50)); + console.log(""); +}); diff --git a/env-agent-finder/vercel.json b/env-agent-finder/vercel.json new file mode 100644 index 0000000..245b0d1 --- /dev/null +++ b/env-agent-finder/vercel.json @@ -0,0 +1,8 @@ +{ + "buildCommand": "", + "outputDirectory": "public", + "framework": null, + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ] +} diff --git a/stubs/stack-auth-nextjs/index.d.ts b/stubs/stack-auth-nextjs/index.d.ts new file mode 100644 index 0000000..48269a4 --- /dev/null +++ b/stubs/stack-auth-nextjs/index.d.ts @@ -0,0 +1,9 @@ +interface StackUser { + id: string; + displayName: string; + primaryEmail: string; + activeOrganizationId: string; +} + +export function useUser(): StackUser; +export function currentUser(): Promise; diff --git a/stubs/stack-auth-nextjs/index.js b/stubs/stack-auth-nextjs/index.js new file mode 100644 index 0000000..7f295e8 --- /dev/null +++ b/stubs/stack-auth-nextjs/index.js @@ -0,0 +1,18 @@ +"use client"; + +const mockUser = { + id: "00000000-0000-0000-0000-000000000002", + displayName: "Dev User", + primaryEmail: "dev@taskflow.local", + activeOrganizationId: "00000000-0000-0000-0000-000000000001", +}; + +function useUser() { + return mockUser; +} + +async function currentUser() { + return mockUser; +} + +module.exports = { useUser, currentUser }; diff --git a/stubs/stack-auth-nextjs/package.json b/stubs/stack-auth-nextjs/package.json new file mode 100644 index 0000000..ccc81de --- /dev/null +++ b/stubs/stack-auth-nextjs/package.json @@ -0,0 +1,12 @@ +{ + "name": "@stack-auth/nextjs", + "version": "0.0.1", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "react-server": "./server.js", + "default": "./index.js" + } + } +} diff --git a/stubs/stack-auth-nextjs/server.js b/stubs/stack-auth-nextjs/server.js new file mode 100644 index 0000000..f746145 --- /dev/null +++ b/stubs/stack-auth-nextjs/server.js @@ -0,0 +1,16 @@ +const mockUser = { + id: "00000000-0000-0000-0000-000000000002", + displayName: "Dev User", + primaryEmail: "dev@taskflow.local", + activeOrganizationId: "00000000-0000-0000-0000-000000000001", +}; + +async function currentUser() { + return mockUser; +} + +function useUser() { + return mockUser; +} + +module.exports = { currentUser, useUser };