kasy-cli 1.5.3 → 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.
@@ -3,6 +3,7 @@ const { promisify } = require('node:util');
3
3
  const readline = require('node:readline');
4
4
  const kleur = require('kleur');
5
5
  const oraPackage = require('ora');
6
+ const ui = require('./ui');
6
7
  const { createTranslator, detectDefaultLanguage } = require('./i18n');
7
8
 
8
9
  const execAsync = promisify(exec);
@@ -290,7 +291,8 @@ async function runChecks(checks, title, options = {}) {
290
291
 
291
292
  // Compact mode: single spinner, show failures only
292
293
  const { spinnerLabel = title, doneLabel = title } = options;
293
- process.stdout.write(` ${kleur.dim(spinnerLabel + '...')}\n`);
294
+ const spinner = ui.spinner();
295
+ spinner.start(spinnerLabel);
294
296
 
295
297
  const results = [];
296
298
  for (const check of checks) {
@@ -298,26 +300,35 @@ async function runChecks(checks, title, options = {}) {
298
300
  }
299
301
 
300
302
  const failures = results.filter((r) => !r.ok);
303
+ const requiredFailures = failures.filter((r) => r.required);
301
304
 
302
305
  if (failures.length === 0) {
303
- console.log(kleur.green(` ✔ ${doneLabel}`));
306
+ spinner.stop(doneLabel);
304
307
  return results;
305
308
  }
306
309
 
310
+ // Close the spinner reflecting what actually happened: red ▲ if a required
311
+ // check failed, default green ✦ if only optional checks failed (warnings).
312
+ if (requiredFailures.length > 0) {
313
+ spinner.error(doneLabel);
314
+ } else {
315
+ spinner.stop(doneLabel);
316
+ }
317
+
307
318
  for (const result of failures) {
308
- const hint = result.failHint ? `\n ${kleur.dim(`→ ${result.failHint}`)}` : '';
319
+ const hint = result.failHint ? `\n${kleur.dim(`→ ${result.failHint}`)}` : '';
309
320
  if (result.required) {
310
321
  const detail = result.autoInstallFailed ? ` — ${t('checks.install.failed')}` : '';
311
- const diagSuffix = result.diagnosis ? `\n ${kleur.dim(`→ ${t(`checks.diagnostic.${result.diagnosis}`, { name: result.name })}`)}` : '';
312
- console.log(kleur.red(` ✖ ${t('checks.missing', { name: result.name })}${detail}${diagSuffix}${hint}`));
322
+ const diagSuffix = result.diagnosis ? `\n${kleur.dim(`→ ${t(`checks.diagnostic.${result.diagnosis}`, { name: result.name })}`)}` : '';
323
+ ui.log.error(`${t('checks.missing', { name: result.name })}${detail}${diagSuffix}${hint}`);
313
324
  } else if (result.diagnosis) {
314
- console.log(kleur.yellow(` ⚠ ${t(`checks.diagnostic.${result.diagnosis}`, { name: result.name })}`));
325
+ ui.log.warn(t(`checks.diagnostic.${result.diagnosis}`, { name: result.name }));
315
326
  } else if (result.warnMessage) {
316
- console.log(kleur.yellow(` ⚠ ${result.warnMessage}${hint}`));
327
+ ui.log.warn(`${result.warnMessage}${hint}`);
317
328
  } else if (result.warnMessageKey) {
318
- console.log(kleur.yellow(` ⚠ ${t(result.warnMessageKey)}${hint}`));
329
+ ui.log.warn(`${t(result.warnMessageKey)}${hint}`);
319
330
  } else {
320
- console.log(kleur.yellow(` ⚠ ${t('checks.notFound', { name: result.name })}${hint}`));
331
+ ui.log.warn(`${t('checks.notFound', { name: result.name })}${hint}`);
321
332
  }
322
333
  }
323
334
 
@@ -1,4 +1,4 @@
1
- const prompts = require('prompts');
1
+ const ui = require('./ui');
2
2
  const { isLicenseFormatValid } = require('./license');
3
3
  const {
4
4
  LANGUAGE_CHOICES,
@@ -15,11 +15,9 @@ const {
15
15
 
16
16
  const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
17
17
 
18
- function getPromptOptions(t) {
19
- return {
20
- onCancel: () => {
21
- throw new Error(t('prompt.cancelled'));
22
- }
18
+ function makeCancel(t) {
19
+ return () => {
20
+ throw new Error(t('prompt.cancelled'));
23
21
  };
24
22
  }
25
23
 
@@ -30,178 +28,120 @@ async function promptLanguage(language) {
30
28
  }
31
29
 
32
30
  const detectedLanguage = detectDefaultLanguage();
33
- const initial = Math.max(
34
- 0,
35
- LANGUAGE_CHOICES.findIndex((choice) => choice.value === detectedLanguage)
36
- );
37
-
38
31
  const t = createTranslator(detectedLanguage);
39
- const response = await prompts(
40
- {
41
- type: 'select',
42
- name: 'language',
43
- message: t('prompt.language.select'),
44
- choices: LANGUAGE_CHOICES,
45
- initial
46
- },
47
- getPromptOptions(t)
48
- );
49
32
 
50
- return normalizeLanguage(response.language) || detectedLanguage;
33
+ const value = await ui.select({
34
+ message: t('prompt.language.select'),
35
+ initialValue: detectedLanguage,
36
+ options: LANGUAGE_CHOICES.map((choice) => ({
37
+ value: choice.value,
38
+ label: choice.title,
39
+ })),
40
+ onCancel: makeCancel(t),
41
+ });
42
+
43
+ return normalizeLanguage(value) || detectedLanguage;
51
44
  }
52
45
 
53
46
  async function promptLicenseKey(initial = '', options = {}) {
54
47
  const t = options.t || createTranslator(options.language);
55
- const response = await prompts(
56
- {
57
- type: 'text',
58
- name: 'licenseKey',
59
- message: t('prompt.license.enter'),
60
- initial,
61
- validate: (value) => {
62
- if (isLicenseFormatValid(value)) {
63
- return true;
64
- }
65
-
66
- return t('prompt.license.invalid');
67
- }
68
- },
69
- getPromptOptions(t)
70
- );
71
-
72
- return response.licenseKey;
48
+ const value = await ui.text({
49
+ message: t('prompt.license.enter'),
50
+ initialValue: initial,
51
+ validate: (v) => (isLicenseFormatValid(v) ? undefined : t('prompt.license.invalid')),
52
+ onCancel: makeCancel(t),
53
+ });
54
+ return value;
73
55
  }
74
56
 
75
57
  async function promptProjectName(options = {}) {
76
58
  const t = options.t || createTranslator(options.language);
77
- const response = await prompts(
78
- {
79
- type: 'text',
80
- name: 'projectName',
81
- message: t('prompt.projectName.enter'),
82
- initial: t('prompt.projectName.default'),
83
- validate: (value) => (value && value.trim() ? true : t('prompt.projectName.required'))
84
- },
85
- getPromptOptions(t)
86
- );
87
- return response.projectName.trim();
59
+ const value = await ui.text({
60
+ message: t('prompt.projectName.enter'),
61
+ initialValue: t('prompt.projectName.default'),
62
+ validate: (v) => (v && v.trim() ? undefined : t('prompt.projectName.required')),
63
+ onCancel: makeCancel(t),
64
+ });
65
+ return String(value).trim();
88
66
  }
89
67
 
90
68
  async function runSetupWizard(options = {}) {
91
69
  const t = options.t || createTranslator(options.language);
70
+ const cancel = makeCancel(t);
92
71
  const defaultAppName = options.defaultAppName || 'MyApp';
93
72
  const selectedFeaturesFromArgv = parseFeatureList(options.selectedFeatures);
94
73
 
95
- const baseQuestions = [
96
- {
97
- type: 'text',
98
- name: 'appName',
99
- message: t('prompt.appName.enter'),
100
- initial: defaultAppName,
101
- validate: (value) => (value && value.trim() ? true : t('prompt.appName.required'))
74
+ const appName = await ui.text({
75
+ message: t('prompt.appName.enter'),
76
+ initialValue: defaultAppName,
77
+ validate: (v) => (v && v.trim() ? undefined : t('prompt.appName.required')),
78
+ onCancel: cancel,
79
+ });
80
+ const bundleId = await ui.text({
81
+ message: t('prompt.bundleId.enter'),
82
+ initialValue: 'com.example.app',
83
+ validate: (v) => {
84
+ if (!v || !v.trim()) return t('prompt.bundleId.required');
85
+ const valid = /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/.test(v.trim());
86
+ return valid ? undefined : t('prompt.bundleId.invalid');
102
87
  },
103
- {
104
- type: 'text',
105
- name: 'bundleId',
106
- message: t('prompt.bundleId.enter'),
107
- initial: 'com.example.app',
108
- validate: (value) => {
109
- if (!value || !value.trim()) {
110
- return t('prompt.bundleId.required');
111
- }
112
-
113
- const valid = /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/.test(value.trim());
114
- return valid ? true : t('prompt.bundleId.invalid');
115
- }
116
- }
117
- ];
88
+ onCancel: cancel,
89
+ });
90
+
91
+ const core = { appName, bundleId };
118
92
 
119
93
  const backendFromArgv = normalizeBackend(options.selectedBackend);
120
- if (!backendFromArgv) {
121
- baseQuestions.push({
122
- type: 'select',
123
- name: 'backend',
94
+ let backend = backendFromArgv;
95
+ if (!backend) {
96
+ backend = await ui.select({
124
97
  message: t('prompt.backend.select'),
125
- choices: AVAILABLE_BACKENDS.map((backend) => ({
126
- title: backend.id,
127
- value: backend.id
128
- })),
129
- initial: 0
98
+ initialValue: AVAILABLE_BACKENDS[0]?.id,
99
+ options: AVAILABLE_BACKENDS.map((b) => ({ value: b.id, label: b.id })),
100
+ onCancel: cancel,
130
101
  });
131
102
  }
132
103
 
133
- if (selectedFeaturesFromArgv.length === 0) {
134
- baseQuestions.push({
135
- type: 'multiselect',
136
- name: 'features',
104
+ let features;
105
+ if (selectedFeaturesFromArgv.length > 0) {
106
+ features = selectedFeaturesFromArgv;
107
+ } else {
108
+ const selected = await ui.multiselect({
137
109
  message: t('prompt.features.select'),
138
- instructions: t('prompt.features.instructions'),
139
- choices: getVisibleFeatures({ audience: KASY_AUDIENCE }).map((feature) => ({
140
- title: feature.status === 'internal' ? `${feature.id} [beta]` : feature.id,
110
+ options: getVisibleFeatures({ audience: KASY_AUDIENCE }).map((feature) => ({
141
111
  value: feature.id,
142
- selected: false
112
+ label: feature.status === 'internal' ? `${feature.id} [beta]` : feature.id,
143
113
  })),
144
- min: 0
114
+ initialValues: [],
115
+ required: false,
116
+ onCancel: cancel,
145
117
  });
118
+ features = parseFeatureList(selected);
146
119
  }
147
120
 
148
- const core = await prompts(baseQuestions, getPromptOptions(t));
149
- const backend = backendFromArgv || core.backend;
150
- const features = selectedFeaturesFromArgv.length > 0
151
- ? selectedFeaturesFromArgv
152
- : parseFeatureList(core.features);
153
-
154
121
  if (backend === 'firebase') {
155
- const firebaseAnswers = await prompts(
156
- {
157
- type: 'text',
158
- name: 'firebaseProjectId',
159
- message: t('prompt.firebase.projectId.enter'),
160
- validate: (value) => (value && value.trim() ? true : t('prompt.firebase.projectId.required'))
161
- },
162
- getPromptOptions(t)
163
- );
164
-
165
- return {
166
- ...core,
167
- backend,
168
- features,
169
- ...firebaseAnswers
170
- };
122
+ const firebaseProjectId = await ui.text({
123
+ message: t('prompt.firebase.projectId.enter'),
124
+ validate: (v) => (v && v.trim() ? undefined : t('prompt.firebase.projectId.required')),
125
+ onCancel: cancel,
126
+ });
127
+ return { ...core, backend, features, firebaseProjectId };
171
128
  }
172
129
 
173
130
  if (backend === 'supabase') {
174
- const supabaseAnswers = await prompts(
175
- [
176
- {
177
- type: 'text',
178
- name: 'supabaseUrl',
179
- message: t('prompt.supabase.url.enter'),
180
- validate: (value) => (value && value.trim() ? true : t('prompt.supabase.url.required'))
181
- },
182
- {
183
- type: 'text',
184
- name: 'supabaseAnonKey',
185
- message: t('prompt.supabase.anonKey.enter'),
186
- validate: (value) => (value && value.trim() ? true : t('prompt.supabase.anonKey.required'))
187
- }
188
- ],
189
- getPromptOptions(t)
190
- );
191
-
192
- return {
193
- ...core,
194
- backend,
195
- features,
196
- ...supabaseAnswers
197
- };
131
+ const supabaseUrl = await ui.text({
132
+ message: t('prompt.supabase.url.enter'),
133
+ validate: (v) => (v && v.trim() ? undefined : t('prompt.supabase.url.required')),
134
+ onCancel: cancel,
135
+ });
136
+ const supabaseAnonKey = await ui.text({
137
+ message: t('prompt.supabase.anonKey.enter'),
138
+ validate: (v) => (v && v.trim() ? undefined : t('prompt.supabase.anonKey.required')),
139
+ onCancel: cancel,
140
+ });
141
+ return { ...core, backend, features, supabaseUrl, supabaseAnonKey };
198
142
  }
199
143
 
200
- return {
201
- ...core,
202
- backend,
203
- features
204
- };
144
+ return { ...core, backend, features };
205
145
  }
206
146
 
207
147
  module.exports = {
package/lib/utils/ui.js CHANGED
@@ -48,6 +48,21 @@ async function text({ message, placeholder, initialValue, defaultValue, validate
48
48
  return handleCancel(result, onCancel);
49
49
  }
50
50
 
51
+ async function password({ message, mask, validate, onCancel }) {
52
+ const result = await clack.password({
53
+ message,
54
+ mask,
55
+ validate: validate
56
+ ? (value) => {
57
+ const out = validate(value);
58
+ if (out === true || out == null) return undefined;
59
+ return out;
60
+ }
61
+ : undefined,
62
+ });
63
+ return handleCancel(result, onCancel);
64
+ }
65
+
51
66
  async function confirm({ message, initialValue = true, onCancel }) {
52
67
  const result = await clack.confirm({ message, initialValue });
53
68
  return handleCancel(result, onCancel);
@@ -63,13 +78,87 @@ function outro(message) { clack.outro(message); }
63
78
  function note(message, title) { clack.note(message, title); }
64
79
  function cancel(message) { clack.cancel(message); }
65
80
 
81
+ /**
82
+ * Spinner integrated with Clack's vertical line (│).
83
+ * Returns: { start(msg), message(msg), stop(msg, code?) }
84
+ * code: 0 = success (✦), 1 = cancel (■), 2 = error (▲)
85
+ */
66
86
  function spinner() { return clack.spinner(); }
67
87
 
88
+ /**
89
+ * Multi-step spinner: each .next(text) succeeds the previous step
90
+ * with the previous message, then starts a new step with `text`.
91
+ * Mimics the old ora-based makeProgressSpinner() but uses Clack's
92
+ * vertical-line aesthetic so the line stays connected.
93
+ */
94
+ function makeStepper() {
95
+ let current = null;
96
+ let currentMsg = '';
97
+ return {
98
+ next(text) {
99
+ if (current) current.stop(currentMsg);
100
+ current = clack.spinner();
101
+ currentMsg = text;
102
+ current.start(text);
103
+ },
104
+ update(text) {
105
+ if (current) {
106
+ currentMsg = text;
107
+ current.message(text);
108
+ }
109
+ },
110
+ succeed(text) {
111
+ if (current) {
112
+ current.stop(text || currentMsg);
113
+ current = null;
114
+ currentMsg = '';
115
+ }
116
+ },
117
+ fail(text) {
118
+ if (current) {
119
+ // .error() renders the red ▲ icon; .stop() defaults to green ✦ success.
120
+ current.error(text || currentMsg);
121
+ current = null;
122
+ currentMsg = '';
123
+ }
124
+ },
125
+ warn(text) {
126
+ // Clack has no "warn" terminal state — prefix with ⚠ and close as success.
127
+ if (current) {
128
+ current.stop(`⚠ ${text || currentMsg}`);
129
+ current = null;
130
+ currentMsg = '';
131
+ }
132
+ },
133
+ stop() {
134
+ if (current) {
135
+ current.stop(currentMsg);
136
+ current = null;
137
+ currentMsg = '';
138
+ }
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Long-running task with rolling log output (e.g. pub get, build_runner).
145
+ * Shows last N lines while running; collapses to a single ✦ on success.
146
+ * Options: { title, limit, retainLog }
147
+ */
148
+ function taskLog(options) { return clack.taskLog(options); }
149
+
150
+ /**
151
+ * Progress bar for operations with known total steps.
152
+ * Returns: { start(msg), advance(n, msg?), message(msg), stop(msg) }
153
+ */
154
+ function progress(options) { return clack.progress(options); }
155
+
68
156
  const log = clack.log;
69
157
 
70
158
  module.exports = {
71
159
  select,
72
160
  text,
161
+ password,
73
162
  confirm,
74
163
  multiselect,
75
164
  intro,
@@ -77,6 +166,9 @@ module.exports = {
77
166
  note,
78
167
  cancel,
79
168
  spinner,
169
+ makeStepper,
170
+ taskLog,
171
+ progress,
80
172
  log,
81
173
  isCancel: clack.isCancel,
82
174
  };
@@ -4,8 +4,8 @@ const https = require('node:https');
4
4
  const path = require('node:path');
5
5
  const { existsSync, readJsonSync, writeJsonSync, ensureDirSync } = require('fs-extra');
6
6
  const kleur = require('kleur');
7
- const prompts = require('prompts');
8
7
  const pkg = require('../../package.json');
8
+ const ui = require('./ui');
9
9
  const { CONFIG_PATH } = require('./license');
10
10
 
11
11
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 horas
@@ -106,19 +106,20 @@ async function warnIfOutdatedBeforeNew(t) {
106
106
  if (!cache.latestVersion || !isNewer(cache.latestVersion, pkg.version)) return;
107
107
 
108
108
  const hint = t ? t('new.outdated.hint') : 'projetos criados agora não terão as últimas melhorias.';
109
- console.log('');
110
109
  console.log(
110
+ '\n' +
111
111
  kleur.bgYellow().black(' UPDATE ') + ' ' +
112
112
  kleur.bold(`v${cache.latestVersion} disponível`) +
113
- kleur.dim(` — ${hint}`)
113
+ kleur.dim(` — ${hint}`) +
114
+ '\n'
114
115
  );
115
- console.log('');
116
116
 
117
- const { upgrade } = await prompts({
118
- type: 'confirm',
119
- name: 'upgrade',
117
+ // Called BEFORE any ui.intro fires in kasy new, so this prompt runs as a
118
+ // standalone Clack confirm (no rail to break). On cancel we exit cleanly.
119
+ const upgrade = await ui.confirm({
120
120
  message: t ? t('new.outdated.upgradeNow') : 'Atualizar antes de criar? (requer assinatura ativa)',
121
- initial: false,
121
+ initialValue: false,
122
+ onCancel: () => process.exit(0),
122
123
  });
123
124
 
124
125
  if (upgrade) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@clack/prompts": "^1.4.0",
48
+ "boxen": "^8.0.1",
48
49
  "commander": "^12.0.0",
49
50
  "fs-extra": "^11.2.0",
50
51
  "gradient-string": "^1.2.0",