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
@@ -29,7 +29,7 @@ function openUrl(url) {
29
29
  } catch (_) {}
30
30
  }
31
31
  const ui = require('../utils/ui');
32
- const { printBanner, infoBox, successBox } = require('../utils/brand');
32
+ const { printBanner, successBox, paintLime } = require('../utils/brand');
33
33
  const fs = require('fs-extra');
34
34
  const { createTranslator } = require('../utils/i18n');
35
35
  const { getStoredLanguage, setStoredLanguage } = require('../utils/license');
@@ -42,7 +42,7 @@ const {
42
42
  runChecks,
43
43
  hasRequiredFailures,
44
44
  } = require('../utils/checks');
45
- const { normalizeBackend, getVisibleFeatures } = require('../scaffold/catalog');
45
+ const { normalizeBackend, getVisibleFeatures, FEATURE_CATALOG } = require('../scaffold/catalog');
46
46
 
47
47
  // Audience gate: set KASY_INTERNAL=1 to reveal beta/internal features.
48
48
  const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
@@ -50,9 +50,10 @@ const { generateFirebaseProject } = require('../scaffold/backends/firebase/gener
50
50
  const { generateSupabaseProject } = require('../scaffold/backends/supabase/generator');
51
51
  const { generateApiProject } = require('../scaffold/backends/api/generator');
52
52
  const { createProjectAndGetKeys, setupLinkedProject, checkLoggedIn, getOrgsList, getProjectsByOrg, getProjectKeys } = require('../scaffold/backends/supabase/deploy');
53
- const { writeSupabaseGoogleAuthOptions, readSupabaseGoogleCredentials, getGoogleClientSecretViaGcloud } = require('../scaffold/shared/post-build');
53
+ const { writeSupabaseGoogleAuthOptions, readSupabaseGoogleCredentials, getGoogleClientSecretViaGcloud, flutterfireConfigure, writeGoogleAuthOptions, writeGoogleIosUrlScheme } = require('../scaffold/shared/post-build');
54
54
  const { toPackageName } = require('../scaffold/backends/firebase/tokens');
55
55
  const { setupFromScratch, setupExistingProject, listBillingAccounts, listGcpOrganizations, checkGcloudAuth, getGcloudInstallInstructions, enableAuthProviders, registerDebugSha1 } = require('../scaffold/backends/firebase/setup-from-scratch');
56
+ const { enableAuthViaFirebaseCli } = require('../scaffold/backends/firebase/enable-auth-via-cli');
56
57
  const { createFcmServiceAccountKey } = require('../scaffold/shared/fcm-service-account');
57
58
 
58
59
  // Região padrão para criação de projetos Supabase via API.
@@ -83,11 +84,9 @@ async function promptBillingAccountIfNeeded(tr, onCancel) {
83
84
  };
84
85
 
85
86
  if (!billingList.accounts?.length) {
86
- ui.note(tr('new.firebase.q.billingAccount.context'));
87
87
  return askManualId();
88
88
  }
89
89
 
90
- ui.note(tr('new.firebase.q.billingAccount.context'));
91
90
  const billingAccountId = await ui.select({
92
91
  message: tr('new.firebase.q.billingAccount'),
93
92
  initialValue: billingList.accounts[0].id,
@@ -188,31 +187,12 @@ function printPrerequisites(tr, backend, firebaseSetupMode = 'existing', checkRe
188
187
  }
189
188
  }
190
189
 
191
- function printSummary(tr, answers) {
192
- const modules = answers.modules.length > 0
193
- ? answers.modules.join(', ')
194
- : tr('new.firebase.confirm.none');
195
-
196
- const backendLabel = { firebase: '🔥 Firebase', supabase: '🟢 Supabase', api: '🔗 API REST' }[answers.backend] || answers.backend;
197
-
198
- const rows = [
199
- `${kleur.dim(tr('new.firebase.confirm.app') + ':')} ${kleur.white(answers.appName)}`,
200
- `${kleur.dim('Bundle:')} ${kleur.white(answers.bundleId)}`,
201
- `${kleur.dim('Backend:')} ${kleur.white(backendLabel)}`,
202
- ];
203
- if (answers.firebaseProjectId) rows.push(`${kleur.dim('Firebase:')} ${kleur.white(answers.firebaseProjectId)}`);
204
- if (answers.supabaseUrl) rows.push(`${kleur.dim('Supabase URL:')} ${kleur.white(answers.supabaseUrl)}`);
205
- if (answers.apiBaseUrl) rows.push(`${kleur.dim('API URL:')} ${kleur.white(answers.apiBaseUrl)}`);
206
- rows.push(`${kleur.dim(tr('new.firebase.confirm.modules') + ':')} ${kleur.white(modules)}`);
207
-
208
- console.log(infoBox(`📦 ${tr('new.firebase.confirm.title')}`, rows.join('\n')));
209
- }
210
-
211
190
  const STEP_LABELS = {
212
191
  'project-setup': { en: 'Project configured', pt: 'Projeto configurado', es: 'Proyecto configurado' },
213
192
  'pub-get': { en: 'Packages installed', pt: 'Pacotes instalados', es: 'Paquetes instalados' },
214
193
  'slang': { en: 'Translations generated', pt: 'Traducoes geradas', es: 'Traducciones generadas' },
215
194
  'build-runner': { en: 'Code generated (Riverpod / Freezed)', pt: 'Codigo gerado (Riverpod / Freezed)', es: 'Codigo generado (Riverpod / Freezed)' },
195
+ 'dart-fix': { en: 'Lints auto-fixed', pt: 'Avisos de estilo corrigidos', es: 'Lints corregidos automaticamente' },
216
196
  'flutterfire': { en: 'Firebase configured (flutterfire)', pt: 'Firebase configurado (flutterfire)', es: 'Firebase configurado (flutterfire)' },
217
197
  'Service Account key': { en: 'Service Account key', pt: 'Chave de servico', es: 'Clave de servicio' },
218
198
  'firebase_key.json': { en: 'Service account key copied', pt: 'Chave de conta de servico copiada', es: 'Clave de cuenta de servicio copiada' },
@@ -232,6 +212,7 @@ const STEP_LABELS = {
232
212
  'supabase db push': { en: 'Database migrated', pt: 'Banco migrado', es: 'Base de datos migrada' },
233
213
  'anonymous sign-in': { en: 'Anonymous sign-in enabled', pt: 'Login anonimo ativado', es: 'Inicio de sesion anonimo activado' },
234
214
  'google sign-in': { en: 'Google Sign-In enabled', pt: 'Google Sign-In ativado', es: 'Google Sign-In activado' },
215
+ 'apple sign-in': { en: 'Apple Sign-In ready (configure credentials before testing — kasy.dev/docs/apple)', pt: 'Apple Sign-In preparado (configure as credenciais antes de testar — kasy.dev/docs/apple)', es: 'Apple Sign-In listo (configura las credenciales antes de probar — kasy.dev/docs/apple)' },
235
216
  'google-auth-options': { en: 'Google client IDs written', pt: 'Client IDs do Google gravados', es: 'Client IDs de Google escritos' },
236
217
  'ios-url-scheme': { en: 'iOS URL scheme registered', pt: 'URL scheme iOS registrado', es: 'URL scheme iOS registrado' },
237
218
  'google-ios-url-scheme': { en: 'iOS Google URL scheme registered', pt: 'URL scheme Google iOS registrado', es: 'URL scheme Google iOS registrado' },
@@ -253,6 +234,7 @@ const STEP_LABELS = {
253
234
  'service-account': { en: 'Service account key created', pt: 'Chave de conta de servico criada', es: 'Clave de cuenta de servicio creada' },
254
235
  'firestore': { en: 'Firestore database created', pt: 'Banco Firestore criado', es: 'Base de datos Firestore creada' },
255
236
  'storage': { en: 'Firebase Storage bucket created', pt: 'Bucket Firebase Storage criado', es: 'Bucket Firebase Storage creado' },
237
+ 'storage-cors': { en: 'CORS enabled on Storage (web images)', pt: 'CORS ativado no Storage (imagens na web)', es: 'CORS activado en Storage (imágenes en web)' },
256
238
  };
257
239
 
258
240
  const STEP_PROGRESS = {
@@ -260,6 +242,7 @@ const STEP_PROGRESS = {
260
242
  'pub-get': { en: 'Installing packages… (may take a few minutes)', pt: 'Instalando pacotes… (pode levar alguns minutos)', es: 'Instalando paquetes… (puede tardar varios minutos)' },
261
243
  'slang': { en: 'Generating translations…', pt: 'Gerando traducoes…', es: 'Generando traducciones…' },
262
244
  'build-runner': { en: 'Generating code (Riverpod / Freezed)… (may take a few minutes)', pt: 'Gerando codigo (Riverpod / Freezed)… (pode demorar)', es: 'Generando codigo (Riverpod / Freezed)… (puede tardar)' },
245
+ 'dart-fix': { en: 'Auto-fixing lints…', pt: 'Corrigindo avisos de estilo…', es: 'Corrigiendo lints automaticamente…' },
263
246
  'flutterfire': { en: 'Connecting to Firebase…', pt: 'Conectando ao Firebase…', es: 'Conectando a Firebase…' },
264
247
  'deploy': { en: 'Deploying backend to Firebase…', pt: 'Publicando backend no Firebase…', es: 'Desplegando backend en Firebase…' },
265
248
  'gcp-project': { en: 'Creating GCP project…', pt: 'Criando projeto GCP…', es: 'Creando proyecto GCP…' },
@@ -273,6 +256,7 @@ const STEP_PROGRESS = {
273
256
  'service-account': { en: 'Creating service account key…', pt: 'Criando chave de conta de servico…', es: 'Creando clave de cuenta de servicio…' },
274
257
  'firestore': { en: 'Creating Firestore database…', pt: 'Criando banco Firestore…', es: 'Creando base de datos Firestore…' },
275
258
  'storage': { en: 'Creating Firebase Storage bucket…', pt: 'Criando bucket Firebase Storage…', es: 'Creando bucket Firebase Storage…' },
259
+ 'storage-cors': { en: 'Enabling CORS on Storage…', pt: 'Ativando CORS no Storage…', es: 'Activando CORS en Storage…' },
276
260
  'deploy-retry-wait': { en: 'Waiting 4 min for GCP permissions to propagate… (do not close terminal)', pt: 'Aguardando 4 min para permissões do GCP propagarem… (não feche o terminal)', es: 'Esperando 4 min para propagar permisos de GCP… (no cierres la terminal)' },
277
261
  'deploy-retry-wait-2': { en: 'Retrying with updated permissions, 2 more min… (almost there!)', pt: 'Tentando novamente com permissões atualizadas, mais 2 min… (quase lá!)', es: 'Reintentando con permisos actualizados, 2 min más… (¡casi listo!)' },
278
262
  };
@@ -324,32 +308,46 @@ function printCreateFromScratchStatus(result, tr) {
324
308
 
325
309
  function printSuccessCard(tr, answers, targetDir) {
326
310
  const folderName = path.basename(targetDir);
327
- const consoleUrl = answers.backend === 'firebase' && answers.firebaseProjectId
328
- ? `https://console.firebase.google.com/project/${answers.firebaseProjectId}`
329
- : answers.backend === 'supabase'
330
- ? 'https://supabase.com/dashboard'
331
- : answers.apiBaseUrl || null;
332
311
 
333
312
  const lines = [];
313
+
314
+ if (answers.modules?.length > 0) {
315
+ const byId = Object.fromEntries(FEATURE_CATALOG.map((f) => [f.id, f]));
316
+ const visible = answers.modules.filter((id) => byId[id]);
317
+ if (visible.length > 0) {
318
+ lines.push(paintLime(`✓ ${tr('new.success.featuresInstalled')}`));
319
+ for (const id of visible) {
320
+ const name = byId[id]?.displayName || id;
321
+ lines.push(` ${kleur.dim('•')} ${name}`);
322
+ }
323
+ lines.push('');
324
+ }
325
+ }
326
+
334
327
  lines.push(kleur.bold(tr('new.success.nextSteps')));
335
328
  lines.push('');
336
- lines.push(`${kleur.dim('1.')} ${kleur.dim(tr('new.success.step.cd'))}`);
329
+
330
+ let stepNum = 1;
331
+ lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.cd'))}`);
337
332
  lines.push(` ${kleur.cyan(`cd ${folderName}`)}`);
338
- let stepNum = 2;
333
+ lines.push('');
334
+
339
335
  if (answers.backend === 'firebase') {
340
- lines.push('');
341
336
  lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.deploy'))}`);
342
337
  lines.push(` ${kleur.cyan('kasy deploy')}`);
338
+ lines.push('');
343
339
  }
340
+
341
+ lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.configure'))}`);
342
+ lines.push(` ${kleur.cyan('kasy configure')}`);
344
343
  lines.push('');
344
+
345
345
  lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.run'))}`);
346
- lines.push(` ${kleur.cyan('kasy run')}`);
347
- lines.push(` ${kleur.dim(tr('new.success.step.run.vscode'))}`);
348
- if (consoleUrl) {
349
- lines.push('');
350
- lines.push(`${kleur.dim(`${stepNum}.`)} ${kleur.dim(tr('new.success.step.console'))}`);
351
- lines.push(` ${kleur.cyan(consoleUrl)}`);
352
- }
346
+ lines.push(` ${kleur.cyan('kasy run')} ${kleur.dim(tr('new.success.step.run.vscode'))}`);
347
+ lines.push('');
348
+
349
+ lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.docs'))}`);
350
+ lines.push(` ${kleur.cyan('https://kasy.dev/docs')}`);
353
351
 
354
352
  console.log(successBox(`🎉 ${tr('new.success.title')}`, lines.join('\n')));
355
353
  }
@@ -464,9 +462,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
464
462
  message: tr('new.q.backend'),
465
463
  initialValue: 'firebase',
466
464
  options: [
467
- { value: 'firebase', label: '🔥 Firebase', hint: tr('new.q.backend.firebase.desc') },
468
- { value: 'supabase', label: '🟢 Supabase', hint: tr('new.q.backend.supabase.desc') },
469
- { value: 'api', label: '🔗 API REST', hint: tr('new.q.backend.api.desc') },
465
+ { value: 'firebase', label: '🔥 Firebase' },
466
+ { value: 'supabase', label: '🟢 Supabase' },
467
+ { value: 'api', label: '🔗 API REST' },
470
468
  ],
471
469
  onCancel: cancel,
472
470
  });
@@ -504,59 +502,38 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
504
502
  }
505
503
  }
506
504
 
507
- // ── App identity: name + bundle ID first things first ────────────────────
508
- let core;
509
- if (yes) {
510
- if (!hasExplicitDir) {
511
- ui.log.error('--yes requires an app name: kasy new MyApp --yes');
512
- ui.cancel(tr('new.firebase.error.aborted'));
513
- process.exit(1);
514
- }
515
- const appName = path.basename(targetDir);
516
- const slug = appName
505
+ // Helper: slug bundle id (e.g. "MeuApp" "com.meuapp.app")
506
+ const deriveBundleId = (name) => {
507
+ const trimmed = (name || '').trim();
508
+ if (!trimmed) return 'com.example.app';
509
+ const slug = trimmed
517
510
  .normalize('NFD')
518
511
  .replace(/[̀-ͯ]/g, '')
519
512
  .toLowerCase()
520
513
  .replace(/[^a-z0-9]/g, '');
521
- const bundleId = (slug && !/^\d/.test(slug)) ? `com.${slug}.app` : 'com.example.app';
522
- core = { appName, bundleId };
523
- ui.log.info(`App: ${kleur.white(appName)}`);
524
- ui.log.info(`Bundle: ${kleur.white(bundleId)}`);
514
+ if (!slug || /^\d/.test(slug)) return 'com.example.app';
515
+ return `com.${slug}.app`;
516
+ };
517
+
518
+ // ── App name — derived from argv when given, otherwise asked once ──────────
519
+ let core;
520
+ if (hasExplicitDir) {
521
+ // `kasy new MeuApp` — use the argument as the name; don't ask again.
522
+ const appName = path.basename(targetDir);
523
+ core = { appName, bundleId: deriveBundleId(appName) };
524
+ ui.log.info(`App: ${kleur.white(core.appName)}`);
525
+ } else if (yes) {
526
+ ui.log.error('--yes requires an app name: kasy new MyApp --yes');
527
+ ui.cancel(tr('new.firebase.error.aborted'));
528
+ process.exit(1);
525
529
  } else {
526
530
  const appName = await ui.text({
527
531
  message: tr('new.firebase.q.appName'),
528
532
  placeholder: tr('new.firebase.q.appName.hint'),
529
- initialValue: hasExplicitDir ? path.basename(targetDir) : '',
530
533
  validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.appName.required')),
531
534
  onCancel: cancel,
532
535
  });
533
-
534
- const defaultBundleId = (() => {
535
- const name = (appName || '').trim();
536
- if (!name) return 'com.example.app';
537
- const slug = name
538
- .normalize('NFD')
539
- .replace(/[̀-ͯ]/g, '')
540
- .toLowerCase()
541
- .replace(/[^a-z0-9]/g, '');
542
- if (!slug || /^\d/.test(slug)) return 'com.example.app';
543
- return `com.${slug}.app`;
544
- })();
545
-
546
- const bundleId = await ui.text({
547
- message: tr('new.firebase.q.bundleId'),
548
- placeholder: tr('new.firebase.q.bundleId.hint'),
549
- initialValue: defaultBundleId,
550
- validate: (v) => {
551
- if (!v || !v.trim()) return tr('new.firebase.q.bundleId.required');
552
- return /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/.test(v.trim())
553
- ? undefined
554
- : tr('new.firebase.q.bundleId.invalid');
555
- },
556
- onCancel: cancel,
557
- });
558
-
559
- core = { appName, bundleId };
536
+ core = { appName, bundleId: deriveBundleId(appName) };
560
537
  }
561
538
 
562
539
  // Resolve targetDir now that we have the app name
@@ -571,10 +548,99 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
571
548
  }
572
549
  }
573
550
 
574
- // ── Firebase setup mode (create vs existing) ────────────────────────────────
575
- // Firebase backend: full setup. Supabase/API: Firebase only for push notifications (FCM).
576
- let firebaseSetupMode = 'existing';
551
+ // ── Wizard mode — Quick (zero config, recommended) or Step-by-step (all options) ─
552
+ let isQuick = yes; // --yes implies Quick mode
577
553
  if (!yes) {
554
+ const wizardMode = await ui.select({
555
+ message: tr('new.q.mode'),
556
+ initialValue: 'quick',
557
+ options: [
558
+ { value: 'quick', label: tr('new.q.mode.quick') },
559
+ { value: 'advanced', label: tr('new.q.mode.advanced') },
560
+ ],
561
+ onCancel: cancel,
562
+ });
563
+ isQuick = wizardMode === 'quick';
564
+ }
565
+
566
+ // ── gcloud is required for Quick mode (creates GCP project + enables Auth via API) ─
567
+ // Failing here, before the project is generated, is much friendlier than dying mid-flow.
568
+ if (isQuick) {
569
+ const gcloudCheck = await checkGcloudAuth();
570
+ if (!gcloudCheck.ok) {
571
+ ui.log.error(tr('new.firebase.create.gcloudRequired'));
572
+ if (gcloudCheck.missing === 'gcloud') {
573
+ const instructions = getGcloudInstallInstructions();
574
+ const noteLines = [tr('new.firebase.create.installTitle')];
575
+ if (instructions.install) noteLines.push(`${tr('new.firebase.create.installCommand')}:\n ${kleur.cyan(instructions.install)}`);
576
+ if (instructions.hint) noteLines.push(instructions.hint);
577
+ noteLines.push(`${tr('new.firebase.create.installAfter')}:\n ${kleur.cyan(instructions.after)}`);
578
+ noteLines.push(`${tr('new.firebase.create.installUrl')}: ${instructions.url}`);
579
+ ui.note(noteLines.join('\n\n'));
580
+ } else {
581
+ ui.note(tr('new.firebase.create.authCommand'));
582
+ }
583
+ ui.cancel(tr('new.firebase.error.aborted'));
584
+ process.exit(1);
585
+ }
586
+
587
+ // ── Billing account check — Firebase needs an active billing account (Blaze) ─
588
+ // Without it, project creation succeeds but Storage and Cloud Functions fail later.
589
+ // Catching it here saves the user from a confusing mid-flow error.
590
+ const ensureBilling = async () => {
591
+ const billing = await listBillingAccounts();
592
+ if (billing.ok && billing.accounts?.length > 0) return true;
593
+ const billingUrl = 'https://console.cloud.google.com/billing/create';
594
+ ui.log.warn(tr('new.firebase.billing.required'));
595
+ ui.note(`${tr('new.firebase.billing.create.steps')}\n${kleur.cyan(billingUrl)}`);
596
+ openUrl(billingUrl);
597
+ const ready = await ui.confirm({
598
+ message: tr('new.firebase.billing.created.ready'),
599
+ initialValue: true,
600
+ onCancel: cancel,
601
+ });
602
+ if (!ready) {
603
+ ui.cancel(tr('new.firebase.error.aborted'));
604
+ process.exit(0);
605
+ }
606
+ const recheck = await listBillingAccounts();
607
+ if (!recheck.ok || !recheck.accounts?.length) {
608
+ ui.log.error(tr('new.firebase.billing.stillMissing'));
609
+ ui.cancel(tr('new.firebase.error.aborted'));
610
+ process.exit(1);
611
+ }
612
+ return true;
613
+ };
614
+ await ensureBilling();
615
+ }
616
+
617
+ // Visible section header for Advanced — helps the user track where they are.
618
+ const section = (key) => {
619
+ if (!isQuick) ui.log.info(paintLime(`── ${tr(key)} ──`));
620
+ };
621
+
622
+ // ── Bundle ID — Quick uses derived value silently; Step-by-step lets user override ─
623
+ section('new.advanced.section.config');
624
+ if (!isQuick) {
625
+ const bundleId = await ui.text({
626
+ message: tr('new.firebase.q.bundleId'),
627
+ placeholder: tr('new.firebase.q.bundleId.hint'),
628
+ initialValue: core.bundleId,
629
+ validate: (v) => {
630
+ if (!v || !v.trim()) return tr('new.firebase.q.bundleId.required');
631
+ return /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/.test(v.trim())
632
+ ? undefined
633
+ : tr('new.firebase.q.bundleId.invalid');
634
+ },
635
+ onCancel: cancel,
636
+ });
637
+ core.bundleId = bundleId;
638
+ }
639
+
640
+ // ── Firebase setup mode — Quick always creates a new project ───────────────
641
+ // (--yes implies Quick, so it also defaults to creating a fresh project.)
642
+ let firebaseSetupMode = 'create';
643
+ if (!isQuick) {
578
644
  if (backend === 'firebase') {
579
645
  firebaseSetupMode = await ui.select({
580
646
  message: tr('new.firebase.q.setupMode'),
@@ -599,25 +665,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
599
665
  }
600
666
  }
601
667
 
602
- // ── Wizard modeQuick (few questions) or Full (all options) ───────────────
603
- // Asked after app name + setup context are clear, so the user understands what it controls.
604
- let isQuick = yes; // --yes implies Quick mode
605
- if (!yes) {
606
- const wizardMode = await ui.select({
607
- message: tr('new.q.mode'),
608
- initialValue: 'quick',
609
- options: [
610
- { value: 'quick', label: tr('new.q.mode.quick') },
611
- { value: 'advanced', label: tr('new.q.mode.advanced') },
612
- ],
613
- onCancel: cancel,
614
- });
615
- isQuick = wizardMode === 'quick';
668
+ // ── Backend-specific prerequisitesonly shown in Step-by-step mode ──────
669
+ // Quick mode hides this list: it intimidates beginners and the CLI handles
670
+ // most prerequisites automatically (gcloud check below, billing prompt, etc.).
671
+ if (!isQuick) {
672
+ printPrerequisites(tr, backend, firebaseSetupMode, backendCheckResults);
616
673
  }
617
674
 
618
- // ── Backend-specific prerequisites (dynamic: only show what's not yet verified) ─
619
- printPrerequisites(tr, backend, firebaseSetupMode, backendCheckResults);
620
-
621
675
  // ── Firebase project ID (if using an existing project) ──────────────────────
622
676
  if (!yes) {
623
677
  const needFirebaseProjectId =
@@ -639,19 +693,12 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
639
693
  core.firebaseProjectId = firebaseProjectId;
640
694
  }
641
695
  } else {
642
- let firebaseProjectId = projectHint?.trim() || '';
643
- if (!firebaseProjectId && backend === 'firebase') {
644
- const pid = await ui.text({
645
- message: tr('new.firebase.q.projectId'),
646
- placeholder: tr('new.firebase.q.projectId.hint'),
647
- validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.projectId.required')),
648
- onCancel: cancel,
649
- });
650
- firebaseProjectId = pid?.trim() || '';
651
- }
652
- if (firebaseProjectId) {
653
- core.firebaseProjectId = firebaseProjectId;
654
- ui.log.info(`Project: ${kleur.white(firebaseProjectId)}`);
696
+ // --yes mode: if --project was passed, reuse that existing project.
697
+ // Otherwise stay in 'create' mode and let setupFromScratch handle creation silently.
698
+ if (projectHint?.trim()) {
699
+ firebaseSetupMode = 'existing';
700
+ core.firebaseProjectId = projectHint.trim();
701
+ ui.log.info(`Project: ${kleur.white(core.firebaseProjectId)}`);
655
702
  }
656
703
  }
657
704
 
@@ -674,11 +721,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
674
721
  // ── Firebase: create from scratch (when selected) ─────────────────────────
675
722
  let firebaseIncludeWeb = true;
676
723
  if (backend === 'firebase' && firebaseSetupMode === 'create') {
677
- firebaseIncludeWeb = await ui.confirm({
678
- message: tr('new.firebase.create.includeWeb'),
679
- initialValue: true,
680
- onCancel: cancel,
681
- });
724
+ if (!isQuick) {
725
+ firebaseIncludeWeb = await ui.confirm({
726
+ message: tr('new.firebase.create.includeWeb'),
727
+ initialValue: true,
728
+ onCancel: cancel,
729
+ });
730
+ }
682
731
  const gcloudCheck = await checkGcloudAuth();
683
732
  if (!gcloudCheck.ok) {
684
733
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
@@ -701,30 +750,55 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
701
750
  onCancel: cancel,
702
751
  });
703
752
  } else {
753
+ // Warn about duration + network before the org/billing prompts so the user
754
+ // can step away (or check the wifi) before the long-running call starts.
755
+ ui.log.info(tr('new.firebase.create.estimatedTime'));
704
756
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
705
757
  let selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
706
- ui.log.info(`${tr('new.firebase.create.estimatedTime')}\n${tr('new.internet.warning')}`);
707
- const ps1 = ui.makeTimedStepper();
758
+ const ps1 = ui.makeQuickStepper({ color: paintLime });
708
759
  ps1.next(tr('new.firebase.create.creating'));
709
- const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
760
+ const ps1OnProgress = (key, data) => {
761
+ if (key === 'wait-propagate') {
762
+ ps1.next(tr('new.firebase.create.waitPropagate'));
763
+ } else if (key === 'firestore') {
764
+ ps1.next(stepProgress('firestore', language));
765
+ } else if (key === 'storage') {
766
+ ps1.next(stepProgress('storage', language));
767
+ } else if (key === 'storage-cors') {
768
+ ps1.next(stepProgress('storage-cors', language));
769
+ } else if (key === 'storage-cors-warn') {
770
+ ps1.stop();
771
+ ui.log.warn(`Storage CORS: ${(data?.error || '').slice(0, 80)}\n${kleur.cyan(data?.url || '')}`);
772
+ } else if (key === 'auth-providers-warn') {
773
+ ps1.stop();
774
+ ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
775
+ }
776
+ };
777
+ let setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
710
778
  includeWeb: firebaseIncludeWeb,
711
779
  region: firebaseRegion,
712
780
  tr,
713
781
  billingAccountId: selectedBillingId || undefined,
714
782
  organizationId: selectedOrgId || undefined,
715
- onProgress: (key, data) => {
716
- if (key === 'wait-propagate') {
717
- ps1.next(tr('new.firebase.create.waitPropagate'));
718
- } else if (key === 'firestore') {
719
- ps1.next(stepProgress('firestore', language));
720
- } else if (key === 'storage') {
721
- ps1.next(stepProgress('storage', language));
722
- } else if (key === 'auth-providers-warn') {
723
- ps1.stop();
724
- ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
725
- }
726
- },
783
+ onProgress: ps1OnProgress,
727
784
  });
785
+
786
+ // Silent retry for transient billing-quota errors (propagation delay,
787
+ // race between createProject and linkBilling). Keeps the same spinner
788
+ // line so the user doesn't see the red error if it succeeds on retry.
789
+ if (!setupResult.ok && setupResult.billingFailed && setupResult.billingQuotaError && setupResult.projectId) {
790
+ ps1.update(tr('new.firebase.create.billingWait'));
791
+ await new Promise((r) => setTimeout(r, 15000));
792
+ setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
793
+ includeWeb: firebaseIncludeWeb,
794
+ region: firebaseRegion,
795
+ tr,
796
+ resumeFromBilling: { projectId: setupResult.projectId },
797
+ billingAccountId: selectedBillingId || undefined,
798
+ organizationId: selectedOrgId || undefined,
799
+ onProgress: ps1OnProgress,
800
+ });
801
+ }
728
802
  const askReady = async (readyKey) => {
729
803
  const ok = await ui.confirm({
730
804
  message: tr(readyKey),
@@ -748,26 +822,25 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
748
822
  ui.log.info(`Project ID: ${core.firebaseProjectId}`);
749
823
  printCreateFromScratchStatus(setupResult, tr);
750
824
  const authUrl = `https://console.firebase.google.com/project/${core.firebaseProjectId}/authentication/providers`;
751
- if (setupResult.authEnabled && !setupResult.googleSignInSkipped) {
752
- // Email/Password, Anonymous AND Google Sign-In were all enabled automatically skip prompt.
753
- } else {
754
- // At least one provider needs manual activation.
755
- const step1Key = setupResult.googleSignInSkipped
756
- ? 'new.firebase.create.beforeContinue.step1'
757
- : 'new.firebase.create.beforeContinue.step1.noAuth';
758
- const readyKey = setupResult.googleSignInSkipped
759
- ? 'new.firebase.create.beforeContinue.ready'
760
- : 'new.firebase.create.beforeContinue.ready.noAuth';
761
- showBeforeContinue(step1Key, authUrl);
825
+ // Stay silent when Google is the only thing missing — the post-flutterfire
826
+ // retry will activate it once the OAuth Web Client exists. We only warn
827
+ // here when Email/Anonymous themselves failed (which is the rare path).
828
+ if (!setupResult.authEnabled) {
829
+ showBeforeContinue('new.firebase.create.beforeContinue.step1.noAuth', authUrl);
762
830
  openUrl(authUrl);
763
- await askReady(readyKey);
831
+ if (!isQuick) {
832
+ await askReady('new.firebase.create.beforeContinue.ready.noAuth');
833
+ }
764
834
  }
765
835
 
766
836
  } else {
767
837
  ps1.fail(tr('new.firebase.create.failed'));
768
838
  let lastResult = setupResult;
769
839
  while (lastResult.billingFailed && lastResult.projectId) {
770
- ui.log.error(`${tr('new.firebase.create.failed')}: ${lastResult.error}`);
840
+ const errLine = lastResult.billingQuotaError
841
+ ? tr('new.firebase.create.billingQuotaError')
842
+ : `${tr('new.firebase.create.failed')}: ${lastResult.error}`;
843
+ ui.log.error(errLine);
771
844
  ui.note(
772
845
  `${kleur.cyan(lastResult.billingManualLink)}\n\n${tr('new.firebase.create.billingRetry.hint')}`,
773
846
  tr('new.firebase.create.billingRetry.title')
@@ -784,7 +857,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
784
857
  }
785
858
  ui.log.message(tr('new.firebase.create.billingRetry.retrying'));
786
859
  selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
787
- const ps2 = ui.makeTimedStepper();
860
+ const ps2 = ui.makeQuickStepper({ color: paintLime });
788
861
  ps2.next(tr('new.firebase.create.creating'));
789
862
  lastResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
790
863
  includeWeb: firebaseIncludeWeb,
@@ -809,19 +882,14 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
809
882
  ui.log.info(`Project ID: ${core.firebaseProjectId}`);
810
883
  printCreateFromScratchStatus(lastResult, tr);
811
884
  const authUrl = `https://console.firebase.google.com/project/${core.firebaseProjectId}/authentication/providers`;
812
- if (lastResult.authEnabled && !lastResult.googleSignInSkipped) {
813
- // All three providers enabled automatically — skip prompt.
814
- } else {
815
- const step1Key = lastResult.googleSignInSkipped
816
- ? 'new.firebase.create.beforeContinue.step1'
817
- : 'new.firebase.create.beforeContinue.step1.noAuth';
818
- const lastReadyKey = lastResult.googleSignInSkipped
819
- ? 'new.firebase.create.beforeContinue.ready'
820
- : 'new.firebase.create.beforeContinue.ready.noAuth';
821
- showBeforeContinue(step1Key, authUrl);
885
+ if (!lastResult.authEnabled) {
886
+ showBeforeContinue('new.firebase.create.beforeContinue.step1.noAuth', authUrl);
822
887
  openUrl(authUrl);
823
- await askReady(lastReadyKey);
888
+ if (!isQuick) {
889
+ await askReady('new.firebase.create.beforeContinue.ready.noAuth');
890
+ }
824
891
  }
892
+ // Google Sign-In status is reported after flutterfire (retry path).
825
893
 
826
894
  break;
827
895
  } else {
@@ -874,10 +942,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
874
942
  onCancel: cancel,
875
943
  });
876
944
  } else {
945
+ // Warn before the org/billing prompts so the user is ready for the long call.
946
+ ui.log.info(tr('new.firebase.create.estimatedTime'));
877
947
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
878
948
  const selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
879
- ui.log.info(tr('new.internet.warning'));
880
- const ps3 = ui.makeTimedStepper();
949
+ const ps3 = ui.makeQuickStepper({ color: paintLime });
881
950
  ps3.next(tr('new.firebase.create.creatingPush'));
882
951
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
883
952
  includeWeb: true,
@@ -926,15 +995,19 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
926
995
  let supabaseExistingResult = null;
927
996
 
928
997
  if (backend === 'supabase') {
929
- supabaseCreate = await ui.select({
930
- message: tr('new.supabase.q.create'),
931
- initialValue: true,
932
- options: [
933
- { value: true, label: tr('new.supabase.q.create.create') },
934
- { value: false, label: tr('new.supabase.q.create.existing') },
935
- ],
936
- onCancel: cancel,
937
- });
998
+ if (isQuick) {
999
+ supabaseCreate = true;
1000
+ } else {
1001
+ supabaseCreate = await ui.select({
1002
+ message: tr('new.supabase.q.create'),
1003
+ initialValue: true,
1004
+ options: [
1005
+ { value: true, label: tr('new.supabase.q.create.create') },
1006
+ { value: false, label: tr('new.supabase.q.create.existing') },
1007
+ ],
1008
+ onCancel: cancel,
1009
+ });
1010
+ }
938
1011
 
939
1012
  const showLoginRequired = () => {
940
1013
  ui.log.warn(`${tr('new.supabase.loginRequired')}\n${kleur.cyan(tr('new.supabase.loginCommand'))}`);
@@ -971,12 +1044,19 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
971
1044
  });
972
1045
  supabaseRegion = region || DEFAULT_SUPABASE_REGION;
973
1046
  }
974
- const dbPassword = await ui.password({
975
- message: tr('new.supabase.q.dbPassword'),
976
- validate: (v) => (v && v.length >= 6 ? undefined : tr('new.supabase.q.dbPassword.required')),
977
- onCancel: cancel,
978
- });
979
- supabaseDbPassword = dbPassword;
1047
+ if (isQuick) {
1048
+ // Quick mode: generate a strong password and save to .kasy/supabase.json after build.
1049
+ // The user can read it from there or rotate later via Supabase dashboard.
1050
+ supabaseDbPassword = crypto.randomBytes(18).toString('base64')
1051
+ .replace(/[+/=]/g, '')
1052
+ .slice(0, 24);
1053
+ } else {
1054
+ supabaseDbPassword = await ui.password({
1055
+ message: tr('new.supabase.q.dbPassword'),
1056
+ validate: (v) => (v && v.length >= 6 ? undefined : tr('new.supabase.q.dbPassword.required')),
1057
+ onCancel: cancel,
1058
+ });
1059
+ }
980
1060
  ui.log.info(tr('new.internet.warning'));
981
1061
  const createSpinner = ui.timedSpinner();
982
1062
  createSpinner.start(tr('new.supabase.creating'));
@@ -1075,7 +1155,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1075
1155
 
1076
1156
  // ── Firebase existing project: enable APIs + create Firestore/Storage ───
1077
1157
  if (backend === 'firebase' && firebaseSetupMode === 'existing' && core.firebaseProjectId) {
1078
- const ps4 = ui.makeTimedStepper();
1158
+ const ps4 = ui.makeQuickStepper({ color: paintLime });
1079
1159
  ps4.next(stepProgress('enable-apis', language));
1080
1160
  const existingSetup = await setupExistingProject(core.firebaseProjectId, {
1081
1161
  onProgress: (key, data) => {
@@ -1088,6 +1168,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1088
1168
  ps4.next(stepProgress('firestore', language));
1089
1169
  } else if (key === 'storage') {
1090
1170
  ps4.next(stepProgress('storage', language));
1171
+ } else if (key === 'storage-cors') {
1172
+ ps4.next(stepProgress('storage-cors', language));
1173
+ } else if (key === 'storage-cors-warn') {
1174
+ ps4.stop();
1175
+ ui.log.warn(`Storage CORS: ${(data?.error || '').slice(0, 80)}\n${kleur.cyan(data?.url || '')}`);
1091
1176
  } else if (key === 'auth-providers-warn') {
1092
1177
  ps4.stop();
1093
1178
  ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
@@ -1124,21 +1209,12 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1124
1209
  // --with flag was passed: use those modules directly, skip preset prompt.
1125
1210
  modules = preselectedModules;
1126
1211
  } else if (isQuick) {
1127
- // Quick mode: show preset picker (no multiselect).
1128
- const preset = await ui.select({
1129
- message: tr('new.q.preset'),
1130
- initialValue: 'starter',
1131
- options: [
1132
- { value: 'starter', label: tr('new.q.preset.starter') },
1133
- { value: 'saas', label: tr('new.q.preset.saas') },
1134
- { value: 'content', label: tr('new.q.preset.content') },
1135
- { value: 'full', label: tr('new.q.preset.full') },
1136
- { value: 'none', label: tr('new.q.preset.none') },
1137
- ],
1138
- onCancel: cancel,
1139
- });
1140
- modules = MODULE_PRESETS[preset] || [];
1212
+ // Quick mode: ship all features by default. Facebook is excluded because
1213
+ // it requires App ID + token that we can't auto-generate — the user adds
1214
+ // it later with `kasy add facebook` when they have the credentials.
1215
+ modules = (MODULE_PRESETS.full || []).filter((m) => m !== 'facebook');
1141
1216
  } else {
1217
+ section('new.advanced.section.features');
1142
1218
  // Advanced mode: full multiselect — built from catalog, filtered by audience + backend.
1143
1219
  const visibleFeatures = getVisibleFeatures({ audience: KASY_AUDIENCE, backend });
1144
1220
 
@@ -1174,10 +1250,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1174
1250
  }
1175
1251
  }
1176
1252
 
1253
+ // Pre-select everything available except Facebook — faster than ticking
1254
+ // 8 boxes; user just deselects what they don't want.
1255
+ const defaultSelected = moduleOptions
1256
+ .map((o) => o.value)
1257
+ .filter((id) => id !== 'facebook');
1177
1258
  const rawModules = await ui.multiselect({
1178
1259
  message: tr('new.firebase.q.modules'),
1179
1260
  options: moduleOptions,
1180
- initialValues: [],
1261
+ initialValues: defaultSelected,
1181
1262
  required: false,
1182
1263
  onCancel: cancel,
1183
1264
  });
@@ -1188,79 +1269,105 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1188
1269
  modules = [...new Set([...modules, 'web'])];
1189
1270
  }
1190
1271
 
1272
+ // ── Credentials shortcut for Advanced ─────────────────────────────────────
1273
+ // Default: defer (no prompts). Matches Quick's "scaffold first, secrets later"
1274
+ // philosophy. User can opt-in to enter everything inline if they prefer.
1275
+ let configureCredsNow = false;
1276
+ if (!isQuick) {
1277
+ const FEATURES_WITH_CREDS = ['revenuecat', 'sentry', 'analytics', 'llm_chat', 'facebook'];
1278
+ if (modules.some((m) => FEATURES_WITH_CREDS.includes(m))) {
1279
+ section('new.advanced.section.creds');
1280
+ configureCredsNow = await ui.confirm({
1281
+ message: tr('new.advanced.q.configureCredsNow'),
1282
+ initialValue: false,
1283
+ onCancel: cancel,
1284
+ });
1285
+ }
1286
+ }
1287
+ const askCreds = !isQuick && configureCredsNow;
1288
+
1191
1289
  // ── Module-specific questions ───────────────────────────────────────────
1192
1290
  const moduleAnswers = {};
1193
1291
 
1194
1292
  if (modules.includes('revenuecat')) {
1195
- // Three keys, all optional, but we require at least one. The kasy run
1196
- // command picks the right key based on the device:
1197
- // simulator/emulator RC_TEST_KEY
1198
- // physical iOS → RC_IOS_PROD_KEY (falls back to test if missing)
1199
- // physical Android → RC_ANDROID_PROD_KEY (falls back to test if missing)
1200
- moduleAnswers.rcTestKey = ((await ui.text({
1201
- message: tr('new.firebase.q.revenuecat.test'),
1202
- validate: (v) => {
1203
- const s = (v || '').trim();
1204
- if (!s) return undefined;
1205
- return /^test_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.test.invalid');
1206
- },
1207
- onCancel: cancel,
1208
- })) || '').trim();
1209
- moduleAnswers.rcIosProdKey = ((await ui.text({
1210
- message: tr('new.firebase.q.revenuecat.iosProd'),
1211
- validate: (v) => {
1212
- const s = (v || '').trim();
1213
- if (!s) return undefined;
1214
- return /^appl_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.iosProd.invalid');
1215
- },
1216
- onCancel: cancel,
1217
- })) || '').trim();
1218
- moduleAnswers.rcAndroidProdKey = ((await ui.text({
1219
- message: tr('new.firebase.q.revenuecat.androidProd'),
1220
- validate: (v) => {
1221
- const s = (v || '').trim();
1222
- if (!s) return undefined;
1223
- return /^goog_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.androidProd.invalid');
1224
- },
1225
- onCancel: cancel,
1226
- })) || '').trim();
1227
- if (!moduleAnswers.rcTestKey && !moduleAnswers.rcIosProdKey && !moduleAnswers.rcAndroidProdKey) {
1228
- // Non-blocking: user can fill .env later. Just warn so they're not surprised.
1229
- ui.log.warn(tr('new.firebase.q.revenuecat.atLeastOne'));
1293
+ if (!askCreds) {
1294
+ // Defer mode: scaffold with empty keys + default paywall; user fills
1295
+ // them later via `kasy configure revenuecat`.
1296
+ moduleAnswers.rcTestKey = '';
1297
+ moduleAnswers.rcIosProdKey = '';
1298
+ moduleAnswers.rcAndroidProdKey = '';
1299
+ moduleAnswers.defaultPaywall = 'basic';
1300
+ } else {
1301
+ // Three keys, all optional, but we require at least one. The kasy run
1302
+ // command picks the right key based on the device:
1303
+ // simulator/emulator RC_TEST_KEY
1304
+ // physical iOS → RC_IOS_PROD_KEY (falls back to test if missing)
1305
+ // physical Android → RC_ANDROID_PROD_KEY (falls back to test if missing)
1306
+ moduleAnswers.rcTestKey = ((await ui.text({
1307
+ message: tr('new.firebase.q.revenuecat.test'),
1308
+ validate: (v) => {
1309
+ const s = (v || '').trim();
1310
+ if (!s) return undefined;
1311
+ return /^test_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.test.invalid');
1312
+ },
1313
+ onCancel: cancel,
1314
+ })) || '').trim();
1315
+ moduleAnswers.rcIosProdKey = ((await ui.text({
1316
+ message: tr('new.firebase.q.revenuecat.iosProd'),
1317
+ validate: (v) => {
1318
+ const s = (v || '').trim();
1319
+ if (!s) return undefined;
1320
+ return /^appl_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.iosProd.invalid');
1321
+ },
1322
+ onCancel: cancel,
1323
+ })) || '').trim();
1324
+ moduleAnswers.rcAndroidProdKey = ((await ui.text({
1325
+ message: tr('new.firebase.q.revenuecat.androidProd'),
1326
+ validate: (v) => {
1327
+ const s = (v || '').trim();
1328
+ if (!s) return undefined;
1329
+ return /^goog_/.test(s) ? undefined : tr('new.firebase.q.revenuecat.androidProd.invalid');
1330
+ },
1331
+ onCancel: cancel,
1332
+ })) || '').trim();
1333
+ if (!moduleAnswers.rcTestKey && !moduleAnswers.rcIosProdKey && !moduleAnswers.rcAndroidProdKey) {
1334
+ // Non-blocking: user can fill .env later. Just warn so they're not surprised.
1335
+ ui.log.warn(tr('new.firebase.q.revenuecat.atLeastOne'));
1336
+ }
1337
+ moduleAnswers.defaultPaywall = await ui.select({
1338
+ message: tr('new.firebase.q.paywall'),
1339
+ initialValue: 'basic',
1340
+ options: [
1341
+ { value: 'basic', label: 'Basic (list of plans)' },
1342
+ { value: 'withSwitch', label: 'With trial switch' },
1343
+ { value: 'basicRow', label: 'Row + comparison table' },
1344
+ { value: 'minimal', label: 'Minimal (benefits + CTA)' },
1345
+ ],
1346
+ onCancel: cancel,
1347
+ });
1230
1348
  }
1231
- moduleAnswers.defaultPaywall = await ui.select({
1232
- message: tr('new.firebase.q.paywall'),
1233
- initialValue: 'basic',
1234
- options: [
1235
- { value: 'basic', label: 'Basic (list of plans)' },
1236
- { value: 'withSwitch', label: 'With trial switch' },
1237
- { value: 'basicRow', label: 'Row + comparison table' },
1238
- { value: 'minimal', label: 'Minimal (benefits + CTA)' },
1239
- ],
1240
- onCancel: cancel,
1241
- });
1242
1349
  }
1243
1350
 
1244
- // RC web key — only in advanced mode (optional credential, can configure later).
1245
- if (!isQuick && modules.includes('revenuecat') && modules.includes('web')) {
1246
- const rcWebKey = await ui.text({
1247
- message: tr('new.firebase.q.revenuecat.webKey'),
1248
- validate: (v) => {
1249
- if (!v || !v.trim()) return undefined; // optional — blank is fine
1250
- return /^rcb_/.test(v.trim()) ? undefined : tr('new.firebase.q.revenuecat.webKey.invalid');
1251
- },
1252
- onCancel: cancel,
1253
- });
1351
+ // RC web key — only when configuring inline. Otherwise mark scaffolded.
1352
+ if (modules.includes('revenuecat') && modules.includes('web')) {
1254
1353
  moduleAnswers.revenuecatWeb = true;
1255
- moduleAnswers.rcWebKey = (rcWebKey || '').trim();
1256
- } else if (modules.includes('revenuecat') && modules.includes('web')) {
1257
- // Quick mode: mark web billing as included but key will be configured later.
1258
- moduleAnswers.revenuecatWeb = true;
1259
- moduleAnswers.rcWebKey = '';
1354
+ if (askCreds) {
1355
+ const rcWebKey = await ui.text({
1356
+ message: tr('new.firebase.q.revenuecat.webKey'),
1357
+ validate: (v) => {
1358
+ if (!v || !v.trim()) return undefined; // optional — blank is fine
1359
+ return /^rcb_/.test(v.trim()) ? undefined : tr('new.firebase.q.revenuecat.webKey.invalid');
1360
+ },
1361
+ onCancel: cancel,
1362
+ });
1363
+ moduleAnswers.rcWebKey = (rcWebKey || '').trim();
1364
+ } else {
1365
+ moduleAnswers.rcWebKey = '';
1366
+ }
1260
1367
  }
1261
1368
 
1262
- // Sentry DSN — already optional (blank = configure later). Skip in quick mode.
1263
- if (!isQuick && modules.includes('sentry')) {
1369
+ // Sentry DSN — only when configuring inline.
1370
+ if (askCreds && modules.includes('sentry')) {
1264
1371
  moduleAnswers.sentryDsn = await ui.text({
1265
1372
  message: tr('new.firebase.q.sentry.dsn'),
1266
1373
  validate: (v) => {
@@ -1273,23 +1380,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1273
1380
  });
1274
1381
  }
1275
1382
 
1276
- // Mixpanel token — already optional. Skip in quick mode.
1277
- if (!isQuick && modules.includes('analytics')) {
1383
+ // Mixpanel token — only when configuring inline.
1384
+ if (askCreds && modules.includes('analytics')) {
1278
1385
  moduleAnswers.mixpanelToken = await ui.text({
1279
1386
  message: tr('new.firebase.q.mixpanel.token'),
1280
1387
  onCancel: cancel,
1281
1388
  });
1282
1389
  }
1283
1390
 
1284
- // LLM Chat credentials — skip in quick mode, all fields optional.
1285
- if (!isQuick && modules.includes('llm_chat')) {
1286
- const configureLlmNow = await ui.confirm({
1287
- message: tr('new.q.llm_chat.configureNow'),
1288
- initialValue: true,
1289
- onCancel: cancel,
1290
- });
1291
-
1292
- if (configureLlmNow) {
1391
+ // LLM Chat credentials.
1392
+ if (modules.includes('llm_chat')) {
1393
+ if (askCreds) {
1293
1394
  moduleAnswers.llmProvider = await ui.select({
1294
1395
  message: tr('add.prompt.llmProvider'),
1295
1396
  initialValue: 'openai',
@@ -1310,36 +1411,33 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1310
1411
  } else {
1311
1412
  moduleAnswers.llmConfigureLater = true;
1312
1413
  }
1313
- } else if (isQuick && modules.includes('llm_chat')) {
1314
- moduleAnswers.llmConfigureLater = true;
1315
1414
  }
1316
1415
 
1317
- // Facebook — required credentials, always ask.
1416
+ // Facebook — required credentials when inline; scaffold-only otherwise.
1318
1417
  if (modules.includes('facebook')) {
1319
- moduleAnswers.fbAppId = await ui.text({
1320
- message: tr('new.firebase.q.facebook.appId'),
1321
- validate: (v) => {
1322
- if (!v || !v.trim()) return tr('new.firebase.q.facebook.appId.required');
1323
- return /^\d+$/.test(v.trim()) ? undefined : tr('new.firebase.q.facebook.appId.invalid');
1324
- },
1325
- onCancel: cancel,
1326
- });
1327
- moduleAnswers.fbToken = await ui.password({
1328
- message: tr('new.firebase.q.facebook.token'),
1329
- validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.facebook.token.required')),
1330
- onCancel: cancel,
1331
- });
1418
+ if (askCreds) {
1419
+ moduleAnswers.fbAppId = await ui.text({
1420
+ message: tr('new.firebase.q.facebook.appId'),
1421
+ validate: (v) => {
1422
+ if (!v || !v.trim()) return tr('new.firebase.q.facebook.appId.required');
1423
+ return /^\d+$/.test(v.trim()) ? undefined : tr('new.firebase.q.facebook.appId.invalid');
1424
+ },
1425
+ onCancel: cancel,
1426
+ });
1427
+ moduleAnswers.fbToken = await ui.password({
1428
+ message: tr('new.firebase.q.facebook.token'),
1429
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.facebook.token.required')),
1430
+ onCancel: cancel,
1431
+ });
1432
+ } else {
1433
+ moduleAnswers.fbAppId = '';
1434
+ moduleAnswers.fbToken = '';
1435
+ }
1332
1436
  }
1333
1437
 
1334
- // Server secrets (webhook, Meta Ads) — skip in quick mode, configure later via `kasy deploy`.
1335
- if (!isQuick && modules.includes('revenuecat') && (backend === 'supabase' || backend === 'firebase')) {
1336
- const configureSecretsNow = await ui.confirm({
1337
- message: tr('new.firebase.q.secrets.configureNow'),
1338
- initialValue: true,
1339
- onCancel: cancel,
1340
- });
1341
-
1342
- if (configureSecretsNow) {
1438
+ // Server secrets (webhook, Meta Ads) — only when configuring inline.
1439
+ if (modules.includes('revenuecat') && (backend === 'supabase' || backend === 'firebase')) {
1440
+ if (askCreds) {
1343
1441
  moduleAnswers.rcWebhookKey = await ui.text({
1344
1442
  message: tr('new.firebase.q.revenuecat.webhookKey'),
1345
1443
  initialValue: generateWebhookKey(),
@@ -1361,9 +1459,6 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1361
1459
  } else {
1362
1460
  moduleAnswers.secretsConfigureLater = true;
1363
1461
  }
1364
- } else if (isQuick && modules.includes('revenuecat')) {
1365
- // Quick mode: always defer secrets to `kasy deploy`.
1366
- moduleAnswers.secretsConfigureLater = true;
1367
1462
  }
1368
1463
 
1369
1464
  // ── Deploy is now a separate command (`kasy deploy`) ─────────────
@@ -1384,25 +1479,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1384
1479
  apiBaseUrl: core.apiBaseUrl?.trim(),
1385
1480
  };
1386
1481
 
1387
- printSummary(tr, answers);
1388
-
1389
- if (!yes) {
1390
- const proceed = await ui.confirm({
1391
- message: tr('new.firebase.confirm.proceed'),
1392
- initialValue: true,
1393
- onCancel: cancel,
1394
- });
1395
- if (!proceed) {
1396
- ui.cancel(tr('prompt.cancelled'));
1397
- process.exit(0);
1398
- }
1399
- }
1482
+ // No mid-flow confirmation: both Quick and Advanced go straight to the
1483
+ // generation step once choices are made. The success card at the end shows
1484
+ // the full summary. Cancel any time with Ctrl+C.
1400
1485
 
1401
1486
  // ── Generate ────────────────────────────────────────────────────────────
1402
- // Stepper shows each step closing as and the next starting as ⠙ — so the
1403
- // user gets explicit "X done → Y starting" feedback instead of a single
1404
- // spinner with a mutating message.
1405
- const stepper = ui.makeTimedStepper();
1487
+ // Quick: single rolling line that mutates message. Advanced: stack each step.
1488
+ const stepper = ui.makeQuickStepper({ color: paintLime });
1406
1489
  // First step started here so even silent prep work shows progress.
1407
1490
  stepper.next(stepProgress('project-setup', language));
1408
1491
 
@@ -1448,6 +1531,10 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1448
1531
  functionsRegion: firebaseRegion,
1449
1532
  language,
1450
1533
  onProgress,
1534
+ // Google Sign-In is enabled in a later step via Firebase CLI, which is
1535
+ // when the OAuth Web Client + REVERSED_CLIENT_ID get created. Defer the
1536
+ // two patches that depend on those IDs so they don't fail noisily here.
1537
+ deferGoogleAuthPatches: true,
1451
1538
  });
1452
1539
  }
1453
1540
  // Close the last in-flight step. We don't need a label — its own message
@@ -1507,7 +1594,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1507
1594
  // only way to make the client secret available via the Identity Toolkit API.
1508
1595
  // Non-fatal: silently ignored if it fails (e.g. API not yet propagated).
1509
1596
  if (answers.firebaseProjectId) {
1510
- await enableAuthProviders(answers.firebaseProjectId);
1597
+ const authResult = await enableAuthProviders(answers.firebaseProjectId);
1598
+ if (authResult.ok && !authResult.googleSignInSkipped) {
1599
+ printStepResult({ name: 'google sign-in', ok: true }, language);
1600
+ }
1601
+ if (authResult.appleEnabled) {
1602
+ printStepResult({ name: 'apple sign-in', ok: true }, language);
1603
+ }
1511
1604
  }
1512
1605
 
1513
1606
  // Try to get the Client Secret from Identity Toolkit API (requires gcloud auth)
@@ -1571,6 +1664,31 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1571
1664
  setupSpinner.error(err.message);
1572
1665
  ui.log.warn(tr('new.supabase.setupManual'));
1573
1666
  }
1667
+
1668
+ // Quick mode generates the DB password automatically — persist it so the
1669
+ // user can recover it later (e.g. to log into Supabase Studio).
1670
+ if (isQuick && supabaseCreate && supabaseDbPassword) {
1671
+ try {
1672
+ const kasyDir = path.join(targetDir, '.kasy');
1673
+ await fs.ensureDir(kasyDir);
1674
+ await fs.writeJson(
1675
+ path.join(kasyDir, 'supabase.json'),
1676
+ { projectRef: supabaseSetupPayload.projectRef, dbPassword: supabaseDbPassword },
1677
+ { spaces: 2 }
1678
+ );
1679
+ const gitignorePath = path.join(targetDir, '.gitignore');
1680
+ const gitignoreEntry = '.kasy/supabase.json';
1681
+ if (await fs.pathExists(gitignorePath)) {
1682
+ const existing = await fs.readFile(gitignorePath, 'utf8');
1683
+ if (!existing.includes(gitignoreEntry)) {
1684
+ await fs.appendFile(gitignorePath, `\n# Supabase auto-generated DB password (credentials — do not commit)\n${gitignoreEntry}\n`, 'utf8');
1685
+ }
1686
+ }
1687
+ ui.log.info(tr('new.supabase.passwordSaved'));
1688
+ } catch (_) {
1689
+ // Non-fatal: user can rotate password via Supabase dashboard if needed.
1690
+ }
1691
+ }
1574
1692
  }
1575
1693
  }
1576
1694
 
@@ -1640,16 +1758,57 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1640
1758
  );
1641
1759
  }
1642
1760
 
1643
- // ── APNs reminder (all backends required for iOS push notifications) ───────
1644
- // Cannot be automated: the .p8 key only exists in Apple Developer Portal.
1645
- if (answers.firebaseProjectId) {
1646
- const apnsUrl = `https://console.firebase.google.com/project/${answers.firebaseProjectId}/settings/cloudmessaging`;
1647
- ui.log.warn(
1648
- `${tr('new.apns.warning')}\n${tr('new.apns.step1')}\n${tr('new.apns.step2')}\n${kleur.cyan(apnsUrl)}`
1649
- );
1650
- openUrl(apnsUrl);
1761
+ // ── Google + Apple Sign-In: use Firebase CLI for Google (creates OAuth client),
1762
+ // then REST API for Apple as best-effort. ───────────────────────────────────
1763
+ // Firebase CLI's `deploy --only auth` is the only documented path that auto-
1764
+ // creates the OAuth 2.0 Web Client without manual Console clicks — the same
1765
+ // backend that the Console hits internally.
1766
+ // (Supabase backend keeps the REST-only retry — its OAuth client is provisioned
1767
+ // differently via the linked Firebase Web app.)
1768
+ if (backend === 'firebase' && answers.firebaseProjectId && flutterfireStep?.ok) {
1769
+ const googleSpinner = ui.spinner();
1770
+ googleSpinner.start(tr('new.google.enabling'));
1771
+ const cliResult = await enableAuthViaFirebaseCli({
1772
+ projectDir: targetDir,
1773
+ projectId: answers.firebaseProjectId,
1774
+ appName: answers.appName,
1775
+ });
1776
+ googleSpinner.stop(tr('new.google.enabling'));
1777
+ if (cliResult.ok) {
1778
+ printStepResult({ name: 'google sign-in', ok: true }, language);
1779
+
1780
+ // Google deploy created the OAuth Web Client + iOS client. Re-run flutterfire
1781
+ // so google-services.json and GoogleService-Info.plist pick up the new IDs,
1782
+ // then re-apply the two patches that failed during the initial pass.
1783
+ const rerunSpinner = ui.spinner();
1784
+ rerunSpinner.start(tr('new.google.refreshConfigs'));
1785
+ const ffRerun = await flutterfireConfigure(targetDir, answers.firebaseProjectId, {
1786
+ includeWeb: answers.includeWeb !== false,
1787
+ });
1788
+ rerunSpinner.stop(tr('new.google.refreshConfigs'));
1789
+ if (ffRerun.ok) {
1790
+ const gaResult = await writeGoogleAuthOptions(targetDir);
1791
+ printStepResult({ name: 'google-auth-options', ok: gaResult.ok, detail: gaResult.error }, language);
1792
+ const iosResult = await writeGoogleIosUrlScheme(targetDir);
1793
+ printStepResult({ name: 'google-ios-url-scheme', ok: iosResult.ok, detail: iosResult.error }, language);
1794
+ }
1795
+ } else {
1796
+ const authUrl = `https://console.firebase.google.com/project/${answers.firebaseProjectId}/authentication/providers`;
1797
+ const reason = cliResult.error === 'support_email_required'
1798
+ ? tr('new.google.manualHint.noEmail')
1799
+ : tr('new.google.manualHint');
1800
+ ui.log.warn(`${reason}\n${kleur.cyan(authUrl)}`);
1801
+ }
1802
+ // Apple Sign-In remains a best-effort REST POST (no CLI support yet).
1803
+ const appleResult = await enableAuthProviders(answers.firebaseProjectId);
1804
+ if (appleResult.appleEnabled) {
1805
+ printStepResult({ name: 'apple sign-in', ok: true }, language);
1806
+ }
1651
1807
  }
1652
1808
 
1809
+ // APNs key (iOS push) is intentionally not mentioned here — it only becomes
1810
+ // relevant when shipping to iOS, and the docs at kasy.dev/docs/apns explain it.
1811
+
1653
1812
  printSuccessCard(tr, answers, targetDir);
1654
1813
 
1655
1814
  ui.outro(kleur.bold(tr('new.success.title')));