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
@@ -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,10 +320,21 @@ 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 {
135
- await spawnFlutterWithSpinner(args, projectDir, t);
337
+ await spawnFlutterWithSpinner(args, projectDir, t, { raw: Boolean(options.raw) });
136
338
  } catch (err) {
137
339
  if (err.code === 'ENOENT') {
138
340
  throw new Error(t('run.error.flutterNotFound'));
@@ -5,7 +5,7 @@ const { exec } = require('node:child_process');
5
5
  const { promisify } = require('node:util');
6
6
  const kleur = require('kleur');
7
7
  const ui = require('../utils/ui');
8
- const { printCompactHeader } = require('../utils/brand');
8
+ const { printCompactHeader, paintLime } = require('../utils/brand');
9
9
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
10
10
  const { writeAndroid12Variant } = require('../utils/png-padding');
11
11
 
@@ -117,7 +117,7 @@ async function runSplash(projectDir, options = {}) {
117
117
  }
118
118
  }
119
119
 
120
- const inspectSpinner = ui.spinner();
120
+ const inspectSpinner = ui.spinner({ color: paintLime });
121
121
  inspectSpinner.start(t('splash.validating'));
122
122
 
123
123
  let lightInfo;
@@ -165,13 +165,13 @@ async function runSplash(projectDir, options = {}) {
165
165
  const destLightA12 = path.join(projectDir, ASSETS_DIR, LIGHT_ANDROID12_NAME);
166
166
  const destDarkA12 = path.join(projectDir, ASSETS_DIR, DARK_ANDROID12_NAME);
167
167
 
168
- const copySpinner = ui.spinner();
168
+ const copySpinner = ui.spinner({ color: paintLime });
169
169
  copySpinner.start(t('splash.copying'));
170
170
  await fs.copy(lightPath, destLight, { overwrite: true });
171
171
  await fs.copy(darkPath, destDark, { overwrite: true });
172
172
  copySpinner.stop(t('splash.copied'));
173
173
 
174
- const a12Spinner = ui.spinner();
174
+ const a12Spinner = ui.spinner({ color: paintLime });
175
175
  a12Spinner.start(t('splash.android12Generating'));
176
176
  await writeAndroid12Variant(destLight, destLightA12);
177
177
  await writeAndroid12Variant(destDark, destDarkA12);
@@ -183,7 +183,7 @@ async function runSplash(projectDir, options = {}) {
183
183
  return;
184
184
  }
185
185
 
186
- const genSpinner = ui.spinner();
186
+ const genSpinner = ui.spinner({ color: paintLime });
187
187
  genSpinner.start(t('splash.generating'));
188
188
  const result = await runFlutterNativeSplash(projectDir);
189
189
  if (result.ok) {
@@ -7,7 +7,7 @@ const fs = require('fs-extra');
7
7
  const pkg = require('../../package.json');
8
8
  const kleur = require('kleur');
9
9
  const ui = require('../utils/ui');
10
- const { printCompactHeader } = require('../utils/brand');
10
+ const { printCompactHeader, paintLime } = require('../utils/brand');
11
11
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
12
12
  const {
13
13
  AVAILABLE_FEATURES,
@@ -185,7 +185,7 @@ async function runUpdate(module, options = {}) {
185
185
  }
186
186
  }
187
187
 
188
- const spinner = ui.spinner();
188
+ const spinner = ui.spinner({ color: paintLime });
189
189
  spinner.start(t('update.applyingComponents'));
190
190
  try {
191
191
  const filesApplied = await applyBaseComponents(projectDir);
@@ -201,7 +201,7 @@ async function runUpdate(module, options = {}) {
201
201
  }
202
202
 
203
203
  {
204
- const spinnerPubGet = ui.spinner();
204
+ const spinnerPubGet = ui.spinner({ color: paintLime });
205
205
  spinnerPubGet.start(t('update.pubGet'));
206
206
  try {
207
207
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
@@ -233,7 +233,7 @@ async function runUpdate(module, options = {}) {
233
233
  }
234
234
  }
235
235
 
236
- const spinner = ui.spinner();
236
+ const spinner = ui.spinner({ color: paintLime });
237
237
  spinner.start(t('update.applyingCore'));
238
238
  try {
239
239
  const filesApplied = await applyCoreFiles(projectDir);
@@ -249,7 +249,7 @@ async function runUpdate(module, options = {}) {
249
249
  }
250
250
 
251
251
  {
252
- const spinnerPubGet = ui.spinner();
252
+ const spinnerPubGet = ui.spinner({ color: paintLime });
253
253
  spinnerPubGet.start(t('update.pubGet'));
254
254
  try {
255
255
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
@@ -284,7 +284,7 @@ async function runUpdate(module, options = {}) {
284
284
  return;
285
285
  }
286
286
  }
287
- const spinner = ui.spinner();
287
+ const spinner = ui.spinner({ color: paintLime });
288
288
  spinner.start(t('update.applying', { module: IOS_RELEASE_UPDATE_TARGET }));
289
289
  try {
290
290
  const { tokens, pathReplacements } = buildTokens({
@@ -345,7 +345,7 @@ async function runUpdate(module, options = {}) {
345
345
 
346
346
  // Re-apply patch
347
347
  {
348
- const spinner = ui.spinner();
348
+ const spinner = ui.spinner({ color: paintLime });
349
349
  spinner.start(t('update.applying', { module: normalized }));
350
350
  try {
351
351
  const { tokens, pathReplacements } = buildTokens({
@@ -362,7 +362,7 @@ async function runUpdate(module, options = {}) {
362
362
 
363
363
  // flutter pub get
364
364
  {
365
- const spinner = ui.spinner();
365
+ const spinner = ui.spinner({ color: paintLime });
366
366
  spinner.start(t('update.pubGet'));
367
367
  try {
368
368
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
@@ -374,7 +374,7 @@ async function runUpdate(module, options = {}) {
374
374
 
375
375
  // build_runner (only for modules that generate code)
376
376
  if (NEEDS_BUILD_RUNNER.includes(normalized)) {
377
- const spinner = ui.timedSpinner();
377
+ const spinner = ui.timedSpinner({ color: paintLime });
378
378
  spinner.start(t('update.buildRunner'));
379
379
  try {
380
380
  await execAsync(
@@ -1,4 +1,27 @@
1
1
  {
2
+ "1.18.0": {
3
+ "modules": {
4
+ "components": {
5
+ "pt": "Novo KasyDatePicker (seleção de data com modo intervalo). KasyTabs redesenhado seguindo o Figma — espaçamento corrigido, transição suave (fade + slide) ao trocar de aba. KasyAvatar refatorado com gradientes estruturados (mais fácil de customizar).",
6
+ "en": "New KasyDatePicker (date selection with range mode). KasyTabs redesigned to match Figma — fixed spacing, smooth fade + slide transition when switching tabs. KasyAvatar refactored with structured gradients (easier to customize).",
7
+ "es": "Nuevo KasyDatePicker (selección de fecha con modo rango). KasyTabs rediseñado siguiendo el Figma — espaciado corregido, transición suave (fade + slide) al cambiar de pestaña. KasyAvatar refactorizado con gradientes estructurados (más fácil de personalizar)."
8
+ },
9
+ "widget": {
10
+ "pt": "Autor da citação agora aparece junto da quote no widget. Locale carregado sincronamente — corrige caso em que o widget abria em inglês mesmo com o app em pt/es por causa de race condition. Cores do widget Android passaram a vir de res/values/colors.xml (branding consistente, fácil de trocar). Layout do widget responde ao tamanho real (pequeno/médio/grande) sem cortar conteúdo.",
11
+ "en": "Quote author now appears alongside the quote in the widget. Locale loaded synchronously — fixes case where the widget opened in English even with the app in pt/es due to a race condition. Android widget colors now come from res/values/colors.xml (consistent branding, easy to swap). Widget layout responds to actual size (small/medium/large) without clipping content.",
12
+ "es": "El autor de la cita ahora aparece junto a la quote en el widget. Locale cargado sincrónicamente — corrige el caso en que el widget abría en inglés aunque la app estuviera en pt/es por una race condition. Los colores del widget Android ahora vienen de res/values/colors.xml (branding consistente, fácil de cambiar). El layout del widget responde al tamaño real (pequeño/mediano/grande) sin cortar contenido."
13
+ }
14
+ }
15
+ },
16
+ "1.17.0": {
17
+ "modules": {
18
+ "revenuecat": {
19
+ "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.",
20
+ "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.",
21
+ "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."
22
+ }
23
+ }
24
+ },
2
25
  "1.13.0": {
3
26
  "modules": {
4
27
  "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
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Enable Firebase Auth providers using the official Firebase CLI flow:
3
+ *
4
+ * 1. Merge `firebase.json` with `auth.providers.{anonymous, emailPassword, googleSignIn}`
5
+ * 2. Run `firebase deploy --only auth --project <id>`
6
+ *
7
+ * Docs: https://firebase.google.com/docs/auth/configure-providers-cli
8
+ *
9
+ * This is the only documented way to activate Google Sign-In without manually
10
+ * clicking "Enable" in the Firebase Console: the deploy creates the OAuth 2.0
11
+ * Web Client automatically (same backend the Console hits internally).
12
+ */
13
+
14
+ const path = require('node:path');
15
+ const { promisify } = require('node:util');
16
+ const execAsync = promisify(require('node:child_process').exec);
17
+ const fs = require('fs-extra');
18
+
19
+ const DEPLOY_TIMEOUT_MS = 5 * 60 * 1000; // 5 min — auth deploys are fast but allow slack
20
+
21
+ async function getGcloudAccountEmail() {
22
+ try {
23
+ const { stdout } = await execAsync('gcloud config get-value account 2>/dev/null');
24
+ const email = (stdout || '').trim();
25
+ return email && email !== '(unset)' ? email : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Merge auth.providers into the project's firebase.json without touching
33
+ * other top-level keys (functions, firestore, storage, flutter, etc.).
34
+ */
35
+ async function mergeAuthIntoFirebaseJson(projectDir, providers) {
36
+ const firebaseJsonPath = path.join(projectDir, 'firebase.json');
37
+ if (!(await fs.pathExists(firebaseJsonPath))) {
38
+ return { ok: false, error: 'firebase.json not found in project root' };
39
+ }
40
+ let config;
41
+ try {
42
+ config = await fs.readJson(firebaseJsonPath);
43
+ } catch (err) {
44
+ return { ok: false, error: `Failed to parse firebase.json: ${err.message}` };
45
+ }
46
+ config.auth = {
47
+ ...(config.auth || {}),
48
+ providers: {
49
+ ...(config.auth?.providers || {}),
50
+ ...providers,
51
+ },
52
+ };
53
+ await fs.writeJson(firebaseJsonPath, config, { spaces: 2 });
54
+ return { ok: true };
55
+ }
56
+
57
+ /**
58
+ * @param {object} options
59
+ * @param {string} options.projectDir
60
+ * @param {string} options.projectId
61
+ * @param {string} options.appName
62
+ * @param {string} [options.supportEmail] - falls back to active gcloud account
63
+ * @returns {{ ok: boolean, error?: string, supportEmail?: string }}
64
+ */
65
+ async function enableAuthViaFirebaseCli({ projectDir, projectId, appName, supportEmail }) {
66
+ // 1. Resolve support email (required by Google's OAuth consent screen)
67
+ let email = (supportEmail || '').trim();
68
+ if (!email) email = await getGcloudAccountEmail();
69
+ if (!email) {
70
+ return {
71
+ ok: false,
72
+ error: 'support_email_required',
73
+ };
74
+ }
75
+
76
+ // 2. Merge firebase.json
77
+ const merge = await mergeAuthIntoFirebaseJson(projectDir, {
78
+ anonymous: true,
79
+ emailPassword: true,
80
+ googleSignIn: {
81
+ oAuthBrandDisplayName: appName,
82
+ supportEmail: email,
83
+ },
84
+ });
85
+ if (!merge.ok) return merge;
86
+
87
+ // 3. Deploy. --non-interactive prevents the CLI from prompting on edge cases.
88
+ const cmd = `firebase deploy --only auth --project ${projectId} --non-interactive`;
89
+ try {
90
+ await execAsync(cmd, { cwd: projectDir, timeout: DEPLOY_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
91
+ return { ok: true, supportEmail: email };
92
+ } catch (err) {
93
+ const stderr = (err.stderr || '').toString();
94
+ const stdout = (err.stdout || '').toString();
95
+ return {
96
+ ok: false,
97
+ error: (stderr || err.message || '').slice(0, 800),
98
+ stdout: stdout.slice(0, 800),
99
+ supportEmail: email,
100
+ };
101
+ }
102
+ }
103
+
104
+ module.exports = {
105
+ enableAuthViaFirebaseCli,
106
+ getGcloudAccountEmail,
107
+ mergeAuthIntoFirebaseJson,
108
+ };
@@ -689,6 +689,8 @@ async function enableAuthProviders(projectId, { maxRetries = 3, retryDelayMs = 1
689
689
  enabled: true,
690
690
  }),
691
691
  });
692
+ let googleEnabled = false;
693
+ let googleSignInSkipped = false;
692
694
  if (!googleRes.ok) {
693
695
  const googleText = await googleRes.text();
694
696
  // 409 = already exists — update it to ensure it's enabled.
@@ -703,13 +705,49 @@ async function enableAuthProviders(projectId, { maxRetries = 3, retryDelayMs = 1
703
705
  },
704
706
  body: JSON.stringify({ enabled: true }),
705
707
  });
706
- return { ok: true };
708
+ googleEnabled = true;
709
+ } else {
710
+ // 400 INVALID_CONFIG (client_id empty) = no OAuth client created yet.
711
+ // Email/Password and Anonymous are already enabled — just mark Google as skipped.
712
+ googleSignInSkipped = true;
707
713
  }
708
- // 400 INVALID_CONFIG (client_id empty) = no OAuth client created yet.
709
- // Email/Password and Anonymous are already enabled — just skip Google.
710
- return { ok: true, googleSignInSkipped: true };
714
+ } else {
715
+ googleEnabled = true;
711
716
  }
712
- return { ok: true };
717
+
718
+ // Step 4: Apple Sign-In (best effort). Apple requires Service ID + JWT client secret
719
+ // that we cannot generate without the user's Apple Developer credentials, but the
720
+ // provider entry itself can be created so the Console shows the row ready for the
721
+ // user to fill in when they ship to iOS. Failure here is silent.
722
+ const appleUrl = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/defaultSupportedIdpConfigs?idpId=apple.com`;
723
+ let appleEnabled = false;
724
+ try {
725
+ const appleRes = await fetch(appleUrl, {
726
+ method: 'POST',
727
+ headers: {
728
+ Authorization: `Bearer ${token}`,
729
+ 'Content-Type': 'application/json',
730
+ 'X-Goog-User-Project': projectId,
731
+ },
732
+ body: JSON.stringify({
733
+ name: `projects/${projectId}/defaultSupportedIdpConfigs/apple.com`,
734
+ enabled: true,
735
+ }),
736
+ });
737
+ if (appleRes.ok) {
738
+ appleEnabled = true;
739
+ } else {
740
+ const appleText = await appleRes.text();
741
+ if (appleRes.status === 409 || appleText.includes('ALREADY_EXISTS')) {
742
+ appleEnabled = true;
743
+ }
744
+ // Any other failure is non-fatal — Apple is a nice-to-have.
745
+ }
746
+ } catch (_) {
747
+ // Network error — skip silently.
748
+ }
749
+
750
+ return { ok: true, googleSignInSkipped, googleEnabled, appleEnabled };
713
751
  }
714
752
  const text = await res.text();
715
753
  lastError = `${res.status}: ${text}`;
@@ -766,6 +804,7 @@ async function setupFromScratch(appName, bundleId, options = {}) {
766
804
  error: `Project created but billing link failed: ${shortError}. Manage projects: ${manageLink}`,
767
805
  projectId,
768
806
  billingFailed: true,
807
+ billingQuotaError: isQuota,
769
808
  billingManualLink: manageLink,
770
809
  };
771
810
  }
@@ -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