kasy-cli 1.31.1 → 1.31.2

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.
@@ -1908,9 +1908,13 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1908
1908
  fcmSpinner.stop(tr('new.fcm.generating'));
1909
1909
  if (fcmResult.ok) {
1910
1910
  fcmServiceAccountJson = fcmResult.json;
1911
+ if (fcmResult.policyAdjusted) ui.log.info(tr('new.fcm.policyLifted'));
1911
1912
  printStepResult({ name: 'fcm-key', ok: true, detail: tr('new.fcm.ok') }, language);
1912
1913
  } else {
1913
- printStepResult({ name: 'fcm-key', ok: false, detail: tr('new.fcm.failSupabase') }, language);
1914
+ const detail = (fcmResult.errorKind === 'orgPolicyNoPermission' || fcmResult.errorKind === 'orgPolicyBlocked')
1915
+ ? tr('new.fcm.orgPolicyBlocked')
1916
+ : tr('new.fcm.failSupabase');
1917
+ printStepResult({ name: 'fcm-key', ok: false, detail }, language);
1914
1918
  }
1915
1919
  }
1916
1920
 
@@ -2002,13 +2006,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
2002
2006
  await fs.appendFile(gitignorePath, `\n# FCM service account key (credentials — do not commit)\n${gitignoreEntry}\n`, 'utf8');
2003
2007
  }
2004
2008
  }
2009
+ if (fcmResult.policyAdjusted) ui.log.info(tr('new.fcm.policyLifted'));
2005
2010
  printStepResult({ name: 'fcm-key-saved', ok: true }, language);
2006
2011
  ui.log.message(tr('new.fcm.serverConfig'));
2007
2012
  } catch (_) {
2008
2013
  printStepResult({ name: 'fcm-key-saved', ok: false, detail: 'salvar arquivo de chave falhou' }, language);
2009
2014
  }
2010
2015
  } else {
2011
- printStepResult({ name: 'fcm-key', ok: false, detail: tr('new.fcm.failApi') }, language);
2016
+ const detail = (fcmResult.errorKind === 'orgPolicyNoPermission' || fcmResult.errorKind === 'orgPolicyBlocked')
2017
+ ? tr('new.fcm.orgPolicyBlocked')
2018
+ : tr('new.fcm.failApi');
2019
+ printStepResult({ name: 'fcm-key', ok: false, detail }, language);
2012
2020
  }
2013
2021
  }
2014
2022
 
@@ -299,6 +299,51 @@ async function getSupabaseAccessToken() {
299
299
  return null;
300
300
  }
301
301
 
302
+ /**
303
+ * PATCH the Supabase Management API auth config with a JSON body.
304
+ *
305
+ * Uses Node's built-in fetch (Node 18+) instead of shelling out to curl. curl
306
+ * with a single-quoted JSON body is unreliable on Windows: cmd.exe does not strip
307
+ * single quotes and treats the inner double quotes specially, so the body arrives
308
+ * mangled and the response comes back as the leaked payload — which then fails to
309
+ * parse ("Unexpected token ''', \"'{external\"... is not valid JSON"). fetch sends
310
+ * the exact bytes with no shell quoting involved, identically on every OS.
311
+ *
312
+ * @param {string} projectRef
313
+ * @param {string} token - Supabase access token
314
+ * @param {object} body - auth config fields to PATCH
315
+ * @returns {Promise<{ ok: boolean, data?: object, error?: string }>}
316
+ */
317
+ async function patchAuthConfig(projectRef, token, body) {
318
+ let res;
319
+ try {
320
+ res = await fetch(
321
+ `https://api.supabase.com/v1/projects/${projectRef}/config/auth`,
322
+ {
323
+ method: 'PATCH',
324
+ headers: {
325
+ Authorization: `Bearer ${token}`,
326
+ 'Content-Type': 'application/json',
327
+ },
328
+ body: JSON.stringify(body),
329
+ }
330
+ );
331
+ } catch (err) {
332
+ return { ok: false, error: `Network error calling Management API: ${err.message}` };
333
+ }
334
+ const text = await res.text();
335
+ let data;
336
+ try {
337
+ data = JSON.parse(text);
338
+ } catch {
339
+ return { ok: false, error: `Management API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}` };
340
+ }
341
+ if (!res.ok) {
342
+ return { ok: false, error: data.message || `Management API error (HTTP ${res.status})`, data };
343
+ }
344
+ return { ok: true, data };
345
+ }
346
+
302
347
  /**
303
348
  * Enable Google Sign-In on the Supabase project via Management API.
304
349
  *
@@ -313,27 +358,15 @@ async function enableGoogleSignIn(projectRef, webClientId, clientSecret) {
313
358
  if (!webClientId || !clientSecret) return { ok: false, error: 'webClientId and clientSecret are required' };
314
359
  const token = await getSupabaseAccessToken();
315
360
  if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
316
- const payload = JSON.stringify({
361
+ const result = await patchAuthConfig(projectRef, token, {
317
362
  external_google_enabled: true,
318
363
  external_google_client_id: webClientId,
319
364
  external_google_secret: clientSecret,
320
365
  external_google_skip_nonce_check: true,
321
366
  });
322
- const result = await run(
323
- `curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
324
- `-H "Authorization: Bearer ${token}" ` +
325
- `-H "Content-Type: application/json" ` +
326
- `-d '${payload}'`,
327
- process.cwd()
328
- );
329
367
  if (!result.ok) return { ok: false, error: result.error };
330
- try {
331
- const data = JSON.parse(result.stdout);
332
- if (data.external_google_enabled === true) return { ok: true };
333
- return { ok: false, error: data.message || JSON.stringify(data) };
334
- } catch {
335
- return { ok: false, error: 'Could not parse Management API response' };
336
- }
368
+ if (result.data.external_google_enabled === true) return { ok: true };
369
+ return { ok: false, error: result.data.message || JSON.stringify(result.data) };
337
370
  }
338
371
 
339
372
  /**
@@ -357,28 +390,16 @@ async function enableAppleSignIn(projectRef, bundleId) {
357
390
  if (!bundleId) return { ok: false, error: 'bundleId is required' };
358
391
  const token = await getSupabaseAccessToken();
359
392
  if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
360
- const payload = JSON.stringify({
393
+ const result = await patchAuthConfig(projectRef, token, {
361
394
  external_apple_enabled: true,
362
395
  external_apple_client_id: bundleId,
363
396
  });
364
- const result = await run(
365
- `curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
366
- `-H "Authorization: Bearer ${token}" ` +
367
- `-H "Content-Type: application/json" ` +
368
- `-d '${payload}'`,
369
- process.cwd()
370
- );
371
397
  if (!result.ok) return { ok: false, error: result.error };
372
- try {
373
- const data = JSON.parse(result.stdout);
374
- const allowedIds = String(data.external_apple_client_id || '')
375
- .split(',')
376
- .map((s) => s.trim());
377
- if (data.external_apple_enabled === true && allowedIds.includes(bundleId)) return { ok: true };
378
- return { ok: false, error: data.message || JSON.stringify(data) };
379
- } catch {
380
- return { ok: false, error: 'Could not parse Management API response' };
381
- }
398
+ const allowedIds = String(result.data.external_apple_client_id || '')
399
+ .split(',')
400
+ .map((s) => s.trim());
401
+ if (result.data.external_apple_enabled === true && allowedIds.includes(bundleId)) return { ok: true };
402
+ return { ok: false, error: result.data.message || JSON.stringify(result.data) };
382
403
  }
383
404
 
384
405
  /**
@@ -391,24 +412,12 @@ async function enableAppleSignIn(projectRef, bundleId) {
391
412
  async function enableAnonymousSignIn(projectRef) {
392
413
  const token = await getSupabaseAccessToken();
393
414
  if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
394
- const payload = JSON.stringify({
415
+ const result = await patchAuthConfig(projectRef, token, {
395
416
  external_anonymous_users_enabled: true,
396
417
  mailer_autoconfirm: true,
397
418
  });
398
- const result = await run(
399
- `curl -s -X PATCH "https://api.supabase.com/v1/projects/${projectRef}/config/auth" ` +
400
- `-H "Authorization: Bearer ${token}" ` +
401
- `-H "Content-Type: application/json" ` +
402
- `-d '${payload}'`,
403
- process.cwd()
404
- );
405
419
  if (!result.ok) return { ok: false, error: result.error };
406
- try {
407
- const data = JSON.parse(result.stdout);
408
- return { ok: data.external_anonymous_users_enabled === true };
409
- } catch {
410
- return { ok: false, error: 'Could not parse Management API response' };
411
- }
420
+ return { ok: result.data.external_anonymous_users_enabled === true };
412
421
  }
413
422
 
414
423
  /**
@@ -75,13 +75,52 @@ async function grantFcmAdminRole(projectId, saEmail) {
75
75
  return { ok: true };
76
76
  }
77
77
 
78
+ /**
79
+ * Detects the one failure mode that NO amount of retrying can fix: the GCP org
80
+ * policy constraints/iam.disableServiceAccountKeyCreation. Google turns this on by
81
+ * default for organizations created since 2024 ("secure by default"), so a brand-new
82
+ * org blocks service-account key creation outright. gcloud reports it as
83
+ * FAILED_PRECONDITION: "Key creation is not allowed on this service account."
84
+ */
85
+ function isKeyCreationBlockedByOrgPolicy(result) {
86
+ const blob = `${result.error || ''} ${result.stderr || ''} ${result.stdout || ''}`;
87
+ return /disableServiceAccountKeyCreation|Key creation is not allowed/i.test(blob);
88
+ }
89
+
90
+ /**
91
+ * Lifts the disableServiceAccountKeyCreation constraint for a SINGLE project (not the
92
+ * whole organization) so the Firebase Admin SDK key can be created. This is the
93
+ * standard, Google-documented way to allow SA keys where they are genuinely required
94
+ * — here, so Supabase Edge Functions / a REST server can authenticate to FCM HTTP v1.
95
+ *
96
+ * The override is scoped to the app's own project, leaving the org-wide policy intact.
97
+ * Requires the caller to have roles/orgpolicy.policyAdmin (org owners normally do).
98
+ *
99
+ * @param {string} projectId
100
+ * @returns {{ ok: boolean, error?: string }}
101
+ */
102
+ async function allowServiceAccountKeyCreation(projectId) {
103
+ const result = await run(
104
+ `gcloud resource-manager org-policies disable-enforce` +
105
+ ` iam.disableServiceAccountKeyCreation --project=${projectId}`
106
+ );
107
+ if (!result.ok) {
108
+ return { ok: false, error: result.stderr || result.error };
109
+ }
110
+ return { ok: true };
111
+ }
112
+
78
113
  /**
79
114
  * Generates a new private key for the Firebase Admin SDK service account and returns
80
115
  * the JSON content as a string. The temporary key file is deleted immediately after reading.
81
116
  * Also ensures the service account has the roles/firebasecloudmessaging.admin IAM role.
82
117
  *
118
+ * If the org blocks SA key creation (default on new GCP orgs), the constraint is lifted
119
+ * for this project only and key creation is retried — otherwise push would silently come
120
+ * out unconfigured on every fresh organization.
121
+ *
83
122
  * @param {string} projectId - Firebase/GCP project ID
84
- * @returns {{ ok: boolean, json?: string, saEmail?: string, error?: string }}
123
+ * @returns {{ ok: boolean, json?: string, saEmail?: string, policyAdjusted?: boolean, errorKind?: string, error?: string }}
85
124
  */
86
125
  async function createFcmServiceAccountKey(projectId) {
87
126
  if (!projectId || !projectId.trim()) {
@@ -116,24 +155,51 @@ async function createFcmServiceAccountKey(projectId) {
116
155
  }
117
156
 
118
157
  const tmpPath = path.join(os.tmpdir(), `kasy-fcm-${Date.now()}.json`);
158
+ const createKey = () => run(
159
+ `gcloud iam service-accounts keys create "${tmpPath}"` +
160
+ ` --iam-account="${saEmail}"` +
161
+ ` --project=${projectId.trim()}`
162
+ );
119
163
 
120
164
  // Right after granting the FCM admin role (and on a brand-new project), the
121
165
  // IAM permission takes a moment to propagate — so the first key-create often
122
166
  // fails with "permission still propagating". Back off and retry a few times
123
167
  // before giving up, instead of leaving push half-configured.
124
168
  let keyResult;
125
- for (let attempt = 1; attempt <= 4; attempt++) {
126
- keyResult = await run(
127
- `gcloud iam service-accounts keys create "${tmpPath}"` +
128
- ` --iam-account="${saEmail}"` +
129
- ` --project=${projectId.trim()}`
130
- );
169
+ let policyAdjusted = false;
170
+ const maxAttempts = 6;
171
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
172
+ keyResult = await createKey();
131
173
  if (keyResult.ok) break;
132
- if (attempt < 4) await sleep(7000);
174
+
175
+ // The org blocks SA keys (default on new GCP orgs). This is NOT a propagation
176
+ // race that retrying alone can fix — the constraint must be lifted. Lift it for
177
+ // THIS project once, then keep retrying: an org policy change itself takes a
178
+ // minute or more to take effect. If we can't lift it (no policy-admin
179
+ // permission), report a specific, actionable error instead of "still propagating".
180
+ if (isKeyCreationBlockedByOrgPolicy(keyResult)) {
181
+ if (!policyAdjusted) {
182
+ const lift = await allowServiceAccountKeyCreation(projectId.trim());
183
+ if (!lift.ok) {
184
+ await fs.remove(tmpPath).catch(() => {});
185
+ return { ok: false, errorKind: 'orgPolicyNoPermission', error: lift.error };
186
+ }
187
+ policyAdjusted = true;
188
+ }
189
+ if (attempt < maxAttempts) await sleep(15000);
190
+ continue;
191
+ }
192
+ if (attempt < maxAttempts) await sleep(7000);
133
193
  }
134
194
 
135
195
  if (!keyResult.ok) {
136
196
  await fs.remove(tmpPath).catch(() => {});
197
+ // Only surface the "ask an org admin" message when we genuinely could NOT lift
198
+ // the policy. If we DID lift it but the change is still propagating, fall through
199
+ // to the generic "still propagating, run again" message instead of blaming perms.
200
+ if (!policyAdjusted && isKeyCreationBlockedByOrgPolicy(keyResult)) {
201
+ return { ok: false, errorKind: 'orgPolicyBlocked', error: keyResult.error };
202
+ }
137
203
  return { ok: false, error: `Failed to generate key: ${keyResult.error}` };
138
204
  }
139
205
 
@@ -141,7 +207,7 @@ async function createFcmServiceAccountKey(projectId) {
141
207
  const jsonContent = await fs.readFile(tmpPath, 'utf8');
142
208
  await fs.remove(tmpPath).catch(() => {});
143
209
  JSON.parse(jsonContent); // validate
144
- return { ok: true, json: jsonContent.trim(), saEmail };
210
+ return { ok: true, json: jsonContent.trim(), saEmail, policyAdjusted };
145
211
  } catch (err) {
146
212
  await fs.remove(tmpPath).catch(() => {});
147
213
  return { ok: false, error: `Failed to read generated key: ${err.message}` };
@@ -900,17 +900,19 @@ async function getGoogleClientSecretViaGcloud(firebaseProjectId) {
900
900
  if (!tokenResult.ok || !tokenResult.stdout.trim()) return '';
901
901
  const token = tokenResult.stdout.trim();
902
902
 
903
- const curlCmd = [
904
- 'curl -sf',
905
- `-H "Authorization: Bearer ${token}"`,
906
- `-H "x-goog-user-project: ${firebaseProjectId}"`,
907
- `"https://identitytoolkit.googleapis.com/admin/v2/projects/${firebaseProjectId}/defaultSupportedIdpConfigs/google.com"`,
908
- ].join(' ');
909
-
910
- const result = await run(curlCmd, process.cwd());
911
- if (!result.ok || !result.stdout.trim()) return '';
912
-
913
- const data = JSON.parse(result.stdout.trim());
903
+ // Use fetch (Node 18+) instead of curl: no shell quoting, identical on every OS,
904
+ // and it does not depend on curl being on PATH (not guaranteed on Windows).
905
+ const res = await fetch(
906
+ `https://identitytoolkit.googleapis.com/admin/v2/projects/${firebaseProjectId}/defaultSupportedIdpConfigs/google.com`,
907
+ {
908
+ headers: {
909
+ Authorization: `Bearer ${token}`,
910
+ 'x-goog-user-project': firebaseProjectId,
911
+ },
912
+ }
913
+ );
914
+ if (!res.ok) return '';
915
+ const data = await res.json();
914
916
  return data.clientSecret || '';
915
917
  } catch (_) {
916
918
  return '';
@@ -746,6 +746,8 @@ module.exports = {
746
746
  'new.fcm.ok': 'generated automatically',
747
747
  'new.fcm.failSupabase': 'not generated (GCP permission still propagating); set FIREBASE_SERVICE_ACCOUNT_JSON in your Supabase secrets',
748
748
  'new.fcm.failApi': 'not generated (GCP permission still propagating); run the command again in a few minutes',
749
+ 'new.fcm.policyLifted': 'Enabled service account key creation for this project (required for push notifications)',
750
+ 'new.fcm.orgPolicyBlocked': 'not generated: your Google Cloud organization blocks service account keys. Ask an organization admin to allow it, or generate the key in the Firebase Console (Project settings > Service accounts > Generate new private key)',
749
751
  'new.sha1.registering': 'Registering SHA-1 for Google Sign-In (Android)…',
750
752
  'new.sha1.failed': 'SHA-1 not added automatically: {error}',
751
753
  'new.sha1.manual': 'Add it manually so Google Sign-In works on Android:',
@@ -746,6 +746,8 @@ module.exports = {
746
746
  'new.fcm.ok': 'generada automáticamente',
747
747
  'new.fcm.failSupabase': 'no generada (permiso de GCP aún propagando); define FIREBASE_SERVICE_ACCOUNT_JSON en los secrets de Supabase',
748
748
  'new.fcm.failApi': 'no generada (permiso de GCP aún propagando); ejecuta el comando de nuevo en unos minutos',
749
+ 'new.fcm.policyLifted': 'Habilitada la creación de claves de cuenta de servicio en este proyecto (necesario para las notificaciones push)',
750
+ 'new.fcm.orgPolicyBlocked': 'no generada: tu organización en Google Cloud bloquea las claves de cuenta de servicio. Pide a un administrador de la organización que lo permita, o genera la clave en la Firebase Console (Configuración del proyecto > Cuentas de servicio > Generar nueva clave privada)',
749
751
  'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
750
752
  'new.sha1.failed': 'SHA-1 no añadido automáticamente: {error}',
751
753
  'new.sha1.manual': 'Agregalo manualmente para que Google Sign-In funcione en Android:',
@@ -746,6 +746,8 @@ module.exports = {
746
746
  'new.fcm.ok': 'gerada automaticamente',
747
747
  'new.fcm.failSupabase': 'não gerada (permissão do GCP ainda propagando); defina FIREBASE_SERVICE_ACCOUNT_JSON nos secrets do Supabase',
748
748
  'new.fcm.failApi': 'não gerada (permissão do GCP ainda propagando); rode o comando de novo em alguns minutos',
749
+ 'new.fcm.policyLifted': 'Liberada a criação de chaves de service account neste projeto (necessário para as notificações push)',
750
+ 'new.fcm.orgPolicyBlocked': 'não gerada: sua organização no Google Cloud bloqueia chaves de service account. Peça a um administrador da organização para liberar, ou gere a chave no Firebase Console (Configurações do projeto > Contas de serviço > Gerar nova chave privada)',
749
751
  'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
750
752
  'new.sha1.failed': 'SHA-1 não adicionado automaticamente: {error}',
751
753
  'new.sha1.manual': 'Adicione manualmente para o Google Sign-In funcionar no Android:',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.1",
3
+ "version": "1.31.2",
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"