Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"clean-logs": "bun ./src/clean-logs/index.ts",
"convert-logs": "bun ./src/convert-logs/index.ts",
"delete": "bun ./src/delete/index.ts",
"export:supabase": "bun ./src/export/index.ts",
"format": "prettier . --write",
"format:test": "prettier . --check",
"lint": "eslint .",
Expand All @@ -27,18 +28,22 @@
"dependencies": {
"@clack/prompts": "^1.0.0-alpha.9",
"@clerk/backend": "^2.29.3",
"@clerk/nextjs": "^6.37.3",
"@clerk/types": "^4.101.11",
"bun": "^1.3.6",
"csv-parser": "^3.2.0",
"dotenv": "16.6.1",
"jose": "^6.1.3",
"mime-types": "^3.0.2",
"p-limit": "^7.2.0",
"pg": "^8.18.0",
"picocolors": "^1.1.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/bun": "^1.3.6",
"@types/mime-types": "^3.0.1",
"@types/pg": "^8.16.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
Expand Down
100 changes: 100 additions & 0 deletions src/export/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Supabase user export CLI
*
* Exports users from a Supabase Postgres database to a JSON file
* compatible with the migration script's Supabase transformer.
*
* Usage:
* bun run export:supabase
* bun run export:supabase --db-url postgresql://... --output users.json
*
* Environment variables:
* SUPABASE_DB_URL - Postgres connection string (alternative to --db-url flag)
*/
import 'dotenv/config';
import * as p from '@clack/prompts';
import color from 'picocolors';
import { exportSupabaseUsers, displayExportSummary } from './supabase';

async function main() {
p.intro(color.bgCyan(color.black('Supabase User Export')));

// Parse CLI flags
const args = process.argv.slice(2);
let dbUrl = process.env.SUPABASE_DB_URL;
let outputFile = 'supabase-export.json';

for (let i = 0; i < args.length; i++) {
if (args[i] === '--db-url' && args[i + 1]) {
dbUrl = args[i + 1];
i++;
} else if (args[i] === '--output' && args[i + 1]) {
outputFile = args[i + 1];
i++;
}
}

// Prompt for DB URL if not provided
if (!dbUrl) {
p.note(
`Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n` +
`${color.bold('Direct connection')} (requires IPv6):\n` +
` ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n` +
`${color.bold('Pooler connection')} (works on IPv4 — use this if direct fails):\n` +
` ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}\n\n` +
color.dim(
'Alternatively, run the export SQL in the Supabase SQL Editor and save the result as JSON.'
),
'Connection String'
);

const input = await p.text({
message: 'Enter your Supabase Postgres connection string',
placeholder:
'postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres',
validate: (value) => {
if (!value || value.trim() === '') {
return 'Connection string is required';
}
if (
!value.startsWith('postgresql://') &&
!value.startsWith('postgres://')
) {
return 'Must be a valid Postgres connection string (postgresql://...)';
}
},
});

if (p.isCancel(input)) {
p.cancel('Export cancelled.');
process.exit(0);
}

dbUrl = input;
}

const spinner = p.spinner();
spinner.start('Connecting to Supabase database...');

try {
const result = await exportSupabaseUsers(dbUrl, outputFile);
spinner.stop(`Found ${result.userCount} users`);

displayExportSummary(result);

p.log.info(
color.dim(
`Next step: run ${color.bold('bun run migrate')} and select "Supabase" with file "${outputFile}"`
)
);

p.outro(color.green('Export complete!'));
} catch (err) {
spinner.stop('Export failed');
const message = err instanceof Error ? err.message : String(err);
p.log.error(color.red(message));
process.exit(1);
}
}

main();
145 changes: 145 additions & 0 deletions src/export/supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Supabase user export module
*
* Connects to a Supabase Postgres database and exports users from the auth.users table
* in a format compatible with the Supabase migration transformer.
*
* Includes:
* - encrypted_password (bcrypt hashes) — not available via Supabase Admin API
* - first_name extracted from raw_user_meta_data.display_name
* - All standard auth fields (email, phone, confirmation status, metadata)
*/
import { Client } from 'pg';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import color from 'picocolors';

/**
* SQL query that exports users in the format expected by the Supabase transformer.
*
* Extracts display_name from raw_user_meta_data as first_name so the transformer
* can map it directly without custom SQL from the user.
*/
const EXPORT_QUERY = `
SELECT
id,
email,
email_confirmed_at,
encrypted_password,
phone,
phone_confirmed_at,
COALESCE(
raw_user_meta_data->>'display_name',
raw_user_meta_data->>'first_name',
raw_user_meta_data->>'name'
) as first_name,
raw_user_meta_data->>'last_name' as last_name,
raw_user_meta_data,
raw_app_meta_data,
created_at
FROM auth.users
ORDER BY created_at
`;

interface ExportResult {
userCount: number;
outputPath: string;
fieldCoverage: {
email: number;
emailConfirmed: number;
password: number;
phone: number;
firstName: number;
lastName: number;
};
}

/**
* Exports users from a Supabase Postgres database to a JSON file.
*
* @param dbUrl - Postgres connection string (e.g., postgresql://postgres:password@db.xxx.supabase.co:5432/postgres)
* @param outputFile - Output file path (relative to project root)
* @returns Export result with user count and field coverage stats
*/
export async function exportSupabaseUsers(
dbUrl: string,
outputFile: string
): Promise<ExportResult> {
const client = new Client({ connectionString: dbUrl });

try {
await client.connect();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to connect to Supabase database: ${message}\n\n` +
`Connection string format: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres\n` +
`Find this in Supabase Dashboard → Settings → Database → Connection string`
);
}

try {
const { rows } = await client.query(EXPORT_QUERY);

// Calculate field coverage
const coverage = {
email: 0,
emailConfirmed: 0,
password: 0,
phone: 0,
firstName: 0,
lastName: 0,
};

for (const row of rows) {
if (row.email) coverage.email++;
if (row.email_confirmed_at) coverage.emailConfirmed++;
if (row.encrypted_password) coverage.password++;
if (row.phone) coverage.phone++;
if (row.first_name) coverage.firstName++;
if (row.last_name) coverage.lastName++;
}

// Write output
const outputPath = path.isAbsolute(outputFile)
? outputFile
: path.join(process.cwd(), outputFile);

fs.writeFileSync(outputPath, JSON.stringify(rows, null, 2));

return {
userCount: rows.length,
outputPath,
fieldCoverage: coverage,
};
} finally {
await client.end();
}
}

/**
* Displays a summary of the export results with field coverage stats.
*/
export function displayExportSummary(result: ExportResult): void {
const { userCount, outputPath, fieldCoverage } = result;

const getIcon = (count: number, total: number): string => {
if (count === total) return color.green('●');
if (count > 0) return color.yellow('○');
return color.dim('○');
};

let summary = '';
summary += `${getIcon(fieldCoverage.email, userCount)} ${color.dim(`${fieldCoverage.email}/${userCount} have email`)}\n`;
summary += `${getIcon(fieldCoverage.emailConfirmed, userCount)} ${color.dim(`${fieldCoverage.emailConfirmed}/${userCount} email confirmed`)}\n`;
summary += `${getIcon(fieldCoverage.password, userCount)} ${color.dim(`${fieldCoverage.password}/${userCount} have password hash`)}\n`;
summary += `${getIcon(fieldCoverage.phone, userCount)} ${color.dim(`${fieldCoverage.phone}/${userCount} have phone`)}\n`;
summary += `${getIcon(fieldCoverage.firstName, userCount)} ${color.dim(`${fieldCoverage.firstName}/${userCount} have first name`)}\n`;
summary += `${getIcon(fieldCoverage.lastName, userCount)} ${color.dim(`${fieldCoverage.lastName}/${userCount} have last name`)}`;

p.note(summary, 'Field Coverage');
p.log.success(
`Exported ${color.bold(String(userCount))} users to ${color.dim(outputPath)}`
);
}
Loading