kasy-cli 1.17.0 → 1.19.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 (110) hide show
  1. package/bin/kasy.js +16 -2
  2. package/lib/commands/add.js +7 -7
  3. package/lib/commands/configure.js +548 -0
  4. package/lib/commands/deploy.js +4 -4
  5. package/lib/commands/doctor.js +17 -0
  6. package/lib/commands/favicon.js +4 -4
  7. package/lib/commands/icon.js +5 -5
  8. package/lib/commands/new.js +483 -324
  9. package/lib/commands/run.js +17 -4
  10. package/lib/commands/splash.js +5 -5
  11. package/lib/commands/update.js +9 -9
  12. package/lib/scaffold/CHANGELOG.json +14 -0
  13. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
  14. package/lib/scaffold/backends/firebase/setup-from-scratch.js +123 -5
  15. package/lib/scaffold/generate.js +24 -8
  16. package/lib/scaffold/shared/post-build.js +8 -0
  17. package/lib/utils/brand.js +16 -12
  18. package/lib/utils/flutter-run.js +139 -11
  19. package/lib/utils/i18n/messages-en.js +62 -5
  20. package/lib/utils/i18n/messages-es.js +62 -5
  21. package/lib/utils/i18n/messages-pt.js +63 -6
  22. package/lib/utils/ui.js +79 -4
  23. package/package.json +1 -2
  24. package/templates/firebase/README.en.md +1 -1
  25. package/templates/firebase/README.es.md +1 -1
  26. package/templates/firebase/README.md +1 -1
  27. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +0 -15
  28. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +65 -26
  29. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
  30. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
  31. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
  32. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
  33. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
  34. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
  35. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  36. package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  37. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  38. package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  39. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  40. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
  41. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  42. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
  43. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  44. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
  45. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  46. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
  47. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
  56. package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
  57. package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
  58. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  59. package/templates/firebase/assets/images/splash_logo_light.png +0 -0
  60. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  61. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
  62. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
  68. package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
  69. package/templates/firebase/lib/components/components.dart +1 -0
  70. package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
  71. package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
  72. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  73. package/templates/firebase/lib/components/kasy_date_picker.dart +2173 -0
  74. package/templates/firebase/lib/components/kasy_tabs.dart +214 -91
  75. package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
  76. package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
  77. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
  78. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
  79. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
  80. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
  81. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
  82. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +12 -18
  83. package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
  84. package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
  85. package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
  86. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
  87. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
  88. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
  89. package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
  90. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +949 -77
  91. package/templates/firebase/lib/features/home/home_page.dart +17 -40
  92. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
  93. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
  94. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
  95. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  96. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  97. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
  98. package/templates/firebase/lib/main.dart +34 -34
  99. package/templates/firebase/pubspec.yaml +2 -1
  100. package/templates/firebase/storage.cors.json +8 -0
  101. package/templates/firebase/web/index.html +24 -2
  102. package/templates/firebase/web/splash/img/dark-1x.png +0 -0
  103. package/templates/firebase/web/splash/img/dark-2x.png +0 -0
  104. package/templates/firebase/web/splash/img/dark-3x.png +0 -0
  105. package/templates/firebase/web/splash/img/dark-4x.png +0 -0
  106. package/templates/firebase/web/splash/img/light-1x.png +0 -0
  107. package/templates/firebase/web/splash/img/light-2x.png +0 -0
  108. package/templates/firebase/web/splash/img/light-3x.png +0 -0
  109. package/templates/firebase/web/splash/img/light-4x.png +0 -0
  110. package/templates/firebase/lib/core/bottom_menu/kasy_bart_navigation.dart +0 -22
@@ -251,18 +251,22 @@ async function runRun(directory, options = {}) {
251
251
  const deviceArgs = [];
252
252
  let resolvedDeviceLabel = null;
253
253
  let pickedDevice = null;
254
+ let isChromeTarget = false;
254
255
  if (options.web) {
256
+ isChromeTarget = true;
255
257
  deviceArgs.push('-d', 'chrome');
256
258
  } else if (options.ios) {
257
259
  deviceArgs.push('-d', 'ios');
258
260
  } else if (options.android) {
259
261
  deviceArgs.push('-d', 'android');
260
262
  } else if (options.device) {
263
+ if (options.device === 'chrome' || options.device === 'web-server') {
264
+ isChromeTarget = true;
265
+ }
261
266
  deviceArgs.push('-d', options.device);
262
267
  } else {
263
268
  const devices = listFlutterDevices(projectDir);
264
269
  if (devices.length > 1) {
265
- printCompactHeader(t);
266
270
  pickedDevice = await pickDevice(devices, t);
267
271
  if (!pickedDevice) {
268
272
  console.log(kleur.yellow(` ⚠ ${t('run.warn.nothingSelected')}`));
@@ -270,9 +274,18 @@ async function runRun(directory, options = {}) {
270
274
  }
271
275
  deviceArgs.push('-d', pickedDevice.id);
272
276
  resolvedDeviceLabel = `${pickedDevice.name} (${pickedDevice.id})`;
277
+ if (classifyDevice(pickedDevice) === 'web') isChromeTarget = true;
278
+ } else if (devices.length === 1 && classifyDevice(devices[0]) === 'web') {
279
+ // Single auto-picked Chrome — still force the fixed port.
280
+ isChromeTarget = true;
273
281
  }
274
- // 0 or 1 device → let flutter handle it; it picks the only one or
275
- // prints its own "no devices" message.
282
+ }
283
+
284
+ if (isChromeTarget) {
285
+ // Pin a fixed port so the Chrome origin stays the same between runs.
286
+ // Firebase Auth persists sessions per-origin (IndexedDB) — a random port
287
+ // each run means the user gets logged out every restart.
288
+ deviceArgs.push('--web-port', options.webPort || '5555');
276
289
  }
277
290
 
278
291
  // Read dart-defines from .vscode/launch.json (skip if --no-defines)
@@ -334,7 +347,7 @@ async function runRun(directory, options = {}) {
334
347
  console.log(kleur.dim(` ✦ ${t('run.updateHint.prefix')} `) + kleur.cyan('kasy update') + kleur.dim(` ${t('run.updateHint.suffix')}\n`));
335
348
 
336
349
  try {
337
- await spawnFlutterWithSpinner(args, projectDir, t);
350
+ await spawnFlutterWithSpinner(args, projectDir, t, { raw: Boolean(options.raw) });
338
351
  } catch (err) {
339
352
  if (err.code === 'ENOENT') {
340
353
  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,18 @@
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
+ },
2
16
  "1.17.0": {
3
17
  "modules": {
4
18
  "revenuecat": {
@@ -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
+ };
@@ -317,6 +317,66 @@ async function createFirebaseStorageBucket(projectId, location = 'us-central1')
317
317
  return { ok: false, error: 'Max retries exceeded' };
318
318
  }
319
319
 
320
+ /**
321
+ * Apply default CORS config to a Cloud Storage bucket so the browser can load
322
+ * objects (e.g. user avatars) from a Flutter web app. Without this the GET
323
+ * request hits a CORS error and the image silently fails to render.
324
+ *
325
+ * Tries the Firebase default bucket name first (<projectId>.firebasestorage.app
326
+ * for projects created after Oct 2024, <projectId>.appspot.com for legacy
327
+ * projects) and falls back to the other if the first 404s. Safe to call
328
+ * repeatedly — it overwrites the bucket-level cors config.
329
+ *
330
+ * Reads the policy from cli/templates/firebase/storage.cors.json so the same
331
+ * file is shipped to the user's project for visibility and manual re-runs.
332
+ *
333
+ * @param {string} projectId
334
+ * @returns {{ ok: boolean, bucket?: string, error?: string }}
335
+ */
336
+ async function applyStorageCors(projectId, options = {}) {
337
+ const { corsFilePath } = options;
338
+ const defaultCorsPath = path.join(__dirname, '..', '..', '..', '..', 'templates', 'firebase', 'storage.cors.json');
339
+ const resolvedPath = corsFilePath || defaultCorsPath;
340
+ let corsConfig;
341
+ try {
342
+ corsConfig = await fs.readJson(resolvedPath);
343
+ } catch (e) {
344
+ return { ok: false, error: `Failed to read CORS config at ${resolvedPath}: ${e.message}` };
345
+ }
346
+
347
+ let token;
348
+ try {
349
+ token = await getAccessToken();
350
+ } catch (e) {
351
+ return { ok: false, error: 'Could not get access token' };
352
+ }
353
+
354
+ const candidates = [
355
+ `${projectId}.firebasestorage.app`,
356
+ `${projectId}.appspot.com`,
357
+ ];
358
+
359
+ let lastError = null;
360
+ for (const bucket of candidates) {
361
+ const url = `https://storage.googleapis.com/storage/v1/b/${bucket}?fields=cors`;
362
+ const res = await fetch(url, {
363
+ method: 'PATCH',
364
+ headers: {
365
+ Authorization: `Bearer ${token}`,
366
+ 'Content-Type': 'application/json',
367
+ 'X-Goog-User-Project': projectId,
368
+ },
369
+ body: JSON.stringify({ cors: corsConfig }),
370
+ });
371
+ if (res.ok) return { ok: true, bucket };
372
+ const text = await res.text();
373
+ lastError = `${res.status}: ${text}`;
374
+ // 404 means the bucket name doesn't exist — try the next candidate.
375
+ if (res.status !== 404) break;
376
+ }
377
+ return { ok: false, error: lastError || 'No matching bucket found' };
378
+ }
379
+
320
380
  /**
321
381
  * Enable required Google Cloud APIs.
322
382
  */
@@ -689,6 +749,8 @@ async function enableAuthProviders(projectId, { maxRetries = 3, retryDelayMs = 1
689
749
  enabled: true,
690
750
  }),
691
751
  });
752
+ let googleEnabled = false;
753
+ let googleSignInSkipped = false;
692
754
  if (!googleRes.ok) {
693
755
  const googleText = await googleRes.text();
694
756
  // 409 = already exists — update it to ensure it's enabled.
@@ -703,13 +765,49 @@ async function enableAuthProviders(projectId, { maxRetries = 3, retryDelayMs = 1
703
765
  },
704
766
  body: JSON.stringify({ enabled: true }),
705
767
  });
706
- return { ok: true };
768
+ googleEnabled = true;
769
+ } else {
770
+ // 400 INVALID_CONFIG (client_id empty) = no OAuth client created yet.
771
+ // Email/Password and Anonymous are already enabled — just mark Google as skipped.
772
+ googleSignInSkipped = true;
707
773
  }
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 };
774
+ } else {
775
+ googleEnabled = true;
711
776
  }
712
- return { ok: true };
777
+
778
+ // Step 4: Apple Sign-In (best effort). Apple requires Service ID + JWT client secret
779
+ // that we cannot generate without the user's Apple Developer credentials, but the
780
+ // provider entry itself can be created so the Console shows the row ready for the
781
+ // user to fill in when they ship to iOS. Failure here is silent.
782
+ const appleUrl = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/defaultSupportedIdpConfigs?idpId=apple.com`;
783
+ let appleEnabled = false;
784
+ try {
785
+ const appleRes = await fetch(appleUrl, {
786
+ method: 'POST',
787
+ headers: {
788
+ Authorization: `Bearer ${token}`,
789
+ 'Content-Type': 'application/json',
790
+ 'X-Goog-User-Project': projectId,
791
+ },
792
+ body: JSON.stringify({
793
+ name: `projects/${projectId}/defaultSupportedIdpConfigs/apple.com`,
794
+ enabled: true,
795
+ }),
796
+ });
797
+ if (appleRes.ok) {
798
+ appleEnabled = true;
799
+ } else {
800
+ const appleText = await appleRes.text();
801
+ if (appleRes.status === 409 || appleText.includes('ALREADY_EXISTS')) {
802
+ appleEnabled = true;
803
+ }
804
+ // Any other failure is non-fatal — Apple is a nice-to-have.
805
+ }
806
+ } catch (_) {
807
+ // Network error — skip silently.
808
+ }
809
+
810
+ return { ok: true, googleSignInSkipped, googleEnabled, appleEnabled };
713
811
  }
714
812
  const text = await res.text();
715
813
  lastError = `${res.status}: ${text}`;
@@ -766,6 +864,7 @@ async function setupFromScratch(appName, bundleId, options = {}) {
766
864
  error: `Project created but billing link failed: ${shortError}. Manage projects: ${manageLink}`,
767
865
  projectId,
768
866
  billingFailed: true,
867
+ billingQuotaError: isQuota,
769
868
  billingManualLink: manageLink,
770
869
  };
771
870
  }
@@ -825,6 +924,15 @@ async function setupFromScratch(appName, bundleId, options = {}) {
825
924
  error: storageResult.error,
826
925
  url: `https://console.firebase.google.com/project/${projectId}/storage`,
827
926
  });
927
+ } else {
928
+ onProgress('storage-cors');
929
+ const corsResult = await applyStorageCors(projectId);
930
+ if (!corsResult.ok) {
931
+ onProgress('storage-cors-warn', {
932
+ error: corsResult.error,
933
+ url: `https://console.cloud.google.com/storage/browser?project=${projectId}`,
934
+ });
935
+ }
828
936
  }
829
937
 
830
938
  onProgress('android-app');
@@ -931,6 +1039,15 @@ async function setupExistingProject(projectId, options = {}) {
931
1039
  error: storageResult.error,
932
1040
  url: `https://console.firebase.google.com/project/${projectId}/storage`,
933
1041
  });
1042
+ } else {
1043
+ onProgress('storage-cors');
1044
+ const corsResult = await applyStorageCors(projectId);
1045
+ if (!corsResult.ok) {
1046
+ onProgress('storage-cors-warn', {
1047
+ error: corsResult.error,
1048
+ url: `https://console.cloud.google.com/storage/browser?project=${projectId}`,
1049
+ });
1050
+ }
934
1051
  }
935
1052
 
936
1053
  return {
@@ -994,6 +1111,7 @@ async function registerDebugSha1(projectId, bundleId) {
994
1111
  module.exports = {
995
1112
  setupFromScratch,
996
1113
  setupExistingProject,
1114
+ applyStorageCors,
997
1115
  checkBillingEnabled,
998
1116
  enableAuthProviders,
999
1117
  listBillingAccounts,
@@ -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
 
@@ -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';
@@ -861,6 +868,7 @@ module.exports = {
861
868
  pubGet,
862
869
  slangGenerate,
863
870
  buildRunner,
871
+ dartFix,
864
872
  flutterfireConfigure,
865
873
  writeGoogleAuthOptions,
866
874
  writeGoogleIosUrlScheme,
@@ -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: 132, g: 204, b: 22 }; // #84CC16 lime (Tailwind lime-500, balanced for dark and light terminals)
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,