kasy-cli 1.17.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.
- package/bin/kasy.js +15 -2
- package/lib/commands/add.js +7 -7
- package/lib/commands/configure.js +548 -0
- package/lib/commands/deploy.js +4 -4
- package/lib/commands/doctor.js +17 -0
- package/lib/commands/favicon.js +4 -4
- package/lib/commands/icon.js +5 -5
- package/lib/commands/new.js +403 -238
- package/lib/commands/run.js +1 -1
- package/lib/commands/splash.js +5 -5
- package/lib/commands/update.js +9 -9
- package/lib/scaffold/CHANGELOG.json +14 -0
- package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +44 -5
- package/lib/scaffold/generate.js +24 -8
- package/lib/scaffold/shared/post-build.js +8 -0
- package/lib/utils/brand.js +16 -12
- package/lib/utils/flutter-run.js +139 -11
- package/lib/utils/i18n/messages-en.js +58 -5
- package/lib/utils/i18n/messages-es.js +58 -5
- package/lib/utils/i18n/messages-pt.js +59 -6
- package/lib/utils/ui.js +79 -4
- package/package.json +1 -1
- package/templates/firebase/README.en.md +1 -1
- package/templates/firebase/README.es.md +1 -1
- package/templates/firebase/README.md +1 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +0 -15
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +65 -26
- package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
- package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
- package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
- package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
- package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
- package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
- package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
- package/templates/firebase/assets/images/splash_logo_light.png +0 -0
- package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
- package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
- package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
- package/templates/firebase/lib/components/components.dart +1 -0
- package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
- package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
- package/templates/firebase/lib/components/kasy_button.dart +8 -8
- package/templates/firebase/lib/components/kasy_date_picker.dart +834 -0
- package/templates/firebase/lib/components/kasy_tabs.dart +145 -61
- package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +12 -18
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +565 -77
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
- package/templates/firebase/lib/i18n/en.i18n.json +2 -1
- package/templates/firebase/lib/i18n/es.i18n.json +2 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/web/index.html +9 -0
- package/templates/firebase/web/splash/img/dark-1x.png +0 -0
- package/templates/firebase/web/splash/img/dark-2x.png +0 -0
- package/templates/firebase/web/splash/img/dark-3x.png +0 -0
- package/templates/firebase/web/splash/img/dark-4x.png +0 -0
- package/templates/firebase/web/splash/img/light-1x.png +0 -0
- package/templates/firebase/web/splash/img/light-2x.png +0 -0
- package/templates/firebase/web/splash/img/light-3x.png +0 -0
- package/templates/firebase/web/splash/img/light-4x.png +0 -0
package/bin/kasy.js
CHANGED
|
@@ -8,6 +8,7 @@ const { runDoctor } = require('../lib/commands/doctor');
|
|
|
8
8
|
const { runFeatures } = require('../lib/commands/features');
|
|
9
9
|
const { runValidate } = require('../lib/commands/validate');
|
|
10
10
|
const { runDeployCommand } = require('../lib/commands/deploy');
|
|
11
|
+
const { runConfigure } = require('../lib/commands/configure');
|
|
11
12
|
const { runCheck } = require('../lib/commands/check');
|
|
12
13
|
const { runRun } = require('../lib/commands/run');
|
|
13
14
|
const { runReset } = require('../lib/commands/reset');
|
|
@@ -137,7 +138,7 @@ function createLocalizedHelpConfig(t) {
|
|
|
137
138
|
// Group root commands by intent for easier scanning by non-devs.
|
|
138
139
|
const groups = [
|
|
139
140
|
{ id: 'start', ids: ['new', 'doctor', 'features'] },
|
|
140
|
-
{ id: 'work', ids: ['add', 'remove', 'update', 'run', 'reset', 'splash', 'icon', 'favicon', 'notifications'] },
|
|
141
|
+
{ id: 'work', ids: ['add', 'configure', 'remove', 'update', 'run', 'reset', 'splash', 'icon', 'favicon', 'notifications'] },
|
|
141
142
|
{ id: 'publish', ids: ['deploy', 'check', 'ios', 'codemagic'] },
|
|
142
143
|
{ id: 'maintenance', ids: ['upgrade', 'version', 'uninstall', 'docs'] },
|
|
143
144
|
{ id: 'advanced', ids: ['setup', 'validate'] },
|
|
@@ -240,7 +241,7 @@ function buildProgram(language) {
|
|
|
240
241
|
.argument('[directory]', 'Target folder (default: asks during setup)', '.')
|
|
241
242
|
.option('-b, --backend <backend>', t('cli.command.setup.backendOption'))
|
|
242
243
|
.option('--with <features>', t('cli.command.setup.featuresOption'))
|
|
243
|
-
.option('--yes', 'Skip interactive questions
|
|
244
|
+
.option('--yes', 'Skip interactive questions (Quick mode, all features)')
|
|
244
245
|
.option('-p, --project <id>', 'Firebase Project ID (used with --yes)')
|
|
245
246
|
.description(t('cli.command.new.description'))
|
|
246
247
|
.action(async (directory, options) => {
|
|
@@ -324,6 +325,17 @@ function buildProgram(language) {
|
|
|
324
325
|
t
|
|
325
326
|
);
|
|
326
327
|
|
|
328
|
+
applyLocalizedHelp(
|
|
329
|
+
program
|
|
330
|
+
.command('configure')
|
|
331
|
+
.argument('[directory]', 'Project folder (default: current directory)', '.')
|
|
332
|
+
.description(t('cli.command.configure.description'))
|
|
333
|
+
.action(async (directory) => {
|
|
334
|
+
await runConfigure({ directory, language });
|
|
335
|
+
}),
|
|
336
|
+
t
|
|
337
|
+
);
|
|
338
|
+
|
|
327
339
|
applyLocalizedHelp(
|
|
328
340
|
program
|
|
329
341
|
.command('check')
|
|
@@ -347,6 +359,7 @@ function buildProgram(language) {
|
|
|
347
359
|
.option('--prod', 'Use production dart-defines (from launch.json)')
|
|
348
360
|
.option('--no-defines', 'Skip dart-defines from launch.json')
|
|
349
361
|
.option('--rc <mode>', 'RevenueCat key mode: auto (default — picks by device), test, or prod')
|
|
362
|
+
.option('--raw', 'Disable spinner and pass Flutter output straight through (auto-on when stdout is not a TTY)')
|
|
350
363
|
.description(t('cli.command.run.description'))
|
|
351
364
|
.action(async (directory, options) => {
|
|
352
365
|
await runRun(directory, { language, ...options });
|
package/lib/commands/add.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs-extra');
|
|
|
6
6
|
const pkg = require('../../package.json');
|
|
7
7
|
const kleur = require('kleur');
|
|
8
8
|
const ui = require('../utils/ui');
|
|
9
|
-
const { printCompactHeader } = require('../utils/brand');
|
|
9
|
+
const { printCompactHeader, paintLime } = require('../utils/brand');
|
|
10
10
|
const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
|
|
11
11
|
const {
|
|
12
12
|
AVAILABLE_FEATURES,
|
|
@@ -448,7 +448,7 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
|
|
|
448
448
|
const refFlag = backend === 'supabase' && projectRef ? ` --project-ref ${projectRef}` : '';
|
|
449
449
|
|
|
450
450
|
if (backend === 'firebase') {
|
|
451
|
-
const spinner = ui.spinner();
|
|
451
|
+
const spinner = ui.spinner({ color: paintLime });
|
|
452
452
|
spinner.start(t('add.llm_chat.settingSecret'));
|
|
453
453
|
try {
|
|
454
454
|
// Write to temp file — avoids trailing newline (echo) and shell injection risks
|
|
@@ -463,7 +463,7 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
|
|
|
463
463
|
ui.log.message(kleur.dim('Run manually: firebase functions:secrets:set LLM_API_KEY'));
|
|
464
464
|
}
|
|
465
465
|
} else if (backend === 'supabase') {
|
|
466
|
-
const spinner = ui.spinner();
|
|
466
|
+
const spinner = ui.spinner({ color: paintLime });
|
|
467
467
|
spinner.start(t('add.llm_chat.settingSecret'));
|
|
468
468
|
// Set LLM_API_KEY, LLM_PROVIDER and LLM_SYSTEM_PROMPT all as Supabase Secrets.
|
|
469
469
|
// Deployed Edge Functions read from Deno.env.get() = Supabase Secrets, NOT from .env files.
|
|
@@ -493,7 +493,7 @@ async function postAddLlmChat(projectDir, kitSetup, answers, t) {
|
|
|
493
493
|
// 3. Deploy the LLM function automatically
|
|
494
494
|
if (backend === 'api') return { deployOk: false, deployAttempted: false };
|
|
495
495
|
|
|
496
|
-
const deploySpinner = ui.timedSpinner();
|
|
496
|
+
const deploySpinner = ui.timedSpinner({ color: paintLime });
|
|
497
497
|
deploySpinner.start(t('add.llm_chat.deploying'));
|
|
498
498
|
try {
|
|
499
499
|
if (backend === 'firebase') {
|
|
@@ -721,7 +721,7 @@ async function runAdd(module, options = {}) {
|
|
|
721
721
|
// 8. Apply patch if it exists under features/<module>/
|
|
722
722
|
const patchDir = path.join(FEATURES_PATCH_DIR, normalized);
|
|
723
723
|
if (await fs.pathExists(patchDir)) {
|
|
724
|
-
const spinner = ui.spinner();
|
|
724
|
+
const spinner = ui.spinner({ color: paintLime });
|
|
725
725
|
spinner.start(t('add.applyingPatch'));
|
|
726
726
|
try {
|
|
727
727
|
const { tokens: patchTokens, pathReplacements: patchPathReplacements } = buildTokens({
|
|
@@ -781,7 +781,7 @@ async function runAdd(module, options = {}) {
|
|
|
781
781
|
|
|
782
782
|
// 10. flutter pub get
|
|
783
783
|
{
|
|
784
|
-
const spinner = ui.spinner();
|
|
784
|
+
const spinner = ui.spinner({ color: paintLime });
|
|
785
785
|
spinner.start(t('add.pubGet'));
|
|
786
786
|
try {
|
|
787
787
|
await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
|
|
@@ -794,7 +794,7 @@ async function runAdd(module, options = {}) {
|
|
|
794
794
|
// 11. build_runner (only when needed: features with codegen)
|
|
795
795
|
const needsBuildRunner = ['revenuecat', 'analytics', 'sentry', 'onboarding', 'llm_chat', 'feedback'].includes(normalized);
|
|
796
796
|
if (needsBuildRunner) {
|
|
797
|
-
const spinner = ui.timedSpinner();
|
|
797
|
+
const spinner = ui.timedSpinner({ color: paintLime });
|
|
798
798
|
spinner.start(t('add.buildRunner'));
|
|
799
799
|
try {
|
|
800
800
|
await execAsync('dart run build_runner build --delete-conflicting-outputs', { cwd: projectDir, timeout: 600_000 });
|
|
@@ -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 };
|