kasy-cli 1.16.0 → 1.17.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.
Files changed (27) hide show
  1. package/bin/kasy.js +1 -0
  2. package/lib/commands/add.js +45 -12
  3. package/lib/commands/doctor.js +37 -6
  4. package/lib/commands/new.js +34 -8
  5. package/lib/commands/remove.js +14 -3
  6. package/lib/commands/run.js +207 -5
  7. package/lib/scaffold/CHANGELOG.json +9 -0
  8. package/lib/scaffold/backends/api/patch/README.md +3 -2
  9. package/lib/scaffold/backends/supabase/patch/README.md +3 -2
  10. package/lib/scaffold/shared/generator-utils.js +52 -8
  11. package/lib/scaffold/shared/post-build.js +105 -31
  12. package/lib/scaffold/shared/template-strings.js +6 -0
  13. package/lib/utils/i18n/messages-en.js +27 -2
  14. package/lib/utils/i18n/messages-es.js +27 -2
  15. package/lib/utils/i18n/messages-pt.js +27 -2
  16. package/package.json +1 -1
  17. package/templates/firebase/README.en.md +17 -7
  18. package/templates/firebase/README.es.md +17 -7
  19. package/templates/firebase/README.md +17 -7
  20. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +15 -0
  21. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +3 -19
  22. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidgetReceiver.kt +37 -0
  23. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/OpenAppAction.kt +26 -0
  24. package/templates/firebase/docs/revenuecat-setup.es.md +28 -8
  25. package/templates/firebase/docs/revenuecat-setup.pt.md +28 -8
  26. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +39 -41
  27. package/templates/firebase/lib/router.dart +15 -1
package/bin/kasy.js CHANGED
@@ -346,6 +346,7 @@ function buildProgram(language) {
346
346
  .option('-d, --device <id>', 'Run on specific device ID')
347
347
  .option('--prod', 'Use production dart-defines (from launch.json)')
348
348
  .option('--no-defines', 'Skip dart-defines from launch.json')
349
+ .option('--rc <mode>', 'RevenueCat key mode: auto (default — picks by device), test, or prod')
349
350
  .description(t('cli.command.run.description'))
350
351
  .action(async (directory, options) => {
351
352
  await runRun(directory, { language, ...options });
@@ -24,7 +24,7 @@ function findBaseDisplayName(id) {
24
24
 
25
25
  const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
26
26
  const { applyPatch } = require('../scaffold/engine');
27
- const { writeRouter, writeNoOpAnalyticsApi, writeNoOpTrackingApi, writeNoOpAdminHomeWidgets, writeNoOpFeatureRequestRepository, writeMainDart, addPubspecDep, localizeReleaseDocs } = require('../scaffold/shared/generator-utils');
27
+ const { writeRouter, writeNoOpAnalyticsApi, writeNoOpTrackingApi, writeNoOpAdminHomeWidgets, writeNoOpFeatureRequestRepository, writeMainDart, addPubspecDep, localizeReleaseDocs, resolveDefaultRcKeys } = require('../scaffold/shared/generator-utils');
28
28
  const { toPackageName, buildTokens } = require('../scaffold/backends/firebase/tokens');
29
29
 
30
30
  const execAsync = promisify(exec);
@@ -217,6 +217,14 @@ function applyKitSetupFlag(config, module, answers) {
217
217
  case 'revenuecat':
218
218
  config.subscriptionModule = true;
219
219
  if (answers.revenuecatWeb) config.revenuecatWeb = true;
220
+ // Track which RC keys the user configured so `kasy doctor` can warn
221
+ // about release readiness without re-reading .env. Booleans only — we
222
+ // never persist the key values themselves to kit_setup.json.
223
+ config.revenuecatKeys = {
224
+ test: !!(answers.rcTestKey && answers.rcTestKey.trim()),
225
+ iosProd: !!(answers.rcIosProdKey && answers.rcIosProdKey.trim()),
226
+ androidProd: !!(answers.rcAndroidProdKey && answers.rcAndroidProdKey.trim()),
227
+ };
220
228
  break;
221
229
  case 'onboarding':
222
230
  config.withOnboarding = true;
@@ -263,14 +271,18 @@ const MODULE_META = {
263
271
  pubspecDeps: { 'facebook_app_events': '^0.24.0', 'flutter_facebook_auth': '^7.1.5' },
264
272
  },
265
273
  revenuecat: {
266
- promptKeys: ['rcAndroidKey', 'rcIosKey'],
267
- defineUpdates: (a) => ({
268
- RC_ANDROID_API_KEY: a.rcAndroidKey || 'YOUR_REVENUECAT_ANDROID_KEY',
269
- RC_IOS_API_KEY: a.rcIosKey || 'YOUR_REVENUECAT_IOS_KEY',
270
- }),
274
+ promptKeys: ['rcTestKey', 'rcIosProdKey', 'rcAndroidProdKey'],
275
+ defineUpdates: (a) => {
276
+ const { android, ios } = resolveDefaultRcKeys(a);
277
+ return {
278
+ RC_ANDROID_API_KEY: android,
279
+ RC_IOS_API_KEY: ios,
280
+ };
281
+ },
271
282
  envLines: (a) => [
272
- `RC_ANDROID_API_KEY=${a.rcAndroidKey || 'YOUR_REVENUECAT_ANDROID_KEY'}`,
273
- `RC_IOS_API_KEY=${a.rcIosKey || 'YOUR_REVENUECAT_IOS_KEY'}`,
283
+ `RC_TEST_KEY=${(a.rcTestKey || '').trim()}`,
284
+ `RC_IOS_PROD_KEY=${(a.rcIosProdKey || '').trim()}`,
285
+ `RC_ANDROID_PROD_KEY=${(a.rcAndroidProdKey || '').trim()}`,
274
286
  ],
275
287
  featureFlag: null,
276
288
  pubspecDeps: {},
@@ -337,12 +349,33 @@ const PROMPT_QUESTIONS = {
337
349
  onCancel: cancel,
338
350
  }),
339
351
  // RevenueCat SDK keys ship inside the client binary — not real secrets, plain text input.
340
- rcAndroidKey: (t, cancel) => ui.text({
341
- message: t('add.prompt.rcAndroidKey'),
352
+ // Three optional keys: test_ (covers iOS+Android in simulator), appl_ (iOS prod), goog_ (Android prod).
353
+ // kasy run picks the right one based on the device at launch time.
354
+ rcTestKey: (t, cancel) => ui.text({
355
+ message: t('add.prompt.rcTestKey'),
356
+ validate: (v) => {
357
+ const s = (v || '').trim();
358
+ if (!s) return undefined;
359
+ return /^test_/.test(s) ? undefined : t('new.firebase.q.revenuecat.test.invalid');
360
+ },
361
+ onCancel: cancel,
362
+ }),
363
+ rcIosProdKey: (t, cancel) => ui.text({
364
+ message: t('add.prompt.rcIosProdKey'),
365
+ validate: (v) => {
366
+ const s = (v || '').trim();
367
+ if (!s) return undefined;
368
+ return /^appl_/.test(s) ? undefined : t('new.firebase.q.revenuecat.iosProd.invalid');
369
+ },
342
370
  onCancel: cancel,
343
371
  }),
344
- rcIosKey: (t, cancel) => ui.text({
345
- message: t('add.prompt.rcIosKey'),
372
+ rcAndroidProdKey: (t, cancel) => ui.text({
373
+ message: t('add.prompt.rcAndroidProdKey'),
374
+ validate: (v) => {
375
+ const s = (v || '').trim();
376
+ if (!s) return undefined;
377
+ return /^goog_/.test(s) ? undefined : t('new.firebase.q.revenuecat.androidProd.invalid');
378
+ },
346
379
  onCancel: cancel,
347
380
  }),
348
381
  llmProvider: (t, cancel) => ui.select({
@@ -175,14 +175,45 @@ async function runRevenueCatChecks(projectDir, t, config) {
175
175
  const rc = await validateRevenueCat(projectDir, config);
176
176
  if (rc.skipped) return;
177
177
 
178
- if (!rc.iosEmpty && !rc.androidEmpty) {
179
- ui.log.success(t('doctor.revenuecat.keysOk'));
178
+ if (rc.legacy) {
179
+ // Project predates the test/prod split — show the old summary.
180
+ if (!rc.iosEmpty && !rc.androidEmpty) {
181
+ ui.log.success(t('doctor.revenuecat.keysOk'));
182
+ } else {
183
+ ui.log.warn(t('doctor.revenuecat.keysEmpty'));
184
+ }
185
+ if (rc.bothTest) {
186
+ ui.log.warn(t('doctor.revenuecat.keysTest'));
187
+ }
180
188
  } else {
181
- ui.log.warn(t('doctor.revenuecat.keysEmpty'));
182
- }
189
+ // New 3-key scheme. One line per key with granular status.
190
+ if (rc.testOk) {
191
+ ui.log.success(t('doctor.revenuecat.testKeyOk'));
192
+ } else if (rc.testBadPrefix) {
193
+ ui.log.warn(t('doctor.revenuecat.prefixMismatch', { key: 'RC_TEST_KEY', expected: 'test_' }));
194
+ } else {
195
+ ui.log.warn(t('doctor.revenuecat.testKeyMissing'));
196
+ }
183
197
 
184
- if (rc.bothTest) {
185
- ui.log.warn(t('doctor.revenuecat.keysTest'));
198
+ if (rc.iosProdOk) {
199
+ ui.log.success(t('doctor.revenuecat.iosProdOk'));
200
+ } else if (rc.iosProdBadPrefix) {
201
+ ui.log.warn(t('doctor.revenuecat.prefixMismatch', { key: 'RC_IOS_PROD_KEY', expected: 'appl_' }));
202
+ } else {
203
+ ui.log.message(kleur.dim(`– ${t('doctor.revenuecat.iosProdMissing')}`));
204
+ }
205
+
206
+ if (rc.androidProdOk) {
207
+ ui.log.success(t('doctor.revenuecat.androidProdOk'));
208
+ } else if (rc.androidProdBadPrefix) {
209
+ ui.log.warn(t('doctor.revenuecat.prefixMismatch', { key: 'RC_ANDROID_PROD_KEY', expected: 'goog_' }));
210
+ } else {
211
+ ui.log.message(kleur.dim(`– ${t('doctor.revenuecat.androidProdMissing')}`));
212
+ }
213
+
214
+ if (rc.onlyTest) {
215
+ ui.log.warn(t('doctor.revenuecat.keysTest'));
216
+ }
186
217
  }
187
218
 
188
219
  if (rc.webhookUrl) {
@@ -1192,16 +1192,42 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1192
1192
  const moduleAnswers = {};
1193
1193
 
1194
1194
  if (modules.includes('revenuecat')) {
1195
- moduleAnswers.rcAndroidKey = await ui.text({
1196
- message: tr('new.firebase.q.revenuecat.android'),
1197
- validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.revenuecat.android.required')),
1195
+ // Three keys, all optional, but we require at least one. The kasy run
1196
+ // command picks the right key based on the device:
1197
+ // simulator/emulator RC_TEST_KEY
1198
+ // physical iOS → RC_IOS_PROD_KEY (falls back to test if missing)
1199
+ // physical Android → RC_ANDROID_PROD_KEY (falls back to test if missing)
1200
+ moduleAnswers.rcTestKey = ((await ui.text({
1201
+ message: tr('new.firebase.q.revenuecat.test'),
1202
+ validate: (v) => {
1203
+ const s = (v || '').trim();
1204
+ if (!s) return undefined;
1205
+ return /^test_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.test.invalid');
1206
+ },
1198
1207
  onCancel: cancel,
1199
- });
1200
- moduleAnswers.rcIosKey = await ui.text({
1201
- message: tr('new.firebase.q.revenuecat.ios'),
1202
- validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.revenuecat.ios.required')),
1208
+ })) || '').trim();
1209
+ moduleAnswers.rcIosProdKey = ((await ui.text({
1210
+ message: tr('new.firebase.q.revenuecat.iosProd'),
1211
+ validate: (v) => {
1212
+ const s = (v || '').trim();
1213
+ if (!s) return undefined;
1214
+ return /^appl_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.iosProd.invalid');
1215
+ },
1203
1216
  onCancel: cancel,
1204
- });
1217
+ })) || '').trim();
1218
+ moduleAnswers.rcAndroidProdKey = ((await ui.text({
1219
+ message: tr('new.firebase.q.revenuecat.androidProd'),
1220
+ validate: (v) => {
1221
+ const s = (v || '').trim();
1222
+ if (!s) return undefined;
1223
+ return /^goog_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.androidProd.invalid');
1224
+ },
1225
+ onCancel: cancel,
1226
+ })) || '').trim();
1227
+ if (!moduleAnswers.rcTestKey && !moduleAnswers.rcIosProdKey && !moduleAnswers.rcAndroidProdKey) {
1228
+ // Non-blocking: user can fill .env later. Just warn so they're not surprised.
1229
+ ui.log.warn(tr('new.firebase.q.revenuecat.atLeastOne'));
1230
+ }
1205
1231
  moduleAnswers.defaultPaywall = await ui.select({
1206
1232
  message: tr('new.firebase.q.paywall'),
1207
1233
  initialValue: 'basic',
@@ -49,17 +49,28 @@ const MODULE_DEPS = {
49
49
  const MODULE_DEFINES = {
50
50
  sentry: ['SENTRY_DSN'],
51
51
  analytics: ['MIXPANEL_TOKEN'],
52
- revenuecat: ['RC_ANDROID_API_KEY', 'RC_IOS_API_KEY'],
52
+ revenuecat: ['RC_ANDROID_API_KEY', 'RC_IOS_API_KEY', 'RC_WEB_API_KEY'],
53
53
  llm_chat: ['LLM_CHAT_ENDPOINT'],
54
54
  };
55
55
 
56
56
  /**
57
- * .env.example variable names to remove per module.
57
+ * .env.example variable names to remove per module. We list both the new
58
+ * test/prod split (RC_TEST_KEY / RC_IOS_PROD_KEY / RC_ANDROID_PROD_KEY) and
59
+ * the legacy single-key vars so removal works on any project age.
58
60
  */
59
61
  const MODULE_ENV_KEYS = {
60
62
  sentry: ['SENTRY_DSN'],
61
63
  analytics: ['MIXPANEL_TOKEN'],
62
- revenuecat: ['RC_ANDROID_API_KEY', 'RC_IOS_API_KEY'],
64
+ revenuecat: [
65
+ 'RC_TEST_KEY',
66
+ 'RC_IOS_PROD_KEY',
67
+ 'RC_ANDROID_PROD_KEY',
68
+ 'RC_WEB_API_KEY',
69
+ // Legacy single-key format — only present in projects generated before
70
+ // the test/prod split. Listed here so removal cleans them up too.
71
+ 'RC_ANDROID_API_KEY',
72
+ 'RC_IOS_API_KEY',
73
+ ],
63
74
  };
64
75
 
65
76
  /**
@@ -69,6 +69,174 @@ async function readLaunchArgs(projectDir, useProd) {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * Parse the project `.env` into a plain object.
74
+ * Supports KEY=VALUE lines; ignores comments and blank lines.
75
+ * Quotes around values are stripped. Missing file returns {}.
76
+ */
77
+ async function loadEnvFile(projectDir) {
78
+ const envPath = path.join(projectDir, '.env');
79
+ if (!(await fs.pathExists(envPath))) return {};
80
+ const content = await fs.readFile(envPath, 'utf8');
81
+ const env = {};
82
+ for (const rawLine of content.split('\n')) {
83
+ const line = rawLine.trim();
84
+ if (!line || line.startsWith('#')) continue;
85
+ const eq = line.indexOf('=');
86
+ if (eq === -1) continue;
87
+ const key = line.slice(0, eq).trim();
88
+ let value = line.slice(eq + 1).trim();
89
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
90
+ value = value.slice(1, -1);
91
+ }
92
+ if (key) env[key] = value;
93
+ }
94
+ return env;
95
+ }
96
+
97
+ /**
98
+ * Decide whether the chosen device is a physical phone or a simulator/emulator.
99
+ * Returns one of: 'ios-simulator' | 'ios-device' | 'android-emulator' |
100
+ * 'android-device' | 'web' | 'desktop' | 'unknown'.
101
+ *
102
+ * When the user passes a generic `--ios`/`--android` shortcut we list devices
103
+ * and use the only matching one. With ambiguous matches we return
104
+ * 'ios-unknown' / 'android-unknown' so the caller can pick a safe default
105
+ * (test key) and warn the user.
106
+ */
107
+ function classifyTargetDevice(projectDir, options, pickedDevice) {
108
+ if (options.web) return 'web';
109
+ if (pickedDevice) return classifyDevice(pickedDevice);
110
+
111
+ const devices = listFlutterDevices(projectDir);
112
+
113
+ if (options.device) {
114
+ const match = devices.find((d) => d.id === options.device || d.name === options.device);
115
+ return match ? classifyDevice(match) : 'unknown';
116
+ }
117
+
118
+ if (options.ios) {
119
+ const ios = devices.filter((d) => (d.targetPlatform || '').toLowerCase() === 'ios');
120
+ if (ios.length === 1) return classifyDevice(ios[0]);
121
+ return 'ios-unknown';
122
+ }
123
+ if (options.android) {
124
+ const android = devices.filter((d) => (d.targetPlatform || '').toLowerCase().startsWith('android'));
125
+ if (android.length === 1) return classifyDevice(android[0]);
126
+ return 'android-unknown';
127
+ }
128
+
129
+ // No flag, no picked device: flutter run picks the only device available.
130
+ // Use it to choose the right RC key when there's exactly one mobile device.
131
+ const mobile = devices.filter((d) => {
132
+ const p = (d.targetPlatform || '').toLowerCase();
133
+ return p === 'ios' || p.startsWith('android');
134
+ });
135
+ if (mobile.length === 1) return classifyDevice(mobile[0]);
136
+
137
+ return 'unknown';
138
+ }
139
+
140
+ /**
141
+ * Resolve which RC keys to inject given the .env contents, the classified
142
+ * device, and the requested mode ('auto' | 'test' | 'prod').
143
+ *
144
+ * Returns { android, ios, mode, warnings } where `mode` is 'test' or 'prod'
145
+ * (informational) and `warnings` is an array of human-readable strings.
146
+ *
147
+ * Behavior:
148
+ * - 'test' mode → both keys = RC_TEST_KEY (or empty if missing).
149
+ * - 'prod' mode → ios = RC_IOS_PROD_KEY, android = RC_ANDROID_PROD_KEY.
150
+ * If either is missing we warn and fall back to RC_TEST_KEY for that one.
151
+ * - 'auto' mode → infer from device class:
152
+ * simulator/emulator/unknown → test
153
+ * ios-device → prod for iOS define (fallback test), test for Android
154
+ * android-device → prod for Android define (fallback test), test for iOS
155
+ *
156
+ * Legacy fallback: if .env has no RC_TEST_KEY / RC_*_PROD_KEY but has the
157
+ * old RC_ANDROID_API_KEY / RC_IOS_API_KEY, we honor those as-is so projects
158
+ * generated before the test/prod split keep working without manual migration.
159
+ */
160
+ function resolveRcKeys(env, deviceClass, mode, t) {
161
+ const warnings = [];
162
+ const legacyAndroid = env.RC_ANDROID_API_KEY;
163
+ const legacyIos = env.RC_IOS_API_KEY;
164
+ const test = env.RC_TEST_KEY;
165
+ const iosProd = env.RC_IOS_PROD_KEY;
166
+ const androidProd = env.RC_ANDROID_PROD_KEY;
167
+
168
+ const hasNewKeys = !!(test || iosProd || androidProd);
169
+ if (!hasNewKeys && (legacyAndroid || legacyIos)) {
170
+ return {
171
+ android: legacyAndroid || '',
172
+ ios: legacyIos || '',
173
+ mode: 'legacy',
174
+ warnings,
175
+ };
176
+ }
177
+
178
+ const isSimulator = deviceClass === 'ios-simulator' || deviceClass === 'android-emulator';
179
+ const isIosDevice = deviceClass === 'ios-device';
180
+ const isAndroidDevice = deviceClass === 'android-device';
181
+
182
+ // Resolve effective mode.
183
+ let effectiveMode = mode;
184
+ if (mode === 'auto') {
185
+ effectiveMode = (isIosDevice || isAndroidDevice) ? 'prod' : 'test';
186
+ }
187
+
188
+ // Build android+ios.
189
+ let android;
190
+ let ios;
191
+
192
+ if (effectiveMode === 'test') {
193
+ android = test || '';
194
+ ios = test || '';
195
+ } else {
196
+ // prod mode — pick per platform, fall back to test if prod missing.
197
+ if (isIosDevice) {
198
+ ios = iosProd || test || '';
199
+ android = test || '';
200
+ if (!iosProd) {
201
+ warnings.push(t('run.rc.fallbackToTest', { platform: 'iOS', var: 'IOS_PROD_KEY' }));
202
+ }
203
+ } else if (isAndroidDevice) {
204
+ android = androidProd || test || '';
205
+ ios = test || '';
206
+ if (!androidProd) {
207
+ warnings.push(t('run.rc.fallbackToTest', { platform: 'Android', var: 'ANDROID_PROD_KEY' }));
208
+ }
209
+ } else {
210
+ // Forced --rc=prod with no clear device: try both prod keys, fall back to test.
211
+ ios = iosProd || test || '';
212
+ android = androidProd || test || '';
213
+ if (!iosProd && !androidProd && !test) {
214
+ warnings.push(t('run.rc.forcedProdMissing'));
215
+ }
216
+ }
217
+ }
218
+
219
+ return { android, ios, mode: effectiveMode, warnings };
220
+ }
221
+
222
+ /**
223
+ * Apply `replacements` to the dart-define list, replacing existing values
224
+ * for the same key. Returns a new array; never mutates the input.
225
+ */
226
+ function overrideDartDefines(defines, replacements) {
227
+ const out = [];
228
+ const seen = new Set(Object.keys(replacements));
229
+ for (const def of defines) {
230
+ const m = def.match(/^--dart-define=([^=]+)=/);
231
+ if (m && seen.has(m[1])) continue;
232
+ out.push(def);
233
+ }
234
+ for (const [key, value] of Object.entries(replacements)) {
235
+ out.push(`--dart-define=${key}=${value}`);
236
+ }
237
+ return out;
238
+ }
239
+
72
240
  async function runRun(directory, options = {}) {
73
241
  const t = createTranslator(options.language || detectDefaultLanguage());
74
242
  const projectDir = path.resolve(directory || '.');
@@ -82,6 +250,7 @@ async function runRun(directory, options = {}) {
82
250
  // bails out with "More than one device connected" otherwise.
83
251
  const deviceArgs = [];
84
252
  let resolvedDeviceLabel = null;
253
+ let pickedDevice = null;
85
254
  if (options.web) {
86
255
  deviceArgs.push('-d', 'chrome');
87
256
  } else if (options.ios) {
@@ -94,23 +263,45 @@ async function runRun(directory, options = {}) {
94
263
  const devices = listFlutterDevices(projectDir);
95
264
  if (devices.length > 1) {
96
265
  printCompactHeader(t);
97
- const picked = await pickDevice(devices, t);
98
- if (!picked) {
266
+ pickedDevice = await pickDevice(devices, t);
267
+ if (!pickedDevice) {
99
268
  console.log(kleur.yellow(` ⚠ ${t('run.warn.nothingSelected')}`));
100
269
  return;
101
270
  }
102
- deviceArgs.push('-d', picked.id);
103
- resolvedDeviceLabel = `${picked.name} (${picked.id})`;
271
+ deviceArgs.push('-d', pickedDevice.id);
272
+ resolvedDeviceLabel = `${pickedDevice.name} (${pickedDevice.id})`;
104
273
  }
105
274
  // 0 or 1 device → let flutter handle it; it picks the only one or
106
275
  // prints its own "no devices" message.
107
276
  }
108
277
 
109
278
  // Read dart-defines from .vscode/launch.json (skip if --no-defines)
110
- const dartDefines = options.noDefines
279
+ let dartDefines = options.noDefines
111
280
  ? []
112
281
  : await readLaunchArgs(projectDir, options.prod);
113
282
 
283
+ // Override RC keys based on device. We only touch RC_ANDROID_API_KEY /
284
+ // RC_IOS_API_KEY when the launch.json already declares them — that's the
285
+ // signal the project uses RevenueCat. Web defines (RC_WEB_API_KEY) aren't
286
+ // touched here; they're already a single key.
287
+ const usesRc = dartDefines.some(
288
+ (a) => a.startsWith('--dart-define=RC_ANDROID_API_KEY=') ||
289
+ a.startsWith('--dart-define=RC_IOS_API_KEY='),
290
+ );
291
+ let rcInfo = null;
292
+ if (usesRc && !options.web) {
293
+ const env = await loadEnvFile(projectDir);
294
+ const mode = (options.rc || 'auto').toLowerCase();
295
+ const deviceClass = classifyTargetDevice(projectDir, options, pickedDevice);
296
+ rcInfo = resolveRcKeys(env, deviceClass, mode, t);
297
+ if (rcInfo.mode !== 'legacy') {
298
+ dartDefines = overrideDartDefines(dartDefines, {
299
+ RC_ANDROID_API_KEY: rcInfo.android,
300
+ RC_IOS_API_KEY: rcInfo.ios,
301
+ });
302
+ }
303
+ }
304
+
114
305
  const args = ['run', ...deviceArgs, ...dartDefines];
115
306
 
116
307
  const envDefine = dartDefines.find((a) => a.startsWith('--dart-define=ENV='));
@@ -129,6 +320,17 @@ async function runRun(directory, options = {}) {
129
320
 
130
321
  printCompactHeader(t);
131
322
  console.log(kleur.bold(`${t('run.launching')}${summary}`));
323
+ if (rcInfo && rcInfo.mode !== 'legacy') {
324
+ const mode = (options.rc || 'auto').toLowerCase();
325
+ let label;
326
+ if (mode === 'test') label = t('run.rc.forcedTest');
327
+ else if (mode === 'prod') label = t('run.rc.forcedProd');
328
+ else label = rcInfo.mode === 'prod' ? t('run.rc.usingProd') : t('run.rc.usingTest');
329
+ console.log(kleur.dim(` ✦ ${label}`));
330
+ for (const w of rcInfo.warnings) {
331
+ console.log(kleur.yellow(` ⚠ ${w}`));
332
+ }
333
+ }
132
334
  console.log(kleur.dim(` ✦ ${t('run.updateHint.prefix')} `) + kleur.cyan('kasy update') + kleur.dim(` ${t('run.updateHint.suffix')}\n`));
133
335
 
134
336
  try {
@@ -1,4 +1,13 @@
1
1
  {
2
+ "1.17.0": {
3
+ "modules": {
4
+ "revenuecat": {
5
+ "pt": "kasy run agora detecta se você está rodando em simulador/emulador ou dispositivo físico e injeta a chave certa automaticamente. Três chaves no .env: RC_TEST_KEY (test_, simulador), RC_IOS_PROD_KEY (appl_, iPhone físico), RC_ANDROID_PROD_KEY (goog_, Android físico). Projetos antigos continuam funcionando — pra ganhar a separação, basta adicionar as 3 chaves no .env. Forçar manualmente: kasy run --rc=test ou --rc=prod.",
6
+ "en": "kasy run now detects whether you're on a simulator/emulator or a physical device and injects the right key automatically. Three keys in .env: RC_TEST_KEY (test_, simulator), RC_IOS_PROD_KEY (appl_, physical iPhone), RC_ANDROID_PROD_KEY (goog_, physical Android). Old projects keep working — to get the split, just add the 3 keys to .env. Force manually: kasy run --rc=test or --rc=prod.",
7
+ "es": "kasy run ahora detecta si estás corriendo en simulador/emulador o dispositivo físico e inyecta la clave correcta automáticamente. Tres claves en .env: RC_TEST_KEY (test_, simulador), RC_IOS_PROD_KEY (appl_, iPhone físico), RC_ANDROID_PROD_KEY (goog_, Android físico). Los proyectos antiguos siguen funcionando — para obtener el split, basta con agregar las 3 claves en .env. Forzar manualmente: kasy run --rc=test o --rc=prod."
8
+ }
9
+ }
10
+ },
2
11
  "1.13.0": {
3
12
  "modules": {
4
13
  "onboarding": {
@@ -32,8 +32,9 @@ Ficam no `.vscode/launch.json` como variáveis de ambiente de build (`--dart-def
32
32
  | Variável | Módulo | Como obter |
33
33
  |----------|--------|------------|
34
34
  | `BACKEND_URL` | API REST | URL da sua API |
35
- | `RC_ANDROID_API_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Android |
36
- | `RC_IOS_API_KEY` | RevenueCat | Dashboard RevenueCat → Apps → iOS |
35
+ | `RC_TEST_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Test Store (chave `test_`, vale iOS+Android, usada automaticamente em simulador) |
36
+ | `RC_IOS_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → App Store (chave `appl_`, usada automaticamente em iPhone físico) |
37
+ | `RC_ANDROID_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Google Play (chave `goog_`, usada automaticamente em Android físico) |
37
38
  | `SENTRY_DSN` | Sentry | Dashboard Sentry → Projeto → DSN |
38
39
  | `MIXPANEL_TOKEN` | Mixpanel | Dashboard Mixpanel → Configurações → Token |
39
40
 
@@ -68,8 +68,9 @@ Ficam no `.vscode/launch.json` como variáveis de ambiente de build (`--dart-def
68
68
  |----------|--------|------------|
69
69
  | `BACKEND_URL` | Supabase | Dashboard Supabase → Project Settings → API → Project URL |
70
70
  | `SUPABASE_TOKEN` | Supabase | Dashboard Supabase → Project Settings → API → anon key |
71
- | `RC_ANDROID_API_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Android |
72
- | `RC_IOS_API_KEY` | RevenueCat | Dashboard RevenueCat → Apps → iOS |
71
+ | `RC_TEST_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Test Store (chave `test_`, vale iOS+Android, usada automaticamente em simulador) |
72
+ | `RC_IOS_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → App Store (chave `appl_`, usada automaticamente em iPhone físico) |
73
+ | `RC_ANDROID_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Google Play (chave `goog_`, usada automaticamente em Android físico) |
73
74
  | `SENTRY_DSN` | Sentry | Dashboard Sentry → Projeto → DSN |
74
75
  | `MIXPANEL_TOKEN` | Mixpanel | Dashboard Mixpanel → Configurações → Token |
75
76
 
@@ -13,6 +13,36 @@ const pkg = require('../../../package.json');
13
13
  /** Backend IDs: firebase | supabase | api */
14
14
  const BACKENDS = Object.freeze(['firebase', 'supabase', 'api']);
15
15
 
16
+ /**
17
+ * Resolve the default RC keys to bake into launch.json / Makefile.
18
+ *
19
+ * `kasy run` overrides these at runtime based on the device picked (test_ for
20
+ * simulator/emulator, appl_/goog_ for physical). The values here are only used
21
+ * when the user runs Flutter directly (e.g. F5 in VS Code, `make run`) without
22
+ * going through `kasy run`. We default to test_ since that's what works in the
23
+ * iOS Simulator and Android Emulator — the common dev case.
24
+ *
25
+ * Legacy support: projects generated before this refactor only ask for a single
26
+ * Android/iOS key (`rcAndroidKey` / `rcIosKey`). When those are passed we honor
27
+ * them as-is.
28
+ */
29
+ function resolveDefaultRcKeys(answers) {
30
+ // Legacy single-key format (pre-test/prod split): keep behavior unchanged.
31
+ if ((answers.rcAndroidKey || answers.rcIosKey) && !answers.rcTestKey && !answers.rcIosProdKey && !answers.rcAndroidProdKey) {
32
+ return {
33
+ android: answers.rcAndroidKey || 'YOUR_REVENUECAT_ANDROID_KEY',
34
+ ios: answers.rcIosKey || 'YOUR_REVENUECAT_IOS_KEY',
35
+ };
36
+ }
37
+ const test = (answers.rcTestKey || '').trim();
38
+ const iosProd = (answers.rcIosProdKey || '').trim();
39
+ const androidProd = (answers.rcAndroidProdKey || '').trim();
40
+ return {
41
+ android: test || androidProd || 'YOUR_REVENUECAT_ANDROID_KEY',
42
+ ios: test || iosProd || 'YOUR_REVENUECAT_IOS_KEY',
43
+ };
44
+ }
45
+
16
46
  /**
17
47
  * Build dart-define args for dev and prod environments.
18
48
  * Backend-specific vars: Firebase (none extra), Supabase (BACKEND_URL, SUPABASE_TOKEN), API (BACKEND_URL).
@@ -40,12 +70,11 @@ function buildDartDefines(backend, modules, answers) {
40
70
  }
41
71
 
42
72
  if (modules.includes('revenuecat')) {
43
- const androidKey = answers.rcAndroidKey || 'YOUR_REVENUECAT_ANDROID_KEY';
44
- const iosKey = answers.rcIosKey || 'YOUR_REVENUECAT_IOS_KEY';
45
- dev.push(`--dart-define=RC_ANDROID_API_KEY=${androidKey}`);
46
- dev.push(`--dart-define=RC_IOS_API_KEY=${iosKey}`);
47
- prod.push(`--dart-define=RC_ANDROID_API_KEY=${androidKey}`);
48
- prod.push(`--dart-define=RC_IOS_API_KEY=${iosKey}`);
73
+ const { android: androidDefault, ios: iosDefault } = resolveDefaultRcKeys(answers);
74
+ dev.push(`--dart-define=RC_ANDROID_API_KEY=${androidDefault}`);
75
+ dev.push(`--dart-define=RC_IOS_API_KEY=${iosDefault}`);
76
+ prod.push(`--dart-define=RC_ANDROID_API_KEY=${androidDefault}`);
77
+ prod.push(`--dart-define=RC_IOS_API_KEY=${iosDefault}`);
49
78
  if (answers.revenuecatWeb) {
50
79
  const webKey = answers.rcWebKey || 'YOUR_REVENUECAT_WEB_KEY';
51
80
  dev.push(`--dart-define=RC_WEB_API_KEY=${webKey}`);
@@ -144,8 +173,11 @@ async function writeEnvExample(projectDir, modules, answers, language = 'en') {
144
173
  if (modules.includes('revenuecat')) {
145
174
  lines.push('');
146
175
  lines.push(t.revenuecat);
147
- lines.push(`RC_ANDROID_API_KEY=${answers.rcAndroidKey || 'YOUR_REVENUECAT_ANDROID_KEY'}`);
148
- lines.push(`RC_IOS_API_KEY=${answers.rcIosKey || 'YOUR_REVENUECAT_IOS_KEY'}`);
176
+ if (t.revenuecatTest) lines.push(t.revenuecatTest);
177
+ lines.push(`RC_TEST_KEY=${(answers.rcTestKey || '').trim()}`);
178
+ if (t.revenuecatProd) lines.push(t.revenuecatProd);
179
+ lines.push(`RC_IOS_PROD_KEY=${(answers.rcIosProdKey || '').trim()}`);
180
+ lines.push(`RC_ANDROID_PROD_KEY=${(answers.rcAndroidProdKey || '').trim()}`);
149
181
  if (answers.revenuecatWeb) {
150
182
  lines.push(`RC_WEB_API_KEY=${answers.rcWebKey || 'YOUR_REVENUECAT_WEB_KEY'}`);
151
183
  }
@@ -468,6 +500,16 @@ const bool revenuecatWeb = ${revenuecatWeb};
468
500
  async function writeKitSetup(projectDir, options) {
469
501
  const { appName, bundleId, backend, modules = [], firebaseProjectId, supabaseUrl, supabaseAnonKey, moduleAnswers = {} } = options;
470
502
  const revenuecatWeb = !!(modules.includes('revenuecat') && modules.includes('web') && moduleAnswers.revenuecatWeb);
503
+ // Booleans tracking which RC keys the user configured. We never persist key
504
+ // values to kit_setup.json — those live in .env (gitignored). These flags
505
+ // let `kasy doctor` warn about release readiness without re-reading .env.
506
+ const revenuecatKeys = modules.includes('revenuecat')
507
+ ? {
508
+ test: !!(moduleAnswers.rcTestKey && moduleAnswers.rcTestKey.trim()),
509
+ iosProd: !!(moduleAnswers.rcIosProdKey && moduleAnswers.rcIosProdKey.trim()),
510
+ androidProd: !!(moduleAnswers.rcAndroidProdKey && moduleAnswers.rcAndroidProdKey.trim()),
511
+ }
512
+ : undefined;
471
513
  const config = {
472
514
  appName: appName || 'App',
473
515
  bundleId: bundleId || 'com.example.app',
@@ -480,6 +522,7 @@ async function writeKitSetup(projectDir, options) {
480
522
  storageProvider: backend === 'firebase' ? 'firebase' : backend === 'supabase' ? 'supabase' : 'api',
481
523
  webCompat: modules.includes('web'),
482
524
  revenuecatWeb,
525
+ ...(revenuecatKeys ? { revenuecatKeys } : {}),
483
526
  internationalization: true,
484
527
  useSentry: modules.includes('sentry'),
485
528
  withOnboarding: modules.includes('onboarding'),
@@ -1638,6 +1681,7 @@ async function localizeReleaseDocs(projectDir, language = 'en') {
1638
1681
  module.exports = {
1639
1682
  BACKENDS,
1640
1683
  buildDartDefines,
1684
+ resolveDefaultRcKeys,
1641
1685
  writeRouter,
1642
1686
  writeVsCodeLaunch,
1643
1687
  writeEnvExample,