kasy-cli 1.30.0 → 1.31.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/lib/scaffold/backends/supabase/deploy.js +111 -18
- package/lib/scaffold/shared/fcm-service-account.js +27 -12
- package/package.json +1 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +43 -10
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +5 -5
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
* Requires: supabase CLI installed, user logged in (supabase login).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const { exec
|
|
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,20 +187,102 @@ 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" with the key/user "access-token". Each OS keeps that in a
|
|
192
|
+
// different vault, so reading it back is per-OS. Used by the Management API
|
|
193
|
+
// calls below (auth providers), which the CLI itself has no command for.
|
|
194
|
+
const SUPABASE_KEYRING_SERVICE = 'Supabase CLI';
|
|
195
|
+
const SUPABASE_KEYRING_USER = 'access-token';
|
|
196
|
+
|
|
197
|
+
// Read a generic credential out of the Windows Credential Manager. go-keyring's
|
|
198
|
+
// wincred backend names the target "<service>:<user>" and stores the secret as a
|
|
199
|
+
// raw blob (UTF-8 or UTF-16LE depending on version), so we return the base64 of
|
|
200
|
+
// the blob and let the caller pick the right decoding.
|
|
201
|
+
async function readWindowsCredentialBase64(target) {
|
|
202
|
+
const ps = `
|
|
203
|
+
$ErrorActionPreference = 'Stop'
|
|
204
|
+
$sig = @"
|
|
205
|
+
using System;
|
|
206
|
+
using System.Runtime.InteropServices;
|
|
207
|
+
public class KasyCred {
|
|
208
|
+
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
209
|
+
public static extern bool CredRead(string target, int type, int flags, out IntPtr cred);
|
|
210
|
+
[DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred);
|
|
211
|
+
[StructLayout(LayoutKind.Sequential)] public struct CREDENTIAL {
|
|
212
|
+
public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
|
|
213
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
214
|
+
public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
|
|
215
|
+
public int AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
"@
|
|
219
|
+
Add-Type $sig | Out-Null
|
|
220
|
+
$ptr = [IntPtr]::Zero
|
|
221
|
+
if ([KasyCred]::CredRead('${target}', 1, 0, [ref]$ptr)) {
|
|
222
|
+
$c = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type]([KasyCred+CREDENTIAL]))
|
|
223
|
+
$bytes = New-Object byte[] $c.CredentialBlobSize
|
|
224
|
+
[System.Runtime.InteropServices.Marshal]::Copy($c.CredentialBlob, $bytes, 0, $c.CredentialBlobSize)
|
|
225
|
+
[KasyCred]::CredFree($ptr)
|
|
226
|
+
[Convert]::ToBase64String($bytes)
|
|
227
|
+
}
|
|
228
|
+
`;
|
|
229
|
+
const encoded = Buffer.from(ps, 'utf16le').toString('base64');
|
|
230
|
+
const { stdout } = await execAsync(
|
|
231
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}`,
|
|
232
|
+
{ windowsHide: true, maxBuffer: 1024 * 1024 },
|
|
233
|
+
);
|
|
234
|
+
return (stdout || '').trim();
|
|
235
|
+
}
|
|
236
|
+
|
|
190
237
|
/**
|
|
191
|
-
* Get the Supabase access token stored by `supabase login
|
|
192
|
-
*
|
|
193
|
-
*
|
|
238
|
+
* Get the Supabase access token stored by `supabase login`, cross-platform:
|
|
239
|
+
* - SUPABASE_ACCESS_TOKEN env var (any OS, also the CI path)
|
|
240
|
+
* - macOS: Keychain (`security`)
|
|
241
|
+
* - Windows: Credential Manager (CredRead via PowerShell)
|
|
242
|
+
* - Linux: libsecret (`secret-tool`)
|
|
243
|
+
* Returns null if none works (caller degrades to a manual hint).
|
|
194
244
|
*/
|
|
195
245
|
async function getSupabaseAccessToken() {
|
|
196
246
|
if (process.env.SUPABASE_ACCESS_TOKEN) return process.env.SUPABASE_ACCESS_TOKEN;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
247
|
+
|
|
248
|
+
const decodeKeyring = (raw) => {
|
|
249
|
+
const s = (raw || '').trim();
|
|
250
|
+
if (!s) return null;
|
|
251
|
+
if (s.startsWith('go-keyring-base64:')) {
|
|
252
|
+
return Buffer.from(s.replace('go-keyring-base64:', ''), 'base64').toString('utf8').trim() || null;
|
|
253
|
+
}
|
|
254
|
+
return s;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (process.platform === 'darwin') {
|
|
258
|
+
try {
|
|
259
|
+
const { stdout } = await execAsync(`security find-generic-password -s "${SUPABASE_KEYRING_SERVICE}" -w`);
|
|
260
|
+
return decodeKeyring(stdout);
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (process.platform === 'win32') {
|
|
267
|
+
try {
|
|
268
|
+
const b64 = await readWindowsCredentialBase64(`${SUPABASE_KEYRING_SERVICE}:${SUPABASE_KEYRING_USER}`);
|
|
269
|
+
if (!b64) return null;
|
|
270
|
+
const buf = Buffer.from(b64, 'base64');
|
|
271
|
+
// UTF-16LE blobs have a NUL after most bytes; UTF-8 blobs don't.
|
|
272
|
+
const nulCount = buf.reduce((n, b) => n + (b === 0 ? 1 : 0), 0);
|
|
273
|
+
const token = (nulCount > buf.length / 4 ? buf.toString('utf16le') : buf.toString('utf8')).trim();
|
|
274
|
+
return decodeKeyring(token);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
202
277
|
}
|
|
203
|
-
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Linux (libsecret).
|
|
281
|
+
try {
|
|
282
|
+
const { stdout } = await execAsync(
|
|
283
|
+
`secret-tool lookup service "${SUPABASE_KEYRING_SERVICE}" username "${SUPABASE_KEYRING_USER}"`,
|
|
284
|
+
);
|
|
285
|
+
return decodeKeyring(stdout);
|
|
204
286
|
} catch {
|
|
205
287
|
return null;
|
|
206
288
|
}
|
|
@@ -352,19 +434,30 @@ async function dbPush(projectDir) {
|
|
|
352
434
|
}
|
|
353
435
|
|
|
354
436
|
/**
|
|
355
|
-
* Set a single Supabase secret
|
|
356
|
-
*
|
|
437
|
+
* Set a single Supabase secret.
|
|
438
|
+
*
|
|
439
|
+
* On Windows the supabase binary is `supabase.cmd`, which execFile (no shell)
|
|
440
|
+
* can't launch — it failed with "spawn supabase ENOENT". We go through the
|
|
441
|
+
* shell-based run() instead, which resolves the .cmd and uses the augmented
|
|
442
|
+
* PATH. To keep the secret value off the command line (no shell escaping /
|
|
443
|
+
* injection), we hand it to the CLI via a temporary --env-file rather than as a
|
|
444
|
+
* positional KEY=VALUE arg.
|
|
357
445
|
*/
|
|
358
446
|
async function setSecret(projectDir, key, value) {
|
|
447
|
+
let tmpDir;
|
|
359
448
|
try {
|
|
360
|
-
await
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
});
|
|
449
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kasy-secret-'));
|
|
450
|
+
const envFile = path.join(tmpDir, 'secret.env');
|
|
451
|
+
// The value is single-line (JSON is compacted upstream). dotenv splits on the
|
|
452
|
+
// first '=' only, so any '=' inside the value is preserved.
|
|
453
|
+
await fs.writeFile(envFile, `${key}=${value}\n`, 'utf8');
|
|
454
|
+
const r = await run(`supabase secrets set --env-file "${envFile}"`, projectDir);
|
|
455
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.error || '').slice(0, 300) };
|
|
365
456
|
return { ok: true };
|
|
366
457
|
} catch (err) {
|
|
367
458
|
return { ok: false, error: err.message };
|
|
459
|
+
} finally {
|
|
460
|
+
if (tmpDir) await fs.remove(tmpDir).catch(() => {});
|
|
368
461
|
}
|
|
369
462
|
}
|
|
370
463
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 <= 5; 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 < 5) await sleep(8000);
|
|
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(
|
package/package.json
CHANGED
|
@@ -15,6 +15,23 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
|
15
15
|
import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
|
|
16
16
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
17
17
|
|
|
18
|
+
/// The bottom-bar tab the user last opened, held at top level so it outlives the
|
|
19
|
+
/// [BottomMenu] remount that happens whenever the responsive layout flips
|
|
20
|
+
/// small↔large. Toggling the web device preview does exactly that: the app
|
|
21
|
+
/// renders inside a phone-width frame when on and at full desktop width when
|
|
22
|
+
/// off, so each toggle rebuilds [bart.BartScaffold] from scratch (its index
|
|
23
|
+
/// notifier starts at 0). Persisting the tab here lets [BottomMenu] restore it
|
|
24
|
+
/// instead of snapping back to the first tab. Null until the user opens a tab.
|
|
25
|
+
final ValueNotifier<String?> activeTabRouteNotifier = ValueNotifier<String?>(
|
|
26
|
+
null,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/// Records the active tab so it survives the next remount. Wired to
|
|
30
|
+
/// [bart.BartScaffold.onRouteChanged].
|
|
31
|
+
void _rememberActiveTab(bart.BartMenuRoute route) {
|
|
32
|
+
activeTabRouteNotifier.value = route.path;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
/// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
|
|
19
36
|
///
|
|
20
37
|
/// [ResponsiveLayout] swaps between three [bart.BartScaffold]s (small / medium /
|
|
@@ -89,6 +106,7 @@ class BottomMenu extends StatelessWidget {
|
|
|
89
106
|
showBottomBarOnStart: showBottomBarOnStart,
|
|
90
107
|
scaffoldOptions: scaffoldOptions,
|
|
91
108
|
sideBarOptions: connectedSidebar(),
|
|
109
|
+
onRouteChanged: _rememberActiveTab,
|
|
92
110
|
),
|
|
93
111
|
);
|
|
94
112
|
|
|
@@ -104,6 +122,7 @@ class BottomMenu extends StatelessWidget {
|
|
|
104
122
|
initialRoute: resolvedInitialRoute,
|
|
105
123
|
showBottomBarOnStart: showBottomBarOnStart,
|
|
106
124
|
scaffoldOptions: scaffoldOptions,
|
|
125
|
+
onRouteChanged: _rememberActiveTab,
|
|
107
126
|
),
|
|
108
127
|
medium: connectedScaffold(),
|
|
109
128
|
large: connectedScaffold(),
|
|
@@ -115,6 +134,19 @@ class BottomMenu extends StatelessWidget {
|
|
|
115
134
|
if (route != null) {
|
|
116
135
|
return route;
|
|
117
136
|
}
|
|
137
|
+
// Restore the last tab across a remount. This is the reliable source: the
|
|
138
|
+
// browser URL is contended by both GoRouter and Bart, but this notifier is
|
|
139
|
+
// owned solely by the bottom bar and lives above the rebuilt subtree.
|
|
140
|
+
//
|
|
141
|
+
// The value is returned as a BARE tab name (e.g. "settings", not
|
|
142
|
+
// "/settings"). Bart's NestedNavigator matches routes by their exact path,
|
|
143
|
+
// which has no leading slash; passing a "/"-prefixed route makes Flutter's
|
|
144
|
+
// Navigator split it into segments that never match, so it falls back to the
|
|
145
|
+
// first tab (home). See bart's nested_navigator.dart onGenerateRoute.
|
|
146
|
+
final String? lastTab = activeTabRouteNotifier.value;
|
|
147
|
+
if (lastTab != null && _isKnownTab(lastTab)) {
|
|
148
|
+
return _bareTab(lastTab);
|
|
149
|
+
}
|
|
118
150
|
if (!kIsWeb) {
|
|
119
151
|
return null;
|
|
120
152
|
}
|
|
@@ -124,21 +156,22 @@ class BottomMenu extends StatelessWidget {
|
|
|
124
156
|
return null;
|
|
125
157
|
}
|
|
126
158
|
// A single segment is a bottom-bar tab (home/notifications/settings/…).
|
|
127
|
-
// Bart keeps the browser URL in sync
|
|
128
|
-
//
|
|
129
|
-
//
|
|
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.
|
|
159
|
+
// Bart keeps the browser URL in sync via history.pushState, so honoring it
|
|
160
|
+
// here also restores the tab on a hard reload (F5). Only accept it when it
|
|
161
|
+
// maps to a real tab; anything else falls back to the default tab.
|
|
132
162
|
if (segments.length == 1) {
|
|
133
|
-
|
|
134
|
-
final bool isKnownTab = subRoutes().any(
|
|
135
|
-
(r) => r.path.replaceAll('/', '') == tab,
|
|
136
|
-
);
|
|
137
|
-
return isKnownTab ? '/$tab' : null;
|
|
163
|
+
return _isKnownTab(segments.first) ? _bareTab(segments.first) : null;
|
|
138
164
|
}
|
|
139
165
|
return '/${segments.join('/')}';
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
String _bareTab(String path) => path.replaceAll('/', '');
|
|
169
|
+
|
|
170
|
+
bool _isKnownTab(String path) {
|
|
171
|
+
final String tab = _bareTab(path);
|
|
172
|
+
return subRoutes().any((r) => _bareTab(r.path) == tab);
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
String _initialWebPath(Uri uri) {
|
|
143
176
|
final path = uri.path;
|
|
144
177
|
if (path != '/' && path.isNotEmpty) {
|
|
@@ -203,11 +203,11 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
|
|
|
203
203
|
Future<void> _bootstrap() async {
|
|
204
204
|
final prefs = await SharedPreferences.getInstance();
|
|
205
205
|
|
|
206
|
-
// Default
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ??
|
|
206
|
+
// Default ON: previewing the mobile app inside a device frame is the
|
|
207
|
+
// expected first view for this mobile-first template. Devs who prefer the
|
|
208
|
+
// real desktop proportions toggle it off (shortcut), and that choice
|
|
209
|
+
// persists for subsequent launches.
|
|
210
|
+
final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ?? true;
|
|
211
211
|
final savedPlatform = prefs.getInt(_platformPrefKey);
|
|
212
212
|
final savedIosIndex = prefs.getInt(_iosIndexPrefKey);
|
|
213
213
|
final savedAndroidIndex = prefs.getInt(_androidIndexPrefKey);
|