kasy-cli 1.13.0 → 1.14.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 (120) hide show
  1. package/bin/kasy.js +122 -7
  2. package/lib/commands/add.js +2 -2
  3. package/lib/commands/codemagic.js +11 -4
  4. package/lib/commands/deploy.js +3 -3
  5. package/lib/commands/favicon.js +115 -0
  6. package/lib/commands/icon.js +143 -0
  7. package/lib/commands/ios.js +20 -5
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +287 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +3 -4
  13. package/lib/commands/update.js +1 -1
  14. package/lib/scaffold/backends/api/patch/README.md +1 -1
  15. package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
  17. package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
  18. package/lib/scaffold/backends/firebase/tokens.js +2 -2
  19. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
  20. package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
  21. package/lib/scaffold/backends/supabase/patch/README.md +1 -1
  22. package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
  23. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
  24. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
  25. package/lib/utils/apple-release.js +85 -16
  26. package/lib/utils/checks.js +4 -105
  27. package/lib/utils/flutter-run.js +173 -0
  28. package/lib/utils/i18n.js +335 -0
  29. package/lib/utils/mobile-identity.js +35 -0
  30. package/lib/utils/ui.js +114 -0
  31. package/package.json +1 -2
  32. package/templates/firebase/README.en.md +1 -1
  33. package/templates/firebase/README.es.md +1 -1
  34. package/templates/firebase/README.md +1 -1
  35. package/templates/firebase/android/app/build.gradle.kts +10 -1
  36. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  37. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  38. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +160 -11
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  41. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  42. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  43. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  44. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  45. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  46. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +46 -0
  47. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  53. package/templates/firebase/assets/images/favicon.png +0 -0
  54. package/templates/firebase/assets/images/icon.png +0 -0
  55. package/templates/firebase/firestore.indexes.json +10 -0
  56. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  57. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  58. package/templates/firebase/functions/src/index.ts +1 -0
  59. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  60. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  61. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  62. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  68. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  69. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  70. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  76. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  77. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  78. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  79. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  80. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  81. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  82. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  83. package/templates/firebase/ios/Runner/Info.plist +2 -2
  84. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  85. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  86. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  87. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  88. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  89. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +67 -19
  90. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  91. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  92. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  93. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  94. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  95. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  96. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  97. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  98. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  99. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  100. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  101. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  102. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  103. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  104. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  105. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  106. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  107. package/templates/firebase/pubspec.yaml +6 -1
  108. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  109. package/templates/firebase/web/favicon.png +0 -0
  110. package/templates/firebase/web/icons/Icon-192.png +0 -0
  111. package/templates/firebase/web/icons/Icon-512.png +0 -0
  112. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  113. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  114. package/templates/firebase/web/index.html +3 -0
  115. package/templates/firebase/web/manifest.json +3 -3
  116. package/templates/firebase/assets/images/app_icon.png +0 -0
  117. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  118. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  119. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  120. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
package/bin/kasy.js CHANGED
@@ -10,12 +10,15 @@ const { runValidate } = require('../lib/commands/validate');
10
10
  const { runDeployCommand } = require('../lib/commands/deploy');
11
11
  const { runCheck } = require('../lib/commands/check');
12
12
  const { runRun } = require('../lib/commands/run');
13
+ const { runReset } = require('../lib/commands/reset');
13
14
  const { runAdd } = require('../lib/commands/add');
14
15
  const { runRemove } = require('../lib/commands/remove');
15
16
  const { runUpdate } = require('../lib/commands/update');
16
17
  const { runDocs } = require('../lib/commands/docs');
17
18
  const { runNotificationsText } = require('../lib/commands/notifications');
18
19
  const { runSplash } = require('../lib/commands/splash');
20
+ const { runIcon } = require('../lib/commands/icon');
21
+ const { runFavicon } = require('../lib/commands/favicon');
19
22
  const {
20
23
  runConfigure: runIosConfigure,
21
24
  runBuild: runIosBuild,
@@ -37,6 +40,7 @@ const { promptLanguage } = require('../lib/utils/prompts');
37
40
  const { ensureLicenseKey, shouldRequireLicenseForArgv } = require('../lib/utils/license-gate');
38
41
  const { checkForUpdates } = require('../lib/utils/updates');
39
42
  const { printCompactHeader } = require('../lib/utils/brand');
43
+ const ui = require('../lib/utils/ui');
40
44
 
41
45
  function createLocalizedHelpConfig(t) {
42
46
  return {
@@ -131,11 +135,15 @@ function createLocalizedHelpConfig(t) {
131
135
  // Group root commands by intent for easier scanning by non-devs.
132
136
  const groups = [
133
137
  { id: 'start', ids: ['new', 'doctor', 'features'] },
134
- { id: 'work', ids: ['add', 'remove', 'update', 'run'] },
138
+ { id: 'work', ids: ['add', 'remove', 'update', 'run', 'reset', 'splash', 'icon', 'favicon', 'notifications'] },
135
139
  { id: 'publish', ids: ['deploy', 'check', 'ios', 'codemagic'] },
136
- { id: 'maintenance', ids: ['upgrade', 'version', 'uninstall', 'docs', 'notifications'] },
140
+ { id: 'maintenance', ids: ['upgrade', 'version', 'uninstall', 'docs'] },
137
141
  { id: 'advanced', ids: ['setup', 'validate'] },
138
142
  ];
143
+ // Commander auto-generates a `help` subcommand. Hide it from the
144
+ // "Other" section — the tip line at the bottom already teaches users
145
+ // how to get per-command help.
146
+ const HIDDEN_FROM_OTHER = new Set(['help']);
139
147
  const knownIds = new Set(groups.flatMap((g) => g.ids));
140
148
  const byName = new Map(visibleCommands.map((c) => [c.name(), c]));
141
149
  for (const group of groups) {
@@ -157,7 +165,7 @@ function createLocalizedHelpConfig(t) {
157
165
  ]);
158
166
  }
159
167
  const otherItems = visibleCommands
160
- .filter((c) => !knownIds.has(c.name()))
168
+ .filter((c) => !knownIds.has(c.name()) && !HIDDEN_FROM_OTHER.has(c.name()))
161
169
  .map((sub) =>
162
170
  formatItem(
163
171
  localizeSubcommandTerm(helper.subcommandTerm(sub)),
@@ -221,7 +229,7 @@ function buildProgram(language) {
221
229
  program.addHelpText('beforeAll', `${kleur.bold().cyan(t('cli.tagline'))}\n`);
222
230
  program.addHelpText(
223
231
  'after',
224
- `\n${t('cli.help.quickStart')} ${kleur.cyan('kasy new')}\n\n${t('cli.help.more', { command: kleur.cyan(`kasy help <${helpParam}>`) })}\n`
232
+ `${t('cli.help.quickStart')} ${kleur.cyan('kasy new')}\n`
225
233
  );
226
234
 
227
235
  applyLocalizedHelp(
@@ -343,6 +351,22 @@ function buildProgram(language) {
343
351
  t
344
352
  );
345
353
 
354
+ applyLocalizedHelp(
355
+ program
356
+ .command('reset')
357
+ .argument('[directory]', 'Project folder (default: current directory)', '.')
358
+ .option('--ios', 'Reset on iOS simulator/device only')
359
+ .option('--android', 'Reset on Android emulator/device only')
360
+ .option('--web', 'Show web reset instructions only')
361
+ .option('-d, --device <id>', 'Reset specific device ID')
362
+ .option('--no-reinstall', 'Only uninstall, skip reinstall')
363
+ .description(t('cli.command.reset.description'))
364
+ .action(async (directory, options) => {
365
+ await runReset(directory, { language, ...options });
366
+ }),
367
+ t
368
+ );
369
+
346
370
  applyLocalizedHelp(
347
371
  program
348
372
  .command('add')
@@ -422,7 +446,27 @@ function buildProgram(language) {
422
446
  t
423
447
  );
424
448
 
425
- const iosCmd = program.command('ios').description(t('cli.command.ios.description'));
449
+ const iosCmd = program
450
+ .command('ios')
451
+ .description(t('cli.command.ios.description'))
452
+ .addHelpText('before', `\n${t('cli.command.ios.help.before')}`)
453
+ .action(async () => {
454
+ printCompactHeader(t);
455
+ ui.intro(kleur.cyan(t('cli.command.ios.picker.intro')));
456
+ const sub = await ui.select({
457
+ message: t('cli.command.ios.picker.message'),
458
+ options: [
459
+ { value: 'configure', label: t('cli.command.ios.configure.description') },
460
+ { value: 'release', label: t('cli.command.ios.release.description') },
461
+ { value: 'build', label: t('cli.command.ios.build.description') },
462
+ { value: 'clean', label: t('cli.command.ios.clean.description') },
463
+ ],
464
+ });
465
+ if (sub === 'configure') await runIosConfigure('.', { language });
466
+ else if (sub === 'release') await runIosRelease('.', { language });
467
+ else if (sub === 'build') await runIosBuild('.', { language });
468
+ else if (sub === 'clean') await runIosClean('.', { language });
469
+ });
426
470
  applyLocalizedHelp(
427
471
  iosCmd
428
472
  .command('configure')
@@ -468,7 +512,31 @@ function buildProgram(language) {
468
512
  t
469
513
  );
470
514
 
471
- const codemagicCmd = program.command('codemagic').description(t('cli.command.codemagic.description'));
515
+ const codemagicCmd = program
516
+ .command('codemagic')
517
+ .description(t('cli.command.codemagic.description'))
518
+ .addHelpText('before', `\n${t('cli.command.codemagic.help.before')}`)
519
+ .action(async () => {
520
+ printCompactHeader(t);
521
+ ui.intro(kleur.cyan(t('cli.command.codemagic.picker.intro')));
522
+ const sub = await ui.select({
523
+ message: t('cli.command.codemagic.picker.message'),
524
+ options: [
525
+ { value: 'configure', label: t('cli.command.codemagic.configure.description') },
526
+ { value: 'release', label: t('cli.command.codemagic.release.description') },
527
+ { value: 'status', label: t('cli.command.codemagic.status.description'), hint: t('cli.command.codemagic.picker.statusHint') },
528
+ ],
529
+ });
530
+ if (sub === 'configure') await runCodemagicConfigure('.', { language });
531
+ else if (sub === 'release') await runCodemagicRelease('.', { language });
532
+ else if (sub === 'status') {
533
+ const buildId = await ui.text({
534
+ message: 'Build ID',
535
+ validate: (v) => (v && v.trim() ? undefined : 'Build ID is required'),
536
+ });
537
+ await runCodemagicStatus(buildId?.trim(), '.', { language });
538
+ }
539
+ });
472
540
  applyLocalizedHelp(
473
541
  codemagicCmd
474
542
  .command('configure')
@@ -531,9 +599,56 @@ function buildProgram(language) {
531
599
  t
532
600
  );
533
601
 
602
+ applyLocalizedHelp(
603
+ program
604
+ .command('icon')
605
+ .argument('[directory]', 'Project folder (default: current directory)', '.')
606
+ .requiredOption('--image <path>', 'PNG with the app icon (square, 1024x1024 recommended)')
607
+ .option('--skip-generate', 'Only copy file, do not run flutter_launcher_icons', false)
608
+ .description(t('cli.command.icon.description'))
609
+ .action(async (directory, options) => {
610
+ const dir = directory || '.';
611
+ await runIcon(dir, {
612
+ language,
613
+ image: options.image,
614
+ skipGenerate: options.skipGenerate,
615
+ });
616
+ }),
617
+ t
618
+ );
619
+
620
+ applyLocalizedHelp(
621
+ program
622
+ .command('favicon')
623
+ .argument('[directory]', 'Project folder (default: current directory)', '.')
624
+ .requiredOption('--image <path>', 'PNG with the web favicon / PWA icon (square, 512x512+ recommended)')
625
+ .option('--skip-generate', 'Only copy file, do not run flutter_launcher_icons', false)
626
+ .description(t('cli.command.favicon.description'))
627
+ .action(async (directory, options) => {
628
+ const dir = directory || '.';
629
+ await runFavicon(dir, {
630
+ language,
631
+ image: options.image,
632
+ skipGenerate: options.skipGenerate,
633
+ });
634
+ }),
635
+ t
636
+ );
637
+
534
638
  const notificationsCmd = program
535
639
  .command('notifications')
536
- .description(t('cli.command.notifications.description'));
640
+ .description(t('cli.command.notifications.description'))
641
+ .action(async () => {
642
+ printCompactHeader(t);
643
+ ui.intro(kleur.cyan(t('cli.command.notifications.picker.intro')));
644
+ const sub = await ui.select({
645
+ message: t('cli.command.notifications.picker.message'),
646
+ options: [
647
+ { value: 'text', label: t('cli.command.notifications.text.description') },
648
+ ],
649
+ });
650
+ if (sub === 'text') await runNotificationsText('.', { language, directory: '.', lang: 'all' });
651
+ });
537
652
  applyLocalizedHelp(
538
653
  notificationsCmd
539
654
  .command('text')
@@ -460,7 +460,7 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
460
460
  // 3. Deploy the LLM function automatically
461
461
  if (backend === 'api') return { deployOk: false, deployAttempted: false };
462
462
 
463
- const deploySpinner = ui.spinner();
463
+ const deploySpinner = ui.timedSpinner();
464
464
  deploySpinner.start(t('add.llm_chat.deploying'));
465
465
  try {
466
466
  if (backend === 'firebase') {
@@ -761,7 +761,7 @@ async function runAdd(module, options = {}) {
761
761
  // 11. build_runner (only when needed: features with codegen)
762
762
  const needsBuildRunner = ['revenuecat', 'analytics', 'sentry', 'onboarding', 'llm_chat', 'feedback'].includes(normalized);
763
763
  if (needsBuildRunner) {
764
- const spinner = ui.spinner();
764
+ const spinner = ui.timedSpinner();
765
765
  spinner.start(t('add.buildRunner'));
766
766
  try {
767
767
  await execAsync('dart run build_runner build --delete-conflicting-outputs', { cwd: projectDir, timeout: 600_000 });
@@ -107,8 +107,11 @@ async function runRelease(directory, options = {}) {
107
107
  printCompactHeader(t);
108
108
  ui.intro(t('codemagic.release.title'));
109
109
 
110
+ const spinner = ui.spinner();
111
+ spinner.start(t('codemagic.release.spin'));
110
112
  try {
111
113
  const result = await triggerBuild(projectDir, validation.env);
114
+ spinner.stop(t('codemagic.release.spinDone'));
112
115
  const buildId = result.buildId || result._id;
113
116
  if (buildId) {
114
117
  ui.log.success(t('codemagic.release.triggered'));
@@ -119,7 +122,7 @@ async function runRelease(directory, options = {}) {
119
122
  ui.outro(t('codemagic.release.triggered'));
120
123
  }
121
124
  } catch (err) {
122
- ui.log.error(err.message);
125
+ spinner.stop(err.message, 2);
123
126
  process.exitCode = 1;
124
127
  }
125
128
  }
@@ -145,14 +148,18 @@ async function runStatus(buildId, directory, options = {}) {
145
148
  return;
146
149
  }
147
150
 
151
+ printCompactHeader(t);
152
+ ui.intro(`${t('codemagic.status.title')}: ${buildId}`);
153
+
154
+ const spinner = ui.spinner();
155
+ spinner.start(t('codemagic.status.spin'));
148
156
  try {
149
157
  const result = await getBuildStatus(buildId, validation.env.CODEMAGIC_API_TOKEN);
150
- printCompactHeader(t);
151
- ui.intro(`${t('codemagic.status.title')}: ${buildId}`);
158
+ spinner.stop(t('codemagic.status.spinDone'));
152
159
  ui.note(JSON.stringify(result, null, 2));
153
160
  ui.outro('');
154
161
  } catch (err) {
155
- ui.log.error(err.message);
162
+ spinner.stop(err.message, 2);
156
163
  process.exitCode = 1;
157
164
  }
158
165
  }
@@ -122,7 +122,7 @@ async function deployFirebase(projectDir, options, tr) {
122
122
  }
123
123
  }
124
124
 
125
- const spinner = ui.spinner();
125
+ const spinner = ui.timedSpinner();
126
126
  spinner.start(tr('deploy.firebase.spin'));
127
127
  let steps;
128
128
  try {
@@ -173,7 +173,7 @@ async function deploySupabase(projectDir, tr) {
173
173
  const firebaseProjectId = await readFirebaseProjectId(projectDir);
174
174
 
175
175
  if (firebaseProjectId) {
176
- const fcmSpinner = ui.spinner();
176
+ const fcmSpinner = ui.timedSpinner();
177
177
  fcmSpinner.start(tr('deploy.supabase.sakSpin'));
178
178
  const fcmResult = await createFcmServiceAccountKey(firebaseProjectId);
179
179
  fcmSpinner.stop(tr('deploy.supabase.sakSpinDone'));
@@ -194,7 +194,7 @@ async function deploySupabase(projectDir, tr) {
194
194
  }
195
195
 
196
196
  // ── 3. Deploy edge functions ────────────────────────────────────────────
197
- const fnSpinner = ui.spinner();
197
+ const fnSpinner = ui.timedSpinner();
198
198
  fnSpinner.start(tr('deploy.supabase.fnSpin'));
199
199
  const fnResult = await deployFunctions(projectDir);
200
200
  fnSpinner.stop(tr('deploy.supabase.fnSpinDone'));
@@ -0,0 +1,115 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const kleur = require('kleur');
4
+ const ui = require('../utils/ui');
5
+ const { printCompactHeader } = require('../utils/brand');
6
+ const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
7
+ const { inspectPng } = require('./splash');
8
+ const { runFlutterLauncherIcons } = require('./icon');
9
+
10
+ const ASSETS_DIR = path.join('assets', 'images');
11
+ const FAVICON_NAME = 'favicon.png';
12
+
13
+ async function assertKasyProject(projectDir, t) {
14
+ const kitSetupPath = path.join(projectDir, 'kit_setup.json');
15
+ const pubspecPath = path.join(projectDir, 'pubspec.yaml');
16
+ if (!(await fs.pathExists(kitSetupPath)) && !(await fs.pathExists(pubspecPath))) {
17
+ throw new Error(t('favicon.error.notKasyProject'));
18
+ }
19
+ const assetsDir = path.join(projectDir, ASSETS_DIR);
20
+ await fs.ensureDir(assetsDir);
21
+ }
22
+
23
+ function resolveInputPath(flagValue) {
24
+ if (!flagValue) return null;
25
+ const expanded = flagValue.startsWith('~')
26
+ ? path.join(require('node:os').homedir(), flagValue.slice(1))
27
+ : flagValue;
28
+ return path.resolve(expanded);
29
+ }
30
+
31
+ async function runFavicon(projectDir, options = {}) {
32
+ const language = options.language || detectDefaultLanguage();
33
+ const t = createTranslator(language);
34
+
35
+ printCompactHeader();
36
+ ui.intro(kleur.bold().cyan(t('favicon.intro')));
37
+
38
+ await assertKasyProject(projectDir, t);
39
+
40
+ const imagePath = resolveInputPath(options.image);
41
+
42
+ if (!imagePath) {
43
+ ui.log.error(t('favicon.error.imageRequired'));
44
+ ui.log.message(kleur.dim('kasy favicon --image <favicon.png>'));
45
+ process.exit(1);
46
+ }
47
+
48
+ if (!(await fs.pathExists(imagePath))) {
49
+ ui.log.error(t('favicon.error.fileNotFound', { path: imagePath }));
50
+ process.exit(1);
51
+ }
52
+
53
+ const inspectSpinner = ui.spinner();
54
+ inspectSpinner.start(t('favicon.validating'));
55
+
56
+ let info;
57
+ try {
58
+ info = await inspectPng(imagePath);
59
+ } catch (err) {
60
+ inspectSpinner.stop(`✖ ${err.message || t('favicon.error.notPng')}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ if (!info.valid) {
65
+ inspectSpinner.stop(`✖ ${t('favicon.error.notPng')}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ inspectSpinner.stop(t('favicon.validated'));
70
+
71
+ const warnings = [];
72
+ if (info.width !== info.height) {
73
+ warnings.push(t('favicon.warn.notSquare', { w: info.width, h: info.height }));
74
+ }
75
+ if (info.width < 512 || info.height < 512) {
76
+ warnings.push(t('favicon.warn.small', { w: info.width, h: info.height }));
77
+ }
78
+ if (warnings.length > 0) {
79
+ ui.note(warnings.map((w) => `${kleur.yellow('⚠')} ${w}`).join('\n'), t('favicon.warn.title'));
80
+ }
81
+
82
+ const dest = path.join(projectDir, ASSETS_DIR, FAVICON_NAME);
83
+
84
+ const copySpinner = ui.spinner();
85
+ copySpinner.start(t('favicon.copying'));
86
+ await fs.copy(imagePath, dest, { overwrite: true });
87
+ copySpinner.stop(t('favicon.copied'));
88
+
89
+ if (options.skipGenerate) {
90
+ ui.note(t('favicon.skipGenerate.hint'), t('favicon.skipGenerate.title'));
91
+ ui.outro(t('favicon.done'));
92
+ return;
93
+ }
94
+
95
+ const genSpinner = ui.spinner();
96
+ genSpinner.start(t('favicon.generating'));
97
+ const result = await runFlutterLauncherIcons(projectDir);
98
+ if (result.ok) {
99
+ genSpinner.stop(t('favicon.generated'));
100
+ } else {
101
+ genSpinner.stop(`⚠ ${t('favicon.error.generateFailed')}`);
102
+ if (result.stderr) {
103
+ ui.log.message(kleur.dim(result.stderr.split('\n').slice(0, 8).join('\n')));
104
+ }
105
+ ui.log.message(kleur.dim('dart run flutter_launcher_icons'));
106
+ process.exit(1);
107
+ }
108
+
109
+ const summary = `${kleur.bold(t('favicon.summary.favicon'))}: ${kleur.white(FAVICON_NAME)} (${info.width}x${info.height})`;
110
+ ui.note(summary, t('favicon.summary.title'));
111
+
112
+ ui.outro(t('favicon.done'));
113
+ }
114
+
115
+ module.exports = { runFavicon };
@@ -0,0 +1,143 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { exec } = require('node:child_process');
4
+ const { promisify } = require('node:util');
5
+ const kleur = require('kleur');
6
+ const ui = require('../utils/ui');
7
+ const { printCompactHeader } = require('../utils/brand');
8
+ const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
9
+ const { inspectPng } = require('./splash');
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ const ASSETS_DIR = path.join('assets', 'images');
14
+ const ICON_NAME = 'icon.png';
15
+
16
+ async function assertKasyProject(projectDir, t) {
17
+ const kitSetupPath = path.join(projectDir, 'kit_setup.json');
18
+ const pubspecPath = path.join(projectDir, 'pubspec.yaml');
19
+ if (!(await fs.pathExists(kitSetupPath)) && !(await fs.pathExists(pubspecPath))) {
20
+ throw new Error(t('icon.error.notKasyProject'));
21
+ }
22
+ const assetsDir = path.join(projectDir, ASSETS_DIR);
23
+ await fs.ensureDir(assetsDir);
24
+ }
25
+
26
+ function resolveInputPath(flagValue) {
27
+ if (!flagValue) return null;
28
+ const expanded = flagValue.startsWith('~')
29
+ ? path.join(require('node:os').homedir(), flagValue.slice(1))
30
+ : flagValue;
31
+ return path.resolve(expanded);
32
+ }
33
+
34
+ async function runIcon(projectDir, options = {}) {
35
+ const language = options.language || detectDefaultLanguage();
36
+ const t = createTranslator(language);
37
+
38
+ printCompactHeader();
39
+ ui.intro(kleur.bold().cyan(t('icon.intro')));
40
+
41
+ await assertKasyProject(projectDir, t);
42
+
43
+ const imagePath = resolveInputPath(options.image);
44
+
45
+ if (!imagePath) {
46
+ ui.log.error(t('icon.error.imageRequired'));
47
+ ui.log.message(kleur.dim('kasy icon --image <icon.png>'));
48
+ process.exit(1);
49
+ }
50
+
51
+ if (!(await fs.pathExists(imagePath))) {
52
+ ui.log.error(t('icon.error.fileNotFound', { path: imagePath }));
53
+ process.exit(1);
54
+ }
55
+
56
+ const inspectSpinner = ui.spinner();
57
+ inspectSpinner.start(t('icon.validating'));
58
+
59
+ let info;
60
+ try {
61
+ info = await inspectPng(imagePath);
62
+ } catch (err) {
63
+ inspectSpinner.stop(`✖ ${err.message || t('icon.error.notPng')}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ if (!info.valid) {
68
+ inspectSpinner.stop(`✖ ${t('icon.error.notPng')}`);
69
+ process.exit(1);
70
+ }
71
+
72
+ inspectSpinner.stop(t('icon.validated'));
73
+
74
+ const warnings = [];
75
+ if (info.width !== info.height) {
76
+ warnings.push(t('icon.warn.notSquare', { w: info.width, h: info.height }));
77
+ }
78
+ if (info.width < 1024 || info.height < 1024) {
79
+ warnings.push(t('icon.warn.small', { w: info.width, h: info.height }));
80
+ }
81
+ if (info.hasAlpha) {
82
+ warnings.push(t('icon.warn.hasAlpha'));
83
+ }
84
+ if (warnings.length > 0) {
85
+ ui.note(warnings.map((w) => `${kleur.yellow('⚠')} ${w}`).join('\n'), t('icon.warn.title'));
86
+ }
87
+
88
+ const dest = path.join(projectDir, ASSETS_DIR, ICON_NAME);
89
+
90
+ const copySpinner = ui.spinner();
91
+ copySpinner.start(t('icon.copying'));
92
+ await fs.copy(imagePath, dest, { overwrite: true });
93
+ copySpinner.stop(t('icon.copied'));
94
+
95
+ if (options.skipGenerate) {
96
+ ui.note(t('icon.skipGenerate.hint'), t('icon.skipGenerate.title'));
97
+ ui.outro(t('icon.done'));
98
+ return;
99
+ }
100
+
101
+ const genSpinner = ui.spinner();
102
+ genSpinner.start(t('icon.generating'));
103
+ const result = await runFlutterLauncherIcons(projectDir);
104
+ if (result.ok) {
105
+ genSpinner.stop(t('icon.generated'));
106
+ } else {
107
+ genSpinner.stop(`⚠ ${t('icon.error.generateFailed')}`);
108
+ if (result.stderr) {
109
+ ui.log.message(kleur.dim(result.stderr.split('\n').slice(0, 8).join('\n')));
110
+ }
111
+ ui.log.message(kleur.dim('dart run flutter_launcher_icons'));
112
+ process.exit(1);
113
+ }
114
+
115
+ const summary = `${kleur.bold(t('icon.summary.icon'))}: ${kleur.white(ICON_NAME)} (${info.width}x${info.height})`;
116
+ ui.note(summary, t('icon.summary.title'));
117
+ ui.note(t('icon.reinstall.hint'), t('icon.reinstall.title'));
118
+
119
+ ui.outro(t('icon.done'));
120
+ }
121
+
122
+ async function runFlutterLauncherIcons(projectDir) {
123
+ try {
124
+ const { stdout, stderr } = await execAsync(
125
+ 'dart run flutter_launcher_icons',
126
+ {
127
+ cwd: projectDir,
128
+ maxBuffer: 16 * 1024 * 1024,
129
+ timeout: 240_000,
130
+ },
131
+ );
132
+ return { ok: true, stdout, stderr };
133
+ } catch (err) {
134
+ return {
135
+ ok: false,
136
+ error: err.message,
137
+ stdout: err.stdout || '',
138
+ stderr: err.stderr || '',
139
+ };
140
+ }
141
+ }
142
+
143
+ module.exports = { runIcon, runFlutterLauncherIcons };
@@ -52,6 +52,8 @@ async function runConfigure(directory, options = {}) {
52
52
  openUrl(URL_APP_STORE_CONNECT_API);
53
53
  openUrl(URL_APP_STORE_CONNECT_APPS);
54
54
 
55
+ ui.note(t('ios.configure.note.body'), t('ios.configure.note.title'));
56
+
55
57
  const cancel = () => { ui.cancel(t('ios.configure.cancelled')); process.exit(0); };
56
58
  const required = (v) => (v && String(v).trim().length > 0 ? undefined : t('ios.configure.q.required'));
57
59
 
@@ -126,12 +128,25 @@ async function runBuildOrRelease(directory, options = {}, mode) {
126
128
  const validation = await validateAppleSetup(projectDir);
127
129
  if (!validation.ok) {
128
130
  printCompactHeader(t);
129
- ui.log.error(t('ios.error.notConfigured'));
130
- if (validation.issues.includes('missing_env')) {
131
- ui.log.message(kleur.dim(t('ios.error.runConfigure')));
131
+ ui.log.warn(t('ios.error.notConfigured'));
132
+ const shouldConfigure = await ui.confirm({
133
+ message: t('ios.release.askConfigure'),
134
+ initialValue: true,
135
+ });
136
+ if (!shouldConfigure) {
137
+ if (validation.issues.includes('missing_env')) {
138
+ ui.log.message(kleur.dim(t('ios.error.runConfigure')));
139
+ }
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ await runConfigure(directory, options);
144
+ const recheck = await validateAppleSetup(projectDir);
145
+ if (!recheck.ok) {
146
+ ui.log.error(t('ios.error.notConfigured'));
147
+ process.exitCode = 1;
148
+ return;
132
149
  }
133
- process.exitCode = 1;
134
- return;
135
150
  }
136
151
  }
137
152
 
@@ -15,22 +15,10 @@ const path = require('node:path');
15
15
  const crypto = require('node:crypto');
16
16
  const kleur = require('kleur');
17
17
 
18
- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
19
-
20
18
  function generateWebhookKey() {
21
19
  return 'rc_wh_' + crypto.randomBytes(16).toString('hex');
22
20
  }
23
21
 
24
-
25
-
26
- async function waitWithCountdown(seconds, label) {
27
- for (let i = seconds; i > 0; i--) {
28
- process.stdout.write(`\r ${label} ${i}s… `);
29
- await sleep(1000);
30
- }
31
- process.stdout.write(`\r ${label} pronto! \n`);
32
- }
33
-
34
22
  function openUrl(url) {
35
23
  try {
36
24
  const { exec } = require('node:child_process');
@@ -716,7 +704,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
716
704
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
717
705
  let selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
718
706
  ui.log.info(`${tr('new.firebase.create.estimatedTime')}\n${tr('new.internet.warning')}`);
719
- const ps1 = ui.makeStepper();
707
+ const ps1 = ui.makeTimedStepper();
720
708
  ps1.next(tr('new.firebase.create.creating'));
721
709
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
722
710
  includeWeb: firebaseIncludeWeb,
@@ -796,7 +784,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
796
784
  }
797
785
  ui.log.message(tr('new.firebase.create.billingRetry.retrying'));
798
786
  selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
799
- const ps2 = ui.makeStepper();
787
+ const ps2 = ui.makeTimedStepper();
800
788
  ps2.next(tr('new.firebase.create.creating'));
801
789
  lastResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
802
790
  includeWeb: firebaseIncludeWeb,
@@ -889,7 +877,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
889
877
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
890
878
  const selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
891
879
  ui.log.info(tr('new.internet.warning'));
892
- const ps3 = ui.makeStepper();
880
+ const ps3 = ui.makeTimedStepper();
893
881
  ps3.next(tr('new.firebase.create.creatingPush'));
894
882
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
895
883
  includeWeb: true,
@@ -990,7 +978,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
990
978
  });
991
979
  supabaseDbPassword = dbPassword;
992
980
  ui.log.info(tr('new.internet.warning'));
993
- const createSpinner = ui.spinner();
981
+ const createSpinner = ui.timedSpinner();
994
982
  createSpinner.start(tr('new.supabase.creating'));
995
983
  supabaseCreateResult = await createProjectAndGetKeys(
996
984
  core.appName.trim().replace(/\s+/g, '-').toLowerCase(),
@@ -1087,7 +1075,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1087
1075
 
1088
1076
  // ── Firebase existing project: enable APIs + create Firestore/Storage ───
1089
1077
  if (backend === 'firebase' && firebaseSetupMode === 'existing' && core.firebaseProjectId) {
1090
- const ps4 = ui.makeStepper();
1078
+ const ps4 = ui.makeTimedStepper();
1091
1079
  ps4.next(stepProgress('enable-apis', language));
1092
1080
  const existingSetup = await setupExistingProject(core.firebaseProjectId, {
1093
1081
  onProgress: (key, data) => {
@@ -1388,7 +1376,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1388
1376
  // Stepper shows each step closing as ✦ and the next starting as ⠙ — so the
1389
1377
  // user gets explicit "X done → Y starting" feedback instead of a single
1390
1378
  // spinner with a mutating message.
1391
- const stepper = ui.makeStepper();
1379
+ const stepper = ui.makeTimedStepper();
1392
1380
  // First step started here so even silent prep work shows progress.
1393
1381
  stepper.next(stepProgress('project-setup', language));
1394
1382
 
@@ -1512,7 +1500,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1512
1500
  // ── FCM Service Account key (best effort via gcloud — Firebase uses ADC, Supabase needs JSON) ──
1513
1501
  let fcmServiceAccountJson = null;
1514
1502
  if (answers.firebaseProjectId) {
1515
- const fcmSpinner = ui.spinner();
1503
+ const fcmSpinner = ui.timedSpinner();
1516
1504
  fcmSpinner.start(tr('new.fcm.generating'));
1517
1505
  const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1518
1506
  fcmSpinner.stop(tr('new.fcm.generating'));
@@ -1562,7 +1550,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1562
1550
 
1563
1551
  // ── API: FCM Service Account key — save to .kasy/ for server configuration ──
1564
1552
  if (backend === 'api' && answers.firebaseProjectId) {
1565
- const fcmSpinner = ui.spinner();
1553
+ const fcmSpinner = ui.timedSpinner();
1566
1554
  fcmSpinner.start(tr('new.fcm.generating'));
1567
1555
  const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1568
1556
  fcmSpinner.stop(tr('new.fcm.generating'));