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
@@ -5,7 +5,7 @@ const fs = require('fs-extra');
5
5
  const os = require('node:os');
6
6
  const kleur = require('kleur');
7
7
  const ui = require('./ui');
8
- const { exec } = require('node:child_process');
8
+ const { exec, spawn } = require('node:child_process');
9
9
  const { promisify } = require('node:util');
10
10
 
11
11
  const execAsync = promisify(exec);
@@ -43,6 +43,27 @@ async function isKasyFlutterProject(projectDir) {
43
43
  }
44
44
 
45
45
  async function readBundleId(projectDir) {
46
+ // Source of truth is the Xcode project file — what the build actually uses
47
+ // when installing on a device. kit_setup.json can drift if the user renames
48
+ // the bundle id manually after `kasy new`, so it's only used as a fallback
49
+ // when the iOS project hasn't been generated yet.
50
+ const pbxPath = path.join(projectDir, 'ios', 'Runner.xcodeproj', 'project.pbxproj');
51
+ if (await fs.pathExists(pbxPath)) {
52
+ const content = await fs.readFile(pbxPath, 'utf8');
53
+ // Match the main target only — skip Widget/NotificationService extensions
54
+ // by picking the first identifier that doesn't contain a dot extension.
55
+ const matches = [...content.matchAll(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g)];
56
+ for (const m of matches) {
57
+ const id = m[1].trim().replace(/"/g, '');
58
+ if (
59
+ !id.endsWith('.HomeWidgetExtension') &&
60
+ !id.endsWith('.NotificationService')
61
+ ) {
62
+ return id;
63
+ }
64
+ }
65
+ if (matches[0]) return matches[0][1].trim().replace(/"/g, '');
66
+ }
46
67
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
47
68
  if (await fs.pathExists(kitSetupPath)) {
48
69
  try {
@@ -52,12 +73,6 @@ async function readBundleId(projectDir) {
52
73
  // ignore
53
74
  }
54
75
  }
55
- const pbxPath = path.join(projectDir, 'ios', 'Runner.xcodeproj', 'project.pbxproj');
56
- if (await fs.pathExists(pbxPath)) {
57
- const content = await fs.readFile(pbxPath, 'utf8');
58
- const m = content.match(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/);
59
- if (m) return m[1].trim().replace(/"/g, '');
60
- }
61
76
  return null;
62
77
  }
63
78
 
@@ -247,15 +262,69 @@ async function runReleaseScript(projectDir, args, t) {
247
262
  if (!(await fs.pathExists(scriptPath))) {
248
263
  throw new Error(t('ios.error.noScript'));
249
264
  }
250
- const cmd = `bash "${scriptPath}" ${args.join(' ')}`.trim();
251
- try {
252
- await execAsync(cmd, { cwd: projectDir, maxBuffer: 50 * 1024 * 1024 });
253
- } catch (err) {
254
- const output = [err.stdout, err.stderr, err.message].filter(Boolean).join('\n');
255
- const error = new Error(output || err.message);
256
- error.buildOutput = output;
257
- throw error;
258
- }
265
+
266
+ const isBuildOnly = args.includes('--no-upload');
267
+ const titleKey = isBuildOnly ? 'ios.build.task.building' : 'ios.release.task.building';
268
+ const doneKey = isBuildOnly ? 'ios.build.task.done' : 'ios.release.task.done';
269
+ const failKey = isBuildOnly ? 'ios.build.task.failed' : 'ios.release.task.failed';
270
+
271
+ const spinner = ui.timedSpinner();
272
+ spinner.start(t(titleKey));
273
+
274
+ return new Promise((resolve, reject) => {
275
+ const proc = spawn('bash', [scriptPath, ...args], {
276
+ cwd: projectDir,
277
+ stdio: ['ignore', 'pipe', 'pipe'],
278
+ });
279
+
280
+ let allOutput = '';
281
+ let uploadStage = false;
282
+
283
+ // Trim noise so the spinner message stays readable. Skip empty lines,
284
+ // pure dotted progress bars (e.g. "....."), and lines that are just
285
+ // separators. Show the last meaningful line as the spinner status.
286
+ const isMeaningful = (line) => {
287
+ if (!line) return false;
288
+ if (/^[.\s]+$/.test(line)) return false;
289
+ if (/^=+$/.test(line)) return false;
290
+ return true;
291
+ };
292
+
293
+ const handleChunk = (chunk) => {
294
+ const text = chunk.toString();
295
+ allOutput += text;
296
+
297
+ if (!uploadStage && /Uploading to App Store Connect/i.test(text)) {
298
+ uploadStage = true;
299
+ spinner.message(t('ios.release.task.uploading'));
300
+ return;
301
+ }
302
+
303
+ const lines = text.split('\n').map((l) => l.replace(/\r/g, '').trim()).filter(isMeaningful);
304
+ const lastLine = lines[lines.length - 1];
305
+ if (lastLine) spinner.message(lastLine);
306
+ };
307
+
308
+ proc.stdout.on('data', handleChunk);
309
+ proc.stderr.on('data', handleChunk);
310
+
311
+ proc.on('close', (code) => {
312
+ if (code === 0) {
313
+ spinner.stop(t(doneKey));
314
+ resolve();
315
+ } else {
316
+ spinner.stop(t(failKey), 2);
317
+ const error = new Error(allOutput || `release script exited with code ${code}`);
318
+ error.buildOutput = allOutput;
319
+ reject(error);
320
+ }
321
+ });
322
+
323
+ proc.on('error', (err) => {
324
+ spinner.stop(t(failKey), 2);
325
+ reject(err);
326
+ });
327
+ });
259
328
  }
260
329
 
261
330
  module.exports = {
@@ -1,13 +1,10 @@
1
1
  const { exec } = require('node:child_process');
2
2
  const { promisify } = require('node:util');
3
- const readline = require('node:readline');
4
3
  const kleur = require('kleur');
5
- const oraPackage = require('ora');
6
4
  const ui = require('./ui');
7
5
  const { createTranslator, detectDefaultLanguage } = require('./i18n');
8
6
 
9
7
  const execAsync = promisify(exec);
10
- const ora = oraPackage.default || oraPackage;
11
8
 
12
9
  // Timeout para verificar se uma ferramenta está instalada (15 s é mais que suficiente)
13
10
  const TOOL_CHECK_TIMEOUT = 15_000;
@@ -19,19 +16,6 @@ const MIN_NODE_VERSION = '18.0.0';
19
16
  const MIN_FLUTTER_VERSION = '3.24.0';
20
17
  const MIN_DART_VERSION = '3.5.0';
21
18
 
22
- /**
23
- * Compare two semver strings. Returns true if actual >= required.
24
- */
25
- function meetsMinVersion(actual, required) {
26
- if (!actual || !required) return true;
27
- const parse = (v) => v.replace(/[^0-9.]/g, '').split('.').map(Number);
28
- const [aMaj, aMin, aPat = 0] = parse(actual);
29
- const [rMaj, rMin, rPat = 0] = parse(required);
30
- if (aMaj !== rMaj) return aMaj > rMaj;
31
- if (aMin !== rMin) return aMin > rMin;
32
- return aPat >= rPat;
33
- }
34
-
35
19
  const BASE_CHECKS = [
36
20
  {
37
21
  name: 'Node.js',
@@ -175,128 +159,43 @@ function extractVersion(stdout, checkName) {
175
159
  return m ? m[0] : raw.slice(0, 20);
176
160
  }
177
161
 
178
- /**
179
- * Wait for the user to press Enter in the terminal.
180
- * Used to pause the flow so the user can install something or authenticate.
181
- */
182
- function waitForUserInput(prompt) {
183
- return new Promise((resolve) => {
184
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
185
- const done = () => { rl.close(); resolve(); };
186
- rl.question(`\n ${kleur.cyan(prompt)}\n `, done);
187
- rl.on('close', resolve);
188
- rl.on('error', resolve);
189
- });
190
- }
191
-
192
162
  async function runSingleCheck(check, options = {}) {
193
163
  const showVersion = check.showVersion !== undefined ? check.showVersion : (options.showVersion !== undefined ? options.showVersion : true);
194
- const silent = options.silent === true;
195
- const t = check.t || createTranslator(check.language || detectDefaultLanguage());
196
- const spinner = silent ? null : ora(t('checks.checking', { name: check.name })).start();
197
164
  let autoInstallFailed = false;
198
165
 
199
166
  try {
200
167
  const { stdout } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
201
168
  const version = showVersion ? extractVersion(stdout, check.name) : null;
202
- const msg = version
203
- ? t('checks.foundWithVersion', { name: check.name, version })
204
- : t('checks.found', { name: check.name });
205
-
206
- if (!silent) {
207
- // Warn if version is below minimum requirement
208
- if (version && check.minVersion && !meetsMinVersion(version, check.minVersion)) {
209
- spinner.warn(`${msg} ${kleur.yellow(`(mínimo recomendado: ${check.minVersion})`)}`);
210
- } else {
211
- spinner.succeed(msg);
212
- }
213
- }
214
169
  return { ...check, ok: true, version: version || null };
215
170
  } catch (err) {
216
171
  const diagnosis = diagnoseFailure(err);
217
172
  if (check.tryInstall) {
218
- if (!silent) spinner.text = t(check.tryInstallMessageKey || 'setup.flutterfire.installing');
219
173
  try {
220
174
  await execAsync(check.tryInstall, { encoding: 'utf8', timeout: INSTALL_TIMEOUT });
221
175
  const { stdout: retryOut } = await execAsync(check.retryCommand || check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
222
176
  const version = showVersion ? extractVersion(retryOut, check.name) : null;
223
- const msg = version
224
- ? t('checks.foundWithVersion', { name: check.name, version: version || 'installed' })
225
- : t('checks.found', { name: check.name });
226
- if (!silent) spinner.succeed(msg);
227
177
  return { ...check, ok: true, version: version || null };
228
178
  } catch {
229
179
  autoInstallFailed = true;
230
180
  }
231
181
  }
232
-
233
- // Guided interactive prompt: show instructions and wait for the user to act.
234
- if (check.waitPrompt && !silent) {
235
- spinner.stop();
236
- if (check.failHint) {
237
- console.log(kleur.yellow(`\n ⚠ ${check.name} não encontrado.\n`));
238
- console.log(kleur.bold(' Execute:\n'));
239
- console.log(kleur.cyan(` ${check.failHint}\n`));
240
- }
241
- await waitForUserInput(check.waitPrompt);
242
- try {
243
- const { stdout: retryOut } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
244
- const version = showVersion ? extractVersion(retryOut, check.name) : null;
245
- const msg = version
246
- ? t('checks.foundWithVersion', { name: check.name, version })
247
- : t('checks.found', { name: check.name });
248
- spinner.succeed(msg);
249
- return { ...check, ok: true, version: version || null };
250
- } catch {
251
- // Still failing — fall through to report
252
- }
253
- }
254
-
255
- if (!silent) {
256
- if (check.required) {
257
- const detail = autoInstallFailed ? ` — ${t('checks.install.failed')}` : '';
258
- const diagSuffix = diagnosis ? `\n ${kleur.dim(`→ ${t(`checks.diagnostic.${diagnosis}`, { name: check.name })}`)}` : '';
259
- spinner.fail(t('checks.missing', { name: check.name }) + detail + diagSuffix);
260
- } else if (diagnosis) {
261
- spinner.warn(t(`checks.diagnostic.${diagnosis}`, { name: check.name }));
262
- } else {
263
- const hint = !check.waitPrompt && check.failHint ? `\n ${kleur.dim(`→ ${check.failHint}`)}` : '';
264
- if (check.warnMessage) {
265
- spinner.warn(check.warnMessage + hint);
266
- } else if (check.warnMessageKey) {
267
- spinner.warn(t(check.warnMessageKey) + hint);
268
- } else {
269
- spinner.warn(t('checks.notFound', { name: check.name }) + hint);
270
- }
271
- }
272
- }
273
-
274
182
  return { ...check, ok: false, autoInstallFailed, diagnosis };
275
183
  }
276
184
  }
277
185
 
278
186
  async function runChecks(checks, title, options = {}) {
279
187
  const t = options.t || createTranslator(options.language || detectDefaultLanguage());
280
- const { showVersion = true, compact = false } = options;
281
-
282
- if (!compact) {
283
- console.log(kleur.bold(`\n${title}`));
284
- const results = [];
285
- for (const check of checks) {
286
- const result = await runSingleCheck({ ...check, t }, { showVersion });
287
- results.push(result);
288
- }
289
- return results;
290
- }
188
+ const { showVersion = true } = options;
291
189
 
292
- // Compact mode: single spinner, show failures only
190
+ // Single spinner over all checks, show failures afterwards. The visual
191
+ // sits inside the clack rail (│) opened by the caller's ui.intro().
293
192
  const { spinnerLabel = title, doneLabel = title } = options;
294
193
  const spinner = ui.spinner();
295
194
  spinner.start(spinnerLabel);
296
195
 
297
196
  const results = [];
298
197
  for (const check of checks) {
299
- results.push(await runSingleCheck({ ...check, t }, { showVersion, silent: true }));
198
+ results.push(await runSingleCheck({ ...check, t }, { showVersion }));
300
199
  }
301
200
 
302
201
  const failures = results.filter((r) => !r.ok);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared launcher for `flutter run` invocations.
3
+ *
4
+ * Both `kasy run` and `kasy reset` (reinstall step) call this so the spinner,
5
+ * stage detection, elapsed timer, hot-reload stdin pass-through and SIGINT
6
+ * forwarding stay consistent in one place.
7
+ *
8
+ * Flow:
9
+ * - Spawn flutter with piped stdio.
10
+ * - Show a Clack spinner with elapsed time, ticking every second.
11
+ * - As Flutter logs scroll, update the spinner message when we see a known
12
+ * stage marker (Gradle, Xcode, install, sync, etc.).
13
+ * - When Flutter signals "ready" (DAP key commands), flush buffered output,
14
+ * drop into stdio pass-through and pipe stdin so hot reload works.
15
+ * - On early exit, replay the buffer so the user can see the error.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { spawn } = require('node:child_process');
21
+ const kleur = require('kleur');
22
+ const ui = require('./ui');
23
+
24
+ // Markers that tell us the initial build is done and the app is running.
25
+ const FLUTTER_READY_RE = /Flutter run key commands\.|is listening on|VM Service|Dart VM service|To hot reload|Hot restart/i;
26
+
27
+ /**
28
+ * Map a chunk of Flutter output to a user-friendly stage label, or null
29
+ * if the chunk doesn't carry any of the known markers.
30
+ */
31
+ function detectStage(text, t) {
32
+ if (/Running Gradle task 'assembleDebug'/.test(text)) {
33
+ return t('run.stage.gradleFirstTime');
34
+ }
35
+ if (/Running Gradle task/.test(text)) {
36
+ return t('run.stage.gradle');
37
+ }
38
+ if (/Running Xcode build/.test(text)) {
39
+ return t('run.stage.xcode');
40
+ }
41
+ if (/Running pod install/i.test(text)) {
42
+ return t('run.stage.pods');
43
+ }
44
+ if (/Installing build\/.+\.(apk|ipa|app)/i.test(text)) {
45
+ return t('run.stage.installing');
46
+ }
47
+ if (/Syncing files to device/i.test(text)) {
48
+ return t('run.stage.syncing');
49
+ }
50
+ if (/BUILD SUCCESSFUL/.test(text)) {
51
+ return t('run.stage.buildSuccess');
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function formatElapsed(startTime) {
57
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
58
+ if (elapsed < 60) return `${elapsed}s`;
59
+ const m = Math.floor(elapsed / 60);
60
+ const s = elapsed % 60;
61
+ return `${m}m ${s}s`;
62
+ }
63
+
64
+ function render(stageMessage, startTime) {
65
+ return `${stageMessage} ${kleur.dim(`[${formatElapsed(startTime)}]`)}`;
66
+ }
67
+
68
+ /**
69
+ * Spawn `flutter` with the given args inside `projectDir`, surfacing progress
70
+ * through a Clack spinner. Resolves on success, rejects on non-zero exit.
71
+ */
72
+ function spawnFlutterWithSpinner(args, projectDir, t) {
73
+ return new Promise((resolve, reject) => {
74
+ const proc = spawn('flutter', args, {
75
+ cwd: projectDir,
76
+ stdio: ['pipe', 'pipe', 'pipe'],
77
+ });
78
+
79
+ const spinner = ui.spinner();
80
+ const startTime = Date.now();
81
+ let stageMessage = t('run.spinner.building');
82
+ let ready = false;
83
+ const buffer = [];
84
+
85
+ spinner.start(render(stageMessage, startTime));
86
+
87
+ // Tick the elapsed time every second while still building. Once ready,
88
+ // the spinner is gone and we stop ticking.
89
+ const tick = setInterval(() => {
90
+ if (!ready) spinner.message(render(stageMessage, startTime));
91
+ }, 1000);
92
+
93
+ const flushAndSwitch = () => {
94
+ if (ready) return;
95
+ ready = true;
96
+ clearInterval(tick);
97
+ const total = formatElapsed(startTime);
98
+ spinner.stop(`${t('run.spinner.ready')} ${kleur.dim(`[${total}]`)}`);
99
+ for (const chunk of buffer) process.stdout.write(chunk);
100
+ buffer.length = 0;
101
+ // Pipe stdin so the user can type r / R / q to control Flutter.
102
+ if (process.stdin.isTTY) {
103
+ try { process.stdin.setRawMode(true); } catch (_) {}
104
+ process.stdin.resume();
105
+ process.stdin.pipe(proc.stdin);
106
+ }
107
+ };
108
+
109
+ const handleStdout = (chunk) => {
110
+ const text = chunk.toString();
111
+ if (!ready) {
112
+ buffer.push(chunk);
113
+ const nextStage = detectStage(text, t);
114
+ if (nextStage && nextStage !== stageMessage) {
115
+ stageMessage = nextStage;
116
+ spinner.message(render(stageMessage, startTime));
117
+ }
118
+ if (FLUTTER_READY_RE.test(text)) flushAndSwitch();
119
+ } else {
120
+ process.stdout.write(chunk);
121
+ }
122
+ };
123
+
124
+ const handleStderr = (chunk) => {
125
+ if (!ready) {
126
+ buffer.push(chunk);
127
+ if (FLUTTER_READY_RE.test(chunk.toString())) flushAndSwitch();
128
+ } else {
129
+ process.stderr.write(chunk);
130
+ }
131
+ };
132
+
133
+ proc.stdout.on('data', handleStdout);
134
+ proc.stderr.on('data', handleStderr);
135
+
136
+ const sigintHandler = () => { try { proc.kill('SIGINT'); } catch (_) {} };
137
+ process.on('SIGINT', sigintHandler);
138
+
139
+ const cleanup = () => {
140
+ clearInterval(tick);
141
+ process.off('SIGINT', sigintHandler);
142
+ if (process.stdin.isTTY) {
143
+ try { process.stdin.setRawMode(false); } catch (_) {}
144
+ process.stdin.unpipe(proc.stdin);
145
+ process.stdin.pause();
146
+ }
147
+ };
148
+
149
+ proc.on('close', (code) => {
150
+ cleanup();
151
+ if (!ready) {
152
+ spinner.stop(t('run.spinner.failed'), 2);
153
+ for (const chunk of buffer) process.stdout.write(chunk);
154
+ }
155
+ if (code === 0) resolve();
156
+ else reject(new Error(`flutter exited with code ${code}`));
157
+ });
158
+
159
+ proc.on('error', (err) => {
160
+ cleanup();
161
+ if (!ready) spinner.stop(t('run.spinner.failed'), 2);
162
+ reject(err);
163
+ });
164
+ });
165
+ }
166
+
167
+ module.exports = {
168
+ spawnFlutterWithSpinner,
169
+ // Exported for tests and reuse in non-flutter spawn helpers.
170
+ detectStage,
171
+ formatElapsed,
172
+ FLUTTER_READY_RE,
173
+ };