kasy-cli 1.15.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 (44) 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/icon.js +29 -1
  5. package/lib/commands/new.js +34 -8
  6. package/lib/commands/remove.js +14 -3
  7. package/lib/commands/run.js +264 -3
  8. package/lib/scaffold/CHANGELOG.json +9 -0
  9. package/lib/scaffold/backends/api/patch/README.md +3 -2
  10. package/lib/scaffold/backends/api/pubspec.yaml.tpl +2 -0
  11. package/lib/scaffold/backends/supabase/patch/README.md +3 -2
  12. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +2 -0
  13. package/lib/scaffold/shared/generator-utils.js +52 -8
  14. package/lib/scaffold/shared/post-build.js +105 -31
  15. package/lib/scaffold/shared/template-strings.js +6 -0
  16. package/lib/utils/i18n/messages-en.js +34 -2
  17. package/lib/utils/i18n/messages-es.js +34 -2
  18. package/lib/utils/i18n/messages-pt.js +34 -2
  19. package/lib/utils/png-padding.js +134 -2
  20. package/package.json +1 -1
  21. package/templates/firebase/README.en.md +17 -7
  22. package/templates/firebase/README.es.md +17 -7
  23. package/templates/firebase/README.md +17 -7
  24. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +15 -0
  25. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +3 -19
  26. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidgetReceiver.kt +37 -0
  27. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/OpenAppAction.kt +26 -0
  28. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  29. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  30. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  31. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  32. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  33. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  34. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  35. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  36. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  37. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  38. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  39. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  40. package/templates/firebase/docs/revenuecat-setup.es.md +28 -8
  41. package/templates/firebase/docs/revenuecat-setup.pt.md +28 -8
  42. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +39 -41
  43. package/templates/firebase/lib/router.dart +15 -1
  44. package/templates/firebase/web/index.html +3 -0
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) {
@@ -7,11 +7,17 @@ const ui = require('../utils/ui');
7
7
  const { printCompactHeader } = require('../utils/brand');
8
8
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
9
9
  const { inspectPng } = require('./splash');
10
+ const {
11
+ writeAndroidAdaptiveBackground,
12
+ writeTransparentSquare,
13
+ } = require('../utils/png-padding');
10
14
 
11
15
  const execAsync = promisify(exec);
12
16
 
13
17
  const ASSETS_DIR = path.join('assets', 'images');
14
18
  const ICON_NAME = 'icon.png';
19
+ const ANDROID_BG_NAME = 'icon_android.png';
20
+ const ANDROID_FG_EMPTY_NAME = 'icon_foreground_empty.png';
15
21
 
16
22
  async function assertKasyProject(projectDir, t) {
17
23
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
@@ -85,13 +91,35 @@ async function runIcon(projectDir, options = {}) {
85
91
  ui.note(warnings.map((w) => `${kleur.yellow('⚠')} ${w}`).join('\n'), t('icon.warn.title'));
86
92
  }
87
93
 
88
- const dest = path.join(projectDir, ASSETS_DIR, ICON_NAME);
94
+ const assetsDir = path.join(projectDir, ASSETS_DIR);
95
+ const dest = path.join(assetsDir, ICON_NAME);
96
+ const androidBgDest = path.join(assetsDir, ANDROID_BG_NAME);
97
+ const androidFgEmptyDest = path.join(assetsDir, ANDROID_FG_EMPTY_NAME);
89
98
 
90
99
  const copySpinner = ui.spinner();
91
100
  copySpinner.start(t('icon.copying'));
92
101
  await fs.copy(imagePath, dest, { overwrite: true });
93
102
  copySpinner.stop(t('icon.copied'));
94
103
 
104
+ const adaptiveSpinner = ui.spinner();
105
+ adaptiveSpinner.start(t('icon.adaptive.generating'));
106
+ let adaptiveInfo;
107
+ try {
108
+ adaptiveInfo = await writeAndroidAdaptiveBackground(dest, androidBgDest);
109
+ if (!(await fs.pathExists(androidFgEmptyDest))) {
110
+ await writeTransparentSquare(androidFgEmptyDest);
111
+ }
112
+ adaptiveSpinner.stop(t('icon.adaptive.generated'));
113
+ } catch (err) {
114
+ adaptiveSpinner.stop(`⚠ ${t('icon.adaptive.failed')}`);
115
+ ui.log.message(kleur.dim(err.message || String(err)));
116
+ process.exit(1);
117
+ }
118
+
119
+ if (!adaptiveInfo.color.sampled) {
120
+ ui.note(t('icon.adaptive.fallbackColor'), t('icon.adaptive.fallbackTitle'));
121
+ }
122
+
95
123
  if (options.skipGenerate) {
96
124
  ui.note(t('icon.skipGenerate.hint'), t('icon.skipGenerate.title'));
97
125
  ui.outro(t('icon.done'));
@@ -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
  /**
@@ -1,10 +1,52 @@
1
1
  const path = require('node:path');
2
+ const { spawnSync } = require('node:child_process');
2
3
  const fs = require('fs-extra');
3
4
  const kleur = require('kleur');
5
+ const ui = require('../utils/ui');
4
6
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
5
7
  const { printCompactHeader } = require('../utils/brand');
6
8
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
7
9
 
10
+ function listFlutterDevices(projectDir) {
11
+ const res = spawnSync('flutter', ['devices', '--machine'], {
12
+ cwd: projectDir,
13
+ encoding: 'utf8',
14
+ });
15
+ if (res.status !== 0) return [];
16
+ try {
17
+ const parsed = JSON.parse(res.stdout);
18
+ return Array.isArray(parsed) ? parsed : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function classifyDevice(device) {
25
+ const platform = (device.targetPlatform || '').toLowerCase();
26
+ if (platform === 'ios') return device.emulator ? 'ios-simulator' : 'ios-device';
27
+ if (platform.startsWith('android')) {
28
+ return device.emulator ? 'android-emulator' : 'android-device';
29
+ }
30
+ if (platform.startsWith('web')) return 'web';
31
+ if (platform.startsWith('darwin')) return 'macos';
32
+ if (platform.startsWith('linux')) return 'linux';
33
+ if (platform.startsWith('windows')) return 'windows';
34
+ return 'unknown';
35
+ }
36
+
37
+ async function pickDevice(devices, t) {
38
+ if (devices.length === 0) return null;
39
+ if (devices.length === 1) return devices[0];
40
+ const choice = await ui.select({
41
+ message: t('run.prompt.pickDevice'),
42
+ options: devices.map((d) => ({
43
+ value: d.id,
44
+ label: `${d.name} ${kleur.dim(`(${classifyDevice(d)})`)}`,
45
+ })),
46
+ });
47
+ return devices.find((d) => d.id === choice) || null;
48
+ }
49
+
8
50
  /**
9
51
  * Read dart-define args from .vscode/launch.json.
10
52
  * Returns the args array from the matching config (dev or prod).
@@ -27,6 +69,174 @@ async function readLaunchArgs(projectDir, useProd) {
27
69
  }
28
70
  }
29
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
+
30
240
  async function runRun(directory, options = {}) {
31
241
  const t = createTranslator(options.language || detectDefaultLanguage());
32
242
  const projectDir = path.resolve(directory || '.');
@@ -35,8 +245,12 @@ async function runRun(directory, options = {}) {
35
245
  throw new Error(t('run.error.notFlutterProject'));
36
246
  }
37
247
 
38
- // Resolve device flag
248
+ // Resolve device flag. If none of the platform shortcuts or -d is set,
249
+ // ask the user when more than one device is available — `flutter run`
250
+ // bails out with "More than one device connected" otherwise.
39
251
  const deviceArgs = [];
252
+ let resolvedDeviceLabel = null;
253
+ let pickedDevice = null;
40
254
  if (options.web) {
41
255
  deviceArgs.push('-d', 'chrome');
42
256
  } else if (options.ios) {
@@ -45,13 +259,49 @@ async function runRun(directory, options = {}) {
45
259
  deviceArgs.push('-d', 'android');
46
260
  } else if (options.device) {
47
261
  deviceArgs.push('-d', options.device);
262
+ } else {
263
+ const devices = listFlutterDevices(projectDir);
264
+ if (devices.length > 1) {
265
+ printCompactHeader(t);
266
+ pickedDevice = await pickDevice(devices, t);
267
+ if (!pickedDevice) {
268
+ console.log(kleur.yellow(` ⚠ ${t('run.warn.nothingSelected')}`));
269
+ return;
270
+ }
271
+ deviceArgs.push('-d', pickedDevice.id);
272
+ resolvedDeviceLabel = `${pickedDevice.name} (${pickedDevice.id})`;
273
+ }
274
+ // 0 or 1 device → let flutter handle it; it picks the only one or
275
+ // prints its own "no devices" message.
48
276
  }
49
277
 
50
278
  // Read dart-defines from .vscode/launch.json (skip if --no-defines)
51
- const dartDefines = options.noDefines
279
+ let dartDefines = options.noDefines
52
280
  ? []
53
281
  : await readLaunchArgs(projectDir, options.prod);
54
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
+
55
305
  const args = ['run', ...deviceArgs, ...dartDefines];
56
306
 
57
307
  const envDefine = dartDefines.find((a) => a.startsWith('--dart-define=ENV='));
@@ -62,7 +312,7 @@ async function runRun(directory, options = {}) {
62
312
  ? 'ios'
63
313
  : options.android
64
314
  ? 'android'
65
- : options.device || null;
315
+ : options.device || resolvedDeviceLabel || null;
66
316
  const summaryParts = [];
67
317
  if (envValue) summaryParts.push(`ENV=${envValue}`);
68
318
  if (deviceLabel) summaryParts.push(`device: ${deviceLabel}`);
@@ -70,6 +320,17 @@ async function runRun(directory, options = {}) {
70
320
 
71
321
  printCompactHeader(t);
72
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
+ }
73
334
  console.log(kleur.dim(` ✦ ${t('run.updateHint.prefix')} `) + kleur.cyan('kasy update') + kleur.dim(` ${t('run.updateHint.suffix')}\n`));
74
335
 
75
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
 
@@ -98,6 +98,8 @@ flutter_launcher_icons:
98
98
  android: ic_launcher
99
99
  ios: true
100
100
  remove_alpha_ios: true
101
+ adaptive_icon_background: assets/images/icon_android.png
102
+ adaptive_icon_foreground: assets/images/icon_foreground_empty.png
101
103
  web:
102
104
  generate: true
103
105
  image_path: assets/images/favicon.png
@@ -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
 
@@ -100,6 +100,8 @@ flutter_launcher_icons:
100
100
  android: ic_launcher
101
101
  ios: true
102
102
  remove_alpha_ios: true
103
+ adaptive_icon_background: assets/images/icon_android.png
104
+ adaptive_icon_foreground: assets/images/icon_foreground_empty.png
103
105
  web:
104
106
  generate: true
105
107
  image_path: assets/images/favicon.png