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.
- package/bin/kasy.js +16 -2
- package/lib/commands/add.js +52 -19
- package/lib/commands/configure.js +548 -0
- package/lib/commands/deploy.js +4 -4
- package/lib/commands/doctor.js +54 -6
- package/lib/commands/favicon.js +4 -4
- package/lib/commands/icon.js +5 -5
- package/lib/commands/new.js +404 -213
- package/lib/commands/remove.js +14 -3
- package/lib/commands/run.js +208 -6
- package/lib/commands/splash.js +5 -5
- package/lib/commands/update.js +9 -9
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/lib/scaffold/backends/api/patch/README.md +3 -2
- 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/backends/supabase/patch/README.md +3 -2
- package/lib/scaffold/generate.js +24 -8
- package/lib/scaffold/shared/generator-utils.js +52 -8
- package/lib/scaffold/shared/post-build.js +113 -31
- package/lib/scaffold/shared/template-strings.js +6 -0
- package/lib/utils/brand.js +16 -12
- package/lib/utils/flutter-run.js +139 -11
- package/lib/utils/i18n/messages-en.js +85 -7
- package/lib/utils/i18n/messages-es.js +85 -7
- package/lib/utils/i18n/messages-pt.js +86 -8
- package/lib/utils/ui.js +79 -4
- package/package.json +1 -1
- package/templates/firebase/README.en.md +18 -8
- package/templates/firebase/README.es.md +18 -8
- package/templates/firebase/README.md +18 -8
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +68 -45
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidgetReceiver.kt +37 -0
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/OpenAppAction.kt +26 -0
- 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/docs/revenuecat-setup.es.md +28 -8
- package/templates/firebase/docs/revenuecat-setup.pt.md +28 -8
- 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 +45 -53
- 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/lib/router.dart +15 -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
|
@@ -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 };
|
package/lib/commands/deploy.js
CHANGED
|
@@ -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'));
|
package/lib/commands/doctor.js
CHANGED
|
@@ -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 (
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
}
|