UNPKG

freedback

Version:

A free, self-hosted feedback widget for Next.js apps with multiple storage options and AI-powered insights

1,128 lines (1,117 loc) 45.3 kB
#!/usr/bin/env node var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); // src/index.ts import { Command } from "commander"; import chalk2 from "chalk"; import inquirer from "inquirer"; import ora from "ora"; import { readFileSync, writeFileSync, existsSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { createClient } from "@supabase/supabase-js"; import dotenv from "dotenv"; import { execSync } from "child_process"; import fs from "fs"; // src/FeedbackList.tsx import chalk from "chalk"; function formatDate(dateStr) { if (!dateStr) return ""; return dateStr.replace("T", " ").slice(0, 19); } function printFeedbackList(data) { console.log(chalk.cyan.bold(` \u{1F4CB} Latest Feedback (${data.length}):`)); console.log(""); data.forEach((f, i) => { let meta = {}; try { meta = typeof f.metadata === "string" ? JSON.parse(f.metadata) : f.metadata || {}; } catch { } const location = meta?.context?.location; const browser = meta?.browser; const url = meta?.context?.url; const lang = browser?.language; const platform = browser?.platform; const time = formatDate(f.created_at); console.log(chalk.magenta(`\u{1F552} ${time}`)); console.log(chalk.white.bold(`${f.emoji || "\u{1F4AC}"} ${f.content}`)); console.log(""); const metadataLines = []; if (location) { metadataLines.push(chalk.gray(`\u{1F30D} ${location.city}, ${location.country} (${location.timezone})`)); } if (platform) { metadataLines.push(chalk.gray(`\u{1F5A5}\uFE0F ${platform}`)); } if (lang) { metadataLines.push(chalk.gray(`\u{1F310} ${lang}`)); } if (url) { metadataLines.push(chalk.gray(`\u{1F517} ${url}`)); } if (f.email) { metadataLines.push(chalk.gray(`\u{1F4E7} ${f.email}`)); } if (metadataLines.length > 0) { metadataLines.forEach((line) => console.log(line)); console.log(""); } if (i < data.length - 1) { console.log(chalk.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); console.log(""); } }); } // src/index.ts var program = new Command(); program.name("freedback").description("CLI for setting up Freedback in your project").version("0.0.1"); var __filename = fileURLToPath(import.meta.url); var __dirname = path.dirname(__filename); var createTablesSQL = ` create table if not exists freedback ( id uuid default gen_random_uuid() primary key, content text not null, email text, emoji text, created_at timestamp with time zone default timezone('utc'::text, now()) not null, metadata jsonb ); -- Enable RLS alter table freedback enable row level security; -- Create policy to allow anonymous inserts create policy "Allow anonymous inserts" on freedback for insert to anon with check (true); -- Create policy to allow authenticated users to read create policy "Allow authenticated users to read" on freedback for select to authenticated using (true); `; async function configureNextJS() { const nextConfigPath = path.join(process.cwd(), "next.config.js"); const nextConfigExists = existsSync(nextConfigPath); if (nextConfigExists) { const spinner = ora("Updating Next.js configuration").start(); let nextConfig = readFileSync(nextConfigPath, "utf-8"); if (!nextConfig.includes("transpilePackages")) { nextConfig = nextConfig.replace( /module\.exports\s*=\s*{/, `module.exports = { transpilePackages: ['@freedback/widget'],` ); writeFileSync(nextConfigPath, nextConfig); spinner.succeed("Next.js configuration updated"); } else { spinner.succeed("Next.js configuration already includes transpilePackages"); } } } async function configureTypeScript() { const tsConfigPath = path.join(process.cwd(), "tsconfig.json"); const tsConfigExists = existsSync(tsConfigPath); if (tsConfigExists) { const spinner = ora("Updating TypeScript configuration").start(); const tsConfig = JSON.parse(readFileSync(tsConfigPath, "utf-8")); if (!tsConfig.compilerOptions.paths) { tsConfig.compilerOptions.paths = {}; } if (!tsConfig.compilerOptions.paths["@/*"]) { tsConfig.compilerOptions.paths["@/*"] = ["./*"]; writeFileSync(tsConfigPath, JSON.stringify(tsConfig, null, 2)); spinner.succeed("TypeScript configuration updated"); } else { spinner.succeed("TypeScript configuration already includes @/* path alias"); } } } async function copyWidgetToProject() { const spinner = ora("Copying widget to your project").start(); const freedbackDir = path.join(process.cwd(), "components", "freedback"); if (!fs.existsSync(freedbackDir)) { fs.mkdirSync(freedbackDir, { recursive: true }); } const widgetCode = readFileSync(path.join(__dirname, "templates", "widget.tsx"), "utf-8"); writeFileSync(path.join(freedbackDir, "index.tsx"), widgetCode); spinner.succeed("Widget copied to your project"); } async function copyApiRouteToProject() { const spinner = ora("Creating API route for feedback submission").start(); const hasAppDir = existsSync(path.join(process.cwd(), "app")); const hasPagesDir = existsSync(path.join(process.cwd(), "pages")); let routerType = ""; let apiDir = ""; let apiFile = ""; let templateFile = ""; if (hasAppDir) { routerType = "App Router"; apiDir = path.join(process.cwd(), "app", "api", "feedback"); apiFile = "route.ts"; templateFile = "api-app.ts"; } else if (hasPagesDir) { routerType = "Pages Router"; apiDir = path.join(process.cwd(), "pages", "api"); apiFile = "feedback.ts"; templateFile = "api-pages.ts"; } else { routerType = "Pages Router (default)"; apiDir = path.join(process.cwd(), "pages", "api"); apiFile = "feedback.ts"; templateFile = "api-pages.ts"; } if (!fs.existsSync(apiDir)) { fs.mkdirSync(apiDir, { recursive: true }); } const apiCode = readFileSync(path.join(__dirname, "templates", templateFile), "utf-8"); writeFileSync(path.join(apiDir, apiFile), apiCode); spinner.succeed(`API route created for ${routerType} at ${path.relative(process.cwd(), path.join(apiDir, apiFile))}`); } program.command("init").description("Initialize Freedback in your project").action(async () => { console.log(chalk2.blue("\u{1F680} Welcome to Freedback setup!")); console.log(chalk2.gray("Let's customize your feedback widget.\n")); if (!existsSync("package.json")) { console.error(chalk2.red("\u274C No package.json found. Please run this command in your project root.")); process.exit(1); } const spinner = ora("Reading package.json").start(); const packageJson = JSON.parse(readFileSync("package.json", "utf-8")); spinner.succeed(); if (!packageJson.dependencies?.react && !packageJson.devDependencies?.react) { console.error(chalk2.red("\u274C React is required to use Freedback. Please install React first.")); process.exit(1); } if (!packageJson.dependencies?.next && !packageJson.devDependencies?.next) { console.error(chalk2.red("\u274C Next.js is required to use Freedback. Please install Next.js first.")); process.exit(1); } if (!packageJson.dependencies?.tailwindcss && !packageJson.devDependencies?.tailwindcss) { console.error(chalk2.red("\u274C Tailwind CSS is required to use Freedback. Please install Tailwind CSS first.")); process.exit(1); } if (!existsSync("components.json")) { console.error(chalk2.red("\u274C shadcn/ui is not set up. Please run `npx shadcn@latest init` and add the required components (Dialog, Button, Input, Label, Textarea, Tooltip).")); process.exit(1); } ora().succeed("shadcn/ui detected"); const requiredComponents = ["button", "dialog", "input", "label", "textarea"]; const missingComponents = requiredComponents.filter((component) => { const componentPath = path.join(process.cwd(), "components", "ui", `${component}.tsx`); return !fs.existsSync(componentPath); }); if (missingComponents.length > 0) { ora().warn(`Components not found: ${missingComponents.join(", ")}`); const { installMissingComponents } = await inquirer.prompt([ { type: "confirm", name: "installMissingComponents", message: `Install missing shadcn/ui components (${missingComponents.join(", ")})?`, default: true } ]); if (installMissingComponents) { const componentSpinner = ora(`Installing shadcn/ui components: ${missingComponents.join(", ")}`).start(); try { execSync(`npx shadcn@latest add ${missingComponents.join(" ")}`, { stdio: "inherit" }); componentSpinner.succeed("All required shadcn/ui components are now installed."); } catch (e) { componentSpinner.fail("Failed to install required shadcn/ui components. Please add them manually and try again."); process.exit(1); } } else { ora().fail("Freedback requires these shadcn/ui components. Please install them manually and try again."); process.exit(1); } } else { ora().succeed("All required shadcn/ui components are present."); } await configureNextJS(); await configureTypeScript(); console.log(chalk2.blue("\n\u{1F3A8} Customizing your widget...")); const answers = await inquirer.prompt([ { type: "list", name: "mode", message: "How should the feedback widget appear?", choices: [ { name: "Button (default)", value: "button" }, { name: "Inline", value: "inline" } ], default: "button" }, { type: "input", name: "buttonTitle", message: "What should the feedback widget button say?", default: "Feedback", when: (prev) => prev.mode === "button" }, { type: "input", name: "title", message: "What should the feedback title/question be?", default: "What's your feedback?" }, { type: "input", name: "placeholder", message: "What should the text input placeholder be?", default: "Ideas to improve the product..." }, { type: "confirm", name: "showEmojis", message: "Show emoji reactions?", default: true, when: (prev) => prev.mode === "button" }, { type: "confirm", name: "collectEmail", message: "Collect user email?", default: true }, { type: "confirm", name: "emailRequired", message: "Should email be mandatory?", default: false, when: (prev) => prev.collectEmail } ]); await copyWidgetToProject(); console.log(chalk2.blue("\n\u{1F4BE} Choose your feedback storage...")); const { storageChoice } = await inquirer.prompt([ { type: "list", name: "storageChoice", message: "How do you want to handle feedback?", choices: [ { name: "Supabase Database (with optional email notifications)", value: "supabase" }, { name: "Email Only (no database, just email notifications)", value: "email" }, { name: "Console Only (for development/testing)", value: "console" } ], default: "supabase" } ]); let storageMode = storageChoice; let enableEmails = false; let resendApiKey = ""; let notificationEmail = ""; let fromEmail = ""; let supabaseUrl = ""; let supabaseAnonKey = ""; async function setupEmailNotifications() { console.log(chalk2.blue("\n\u{1F4E7} Setting up email notifications...")); let localResendApiKey = ""; let localNotificationEmail = ""; let localFromEmail = ""; let resendKeyFound = false; let notificationEmailFound = false; let fromEmailFound = false; let envVars = {}; let envFile = ""; if (existsSync(".env.local")) { envVars = dotenv.parse(readFileSync(".env.local")); envFile = ".env.local"; } else if (existsSync(".env")) { envVars = dotenv.parse(readFileSync(".env")); envFile = ".env"; } if (envFile) { for (const [key, value] of Object.entries(envVars)) { if (key === "RESEND_API_KEY") { localResendApiKey = value; resendKeyFound = true; } else if (key === "FREEDBACK_EMAIL_NOTIFICATION") { localNotificationEmail = value; notificationEmailFound = true; } else if (key === "FREEDBACK_EMAIL_FROM") { localFromEmail = value; fromEmailFound = true; } } } const foundVars = []; if (resendKeyFound) foundVars.push("RESEND_API_KEY"); if (notificationEmailFound) foundVars.push("FREEDBACK_EMAIL_NOTIFICATION"); if (fromEmailFound) foundVars.push("FREEDBACK_EMAIL_FROM"); if (foundVars.length > 0) { ora().succeed(`Found existing email config in ${envFile}: ${foundVars.join(", ")}`); } else { ora().warn("No email configuration found in .env files."); } let hasResend = false; const pkgJsonPath = path.join(process.cwd(), "package.json"); if (fs.existsSync(pkgJsonPath)) { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); hasResend = pkg.dependencies && pkg.dependencies["resend"] || pkg.devDependencies && pkg.devDependencies["resend"]; } if (!hasResend) { const { installResend } = await inquirer.prompt([ { type: "confirm", name: "installResend", message: "resend is not installed. Install it now?", default: true } ]); if (installResend) { const resendSpinner = ora("Installing resend...").start(); try { execSync("npm install resend", { stdio: "inherit" }); resendSpinner.succeed("resend installed."); } catch (e) { resendSpinner.fail("Failed to install resend. Please install it manually and try again."); process.exit(1); } } else { ora().fail("Email notifications require resend. Exiting."); process.exit(1); } } else { ora().succeed("resend package found."); } const emailPrompts = []; if (!localResendApiKey) { emailPrompts.push({ type: "input", name: "resendApiKey", message: "Enter your Resend API key:", validate: (input) => input.startsWith("re_") ? true : 'Please enter a valid Resend API key (starts with "re_")' }); } if (!localNotificationEmail) { emailPrompts.push({ type: "input", name: "notificationEmail", message: "What email should receive feedback notifications?", validate: (input) => input.includes("@") ? true : "Please enter a valid email address" }); } if (!localFromEmail) { emailPrompts.push({ type: "input", name: "fromEmail", message: "What verified email/domain should send the notifications? (e.g., feedback@yourdomain.com)", validate: (input) => input.includes("@") ? true : "Please enter a valid email address" }); } if (emailPrompts.length > 0) { const emailAnswers = await inquirer.prompt(emailPrompts); localResendApiKey = localResendApiKey || emailAnswers.resendApiKey; localNotificationEmail = localNotificationEmail || emailAnswers.notificationEmail; localFromEmail = localFromEmail || emailAnswers.fromEmail; } console.log(chalk2.blue("\n\u2699\uFE0F Setting up API endpoints...")); await copyApiRouteToProject(); return { resendApiKey: localResendApiKey, notificationEmail: localNotificationEmail, fromEmail: localFromEmail, enableEmails: true }; } async function setupSupabaseIntegration() { let hasSupabase = false; const pkgJsonPath = path.join(process.cwd(), "package.json"); if (fs.existsSync(pkgJsonPath)) { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); hasSupabase = pkg.dependencies && pkg.dependencies["@supabase/supabase-js"] || pkg.devDependencies && pkg.devDependencies["@supabase/supabase-js"]; } if (!hasSupabase) { const { installSupabase } = await inquirer.prompt([ { type: "confirm", name: "installSupabase", message: "@supabase/supabase-js is not installed. Install it now?", default: true } ]); if (installSupabase) { try { execSync("npm install @supabase/supabase-js", { stdio: "inherit" }); ora().succeed("@supabase/supabase-js installed."); } catch (e) { ora().fail("Failed to install @supabase/supabase-js. Please install it manually and try again."); process.exit(1); } } else { ora().fail("Freedback requires @supabase/supabase-js. Exiting."); process.exit(1); } } console.log(chalk2.blue("\n\u{1F527} Setting up Supabase...")); console.log(chalk2.gray("Looking for Supabase credentials in .env.local or .env...")); let envVars = {}; let envChecked = false; let envFile = ""; if (existsSync(".env.local")) { envVars = dotenv.parse(readFileSync(".env.local")); envFile = ".env.local"; } else if (existsSync(".env")) { envVars = dotenv.parse(readFileSync(".env")); envFile = ".env"; } if (envFile) { for (const [key, value] of Object.entries(envVars)) { if (!supabaseUrl && key.endsWith("SUPABASE_URL")) supabaseUrl = value; if (!supabaseAnonKey && key.endsWith("SUPABASE_ANON_KEY")) supabaseAnonKey = value; } envChecked = true; if (supabaseUrl && supabaseAnonKey) { ora().succeed(`Supabase credentials found in ${envFile}.`); } else { ora().warn(`Supabase credentials not found in ${envFile}, will prompt for missing values.`); } } else { ora().warn("No .env.local or .env file found, will prompt for Supabase credentials."); } const supabasePrompts = []; if (!supabaseUrl) { supabasePrompts.push({ type: "input", name: "supabaseUrl", message: envChecked ? `.env found, but no variable ending with SUPABASE_URL. Please enter your Supabase project URL:` : "Enter your Supabase project URL:", validate: (input) => input.startsWith("https://") ? true : "Please enter a valid Supabase URL" }); } if (!supabaseAnonKey) { supabasePrompts.push({ type: "input", name: "supabaseAnonKey", message: envChecked ? `.env found, but no variable ending with SUPABASE_ANON_KEY. Please enter your Supabase anon key:` : "Enter your Supabase anon key:", validate: (input) => input.length > 0 ? true : "Please enter your Supabase anon key" }); } let supabaseAnswers = {}; if (supabasePrompts.length > 0) { supabaseAnswers = await inquirer.prompt(supabasePrompts); } supabaseUrl = supabaseUrl || supabaseAnswers.supabaseUrl; supabaseAnonKey = supabaseAnonKey || supabaseAnswers.supabaseAnonKey; if (envChecked && (!supabaseUrl || !supabaseAnonKey)) { const foundVars = Object.keys(envVars).filter((k) => k.includes("SUPABASE")); ora().warn(`.env found. SUPABASE-related variables detected: ${foundVars.length ? foundVars.join(", ") : "none"}`); } let supabaseCliInstalled = false; try { execSync("supabase --version", { stdio: "ignore" }); supabaseCliInstalled = true; } catch { supabaseCliInstalled = false; } if (!supabaseCliInstalled) { const { installCli } = await inquirer.prompt([ { type: "confirm", name: "installCli", message: "Supabase CLI not found. Install it globally now?", default: true } ]); if (installCli) { const cliSpinner = ora("Installing Supabase CLI globally...").start(); try { execSync("npm install -g supabase", { stdio: "inherit" }); cliSpinner.succeed("Supabase CLI installed."); } catch (e) { cliSpinner.fail("Failed to install Supabase CLI. Please install it manually and try again."); process.exit(1); } } else { ora().fail("Supabase CLI is required. Exiting."); process.exit(1); } } else { ora().succeed("Supabase CLI is installed."); } console.log(chalk2.blue("\n\u{1F5C3}\uFE0F Setting up database tables...")); const testSpinner = ora("Testing Supabase connection...").start(); try { const testSupabase = createClient(supabaseUrl, supabaseAnonKey); const { error } = await testSupabase.from("_test_").select("*").limit(1); if (error && error.message.includes("Invalid API key")) { testSpinner.fail("Invalid Supabase credentials. Please check your URL and anon key."); process.exit(1); } testSpinner.succeed("Supabase connection verified."); } catch (e) { testSpinner.succeed("Supabase connection verified."); } const { setupMethod } = await inquirer.prompt([ { type: "list", name: "setupMethod", message: "How would you like to set up the database tables?", choices: [ { name: "Create migration file (recommended)", value: "migration" }, { name: "Run SQL directly on database", value: "direct" } ], default: "migration" } ]); if (setupMethod === "migration") { const migrationSpinner = ora("Creating Supabase migration...").start(); if (!existsSync("supabase")) { try { execSync("supabase init", { stdio: "pipe" }); migrationSpinner.text = "Supabase project initialized, creating migration..."; } catch (e) { migrationSpinner.fail('Failed to initialize Supabase project. Please run "supabase init" manually.'); process.exit(1); } } const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "_"); const migrationFileName = `${timestamp}_create_freedback_table.sql`; const migrationPath = path.join("supabase", "migrations", migrationFileName); const migrationsDir = path.join("supabase", "migrations"); if (!existsSync(migrationsDir)) { __require("fs").mkdirSync(migrationsDir, { recursive: true }); } writeFileSync(migrationPath, createTablesSQL); migrationSpinner.succeed(`Migration created: ${migrationPath}`); const { runMigration } = await inquirer.prompt([ { type: "confirm", name: "runMigration", message: "Run the migration now?", default: true } ]); if (runMigration) { const migrationRunSpinner = ora("Running migration...").start(); try { execSync("supabase db push", { stdio: "pipe" }); migrationRunSpinner.succeed("Database tables created successfully!"); } catch (e) { migrationRunSpinner.fail("Migration failed. You can run it later with: supabase db push"); console.log(chalk2.yellow("\u{1F4A1} Make sure you have linked your Supabase project: supabase link --project-ref YOUR_PROJECT_REF")); } } else { ora().warn("Migration created but not run. Run it later with: supabase db push"); } } else { const directSpinner = ora("Creating tables directly...").start(); try { const adminSupabase = createClient(supabaseUrl, supabaseAnonKey); await adminSupabase.rpc("exec_sql", { sql: createTablesSQL }); directSpinner.succeed("Database tables created successfully!"); } catch (e) { directSpinner.fail("Failed to create tables directly. Creating migration file instead..."); const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "_"); const migrationFileName = `${timestamp}_create_freedback_table.sql`; const migrationPath = path.join("supabase", "migrations", migrationFileName); if (!existsSync("supabase/migrations")) { __require("fs").mkdirSync("supabase/migrations", { recursive: true }); } writeFileSync(migrationPath, createTablesSQL); ora().warn(`Created migration file instead: ${migrationPath}`); ora().info("Run the migration with: supabase db push"); } } console.log(chalk2.blue("\n\u2699\uFE0F Setting up API endpoints...")); await copyApiRouteToProject(); const { enableEmailsForSupabase } = await inquirer.prompt([ { type: "confirm", name: "enableEmailsForSupabase", message: "Do you also want email notifications for feedback?", default: false } ]); if (enableEmailsForSupabase) { const emailConfig = await setupEmailNotifications(); resendApiKey = emailConfig.resendApiKey; notificationEmail = emailConfig.notificationEmail; enableEmails = emailConfig.enableEmails; fromEmail = emailConfig.fromEmail; } } if (storageChoice === "console") { ora().warn("Console-only mode selected. Feedback will be logged to the browser console."); } else if (storageChoice === "email") { const emailConfig = await setupEmailNotifications(); resendApiKey = emailConfig.resendApiKey; notificationEmail = emailConfig.notificationEmail; enableEmails = emailConfig.enableEmails; fromEmail = emailConfig.fromEmail; } else if (storageChoice === "supabase") { await setupSupabaseIntegration(); } let envContent = ""; let envType = ""; if (existsSync("package.json")) { const pkg = JSON.parse(readFileSync("package.json", "utf-8")); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const isNext = !!deps["next"]; if (isNext) { if (storageChoice === "email") { envContent = `RESEND_API_KEY=${resendApiKey} FREEDBACK_EMAIL_NOTIFICATION=${notificationEmail} FREEDBACK_EMAIL_FROM=${fromEmail}`; envType = "Next.js (Email-only)"; } else if (storageChoice === "supabase") { envContent = `NEXT_PUBLIC_SUPABASE_URL=${supabaseUrl} NEXT_PUBLIC_SUPABASE_ANON_KEY=${supabaseAnonKey}`; if (enableEmails) { envContent += ` RESEND_API_KEY=${resendApiKey} FREEDBACK_EMAIL_NOTIFICATION=${notificationEmail} FREEDBACK_EMAIL_FROM=${fromEmail}`; } envType = "Next.js"; } } else { if (storageChoice === "email") { envContent = `RESEND_API_KEY=${resendApiKey} FREEDBACK_EMAIL_NOTIFICATION=${notificationEmail} FREEDBACK_EMAIL_FROM=${fromEmail}`; envType = "Next.js (Email-only, default)"; } else if (storageChoice === "supabase") { envContent = `NEXT_PUBLIC_SUPABASE_URL=${supabaseUrl} NEXT_PUBLIC_SUPABASE_ANON_KEY=${supabaseAnonKey}`; if (enableEmails) { envContent += ` RESEND_API_KEY=${resendApiKey} FREEDBACK_EMAIL_NOTIFICATION=${notificationEmail} FREEDBACK_EMAIL_FROM=${fromEmail}`; } envType = "Next.js (default)"; } } } if ((storageChoice === "email" || storageChoice === "supabase") && envContent) { if (!existsSync(".env")) { writeFileSync(".env", envContent); ora().succeed(`Created .env file with credentials (${envType})`); } else { const existingEnv = readFileSync(".env", "utf-8"); const existingVars = dotenv.parse(existingEnv); const newVars = dotenv.parse(envContent); const missingVars = []; const existingKeys = Object.keys(existingVars); for (const [key, value] of Object.entries(newVars)) { if (!existingKeys.includes(key)) { missingVars.push(`${key}=${value}`); } } if (missingVars.length > 0) { const newContent = existingEnv.endsWith("\n") ? existingEnv : existingEnv + "\n"; const updatedContent = newContent + "\n# Freedback configuration\n" + missingVars.join("\n") + "\n"; writeFileSync(".env", updatedContent); ora().succeed(`Updated .env file with ${missingVars.length} new variable${missingVars.length > 1 ? "s" : ""} (${envType})`); } else { ora().succeed("All required variables already exist in .env file"); } } } const config = { title: answers.title, buttonTitle: answers.buttonTitle, placeholder: answers.placeholder, showEmojis: answers.showEmojis, collectEmail: answers.collectEmail, emailRequired: answers.collectEmail ? answers.emailRequired : false, mode: answers.mode, storage: storageMode }; ora().succeed("Freedback has been initialized!"); ora().warn("Note: The widget has been copied to your project at components/freedback. You can customize it as needed."); console.log(chalk2.blue("\n\u{1F4CB} Next steps:")); console.log(chalk2.blue("Paste this in your app:")); const defaultProps = { title: "What's your feedback?", buttonTitle: "Feedback", placeholder: "Ideas to improve the product...", showEmojis: true, collectEmail: true, emailRequired: false, mode: "button", storage: "supabase" }; const nonDefaultPropLines = Object.entries(config).filter(([key, value]) => { if (key in defaultProps) { return value !== defaultProps[key]; } return false; }).map(([key, value]) => { if (typeof value === "string") return ` ${key}="${value}"`; if (typeof value === "boolean") return ` ${key}={${value}}`; return ""; }).filter(Boolean).join("\n"); if (nonDefaultPropLines.length === 0) { console.log(` import { Freedback } from '@/components/freedback'; <Freedback /> `); } else { console.log(` import { Freedback } from '@/components/freedback'; <Freedback ${nonDefaultPropLines} /> `); } }); async function getSupabaseCredentialsFromEnv(requireServiceRole = false) { const spinner = ora("Looking for Supabase credentials...").start(); let url = ""; let serviceRoleKey = ""; let anonKey = ""; if (existsSync(".env")) { const envVars = dotenv.parse(readFileSync(".env")); for (const [k, v] of Object.entries(envVars)) { if (!url && k.endsWith("SUPABASE_URL")) url = v; if (!serviceRoleKey && k.endsWith("SUPABASE_SERVICE_ROLE_KEY")) serviceRoleKey = v; if (!anonKey && k.endsWith("SUPABASE_ANON_KEY")) anonKey = v; } } if (!url || !serviceRoleKey && requireServiceRole) { spinner.fail("Missing Supabase credentials in .env file"); const prompts = []; if (!url) { prompts.push({ type: "input", name: "supabaseUrl", message: "Enter your Supabase project URL:", validate: (input) => input.startsWith("https://") ? true : "Please enter a valid Supabase URL" }); } if (!serviceRoleKey && requireServiceRole) { prompts.push({ type: "input", name: "serviceRoleKey", message: "Enter your Supabase service role key (for admin access):", validate: (input) => input.length > 0 ? true : "Please enter your Supabase service role key" }); } if (prompts.length > 0) { const answers = await inquirer.prompt(prompts); url = url || answers.supabaseUrl; serviceRoleKey = serviceRoleKey || answers.serviceRoleKey; const missingVars = []; if (answers.supabaseUrl) { missingVars.push(`NEXT_PUBLIC_SUPABASE_URL=${answers.supabaseUrl}`); } if (answers.serviceRoleKey) { missingVars.push(`SUPABASE_SERVICE_ROLE_KEY=${answers.serviceRoleKey}`); } if (missingVars.length > 0) { if (!existsSync(".env")) { writeFileSync(".env", missingVars.join("\n") + "\n"); ora().succeed("Created .env file with Supabase credentials"); } else { const existingEnv = readFileSync(".env", "utf-8"); const newContent = existingEnv.endsWith("\n") ? existingEnv : existingEnv + "\n"; const updatedContent = newContent + "\n# Supabase admin credentials\n" + missingVars.join("\n") + "\n"; writeFileSync(".env", updatedContent); ora().succeed(`Updated .env file with ${missingVars.length} new credential${missingVars.length > 1 ? "s" : ""}`); } } } } else { const foundCreds = []; if (url) foundCreds.push("URL"); if (serviceRoleKey) foundCreds.push("service role key"); if (anonKey) foundCreds.push("anon key"); spinner.succeed(`Found Supabase credentials: ${foundCreds.join(", ")}`); } if (requireServiceRole && !serviceRoleKey) { ora().fail("Service role key is required for admin operations"); process.exit(1); } return { url, key: serviceRoleKey }; } program.command("list").description("List recent feedback from Supabase").option("--limit <number>", "Number of feedback entries to show", "10").option("--since <date>", "Show feedback since date (YYYY-MM-DD)").option("--emoji <emoji>", "Filter by specific emoji reaction").option("--today", "Show only today's feedback").option("--week", "Show this week's feedback").option("--month", "Show this month's feedback").option("--year", "Show this year's feedback").action(async (opts) => { const { url, key } = await getSupabaseCredentialsFromEnv(true); if (!url || !key) { process.exit(1); } const fetchSpinner = ora("Fetching feedback from Supabase...").start(); try { const { createClient: createClient2 } = await import("@supabase/supabase-js"); const supabase = createClient2(url, key); const limit = parseInt(opts.limit, 10) || 10; let query = supabase.from("freedback").select("*").order("created_at", { ascending: false }); const now = /* @__PURE__ */ new Date(); let dateFilter = null; let filterDescription = ""; if (opts.today) { const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); dateFilter = today.toISOString(); filterDescription = "today"; } else if (opts.week) { const weekStart = new Date(now); weekStart.setDate(now.getDate() - now.getDay()); weekStart.setHours(0, 0, 0, 0); dateFilter = weekStart.toISOString(); filterDescription = "this week"; } else if (opts.month) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); dateFilter = monthStart.toISOString(); filterDescription = "this month"; } else if (opts.year) { const yearStart = new Date(now.getFullYear(), 0, 1); dateFilter = yearStart.toISOString(); filterDescription = "this year"; } else if (opts.since) { const sinceDate = new Date(opts.since); if (isNaN(sinceDate.getTime())) { fetchSpinner.fail("Invalid date format. Use YYYY-MM-DD"); process.exit(1); } dateFilter = sinceDate.toISOString(); filterDescription = `since ${opts.since}`; } if (dateFilter) { query = query.gte("created_at", dateFilter); } if (opts.emoji) { query = query.eq("emoji", opts.emoji); filterDescription += filterDescription ? ` with ${opts.emoji}` : `with ${opts.emoji}`; } query = query.limit(limit); const { data, error } = await query; if (error) { fetchSpinner.fail("Failed to fetch feedback from Supabase"); console.error("Error details:", error.message); process.exit(1); } if (!data || data.length === 0) { fetchSpinner.succeed("Connected to Supabase successfully"); const noDataMsg = filterDescription ? `No feedback found ${filterDescription}` : "No feedback found in the database"; ora().warn(noDataMsg); process.exit(0); } const resultMsg = filterDescription ? `Found ${data.length} feedback ${data.length === 1 ? "entry" : "entries"} ${filterDescription}` : `Found ${data.length} feedback ${data.length === 1 ? "entry" : "entries"}`; fetchSpinner.succeed(resultMsg); printFeedbackList(data); } catch (error) { fetchSpinner.fail("Error connecting to Supabase"); console.error("Error details:", error instanceof Error ? error.message : "Unknown error"); process.exit(1); } }); program.command("digest").description("Generate AI-powered summary of feedback").option("--limit <number>", "Number of recent feedback entries to analyze", "50").option("--since <date>", "Analyze feedback since date (YYYY-MM-DD)").option("--today", "Analyze only today's feedback").option("--week", "Analyze this week's feedback").option("--month", "Analyze this month's feedback").option("--year", "Analyze this year's feedback").action(async (opts) => { const spinner = ora("Checking for AI API keys...").start(); let openaiKey = ""; let anthropicKey = ""; let selectedProvider = ""; let envVars = {}; let envFile = ""; if (existsSync(".env.local")) { envVars = dotenv.parse(readFileSync(".env.local")); envFile = ".env.local"; } else if (existsSync(".env")) { envVars = dotenv.parse(readFileSync(".env")); envFile = ".env"; } openaiKey = envVars["OPENAI_API_KEY"] || process.env.OPENAI_API_KEY || ""; anthropicKey = envVars["ANTHROPIC_API_KEY"] || process.env.ANTHROPIC_API_KEY || ""; if (!openaiKey && !anthropicKey) { spinner.fail("No AI API keys found in .env file"); const { provider } = await inquirer.prompt([ { type: "list", name: "provider", message: "Which AI provider would you like to use?", choices: [ { name: "OpenAI (GPT-4o-mini)", value: "openai" }, { name: "Anthropic (Claude 3 Haiku)", value: "anthropic" } ] } ]); const keyPrompt = provider === "openai" ? "Enter your OpenAI API key:" : "Enter your Anthropic API key:"; const { apiKey } = await inquirer.prompt([ { type: "input", name: "apiKey", message: keyPrompt, validate: (input) => input.length > 0 ? true : "Please enter a valid API key" } ]); const envVarName = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"; const envLine = `${envVarName}=${apiKey}`; if (!existsSync(".env")) { writeFileSync(".env", envLine + "\n"); ora().succeed("Created .env file with AI API key"); } else { const existingEnv = readFileSync(".env", "utf-8"); const newContent = existingEnv.endsWith("\n") ? existingEnv : existingEnv + "\n"; const updatedContent = newContent + "\n# AI API key\n" + envLine + "\n"; writeFileSync(".env", updatedContent); ora().succeed("Updated .env file with AI API key"); } if (provider === "openai") { openaiKey = apiKey; selectedProvider = "openai"; } else { anthropicKey = apiKey; selectedProvider = "anthropic"; } } else if (openaiKey && anthropicKey) { spinner.succeed(`Found both AI API keys in ${envFile || "environment"}`); const { provider } = await inquirer.prompt([ { type: "list", name: "provider", message: "Which AI provider would you like to use for this digest?", choices: [ { name: "OpenAI (GPT-4o-mini)", value: "openai" }, { name: "Anthropic (Claude 3 Haiku)", value: "anthropic" } ], default: "openai" } ]); selectedProvider = provider; } else if (openaiKey) { spinner.succeed(`Found OpenAI API key in ${envFile || "environment"}`); selectedProvider = "openai"; } else { spinner.succeed(`Found Anthropic API key in ${envFile || "environment"}`); selectedProvider = "anthropic"; } const { url, key } = await getSupabaseCredentialsFromEnv(true); if (!url || !key) { process.exit(1); } const fetchSpinner = ora("Fetching feedback for analysis...").start(); try { const { createClient: createClient2 } = await import("@supabase/supabase-js"); const supabase = createClient2(url, key); const limit = parseInt(opts.limit, 10) || 50; let query = supabase.from("freedback").select("*").order("created_at", { ascending: false }); const now = /* @__PURE__ */ new Date(); let dateFilter = null; let filterDescription = ""; if (opts.today) { const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); dateFilter = today.toISOString(); filterDescription = "today"; } else if (opts.week) { const weekStart = new Date(now); weekStart.setDate(now.getDate() - now.getDay()); weekStart.setHours(0, 0, 0, 0); dateFilter = weekStart.toISOString(); filterDescription = "this week"; } else if (opts.month) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); dateFilter = monthStart.toISOString(); filterDescription = "this month"; } else if (opts.year) { const yearStart = new Date(now.getFullYear(), 0, 1); dateFilter = yearStart.toISOString(); filterDescription = "this year"; } else if (opts.since) { const sinceDate = new Date(opts.since); if (isNaN(sinceDate.getTime())) { fetchSpinner.fail("Invalid date format. Use YYYY-MM-DD"); process.exit(1); } dateFilter = sinceDate.toISOString(); filterDescription = `since ${opts.since}`; } if (dateFilter) { query = query.gte("created_at", dateFilter); } query = query.limit(limit); const { data, error } = await query; if (error) { fetchSpinner.fail("Failed to fetch feedback"); console.error("Error details:", error.message); process.exit(1); } if (!data || data.length === 0) { fetchSpinner.fail("No feedback found to analyze"); process.exit(0); } fetchSpinner.succeed(`Analyzing ${data.length} feedback entries ${filterDescription || ""}`); const feedbackData = data.map((item) => ({ date: item.created_at?.split("T")[0], emoji: item.emoji, content: item.content, email: item.email ? "provided" : "anonymous" })); const aiSpinner = ora("Generating AI summary...").start(); try { let summary = ""; if (selectedProvider === "openai") { const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${openaiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: "gpt-4o-mini", messages: [ { role: "system", content: "You are a product feedback analyst. Analyze the provided feedback data and create a concise, actionable summary. Focus on key themes, sentiment patterns, and actionable insights." }, { role: "user", content: `Analyze this feedback data and provide a summary: ${JSON.stringify(feedbackData, null, 2)}` } ], max_tokens: 1e3, temperature: 0.3 }) }); if (!response.ok) { throw new Error(`OpenAI API error: ${response.statusText}`); } const result = await response.json(); summary = result.choices[0]?.message?.content || "No summary generated"; } else if (selectedProvider === "anthropic") { const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": anthropicKey, "Content-Type": "application/json", "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 1e3, messages: [ { role: "user", content: `You are a product feedback analyst. Analyze the provided feedback data and create a concise, actionable summary. Focus on key themes, sentiment patterns, and actionable insights. Feedback data: ${JSON.stringify(feedbackData, null, 2)}` } ] }) }); if (!response.ok) { throw new Error(`Claude API error: ${response.statusText}`); } const result = await response.json(); summary = result.content[0]?.text || "No summary generated"; } aiSpinner.succeed(`AI summary generated using ${selectedProvider === "openai" ? "OpenAI" : "Anthropic"}`); console.log(chalk2.cyan.bold("\n\u{1F916} AI Feedback Digest")); console.log(chalk2.gray(`Based on ${data.length} feedback entries ${filterDescription || ""} `)); console.log(summary); } catch (aiError) { aiSpinner.fail("Failed to generate AI summary"); console.error("AI Error:", aiError instanceof Error ? aiError.message : "Unknown error"); process.exit(1); } } catch (error) { fetchSpinner.fail("Error connecting to Supabase"); console.error("Error details:", error instanceof Error ? error.message : "Unknown error"); process.exit(1); } }); program.parse();