kasy-cli 1.5.3 → 1.7.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.
package/bin/kasy.js CHANGED
@@ -35,6 +35,7 @@ const { getStoredLanguage, setStoredLanguage } = require('../lib/utils/license')
35
35
  const { promptLanguage } = require('../lib/utils/prompts');
36
36
  const { ensureLicenseKey, shouldRequireLicenseForArgv } = require('../lib/utils/license-gate');
37
37
  const { checkForUpdates } = require('../lib/utils/updates');
38
+ const { printCompactHeader } = require('../lib/utils/brand');
38
39
 
39
40
  function createLocalizedHelpConfig(t) {
40
41
  return {
@@ -276,7 +277,7 @@ function buildProgram(language) {
276
277
  applyLocalizedHelp(
277
278
  program
278
279
  .command('add')
279
- .argument('[module]', 'Module to add (e.g. sentry, analytics, revenuecat, onboarding, camera, ci...)')
280
+ .argument('[module]', 'Module to add (e.g. sentry, analytics, revenuecat, onboarding, llm_chat, ci...)')
280
281
  .option('--list', 'List all available modules and their current status')
281
282
  .option('--yes', 'Skip interactive prompts (use placeholder values)')
282
283
  .option('-d, --directory <path>', 'Project folder (default: current directory)', '.')
@@ -290,7 +291,7 @@ function buildProgram(language) {
290
291
  applyLocalizedHelp(
291
292
  program
292
293
  .command('remove')
293
- .argument('[module]', 'Module to remove (e.g. sentry, analytics, camera, ci...)')
294
+ .argument('[module]', 'Module to remove (e.g. sentry, analytics, llm_chat, ci...)')
294
295
  .option('--yes', 'Skip confirmation prompt')
295
296
  .option('-d, --directory <path>', 'Project folder (default: current directory)', '.')
296
297
  .description(t('cli.command.remove.description'))
@@ -322,7 +323,8 @@ function buildProgram(language) {
322
323
  .description(t('cli.command.upgrade.description'))
323
324
  .action(() => {
324
325
  const { spawnSync } = require('node:child_process');
325
- console.log('\n' + kleur.cyan(t('cli.command.upgrade.running')) + '\n');
326
+ printCompactHeader(t);
327
+ console.log(kleur.cyan(t('cli.command.upgrade.running')) + '\n');
326
328
  const result = spawnSync('npm', ['install', '-g', 'kasy-cli'], { stdio: 'inherit', shell: true });
327
329
  if (result.status === 0) {
328
330
  console.log('\n' + kleur.green('✓ ' + t('cli.command.upgrade.done')) + '\n');
@@ -339,7 +341,8 @@ function buildProgram(language) {
339
341
  .description(t('cli.command.uninstall.description'))
340
342
  .action(() => {
341
343
  const { spawnSync } = require('node:child_process');
342
- console.log('\n' + kleur.cyan(t('cli.command.uninstall.running')) + '\n');
344
+ printCompactHeader(t);
345
+ console.log(kleur.cyan(t('cli.command.uninstall.running')) + '\n');
343
346
  const result = spawnSync('npm', ['uninstall', '-g', 'kasy-cli'], { stdio: 'inherit', shell: true });
344
347
  if (result.status === 0) {
345
348
  console.log('\n' + kleur.green('✓ ' + t('cli.command.uninstall.done')) + '\n');
@@ -17,7 +17,7 @@ kasy new # interativo — pergunta tudo
17
17
  kasy new meu-app # cria na pasta meu-app
18
18
  kasy new --yes # modo rápido, preset Starter
19
19
  kasy new --backend supabase # define backend sem perguntar
20
- kasy new --with camera,sentry # pré-seleciona features
20
+ kasy new --with llm_chat,sentry # pré-seleciona features
21
21
  ```
22
22
 
23
23
  **Opções:**
@@ -36,12 +36,12 @@ kasy new --with camera,sentry # pré-seleciona features
36
36
  Adiciona uma feature a um projeto Kasy existente.
37
37
 
38
38
  ```bash
39
- kasy add camera
39
+ kasy add llm_chat
40
40
  kasy add sentry
41
41
  kasy add revenuecat
42
42
  kasy add --list # lista features disponíveis e status
43
- kasy add --yes camera # sem perguntas interativas
44
- kasy add -d ./outro-app camera
43
+ kasy add --yes llm_chat # sem perguntas interativas
44
+ kasy add -d ./outro-app llm_chat
45
45
  ```
46
46
 
47
47
  **Features disponíveis:**
@@ -54,7 +54,6 @@ kasy add -d ./outro-app camera
54
54
  | `onboarding` | Fluxo de onboarding customizável |
55
55
  | `web` | Suporte a Flutter Web |
56
56
  | `widget` | Home widgets para iOS e Android |
57
- | `camera` | Câmera com CameraAwesome |
58
57
  | `llm_chat` | Chat com IA via Cloud/Edge Functions |
59
58
  | `feedback` | Sistema de feedback e feature requests |
60
59
  | `ci` | CI/CD com GitHub Actions / Codemagic |
@@ -202,7 +201,6 @@ lib/
202
201
  home/ # tela principal
203
202
  notifications/ # notificações locais e push
204
203
  settings/ # configurações do app
205
- camera/ # [feature] câmera
206
204
  llm_chat/ # [feature] chat com IA
207
205
  feedbacks/ # [feature] feedback e feature requests
208
206
  onboarding/ # [feature] onboarding
@@ -5,23 +5,29 @@ const os = require('node:os');
5
5
  const fs = require('fs-extra');
6
6
  const pkg = require('../../package.json');
7
7
  const kleur = require('kleur');
8
- const prompts = require('prompts');
9
- const oraPackage = require('ora');
8
+ const ui = require('../utils/ui');
9
+ const { printCompactHeader } = require('../utils/brand');
10
10
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
11
11
  const {
12
12
  AVAILABLE_FEATURES,
13
13
  FEATURES_PATCH_DIR,
14
14
  normalizeFeature,
15
15
  getVisibleFeatures,
16
+ getBaseFeatures,
17
+ BASE_FEATURES,
16
18
  } = require('../scaffold/catalog');
17
19
 
20
+ function findBaseDisplayName(id) {
21
+ const base = BASE_FEATURES.find((f) => f.id === id);
22
+ return base ? base.displayName : id;
23
+ }
24
+
18
25
  const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
19
26
  const { applyPatch } = require('../scaffold/engine');
20
27
  const { writeRouter, writeNoOpAnalyticsApi, writeNoOpTrackingApi, writeNoOpAdminHomeWidgets, writeNoOpFeatureRequestRepository, writeMainDart, addPubspecDep, localizeReleaseDocs } = require('../scaffold/shared/generator-utils');
21
28
  const { toPackageName, buildTokens } = require('../scaffold/backends/firebase/tokens');
22
29
 
23
30
  const execAsync = promisify(exec);
24
- const ora = oraPackage.default || oraPackage;
25
31
 
26
32
  /** URL do endpoint LLM no app (espelha defineUpdates de llm_chat). */
27
33
  function resolveLlmChatEndpoint(answers, kitSetup) {
@@ -317,58 +323,48 @@ const MODULE_META = {
317
323
  },
318
324
  };
319
325
 
326
+ /**
327
+ * Each entry returns a fn (t, cancel) -> Promise<value> that asks the user.
328
+ * Secret-like values (DSNs, API keys, tokens) use ui.password to mask input.
329
+ */
320
330
  const PROMPT_QUESTIONS = {
321
- sentryDsn: (t) => ({
322
- type: 'text',
323
- name: 'sentryDsn',
331
+ sentryDsn: (t, cancel) => ui.password({
324
332
  message: t('add.prompt.sentryDsn'),
325
- initial: '',
333
+ onCancel: cancel,
326
334
  }),
327
- mixpanelToken: (t) => ({
328
- type: 'text',
329
- name: 'mixpanelToken',
335
+ mixpanelToken: (t, cancel) => ui.password({
330
336
  message: t('add.prompt.mixpanelToken'),
331
- initial: '',
337
+ onCancel: cancel,
332
338
  }),
333
- rcAndroidKey: (t) => ({
334
- type: 'text',
335
- name: 'rcAndroidKey',
339
+ // RevenueCat SDK keys ship inside the client binary — not real secrets, plain text input.
340
+ rcAndroidKey: (t, cancel) => ui.text({
336
341
  message: t('add.prompt.rcAndroidKey'),
337
- initial: '',
342
+ onCancel: cancel,
338
343
  }),
339
- rcIosKey: (t) => ({
340
- type: 'text',
341
- name: 'rcIosKey',
344
+ rcIosKey: (t, cancel) => ui.text({
342
345
  message: t('add.prompt.rcIosKey'),
343
- initial: '',
346
+ onCancel: cancel,
344
347
  }),
345
- llmProvider: (t) => ({
346
- type: 'select',
347
- name: 'llmProvider',
348
+ llmProvider: (t, cancel) => ui.select({
348
349
  message: t('add.prompt.llmProvider'),
349
- choices: [
350
- { title: 'OpenAI (gpt-4o-mini)', value: 'openai' },
351
- { title: 'Google Gemini (gemini-1.5-flash)', value: 'gemini' },
350
+ initialValue: 'openai',
351
+ options: [
352
+ { value: 'openai', label: 'OpenAI (gpt-4o-mini)' },
353
+ { value: 'gemini', label: 'Google Gemini (gemini-1.5-flash)' },
352
354
  ],
353
- initial: 0,
355
+ onCancel: cancel,
354
356
  }),
355
- llmSystemPrompt: (t) => ({
356
- type: 'text',
357
- name: 'llmSystemPrompt',
357
+ llmSystemPrompt: (t, cancel) => ui.text({
358
358
  message: t('add.prompt.llmSystemPrompt'),
359
- initial: '',
359
+ onCancel: cancel,
360
360
  }),
361
- llmApiKey: (t) => ({
362
- type: 'text',
363
- name: 'llmApiKey',
361
+ llmApiKey: (t, cancel) => ui.password({
364
362
  message: t('add.prompt.llmApiKey'),
365
- initial: '',
363
+ onCancel: cancel,
366
364
  }),
367
- llmEndpoint: (t) => ({
368
- type: 'text',
369
- name: 'llmEndpoint',
365
+ llmEndpoint: (t, cancel) => ui.text({
370
366
  message: t('add.prompt.llmEndpoint'),
371
- initial: '',
367
+ onCancel: cancel,
372
368
  }),
373
369
  };
374
370
 
@@ -411,7 +407,7 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
411
407
 
412
408
  // 2. Set API key as secret (skip if blank)
413
409
  if (!apiKey) {
414
- console.log(kleur.yellow(`\n ${t('add.llm_chat.skipSecret')}\n`));
410
+ ui.log.warn(t('add.llm_chat.skipSecret'));
415
411
  return { deployOk: false, deployAttempted: false };
416
412
  }
417
413
 
@@ -419,21 +415,23 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
419
415
  const refFlag = backend === 'supabase' && projectRef ? ` --project-ref ${projectRef}` : '';
420
416
 
421
417
  if (backend === 'firebase') {
422
- const spinner = ora(t('add.llm_chat.settingSecret')).start();
418
+ const spinner = ui.spinner();
419
+ spinner.start(t('add.llm_chat.settingSecret'));
423
420
  try {
424
421
  // Write to temp file — avoids trailing newline (echo) and shell injection risks
425
422
  const tmpFile = path.join(os.tmpdir(), `llm_api_key_${Date.now()}.tmp`);
426
423
  await fs.outputFile(tmpFile, apiKey, 'utf8');
427
424
  await execAsync(`firebase functions:secrets:set LLM_API_KEY --data-file="${tmpFile}"`, { cwd: projectDir });
428
425
  await fs.remove(tmpFile);
429
- spinner.succeed(t('add.llm_chat.secretSet'));
426
+ spinner.stop(t('add.llm_chat.secretSet'));
430
427
  secretsOk = true;
431
428
  } catch {
432
- spinner.warn(t('add.llm_chat.secretFailed'));
433
- console.log(kleur.dim(` Run manually: firebase functions:secrets:set LLM_API_KEY`));
429
+ spinner.stop(`⚠ ${t('add.llm_chat.secretFailed')}`);
430
+ ui.log.message(kleur.dim('Run manually: firebase functions:secrets:set LLM_API_KEY'));
434
431
  }
435
432
  } else if (backend === 'supabase') {
436
- const spinner = ora(t('add.llm_chat.settingSecret')).start();
433
+ const spinner = ui.spinner();
434
+ spinner.start(t('add.llm_chat.settingSecret'));
437
435
  // Set LLM_API_KEY, LLM_PROVIDER and LLM_SYSTEM_PROMPT all as Supabase Secrets.
438
436
  // Deployed Edge Functions read from Deno.env.get() = Supabase Secrets, NOT from .env files.
439
437
  const esc = (v) => String(v).replace(/"/g, '\\"');
@@ -451,32 +449,33 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
451
449
  if (r && r.ok === false) allOk = false;
452
450
  }
453
451
  if (allOk) {
454
- spinner.succeed(t('add.llm_chat.secretSet'));
452
+ spinner.stop(t('add.llm_chat.secretSet'));
455
453
  secretsOk = true;
456
454
  } else {
457
- spinner.warn(t('add.llm_chat.secretFailed'));
458
- console.log(kleur.dim(` Run manually: supabase secrets set LLM_API_KEY=YOUR_KEY LLM_PROVIDER=${provider}${refFlag}`));
455
+ spinner.stop(`⚠ ${t('add.llm_chat.secretFailed')}`);
456
+ ui.log.message(kleur.dim(`Run manually: supabase secrets set LLM_API_KEY=YOUR_KEY LLM_PROVIDER=${provider}${refFlag}`));
459
457
  }
460
458
  }
461
459
 
462
460
  // 3. Deploy the LLM function automatically
463
461
  if (backend === 'api') return { deployOk: false, deployAttempted: false };
464
462
 
465
- const deploySpinner = ora(t('add.llm_chat.deploying')).start();
463
+ const deploySpinner = ui.spinner();
464
+ deploySpinner.start(t('add.llm_chat.deploying'));
466
465
  try {
467
466
  if (backend === 'firebase') {
468
467
  await execAsync('firebase deploy --only functions:llmChat', { cwd: projectDir, timeout: 180_000 });
469
468
  } else if (backend === 'supabase') {
470
469
  await execAsync(`supabase functions deploy llm-chat --no-verify-jwt${refFlag}`, { cwd: projectDir, timeout: 180_000 });
471
470
  }
472
- deploySpinner.succeed(t('add.llm_chat.deployed'));
471
+ deploySpinner.stop(t('add.llm_chat.deployed'));
473
472
  return { deployOk: true, deployAttempted: true };
474
473
  } catch {
475
- deploySpinner.warn(t('add.llm_chat.deployFailed'));
474
+ deploySpinner.stop(`⚠ ${t('add.llm_chat.deployFailed')}`);
476
475
  if (backend === 'firebase') {
477
- console.log(kleur.dim(` Run manually: firebase deploy --only functions:llmChat`));
476
+ ui.log.message(kleur.dim('Run manually: firebase deploy --only functions:llmChat'));
478
477
  } else {
479
- console.log(kleur.dim(` Run manually: supabase functions deploy llm-chat --no-verify-jwt${refFlag}`));
478
+ ui.log.message(kleur.dim(`Run manually: supabase functions deploy llm-chat --no-verify-jwt${refFlag}`));
480
479
  }
481
480
  return { deployOk: false, deployAttempted: true };
482
481
  }
@@ -485,8 +484,6 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
485
484
  // ── List command ──────────────────────────────────────────────────────────────
486
485
 
487
486
  async function listModules(projectDir, t) {
488
- console.log(kleur.bold(`\n${t('add.list.title')}\n`));
489
-
490
487
  let activeModules = [];
491
488
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
492
489
  if (await fs.pathExists(kitSetupPath)) {
@@ -498,14 +495,33 @@ async function listModules(projectDir, t) {
498
495
  }
499
496
  }
500
497
 
498
+ const lines = [];
499
+
500
+ lines.push(kleur.bold(t('modules.featuresBase')));
501
+ for (const feature of getBaseFeatures()) {
502
+ lines.push(`${kleur.green('✓')} ${kleur.green(feature.displayName)}`);
503
+ }
504
+
505
+ // Subscriptions is base, but inactive until RevenueCat is added.
506
+ if (!activeModules.includes('revenuecat')) {
507
+ lines.push('');
508
+ lines.push(kleur.dim(t('modules.hint.subscriptionNoRc')));
509
+ }
510
+
511
+ lines.push('');
512
+ lines.push(kleur.bold(t('modules.features')));
501
513
  for (const feature of getVisibleFeatures({ audience: KASY_AUDIENCE })) {
502
514
  const active = activeModules.includes(feature.id);
503
- const badge = feature.status === 'internal' ? kleur.yellow(' [beta]') : '';
515
+ const betaBadge = feature.status === 'internal' ? kleur.yellow(' [beta]') : '';
516
+ const tagBadge = feature.tag ? kleur.yellow(` [${t(`modules.tag.${feature.tag}`)}]`) : '';
517
+ const enhancesBadge = feature.enhances
518
+ ? kleur.magenta(` [${t('modules.tag.enhances', { target: findBaseDisplayName(feature.enhances) })}]`)
519
+ : '';
504
520
  const icon = active ? kleur.green('✓') : kleur.dim('○');
505
- const label = active ? kleur.green(feature.id) : kleur.white(feature.id);
506
- console.log(` ${icon} ${label}${badge}`);
521
+ const label = active ? kleur.green(feature.displayName) : kleur.white(feature.displayName);
522
+ lines.push(`${icon} ${label}${tagBadge}${enhancesBadge}${betaBadge}`);
507
523
  }
508
- console.log('');
524
+ ui.note(lines.join('\n'), t('add.list.title'));
509
525
  }
510
526
 
511
527
  // ── Main command ──────────────────────────────────────────────────────────────
@@ -513,16 +529,23 @@ async function listModules(projectDir, t) {
513
529
  async function runAdd(module, options = {}) {
514
530
  const t = createTranslator(options.language || detectDefaultLanguage());
515
531
  const projectDir = path.resolve(options.directory || '.');
532
+ const cancel = () => { ui.cancel(t('add.cancelled')); process.exit(0); };
516
533
 
517
534
  // --list flag
518
535
  if (options.list) {
536
+ printCompactHeader(t);
537
+ ui.intro(t('add.list.title'));
519
538
  await listModules(projectDir, t);
539
+ ui.outro('');
520
540
  return;
521
541
  }
522
542
 
523
543
  if (!module) {
524
- console.log(kleur.yellow(t('add.error.noModule')));
544
+ printCompactHeader(t);
545
+ ui.intro(t('add.list.title'));
546
+ ui.log.warn(t('add.error.noModule'));
525
547
  await listModules(projectDir, t);
548
+ ui.outro('');
526
549
  return;
527
550
  }
528
551
 
@@ -542,13 +565,16 @@ async function runAdd(module, options = {}) {
542
565
  if (moduleLower === 'ios-release' || moduleLower === 'ios_release' || moduleLower === 'iosrelease') {
543
566
  const scriptPath = path.join(projectDir, 'scripts', 'release-ios.sh');
544
567
  if (await fs.pathExists(scriptPath)) {
545
- console.log(kleur.yellow(`\n${t('add.iosRelease.already')}\n`));
568
+ printCompactHeader(t);
569
+ ui.log.warn(t('add.iosRelease.already'));
546
570
  return;
547
571
  }
548
572
  const patchDir = path.join(FEATURES_PATCH_DIR, 'ios-release');
549
573
  if (!(await fs.pathExists(patchDir))) {
550
574
  throw new Error(t('add.error.unknownModule', { module, list: 'ios-release' }));
551
575
  }
576
+ printCompactHeader(t);
577
+ ui.intro(t('add.applying', { module: 'ios-release' }));
552
578
  const { tokens, pathReplacements } = buildTokens({
553
579
  appName: kitSetup.appName,
554
580
  bundleId: kitSetup.bundleId,
@@ -557,8 +583,8 @@ async function runAdd(module, options = {}) {
557
583
  await localizeReleaseDocs(projectDir, options.language || detectDefaultLanguage());
558
584
  await ensureIosReleaseMakefile(projectDir);
559
585
  await ensureGitignoreKasyEnv(projectDir);
560
- console.log(kleur.green(`\n✓ ${t('add.iosRelease.success')}\n`));
561
- console.log(kleur.cyan(` kasy ios configure\n`));
586
+ ui.log.success(t('add.iosRelease.success'));
587
+ ui.outro(kleur.cyan('kasy ios configure'));
562
588
  return;
563
589
  }
564
590
 
@@ -578,23 +604,22 @@ async function runAdd(module, options = {}) {
578
604
  if (activeModules.includes(normalized)) {
579
605
  // llm_chat can be re-run to reconfigure credentials even when already active
580
606
  if (normalized !== 'llm_chat') {
581
- console.log(kleur.yellow(`\n${t('add.alreadyActive', { module: normalized })}\n`));
607
+ printCompactHeader(t);
608
+ ui.log.warn(t('add.alreadyActive', { module: normalized }));
582
609
  return;
583
610
  }
584
- console.log(kleur.yellow(`\n${t('add.llm_chat.reconfigure')}\n`));
611
+ printCompactHeader(t);
612
+ ui.intro(t('add.llm_chat.reconfigure'));
585
613
  // Skip to credential-only flow: prompt → postAdd → done
586
614
  const answers = {};
587
615
  if (!options.yes) {
588
- console.log('');
589
616
  for (const key of ['llmProvider', 'llmSystemPrompt', 'llmApiKey']) {
590
- const question = PROMPT_QUESTIONS[key]?.(t);
591
- if (!question) continue;
592
- const response = await prompts(question, { onCancel: () => { throw new Error(t('add.cancelled')); } });
593
- answers[key] = response[key] ?? '';
617
+ const ask = PROMPT_QUESTIONS[key];
618
+ if (!ask) continue;
619
+ answers[key] = (await ask(t, cancel)) ?? '';
594
620
  }
595
621
  if (kitSetup.backendProvider === 'api') {
596
- const response = await prompts(PROMPT_QUESTIONS.llmEndpoint(t), { onCancel: () => { throw new Error(t('add.cancelled')); } });
597
- answers.llmEndpoint = response.llmEndpoint || '';
622
+ answers.llmEndpoint = (await PROMPT_QUESTIONS.llmEndpoint(t, cancel)) || '';
598
623
  }
599
624
  }
600
625
  const defines = MODULE_META.llm_chat.defineUpdates(answers, kitSetup);
@@ -610,41 +635,37 @@ async function runAdd(module, options = {}) {
610
635
  const { deployOk, deployAttempted } = await postAddLlmChat(projectDir, kitSetup, answers, t);
611
636
  const backend = kitSetup?.backendProvider ?? 'firebase';
612
637
  const needsManualDeploy = deployAttempted && !deployOk;
638
+ let nextStepsMsg;
613
639
  if (backend === 'firebase') {
614
- console.log(kleur.cyan(t(needsManualDeploy ? 'add.llm_chat.nextSteps.firebase.deployFailed' : 'add.llm_chat.nextSteps.firebase')));
640
+ nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.firebase.deployFailed' : 'add.llm_chat.nextSteps.firebase');
615
641
  } else if (backend === 'supabase') {
616
- console.log(kleur.cyan(t(needsManualDeploy ? 'add.llm_chat.nextSteps.supabase.deployFailed' : 'add.llm_chat.nextSteps.supabase')));
642
+ nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.supabase.deployFailed' : 'add.llm_chat.nextSteps.supabase');
617
643
  } else {
618
- console.log(kleur.cyan(t('add.llm_chat.nextSteps.api')));
644
+ nextStepsMsg = t('add.llm_chat.nextSteps.api');
619
645
  }
646
+ ui.outro(kleur.cyan(nextStepsMsg));
620
647
  return;
621
648
  }
622
649
 
623
650
  const meta = MODULE_META[normalized] || { promptKeys: [], defineUpdates: () => ({}), envLines: () => [], featureFlag: null };
624
651
 
652
+ printCompactHeader(t);
653
+ ui.intro(t('add.applying', { module: normalized }));
654
+
625
655
  // 4. Prompt for API keys (skip if --yes)
626
656
  const answers = {};
627
657
  if (!options.yes && meta.promptKeys.length > 0) {
628
- console.log('');
629
658
  for (const key of meta.promptKeys) {
630
- const question = PROMPT_QUESTIONS[key]?.(t);
631
- if (!question) continue;
632
- const response = await prompts(question, {
633
- onCancel: () => { throw new Error(t('add.cancelled')); },
634
- });
635
- answers[key] = response[key] ?? '';
659
+ const ask = PROMPT_QUESTIONS[key];
660
+ if (!ask) continue;
661
+ answers[key] = (await ask(t, cancel)) ?? '';
636
662
  }
637
663
  // Extra prompt for API backend (custom LLM endpoint)
638
664
  if (normalized === 'llm_chat' && kitSetup.backendProvider === 'api') {
639
- const response = await prompts(PROMPT_QUESTIONS.llmEndpoint(t), {
640
- onCancel: () => { throw new Error(t('add.cancelled')); },
641
- });
642
- answers.llmEndpoint = response.llmEndpoint || '';
665
+ answers.llmEndpoint = (await PROMPT_QUESTIONS.llmEndpoint(t, cancel)) || '';
643
666
  }
644
667
  }
645
668
 
646
- console.log(kleur.bold(`\n${t('add.applying', { module: normalized })}\n`));
647
-
648
669
  // 5. Enable feature flag in features.dart
649
670
  if (meta.featureFlag) {
650
671
  await enableFeatureFlag(projectDir, meta.featureFlag);
@@ -667,16 +688,17 @@ async function runAdd(module, options = {}) {
667
688
  // 8. Apply patch if it exists under features/<module>/
668
689
  const patchDir = path.join(FEATURES_PATCH_DIR, normalized);
669
690
  if (await fs.pathExists(patchDir)) {
670
- const spinner = ora(t('add.applyingPatch')).start();
691
+ const spinner = ui.spinner();
692
+ spinner.start(t('add.applyingPatch'));
671
693
  try {
672
694
  const { tokens: patchTokens, pathReplacements: patchPathReplacements } = buildTokens({
673
695
  appName: kitSetup.appName,
674
696
  bundleId: kitSetup.bundleId,
675
697
  });
676
698
  await applyPatch(patchDir, projectDir, patchTokens, patchPathReplacements);
677
- spinner.succeed(t('add.patchApplied'));
699
+ spinner.stop(t('add.patchApplied'));
678
700
  } catch (err) {
679
- spinner.fail(t('add.patchFailed'));
701
+ spinner.error(t('add.patchFailed'));
680
702
  throw err;
681
703
  }
682
704
  }
@@ -726,24 +748,26 @@ async function runAdd(module, options = {}) {
726
748
 
727
749
  // 10. flutter pub get
728
750
  {
729
- const spinner = ora(t('add.pubGet')).start();
751
+ const spinner = ui.spinner();
752
+ spinner.start(t('add.pubGet'));
730
753
  try {
731
754
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
732
- spinner.succeed(t('add.pubGetDone'));
755
+ spinner.stop(t('add.pubGetDone'));
733
756
  } catch {
734
- spinner.warn(t('add.pubGetFailed'));
757
+ spinner.stop(`⚠ ${t('add.pubGetFailed')}`);
735
758
  }
736
759
  }
737
760
 
738
761
  // 11. build_runner (only when needed: features with codegen)
739
762
  const needsBuildRunner = ['revenuecat', 'analytics', 'sentry', 'onboarding', 'llm_chat', 'feedback'].includes(normalized);
740
763
  if (needsBuildRunner) {
741
- const spinner = ora(t('add.buildRunner')).start();
764
+ const spinner = ui.spinner();
765
+ spinner.start(t('add.buildRunner'));
742
766
  try {
743
767
  await execAsync('dart run build_runner build --delete-conflicting-outputs', { cwd: projectDir, timeout: 600_000 });
744
- spinner.succeed(t('add.buildRunnerDone'));
768
+ spinner.stop(t('add.buildRunnerDone'));
745
769
  } catch {
746
- spinner.warn(t('add.buildRunnerFailed'));
770
+ spinner.stop(`⚠ ${t('add.buildRunnerFailed')}`);
747
771
  }
748
772
  }
749
773
 
@@ -755,23 +779,25 @@ async function runAdd(module, options = {}) {
755
779
 
756
780
  // Show note if any
757
781
  if (meta.note) {
758
- console.log(kleur.dim(`\n ${t(meta.note)}\n`));
782
+ ui.log.message(kleur.dim(t(meta.note)));
759
783
  }
760
784
 
761
- console.log(kleur.green(`\n✓ ${t('add.success', { module: normalized })}\n`));
762
-
763
785
  // LLM Chat: show next steps (adjust based on whether deploy succeeded)
764
786
  if (normalized === 'llm_chat') {
765
787
  const backend = kitSetup?.backendProvider ?? 'firebase';
766
788
  const needsManualDeploy = llmDeployResult?.deployAttempted && !llmDeployResult?.deployOk;
789
+ let nextStepsMsg;
767
790
  if (backend === 'firebase') {
768
- console.log(kleur.cyan(t(needsManualDeploy ? 'add.llm_chat.nextSteps.firebase.deployFailed' : 'add.llm_chat.nextSteps.firebase')));
791
+ nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.firebase.deployFailed' : 'add.llm_chat.nextSteps.firebase');
769
792
  } else if (backend === 'supabase') {
770
- console.log(kleur.cyan(t(needsManualDeploy ? 'add.llm_chat.nextSteps.supabase.deployFailed' : 'add.llm_chat.nextSteps.supabase')));
793
+ nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.supabase.deployFailed' : 'add.llm_chat.nextSteps.supabase');
771
794
  } else {
772
- console.log(kleur.cyan(t('add.llm_chat.nextSteps.api')));
795
+ nextStepsMsg = t('add.llm_chat.nextSteps.api');
773
796
  }
797
+ ui.log.info(nextStepsMsg);
774
798
  }
799
+
800
+ ui.outro(t('add.success', { module: normalized }));
775
801
  }
776
802
 
777
803
  module.exports = { runAdd };