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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/cli.js +59 -0
- package/package.json +34 -0
- package/src/commands/export.js +34 -0
- package/src/commands/init.js +89 -0
- package/src/commands/setup.js +622 -0
- package/src/commands/start.js +178 -0
- package/src/commands/status.js +65 -0
- package/src/commands/stop.js +50 -0
- package/src/commands/sync.js +63 -0
- package/src/lib/cloud.js +169 -0
- package/src/lib/config.js +75 -0
- package/src/lib/docker.js +96 -0
- package/src/lib/state.js +204 -0
- package/src/utils/log.js +21 -0
- package/src/utils/prompt.js +81 -0
|
@@ -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
|
+
}
|