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.
@@ -6,8 +6,8 @@ const path = require('node:path');
6
6
  const fs = require('fs-extra');
7
7
  const pkg = require('../../package.json');
8
8
  const kleur = require('kleur');
9
- const prompts = require('prompts');
10
- const oraPackage = require('ora');
9
+ const ui = require('../utils/ui');
10
+ const { printCompactHeader } = require('../utils/brand');
11
11
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
12
12
  const {
13
13
  AVAILABLE_FEATURES,
@@ -22,7 +22,6 @@ const { buildTokens } = require('../scaffold/backends/firebase/tokens');
22
22
  const { localizeReleaseDocs } = require('../scaffold/shared/generator-utils');
23
23
 
24
24
  const execAsync = promisify(exec);
25
- const ora = oraPackage.default || oraPackage;
26
25
 
27
26
  const CHANGELOG_PATH = path.join(__dirname, '..', 'scaffold', 'CHANGELOG.json');
28
27
 
@@ -146,6 +145,7 @@ async function getActiveModules(kitSetup, projectDir) {
146
145
  async function runUpdate(module, options = {}) {
147
146
  const t = createTranslator(options.language || detectDefaultLanguage());
148
147
  const projectDir = path.resolve(options.directory || '.');
148
+ const cancel = () => { ui.cancel(t('update.cancelled')); process.exit(0); };
149
149
 
150
150
  // 1. Must be inside a kasy project
151
151
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
@@ -170,93 +170,98 @@ async function runUpdate(module, options = {}) {
170
170
  if (module) {
171
171
  const normalizedTarget = normalizeUpdateTarget(module);
172
172
  if (normalizedTarget === COMPONENTS_UPDATE_TARGET) {
173
+ printCompactHeader(t);
174
+ ui.intro(t('update.applyingComponents'));
173
175
  if (!options.yes) {
174
- console.log(kleur.yellow(`\n ⚠ ${t('update.warn.commitComponents')}\n`));
175
- const { confirmed } = await prompts(
176
- {
177
- type: 'confirm',
178
- name: 'confirmed',
179
- message: t('update.confirmComponents'),
180
- initial: false,
181
- },
182
- { onCancel: () => { throw new Error(t('update.cancelled')); } }
183
- );
176
+ ui.log.warn(t('update.warn.commitComponents'));
177
+ const confirmed = await ui.confirm({
178
+ message: t('update.confirmComponents'),
179
+ initialValue: false,
180
+ onCancel: cancel,
181
+ });
184
182
  if (!confirmed) {
185
- console.log(kleur.dim(`\n${t('update.cancelled')}\n`));
183
+ ui.outro(t('update.cancelled'));
186
184
  return;
187
185
  }
188
186
  }
189
187
 
190
- console.log('');
191
- const spinner = ora(t('update.applyingComponents')).start();
188
+ const spinner = ui.spinner();
189
+ spinner.start(t('update.applyingComponents'));
192
190
  try {
193
191
  const filesApplied = await applyBaseComponents(projectDir);
194
192
  if (filesApplied === 0) {
195
- spinner.warn(t('update.noComponentFiles'));
193
+ spinner.stop(`⚠ ${t('update.noComponentFiles')}`);
194
+ ui.outro(t('update.cancelled'));
196
195
  return;
197
196
  }
198
- spinner.succeed(t('update.appliedComponents', { count: filesApplied }));
197
+ spinner.stop(t('update.appliedComponents', { count: filesApplied }));
199
198
  } catch (err) {
200
- spinner.fail(t('update.applyComponentsFailed'));
199
+ spinner.error(t('update.applyComponentsFailed'));
201
200
  throw err;
202
201
  }
203
202
 
204
203
  {
205
- const spinnerPubGet = ora(t('update.pubGet')).start();
204
+ const spinnerPubGet = ui.spinner();
205
+ spinnerPubGet.start(t('update.pubGet'));
206
206
  try {
207
207
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
208
- spinnerPubGet.succeed(t('update.pubGetDone'));
208
+ spinnerPubGet.stop(t('update.pubGetDone'));
209
209
  } catch {
210
- spinnerPubGet.warn(t('update.pubGetFailed'));
210
+ spinnerPubGet.stop(`⚠ ${t('update.pubGetFailed')}`);
211
211
  }
212
212
  }
213
213
 
214
214
  kitSetup.cliVersion = currentVersion;
215
215
  await fs.outputFile(kitSetupPath, JSON.stringify(kitSetup, null, 2) + '\n', 'utf8');
216
- console.log(kleur.green(`\n✓ ${t('update.componentsSuccess')}\n`));
216
+ ui.outro(t('update.componentsSuccess'));
217
217
  return;
218
218
  }
219
219
 
220
220
  if (normalizedTarget === CORE_UPDATE_TARGET) {
221
+ printCompactHeader(t);
222
+ ui.intro(t('update.applyingCore'));
221
223
  if (!options.yes) {
222
- console.log(kleur.yellow(`\n ⚠ ${t('update.warn.commitComponents')}\n`));
223
- const { confirmed } = await prompts(
224
- { type: 'confirm', name: 'confirmed', message: t('update.confirmCore'), initial: false },
225
- { onCancel: () => { throw new Error(t('update.cancelled')); } }
226
- );
224
+ ui.log.warn(t('update.warn.commitComponents'));
225
+ const confirmed = await ui.confirm({
226
+ message: t('update.confirmCore'),
227
+ initialValue: false,
228
+ onCancel: cancel,
229
+ });
227
230
  if (!confirmed) {
228
- console.log(kleur.dim(`\n${t('update.cancelled')}\n`));
231
+ ui.outro(t('update.cancelled'));
229
232
  return;
230
233
  }
231
234
  }
232
235
 
233
- console.log('');
234
- const spinner = ora(t('update.applyingCore')).start();
236
+ const spinner = ui.spinner();
237
+ spinner.start(t('update.applyingCore'));
235
238
  try {
236
239
  const filesApplied = await applyCoreFiles(projectDir);
237
240
  if (filesApplied === 0) {
238
- spinner.warn(t('update.noComponentFiles'));
241
+ spinner.stop(`⚠ ${t('update.noComponentFiles')}`);
242
+ ui.outro(t('update.cancelled'));
239
243
  return;
240
244
  }
241
- spinner.succeed(t('update.appliedCore', { count: filesApplied }));
245
+ spinner.stop(t('update.appliedCore', { count: filesApplied }));
242
246
  } catch (err) {
243
- spinner.fail(t('update.applyComponentsFailed'));
247
+ spinner.error(t('update.applyComponentsFailed'));
244
248
  throw err;
245
249
  }
246
250
 
247
251
  {
248
- const spinnerPubGet = ora(t('update.pubGet')).start();
252
+ const spinnerPubGet = ui.spinner();
253
+ spinnerPubGet.start(t('update.pubGet'));
249
254
  try {
250
255
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
251
- spinnerPubGet.succeed(t('update.pubGetDone'));
256
+ spinnerPubGet.stop(t('update.pubGetDone'));
252
257
  } catch {
253
- spinnerPubGet.warn(t('update.pubGetFailed'));
258
+ spinnerPubGet.stop(`⚠ ${t('update.pubGetFailed')}`);
254
259
  }
255
260
  }
256
261
 
257
262
  kitSetup.cliVersion = currentVersion;
258
263
  await fs.outputFile(kitSetupPath, JSON.stringify(kitSetup, null, 2) + '\n', 'utf8');
259
- console.log(kleur.green(`\n✓ ${t('update.coreSuccess')}\n`));
264
+ ui.outro(t('update.coreSuccess'));
260
265
  return;
261
266
  }
262
267
 
@@ -265,24 +270,22 @@ async function runUpdate(module, options = {}) {
265
270
  if (!(await fs.pathExists(patchDir))) {
266
271
  throw new Error(t('update.error.noIosReleasePatch'));
267
272
  }
273
+ printCompactHeader(t);
274
+ ui.intro(t('update.applying', { module: IOS_RELEASE_UPDATE_TARGET }));
268
275
  if (!options.yes) {
269
- console.log(kleur.yellow(`\n ⚠ ${t('update.warn.commit', { module: IOS_RELEASE_UPDATE_TARGET })}\n`));
270
- const { confirmed } = await prompts(
271
- {
272
- type: 'confirm',
273
- name: 'confirmed',
274
- message: t('update.confirm', { module: IOS_RELEASE_UPDATE_TARGET }),
275
- initial: false,
276
- },
277
- { onCancel: () => { throw new Error(t('update.cancelled')); } }
278
- );
276
+ ui.log.warn(t('update.warn.commit', { module: IOS_RELEASE_UPDATE_TARGET }));
277
+ const confirmed = await ui.confirm({
278
+ message: t('update.confirm', { module: IOS_RELEASE_UPDATE_TARGET }),
279
+ initialValue: false,
280
+ onCancel: cancel,
281
+ });
279
282
  if (!confirmed) {
280
- console.log(kleur.dim(`\n${t('update.cancelled')}\n`));
283
+ ui.outro(t('update.cancelled'));
281
284
  return;
282
285
  }
283
286
  }
284
- console.log('');
285
- const spinner = ora(t('update.applying', { module: IOS_RELEASE_UPDATE_TARGET })).start();
287
+ const spinner = ui.spinner();
288
+ spinner.start(t('update.applying', { module: IOS_RELEASE_UPDATE_TARGET }));
286
289
  try {
287
290
  const { tokens, pathReplacements } = buildTokens({
288
291
  appName: kitSetup.appName,
@@ -290,14 +293,14 @@ async function runUpdate(module, options = {}) {
290
293
  });
291
294
  await applyPatch(patchDir, projectDir, tokens, pathReplacements);
292
295
  await localizeReleaseDocs(projectDir, options.language || detectDefaultLanguage());
293
- spinner.succeed(t('update.iosRelease.success'));
296
+ spinner.stop(t('update.iosRelease.success'));
294
297
  } catch (err) {
295
- spinner.fail(t('update.applyFailed', { module: IOS_RELEASE_UPDATE_TARGET }));
298
+ spinner.error(t('update.applyFailed', { module: IOS_RELEASE_UPDATE_TARGET }));
296
299
  throw err;
297
300
  }
298
301
  kitSetup.cliVersion = currentVersion;
299
302
  await fs.outputFile(kitSetupPath, JSON.stringify(kitSetup, null, 2) + '\n', 'utf8');
300
- console.log('');
303
+ ui.outro(t('update.iosRelease.success'));
301
304
  return;
302
305
  }
303
306
 
@@ -311,74 +314,76 @@ async function runUpdate(module, options = {}) {
311
314
  );
312
315
  }
313
316
  if (!activeModules.includes(normalized)) {
314
- console.log(kleur.yellow(`\n${t('update.error.notActive', { module: normalized })}\n`));
317
+ printCompactHeader(t);
318
+ ui.log.warn(t('update.error.notActive', { module: normalized }));
315
319
  return;
316
320
  }
317
321
 
318
322
  const patchDir = path.join(FEATURES_PATCH_DIR, normalized);
319
323
  if (!(await fs.pathExists(patchDir))) {
320
- console.log(kleur.dim(`\n ${t('update.noPatch', { module: normalized })}\n`));
324
+ printCompactHeader(t);
325
+ ui.log.message(kleur.dim(t('update.noPatch', { module: normalized })));
321
326
  return;
322
327
  }
323
328
 
329
+ printCompactHeader(t);
330
+ ui.intro(t('update.applying', { module: normalized }));
331
+
324
332
  // Warn and confirm
325
333
  if (!options.yes) {
326
- console.log(kleur.yellow(`\n ⚠ ${t('update.warn.commit', { module: normalized })}\n`));
327
- const { confirmed } = await prompts(
328
- {
329
- type: 'confirm',
330
- name: 'confirmed',
331
- message: t('update.confirm', { module: normalized }),
332
- initial: false,
333
- },
334
- { onCancel: () => { throw new Error(t('update.cancelled')); } }
335
- );
334
+ ui.log.warn(t('update.warn.commit', { module: normalized }));
335
+ const confirmed = await ui.confirm({
336
+ message: t('update.confirm', { module: normalized }),
337
+ initialValue: false,
338
+ onCancel: cancel,
339
+ });
336
340
  if (!confirmed) {
337
- console.log(kleur.dim(`\n${t('update.cancelled')}\n`));
341
+ ui.outro(t('update.cancelled'));
338
342
  return;
339
343
  }
340
344
  }
341
345
 
342
- console.log('');
343
-
344
346
  // Re-apply patch
345
347
  {
346
- const spinner = ora(t('update.applying', { module: normalized })).start();
348
+ const spinner = ui.spinner();
349
+ spinner.start(t('update.applying', { module: normalized }));
347
350
  try {
348
351
  const { tokens, pathReplacements } = buildTokens({
349
352
  appName: kitSetup.appName,
350
353
  bundleId: kitSetup.bundleId,
351
354
  });
352
355
  await applyPatch(patchDir, projectDir, tokens, pathReplacements);
353
- spinner.succeed(t('update.applied', { module: normalized }));
356
+ spinner.stop(t('update.applied', { module: normalized }));
354
357
  } catch (err) {
355
- spinner.fail(t('update.applyFailed', { module: normalized }));
358
+ spinner.error(t('update.applyFailed', { module: normalized }));
356
359
  throw err;
357
360
  }
358
361
  }
359
362
 
360
363
  // flutter pub get
361
364
  {
362
- const spinner = ora(t('update.pubGet')).start();
365
+ const spinner = ui.spinner();
366
+ spinner.start(t('update.pubGet'));
363
367
  try {
364
368
  await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
365
- spinner.succeed(t('update.pubGetDone'));
369
+ spinner.stop(t('update.pubGetDone'));
366
370
  } catch {
367
- spinner.warn(t('update.pubGetFailed'));
371
+ spinner.stop(`⚠ ${t('update.pubGetFailed')}`);
368
372
  }
369
373
  }
370
374
 
371
375
  // build_runner (only for modules that generate code)
372
376
  if (NEEDS_BUILD_RUNNER.includes(normalized)) {
373
- const spinner = ora(t('update.buildRunner')).start();
377
+ const spinner = ui.spinner();
378
+ spinner.start(t('update.buildRunner'));
374
379
  try {
375
380
  await execAsync(
376
381
  'dart run build_runner build --delete-conflicting-outputs',
377
382
  { cwd: projectDir, timeout: 600_000 }
378
383
  );
379
- spinner.succeed(t('update.buildRunnerDone'));
384
+ spinner.stop(t('update.buildRunnerDone'));
380
385
  } catch {
381
- spinner.warn(t('update.buildRunnerFailed'));
386
+ spinner.stop(`⚠ ${t('update.buildRunnerFailed')}`);
382
387
  }
383
388
  }
384
389
 
@@ -386,7 +391,7 @@ async function runUpdate(module, options = {}) {
386
391
  kitSetup.cliVersion = currentVersion;
387
392
  await fs.outputFile(kitSetupPath, JSON.stringify(kitSetup, null, 2) + '\n', 'utf8');
388
393
 
389
- console.log(kleur.green(`\n✓ ${t('update.success', { module: normalized })}\n`));
394
+ ui.outro(t('update.success', { module: normalized }));
390
395
  return;
391
396
  }
392
397
 
@@ -404,25 +409,26 @@ async function runUpdate(module, options = {}) {
404
409
  }
405
410
 
406
411
  if (alreadyUpToDate && Object.keys(changes).length === 0) {
407
- console.log(kleur.green(`\n✓ ${t('update.alreadyUpToDate', { version: currentVersion })}\n`));
412
+ printCompactHeader(t);
413
+ ui.outro(t('update.alreadyUpToDate', { version: currentVersion }));
408
414
  return;
409
415
  }
410
416
 
411
- // Header
417
+ printCompactHeader(t);
412
418
  const fromLabel = projectVersion || '?';
413
- console.log(`\n${t('update.status', { from: fromLabel, to: currentVersion })}\n`);
419
+ ui.intro(t('update.status', { from: fromLabel, to: currentVersion }));
414
420
 
415
421
  // Modules/components with actual changelog entries — show what improved + the command
416
422
  const modulesWithChanges = Object.keys(changes);
417
423
  if (modulesWithChanges.length > 0) {
418
- console.log(kleur.bold(t('update.changesTitle')));
424
+ const lines = [kleur.bold(t('update.changesTitle'))];
419
425
  for (const mod of modulesWithChanges) {
420
426
  for (const { description } of changes[mod]) {
421
- console.log(` ${kleur.cyan('✦')} ${kleur.bold(mod)} ${kleur.dim('→')} ${kleur.cyan(`kasy update ${mod}`)}`);
422
- console.log(` ${kleur.dim(description)}`);
427
+ lines.push(`${kleur.cyan('✦')} ${kleur.bold(mod)} ${kleur.dim('→')} ${kleur.cyan(`kasy update ${mod}`)}`);
428
+ lines.push(` ${kleur.dim(description)}`);
423
429
  }
424
430
  }
425
- console.log('');
431
+ ui.log.message(lines.join('\n'));
426
432
  }
427
433
 
428
434
  // Modules without changelog entries that can still be re-applied (advanced / recovery)
@@ -431,27 +437,29 @@ async function runUpdate(module, options = {}) {
431
437
 
432
438
  if (modulesWithChanges.length === 0 && !hasComponentChanges) {
433
439
  // Nothing new — show everything available
440
+ const lines = [];
434
441
  if (patchableModules.length > 0) {
435
- console.log(t('update.howToUpdate'));
442
+ lines.push(t('update.howToUpdate'));
436
443
  for (const m of patchableModules) {
437
- console.log(kleur.cyan(` kasy update ${m}`));
444
+ lines.push(kleur.cyan(` kasy update ${m}`));
438
445
  }
439
- console.log('');
446
+ lines.push('');
440
447
  }
441
- console.log(t('update.howToUpdateComponents'));
442
- console.log(kleur.cyan(` kasy update ${COMPONENTS_UPDATE_TARGET}`));
443
- console.log('');
448
+ lines.push(t('update.howToUpdateComponents'));
449
+ lines.push(kleur.cyan(` kasy update ${COMPONENTS_UPDATE_TARGET}`));
450
+ ui.log.message(lines.join('\n'));
444
451
  } else if (modulesWithoutChanges.length > 0) {
445
452
  // Some modules have no new changes — show as secondary info
446
- console.log(kleur.dim(t('update.reapplyTitle')));
453
+ const lines = [kleur.dim(t('update.reapplyTitle'))];
447
454
  for (const m of modulesWithoutChanges) {
448
- console.log(kleur.dim(` kasy update ${m}`));
455
+ lines.push(kleur.dim(` kasy update ${m}`));
449
456
  }
450
457
  if (!hasComponentChanges) {
451
- console.log(kleur.dim(` kasy update ${COMPONENTS_UPDATE_TARGET}`));
458
+ lines.push(kleur.dim(` kasy update ${COMPONENTS_UPDATE_TARGET}`));
452
459
  }
453
- console.log('');
460
+ ui.log.message(lines.join('\n'));
454
461
  }
462
+ ui.outro('');
455
463
  }
456
464
 
457
465
  module.exports = { runUpdate };
@@ -2,6 +2,8 @@ const path = require('node:path');
2
2
  const { promisify } = require('node:util');
3
3
  const { execFile } = require('node:child_process');
4
4
  const kleur = require('kleur');
5
+ const ui = require('../utils/ui');
6
+ const { printCompactHeader } = require('../utils/brand');
5
7
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
6
8
  const { pathExists } = require('../utils/fs');
7
9
 
@@ -58,12 +60,16 @@ async function runValidate({ language, analyzeOnly = false } = {}) {
58
60
  ).then(() => true).catch(() => false);
59
61
 
60
62
  if (!anyExists) {
61
- console.log(kleur.yellow('\n⚠ kasy validate is only available in the Kasy development environment.'));
62
- console.log(kleur.gray(' Generate test projects with `kasy new` first, then run this command.\n'));
63
+ printCompactHeader(t);
64
+ ui.intro(t('validate.title'));
65
+ ui.log.warn('kasy validate is only available in the Kasy development environment.');
66
+ ui.log.message(kleur.dim('Generate test projects with `kasy new` first, then run this command.'));
67
+ ui.outro('Skipped');
63
68
  return;
64
69
  }
65
70
 
66
- console.log(kleur.bold(`\n${t('validate.title')}\n`));
71
+ printCompactHeader(t);
72
+ ui.intro(t('validate.title'));
67
73
 
68
74
  const commands = buildCommandsForPlatform(analyzeOnly);
69
75
  const failures = [];
@@ -75,54 +81,53 @@ async function runValidate({ language, analyzeOnly = false } = {}) {
75
81
  if (!exists) {
76
82
  const projectName = path.basename(combo.projectPath);
77
83
  failures.push({ combo: combo.id, reason: `${t('validate.projectNotFound')}: ${combo.projectPath}` });
78
- console.log(kleur.red(`✗ ${label}`));
79
- console.log(kleur.gray(` ${combo.projectPath}`));
80
- console.log(
84
+ ui.log.error(label);
85
+ ui.log.message(
86
+ `${kleur.dim(combo.projectPath)}\n` +
81
87
  kleur.yellow(
82
- ` ⚠ Projeto base não encontrado. Gere-o antes de validar:\n` +
83
- ` kasy new ${projectName} --backend ${combo.backend}\n` +
84
- ` O projeto deve ser gerado uma vez e mantido localmente para CI.`
88
+ `⚠ Projeto base não encontrado. Gere-o antes de validar:\n` +
89
+ ` kasy new ${projectName} --backend ${combo.backend}\n` +
90
+ ` O projeto deve ser gerado uma vez e mantido localmente para CI.`
85
91
  )
86
92
  );
87
93
  continue;
88
94
  }
89
95
 
90
- console.log(kleur.cyan(`• ${label}`));
91
- console.log(kleur.gray(` ${combo.projectPath}`));
96
+ ui.log.step(`${kleur.cyan('•')} ${label}\n${kleur.dim(combo.projectPath)}`);
92
97
 
93
98
  let comboFailed = false;
94
99
  for (const [cmd, args] of commands) {
95
100
  const printable = `${cmd} ${args.join(' ')}`;
96
- process.stdout.write(kleur.gray(` - ${printable} ... `));
97
-
101
+ const spinner = ui.spinner();
102
+ spinner.start(printable);
98
103
  try {
99
104
  await runCommand(cmd, args, combo.projectPath);
100
- process.stdout.write(`${kleur.green(t('validate.ok'))}\n`);
105
+ spinner.stop(`${printable} ${kleur.green(t('validate.ok'))}`);
101
106
  } catch (error) {
102
107
  comboFailed = true;
103
108
  failures.push({
104
109
  combo: combo.id,
105
110
  reason: `${printable} ${t('validate.fail')}`
106
111
  });
107
- process.stdout.write(`${kleur.red(t('validate.fail'))}\n`);
112
+ spinner.error(`${printable} ${t('validate.fail')}`);
108
113
  break;
109
114
  }
110
115
  }
111
116
 
112
117
  if (!comboFailed) {
113
- console.log(kleur.green(` ✓ ${combo.id} ${t('validate.passed')}`));
118
+ ui.log.success(`${combo.id} ${t('validate.passed')}`);
114
119
  }
115
120
  }
116
121
 
117
122
  if (failures.length > 0) {
118
- console.log(kleur.red(`\n${t('validate.failed')}`));
123
+ ui.log.error(t('validate.failed'));
119
124
  failures.forEach((failure) => {
120
- console.log(kleur.red(`- ${failure.combo}: ${failure.reason}`));
125
+ ui.log.error(`- ${failure.combo}: ${failure.reason}`);
121
126
  });
122
127
  throw new Error(t('validate.error'));
123
128
  }
124
129
 
125
- console.log(kleur.green(`\n${t('validate.success')}`));
130
+ ui.outro(t('validate.success'));
126
131
  }
127
132
 
128
133
  module.exports = {
@@ -25,26 +25,58 @@ const AVAILABLE_BACKENDS = [
25
25
  *
26
26
  * availableIn: backends that support this feature
27
27
  * defaultInPresets: which named presets include this feature by default
28
+ * displayName: user-facing label shown in CLI listings (Title Case, English)
29
+ * tag: optional restriction tag — `firebaseOnly` or `requiresDb` —
30
+ * rendered as a colored badge in CLI listings
31
+ * enhances: id of a base feature this optional feature activates real
32
+ * functionality for. Example: `revenuecat` enhances
33
+ * `subscription` — the Subscriptions screen exists in the base
34
+ * project but only sells real plans once RevenueCat is added.
35
+ *
36
+ * Note on id vs folder name (intentional, not a bug):
37
+ * - id `local_notifications` → folder `lib/features/local_reminder/`
38
+ * - id `feedback` → folder `lib/features/feedbacks/`
39
+ * The id describes the user-facing capability; the folder describes the
40
+ * Dart component. The generator (see generator-utils.js writeRouter and
41
+ * the cleanup step around feature dirs) removes these folders when the
42
+ * feature is not selected.
28
43
  */
29
44
  const FEATURE_CATALOG = [
30
45
  // integrations
31
- { id: 'sentry', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
32
- { id: 'analytics', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
33
- { id: 'facebook', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
46
+ { id: 'sentry', displayName: 'Crash Reports (Sentry)', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
47
+ { id: 'analytics', displayName: 'Analytics', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
48
+ { id: 'facebook', displayName: 'Facebook (Login + Ads)', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
34
49
  // monetization
35
- { id: 'revenuecat', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['saas', 'full'] },
50
+ { id: 'revenuecat', displayName: 'RevenueCat', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['saas', 'full'], enhances: 'subscription' },
36
51
  // features
37
- { id: 'onboarding', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
38
- { id: 'web', status: 'public', availableIn: ['firebase'], defaultInPresets: [] },
39
- { id: 'widget', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
40
- { id: 'llm_chat', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['content', 'full'] },
41
- { id: 'local_notifications', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: [] },
52
+ { id: 'onboarding', displayName: 'Onboarding', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['starter', 'saas', 'content', 'full'] },
53
+ { id: 'web', displayName: 'Web Support (PWA)', status: 'public', availableIn: ['firebase'], defaultInPresets: [], tag: 'firebaseOnly' },
54
+ { id: 'widget', displayName: 'Home Widget', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
55
+ { id: 'llm_chat', displayName: 'AI Chat', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['content', 'full'] },
56
+ { id: 'local_notifications', displayName: 'Local Reminders', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: [] },
42
57
  // feedback (Firebase/Supabase only)
43
- { id: 'feedback', status: 'public', availableIn: ['firebase', 'supabase'], defaultInPresets: ['saas', 'full'] },
58
+ { id: 'feedback', displayName: 'Feature Requests', status: 'public', availableIn: ['firebase', 'supabase'], defaultInPresets: ['saas', 'full'], tag: 'requiresDb' },
44
59
  // ci/cd
45
- { id: 'ci', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
60
+ { id: 'ci', displayName: 'CI/CD', status: 'public', availableIn: ['firebase', 'supabase', 'api'], defaultInPresets: ['full'] },
46
61
  ];
47
62
 
63
+ /**
64
+ * Base features — always present in every generated project.
65
+ * These cannot be added or removed via `kasy add` / `kasy remove`.
66
+ * Listed in the CLI so users understand what ships with every project.
67
+ */
68
+ const BASE_FEATURES = [
69
+ { id: 'authentication', displayName: 'Auth' },
70
+ { id: 'home', displayName: 'Home Screen' },
71
+ { id: 'settings', displayName: 'Settings Screen' },
72
+ { id: 'notifications', displayName: 'Push Notifications' },
73
+ { id: 'subscription', displayName: 'Subscriptions' },
74
+ ];
75
+
76
+ function getBaseFeatures() {
77
+ return BASE_FEATURES;
78
+ }
79
+
48
80
  /**
49
81
  * Returns features visible to the given audience, optionally filtered by backend.
50
82
  *
@@ -215,6 +247,7 @@ module.exports = {
215
247
  FEATURES_PATCH_DIR,
216
248
  AVAILABLE_BACKENDS,
217
249
  FEATURE_CATALOG,
250
+ BASE_FEATURES,
218
251
  AVAILABLE_FEATURES,
219
252
  DEFAULT_FEATURES,
220
253
  BASE_COMPONENT_FILES,
@@ -223,4 +256,5 @@ module.exports = {
223
256
  normalizeFeature,
224
257
  parseFeatureList,
225
258
  getVisibleFeatures,
259
+ getBaseFeatures,
226
260
  };
@@ -21,7 +21,7 @@ features/
21
21
  ...
22
22
  ```
23
23
 
24
- Cada subdiretório tem o **mesmo nome** que o valor do módulo em `new.js` (`sentry`, `analytics`, `facebook`, `revenuecat`, `onboarding`, `web`, `widget`, `camera`, `llm_chat`, `feedback`, `ci`).
24
+ Cada subdiretório tem o **mesmo nome** que o valor do módulo em `new.js` (`sentry`, `analytics`, `facebook`, `revenuecat`, `onboarding`, `web`, `widget`, `llm_chat`, `local_notifications`, `feedback`, `ci`).
25
25
 
26
26
  Ao gerar um projeto, o engine copia recursivamente o conteúdo de `features/{modulo}/` para a raiz do projeto, sobrescrevendo ou acrescentando arquivos.
27
27
 
@@ -32,7 +32,6 @@ Ao gerar um projeto, o engine copia recursivamente o conteúdo de `features/{mod
32
32
  | `ci` | ✅ Sim | Adiciona `.github/`, `.gitlab-ci.yml`, etc. |
33
33
  | `web` | ✅ Sim | Adiciona pasta `web/` e configurações de plataforma |
34
34
  | `widget` | ✅ Sim | Adiciona configurações Android para home widgets |
35
- | `camera` | Opcional | Permissões extras no AndroidManifest / Info.plist |
36
35
  | `llm_chat` | Não | Apenas `LLM_CHAT_ENDPOINT` via dart-define. A chave da API LLM fica no servidor (Firebase Secret / Supabase Secret) — nunca no app. |
37
36
  | `sentry` | Não | Apenas dart-define (`SENTRY_DSN`) |
38
37
  | `analytics` | Não | Apenas dart-define |
@@ -985,7 +985,7 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
985
985
  * directories for modules the user did not select to avoid dead code.
986
986
  *
987
987
  * @param {string} projectDir
988
- * @param {string[]} modules - Selected modules (e.g. ['camera', 'analytics'])
988
+ * @param {string[]} modules - Selected modules (e.g. ['analytics', 'sentry'])
989
989
  */
990
990
  async function removeModuleDirs(projectDir, modules) {
991
991
  const removable = [