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
JavaScript
#!/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();