@codebakers/cli 1.6.0 → 2.0.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/dist/commands/audit.d.ts +19 -0
- package/dist/commands/audit.js +730 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +176 -0
- package/dist/commands/doctor.js +59 -4
- package/dist/commands/heal.d.ts +41 -0
- package/dist/commands/heal.js +734 -0
- package/dist/commands/login.js +12 -16
- package/dist/commands/provision.d.ts +55 -3
- package/dist/commands/provision.js +243 -74
- package/dist/commands/scaffold.js +158 -80
- package/dist/commands/setup.js +60 -19
- package/dist/commands/upgrade.d.ts +4 -0
- package/dist/commands/upgrade.js +90 -0
- package/dist/config.d.ts +61 -5
- package/dist/config.js +268 -5
- package/dist/index.js +44 -3
- package/dist/lib/api.d.ts +45 -0
- package/dist/lib/api.js +159 -0
- package/dist/mcp/server.js +146 -0
- package/package.json +1 -1
- package/src/commands/audit.ts +827 -0
- package/src/commands/config.ts +216 -0
- package/src/commands/doctor.ts +69 -4
- package/src/commands/heal.ts +889 -0
- package/src/commands/login.ts +14 -18
- package/src/commands/provision.ts +323 -101
- package/src/commands/scaffold.ts +188 -81
- package/src/commands/setup.ts +65 -20
- package/src/commands/upgrade.ts +110 -0
- package/src/config.ts +320 -11
- package/src/index.ts +48 -3
- package/src/lib/api.ts +183 -0
- package/src/mcp/server.ts +160 -0
package/src/commands/login.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import { createInterface } from 'readline';
|
|
4
|
-
import { setApiKey
|
|
4
|
+
import { setApiKey } from '../config.js';
|
|
5
|
+
import { validateApiKey, formatApiError, type ApiError } from '../lib/api.js';
|
|
5
6
|
|
|
6
7
|
async function prompt(question: string): Promise<string> {
|
|
7
8
|
const rl = createInterface({
|
|
@@ -12,7 +13,7 @@ async function prompt(question: string): Promise<string> {
|
|
|
12
13
|
return new Promise((resolve) => {
|
|
13
14
|
rl.question(question, (answer) => {
|
|
14
15
|
rl.close();
|
|
15
|
-
resolve(answer);
|
|
16
|
+
resolve(answer.trim());
|
|
16
17
|
});
|
|
17
18
|
});
|
|
18
19
|
}
|
|
@@ -23,34 +24,29 @@ export async function login(): Promise<void> {
|
|
|
23
24
|
|
|
24
25
|
const apiKey = await prompt(' Enter your API key: ');
|
|
25
26
|
|
|
26
|
-
if (!apiKey
|
|
27
|
-
console.log(chalk.red('\n
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
console.log(chalk.red('\n API key is required.\n'));
|
|
28
29
|
process.exit(1);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const spinner = ora('Validating API key...').start();
|
|
32
33
|
|
|
33
34
|
try {
|
|
34
|
-
|
|
35
|
-
const response = await fetch(`${apiUrl}/api/content`, {
|
|
36
|
-
method: 'GET',
|
|
37
|
-
headers: {
|
|
38
|
-
Authorization: `Bearer ${apiKey}`,
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
if (!response.ok) {
|
|
43
|
-
const error = await response.json().catch(() => ({}));
|
|
44
|
-
throw new Error(error.error || 'Invalid API key');
|
|
45
|
-
}
|
|
35
|
+
await validateApiKey(apiKey);
|
|
46
36
|
|
|
47
37
|
setApiKey(apiKey);
|
|
48
38
|
spinner.succeed('Logged in successfully!');
|
|
49
39
|
console.log(chalk.green('\n You can now run `codebakers install` in your project.\n'));
|
|
50
40
|
} catch (error) {
|
|
51
41
|
spinner.fail('Login failed');
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
|
|
43
|
+
if (error && typeof error === 'object' && 'recoverySteps' in error) {
|
|
44
|
+
console.log(chalk.red(`\n ${formatApiError(error as ApiError)}\n`));
|
|
45
|
+
} else {
|
|
46
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
47
|
+
console.log(chalk.red(`\n Error: ${message}\n`));
|
|
48
|
+
}
|
|
49
|
+
|
|
54
50
|
process.exit(1);
|
|
55
51
|
}
|
|
56
52
|
}
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
|
-
import
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
4
5
|
import { createInterface } from 'readline';
|
|
5
|
-
import { getServiceKey, setServiceKey, type ServiceName } from '../config.js';
|
|
6
|
+
import { getServiceKey, setServiceKey, SERVICE_KEY_LABELS, type ServiceName } from '../config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Supabase regions - allow user to select closest region
|
|
10
|
+
*/
|
|
11
|
+
const SUPABASE_REGIONS = [
|
|
12
|
+
{ id: 'us-east-1', name: 'US East (N. Virginia)', flag: '🇺🇸' },
|
|
13
|
+
{ id: 'us-west-1', name: 'US West (N. California)', flag: '🇺🇸' },
|
|
14
|
+
{ id: 'eu-west-1', name: 'EU West (Ireland)', flag: '🇮🇪' },
|
|
15
|
+
{ id: 'eu-west-2', name: 'EU West (London)', flag: '🇬🇧' },
|
|
16
|
+
{ id: 'eu-central-1', name: 'EU Central (Frankfurt)', flag: '🇩🇪' },
|
|
17
|
+
{ id: 'ap-southeast-1', name: 'Asia Pacific (Singapore)', flag: '🇸🇬' },
|
|
18
|
+
{ id: 'ap-southeast-2', name: 'Asia Pacific (Sydney)', flag: '🇦🇺' },
|
|
19
|
+
{ id: 'ap-northeast-1', name: 'Asia Pacific (Tokyo)', flag: '🇯🇵' },
|
|
20
|
+
{ id: 'sa-east-1', name: 'South America (São Paulo)', flag: '🇧🇷' },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
type SupabaseRegion = typeof SUPABASE_REGIONS[number]['id'];
|
|
6
24
|
|
|
7
25
|
function prompt(question: string): Promise<string> {
|
|
8
26
|
const rl = createInterface({
|
|
@@ -28,13 +46,63 @@ export interface ProvisionResult {
|
|
|
28
46
|
projectUrl: string;
|
|
29
47
|
apiUrl: string;
|
|
30
48
|
anonKey: string;
|
|
49
|
+
dbPassword: string;
|
|
31
50
|
};
|
|
32
51
|
vercel?: {
|
|
33
52
|
projectId: string;
|
|
34
53
|
projectUrl: string;
|
|
54
|
+
envVarsSet: boolean;
|
|
35
55
|
};
|
|
36
56
|
}
|
|
37
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Retry a function with exponential backoff
|
|
60
|
+
*/
|
|
61
|
+
async function withRetry<T>(
|
|
62
|
+
fn: () => Promise<T>,
|
|
63
|
+
options: {
|
|
64
|
+
retries?: number;
|
|
65
|
+
delay?: number;
|
|
66
|
+
onRetry?: (attempt: number, error: Error) => void;
|
|
67
|
+
} = {}
|
|
68
|
+
): Promise<T> {
|
|
69
|
+
const { retries = 3, delay = 1000, onRetry } = options;
|
|
70
|
+
|
|
71
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (attempt === retries) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
80
|
+
|
|
81
|
+
// Don't retry on authentication errors
|
|
82
|
+
if (err.message.includes('401') || err.message.includes('403') || err.message.includes('Invalid')) {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onRetry?.(attempt, err);
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, delay * attempt));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new Error('Max retries exceeded');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate a cryptographically secure password
|
|
96
|
+
*/
|
|
97
|
+
function generateSecurePassword(): string {
|
|
98
|
+
// Generate 24 random bytes and convert to base64url (URL-safe, no special chars)
|
|
99
|
+
const bytes = crypto.randomBytes(24);
|
|
100
|
+
const password = bytes.toString('base64url');
|
|
101
|
+
|
|
102
|
+
// Ensure it meets typical password requirements (add special char and number)
|
|
103
|
+
return `${password}!1`;
|
|
104
|
+
}
|
|
105
|
+
|
|
38
106
|
/**
|
|
39
107
|
* Check if a service key is configured, prompt if not
|
|
40
108
|
*/
|
|
@@ -42,17 +110,17 @@ async function ensureServiceKey(service: ServiceName, instructions: string): Pro
|
|
|
42
110
|
let key = getServiceKey(service);
|
|
43
111
|
|
|
44
112
|
if (!key) {
|
|
45
|
-
console.log(chalk.yellow(`\n No ${service} API key configured.\n`));
|
|
113
|
+
console.log(chalk.yellow(`\n No ${SERVICE_KEY_LABELS[service]} API key configured.\n`));
|
|
46
114
|
console.log(chalk.gray(instructions));
|
|
47
115
|
|
|
48
|
-
const inputKey = await prompt(`\n ${service} API key (or press Enter to skip): `);
|
|
116
|
+
const inputKey = await prompt(`\n ${SERVICE_KEY_LABELS[service]} API key (or press Enter to skip): `);
|
|
49
117
|
|
|
50
118
|
if (inputKey) {
|
|
51
119
|
setServiceKey(service, inputKey);
|
|
52
120
|
key = inputKey;
|
|
53
|
-
console.log(chalk.green(` ✓ ${service} key saved\n`));
|
|
121
|
+
console.log(chalk.green(` ✓ ${SERVICE_KEY_LABELS[service]} key saved\n`));
|
|
54
122
|
} else {
|
|
55
|
-
console.log(chalk.gray(` Skipping ${service} provisioning.\n`));
|
|
123
|
+
console.log(chalk.gray(` Skipping ${SERVICE_KEY_LABELS[service]} provisioning.\n`));
|
|
56
124
|
return null;
|
|
57
125
|
}
|
|
58
126
|
}
|
|
@@ -60,6 +128,40 @@ async function ensureServiceKey(service: ServiceName, instructions: string): Pro
|
|
|
60
128
|
return key;
|
|
61
129
|
}
|
|
62
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Prompt for Supabase region selection
|
|
133
|
+
*/
|
|
134
|
+
async function selectSupabaseRegion(): Promise<SupabaseRegion> {
|
|
135
|
+
console.log(chalk.white('\n Select Supabase region (for lowest latency, pick closest to your users):\n'));
|
|
136
|
+
|
|
137
|
+
SUPABASE_REGIONS.forEach((region, index) => {
|
|
138
|
+
const isDefault = region.id === 'us-east-1';
|
|
139
|
+
console.log(
|
|
140
|
+
chalk.gray(` ${index + 1}. `) +
|
|
141
|
+
chalk.cyan(`${region.flag} ${region.name}`) +
|
|
142
|
+
(isDefault ? chalk.green(' (default)') : '')
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
console.log('');
|
|
147
|
+
|
|
148
|
+
const input = await prompt(` Enter 1-${SUPABASE_REGIONS.length} (or press Enter for default): `);
|
|
149
|
+
|
|
150
|
+
if (!input) {
|
|
151
|
+
return 'us-east-1';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const index = parseInt(input, 10) - 1;
|
|
155
|
+
if (index >= 0 && index < SUPABASE_REGIONS.length) {
|
|
156
|
+
const selected = SUPABASE_REGIONS[index];
|
|
157
|
+
console.log(chalk.green(` ✓ Selected: ${selected.flag} ${selected.name}\n`));
|
|
158
|
+
return selected.id;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(chalk.yellow(' Invalid selection, using default (US East).\n'));
|
|
162
|
+
return 'us-east-1';
|
|
163
|
+
}
|
|
164
|
+
|
|
63
165
|
/**
|
|
64
166
|
* Create a GitHub repository
|
|
65
167
|
*/
|
|
@@ -71,7 +173,9 @@ export async function createGitHubRepo(
|
|
|
71
173
|
try {
|
|
72
174
|
execSync('gh --version', { stdio: 'pipe' });
|
|
73
175
|
} catch {
|
|
74
|
-
console.log(chalk.yellow(' GitHub CLI (gh) not found
|
|
176
|
+
console.log(chalk.yellow(' GitHub CLI (gh) not found.\n'));
|
|
177
|
+
console.log(chalk.gray(' Install from: https://cli.github.com'));
|
|
178
|
+
console.log(chalk.gray(' Or skip GitHub and create manually later.\n'));
|
|
75
179
|
return null;
|
|
76
180
|
}
|
|
77
181
|
|
|
@@ -80,16 +184,20 @@ export async function createGitHubRepo(
|
|
|
80
184
|
execSync('gh auth status', { stdio: 'pipe' });
|
|
81
185
|
} catch {
|
|
82
186
|
console.log(chalk.yellow(' Not logged into GitHub CLI.\n'));
|
|
83
|
-
console.log(chalk.gray(' Run: gh auth login
|
|
187
|
+
console.log(chalk.gray(' Run: gh auth login'));
|
|
188
|
+
console.log(chalk.gray(' Then try again.\n'));
|
|
84
189
|
return null;
|
|
85
190
|
}
|
|
86
191
|
|
|
87
192
|
const spinner = ora('Creating GitHub repository...').start();
|
|
88
193
|
|
|
89
194
|
try {
|
|
195
|
+
// Escape description for shell
|
|
196
|
+
const safeDescription = description.replace(/"/g, '\\"');
|
|
197
|
+
|
|
90
198
|
// Create the repo
|
|
91
|
-
|
|
92
|
-
`gh repo create ${projectName} --public --description "${
|
|
199
|
+
execSync(
|
|
200
|
+
`gh repo create ${projectName} --public --description "${safeDescription}" --source . --remote origin --push`,
|
|
93
201
|
{ encoding: 'utf-8', stdio: 'pipe' }
|
|
94
202
|
);
|
|
95
203
|
|
|
@@ -107,8 +215,10 @@ export async function createGitHubRepo(
|
|
|
107
215
|
|
|
108
216
|
if (message.includes('already exists')) {
|
|
109
217
|
console.log(chalk.yellow(' Repository already exists. Skipping.\n'));
|
|
218
|
+
console.log(chalk.gray(' Tip: You can link an existing repo with: gh repo view\n'));
|
|
110
219
|
} else {
|
|
111
|
-
console.log(chalk.red(` Error: ${message}
|
|
220
|
+
console.log(chalk.red(` Error: ${message}`));
|
|
221
|
+
console.log(chalk.gray(' You can create the repository manually on GitHub.\n'));
|
|
112
222
|
}
|
|
113
223
|
return null;
|
|
114
224
|
}
|
|
@@ -119,94 +229,118 @@ export async function createGitHubRepo(
|
|
|
119
229
|
*/
|
|
120
230
|
export async function createSupabaseProject(
|
|
121
231
|
projectName: string,
|
|
122
|
-
|
|
123
|
-
|
|
232
|
+
options?: {
|
|
233
|
+
organizationId?: string;
|
|
234
|
+
region?: SupabaseRegion;
|
|
235
|
+
}
|
|
236
|
+
): Promise<{ projectId: string; projectUrl: string; apiUrl: string; anonKey: string; dbPassword: string } | null> {
|
|
124
237
|
const accessToken = await ensureServiceKey('supabase',
|
|
125
238
|
' Get your access token from: https://supabase.com/dashboard/account/tokens'
|
|
126
239
|
);
|
|
127
240
|
|
|
128
241
|
if (!accessToken) return null;
|
|
129
242
|
|
|
243
|
+
// Select region if not provided
|
|
244
|
+
const region = options?.region || await selectSupabaseRegion();
|
|
245
|
+
|
|
130
246
|
const spinner = ora('Creating Supabase project...').start();
|
|
131
247
|
|
|
132
248
|
try {
|
|
249
|
+
let organizationId = options?.organizationId;
|
|
250
|
+
|
|
133
251
|
// Get organization if not provided
|
|
134
252
|
if (!organizationId) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
253
|
+
spinner.text = 'Fetching organizations...';
|
|
254
|
+
|
|
255
|
+
const orgsResponse = await withRetry(
|
|
256
|
+
() => fetch('https://api.supabase.com/v1/organizations', {
|
|
257
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
258
|
+
}).then(r => {
|
|
259
|
+
if (!r.ok) throw new Error(`Failed to fetch organizations (${r.status})`);
|
|
260
|
+
return r.json();
|
|
261
|
+
}),
|
|
262
|
+
{
|
|
263
|
+
onRetry: (attempt) => {
|
|
264
|
+
spinner.text = `Fetching organizations (retry ${attempt})...`;
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
);
|
|
144
268
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
throw new Error('No organizations found. Create one at supabase.com first.');
|
|
269
|
+
if (!orgsResponse || orgsResponse.length === 0) {
|
|
270
|
+
throw new Error('No organizations found. Create one at supabase.com/dashboard first.');
|
|
148
271
|
}
|
|
149
272
|
|
|
150
|
-
organizationId =
|
|
273
|
+
organizationId = orgsResponse[0].id;
|
|
151
274
|
}
|
|
152
275
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
276
|
+
spinner.text = 'Creating Supabase project...';
|
|
277
|
+
|
|
278
|
+
// Generate a cryptographically secure password
|
|
279
|
+
const dbPassword = generateSecurePassword();
|
|
157
280
|
|
|
158
281
|
// Create the project
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
282
|
+
const project = await withRetry(
|
|
283
|
+
() => fetch('https://api.supabase.com/v1/projects', {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: {
|
|
286
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
287
|
+
'Content-Type': 'application/json',
|
|
288
|
+
},
|
|
289
|
+
body: JSON.stringify({
|
|
290
|
+
name: projectName,
|
|
291
|
+
organization_id: organizationId,
|
|
292
|
+
region: region,
|
|
293
|
+
plan: 'free',
|
|
294
|
+
db_pass: dbPassword,
|
|
295
|
+
}),
|
|
296
|
+
}).then(async r => {
|
|
297
|
+
if (!r.ok) {
|
|
298
|
+
const error = await r.json().catch(() => ({}));
|
|
299
|
+
throw new Error(error.message || `Failed to create project (${r.status})`);
|
|
300
|
+
}
|
|
301
|
+
return r.json();
|
|
171
302
|
}),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const project = await createResponse.json();
|
|
303
|
+
{
|
|
304
|
+
onRetry: (attempt) => {
|
|
305
|
+
spinner.text = `Creating Supabase project (retry ${attempt})...`;
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
);
|
|
180
309
|
|
|
181
310
|
spinner.succeed('Supabase project created!');
|
|
182
311
|
|
|
183
|
-
// Wait for project to be ready
|
|
312
|
+
// Wait for project to be ready
|
|
184
313
|
const waitSpinner = ora('Waiting for project to be ready...').start();
|
|
185
314
|
|
|
186
315
|
let projectReady = false;
|
|
187
316
|
let attempts = 0;
|
|
188
317
|
let projectDetails: { api_url?: string; anon_key?: string } = {};
|
|
189
318
|
|
|
190
|
-
while (!projectReady && attempts <
|
|
319
|
+
while (!projectReady && attempts < 60) { // Up to 2 minutes
|
|
191
320
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
192
321
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
'Authorization': `Bearer ${accessToken}
|
|
196
|
-
}
|
|
197
|
-
});
|
|
322
|
+
try {
|
|
323
|
+
const statusResponse = await fetch(`https://api.supabase.com/v1/projects/${project.id}`, {
|
|
324
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
325
|
+
});
|
|
198
326
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
327
|
+
if (statusResponse.ok) {
|
|
328
|
+
projectDetails = await statusResponse.json();
|
|
329
|
+
if (projectDetails.api_url) {
|
|
330
|
+
projectReady = true;
|
|
331
|
+
}
|
|
203
332
|
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Ignore errors during polling
|
|
204
335
|
}
|
|
336
|
+
|
|
205
337
|
attempts++;
|
|
338
|
+
waitSpinner.text = `Waiting for project to be ready... (${attempts * 2}s)`;
|
|
206
339
|
}
|
|
207
340
|
|
|
208
341
|
if (!projectReady) {
|
|
209
|
-
waitSpinner.warn('Project created but may
|
|
342
|
+
waitSpinner.warn('Project created but may need a few more minutes to be fully ready.');
|
|
343
|
+
console.log(chalk.gray(' You can check status at the project URL.\n'));
|
|
210
344
|
} else {
|
|
211
345
|
waitSpinner.succeed('Project ready!');
|
|
212
346
|
}
|
|
@@ -215,17 +349,20 @@ export async function createSupabaseProject(
|
|
|
215
349
|
console.log(chalk.gray(` ${projectUrl}\n`));
|
|
216
350
|
|
|
217
351
|
// Get the anon key
|
|
218
|
-
const keysResponse = await fetch(`https://api.supabase.com/v1/projects/${project.id}/api-keys`, {
|
|
219
|
-
headers: {
|
|
220
|
-
'Authorization': `Bearer ${accessToken}`,
|
|
221
|
-
},
|
|
222
|
-
});
|
|
223
|
-
|
|
224
352
|
let anonKey = '';
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
353
|
+
try {
|
|
354
|
+
const keysResponse = await fetch(`https://api.supabase.com/v1/projects/${project.id}/api-keys`, {
|
|
355
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (keysResponse.ok) {
|
|
359
|
+
const keys = await keysResponse.json();
|
|
360
|
+
const anonKeyObj = keys.find((k: { name: string }) => k.name === 'anon');
|
|
361
|
+
anonKey = anonKeyObj?.api_key || '';
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
console.log(chalk.yellow(' Note: Could not fetch anon key automatically.'));
|
|
365
|
+
console.log(chalk.gray(' Get it from: Settings → API in your Supabase dashboard.\n'));
|
|
229
366
|
}
|
|
230
367
|
|
|
231
368
|
return {
|
|
@@ -233,22 +370,34 @@ export async function createSupabaseProject(
|
|
|
233
370
|
projectUrl,
|
|
234
371
|
apiUrl: projectDetails.api_url || `https://${project.id}.supabase.co`,
|
|
235
372
|
anonKey,
|
|
373
|
+
dbPassword,
|
|
236
374
|
};
|
|
237
375
|
} catch (error) {
|
|
238
376
|
spinner.fail('Failed to create Supabase project');
|
|
239
377
|
const message = error instanceof Error ? error.message : String(error);
|
|
240
|
-
|
|
378
|
+
|
|
379
|
+
if (message.includes('limit')) {
|
|
380
|
+
console.log(chalk.red(` Error: ${message}`));
|
|
381
|
+
console.log(chalk.gray(' Free tier allows 2 projects. Delete old ones or upgrade.\n'));
|
|
382
|
+
} else {
|
|
383
|
+
console.log(chalk.red(` Error: ${message}`));
|
|
384
|
+
console.log(chalk.gray(' Check your access token and try again.\n'));
|
|
385
|
+
}
|
|
386
|
+
|
|
241
387
|
return null;
|
|
242
388
|
}
|
|
243
389
|
}
|
|
244
390
|
|
|
245
391
|
/**
|
|
246
|
-
* Create a Vercel project
|
|
392
|
+
* Create a Vercel project with environment variables
|
|
247
393
|
*/
|
|
248
394
|
export async function createVercelProject(
|
|
249
395
|
projectName: string,
|
|
250
|
-
|
|
251
|
-
|
|
396
|
+
options?: {
|
|
397
|
+
gitRepoUrl?: string;
|
|
398
|
+
envVars?: Record<string, string>;
|
|
399
|
+
}
|
|
400
|
+
): Promise<{ projectId: string; projectUrl: string; envVarsSet: boolean } | null> {
|
|
252
401
|
const accessToken = await ensureServiceKey('vercel',
|
|
253
402
|
' Get your token from: https://vercel.com/account/tokens'
|
|
254
403
|
);
|
|
@@ -259,39 +408,93 @@ export async function createVercelProject(
|
|
|
259
408
|
|
|
260
409
|
try {
|
|
261
410
|
// Create the project
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
'Content-Type': 'application/json',
|
|
267
|
-
},
|
|
268
|
-
body: JSON.stringify({
|
|
269
|
-
name: projectName,
|
|
270
|
-
framework: 'nextjs',
|
|
271
|
-
...(gitRepoUrl && {
|
|
272
|
-
gitRepository: {
|
|
273
|
-
type: 'github',
|
|
274
|
-
repo: gitRepoUrl.replace('https://github.com/', ''),
|
|
275
|
-
},
|
|
276
|
-
}),
|
|
277
|
-
}),
|
|
278
|
-
});
|
|
411
|
+
const projectBody: Record<string, unknown> = {
|
|
412
|
+
name: projectName,
|
|
413
|
+
framework: 'nextjs',
|
|
414
|
+
};
|
|
279
415
|
|
|
280
|
-
if
|
|
281
|
-
|
|
282
|
-
|
|
416
|
+
// Link to GitHub repo if provided
|
|
417
|
+
if (options?.gitRepoUrl) {
|
|
418
|
+
projectBody.gitRepository = {
|
|
419
|
+
type: 'github',
|
|
420
|
+
repo: options.gitRepoUrl.replace('https://github.com/', ''),
|
|
421
|
+
};
|
|
283
422
|
}
|
|
284
423
|
|
|
285
|
-
const project = await
|
|
424
|
+
const project = await withRetry(
|
|
425
|
+
() => fetch('https://api.vercel.com/v10/projects', {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
headers: {
|
|
428
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
429
|
+
'Content-Type': 'application/json',
|
|
430
|
+
},
|
|
431
|
+
body: JSON.stringify(projectBody),
|
|
432
|
+
}).then(async r => {
|
|
433
|
+
if (!r.ok) {
|
|
434
|
+
const error = await r.json().catch(() => ({}));
|
|
435
|
+
throw new Error(error.error?.message || `Failed to create project (${r.status})`);
|
|
436
|
+
}
|
|
437
|
+
return r.json();
|
|
438
|
+
}),
|
|
439
|
+
{
|
|
440
|
+
onRetry: (attempt) => {
|
|
441
|
+
spinner.text = `Creating Vercel project (retry ${attempt})...`;
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
);
|
|
286
445
|
|
|
287
446
|
spinner.succeed('Vercel project created!');
|
|
288
447
|
|
|
289
448
|
const projectUrl = `https://vercel.com/${project.accountId}/${project.name}`;
|
|
290
449
|
console.log(chalk.gray(` ${projectUrl}\n`));
|
|
291
450
|
|
|
451
|
+
// Set environment variables if provided
|
|
452
|
+
let envVarsSet = false;
|
|
453
|
+
if (options?.envVars && Object.keys(options.envVars).length > 0) {
|
|
454
|
+
const envSpinner = ora('Setting environment variables...').start();
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// Vercel expects env vars in a specific format
|
|
458
|
+
const envVarPromises = Object.entries(options.envVars).map(([key, value]) =>
|
|
459
|
+
fetch(`https://api.vercel.com/v10/projects/${project.id}/env`, {
|
|
460
|
+
method: 'POST',
|
|
461
|
+
headers: {
|
|
462
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
463
|
+
'Content-Type': 'application/json',
|
|
464
|
+
},
|
|
465
|
+
body: JSON.stringify({
|
|
466
|
+
key,
|
|
467
|
+
value,
|
|
468
|
+
type: key.startsWith('NEXT_PUBLIC_') ? 'plain' : 'encrypted',
|
|
469
|
+
target: ['production', 'preview', 'development'],
|
|
470
|
+
}),
|
|
471
|
+
}).then(r => {
|
|
472
|
+
if (!r.ok) {
|
|
473
|
+
console.log(chalk.yellow(` Warning: Could not set ${key}`));
|
|
474
|
+
}
|
|
475
|
+
return r.ok;
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
const results = await Promise.all(envVarPromises);
|
|
480
|
+
const successCount = results.filter(Boolean).length;
|
|
481
|
+
|
|
482
|
+
if (successCount === Object.keys(options.envVars).length) {
|
|
483
|
+
envSpinner.succeed(`Set ${successCount} environment variables!`);
|
|
484
|
+
envVarsSet = true;
|
|
485
|
+
} else {
|
|
486
|
+
envSpinner.warn(`Set ${successCount}/${Object.keys(options.envVars).length} environment variables`);
|
|
487
|
+
}
|
|
488
|
+
} catch (error) {
|
|
489
|
+
envSpinner.warn('Could not set some environment variables');
|
|
490
|
+
console.log(chalk.gray(' Set them manually in the Vercel dashboard.\n'));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
292
494
|
return {
|
|
293
495
|
projectId: project.id,
|
|
294
496
|
projectUrl,
|
|
497
|
+
envVarsSet,
|
|
295
498
|
};
|
|
296
499
|
} catch (error) {
|
|
297
500
|
spinner.fail('Failed to create Vercel project');
|
|
@@ -299,9 +502,12 @@ export async function createVercelProject(
|
|
|
299
502
|
|
|
300
503
|
if (message.includes('already exists')) {
|
|
301
504
|
console.log(chalk.yellow(' Project already exists. Skipping.\n'));
|
|
505
|
+
console.log(chalk.gray(' You can link to existing project in Vercel dashboard.\n'));
|
|
302
506
|
} else {
|
|
303
|
-
console.log(chalk.red(` Error: ${message}
|
|
507
|
+
console.log(chalk.red(` Error: ${message}`));
|
|
508
|
+
console.log(chalk.gray(' Check your token and try again.\n'));
|
|
304
509
|
}
|
|
510
|
+
|
|
305
511
|
return null;
|
|
306
512
|
}
|
|
307
513
|
}
|
|
@@ -333,9 +539,24 @@ export async function provisionAll(
|
|
|
333
539
|
result.supabase = supabase;
|
|
334
540
|
}
|
|
335
541
|
|
|
336
|
-
// 3. Vercel
|
|
542
|
+
// 3. Vercel (with environment variables from Supabase)
|
|
337
543
|
console.log(chalk.white(' Step 3: Vercel Project\n'));
|
|
338
|
-
|
|
544
|
+
|
|
545
|
+
// Prepare env vars for Vercel
|
|
546
|
+
const vercelEnvVars: Record<string, string> = {};
|
|
547
|
+
|
|
548
|
+
if (result.supabase) {
|
|
549
|
+
vercelEnvVars['NEXT_PUBLIC_SUPABASE_URL'] = result.supabase.apiUrl;
|
|
550
|
+
if (result.supabase.anonKey) {
|
|
551
|
+
vercelEnvVars['NEXT_PUBLIC_SUPABASE_ANON_KEY'] = result.supabase.anonKey;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const vercel = await createVercelProject(projectName, {
|
|
556
|
+
gitRepoUrl: result.github?.repoUrl,
|
|
557
|
+
envVars: Object.keys(vercelEnvVars).length > 0 ? vercelEnvVars : undefined,
|
|
558
|
+
});
|
|
559
|
+
|
|
339
560
|
if (vercel) {
|
|
340
561
|
result.vercel = vercel;
|
|
341
562
|
}
|
|
@@ -358,7 +579,8 @@ export async function provisionAll(
|
|
|
358
579
|
}
|
|
359
580
|
|
|
360
581
|
if (result.vercel) {
|
|
361
|
-
|
|
582
|
+
const envStatus = result.vercel.envVarsSet ? ' (env vars set!)' : '';
|
|
583
|
+
console.log(chalk.green(' ✅ Vercel: ') + chalk.gray(result.vercel.projectUrl) + chalk.green(envStatus));
|
|
362
584
|
} else {
|
|
363
585
|
console.log(chalk.yellow(' ⏭️ Vercel: Skipped'));
|
|
364
586
|
}
|