kasy-cli 1.13.0 → 1.15.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 +140 -12
- package/lib/commands/add.js +2 -2
- package/lib/commands/codemagic.js +11 -4
- package/lib/commands/deploy.js +3 -3
- package/lib/commands/favicon.js +115 -0
- package/lib/commands/icon.js +143 -0
- package/lib/commands/ios.js +28 -7
- package/lib/commands/new.js +8 -20
- package/lib/commands/remove.js +1 -1
- package/lib/commands/reset.js +385 -0
- package/lib/commands/run.js +24 -17
- package/lib/commands/splash.js +14 -4
- package/lib/commands/update.js +1 -1
- package/lib/scaffold/backends/api/patch/README.md +1 -1
- package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
- package/lib/scaffold/backends/firebase/tokens.js +2 -2
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
- package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
- package/lib/scaffold/backends/supabase/patch/README.md +1 -1
- package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
- package/lib/utils/apple-release.js +115 -16
- package/lib/utils/checks.js +45 -107
- package/lib/utils/debug.js +75 -0
- package/lib/utils/flutter-run.js +173 -0
- package/lib/utils/friendly-error.js +91 -0
- package/lib/utils/i18n/messages-en.js +970 -0
- package/lib/utils/i18n/messages-es.js +968 -0
- package/lib/utils/i18n/messages-pt.js +968 -0
- package/lib/utils/i18n.js +21 -2483
- package/lib/utils/mobile-identity.js +35 -0
- package/lib/utils/png-padding.js +120 -0
- package/lib/utils/ui.js +114 -0
- package/package.json +8 -4
- 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/build.gradle.kts +10 -1
- package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +161 -11
- package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.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/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.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-mdpi/android12splash.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-xxhdpi/android12splash.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-xhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.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/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.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/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +53 -0
- package/templates/firebase/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +9 -0
- package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
- package/templates/firebase/assets/images/favicon.png +0 -0
- package/templates/firebase/assets/images/icon.png +0 -0
- package/templates/firebase/assets/images/icon_android.png +0 -0
- package/templates/firebase/assets/images/icon_foreground_empty.png +0 -0
- package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
- package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
- package/templates/firebase/firestore.indexes.json +10 -0
- package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
- package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
- package/templates/firebase/functions/src/index.ts +1 -0
- package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
- package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
- package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
- package/templates/firebase/ios/Runner/Info.plist +2 -2
- package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/lib/components/components.dart +1 -0
- package/templates/firebase/lib/components/kasy_avatar.dart +88 -57
- package/templates/firebase/lib/components/kasy_avatar_presets.dart +116 -74
- package/templates/firebase/lib/components/kasy_button.dart +8 -0
- package/templates/firebase/lib/components/kasy_tabs.dart +431 -0
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
- package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +73 -19
- package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
- package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
- package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
- package/templates/firebase/lib/features/home/home_components_page.dart +1 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +316 -93
- package/templates/firebase/lib/features/home/home_page.dart +0 -6
- package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
- package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
- package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
- package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
- package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
- package/templates/firebase/lib/i18n/en.i18n.json +4 -1
- package/templates/firebase/lib/i18n/es.i18n.json +4 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
- package/templates/firebase/pubspec.yaml +10 -3
- package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
- package/templates/firebase/web/favicon.png +0 -0
- package/templates/firebase/web/icons/Icon-192.png +0 -0
- package/templates/firebase/web/icons/Icon-512.png +0 -0
- package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
- package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
- package/templates/firebase/web/index.html +9 -0
- package/templates/firebase/web/manifest.json +3 -3
- package/templates/firebase/assets/images/app_icon.png +0 -0
- package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
- package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
- package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
- package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
|
@@ -29,6 +29,14 @@ abstract class DeviceApi {
|
|
|
29
29
|
/// Register the device in the backend
|
|
30
30
|
/// Of course your backend should check if the device is already registered
|
|
31
31
|
/// throws an [ApiError] if something goes wrong
|
|
32
|
+
///
|
|
33
|
+
/// IMPORTANT — Cross-user token uniqueness:
|
|
34
|
+
/// The backend `POST /users/{userId}/devices` endpoint MUST guarantee that
|
|
35
|
+
/// the FCM token is unique across users. When the same token is registered
|
|
36
|
+
/// for a new user, delete any existing record holding that same token under
|
|
37
|
+
/// other users. Without this, a failed logout (offline) leaves the phone
|
|
38
|
+
/// registered to both accounts, and push for account A delivers to a phone
|
|
39
|
+
/// now signed in as account B.
|
|
32
40
|
Future<DeviceEntity> register(String userId, DeviceEntity device);
|
|
33
41
|
|
|
34
42
|
/// Update the device in the backend
|
|
@@ -38,6 +46,25 @@ abstract class DeviceApi {
|
|
|
38
46
|
/// Unregister the device in the backend
|
|
39
47
|
Future<void> unregister(String userId, String deviceId);
|
|
40
48
|
|
|
49
|
+
/// Heartbeat — tell the backend this install is still active.
|
|
50
|
+
/// Backend should update a `lastUpdateDate` (or equivalent) timestamp and use
|
|
51
|
+
/// it to skip stale devices when sending push notifications. Without this,
|
|
52
|
+
/// re-installing the app (Xcode -> TestFlight, build updates) leaves
|
|
53
|
+
/// orphan device records that still receive push, causing duplicates.
|
|
54
|
+
///
|
|
55
|
+
/// Suggested endpoint: `PATCH /users/{userId}/devices/{installationId}/touch`
|
|
56
|
+
/// Implement on your API to update the device row's last-seen timestamp.
|
|
57
|
+
Future<void> touch(String userId, String installationId);
|
|
58
|
+
|
|
59
|
+
/// Ask the backend to drop device records of the same user that haven't
|
|
60
|
+
/// been heartbeated in a while (typically 30 days). Called after registering
|
|
61
|
+
/// a fresh installation to clean up orphans from previous installs on the
|
|
62
|
+
/// same physical device.
|
|
63
|
+
///
|
|
64
|
+
/// Suggested endpoint: `POST /users/{userId}/devices/cleanup-stale`
|
|
65
|
+
/// with body `{ "currentInstallationId": "...", "olderThanDays": 30 }`.
|
|
66
|
+
Future<void> cleanupStaleDevices(String userId, String currentInstallationId);
|
|
67
|
+
|
|
41
68
|
/// Listen to token refresh
|
|
42
69
|
void onTokenRefresh(OnTokenRefresh onTokenRefresh);
|
|
43
70
|
|
|
@@ -153,6 +180,32 @@ class FirebaseDeviceApi implements DeviceApi {
|
|
|
153
180
|
}
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
@override
|
|
184
|
+
Future<void> touch(String userId, String installationId) async {
|
|
185
|
+
// Fire-and-forget: silently no-ops if the backend doesn't implement
|
|
186
|
+
// the touch endpoint yet. The duplicated-push protection becomes active
|
|
187
|
+
// once the API exposes the endpoint described in the abstract above.
|
|
188
|
+
try {
|
|
189
|
+
await _client.patch('/users/$userId/devices/$installationId/touch');
|
|
190
|
+
} catch (_) {}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@override
|
|
194
|
+
Future<void> cleanupStaleDevices(
|
|
195
|
+
String userId,
|
|
196
|
+
String currentInstallationId,
|
|
197
|
+
) async {
|
|
198
|
+
try {
|
|
199
|
+
await _client.post(
|
|
200
|
+
'/users/$userId/devices/cleanup-stale',
|
|
201
|
+
data: {
|
|
202
|
+
'currentInstallationId': currentInstallationId,
|
|
203
|
+
'olderThanDays': 30,
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
} catch (_) {}
|
|
207
|
+
}
|
|
208
|
+
|
|
156
209
|
@override
|
|
157
210
|
void onTokenRefresh(OnTokenRefresh onTokenRefresh) {
|
|
158
211
|
_onTokenRefreshSubscription =
|
|
@@ -98,11 +98,21 @@ flutter_launcher_icons:
|
|
|
98
98
|
android: ic_launcher
|
|
99
99
|
ios: true
|
|
100
100
|
remove_alpha_ios: true
|
|
101
|
+
web:
|
|
102
|
+
generate: true
|
|
103
|
+
image_path: assets/images/favicon.png
|
|
104
|
+
background_color: "#01171f"
|
|
105
|
+
theme_color: "#01171f"
|
|
101
106
|
flutter_native_splash:
|
|
102
107
|
color: "#FFFFFF"
|
|
108
|
+
color_dark: "#000000"
|
|
103
109
|
fullscreen: true
|
|
104
110
|
ios: true
|
|
105
111
|
android: true
|
|
106
|
-
image: assets/images/
|
|
112
|
+
image: assets/images/splash_logo_light.png
|
|
113
|
+
image_dark: assets/images/splash_logo_dark.png
|
|
107
114
|
android_12:
|
|
108
115
|
color: "#FFFFFF"
|
|
116
|
+
color_dark: "#000000"
|
|
117
|
+
image: assets/images/splash_logo_light_android12.png
|
|
118
|
+
image_dark: assets/images/splash_logo_dark_android12.png
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
* Original hardcoded values in Firebase/:
|
|
11
11
|
* - package name : kasy_kit (Dart import path prefix)
|
|
12
12
|
* - bundle ID : com.aicrus.firebase.kit (Android namespace, iOS bundle ID)
|
|
13
|
-
* - app display :
|
|
13
|
+
* - app display : Kasy App (AndroidManifest, Info.plist) — unique string with space, won't collide with KasyButton/KasyTheme/etc
|
|
14
14
|
* - short name : appfirebase (kAppName Dart constant)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const ORIGINAL_PACKAGE = 'kasy_kit';
|
|
18
18
|
const ORIGINAL_BUNDLE_ID = 'com.aicrus.firebase.kit';
|
|
19
|
-
const ORIGINAL_APP_NAME = '
|
|
19
|
+
const ORIGINAL_APP_NAME = 'Kasy App';
|
|
20
20
|
const ORIGINAL_SHORT_NAME = 'appfirebase';
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -254,11 +254,17 @@ Deno.serve(async (req: Request) => {
|
|
|
254
254
|
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
255
255
|
const supabase = createClient(supabaseUrl, serviceRoleKey);
|
|
256
256
|
|
|
257
|
-
// Fetch device tokens for the user
|
|
257
|
+
// Fetch device tokens for the user, skipping orphan installs.
|
|
258
|
+
// Devices not touched in the last 60 days are treated as leftovers from
|
|
259
|
+
// previous installations on the same physical device (each install gets a
|
|
260
|
+
// fresh installation_id). Sending to them causes duplicated push delivery.
|
|
261
|
+
const STALE_DEVICE_TTL_MS = 60 * 24 * 60 * 60 * 1000;
|
|
262
|
+
const cutoffIso = new Date(Date.now() - STALE_DEVICE_TTL_MS).toISOString();
|
|
258
263
|
const { data: devices, error: devErr } = await supabase
|
|
259
264
|
.from("devices")
|
|
260
265
|
.select("id, token")
|
|
261
|
-
.eq("user_id", notification.user_id)
|
|
266
|
+
.eq("user_id", notification.user_id)
|
|
267
|
+
.or(`last_update_date.is.null,last_update_date.gte.${cutoffIso}`);
|
|
262
268
|
|
|
263
269
|
if (devErr || !devices?.length) {
|
|
264
270
|
console.log(`[send-push] no devices for user ${notification.user_id}`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Cross-user device token deduplication.
|
|
2
|
+
--
|
|
3
|
+
-- Guarantees that a single FCM token belongs to at most one user at any time.
|
|
4
|
+
-- When the same install registers under a new account (typical scenario:
|
|
5
|
+
-- logout failed offline), the old row is automatically deleted so the iPhone
|
|
6
|
+
-- only receives push for the account currently signed in.
|
|
7
|
+
--
|
|
8
|
+
-- Winner = the row that was just inserted/updated (most recent intent).
|
|
9
|
+
|
|
10
|
+
CREATE OR REPLACE FUNCTION public.cleanup_duplicate_device_tokens()
|
|
11
|
+
RETURNS TRIGGER
|
|
12
|
+
LANGUAGE plpgsql
|
|
13
|
+
SECURITY DEFINER
|
|
14
|
+
SET search_path = public
|
|
15
|
+
AS $$
|
|
16
|
+
BEGIN
|
|
17
|
+
IF NEW.token IS NULL OR NEW.token = '' THEN
|
|
18
|
+
RETURN NEW;
|
|
19
|
+
END IF;
|
|
20
|
+
|
|
21
|
+
DELETE FROM public.devices
|
|
22
|
+
WHERE token = NEW.token
|
|
23
|
+
AND id <> NEW.id;
|
|
24
|
+
|
|
25
|
+
RETURN NEW;
|
|
26
|
+
END;
|
|
27
|
+
$$;
|
|
28
|
+
|
|
29
|
+
DROP TRIGGER IF EXISTS trg_cleanup_duplicate_device_tokens ON public.devices;
|
|
30
|
+
|
|
31
|
+
CREATE TRIGGER trg_cleanup_duplicate_device_tokens
|
|
32
|
+
AFTER INSERT OR UPDATE OF token ON public.devices
|
|
33
|
+
FOR EACH ROW
|
|
34
|
+
EXECUTE FUNCTION public.cleanup_duplicate_device_tokens();
|
|
@@ -36,6 +36,15 @@ abstract class DeviceApi {
|
|
|
36
36
|
/// Unregister the device in the backend
|
|
37
37
|
Future<void> unregister(String userId, String deviceId);
|
|
38
38
|
|
|
39
|
+
/// Heartbeat — update `last_update_date` on the current device row.
|
|
40
|
+
/// Used so the backend can detect orphan rows from previous installs.
|
|
41
|
+
Future<void> touch(String userId, String installationId);
|
|
42
|
+
|
|
43
|
+
/// Delete device rows of the same user that haven't been touched in a while.
|
|
44
|
+
/// Called after registering a fresh installation to remove orphans left by
|
|
45
|
+
/// previous installs (whose installation_id no longer matches).
|
|
46
|
+
Future<void> cleanupStaleDevices(String userId, String currentInstallationId);
|
|
47
|
+
|
|
39
48
|
/// Listen to token refresh
|
|
40
49
|
void onTokenRefresh(OnTokenRefresh onTokenRefresh);
|
|
41
50
|
|
|
@@ -160,6 +169,40 @@ class FirebaseDeviceApi implements DeviceApi {
|
|
|
160
169
|
}
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
@override
|
|
173
|
+
Future<void> touch(String userId, String installationId) async {
|
|
174
|
+
try {
|
|
175
|
+
await _client
|
|
176
|
+
.from('devices')
|
|
177
|
+
.update({'last_update_date': DateTime.now().toIso8601String()})
|
|
178
|
+
.eq('user_id', userId)
|
|
179
|
+
.eq('installation_id', installationId);
|
|
180
|
+
} catch (_) {
|
|
181
|
+
// Missing row — caller will re-register on next session.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@override
|
|
186
|
+
Future<void> cleanupStaleDevices(
|
|
187
|
+
String userId,
|
|
188
|
+
String currentInstallationId,
|
|
189
|
+
) async {
|
|
190
|
+
// Devices not touched in the last 30 days are treated as orphans from
|
|
191
|
+
// previous installations on the same physical device. Active second
|
|
192
|
+
// devices stay above this threshold via heartbeat.
|
|
193
|
+
final cutoff = DateTime.now().subtract(const Duration(days: 30));
|
|
194
|
+
try {
|
|
195
|
+
await _client
|
|
196
|
+
.from('devices')
|
|
197
|
+
.delete()
|
|
198
|
+
.eq('user_id', userId)
|
|
199
|
+
.neq('installation_id', currentInstallationId)
|
|
200
|
+
.lt('last_update_date', cutoff.toIso8601String());
|
|
201
|
+
} catch (e) {
|
|
202
|
+
Logger().w('cleanupStaleDevices failed: $e');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
163
206
|
@override
|
|
164
207
|
Future<void> clear(String userId) async {
|
|
165
208
|
try {
|
|
@@ -100,11 +100,21 @@ flutter_launcher_icons:
|
|
|
100
100
|
android: ic_launcher
|
|
101
101
|
ios: true
|
|
102
102
|
remove_alpha_ios: true
|
|
103
|
+
web:
|
|
104
|
+
generate: true
|
|
105
|
+
image_path: assets/images/favicon.png
|
|
106
|
+
background_color: "#01171f"
|
|
107
|
+
theme_color: "#01171f"
|
|
103
108
|
flutter_native_splash:
|
|
104
109
|
color: "#FFFFFF"
|
|
110
|
+
color_dark: "#000000"
|
|
105
111
|
fullscreen: true
|
|
106
112
|
ios: true
|
|
107
113
|
android: true
|
|
108
|
-
image: assets/images/
|
|
114
|
+
image: assets/images/splash_logo_light.png
|
|
115
|
+
image_dark: assets/images/splash_logo_dark.png
|
|
109
116
|
android_12:
|
|
110
117
|
color: "#FFFFFF"
|
|
118
|
+
color_dark: "#000000"
|
|
119
|
+
image: assets/images/splash_logo_light_android12.png
|
|
120
|
+
image_dark: assets/images/splash_logo_dark_android12.png
|
|
@@ -5,7 +5,7 @@ const fs = require('fs-extra');
|
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const kleur = require('kleur');
|
|
7
7
|
const ui = require('./ui');
|
|
8
|
-
const { exec } = require('node:child_process');
|
|
8
|
+
const { exec, spawn } = require('node:child_process');
|
|
9
9
|
const { promisify } = require('node:util');
|
|
10
10
|
|
|
11
11
|
const execAsync = promisify(exec);
|
|
@@ -43,6 +43,27 @@ async function isKasyFlutterProject(projectDir) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
async function readBundleId(projectDir) {
|
|
46
|
+
// Source of truth is the Xcode project file — what the build actually uses
|
|
47
|
+
// when installing on a device. kit_setup.json can drift if the user renames
|
|
48
|
+
// the bundle id manually after `kasy new`, so it's only used as a fallback
|
|
49
|
+
// when the iOS project hasn't been generated yet.
|
|
50
|
+
const pbxPath = path.join(projectDir, 'ios', 'Runner.xcodeproj', 'project.pbxproj');
|
|
51
|
+
if (await fs.pathExists(pbxPath)) {
|
|
52
|
+
const content = await fs.readFile(pbxPath, 'utf8');
|
|
53
|
+
// Match the main target only — skip Widget/NotificationService extensions
|
|
54
|
+
// by picking the first identifier that doesn't contain a dot extension.
|
|
55
|
+
const matches = [...content.matchAll(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g)];
|
|
56
|
+
for (const m of matches) {
|
|
57
|
+
const id = m[1].trim().replace(/"/g, '');
|
|
58
|
+
if (
|
|
59
|
+
!id.endsWith('.HomeWidgetExtension') &&
|
|
60
|
+
!id.endsWith('.NotificationService')
|
|
61
|
+
) {
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (matches[0]) return matches[0][1].trim().replace(/"/g, '');
|
|
66
|
+
}
|
|
46
67
|
const kitSetupPath = path.join(projectDir, 'kit_setup.json');
|
|
47
68
|
if (await fs.pathExists(kitSetupPath)) {
|
|
48
69
|
try {
|
|
@@ -52,12 +73,6 @@ async function readBundleId(projectDir) {
|
|
|
52
73
|
// ignore
|
|
53
74
|
}
|
|
54
75
|
}
|
|
55
|
-
const pbxPath = path.join(projectDir, 'ios', 'Runner.xcodeproj', 'project.pbxproj');
|
|
56
|
-
if (await fs.pathExists(pbxPath)) {
|
|
57
|
-
const content = await fs.readFile(pbxPath, 'utf8');
|
|
58
|
-
const m = content.match(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/);
|
|
59
|
-
if (m) return m[1].trim().replace(/"/g, '');
|
|
60
|
-
}
|
|
61
76
|
return null;
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -170,6 +185,18 @@ const XCODE_CACHE_ERROR_PATTERNS = [
|
|
|
170
185
|
/clang: error: no such file or directory:.*\.swiftmodule/i,
|
|
171
186
|
];
|
|
172
187
|
|
|
188
|
+
const POD_NETWORK_ERROR_PATTERNS = [
|
|
189
|
+
/curl:\s*\(6\)/i,
|
|
190
|
+
/curl:\s*\(7\)/i,
|
|
191
|
+
/curl:\s*\(28\)/i,
|
|
192
|
+
/curl:\s*\(35\)/i,
|
|
193
|
+
/curl:\s*\(56\)/i,
|
|
194
|
+
/Connection reset by peer/i,
|
|
195
|
+
/Could not resolve host/i,
|
|
196
|
+
/Network is unreachable/i,
|
|
197
|
+
/Operation timed out/i,
|
|
198
|
+
];
|
|
199
|
+
|
|
173
200
|
async function getFreeDiskGb(checkPath) {
|
|
174
201
|
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
175
202
|
return null;
|
|
@@ -201,6 +228,11 @@ function isXcodeCacheBuildError(output) {
|
|
|
201
228
|
return XCODE_CACHE_ERROR_PATTERNS.some((re) => re.test(text));
|
|
202
229
|
}
|
|
203
230
|
|
|
231
|
+
function isPodNetworkError(output) {
|
|
232
|
+
const text = String(output || '');
|
|
233
|
+
return POD_NETWORK_ERROR_PATTERNS.some((re) => re.test(text));
|
|
234
|
+
}
|
|
235
|
+
|
|
204
236
|
function printBuildFailureHints(t, projectDir) {
|
|
205
237
|
const name = path.basename(projectDir);
|
|
206
238
|
const cleanArg = projectDir !== process.cwd() ? ` ${projectDir}` : '';
|
|
@@ -220,6 +252,17 @@ function printBuildFailureHints(t, projectDir) {
|
|
|
220
252
|
ui.note(lines.join('\n'), t('ios.hints.title'));
|
|
221
253
|
}
|
|
222
254
|
|
|
255
|
+
function printPodNetworkHints(t) {
|
|
256
|
+
const lines = [
|
|
257
|
+
t('ios.hints.network.body'),
|
|
258
|
+
'',
|
|
259
|
+
`1. ${t('ios.hints.network.step1')}`,
|
|
260
|
+
`2. ${t('ios.hints.network.step2')}: ${kleur.cyan('kasy ios')}`,
|
|
261
|
+
`3. ${t('ios.hints.network.step3')}: ${kleur.cyan('kasy ios clean')}`,
|
|
262
|
+
];
|
|
263
|
+
ui.note(lines.join('\n'), t('ios.hints.network.title'));
|
|
264
|
+
}
|
|
265
|
+
|
|
223
266
|
async function runIosClean(projectDir, t) {
|
|
224
267
|
const steps = [
|
|
225
268
|
{ label: t('ios.clean.step.flutterClean'), cmd: 'flutter clean' },
|
|
@@ -247,15 +290,69 @@ async function runReleaseScript(projectDir, args, t) {
|
|
|
247
290
|
if (!(await fs.pathExists(scriptPath))) {
|
|
248
291
|
throw new Error(t('ios.error.noScript'));
|
|
249
292
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
293
|
+
|
|
294
|
+
const isBuildOnly = args.includes('--no-upload');
|
|
295
|
+
const titleKey = isBuildOnly ? 'ios.build.task.building' : 'ios.release.task.building';
|
|
296
|
+
const doneKey = isBuildOnly ? 'ios.build.task.done' : 'ios.release.task.done';
|
|
297
|
+
const failKey = isBuildOnly ? 'ios.build.task.failed' : 'ios.release.task.failed';
|
|
298
|
+
|
|
299
|
+
const spinner = ui.timedSpinner();
|
|
300
|
+
spinner.start(t(titleKey));
|
|
301
|
+
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
const proc = spawn('bash', [scriptPath, ...args], {
|
|
304
|
+
cwd: projectDir,
|
|
305
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
let allOutput = '';
|
|
309
|
+
let uploadStage = false;
|
|
310
|
+
|
|
311
|
+
// Trim noise so the spinner message stays readable. Skip empty lines,
|
|
312
|
+
// pure dotted progress bars (e.g. "....."), and lines that are just
|
|
313
|
+
// separators. Show the last meaningful line as the spinner status.
|
|
314
|
+
const isMeaningful = (line) => {
|
|
315
|
+
if (!line) return false;
|
|
316
|
+
if (/^[.\s]+$/.test(line)) return false;
|
|
317
|
+
if (/^=+$/.test(line)) return false;
|
|
318
|
+
return true;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleChunk = (chunk) => {
|
|
322
|
+
const text = chunk.toString();
|
|
323
|
+
allOutput += text;
|
|
324
|
+
|
|
325
|
+
if (!uploadStage && /Uploading to App Store Connect/i.test(text)) {
|
|
326
|
+
uploadStage = true;
|
|
327
|
+
spinner.message(t('ios.release.task.uploading'));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const lines = text.split('\n').map((l) => l.replace(/\r/g, '').trim()).filter(isMeaningful);
|
|
332
|
+
const lastLine = lines[lines.length - 1];
|
|
333
|
+
if (lastLine) spinner.message(lastLine);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
proc.stdout.on('data', handleChunk);
|
|
337
|
+
proc.stderr.on('data', handleChunk);
|
|
338
|
+
|
|
339
|
+
proc.on('close', (code) => {
|
|
340
|
+
if (code === 0) {
|
|
341
|
+
spinner.stop(t(doneKey));
|
|
342
|
+
resolve();
|
|
343
|
+
} else {
|
|
344
|
+
spinner.stop(t(failKey), 2);
|
|
345
|
+
const error = new Error(allOutput || `release script exited with code ${code}`);
|
|
346
|
+
error.buildOutput = allOutput;
|
|
347
|
+
reject(error);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
proc.on('error', (err) => {
|
|
352
|
+
spinner.stop(t(failKey), 2);
|
|
353
|
+
reject(err);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
259
356
|
}
|
|
260
357
|
|
|
261
358
|
module.exports = {
|
|
@@ -280,6 +377,8 @@ module.exports = {
|
|
|
280
377
|
getFreeDiskGb,
|
|
281
378
|
checkDiskSpaceForIosBuild,
|
|
282
379
|
isXcodeCacheBuildError,
|
|
380
|
+
isPodNetworkError,
|
|
283
381
|
printBuildFailureHints,
|
|
382
|
+
printPodNetworkHints,
|
|
284
383
|
runIosClean,
|
|
285
384
|
};
|
package/lib/utils/checks.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
const { exec } = require('node:child_process');
|
|
2
2
|
const { promisify } = require('node:util');
|
|
3
|
-
const readline = require('node:readline');
|
|
4
3
|
const kleur = require('kleur');
|
|
5
|
-
const oraPackage = require('ora');
|
|
6
4
|
const ui = require('./ui');
|
|
7
5
|
const { createTranslator, detectDefaultLanguage } = require('./i18n');
|
|
8
6
|
|
|
9
7
|
const execAsync = promisify(exec);
|
|
10
|
-
const ora = oraPackage.default || oraPackage;
|
|
11
8
|
|
|
12
9
|
// Timeout para verificar se uma ferramenta está instalada (15 s é mais que suficiente)
|
|
13
10
|
const TOOL_CHECK_TIMEOUT = 15_000;
|
|
@@ -19,19 +16,6 @@ const MIN_NODE_VERSION = '18.0.0';
|
|
|
19
16
|
const MIN_FLUTTER_VERSION = '3.24.0';
|
|
20
17
|
const MIN_DART_VERSION = '3.5.0';
|
|
21
18
|
|
|
22
|
-
/**
|
|
23
|
-
* Compare two semver strings. Returns true if actual >= required.
|
|
24
|
-
*/
|
|
25
|
-
function meetsMinVersion(actual, required) {
|
|
26
|
-
if (!actual || !required) return true;
|
|
27
|
-
const parse = (v) => v.replace(/[^0-9.]/g, '').split('.').map(Number);
|
|
28
|
-
const [aMaj, aMin, aPat = 0] = parse(actual);
|
|
29
|
-
const [rMaj, rMin, rPat = 0] = parse(required);
|
|
30
|
-
if (aMaj !== rMaj) return aMaj > rMaj;
|
|
31
|
-
if (aMin !== rMin) return aMin > rMin;
|
|
32
|
-
return aPat >= rPat;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
19
|
const BASE_CHECKS = [
|
|
36
20
|
{
|
|
37
21
|
name: 'Node.js',
|
|
@@ -106,7 +90,7 @@ const FIREBASE_CHECKS = [
|
|
|
106
90
|
command: 'gcloud --version',
|
|
107
91
|
required: false,
|
|
108
92
|
failHint: getGcloudInstallHint(),
|
|
109
|
-
|
|
93
|
+
waitPromptKey: 'checks.waitPrompt.gcloud.install',
|
|
110
94
|
},
|
|
111
95
|
{
|
|
112
96
|
name: 'gcloud auth (create-from-scratch)',
|
|
@@ -114,7 +98,7 @@ const FIREBASE_CHECKS = [
|
|
|
114
98
|
required: false,
|
|
115
99
|
showVersion: false,
|
|
116
100
|
failHint: 'gcloud auth login',
|
|
117
|
-
|
|
101
|
+
waitPromptKey: 'checks.waitPrompt.gcloud.auth',
|
|
118
102
|
},
|
|
119
103
|
];
|
|
120
104
|
|
|
@@ -175,128 +159,71 @@ function extractVersion(stdout, checkName) {
|
|
|
175
159
|
return m ? m[0] : raw.slice(0, 20);
|
|
176
160
|
}
|
|
177
161
|
|
|
178
|
-
/**
|
|
179
|
-
* Wait for the user to press Enter in the terminal.
|
|
180
|
-
* Used to pause the flow so the user can install something or authenticate.
|
|
181
|
-
*/
|
|
182
|
-
function waitForUserInput(prompt) {
|
|
183
|
-
return new Promise((resolve) => {
|
|
184
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
185
|
-
const done = () => { rl.close(); resolve(); };
|
|
186
|
-
rl.question(`\n ${kleur.cyan(prompt)}\n `, done);
|
|
187
|
-
rl.on('close', resolve);
|
|
188
|
-
rl.on('error', resolve);
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
162
|
async function runSingleCheck(check, options = {}) {
|
|
193
163
|
const showVersion = check.showVersion !== undefined ? check.showVersion : (options.showVersion !== undefined ? options.showVersion : true);
|
|
194
|
-
const silent = options.silent === true;
|
|
195
|
-
const t = check.t || createTranslator(check.language || detectDefaultLanguage());
|
|
196
|
-
const spinner = silent ? null : ora(t('checks.checking', { name: check.name })).start();
|
|
197
164
|
let autoInstallFailed = false;
|
|
198
165
|
|
|
199
166
|
try {
|
|
200
167
|
const { stdout } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
|
|
201
168
|
const version = showVersion ? extractVersion(stdout, check.name) : null;
|
|
202
|
-
const msg = version
|
|
203
|
-
? t('checks.foundWithVersion', { name: check.name, version })
|
|
204
|
-
: t('checks.found', { name: check.name });
|
|
205
|
-
|
|
206
|
-
if (!silent) {
|
|
207
|
-
// Warn if version is below minimum requirement
|
|
208
|
-
if (version && check.minVersion && !meetsMinVersion(version, check.minVersion)) {
|
|
209
|
-
spinner.warn(`${msg} ${kleur.yellow(`(mínimo recomendado: ${check.minVersion})`)}`);
|
|
210
|
-
} else {
|
|
211
|
-
spinner.succeed(msg);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
169
|
return { ...check, ok: true, version: version || null };
|
|
215
170
|
} catch (err) {
|
|
216
171
|
const diagnosis = diagnoseFailure(err);
|
|
217
172
|
if (check.tryInstall) {
|
|
218
|
-
if (!silent) spinner.text = t(check.tryInstallMessageKey || 'setup.flutterfire.installing');
|
|
219
173
|
try {
|
|
220
174
|
await execAsync(check.tryInstall, { encoding: 'utf8', timeout: INSTALL_TIMEOUT });
|
|
221
175
|
const { stdout: retryOut } = await execAsync(check.retryCommand || check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
|
|
222
176
|
const version = showVersion ? extractVersion(retryOut, check.name) : null;
|
|
223
|
-
const msg = version
|
|
224
|
-
? t('checks.foundWithVersion', { name: check.name, version: version || 'installed' })
|
|
225
|
-
: t('checks.found', { name: check.name });
|
|
226
|
-
if (!silent) spinner.succeed(msg);
|
|
227
177
|
return { ...check, ok: true, version: version || null };
|
|
228
178
|
} catch {
|
|
229
179
|
autoInstallFailed = true;
|
|
230
180
|
}
|
|
231
181
|
}
|
|
232
|
-
|
|
233
|
-
// Guided interactive prompt: show instructions and wait for the user to act.
|
|
234
|
-
if (check.waitPrompt && !silent) {
|
|
235
|
-
spinner.stop();
|
|
236
|
-
if (check.failHint) {
|
|
237
|
-
console.log(kleur.yellow(`\n ⚠ ${check.name} não encontrado.\n`));
|
|
238
|
-
console.log(kleur.bold(' Execute:\n'));
|
|
239
|
-
console.log(kleur.cyan(` ${check.failHint}\n`));
|
|
240
|
-
}
|
|
241
|
-
await waitForUserInput(check.waitPrompt);
|
|
242
|
-
try {
|
|
243
|
-
const { stdout: retryOut } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
|
|
244
|
-
const version = showVersion ? extractVersion(retryOut, check.name) : null;
|
|
245
|
-
const msg = version
|
|
246
|
-
? t('checks.foundWithVersion', { name: check.name, version })
|
|
247
|
-
: t('checks.found', { name: check.name });
|
|
248
|
-
spinner.succeed(msg);
|
|
249
|
-
return { ...check, ok: true, version: version || null };
|
|
250
|
-
} catch {
|
|
251
|
-
// Still failing — fall through to report
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (!silent) {
|
|
256
|
-
if (check.required) {
|
|
257
|
-
const detail = autoInstallFailed ? ` — ${t('checks.install.failed')}` : '';
|
|
258
|
-
const diagSuffix = diagnosis ? `\n ${kleur.dim(`→ ${t(`checks.diagnostic.${diagnosis}`, { name: check.name })}`)}` : '';
|
|
259
|
-
spinner.fail(t('checks.missing', { name: check.name }) + detail + diagSuffix);
|
|
260
|
-
} else if (diagnosis) {
|
|
261
|
-
spinner.warn(t(`checks.diagnostic.${diagnosis}`, { name: check.name }));
|
|
262
|
-
} else {
|
|
263
|
-
const hint = !check.waitPrompt && check.failHint ? `\n ${kleur.dim(`→ ${check.failHint}`)}` : '';
|
|
264
|
-
if (check.warnMessage) {
|
|
265
|
-
spinner.warn(check.warnMessage + hint);
|
|
266
|
-
} else if (check.warnMessageKey) {
|
|
267
|
-
spinner.warn(t(check.warnMessageKey) + hint);
|
|
268
|
-
} else {
|
|
269
|
-
spinner.warn(t('checks.notFound', { name: check.name }) + hint);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
182
|
return { ...check, ok: false, autoInstallFailed, diagnosis };
|
|
275
183
|
}
|
|
276
184
|
}
|
|
277
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Retry a check after the user installs/auths a missing tool. Used when the
|
|
188
|
+
* check definition has `waitPrompt` — we offer the user to run the install
|
|
189
|
+
* step, then re-check on Enter. Returns true if the recheck succeeded.
|
|
190
|
+
*/
|
|
191
|
+
async function retryCheckInteractively(check, t) {
|
|
192
|
+
ui.log.warn(`${check.name} ${t('checks.notFound.short') || 'not found'}`);
|
|
193
|
+
if (check.failHint) {
|
|
194
|
+
ui.log.message(`${t('checks.runHint') || 'Run'}: ${kleur.cyan(check.failHint)}`);
|
|
195
|
+
}
|
|
196
|
+
const proceed = await ui.confirm({
|
|
197
|
+
message: check.waitPrompt,
|
|
198
|
+
initialValue: true,
|
|
199
|
+
});
|
|
200
|
+
if (!proceed) return false;
|
|
201
|
+
try {
|
|
202
|
+
const { stdout } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
|
|
203
|
+
const version = extractVersion(stdout, check.name);
|
|
204
|
+
ui.log.success(version
|
|
205
|
+
? `${check.name} — ${version}`
|
|
206
|
+
: check.name);
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
ui.log.error(`${check.name} ${t('checks.stillMissing') || 'still missing'}`);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
278
214
|
async function runChecks(checks, title, options = {}) {
|
|
279
215
|
const t = options.t || createTranslator(options.language || detectDefaultLanguage());
|
|
280
|
-
const { showVersion = true
|
|
216
|
+
const { showVersion = true } = options;
|
|
281
217
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const results = [];
|
|
285
|
-
for (const check of checks) {
|
|
286
|
-
const result = await runSingleCheck({ ...check, t }, { showVersion });
|
|
287
|
-
results.push(result);
|
|
288
|
-
}
|
|
289
|
-
return results;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Compact mode: single spinner, show failures only
|
|
218
|
+
// Single spinner over all checks, show failures afterwards. The visual
|
|
219
|
+
// sits inside the clack rail (│) opened by the caller's ui.intro().
|
|
293
220
|
const { spinnerLabel = title, doneLabel = title } = options;
|
|
294
221
|
const spinner = ui.spinner();
|
|
295
222
|
spinner.start(spinnerLabel);
|
|
296
223
|
|
|
297
224
|
const results = [];
|
|
298
225
|
for (const check of checks) {
|
|
299
|
-
results.push(await runSingleCheck({ ...check, t }, { showVersion
|
|
226
|
+
results.push(await runSingleCheck({ ...check, t }, { showVersion }));
|
|
300
227
|
}
|
|
301
228
|
|
|
302
229
|
const failures = results.filter((r) => !r.ok);
|
|
@@ -316,6 +243,17 @@ async function runChecks(checks, title, options = {}) {
|
|
|
316
243
|
}
|
|
317
244
|
|
|
318
245
|
for (const result of failures) {
|
|
246
|
+
// Interactive recovery for checks with a waitPrompt (e.g. gcloud install).
|
|
247
|
+
// Lets the user fix the env without restarting the command.
|
|
248
|
+
const waitPromptText = result.waitPromptKey ? t(result.waitPromptKey) : result.waitPrompt;
|
|
249
|
+
if (waitPromptText) {
|
|
250
|
+
const recovered = await retryCheckInteractively({ ...result, waitPrompt: waitPromptText }, t);
|
|
251
|
+
if (recovered) {
|
|
252
|
+
const idx = results.indexOf(result);
|
|
253
|
+
if (idx >= 0) results[idx] = { ...result, ok: true };
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
319
257
|
const hint = result.failHint ? `\n${kleur.dim(`→ ${result.failHint}`)}` : '';
|
|
320
258
|
if (result.required) {
|
|
321
259
|
const detail = result.autoInstallFailed ? ` — ${t('checks.install.failed')}` : '';
|