kasy-cli 1.5.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 +40,8 @@ function openUrl(url) {
68
40
  exec(cmd, { shell: true });
69
41
  } catch (_) {}
70
42
  }
71
- const prompts = require('prompts');
72
43
  const ui = require('../utils/ui');
44
+ const { printBanner, infoBox, successBox } = require('../utils/brand');
73
45
  const fs = require('fs-extra');
74
46
  const { createTranslator } = require('../utils/i18n');
75
47
  const { getStoredLanguage, setStoredLanguage } = require('../utils/license');
@@ -111,51 +83,37 @@ const BILLING_OTHER = '__other__';
111
83
  async function promptBillingAccountIfNeeded(tr, onCancel) {
112
84
  const billingList = await listBillingAccounts();
113
85
  if (!billingList.ok) return null;
114
- if (!billingList.accounts?.length) {
115
- console.log(kleur.yellow(` ${tr('new.firebase.q.billingAccount.context')}`));
116
- const { manualId } = await prompts(
117
- {
118
- type: 'text',
119
- name: 'manualId',
120
- message: tr('new.firebase.q.billingAccount.manualId'),
121
- hint: tr('new.firebase.q.billingAccount.manualId.hint'),
122
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.billingAccount.manualId.required')),
123
- },
124
- { onCancel }
125
- );
126
- return manualId?.trim() || null;
127
- }
128
- const choices = [
129
- ...billingList.accounts.map((a) => ({
130
- title: `${a.name || a.id} (${a.id})`,
131
- value: a.id,
132
- })),
133
- { title: tr('new.firebase.q.billingAccount.other'), value: BILLING_OTHER },
134
- ];
135
- console.log(kleur.yellow(` ${tr('new.firebase.q.billingAccount.context')}`));
136
- const { billingAccountId } = await prompts(
137
- {
138
- type: 'select',
139
- name: 'billingAccountId',
140
- message: tr('new.firebase.q.billingAccount'),
141
- hint: tr('new.firebase.q.billingAccount.hint'),
142
- choices,
143
- },
144
- { onCancel }
145
- );
146
- if (billingAccountId === BILLING_OTHER) {
147
- const { manualId } = await prompts(
148
- {
149
- type: 'text',
150
- name: 'manualId',
151
- message: tr('new.firebase.q.billingAccount.manualId'),
152
- hint: tr('new.firebase.q.billingAccount.manualId.hint'),
153
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.billingAccount.manualId.required')),
154
- },
155
- { onCancel }
156
- );
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
+ });
157
94
  return manualId?.trim() || null;
95
+ };
96
+
97
+ if (!billingList.accounts?.length) {
98
+ ui.note(tr('new.firebase.q.billingAccount.context'));
99
+ return askManualId();
158
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();
159
117
  return billingAccountId;
160
118
  }
161
119
 
@@ -169,100 +127,77 @@ async function promptBillingAccountIfNeeded(tr, onCancel) {
169
127
  async function promptOrganizationIfNeeded(tr, onCancel) {
170
128
  const orgList = await listGcpOrganizations();
171
129
  if (!orgList.ok || !orgList.organizations?.length) return null;
172
- const choices = [
173
- ...orgList.organizations.map((o) => ({
174
- title: `${o.name} (${o.id})`,
175
- value: o.id,
176
- })),
177
- { title: tr('new.firebase.q.organization.none'), value: null },
178
- ];
179
- const { organizationId } = await prompts(
180
- {
181
- type: 'select',
182
- name: 'organizationId',
183
- message: tr('new.firebase.q.organization'),
184
- hint: tr('new.firebase.q.organization.hint'),
185
- choices,
186
- },
187
- { onCancel }
188
- );
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
+ });
189
142
  return organizationId || null;
190
143
  }
191
144
 
192
145
  // ── Helpers ───────────────────────────────────────────────────────────────────
193
146
 
194
- function printBanner(tr) {
195
- const bar = kleur.gray('─────────────────────────────────────────────────');
196
- const logo = [
197
- ' ╦╔═ ╔═╗ ╔═╗ ╦ ╦',
198
- ' ╠╩╗ ╠═╣ ╚═╗ ╚╦╝',
199
- ' ╩ ╩ ╩ ╩ ╚═╝ ╩ ',
200
- ]
201
- .map((line) => gradient(['#a78bfa', '#60a5fa'])(line))
202
- .join('\n');
203
- console.log(`\n${bar}\n`);
204
- console.log(logo);
205
- console.log('');
206
- console.log(` ${kleur.dim(tr('new.subtitle2'))}`);
207
- console.log(`\n${bar}\n`);
208
- }
209
-
210
147
  function printPrerequisites(tr, backend, firebaseSetupMode = 'existing', checkResults = []) {
211
148
  const gcloudOk = checkResults.every(
212
149
  (r) => !r.name?.includes('gcloud') || r.ok
213
150
  );
214
- console.log(kleur.bold().yellow(` ${tr('new.prereq.title')}`));
215
151
  const firebaseCreate = firebaseSetupMode === 'create';
152
+ const items = [];
153
+
216
154
  if (backend === 'firebase') {
217
155
  if (firebaseCreate) {
218
- if (!gcloudOk) {
219
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.1')}`));
220
- }
221
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.2')}`));
222
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.projectQuota')}`));
223
- 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'));
224
160
  } else {
225
- console.log(kleur.gray(` ${tr('new.firebase.prereq.1')}`));
226
- console.log(kleur.gray(` ${tr('new.firebase.prereq.2')}`));
227
- console.log(kleur.gray(` ${tr('new.firebase.prereq.3')}`));
228
- console.log(kleur.gray(` ${tr('new.firebase.prereq.4')}`));
229
- console.log(kleur.gray(` ${tr('new.firebase.prereq.5')}`));
230
- 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'));
231
167
  }
232
168
  } else if (backend === 'supabase') {
233
169
  if (firebaseCreate) {
234
- if (!gcloudOk) {
235
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.1')}`));
236
- }
237
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.2')}`));
238
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.projectQuota')}`));
239
- console.log(kleur.cyan(` ${tr('new.firebase.prereq.create.pushNote')}`));
240
- console.log(kleur.gray(` ${tr('new.supabase.prereq.1')}`));
241
- console.log(kleur.gray(` ${tr('new.supabase.prereq.2')}`));
242
- 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'));
243
177
  } else {
244
- console.log(kleur.gray(` ${tr('new.supabase.prereq.1')}`));
245
- console.log(kleur.gray(` ${tr('new.supabase.prereq.2')}`));
246
- console.log(kleur.gray(` ${tr('new.supabase.prereq.3')}`));
247
- 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'));
248
182
  }
249
183
  } else if (backend === 'api') {
250
184
  if (firebaseCreate) {
251
- if (!gcloudOk) {
252
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.1')}`));
253
- }
254
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.2')}`));
255
- console.log(kleur.gray(` ${tr('new.firebase.prereq.create.projectQuota')}`));
256
- console.log(kleur.cyan(` ${tr('new.firebase.prereq.create.pushNote')}`));
257
- console.log(kleur.gray(` ${tr('new.api.prereq.1')}`));
258
- 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'));
259
191
  } else {
260
- console.log(kleur.gray(` ${tr('new.api.prereq.1')}`));
261
- console.log(kleur.gray(` ${tr('new.api.prereq.2')}`));
262
- 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'));
263
195
  }
264
196
  }
265
- console.log('');
197
+
198
+ if (items.length > 0) {
199
+ ui.note(items.map((i) => `• ${i}`).join('\n'), tr('new.prereq.title'));
200
+ }
266
201
  }
267
202
 
268
203
  function printSummary(tr, answers) {
@@ -271,25 +206,18 @@ function printSummary(tr, answers) {
271
206
  : tr('new.firebase.confirm.none');
272
207
 
273
208
  const backendLabel = { firebase: '🔥 Firebase', supabase: '🟢 Supabase', api: '🔗 API REST' }[answers.backend] || answers.backend;
274
- const bar = kleur.gray(' ─────────────────────────────────────────────');
275
-
276
- console.log(`\n${bar}`);
277
- console.log(kleur.bold(` 📦 ${tr('new.firebase.confirm.title')}`));
278
- console.log(bar);
279
- console.log(` ${kleur.dim(tr('new.firebase.confirm.app') + ':')} ${kleur.white(answers.appName)}`);
280
- console.log(` ${kleur.dim('Bundle:')} ${kleur.white(answers.bundleId)}`);
281
- console.log(` ${kleur.dim('Backend:')} ${kleur.white(backendLabel)}`);
282
- if (answers.firebaseProjectId) {
283
- console.log(` ${kleur.dim('Firebase:')} ${kleur.white(answers.firebaseProjectId)}`);
284
- }
285
- if (answers.supabaseUrl) {
286
- console.log(` ${kleur.dim('Supabase URL:')} ${kleur.white(answers.supabaseUrl)}`);
287
- }
288
- if (answers.apiBaseUrl) {
289
- console.log(` ${kleur.dim('API URL:')} ${kleur.white(answers.apiBaseUrl)}`);
290
- }
291
- console.log(` ${kleur.dim(tr('new.firebase.confirm.modules') + ':')} ${kleur.white(modules)}`);
292
- 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')));
293
221
  }
294
222
 
295
223
  const STEP_LABELS = {
@@ -378,38 +306,35 @@ function stepProgress(key, lang) {
378
306
  function printCreateFromScratchStatus(result, tr) {
379
307
  if (result.sha1Skipped) {
380
308
  const err = (result.sha1Error || '').replace(/\s+/g, ' ').slice(0, 120);
381
- if (result.sha1Skipped === 'api_failed') {
382
- console.log(kleur.yellow(` ${tr('new.sha1.skipped.apiFailed', { error: err })}`));
383
- } else {
384
- console.log(kleur.yellow(` ${tr('new.sha1.skipped.other', { reason: result.sha1Skipped })}`));
385
- }
386
- console.log(kleur.yellow(` ${tr('new.sha1.addManually')}`));
387
- 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}`);
388
314
  } else {
389
- console.log(kleur.green(` ${tr('new.sha1.added')}`));
315
+ ui.log.success(tr('new.sha1.added'));
390
316
  }
391
317
 
392
318
  if (result.firestoreCreated) {
393
- console.log(kleur.green(` ${tr('new.firestore.created')}`));
319
+ ui.log.success(tr('new.firestore.created'));
394
320
  } else {
395
- if (result.firestoreError) console.log(kleur.yellow(` ${tr('new.firestore.notCreated.error', { error: (result.firestoreError || '').slice(0, 100) })}`));
396
- else console.log(kleur.yellow(` ${tr('new.firestore.notCreated')}`));
397
- console.log(kleur.yellow(` ${tr('new.activateManually')}`));
398
- 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)}`);
399
325
  }
400
326
 
401
327
  if (result.storageCreated) {
402
- console.log(kleur.green(` ${tr('new.storage.created')}`));
328
+ ui.log.success(tr('new.storage.created'));
403
329
  } else {
404
- if (result.storageError) console.log(kleur.yellow(` ${tr('new.storage.notCreated.error', { error: (result.storageError || '').slice(0, 100) })}`));
405
- else console.log(kleur.yellow(` ${tr('new.storage.notCreated')}`));
406
- console.log(kleur.yellow(` ${tr('new.activateManually')}`));
407
- 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)}`);
408
334
  }
409
335
  }
410
336
 
411
337
  function printSuccessCard(tr, answers, targetDir) {
412
- const bar = kleur.gray(' ─────────────────────────────────────────────');
413
338
  const folderName = path.basename(targetDir);
414
339
  const consoleUrl = answers.backend === 'firebase' && answers.firebaseProjectId
415
340
  ? `https://console.firebase.google.com/project/${answers.firebaseProjectId}`
@@ -417,71 +342,62 @@ function printSuccessCard(tr, answers, targetDir) {
417
342
  ? 'https://supabase.com/dashboard'
418
343
  : answers.apiBaseUrl || null;
419
344
 
420
- console.log(`\n${bar}`);
421
- console.log(kleur.bold(gradient(['#a78bfa', '#60a5fa'])(` 🎉 ${tr('new.success.title')}`)));
422
- console.log(bar);
423
- console.log('');
424
- console.log(` ${kleur.bold(tr('new.success.nextSteps'))}`);
425
- console.log('');
426
- console.log(` ${kleur.dim('1.')} ${kleur.dim(tr('new.success.step.cd'))}`);
427
- console.log(` ${kleur.cyan(`cd ${folderName}`)}`);
428
- 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}`)}`);
429
350
  let stepNum = 2;
430
351
  if (answers.backend === 'firebase') {
431
- console.log(` ${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.deploy'))}`);
432
- console.log(` ${kleur.cyan('kasy deploy')}`);
433
- 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')}`);
434
355
  }
435
- console.log(` ${kleur.dim(`${stepNum++}.`)} ${kleur.dim(tr('new.success.step.run'))}`);
436
- console.log(` ${kleur.cyan('kasy run')}`);
437
- 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'))}`);
438
360
  if (consoleUrl) {
439
- console.log('');
440
- console.log(` ${kleur.dim(`${stepNum}.`)} ${kleur.dim(tr('new.success.step.console'))}`);
441
- 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)}`);
442
364
  }
443
- console.log('');
444
- console.log(bar);
445
- console.log('');
365
+
366
+ console.log(successBox(`🎉 ${tr('new.success.title')}`, lines.join('\n')));
446
367
  }
447
368
 
448
369
  function printStepResult(step, lang = 'pt') {
370
+ const label = stepLabel(step.name, lang);
449
371
  if (step.skipped) {
450
- const label = stepLabel(step.name, lang);
451
- console.log(` ${kleur.gray('⏭')} ${kleur.gray(label)} ${kleur.gray('(skipped)')}`);
372
+ ui.log.step(`${kleur.dim(label)} ${kleur.dim('(skipped)')}`);
452
373
  return;
453
374
  }
454
- const icon = step.ok ? kleur.green('') : kleur.red('');
455
- const label = step.ok
456
- ? kleur.white(stepLabel(step.name, lang))
457
- : kleur.red(stepLabel(step.name, lang));
458
- 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
+ }
459
381
  }
460
382
 
461
383
  function onCancel(tr) {
462
- console.log(kleur.yellow(`\n ${tr('new.firebase.error.aborted')}\n`));
384
+ ui.cancel(tr('new.firebase.error.aborted'));
463
385
  process.exit(0);
464
386
  }
465
387
 
466
388
  async function promptSupabaseManual(tr, cancel) {
467
- return prompts(
468
- [
469
- {
470
- type: 'text',
471
- name: 'supabaseUrl',
472
- message: tr('prompt.supabase.url.enter'),
473
- hint: 'https://xxxx.supabase.co',
474
- validate: (v) => (v && v.trim() ? true : tr('prompt.supabase.url.required')),
475
- },
476
- {
477
- type: 'text',
478
- name: 'supabaseAnonKey',
479
- message: tr('prompt.supabase.anonKey.enter'),
480
- validate: (v) => (v && v.trim() ? true : tr('prompt.supabase.anonKey.required')),
481
- },
482
- ],
483
- { onCancel: cancel }
484
- );
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 };
485
401
  }
486
402
 
487
403
  // ── Module presets ────────────────────────────────────────────────────────────
@@ -568,7 +484,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
568
484
  });
569
485
  } else {
570
486
  const backendLabel = { firebase: '🔥 Firebase', supabase: '🟢 Supabase', api: '🔗 API REST' }[backend] || backend;
571
- console.log(kleur.gray(` Backend: ${kleur.white(backendLabel)}`));
487
+ ui.log.info(`Backend: ${kleur.white(backendLabel)}`);
572
488
  }
573
489
 
574
490
  // ── 3b. Backend tool checks (required — blocks if missing) ───────────────
@@ -577,7 +493,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
577
493
  let backendCheckResults = [];
578
494
  if (backendChecks.length > 0) {
579
495
  if (backend === 'supabase' || backend === 'api') {
580
- console.log(kleur.dim(`\n ℹ ${tr('new.checks.firebaseForPush')}`));
496
+ ui.log.info(tr('new.checks.firebaseForPush'));
581
497
  }
582
498
  const backendLabel = tr('setup.checks.backend', { backend }) || ` Checking ${backend} tools…`;
583
499
  backendCheckResults = await runChecks(backendChecks, backendLabel, {
@@ -587,14 +503,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
587
503
  doneLabel: tr('setup.checks.backend.done', { backend }) || `${backend} tools ready`,
588
504
  });
589
505
  if (hasRequiredFailures(backendCheckResults)) {
590
- console.log(kleur.red(`\n ✖ ${tr('new.checks.requiredBlock')}`));
591
- console.log(kleur.dim(`\n ${tr('new.checks.installFirebase')}`));
506
+ const installSteps = [tr('new.checks.installFirebase')];
592
507
  if (backend === 'supabase') {
593
508
  const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux';
594
509
  const supabaseKey = `new.checks.installSupabase.${platform}`;
595
- console.log(kleur.dim(` ${tr(supabaseKey) || tr('new.checks.installSupabase')}`));
510
+ installSteps.push(tr(supabaseKey) || tr('new.checks.installSupabase'));
596
511
  }
597
- 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'));
598
515
  process.exit(1);
599
516
  }
600
517
  }
@@ -603,7 +520,8 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
603
520
  let core;
604
521
  if (yes) {
605
522
  if (!hasExplicitDir) {
606
- console.error(kleur.red(`\n ✗ --yes requires an app name: kasy new MyApp --yes\n`));
523
+ ui.log.error('--yes requires an app name: kasy new MyApp --yes');
524
+ ui.cancel(tr('new.firebase.error.aborted'));
607
525
  process.exit(1);
608
526
  }
609
527
  const appName = path.basename(targetDir);
@@ -614,8 +532,8 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
614
532
  .replace(/[^a-z0-9]/g, '');
615
533
  const bundleId = (slug && !/^\d/.test(slug)) ? `com.${slug}.app` : 'com.example.app';
616
534
  core = { appName, bundleId };
617
- console.log(kleur.gray(` App: ${kleur.white(appName)}`));
618
- console.log(kleur.gray(` Bundle: ${kleur.white(bundleId)}`));
535
+ ui.log.info(`App: ${kleur.white(appName)}`);
536
+ ui.log.info(`Bundle: ${kleur.white(bundleId)}`);
619
537
  } else {
620
538
  const appName = await ui.text({
621
539
  message: tr('new.firebase.q.appName'),
@@ -735,96 +653,70 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
735
653
  } else {
736
654
  let firebaseProjectId = projectHint?.trim() || '';
737
655
  if (!firebaseProjectId && backend === 'firebase') {
738
- const { pid } = await prompts(
739
- {
740
- type: 'text',
741
- name: 'pid',
742
- message: tr('new.firebase.q.projectId'),
743
- hint: tr('new.firebase.q.projectId.hint'),
744
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
745
- },
746
- { onCancel: cancel }
747
- );
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
+ });
748
662
  firebaseProjectId = pid?.trim() || '';
749
663
  }
750
664
  if (firebaseProjectId) {
751
665
  core.firebaseProjectId = firebaseProjectId;
752
- console.log(kleur.gray(` Project: ${kleur.white(firebaseProjectId)}`));
666
+ ui.log.info(`Project: ${kleur.white(firebaseProjectId)}`);
753
667
  }
754
668
  }
755
669
 
756
670
  // ── Firebase region — Quick mode uses default (us-central1) ──────────
757
671
  let firebaseRegion = 'us-central1';
758
672
  if (backend === 'firebase' && !isQuick) {
759
- const { region } = await prompts(
760
- {
761
- type: 'select',
762
- name: 'region',
763
- message: tr('new.firebase.q.region'),
764
- choices: [
765
- { title: tr('new.firebase.q.region.us'), value: 'us-central1' },
766
- { title: tr('new.firebase.q.region.europe'), value: 'europe-west1' },
767
- { title: tr('new.firebase.q.region.brazil'), value: 'southamerica-east1' },
768
- ],
769
- initial: 0,
770
- },
771
- { onCancel: cancel }
772
- );
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
+ });
773
683
  firebaseRegion = region || 'us-central1';
774
684
  }
775
685
 
776
686
  // ── Firebase: create from scratch (when selected) ─────────────────────────
777
687
  let firebaseIncludeWeb = true;
778
688
  if (backend === 'firebase' && firebaseSetupMode === 'create') {
779
- const { includeWeb } = await prompts(
780
- {
781
- type: 'confirm',
782
- name: 'includeWeb',
783
- message: tr('new.firebase.create.includeWeb'),
784
- initial: true,
785
- },
786
- { onCancel: cancel }
787
- );
788
- firebaseIncludeWeb = includeWeb !== false;
689
+ firebaseIncludeWeb = await ui.confirm({
690
+ message: tr('new.firebase.create.includeWeb'),
691
+ initialValue: true,
692
+ onCancel: cancel,
693
+ });
789
694
  const gcloudCheck = await checkGcloudAuth();
790
695
  if (!gcloudCheck.ok) {
791
- console.log(kleur.red(` ${tr('new.firebase.create.gcloudRequired')}`));
696
+ ui.log.error(tr('new.firebase.create.gcloudRequired'));
792
697
  if (gcloudCheck.missing === 'gcloud') {
793
698
  const instructions = getGcloudInstallInstructions();
794
- console.log(kleur.cyan(`\n ${tr('new.firebase.create.installTitle')}`));
795
- if (instructions.install) {
796
- console.log(kleur.white(` ${tr('new.firebase.create.installCommand')}:`));
797
- console.log(kleur.cyan(` ${instructions.install}`));
798
- }
799
- if (instructions.hint) {
800
- console.log(kleur.gray(` ${instructions.hint}`));
801
- }
802
- console.log(kleur.white(` ${tr('new.firebase.create.installAfter')}:`));
803
- console.log(kleur.cyan(` ${instructions.after}`));
804
- console.log(kleur.gray(` ${tr('new.firebase.create.installUrl')}: ${instructions.url}`));
805
- 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'));
806
705
  } else {
807
- console.log(kleur.cyan(` ${tr('new.firebase.create.authCommand')}`));
808
- console.log('');
706
+ ui.note(tr('new.firebase.create.authCommand'));
809
707
  }
810
- console.log(kleur.yellow(` ${tr('new.firebase.create.fallbackHint')}`));
811
- const fallback = await prompts(
812
- {
813
- type: 'text',
814
- name: 'firebaseProjectId',
815
- message: tr('new.firebase.q.projectId'),
816
- hint: tr('new.firebase.q.projectId.hint'),
817
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
818
- },
819
- { onCancel: cancel }
820
- );
821
- 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
+ });
822
715
  } else {
823
716
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
824
717
  let selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
825
- console.log(kleur.dim(`\n ⏱ ${tr('new.firebase.create.estimatedTime')}`));
826
- console.log(kleur.dim(` ${tr('new.internet.warning')}\n`));
827
- const ps1 = makeProgressSpinner();
718
+ ui.log.info(`${tr('new.firebase.create.estimatedTime')}\n${tr('new.internet.warning')}`);
719
+ const ps1 = ui.makeStepper();
828
720
  ps1.next(tr('new.firebase.create.creating'));
829
721
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
830
722
  includeWeb: firebaseIncludeWeb,
@@ -841,15 +733,31 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
841
733
  ps1.next(stepProgress('storage', language));
842
734
  } else if (key === 'auth-providers-warn') {
843
735
  ps1.stop();
844
- console.log(kleur.yellow(` ⚠ ${tr('new.firebase.interactive.authWarn')}`));
845
- console.log(kleur.cyan(` ${data?.url || ''}`));
736
+ ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
846
737
  }
847
738
  },
848
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
+ };
849
757
  if (setupResult.ok) {
850
758
  ps1.succeed(tr('new.firebase.create.success'));
851
759
  core.firebaseProjectId = setupResult.projectId;
852
- console.log(kleur.dim(` Project ID: ${core.firebaseProjectId}`));
760
+ ui.log.info(`Project ID: ${core.firebaseProjectId}`);
853
761
  printCreateFromScratchStatus(setupResult, tr);
854
762
  const authUrl = `https://console.firebase.google.com/project/${core.firebaseProjectId}/authentication/providers`;
855
763
  if (setupResult.authEnabled && !setupResult.googleSignInSkipped) {
@@ -862,50 +770,33 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
862
770
  const readyKey = setupResult.googleSignInSkipped
863
771
  ? 'new.firebase.create.beforeContinue.ready'
864
772
  : 'new.firebase.create.beforeContinue.ready.noAuth';
865
- console.log(kleur.bold().yellow(`\n ${tr('new.firebase.create.beforeContinue.title')}`));
866
- console.log(kleur.gray(` ${tr(step1Key)}`));
867
- console.log(kleur.cyan(` ${authUrl}`));
773
+ showBeforeContinue(step1Key, authUrl);
868
774
  openUrl(authUrl);
869
- const { ready } = await prompts(
870
- {
871
- type: 'confirm',
872
- name: 'ready',
873
- message: tr(readyKey),
874
- initial: true,
875
- },
876
- { onCancel: cancel }
877
- );
878
- if (!ready) {
879
- console.log(kleur.gray(` ${tr('prompt.cancelled')}`));
880
- process.exit(0);
881
- }
775
+ await askReady(readyKey);
882
776
  }
883
777
 
884
778
  } else {
885
779
  ps1.fail(tr('new.firebase.create.failed'));
886
780
  let lastResult = setupResult;
887
781
  while (lastResult.billingFailed && lastResult.projectId) {
888
- console.log(kleur.red(` ${tr('new.firebase.create.failed')}: ${lastResult.error}`));
889
- console.log(kleur.yellow(`\n ${tr('new.firebase.create.billingRetry.title')}`));
890
- console.log(kleur.cyan(` ${lastResult.billingManualLink}`));
891
- console.log(kleur.gray(` ${tr('new.firebase.create.billingRetry.hint')}`));
892
- const { retry } = await prompts(
893
- {
894
- type: 'confirm',
895
- name: 'retry',
896
- message: tr('new.firebase.create.billingRetry.ready'),
897
- initial: true,
898
- },
899
- { 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')
900
786
  );
787
+ const retry = await ui.confirm({
788
+ message: tr('new.firebase.create.billingRetry.ready'),
789
+ initialValue: true,
790
+ onCancel: cancel,
791
+ });
901
792
  if (!retry) {
902
- console.log(kleur.gray(` ${tr('new.firebase.create.billingRetry.exit')}`));
903
- 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 }));
904
795
  process.exit(0);
905
796
  }
906
- console.log(kleur.gray(` ${tr('new.firebase.create.billingRetry.retrying')}`));
797
+ ui.log.message(tr('new.firebase.create.billingRetry.retrying'));
907
798
  selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
908
- const ps2 = makeProgressSpinner();
799
+ const ps2 = ui.makeStepper();
909
800
  ps2.next(tr('new.firebase.create.creating'));
910
801
  lastResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
911
802
  includeWeb: firebaseIncludeWeb,
@@ -927,7 +818,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
927
818
  if (lastResult.ok) {
928
819
  ps2.succeed(tr('new.firebase.create.success'));
929
820
  core.firebaseProjectId = lastResult.projectId;
930
- console.log(kleur.dim(` Project ID: ${core.firebaseProjectId}`));
821
+ ui.log.info(`Project ID: ${core.firebaseProjectId}`);
931
822
  printCreateFromScratchStatus(lastResult, tr);
932
823
  const authUrl = `https://console.firebase.google.com/project/${core.firebaseProjectId}/authentication/providers`;
933
824
  if (lastResult.authEnabled && !lastResult.googleSignInSkipped) {
@@ -939,47 +830,32 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
939
830
  const lastReadyKey = lastResult.googleSignInSkipped
940
831
  ? 'new.firebase.create.beforeContinue.ready'
941
832
  : 'new.firebase.create.beforeContinue.ready.noAuth';
942
- console.log(kleur.bold().yellow(`\n ${tr('new.firebase.create.beforeContinue.title')}`));
943
- console.log(kleur.gray(` ${tr(step1Key)}`));
944
- console.log(kleur.cyan(` ${authUrl}`));
833
+ showBeforeContinue(step1Key, authUrl);
945
834
  openUrl(authUrl);
946
- const { ready } = await prompts(
947
- {
948
- type: 'confirm',
949
- name: 'ready',
950
- message: tr(lastReadyKey),
951
- initial: true,
952
- },
953
- { onCancel: cancel }
954
- );
955
- if (!ready) {
956
- console.log(kleur.gray(` ${tr('prompt.cancelled')}`));
957
- process.exit(0);
958
- }
835
+ await askReady(lastReadyKey);
959
836
  }
960
837
 
961
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'));
962
843
  }
963
844
  }
964
845
  if (!lastResult.ok && !lastResult.billingFailed) {
965
846
  const isProjectQuota = /exceeded your allotted project quota|project quota/i.test(String(lastResult.error || ''));
966
847
  const msg = isProjectQuota ? tr('new.firebase.create.projectQuotaExceeded') : `${tr('new.firebase.create.failed')}: ${lastResult.error}`;
967
- console.log(kleur.red(` ${msg}`));
848
+ ui.log.error(msg);
968
849
  if (lastResult.projectId) {
969
850
  core.firebaseProjectId = lastResult.projectId;
970
- console.log(kleur.gray(` ${tr('new.firebase.create.usingProjectId', { id: lastResult.projectId })}`));
851
+ ui.log.message(tr('new.firebase.create.usingProjectId', { id: lastResult.projectId }));
971
852
  } else {
972
- const fallback = await prompts(
973
- {
974
- type: 'text',
975
- name: 'firebaseProjectId',
976
- message: tr('new.firebase.q.projectId'),
977
- hint: tr('new.firebase.q.projectId.hint'),
978
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
979
- },
980
- { onCancel: cancel }
981
- );
982
- 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
+ });
983
859
  }
984
860
  }
985
861
  }
@@ -990,42 +866,30 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
990
866
  if ((backend === 'supabase' || backend === 'api') && firebaseSetupMode === 'create') {
991
867
  const gcloudCheck = await checkGcloudAuth();
992
868
  if (!gcloudCheck.ok) {
993
- console.log(kleur.red(` ${tr('new.firebase.create.gcloudRequired')}`));
869
+ ui.log.error(tr('new.firebase.create.gcloudRequired'));
994
870
  if (gcloudCheck.missing === 'gcloud') {
995
871
  const instructions = getGcloudInstallInstructions();
996
- console.log(kleur.cyan(`\n ${tr('new.firebase.create.installTitle')}`));
997
- if (instructions.install) {
998
- console.log(kleur.white(` ${tr('new.firebase.create.installCommand')}:`));
999
- console.log(kleur.cyan(` ${instructions.install}`));
1000
- }
1001
- if (instructions.hint) {
1002
- console.log(kleur.gray(` ${instructions.hint}`));
1003
- }
1004
- console.log(kleur.white(` ${tr('new.firebase.create.installAfter')}:`));
1005
- console.log(kleur.cyan(` ${instructions.after}`));
1006
- console.log(kleur.gray(` ${tr('new.firebase.create.installUrl')}: ${instructions.url}`));
1007
- 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'));
1008
878
  } else {
1009
- console.log(kleur.cyan(` ${tr('new.firebase.create.authCommand')}`));
1010
- console.log('');
879
+ ui.note(tr('new.firebase.create.authCommand'));
1011
880
  }
1012
- console.log(kleur.yellow(` ${tr('new.firebase.create.fallbackHint')}`));
1013
- const fallback = await prompts(
1014
- {
1015
- type: 'text',
1016
- name: 'firebaseProjectId',
1017
- message: tr('new.firebase.q.projectId'),
1018
- hint: tr('new.firebase.q.projectId.hint') + ' (FCM + Remote Config)',
1019
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
1020
- },
1021
- { onCancel: cancel }
1022
- );
1023
- 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
+ });
1024
888
  } else {
1025
889
  const selectedOrgId = await promptOrganizationIfNeeded(tr, cancel);
1026
890
  const selectedBillingId = await promptBillingAccountIfNeeded(tr, cancel);
1027
- console.log(kleur.dim(`\n ${tr('new.internet.warning')}\n`));
1028
- const ps3 = makeProgressSpinner();
891
+ ui.log.info(tr('new.internet.warning'));
892
+ const ps3 = ui.makeStepper();
1029
893
  ps3.next(tr('new.firebase.create.creatingPush'));
1030
894
  const setupResult = await setupFromScratch(core.appName.trim(), core.bundleId.trim(), {
1031
895
  includeWeb: true,
@@ -1046,26 +910,21 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1046
910
  if (setupResult.ok) {
1047
911
  ps3.succeed(tr('new.firebase.create.successPush'));
1048
912
  core.firebaseProjectId = setupResult.projectId;
1049
- console.log(kleur.dim(` Project ID: ${core.firebaseProjectId}`));
913
+ ui.log.info(`Project ID: ${core.firebaseProjectId}`);
1050
914
  printCreateFromScratchStatus(setupResult, tr);
1051
915
  } else {
1052
916
  ps3.fail(tr('new.firebase.create.failed'));
1053
- console.log(kleur.red(` ${tr('new.firebase.create.failed')}: ${setupResult.error}`));
917
+ ui.log.error(`${tr('new.firebase.create.failed')}: ${setupResult.error}`);
1054
918
  if (setupResult.projectId) {
1055
919
  core.firebaseProjectId = setupResult.projectId;
1056
- console.log(kleur.gray(` ${tr('new.firebase.create.usingProjectId', { id: setupResult.projectId })}`));
920
+ ui.log.message(tr('new.firebase.create.usingProjectId', { id: setupResult.projectId }));
1057
921
  } else {
1058
- const fallback = await prompts(
1059
- {
1060
- type: 'text',
1061
- name: 'firebaseProjectId',
1062
- message: tr('new.firebase.q.projectId'),
1063
- hint: tr('new.firebase.q.projectId.hint') + ' (FCM + Remote Config)',
1064
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.projectId.required')),
1065
- },
1066
- { onCancel: cancel }
1067
- );
1068
- 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
+ });
1069
928
  }
1070
929
  }
1071
930
  }
@@ -1079,91 +938,75 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1079
938
  let supabaseExistingResult = null;
1080
939
 
1081
940
  if (backend === 'supabase') {
1082
- const { createSupabase } = await prompts(
1083
- {
1084
- type: 'select',
1085
- name: 'createSupabase',
1086
- message: tr('new.supabase.q.create'),
1087
- choices: [
1088
- { title: tr('new.supabase.q.create.create'), value: true },
1089
- { title: tr('new.supabase.q.create.existing'), value: false },
1090
- ],
1091
- initial: 0,
1092
- },
1093
- { onCancel: cancel }
1094
- );
1095
- 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
+ };
1096
954
 
1097
955
  if (supabaseCreate) {
1098
956
  const loginCheck = await checkLoggedIn();
1099
- if (!loginCheck.ok) {
1100
- console.log(kleur.yellow(` ${tr('new.supabase.loginRequired')}`));
1101
- console.log(kleur.cyan(` ${tr('new.supabase.loginCommand')}`));
1102
- console.log('');
1103
- }
957
+ if (!loginCheck.ok) showLoginRequired();
1104
958
  const orgsResult = await getOrgsList();
1105
959
  if (!orgsResult.ok || !orgsResult.orgs?.length) {
1106
- console.log(kleur.red(` ${tr('new.supabase.orgsRequired')}`));
960
+ ui.log.error(tr('new.supabase.orgsRequired'));
1107
961
  supabaseCreateResult = { ok: false, error: tr('new.supabase.orgsRequired') };
1108
962
  } else {
1109
963
  let orgId = orgsResult.orgs[0].id;
1110
964
  if (orgsResult.orgs.length > 1) {
1111
- const { selectedOrgId } = await prompts(
1112
- {
1113
- type: 'select',
1114
- name: 'selectedOrgId',
1115
- message: tr('new.supabase.q.orgSelect'),
1116
- choices: orgsResult.orgs.map((o) => ({ title: o.name, value: o.id })),
1117
- },
1118
- { onCancel: cancel }
1119
- );
1120
- 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
+ });
1121
971
  }
1122
972
  let supabaseRegion = DEFAULT_SUPABASE_REGION;
1123
973
  if (!isQuick) {
1124
- const { region } = await prompts(
1125
- {
1126
- type: 'select',
1127
- name: 'region',
1128
- message: tr('new.supabase.q.region'),
1129
- choices: [
1130
- { title: tr('new.supabase.q.region.brazil'), value: 'sa-east-1' },
1131
- { title: tr('new.supabase.q.region.us'), value: 'us-east-1' },
1132
- { title: tr('new.supabase.q.region.europe'), value: 'eu-west-1' },
1133
- { title: tr('new.supabase.q.region.global'), value: 'us-east-1' },
1134
- ],
1135
- initial: 0,
1136
- },
1137
- { onCancel: cancel }
1138
- );
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
+ });
1139
984
  supabaseRegion = region || DEFAULT_SUPABASE_REGION;
1140
985
  }
1141
- const { dbPassword } = await prompts(
1142
- {
1143
- type: 'password',
1144
- name: 'dbPassword',
1145
- message: tr('new.supabase.q.dbPassword'),
1146
- validate: (v) => (v && v.length >= 6 ? true : tr('new.supabase.q.dbPassword.required')),
1147
- },
1148
- { onCancel: cancel }
1149
- );
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
+ });
1150
991
  supabaseDbPassword = dbPassword;
1151
- console.log(kleur.dim(` ${tr('new.internet.warning')}`));
1152
- 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'));
1153
995
  supabaseCreateResult = await createProjectAndGetKeys(
1154
996
  core.appName.trim().replace(/\s+/g, '-').toLowerCase(),
1155
997
  supabaseDbPassword,
1156
998
  supabaseRegion,
1157
999
  orgId
1158
1000
  );
1001
+ createSpinner.stop(tr('new.supabase.creating'));
1159
1002
  }
1160
1003
  if (supabaseCreateResult.ok) {
1161
1004
  core.supabaseUrl = supabaseCreateResult.supabaseUrl;
1162
1005
  core.supabaseAnonKey = supabaseCreateResult.supabaseAnonKey;
1163
- console.log(kleur.green(` ✓ ${tr('new.supabase.created')}`));
1006
+ ui.log.success(tr('new.supabase.created'));
1164
1007
  } else {
1165
- console.log(kleur.red(` ${tr('new.supabase.createFailed')}: ${supabaseCreateResult.error}`));
1166
- 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'));
1167
1010
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1168
1011
  supabaseCreate = false;
1169
1012
  }
@@ -1171,54 +1014,46 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1171
1014
  // Usar projeto Supabase existente: org → projeto → keys → senha (mesmo fluxo de setup, sem criar)
1172
1015
  const loginCheck = await checkLoggedIn();
1173
1016
  if (!loginCheck.ok) {
1174
- console.log(kleur.yellow(` ${tr('new.supabase.loginRequired')}`));
1175
- console.log(kleur.cyan(` ${tr('new.supabase.loginCommand')}`));
1176
- console.log('');
1017
+ showLoginRequired();
1177
1018
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1178
1019
  } else {
1179
1020
  const orgsResult = await getOrgsList();
1180
1021
  if (!orgsResult.ok || !orgsResult.orgs?.length) {
1181
- console.log(kleur.red(` ${tr('new.supabase.orgsRequired')}`));
1022
+ ui.log.error(tr('new.supabase.orgsRequired'));
1182
1023
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1183
1024
  } else {
1184
1025
  let orgId = orgsResult.orgs[0].id;
1185
1026
  if (orgsResult.orgs.length > 1) {
1186
- const { selectedOrgId } = await prompts(
1187
- { type: 'select', name: 'selectedOrgId', message: tr('new.supabase.q.useExisting.orgSelect'), choices: orgsResult.orgs.map((o) => ({ title: o.name, value: o.id })) },
1188
- { onCancel: cancel }
1189
- );
1190
- 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
+ });
1191
1033
  }
1192
1034
  const projectsResult = await getProjectsByOrg(orgId);
1193
1035
  if (!projectsResult.ok || !projectsResult.projects?.length) {
1194
- console.log(kleur.yellow(` ${tr('new.supabase.projectsRequired')}`));
1036
+ ui.log.warn(tr('new.supabase.projectsRequired'));
1195
1037
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1196
1038
  } else {
1197
- const { selectedProjectRef } = await prompts(
1198
- {
1199
- type: 'select',
1200
- name: 'selectedProjectRef',
1201
- message: tr('new.supabase.q.useExisting.projectSelect'),
1202
- choices: projectsResult.projects.map((p) => ({ title: `${p.name} (${p.id})`, value: p.id })),
1203
- },
1204
- { onCancel: cancel }
1205
- );
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
+ });
1206
1045
  const keysResult = await getProjectKeys(selectedProjectRef);
1207
1046
  if (!keysResult.ok) {
1208
- console.log(kleur.red(` ${keysResult.error}`));
1047
+ ui.log.error(keysResult.error);
1209
1048
  Object.assign(core, await promptSupabaseManual(tr, cancel));
1210
1049
  } else {
1211
1050
  core.supabaseUrl = keysResult.supabaseUrl;
1212
1051
  core.supabaseAnonKey = keysResult.supabaseAnonKey;
1213
- const { dbPassword } = await prompts(
1214
- {
1215
- type: 'password',
1216
- name: 'dbPassword',
1217
- message: tr('new.supabase.q.dbPassword.existing'),
1218
- validate: (v) => (v && v.length >= 6 ? true : tr('new.supabase.q.dbPassword.required')),
1219
- },
1220
- { onCancel: cancel }
1221
- );
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
+ });
1222
1057
  supabaseExistingResult = {
1223
1058
  ok: true,
1224
1059
  projectRef: selectedProjectRef,
@@ -1226,7 +1061,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1226
1061
  supabaseAnonKey: keysResult.supabaseAnonKey,
1227
1062
  dbPassword,
1228
1063
  };
1229
- console.log(kleur.green(` ✓ ${tr('new.supabase.existingLinked')}`));
1064
+ ui.log.success(tr('new.supabase.existingLinked'));
1230
1065
  }
1231
1066
  }
1232
1067
  }
@@ -1242,22 +1077,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1242
1077
  let googleIosClientId = '';
1243
1078
 
1244
1079
  if (backend === 'api') {
1245
- const api = await prompts(
1246
- {
1247
- type: 'text',
1248
- name: 'apiBaseUrl',
1249
- message: tr('new.api.q.baseUrl'),
1250
- hint: tr('new.api.q.baseUrl.hint'),
1251
- initial: 'https://api.example.com',
1252
- },
1253
- { onCancel: cancel }
1254
- );
1255
- Object.assign(core, api);
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
+ });
1256
1086
  }
1257
1087
 
1258
1088
  // ── Firebase existing project: enable APIs + create Firestore/Storage ───
1259
1089
  if (backend === 'firebase' && firebaseSetupMode === 'existing' && core.firebaseProjectId) {
1260
- const ps4 = makeProgressSpinner();
1090
+ const ps4 = ui.makeStepper();
1261
1091
  ps4.next(stepProgress('enable-apis', language));
1262
1092
  const existingSetup = await setupExistingProject(core.firebaseProjectId, {
1263
1093
  onProgress: (key, data) => {
@@ -1265,19 +1095,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1265
1095
  ps4.next(stepProgress('enable-apis', language));
1266
1096
  } else if (key === 'enable-apis-warn') {
1267
1097
  ps4.warn(`${tr('new.firebase.create.failed')}: APIs`);
1268
- console.log(kleur.yellow(` ⚠ ${tr('new.firebase.existing.apisFailed')} ${(data?.error || '').slice(0, 80)}`));
1098
+ ui.log.warn(`${tr('new.firebase.existing.apisFailed')} ${(data?.error || '').slice(0, 80)}`);
1269
1099
  } else if (key === 'firestore') {
1270
1100
  ps4.next(stepProgress('firestore', language));
1271
1101
  } else if (key === 'storage') {
1272
1102
  ps4.next(stepProgress('storage', language));
1273
1103
  } else if (key === 'auth-providers-warn') {
1274
1104
  ps4.stop();
1275
- console.log(kleur.yellow(` ⚠ ${tr('new.firebase.interactive.authWarn')}`));
1276
- console.log(kleur.cyan(` ${data?.url || ''}`));
1105
+ ui.log.warn(`${tr('new.firebase.interactive.authWarn')}\n${kleur.cyan(data?.url || '')}`);
1277
1106
  } else if (key === 'auth-google-warn') {
1278
1107
  ps4.stop();
1279
- console.log(kleur.yellow(` ⚠ ${tr('new.firebase.existing.googleSignInManual')}`));
1280
- console.log(kleur.cyan(` ${data?.url || ''}`));
1108
+ ui.log.warn(`${tr('new.firebase.existing.googleSignInManual')}\n${kleur.cyan(data?.url || '')}`);
1281
1109
  }
1282
1110
  },
1283
1111
  });
@@ -1285,17 +1113,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1285
1113
  ps4.succeed(stepLabel('firestore', language));
1286
1114
  } else if (existingSetup.firestoreError && !existingSetup.firestoreError.includes('ALREADY_EXISTS')) {
1287
1115
  ps4.fail(`Firestore: ${(existingSetup.firestoreError || '').slice(0, 80)}`);
1288
- console.log(kleur.cyan(` ${existingSetup.firestoreUrl}`));
1116
+ ui.log.message(kleur.cyan(existingSetup.firestoreUrl));
1289
1117
  } else {
1290
1118
  ps4.stop();
1291
1119
  }
1292
1120
  if (existingSetup.storageCreated) {
1293
- console.log(kleur.green(` ✔ ${stepLabel('storage', language)}`));
1121
+ ui.log.success(stepLabel('storage', language));
1294
1122
  } else if (existingSetup.storageError && !existingSetup.storageError.includes('ALREADY_EXISTS')) {
1295
- console.log(kleur.yellow(`Storage: ${(existingSetup.storageError || '').slice(0, 80)}`));
1296
- console.log(kleur.cyan(` ${existingSetup.storageUrl}`));
1123
+ ui.log.warn(`Storage: ${(existingSetup.storageError || '').slice(0, 80)}\n${kleur.cyan(existingSetup.storageUrl)}`);
1297
1124
  }
1298
- console.log('');
1299
1125
  }
1300
1126
 
1301
1127
  // ── Optional modules ────────────────────────────────────────────────────
@@ -1311,22 +1137,18 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1311
1137
  modules = preselectedModules;
1312
1138
  } else if (isQuick) {
1313
1139
  // Quick mode: show preset picker (no multiselect).
1314
- const { preset } = await prompts(
1315
- {
1316
- type: 'select',
1317
- name: 'preset',
1318
- message: tr('new.q.preset'),
1319
- choices: [
1320
- { title: tr('new.q.preset.starter'), value: 'starter' },
1321
- { title: tr('new.q.preset.saas'), value: 'saas' },
1322
- { title: tr('new.q.preset.content'), value: 'content' },
1323
- { title: tr('new.q.preset.full'), value: 'full' },
1324
- { title: tr('new.q.preset.none'), value: 'none' },
1325
- ],
1326
- initial: 0,
1327
- },
1328
- { onCancel: cancel }
1329
- );
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
+ });
1330
1152
  modules = MODULE_PRESETS[preset] || [];
1331
1153
  } else {
1332
1154
  // Advanced mode: full multiselect — built from catalog, filtered by audience + backend.
@@ -1344,7 +1166,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1344
1166
  { header: 'new.modules.header.ci', ids: ['ci'] },
1345
1167
  ];
1346
1168
 
1347
- 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 = [];
1348
1172
  for (const group of groups) {
1349
1173
  const inGroup = visibleFeatures.filter((f) => {
1350
1174
  if (!group.ids.includes(f.id)) return false;
@@ -1352,29 +1176,24 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1352
1176
  return true;
1353
1177
  });
1354
1178
  if (inGroup.length === 0) continue;
1355
- if (group.header) {
1356
- moduleChoices.push({ title: tr(group.header), value: `__header_${group.header}__`, disabled: true });
1357
- }
1179
+ const groupLabel = group.header ? tr(group.header) : null;
1358
1180
  for (const f of inGroup) {
1359
- const label = tr(`new.firebase.module.${f.id}`) + (f.status === 'internal' ? ' [beta]' : '');
1360
- 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 });
1361
1186
  }
1362
1187
  }
1363
1188
 
1364
- const { modules: rawModules } = await prompts(
1365
- {
1366
- type: 'multiselect',
1367
- name: 'modules',
1368
- message: tr('new.firebase.q.modules'),
1369
- hint: tr('new.firebase.q.modules.hint'),
1370
- instructions: tr('prompt.multiselect.instructions'),
1371
- choices: moduleChoices,
1372
- min: 0,
1373
- warn: tr('prompt.multiselect.warnDisabled'),
1374
- },
1375
- { onCancel: cancel }
1376
- );
1377
- 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 || [];
1378
1197
  }
1379
1198
 
1380
1199
  if (backend === 'firebase' && firebaseSetupMode === 'create' && firebaseIncludeWeb) {
@@ -1385,57 +1204,41 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1385
1204
  const moduleAnswers = {};
1386
1205
 
1387
1206
  if (modules.includes('revenuecat')) {
1388
- const rc = await prompts(
1389
- [
1390
- {
1391
- type: 'text',
1392
- name: 'rcAndroidKey',
1393
- message: tr('new.firebase.q.revenuecat.android'),
1394
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.revenuecat.android.required')),
1395
- },
1396
- {
1397
- type: 'text',
1398
- name: 'rcIosKey',
1399
- message: tr('new.firebase.q.revenuecat.ios'),
1400
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.revenuecat.ios.required')),
1401
- },
1402
- {
1403
- type: 'select',
1404
- name: 'defaultPaywall',
1405
- message: tr('new.firebase.q.paywall'),
1406
- hint: tr('new.firebase.q.paywall.hint'),
1407
- choices: [
1408
- { title: 'Basic (list of plans)', value: 'basic' },
1409
- { title: 'With trial switch', value: 'withSwitch' },
1410
- { title: 'Row + comparison table', value: 'basicRow' },
1411
- { title: 'Minimal (benefits + CTA)', value: 'minimal' },
1412
- ],
1413
- initial: 0,
1414
- },
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)' },
1415
1225
  ],
1416
- { onCancel: cancel }
1417
- );
1418
- Object.assign(moduleAnswers, rc);
1226
+ onCancel: cancel,
1227
+ });
1419
1228
  }
1420
1229
 
1421
1230
  // RC web key — only in advanced mode (optional credential, can configure later).
1422
1231
  if (!isQuick && modules.includes('revenuecat') && modules.includes('web')) {
1423
- const rcWebKey = await prompts(
1424
- {
1425
- type: 'text',
1426
- name: 'rcWebKey',
1427
- message: tr('new.firebase.q.revenuecat.webKey'),
1428
- validate: (v) => {
1429
- if (!v || !v.trim()) return true; // optional — blank is fine
1430
- return /^rcb_/.test(v.trim()) ? true : tr('new.firebase.q.revenuecat.webKey.invalid');
1431
- },
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');
1432
1237
  },
1433
- { onCancel: cancel }
1434
- );
1435
- Object.assign(moduleAnswers, {
1436
- revenuecatWeb: true,
1437
- rcWebKey: (rcWebKey.rcWebKey || '').trim(),
1238
+ onCancel: cancel,
1438
1239
  });
1240
+ moduleAnswers.revenuecatWeb = true;
1241
+ moduleAnswers.rcWebKey = (rcWebKey || '').trim();
1439
1242
  } else if (modules.includes('revenuecat') && modules.includes('web')) {
1440
1243
  // Quick mode: mark web billing as included but key will be configured later.
1441
1244
  moduleAnswers.revenuecatWeb = true;
@@ -1444,76 +1247,52 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1444
1247
 
1445
1248
  // Sentry DSN — already optional (blank = configure later). Skip in quick mode.
1446
1249
  if (!isQuick && modules.includes('sentry')) {
1447
- const s = await prompts(
1448
- {
1449
- type: 'text',
1450
- name: 'sentryDsn',
1451
- message: tr('new.firebase.q.sentry.dsn'),
1452
- validate: (v) => {
1453
- if (!v || !v.trim()) return true; // optional — blank is fine
1454
- return /^https?:\/\/[^@\s]+@[^/\s]+\/\d+$/.test(v.trim())
1455
- ? true
1456
- : tr('new.firebase.q.sentry.dsn.invalid');
1457
- },
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');
1458
1257
  },
1459
- { onCancel: cancel }
1460
- );
1461
- Object.assign(moduleAnswers, s);
1258
+ onCancel: cancel,
1259
+ });
1462
1260
  }
1463
1261
 
1464
1262
  // Mixpanel token — already optional. Skip in quick mode.
1465
1263
  if (!isQuick && modules.includes('analytics')) {
1466
- const mx = await prompts(
1467
- {
1468
- type: 'text',
1469
- name: 'mixpanelToken',
1470
- message: tr('new.firebase.q.mixpanel.token'),
1471
- },
1472
- { onCancel: cancel }
1473
- );
1474
- Object.assign(moduleAnswers, mx);
1264
+ moduleAnswers.mixpanelToken = await ui.text({
1265
+ message: tr('new.firebase.q.mixpanel.token'),
1266
+ onCancel: cancel,
1267
+ });
1475
1268
  }
1476
1269
 
1477
1270
  // LLM Chat credentials — skip in quick mode, all fields optional.
1478
1271
  if (!isQuick && modules.includes('llm_chat')) {
1479
- const { configureLlmNow } = await prompts(
1480
- {
1481
- type: 'confirm',
1482
- name: 'configureLlmNow',
1483
- message: tr('new.q.llm_chat.configureNow'),
1484
- hint: tr('new.q.llm_chat.configureNow.hint'),
1485
- initial: true,
1486
- },
1487
- { onCancel: cancel }
1488
- );
1272
+ const configureLlmNow = await ui.confirm({
1273
+ message: tr('new.q.llm_chat.configureNow'),
1274
+ initialValue: true,
1275
+ onCancel: cancel,
1276
+ });
1489
1277
 
1490
1278
  if (configureLlmNow) {
1491
- const llm = await prompts(
1492
- [
1493
- {
1494
- type: 'select',
1495
- name: 'llmProvider',
1496
- message: tr('add.prompt.llmProvider'),
1497
- choices: [
1498
- { title: 'OpenAI (gpt-4o-mini)', value: 'openai' },
1499
- { title: 'Google Gemini (gemini-1.5-flash)', value: 'gemini' },
1500
- ],
1501
- initial: 0,
1502
- },
1503
- {
1504
- type: 'text',
1505
- name: 'llmSystemPrompt',
1506
- message: tr('add.prompt.llmSystemPrompt'),
1507
- },
1508
- {
1509
- type: 'text',
1510
- name: 'llmApiKey',
1511
- message: tr('add.prompt.llmApiKey'),
1512
- },
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)' },
1513
1285
  ],
1514
- { onCancel: cancel }
1515
- );
1516
- 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
+ });
1517
1296
  } else {
1518
1297
  moduleAnswers.llmConfigureLater = true;
1519
1298
  }
@@ -1523,70 +1302,48 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1523
1302
 
1524
1303
  // Facebook — required credentials, always ask.
1525
1304
  if (modules.includes('facebook')) {
1526
- const fb = await prompts(
1527
- [
1528
- {
1529
- type: 'text',
1530
- name: 'fbAppId',
1531
- message: tr('new.firebase.q.facebook.appId'),
1532
- validate: (v) => {
1533
- if (!v || !v.trim()) return tr('new.firebase.q.facebook.appId.required');
1534
- return /^\d+$/.test(v.trim()) ? true : tr('new.firebase.q.facebook.appId.invalid');
1535
- },
1536
- },
1537
- {
1538
- type: 'text',
1539
- name: 'fbToken',
1540
- message: tr('new.firebase.q.facebook.token'),
1541
- validate: (v) => (v && v.trim() ? true : tr('new.firebase.q.facebook.token.required')),
1542
- },
1543
- ],
1544
- { onCancel: cancel }
1545
- );
1546
- 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
+ });
1547
1318
  }
1548
1319
 
1549
1320
  // Server secrets (webhook, Meta Ads) — skip in quick mode, configure later via `kasy deploy`.
1550
1321
  if (!isQuick && modules.includes('revenuecat') && (backend === 'supabase' || backend === 'firebase')) {
1551
- const { configureSecretsNow } = await prompts(
1552
- {
1553
- type: 'confirm',
1554
- name: 'configureSecretsNow',
1555
- message: tr('new.firebase.q.secrets.configureNow'),
1556
- hint: tr('new.firebase.q.secrets.configureNow.hint'),
1557
- initial: true,
1558
- },
1559
- { onCancel: cancel }
1560
- );
1322
+ const configureSecretsNow = await ui.confirm({
1323
+ message: tr('new.firebase.q.secrets.configureNow'),
1324
+ initialValue: true,
1325
+ onCancel: cancel,
1326
+ });
1561
1327
 
1562
1328
  if (configureSecretsNow) {
1563
- const rcSecrets = await prompts(
1564
- [
1565
- {
1566
- type: 'text',
1567
- name: 'rcWebhookKey',
1568
- message: tr('new.firebase.q.revenuecat.webhookKey'),
1569
- initial: generateWebhookKey(),
1570
- hint: tr('new.firebase.q.revenuecat.webhookKey.hint'),
1571
- },
1572
- {
1573
- type: 'text',
1574
- name: 'metaAccessToken',
1575
- message: tr('new.firebase.q.revenuecat.metaToken'),
1576
- },
1577
- {
1578
- type: 'text',
1579
- name: 'metaDatasetId',
1580
- message: tr('new.firebase.q.revenuecat.metaDataset'),
1581
- validate: (v) => {
1582
- if (!v || !v.trim()) return true; // optional — blank is fine
1583
- return /^\d+$/.test(v.trim()) ? true : tr('new.firebase.q.revenuecat.metaDataset.invalid');
1584
- },
1585
- },
1586
- ],
1587
- { onCancel: cancel }
1588
- );
1589
- 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
+ });
1590
1347
  } else {
1591
1348
  moduleAnswers.secretsConfigureLater = true;
1592
1349
  }
@@ -1616,24 +1373,29 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1616
1373
  printSummary(tr, answers);
1617
1374
 
1618
1375
  if (!yes) {
1619
- const { proceed } = await prompts(
1620
- {
1621
- type: 'confirm',
1622
- name: 'proceed',
1623
- message: tr('new.firebase.confirm.proceed'),
1624
- initial: true,
1625
- },
1626
- { onCancel: cancel }
1627
- );
1376
+ const proceed = await ui.confirm({
1377
+ message: tr('new.firebase.confirm.proceed'),
1378
+ initialValue: true,
1379
+ onCancel: cancel,
1380
+ });
1628
1381
  if (!proceed) {
1629
- console.log(kleur.gray(`\n ${tr('prompt.cancelled')}\n`));
1382
+ ui.cancel(tr('prompt.cancelled'));
1630
1383
  process.exit(0);
1631
1384
  }
1632
1385
  }
1633
1386
 
1634
1387
  // ── Generate ────────────────────────────────────────────────────────────
1635
- console.log('');
1636
- 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
+ };
1637
1399
 
1638
1400
  let result;
1639
1401
  try {
@@ -1647,9 +1409,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1647
1409
  modules: answers.modules,
1648
1410
  moduleAnswers,
1649
1411
  language,
1650
- onProgress: (key) => {
1651
- spinner.text = kleur.cyan(stepProgress(key, language));
1652
- },
1412
+ onProgress,
1653
1413
  });
1654
1414
  } else if (backend === 'api') {
1655
1415
  result = await generateApiProject(targetDir, {
@@ -1660,9 +1420,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1660
1420
  modules: answers.modules,
1661
1421
  moduleAnswers,
1662
1422
  language,
1663
- onProgress: (key) => {
1664
- spinner.text = kleur.cyan(stepProgress(key, language));
1665
- },
1423
+ onProgress,
1666
1424
  });
1667
1425
  } else {
1668
1426
  result = await generateFirebaseProject(targetDir, {
@@ -1675,14 +1433,14 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1675
1433
  includeWeb: answers.includeWeb !== false,
1676
1434
  functionsRegion: firebaseRegion,
1677
1435
  language,
1678
- onProgress: (key) => {
1679
- spinner.text = kleur.cyan(stepProgress(key, language));
1680
- },
1436
+ onProgress,
1681
1437
  });
1682
1438
  }
1683
- 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();
1684
1442
  } catch (err) {
1685
- spinner.fail(kleur.red(err.message));
1443
+ stepper.fail(err.message);
1686
1444
  throw err;
1687
1445
  }
1688
1446
 
@@ -1709,8 +1467,12 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1709
1467
  }
1710
1468
 
1711
1469
  // ── Results ─────────────────────────────────────────────────────────────
1712
- console.log('');
1713
- 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));
1714
1476
 
1715
1477
  // ── Supabase: link + db push + functions deploy (when project was created or existing selected) ─
1716
1478
  const supabaseSetupPayload = (supabaseCreate && supabaseCreateResult?.ok)
@@ -1750,19 +1512,20 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1750
1512
  // ── FCM Service Account key (best effort via gcloud — Firebase uses ADC, Supabase needs JSON) ──
1751
1513
  let fcmServiceAccountJson = null;
1752
1514
  if (answers.firebaseProjectId) {
1753
- const fcmSpinner = ora(kleur.cyan(tr('new.fcm.generating'))).start();
1515
+ const fcmSpinner = ui.spinner();
1516
+ fcmSpinner.start(tr('new.fcm.generating'));
1754
1517
  const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1518
+ fcmSpinner.stop(tr('new.fcm.generating'));
1755
1519
  if (fcmResult.ok) {
1756
1520
  fcmServiceAccountJson = fcmResult.json;
1757
- fcmSpinner.stop();
1758
1521
  printStepResult({ name: 'fcm-key', ok: true }, language);
1759
1522
  } else {
1760
- fcmSpinner.stop();
1761
1523
  printStepResult({ name: 'fcm-key', ok: false, detail: 'configure FIREBASE_SERVICE_ACCOUNT_JSON manualmente' }, language);
1762
1524
  }
1763
1525
  }
1764
1526
 
1765
- const setupSpinner = ora(kleur.cyan(tr('new.supabase.setup'))).start();
1527
+ const setupSpinner = ui.spinner();
1528
+ setupSpinner.start(tr('new.supabase.setup'));
1766
1529
  try {
1767
1530
  const secrets = {
1768
1531
  firebaseProjectId: answers.firebaseProjectId,
@@ -1788,21 +1551,22 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1788
1551
  secrets,
1789
1552
  google
1790
1553
  );
1791
- setupSpinner.stop();
1554
+ setupSpinner.stop(tr('new.supabase.setup'));
1792
1555
  setupSteps.forEach((s) => printStepResult(s, language));
1793
1556
  } catch (err) {
1794
- setupSpinner.fail(kleur.red(err.message));
1795
- console.log(kleur.yellow(` ${tr('new.supabase.setupManual')}`));
1557
+ setupSpinner.error(err.message);
1558
+ ui.log.warn(tr('new.supabase.setupManual'));
1796
1559
  }
1797
1560
  }
1798
1561
  }
1799
1562
 
1800
1563
  // ── API: FCM Service Account key — save to .kasy/ for server configuration ──
1801
1564
  if (backend === 'api' && answers.firebaseProjectId) {
1802
- const fcmSpinner = ora(kleur.cyan(tr('new.fcm.generating'))).start();
1565
+ const fcmSpinner = ui.spinner();
1566
+ fcmSpinner.start(tr('new.fcm.generating'));
1803
1567
  const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1568
+ fcmSpinner.stop(tr('new.fcm.generating'));
1804
1569
  if (fcmResult.ok) {
1805
- fcmSpinner.stop();
1806
1570
  try {
1807
1571
  const kasyDir = path.join(targetDir, '.kasy');
1808
1572
  await fs.ensureDir(kasyDir);
@@ -1817,12 +1581,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1817
1581
  }
1818
1582
  }
1819
1583
  printStepResult({ name: 'fcm-key-saved', ok: true }, language);
1820
- console.log(kleur.gray(` ${tr('new.fcm.serverConfig')}`));
1584
+ ui.log.message(tr('new.fcm.serverConfig'));
1821
1585
  } catch (_) {
1822
1586
  printStepResult({ name: 'fcm-key-saved', ok: false, detail: 'salvar arquivo de chave falhou' }, language);
1823
1587
  }
1824
1588
  } else {
1825
- fcmSpinner.stop();
1826
1589
  printStepResult({ name: 'fcm-key', ok: false, detail: 'configure FIREBASE_SERVICE_ACCOUNT_JSON manualmente' }, language);
1827
1590
  }
1828
1591
  }
@@ -1835,9 +1598,10 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1835
1598
  if (answers.firebaseProjectId && firebaseSetupMode === 'existing') {
1836
1599
  const flutterfireOkForSha1 = result.steps.find((s) => s.name === 'flutterfire')?.ok;
1837
1600
  if (flutterfireOkForSha1) {
1838
- const sha1Spinner = ora(kleur.cyan(tr('new.sha1.registering'))).start();
1601
+ const sha1Spinner = ui.spinner();
1602
+ sha1Spinner.start(tr('new.sha1.registering'));
1839
1603
  const sha1Result = await registerDebugSha1(answers.firebaseProjectId, answers.bundleId);
1840
- sha1Spinner.stop();
1604
+ sha1Spinner.stop(tr('new.sha1.registering'));
1841
1605
  if (sha1Result.ok) {
1842
1606
  if (!sha1Result.existed) {
1843
1607
  printStepResult({ name: 'sha1', ok: true }, language);
@@ -1845,9 +1609,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1845
1609
  // existed === true → already registered, silent success
1846
1610
  } else {
1847
1611
  const sha1ManualUrl = `https://console.firebase.google.com/project/${answers.firebaseProjectId}/settings/general/android:${answers.bundleId}`;
1848
- console.log(kleur.yellow(` ⚠ ${tr('new.sha1.failed', { error: (sha1Result.sha1Error || '').slice(0, 120) })}`));
1849
- console.log(kleur.yellow(` ${tr('new.sha1.manual')}`));
1850
- 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
+ );
1851
1615
  openUrl(sha1ManualUrl);
1852
1616
  }
1853
1617
  }
@@ -1856,14 +1620,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1856
1620
  const flutterfireStep = result.steps.find((s) => s.name === 'flutterfire');
1857
1621
  if (flutterfireStep && !flutterfireStep.ok) {
1858
1622
  const platforms = answers.includeWeb !== false ? 'android,ios,web' : 'android,ios';
1859
- console.log(kleur.yellow(`\n ${tr('new.firebase.warn.flutterfire')}`));
1860
- console.log(kleur.yellow(` ${tr('new.firebase.warn.flutterfire.manual')}`));
1861
- console.log(
1862
- kleur.cyan(
1863
- ` dart pub global run flutterfire_cli:flutterfire configure` +
1864
- ` --project=${answers.firebaseProjectId}` +
1865
- ` --out lib/firebase_options_dev.dart --platforms=${platforms} --yes`
1866
- )
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)}`
1867
1626
  );
1868
1627
  }
1869
1628
 
@@ -1871,16 +1630,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1871
1630
  // Cannot be automated: the .p8 key only exists in Apple Developer Portal.
1872
1631
  if (answers.firebaseProjectId) {
1873
1632
  const apnsUrl = `https://console.firebase.google.com/project/${answers.firebaseProjectId}/settings/cloudmessaging`;
1874
- console.log('');
1875
- console.log(kleur.bold().yellow(` ${tr('new.apns.warning')}`));
1876
- console.log(kleur.gray(` ${tr('new.apns.step1')}`));
1877
- console.log(kleur.gray(` ${tr('new.apns.step2')}`));
1878
- 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
+ );
1879
1636
  openUrl(apnsUrl);
1880
1637
  }
1881
1638
 
1882
1639
  printSuccessCard(tr, answers, targetDir);
1883
1640
 
1641
+ ui.outro(kleur.bold(tr('new.success.title')));
1884
1642
  }
1885
1643
 
1886
1644
  module.exports = { runNew };