supabase-stateful 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Setup command - complete setup including Supabase client files
3
+ *
4
+ * This does everything init does, plus:
5
+ * 1. Creates src/utils/supabase/ client files with local/production switching
6
+ * 2. Adds dev:local and dev:all:local scripts
7
+ * 3. Optionally installs GitHub Actions workflow
8
+ * 4. Optionally creates dev-local.sh with graceful shutdown
9
+ */
10
+
11
+ import fs from 'fs/promises';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { execSync } from 'child_process';
15
+ import { log } from '../utils/log.js';
16
+ import { fileExists } from '../lib/config.js';
17
+ import { init } from './init.js';
18
+ import { confirm, select } from '../utils/prompt.js';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ // Template files for Supabase clients
23
+ const CONFIG_TEMPLATE = `/**
24
+ * Supabase Environment Configuration
25
+ *
26
+ * Automatically switches between local and production Supabase
27
+ * based on the NEXT_PUBLIC_SUPABASE_LOCAL environment variable.
28
+ *
29
+ * Usage:
30
+ * npm run dev:local -> uses localhost:54321
31
+ * npm run dev -> uses cloud Supabase
32
+ */
33
+
34
+ const isLocalDev = process.env.NEXT_PUBLIC_SUPABASE_LOCAL === 'true'
35
+ const isProduction = process.env.NODE_ENV === 'production'
36
+
37
+ // Local Supabase - default keys from \`supabase start\`
38
+ const LOCAL_CONFIG = {
39
+ url: 'http://127.0.0.1:54321',
40
+ anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0',
41
+ serviceRoleKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
42
+ }
43
+
44
+ // Production - from .env
45
+ const PRODUCTION_CONFIG = {
46
+ url: process.env.NEXT_PUBLIC_SUPABASE_URL,
47
+ anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
48
+ serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY
49
+ }
50
+
51
+ export function getSupabaseConfig() {
52
+ const config = isLocalDev ? LOCAL_CONFIG : PRODUCTION_CONFIG
53
+
54
+ if (!config.url || !config.anonKey) {
55
+ throw new Error(\`Missing Supabase config for \${isLocalDev ? 'local' : 'production'}\`)
56
+ }
57
+
58
+ return {
59
+ url: config.url,
60
+ anonKey: config.anonKey,
61
+ serviceRoleKey: config.serviceRoleKey,
62
+ isLocal: isLocalDev,
63
+ environment: isLocalDev ? 'local' : (isProduction ? 'production' : 'development')
64
+ }
65
+ }
66
+
67
+ export function getSupabaseAdminConfig() {
68
+ const config = getSupabaseConfig()
69
+ if (!config.serviceRoleKey) {
70
+ throw new Error(\`Missing service role key for \${config.environment}\`)
71
+ }
72
+ return { url: config.url, serviceRoleKey: config.serviceRoleKey }
73
+ }
74
+ `;
75
+
76
+ const CLIENT_TEMPLATE = `import { createBrowserClient } from '@supabase/ssr'
77
+ import { getSupabaseConfig } from './config'
78
+
79
+ export function createClient() {
80
+ const config = getSupabaseConfig()
81
+ return createBrowserClient(config.url, config.anonKey)
82
+ }
83
+ `;
84
+
85
+ const SERVER_TEMPLATE = `import { createServerClient } from '@supabase/ssr'
86
+ import { createClient as createSupabaseClient } from '@supabase/supabase-js'
87
+ import { cookies } from 'next/headers'
88
+ import { getSupabaseConfig, getSupabaseAdminConfig } from './config'
89
+
90
+ export async function createClient() {
91
+ const cookieStore = await cookies()
92
+ const config = getSupabaseConfig()
93
+
94
+ return createServerClient(config.url, config.anonKey, {
95
+ cookies: {
96
+ getAll() {
97
+ return cookieStore.getAll()
98
+ },
99
+ setAll(cookiesToSet) {
100
+ try {
101
+ cookiesToSet.forEach(({ name, value, options }) =>
102
+ cookieStore.set(name, value, options)
103
+ )
104
+ } catch {
105
+ // Called from Server Component - middleware handles refresh
106
+ }
107
+ },
108
+ },
109
+ })
110
+ }
111
+
112
+ export function createAdminClient() {
113
+ const config = getSupabaseAdminConfig()
114
+ return createSupabaseClient(config.url, config.serviceRoleKey, {
115
+ auth: { autoRefreshToken: false, persistSession: false }
116
+ })
117
+ }
118
+ `;
119
+
120
+ const MIDDLEWARE_TEMPLATE = `import { createServerClient } from '@supabase/ssr'
121
+ import { NextResponse } from 'next/server'
122
+ import { getSupabaseConfig } from './config'
123
+
124
+ export async function updateSession(request) {
125
+ let supabaseResponse = NextResponse.next({ request })
126
+ const config = getSupabaseConfig()
127
+
128
+ const supabase = createServerClient(config.url, config.anonKey, {
129
+ cookies: {
130
+ getAll() {
131
+ return request.cookies.getAll()
132
+ },
133
+ setAll(cookiesToSet) {
134
+ cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
135
+ supabaseResponse = NextResponse.next({ request })
136
+ cookiesToSet.forEach(({ name, value, options }) =>
137
+ supabaseResponse.cookies.set(name, value, options)
138
+ )
139
+ },
140
+ },
141
+ })
142
+
143
+ await supabase.auth.getUser()
144
+ return supabaseResponse
145
+ }
146
+ `;
147
+
148
+ /**
149
+ * Main setup command
150
+ */
151
+ export async function setup(options = {}) {
152
+ const isInteractive = !options.yes && process.stdin.isTTY;
153
+
154
+ log.info('Setting up supabase-stateful...');
155
+
156
+ // Step 1: Run basic init
157
+ await init();
158
+
159
+ // Step 2: Check if this is a Next.js project (required for client files)
160
+ const isNextJs = await isNextJsProject();
161
+
162
+ if (isNextJs) {
163
+ // Step 3: Check for Supabase dependencies
164
+ const hasSupabaseSsr = await hasDependency('@supabase/ssr');
165
+ const hasSupabaseJs = await hasDependency('@supabase/supabase-js');
166
+
167
+ const missingDeps = [];
168
+ if (!hasSupabaseSsr) missingDeps.push('@supabase/ssr');
169
+ if (!hasSupabaseJs) missingDeps.push('@supabase/supabase-js');
170
+
171
+ if (missingDeps.length > 0) {
172
+ let shouldInstall = true;
173
+
174
+ if (isInteractive) {
175
+ shouldInstall = await confirm(
176
+ `Install ${missingDeps.join(' and ')}?`,
177
+ 'Required for the Supabase client files.\n' +
178
+ '• @supabase/ssr - Server-side rendering support for auth\n' +
179
+ '• @supabase/supabase-js - Core Supabase client library',
180
+ true
181
+ );
182
+ }
183
+
184
+ if (shouldInstall) {
185
+ for (const dep of missingDeps) {
186
+ installDependency(dep);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Step 4: Detect utils path and create client files
192
+ const utilsPath = await detectUtilsPath();
193
+ if (utilsPath) {
194
+ let createClients = true;
195
+
196
+ if (isInteractive) {
197
+ createClients = await confirm(
198
+ 'Create Supabase client files?',
199
+ 'This creates config.js, client.js, server.js, and middleware.js\n' +
200
+ `in ${utilsPath}/supabase/ with automatic local/production switching.\n` +
201
+ 'Use \`npm run dev:local\` for local, \`npm run dev\` for production.',
202
+ true
203
+ );
204
+ }
205
+
206
+ if (createClients) {
207
+ await createClientFiles(utilsPath, options.force, isInteractive);
208
+ }
209
+ } else {
210
+ log.warn('Could not detect utils path - skipping client file creation');
211
+ log.info('Manually copy files from templates/supabase/ to your project');
212
+ }
213
+ } else {
214
+ log.dim('Skipping Supabase client files (Next.js not detected)');
215
+ log.dim('The generated client files are Next.js App Router specific.');
216
+ }
217
+
218
+ // Step 4: Check for concurrently (needed for graceful shutdown)
219
+ let services = await detectServices();
220
+ if (!services.hasConcurrently) {
221
+ let shouldInstall = false;
222
+
223
+ if (isInteractive) {
224
+ shouldInstall = await confirm(
225
+ 'Install concurrently?',
226
+ 'Enables running multiple services together and graceful shutdown.\n' +
227
+ 'With this, pressing Ctrl+C will automatically save your database\n' +
228
+ 'state before stopping. Without it, you must manually run\n' +
229
+ '\`npm run supabase:stop\` before closing your terminal.',
230
+ true
231
+ );
232
+ }
233
+
234
+ if (shouldInstall) {
235
+ if (installDependency('concurrently', true)) {
236
+ // Re-detect services after installing
237
+ services = await detectServices();
238
+ services.hasConcurrently = true;
239
+ }
240
+ }
241
+ }
242
+
243
+ // Step 5: Add dev scripts
244
+ await addDevScripts(services);
245
+
246
+ // Step 6: GitHub workflow (only if remote Supabase is configured)
247
+ if (isInteractive) {
248
+ const hasRemote = await hasRemoteSupabase();
249
+
250
+ if (hasRemote) {
251
+ const installWorkflow = await confirm(
252
+ 'Install GitHub Actions workflow for CI/CD?',
253
+ 'Automatically applies database migrations when you push to main.',
254
+ false
255
+ );
256
+
257
+ if (installWorkflow) {
258
+ const workflowType = await select('Select workflow type:', [
259
+ { value: 'migrations', label: 'Migrations only (Supabase CLI)' },
260
+ { value: 'full', label: 'Migrations + Vercel deploy' },
261
+ ], 'migrations');
262
+
263
+ await installGitHubWorkflow(workflowType, options.force);
264
+ }
265
+ }
266
+ }
267
+
268
+ // Step 7: dev-local.sh script (requires concurrently)
269
+ if (services.hasConcurrently) {
270
+ let createDevScript = !isInteractive; // Auto-create in non-interactive mode
271
+
272
+ if (isInteractive) {
273
+ createDevScript = await confirm(
274
+ 'Create scripts/dev-local.sh with graceful shutdown?',
275
+ 'This script saves your database state when you press Ctrl+C.\n' +
276
+ 'Without it, you need to manually run \`npm run supabase:stop\`\n' +
277
+ 'before closing your terminal to preserve your test data.',
278
+ true
279
+ );
280
+ }
281
+
282
+ if (createDevScript) {
283
+ await createDevLocalScript(services, options.force);
284
+ }
285
+ }
286
+
287
+ // Final output
288
+ console.log('');
289
+ log.success('Setup complete!');
290
+ console.log('');
291
+ console.log('Usage:');
292
+ console.log(' npm run dev:local Start with local Supabase');
293
+ if (services.hasConcurrently) {
294
+ console.log(' npm run dev:all:local Start all services with local Supabase');
295
+ if (await fileExists('scripts/dev-local.sh')) {
296
+ console.log(' ./scripts/dev-local.sh Start with graceful shutdown (Ctrl+C saves state)');
297
+ }
298
+ }
299
+ console.log(' npm run supabase:stop Save state and stop');
300
+ }
301
+
302
+ /**
303
+ * Detect where utils folder is (src/utils, utils, lib, src/lib)
304
+ */
305
+ async function detectUtilsPath() {
306
+ const candidates = [
307
+ 'src/utils',
308
+ 'src/lib',
309
+ 'utils',
310
+ 'lib',
311
+ ];
312
+
313
+ for (const candidate of candidates) {
314
+ if (await fileExists(candidate)) {
315
+ return candidate;
316
+ }
317
+ }
318
+
319
+ // If src exists, create src/utils
320
+ if (await fileExists('src')) {
321
+ return 'src/utils';
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Detect available services from package.json
329
+ */
330
+ async function detectServices() {
331
+ try {
332
+ const pkgContent = await fs.readFile('package.json', 'utf8');
333
+ const pkg = JSON.parse(pkgContent);
334
+
335
+ return {
336
+ devCmd: pkg.scripts?.dev || 'next dev',
337
+ hasInngest: !!(pkg.scripts?.inngest || pkg.scripts?.['dev:inngest'] || pkg.dependencies?.inngest || pkg.devDependencies?.inngest),
338
+ hasNgrok: !!(pkg.scripts?.ngrok || pkg.dependencies?.ngrok || pkg.devDependencies?.ngrok),
339
+ hasConcurrently: !!(pkg.dependencies?.concurrently || pkg.devDependencies?.concurrently),
340
+ };
341
+ } catch {
342
+ return { devCmd: 'next dev', hasInngest: false, hasNgrok: false, hasConcurrently: false };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Create Supabase client files in the utils directory
348
+ */
349
+ async function createClientFiles(utilsPath, force = false, isInteractive = false) {
350
+ const supabasePath = path.join(utilsPath, 'supabase');
351
+ await fs.mkdir(supabasePath, { recursive: true });
352
+
353
+ const files = [
354
+ { name: 'config.js', content: CONFIG_TEMPLATE },
355
+ { name: 'client.js', content: CLIENT_TEMPLATE },
356
+ { name: 'server.js', content: SERVER_TEMPLATE },
357
+ { name: 'middleware.js', content: MIDDLEWARE_TEMPLATE },
358
+ ];
359
+
360
+ // Check for existing files
361
+ const existingFiles = [];
362
+ for (const file of files) {
363
+ const filePath = path.join(supabasePath, file.name);
364
+ if (await fileExists(filePath)) {
365
+ existingFiles.push(file.name);
366
+ }
367
+ }
368
+
369
+ // Handle conflicts
370
+ let shouldOverwrite = force;
371
+ if (existingFiles.length > 0 && !force && isInteractive) {
372
+ log.warn(`Some Supabase client files already exist: ${existingFiles.join(', ')}`);
373
+ shouldOverwrite = await confirm(
374
+ 'Overwrite existing files?',
375
+ 'This will replace any customizations you made to these files.',
376
+ false
377
+ );
378
+ }
379
+
380
+ // Write files
381
+ for (const file of files) {
382
+ const filePath = path.join(supabasePath, file.name);
383
+ const exists = await fileExists(filePath);
384
+
385
+ if (exists && !shouldOverwrite) {
386
+ log.dim(`Skipped ${file.name} (already exists)`);
387
+ } else {
388
+ await fs.writeFile(filePath, file.content);
389
+ log.success(exists ? `Overwrote ${filePath}` : `Created ${filePath}`);
390
+ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Add dev:local and dev:all:local scripts to package.json
396
+ */
397
+ async function addDevScripts(services) {
398
+ if (!await fileExists('package.json')) return;
399
+
400
+ try {
401
+ const pkgContent = await fs.readFile('package.json', 'utf8');
402
+ const pkg = JSON.parse(pkgContent);
403
+ pkg.scripts = pkg.scripts || {};
404
+
405
+ // Add dev:local
406
+ if (!pkg.scripts['dev:local']) {
407
+ pkg.scripts['dev:local'] = `NEXT_PUBLIC_SUPABASE_LOCAL=true ${services.devCmd}`;
408
+ log.success('Added dev:local script');
409
+ }
410
+
411
+ // Add dev:all:local if they have concurrently
412
+ if (services.hasConcurrently && !pkg.scripts['dev:all:local']) {
413
+ let cmds = '"npm run supabase:start" "npm run dev:local"';
414
+ let names = 'SUPABASE,NEXT';
415
+ let colors = 'green,cyan';
416
+
417
+ if (services.hasInngest) {
418
+ const inngestCmd = pkg.scripts['dev:inngest'] ? 'dev:inngest' : 'inngest';
419
+ cmds += ` "npm run ${inngestCmd}"`;
420
+ names += ',INNGEST';
421
+ colors += ',magenta';
422
+ }
423
+
424
+ if (services.hasNgrok) {
425
+ cmds += ' "npm run ngrok"';
426
+ names += ',NGROK';
427
+ colors += ',yellow';
428
+ }
429
+
430
+ pkg.scripts['dev:all:local'] = `concurrently ${cmds} --names "${names}" --prefix-colors "${colors}"`;
431
+ log.success('Added dev:all:local script');
432
+ }
433
+
434
+ await fs.writeFile('package.json', JSON.stringify(pkg, null, 2) + '\n');
435
+ } catch (err) {
436
+ log.warn(`Could not update package.json: ${err.message}`);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Install GitHub workflow file
442
+ */
443
+ async function installGitHubWorkflow(workflowType, force = false) {
444
+ const workflowDir = '.github/workflows';
445
+ await fs.mkdir(workflowDir, { recursive: true });
446
+
447
+ const targetFile = path.join(workflowDir, 'deploy.yml');
448
+
449
+ if (await fileExists(targetFile) && !force) {
450
+ log.dim('Skipped GitHub workflow (already exists)');
451
+ return;
452
+ }
453
+
454
+ // Read template from package templates directory
455
+ const templatesDir = path.resolve(__dirname, '../../templates/github-workflow');
456
+ const sourceFile = workflowType === 'migrations'
457
+ ? path.join(templatesDir, 'migrations-only.yml')
458
+ : path.join(templatesDir, 'deploy.yml');
459
+
460
+ const content = await fs.readFile(sourceFile, 'utf8');
461
+ await fs.writeFile(targetFile, content);
462
+
463
+ log.success(`Created ${targetFile} (${workflowType === 'migrations' ? 'migrations only' : 'migrations + Vercel deploy'})`);
464
+
465
+ // Show required secrets
466
+ console.log('');
467
+ log.info('Required GitHub secrets:');
468
+ console.log(' - SUPABASE_ACCESS_TOKEN');
469
+ console.log(' - SUPABASE_PROJECT_REF');
470
+ console.log(' - SUPABASE_DB_PASSWORD');
471
+ if (workflowType === 'full') {
472
+ console.log(' - VERCEL_TOKEN');
473
+ console.log(' - VERCEL_ORG_ID');
474
+ console.log(' - VERCEL_PROJECT_ID');
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Generate dev-local.sh script content
480
+ */
481
+ function generateDevLocalScript(services) {
482
+ const commands = ['"npm run supabase:start"', '"npm run dev:local"'];
483
+ const names = ['SUPABASE', 'NEXT'];
484
+ const colors = ['green', 'cyan'];
485
+
486
+ if (services.hasInngest) {
487
+ commands.push('"npm run inngest"');
488
+ names.push('INNGEST');
489
+ colors.push('magenta');
490
+ }
491
+
492
+ if (services.hasNgrok) {
493
+ commands.push('"npm run ngrok"');
494
+ names.push('NGROK');
495
+ colors.push('yellow');
496
+ }
497
+
498
+ return `#!/bin/bash
499
+
500
+ # Local Development Script with Graceful Shutdown
501
+ # Generated by supabase-stateful setup
502
+ # Press Ctrl+C to save state and exit
503
+
504
+ GREEN='\\033[0;32m'
505
+ YELLOW='\\033[0;33m'
506
+ CYAN='\\033[0;36m'
507
+ NC='\\033[0m'
508
+
509
+ echo -e "\${CYAN}Starting local development environment...\${NC}"
510
+ echo ""
511
+
512
+ cleanup() {
513
+ echo ""
514
+ echo -e "\${YELLOW}Shutting down gracefully...\${NC}"
515
+ echo -e "\${CYAN}Saving Supabase state...\${NC}"
516
+
517
+ kill $DEV_PID 2>/dev/null
518
+ sleep 1
519
+
520
+ npm run supabase:stop
521
+
522
+ echo ""
523
+ echo -e "\${GREEN}Development environment stopped. State saved.\${NC}"
524
+ exit 0
525
+ }
526
+
527
+ trap cleanup SIGINT SIGTERM
528
+
529
+ npx concurrently \\
530
+ --names "${names.join(',')}" \\
531
+ --prefix-colors "${colors.join(',')}" \\
532
+ ${commands.join(' \\\n ')} &
533
+
534
+ DEV_PID=$!
535
+
536
+ wait $DEV_PID
537
+ `;
538
+ }
539
+
540
+ /**
541
+ * Create the dev-local.sh script
542
+ */
543
+ async function createDevLocalScript(services, force = false) {
544
+ const scriptsDir = 'scripts';
545
+ await fs.mkdir(scriptsDir, { recursive: true });
546
+
547
+ const scriptPath = path.join(scriptsDir, 'dev-local.sh');
548
+
549
+ if (await fileExists(scriptPath) && !force) {
550
+ log.dim('Skipped scripts/dev-local.sh (already exists)');
551
+ return;
552
+ }
553
+
554
+ const content = generateDevLocalScript(services);
555
+ await fs.writeFile(scriptPath, content, { mode: 0o755 });
556
+
557
+ log.success('Created scripts/dev-local.sh');
558
+ }
559
+
560
+ /**
561
+ * Check if a dependency is installed
562
+ */
563
+ async function hasDependency(name) {
564
+ try {
565
+ const pkgContent = await fs.readFile('package.json', 'utf8');
566
+ const pkg = JSON.parse(pkgContent);
567
+ return !!(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
568
+ } catch {
569
+ return false;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Install a dependency using npm
575
+ */
576
+ function installDependency(name, isDev = false) {
577
+ const flag = isDev ? '--save-dev' : '--save';
578
+ try {
579
+ log.info(`Installing ${name}...`);
580
+ execSync(`npm install ${flag} ${name}`, { stdio: 'inherit' });
581
+ log.success(`Installed ${name}`);
582
+ return true;
583
+ } catch {
584
+ log.error(`Failed to install ${name}`);
585
+ return false;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Check if project has a remote Supabase configured
591
+ * Looks for .env with SUPABASE_URL or supabase/.temp/project-ref
592
+ */
593
+ async function hasRemoteSupabase() {
594
+ // Check for linked project (created by `supabase link`)
595
+ if (await fileExists('supabase/.temp/project-ref')) {
596
+ return true;
597
+ }
598
+
599
+ // Check for .env with Supabase URL
600
+ try {
601
+ const envFiles = ['.env', '.env.local', '.env.production'];
602
+ for (const envFile of envFiles) {
603
+ if (await fileExists(envFile)) {
604
+ const content = await fs.readFile(envFile, 'utf8');
605
+ if (content.includes('SUPABASE_URL') || content.includes('NEXT_PUBLIC_SUPABASE_URL')) {
606
+ return true;
607
+ }
608
+ }
609
+ }
610
+ } catch {
611
+ // Ignore errors
612
+ }
613
+
614
+ return false;
615
+ }
616
+
617
+ /**
618
+ * Check if this is a Next.js project
619
+ */
620
+ async function isNextJsProject() {
621
+ return await hasDependency('next');
622
+ }