kasy-cli 1.5.2 → 1.6.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.
@@ -14,37 +14,9 @@
14
14
  const path = require('node:path');
15
15
  const crypto = require('node:crypto');
16
16
  const kleur = require('kleur');
17
- const gradient = require('gradient-string');
18
- const oraPackage = require('ora');
19
- const ora = oraPackage.default || oraPackage;
20
17
 
21
18
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
22
19
 
23
- // Spinner manager for multi-step long-running operations.
24
- // Each call to .next(text) succeeds the previous step and starts a new one.
25
- // Call .succeed(text) or .fail(text) to close the last step.
26
- function makeProgressSpinner() {
27
- let current = null;
28
- return {
29
- next(text) {
30
- if (current) current.succeed();
31
- current = ora(` ${text}`).start();
32
- },
33
- succeed(text) {
34
- if (current) { current.succeed(text ? ` ${text}` : undefined); current = null; }
35
- },
36
- fail(text) {
37
- if (current) { current.fail(text ? ` ${text}` : undefined); current = null; }
38
- },
39
- warn(text) {
40
- if (current) { current.warn(text ? ` ${text}` : undefined); current = null; }
41
- },
42
- stop() {
43
- if (current) { current.stop(); current = null; }
44
- },
45
- };
46
- }
47
-
48
20
  function generateWebhookKey() {
49
21
  return 'rc_wh_' + crypto.randomBytes(16).toString('hex');
50
22
  }
@@ -68,7 +40,8 @@ function openUrl(url) {
68
40
  exec(cmd, { shell: true });
69
41
  } catch (_) {}
70
42
  }
71
- const prompts = require('prompts');
43
+ const ui = require('../utils/ui');
44
+ const { printBanner, infoBox, successBox } = require('../utils/brand');
72
45
  const fs = require('fs-extra');
73
46
  const { createTranslator } = require('../utils/i18n');
74
47
  const { getStoredLanguage, setStoredLanguage } = require('../utils/license');
@@ -110,51 +83,37 @@ const BILLING_OTHER = '__other__';
110
83
  async function promptBillingAccountIfNeeded(tr, onCancel) {
111
84
  const billingList = await listBillingAccounts();
112
85
  if (!billingList.ok) return null;
113
- if (!billingList.accounts?.length) {
114
- console.log(kleur.yellow(` ${tr('new.firebase.q.billingAccount.context')}`));
115
- const { manualId } = await prompts(
116
- {
117
- type: 'text',
118
- name: 'manualId',
119
- message: tr('new.firebase.q.billingAccount.manualId'),
120
- hint: tr('new.firebase.q.billingAccount.manualId.hint'),
121
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.billingAccount.manualId.required')),
122
- },
123
- { onCancel }
124
- );
125
- return manualId?.trim() || null;
126
- }
127
- const choices = [
128
- ...billingList.accounts.map((a) => ({
129
- title: `${a.name || a.id} (${a.id})`,
130
- value: a.id,
131
- })),
132
- { title: tr('new.firebase.q.billingAccount.other'), value: BILLING_OTHER },
133
- ];
134
- console.log(kleur.yellow(` ${tr('new.firebase.q.billingAccount.context')}`));
135
- const { billingAccountId } = await prompts(
136
- {
137
- type: 'select',
138
- name: 'billingAccountId',
139
- message: tr('new.firebase.q.billingAccount'),
140
- hint: tr('new.firebase.q.billingAccount.hint'),
141
- choices,
142
- },
143
- { onCancel }
144
- );
145
- if (billingAccountId === BILLING_OTHER) {
146
- const { manualId } = await prompts(
147
- {
148
- type: 'text',
149
- name: 'manualId',
150
- message: tr('new.firebase.q.billingAccount.manualId'),
151
- hint: tr('new.firebase.q.billingAccount.manualId.hint'),
152
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.billingAccount.manualId.required')),
153
- },
154
- { onCancel }
155
- );
86
+
87
+ const askManualId = async () => {
88
+ const manualId = await ui.text({
89
+ message: tr('new.firebase.q.billingAccount.manualId'),
90
+ placeholder: tr('new.firebase.q.billingAccount.manualId.hint'),
91
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.billingAccount.manualId.required')),
92
+ onCancel,
93
+ });
156
94
  return manualId?.trim() || null;
95
+ };
96
+
97
+ if (!billingList.accounts?.length) {
98
+ ui.note(tr('new.firebase.q.billingAccount.context'));
99
+ return askManualId();
157
100
  }
101
+
102
+ ui.note(tr('new.firebase.q.billingAccount.context'));
103
+ const billingAccountId = await ui.select({
104
+ message: tr('new.firebase.q.billingAccount'),
105
+ initialValue: billingList.accounts[0].id,
106
+ options: [
107
+ ...billingList.accounts.map((a) => ({
108
+ value: a.id,
109
+ label: `${a.name || a.id} (${a.id})`,
110
+ })),
111
+ { value: BILLING_OTHER, label: tr('new.firebase.q.billingAccount.other') },
112
+ ],
113
+ onCancel,
114
+ });
115
+
116
+ if (billingAccountId === BILLING_OTHER) return askManualId();
158
117
  return billingAccountId;
159
118
  }
160
119
 
@@ -168,100 +127,77 @@ async function promptBillingAccountIfNeeded(tr, onCancel) {
168
127
  async function promptOrganizationIfNeeded(tr, onCancel) {
169
128
  const orgList = await listGcpOrganizations();
170
129
  if (!orgList.ok || !orgList.organizations?.length) return null;
171
- const choices = [
172
- ...orgList.organizations.map((o) => ({
173
- title: `${o.name} (${o.id})`,
174
- value: o.id,
175
- })),
176
- { title: tr('new.firebase.q.organization.none'), value: null },
177
- ];
178
- const { organizationId } = await prompts(
179
- {
180
- type: 'select',
181
- name: 'organizationId',
182
- message: tr('new.firebase.q.organization'),
183
- hint: tr('new.firebase.q.organization.hint'),
184
- choices,
185
- },
186
- { onCancel }
187
- );
130
+ const organizationId = await ui.select({
131
+ message: tr('new.firebase.q.organization'),
132
+ initialValue: orgList.organizations[0].id,
133
+ options: [
134
+ ...orgList.organizations.map((o) => ({
135
+ value: o.id,
136
+ label: `${o.name} (${o.id})`,
137
+ })),
138
+ { value: null, label: tr('new.firebase.q.organization.none') },
139
+ ],
140
+ onCancel,
141
+ });
188
142
  return organizationId || null;
189
143
  }
190
144
 
191
145
  // ── Helpers ───────────────────────────────────────────────────────────────────
192
146
 
193
- function printBanner(tr) {
194
- const bar = kleur.gray('─────────────────────────────────────────────────');
195
- const logo = [
196
- ' ╦╔═ ╔═╗ ╔═╗ ╦ ╦',
197
- ' ╠╩╗ ╠═╣ ╚═╗ ╚╦╝',
198
- ' ╩ ╩ ╩ ╩ ╚═╝ ╩ ',
199
- ]
200
- .map((line) => gradient(['#a78bfa', '#60a5fa'])(line))
201
- .join('\n');
202
- console.log(`\n${bar}\n`);
203
- console.log(logo);
204
- console.log('');
205
- console.log(` ${kleur.dim(tr('new.subtitle2'))}`);
206
- console.log(`\n${bar}\n`);
207
- }
208
-
209
147
  function printPrerequisites(tr, backend, firebaseSetupMode = 'existing', checkResults = []) {
210
148
  const gcloudOk = checkResults.every(
211
149
  (r) => !r.name?.includes('gcloud') || r.ok
212
150
  );
213
- console.log(kleur.bold().yellow(` ${tr('new.prereq.title')}`));
214
151
  const firebaseCreate = firebaseSetupMode === 'create';
152
+ const items = [];
153
+
215
154
  if (backend === 'firebase') {
216
155
  if (firebaseCreate) {
217
- if (!gcloudOk) {
218
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.1')}`));
219
- }
220
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.2')}`));
221
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.projectQuota')}`));
222
- console.log(kleur.cyan(` ${tr('new.firebase.prereq.create.note')}`));
156
+ if (!gcloudOk) items.push(tr('new.firebase.prereq.create.1'));
157
+ items.push(tr('new.firebase.prereq.create.2'));
158
+ items.push(tr('new.firebase.prereq.create.projectQuota'));
159
+ items.push(tr('new.firebase.prereq.create.note'));
223
160
  } else {
224
- console.log(kleur.gray(` ${tr('new.firebase.prereq.1')}`));
225
- console.log(kleur.gray(` ${tr('new.firebase.prereq.2')}`));
226
- console.log(kleur.gray(` ${tr('new.firebase.prereq.3')}`));
227
- console.log(kleur.gray(` ${tr('new.firebase.prereq.4')}`));
228
- console.log(kleur.gray(` ${tr('new.firebase.prereq.5')}`));
229
- console.log(kleur.cyan(` ${tr('new.firebase.prereq.doc')}`));
161
+ items.push(tr('new.firebase.prereq.1'));
162
+ items.push(tr('new.firebase.prereq.2'));
163
+ items.push(tr('new.firebase.prereq.3'));
164
+ items.push(tr('new.firebase.prereq.4'));
165
+ items.push(tr('new.firebase.prereq.5'));
166
+ items.push(tr('new.firebase.prereq.doc'));
230
167
  }
231
168
  } else if (backend === 'supabase') {
232
169
  if (firebaseCreate) {
233
- if (!gcloudOk) {
234
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.1')}`));
235
- }
236
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.2')}`));
237
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.projectQuota')}`));
238
- console.log(kleur.cyan(` ${tr('new.firebase.prereq.create.pushNote')}`));
239
- console.log(kleur.gray(` ${tr('new.supabase.prereq.1')}`));
240
- console.log(kleur.gray(` ${tr('new.supabase.prereq.2')}`));
241
- console.log(kleur.cyan(` ${tr('new.supabase.prereq.login')}`));
170
+ if (!gcloudOk) items.push(tr('new.firebase.prereq.create.1'));
171
+ items.push(tr('new.firebase.prereq.create.2'));
172
+ items.push(tr('new.firebase.prereq.create.projectQuota'));
173
+ items.push(tr('new.firebase.prereq.create.pushNote'));
174
+ items.push(tr('new.supabase.prereq.1'));
175
+ items.push(tr('new.supabase.prereq.2'));
176
+ items.push(tr('new.supabase.prereq.login'));
242
177
  } else {
243
- console.log(kleur.gray(` ${tr('new.supabase.prereq.1')}`));
244
- console.log(kleur.gray(` ${tr('new.supabase.prereq.2')}`));
245
- console.log(kleur.gray(` ${tr('new.supabase.prereq.3')}`));
246
- console.log(kleur.cyan(` ${tr('new.supabase.prereq.login')}`));
178
+ items.push(tr('new.supabase.prereq.1'));
179
+ items.push(tr('new.supabase.prereq.2'));
180
+ items.push(tr('new.supabase.prereq.3'));
181
+ items.push(tr('new.supabase.prereq.login'));
247
182
  }
248
183
  } else if (backend === 'api') {
249
184
  if (firebaseCreate) {
250
- if (!gcloudOk) {
251
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.1')}`));
252
- }
253
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.2')}`));
254
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.projectQuota')}`));
255
- console.log(kleur.cyan(` ${tr('new.firebase.prereq.create.pushNote')}`));
256
- console.log(kleur.gray(` ${tr('new.api.prereq.1')}`));
257
- console.log(kleur.gray(` ${tr('new.api.prereq.2')}`));
185
+ if (!gcloudOk) items.push(tr('new.firebase.prereq.create.1'));
186
+ items.push(tr('new.firebase.prereq.create.2'));
187
+ items.push(tr('new.firebase.prereq.create.projectQuota'));
188
+ items.push(tr('new.firebase.prereq.create.pushNote'));
189
+ items.push(tr('new.api.prereq.1'));
190
+ items.push(tr('new.api.prereq.2'));
258
191
  } else {
259
- console.log(kleur.gray(` ${tr('new.api.prereq.1')}`));
260
- console.log(kleur.gray(` ${tr('new.api.prereq.2')}`));
261
- console.log(kleur.gray(` ${tr('new.api.prereq.3')}`));
192
+ items.push(tr('new.api.prereq.1'));
193
+ items.push(tr('new.api.prereq.2'));
194
+ items.push(tr('new.api.prereq.3'));
262
195
  }
263
196
  }
264
- console.log('');
197
+
198
+ if (items.length > 0) {
199
+ ui.note(items.map((i) => `• ${i}`).join('\n'), tr('new.prereq.title'));
200
+ }
265
201
  }
266
202
 
267
203
  function printSummary(tr, answers) {
@@ -270,25 +206,18 @@ function printSummary(tr, answers) {
270
206
  : tr('new.firebase.confirm.none');
271
207
 
272
208
  const backendLabel = { firebase: '🔥 Firebase', supabase: '🟢 Supabase', api: '🔗 API REST' }[answers.backend] || answers.backend;
273
- const bar = kleur.gray(' ─────────────────────────────────────────────');
274
-
275
- console.log(`\n${bar}`);
276
- console.log(kleur.bold(` 📦 ${tr('new.firebase.confirm.title')}`));
277
- console.log(bar);
278
- console.log(` ${kleur.dim(tr('new.firebase.confirm.app') + ':')} ${kleur.white(answers.appName)}`);
279
- console.log(` ${kleur.dim('Bundle:')} ${kleur.white(answers.bundleId)}`);
280
- console.log(` ${kleur.dim('Backend:')} ${kleur.white(backendLabel)}`);
281
- if (answers.firebaseProjectId) {
282
- console.log(` ${kleur.dim('Firebase:')} ${kleur.white(answers.firebaseProjectId)}`);
283
- }
284
- if (answers.supabaseUrl) {
285
- console.log(` ${kleur.dim('Supabase URL:')} ${kleur.white(answers.supabaseUrl)}`);
286
- }
287
- if (answers.apiBaseUrl) {
288
- console.log(` ${kleur.dim('API URL:')} ${kleur.white(answers.apiBaseUrl)}`);
289
- }
290
- console.log(` ${kleur.dim(tr('new.firebase.confirm.modules') + ':')} ${kleur.white(modules)}`);
291
- console.log(bar);
209
+
210
+ const rows = [
211
+ `${kleur.dim(tr('new.firebase.confirm.app') + ':')} ${kleur.white(answers.appName)}`,
212
+ `${kleur.dim('Bundle:')} ${kleur.white(answers.bundleId)}`,
213
+ `${kleur.dim('Backend:')} ${kleur.white(backendLabel)}`,
214
+ ];
215
+ if (answers.firebaseProjectId) rows.push(`${kleur.dim('Firebase:')} ${kleur.white(answers.firebaseProjectId)}`);
216
+ if (answers.supabaseUrl) rows.push(`${kleur.dim('Supabase URL:')} ${kleur.white(answers.supabaseUrl)}`);
217
+ if (answers.apiBaseUrl) rows.push(`${kleur.dim('API URL:')} ${kleur.white(answers.apiBaseUrl)}`);
218
+ rows.push(`${kleur.dim(tr('new.firebase.confirm.modules') + ':')} ${kleur.white(modules)}`);
219
+
220
+ console.log(infoBox(`📦 ${tr('new.firebase.confirm.title')}`, rows.join('\n')));
292
221
  }
293
222
 
294
223
  const STEP_LABELS = {
@@ -377,38 +306,35 @@ function stepProgress(key, lang) {
377
306
  function printCreateFromScratchStatus(result, tr) {
378
307
  if (result.sha1Skipped) {
379
308
  const err = (result.sha1Error || '').replace(/\s+/g, ' ').slice(0, 120);
380
- if (result.sha1Skipped === 'api_failed') {
381
- console.log(kleur.yellow(` ${tr('new.sha1.skipped.apiFailed', { error: err })}`));
382
- } else {
383
- console.log(kleur.yellow(` ${tr('new.sha1.skipped.other', { reason: result.sha1Skipped })}`));
384
- }
385
- console.log(kleur.yellow(` ${tr('new.sha1.addManually')}`));
386
- if (result.sha1ManualUrl) console.log(kleur.cyan(` ${result.sha1ManualUrl}`));
309
+ const msg = result.sha1Skipped === 'api_failed'
310
+ ? tr('new.sha1.skipped.apiFailed', { error: err })
311
+ : tr('new.sha1.skipped.other', { reason: result.sha1Skipped });
312
+ const url = result.sha1ManualUrl ? `\n${kleur.cyan(result.sha1ManualUrl)}` : '';
313
+ ui.log.warn(`${msg}\n${tr('new.sha1.addManually')}${url}`);
387
314
  } else {
388
- console.log(kleur.green(` ${tr('new.sha1.added')}`));
315
+ ui.log.success(tr('new.sha1.added'));
389
316
  }
390
317
 
391
318
  if (result.firestoreCreated) {
392
- console.log(kleur.green(` ${tr('new.firestore.created')}`));
319
+ ui.log.success(tr('new.firestore.created'));
393
320
  } else {
394
- if (result.firestoreError) console.log(kleur.yellow(` ${tr('new.firestore.notCreated.error', { error: (result.firestoreError || '').slice(0, 100) })}`));
395
- else console.log(kleur.yellow(` ${tr('new.firestore.notCreated')}`));
396
- console.log(kleur.yellow(` ${tr('new.activateManually')}`));
397
- console.log(kleur.cyan(` ${result.firestoreUrl}`));
321
+ const msg = result.firestoreError
322
+ ? tr('new.firestore.notCreated.error', { error: (result.firestoreError || '').slice(0, 100) })
323
+ : tr('new.firestore.notCreated');
324
+ ui.log.warn(`${msg}\n${tr('new.activateManually')}\n${kleur.cyan(result.firestoreUrl)}`);
398
325
  }
399
326
 
400
327
  if (result.storageCreated) {
401
- console.log(kleur.green(` ${tr('new.storage.created')}`));
328
+ ui.log.success(tr('new.storage.created'));
402
329
  } else {
403
- if (result.storageError) console.log(kleur.yellow(` ${tr('new.storage.notCreated.error', { error: (result.storageError || '').slice(0, 100) })}`));
404
- else console.log(kleur.yellow(` ${tr('new.storage.notCreated')}`));
405
- console.log(kleur.yellow(` ${tr('new.activateManually')}`));
406
- console.log(kleur.cyan(` ${result.storageUrl}`));
330
+ const msg = result.storageError
331
+ ? tr('new.storage.notCreated.error', { error: (result.storageError || '').slice(0, 100) })
332
+ : tr('new.storage.notCreated');
333
+ ui.log.warn(`${msg}\n${tr('new.activateManually')}\n${kleur.cyan(result.storageUrl)}`);
407
334
  }
408
335
  }
409
336
 
410
337
  function printSuccessCard(tr, answers, targetDir) {
411
- const bar = kleur.gray(' ─────────────────────────────────────────────');
412
338
  const folderName = path.basename(targetDir);
413
339
  const consoleUrl = answers.backend === 'firebase' && answers.firebaseProjectId
414
340
  ? `https://console.firebase.google.com/project/${answers.firebaseProjectId}`
@@ -416,71 +342,62 @@ function printSuccessCard(tr, answers, targetDir) {
416
342
  ? 'https://supabase.com/dashboard'
417
343
  : answers.apiBaseUrl || null;
418
344
 
419
- console.log(`\n${bar}`);
420
- console.log(kleur.bold(gradient(['#a78bfa', '#60a5fa'])(` 🎉 ${tr('new.success.title')}`)));
421
- console.log(bar);
422
- console.log('');
423
- console.log(` ${kleur.bold(tr('new.success.nextSteps'))}`);
424
- console.log('');
425
- console.log(` ${kleur.dim('1.')} ${kleur.dim(tr('new.success.step.cd'))}`);
426
- console.log(` ${kleur.cyan(`cd ${folderName}`)}`);
427
- console.log('');
345
+ const lines = [];
346
+ lines.push(kleur.bold(tr('new.success.nextSteps')));
347
+ lines.push('');
348
+ lines.push(`${kleur.dim('1.')} ${kleur.dim(tr('new.success.step.cd'))}`);
349
+ lines.push(` ${kleur.cyan(`cd ${folderName}`)}`);
428
350
  let stepNum = 2;
429
351
  if (answers.backend === 'firebase') {
430
- console.log(` ${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.deploy'))}`);
431
- console.log(` ${kleur.cyan('kasy deploy')}`);
432
- console.log('');
352
+ lines.push('');
353
+ lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.deploy'))}`);
354
+ lines.push(` ${kleur.cyan('kasy deploy')}`);
433
355
  }
434
- console.log(` ${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.run'))}`);
435
- console.log(` ${kleur.cyan('kasy run')}`);
436
- console.log(` ${kleur.dim(tr('new.success.step.run.vscode'))}`);
356
+ lines.push('');
357
+ lines.push(`${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.run'))}`);
358
+ lines.push(` ${kleur.cyan('kasy run')}`);
359
+ lines.push(` ${kleur.dim(tr('new.success.step.run.vscode'))}`);
437
360
  if (consoleUrl) {
438
- console.log('');
439
- console.log(` ${kleur.dim(`${stepNum}.`)} ${kleur.dim(tr('new.success.step.console'))}`);
440
- console.log(` ${kleur.cyan(consoleUrl)}`);
361
+ lines.push('');
362
+ lines.push(`${kleur.dim(`${stepNum}.`)} ${kleur.dim(tr('new.success.step.console'))}`);
363
+ lines.push(` ${kleur.cyan(consoleUrl)}`);
441
364
  }
442
- console.log('');
443
- console.log(bar);
444
- console.log('');
365
+
366
+ console.log(successBox(`🎉 ${tr('new.success.title')}`, lines.join('\n')));
445
367
  }
446
368
 
447
369
  function printStepResult(step, lang = 'pt') {
370
+ const label = stepLabel(step.name, lang);
448
371
  if (step.skipped) {
449
- const label = stepLabel(step.name, lang);
450
- console.log(` ${kleur.gray('⏭')} ${kleur.gray(label)} ${kleur.gray('(skipped)')}`);
372
+ ui.log.step(`${kleur.dim(label)} ${kleur.dim('(skipped)')}`);
451
373
  return;
452
374
  }
453
- const icon = step.ok ? kleur.green('') : kleur.red('');
454
- const label = step.ok
455
- ? kleur.white(stepLabel(step.name, lang))
456
- : kleur.red(stepLabel(step.name, lang));
457
- console.log(` ${icon} ${label}${step.detail ? kleur.gray(` — ${step.detail.split('\n')[0]}`) : ''}`);
375
+ const detail = step.detail ? kleur.dim(` — ${step.detail.split('\n')[0]}`) : '';
376
+ if (step.ok) {
377
+ ui.log.success(`${label}${detail}`);
378
+ } else {
379
+ ui.log.error(`${label}${detail}`);
380
+ }
458
381
  }
459
382
 
460
383
  function onCancel(tr) {
461
- console.log(kleur.yellow(`\n ${tr('new.firebase.error.aborted')}\n`));
384
+ ui.cancel(tr('new.firebase.error.aborted'));
462
385
  process.exit(0);
463
386
  }
464
387
 
465
388
  async function promptSupabaseManual(tr, cancel) {
466
- return prompts(
467
- [
468
- {
469
- type: 'text',
470
- name: 'supabaseUrl',
471
- message: tr('prompt.supabase.url.enter'),
472
- hint: 'https://xxxx.supabase.co',
473
- validate: (v) => (v && v.trim() ? true : tr('prompt.supabase.url.required')),
474
- },
475
- {
476
- type: 'text',
477
- name: 'supabaseAnonKey',
478
- message: tr('prompt.supabase.anonKey.enter'),
479
- validate: (v) => (v && v.trim() ? true : tr('prompt.supabase.anonKey.required')),
480
- },
481
- ],
482
- { onCancel: cancel }
483
- );
389
+ const supabaseUrl = await ui.text({
390
+ message: tr('prompt.supabase.url.enter'),
391
+ placeholder: 'https://xxxx.supabase.co',
392
+ validate: (v) => (v && v.trim() ? undefined : tr('prompt.supabase.url.required')),
393
+ onCancel: cancel,
394
+ });
395
+ const supabaseAnonKey = await ui.text({
396
+ message: tr('prompt.supabase.anonKey.enter'),
397
+ validate: (v) => (v && v.trim() ? undefined : tr('prompt.supabase.anonKey.required')),
398
+ onCancel: cancel,
399
+ });
400
+ return { supabaseUrl, supabaseAnonKey };
484
401
  }
485
402
 
486
403
  // ── Module presets ────────────────────────────────────────────────────────────
@@ -534,6 +451,8 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
534
451
 
535
452
  printBanner(tr);
536
453
 
454
+ ui.intro(kleur.bold(tr('new.subtitle2')));
455
+
537
456
  // Whether an explicit target directory was provided by the user
538
457
  const hasExplicitDir = directory && directory !== '.';
539
458
 
@@ -553,24 +472,19 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
553
472
  // ── 3. Backend selection (support pre-selection via --backend flag) ─────
554
473
  let backend = normalizeBackend(backendHint);
555
474
  if (!backend) {
556
- const { backend: selectedBackend } = await prompts(
557
- {
558
- type: 'select',
559
- name: 'backend',
560
- message: tr('new.q.backend'),
561
- choices: [
562
- { title: '🔥 Firebase', description: tr('new.q.backend.firebase.desc'), value: 'firebase' },
563
- { title: '🟢 Supabase', description: tr('new.q.backend.supabase.desc'), value: 'supabase' },
564
- { title: '🔗 API REST', description: tr('new.q.backend.api.desc'), value: 'api' },
565
- ],
566
- initial: 0,
567
- },
568
- { onCancel: cancel }
569
- );
570
- backend = selectedBackend;
475
+ backend = await ui.select({
476
+ message: tr('new.q.backend'),
477
+ initialValue: 'firebase',
478
+ options: [
479
+ { value: 'firebase', label: '🔥 Firebase', hint: tr('new.q.backend.firebase.desc') },
480
+ { value: 'supabase', label: '🟢 Supabase', hint: tr('new.q.backend.supabase.desc') },
481
+ { value: 'api', label: '🔗 API REST', hint: tr('new.q.backend.api.desc') },
482
+ ],
483
+ onCancel: cancel,
484
+ });
571
485
  } else {
572
486
  const backendLabel = { firebase: '🔥 Firebase', supabase: '🟢 Supabase', api: '🔗 API REST' }[backend] || backend;
573
- console.log(kleur.gray(` Backend: ${kleur.white(backendLabel)}`));
487
+ ui.log.info(`Backend: ${kleur.white(backendLabel)}`);
574
488
  }
575
489
 
576
490
  // ── 3b. Backend tool checks (required — blocks if missing) ───────────────
@@ -579,7 +493,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
579
493
  let backendCheckResults = [];
580
494
  if (backendChecks.length > 0) {
581
495
  if (backend === 'supabase' || backend === 'api') {
582
- console.log(kleur.dim(`\n ℹ ${tr('new.checks.firebaseForPush')}`));
496
+ ui.log.info(tr('new.checks.firebaseForPush'));
583
497
  }
584
498
  const backendLabel = tr('setup.checks.backend', { backend }) || ` Checking ${backend} tools…`;
585
499
  backendCheckResults = await runChecks(backendChecks, backendLabel, {
@@ -589,53 +503,111 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
589
503
  doneLabel: tr('setup.checks.backend.done', { backend }) || `${backend} tools ready`,
590
504
  });
591
505
  if (hasRequiredFailures(backendCheckResults)) {
592
- console.log(kleur.red(`\n ✖ ${tr('new.checks.requiredBlock')}`));
593
- console.log(kleur.dim(`\n ${tr('new.checks.installFirebase')}`));
506
+ const installSteps = [tr('new.checks.installFirebase')];
594
507
  if (backend === 'supabase') {
595
508
  const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux';
596
509
  const supabaseKey = `new.checks.installSupabase.${platform}`;
597
- console.log(kleur.dim(` ${tr(supabaseKey) || tr('new.checks.installSupabase')}`));
510
+ installSteps.push(tr(supabaseKey) || tr('new.checks.installSupabase'));
598
511
  }
599
- console.log('');
512
+ ui.log.error(tr('new.checks.requiredBlock'));
513
+ ui.note(installSteps.map((s) => `• ${s}`).join('\n'));
514
+ ui.cancel(tr('new.firebase.error.aborted'));
600
515
  process.exit(1);
601
516
  }
602
517
  }
603
518
 
519
+ // ── App identity: name + bundle ID — first things first ────────────────────
520
+ let core;
521
+ if (yes) {
522
+ if (!hasExplicitDir) {
523
+ ui.log.error('--yes requires an app name: kasy new MyApp --yes');
524
+ ui.cancel(tr('new.firebase.error.aborted'));
525
+ process.exit(1);
526
+ }
527
+ const appName = path.basename(targetDir);
528
+ const slug = appName
529
+ .normalize('NFD')
530
+ .replace(/[̀-ͯ]/g, '')
531
+ .toLowerCase()
532
+ .replace(/[^a-z0-9]/g, '');
533
+ const bundleId = (slug && !/^\d/.test(slug)) ? `com.${slug}.app` : 'com.example.app';
534
+ core = { appName, bundleId };
535
+ ui.log.info(`App: ${kleur.white(appName)}`);
536
+ ui.log.info(`Bundle: ${kleur.white(bundleId)}`);
537
+ } else {
538
+ const appName = await ui.text({
539
+ message: tr('new.firebase.q.appName'),
540
+ placeholder: tr('new.firebase.q.appName.hint'),
541
+ initialValue: hasExplicitDir ? path.basename(targetDir) : '',
542
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.appName.required')),
543
+ onCancel: cancel,
544
+ });
545
+
546
+ const defaultBundleId = (() => {
547
+ const name = (appName || '').trim();
548
+ if (!name) return 'com.example.app';
549
+ const slug = name
550
+ .normalize('NFD')
551
+ .replace(/[̀-ͯ]/g, '')
552
+ .toLowerCase()
553
+ .replace(/[^a-z0-9]/g, '');
554
+ if (!slug || /^\d/.test(slug)) return 'com.example.app';
555
+ return `com.${slug}.app`;
556
+ })();
557
+
558
+ const bundleId = await ui.text({
559
+ message: tr('new.firebase.q.bundleId'),
560
+ placeholder: tr('new.firebase.q.bundleId.hint'),
561
+ initialValue: defaultBundleId,
562
+ validate: (v) => {
563
+ if (!v || !v.trim()) return tr('new.firebase.q.bundleId.required');
564
+ return /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/.test(v.trim())
565
+ ? undefined
566
+ : tr('new.firebase.q.bundleId.invalid');
567
+ },
568
+ onCancel: cancel,
569
+ });
570
+
571
+ core = { appName, bundleId };
572
+ }
573
+
574
+ // Resolve targetDir now that we have the app name
575
+ if (!targetDir) {
576
+ const folderName = toPackageName(core.appName.trim());
577
+ targetDir = path.resolve(process.cwd(), folderName);
578
+ if (await fs.pathExists(targetDir)) {
579
+ const contents = await fs.readdir(targetDir);
580
+ if (contents.length > 0) {
581
+ throw new Error(tr('new.firebase.error.dirNotEmpty', { path: targetDir }));
582
+ }
583
+ }
584
+ }
585
+
604
586
  // ── Firebase setup mode (create vs existing) ────────────────────────────────
605
587
  // Firebase backend: full setup. Supabase/API: Firebase only for push notifications (FCM).
606
588
  let firebaseSetupMode = 'existing';
607
589
  if (!yes) {
608
590
  if (backend === 'firebase') {
609
- const { setupMode } = await prompts(
610
- {
611
- type: 'select',
612
- name: 'setupMode',
613
- message: tr('new.firebase.q.setupMode'),
614
- choices: [
615
- { title: tr('new.firebase.q.setupMode.create'), value: 'create' },
616
- { title: tr('new.firebase.q.setupMode.existing'), value: 'existing' },
617
- ],
618
- initial: 0,
619
- },
620
- { onCancel: cancel }
621
- );
622
- firebaseSetupMode = setupMode;
591
+ firebaseSetupMode = await ui.select({
592
+ message: tr('new.firebase.q.setupMode'),
593
+ initialValue: 'create',
594
+ options: [
595
+ { value: 'create', label: tr('new.firebase.q.setupMode.create') },
596
+ { value: 'existing', label: tr('new.firebase.q.setupMode.existing') },
597
+ ],
598
+ onCancel: cancel,
599
+ });
623
600
  } else if (backend === 'supabase' || backend === 'api') {
624
- console.log(kleur.dim(`\n ℹ ${tr('new.firebase.q.setupMode.push.explain')}\n`));
625
- const { setupMode } = await prompts(
626
- {
627
- type: 'select',
628
- name: 'setupMode',
629
- message: tr('new.firebase.q.setupMode.push'),
630
- choices: [
631
- { title: tr('new.firebase.q.setupMode.create'), value: 'create' },
632
- { title: tr('new.firebase.q.setupMode.existing'), value: 'existing' },
633
- ],
634
- initial: 0,
635
- },
636
- { onCancel: cancel }
637
- );
638
- firebaseSetupMode = setupMode;
601
+ ui.note(tr('new.firebase.q.setupMode.push.explain'));
602
+ firebaseSetupMode = await ui.select({
603
+ message: tr('new.firebase.q.setupMode.push'),
604
+ initialValue: 'create',
605
+ options: [
606
+ { value: 'create', label: tr('new.firebase.q.setupMode.create') },
607
+ { value: 'existing', label: tr('new.firebase.q.setupMode.existing') },
608
+ ],
609
+ onCancel: cancel,
610
+ });
639
611
  }
640
612
  }
641
613
 
@@ -643,184 +615,108 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
643
615
  // Asked after app name + setup context are clear, so the user understands what it controls.
644
616
  let isQuick = yes; // --yes implies Quick mode
645
617
  if (!yes) {
646
- const { wizardMode } = await prompts(
647
- {
648
- type: 'select',
649
- name: 'wizardMode',
650
- message: tr('new.q.mode'),
651
- choices: [
652
- { title: tr('new.q.mode.quick'), value: 'quick' },
653
- { title: tr('new.q.mode.advanced'), value: 'advanced' },
654
- ],
655
- initial: 0,
656
- },
657
- { onCancel: cancel }
658
- );
618
+ const wizardMode = await ui.select({
619
+ message: tr('new.q.mode'),
620
+ initialValue: 'quick',
621
+ options: [
622
+ { value: 'quick', label: tr('new.q.mode.quick') },
623
+ { value: 'advanced', label: tr('new.q.mode.advanced') },
624
+ ],
625
+ onCancel: cancel,
626
+ });
659
627
  isQuick = wizardMode === 'quick';
660
628
  }
661
629
 
662
630
  // ── Backend-specific prerequisites (dynamic: only show what's not yet verified) ─
663
631
  printPrerequisites(tr, backend, firebaseSetupMode, backendCheckResults);
664
632
 
665
- // ── Firebase region Quick mode uses default (us-central1) ──────────
666
- let firebaseRegion = 'us-central1';
667
- if (backend === 'firebase' && !isQuick) {
668
- const { region } = await prompts(
669
- {
670
- type: 'select',
671
- name: 'region',
672
- message: tr('new.firebase.q.region'),
673
- choices: [
674
- { title: tr('new.firebase.q.region.us'), value: 'us-central1' },
675
- { title: tr('new.firebase.q.region.europe'), value: 'europe-west1' },
676
- { title: tr('new.firebase.q.region.brazil'), value: 'southamerica-east1' },
677
- ],
678
- initial: 0,
679
- },
680
- { onCancel: cancel }
681
- );
682
- firebaseRegion = region || 'us-central1';
683
- }
684
-
685
- // ── Core questions (appName, bundleId) ────────────────────────────────────
686
- const coreQuestions = [
687
- {
688
- type: 'text',
689
- name: 'appName',
690
- message: tr('new.firebase.q.appName'),
691
- hint: tr('new.firebase.q.appName.hint'),
692
- initial: hasExplicitDir ? path.basename(targetDir) : '',
693
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.appName.required')),
694
- },
695
- {
696
- type: 'text',
697
- name: 'bundleId',
698
- message: tr('new.firebase.q.bundleId'),
699
- hint: tr('new.firebase.q.bundleId.hint'),
700
- initial: (prev, values) => {
701
- const name = (values?.appName || prev || '').trim();
702
- if (!name) return 'com.example.app';
703
- const slug = name
704
- .normalize('NFD')
705
- .replace(/[\u0300-\u036f]/g, '')
706
- .toLowerCase()
707
- .replace(/[^a-z0-9]/g, '');
708
- if (!slug || /^\d/.test(slug)) return 'com.example.app';
709
- return `com.${slug}.app`;
710
- },
711
- validate: (v) => {
712
- if (!v || !v.trim()) return tr('new.firebase.q.bundleId.required');
713
- return /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/.test(v.trim())
714
- ? true
715
- : tr('new.firebase.q.bundleId.invalid');
716
- },
717
- },
718
- ];
719
- const needFirebaseProjectIdNow =
720
- (backend === 'firebase' && firebaseSetupMode === 'existing') ||
721
- ((backend === 'supabase' || backend === 'api') && firebaseSetupMode === 'existing');
722
- if (needFirebaseProjectIdNow) {
723
- coreQuestions.push({
724
- type: 'text',
725
- name: 'firebaseProjectId',
726
- message: tr('new.firebase.q.projectId'),
727
- hint: tr('new.firebase.q.projectId.hint') + (backend !== 'firebase' ? ' (FCM + Remote Config)' : ''),
728
- validate: (v) => {
729
- if (!v || !v.trim()) return tr('new.firebase.q.projectId.required');
730
- const id = v.trim();
731
- if (id.length < 6 || id.length > 30) return 'ID deve ter entre 6 e 30 caracteres (ex: meu-app-123)';
732
- if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(id)) return 'ID inválido: use letras minúsculas, números e hífens, começando com letra (ex: meu-app-123)';
733
- return true;
734
- },
735
- });
736
- }
737
- let core;
738
- if (yes) {
739
- if (!hasExplicitDir) {
740
- console.error(kleur.red(`\n ✗ --yes requires an app name: kasy new MyApp --yes\n`));
741
- process.exit(1);
633
+ // ── Firebase project ID (if using an existing project) ──────────────────────
634
+ if (!yes) {
635
+ const needFirebaseProjectId =
636
+ (backend === 'firebase' && firebaseSetupMode === 'existing') ||
637
+ ((backend === 'supabase' || backend === 'api') && firebaseSetupMode === 'existing');
638
+ if (needFirebaseProjectId) {
639
+ const firebaseProjectId = await ui.text({
640
+ message: tr('new.firebase.q.projectId'),
641
+ placeholder: tr('new.firebase.q.projectId.hint') + (backend !== 'firebase' ? ' (FCM + Remote Config)' : ''),
642
+ validate: (v) => {
643
+ if (!v || !v.trim()) return tr('new.firebase.q.projectId.required');
644
+ const id = v.trim();
645
+ if (id.length < 6 || id.length > 30) return 'ID deve ter entre 6 e 30 caracteres (ex: meu-app-123)';
646
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(id)) return 'ID inválido: use letras minúsculas, números e hífens, começando com letra (ex: meu-app-123)';
647
+ return undefined;
648
+ },
649
+ onCancel: cancel,
650
+ });
651
+ core.firebaseProjectId = firebaseProjectId;
742
652
  }
743
- const appName = path.basename(targetDir);
744
- const slug = appName
745
- .normalize('NFD')
746
- .replace(/[\u0300-\u036f]/g, '')
747
- .toLowerCase()
748
- .replace(/[^a-z0-9]/g, '');
749
- const bundleId = (slug && !/^\d/.test(slug)) ? `com.${slug}.app` : 'com.example.app';
653
+ } else {
750
654
  let firebaseProjectId = projectHint?.trim() || '';
751
655
  if (!firebaseProjectId && backend === 'firebase') {
752
- const { pid } = await prompts(
753
- {
754
- type: 'text',
755
- name: 'pid',
756
- message: tr('new.firebase.q.projectId'),
757
- hint: tr('new.firebase.q.projectId.hint'),
758
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
759
- },
760
- { onCancel: cancel }
761
- );
656
+ const pid = await ui.text({
657
+ message: tr('new.firebase.q.projectId'),
658
+ placeholder: tr('new.firebase.q.projectId.hint'),
659
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.projectId.required')),
660
+ onCancel: cancel,
661
+ });
762
662
  firebaseProjectId = pid?.trim() || '';
763
663
  }
764
- core = { appName, bundleId, firebaseProjectId };
765
- console.log(kleur.gray(` App: ${kleur.white(appName)}`));
766
- console.log(kleur.gray(` Bundle: ${kleur.white(bundleId)}`));
767
- if (firebaseProjectId) console.log(kleur.gray(` Project: ${kleur.white(firebaseProjectId)}`));
768
- } else {
769
- core = await prompts(coreQuestions, { onCancel: cancel });
664
+ if (firebaseProjectId) {
665
+ core.firebaseProjectId = firebaseProjectId;
666
+ ui.log.info(`Project: ${kleur.white(firebaseProjectId)}`);
667
+ }
668
+ }
669
+
670
+ // ── Firebase region — Quick mode uses default (us-central1) ──────────
671
+ let firebaseRegion = 'us-central1';
672
+ if (backend === 'firebase' && !isQuick) {
673
+ const region = await ui.select({
674
+ message: tr('new.firebase.q.region'),
675
+ initialValue: 'us-central1',
676
+ options: [
677
+ { value: 'us-central1', label: tr('new.firebase.q.region.us') },
678
+ { value: 'europe-west1', label: tr('new.firebase.q.region.europe') },
679
+ { value: 'southamerica-east1', label: tr('new.firebase.q.region.brazil') },
680
+ ],
681
+ onCancel: cancel,
682
+ });
683
+ firebaseRegion = region || 'us-central1';
770
684
  }
771
685
 
772
686
  // ── Firebase: create from scratch (when selected) ─────────────────────────
773
687
  let firebaseIncludeWeb = true;
774
688
  if (backend === 'firebase' && firebaseSetupMode === 'create') {
775
- const { includeWeb } = await prompts(
776
- {
777
- type: 'confirm',
778
- name: 'includeWeb',
779
- message: tr('new.firebase.create.includeWeb'),
780
- initial: true,
781
- },
782
- { onCancel: cancel }
783
- );
784
- firebaseIncludeWeb = includeWeb !== false;
689
+ firebaseIncludeWeb = await ui.confirm({
690
+ message: tr('new.firebase.create.includeWeb'),
691
+ initialValue: true,
692
+ onCancel: cancel,
693
+ });
785
694
  const gcloudCheck = await checkGcloudAuth();
786
695
  if (!gcloudCheck.ok) {
787
- console.log(kleur.red(` ${tr('new.firebase.create.gcloudRequired')}`));
696
+ ui.log.error(tr('new.firebase.create.gcloudRequired'));
788
697
  if (gcloudCheck.missing === 'gcloud') {
789
698
  const instructions = getGcloudInstallInstructions();
790
- console.log(kleur.cyan(`\n ${tr('new.firebase.create.installTitle')}`));
791
- if (instructions.install) {
792
- console.log(kleur.white(` ${tr('new.firebase.create.installCommand')}:`));
793
- console.log(kleur.cyan(` ${instructions.install}`));
794
- }
795
- if (instructions.hint) {
796
- console.log(kleur.gray(` ${instructions.hint}`));
797
- }
798
- console.log(kleur.white(` ${tr('new.firebase.create.installAfter')}:`));
799
- console.log(kleur.cyan(` ${instructions.after}`));
800
- console.log(kleur.gray(` ${tr('new.firebase.create.installUrl')}: ${instructions.url}`));
801
- console.log('');
699
+ const noteLines = [tr('new.firebase.create.installTitle')];
700
+ if (instructions.install) noteLines.push(`${tr('new.firebase.create.installCommand')}:\n ${kleur.cyan(instructions.install)}`);
701
+ if (instructions.hint) noteLines.push(instructions.hint);
702
+ noteLines.push(`${tr('new.firebase.create.installAfter')}:\n ${kleur.cyan(instructions.after)}`);
703
+ noteLines.push(`${tr('new.firebase.create.installUrl')}: ${instructions.url}`);
704
+ ui.note(noteLines.join('\n\n'));
802
705
  } else {
803
- console.log(kleur.cyan(` ${tr('new.firebase.create.authCommand')}`));
804
- console.log('');
706
+ ui.note(tr('new.firebase.create.authCommand'));
805
707
  }
806
- console.log(kleur.yellow(` ${tr('new.firebase.create.fallbackHint')}`));
807
- const fallback = await prompts(
808
- {
809
- type: 'text',
810
- name: 'firebaseProjectId',
811
- message: tr('new.firebase.q.projectId'),
812
- hint: tr('new.firebase.q.projectId.hint'),
813
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
814
- },
815
- { onCancel: cancel }
816
- );
817
- core.firebaseProjectId = fallback.firebaseProjectId;
708
+ ui.log.warn(tr('new.firebase.create.fallbackHint'));
709
+ core.firebaseProjectId = await ui.text({
710
+ message: tr('new.firebase.q.projectId'),
711
+ placeholder: tr('new.firebase.q.projectId.hint'),
712
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.projectId.required')),
713
+ onCancel: cancel,
714
+ });
818
715
  } else {
819
716
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
820
717
  let selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
821
- console.log(kleur.dim(`\n ⏱ ${tr('new.firebase.create.estimatedTime')}`));
822
- console.log(kleur.dim(` ${tr('new.internet.warning')}\n`));
823
- const ps1 = makeProgressSpinner();
718
+ ui.log.info(`${tr('new.firebase.create.estimatedTime')}\n${tr('new.internet.warning')}`);
719
+ const ps1 = ui.makeStepper();
824
720
  ps1.next(tr('new.firebase.create.creating'));
825
721
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
826
722
  includeWeb: firebaseIncludeWeb,
@@ -837,15 +733,31 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
837
733
  ps1.next(stepProgress('storage', language));
838
734
  } else if (key === 'auth-providers-warn') {
839
735
  ps1.stop();
840
- console.log(kleur.yellow(` ⚠ ${tr('new.firebase.interactive.authWarn')}`));
841
- console.log(kleur.cyan(` ${data?.url || ''}`));
736
+ ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
842
737
  }
843
738
  },
844
739
  });
740
+ const askReady = async (readyKey) => {
741
+ const ok = await ui.confirm({
742
+ message: tr(readyKey),
743
+ initialValue: true,
744
+ onCancel: cancel,
745
+ });
746
+ if (!ok) {
747
+ ui.cancel(tr('prompt.cancelled'));
748
+ process.exit(0);
749
+ }
750
+ };
751
+ const showBeforeContinue = (step1Key, authUrl) => {
752
+ ui.note(
753
+ `${tr(step1Key)}\n${kleur.cyan(authUrl)}`,
754
+ tr('new.firebase.create.beforeContinue.title')
755
+ );
756
+ };
845
757
  if (setupResult.ok) {
846
758
  ps1.succeed(tr('new.firebase.create.success'));
847
759
  core.firebaseProjectId = setupResult.projectId;
848
- console.log(kleur.dim(` Project ID: ${core.firebaseProjectId}`));
760
+ ui.log.info(`Project ID: ${core.firebaseProjectId}`);
849
761
  printCreateFromScratchStatus(setupResult, tr);
850
762
  const authUrl = `https://console.firebase.google.com/project/${core.firebaseProjectId}/authentication/providers`;
851
763
  if (setupResult.authEnabled && !setupResult.googleSignInSkipped) {
@@ -858,50 +770,33 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
858
770
  const readyKey = setupResult.googleSignInSkipped
859
771
  ? 'new.firebase.create.beforeContinue.ready'
860
772
  : 'new.firebase.create.beforeContinue.ready.noAuth';
861
- console.log(kleur.bold().yellow(`\n ${tr('new.firebase.create.beforeContinue.title')}`));
862
- console.log(kleur.gray(` ${tr(step1Key)}`));
863
- console.log(kleur.cyan(` ${authUrl}`));
773
+ showBeforeContinue(step1Key, authUrl);
864
774
  openUrl(authUrl);
865
- const { ready } = await prompts(
866
- {
867
- type: 'confirm',
868
- name: 'ready',
869
- message: tr(readyKey),
870
- initial: true,
871
- },
872
- { onCancel: cancel }
873
- );
874
- if (!ready) {
875
- console.log(kleur.gray(` ${tr('prompt.cancelled')}`));
876
- process.exit(0);
877
- }
775
+ await askReady(readyKey);
878
776
  }
879
777
 
880
778
  } else {
881
779
  ps1.fail(tr('new.firebase.create.failed'));
882
780
  let lastResult = setupResult;
883
781
  while (lastResult.billingFailed && lastResult.projectId) {
884
- console.log(kleur.red(` ${tr('new.firebase.create.failed')}: ${lastResult.error}`));
885
- console.log(kleur.yellow(`\n ${tr('new.firebase.create.billingRetry.title')}`));
886
- console.log(kleur.cyan(` ${lastResult.billingManualLink}`));
887
- console.log(kleur.gray(` ${tr('new.firebase.create.billingRetry.hint')}`));
888
- const { retry } = await prompts(
889
- {
890
- type: 'confirm',
891
- name: 'retry',
892
- message: tr('new.firebase.create.billingRetry.ready'),
893
- initial: true,
894
- },
895
- { onCancel: cancel }
782
+ ui.log.error(`${tr('new.firebase.create.failed')}: ${lastResult.error}`);
783
+ ui.note(
784
+ `${kleur.cyan(lastResult.billingManualLink)}\n\n${tr('new.firebase.create.billingRetry.hint')}`,
785
+ tr('new.firebase.create.billingRetry.title')
896
786
  );
787
+ const retry = await ui.confirm({
788
+ message: tr('new.firebase.create.billingRetry.ready'),
789
+ initialValue: true,
790
+ onCancel: cancel,
791
+ });
897
792
  if (!retry) {
898
- console.log(kleur.gray(` ${tr('new.firebase.create.billingRetry.exit')}`));
899
- console.log(kleur.cyan(` ${tr('new.firebase.create.useExistingHint', { id: lastResult.projectId })}`));
793
+ ui.log.message(tr('new.firebase.create.billingRetry.exit'));
794
+ ui.log.info(tr('new.firebase.create.useExistingHint', { id: lastResult.projectId }));
900
795
  process.exit(0);
901
796
  }
902
- console.log(kleur.gray(` ${tr('new.firebase.create.billingRetry.retrying')}`));
797
+ ui.log.message(tr('new.firebase.create.billingRetry.retrying'));
903
798
  selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
904
- const ps2 = makeProgressSpinner();
799
+ const ps2 = ui.makeStepper();
905
800
  ps2.next(tr('new.firebase.create.creating'));
906
801
  lastResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
907
802
  includeWeb: firebaseIncludeWeb,
@@ -923,7 +818,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
923
818
  if (lastResult.ok) {
924
819
  ps2.succeed(tr('new.firebase.create.success'));
925
820
  core.firebaseProjectId = lastResult.projectId;
926
- console.log(kleur.dim(` Project ID: ${core.firebaseProjectId}`));
821
+ ui.log.info(`Project ID: ${core.firebaseProjectId}`);
927
822
  printCreateFromScratchStatus(lastResult, tr);
928
823
  const authUrl = `https://console.firebase.google.com/project/${core.firebaseProjectId}/authentication/providers`;
929
824
  if (lastResult.authEnabled && !lastResult.googleSignInSkipped) {
@@ -935,47 +830,32 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
935
830
  const lastReadyKey = lastResult.googleSignInSkipped
936
831
  ? 'new.firebase.create.beforeContinue.ready'
937
832
  : 'new.firebase.create.beforeContinue.ready.noAuth';
938
- console.log(kleur.bold().yellow(`\n ${tr('new.firebase.create.beforeContinue.title')}`));
939
- console.log(kleur.gray(` ${tr(step1Key)}`));
940
- console.log(kleur.cyan(` ${authUrl}`));
833
+ showBeforeContinue(step1Key, authUrl);
941
834
  openUrl(authUrl);
942
- const { ready } = await prompts(
943
- {
944
- type: 'confirm',
945
- name: 'ready',
946
- message: tr(lastReadyKey),
947
- initial: true,
948
- },
949
- { onCancel: cancel }
950
- );
951
- if (!ready) {
952
- console.log(kleur.gray(` ${tr('prompt.cancelled')}`));
953
- process.exit(0);
954
- }
835
+ await askReady(lastReadyKey);
955
836
  }
956
837
 
957
838
  break;
839
+ } else {
840
+ // Loop will replace ps2 next iteration; close the failed step now
841
+ // so the previous spinner doesn't stay in the running state visually.
842
+ ps2.fail(tr('new.firebase.create.failed'));
958
843
  }
959
844
  }
960
845
  if (!lastResult.ok && !lastResult.billingFailed) {
961
846
  const isProjectQuota = /exceeded your allotted project quota|project quota/i.test(String(lastResult.error || ''));
962
847
  const msg = isProjectQuota ? tr('new.firebase.create.projectQuotaExceeded') : `${tr('new.firebase.create.failed')}: ${lastResult.error}`;
963
- console.log(kleur.red(` ${msg}`));
848
+ ui.log.error(msg);
964
849
  if (lastResult.projectId) {
965
850
  core.firebaseProjectId = lastResult.projectId;
966
- console.log(kleur.gray(` ${tr('new.firebase.create.usingProjectId', { id: lastResult.projectId })}`));
851
+ ui.log.message(tr('new.firebase.create.usingProjectId', { id: lastResult.projectId }));
967
852
  } else {
968
- const fallback = await prompts(
969
- {
970
- type: 'text',
971
- name: 'firebaseProjectId',
972
- message: tr('new.firebase.q.projectId'),
973
- hint: tr('new.firebase.q.projectId.hint'),
974
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
975
- },
976
- { onCancel: cancel }
977
- );
978
- core.firebaseProjectId = fallback.firebaseProjectId;
853
+ core.firebaseProjectId = await ui.text({
854
+ message: tr('new.firebase.q.projectId'),
855
+ placeholder: tr('new.firebase.q.projectId.hint'),
856
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.projectId.required')),
857
+ onCancel: cancel,
858
+ });
979
859
  }
980
860
  }
981
861
  }
@@ -986,42 +866,30 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
986
866
  if ((backend === 'supabase' || backend === 'api') && firebaseSetupMode === 'create') {
987
867
  const gcloudCheck = await checkGcloudAuth();
988
868
  if (!gcloudCheck.ok) {
989
- console.log(kleur.red(` ${tr('new.firebase.create.gcloudRequired')}`));
869
+ ui.log.error(tr('new.firebase.create.gcloudRequired'));
990
870
  if (gcloudCheck.missing === 'gcloud') {
991
871
  const instructions = getGcloudInstallInstructions();
992
- console.log(kleur.cyan(`\n ${tr('new.firebase.create.installTitle')}`));
993
- if (instructions.install) {
994
- console.log(kleur.white(` ${tr('new.firebase.create.installCommand')}:`));
995
- console.log(kleur.cyan(` ${instructions.install}`));
996
- }
997
- if (instructions.hint) {
998
- console.log(kleur.gray(` ${instructions.hint}`));
999
- }
1000
- console.log(kleur.white(` ${tr('new.firebase.create.installAfter')}:`));
1001
- console.log(kleur.cyan(` ${instructions.after}`));
1002
- console.log(kleur.gray(` ${tr('new.firebase.create.installUrl')}: ${instructions.url}`));
1003
- console.log('');
872
+ const noteLines = [tr('new.firebase.create.installTitle')];
873
+ if (instructions.install) noteLines.push(`${tr('new.firebase.create.installCommand')}:\n ${kleur.cyan(instructions.install)}`);
874
+ if (instructions.hint) noteLines.push(instructions.hint);
875
+ noteLines.push(`${tr('new.firebase.create.installAfter')}:\n ${kleur.cyan(instructions.after)}`);
876
+ noteLines.push(`${tr('new.firebase.create.installUrl')}: ${instructions.url}`);
877
+ ui.note(noteLines.join('\n\n'));
1004
878
  } else {
1005
- console.log(kleur.cyan(` ${tr('new.firebase.create.authCommand')}`));
1006
- console.log('');
879
+ ui.note(tr('new.firebase.create.authCommand'));
1007
880
  }
1008
- console.log(kleur.yellow(` ${tr('new.firebase.create.fallbackHint')}`));
1009
- const fallback = await prompts(
1010
- {
1011
- type: 'text',
1012
- name: 'firebaseProjectId',
1013
- message: tr('new.firebase.q.projectId'),
1014
- hint: tr('new.firebase.q.projectId.hint') + ' (FCM + Remote Config)',
1015
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
1016
- },
1017
- { onCancel: cancel }
1018
- );
1019
- core.firebaseProjectId = fallback.firebaseProjectId;
881
+ ui.log.warn(tr('new.firebase.create.fallbackHint'));
882
+ core.firebaseProjectId = await ui.text({
883
+ message: tr('new.firebase.q.projectId'),
884
+ placeholder: tr('new.firebase.q.projectId.hint') + ' (FCM + Remote Config)',
885
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.projectId.required')),
886
+ onCancel: cancel,
887
+ });
1020
888
  } else {
1021
889
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
1022
890
  const selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
1023
- console.log(kleur.dim(`\n ${tr('new.internet.warning')}\n`));
1024
- const ps3 = makeProgressSpinner();
891
+ ui.log.info(tr('new.internet.warning'));
892
+ const ps3 = ui.makeStepper();
1025
893
  ps3.next(tr('new.firebase.create.creatingPush'));
1026
894
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
1027
895
  includeWeb: true,
@@ -1042,26 +910,21 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1042
910
  if (setupResult.ok) {
1043
911
  ps3.succeed(tr('new.firebase.create.successPush'));
1044
912
  core.firebaseProjectId = setupResult.projectId;
1045
- console.log(kleur.dim(` Project ID: ${core.firebaseProjectId}`));
913
+ ui.log.info(`Project ID: ${core.firebaseProjectId}`);
1046
914
  printCreateFromScratchStatus(setupResult, tr);
1047
915
  } else {
1048
916
  ps3.fail(tr('new.firebase.create.failed'));
1049
- console.log(kleur.red(` ${tr('new.firebase.create.failed')}: ${setupResult.error}`));
917
+ ui.log.error(`${tr('new.firebase.create.failed')}: ${setupResult.error}`);
1050
918
  if (setupResult.projectId) {
1051
919
  core.firebaseProjectId = setupResult.projectId;
1052
- console.log(kleur.gray(` ${tr('new.firebase.create.usingProjectId', { id: setupResult.projectId })}`));
920
+ ui.log.message(tr('new.firebase.create.usingProjectId', { id: setupResult.projectId }));
1053
921
  } else {
1054
- const fallback = await prompts(
1055
- {
1056
- type: 'text',
1057
- name: 'firebaseProjectId',
1058
- message: tr('new.firebase.q.projectId'),
1059
- hint: tr('new.firebase.q.projectId.hint') + ' (FCM + Remote Config)',
1060
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
1061
- },
1062
- { onCancel: cancel }
1063
- );
1064
- core.firebaseProjectId = fallback.firebaseProjectId;
922
+ core.firebaseProjectId = await ui.text({
923
+ message: tr('new.firebase.q.projectId'),
924
+ placeholder: tr('new.firebase.q.projectId.hint') + ' (FCM + Remote Config)',
925
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.projectId.required')),
926
+ onCancel: cancel,
927
+ });
1065
928
  }
1066
929
  }
1067
930
  }
@@ -1075,91 +938,75 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1075
938
  let supabaseExistingResult = null;
1076
939
 
1077
940
  if (backend === 'supabase') {
1078
- const { createSupabase } = await prompts(
1079
- {
1080
- type: 'select',
1081
- name: 'createSupabase',
1082
- message: tr('new.supabase.q.create'),
1083
- choices: [
1084
- { title: tr('new.supabase.q.create.create'), value: true },
1085
- { title: tr('new.supabase.q.create.existing'), value: false },
1086
- ],
1087
- initial: 0,
1088
- },
1089
- { onCancel: cancel }
1090
- );
1091
- supabaseCreate = createSupabase;
941
+ supabaseCreate = await ui.select({
942
+ message: tr('new.supabase.q.create'),
943
+ initialValue: true,
944
+ options: [
945
+ { value: true, label: tr('new.supabase.q.create.create') },
946
+ { value: false, label: tr('new.supabase.q.create.existing') },
947
+ ],
948
+ onCancel: cancel,
949
+ });
950
+
951
+ const showLoginRequired = () => {
952
+ ui.log.warn(`${tr('new.supabase.loginRequired')}\n${kleur.cyan(tr('new.supabase.loginCommand'))}`);
953
+ };
1092
954
 
1093
955
  if (supabaseCreate) {
1094
956
  const loginCheck = await checkLoggedIn();
1095
- if (!loginCheck.ok) {
1096
- console.log(kleur.yellow(` ${tr('new.supabase.loginRequired')}`));
1097
- console.log(kleur.cyan(` ${tr('new.supabase.loginCommand')}`));
1098
- console.log('');
1099
- }
957
+ if (!loginCheck.ok) showLoginRequired();
1100
958
  const orgsResult = await getOrgsList();
1101
959
  if (!orgsResult.ok || !orgsResult.orgs?.length) {
1102
- console.log(kleur.red(` ${tr('new.supabase.orgsRequired')}`));
960
+ ui.log.error(tr('new.supabase.orgsRequired'));
1103
961
  supabaseCreateResult = { ok: false, error: tr('new.supabase.orgsRequired') };
1104
962
  } else {
1105
963
  let orgId = orgsResult.orgs[0].id;
1106
964
  if (orgsResult.orgs.length > 1) {
1107
- const { selectedOrgId } = await prompts(
1108
- {
1109
- type: 'select',
1110
- name: 'selectedOrgId',
1111
- message: tr('new.supabase.q.orgSelect'),
1112
- choices: orgsResult.orgs.map((o) => ({ title: o.name, value: o.id })),
1113
- },
1114
- { onCancel: cancel }
1115
- );
1116
- orgId = selectedOrgId;
965
+ orgId = await ui.select({
966
+ message: tr('new.supabase.q.orgSelect'),
967
+ initialValue: orgsResult.orgs[0].id,
968
+ options: orgsResult.orgs.map((o) => ({ value: o.id, label: o.name })),
969
+ onCancel: cancel,
970
+ });
1117
971
  }
1118
972
  let supabaseRegion = DEFAULT_SUPABASE_REGION;
1119
973
  if (!isQuick) {
1120
- const { region } = await prompts(
1121
- {
1122
- type: 'select',
1123
- name: 'region',
1124
- message: tr('new.supabase.q.region'),
1125
- choices: [
1126
- { title: tr('new.supabase.q.region.brazil'), value: 'sa-east-1' },
1127
- { title: tr('new.supabase.q.region.us'), value: 'us-east-1' },
1128
- { title: tr('new.supabase.q.region.europe'), value: 'eu-west-1' },
1129
- { title: tr('new.supabase.q.region.global'), value: 'us-east-1' },
1130
- ],
1131
- initial: 0,
1132
- },
1133
- { onCancel: cancel }
1134
- );
974
+ const region = await ui.select({
975
+ message: tr('new.supabase.q.region'),
976
+ initialValue: 'sa-east-1',
977
+ options: [
978
+ { value: 'sa-east-1', label: tr('new.supabase.q.region.brazil') },
979
+ { value: 'us-east-1', label: tr('new.supabase.q.region.us') },
980
+ { value: 'eu-west-1', label: tr('new.supabase.q.region.europe') },
981
+ ],
982
+ onCancel: cancel,
983
+ });
1135
984
  supabaseRegion = region || DEFAULT_SUPABASE_REGION;
1136
985
  }
1137
- const { dbPassword } = await prompts(
1138
- {
1139
- type: 'password',
1140
- name: 'dbPassword',
1141
- message: tr('new.supabase.q.dbPassword'),
1142
- validate: (v) => (v && v.length >= 6 ? true : tr('new.supabase.q.dbPassword.required')),
1143
- },
1144
- { onCancel: cancel }
1145
- );
986
+ const dbPassword = await ui.password({
987
+ message: tr('new.supabase.q.dbPassword'),
988
+ validate: (v) => (v && v.length >= 6 ? undefined : tr('new.supabase.q.dbPassword.required')),
989
+ onCancel: cancel,
990
+ });
1146
991
  supabaseDbPassword = dbPassword;
1147
- console.log(kleur.dim(` ${tr('new.internet.warning')}`));
1148
- console.log(kleur.gray(` ${tr('new.supabase.creating')}`));
992
+ ui.log.info(tr('new.internet.warning'));
993
+ const createSpinner = ui.spinner();
994
+ createSpinner.start(tr('new.supabase.creating'));
1149
995
  supabaseCreateResult = await createProjectAndGetKeys(
1150
996
  core.appName.trim().replace(/\s+/g, '-').toLowerCase(),
1151
997
  supabaseDbPassword,
1152
998
  supabaseRegion,
1153
999
  orgId
1154
1000
  );
1001
+ createSpinner.stop(tr('new.supabase.creating'));
1155
1002
  }
1156
1003
  if (supabaseCreateResult.ok) {
1157
1004
  core.supabaseUrl = supabaseCreateResult.supabaseUrl;
1158
1005
  core.supabaseAnonKey = supabaseCreateResult.supabaseAnonKey;
1159
- console.log(kleur.green(` ✓ ${tr('new.supabase.created')}`));
1006
+ ui.log.success(tr('new.supabase.created'));
1160
1007
  } else {
1161
- console.log(kleur.red(` ${tr('new.supabase.createFailed')}: ${supabaseCreateResult.error}`));
1162
- console.log(kleur.gray(` ${tr('new.supabase.loginHint')}`));
1008
+ ui.log.error(`${tr('new.supabase.createFailed')}: ${supabaseCreateResult.error}`);
1009
+ ui.log.message(tr('new.supabase.loginHint'));
1163
1010
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1164
1011
  supabaseCreate = false;
1165
1012
  }
@@ -1167,54 +1014,46 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1167
1014
  // Usar projeto Supabase existente: org → projeto → keys → senha (mesmo fluxo de setup, sem criar)
1168
1015
  const loginCheck = await checkLoggedIn();
1169
1016
  if (!loginCheck.ok) {
1170
- console.log(kleur.yellow(` ${tr('new.supabase.loginRequired')}`));
1171
- console.log(kleur.cyan(` ${tr('new.supabase.loginCommand')}`));
1172
- console.log('');
1017
+ showLoginRequired();
1173
1018
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1174
1019
  } else {
1175
1020
  const orgsResult = await getOrgsList();
1176
1021
  if (!orgsResult.ok || !orgsResult.orgs?.length) {
1177
- console.log(kleur.red(` ${tr('new.supabase.orgsRequired')}`));
1022
+ ui.log.error(tr('new.supabase.orgsRequired'));
1178
1023
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1179
1024
  } else {
1180
1025
  let orgId = orgsResult.orgs[0].id;
1181
1026
  if (orgsResult.orgs.length > 1) {
1182
- const { selectedOrgId } = await prompts(
1183
- { type: 'select', name: 'selectedOrgId', message: tr('new.supabase.q.useExisting.orgSelect'), choices: orgsResult.orgs.map((o) => ({ title: o.name, value: o.id })) },
1184
- { onCancel: cancel }
1185
- );
1186
- orgId = selectedOrgId;
1027
+ orgId = await ui.select({
1028
+ message: tr('new.supabase.q.useExisting.orgSelect'),
1029
+ initialValue: orgsResult.orgs[0].id,
1030
+ options: orgsResult.orgs.map((o) => ({ value: o.id, label: o.name })),
1031
+ onCancel: cancel,
1032
+ });
1187
1033
  }
1188
1034
  const projectsResult = await getProjectsByOrg(orgId);
1189
1035
  if (!projectsResult.ok || !projectsResult.projects?.length) {
1190
- console.log(kleur.yellow(` ${tr('new.supabase.projectsRequired')}`));
1036
+ ui.log.warn(tr('new.supabase.projectsRequired'));
1191
1037
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1192
1038
  } else {
1193
- const { selectedProjectRef } = await prompts(
1194
- {
1195
- type: 'select',
1196
- name: 'selectedProjectRef',
1197
- message: tr('new.supabase.q.useExisting.projectSelect'),
1198
- choices: projectsResult.projects.map((p) => ({ title: `${p.name} (${p.id})`, value: p.id })),
1199
- },
1200
- { onCancel: cancel }
1201
- );
1039
+ const selectedProjectRef = await ui.select({
1040
+ message: tr('new.supabase.q.useExisting.projectSelect'),
1041
+ initialValue: projectsResult.projects[0].id,
1042
+ options: projectsResult.projects.map((p) => ({ value: p.id, label: `${p.name} (${p.id})` })),
1043
+ onCancel: cancel,
1044
+ });
1202
1045
  const keysResult = await getProjectKeys(selectedProjectRef);
1203
1046
  if (!keysResult.ok) {
1204
- console.log(kleur.red(` ${keysResult.error}`));
1047
+ ui.log.error(keysResult.error);
1205
1048
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1206
1049
  } else {
1207
1050
  core.supabaseUrl = keysResult.supabaseUrl;
1208
1051
  core.supabaseAnonKey = keysResult.supabaseAnonKey;
1209
- const { dbPassword } = await prompts(
1210
- {
1211
- type: 'password',
1212
- name: 'dbPassword',
1213
- message: tr('new.supabase.q.dbPassword.existing'),
1214
- validate: (v) => (v && v.length >= 6 ? true : tr('new.supabase.q.dbPassword.required')),
1215
- },
1216
- { onCancel: cancel }
1217
- );
1052
+ const dbPassword = await ui.password({
1053
+ message: tr('new.supabase.q.dbPassword.existing'),
1054
+ validate: (v) => (v && v.length >= 6 ? undefined : tr('new.supabase.q.dbPassword.required')),
1055
+ onCancel: cancel,
1056
+ });
1218
1057
  supabaseExistingResult = {
1219
1058
  ok: true,
1220
1059
  projectRef: selectedProjectRef,
@@ -1222,7 +1061,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1222
1061
  supabaseAnonKey: keysResult.supabaseAnonKey,
1223
1062
  dbPassword,
1224
1063
  };
1225
- console.log(kleur.green(` ✓ ${tr('new.supabase.existingLinked')}`));
1064
+ ui.log.success(tr('new.supabase.existingLinked'));
1226
1065
  }
1227
1066
  }
1228
1067
  }
@@ -1238,35 +1077,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1238
1077
  let googleIosClientId = '';
1239
1078
 
1240
1079
  if (backend === 'api') {
1241
- const api = await prompts(
1242
- {
1243
- type: 'text',
1244
- name: 'apiBaseUrl',
1245
- message: tr('new.api.q.baseUrl'),
1246
- hint: tr('new.api.q.baseUrl.hint'),
1247
- initial: 'https://api.example.com',
1248
- },
1249
- { onCancel: cancel }
1250
- );
1251
- Object.assign(core, api);
1252
- }
1253
-
1254
- // Resolve targetDir now that we have the app name
1255
- if (!targetDir) {
1256
- const folderName = toPackageName(core.appName.trim());
1257
- targetDir = path.resolve(process.cwd(), folderName);
1258
- // Guard: derived dir must not already exist
1259
- if (await fs.pathExists(targetDir)) {
1260
- const contents = await fs.readdir(targetDir);
1261
- if (contents.length > 0) {
1262
- throw new Error(tr('new.firebase.error.dirNotEmpty', { path: targetDir }));
1263
- }
1264
- }
1080
+ core.apiBaseUrl = await ui.text({
1081
+ message: tr('new.api.q.baseUrl'),
1082
+ placeholder: tr('new.api.q.baseUrl.hint'),
1083
+ initialValue: 'https://api.example.com',
1084
+ onCancel: cancel,
1085
+ });
1265
1086
  }
1266
1087
 
1267
1088
  // ── Firebase existing project: enable APIs + create Firestore/Storage ───
1268
1089
  if (backend === 'firebase' && firebaseSetupMode === 'existing' && core.firebaseProjectId) {
1269
- const ps4 = makeProgressSpinner();
1090
+ const ps4 = ui.makeStepper();
1270
1091
  ps4.next(stepProgress('enable-apis', language));
1271
1092
  const existingSetup = await setupExistingProject(core.firebaseProjectId, {
1272
1093
  onProgress: (key, data) => {
@@ -1274,19 +1095,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1274
1095
  ps4.next(stepProgress('enable-apis', language));
1275
1096
  } else if (key === 'enable-apis-warn') {
1276
1097
  ps4.warn(`${tr('new.firebase.create.failed')}: APIs`);
1277
- console.log(kleur.yellow(` ⚠ Não foi possível ativar APIs: ${(data?.error || '').slice(0, 80)}`));
1098
+ ui.log.warn(`${tr('new.firebase.existing.apisFailed')} ${(data?.error || '').slice(0, 80)}`);
1278
1099
  } else if (key === 'firestore') {
1279
1100
  ps4.next(stepProgress('firestore', language));
1280
1101
  } else if (key === 'storage') {
1281
1102
  ps4.next(stepProgress('storage', language));
1282
1103
  } else if (key === 'auth-providers-warn') {
1283
1104
  ps4.stop();
1284
- console.log(kleur.yellow(` ⚠ ${tr('new.firebase.interactive.authWarn')}`));
1285
- console.log(kleur.cyan(` ${data?.url || ''}`));
1105
+ ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
1286
1106
  } else if (key === 'auth-google-warn') {
1287
1107
  ps4.stop();
1288
- console.log(kleur.yellow(` ⚠ Google Sign-In: ative manualmente em Authentication → Sign-in method → Google`));
1289
- console.log(kleur.cyan(` ${data?.url || ''}`));
1108
+ ui.log.warn(`${tr('new.firebase.existing.googleSignInManual')}\n${kleur.cyan(data?.url || '')}`);
1290
1109
  }
1291
1110
  },
1292
1111
  });
@@ -1294,17 +1113,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1294
1113
  ps4.succeed(stepLabel('firestore', language));
1295
1114
  } else if (existingSetup.firestoreError && !existingSetup.firestoreError.includes('ALREADY_EXISTS')) {
1296
1115
  ps4.fail(`Firestore: ${(existingSetup.firestoreError || '').slice(0, 80)}`);
1297
- console.log(kleur.cyan(` ${existingSetup.firestoreUrl}`));
1116
+ ui.log.message(kleur.cyan(existingSetup.firestoreUrl));
1298
1117
  } else {
1299
1118
  ps4.stop();
1300
1119
  }
1301
1120
  if (existingSetup.storageCreated) {
1302
- console.log(kleur.green(` ✔ ${stepLabel('storage', language)}`));
1121
+ ui.log.success(stepLabel('storage', language));
1303
1122
  } else if (existingSetup.storageError && !existingSetup.storageError.includes('ALREADY_EXISTS')) {
1304
- console.log(kleur.yellow(`Storage: ${(existingSetup.storageError || '').slice(0, 80)}`));
1305
- console.log(kleur.cyan(` ${existingSetup.storageUrl}`));
1123
+ ui.log.warn(`Storage: ${(existingSetup.storageError || '').slice(0, 80)}\n${kleur.cyan(existingSetup.storageUrl)}`);
1306
1124
  }
1307
- console.log('');
1308
1125
  }
1309
1126
 
1310
1127
  // ── Optional modules ────────────────────────────────────────────────────
@@ -1320,22 +1137,18 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1320
1137
  modules = preselectedModules;
1321
1138
  } else if (isQuick) {
1322
1139
  // Quick mode: show preset picker (no multiselect).
1323
- const { preset } = await prompts(
1324
- {
1325
- type: 'select',
1326
- name: 'preset',
1327
- message: tr('new.q.preset'),
1328
- choices: [
1329
- { title: tr('new.q.preset.starter'), value: 'starter' },
1330
- { title: tr('new.q.preset.saas'), value: 'saas' },
1331
- { title: tr('new.q.preset.content'), value: 'content' },
1332
- { title: tr('new.q.preset.full'), value: 'full' },
1333
- { title: tr('new.q.preset.none'), value: 'none' },
1334
- ],
1335
- initial: 0,
1336
- },
1337
- { onCancel: cancel }
1338
- );
1140
+ const preset = await ui.select({
1141
+ message: tr('new.q.preset'),
1142
+ initialValue: 'starter',
1143
+ options: [
1144
+ { value: 'starter', label: tr('new.q.preset.starter') },
1145
+ { value: 'saas', label: tr('new.q.preset.saas') },
1146
+ { value: 'content', label: tr('new.q.preset.content') },
1147
+ { value: 'full', label: tr('new.q.preset.full') },
1148
+ { value: 'none', label: tr('new.q.preset.none') },
1149
+ ],
1150
+ onCancel: cancel,
1151
+ });
1339
1152
  modules = MODULE_PRESETS[preset] || [];
1340
1153
  } else {
1341
1154
  // Advanced mode: full multiselect — built from catalog, filtered by audience + backend.
@@ -1353,7 +1166,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1353
1166
  { header: 'new.modules.header.ci', ids: ['ci'] },
1354
1167
  ];
1355
1168
 
1356
- const moduleChoices = [];
1169
+ // Clack multiselect does not support disabled separator rows, so we
1170
+ // prefix each option with its group name (e.g. "Monetização · RevenueCat").
1171
+ const moduleOptions = [];
1357
1172
  for (const group of groups) {
1358
1173
  const inGroup = visibleFeatures.filter((f) => {
1359
1174
  if (!group.ids.includes(f.id)) return false;
@@ -1361,29 +1176,24 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1361
1176
  return true;
1362
1177
  });
1363
1178
  if (inGroup.length === 0) continue;
1364
- if (group.header) {
1365
- moduleChoices.push({ title: tr(group.header), value: `__header_${group.header}__`, disabled: true });
1366
- }
1179
+ const groupLabel = group.header ? tr(group.header) : null;
1367
1180
  for (const f of inGroup) {
1368
- const label = tr(`new.firebase.module.${f.id}`) + (f.status === 'internal' ? ' [beta]' : '');
1369
- moduleChoices.push({ title: label, value: f.id, selected: false });
1181
+ const moduleLabel = tr(`new.firebase.module.${f.id}`) + (f.status === 'internal' ? ' [beta]' : '');
1182
+ const label = groupLabel
1183
+ ? `${kleur.dim(groupLabel + ' · ')}${moduleLabel}`
1184
+ : moduleLabel;
1185
+ moduleOptions.push({ value: f.id, label });
1370
1186
  }
1371
1187
  }
1372
1188
 
1373
- const { modules: rawModules } = await prompts(
1374
- {
1375
- type: 'multiselect',
1376
- name: 'modules',
1377
- message: tr('new.firebase.q.modules'),
1378
- hint: tr('new.firebase.q.modules.hint'),
1379
- instructions: tr('prompt.multiselect.instructions'),
1380
- choices: moduleChoices,
1381
- min: 0,
1382
- warn: tr('prompt.multiselect.warnDisabled'),
1383
- },
1384
- { onCancel: cancel }
1385
- );
1386
- modules = (rawModules || []).filter((m) => !String(m).startsWith('__header_'));
1189
+ const rawModules = await ui.multiselect({
1190
+ message: tr('new.firebase.q.modules'),
1191
+ options: moduleOptions,
1192
+ initialValues: [],
1193
+ required: false,
1194
+ onCancel: cancel,
1195
+ });
1196
+ modules = rawModules || [];
1387
1197
  }
1388
1198
 
1389
1199
  if (backend === 'firebase' && firebaseSetupMode === 'create' && firebaseIncludeWeb) {
@@ -1394,57 +1204,41 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1394
1204
  const moduleAnswers = {};
1395
1205
 
1396
1206
  if (modules.includes('revenuecat')) {
1397
- const rc = await prompts(
1398
- [
1399
- {
1400
- type: 'text',
1401
- name: 'rcAndroidKey',
1402
- message: tr('new.firebase.q.revenuecat.android'),
1403
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.revenuecat.android.required')),
1404
- },
1405
- {
1406
- type: 'text',
1407
- name: 'rcIosKey',
1408
- message: tr('new.firebase.q.revenuecat.ios'),
1409
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.revenuecat.ios.required')),
1410
- },
1411
- {
1412
- type: 'select',
1413
- name: 'defaultPaywall',
1414
- message: tr('new.firebase.q.paywall'),
1415
- hint: tr('new.firebase.q.paywall.hint'),
1416
- choices: [
1417
- { title: 'Basic (list of plans)', value: 'basic' },
1418
- { title: 'With trial switch', value: 'withSwitch' },
1419
- { title: 'Row + comparison table', value: 'basicRow' },
1420
- { title: 'Minimal (benefits + CTA)', value: 'minimal' },
1421
- ],
1422
- initial: 0,
1423
- },
1207
+ moduleAnswers.rcAndroidKey = await ui.text({
1208
+ message: tr('new.firebase.q.revenuecat.android'),
1209
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.revenuecat.android.required')),
1210
+ onCancel: cancel,
1211
+ });
1212
+ moduleAnswers.rcIosKey = await ui.text({
1213
+ message: tr('new.firebase.q.revenuecat.ios'),
1214
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.revenuecat.ios.required')),
1215
+ onCancel: cancel,
1216
+ });
1217
+ moduleAnswers.defaultPaywall = await ui.select({
1218
+ message: tr('new.firebase.q.paywall'),
1219
+ initialValue: 'basic',
1220
+ options: [
1221
+ { value: 'basic', label: 'Basic (list of plans)' },
1222
+ { value: 'withSwitch', label: 'With trial switch' },
1223
+ { value: 'basicRow', label: 'Row + comparison table' },
1224
+ { value: 'minimal', label: 'Minimal (benefits + CTA)' },
1424
1225
  ],
1425
- { onCancel: cancel }
1426
- );
1427
- Object.assign(moduleAnswers, rc);
1226
+ onCancel: cancel,
1227
+ });
1428
1228
  }
1429
1229
 
1430
1230
  // RC web key — only in advanced mode (optional credential, can configure later).
1431
1231
  if (!isQuick && modules.includes('revenuecat') && modules.includes('web')) {
1432
- const rcWebKey = await prompts(
1433
- {
1434
- type: 'text',
1435
- name: 'rcWebKey',
1436
- message: tr('new.firebase.q.revenuecat.webKey'),
1437
- validate: (v) => {
1438
- if (!v || !v.trim()) return true; // optional — blank is fine
1439
- return /^rcb_/.test(v.trim()) ? true : tr('new.firebase.q.revenuecat.webKey.invalid');
1440
- },
1232
+ const rcWebKey = await ui.text({
1233
+ message: tr('new.firebase.q.revenuecat.webKey'),
1234
+ validate: (v) => {
1235
+ if (!v || !v.trim()) return undefined; // optional — blank is fine
1236
+ return /^rcb_/.test(v.trim()) ? undefined : tr('new.firebase.q.revenuecat.webKey.invalid');
1441
1237
  },
1442
- { onCancel: cancel }
1443
- );
1444
- Object.assign(moduleAnswers, {
1445
- revenuecatWeb: true,
1446
- rcWebKey: (rcWebKey.rcWebKey || '').trim(),
1238
+ onCancel: cancel,
1447
1239
  });
1240
+ moduleAnswers.revenuecatWeb = true;
1241
+ moduleAnswers.rcWebKey = (rcWebKey || '').trim();
1448
1242
  } else if (modules.includes('revenuecat') && modules.includes('web')) {
1449
1243
  // Quick mode: mark web billing as included but key will be configured later.
1450
1244
  moduleAnswers.revenuecatWeb = true;
@@ -1453,76 +1247,52 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1453
1247
 
1454
1248
  // Sentry DSN — already optional (blank = configure later). Skip in quick mode.
1455
1249
  if (!isQuick && modules.includes('sentry')) {
1456
- const s = await prompts(
1457
- {
1458
- type: 'text',
1459
- name: 'sentryDsn',
1460
- message: tr('new.firebase.q.sentry.dsn'),
1461
- validate: (v) => {
1462
- if (!v || !v.trim()) return true; // optional — blank is fine
1463
- return /^https?:\/\/[^@\s]+@[^/\s]+\/\d+$/.test(v.trim())
1464
- ? true
1465
- : tr('new.firebase.q.sentry.dsn.invalid');
1466
- },
1250
+ moduleAnswers.sentryDsn = await ui.text({
1251
+ message: tr('new.firebase.q.sentry.dsn'),
1252
+ validate: (v) => {
1253
+ if (!v || !v.trim()) return undefined; // optional — blank is fine
1254
+ return /^https?:\/\/[^@\s]+@[^/\s]+\/\d+$/.test(v.trim())
1255
+ ? undefined
1256
+ : tr('new.firebase.q.sentry.dsn.invalid');
1467
1257
  },
1468
- { onCancel: cancel }
1469
- );
1470
- Object.assign(moduleAnswers, s);
1258
+ onCancel: cancel,
1259
+ });
1471
1260
  }
1472
1261
 
1473
1262
  // Mixpanel token — already optional. Skip in quick mode.
1474
1263
  if (!isQuick && modules.includes('analytics')) {
1475
- const mx = await prompts(
1476
- {
1477
- type: 'text',
1478
- name: 'mixpanelToken',
1479
- message: tr('new.firebase.q.mixpanel.token'),
1480
- },
1481
- { onCancel: cancel }
1482
- );
1483
- Object.assign(moduleAnswers, mx);
1264
+ moduleAnswers.mixpanelToken = await ui.text({
1265
+ message: tr('new.firebase.q.mixpanel.token'),
1266
+ onCancel: cancel,
1267
+ });
1484
1268
  }
1485
1269
 
1486
1270
  // LLM Chat credentials — skip in quick mode, all fields optional.
1487
1271
  if (!isQuick && modules.includes('llm_chat')) {
1488
- const { configureLlmNow } = await prompts(
1489
- {
1490
- type: 'confirm',
1491
- name: 'configureLlmNow',
1492
- message: tr('new.q.llm_chat.configureNow'),
1493
- hint: tr('new.q.llm_chat.configureNow.hint'),
1494
- initial: true,
1495
- },
1496
- { onCancel: cancel }
1497
- );
1272
+ const configureLlmNow = await ui.confirm({
1273
+ message: tr('new.q.llm_chat.configureNow'),
1274
+ initialValue: true,
1275
+ onCancel: cancel,
1276
+ });
1498
1277
 
1499
1278
  if (configureLlmNow) {
1500
- const llm = await prompts(
1501
- [
1502
- {
1503
- type: 'select',
1504
- name: 'llmProvider',
1505
- message: tr('add.prompt.llmProvider'),
1506
- choices: [
1507
- { title: 'OpenAI (gpt-4o-mini)', value: 'openai' },
1508
- { title: 'Google Gemini (gemini-1.5-flash)', value: 'gemini' },
1509
- ],
1510
- initial: 0,
1511
- },
1512
- {
1513
- type: 'text',
1514
- name: 'llmSystemPrompt',
1515
- message: tr('add.prompt.llmSystemPrompt'),
1516
- },
1517
- {
1518
- type: 'text',
1519
- name: 'llmApiKey',
1520
- message: tr('add.prompt.llmApiKey'),
1521
- },
1279
+ moduleAnswers.llmProvider = await ui.select({
1280
+ message: tr('add.prompt.llmProvider'),
1281
+ initialValue: 'openai',
1282
+ options: [
1283
+ { value: 'openai', label: 'OpenAI (gpt-4o-mini)' },
1284
+ { value: 'gemini', label: 'Google Gemini (gemini-1.5-flash)' },
1522
1285
  ],
1523
- { onCancel: cancel }
1524
- );
1525
- Object.assign(moduleAnswers, llm);
1286
+ onCancel: cancel,
1287
+ });
1288
+ moduleAnswers.llmSystemPrompt = await ui.text({
1289
+ message: tr('add.prompt.llmSystemPrompt'),
1290
+ onCancel: cancel,
1291
+ });
1292
+ moduleAnswers.llmApiKey = await ui.password({
1293
+ message: tr('add.prompt.llmApiKey'),
1294
+ onCancel: cancel,
1295
+ });
1526
1296
  } else {
1527
1297
  moduleAnswers.llmConfigureLater = true;
1528
1298
  }
@@ -1532,70 +1302,48 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1532
1302
 
1533
1303
  // Facebook — required credentials, always ask.
1534
1304
  if (modules.includes('facebook')) {
1535
- const fb = await prompts(
1536
- [
1537
- {
1538
- type: 'text',
1539
- name: 'fbAppId',
1540
- message: tr('new.firebase.q.facebook.appId'),
1541
- validate: (v) => {
1542
- if (!v || !v.trim()) return tr('new.firebase.q.facebook.appId.required');
1543
- return /^\d+$/.test(v.trim()) ? true : tr('new.firebase.q.facebook.appId.invalid');
1544
- },
1545
- },
1546
- {
1547
- type: 'text',
1548
- name: 'fbToken',
1549
- message: tr('new.firebase.q.facebook.token'),
1550
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.facebook.token.required')),
1551
- },
1552
- ],
1553
- { onCancel: cancel }
1554
- );
1555
- Object.assign(moduleAnswers, fb);
1305
+ moduleAnswers.fbAppId = await ui.text({
1306
+ message: tr('new.firebase.q.facebook.appId'),
1307
+ validate: (v) => {
1308
+ if (!v || !v.trim()) return tr('new.firebase.q.facebook.appId.required');
1309
+ return /^\d+$/.test(v.trim()) ? undefined : tr('new.firebase.q.facebook.appId.invalid');
1310
+ },
1311
+ onCancel: cancel,
1312
+ });
1313
+ moduleAnswers.fbToken = await ui.password({
1314
+ message: tr('new.firebase.q.facebook.token'),
1315
+ validate: (v) => (v && v.trim() ? undefined : tr('new.firebase.q.facebook.token.required')),
1316
+ onCancel: cancel,
1317
+ });
1556
1318
  }
1557
1319
 
1558
1320
  // Server secrets (webhook, Meta Ads) — skip in quick mode, configure later via `kasy deploy`.
1559
1321
  if (!isQuick && modules.includes('revenuecat') && (backend === 'supabase' || backend === 'firebase')) {
1560
- const { configureSecretsNow } = await prompts(
1561
- {
1562
- type: 'confirm',
1563
- name: 'configureSecretsNow',
1564
- message: tr('new.firebase.q.secrets.configureNow'),
1565
- hint: tr('new.firebase.q.secrets.configureNow.hint'),
1566
- initial: true,
1567
- },
1568
- { onCancel: cancel }
1569
- );
1322
+ const configureSecretsNow = await ui.confirm({
1323
+ message: tr('new.firebase.q.secrets.configureNow'),
1324
+ initialValue: true,
1325
+ onCancel: cancel,
1326
+ });
1570
1327
 
1571
1328
  if (configureSecretsNow) {
1572
- const rcSecrets = await prompts(
1573
- [
1574
- {
1575
- type: 'text',
1576
- name: 'rcWebhookKey',
1577
- message: tr('new.firebase.q.revenuecat.webhookKey'),
1578
- initial: generateWebhookKey(),
1579
- hint: tr('new.firebase.q.revenuecat.webhookKey.hint'),
1580
- },
1581
- {
1582
- type: 'text',
1583
- name: 'metaAccessToken',
1584
- message: tr('new.firebase.q.revenuecat.metaToken'),
1585
- },
1586
- {
1587
- type: 'text',
1588
- name: 'metaDatasetId',
1589
- message: tr('new.firebase.q.revenuecat.metaDataset'),
1590
- validate: (v) => {
1591
- if (!v || !v.trim()) return true; // optional — blank is fine
1592
- return /^\d+$/.test(v.trim()) ? true : tr('new.firebase.q.revenuecat.metaDataset.invalid');
1593
- },
1594
- },
1595
- ],
1596
- { onCancel: cancel }
1597
- );
1598
- Object.assign(moduleAnswers, rcSecrets);
1329
+ moduleAnswers.rcWebhookKey = await ui.text({
1330
+ message: tr('new.firebase.q.revenuecat.webhookKey'),
1331
+ initialValue: generateWebhookKey(),
1332
+ placeholder: tr('new.firebase.q.revenuecat.webhookKey.hint'),
1333
+ onCancel: cancel,
1334
+ });
1335
+ moduleAnswers.metaAccessToken = await ui.password({
1336
+ message: tr('new.firebase.q.revenuecat.metaToken'),
1337
+ onCancel: cancel,
1338
+ });
1339
+ moduleAnswers.metaDatasetId = await ui.text({
1340
+ message: tr('new.firebase.q.revenuecat.metaDataset'),
1341
+ validate: (v) => {
1342
+ if (!v || !v.trim()) return undefined; // optional — blank is fine
1343
+ return /^\d+$/.test(v.trim()) ? undefined : tr('new.firebase.q.revenuecat.metaDataset.invalid');
1344
+ },
1345
+ onCancel: cancel,
1346
+ });
1599
1347
  } else {
1600
1348
  moduleAnswers.secretsConfigureLater = true;
1601
1349
  }
@@ -1625,24 +1373,29 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1625
1373
  printSummary(tr, answers);
1626
1374
 
1627
1375
  if (!yes) {
1628
- const { proceed } = await prompts(
1629
- {
1630
- type: 'confirm',
1631
- name: 'proceed',
1632
- message: tr('new.firebase.confirm.proceed'),
1633
- initial: true,
1634
- },
1635
- { onCancel: cancel }
1636
- );
1376
+ const proceed = await ui.confirm({
1377
+ message: tr('new.firebase.confirm.proceed'),
1378
+ initialValue: true,
1379
+ onCancel: cancel,
1380
+ });
1637
1381
  if (!proceed) {
1638
- console.log(kleur.gray(`\n ${tr('prompt.cancelled')}\n`));
1382
+ ui.cancel(tr('prompt.cancelled'));
1639
1383
  process.exit(0);
1640
1384
  }
1641
1385
  }
1642
1386
 
1643
1387
  // ── Generate ────────────────────────────────────────────────────────────
1644
- console.log('');
1645
- const spinner = ora(stepProgress('project-setup', language)).start();
1388
+ // Stepper shows each step closing as ✦ and the next starting as ⠙ — so the
1389
+ // user gets explicit "X done → Y starting" feedback instead of a single
1390
+ // spinner with a mutating message.
1391
+ const stepper = ui.makeStepper();
1392
+ // First step started here so even silent prep work shows progress.
1393
+ stepper.next(stepProgress('project-setup', language));
1394
+
1395
+ const onProgress = (key) => {
1396
+ // Each onProgress closes previous step with ✦ and starts the new one.
1397
+ stepper.next(stepProgress(key, language));
1398
+ };
1646
1399
 
1647
1400
  let result;
1648
1401
  try {
@@ -1656,9 +1409,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1656
1409
  modules: answers.modules,
1657
1410
  moduleAnswers,
1658
1411
  language,
1659
- onProgress: (key) => {
1660
- spinner.text = kleur.cyan(stepProgress(key, language));
1661
- },
1412
+ onProgress,
1662
1413
  });
1663
1414
  } else if (backend === 'api') {
1664
1415
  result = await generateApiProject(targetDir, {
@@ -1669,9 +1420,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1669
1420
  modules: answers.modules,
1670
1421
  moduleAnswers,
1671
1422
  language,
1672
- onProgress: (key) => {
1673
- spinner.text = kleur.cyan(stepProgress(key, language));
1674
- },
1423
+ onProgress,
1675
1424
  });
1676
1425
  } else {
1677
1426
  result = await generateFirebaseProject(targetDir, {
@@ -1684,14 +1433,14 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1684
1433
  includeWeb: answers.includeWeb !== false,
1685
1434
  functionsRegion: firebaseRegion,
1686
1435
  language,
1687
- onProgress: (key) => {
1688
- spinner.text = kleur.cyan(stepProgress(key, language));
1689
- },
1436
+ onProgress,
1690
1437
  });
1691
1438
  }
1692
- spinner.stop();
1439
+ // Close the last in-flight step. We don't need a label — its own message
1440
+ // is already shown next to the ✦ by Clack.
1441
+ stepper.succeed();
1693
1442
  } catch (err) {
1694
- spinner.fail(kleur.red(err.message));
1443
+ stepper.fail(err.message);
1695
1444
  throw err;
1696
1445
  }
1697
1446
 
@@ -1718,8 +1467,12 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1718
1467
  }
1719
1468
 
1720
1469
  // ── Results ─────────────────────────────────────────────────────────────
1721
- console.log('');
1722
- result.steps.forEach((s) => printStepResult(s, language));
1470
+ // Stepper already announced each successful step. Print only failures and
1471
+ // skipped steps here, so the user notices what didn't run without seeing
1472
+ // every success twice.
1473
+ result.steps
1474
+ .filter((s) => s.skipped || !s.ok)
1475
+ .forEach((s) => printStepResult(s, language));
1723
1476
 
1724
1477
  // ── Supabase: link + db push + functions deploy (when project was created or existing selected) ─
1725
1478
  const supabaseSetupPayload = (supabaseCreate && supabaseCreateResult?.ok)
@@ -1759,19 +1512,20 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1759
1512
  // ── FCM Service Account key (best effort via gcloud — Firebase uses ADC, Supabase needs JSON) ──
1760
1513
  let fcmServiceAccountJson = null;
1761
1514
  if (answers.firebaseProjectId) {
1762
- const fcmSpinner = ora(kleur.cyan(tr('new.fcm.generating'))).start();
1515
+ const fcmSpinner = ui.spinner();
1516
+ fcmSpinner.start(tr('new.fcm.generating'));
1763
1517
  const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1518
+ fcmSpinner.stop(tr('new.fcm.generating'));
1764
1519
  if (fcmResult.ok) {
1765
1520
  fcmServiceAccountJson = fcmResult.json;
1766
- fcmSpinner.stop();
1767
1521
  printStepResult({ name: 'fcm-key', ok: true }, language);
1768
1522
  } else {
1769
- fcmSpinner.stop();
1770
1523
  printStepResult({ name: 'fcm-key', ok: false, detail: 'configure FIREBASE_SERVICE_ACCOUNT_JSON manualmente' }, language);
1771
1524
  }
1772
1525
  }
1773
1526
 
1774
- const setupSpinner = ora(kleur.cyan(tr('new.supabase.setup'))).start();
1527
+ const setupSpinner = ui.spinner();
1528
+ setupSpinner.start(tr('new.supabase.setup'));
1775
1529
  try {
1776
1530
  const secrets = {
1777
1531
  firebaseProjectId: answers.firebaseProjectId,
@@ -1797,21 +1551,22 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1797
1551
  secrets,
1798
1552
  google
1799
1553
  );
1800
- setupSpinner.stop();
1554
+ setupSpinner.stop(tr('new.supabase.setup'));
1801
1555
  setupSteps.forEach((s) => printStepResult(s, language));
1802
1556
  } catch (err) {
1803
- setupSpinner.fail(kleur.red(err.message));
1804
- console.log(kleur.yellow(` ${tr('new.supabase.setupManual')}`));
1557
+ setupSpinner.error(err.message);
1558
+ ui.log.warn(tr('new.supabase.setupManual'));
1805
1559
  }
1806
1560
  }
1807
1561
  }
1808
1562
 
1809
1563
  // ── API: FCM Service Account key — save to .kasy/ for server configuration ──
1810
1564
  if (backend === 'api' && answers.firebaseProjectId) {
1811
- const fcmSpinner = ora(kleur.cyan(tr('new.fcm.generating'))).start();
1565
+ const fcmSpinner = ui.spinner();
1566
+ fcmSpinner.start(tr('new.fcm.generating'));
1812
1567
  const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1568
+ fcmSpinner.stop(tr('new.fcm.generating'));
1813
1569
  if (fcmResult.ok) {
1814
- fcmSpinner.stop();
1815
1570
  try {
1816
1571
  const kasyDir = path.join(targetDir, '.kasy');
1817
1572
  await fs.ensureDir(kasyDir);
@@ -1826,12 +1581,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1826
1581
  }
1827
1582
  }
1828
1583
  printStepResult({ name: 'fcm-key-saved', ok: true }, language);
1829
- console.log(kleur.gray(` ${tr('new.fcm.serverConfig')}`));
1584
+ ui.log.message(tr('new.fcm.serverConfig'));
1830
1585
  } catch (_) {
1831
1586
  printStepResult({ name: 'fcm-key-saved', ok: false, detail: 'salvar arquivo de chave falhou' }, language);
1832
1587
  }
1833
1588
  } else {
1834
- fcmSpinner.stop();
1835
1589
  printStepResult({ name: 'fcm-key', ok: false, detail: 'configure FIREBASE_SERVICE_ACCOUNT_JSON manualmente' }, language);
1836
1590
  }
1837
1591
  }
@@ -1844,9 +1598,10 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1844
1598
  if (answers.firebaseProjectId && firebaseSetupMode === 'existing') {
1845
1599
  const flutterfireOkForSha1 = result.steps.find((s) => s.name === 'flutterfire')?.ok;
1846
1600
  if (flutterfireOkForSha1) {
1847
- const sha1Spinner = ora(kleur.cyan(tr('new.sha1.registering'))).start();
1601
+ const sha1Spinner = ui.spinner();
1602
+ sha1Spinner.start(tr('new.sha1.registering'));
1848
1603
  const sha1Result = await registerDebugSha1(answers.firebaseProjectId, answers.bundleId);
1849
- sha1Spinner.stop();
1604
+ sha1Spinner.stop(tr('new.sha1.registering'));
1850
1605
  if (sha1Result.ok) {
1851
1606
  if (!sha1Result.existed) {
1852
1607
  printStepResult({ name: 'sha1', ok: true }, language);
@@ -1854,9 +1609,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1854
1609
  // existed === true → already registered, silent success
1855
1610
  } else {
1856
1611
  const sha1ManualUrl = `https://console.firebase.google.com/project/${answers.firebaseProjectId}/settings/general/android:${answers.bundleId}`;
1857
- console.log(kleur.yellow(` ⚠ ${tr('new.sha1.failed', { error: (sha1Result.sha1Error || '').slice(0, 120) })}`));
1858
- console.log(kleur.yellow(` ${tr('new.sha1.manual')}`));
1859
- console.log(kleur.cyan(` ${sha1ManualUrl}`));
1612
+ ui.log.warn(
1613
+ `${tr('new.sha1.failed', { error: (sha1Result.sha1Error || '').slice(0, 120) })}\n${tr('new.sha1.manual')}\n${kleur.cyan(sha1ManualUrl)}`
1614
+ );
1860
1615
  openUrl(sha1ManualUrl);
1861
1616
  }
1862
1617
  }
@@ -1865,14 +1620,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1865
1620
  const flutterfireStep = result.steps.find((s) => s.name === 'flutterfire');
1866
1621
  if (flutterfireStep && !flutterfireStep.ok) {
1867
1622
  const platforms = answers.includeWeb !== false ? 'android,ios,web' : 'android,ios';
1868
- console.log(kleur.yellow(`\n ${tr('new.firebase.warn.flutterfire')}`));
1869
- console.log(kleur.yellow(` ${tr('new.firebase.warn.flutterfire.manual')}`));
1870
- console.log(
1871
- kleur.cyan(
1872
- ` dart pub global run flutterfire_cli:flutterfire configure` +
1873
- ` --project=${answers.firebaseProjectId}` +
1874
- ` --out lib/firebase_options_dev.dart --platforms=${platforms} --yes`
1875
- )
1623
+ const cmd = `dart pub global run flutterfire_cli:flutterfire configure --project=${answers.firebaseProjectId} --out lib/firebase_options_dev.dart --platforms=${platforms} --yes`;
1624
+ ui.log.warn(
1625
+ `${tr('new.firebase.warn.flutterfire')}\n${tr('new.firebase.warn.flutterfire.manual')}\n${kleur.cyan(cmd)}`
1876
1626
  );
1877
1627
  }
1878
1628
 
@@ -1880,16 +1630,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1880
1630
  // Cannot be automated: the .p8 key only exists in Apple Developer Portal.
1881
1631
  if (answers.firebaseProjectId) {
1882
1632
  const apnsUrl = `https://console.firebase.google.com/project/${answers.firebaseProjectId}/settings/cloudmessaging`;
1883
- console.log('');
1884
- console.log(kleur.bold().yellow(` ${tr('new.apns.warning')}`));
1885
- console.log(kleur.gray(` ${tr('new.apns.step1')}`));
1886
- console.log(kleur.gray(` ${tr('new.apns.step2')}`));
1887
- console.log(kleur.cyan(` ${apnsUrl}`));
1633
+ ui.log.warn(
1634
+ `${tr('new.apns.warning')}\n${tr('new.apns.step1')}\n${tr('new.apns.step2')}\n${kleur.cyan(apnsUrl)}`
1635
+ );
1888
1636
  openUrl(apnsUrl);
1889
1637
  }
1890
1638
 
1891
1639
  printSuccessCard(tr, answers, targetDir);
1892
1640
 
1641
+ ui.outro(kleur.bold(tr('new.success.title')));
1893
1642
  }
1894
1643
 
1895
1644
  module.exports = { runNew };