kasy-cli 1.16.0 → 1.18.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.
Files changed (99) hide show
  1. package/bin/kasy.js +16 -2
  2. package/lib/commands/add.js +52 -19
  3. package/lib/commands/configure.js +548 -0
  4. package/lib/commands/deploy.js +4 -4
  5. package/lib/commands/doctor.js +54 -6
  6. package/lib/commands/favicon.js +4 -4
  7. package/lib/commands/icon.js +5 -5
  8. package/lib/commands/new.js +404 -213
  9. package/lib/commands/remove.js +14 -3
  10. package/lib/commands/run.js +208 -6
  11. package/lib/commands/splash.js +5 -5
  12. package/lib/commands/update.js +9 -9
  13. package/lib/scaffold/CHANGELOG.json +23 -0
  14. package/lib/scaffold/backends/api/patch/README.md +3 -2
  15. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +44 -5
  17. package/lib/scaffold/backends/supabase/patch/README.md +3 -2
  18. package/lib/scaffold/generate.js +24 -8
  19. package/lib/scaffold/shared/generator-utils.js +52 -8
  20. package/lib/scaffold/shared/post-build.js +113 -31
  21. package/lib/scaffold/shared/template-strings.js +6 -0
  22. package/lib/utils/brand.js +16 -12
  23. package/lib/utils/flutter-run.js +139 -11
  24. package/lib/utils/i18n/messages-en.js +85 -7
  25. package/lib/utils/i18n/messages-es.js +85 -7
  26. package/lib/utils/i18n/messages-pt.js +86 -8
  27. package/lib/utils/ui.js +79 -4
  28. package/package.json +1 -1
  29. package/templates/firebase/README.en.md +18 -8
  30. package/templates/firebase/README.es.md +18 -8
  31. package/templates/firebase/README.md +18 -8
  32. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +68 -45
  33. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidgetReceiver.kt +37 -0
  34. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/OpenAppAction.kt +26 -0
  35. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
  36. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
  37. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
  38. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
  41. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  42. package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  43. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  44. package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  45. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  46. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
  47. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  56. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  57. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  58. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  59. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  60. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  61. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
  62. package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
  63. package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
  64. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  65. package/templates/firebase/assets/images/splash_logo_light.png +0 -0
  66. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  67. package/templates/firebase/docs/revenuecat-setup.es.md +28 -8
  68. package/templates/firebase/docs/revenuecat-setup.pt.md +28 -8
  69. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
  70. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
  76. package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
  77. package/templates/firebase/lib/components/components.dart +1 -0
  78. package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
  79. package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
  80. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  81. package/templates/firebase/lib/components/kasy_date_picker.dart +834 -0
  82. package/templates/firebase/lib/components/kasy_tabs.dart +145 -61
  83. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +45 -53
  84. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +565 -77
  85. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
  86. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  87. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  88. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
  89. package/templates/firebase/lib/router.dart +15 -1
  90. package/templates/firebase/pubspec.yaml +1 -1
  91. package/templates/firebase/web/index.html +9 -0
  92. package/templates/firebase/web/splash/img/dark-1x.png +0 -0
  93. package/templates/firebase/web/splash/img/dark-2x.png +0 -0
  94. package/templates/firebase/web/splash/img/dark-3x.png +0 -0
  95. package/templates/firebase/web/splash/img/dark-4x.png +0 -0
  96. package/templates/firebase/web/splash/img/light-1x.png +0 -0
  97. package/templates/firebase/web/splash/img/light-2x.png +0 -0
  98. package/templates/firebase/web/splash/img/light-3x.png +0 -0
  99. package/templates/firebase/web/splash/img/light-4x.png +0 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * `kasy configure` — interactive credential wizard, features-aware.
3
+ *
4
+ * Reads kit_setup.json + features.dart to know which features the project
5
+ * actually uses, then asks only for the credentials those features need.
6
+ * Idempotent: skips what's already filled, lets the user skip any field with
7
+ * Enter, and persists to the correct destination (.env, functions/.env, or
8
+ * Firebase Secret Manager).
9
+ *
10
+ * Out of scope (handled separately): Facebook (Info.plist + strings.xml),
11
+ * Apple Sign-In (Firebase Console only), APNs key (Apple Developer Portal).
12
+ */
13
+
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+ const { promisify } = require('node:util');
17
+ const execAsync = promisify(require('node:child_process').exec);
18
+ const fs = require('fs-extra');
19
+ const kleur = require('kleur');
20
+ const ui = require('../utils/ui');
21
+ const { printCompactHeader } = require('../utils/brand');
22
+ const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
23
+
24
+ const PLACEHOLDER_REGEX = /^(YOUR_|TODO|CHANGE_ME|<.*>$)/i;
25
+
26
+ // ── Field catalog ─────────────────────────────────────────────────────────
27
+ // Grouped by feature so we only ask for credentials that the project actually
28
+ // uses. Each field declares its `destination`:
29
+ // - 'env' → project root .env (client-side)
30
+ // - 'functionsEnv' → functions/.env (server-side non-secret)
31
+ // - 'firebaseSecret' → firebase functions:secrets:set (server-side secret)
32
+
33
+ const FEATURE_BLOCKS = [
34
+ {
35
+ id: 'app_store',
36
+ titleKey: 'configure.section.appStore',
37
+ isActive: () => true,
38
+ fields: [
39
+ {
40
+ key: 'APP_STORE_ID',
41
+ destination: 'env',
42
+ label: 'App Store ID (numeric)',
43
+ hint: 'App Store Connect → App → General → Apple ID',
44
+ validate: (v) => (/^\d+$/.test(v) ? undefined : 'Numbers only'),
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ id: 'sentry',
50
+ titleKey: 'configure.section.sentry',
51
+ isActive: (kit) => !!kit.useSentry,
52
+ fields: [
53
+ {
54
+ key: 'SENTRY_DSN',
55
+ destination: 'env',
56
+ label: 'Sentry DSN',
57
+ hint: 'Sentry → Project → Settings → Client Keys (DSN)',
58
+ validate: (v) => (/^https?:\/\/[^@\s]+@[^/\s]+\/\d+$/.test(v) ? undefined : 'Format: https://xxx@yyy/123'),
59
+ },
60
+ ],
61
+ },
62
+ {
63
+ id: 'analytics',
64
+ titleKey: 'configure.section.mixpanel',
65
+ isActive: (kit) => kit.analyticsProvider === 'mixpanel',
66
+ fields: [
67
+ {
68
+ key: 'MIXPANEL_TOKEN',
69
+ destination: 'env',
70
+ label: 'Mixpanel Project Token',
71
+ hint: 'Mixpanel → Project Settings → Project Token',
72
+ validate: () => undefined,
73
+ },
74
+ ],
75
+ },
76
+ {
77
+ id: 'revenuecat',
78
+ titleKey: 'configure.section.revenuecat',
79
+ isActive: (kit) => !!kit.subscriptionModule,
80
+ fields: [
81
+ {
82
+ key: 'RC_TEST_KEY',
83
+ destination: 'env',
84
+ label: 'RevenueCat — Test Key (test_…)',
85
+ hint: 'Used on simulator/emulator',
86
+ validate: (v) => (/^test_/.test(v) ? undefined : 'Must start with `test_`'),
87
+ },
88
+ {
89
+ key: 'RC_IOS_PROD_KEY',
90
+ destination: 'env',
91
+ label: 'RevenueCat — iOS prod (appl_…)',
92
+ hint: 'Used on physical iPhone',
93
+ validate: (v) => (/^appl_/.test(v) ? undefined : 'Must start with `appl_`'),
94
+ },
95
+ {
96
+ key: 'RC_ANDROID_PROD_KEY',
97
+ destination: 'env',
98
+ label: 'RevenueCat — Android prod (goog_…)',
99
+ hint: 'Used on physical Android',
100
+ validate: (v) => (/^goog_/.test(v) ? undefined : 'Must start with `goog_`'),
101
+ },
102
+ {
103
+ key: 'RC_WEB_API_KEY',
104
+ destination: 'env',
105
+ label: 'RevenueCat — Web Billing (rcb_…)',
106
+ hint: 'Only if app has Web/PWA',
107
+ onlyIf: (kit) => !!kit.revenuecatWeb,
108
+ validate: (v) => (/^rcb_/.test(v) ? undefined : 'Must start with `rcb_`'),
109
+ },
110
+ {
111
+ key: 'RC_WEBHOOK_KEY',
112
+ destination: 'firebaseSecret',
113
+ label: 'RevenueCat — Webhook Authorization header',
114
+ hint: 'Authorization header secret used by your RevenueCat webhook',
115
+ onlyIf: (kit) => kit.backendProvider === 'firebase',
116
+ validate: () => undefined,
117
+ },
118
+ {
119
+ key: 'META_ACCESS_TOKEN',
120
+ destination: 'firebaseSecret',
121
+ label: 'Meta Conversions API — Access Token',
122
+ hint: 'Meta Business → System User → Generate Token',
123
+ onlyIf: (kit) => kit.backendProvider === 'firebase',
124
+ validate: () => undefined,
125
+ },
126
+ {
127
+ key: 'META_DATASET_ID',
128
+ destination: 'firebaseSecret',
129
+ label: 'Meta — Dataset (Pixel) ID',
130
+ hint: 'Meta Events Manager → Data Source ID',
131
+ onlyIf: (kit) => kit.backendProvider === 'firebase',
132
+ validate: (v) => (/^\d+$/.test(v) ? undefined : 'Numbers only'),
133
+ },
134
+ ],
135
+ },
136
+ {
137
+ id: 'facebook',
138
+ titleKey: 'configure.section.facebook',
139
+ isActive: (kit) => !!kit.withFacebookPixel,
140
+ fields: [
141
+ {
142
+ key: 'FB_APP_ID',
143
+ destination: 'facebook',
144
+ label: 'Facebook App ID (numeric)',
145
+ hint: 'Meta for Developers → My Apps → Settings → Basic → App ID',
146
+ validate: (v) => (/^\d+$/.test(v) ? undefined : 'Numbers only'),
147
+ },
148
+ {
149
+ key: 'FB_CLIENT_TOKEN',
150
+ destination: 'facebook',
151
+ label: 'Facebook Client Token (32 hex)',
152
+ hint: 'Meta → Settings → Advanced → Security → Client Token',
153
+ validate: (v) => (/^[a-fA-F0-9]{32}$/.test(v) ? undefined : 'Should be 32 hex characters'),
154
+ },
155
+ ],
156
+ },
157
+ {
158
+ id: 'llm_chat',
159
+ titleKey: 'configure.section.llmChat',
160
+ isActive: (kit, ctx) => !!ctx.hasLlmChat,
161
+ fields: [
162
+ {
163
+ key: 'LLM_PROVIDER',
164
+ destination: 'functionsEnv',
165
+ label: 'LLM Provider (openai or gemini)',
166
+ validate: (v) => (/^(openai|gemini)$/.test(v) ? undefined : 'Use openai or gemini'),
167
+ },
168
+ {
169
+ key: 'LLM_SYSTEM_PROMPT',
170
+ destination: 'functionsEnv',
171
+ label: 'LLM system prompt (instructions)',
172
+ validate: () => undefined,
173
+ },
174
+ {
175
+ key: 'LLM_API_KEY',
176
+ destination: 'firebaseSecret',
177
+ label: 'LLM provider API key',
178
+ hint: 'OpenAI dashboard → API keys, or Google AI Studio',
179
+ onlyIf: (kit) => kit.backendProvider === 'firebase',
180
+ validate: () => undefined,
181
+ },
182
+ ],
183
+ },
184
+ ];
185
+
186
+ // ── Helpers ───────────────────────────────────────────────────────────────
187
+
188
+ function parseEnv(content) {
189
+ const map = new Map();
190
+ for (const line of content.split('\n')) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed || trimmed.startsWith('#')) continue;
193
+ const eq = trimmed.indexOf('=');
194
+ if (eq === -1) continue;
195
+ map.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim());
196
+ }
197
+ return map;
198
+ }
199
+
200
+ function isFilled(value) {
201
+ if (!value) return false;
202
+ if (PLACEHOLDER_REGEX.test(value)) return false;
203
+ return true;
204
+ }
205
+
206
+ async function readEnvFile(envPath) {
207
+ if (!(await fs.pathExists(envPath))) return new Map();
208
+ return parseEnv(await fs.readFile(envPath, 'utf8'));
209
+ }
210
+
211
+ async function updateEnvFile(envPath, updates) {
212
+ let original = '';
213
+ if (await fs.pathExists(envPath)) original = await fs.readFile(envPath, 'utf8');
214
+ const lines = original.split('\n');
215
+ const handled = new Set();
216
+ const updated = lines.map((line) => {
217
+ const trimmed = line.trim();
218
+ if (!trimmed || trimmed.startsWith('#')) return line;
219
+ const eq = trimmed.indexOf('=');
220
+ if (eq === -1) return line;
221
+ const key = trimmed.slice(0, eq).trim();
222
+ if (updates.has(key)) {
223
+ handled.add(key);
224
+ return `${key}=${updates.get(key)}`;
225
+ }
226
+ return line;
227
+ });
228
+ const appended = [];
229
+ for (const [key, value] of updates) if (!handled.has(key)) appended.push(`${key}=${value}`);
230
+ if (appended.length > 0) {
231
+ if (updated.length > 0 && updated[updated.length - 1].trim() !== '') updated.push('');
232
+ updated.push('# Added by `kasy configure`');
233
+ updated.push(...appended);
234
+ }
235
+ await fs.outputFile(envPath, updated.join('\n'), 'utf8');
236
+ }
237
+
238
+ // ── Facebook patches ──────────────────────────────────────────────────────
239
+ // Facebook needs the same values in two build-time files: iOS Info.plist and
240
+ // Android strings.xml. Both ship with placeholder zeros; we replace them in
241
+ // place so the user only types each value once.
242
+
243
+ function readFacebookCurrent(content, kind) {
244
+ if (kind === 'plist') {
245
+ const appId = content.match(/<key>FacebookAppID<\/key>\s*<string>([^<]*)<\/string>/);
246
+ const token = content.match(/<key>FacebookClientToken<\/key>\s*<string>([^<]*)<\/string>/);
247
+ return { appId: (appId?.[1] || '').trim(), token: (token?.[1] || '').trim() };
248
+ }
249
+ // strings.xml
250
+ const appId = content.match(/<string name="facebook_app_id"[^>]*>([^<]*)<\/string>/);
251
+ const token = content.match(/<string name="facebook_client_token"[^>]*>([^<]*)<\/string>/);
252
+ return { appId: (appId?.[1] || '').trim(), token: (token?.[1] || '').trim() };
253
+ }
254
+
255
+ function isFacebookPlaceholder(value) {
256
+ return !value || /^0+$/.test(value);
257
+ }
258
+
259
+ async function readFacebookState(projectDir) {
260
+ const plistPath = path.join(projectDir, 'ios', 'Runner', 'Info.plist');
261
+ const stringsPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml');
262
+ const state = { appId: '', token: '', plistExists: false, stringsExists: false };
263
+ if (await fs.pathExists(plistPath)) {
264
+ state.plistExists = true;
265
+ const plistRead = readFacebookCurrent(await fs.readFile(plistPath, 'utf8'), 'plist');
266
+ if (!isFacebookPlaceholder(plistRead.appId)) state.appId = plistRead.appId;
267
+ if (!isFacebookPlaceholder(plistRead.token)) state.token = plistRead.token;
268
+ }
269
+ if (await fs.pathExists(stringsPath)) {
270
+ state.stringsExists = true;
271
+ const stringsRead = readFacebookCurrent(await fs.readFile(stringsPath, 'utf8'), 'strings');
272
+ // Prefer plist value when both exist; only fall back if plist was placeholder.
273
+ if (!state.appId && !isFacebookPlaceholder(stringsRead.appId)) state.appId = stringsRead.appId;
274
+ if (!state.token && !isFacebookPlaceholder(stringsRead.token)) state.token = stringsRead.token;
275
+ }
276
+ return state;
277
+ }
278
+
279
+ async function writeFacebookCredentials(projectDir, appId, clientToken) {
280
+ const plistPath = path.join(projectDir, 'ios', 'Runner', 'Info.plist');
281
+ const stringsPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml');
282
+ const results = { plist: 'skipped', strings: 'skipped' };
283
+
284
+ if (appId && await fs.pathExists(plistPath)) {
285
+ let plist = await fs.readFile(plistPath, 'utf8');
286
+ plist = plist.replace(
287
+ /(<key>FacebookAppID<\/key>\s*<string>)[^<]*(<\/string>)/,
288
+ `$1${appId}$2`
289
+ );
290
+ if (clientToken) {
291
+ plist = plist.replace(
292
+ /(<key>FacebookClientToken<\/key>\s*<string>)[^<]*(<\/string>)/,
293
+ `$1${clientToken}$2`
294
+ );
295
+ }
296
+ // CFBundleURLSchemes — replace `fb<zeros>` with `fb<appId>` so deep links work.
297
+ plist = plist.replace(/<string>fb0+<\/string>/, `<string>fb${appId}</string>`);
298
+ await fs.outputFile(plistPath, plist, 'utf8');
299
+ results.plist = 'ok';
300
+ } else if (appId) {
301
+ results.plist = 'missing_file';
302
+ }
303
+
304
+ if (appId && await fs.pathExists(stringsPath)) {
305
+ let xml = await fs.readFile(stringsPath, 'utf8');
306
+ xml = xml.replace(
307
+ /(<string name="facebook_app_id"[^>]*>)[^<]*(<\/string>)/,
308
+ `$1${appId}$2`
309
+ );
310
+ if (clientToken) {
311
+ xml = xml.replace(
312
+ /(<string name="facebook_client_token"[^>]*>)[^<]*(<\/string>)/,
313
+ `$1${clientToken}$2`
314
+ );
315
+ }
316
+ // fb_login_protocol_scheme — set if present.
317
+ xml = xml.replace(
318
+ /(<string name="fb_login_protocol_scheme"[^>]*>)[^<]*(<\/string>)/,
319
+ `$1fb${appId}$2`
320
+ );
321
+ await fs.outputFile(stringsPath, xml, 'utf8');
322
+ results.strings = 'ok';
323
+ } else if (appId) {
324
+ results.strings = 'missing_file';
325
+ }
326
+
327
+ return results;
328
+ }
329
+
330
+ async function setFirebaseSecret(projectDir, key, value, t) {
331
+ // Use a tmp file to avoid newline injection / shell escaping issues.
332
+ const tmpFile = path.join(os.tmpdir(), `kasy_secret_${key}_${Date.now()}.tmp`);
333
+ await fs.outputFile(tmpFile, value, 'utf8');
334
+ try {
335
+ await execAsync(
336
+ `firebase functions:secrets:set ${key} --data-file="${tmpFile}"`,
337
+ { cwd: projectDir }
338
+ );
339
+ return { ok: true };
340
+ } catch (err) {
341
+ return { ok: false, error: err.stderr || err.message };
342
+ } finally {
343
+ await fs.remove(tmpFile).catch(() => {});
344
+ }
345
+ }
346
+
347
+ async function detectFeatureContext(projectDir) {
348
+ const ctx = { hasLlmChat: false };
349
+ const featuresPath = path.join(projectDir, 'lib', 'core', 'config', 'features.dart');
350
+ if (await fs.pathExists(featuresPath)) {
351
+ const content = await fs.readFile(featuresPath, 'utf8');
352
+ if (/const bool withLlmChat\s*=\s*true/.test(content)) ctx.hasLlmChat = true;
353
+ }
354
+ return ctx;
355
+ }
356
+
357
+ // ── Main ──────────────────────────────────────────────────────────────────
358
+
359
+ async function runConfigure(options = {}) {
360
+ const language = options.language || detectDefaultLanguage();
361
+ const t = createTranslator(language);
362
+ const projectDir = path.resolve(options.directory || '.');
363
+
364
+ printCompactHeader(t);
365
+ ui.intro(kleur.bold(t('configure.title')));
366
+
367
+ const kitSetupPath = path.join(projectDir, 'kit_setup.json');
368
+ if (!(await fs.pathExists(kitSetupPath))) {
369
+ ui.log.error(t('configure.notKasyProject'));
370
+ ui.cancel(t('configure.notKasyProject'));
371
+ process.exit(1);
372
+ }
373
+ const kit = await fs.readJson(kitSetupPath);
374
+ const ctx = await detectFeatureContext(projectDir);
375
+
376
+ // Read current state of all sources
377
+ const envPath = path.join(projectDir, '.env');
378
+ const fnEnvPath = path.join(projectDir, 'functions', '.env');
379
+ const envState = await readEnvFile(envPath);
380
+ const fnEnvState = await readEnvFile(fnEnvPath);
381
+
382
+ // Firebase Secrets: we can't reliably introspect existing values without
383
+ // running `firebase functions:secrets:access` per key (slow + may prompt).
384
+ // Treat them as "always ask if not in this session". Status display will say
385
+ // "set on demand" so the user knows nothing is destructive.
386
+
387
+ const activeBlocks = FEATURE_BLOCKS.filter((b) => b.isActive(kit, ctx));
388
+ if (activeBlocks.length === 0) {
389
+ ui.log.success(t('configure.noOptionalFeatures'));
390
+ ui.outro(t('configure.outroNoChange'));
391
+ return;
392
+ }
393
+
394
+ // Facebook reads from both build files; cache once if the block is active.
395
+ const facebookState = activeBlocks.some((b) => b.id === 'facebook')
396
+ ? await readFacebookState(projectDir)
397
+ : null;
398
+
399
+ // Pre-scan: compute filled vs pending across all fields, grouped by block.
400
+ const plan = [];
401
+ for (const block of activeBlocks) {
402
+ const items = [];
403
+ for (const field of block.fields) {
404
+ if (field.onlyIf && !field.onlyIf(kit, ctx)) continue;
405
+ let currentValue;
406
+ if (field.destination === 'env') currentValue = envState.get(field.key);
407
+ else if (field.destination === 'functionsEnv') currentValue = fnEnvState.get(field.key);
408
+ else if (field.destination === 'firebaseSecret') currentValue = undefined;
409
+ else if (field.destination === 'facebook') {
410
+ currentValue = field.key === 'FB_APP_ID' ? facebookState?.appId : facebookState?.token;
411
+ }
412
+ items.push({ field, filled: isFilled(currentValue) });
413
+ }
414
+ if (items.length > 0) plan.push({ block, items });
415
+ }
416
+
417
+ // Status report
418
+ let totalFilled = 0;
419
+ let totalPending = 0;
420
+ for (const { items } of plan) {
421
+ for (const item of items) {
422
+ if (item.filled) totalFilled++;
423
+ else totalPending++;
424
+ }
425
+ }
426
+
427
+ ui.log.step(kleur.bold(t('configure.statusSummary', { filled: totalFilled, pending: totalPending })));
428
+ if (totalPending === 0) {
429
+ ui.log.success(t('configure.allDone'));
430
+ ui.outro(t('configure.allDone'));
431
+ return;
432
+ }
433
+ ui.log.message(kleur.dim(t('configure.skipHint')));
434
+
435
+ const envUpdates = new Map();
436
+ const fnEnvUpdates = new Map();
437
+ const secretUpdates = new Map();
438
+ const facebookUpdates = {};
439
+ let answeredCount = 0;
440
+ let skippedCount = 0;
441
+
442
+ for (const { block, items } of plan) {
443
+ const pending = items.filter((it) => !it.filled);
444
+ if (pending.length === 0) {
445
+ // All filled — show a compact success line per block.
446
+ ui.log.success(`${t(block.titleKey)} ${kleur.dim('— ' + t('configure.alreadyDone'))}`);
447
+ continue;
448
+ }
449
+
450
+ ui.log.step(kleur.bold(t(block.titleKey)));
451
+ for (const { field, filled } of items) {
452
+ if (filled) {
453
+ ui.log.message(kleur.dim(` ✓ ${field.label} ${kleur.dim('— ' + t('configure.alreadyFilledShort'))}`));
454
+ continue;
455
+ }
456
+ const dst = field.destination === 'firebaseSecret' ? ' [Firebase Secret]'
457
+ : field.destination === 'functionsEnv' ? ' [functions/.env]'
458
+ : field.destination === 'facebook' ? ' [Info.plist + strings.xml]'
459
+ : '';
460
+ const value = await ui.text({
461
+ message: `${field.label}${kleur.dim(dst)}`,
462
+ placeholder: t('configure.skipPlaceholder'),
463
+ validate: (v) => {
464
+ const s = (v || '').trim();
465
+ if (!s) return undefined;
466
+ return field.validate(s);
467
+ },
468
+ onCancel: () => {
469
+ ui.cancel(t('configure.aborted'));
470
+ process.exit(0);
471
+ },
472
+ });
473
+ if (field.hint) ui.log.message(kleur.dim(` ${field.hint}`));
474
+ const trimmed = (value || '').trim();
475
+ if (!trimmed) {
476
+ skippedCount++;
477
+ continue;
478
+ }
479
+ if (field.destination === 'env') envUpdates.set(field.key, trimmed);
480
+ else if (field.destination === 'functionsEnv') fnEnvUpdates.set(field.key, trimmed);
481
+ else if (field.destination === 'firebaseSecret') secretUpdates.set(field.key, trimmed);
482
+ else if (field.destination === 'facebook') {
483
+ if (field.key === 'FB_APP_ID') facebookUpdates.appId = trimmed;
484
+ else if (field.key === 'FB_CLIENT_TOKEN') facebookUpdates.token = trimmed;
485
+ }
486
+ answeredCount++;
487
+ }
488
+ }
489
+
490
+ // Persist
491
+ if (envUpdates.size > 0) {
492
+ await updateEnvFile(envPath, envUpdates);
493
+ ui.log.success(t('configure.savedEnv', { count: envUpdates.size }));
494
+ }
495
+ if (fnEnvUpdates.size > 0) {
496
+ await fs.ensureDir(path.dirname(fnEnvPath));
497
+ await updateEnvFile(fnEnvPath, fnEnvUpdates);
498
+ ui.log.success(t('configure.savedFnEnv', { count: fnEnvUpdates.size }));
499
+ }
500
+ if (facebookUpdates.appId || facebookUpdates.token) {
501
+ // If only one of the two was provided this run, fall back to the existing
502
+ // value (or leave the placeholder if there's none) for the other.
503
+ const appId = facebookUpdates.appId || facebookState?.appId || '';
504
+ const token = facebookUpdates.token || facebookState?.token || '';
505
+ if (appId) {
506
+ const fbResult = await writeFacebookCredentials(projectDir, appId, token);
507
+ const wroteSome = fbResult.plist === 'ok' || fbResult.strings === 'ok';
508
+ if (wroteSome) ui.log.success(t('configure.savedFacebook'));
509
+ if (fbResult.plist === 'missing_file') ui.log.warn(t('configure.facebookPlistMissing'));
510
+ if (fbResult.strings === 'missing_file') ui.log.warn(t('configure.facebookStringsMissing'));
511
+ } else {
512
+ ui.log.warn(t('configure.facebookNeedsAppId'));
513
+ }
514
+ }
515
+
516
+ if (secretUpdates.size > 0) {
517
+ const secretSpinner = ui.spinner();
518
+ secretSpinner.start(t('configure.settingSecrets', { count: secretUpdates.size }));
519
+ let okCount = 0;
520
+ const failures = [];
521
+ for (const [key, value] of secretUpdates) {
522
+ const result = await setFirebaseSecret(projectDir, key, value, t);
523
+ if (result.ok) okCount++;
524
+ else failures.push({ key, error: result.error });
525
+ }
526
+ secretSpinner.stop(t('configure.settingSecrets', { count: secretUpdates.size }));
527
+ if (okCount > 0) ui.log.success(t('configure.savedSecrets', { count: okCount }));
528
+ for (const f of failures) {
529
+ ui.log.warn(`${t('configure.secretFailed', { key: f.key })}\n${kleur.dim((f.error || '').slice(0, 200))}`);
530
+ }
531
+ }
532
+
533
+ const stillPending = totalPending - answeredCount;
534
+ if (stillPending > 0) {
535
+ ui.log.message(kleur.dim(`– ${t('configure.stillPending', { count: stillPending })}`));
536
+ ui.log.message(kleur.dim(` ${t('configure.runAgainHint')}`));
537
+ } else if (answeredCount > 0) {
538
+ ui.log.success(t('configure.allDone'));
539
+ } else if (skippedCount > 0) {
540
+ ui.log.message(kleur.dim(`– ${t('configure.allSkipped')}`));
541
+ }
542
+
543
+ ui.outro(answeredCount > 0
544
+ ? t('configure.outroSaved', { count: answeredCount })
545
+ : t('configure.outroNoChange'));
546
+ }
547
+
548
+ module.exports = { runConfigure };
@@ -17,7 +17,7 @@ const path = require('node:path');
17
17
  const fs = require('fs-extra');
18
18
  const kleur = require('kleur');
19
19
  const ui = require('../utils/ui');
20
- const { printCompactHeader } = require('../utils/brand');
20
+ const { printCompactHeader, paintLime } = require('../utils/brand');
21
21
 
22
22
  const { createTranslator } = require('../utils/i18n');
23
23
  const { getStoredLanguage } = require('../utils/license');
@@ -122,7 +122,7 @@ async function deployFirebase(projectDir, options, tr) {
122
122
  }
123
123
  }
124
124
 
125
- const spinner = ui.timedSpinner();
125
+ const spinner = ui.timedSpinner({ color: paintLime });
126
126
  spinner.start(tr('deploy.firebase.spin'));
127
127
  let steps;
128
128
  try {
@@ -173,7 +173,7 @@ async function deploySupabase(projectDir, tr) {
173
173
  const firebaseProjectId = await readFirebaseProjectId(projectDir);
174
174
 
175
175
  if (firebaseProjectId) {
176
- const fcmSpinner = ui.timedSpinner();
176
+ const fcmSpinner = ui.timedSpinner({ color: paintLime });
177
177
  fcmSpinner.start(tr('deploy.supabase.sakSpin'));
178
178
  const fcmResult = await createFcmServiceAccountKey(firebaseProjectId);
179
179
  fcmSpinner.stop(tr('deploy.supabase.sakSpinDone'));
@@ -194,7 +194,7 @@ async function deploySupabase(projectDir, tr) {
194
194
  }
195
195
 
196
196
  // ── 3. Deploy edge functions ────────────────────────────────────────────
197
- const fnSpinner = ui.timedSpinner();
197
+ const fnSpinner = ui.timedSpinner({ color: paintLime });
198
198
  fnSpinner.start(tr('deploy.supabase.fnSpin'));
199
199
  const fnResult = await deployFunctions(projectDir);
200
200
  fnSpinner.stop(tr('deploy.supabase.fnSpinDone'));
@@ -18,6 +18,7 @@ const {
18
18
  } = require('../utils/apple-release');
19
19
  const { validateCodemagicSetup, codemagicReleaseDocPath } = require('../utils/codemagic-release');
20
20
  const { validateGoogleIosUrlScheme, validateAppleSignInEntitlement, validateFacebookInfoPlist, validateFacebookAndroidStrings, validateRevenueCat } = require('../scaffold/shared/post-build');
21
+ const { listBillingAccounts, checkGcloudAuth } = require('../scaffold/backends/firebase/setup-from-scratch');
21
22
  const { printCompactHeader } = require('../utils/brand');
22
23
 
23
24
  function collectOptionalBackendChecks() {
@@ -175,14 +176,45 @@ async function runRevenueCatChecks(projectDir, t, config) {
175
176
  const rc = await validateRevenueCat(projectDir, config);
176
177
  if (rc.skipped) return;
177
178
 
178
- if (!rc.iosEmpty && !rc.androidEmpty) {
179
- ui.log.success(t('doctor.revenuecat.keysOk'));
179
+ if (rc.legacy) {
180
+ // Project predates the test/prod split — show the old summary.
181
+ if (!rc.iosEmpty && !rc.androidEmpty) {
182
+ ui.log.success(t('doctor.revenuecat.keysOk'));
183
+ } else {
184
+ ui.log.warn(t('doctor.revenuecat.keysEmpty'));
185
+ }
186
+ if (rc.bothTest) {
187
+ ui.log.warn(t('doctor.revenuecat.keysTest'));
188
+ }
180
189
  } else {
181
- ui.log.warn(t('doctor.revenuecat.keysEmpty'));
182
- }
190
+ // New 3-key scheme. One line per key with granular status.
191
+ if (rc.testOk) {
192
+ ui.log.success(t('doctor.revenuecat.testKeyOk'));
193
+ } else if (rc.testBadPrefix) {
194
+ ui.log.warn(t('doctor.revenuecat.prefixMismatch', { key: 'RC_TEST_KEY', expected: 'test_' }));
195
+ } else {
196
+ ui.log.warn(t('doctor.revenuecat.testKeyMissing'));
197
+ }
198
+
199
+ if (rc.iosProdOk) {
200
+ ui.log.success(t('doctor.revenuecat.iosProdOk'));
201
+ } else if (rc.iosProdBadPrefix) {
202
+ ui.log.warn(t('doctor.revenuecat.prefixMismatch', { key: 'RC_IOS_PROD_KEY', expected: 'appl_' }));
203
+ } else {
204
+ ui.log.message(kleur.dim(`– ${t('doctor.revenuecat.iosProdMissing')}`));
205
+ }
183
206
 
184
- if (rc.bothTest) {
185
- ui.log.warn(t('doctor.revenuecat.keysTest'));
207
+ if (rc.androidProdOk) {
208
+ ui.log.success(t('doctor.revenuecat.androidProdOk'));
209
+ } else if (rc.androidProdBadPrefix) {
210
+ ui.log.warn(t('doctor.revenuecat.prefixMismatch', { key: 'RC_ANDROID_PROD_KEY', expected: 'goog_' }));
211
+ } else {
212
+ ui.log.message(kleur.dim(`– ${t('doctor.revenuecat.androidProdMissing')}`));
213
+ }
214
+
215
+ if (rc.onlyTest) {
216
+ ui.log.warn(t('doctor.revenuecat.keysTest'));
217
+ }
186
218
  }
187
219
 
188
220
  if (rc.webhookUrl) {
@@ -217,6 +249,22 @@ async function runDoctor(options = {}) {
217
249
  });
218
250
  }
219
251
 
252
+ // ── Google Cloud billing account (Firebase Blaze) ─────────────────────
253
+ // Only meaningful when gcloud is authenticated; otherwise users already saw
254
+ // a gcloud check fail above and this would just repeat the noise.
255
+ const gcloudAuth = await checkGcloudAuth();
256
+ if (gcloudAuth.ok) {
257
+ ui.log.step(kleur.bold(t('doctor.gcpBilling.title')));
258
+ const billing = await listBillingAccounts();
259
+ if (billing.ok && billing.accounts?.length > 0) {
260
+ const sample = billing.accounts.slice(0, 3).map((a) => `${a.name || a.id} (${a.id})`).join(', ');
261
+ ui.log.success(`${t('doctor.gcpBilling.found', { count: billing.accounts.length })} ${kleur.dim(sample)}`);
262
+ } else {
263
+ ui.log.warn(t('doctor.gcpBilling.missing'));
264
+ ui.log.message(kleur.cyan('https://console.cloud.google.com/billing/create'));
265
+ }
266
+ }
267
+
220
268
  if (hasRequiredFailures(baseResults)) {
221
269
  throw new Error(t('doctor.requiredMissing'));
222
270
  }