kasy-cli 1.30.0 → 1.31.1

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.
@@ -9,14 +9,14 @@
9
9
  * Requires: supabase CLI installed, user logged in (supabase login).
10
10
  */
11
11
 
12
- const { exec, execFile } = require('node:child_process');
12
+ const { exec } = require('node:child_process');
13
13
  const { promisify } = require('node:util');
14
14
  const path = require('node:path');
15
+ const os = require('node:os');
15
16
  const fs = require('fs-extra');
16
17
  const { augmentedEnv } = require('../../../utils/env-tools');
17
18
 
18
19
  const execAsync = promisify(exec);
19
- const execFileAsync = promisify(execFile);
20
20
 
21
21
  /**
22
22
  * Parse JSON from a CLI's stdout tolerantly. The Supabase CLI sometimes prints
@@ -187,23 +187,116 @@ async function getProjectKeys(projectRef) {
187
187
  }
188
188
  }
189
189
 
190
+ // The Supabase CLI stores its access token via go-keyring under the service
191
+ // "Supabase CLI". The account/key has varied across CLI versions (e.g.
192
+ // "supabase", "access-token"), so we DON'T hardcode it — we read by service
193
+ // only. Each OS keeps this in a different vault. Used by the Management API
194
+ // calls below (auth providers), which the CLI itself has no command for.
195
+ const SUPABASE_KEYRING_SERVICE = 'Supabase CLI';
196
+
197
+ // Read the Supabase token out of the Windows Credential Manager. go-keyring's
198
+ // wincred backend names the target "<service>:<user>", but since the user part
199
+ // differs by CLI version we ENUMERATE every credential whose target starts with
200
+ // the service name and return the first non-empty blob as base64 — the caller
201
+ // picks the decoding. Robust to the account name we can't see from here.
202
+ async function readWindowsSupabaseTokenBase64() {
203
+ const ps = `
204
+ $ErrorActionPreference = 'Stop'
205
+ $sig = @"
206
+ using System;
207
+ using System.Runtime.InteropServices;
208
+ public class KasyCred {
209
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
210
+ public static extern bool CredEnumerate(string filter, int flag, out int count, out IntPtr creds);
211
+ [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred);
212
+ [StructLayout(LayoutKind.Sequential)] public struct CREDENTIAL {
213
+ public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
214
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
215
+ public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
216
+ public int AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;
217
+ }
218
+ }
219
+ "@
220
+ Add-Type $sig | Out-Null
221
+ $count = 0; $ptr = [IntPtr]::Zero
222
+ if ([KasyCred]::CredEnumerate('${SUPABASE_KEYRING_SERVICE}*', 0, [ref]$count, [ref]$ptr)) {
223
+ for ($i = 0; $i -lt $count; $i++) {
224
+ $credPtr = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($ptr, $i * [IntPtr]::Size)
225
+ $c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [type]([KasyCred+CREDENTIAL]))
226
+ if ($c.CredentialBlobSize -gt 0) {
227
+ $bytes = New-Object byte[] $c.CredentialBlobSize
228
+ [System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $bytes, 0, $c.CredentialBlobSize)
229
+ [Convert]::ToBase64String($bytes)
230
+ break
231
+ }
232
+ }
233
+ }
234
+ `;
235
+ const encoded = Buffer.from(ps, 'utf16le').toString('base64');
236
+ const { stdout } = await execAsync(
237
+ `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}`,
238
+ { windowsHide: true, maxBuffer: 1024 * 1024 },
239
+ );
240
+ return (stdout || '').trim();
241
+ }
242
+
190
243
  /**
191
- * Get the Supabase access token stored by `supabase login`.
192
- * macOS: stored in Keychain under "Supabase CLI" (go-keyring-base64 encoded).
193
- * Fallback: SUPABASE_ACCESS_TOKEN environment variable.
244
+ * Get the Supabase access token stored by `supabase login`, cross-platform:
245
+ * - SUPABASE_ACCESS_TOKEN env var (any OS, also the CI path)
246
+ * - macOS: Keychain (`security`)
247
+ * - Windows: Credential Manager (CredRead via PowerShell)
248
+ * - Linux: libsecret (`secret-tool`)
249
+ * Returns null if none works (caller degrades to a manual hint).
194
250
  */
195
251
  async function getSupabaseAccessToken() {
196
252
  if (process.env.SUPABASE_ACCESS_TOKEN) return process.env.SUPABASE_ACCESS_TOKEN;
197
- try {
198
- const { stdout } = await execAsync('security find-generic-password -s "Supabase CLI" -w');
199
- const raw = stdout.trim();
200
- if (raw.startsWith('go-keyring-base64:')) {
201
- return Buffer.from(raw.replace('go-keyring-base64:', ''), 'base64').toString('utf8');
253
+
254
+ const decodeKeyring = (raw) => {
255
+ const s = (raw || '').trim();
256
+ if (!s) return null;
257
+ if (s.startsWith('go-keyring-base64:')) {
258
+ return Buffer.from(s.replace('go-keyring-base64:', ''), 'base64').toString('utf8').trim() || null;
259
+ }
260
+ return s;
261
+ };
262
+
263
+ if (process.platform === 'darwin') {
264
+ try {
265
+ const { stdout } = await execAsync(`security find-generic-password -s "${SUPABASE_KEYRING_SERVICE}" -w`);
266
+ return decodeKeyring(stdout);
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ if (process.platform === 'win32') {
273
+ try {
274
+ const b64 = await readWindowsSupabaseTokenBase64();
275
+ if (!b64) return null;
276
+ const buf = Buffer.from(b64, 'base64');
277
+ // UTF-16LE blobs have a NUL after most bytes; UTF-8 blobs don't.
278
+ const nulCount = buf.reduce((n, b) => n + (b === 0 ? 1 : 0), 0);
279
+ const token = (nulCount > buf.length / 4 ? buf.toString('utf16le') : buf.toString('utf8')).trim();
280
+ return decodeKeyring(token);
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ // Linux (libsecret). The account key has varied by CLI version, so try the
287
+ // ones we've seen before giving up.
288
+ for (const user of ['supabase', 'access-token']) {
289
+ try {
290
+ const { stdout } = await execAsync(
291
+ `secret-tool lookup service "${SUPABASE_KEYRING_SERVICE}" username "${user}"`,
292
+ );
293
+ const token = decodeKeyring(stdout);
294
+ if (token) return token;
295
+ } catch {
296
+ // try next
202
297
  }
203
- return raw || null;
204
- } catch {
205
- return null;
206
298
  }
299
+ return null;
207
300
  }
208
301
 
209
302
  /**
@@ -352,19 +445,30 @@ async function dbPush(projectDir) {
352
445
  }
353
446
 
354
447
  /**
355
- * Set a single Supabase secret safely using execFile (no shell expansion).
356
- * supabase secrets set accepts KEY=VALUE as a positional arg.
448
+ * Set a single Supabase secret.
449
+ *
450
+ * On Windows the supabase binary is `supabase.cmd`, which execFile (no shell)
451
+ * can't launch — it failed with "spawn supabase ENOENT". We go through the
452
+ * shell-based run() instead, which resolves the .cmd and uses the augmented
453
+ * PATH. To keep the secret value off the command line (no shell escaping /
454
+ * injection), we hand it to the CLI via a temporary --env-file rather than as a
455
+ * positional KEY=VALUE arg.
357
456
  */
358
457
  async function setSecret(projectDir, key, value) {
458
+ let tmpDir;
359
459
  try {
360
- await execFileAsync('supabase', ['secrets', 'set', `${key}=${value}`], {
361
- cwd: projectDir,
362
- maxBuffer: 10 * 1024 * 1024,
363
- env: process.env,
364
- });
460
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kasy-secret-'));
461
+ const envFile = path.join(tmpDir, 'secret.env');
462
+ // The value is single-line (JSON is compacted upstream). dotenv splits on the
463
+ // first '=' only, so any '=' inside the value is preserved.
464
+ await fs.writeFile(envFile, `${key}=${value}\n`, 'utf8');
465
+ const r = await run(`supabase secrets set --env-file "${envFile}"`, projectDir);
466
+ if (!r.ok) return { ok: false, error: (r.stderr || r.error || '').slice(0, 300) };
365
467
  return { ok: true };
366
468
  } catch (err) {
367
469
  return { ok: false, error: err.message };
470
+ } finally {
471
+ if (tmpDir) await fs.remove(tmpDir).catch(() => {});
368
472
  }
369
473
  }
370
474
 
@@ -14,12 +14,14 @@ const { promisify } = require('node:util');
14
14
  const fs = require('fs-extra');
15
15
  const path = require('node:path');
16
16
  const os = require('node:os');
17
+ const { augmentedEnv } = require('../../utils/env-tools');
17
18
 
18
19
  const execAsync = promisify(exec);
19
20
 
20
21
  async function run(cmd) {
21
22
  try {
22
- const { stdout, stderr } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024 });
23
+ // augmentedEnv so a gcloud installed earlier this run is found on Windows.
24
+ const { stdout, stderr } = await execAsync(cmd, { maxBuffer: 5 * 1024 * 1024, env: augmentedEnv() });
23
25
  return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() };
24
26
  } catch (err) {
25
27
  return {
@@ -86,17 +88,31 @@ async function createFcmServiceAccountKey(projectId) {
86
88
  return { ok: false, error: 'projectId is required' };
87
89
  }
88
90
 
89
- const saResult = await findFirebaseAdminSdkSA(projectId.trim());
90
- if (!saResult.ok) {
91
- return { ok: false, error: saResult.error };
92
- }
93
-
94
- const saEmail = saResult.email;
91
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
95
92
 
96
- // Ensure the service account can send FCM messages (idempotent)
97
- const roleResult = await grantFcmAdminRole(projectId.trim(), saEmail);
98
- if (!roleResult.ok) {
99
- return { ok: false, error: roleResult.error };
93
+ // On a brand-new project the Firebase Admin SDK service account is provisioned
94
+ // asynchronously after Firebase is added, and the IAM role grant also needs a
95
+ // moment to take. Both can fail immediately on the first try, which is why
96
+ // push used to come out half-configured on fresh Supabase/API projects. Retry
97
+ // the discover+grant with backoff before giving up.
98
+ let saEmail = '';
99
+ let lastErr = 'Firebase Admin SDK service account not found';
100
+ for (let attempt = 1; attempt <= 7; attempt++) {
101
+ const saResult = await findFirebaseAdminSdkSA(projectId.trim());
102
+ if (saResult.ok) {
103
+ const roleResult = await grantFcmAdminRole(projectId.trim(), saResult.email);
104
+ if (roleResult.ok) {
105
+ saEmail = saResult.email;
106
+ break;
107
+ }
108
+ lastErr = roleResult.error;
109
+ } else {
110
+ lastErr = saResult.error;
111
+ }
112
+ if (attempt < 7) await sleep(10000);
113
+ }
114
+ if (!saEmail) {
115
+ return { ok: false, error: lastErr };
100
116
  }
101
117
 
102
118
  const tmpPath = path.join(os.tmpdir(), `kasy-fcm-${Date.now()}.json`);
@@ -105,7 +121,6 @@ async function createFcmServiceAccountKey(projectId) {
105
121
  // IAM permission takes a moment to propagate — so the first key-create often
106
122
  // fails with "permission still propagating". Back off and retry a few times
107
123
  // before giving up, instead of leaving push half-configured.
108
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
109
124
  let keyResult;
110
125
  for (let attempt = 1; attempt <= 4; attempt++) {
111
126
  keyResult = await run(
@@ -38,7 +38,11 @@ async function run(cmd, cwd, timeout) {
38
38
  }
39
39
 
40
40
  async function pubGet(projectDir) {
41
- return run('flutter pub get', projectDir, 300_000); // 5 min
41
+ // 15 min: the FIRST `flutter pub get` on a fresh machine downloads the whole
42
+ // dependency tree (this template pulls in firebase, supabase, revenuecat,
43
+ // stripe, sentry…), which timed out at the old 5-min cap on slower Windows
44
+ // connections. It's a ceiling, not a wait — a warm cache still returns fast.
45
+ return run('flutter pub get', projectDir, 900_000);
42
46
  }
43
47
 
44
48
  async function slangGenerate(projectDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.30.0",
3
+ "version": "1.31.1",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -0,0 +1,14 @@
1
+ import 'package:flutter/foundation.dart';
2
+
3
+ /// The bottom-bar tab the user last opened, held at top level so it outlives the
4
+ /// [BottomMenu] remount that happens whenever the responsive layout flips
5
+ /// small↔large (e.g. toggling the web device preview, which renders the app in a
6
+ /// phone-width frame). Persisting it lets the bottom bar restore the tab instead
7
+ /// of snapping back to the first one on remount or hard reload (F5).
8
+ ///
9
+ /// It lives in its own dependency-free file so both [BottomMenu] and the logout
10
+ /// flow can touch it without an import cycle. Cleared on logout so a fresh login
11
+ /// always lands on the default tab. Null until the user opens a tab.
12
+ final ValueNotifier<String?> activeTabRouteNotifier = ValueNotifier<String?>(
13
+ null,
14
+ );
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
4
4
  import 'package:flutter/services.dart';
5
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
  import 'package:kasy_kit/components/kasy_sidebar.dart';
7
+ import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
7
8
  import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
8
9
  import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
9
10
  import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
@@ -15,6 +16,12 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
15
16
  import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
16
17
  import 'package:kasy_kit/i18n/translations.g.dart';
17
18
 
19
+ /// Records the active tab so it survives the next remount. Wired to
20
+ /// [bart.BartScaffold.onRouteChanged]. See [activeTabRouteNotifier].
21
+ void _rememberActiveTab(bart.BartMenuRoute route) {
22
+ activeTabRouteNotifier.value = route.path;
23
+ }
24
+
18
25
  /// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
19
26
  ///
20
27
  /// [ResponsiveLayout] swaps between three [bart.BartScaffold]s (small / medium /
@@ -89,6 +96,7 @@ class BottomMenu extends StatelessWidget {
89
96
  showBottomBarOnStart: showBottomBarOnStart,
90
97
  scaffoldOptions: scaffoldOptions,
91
98
  sideBarOptions: connectedSidebar(),
99
+ onRouteChanged: _rememberActiveTab,
92
100
  ),
93
101
  );
94
102
 
@@ -104,6 +112,7 @@ class BottomMenu extends StatelessWidget {
104
112
  initialRoute: resolvedInitialRoute,
105
113
  showBottomBarOnStart: showBottomBarOnStart,
106
114
  scaffoldOptions: scaffoldOptions,
115
+ onRouteChanged: _rememberActiveTab,
107
116
  ),
108
117
  medium: connectedScaffold(),
109
118
  large: connectedScaffold(),
@@ -115,6 +124,19 @@ class BottomMenu extends StatelessWidget {
115
124
  if (route != null) {
116
125
  return route;
117
126
  }
127
+ // Restore the last tab across a remount. This is the reliable source: the
128
+ // browser URL is contended by both GoRouter and Bart, but this notifier is
129
+ // owned solely by the bottom bar and lives above the rebuilt subtree.
130
+ //
131
+ // The value is returned as a BARE tab name (e.g. "settings", not
132
+ // "/settings"). Bart's NestedNavigator matches routes by their exact path,
133
+ // which has no leading slash; passing a "/"-prefixed route makes Flutter's
134
+ // Navigator split it into segments that never match, so it falls back to the
135
+ // first tab (home). See bart's nested_navigator.dart onGenerateRoute.
136
+ final String? lastTab = activeTabRouteNotifier.value;
137
+ if (lastTab != null && _isKnownTab(lastTab)) {
138
+ return _bareTab(lastTab);
139
+ }
118
140
  if (!kIsWeb) {
119
141
  return null;
120
142
  }
@@ -124,21 +146,22 @@ class BottomMenu extends StatelessWidget {
124
146
  return null;
125
147
  }
126
148
  // A single segment is a bottom-bar tab (home/notifications/settings/…).
127
- // Bart keeps the browser URL in sync with the active tab via
128
- // history.pushState, so honoring it here means a remount (e.g. toggling the
129
- // web device preview) or a hard reload restores the tab the user was on
130
- // instead of snapping back to the first one. Only accept it when it maps to
131
- // a real tab; anything else falls back to the default tab.
149
+ // Bart keeps the browser URL in sync via history.pushState, so honoring it
150
+ // here also restores the tab on a hard reload (F5). Only accept it when it
151
+ // maps to a real tab; anything else falls back to the default tab.
132
152
  if (segments.length == 1) {
133
- final String tab = segments.first;
134
- final bool isKnownTab = subRoutes().any(
135
- (r) => r.path.replaceAll('/', '') == tab,
136
- );
137
- return isKnownTab ? '/$tab' : null;
153
+ return _isKnownTab(segments.first) ? _bareTab(segments.first) : null;
138
154
  }
139
155
  return '/${segments.join('/')}';
140
156
  }
141
157
 
158
+ String _bareTab(String path) => path.replaceAll('/', '');
159
+
160
+ bool _isKnownTab(String path) {
161
+ final String tab = _bareTab(path);
162
+ return subRoutes().any((r) => _bareTab(r.path) == tab);
163
+ }
164
+
142
165
  String _initialWebPath(Uri uri) {
143
166
  final path = uri.path;
144
167
  if (path != '/' && path.isNotEmpty) {
@@ -1,6 +1,7 @@
1
1
  import 'dart:async';
2
2
 
3
3
  import 'package:flutter/foundation.dart';
4
+ import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
4
5
  import 'package:kasy_kit/core/config/features.dart';
5
6
  import 'package:kasy_kit/core/data/models/entitlement.dart';
6
7
  import 'package:kasy_kit/core/data/models/subscription.dart';
@@ -139,6 +140,9 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
139
140
  // Biometric lock is a per-account preference, not a device-wide one.
140
141
  // The next user signing in on this install should start without it set.
141
142
  await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
143
+ // Forget the last bottom-bar tab so the next login lands on the default tab
144
+ // (Home) instead of wherever the previous account left off.
145
+ activeTabRouteNotifier.value = null;
142
146
  state = const UserState(user: User.anonymous());
143
147
  if (mode == AuthenticationMode.anonymous) {
144
148
  await _loadAnonymousState();
@@ -13,7 +13,10 @@ import 'package:provider/provider.dart';
13
13
  import 'package:shared_preferences/shared_preferences.dart';
14
14
  import 'package:universal_html/html.dart' as html;
15
15
 
16
- const String webDevicePreviewEnabledPrefKey = 'web_device_preview_enabled';
16
+ // Suffixed `_v2` because the default flipped from OFF to ON. Values saved under
17
+ // the old key were written while the default was OFF, so we ignore them and
18
+ // start fresh — every install now gets the new ON default until it's toggled.
19
+ const String webDevicePreviewEnabledPrefKey = 'web_device_preview_enabled_v2';
17
20
  const String _platformPrefKey = 'web_device_preview_platform';
18
21
  const String _iosIndexPrefKey = 'web_device_preview_ios_index';
19
22
  const String _androidIndexPrefKey = 'web_device_preview_android_index';
@@ -203,11 +206,11 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
203
206
  Future<void> _bootstrap() async {
204
207
  final prefs = await SharedPreferences.getInstance();
205
208
 
206
- // Default OFF: the web app renders at its real desktop proportions instead
207
- // of inside a simulated device frame (which distorts scale/proportion).
208
- // Devs who want to preview the mobile app in a device frame toggle it on
209
- // (shortcut), and that choice persists for subsequent launches.
210
- final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ?? false;
209
+ // Default ON: previewing the mobile app inside a device frame is the
210
+ // expected first view for this mobile-first template. Devs who prefer the
211
+ // real desktop proportions toggle it off (shortcut), and that choice
212
+ // persists for subsequent launches.
213
+ final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ?? true;
211
214
  final savedPlatform = prefs.getInt(_platformPrefKey);
212
215
  final savedIosIndex = prefs.getInt(_iosIndexPrefKey);
213
216
  final savedAndroidIndex = prefs.getInt(_androidIndexPrefKey);
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
16
16
  # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
17
  # In Windows, build-name is used as the major, minor, and patch parts
18
18
  # of the product and file versions while build-number is used as the build suffix.
19
- version: 1.0.0+34
19
+ version: 1.0.0+35
20
20
 
21
21
  environment:
22
22
  sdk: ^3.11.0