kasy-cli 1.16.0 → 1.18.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 (99) hide show
  1. package/bin/kasy.js +16 -2
  2. package/lib/commands/add.js +52 -19
  3. package/lib/commands/configure.js +548 -0
  4. package/lib/commands/deploy.js +4 -4
  5. package/lib/commands/doctor.js +54 -6
  6. package/lib/commands/favicon.js +4 -4
  7. package/lib/commands/icon.js +5 -5
  8. package/lib/commands/new.js +404 -213
  9. package/lib/commands/remove.js +14 -3
  10. package/lib/commands/run.js +208 -6
  11. package/lib/commands/splash.js +5 -5
  12. package/lib/commands/update.js +9 -9
  13. package/lib/scaffold/CHANGELOG.json +23 -0
  14. package/lib/scaffold/backends/api/patch/README.md +3 -2
  15. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +44 -5
  17. package/lib/scaffold/backends/supabase/patch/README.md +3 -2
  18. package/lib/scaffold/generate.js +24 -8
  19. package/lib/scaffold/shared/generator-utils.js +52 -8
  20. package/lib/scaffold/shared/post-build.js +113 -31
  21. package/lib/scaffold/shared/template-strings.js +6 -0
  22. package/lib/utils/brand.js +16 -12
  23. package/lib/utils/flutter-run.js +139 -11
  24. package/lib/utils/i18n/messages-en.js +85 -7
  25. package/lib/utils/i18n/messages-es.js +85 -7
  26. package/lib/utils/i18n/messages-pt.js +86 -8
  27. package/lib/utils/ui.js +79 -4
  28. package/package.json +1 -1
  29. package/templates/firebase/README.en.md +18 -8
  30. package/templates/firebase/README.es.md +18 -8
  31. package/templates/firebase/README.md +18 -8
  32. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +68 -45
  33. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidgetReceiver.kt +37 -0
  34. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/OpenAppAction.kt +26 -0
  35. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
  36. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
  37. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
  38. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
  41. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  42. package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  43. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  44. package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  45. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  46. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
  47. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  56. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  57. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  58. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  59. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  60. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  61. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
  62. package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
  63. package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
  64. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  65. package/templates/firebase/assets/images/splash_logo_light.png +0 -0
  66. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  67. package/templates/firebase/docs/revenuecat-setup.es.md +28 -8
  68. package/templates/firebase/docs/revenuecat-setup.pt.md +28 -8
  69. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
  70. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
  76. package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
  77. package/templates/firebase/lib/components/components.dart +1 -0
  78. package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
  79. package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
  80. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  81. package/templates/firebase/lib/components/kasy_date_picker.dart +834 -0
  82. package/templates/firebase/lib/components/kasy_tabs.dart +145 -61
  83. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +45 -53
  84. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +565 -77
  85. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
  86. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  87. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  88. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
  89. package/templates/firebase/lib/router.dart +15 -1
  90. package/templates/firebase/pubspec.yaml +1 -1
  91. package/templates/firebase/web/index.html +9 -0
  92. package/templates/firebase/web/splash/img/dark-1x.png +0 -0
  93. package/templates/firebase/web/splash/img/dark-2x.png +0 -0
  94. package/templates/firebase/web/splash/img/dark-3x.png +0 -0
  95. package/templates/firebase/web/splash/img/dark-4x.png +0 -0
  96. package/templates/firebase/web/splash/img/light-1x.png +0 -0
  97. package/templates/firebase/web/splash/img/light-2x.png +0 -0
  98. package/templates/firebase/web/splash/img/light-3x.png +0 -0
  99. package/templates/firebase/web/splash/img/light-4x.png +0 -0
@@ -66,7 +66,7 @@ const {
66
66
  removeDevelopmentTeam,
67
67
  localizeReleaseDocs,
68
68
  } = require('./shared/generator-utils');
69
- const { pubGet, slangGenerate, buildRunner, flutterfireConfigure, writeGoogleAuthOptions, writeGoogleIosUrlScheme, patchFirebaseServiceWorker } = require('./shared/post-build');
69
+ const { pubGet, slangGenerate, buildRunner, dartFix, flutterfireConfigure, writeGoogleAuthOptions, writeGoogleIosUrlScheme, patchFirebaseServiceWorker } = require('./shared/post-build');
70
70
  const { FIREBASE_SOURCE_DIR } = require('./shared/backend-config');
71
71
 
72
72
  /**
@@ -97,6 +97,7 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
97
97
  onProgress = () => {},
98
98
  includeWeb = true,
99
99
  language = 'en',
100
+ deferGoogleAuthPatches = false,
100
101
  } = options;
101
102
 
102
103
  const { applyBackendSetup = null, postBuild = null } = hooks;
@@ -325,7 +326,12 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
325
326
  // Android: populate kGoogleWebClientId in lib/google_auth_options.dart.
326
327
  // Skip for Supabase — it defines kGoogleIosClientId too, handled separately
327
328
  // in new.js after credentials are resolved (readSupabaseGoogleCredentials).
328
- if (backend !== 'supabase') {
329
+ //
330
+ // When `deferGoogleAuthPatches` is set, the caller will re-run flutterfire
331
+ // and these patches AFTER enabling Google Sign-In (which creates the OAuth
332
+ // Web Client + REVERSED_CLIENT_ID). Running them here would fail because the
333
+ // IDs don't exist yet, surfacing scary red errors that look like real bugs.
334
+ if (backend !== 'supabase' && !deferGoogleAuthPatches) {
329
335
  const gaResult = await writeGoogleAuthOptions(targetDir);
330
336
  steps.push({
331
337
  name: 'google-auth-options',
@@ -335,12 +341,14 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
335
341
  }
336
342
 
337
343
  // iOS: register REVERSED_CLIENT_ID as a URL scheme in ios/Runner/Info.plist
338
- const iosSchemeResult = await writeGoogleIosUrlScheme(targetDir);
339
- steps.push({
340
- name: 'google-ios-url-scheme',
341
- ok: iosSchemeResult.ok,
342
- detail: iosSchemeResult.ok ? null : iosSchemeResult.error,
343
- });
344
+ if (!deferGoogleAuthPatches) {
345
+ const iosSchemeResult = await writeGoogleIosUrlScheme(targetDir);
346
+ steps.push({
347
+ name: 'google-ios-url-scheme',
348
+ ok: iosSchemeResult.ok,
349
+ detail: iosSchemeResult.ok ? null : iosSchemeResult.error,
350
+ });
351
+ }
344
352
 
345
353
  // Web: patch firebase-messaging-sw.js with real config values (only when web module is selected)
346
354
  if (modules.includes('web')) {
@@ -364,6 +372,14 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
364
372
  }
365
373
  }
366
374
 
375
+ // ── 4. Auto-fix lints (directives_ordering, unused_import, etc.) ───────────
376
+ // Runs last so generated code, patches and post-build edits are all covered.
377
+ // Non-fatal: if it fails we still ship a working project; the user can run
378
+ // `dart fix --apply` manually later.
379
+ onProgress('dart-fix');
380
+ const dartFixResult = await dartFix(targetDir);
381
+ steps.push({ name: 'dart-fix', ok: dartFixResult.ok, detail: dartFixResult.ok ? null : dartFixResult.error });
382
+
367
383
  return { steps, packageName, appName, bundleId, firebaseProjectId, ...returnExtra };
368
384
  }
369
385
 
@@ -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,
@@ -45,6 +45,13 @@ async function buildRunner(projectDir) {
45
45
  return run('dart run build_runner build --delete-conflicting-outputs', projectDir, 600_000); // 10 min
46
46
  }
47
47
 
48
+ // Auto-applies fixable lints (directives_ordering, unused_import, etc.) so the
49
+ // generated project opens in the user's IDE with zero warnings. Runs after
50
+ // build_runner so generated files are reordered too where applicable.
51
+ async function dartFix(projectDir) {
52
+ return run('dart fix --apply', projectDir, 180_000); // 3 min
53
+ }
54
+
48
55
  async function flutterfireConfigure(projectDir, firebaseProjectId, options = {}) {
49
56
  const { includeWeb = true } = options;
50
57
  const platforms = includeWeb ? 'android,ios,web' : 'android,ios';
@@ -665,49 +672,123 @@ async function validateFacebookAndroidStrings(projectDir) {
665
672
  }
666
673
 
667
674
  /**
668
- * Validate RevenueCat API keys (RC_IOS_API_KEY, RC_ANDROID_API_KEY) from the project Makefile
669
- * and return the webhook URL derived from kit_setup.json backend config.
675
+ * Read a project `.env` file into a plain object. Returns `{}` if the file is
676
+ * missing. Supports KEY=VALUE lines; ignores comments and blank lines.
670
677
  */
671
- async function validateRevenueCat(projectDir, config = {}) {
672
- const makefilePath = path.join(projectDir, 'Makefile');
673
- if (!(await fs.pathExists(makefilePath))) {
674
- return { ok: true, skipped: true, reason: 'no_makefile' };
675
- }
676
-
678
+ async function readDotenv(projectDir) {
679
+ const envPath = path.join(projectDir, '.env');
680
+ if (!(await fs.pathExists(envPath))) return {};
677
681
  let content;
678
682
  try {
679
- content = await fs.readFile(makefilePath, 'utf8');
680
- } catch (err) {
681
- return { ok: false, error: `Failed to read Makefile: ${err.message}` };
683
+ content = await fs.readFile(envPath, 'utf8');
684
+ } catch {
685
+ return {};
686
+ }
687
+ const env = {};
688
+ for (const rawLine of content.split('\n')) {
689
+ const line = rawLine.trim();
690
+ if (!line || line.startsWith('#')) continue;
691
+ const eq = line.indexOf('=');
692
+ if (eq === -1) continue;
693
+ const key = line.slice(0, eq).trim();
694
+ let value = line.slice(eq + 1).trim();
695
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
696
+ value = value.slice(1, -1);
697
+ }
698
+ if (key) env[key] = value;
682
699
  }
700
+ return env;
701
+ }
683
702
 
684
- // Only consider non-commented lines
685
- const activeLines = content.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
686
-
687
- const iosMatch = activeLines.match(/RC_IOS_API_KEY=([^\s\\]+)/);
688
- const androidMatch = activeLines.match(/RC_ANDROID_API_KEY=([^\s\\]+)/);
689
-
690
- const iosKey = iosMatch ? iosMatch[1].trim() : '';
691
- const androidKey = androidMatch ? androidMatch[1].trim() : '';
692
-
693
- const iosEmpty = !iosKey || iosKey === 'xxx';
694
- const androidEmpty = !androidKey || androidKey === 'xxx';
695
-
696
- const iosTest = iosKey.startsWith('test_');
697
- const androidTest = androidKey.startsWith('test_');
703
+ /**
704
+ * Validate RevenueCat keys. The new `.env` is the source of truth:
705
+ * RC_TEST_KEY (test_xxx — simulator/emulator)
706
+ * RC_IOS_PROD_KEY (appl_xxx — physical iOS)
707
+ * RC_ANDROID_PROD_KEY (goog_xxx — physical Android)
708
+ *
709
+ * Legacy fallback: if none of the new vars are set we read the old
710
+ * RC_ANDROID_API_KEY/RC_IOS_API_KEY from `.env`, and finally the Makefile
711
+ * (oldest projects). Returns flags `doctor.js` uses to render granular hints.
712
+ */
713
+ async function validateRevenueCat(projectDir, config = {}) {
714
+ const env = await readDotenv(projectDir);
698
715
 
716
+ // Webhook URL (only Supabase exposes it directly; Firebase points to Console).
699
717
  let webhookUrl = null;
700
718
  if (config.backendProvider === 'supabase' && config.supabaseProjectId) {
701
719
  webhookUrl = `https://${config.supabaseProjectId}.supabase.co/functions/v1/revenuecat-webhook`;
702
720
  }
703
721
 
722
+ const testKey = (env.RC_TEST_KEY || '').trim();
723
+ const iosProdKey = (env.RC_IOS_PROD_KEY || '').trim();
724
+ const androidProdKey = (env.RC_ANDROID_PROD_KEY || '').trim();
725
+ const hasNewKeys = !!(testKey || iosProdKey || androidProdKey);
726
+
727
+ // Legacy single-key path (for projects generated before the test/prod split).
728
+ if (!hasNewKeys) {
729
+ let iosKey = (env.RC_IOS_API_KEY || '').trim();
730
+ let androidKey = (env.RC_ANDROID_API_KEY || '').trim();
731
+
732
+ // Last resort: read from Makefile (oldest projects).
733
+ if (!iosKey && !androidKey) {
734
+ const makefilePath = path.join(projectDir, 'Makefile');
735
+ if (await fs.pathExists(makefilePath)) {
736
+ try {
737
+ const content = await fs.readFile(makefilePath, 'utf8');
738
+ const activeLines = content.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
739
+ const iosMatch = activeLines.match(/RC_IOS_API_KEY=([^\s\\]+)/);
740
+ const androidMatch = activeLines.match(/RC_ANDROID_API_KEY=([^\s\\]+)/);
741
+ iosKey = iosMatch ? iosMatch[1].trim() : '';
742
+ androidKey = androidMatch ? androidMatch[1].trim() : '';
743
+ } catch { /* ignore */ }
744
+ }
745
+ }
746
+
747
+ const iosEmpty = !iosKey || iosKey === 'xxx';
748
+ const androidEmpty = !androidKey || androidKey === 'xxx';
749
+ const iosTest = iosKey.startsWith('test_');
750
+ const androidTest = androidKey.startsWith('test_');
751
+
752
+ return {
753
+ ok: !iosEmpty && !androidEmpty,
754
+ legacy: true,
755
+ iosKey,
756
+ androidKey,
757
+ iosEmpty,
758
+ androidEmpty,
759
+ bothTest: iosTest && androidTest,
760
+ webhookUrl,
761
+ };
762
+ }
763
+
764
+ // New 3-key scheme.
765
+ const testOk = !!testKey && /^test_/.test(testKey);
766
+ const testBadPrefix = !!testKey && !/^test_/.test(testKey);
767
+ const iosProdOk = !!iosProdKey && /^appl_/.test(iosProdKey);
768
+ const iosProdBadPrefix = !!iosProdKey && !/^appl_/.test(iosProdKey);
769
+ const androidProdOk = !!androidProdKey && /^goog_/.test(androidProdKey);
770
+ const androidProdBadPrefix = !!androidProdKey && !/^goog_/.test(androidProdKey);
771
+
772
+ // OK if user can run at least one device flow end-to-end. Test alone is fine
773
+ // for development; prod-only configurations also work but skip the simulator.
774
+ const anyOk = testOk || iosProdOk || androidProdOk;
775
+
704
776
  return {
705
- ok: !iosEmpty && !androidEmpty,
706
- iosKey,
707
- androidKey,
708
- iosEmpty,
709
- androidEmpty,
710
- bothTest: iosTest && androidTest,
777
+ ok: anyOk,
778
+ legacy: false,
779
+ testKey,
780
+ iosProdKey,
781
+ androidProdKey,
782
+ testOk,
783
+ testBadPrefix,
784
+ iosProdOk,
785
+ iosProdBadPrefix,
786
+ androidProdOk,
787
+ androidProdBadPrefix,
788
+ testMissing: !testKey,
789
+ iosProdMissing: !iosProdKey,
790
+ androidProdMissing: !androidProdKey,
791
+ onlyTest: testOk && !iosProdOk && !androidProdOk,
711
792
  webhookUrl,
712
793
  };
713
794
  }
@@ -787,6 +868,7 @@ module.exports = {
787
868
  pubGet,
788
869
  slangGenerate,
789
870
  buildRunner,
871
+ dartFix,
790
872
  flutterfireConfigure,
791
873
  writeGoogleAuthOptions,
792
874
  writeGoogleIosUrlScheme,
@@ -33,6 +33,8 @@ const TEMPLATE_STRINGS = {
33
33
  supabase: '# Supabase',
34
34
  apiRest: '# API REST',
35
35
  revenuecat: '# RevenueCat',
36
+ revenuecatTest: '# Test Store key (test_xxx) — auto-used on simulator/emulator',
37
+ revenuecatProd: '# Production keys — auto-used on physical devices',
36
38
  sentry: '# Sentry (prod only)',
37
39
  mixpanel: '# Mixpanel',
38
40
  llmChat: '# LLM Chat — Cloud/Edge Function endpoint (API key stays on the server, not here)',
@@ -65,6 +67,8 @@ const TEMPLATE_STRINGS = {
65
67
  supabase: '# Supabase',
66
68
  apiRest: '# API REST',
67
69
  revenuecat: '# RevenueCat',
70
+ revenuecatTest: '# Chave Test Store (test_xxx) — usada automaticamente em simulador/emulador',
71
+ revenuecatProd: '# Chaves de produção — usadas automaticamente em dispositivo físico',
68
72
  sentry: '# Sentry (apenas prod)',
69
73
  mixpanel: '# Mixpanel',
70
74
  llmChat: '# LLM Chat — endpoint da Cloud/Edge Function (a chave de API fica no servidor, não aqui)',
@@ -97,6 +101,8 @@ const TEMPLATE_STRINGS = {
97
101
  supabase: '# Supabase',
98
102
  apiRest: '# API REST',
99
103
  revenuecat: '# RevenueCat',
104
+ revenuecatTest: '# Clave Test Store (test_xxx) — se usa automáticamente en simulador/emulador',
105
+ revenuecatProd: '# Claves de producción — se usan automáticamente en dispositivo físico',
100
106
  sentry: '# Sentry (solo prod)',
101
107
  mixpanel: '# Mixpanel',
102
108
  llmChat: '# LLM Chat — endpoint de Cloud/Edge Function (la clave de API queda en el servidor, no aquí)',
@@ -10,22 +10,26 @@
10
10
  */
11
11
 
12
12
  const kleur = require('kleur');
13
- const gradient = require('gradient-string');
14
13
  const boxenPackage = require('boxen');
15
14
  const boxen = boxenPackage.default || boxenPackage;
16
15
 
17
- // Kept around for accents and boxes; the wordmark itself is now plain white.
18
- const BRAND_GRADIENT = ['#a78bfa', '#60a5fa'];
19
- const brandGradient = gradient(BRAND_GRADIENT);
20
-
16
+ // Brand palette (memory/kasy_brand_colors.md). Single source of truth for the
17
+ // CLI's only brand color: change BRAND_RGB and every spinner, header, logo and
18
+ // success card across all commands updates automatically.
19
+ //
20
+ // kleur 4 in this repo doesn't ship `.hex()`, so we emit 24-bit ANSI directly.
21
+ // Truecolor is supported by every macOS/Linux terminal we care about.
22
+ const BRAND_RGB = { r: 210, g: 245, b: 30 }; // #D2F51E lime
23
+ const paintLime = (text) => `\x1b[38;2;${BRAND_RGB.r};${BRAND_RGB.g};${BRAND_RGB.b}m${text}\x1b[0m`;
24
+ const wordmark = paintLime;
21
25
  const DOMAIN_SUFFIX = kleur.gray('.dev');
22
26
 
23
27
  function printBanner(_tr) {
24
28
  const bar = kleur.gray('─────────────────────────────────────────────────');
25
29
  const logo = [
26
- kleur.white(' ╦╔═ ╔═╗ ╔═╗ ╦ ╦'),
27
- kleur.white(' ╠╩╗ ╠═╣ ╚═╗ ╚╦╝'),
28
- `${kleur.white(' ╩ ╩ ╩ ╩ ╚═╝ ╩ ')} ${DOMAIN_SUFFIX}`,
30
+ wordmark(' ╦╔═ ╔═╗ ╔═╗ ╦ ╦'),
31
+ wordmark(' ╠╩╗ ╠═╣ ╚═╗ ╚╦╝'),
32
+ `${wordmark(' ╩ ╩ ╩ ╩ ╚═╝ ╩ ')} ${DOMAIN_SUFFIX}`,
29
33
  ].join('\n');
30
34
 
31
35
  console.log(`\n${bar}\n`);
@@ -35,18 +39,18 @@ function printBanner(_tr) {
35
39
 
36
40
  function printCompactHeader(_tr) {
37
41
  console.log('');
38
- console.log(` ${kleur.white('✦ KASY')}${DOMAIN_SUFFIX}`);
42
+ console.log(` ${wordmark('✦ KASY')}${DOMAIN_SUFFIX}`);
39
43
  console.log('');
40
44
  }
41
45
 
42
46
  function successBox(title, body, { padding = 1, marginTop = 1, marginBottom = 1 } = {}) {
43
47
  return boxen(
44
- `${brandGradient(`✦ ${title}`)}\n\n${body}`,
48
+ `${paintLime(`✦ ${title}`)}\n\n${body}`,
45
49
  {
46
50
  padding,
47
51
  margin: { top: marginTop, bottom: marginBottom, left: 1, right: 1 },
48
52
  borderStyle: 'round',
49
- borderColor: 'cyan',
53
+ borderColor: 'gray',
50
54
  }
51
55
  );
52
56
  }
@@ -64,7 +68,7 @@ function infoBox(title, body, { padding = 1, marginTop = 1, marginBottom = 1 } =
64
68
  }
65
69
 
66
70
  module.exports = {
67
- brandGradient,
71
+ paintLime,
68
72
  printBanner,
69
73
  printCompactHeader,
70
74
  successBox,
@@ -5,18 +5,29 @@
5
5
  * stage detection, elapsed timer, hot-reload stdin pass-through and SIGINT
6
6
  * forwarding stay consistent in one place.
7
7
  *
8
- * Flow:
9
- * - Spawn flutter with piped stdio.
10
- * - Show a Clack spinner with elapsed time, ticking every second.
11
- * - As Flutter logs scroll, update the spinner message when we see a known
12
- * stage marker (Gradle, Xcode, install, sync, etc.).
13
- * - When Flutter signals "ready" (DAP key commands), flush buffered output,
14
- * drop into stdio pass-through and pipe stdin so hot reload works.
15
- * - On early exit, replay the buffer so the user can see the error.
8
+ * Two modes:
9
+ *
10
+ * - spinner mode (default, TTY only): Spawn flutter with piped stdio. Show a
11
+ * Clack spinner with elapsed time. Update the spinner message when we see a
12
+ * known stage marker (Gradle, Xcode, install, sync, etc.). When Flutter
13
+ * signals "ready" (DAP key commands), flush buffered output, drop into stdio
14
+ * pass-through, and pipe stdin so hot reload works. On early exit, replay
15
+ * the buffer so the user can see the error.
16
+ *
17
+ * - raw mode (auto when stdout is not a TTY, or forced via `--raw`):
18
+ * No spinner, no buffer. Flutter output is piped straight through so devs
19
+ * and AI agents see the unmodified stream.
20
+ *
21
+ * In both modes we always tee the raw (ANSI-stripped) flutter output to
22
+ * `<project>/.kasy/run.log`. This gives AI assistants a stable, single-file
23
+ * source of truth for the last run, even when the spinner cosmetically rewrites
24
+ * the human terminal.
16
25
  */
17
26
 
18
27
  'use strict';
19
28
 
29
+ const path = require('node:path');
30
+ const fs = require('node:fs');
20
31
  const { spawn } = require('node:child_process');
21
32
  const kleur = require('kleur');
22
33
  const ui = require('./ui');
@@ -24,6 +35,35 @@ const ui = require('./ui');
24
35
  // Markers that tell us the initial build is done and the app is running.
25
36
  const FLUTTER_READY_RE = /Flutter run key commands\.|is listening on|VM Service|Dart VM service|To hot reload|Hot restart/i;
26
37
 
38
+ // Strip ANSI escape sequences before persisting to the log file so the file
39
+ // is grep-friendly for tooling (CI logs, agents, editors).
40
+ const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
41
+
42
+ function stripAnsi(text) {
43
+ return String(text).replace(ANSI_RE, '');
44
+ }
45
+
46
+ /**
47
+ * Open (and create the parent dir of) the persistent run log. Overwrites any
48
+ * previous run — we want "the last run", not an ever-growing history.
49
+ *
50
+ * Returns a write stream, or null when the file can't be created (read-only
51
+ * project, permissions, etc.) — failure here must never break `kasy run`.
52
+ */
53
+ function openRunLog(projectDir) {
54
+ const logPath = path.join(projectDir, '.kasy', 'run.log');
55
+ try {
56
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
57
+ const stream = fs.createWriteStream(logPath, { flags: 'w' });
58
+ stream.on('error', () => {});
59
+ const header = `# kasy run — ${new Date().toISOString()}\n`;
60
+ stream.write(header);
61
+ return { stream, logPath };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
27
67
  /**
28
68
  * Map a chunk of Flutter output to a user-friendly stage label, or null
29
69
  * if the chunk doesn't carry any of the known markers.
@@ -66,10 +106,82 @@ function render(stageMessage, startTime) {
66
106
  }
67
107
 
68
108
  /**
69
- * Spawn `flutter` with the given args inside `projectDir`, surfacing progress
70
- * through a Clack spinner. Resolves on success, rejects on non-zero exit.
109
+ * Pretty-print the path used for the run log. Falls back to the absolute path
110
+ * when the log lives outside cwd (unusual, but keeps the message accurate).
71
111
  */
72
- function spawnFlutterWithSpinner(args, projectDir, t) {
112
+ function relLogPath(logPath) {
113
+ const rel = path.relative(process.cwd(), logPath);
114
+ return rel.startsWith('..') ? logPath : rel;
115
+ }
116
+
117
+ /**
118
+ * Spawn `flutter` with the given args inside `projectDir`. When `options.raw`
119
+ * is true or stdout is not a TTY, runs in raw pass-through mode. Otherwise,
120
+ * shows a Clack spinner that buffers output until Flutter is ready.
121
+ *
122
+ * Resolves on success, rejects on non-zero exit. The log file path is always
123
+ * announced at the end so callers (and AI agents) can find the full output.
124
+ */
125
+ function spawnFlutterWithSpinner(args, projectDir, t, options = {}) {
126
+ const raw = Boolean(options.raw) || !process.stdout.isTTY;
127
+ const log = openRunLog(projectDir);
128
+
129
+ if (raw) return spawnRaw(args, projectDir, t, log);
130
+ return spawnWithSpinner(args, projectDir, t, log);
131
+ }
132
+
133
+ /**
134
+ * Raw mode: no spinner, no buffer. Flutter stdout/stderr are piped straight
135
+ * through to the parent process, and tee'd (ANSI-stripped) into the log file.
136
+ *
137
+ * Stdin is forwarded directly so hot-reload keys (r/R/q) work without any
138
+ * raw-mode toggling — the user is already on a "dumb" terminal here.
139
+ */
140
+ function spawnRaw(args, projectDir, t, log) {
141
+ return new Promise((resolve, reject) => {
142
+ const proc = spawn('flutter', args, {
143
+ cwd: projectDir,
144
+ stdio: ['inherit', 'pipe', 'pipe'],
145
+ });
146
+
147
+ const tee = (chunk, sink) => {
148
+ sink.write(chunk);
149
+ if (log) log.stream.write(stripAnsi(chunk.toString()));
150
+ };
151
+
152
+ proc.stdout.on('data', (c) => tee(c, process.stdout));
153
+ proc.stderr.on('data', (c) => tee(c, process.stderr));
154
+
155
+ const sigintHandler = () => { try { proc.kill('SIGINT'); } catch (_) {} };
156
+ process.on('SIGINT', sigintHandler);
157
+
158
+ const cleanup = () => {
159
+ process.off('SIGINT', sigintHandler);
160
+ if (log) log.stream.end();
161
+ };
162
+
163
+ proc.on('close', (code) => {
164
+ cleanup();
165
+ announceLog(log, t);
166
+ if (code === 0) resolve();
167
+ else reject(new Error(`flutter exited with code ${code}`));
168
+ });
169
+
170
+ proc.on('error', (err) => {
171
+ cleanup();
172
+ announceLog(log, t);
173
+ reject(err);
174
+ });
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Spinner mode: buffer flutter output during the build phase, surface progress
180
+ * as friendly stage labels, then flush + pass-through once Flutter signals
181
+ * ready. On early exit, the buffer is replayed so the user sees the real
182
+ * Flutter error.
183
+ */
184
+ function spawnWithSpinner(args, projectDir, t, log) {
73
185
  return new Promise((resolve, reject) => {
74
186
  const proc = spawn('flutter', args, {
75
187
  cwd: projectDir,
@@ -106,7 +218,12 @@ function spawnFlutterWithSpinner(args, projectDir, t) {
106
218
  }
107
219
  };
108
220
 
221
+ const teeLog = (chunk) => {
222
+ if (log) log.stream.write(stripAnsi(chunk.toString()));
223
+ };
224
+
109
225
  const handleStdout = (chunk) => {
226
+ teeLog(chunk);
110
227
  const text = chunk.toString();
111
228
  if (!ready) {
112
229
  buffer.push(chunk);
@@ -122,6 +239,7 @@ function spawnFlutterWithSpinner(args, projectDir, t) {
122
239
  };
123
240
 
124
241
  const handleStderr = (chunk) => {
242
+ teeLog(chunk);
125
243
  if (!ready) {
126
244
  buffer.push(chunk);
127
245
  if (FLUTTER_READY_RE.test(chunk.toString())) flushAndSwitch();
@@ -144,6 +262,7 @@ function spawnFlutterWithSpinner(args, projectDir, t) {
144
262
  process.stdin.unpipe(proc.stdin);
145
263
  process.stdin.pause();
146
264
  }
265
+ if (log) log.stream.end();
147
266
  };
148
267
 
149
268
  proc.on('close', (code) => {
@@ -152,6 +271,7 @@ function spawnFlutterWithSpinner(args, projectDir, t) {
152
271
  spinner.stop(t('run.spinner.failed'), 2);
153
272
  for (const chunk of buffer) process.stdout.write(chunk);
154
273
  }
274
+ announceLog(log, t);
155
275
  if (code === 0) resolve();
156
276
  else reject(new Error(`flutter exited with code ${code}`));
157
277
  });
@@ -159,15 +279,23 @@ function spawnFlutterWithSpinner(args, projectDir, t) {
159
279
  proc.on('error', (err) => {
160
280
  cleanup();
161
281
  if (!ready) spinner.stop(t('run.spinner.failed'), 2);
282
+ announceLog(log, t);
162
283
  reject(err);
163
284
  });
164
285
  });
165
286
  }
166
287
 
288
+ function announceLog(log, t) {
289
+ if (!log) return;
290
+ const msg = t('run.log.savedTo', { path: relLogPath(log.logPath) });
291
+ console.log(kleur.dim(`\n ${msg}`));
292
+ }
293
+
167
294
  module.exports = {
168
295
  spawnFlutterWithSpinner,
169
296
  // Exported for tests and reuse in non-flutter spawn helpers.
170
297
  detectStage,
171
298
  formatElapsed,
172
299
  FLUTTER_READY_RE,
300
+ stripAnsi,
173
301
  };