nuxt-supabase-team-auth
Version:
Drop-in Nuxt 3 module for team-based authentication with Supabase
428 lines (426 loc) • 18.4 kB
JavaScript
import { existsSync, readdirSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { program } from 'commander';
function findSupabaseDir() {
const startDir = process.cwd();
let currentDir = startDir;
const rootDir = "/";
while (currentDir !== rootDir) {
const supabaseConfigPath = join(currentDir, "supabase", "config.toml");
if (existsSync(supabaseConfigPath)) {
const supabaseDir = join(currentDir, "supabase");
const relativePath = relative(startDir, supabaseDir);
return relativePath || "supabase";
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return null;
}
function getModuleRoot() {
const currentPath = fileURLToPath(import.meta.url);
const distDir = dirname(currentPath);
let moduleRoot = dirname(distDir);
if (!existsSync(join(moduleRoot, "package.json"))) {
moduleRoot = dirname(moduleRoot);
}
return moduleRoot;
}
function copyDirectoryRecursive(source, target, force = false) {
let copiedCount = 0;
if (!existsSync(source)) {
return copiedCount;
}
if (!existsSync(target)) {
mkdirSync(target, { recursive: true });
}
const entries = readdirSync(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = join(source, entry.name);
const targetPath = join(target, entry.name);
if (entry.isDirectory()) {
copiedCount += copyDirectoryRecursive(sourcePath, targetPath, force);
} else {
if (!existsSync(targetPath) || force) {
copyFileSync(sourcePath, targetPath);
copiedCount++;
}
}
}
return copiedCount;
}
async function checkTableConflicts() {
const conflictingTables = [];
const requiredTables = ["profiles", "teams", "team_members", "impersonation_sessions"];
for (const table of requiredTables) {
try {
const result = execSync(
`psql postgresql://postgres:postgres@127.0.0.1:54322/postgres -t -c "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '${table}');"`,
{ stdio: "pipe" }
).toString().trim();
if (result === "t") {
conflictingTables.push(table);
}
} catch {
}
}
return conflictingTables;
}
function loadMigrationTracker() {
const trackerPath = ".team-auth-version.json";
if (!existsSync(trackerPath)) {
return null;
}
try {
const content = readFileSync(trackerPath, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
}
function saveMigrationTracker(tracker) {
const trackerPath = ".team-auth-version.json";
writeFileSync(trackerPath, JSON.stringify(tracker, null, 2));
}
program.name("team-auth").description("CLI for Nuxt Supabase Team Auth module").version("0.1.0");
program.command("init").description("Initialize team-auth in your Supabase project").option("--force", "Overwrite existing files").action(async (options) => {
try {
console.log("\u{1F680} Initializing team-auth module...");
const supabaseDir = findSupabaseDir();
if (!supabaseDir) {
console.error("\u274C No Supabase project found. Run `supabase init` first.");
console.error(" Searched up directory tree from current working directory");
process.exit(1);
}
if (supabaseDir !== "supabase") {
console.log(`\u{1F4C1} Found Supabase project at: ${supabaseDir}`);
}
let supabaseRunning = false;
try {
execSync("supabase status", { stdio: "pipe" });
supabaseRunning = true;
} catch {
console.warn("\u26A0\uFE0F Supabase not running. Table conflict detection skipped.");
}
if (supabaseRunning) {
const conflicts = await checkTableConflicts();
if (conflicts.length > 0) {
console.log("");
console.log("\u26A0\uFE0F Found existing tables that may conflict:");
conflicts.forEach((table) => {
console.log(` - public.${table} (existing table)`);
});
console.log("");
console.log("This module requires these tables for team authentication.");
console.log("Options:");
console.log("1. Backup and remove existing tables first");
console.log("2. Use --force to continue anyway (advanced users)");
console.log("3. See documentation for manual integration");
console.log("");
if (!options.force) {
const confirm = await confirmAction("Continue anyway?");
if (!confirm) {
console.log("\u274C Initialization cancelled. Please resolve conflicts first.");
process.exit(0);
}
}
} else {
console.log("\u2705 No conflicting tables found");
}
}
const moduleRoot = getModuleRoot();
console.log("\u{1F4E6} Module root:", moduleRoot);
const migrationsSource = join(moduleRoot, "supabase", "migrations");
const migrationsTarget = join(supabaseDir, "migrations");
if (existsSync(migrationsSource)) {
const copiedMigrations = copyDirectoryRecursive(migrationsSource, migrationsTarget, options.force);
console.log(`\u2705 Copied ${copiedMigrations} migration files`);
} else {
console.warn("\u26A0\uFE0F Migration files not found in module");
}
const functionsSource = join(moduleRoot, "supabase", "functions");
const functionsTarget = join(supabaseDir, "functions");
if (existsSync(functionsSource)) {
const functionDirs = readdirSync(functionsSource, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
let copiedFunctions = 0;
functionDirs.forEach((funcName) => {
const sourceDir = join(functionsSource, funcName);
const targetDir = join(functionsTarget, funcName);
copiedFunctions += copyDirectoryRecursive(sourceDir, targetDir, options.force);
});
console.log(`\u2705 Copied ${functionDirs.length} Edge Function directories (${copiedFunctions} files)`);
} else {
console.warn("\u26A0\uFE0F Edge Function files not found in module");
}
const packagePath = join(moduleRoot, "package.json");
const packageJson = JSON.parse(readFileSync(packagePath, "utf-8"));
const appliedMigrations = [];
if (existsSync(migrationsSource)) {
const migrationFiles = readdirSync(migrationsSource).filter((file) => file.endsWith(".sql")).sort();
appliedMigrations.push(...migrationFiles);
}
const initialTracker = {
version: packageJson.version,
appliedMigrations,
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
};
saveMigrationTracker(initialTracker);
console.log(`\u2705 Version tracker initialized (v${packageJson.version})`);
if (existsSync("package.json")) {
const packageJson2 = JSON.parse(readFileSync("package.json", "utf-8"));
if (!packageJson2.scripts) {
packageJson2.scripts = {};
}
packageJson2.scripts["team-auth:migrate"] = "team-auth migrate";
writeFileSync("package.json", JSON.stringify(packageJson2, null, 2));
console.log("\u2705 Updated package.json scripts");
}
try {
execSync("supabase status", { stdio: "pipe" });
console.log("\u2705 Supabase project is linked");
const shouldPush = process.env.SUPABASE_DB_PUSH !== "false";
if (shouldPush) {
console.log("\u{1F504} Applying migrations to local database...");
execSync("supabase db push", { stdio: "inherit" });
console.log("\u2705 Migrations applied");
}
} catch {
console.warn("\u26A0\uFE0F Supabase CLI not available or project not linked");
console.log(" Run `supabase db push` manually after linking your project");
}
console.log("");
console.log("\u{1F389} Team-auth initialization complete!");
console.log("");
console.log("Next steps:");
console.log("1. Configure your environment variables (.env)");
console.log("2. Run `npm run dev` to start development");
console.log("3. Check out the documentation for usage examples");
} catch (error) {
console.error("\u274C Initialization failed:", error);
process.exit(1);
}
});
program.command("migrate").description("Apply new team-auth migrations").option("--dry-run", "Show what would be applied without making changes").action(async (options) => {
try {
console.log("\u{1F504} Checking for team-auth migrations...");
const supabaseDir = findSupabaseDir();
if (!supabaseDir) {
console.error("\u274C No Supabase project found. Run `supabase init` first.");
console.error(" Searched up directory tree from current working directory");
process.exit(1);
}
const currentTracker = loadMigrationTracker();
if (!currentTracker) {
console.error("\u274C No migration tracker found. Run `npx nuxt-supabase-team-auth init` first.");
process.exit(1);
}
const moduleRoot = getModuleRoot();
const modulePackagePath = join(moduleRoot, "package.json");
const modulePackageJson = JSON.parse(readFileSync(modulePackagePath, "utf-8"));
console.log(`\u{1F4E6} Current version: ${currentTracker.version}`);
console.log(`\u{1F4E6} Module version: ${modulePackageJson.version}`);
const migrationsSource = join(moduleRoot, "supabase", "migrations");
if (!existsSync(migrationsSource)) {
console.log("\u2705 No migrations found in module");
return;
}
const availableMigrations = readdirSync(migrationsSource).filter((file) => file.endsWith(".sql")).sort();
const newMigrations = availableMigrations.filter(
(migration) => !currentTracker.appliedMigrations.includes(migration)
);
if (newMigrations.length === 0) {
console.log("\u2705 No new migrations to apply");
return;
}
console.log(`\u{1F4CB} Found ${newMigrations.length} new migration(s):`);
newMigrations.forEach((migration) => {
console.log(` - ${migration}`);
});
if (options.dryRun) {
console.log("\u{1F50D} Dry run complete - no changes made");
return;
}
const confirm = await confirmAction("Apply these migrations?");
if (!confirm) {
console.log("\u274C Migration cancelled");
process.exit(0);
}
const migrationsTarget = join(supabaseDir, "migrations");
for (const migration of newMigrations) {
const sourcePath = join(migrationsSource, migration);
const targetPath = join(migrationsTarget, migration);
if (!existsSync(targetPath)) {
copyFileSync(sourcePath, targetPath);
console.log(`\u2705 Copied ${migration}`);
}
}
const functionsSource = join(moduleRoot, "supabase", "functions");
const functionsTarget = join("supabase", "functions");
if (existsSync(functionsSource)) {
const functionDirs = readdirSync(functionsSource, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
let copiedFunctions = 0;
functionDirs.forEach((funcName) => {
const sourceDir = join(functionsSource, funcName);
const targetDir = join(functionsTarget, funcName);
copiedFunctions += copyDirectoryRecursive(sourceDir, targetDir, true);
});
if (copiedFunctions > 0) {
console.log(`\u2705 Updated ${functionDirs.length} Edge Function directories (${copiedFunctions} files)`);
}
}
const updatedTracker = {
version: modulePackageJson.version,
appliedMigrations: [...currentTracker.appliedMigrations, ...newMigrations],
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
};
saveMigrationTracker(updatedTracker);
try {
execSync("supabase status", { stdio: "pipe" });
console.log("\u{1F504} Applying migrations to local database...");
execSync("supabase db push", { stdio: "inherit" });
console.log("\u2705 Database updated successfully");
} catch {
console.warn("\u26A0\uFE0F Supabase not running. Run `supabase db push` manually.");
}
console.log("");
console.log("\u{1F389} Migration complete!");
console.log(`\u{1F4E6} Updated to version ${modulePackageJson.version}`);
console.log(`\u{1F4CB} Applied ${newMigrations.length} new migration(s)`);
} catch (error) {
console.error("\u274C Migration failed:", error);
process.exit(1);
}
});
program.command("cleanup").description("Database cleanup operations for development").option("--all", "Reset entire database (equivalent to supabase db reset)").option("--test-data", "Clean only test data (users with @example.com emails)").option("--team <team-id>", "Delete specific team by ID").action(async (options) => {
try {
const supabaseDir = findSupabaseDir();
if (!supabaseDir) {
console.error("\u274C No Supabase project found. Must be run from Supabase project root.");
console.error(" Searched up directory tree from current working directory");
process.exit(1);
}
try {
execSync("supabase status", { stdio: "pipe" });
} catch {
console.error("\u274C Supabase services not running. Run `supabase start` first.");
process.exit(1);
}
if (options.all) {
console.log("\u{1F5D1}\uFE0F Resetting entire database...");
console.log("\u26A0\uFE0F This will remove ALL data and reapply migrations.");
const confirm = await confirmAction("Are you sure you want to reset the entire database?");
if (!confirm) {
console.log("\u274C Database reset cancelled");
process.exit(0);
}
execSync("supabase db reset --debug", { stdio: "inherit" });
console.log("\u2705 Database reset complete");
} else if (options.testData) {
console.log("\u{1F9F9} Cleaning test data...");
try {
execSync('psql postgresql://postgres:postgres@127.0.0.1:54322/postgres -c "SELECT cleanup_all_test_data();"', { stdio: "inherit" });
console.log("\u2705 Test data cleanup complete");
} catch (error) {
console.error("\u274C Test data cleanup failed:", error);
process.exit(1);
}
} else if (options.team) {
const teamId = options.team;
console.log(`\u{1F5D1}\uFE0F Deleting team: ${teamId}`);
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(teamId)) {
console.error("\u274C Invalid team ID format. Must be a valid UUID.");
process.exit(1);
}
try {
execSync(`psql postgresql://postgres:postgres@127.0.0.1:54322/postgres -c "SELECT cleanup_test_team('${teamId}');"`, { stdio: "inherit" });
console.log("\u2705 Team deletion complete");
} catch (error) {
console.error("\u274C Team deletion failed:", error);
process.exit(1);
}
} else {
console.log("\u2753 No cleanup option specified. Use --help for available options.");
console.log("");
console.log("Available cleanup options:");
console.log(" --all Reset entire database");
console.log(" --test-data Clean only test data");
console.log(" --team <id> Delete specific team");
process.exit(1);
}
} catch (error) {
console.error("\u274C Cleanup failed:", error);
process.exit(1);
}
});
program.command("db").description("Database status and information").option("--status", "Show Supabase services status").option("--teams", "List all teams").option("--users", "List all users").action(async (options) => {
try {
const supabaseDir = findSupabaseDir();
if (!supabaseDir) {
console.error("\u274C No Supabase project found. Must be run from Supabase project root.");
console.error(" Searched up directory tree from current working directory");
process.exit(1);
}
if (options.status) {
console.log("\u{1F4CA} Supabase Services Status:");
execSync("supabase status", { stdio: "inherit" });
} else if (options.teams) {
console.log("\u{1F465} Teams in database:");
execSync('psql postgresql://postgres:postgres@127.0.0.1:54322/postgres -c "SELECT id, name, created_at FROM teams ORDER BY created_at;"', { stdio: "inherit" });
} else if (options.users) {
console.log("\u{1F464} Users in database:");
execSync('psql postgresql://postgres:postgres@127.0.0.1:54322/postgres -c "SELECT p.id, p.full_name, p.email, p.created_at FROM profiles p ORDER BY p.created_at LIMIT 50;"', { stdio: "inherit" });
} else {
console.log("\u2753 No database option specified. Use --help for available options.");
console.log("");
console.log("Available database options:");
console.log(" --status Show Supabase services status");
console.log(" --teams List all teams");
console.log(" --users List all users (limited to 50)");
process.exit(1);
}
} catch (error) {
console.error("\u274C Database operation failed:", error);
process.exit(1);
}
});
async function confirmAction(message) {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(`${message} (y/N): `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
});
});
}
program.command("docs").description("Open documentation (README)").action(() => {
const moduleRoot = getModuleRoot();
const readmePath = join(moduleRoot, "README.md");
if (existsSync(readmePath)) {
console.log("\u{1F4D6} Opening documentation...");
console.log(`\u{1F4CD} README location: ${readmePath}`);
try {
execSync(`open "${readmePath}"`, { stdio: "ignore" });
} catch {
try {
execSync(`xdg-open "${readmePath}"`, { stdio: "ignore" });
} catch {
console.log("\u{1F4A1} View the README at the path above, or visit:");
console.log(" https://github.com/pengelbrecht/nuxt-supabase-team-auth");
}
}
} else {
console.log("\u{1F4D6} Documentation: https://github.com/pengelbrecht/nuxt-supabase-team-auth");
}
});
program.parse();