kasy-cli 1.18.0 → 1.19.1

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 (34) hide show
  1. package/bin/kasy.js +3 -1
  2. package/lib/commands/new.js +99 -105
  3. package/lib/commands/run.js +34 -6
  4. package/lib/scaffold/backends/firebase/setup-from-scratch.js +79 -0
  5. package/lib/utils/brand.js +1 -1
  6. package/lib/utils/i18n/messages-en.js +6 -0
  7. package/lib/utils/i18n/messages-es.js +6 -0
  8. package/lib/utils/i18n/messages-pt.js +6 -0
  9. package/package.json +1 -2
  10. package/templates/firebase/lib/components/kasy_date_picker.dart +1670 -331
  11. package/templates/firebase/lib/components/kasy_tabs.dart +111 -72
  12. package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
  13. package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
  14. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
  15. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
  16. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
  17. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
  18. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
  19. package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
  20. package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
  21. package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
  22. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
  23. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
  24. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
  25. package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
  26. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +457 -73
  27. package/templates/firebase/lib/features/home/home_page.dart +17 -40
  28. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
  29. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
  30. package/templates/firebase/lib/main.dart +34 -34
  31. package/templates/firebase/pubspec.yaml +1 -0
  32. package/templates/firebase/storage.cors.json +8 -0
  33. package/templates/firebase/web/index.html +15 -2
  34. package/templates/firebase/lib/core/bottom_menu/kasy_bart_navigation.dart +0 -22
package/bin/kasy.js CHANGED
@@ -354,7 +354,9 @@ function buildProgram(language) {
354
354
  .argument('[directory]', 'Project folder (default: current directory)', '.')
355
355
  .option('--ios', 'Run on iOS simulator/device')
356
356
  .option('--android', 'Run on Android emulator/device')
357
- .option('--web', 'Run on Chrome (web)')
357
+ .option('--web', 'Run on web — prints localhost URL in lime so you open it in your own browser (your extensions, your accounts)')
358
+ .option('--open', 'With --web: auto-launch a clean Chrome window (Flutter profile, no extensions) instead of just printing the URL')
359
+ .option('--web-port <port>', 'Fixed port for web (default 5555) — keeps the origin stable so Firebase Auth sessions persist between runs')
358
360
  .option('-d, --device <id>', 'Run on specific device ID')
359
361
  .option('--prod', 'Use production dart-defines (from launch.json)')
360
362
  .option('--no-defines', 'Skip dart-defines from launch.json')
@@ -29,7 +29,7 @@ function openUrl(url) {
29
29
  } catch (_) {}
30
30
  }
31
31
  const ui = require('../utils/ui');
32
- const { printBanner, infoBox, successBox, paintLime } = 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');
@@ -187,26 +187,6 @@ function printPrerequisites(tr, backend, firebaseSetupMode = 'existing', checkRe
187
187
  }
188
188
  }
189
189
 
190
- function printSummary(tr, answers) {
191
- const modules = answers.modules.length > 0
192
- ? answers.modules.join(', ')
193
- : tr('new.firebase.confirm.none');
194
-
195
- const backendLabel = { firebase: '🔥 Firebase', supabase: '🟢 Supabase', api: '🔗 API REST' }[answers.backend] || answers.backend;
196
-
197
- const rows = [
198
- `${kleur.dim(tr('new.firebase.confirm.app') + ':')} ${kleur.white(answers.appName)}`,
199
- `${kleur.dim('Bundle:')} ${kleur.white(answers.bundleId)}`,
200
- `${kleur.dim('Backend:')} ${kleur.white(backendLabel)}`,
201
- ];
202
- if (answers.firebaseProjectId) rows.push(`${kleur.dim('Firebase:')} ${kleur.white(answers.firebaseProjectId)}`);
203
- if (answers.supabaseUrl) rows.push(`${kleur.dim('Supabase URL:')} ${kleur.white(answers.supabaseUrl)}`);
204
- if (answers.apiBaseUrl) rows.push(`${kleur.dim('API URL:')} ${kleur.white(answers.apiBaseUrl)}`);
205
- rows.push(`${kleur.dim(tr('new.firebase.confirm.modules') + ':')} ${kleur.white(modules)}`);
206
-
207
- console.log(infoBox(`📦 ${tr('new.firebase.confirm.title')}`, rows.join('\n')));
208
- }
209
-
210
190
  const STEP_LABELS = {
211
191
  'project-setup': { en: 'Project configured', pt: 'Projeto configurado', es: 'Proyecto configurado' },
212
192
  'pub-get': { en: 'Packages installed', pt: 'Pacotes instalados', es: 'Paquetes instalados' },
@@ -254,6 +234,7 @@ const STEP_LABELS = {
254
234
  'service-account': { en: 'Service account key created', pt: 'Chave de conta de servico criada', es: 'Clave de cuenta de servicio creada' },
255
235
  'firestore': { en: 'Firestore database created', pt: 'Banco Firestore criado', es: 'Base de datos Firestore creada' },
256
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)' },
257
238
  };
258
239
 
259
240
  const STEP_PROGRESS = {
@@ -275,6 +256,7 @@ const STEP_PROGRESS = {
275
256
  'service-account': { en: 'Creating service account key…', pt: 'Criando chave de conta de servico…', es: 'Creando clave de cuenta de servicio…' },
276
257
  'firestore': { en: 'Creating Firestore database…', pt: 'Criando banco Firestore…', es: 'Creando base de datos Firestore…' },
277
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…' },
278
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)' },
279
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!)' },
280
262
  };
@@ -632,7 +614,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
632
614
  await ensureBilling();
633
615
  }
634
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
+
635
622
  // ── Bundle ID — Quick uses derived value silently; Step-by-step lets user override ─
623
+ section('new.advanced.section.config');
636
624
  if (!isQuick) {
637
625
  const bundleId = await ui.text({
638
626
  message: tr('new.firebase.q.bundleId'),
@@ -767,7 +755,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
767
755
  ui.log.info(tr('new.firebase.create.estimatedTime'));
768
756
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
769
757
  let selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
770
- const ps1 = isQuick ? ui.makeQuickStepper({ color: paintLime }) : ui.makeTimedStepper();
758
+ const ps1 = ui.makeQuickStepper({ color: paintLime });
771
759
  ps1.next(tr('new.firebase.create.creating'));
772
760
  const ps1OnProgress = (key, data) => {
773
761
  if (key === 'wait-propagate') {
@@ -776,6 +764,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
776
764
  ps1.next(stepProgress('firestore', language));
777
765
  } else if (key === 'storage') {
778
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 || '')}`);
779
772
  } else if (key === 'auth-providers-warn') {
780
773
  ps1.stop();
781
774
  ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
@@ -864,7 +857,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
864
857
  }
865
858
  ui.log.message(tr('new.firebase.create.billingRetry.retrying'));
866
859
  selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
867
- const ps2 = isQuick ? ui.makeQuickStepper({ color: paintLime }) : ui.makeTimedStepper();
860
+ const ps2 = ui.makeQuickStepper({ color: paintLime });
868
861
  ps2.next(tr('new.firebase.create.creating'));
869
862
  lastResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
870
863
  includeWeb: firebaseIncludeWeb,
@@ -953,7 +946,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
953
946
  ui.log.info(tr('new.firebase.create.estimatedTime'));
954
947
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
955
948
  const selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
956
- const ps3 = isQuick ? ui.makeQuickStepper({ color: paintLime }) : ui.makeTimedStepper();
949
+ const ps3 = ui.makeQuickStepper({ color: paintLime });
957
950
  ps3.next(tr('new.firebase.create.creatingPush'));
958
951
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
959
952
  includeWeb: true,
@@ -1162,7 +1155,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1162
1155
 
1163
1156
  // ── Firebase existing project: enable APIs + create Firestore/Storage ───
1164
1157
  if (backend === 'firebase' && firebaseSetupMode === 'existing' && core.firebaseProjectId) {
1165
- const ps4 = ui.makeTimedStepper();
1158
+ const ps4 = ui.makeQuickStepper({ color: paintLime });
1166
1159
  ps4.next(stepProgress('enable-apis', language));
1167
1160
  const existingSetup = await setupExistingProject(core.firebaseProjectId, {
1168
1161
  onProgress: (key, data) => {
@@ -1175,6 +1168,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1175
1168
  ps4.next(stepProgress('firestore', language));
1176
1169
  } else if (key === 'storage') {
1177
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 || '')}`);
1178
1176
  } else if (key === 'auth-providers-warn') {
1179
1177
  ps4.stop();
1180
1178
  ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
@@ -1216,6 +1214,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1216
1214
  // it later with `kasy add facebook` when they have the credentials.
1217
1215
  modules = (MODULE_PRESETS.full || []).filter((m) => m !== 'facebook');
1218
1216
  } else {
1217
+ section('new.advanced.section.features');
1219
1218
  // Advanced mode: full multiselect — built from catalog, filtered by audience + backend.
1220
1219
  const visibleFeatures = getVisibleFeatures({ audience: KASY_AUDIENCE, backend });
1221
1220
 
@@ -1251,10 +1250,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1251
1250
  }
1252
1251
  }
1253
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');
1254
1258
  const rawModules = await ui.multiselect({
1255
1259
  message: tr('new.firebase.q.modules'),
1256
1260
  options: moduleOptions,
1257
- initialValues: [],
1261
+ initialValues: defaultSelected,
1258
1262
  required: false,
1259
1263
  onCancel: cancel,
1260
1264
  });
@@ -1265,13 +1269,30 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1265
1269
  modules = [...new Set([...modules, 'web'])];
1266
1270
  }
1267
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
+
1268
1289
  // ── Module-specific questions ───────────────────────────────────────────
1269
1290
  const moduleAnswers = {};
1270
1291
 
1271
1292
  if (modules.includes('revenuecat')) {
1272
- if (isQuick) {
1273
- // Quick mode: ship RevenueCat scaffolded with empty keys + default paywall.
1274
- // The user fills the keys later via `kasy configure revenuecat`.
1293
+ if (!askCreds) {
1294
+ // Defer mode: scaffold with empty keys + default paywall; user fills
1295
+ // them later via `kasy configure revenuecat`.
1275
1296
  moduleAnswers.rcTestKey = '';
1276
1297
  moduleAnswers.rcIosProdKey = '';
1277
1298
  moduleAnswers.rcAndroidProdKey = '';
@@ -1327,26 +1348,26 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1327
1348
  }
1328
1349
  }
1329
1350
 
1330
- // RC web key — only in advanced mode (optional credential, can configure later).
1331
- if (!isQuick && modules.includes('revenuecat') && modules.includes('web')) {
1332
- const rcWebKey = await ui.text({
1333
- message: tr('new.firebase.q.revenuecat.webKey'),
1334
- validate: (v) => {
1335
- if (!v || !v.trim()) return undefined; // optional — blank is fine
1336
- return /^rcb_/.test(v.trim()) ? undefined : tr('new.firebase.q.revenuecat.webKey.invalid');
1337
- },
1338
- onCancel: cancel,
1339
- });
1351
+ // RC web key — only when configuring inline. Otherwise mark scaffolded.
1352
+ if (modules.includes('revenuecat') && modules.includes('web')) {
1340
1353
  moduleAnswers.revenuecatWeb = true;
1341
- moduleAnswers.rcWebKey = (rcWebKey || '').trim();
1342
- } else if (modules.includes('revenuecat') && modules.includes('web')) {
1343
- // Quick mode: mark web billing as included but key will be configured later.
1344
- moduleAnswers.revenuecatWeb = true;
1345
- 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
+ }
1346
1367
  }
1347
1368
 
1348
- // Sentry DSN — already optional (blank = configure later). Skip in quick mode.
1349
- if (!isQuick && modules.includes('sentry')) {
1369
+ // Sentry DSN — only when configuring inline.
1370
+ if (askCreds && modules.includes('sentry')) {
1350
1371
  moduleAnswers.sentryDsn = await ui.text({
1351
1372
  message: tr('new.firebase.q.sentry.dsn'),
1352
1373
  validate: (v) => {
@@ -1359,23 +1380,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1359
1380
  });
1360
1381
  }
1361
1382
 
1362
- // Mixpanel token — already optional. Skip in quick mode.
1363
- if (!isQuick && modules.includes('analytics')) {
1383
+ // Mixpanel token — only when configuring inline.
1384
+ if (askCreds && modules.includes('analytics')) {
1364
1385
  moduleAnswers.mixpanelToken = await ui.text({
1365
1386
  message: tr('new.firebase.q.mixpanel.token'),
1366
1387
  onCancel: cancel,
1367
1388
  });
1368
1389
  }
1369
1390
 
1370
- // LLM Chat credentials — skip in quick mode, all fields optional.
1371
- if (!isQuick && modules.includes('llm_chat')) {
1372
- const configureLlmNow = await ui.confirm({
1373
- message: tr('new.q.llm_chat.configureNow'),
1374
- initialValue: true,
1375
- onCancel: cancel,
1376
- });
1377
-
1378
- if (configureLlmNow) {
1391
+ // LLM Chat credentials.
1392
+ if (modules.includes('llm_chat')) {
1393
+ if (askCreds) {
1379
1394
  moduleAnswers.llmProvider = await ui.select({
1380
1395
  message: tr('add.prompt.llmProvider'),
1381
1396
  initialValue: 'openai',
@@ -1396,40 +1411,33 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1396
1411
  } else {
1397
1412
  moduleAnswers.llmConfigureLater = true;
1398
1413
  }
1399
- } else if (isQuick && modules.includes('llm_chat')) {
1400
- moduleAnswers.llmConfigureLater = true;
1401
1414
  }
1402
1415
 
1403
- // Facebook — required credentials. Quick mode ships scaffolded but empty
1404
- // (user adds credentials later via `kasy configure facebook`).
1405
- if (modules.includes('facebook') && !isQuick) {
1406
- moduleAnswers.fbAppId = await ui.text({
1407
- message: tr('new.firebase.q.facebook.appId'),
1408
- validate: (v) => {
1409
- if (!v || !v.trim()) return tr('new.firebase.q.facebook.appId.required');
1410
- return /^\d+$/.test(v.trim()) ? undefined : tr('new.firebase.q.facebook.appId.invalid');
1411
- },
1412
- onCancel: cancel,
1413
- });
1414
- moduleAnswers.fbToken = await ui.password({
1415
- message: tr('new.firebase.q.facebook.token'),
1416
- validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.facebook.token.required')),
1417
- onCancel: cancel,
1418
- });
1419
- } else if (modules.includes('facebook') && isQuick) {
1420
- moduleAnswers.fbAppId = '';
1421
- moduleAnswers.fbToken = '';
1416
+ // Facebook — required credentials when inline; scaffold-only otherwise.
1417
+ if (modules.includes('facebook')) {
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
+ }
1422
1436
  }
1423
1437
 
1424
- // Server secrets (webhook, Meta Ads) — skip in quick mode, configure later via `kasy deploy`.
1425
- if (!isQuick && modules.includes('revenuecat') && (backend === 'supabase' || backend === 'firebase')) {
1426
- const configureSecretsNow = await ui.confirm({
1427
- message: tr('new.firebase.q.secrets.configureNow'),
1428
- initialValue: true,
1429
- onCancel: cancel,
1430
- });
1431
-
1432
- if (configureSecretsNow) {
1438
+ // Server secrets (webhook, Meta Ads) — only when configuring inline.
1439
+ if (modules.includes('revenuecat') && (backend === 'supabase' || backend === 'firebase')) {
1440
+ if (askCreds) {
1433
1441
  moduleAnswers.rcWebhookKey = await ui.text({
1434
1442
  message: tr('new.firebase.q.revenuecat.webhookKey'),
1435
1443
  initialValue: generateWebhookKey(),
@@ -1451,9 +1459,6 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1451
1459
  } else {
1452
1460
  moduleAnswers.secretsConfigureLater = true;
1453
1461
  }
1454
- } else if (isQuick && modules.includes('revenuecat')) {
1455
- // Quick mode: always defer secrets to `kasy deploy`.
1456
- moduleAnswers.secretsConfigureLater = true;
1457
1462
  }
1458
1463
 
1459
1464
  // ── Deploy is now a separate command (`kasy deploy`) ─────────────
@@ -1474,24 +1479,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1474
1479
  apiBaseUrl: core.apiBaseUrl?.trim(),
1475
1480
  };
1476
1481
 
1477
- // Step-by-step mode shows summary before generating so the user can confirm.
1478
- // Quick mode skips it here the summary is shown at the end with the success card.
1479
- if (!yes && !isQuick) {
1480
- printSummary(tr, answers);
1481
- const proceed = await ui.confirm({
1482
- message: tr('new.firebase.confirm.proceed'),
1483
- initialValue: true,
1484
- onCancel: cancel,
1485
- });
1486
- if (!proceed) {
1487
- ui.cancel(tr('prompt.cancelled'));
1488
- process.exit(0);
1489
- }
1490
- }
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.
1491
1485
 
1492
1486
  // ── Generate ────────────────────────────────────────────────────────────
1493
1487
  // Quick: single rolling line that mutates message. Advanced: stack each step.
1494
- const stepper = isQuick ? ui.makeQuickStepper({ color: paintLime }) : ui.makeTimedStepper();
1488
+ const stepper = ui.makeQuickStepper({ color: paintLime });
1495
1489
  // First step started here so even silent prep work shows progress.
1496
1490
  stepper.next(stepProgress('project-setup', language));
1497
1491
 
@@ -4,7 +4,7 @@ const fs = require('fs-extra');
4
4
  const kleur = require('kleur');
5
5
  const ui = require('../utils/ui');
6
6
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
7
- const { printCompactHeader } = require('../utils/brand');
7
+ const { printCompactHeader, paintLime } = require('../utils/brand');
8
8
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
9
9
 
10
10
  function listFlutterDevices(projectDir) {
@@ -251,18 +251,32 @@ async function runRun(directory, options = {}) {
251
251
  const deviceArgs = [];
252
252
  let resolvedDeviceLabel = null;
253
253
  let pickedDevice = null;
254
+ let isChromeTarget = false;
255
+ let isWebServerTarget = false;
254
256
  if (options.web) {
255
- deviceArgs.push('-d', 'chrome');
257
+ // Default: web-server (no auto-launched Chrome window with a throwaway
258
+ // profile). The URL is printed in lime so the user opens it in their own
259
+ // browser — keeping access to extensions, logged-in accounts, etc.
260
+ // Pass --open to fall back to flutter's auto-Chrome behavior.
261
+ if (options.open) {
262
+ isChromeTarget = true;
263
+ deviceArgs.push('-d', 'chrome');
264
+ } else {
265
+ isWebServerTarget = true;
266
+ deviceArgs.push('-d', 'web-server');
267
+ }
256
268
  } else if (options.ios) {
257
269
  deviceArgs.push('-d', 'ios');
258
270
  } else if (options.android) {
259
271
  deviceArgs.push('-d', 'android');
260
272
  } else if (options.device) {
273
+ if (options.device === 'chrome' || options.device === 'web-server') {
274
+ isChromeTarget = true;
275
+ }
261
276
  deviceArgs.push('-d', options.device);
262
277
  } else {
263
278
  const devices = listFlutterDevices(projectDir);
264
279
  if (devices.length > 1) {
265
- printCompactHeader(t);
266
280
  pickedDevice = await pickDevice(devices, t);
267
281
  if (!pickedDevice) {
268
282
  console.log(kleur.yellow(` ⚠ ${t('run.warn.nothingSelected')}`));
@@ -270,9 +284,18 @@ async function runRun(directory, options = {}) {
270
284
  }
271
285
  deviceArgs.push('-d', pickedDevice.id);
272
286
  resolvedDeviceLabel = `${pickedDevice.name} (${pickedDevice.id})`;
287
+ if (classifyDevice(pickedDevice) === 'web') isChromeTarget = true;
288
+ } else if (devices.length === 1 && classifyDevice(devices[0]) === 'web') {
289
+ // Single auto-picked Chrome — still force the fixed port.
290
+ isChromeTarget = true;
273
291
  }
274
- // 0 or 1 device → let flutter handle it; it picks the only one or
275
- // prints its own "no devices" message.
292
+ }
293
+
294
+ if (isChromeTarget || isWebServerTarget) {
295
+ // Pin a fixed port so the Chrome origin stays the same between runs.
296
+ // Firebase Auth persists sessions per-origin (IndexedDB) — a random port
297
+ // each run means the user gets logged out every restart.
298
+ deviceArgs.push('--web-port', options.webPort || '5555');
276
299
  }
277
300
 
278
301
  // Read dart-defines from .vscode/launch.json (skip if --no-defines)
@@ -307,7 +330,7 @@ async function runRun(directory, options = {}) {
307
330
  const envDefine = dartDefines.find((a) => a.startsWith('--dart-define=ENV='));
308
331
  const envValue = envDefine ? envDefine.split('=').pop() : null;
309
332
  const deviceLabel = options.web
310
- ? 'chrome'
333
+ ? (isWebServerTarget ? 'web-server' : 'chrome')
311
334
  : options.ios
312
335
  ? 'ios'
313
336
  : options.android
@@ -320,6 +343,11 @@ async function runRun(directory, options = {}) {
320
343
 
321
344
  printCompactHeader(t);
322
345
  console.log(kleur.bold(`${t('run.launching')}${summary}`));
346
+ if (isWebServerTarget) {
347
+ const url = `http://localhost:${options.webPort || '5555'}`;
348
+ console.log(` ${paintLime(`✦ ${t('run.web.open')}: ${url}`)}`);
349
+ console.log(kleur.dim(` ${t('run.web.openHint')}`));
350
+ }
323
351
  if (rcInfo && rcInfo.mode !== 'legacy') {
324
352
  const mode = (options.rc || 'auto').toLowerCase();
325
353
  let label;
@@ -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
  */
@@ -864,6 +924,15 @@ async function setupFromScratch(appName, bundleId, options = {}) {
864
924
  error: storageResult.error,
865
925
  url: `https://console.firebase.google.com/project/${projectId}/storage`,
866
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
+ }
867
936
  }
868
937
 
869
938
  onProgress('android-app');
@@ -970,6 +1039,15 @@ async function setupExistingProject(projectId, options = {}) {
970
1039
  error: storageResult.error,
971
1040
  url: `https://console.firebase.google.com/project/${projectId}/storage`,
972
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
+ }
973
1051
  }
974
1052
 
975
1053
  return {
@@ -1033,6 +1111,7 @@ async function registerDebugSha1(projectId, bundleId) {
1033
1111
  module.exports = {
1034
1112
  setupFromScratch,
1035
1113
  setupExistingProject,
1114
+ applyStorageCors,
1036
1115
  checkBillingEnabled,
1037
1116
  enableAuthProviders,
1038
1117
  listBillingAccounts,
@@ -19,7 +19,7 @@ const boxen = boxenPackage.default || boxenPackage;
19
19
  //
20
20
  // kleur 4 in this repo doesn't ship `.hex()`, so we emit 24-bit ANSI directly.
21
21
  // Truecolor is supported by every macOS/Linux terminal we care about.
22
- const BRAND_RGB = { r: 210, g: 245, b: 30 }; // #D2F51E lime
22
+ const BRAND_RGB = { r: 132, g: 204, b: 22 }; // #84CC16 lime (Tailwind lime-500, balanced for dark and light terminals)
23
23
  const paintLime = (text) => `\x1b[38;2;${BRAND_RGB.r};${BRAND_RGB.g};${BRAND_RGB.b}m${text}\x1b[0m`;
24
24
  const wordmark = paintLime;
25
25
  const DOMAIN_SUFFIX = kleur.gray('.dev');
@@ -575,6 +575,10 @@ module.exports = {
575
575
  'new.firebase.confirm.modules': 'Features',
576
576
  'new.firebase.confirm.none': 'none',
577
577
  'new.firebase.confirm.proceed': 'Create project now?',
578
+ 'new.advanced.section.config': 'App configuration',
579
+ 'new.advanced.section.features': 'Features',
580
+ 'new.advanced.section.creds': 'Credentials',
581
+ 'new.advanced.q.configureCredsNow': 'Enter credentials now? (you can defer to `kasy configure` later)',
578
582
 
579
583
  'new.firebase.step.copying': 'Setting up your project...',
580
584
  'new.firebase.step.pubGet': 'Installing Flutter packages...',
@@ -712,6 +716,8 @@ module.exports = {
712
716
  // run command
713
717
  'cli.command.run.description': 'Run your app on phone, simulator, or browser',
714
718
  'run.launching': 'Launching Flutter app...',
719
+ 'run.web.open': 'Open in your browser',
720
+ 'run.web.openHint': 'Cmd+click the link above (or copy/paste it). Use --open to auto-launch a dedicated Flutter Chrome window instead.',
715
721
  'run.prompt.pickDevice': 'Multiple devices detected. Which one do you want to run on?',
716
722
  'run.warn.nothingSelected': 'No device selected.',
717
723
  'run.updateHint.prefix': 'Project improvements available —',
@@ -575,6 +575,10 @@ module.exports = {
575
575
  'new.firebase.confirm.modules': 'Features',
576
576
  'new.firebase.confirm.none': 'ninguno',
577
577
  'new.firebase.confirm.proceed': '¿Crear el proyecto ahora?',
578
+ 'new.advanced.section.config': 'Configuración del app',
579
+ 'new.advanced.section.features': 'Funcionalidades',
580
+ 'new.advanced.section.creds': 'Credenciales',
581
+ 'new.advanced.q.configureCredsNow': '¿Configurar credenciales ahora? (puedes dejarlo para `kasy configure`)',
578
582
 
579
583
  'new.firebase.step.copying': 'Copiando plantilla del proyecto...',
580
584
  'new.firebase.step.pubGet': 'Instalando paquetes Flutter...',
@@ -745,6 +749,8 @@ module.exports = {
745
749
  'reset.warn.launcherCacheFailed': 'No se pudo limpiar la caché del launcher.',
746
750
  'reset.warn.launcherNotDetected': 'Launcher por defecto no detectado — saltando limpieza de caché.',
747
751
  'run.launching': 'Iniciando app Flutter...',
752
+ 'run.web.open': 'Abre en tu navegador',
753
+ 'run.web.openHint': 'Cmd+clic en el enlace de arriba (o copia/pega). Usa --open para abrir automáticamente una ventana dedicada de Chrome de Flutter.',
748
754
  'run.prompt.pickDevice': 'Varios dispositivos detectados. ¿En cuál quieres ejecutar?',
749
755
  'run.warn.nothingSelected': 'Ningún dispositivo seleccionado.',
750
756
  'run.updateHint.prefix': 'Mejoras disponibles para el proyecto —',
@@ -575,6 +575,10 @@ module.exports = {
575
575
  'new.firebase.confirm.modules': 'Features',
576
576
  'new.firebase.confirm.none': 'nenhum',
577
577
  'new.firebase.confirm.proceed': 'Criar o projeto agora?',
578
+ 'new.advanced.section.config': 'Configuração do app',
579
+ 'new.advanced.section.features': 'Funcionalidades',
580
+ 'new.advanced.section.creds': 'Credenciais',
581
+ 'new.advanced.q.configureCredsNow': 'Configurar credenciais agora? (pode deixar pra depois com `kasy configure`)',
578
582
 
579
583
  'new.firebase.step.copying': 'Criando seu projeto...',
580
584
  'new.firebase.step.pubGet': 'Instalando pacotes Flutter...',
@@ -745,6 +749,8 @@ module.exports = {
745
749
  'reset.warn.launcherCacheFailed': 'Não foi possível limpar o cache do launcher.',
746
750
  'reset.warn.launcherNotDetected': 'Launcher padrão não detectado — pulando limpeza de cache.',
747
751
  'run.launching': 'Iniciando app Flutter...',
752
+ 'run.web.open': 'Abra no seu navegador',
753
+ 'run.web.openHint': 'Cmd+clique no link acima (ou copie e cole). Use --open pra abrir automaticamente num Chrome dedicado do Flutter.',
748
754
  'run.prompt.pickDevice': 'Vários dispositivos detectados. Em qual deles rodar?',
749
755
  'run.warn.nothingSelected': 'Nenhum dispositivo selecionado.',
750
756
  'run.updateHint.prefix': 'Melhorias disponíveis para o projeto —',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.1",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -51,7 +51,6 @@
51
51
  "boxen": "^8.0.1",
52
52
  "commander": "^12.0.0",
53
53
  "fs-extra": "^11.2.0",
54
- "gradient-string": "^1.2.0",
55
54
  "kleur": "^4.1.5",
56
55
  "pngjs": "^7.0.0",
57
56
  "yaml": "^2.4.2"