@vibecodemax/cli 0.1.6 → 0.1.8

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/cli.js CHANGED
@@ -2,14 +2,15 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- import { checkS3Context, setupS3Storage, smokeTestS3 } from "./storageS3.js";
5
+ import { randomBytes } from "node:crypto";
6
+ import { checkS3Context, setupS3Storage } from "./storageS3.js";
6
7
  const SETUP_STATE_PATH = path.join(".vibecodemax", "setup-state.json");
8
+ const SETUP_CONFIG_PATH = path.join(".vibecodemax", "setup-config.json");
7
9
  const MANAGEMENT_API_BASE = "https://api.supabase.com";
8
10
  const DEFAULT_LOCALHOST_URL = "http://localhost:3000";
9
11
  const STORAGE_PUBLIC_BUCKET = "public-assets";
10
12
  const STORAGE_PRIVATE_BUCKET = "private-uploads";
11
13
  const STORAGE_REQUIRED_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
12
- const STORAGE_HEALTHCHECK_PREFIX = "_vibecodemax/healthcheck/default";
13
14
  const STORAGE_MIME_TYPES_BY_CATEGORY = {
14
15
  images: ["image/jpeg", "image/png", "image/webp", "image/gif"],
15
16
  documents: [
@@ -23,15 +24,25 @@ const STORAGE_MIME_TYPES_BY_CATEGORY = {
23
24
  video: ["video/mp4", "video/webm", "video/quicktime"],
24
25
  };
25
26
  const STORAGE_DEFAULT_MIME_CATEGORIES = ["images"];
26
- const STORAGE_REQUIRED_POLICY_NAMES = [
27
- "public_assets_select_own",
28
- "public_assets_insert_own",
29
- "public_assets_update_own",
30
- "public_assets_delete_own",
31
- "private_uploads_select_own",
32
- "private_uploads_insert_own",
33
- "private_uploads_update_own",
34
- "private_uploads_delete_own",
27
+ const STRIPE_EVENTS = [
28
+ "checkout.session.completed",
29
+ "customer.subscription.created",
30
+ "customer.subscription.updated",
31
+ "customer.subscription.deleted",
32
+ "invoice.payment_succeeded",
33
+ "invoice.payment_failed",
34
+ ];
35
+ const LEMON_EVENTS = [
36
+ "order_created",
37
+ "subscription_created",
38
+ "subscription_updated",
39
+ "subscription_cancelled",
40
+ "subscription_resumed",
41
+ "subscription_expired",
42
+ "subscription_paused",
43
+ "subscription_unpaused",
44
+ "subscription_payment_failed",
45
+ "subscription_payment_success",
35
46
  ];
36
47
  function printJson(value) {
37
48
  process.stdout.write(`${JSON.stringify(value)}\n`);
@@ -58,7 +69,7 @@ function parseArgs(argv) {
58
69
  let subcommand;
59
70
  const flags = {};
60
71
  let index = 0;
61
- if ((command === "admin" || command === "storage") && rest[0] && !rest[0].startsWith("--")) {
72
+ if ((command === "admin" || command === "storage" || command === "payments") && rest[0] && !rest[0].startsWith("--")) {
62
73
  subcommand = rest[0];
63
74
  index = 1;
64
75
  }
@@ -114,6 +125,18 @@ function loadLocalEnv(cwd = process.cwd()) {
114
125
  },
115
126
  };
116
127
  }
128
+ function readSetupConfig(cwd = process.cwd()) {
129
+ const raw = readFileIfExists(path.join(cwd, SETUP_CONFIG_PATH)).trim();
130
+ if (!raw)
131
+ return {};
132
+ try {
133
+ const parsed = JSON.parse(raw);
134
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
135
+ }
136
+ catch {
137
+ return {};
138
+ }
139
+ }
117
140
  function isNonEmptyString(value) {
118
141
  return typeof value === "string" && value.trim().length > 0;
119
142
  }
@@ -656,6 +679,44 @@ function runLinkedSupabaseCommand(command, cwd, failureCode, fallbackMessage) {
656
679
  }
657
680
  return typeof result.stdout === "string" ? result.stdout.trim() : "";
658
681
  }
682
+ function isMigrationOrderingConflict(message) {
683
+ return /Found local migration files to be inserted before the last migration on remote database\./i.test(message);
684
+ }
685
+ function runStorageMigrationPush(commandBase, cwd) {
686
+ const firstResult = spawnSync(process.env.SHELL || "/bin/zsh", ["-lc", commandBase], {
687
+ cwd,
688
+ env: process.env,
689
+ encoding: "utf8",
690
+ });
691
+ if (firstResult.status === 0) {
692
+ return {
693
+ stdout: typeof firstResult.stdout === "string" ? firstResult.stdout.trim() : "",
694
+ includeAll: false,
695
+ };
696
+ }
697
+ const firstMessage = [firstResult.stderr, firstResult.stdout]
698
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
699
+ .find(Boolean) || "Failed to apply storage migrations to the linked Supabase project.";
700
+ if (!isMigrationOrderingConflict(firstMessage)) {
701
+ fail("STORAGE_POLICY_MIGRATION_APPLY_FAILED", firstMessage);
702
+ }
703
+ const retryCommand = `${commandBase} --include-all`;
704
+ const retryResult = spawnSync(process.env.SHELL || "/bin/zsh", ["-lc", retryCommand], {
705
+ cwd,
706
+ env: process.env,
707
+ encoding: "utf8",
708
+ });
709
+ if (retryResult.status !== 0) {
710
+ const retryMessage = [retryResult.stderr, retryResult.stdout]
711
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
712
+ .find(Boolean) || "Failed to apply storage migrations to the linked Supabase project.";
713
+ fail("STORAGE_POLICY_MIGRATION_APPLY_FAILED", retryMessage);
714
+ }
715
+ return {
716
+ stdout: typeof retryResult.stdout === "string" ? retryResult.stdout.trim() : "",
717
+ includeAll: true,
718
+ };
719
+ }
659
720
  function normalizeStorageMimeCategories(rawValue) {
660
721
  const requested = rawValue
661
722
  .split(",")
@@ -664,11 +725,18 @@ function normalizeStorageMimeCategories(rawValue) {
664
725
  const selected = requested.filter((value) => Object.prototype.hasOwnProperty.call(STORAGE_MIME_TYPES_BY_CATEGORY, value));
665
726
  return selected.length > 0 ? [...new Set(selected)] : [...STORAGE_DEFAULT_MIME_CATEGORIES];
666
727
  }
667
- function resolveStorageMimeCategories(flags) {
668
- const raw = readStringFlag(flags, "mime-categories");
669
- if (!raw)
728
+ function resolveStorageMimeCategories(cwd) {
729
+ const setupConfig = readSetupConfig(cwd);
730
+ const storageConfig = setupConfig.storage && typeof setupConfig.storage === "object"
731
+ ? setupConfig.storage
732
+ : {};
733
+ const rawCategories = Array.isArray(storageConfig.mimeCategories)
734
+ ? storageConfig.mimeCategories.filter((value) => isNonEmptyString(value))
735
+ : [];
736
+ if (rawCategories.length === 0) {
670
737
  return [...STORAGE_DEFAULT_MIME_CATEGORIES];
671
- return normalizeStorageMimeCategories(raw);
738
+ }
739
+ return normalizeStorageMimeCategories(rawCategories.join(","));
672
740
  }
673
741
  function expandStorageMimeCategories(categories) {
674
742
  const mimeTypes = [];
@@ -694,28 +762,47 @@ function parseStorageMigrationFilename(filename) {
694
762
  return null;
695
763
  }
696
764
  function discoverStorageMigrationFiles(cwd) {
697
- const migrationsRoot = path.join(cwd, "supabase", "migrations");
698
- if (!fs.existsSync(migrationsRoot)) {
765
+ const storageMigrationsRoot = path.join(cwd, "supabase", "storage-migrations");
766
+ const activeMigrationsRoot = path.join(cwd, "supabase", "migrations");
767
+ if (!fs.existsSync(activeMigrationsRoot)) {
699
768
  fail("MISSING_STORAGE_MIGRATIONS", "supabase/migrations is missing. Run bootstrap.base first so the local Supabase project is initialized.");
700
769
  }
701
- const selected = fs.readdirSync(migrationsRoot)
770
+ if (!fs.existsSync(storageMigrationsRoot)) {
771
+ fail("MISSING_STORAGE_MIGRATIONS", "supabase/storage-migrations is missing. Regenerate the project so storage-owned migrations are available locally.");
772
+ }
773
+ const policyFiles = fs.readdirSync(storageMigrationsRoot)
702
774
  .map((filename) => parseStorageMigrationFilename(filename))
703
- .filter((entry) => entry !== null && entry.scope.startsWith("storage_"))
775
+ .filter((entry) => entry !== null && /(^|_)storage_policies$/.test(entry.scope))
704
776
  .sort((a, b) => a.filename.localeCompare(b.filename));
705
- if (selected.length === 0) {
706
- fail("MISSING_STORAGE_MIGRATIONS", "No storage migration files were found. Add a migration matching YYYYMMDDHHMMSS_storage_*.sql in supabase/migrations.");
707
- }
708
- const policyFiles = selected
709
- .filter((entry) => /(^|_)storage_policies\.sql$/.test(entry.filename))
710
- .map((entry) => entry.filename);
711
777
  if (policyFiles.length === 0) {
712
- fail("MISSING_STORAGE_POLICY_MIGRATION", "No storage policy migration file was found. Add a migration ending in storage_policies.sql in supabase/migrations.");
778
+ fail("MISSING_STORAGE_POLICY_MIGRATION", "No storage policy migration file was found in supabase/storage-migrations. This directory is for storage policy SQL only. Buckets are created by storage bootstrap through the Supabase Storage API. Regenerate the project or add a file ending in _storage_policies.sql.");
713
779
  }
714
780
  return {
715
- files: selected.map((entry) => entry.filename),
716
- policyFiles,
781
+ files: policyFiles.map((entry) => entry.filename),
782
+ policyFiles: policyFiles.map((entry) => entry.filename),
717
783
  };
718
784
  }
785
+ function materializeStorageMigrationFiles(cwd, files) {
786
+ const storageMigrationsRoot = path.join(cwd, "supabase", "storage-migrations");
787
+ const activeMigrationsRoot = path.join(cwd, "supabase", "migrations");
788
+ const preparedFiles = [];
789
+ for (const filename of files) {
790
+ const sourcePath = path.join(storageMigrationsRoot, filename);
791
+ const targetPath = path.join(activeMigrationsRoot, filename);
792
+ const sourceSql = fs.readFileSync(sourcePath, "utf8");
793
+ if (fs.existsSync(targetPath)) {
794
+ const existingSql = fs.readFileSync(targetPath, "utf8");
795
+ if (existingSql !== sourceSql) {
796
+ fail("STORAGE_MIGRATION_CONFLICT", `Active migration ${filename} does not match supabase/storage-migrations/${filename}.`);
797
+ }
798
+ }
799
+ else {
800
+ fs.copyFileSync(sourcePath, targetPath);
801
+ }
802
+ preparedFiles.push(filename);
803
+ }
804
+ return preparedFiles;
805
+ }
719
806
  async function storageRequest(params) {
720
807
  const response = await fetch(`${params.supabaseUrl}${params.endpoint}`, {
721
808
  method: params.method,
@@ -842,9 +929,6 @@ async function ensureStorageBucket(supabaseUrl, serviceRoleKey, bucketId, isPubl
842
929
  verified: true,
843
930
  };
844
931
  }
845
- function buildStorageHealthcheckObjectPath(runId) {
846
- return `${STORAGE_HEALTHCHECK_PREFIX}/${runId}/upload.txt`;
847
- }
848
932
  async function checkSupabaseContext() {
849
933
  const { values, envLocalPath } = loadLocalEnv();
850
934
  const missingKeys = [];
@@ -880,10 +964,11 @@ async function setupSupabaseStorage(flags) {
880
964
  const serviceRoleKey = requireServiceRoleKey(values);
881
965
  const dependencyManager = detectDependencyManager(cwd, flags);
882
966
  const supabaseRunner = getSupabaseRunner(dependencyManager);
883
- const mimeCategories = resolveStorageMimeCategories(flags);
967
+ const mimeCategories = resolveStorageMimeCategories(cwd);
884
968
  const allowedMimeTypes = expandStorageMimeCategories(mimeCategories);
885
969
  const migrations = discoverStorageMigrationFiles(cwd);
886
- runLinkedSupabaseCommand(`${supabaseRunner} db push --linked`, cwd, "STORAGE_POLICY_MIGRATION_APPLY_FAILED", "Failed to apply storage migrations to the linked Supabase project.");
970
+ const preparedMigrationFiles = materializeStorageMigrationFiles(cwd, migrations.files);
971
+ const migrationPush = runStorageMigrationPush(`${supabaseRunner} db push --linked`, cwd);
887
972
  const publicBucket = await ensureStorageBucket(supabaseUrl, serviceRoleKey, STORAGE_PUBLIC_BUCKET, true, allowedMimeTypes);
888
973
  const privateBucket = await ensureStorageBucket(supabaseUrl, serviceRoleKey, STORAGE_PRIVATE_BUCKET, false, allowedMimeTypes);
889
974
  mergeEnvFile(envLocalPath, {
@@ -902,55 +987,393 @@ async function setupSupabaseStorage(flags) {
902
987
  fileSizeLimit: STORAGE_REQUIRED_FILE_SIZE_LIMIT,
903
988
  migrationFiles: migrations.files,
904
989
  policyFiles: migrations.policyFiles,
990
+ preparedMigrationFiles,
905
991
  policyMigrationsDiscovered: true,
906
992
  policyMigrationsApplied: true,
907
- policiesVerified: true,
908
- policyVerificationMethod: "linked_db_push",
993
+ migrationPushIncludeAll: migrationPush.includeAll,
909
994
  envWritten: ["SUPABASE_PUBLIC_BUCKET", "SUPABASE_PRIVATE_BUCKET"],
910
995
  });
911
996
  }
912
- async function smokeTestSupabase() {
997
+ function normalizePaymentsMode(value) {
998
+ return value === "production" ? "production" : "test";
999
+ }
1000
+ function isStripePublishableKey(value) {
1001
+ return isNonEmptyString(value) && /^pk_(test|live)_[A-Za-z0-9_]+$/.test(value.trim());
1002
+ }
1003
+ function isStripeSecretKey(value) {
1004
+ return isNonEmptyString(value) && /^sk_(test|live)_[A-Za-z0-9_]+$/.test(value.trim());
1005
+ }
1006
+ function isStripeWebhookSecret(value) {
1007
+ return isNonEmptyString(value) && /^whsec_[A-Za-z0-9]+$/.test(value.trim());
1008
+ }
1009
+ function isNumericStoreId(value) {
1010
+ return isNonEmptyString(value) && /^\d+$/.test(value.trim());
1011
+ }
1012
+ function requireWebhookUrl(value, expectedPath, provider) {
1013
+ const trimmed = value.trim();
1014
+ if (!trimmed) {
1015
+ fail("INVALID_WEBHOOK_URL", `${provider} webhook URL is required.`);
1016
+ }
1017
+ try {
1018
+ const parsed = new URL(trimmed);
1019
+ if (parsed.protocol !== "https:" || parsed.pathname !== expectedPath) {
1020
+ fail("INVALID_WEBHOOK_URL", `${provider} webhook URL must be an https URL ending in ${expectedPath}.`);
1021
+ }
1022
+ }
1023
+ catch {
1024
+ fail("INVALID_WEBHOOK_URL", `${provider} webhook URL must be a valid https URL ending in ${expectedPath}.`);
1025
+ }
1026
+ return trimmed;
1027
+ }
1028
+ function formEncode(value, prefix = "") {
1029
+ const entries = [];
1030
+ if (Array.isArray(value)) {
1031
+ value.forEach((item, index) => {
1032
+ entries.push(...formEncode(item, `${prefix}[${index}]`));
1033
+ });
1034
+ return entries;
1035
+ }
1036
+ if (value && typeof value === "object") {
1037
+ for (const [key, nested] of Object.entries(value)) {
1038
+ entries.push(...formEncode(nested, prefix ? `${prefix}[${key}]` : key));
1039
+ }
1040
+ return entries;
1041
+ }
1042
+ if (value === null || value === undefined)
1043
+ return entries;
1044
+ entries.push([prefix, String(value)]);
1045
+ return entries;
1046
+ }
1047
+ async function stripeRequest(params) {
1048
+ const url = new URL(`https://api.stripe.com${params.path}`);
1049
+ if (params.query) {
1050
+ for (const [key, value] of formEncode(params.query)) {
1051
+ url.searchParams.append(key, value);
1052
+ }
1053
+ }
1054
+ const headers = {
1055
+ Authorization: `Bearer ${params.secretKey}`,
1056
+ };
1057
+ let body;
1058
+ if (params.body) {
1059
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
1060
+ body = new URLSearchParams(formEncode(params.body));
1061
+ }
1062
+ const response = await fetch(url.toString(), {
1063
+ method: params.method,
1064
+ headers,
1065
+ body,
1066
+ });
1067
+ const json = await response.json().catch(() => null);
1068
+ if (!response.ok) {
1069
+ const message = extractErrorMessage(json?.error) || extractErrorMessage(json) || `Stripe returned ${response.status}`;
1070
+ fail("STRIPE_API_ERROR", message, 1, { status: response.status });
1071
+ }
1072
+ return json;
1073
+ }
1074
+ async function lemonRequest(params) {
1075
+ const url = new URL(`https://api.lemonsqueezy.com/v1${params.path}`);
1076
+ if (params.query) {
1077
+ for (const [key, value] of Object.entries(params.query)) {
1078
+ url.searchParams.set(key, value);
1079
+ }
1080
+ }
1081
+ const response = await fetch(url.toString(), {
1082
+ method: params.method,
1083
+ headers: {
1084
+ Accept: "application/vnd.api+json",
1085
+ Authorization: `Bearer ${params.apiKey}`,
1086
+ ...(params.body ? { "Content-Type": "application/vnd.api+json" } : {}),
1087
+ },
1088
+ body: params.body ? JSON.stringify(params.body) : undefined,
1089
+ });
1090
+ const json = await response.json().catch(() => null);
1091
+ if (!response.ok) {
1092
+ const detail = Array.isArray(json?.errors)
1093
+ ? (json.errors[0]?.detail)
1094
+ : null;
1095
+ const message = isNonEmptyString(detail) ? detail : (extractErrorMessage(json) || `Lemon Squeezy returned ${response.status}`);
1096
+ fail("LEMONSQUEEZY_API_ERROR", message, 1, { status: response.status });
1097
+ }
1098
+ return json;
1099
+ }
1100
+ function checkStripeKeys(flags) {
913
1101
  const { values } = loadLocalEnv();
914
- const supabaseUrl = requireSupabaseUrl(values);
915
- const serviceRoleKey = requireServiceRoleKey(values);
916
- const runId = `run_${Date.now()}`;
917
- const objectPath = buildStorageHealthcheckObjectPath(runId);
918
- const prefix = `${STORAGE_HEALTHCHECK_PREFIX}/${runId}/`;
919
- await storageRequest({
920
- supabaseUrl,
921
- serviceRoleKey,
1102
+ const mode = normalizePaymentsMode(readStringFlag(flags, "mode"));
1103
+ const publishableKey = values.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
1104
+ const secretKey = values.STRIPE_SECRET_KEY;
1105
+ const expectedPublishablePrefix = mode === "test" ? "pk_test_" : "pk_live_";
1106
+ const expectedSecretPrefix = mode === "test" ? "sk_test_" : "sk_live_";
1107
+ const missingKeys = [];
1108
+ const invalidKeys = [];
1109
+ const mistakes = [];
1110
+ if (!isNonEmptyString(publishableKey)) {
1111
+ missingKeys.push("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY");
1112
+ }
1113
+ else if (!isStripePublishableKey(publishableKey)) {
1114
+ invalidKeys.push("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY");
1115
+ }
1116
+ else if (!publishableKey.startsWith(expectedPublishablePrefix)) {
1117
+ mistakes.push(`NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY does not match ${mode} mode.`);
1118
+ }
1119
+ if (!isNonEmptyString(secretKey)) {
1120
+ missingKeys.push("STRIPE_SECRET_KEY");
1121
+ }
1122
+ else if (!isStripeSecretKey(secretKey)) {
1123
+ invalidKeys.push("STRIPE_SECRET_KEY");
1124
+ }
1125
+ else if (!secretKey.startsWith(expectedSecretPrefix)) {
1126
+ mistakes.push(`STRIPE_SECRET_KEY does not match ${mode} mode.`);
1127
+ }
1128
+ if (isNonEmptyString(publishableKey) && isNonEmptyString(secretKey)) {
1129
+ if (publishableKey.startsWith("pk_live_") && secretKey.startsWith("sk_test_")) {
1130
+ mistakes.push("Publishable and secret keys are from different Stripe modes (pk_live with sk_test).");
1131
+ }
1132
+ if (publishableKey.startsWith("pk_test_") && secretKey.startsWith("sk_live_")) {
1133
+ mistakes.push("Publishable and secret keys are from different Stripe modes (pk_test with sk_live).");
1134
+ }
1135
+ }
1136
+ if (missingKeys.length > 0 || invalidKeys.length > 0 || mistakes.length > 0) {
1137
+ fail("INVALID_PAYMENTS_ENV", "Stripe keys are missing, malformed, or inconsistent with the selected mode.", 1, { missingKeys, invalidKeys, mistakes });
1138
+ }
1139
+ printJson({
1140
+ ok: true,
1141
+ command: "payments check-stripe-keys",
1142
+ mode,
1143
+ verified: true,
1144
+ presentKeys: ["NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "STRIPE_SECRET_KEY"],
1145
+ checks: ["presence", "format", "mode_consistency"],
1146
+ });
1147
+ }
1148
+ function checkLemonKeys(flags) {
1149
+ const { values } = loadLocalEnv();
1150
+ const mode = normalizePaymentsMode(readStringFlag(flags, "mode"));
1151
+ const apiKey = values.LEMONSQUEEZY_API_KEY;
1152
+ const storeId = values.LEMONSQUEEZY_STORE_ID;
1153
+ const missingKeys = [];
1154
+ const invalidKeys = [];
1155
+ if (!isNonEmptyString(apiKey)) {
1156
+ missingKeys.push("LEMONSQUEEZY_API_KEY");
1157
+ }
1158
+ if (!isNonEmptyString(storeId)) {
1159
+ missingKeys.push("LEMONSQUEEZY_STORE_ID");
1160
+ }
1161
+ else if (!isNumericStoreId(storeId)) {
1162
+ invalidKeys.push("LEMONSQUEEZY_STORE_ID");
1163
+ }
1164
+ if (missingKeys.length > 0 || invalidKeys.length > 0) {
1165
+ fail("INVALID_PAYMENTS_ENV", "Lemon Squeezy API key or store ID is missing or malformed.", 1, { missingKeys, invalidKeys });
1166
+ }
1167
+ printJson({
1168
+ ok: true,
1169
+ command: "payments check-lemonsqueezy-keys",
1170
+ mode,
1171
+ verified: true,
1172
+ presentKeys: ["LEMONSQUEEZY_API_KEY", "LEMONSQUEEZY_STORE_ID"],
1173
+ checks: ["presence", "store_id_format"],
1174
+ });
1175
+ }
1176
+ async function createStripeWebhook(flags) {
1177
+ const { envLocalPath, values } = loadLocalEnv();
1178
+ const mode = normalizePaymentsMode(readStringFlag(flags, "mode"));
1179
+ const webhookUrl = requireWebhookUrl(readStringFlag(flags, "webhook-url"), "/api/webhooks/stripe", "Stripe");
1180
+ const secretKey = values.STRIPE_SECRET_KEY;
1181
+ if (!isStripeSecretKey(secretKey)) {
1182
+ fail("INVALID_PAYMENTS_ENV", "STRIPE_SECRET_KEY is missing or malformed in .env.local.");
1183
+ }
1184
+ const listed = await stripeRequest({
1185
+ secretKey,
1186
+ method: "GET",
1187
+ path: "/v1/webhook_endpoints",
1188
+ query: { limit: 100 },
1189
+ });
1190
+ const existing = Array.isArray(listed.data)
1191
+ ? listed.data.find((endpoint) => {
1192
+ const url = typeof endpoint.url === "string" ? endpoint.url : "";
1193
+ const events = Array.isArray(endpoint.enabled_events) ? endpoint.enabled_events : [];
1194
+ return url === webhookUrl && JSON.stringify([...events].sort()) === JSON.stringify([...STRIPE_EVENTS].sort());
1195
+ })
1196
+ : null;
1197
+ if (existing && isStripeWebhookSecret(values.STRIPE_WEBHOOK_SECRET)) {
1198
+ printJson({
1199
+ ok: true,
1200
+ command: "payments create-stripe-webhook",
1201
+ mode,
1202
+ created: false,
1203
+ reused: true,
1204
+ endpointId: typeof existing.id === "string" ? existing.id : null,
1205
+ webhookUrl,
1206
+ enabledEvents: STRIPE_EVENTS,
1207
+ envWritten: [],
1208
+ });
1209
+ return;
1210
+ }
1211
+ const created = await stripeRequest({
1212
+ secretKey,
922
1213
  method: "POST",
923
- endpoint: `/storage/v1/object/${STORAGE_PUBLIC_BUCKET}/${objectPath}`,
924
- rawBody: "healthcheck probe",
925
- contentType: "text/plain",
1214
+ path: "/v1/webhook_endpoints",
1215
+ body: {
1216
+ url: webhookUrl,
1217
+ enabled_events: STRIPE_EVENTS,
1218
+ description: "VibeCodeMax payments webhook",
1219
+ },
926
1220
  });
927
- const listResult = await storageRequest({
928
- supabaseUrl,
929
- serviceRoleKey,
1221
+ if (!isStripeWebhookSecret(created.secret)) {
1222
+ fail("STRIPE_WEBHOOK_CREATE_FAILED", "Stripe did not return a webhook signing secret.");
1223
+ }
1224
+ mergeEnvFile(envLocalPath, {
1225
+ STRIPE_WEBHOOK_SECRET: created.secret.trim(),
1226
+ });
1227
+ printJson({
1228
+ ok: true,
1229
+ command: "payments create-stripe-webhook",
1230
+ mode,
1231
+ created: true,
1232
+ reused: false,
1233
+ endpointId: typeof created.id === "string" ? created.id : null,
1234
+ webhookUrl: typeof created.url === "string" ? created.url : webhookUrl,
1235
+ enabledEvents: STRIPE_EVENTS,
1236
+ envWritten: ["STRIPE_WEBHOOK_SECRET"],
1237
+ });
1238
+ }
1239
+ function checkStripeWebhookSecret(flags) {
1240
+ const { values } = loadLocalEnv();
1241
+ const mode = normalizePaymentsMode(readStringFlag(flags, "mode"));
1242
+ const webhookUrl = requireWebhookUrl(readStringFlag(flags, "webhook-url"), "/api/webhooks/stripe", "Stripe");
1243
+ const missingKeys = [];
1244
+ const invalidKeys = [];
1245
+ if (!isNonEmptyString(values.STRIPE_WEBHOOK_SECRET)) {
1246
+ missingKeys.push("STRIPE_WEBHOOK_SECRET");
1247
+ }
1248
+ else if (!isStripeWebhookSecret(values.STRIPE_WEBHOOK_SECRET)) {
1249
+ invalidKeys.push("STRIPE_WEBHOOK_SECRET");
1250
+ }
1251
+ if (missingKeys.length > 0 || invalidKeys.length > 0) {
1252
+ fail("INVALID_PAYMENTS_ENV", "Stripe webhook secret is missing or malformed in .env.local.", 1, { missingKeys, invalidKeys });
1253
+ }
1254
+ printJson({
1255
+ ok: true,
1256
+ command: "payments check-stripe-webhook-secret",
1257
+ mode,
1258
+ verified: true,
1259
+ webhookUrl,
1260
+ presentKeys: ["STRIPE_WEBHOOK_SECRET"],
1261
+ checks: ["presence", "format", "webhook_url_format"],
1262
+ });
1263
+ }
1264
+ async function createLemonWebhook(flags) {
1265
+ const { envLocalPath, values } = loadLocalEnv();
1266
+ const mode = normalizePaymentsMode(readStringFlag(flags, "mode"));
1267
+ const webhookUrl = requireWebhookUrl(readStringFlag(flags, "webhook-url"), "/api/webhooks/lemonsqueezy", "Lemon Squeezy");
1268
+ const apiKey = values.LEMONSQUEEZY_API_KEY;
1269
+ const storeId = values.LEMONSQUEEZY_STORE_ID;
1270
+ if (!isNonEmptyString(apiKey)) {
1271
+ fail("INVALID_PAYMENTS_ENV", "LEMONSQUEEZY_API_KEY is missing in .env.local.");
1272
+ }
1273
+ if (!isNumericStoreId(storeId)) {
1274
+ fail("INVALID_PAYMENTS_ENV", "LEMONSQUEEZY_STORE_ID is missing or malformed in .env.local.");
1275
+ }
1276
+ const listed = await lemonRequest({
1277
+ apiKey,
1278
+ method: "GET",
1279
+ path: "/webhooks",
1280
+ query: {
1281
+ "filter[store_id]": storeId.trim(),
1282
+ "page[size]": "100",
1283
+ },
1284
+ });
1285
+ const existing = Array.isArray(listed.data)
1286
+ ? listed.data.find((endpoint) => {
1287
+ const attrs = endpoint.attributes && typeof endpoint.attributes === "object"
1288
+ ? endpoint.attributes
1289
+ : {};
1290
+ const relationships = endpoint.relationships && typeof endpoint.relationships === "object"
1291
+ ? endpoint.relationships
1292
+ : {};
1293
+ const endpointStoreId = typeof relationships.store?.data?.id === "string"
1294
+ ? (relationships.store.data.id)
1295
+ : "";
1296
+ const events = Array.isArray(attrs.events) ? attrs.events : [];
1297
+ return String(attrs.url || "") === webhookUrl
1298
+ && endpointStoreId === storeId.trim()
1299
+ && JSON.stringify([...events].sort()) === JSON.stringify([...LEMON_EVENTS].sort());
1300
+ })
1301
+ : null;
1302
+ if (existing && isNonEmptyString(values.LEMONSQUEEZY_WEBHOOK_SECRET)) {
1303
+ printJson({
1304
+ ok: true,
1305
+ command: "payments create-lemonsqueezy-webhook",
1306
+ mode,
1307
+ created: false,
1308
+ reused: true,
1309
+ endpointId: typeof existing.id === "string" ? existing.id : null,
1310
+ webhookUrl,
1311
+ storeId: storeId.trim(),
1312
+ enabledEvents: LEMON_EVENTS,
1313
+ envWritten: [],
1314
+ });
1315
+ return;
1316
+ }
1317
+ const generatedSecret = randomBytes(24).toString("hex");
1318
+ const created = await lemonRequest({
1319
+ apiKey,
930
1320
  method: "POST",
931
- endpoint: `/storage/v1/object/list/${STORAGE_PUBLIC_BUCKET}`,
932
- body: { prefix },
933
- contentType: "application/json",
1321
+ path: "/webhooks",
1322
+ body: {
1323
+ data: {
1324
+ type: "webhooks",
1325
+ attributes: {
1326
+ url: webhookUrl,
1327
+ events: LEMON_EVENTS,
1328
+ secret: generatedSecret,
1329
+ test_mode: mode === "test",
1330
+ },
1331
+ relationships: {
1332
+ store: {
1333
+ data: {
1334
+ type: "stores",
1335
+ id: storeId.trim(),
1336
+ },
1337
+ },
1338
+ },
1339
+ },
1340
+ },
934
1341
  });
935
- await storageRequest({
936
- supabaseUrl,
937
- serviceRoleKey,
938
- method: "DELETE",
939
- endpoint: `/storage/v1/object/${STORAGE_PUBLIC_BUCKET}`,
940
- body: { prefixes: [objectPath] },
941
- contentType: "application/json",
1342
+ mergeEnvFile(envLocalPath, {
1343
+ LEMONSQUEEZY_WEBHOOK_SECRET: generatedSecret,
942
1344
  });
943
1345
  printJson({
944
1346
  ok: true,
945
- command: "storage smoke-test-supabase",
946
- runId,
947
- bucketId: STORAGE_PUBLIC_BUCKET,
948
- objectPath,
949
- prefix,
950
- upload: true,
951
- list: true,
952
- delete: true,
953
- listedItems: Array.isArray(listResult) ? listResult.length : 0,
1347
+ command: "payments create-lemonsqueezy-webhook",
1348
+ mode,
1349
+ created: true,
1350
+ reused: false,
1351
+ endpointId: typeof created.data?.id === "string" ? created.data.id : null,
1352
+ webhookUrl: typeof created.data?.attributes?.url === "string" ? created.data.attributes.url : webhookUrl,
1353
+ storeId: storeId.trim(),
1354
+ enabledEvents: LEMON_EVENTS,
1355
+ envWritten: ["LEMONSQUEEZY_WEBHOOK_SECRET"],
1356
+ });
1357
+ }
1358
+ function checkLemonWebhookSecret(flags) {
1359
+ const { values } = loadLocalEnv();
1360
+ const mode = normalizePaymentsMode(readStringFlag(flags, "mode"));
1361
+ const webhookUrl = requireWebhookUrl(readStringFlag(flags, "webhook-url"), "/api/webhooks/lemonsqueezy", "Lemon Squeezy");
1362
+ const missingKeys = [];
1363
+ if (!isNonEmptyString(values.LEMONSQUEEZY_WEBHOOK_SECRET)) {
1364
+ missingKeys.push("LEMONSQUEEZY_WEBHOOK_SECRET");
1365
+ }
1366
+ if (missingKeys.length > 0) {
1367
+ fail("INVALID_PAYMENTS_ENV", "Lemon Squeezy webhook secret is missing in .env.local.", 1, { missingKeys });
1368
+ }
1369
+ printJson({
1370
+ ok: true,
1371
+ command: "payments check-lemonsqueezy-webhook-secret",
1372
+ mode,
1373
+ verified: true,
1374
+ webhookUrl,
1375
+ presentKeys: ["LEMONSQUEEZY_WEBHOOK_SECRET"],
1376
+ checks: ["presence", "webhook_url_format"],
954
1377
  });
955
1378
  }
956
1379
  async function main() {
@@ -963,10 +1386,14 @@ async function main() {
963
1386
  "admin ensure-admin",
964
1387
  "storage check-supabase-context",
965
1388
  "storage setup-supabase",
966
- "storage smoke-test-supabase",
967
1389
  "storage check-s3-context",
968
1390
  "storage setup-s3",
969
- "storage smoke-test-s3",
1391
+ "payments check-stripe-keys",
1392
+ "payments create-stripe-webhook",
1393
+ "payments check-stripe-webhook-secret",
1394
+ "payments check-lemonsqueezy-keys",
1395
+ "payments create-lemonsqueezy-webhook",
1396
+ "payments check-lemonsqueezy-webhook-secret",
970
1397
  "configure-site-redirects",
971
1398
  "configure-email-password",
972
1399
  "enable-google-provider",
@@ -989,14 +1416,22 @@ async function main() {
989
1416
  return checkSupabaseContext();
990
1417
  if (command === "storage" && subcommand === "setup-supabase")
991
1418
  return setupSupabaseStorage(flags);
992
- if (command === "storage" && subcommand === "smoke-test-supabase")
993
- return smokeTestSupabase();
994
1419
  if (command === "storage" && subcommand === "check-s3-context")
995
1420
  return checkS3Context(flags);
996
1421
  if (command === "storage" && subcommand === "setup-s3")
997
1422
  return setupS3Storage(flags);
998
- if (command === "storage" && subcommand === "smoke-test-s3")
999
- return smokeTestS3(flags);
1423
+ if (command === "payments" && subcommand === "check-stripe-keys")
1424
+ return checkStripeKeys(flags);
1425
+ if (command === "payments" && subcommand === "create-stripe-webhook")
1426
+ return createStripeWebhook(flags);
1427
+ if (command === "payments" && subcommand === "check-stripe-webhook-secret")
1428
+ return checkStripeWebhookSecret(flags);
1429
+ if (command === "payments" && subcommand === "check-lemonsqueezy-keys")
1430
+ return checkLemonKeys(flags);
1431
+ if (command === "payments" && subcommand === "create-lemonsqueezy-webhook")
1432
+ return createLemonWebhook(flags);
1433
+ if (command === "payments" && subcommand === "check-lemonsqueezy-webhook-secret")
1434
+ return checkLemonWebhookSecret(flags);
1000
1435
  if (command === "configure-site-redirects")
1001
1436
  return configureSiteRedirects(flags);
1002
1437
  if (command === "configure-email-password")
package/dist/storageS3.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as crypto from "node:crypto";
4
- import { S3Client, HeadBucketCommand, CreateBucketCommand, PutPublicAccessBlockCommand, PutBucketEncryptionCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketCorsCommand, GetBucketLocationCommand, GetPublicAccessBlockCommand, GetBucketEncryptionCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketCorsCommand, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand, GetObjectCommand, } from "@aws-sdk/client-s3";
4
+ import { S3Client, HeadBucketCommand, CreateBucketCommand, PutPublicAccessBlockCommand, PutBucketEncryptionCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketCorsCommand, GetBucketLocationCommand, GetPublicAccessBlockCommand, GetBucketEncryptionCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketCorsCommand, } from "@aws-sdk/client-s3";
5
5
  import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
6
6
  const SETUP_CONFIG_PATH = path.join(".vibecodemax", "setup-config.json");
7
7
  const DEFAULT_BUCKETS = {
@@ -10,7 +10,6 @@ const DEFAULT_BUCKETS = {
10
10
  };
11
11
  const DEFAULT_PROJECT_SLUG = "vibecodemax";
12
12
  const DEFAULT_REGION = "us-east-1";
13
- const HEALTHCHECK_PREFIX = "_vibecodemax/healthcheck/default";
14
13
  const PUBLIC_ACCESS_BLOCK_PRIVATE = {
15
14
  BlockPublicAcls: true,
16
15
  IgnorePublicAcls: true,
@@ -482,55 +481,6 @@ async function verifyTwoBuckets(region, credentials, buckets) {
482
481
  });
483
482
  }
484
483
  }
485
- function buildObjectUrl(bucket, region, key) {
486
- const encoded = key.split("/").map((segment) => encodeURIComponent(segment)).join("/");
487
- if (region === "us-east-1")
488
- return `https://${bucket}.s3.amazonaws.com/${encoded}`;
489
- return `https://${bucket}.s3.${region}.amazonaws.com/${encoded}`;
490
- }
491
- async function smokeTestBuckets(region, credentials, buckets) {
492
- const { s3Client } = createAwsClients(region, credentials);
493
- const runId = `run_${Date.now()}`;
494
- const prefix = `${HEALTHCHECK_PREFIX}/${runId}`;
495
- const publicKey = `${prefix}/public-probe.txt`;
496
- const privateKey = `${prefix}/private-probe.txt`;
497
- await s3Client.send(new PutObjectCommand({ Bucket: buckets.public, Key: publicKey, Body: "public healthcheck probe", ContentType: "text/plain" }));
498
- await s3Client.send(new PutObjectCommand({ Bucket: buckets.private, Key: privateKey, Body: "private healthcheck probe", ContentType: "text/plain" }));
499
- const publicList = await s3Client.send(new ListObjectsV2Command({ Bucket: buckets.public, Prefix: prefix }));
500
- const privateList = await s3Client.send(new ListObjectsV2Command({ Bucket: buckets.private, Prefix: prefix }));
501
- const listPassed = Number(publicList.KeyCount || 0) > 0 && Number(privateList.KeyCount || 0) > 0;
502
- if (!listPassed) {
503
- fail("AWS_SMOKE_TEST_FAILED", "AWS S3 smoke-test list operation did not return the uploaded healthcheck objects.", 1, { prefix });
504
- }
505
- const publicUrl = buildObjectUrl(buckets.public, region, publicKey);
506
- const privateUrl = buildObjectUrl(buckets.private, region, privateKey);
507
- const publicReadResponse = await fetch(publicUrl);
508
- const privateReadResponse = await fetch(privateUrl);
509
- const publicRead = publicReadResponse.ok;
510
- const privateReadBlocked = !privateReadResponse.ok;
511
- if (!publicRead || !privateReadBlocked) {
512
- fail("AWS_SMOKE_TEST_FAILED", "AWS S3 smoke-test public/private read checks failed.", 1, {
513
- publicReadStatus: publicReadResponse.status,
514
- privateReadStatus: privateReadResponse.status,
515
- });
516
- }
517
- await s3Client.send(new GetObjectCommand({ Bucket: buckets.private, Key: privateKey }));
518
- await s3Client.send(new DeleteObjectCommand({ Bucket: buckets.public, Key: publicKey }));
519
- await s3Client.send(new DeleteObjectCommand({ Bucket: buckets.private, Key: privateKey }));
520
- return {
521
- ok: true,
522
- command: "storage smoke-test-s3",
523
- runId,
524
- prefix,
525
- buckets,
526
- upload: true,
527
- list: true,
528
- publicRead: true,
529
- privateRead: true,
530
- delete: true,
531
- publicObjectUrl: publicUrl,
532
- };
533
- }
534
484
  export async function checkS3Context(flags) {
535
485
  const context = readAwsContext(flags);
536
486
  if (context.missingKeys.length > 0) {
@@ -603,28 +553,6 @@ export async function setupS3Storage(flags) {
603
553
  envWritten: ["AWS_REGION", "AWS_S3_PUBLIC_BUCKET", "AWS_S3_PRIVATE_BUCKET"],
604
554
  });
605
555
  }
606
- export async function smokeTestS3(flags) {
607
- const context = readAwsContext(flags);
608
- const publicBucket = context.localEnv.values.AWS_S3_PUBLIC_BUCKET || "";
609
- const privateBucket = context.localEnv.values.AWS_S3_PRIVATE_BUCKET || "";
610
- if (context.missingKeys.length > 0) {
611
- fail("MISSING_ENV", `Missing required AWS values: ${context.missingKeys.join(", ")}. Add them to .env.bootstrap.local.`);
612
- }
613
- if (!isNonEmptyString(publicBucket) || !isNonEmptyString(privateBucket)) {
614
- fail("MISSING_ENV", "AWS_S3_PUBLIC_BUCKET or AWS_S3_PRIVATE_BUCKET is missing. Run storage setup first so .env.local is populated.");
615
- }
616
- const credentials = {
617
- accessKeyId: context.accessKeyId,
618
- secretAccessKey: context.secretAccessKey,
619
- ...(context.sessionToken ? { sessionToken: context.sessionToken } : {}),
620
- };
621
- await validateCredentials(context.region, credentials);
622
- const result = await smokeTestBuckets(context.region, credentials, {
623
- public: publicBucket.trim(),
624
- private: privateBucket.trim(),
625
- });
626
- printJson(result);
627
- }
628
556
  export function __testOnlyDeterministicBuckets(projectSlug, accountId) {
629
557
  return deterministicBuckets({ projectSlug, accountId });
630
558
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodemax/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "VibeCodeMax CLI — local provider setup for bootstrap and project configuration",
5
5
  "type": "module",
6
6
  "bin": {