@vibecodemax/cli 0.1.1 → 0.1.3
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/README.md +34 -88
- package/dist/cli.js +617 -10
- package/dist/storageS3.js +630 -0
- package/package.json +8 -3
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { checkS3Context, setupS3Storage, smokeTestS3 } from "./storageS3.js";
|
|
7
|
+
const SETUP_STATE_PATH = path.join(".vibecodemax", "setup-state.json");
|
|
4
8
|
const MANAGEMENT_API_BASE = "https://api.supabase.com";
|
|
5
9
|
const DEFAULT_LOCALHOST_URL = "http://localhost:3000";
|
|
10
|
+
const STORAGE_PUBLIC_BUCKET = "public-assets";
|
|
11
|
+
const STORAGE_PRIVATE_BUCKET = "private-uploads";
|
|
12
|
+
const STORAGE_REQUIRED_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
|
13
|
+
const STORAGE_HEALTHCHECK_PREFIX = "_vibecodemax/healthcheck/default";
|
|
14
|
+
const STORAGE_MIME_TYPES_BY_CATEGORY = {
|
|
15
|
+
images: ["image/jpeg", "image/png", "image/webp", "image/gif"],
|
|
16
|
+
documents: [
|
|
17
|
+
"application/pdf",
|
|
18
|
+
"text/plain",
|
|
19
|
+
"text/csv",
|
|
20
|
+
"application/json",
|
|
21
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
22
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
23
|
+
],
|
|
24
|
+
video: ["video/mp4", "video/webm", "video/quicktime"],
|
|
25
|
+
};
|
|
26
|
+
const STORAGE_DEFAULT_MIME_CATEGORIES = ["images"];
|
|
27
|
+
const STORAGE_REQUIRED_POLICY_NAMES = [
|
|
28
|
+
"public_assets_select_own",
|
|
29
|
+
"public_assets_insert_own",
|
|
30
|
+
"public_assets_update_own",
|
|
31
|
+
"public_assets_delete_own",
|
|
32
|
+
"private_uploads_select_own",
|
|
33
|
+
"private_uploads_insert_own",
|
|
34
|
+
"private_uploads_update_own",
|
|
35
|
+
"private_uploads_delete_own",
|
|
36
|
+
];
|
|
6
37
|
function printJson(value) {
|
|
7
38
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
8
39
|
}
|
|
@@ -12,8 +43,14 @@ function fail(code, message, exitCode = 1, extra = {}) {
|
|
|
12
43
|
}
|
|
13
44
|
function parseArgs(argv) {
|
|
14
45
|
const [command, ...rest] = argv;
|
|
46
|
+
let subcommand;
|
|
15
47
|
const flags = {};
|
|
16
|
-
|
|
48
|
+
let index = 0;
|
|
49
|
+
if ((command === "admin" || command === "storage") && rest[0] && !rest[0].startsWith("--")) {
|
|
50
|
+
subcommand = rest[0];
|
|
51
|
+
index = 1;
|
|
52
|
+
}
|
|
53
|
+
for (; index < rest.length; index += 1) {
|
|
17
54
|
const token = rest[index];
|
|
18
55
|
if (!token.startsWith("--"))
|
|
19
56
|
continue;
|
|
@@ -26,7 +63,7 @@ function parseArgs(argv) {
|
|
|
26
63
|
flags[key] = next;
|
|
27
64
|
index += 1;
|
|
28
65
|
}
|
|
29
|
-
return { command, flags };
|
|
66
|
+
return { command, subcommand, flags };
|
|
30
67
|
}
|
|
31
68
|
function readFileIfExists(filePath) {
|
|
32
69
|
try {
|
|
@@ -141,6 +178,19 @@ function requireEnvValue(envValues, key, fileHint) {
|
|
|
141
178
|
}
|
|
142
179
|
return value.trim();
|
|
143
180
|
}
|
|
181
|
+
function requireAuthAccessToken(envValues, fileHint) {
|
|
182
|
+
return requireEnvValue(envValues, "SUPABASE_ACCESS_TOKEN", fileHint);
|
|
183
|
+
}
|
|
184
|
+
function requireSupabaseUrl(envValues) {
|
|
185
|
+
const value = envValues.SUPABASE_URL || envValues.NEXT_PUBLIC_SUPABASE_URL;
|
|
186
|
+
if (!isNonEmptyString(value)) {
|
|
187
|
+
fail("MISSING_ENV", "SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL is missing. Add one of them to .env.local.");
|
|
188
|
+
}
|
|
189
|
+
return value.trim();
|
|
190
|
+
}
|
|
191
|
+
function requireServiceRoleKey(envValues) {
|
|
192
|
+
return requireEnvValue(envValues, "SUPABASE_SERVICE_ROLE_KEY", ".env.local");
|
|
193
|
+
}
|
|
144
194
|
function mergeEnvFile(filePath, nextValues) {
|
|
145
195
|
const existing = parseDotEnv(readFileIfExists(filePath));
|
|
146
196
|
const merged = { ...existing, ...nextValues };
|
|
@@ -148,6 +198,25 @@ function mergeEnvFile(filePath, nextValues) {
|
|
|
148
198
|
const content = `${keys.map((key) => `${key}=${merged[key]}`).join("\n")}\n`;
|
|
149
199
|
fs.writeFileSync(filePath, content, "utf8");
|
|
150
200
|
}
|
|
201
|
+
function readSetupState() {
|
|
202
|
+
const setupStatePath = path.join(process.cwd(), SETUP_STATE_PATH);
|
|
203
|
+
const content = readFileIfExists(setupStatePath).trim();
|
|
204
|
+
if (!content) {
|
|
205
|
+
printJson({ ok: true, setupState: {} });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = JSON.parse(content);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
fail("INVALID_SETUP_STATE", `${SETUP_STATE_PATH} is not valid JSON.`);
|
|
214
|
+
}
|
|
215
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
216
|
+
fail("INVALID_SETUP_STATE", `${SETUP_STATE_PATH} must contain a JSON object.`);
|
|
217
|
+
}
|
|
218
|
+
printJson({ ok: true, setupState: parsed });
|
|
219
|
+
}
|
|
151
220
|
async function managementRequest({ method, projectRef, token, body }) {
|
|
152
221
|
const endpoint = `${MANAGEMENT_API_BASE}/v1/projects/${projectRef}/config/auth`;
|
|
153
222
|
const response = await fetch(endpoint, {
|
|
@@ -235,7 +304,7 @@ async function configureSiteRedirects(flags) {
|
|
|
235
304
|
if (!projectRef) {
|
|
236
305
|
fail("MISSING_PROJECT_REF", "SUPABASE_PROJECT_REF is missing. Add it to .env.bootstrap.local or ensure NEXT_PUBLIC_SUPABASE_URL is present in .env.local.");
|
|
237
306
|
}
|
|
238
|
-
const accessToken =
|
|
307
|
+
const accessToken = requireAuthAccessToken(values, path.basename(envBootstrapPath));
|
|
239
308
|
const mode = readStringFlag(flags, "mode") === "production" ? "production" : "test";
|
|
240
309
|
const productionDomain = mode === "production" ? normalizeBaseUrl(readStringFlag(flags, "production-domain")) : "";
|
|
241
310
|
if (mode === "production" && !productionDomain) {
|
|
@@ -273,7 +342,7 @@ async function configureEmailPassword(flags) {
|
|
|
273
342
|
if (!projectRef) {
|
|
274
343
|
fail("MISSING_PROJECT_REF", "SUPABASE_PROJECT_REF is missing. Add it to .env.bootstrap.local or ensure NEXT_PUBLIC_SUPABASE_URL is present in .env.local.");
|
|
275
344
|
}
|
|
276
|
-
const accessToken =
|
|
345
|
+
const accessToken = requireAuthAccessToken(values, path.basename(envBootstrapPath));
|
|
277
346
|
const emailConfirmation = readStringFlag(flags, "email-confirmation") !== "no";
|
|
278
347
|
await managementRequest({
|
|
279
348
|
method: "PATCH",
|
|
@@ -298,13 +367,13 @@ async function enableGoogleProvider(flags) {
|
|
|
298
367
|
if (!projectRef) {
|
|
299
368
|
fail("MISSING_PROJECT_REF", "SUPABASE_PROJECT_REF is missing. Add it to .env.bootstrap.local or ensure NEXT_PUBLIC_SUPABASE_URL is present in .env.local.");
|
|
300
369
|
}
|
|
301
|
-
const
|
|
370
|
+
const accessToken = requireAuthAccessToken(values, path.basename(envBootstrapPath));
|
|
302
371
|
const googleClientId = requireEnvValue(values, "GOOGLE_CLIENT_ID", path.basename(envBootstrapPath));
|
|
303
372
|
const googleClientSecret = requireEnvValue(values, "GOOGLE_CLIENT_SECRET", path.basename(envBootstrapPath));
|
|
304
373
|
await managementRequest({
|
|
305
374
|
method: "PATCH",
|
|
306
375
|
projectRef,
|
|
307
|
-
token:
|
|
376
|
+
token: accessToken,
|
|
308
377
|
body: {
|
|
309
378
|
external_google_enabled: true,
|
|
310
379
|
external_google_client_id: googleClientId,
|
|
@@ -325,7 +394,7 @@ async function applyAuthTemplates(flags) {
|
|
|
325
394
|
if (!projectRef) {
|
|
326
395
|
fail("MISSING_PROJECT_REF", "SUPABASE_PROJECT_REF is missing. Add it to .env.bootstrap.local or ensure NEXT_PUBLIC_SUPABASE_URL is present in .env.local.");
|
|
327
396
|
}
|
|
328
|
-
const
|
|
397
|
+
const accessToken = requireAuthAccessToken(values, path.basename(envBootstrapPath));
|
|
329
398
|
const desiredFields = buildTemplateDesiredFields({
|
|
330
399
|
confirmEmailEnabled: readStringFlag(flags, "confirm-email-enabled") === "true",
|
|
331
400
|
confirmEmailSubject: readStringFlag(flags, "confirm-email-subject"),
|
|
@@ -334,11 +403,11 @@ async function applyAuthTemplates(flags) {
|
|
|
334
403
|
resetPasswordSubject: readStringFlag(flags, "reset-password-subject"),
|
|
335
404
|
resetPasswordPath: readStringFlag(flags, "reset-password-path"),
|
|
336
405
|
});
|
|
337
|
-
const current = await managementRequest({ method: "GET", projectRef, token:
|
|
406
|
+
const current = await managementRequest({ method: "GET", projectRef, token: accessToken });
|
|
338
407
|
const currentAuthConfig = readAuthConfigEnvelope(current);
|
|
339
408
|
const diff = diffTemplateFields(currentAuthConfig, desiredFields);
|
|
340
409
|
if (!diff.noChanges) {
|
|
341
|
-
await managementRequest({ method: "PATCH", projectRef, token:
|
|
410
|
+
await managementRequest({ method: "PATCH", projectRef, token: accessToken, body: diff.patch });
|
|
342
411
|
}
|
|
343
412
|
printJson({
|
|
344
413
|
ok: true,
|
|
@@ -349,12 +418,534 @@ async function applyAuthTemplates(flags) {
|
|
|
349
418
|
noChanges: diff.noChanges,
|
|
350
419
|
});
|
|
351
420
|
}
|
|
421
|
+
async function supabaseAdminRequest(params) {
|
|
422
|
+
const response = await fetch(`${params.supabaseUrl}${params.endpoint}`, {
|
|
423
|
+
method: params.method,
|
|
424
|
+
headers: {
|
|
425
|
+
apikey: params.serviceRoleKey,
|
|
426
|
+
Authorization: `Bearer ${params.serviceRoleKey}`,
|
|
427
|
+
"Content-Type": "application/json",
|
|
428
|
+
},
|
|
429
|
+
body: params.body ? JSON.stringify(params.body) : undefined,
|
|
430
|
+
});
|
|
431
|
+
let json = null;
|
|
432
|
+
try {
|
|
433
|
+
json = await response.json();
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
json = null;
|
|
437
|
+
}
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
const message = isNonEmptyString(json?.msg)
|
|
440
|
+
? json.msg
|
|
441
|
+
: isNonEmptyString(json?.message)
|
|
442
|
+
? json.message
|
|
443
|
+
: isNonEmptyString(json?.error)
|
|
444
|
+
? json.error
|
|
445
|
+
: `Supabase returned ${response.status}`;
|
|
446
|
+
fail("SUPABASE_ADMIN_ERROR", message, 1, { status: response.status });
|
|
447
|
+
}
|
|
448
|
+
return json;
|
|
449
|
+
}
|
|
450
|
+
async function findUserByEmail(supabaseUrl, serviceRoleKey, email) {
|
|
451
|
+
const result = await supabaseAdminRequest({
|
|
452
|
+
supabaseUrl,
|
|
453
|
+
serviceRoleKey,
|
|
454
|
+
method: "GET",
|
|
455
|
+
endpoint: `/auth/v1/admin/users?email=${encodeURIComponent(email)}`,
|
|
456
|
+
});
|
|
457
|
+
const users = Array.isArray(result.users) ? result.users : [];
|
|
458
|
+
const user = users.find((candidate) => {
|
|
459
|
+
if (!candidate || typeof candidate !== "object")
|
|
460
|
+
return false;
|
|
461
|
+
const candidateEmail = candidate.email;
|
|
462
|
+
return typeof candidateEmail === "string" && candidateEmail.toLowerCase() === email.toLowerCase();
|
|
463
|
+
});
|
|
464
|
+
return {
|
|
465
|
+
exists: Boolean(user),
|
|
466
|
+
userId: user && typeof user.id === "string" ? user.id : null,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function generateTemporaryPassword() {
|
|
470
|
+
return `VcmAdmin!${Math.random().toString(36).slice(2, 10)}9aA`;
|
|
471
|
+
}
|
|
472
|
+
async function createAdminUser(supabaseUrl, serviceRoleKey, email, temporaryPassword) {
|
|
473
|
+
const result = await supabaseAdminRequest({
|
|
474
|
+
supabaseUrl,
|
|
475
|
+
serviceRoleKey,
|
|
476
|
+
method: "POST",
|
|
477
|
+
endpoint: "/auth/v1/admin/users",
|
|
478
|
+
body: {
|
|
479
|
+
email,
|
|
480
|
+
password: temporaryPassword,
|
|
481
|
+
email_confirm: true,
|
|
482
|
+
user_metadata: { name: "Admin User" },
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
const userId = typeof result.user?.id === "string"
|
|
486
|
+
? result.user.id
|
|
487
|
+
: typeof result.id === "string"
|
|
488
|
+
? result.id
|
|
489
|
+
: "";
|
|
490
|
+
if (!userId) {
|
|
491
|
+
fail("INVALID_RESPONSE", "Supabase did not return a user ID for the created admin user.");
|
|
492
|
+
}
|
|
493
|
+
return userId;
|
|
494
|
+
}
|
|
495
|
+
async function promoteAdminProfile(supabaseUrl, serviceRoleKey, userId, email) {
|
|
496
|
+
await supabaseAdminRequest({
|
|
497
|
+
supabaseUrl,
|
|
498
|
+
serviceRoleKey,
|
|
499
|
+
method: "POST",
|
|
500
|
+
endpoint: "/rest/v1/profiles?on_conflict=id",
|
|
501
|
+
body: [
|
|
502
|
+
{
|
|
503
|
+
id: userId,
|
|
504
|
+
email,
|
|
505
|
+
name: "Admin User",
|
|
506
|
+
role: "admin",
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
async function verifyAdminProfile(supabaseUrl, serviceRoleKey, userId) {
|
|
512
|
+
const result = await supabaseAdminRequest({
|
|
513
|
+
supabaseUrl,
|
|
514
|
+
serviceRoleKey,
|
|
515
|
+
method: "GET",
|
|
516
|
+
endpoint: `/rest/v1/profiles?id=eq.${userId}&select=id,role`,
|
|
517
|
+
});
|
|
518
|
+
const rows = Array.isArray(result) ? result : [];
|
|
519
|
+
return rows.some((row) => row && typeof row === "object" && row.role === "admin");
|
|
520
|
+
}
|
|
521
|
+
async function ensureAdmin(flags) {
|
|
522
|
+
const { values } = loadLocalEnv();
|
|
523
|
+
const email = readStringFlag(flags, "email");
|
|
524
|
+
if (!email || !/^[^@]+@[^@]+\.[^@]+$/.test(email)) {
|
|
525
|
+
fail("INVALID_EMAIL", "A valid --email value is required.");
|
|
526
|
+
}
|
|
527
|
+
const supabaseUrl = requireSupabaseUrl(values);
|
|
528
|
+
const serviceRoleKey = requireServiceRoleKey(values);
|
|
529
|
+
const lookup = await findUserByEmail(supabaseUrl, serviceRoleKey, email);
|
|
530
|
+
let userId = lookup.userId;
|
|
531
|
+
let temporaryPassword = "";
|
|
532
|
+
let created = false;
|
|
533
|
+
if (!lookup.exists) {
|
|
534
|
+
temporaryPassword = generateTemporaryPassword();
|
|
535
|
+
userId = await createAdminUser(supabaseUrl, serviceRoleKey, email, temporaryPassword);
|
|
536
|
+
created = true;
|
|
537
|
+
}
|
|
538
|
+
if (!userId) {
|
|
539
|
+
fail("INVALID_RESPONSE", "Resolved admin user ID is missing after ensure-admin.");
|
|
540
|
+
}
|
|
541
|
+
await promoteAdminProfile(supabaseUrl, serviceRoleKey, userId, email);
|
|
542
|
+
const verified = await verifyAdminProfile(supabaseUrl, serviceRoleKey, userId);
|
|
543
|
+
if (!verified) {
|
|
544
|
+
fail("ADMIN_VERIFY_FAILED", "The admin role could not be verified after promotion.");
|
|
545
|
+
}
|
|
546
|
+
const result = {
|
|
547
|
+
ok: true,
|
|
548
|
+
command: "admin ensure-admin",
|
|
549
|
+
email,
|
|
550
|
+
userId,
|
|
551
|
+
userExists: lookup.exists,
|
|
552
|
+
created,
|
|
553
|
+
promoted: true,
|
|
554
|
+
verified: true,
|
|
555
|
+
defaultName: "Admin User",
|
|
556
|
+
applied: ["auth_user", "admin_profile"],
|
|
557
|
+
};
|
|
558
|
+
if (temporaryPassword) {
|
|
559
|
+
result.temporaryPassword = temporaryPassword;
|
|
560
|
+
}
|
|
561
|
+
printJson(result);
|
|
562
|
+
}
|
|
563
|
+
function shellQuote(value) {
|
|
564
|
+
const text = String(value);
|
|
565
|
+
return `'${text.replace(/'/g, `"'"'`)}'`;
|
|
566
|
+
}
|
|
567
|
+
function normalizeDependencyManagerName(value) {
|
|
568
|
+
if (value === "pnpm")
|
|
569
|
+
return "pnpm";
|
|
570
|
+
if (value === "yarn")
|
|
571
|
+
return "yarn";
|
|
572
|
+
return "npm";
|
|
573
|
+
}
|
|
574
|
+
function detectDependencyManager(cwd, flags) {
|
|
575
|
+
const explicitRaw = readStringFlag(flags, "dependency-manager");
|
|
576
|
+
if (explicitRaw) {
|
|
577
|
+
return normalizeDependencyManagerName(explicitRaw);
|
|
578
|
+
}
|
|
579
|
+
const packageJsonPath = path.join(cwd, "package.json");
|
|
580
|
+
try {
|
|
581
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
582
|
+
if (isNonEmptyString(packageJson.packageManager)) {
|
|
583
|
+
const manager = packageJson.packageManager.split("@")[0];
|
|
584
|
+
if (manager === "pnpm" || manager === "yarn")
|
|
585
|
+
return manager;
|
|
586
|
+
if (manager === "npm")
|
|
587
|
+
return "npm";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
// ignore and fall back to lockfile detection
|
|
592
|
+
}
|
|
593
|
+
if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml")))
|
|
594
|
+
return "pnpm";
|
|
595
|
+
if (fs.existsSync(path.join(cwd, "yarn.lock")))
|
|
596
|
+
return "yarn";
|
|
597
|
+
return "npm";
|
|
598
|
+
}
|
|
599
|
+
function getSupabaseRunner(dependencyManager) {
|
|
600
|
+
if (dependencyManager === "pnpm")
|
|
601
|
+
return "pnpm exec supabase";
|
|
602
|
+
if (dependencyManager === "yarn")
|
|
603
|
+
return "yarn supabase";
|
|
604
|
+
return "npx supabase";
|
|
605
|
+
}
|
|
606
|
+
function runShellCommand(command, cwd) {
|
|
607
|
+
const result = spawnSync(process.env.SHELL || "/bin/zsh", ["-lc", command], {
|
|
608
|
+
cwd,
|
|
609
|
+
env: process.env,
|
|
610
|
+
encoding: "utf8",
|
|
611
|
+
});
|
|
612
|
+
if (result.status !== 0) {
|
|
613
|
+
const message = [result.stderr, result.stdout]
|
|
614
|
+
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
|
615
|
+
.find(Boolean) || `Command failed: ${command}`;
|
|
616
|
+
fail("LOCAL_COMMAND_FAILED", message);
|
|
617
|
+
}
|
|
618
|
+
return typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
619
|
+
}
|
|
620
|
+
function normalizeStorageMimeCategories(rawValue) {
|
|
621
|
+
const requested = rawValue
|
|
622
|
+
.split(",")
|
|
623
|
+
.map((value) => value.trim())
|
|
624
|
+
.filter(Boolean);
|
|
625
|
+
const selected = requested.filter((value) => Object.prototype.hasOwnProperty.call(STORAGE_MIME_TYPES_BY_CATEGORY, value));
|
|
626
|
+
return selected.length > 0 ? [...new Set(selected)] : [...STORAGE_DEFAULT_MIME_CATEGORIES];
|
|
627
|
+
}
|
|
628
|
+
function resolveStorageMimeCategories(flags) {
|
|
629
|
+
const raw = readStringFlag(flags, "mime-categories");
|
|
630
|
+
if (!raw)
|
|
631
|
+
return [...STORAGE_DEFAULT_MIME_CATEGORIES];
|
|
632
|
+
return normalizeStorageMimeCategories(raw);
|
|
633
|
+
}
|
|
634
|
+
function expandStorageMimeCategories(categories) {
|
|
635
|
+
const mimeTypes = [];
|
|
636
|
+
for (const category of categories) {
|
|
637
|
+
const mapped = STORAGE_MIME_TYPES_BY_CATEGORY[category] || [];
|
|
638
|
+
for (const mimeType of mapped) {
|
|
639
|
+
if (!mimeTypes.includes(mimeType)) {
|
|
640
|
+
mimeTypes.push(mimeType);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return mimeTypes;
|
|
645
|
+
}
|
|
646
|
+
function parseStorageMigrationFilename(filename) {
|
|
647
|
+
const doubleUnderscore = filename.match(/^(\d{14})__([a-z0-9_]+)\.sql$/);
|
|
648
|
+
if (doubleUnderscore) {
|
|
649
|
+
return { filename, timestamp: doubleUnderscore[1], scope: doubleUnderscore[2] };
|
|
650
|
+
}
|
|
651
|
+
const singleUnderscore = filename.match(/^(\d{14})_([a-z0-9_]+)\.sql$/);
|
|
652
|
+
if (singleUnderscore) {
|
|
653
|
+
return { filename, timestamp: singleUnderscore[1], scope: singleUnderscore[2] };
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
function discoverStorageMigrationFiles(cwd) {
|
|
658
|
+
const migrationsRoot = path.join(cwd, "supabase", "migrations");
|
|
659
|
+
if (!fs.existsSync(migrationsRoot)) {
|
|
660
|
+
fail("MISSING_STORAGE_MIGRATIONS", "supabase/migrations is missing. Run bootstrap.base first so the local Supabase project is initialized.");
|
|
661
|
+
}
|
|
662
|
+
const selected = fs.readdirSync(migrationsRoot)
|
|
663
|
+
.map((filename) => parseStorageMigrationFilename(filename))
|
|
664
|
+
.filter((entry) => entry !== null && entry.scope.startsWith("storage_"))
|
|
665
|
+
.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
666
|
+
if (selected.length === 0) {
|
|
667
|
+
fail("MISSING_STORAGE_MIGRATIONS", "No storage migration files were found. Add a migration matching YYYYMMDDHHMMSS_storage_*.sql in supabase/migrations.");
|
|
668
|
+
}
|
|
669
|
+
const policyFiles = selected
|
|
670
|
+
.filter((entry) => /(^|_)storage_policies\.sql$/.test(entry.filename))
|
|
671
|
+
.map((entry) => entry.filename);
|
|
672
|
+
if (policyFiles.length === 0) {
|
|
673
|
+
fail("MISSING_STORAGE_POLICY_MIGRATION", "No storage policy migration file was found. Add a migration ending in storage_policies.sql in supabase/migrations.");
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
files: selected.map((entry) => entry.filename),
|
|
677
|
+
policyFiles,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async function storageRequest(params) {
|
|
681
|
+
const response = await fetch(`${params.supabaseUrl}${params.endpoint}`, {
|
|
682
|
+
method: params.method,
|
|
683
|
+
headers: {
|
|
684
|
+
apikey: params.serviceRoleKey,
|
|
685
|
+
Authorization: `Bearer ${params.serviceRoleKey}`,
|
|
686
|
+
...(params.contentType ? { "Content-Type": params.contentType } : {}),
|
|
687
|
+
},
|
|
688
|
+
body: params.rawBody ?? (params.body ? JSON.stringify(params.body) : undefined),
|
|
689
|
+
});
|
|
690
|
+
let json = null;
|
|
691
|
+
try {
|
|
692
|
+
json = await response.json();
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
json = null;
|
|
696
|
+
}
|
|
697
|
+
if (!response.ok) {
|
|
698
|
+
const message = isNonEmptyString(json?.error)
|
|
699
|
+
? json.error
|
|
700
|
+
: isNonEmptyString(json?.message)
|
|
701
|
+
? json.message
|
|
702
|
+
: isNonEmptyString(json?.msg)
|
|
703
|
+
? json.msg
|
|
704
|
+
: `Supabase returned ${response.status}`;
|
|
705
|
+
fail("SUPABASE_STORAGE_ERROR", message, 1, { status: response.status });
|
|
706
|
+
}
|
|
707
|
+
return json;
|
|
708
|
+
}
|
|
709
|
+
function normalizeBucket(data = {}) {
|
|
710
|
+
const allowedMimeTypes = Array.isArray(data.allowed_mime_types)
|
|
711
|
+
? data.allowed_mime_types
|
|
712
|
+
: Array.isArray(data.allowedMimeTypes)
|
|
713
|
+
? data.allowedMimeTypes
|
|
714
|
+
: [];
|
|
715
|
+
const fileSizeLimit = Number.isFinite(data.file_size_limit)
|
|
716
|
+
? Number(data.file_size_limit)
|
|
717
|
+
: Number.isFinite(data.fileSizeLimit)
|
|
718
|
+
? Number(data.fileSizeLimit)
|
|
719
|
+
: null;
|
|
720
|
+
return {
|
|
721
|
+
id: isNonEmptyString(data.id) ? data.id : isNonEmptyString(data.name) ? String(data.name) : null,
|
|
722
|
+
public: Boolean(data.public),
|
|
723
|
+
allowedMimeTypes: [...allowedMimeTypes].filter((value) => typeof value === "string").sort(),
|
|
724
|
+
fileSizeLimit,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function compareBucketConfig(observed, expected) {
|
|
728
|
+
const expectedMimes = [...expected.allowedMimeTypes].sort();
|
|
729
|
+
const observedMimes = [...observed.allowedMimeTypes].sort();
|
|
730
|
+
return {
|
|
731
|
+
publicMatches: observed.public === expected.public,
|
|
732
|
+
mimeMatches: JSON.stringify(observedMimes) === JSON.stringify(expectedMimes),
|
|
733
|
+
fileSizeMatches: observed.fileSizeLimit === expected.fileSizeLimit,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
async function readStorageBucket(supabaseUrl, serviceRoleKey, bucketId) {
|
|
737
|
+
const response = await fetch(`${supabaseUrl}/storage/v1/bucket/${bucketId}`, {
|
|
738
|
+
method: "GET",
|
|
739
|
+
headers: {
|
|
740
|
+
apikey: serviceRoleKey,
|
|
741
|
+
Authorization: `Bearer ${serviceRoleKey}`,
|
|
742
|
+
Accept: "application/json",
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
let json = null;
|
|
746
|
+
try {
|
|
747
|
+
json = await response.json();
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
json = null;
|
|
751
|
+
}
|
|
752
|
+
if (response.status === 404)
|
|
753
|
+
return null;
|
|
754
|
+
if (!response.ok) {
|
|
755
|
+
const message = isNonEmptyString(json?.error)
|
|
756
|
+
? json.error
|
|
757
|
+
: isNonEmptyString(json?.message)
|
|
758
|
+
? json.message
|
|
759
|
+
: isNonEmptyString(json?.msg)
|
|
760
|
+
? json.msg
|
|
761
|
+
: `Supabase returned ${response.status}`;
|
|
762
|
+
fail("SUPABASE_STORAGE_ERROR", message, 1, { status: response.status });
|
|
763
|
+
}
|
|
764
|
+
return json && typeof json === "object" ? json : {};
|
|
765
|
+
}
|
|
766
|
+
async function ensureStorageBucket(supabaseUrl, serviceRoleKey, bucketId, isPublic, allowedMimeTypes) {
|
|
767
|
+
const existing = await readStorageBucket(supabaseUrl, serviceRoleKey, bucketId);
|
|
768
|
+
const endpoint = existing ? `/storage/v1/bucket/${bucketId}` : "/storage/v1/bucket";
|
|
769
|
+
const method = existing ? "PUT" : "POST";
|
|
770
|
+
const body = existing
|
|
771
|
+
? {
|
|
772
|
+
public: isPublic,
|
|
773
|
+
allowed_mime_types: allowedMimeTypes,
|
|
774
|
+
file_size_limit: STORAGE_REQUIRED_FILE_SIZE_LIMIT,
|
|
775
|
+
}
|
|
776
|
+
: {
|
|
777
|
+
id: bucketId,
|
|
778
|
+
name: bucketId,
|
|
779
|
+
public: isPublic,
|
|
780
|
+
allowed_mime_types: allowedMimeTypes,
|
|
781
|
+
file_size_limit: STORAGE_REQUIRED_FILE_SIZE_LIMIT,
|
|
782
|
+
};
|
|
783
|
+
await storageRequest({
|
|
784
|
+
supabaseUrl,
|
|
785
|
+
serviceRoleKey,
|
|
786
|
+
method,
|
|
787
|
+
endpoint,
|
|
788
|
+
body,
|
|
789
|
+
contentType: "application/json",
|
|
790
|
+
});
|
|
791
|
+
const observedRaw = await readStorageBucket(supabaseUrl, serviceRoleKey, bucketId);
|
|
792
|
+
if (!observedRaw) {
|
|
793
|
+
fail("SUPABASE_STORAGE_ERROR", `Bucket ${bucketId} could not be read after configuration.`);
|
|
794
|
+
}
|
|
795
|
+
const observed = normalizeBucket(observedRaw);
|
|
796
|
+
const expected = {
|
|
797
|
+
public: isPublic,
|
|
798
|
+
allowedMimeTypes,
|
|
799
|
+
fileSizeLimit: STORAGE_REQUIRED_FILE_SIZE_LIMIT,
|
|
800
|
+
};
|
|
801
|
+
const comparison = compareBucketConfig(observed, expected);
|
|
802
|
+
if (!comparison.publicMatches || !comparison.mimeMatches || !comparison.fileSizeMatches) {
|
|
803
|
+
fail("SUPABASE_BUCKET_VERIFY_FAILED", `Bucket ${bucketId} does not match the required configuration.`);
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
bucket: bucketId,
|
|
807
|
+
created: !existing,
|
|
808
|
+
updated: Boolean(existing),
|
|
809
|
+
verified: true,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function verifyRequiredStoragePolicies(dumpContent) {
|
|
813
|
+
const normalized = dumpContent.toLowerCase();
|
|
814
|
+
const missing = STORAGE_REQUIRED_POLICY_NAMES.filter((policyName) => !normalized.includes(`create policy \"${policyName}\"`));
|
|
815
|
+
if (missing.length > 0) {
|
|
816
|
+
fail("STORAGE_POLICY_VERIFY_FAILED", `Missing required storage policies after local Supabase CLI apply: ${missing.join(", ")}.`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function buildStorageHealthcheckObjectPath(runId) {
|
|
820
|
+
return `${STORAGE_HEALTHCHECK_PREFIX}/${runId}/upload.txt`;
|
|
821
|
+
}
|
|
822
|
+
async function checkSupabaseContext() {
|
|
823
|
+
const { values, envLocalPath } = loadLocalEnv();
|
|
824
|
+
const missingKeys = [];
|
|
825
|
+
const presentKeys = [];
|
|
826
|
+
const supabaseUrl = values.SUPABASE_URL || values.NEXT_PUBLIC_SUPABASE_URL;
|
|
827
|
+
if (isNonEmptyString(supabaseUrl)) {
|
|
828
|
+
presentKeys.push(isNonEmptyString(values.SUPABASE_URL) ? "SUPABASE_URL" : "NEXT_PUBLIC_SUPABASE_URL");
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
missingKeys.push("NEXT_PUBLIC_SUPABASE_URL");
|
|
832
|
+
}
|
|
833
|
+
if (isNonEmptyString(values.SUPABASE_SERVICE_ROLE_KEY)) {
|
|
834
|
+
presentKeys.push("SUPABASE_SERVICE_ROLE_KEY");
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
missingKeys.push("SUPABASE_SERVICE_ROLE_KEY");
|
|
838
|
+
}
|
|
839
|
+
printJson({
|
|
840
|
+
ok: true,
|
|
841
|
+
command: "storage check-supabase-context",
|
|
842
|
+
ready: missingKeys.length === 0,
|
|
843
|
+
missingKeys,
|
|
844
|
+
presentKeys,
|
|
845
|
+
expectedExisting: true,
|
|
846
|
+
checkedFiles: [path.basename(envLocalPath)],
|
|
847
|
+
projectRef: resolveProjectRef({}, values) || null,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
async function setupSupabaseStorage(flags) {
|
|
851
|
+
const cwd = process.cwd();
|
|
852
|
+
const { envLocalPath, values } = loadLocalEnv();
|
|
853
|
+
const supabaseUrl = requireSupabaseUrl(values);
|
|
854
|
+
const serviceRoleKey = requireServiceRoleKey(values);
|
|
855
|
+
const dependencyManager = detectDependencyManager(cwd, flags);
|
|
856
|
+
const supabaseRunner = getSupabaseRunner(dependencyManager);
|
|
857
|
+
const mimeCategories = resolveStorageMimeCategories(flags);
|
|
858
|
+
const allowedMimeTypes = expandStorageMimeCategories(mimeCategories);
|
|
859
|
+
const publicBucket = await ensureStorageBucket(supabaseUrl, serviceRoleKey, STORAGE_PUBLIC_BUCKET, true, allowedMimeTypes);
|
|
860
|
+
const privateBucket = await ensureStorageBucket(supabaseUrl, serviceRoleKey, STORAGE_PRIVATE_BUCKET, false, allowedMimeTypes);
|
|
861
|
+
mergeEnvFile(envLocalPath, {
|
|
862
|
+
SUPABASE_PUBLIC_BUCKET: STORAGE_PUBLIC_BUCKET,
|
|
863
|
+
SUPABASE_PRIVATE_BUCKET: STORAGE_PRIVATE_BUCKET,
|
|
864
|
+
});
|
|
865
|
+
const migrations = discoverStorageMigrationFiles(cwd);
|
|
866
|
+
runShellCommand(`${supabaseRunner} db push --linked`, cwd);
|
|
867
|
+
const dumpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vibecodemax-storage-dump-"));
|
|
868
|
+
const dumpPath = path.join(dumpDir, "storage.sql");
|
|
869
|
+
runShellCommand(`${supabaseRunner} db dump --linked --schema storage -f ${shellQuote(dumpPath)} >/dev/null`, cwd);
|
|
870
|
+
const dumpContent = readFileIfExists(dumpPath);
|
|
871
|
+
verifyRequiredStoragePolicies(dumpContent);
|
|
872
|
+
fs.rmSync(dumpDir, { recursive: true, force: true });
|
|
873
|
+
printJson({
|
|
874
|
+
ok: true,
|
|
875
|
+
command: "storage setup-supabase",
|
|
876
|
+
dependencyManager,
|
|
877
|
+
publicBucket: STORAGE_PUBLIC_BUCKET,
|
|
878
|
+
privateBucket: STORAGE_PRIVATE_BUCKET,
|
|
879
|
+
bucketsConfigured: [publicBucket, privateBucket],
|
|
880
|
+
mimeCategories,
|
|
881
|
+
allowedMimeTypes,
|
|
882
|
+
fileSizeLimit: STORAGE_REQUIRED_FILE_SIZE_LIMIT,
|
|
883
|
+
migrationFiles: migrations.files,
|
|
884
|
+
policyFiles: migrations.policyFiles,
|
|
885
|
+
policyMigrationsDiscovered: true,
|
|
886
|
+
policyMigrationsApplied: true,
|
|
887
|
+
policiesVerified: true,
|
|
888
|
+
envWritten: ["SUPABASE_PUBLIC_BUCKET", "SUPABASE_PRIVATE_BUCKET"],
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
async function smokeTestSupabase() {
|
|
892
|
+
const { values } = loadLocalEnv();
|
|
893
|
+
const supabaseUrl = requireSupabaseUrl(values);
|
|
894
|
+
const serviceRoleKey = requireServiceRoleKey(values);
|
|
895
|
+
const runId = `run_${Date.now()}`;
|
|
896
|
+
const objectPath = buildStorageHealthcheckObjectPath(runId);
|
|
897
|
+
const prefix = `${STORAGE_HEALTHCHECK_PREFIX}/${runId}/`;
|
|
898
|
+
await storageRequest({
|
|
899
|
+
supabaseUrl,
|
|
900
|
+
serviceRoleKey,
|
|
901
|
+
method: "POST",
|
|
902
|
+
endpoint: `/storage/v1/object/${STORAGE_PUBLIC_BUCKET}/${objectPath}`,
|
|
903
|
+
rawBody: "healthcheck probe",
|
|
904
|
+
contentType: "text/plain",
|
|
905
|
+
});
|
|
906
|
+
const listResult = await storageRequest({
|
|
907
|
+
supabaseUrl,
|
|
908
|
+
serviceRoleKey,
|
|
909
|
+
method: "POST",
|
|
910
|
+
endpoint: `/storage/v1/object/list/${STORAGE_PUBLIC_BUCKET}`,
|
|
911
|
+
body: { prefix },
|
|
912
|
+
contentType: "application/json",
|
|
913
|
+
});
|
|
914
|
+
await storageRequest({
|
|
915
|
+
supabaseUrl,
|
|
916
|
+
serviceRoleKey,
|
|
917
|
+
method: "DELETE",
|
|
918
|
+
endpoint: `/storage/v1/object/${STORAGE_PUBLIC_BUCKET}`,
|
|
919
|
+
body: { prefixes: [objectPath] },
|
|
920
|
+
contentType: "application/json",
|
|
921
|
+
});
|
|
922
|
+
printJson({
|
|
923
|
+
ok: true,
|
|
924
|
+
command: "storage smoke-test-supabase",
|
|
925
|
+
runId,
|
|
926
|
+
bucketId: STORAGE_PUBLIC_BUCKET,
|
|
927
|
+
objectPath,
|
|
928
|
+
prefix,
|
|
929
|
+
upload: true,
|
|
930
|
+
list: true,
|
|
931
|
+
delete: true,
|
|
932
|
+
listedItems: Array.isArray(listResult) ? listResult.length : 0,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
352
935
|
async function main() {
|
|
353
|
-
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
936
|
+
const { command, subcommand, flags } = parseArgs(process.argv.slice(2));
|
|
354
937
|
if (!command || command === "--help" || command === "help") {
|
|
355
938
|
printJson({
|
|
356
939
|
ok: true,
|
|
357
940
|
commands: [
|
|
941
|
+
"read-setup-state",
|
|
942
|
+
"admin ensure-admin",
|
|
943
|
+
"storage check-supabase-context",
|
|
944
|
+
"storage setup-supabase",
|
|
945
|
+
"storage smoke-test-supabase",
|
|
946
|
+
"storage check-s3-context",
|
|
947
|
+
"storage setup-s3",
|
|
948
|
+
"storage smoke-test-s3",
|
|
358
949
|
"configure-site-redirects",
|
|
359
950
|
"configure-email-password",
|
|
360
951
|
"enable-google-provider",
|
|
@@ -363,6 +954,22 @@ async function main() {
|
|
|
363
954
|
});
|
|
364
955
|
return;
|
|
365
956
|
}
|
|
957
|
+
if (command === "read-setup-state")
|
|
958
|
+
return readSetupState();
|
|
959
|
+
if (command === "admin" && subcommand === "ensure-admin")
|
|
960
|
+
return ensureAdmin(flags);
|
|
961
|
+
if (command === "storage" && subcommand === "check-supabase-context")
|
|
962
|
+
return checkSupabaseContext();
|
|
963
|
+
if (command === "storage" && subcommand === "setup-supabase")
|
|
964
|
+
return setupSupabaseStorage(flags);
|
|
965
|
+
if (command === "storage" && subcommand === "smoke-test-supabase")
|
|
966
|
+
return smokeTestSupabase();
|
|
967
|
+
if (command === "storage" && subcommand === "check-s3-context")
|
|
968
|
+
return checkS3Context(flags);
|
|
969
|
+
if (command === "storage" && subcommand === "setup-s3")
|
|
970
|
+
return setupS3Storage(flags);
|
|
971
|
+
if (command === "storage" && subcommand === "smoke-test-s3")
|
|
972
|
+
return smokeTestS3(flags);
|
|
366
973
|
if (command === "configure-site-redirects")
|
|
367
974
|
return configureSiteRedirects(flags);
|
|
368
975
|
if (command === "configure-email-password")
|