kasy-cli 1.31.1 → 1.31.3

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.
@@ -186,7 +186,9 @@ async function runCheck(options = {}) {
186
186
  if (options.fix && firebaseProjectId) {
187
187
  const fixSpinner = ui.spinner();
188
188
  fixSpinner.start(t('check.fbSak.spin'));
189
- const fcmResult = await createFcmServiceAccountKey(firebaseProjectId);
189
+ const fcmResult = await createFcmServiceAccountKey(firebaseProjectId, {
190
+ onProgress: (stage) => { if (stage === 'orgPolicy') fixSpinner.message(t('new.fcm.adjustingOrgPolicy')); },
191
+ });
190
192
  fixSpinner.stop(t('check.fbSak.spinDone'));
191
193
 
192
194
  if (fcmResult.ok) {
@@ -546,6 +546,10 @@ function printStepResult(step, lang = 'pt') {
546
546
  const detail = step.detail ? kleur.dim(` — ${step.detail.split('\n')[0]}`) : '';
547
547
  if (step.ok) {
548
548
  ui.log.success(`${label}${detail}`);
549
+ } else if (step.warn) {
550
+ // Non-fatal: something we deliberately deferred (e.g. pub get on a slow
551
+ // connection). Show it as a warning, not a scary red error.
552
+ ui.log.warn(`${label}${detail}`);
549
553
  } else {
550
554
  ui.log.error(`${label}${detail}`);
551
555
  }
@@ -1904,13 +1908,19 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1904
1908
  if (answers.firebaseProjectId) {
1905
1909
  const fcmSpinner = ui.timedSpinner();
1906
1910
  fcmSpinner.start(tr('new.fcm.generating'));
1907
- const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1911
+ const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId, {
1912
+ onProgress: (stage) => { if (stage === 'orgPolicy') fcmSpinner.message(tr('new.fcm.adjustingOrgPolicy')); },
1913
+ });
1908
1914
  fcmSpinner.stop(tr('new.fcm.generating'));
1909
1915
  if (fcmResult.ok) {
1910
1916
  fcmServiceAccountJson = fcmResult.json;
1917
+ if (fcmResult.policyAdjusted) ui.log.info(tr('new.fcm.policyLifted'));
1911
1918
  printStepResult({ name: 'fcm-key', ok: true, detail: tr('new.fcm.ok') }, language);
1912
1919
  } else {
1913
- printStepResult({ name: 'fcm-key', ok: false, detail: tr('new.fcm.failSupabase') }, language);
1920
+ const detail = (fcmResult.errorKind === 'orgPolicyNoPermission' || fcmResult.errorKind === 'orgPolicyBlocked')
1921
+ ? tr('new.fcm.orgPolicyBlocked')
1922
+ : tr('new.fcm.failSupabase');
1923
+ printStepResult({ name: 'fcm-key', ok: false, detail }, language);
1914
1924
  }
1915
1925
  }
1916
1926
 
@@ -1986,7 +1996,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1986
1996
  if (backend === 'api' && answers.firebaseProjectId) {
1987
1997
  const fcmSpinner = ui.timedSpinner();
1988
1998
  fcmSpinner.start(tr('new.fcm.generating'));
1989
- const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId);
1999
+ const fcmResult = await createFcmServiceAccountKey(answers.firebaseProjectId, {
2000
+ onProgress: (stage) => { if (stage === 'orgPolicy') fcmSpinner.message(tr('new.fcm.adjustingOrgPolicy')); },
2001
+ });
1990
2002
  fcmSpinner.stop(tr('new.fcm.generating'));
1991
2003
  if (fcmResult.ok) {
1992
2004
  try {
@@ -2002,13 +2014,17 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
2002
2014
  await fs.appendFile(gitignorePath, `\n# FCM service account key (credentials — do not commit)\n${gitignoreEntry}\n`, 'utf8');
2003
2015
  }
2004
2016
  }
2017
+ if (fcmResult.policyAdjusted) ui.log.info(tr('new.fcm.policyLifted'));
2005
2018
  printStepResult({ name: 'fcm-key-saved', ok: true }, language);
2006
2019
  ui.log.message(tr('new.fcm.serverConfig'));
2007
2020
  } catch (_) {
2008
2021
  printStepResult({ name: 'fcm-key-saved', ok: false, detail: 'salvar arquivo de chave falhou' }, language);
2009
2022
  }
2010
2023
  } else {
2011
- printStepResult({ name: 'fcm-key', ok: false, detail: tr('new.fcm.failApi') }, language);
2024
+ const detail = (fcmResult.errorKind === 'orgPolicyNoPermission' || fcmResult.errorKind === 'orgPolicyBlocked')
2025
+ ? tr('new.fcm.orgPolicyBlocked')
2026
+ : tr('new.fcm.failApi');
2027
+ printStepResult({ name: 'fcm-key', ok: false, detail }, language);
2012
2028
  }
2013
2029
  }
2014
2030
 
@@ -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
  /**
@@ -319,22 +319,40 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
319
319
  // ── 2. Post-build comum ────────────────────────────────────────────────────
320
320
  onProgress('pub-get');
321
321
  const pubGetResult = await pubGet(targetDir);
322
- steps.push({ name: 'pub-get', ok: pubGetResult.ok, detail: pubGetResult.ok ? null : pubGetResult.error });
323
- if (!pubGetResult.ok) return { steps };
324
-
325
- onProgress('slang');
326
- const slangResult = await slangGenerate(targetDir);
327
- steps.push({ name: 'slang', ok: slangResult.ok, detail: slangResult.ok ? null : slangResult.error });
328
- if (!slangResult.ok) return { steps };
329
-
330
- onProgress('build-runner');
331
- const buildRunnerResult = await buildRunner(targetDir);
332
- steps.push({
333
- name: 'build-runner',
334
- ok: buildRunnerResult.ok,
335
- detail: buildRunnerResult.ok ? null : buildRunnerResult.error,
336
- });
337
- if (!buildRunnerResult.ok) return { steps };
322
+
323
+ // pub get is the prerequisite for the codegen steps (slang, build_runner): they
324
+ // run `dart run` inside the project and can't work without dependencies. If it
325
+ // didn't finish (a very slow/flaky Windows download, even after retries), DON'T
326
+ // abort the whole project — skip only the codegen steps and keep going, so
327
+ // flutterfire + backend config still complete. The user finishes with one
328
+ // `flutter pub get` later, which the IDE / `kasy run` does automatically. That
329
+ // turns a hard "Command failed" into a near-complete project plus one next step.
330
+ if (pubGetResult.ok) {
331
+ steps.push({ name: 'pub-get', ok: true });
332
+
333
+ onProgress('slang');
334
+ const slangResult = await slangGenerate(targetDir);
335
+ steps.push({ name: 'slang', ok: slangResult.ok, detail: slangResult.ok ? null : slangResult.error });
336
+ if (!slangResult.ok) return { steps };
337
+
338
+ onProgress('build-runner');
339
+ const buildRunnerResult = await buildRunner(targetDir);
340
+ steps.push({
341
+ name: 'build-runner',
342
+ ok: buildRunnerResult.ok,
343
+ detail: buildRunnerResult.ok ? null : buildRunnerResult.error,
344
+ });
345
+ if (!buildRunnerResult.ok) return { steps };
346
+ } else {
347
+ const deferredMsg = {
348
+ en: 'not downloaded yet (slow connection); open the project in VS Code to install',
349
+ pt: 'não baixou agora (conexão lenta); abra o projeto no VS Code que ele instala',
350
+ es: 'no se descargó ahora (conexión lenta); abre el proyecto en VS Code para instalar',
351
+ };
352
+ steps.push({ name: 'pub-get', ok: false, warn: true, detail: deferredMsg[language] || deferredMsg.en });
353
+ steps.push({ name: 'slang', skipped: true });
354
+ steps.push({ name: 'build-runner', skipped: true });
355
+ }
338
356
 
339
357
  onProgress('flutterfire');
340
358
  const ffResult = await flutterfireConfigure(targetDir, firebaseProjectId, { includeWeb });
@@ -75,20 +75,126 @@ 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
+
113
+ /**
114
+ * True when a gcloud command failed because the account lacks the required IAM
115
+ * permission (as opposed to a transient/propagation error). Used to tell "you
116
+ * can't do this" apart from "try again in a moment".
117
+ */
118
+ function isPermissionError(result) {
119
+ const blob = `${result.error || ''} ${result.stderr || ''}`;
120
+ return /PERMISSION_DENIED|does not have permission|Permission .*denied|\bforbidden\b|\b403\b/i.test(blob);
121
+ }
122
+
123
+ /**
124
+ * The active gcloud account as an IAM member string (e.g. "user:me@gmail.com").
125
+ * Service-account logins get the "serviceAccount:" prefix instead.
126
+ */
127
+ async function getCurrentGcloudMember() {
128
+ const result = await run('gcloud config get-value account');
129
+ if (!result.ok) return null;
130
+ const email = (result.stdout || '').trim();
131
+ if (!email || email === '(unset)') return null;
132
+ const prefix = email.endsWith('.gserviceaccount.com') ? 'serviceAccount' : 'user';
133
+ return `${prefix}:${email}`;
134
+ }
135
+
136
+ /**
137
+ * The organization ID a project belongs to, or null if the project has no org
138
+ * (most personal Google accounts) — in which case there is no org policy to lift.
139
+ */
140
+ async function getProjectOrganizationId(projectId) {
141
+ const result = await run(
142
+ `gcloud projects get-ancestors ${projectId} --format="value(id,type)"`
143
+ );
144
+ if (!result.ok) return null;
145
+ for (const line of (result.stdout || '').split('\n')) {
146
+ const [id, type] = line.trim().split(/\s+/);
147
+ if (type === 'organization' && id) return id;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Grants roles/orgpolicy.policyAdmin to an org member. Org OWNERS lack this role
154
+ * by default but CAN grant it (organizationAdmin includes setIamPolicy), which is
155
+ * what lets the lift below succeed. Returns ok:false if the account can't grant it
156
+ * (e.g. it's a non-owner member of a company org).
157
+ */
158
+ async function grantOrgPolicyAdmin(orgId, member) {
159
+ const result = await run(
160
+ `gcloud organizations add-iam-policy-binding ${orgId}` +
161
+ ` --member="${member}" --role="roles/orgpolicy.policyAdmin" --condition=None`
162
+ );
163
+ return { ok: result.ok, error: result.stderr || result.error };
164
+ }
165
+
166
+ /**
167
+ * Removes the policyAdmin role we granted, returning the org to its original
168
+ * posture. Best effort — cleanup must never fail the overall key creation.
169
+ * The project-level unenforce stays (it needs no admin role and is the minimum
170
+ * required to rotate the key later).
171
+ */
172
+ async function removeOrgPolicyAdmin(orgId, member) {
173
+ await run(
174
+ `gcloud organizations remove-iam-policy-binding ${orgId}` +
175
+ ` --member="${member}" --role="roles/orgpolicy.policyAdmin" --condition=None`
176
+ );
177
+ }
178
+
78
179
  /**
79
180
  * Generates a new private key for the Firebase Admin SDK service account and returns
80
181
  * the JSON content as a string. The temporary key file is deleted immediately after reading.
81
182
  * Also ensures the service account has the roles/firebasecloudmessaging.admin IAM role.
82
183
  *
184
+ * If the org blocks SA key creation (default on new GCP orgs), the constraint is lifted
185
+ * for this project only and key creation is retried — otherwise push would silently come
186
+ * out unconfigured on every fresh organization.
187
+ *
83
188
  * @param {string} projectId - Firebase/GCP project ID
84
- * @returns {{ ok: boolean, json?: string, saEmail?: string, error?: string }}
189
+ * @returns {{ ok: boolean, json?: string, saEmail?: string, policyAdjusted?: boolean, errorKind?: string, error?: string }}
85
190
  */
86
- async function createFcmServiceAccountKey(projectId) {
191
+ async function createFcmServiceAccountKey(projectId, { onProgress } = {}) {
87
192
  if (!projectId || !projectId.trim()) {
88
193
  return { ok: false, error: 'projectId is required' };
89
194
  }
90
195
 
91
196
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
197
+ const notify = (stage) => { try { if (onProgress) onProgress(stage); } catch (_) {} };
92
198
 
93
199
  // On a brand-new project the Firebase Admin SDK service account is provisioned
94
200
  // asynchronously after Firebase is added, and the IAM role grant also needs a
@@ -116,24 +222,73 @@ async function createFcmServiceAccountKey(projectId) {
116
222
  }
117
223
 
118
224
  const tmpPath = path.join(os.tmpdir(), `kasy-fcm-${Date.now()}.json`);
225
+ const createKey = () => run(
226
+ `gcloud iam service-accounts keys create "${tmpPath}"` +
227
+ ` --iam-account="${saEmail}"` +
228
+ ` --project=${projectId.trim()}`
229
+ );
119
230
 
120
- // Right after granting the FCM admin role (and on a brand-new project), the
121
- // IAM permission takes a moment to propagate so the first key-create often
122
- // fails with "permission still propagating". Back off and retry a few times
123
- // before giving up, instead of leaving push half-configured.
231
+ // Phase 1 try a direct create. On personal Google accounts with no
232
+ // organization there is no blocking policy, so this just works once the IAM
233
+ // role settles. A couple of quick retries cover that role propagation.
124
234
  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
- );
131
- if (keyResult.ok) break;
132
- if (attempt < 4) await sleep(7000);
235
+ for (let attempt = 1; attempt <= 3; attempt++) {
236
+ keyResult = await createKey();
237
+ if (keyResult.ok || !isKeyCreationBlockedByOrgPolicy(keyResult)) break;
238
+ if (attempt < 3) await sleep(5000);
133
239
  }
134
240
 
241
+ // Phase 2 — the org blocks SA keys (default on orgs created since 2024). Lift the
242
+ // constraint for THIS project only. Lifting needs roles/orgpolicy.policyAdmin,
243
+ // which even org OWNERS lack by default — but an owner CAN grant it to itself
244
+ // (organizationAdmin includes setIamPolicy). So: try to lift; if denied, self-grant
245
+ // policyAdmin and retry the lift; roll the grant back in Phase 3.
246
+ let policyAdjusted = false;
247
+ let selfGranted = null; // { orgId, member } to roll back afterward
248
+ if (!keyResult.ok && isKeyCreationBlockedByOrgPolicy(keyResult)) {
249
+ notify('orgPolicy');
250
+ let lift = await allowServiceAccountKeyCreation(projectId.trim());
251
+
252
+ if (!lift.ok && isPermissionError(lift)) {
253
+ const orgId = await getProjectOrganizationId(projectId.trim());
254
+ const member = await getCurrentGcloudMember();
255
+ if (orgId && member) {
256
+ const grant = await grantOrgPolicyAdmin(orgId, member);
257
+ if (grant.ok) {
258
+ selfGranted = { orgId, member };
259
+ // The grant takes a moment to propagate before the lift is accepted.
260
+ for (let attempt = 1; attempt <= 8 && !lift.ok; attempt++) {
261
+ await sleep(15000);
262
+ lift = await allowServiceAccountKeyCreation(projectId.trim());
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ if (lift.ok) {
269
+ policyAdjusted = true;
270
+ // The lift itself takes ~1-2 min to take effect — retry the create with backoff.
271
+ for (let attempt = 1; attempt <= 10; attempt++) {
272
+ keyResult = await createKey();
273
+ if (keyResult.ok) break;
274
+ if (attempt < 10) await sleep(15000);
275
+ }
276
+ }
277
+ }
278
+
279
+ // Phase 3 — roll back the broad policyAdmin grant so the organization returns to
280
+ // its original posture. The project-level unenforce stays: it needs no admin role
281
+ // and is the minimum required to rotate this key later.
282
+ if (selfGranted) await removeOrgPolicyAdmin(selfGranted.orgId, selfGranted.member);
283
+
135
284
  if (!keyResult.ok) {
136
285
  await fs.remove(tmpPath).catch(() => {});
286
+ // "Ask an org admin" only when we genuinely could not lift the policy (e.g. a
287
+ // non-owner member of a company org). If we DID lift it but creation is still
288
+ // propagating, fall through to the generic "try again" message, not a perms blame.
289
+ if (!policyAdjusted && isKeyCreationBlockedByOrgPolicy(keyResult)) {
290
+ return { ok: false, errorKind: 'orgPolicyBlocked', error: keyResult.error };
291
+ }
137
292
  return { ok: false, error: `Failed to generate key: ${keyResult.error}` };
138
293
  }
139
294
 
@@ -141,7 +296,7 @@ async function createFcmServiceAccountKey(projectId) {
141
296
  const jsonContent = await fs.readFile(tmpPath, 'utf8');
142
297
  await fs.remove(tmpPath).catch(() => {});
143
298
  JSON.parse(jsonContent); // validate
144
- return { ok: true, json: jsonContent.trim(), saEmail };
299
+ return { ok: true, json: jsonContent.trim(), saEmail, policyAdjusted };
145
300
  } catch (err) {
146
301
  await fs.remove(tmpPath).catch(() => {});
147
302
  return { ok: false, error: `Failed to read generated key: ${err.message}` };
@@ -37,12 +37,21 @@ async function run(cmd, cwd, timeout) {
37
37
  }
38
38
  }
39
39
 
40
- async function pubGet(projectDir) {
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);
40
+ async function pubGet(projectDir, { onAttempt } = {}) {
41
+ // The FIRST `flutter pub get` on a fresh machine downloads the whole dependency
42
+ // tree (firebase, supabase, revenuecat, stripe, sentry…). On a slow Windows
43
+ // connection or with antivirus scanning every cached file a single attempt
44
+ // can time out (the user saw 15 min, then "Command failed"). The pub cache is
45
+ // global and persists between attempts, so each retry resumes where the last one
46
+ // stopped and tends to finish fast. A retry also clears a hung download. Try a
47
+ // few times with a shorter per-attempt ceiling before giving up.
48
+ let last;
49
+ for (let attempt = 1; attempt <= 2; attempt++) {
50
+ if (onAttempt) onAttempt(attempt);
51
+ last = await run('flutter pub get', projectDir, 600_000); // 10 min per attempt
52
+ if (last.ok) return last;
53
+ }
54
+ return last;
46
55
  }
47
56
 
48
57
  async function slangGenerate(projectDir) {
@@ -900,17 +909,19 @@ async function getGoogleClientSecretViaGcloud(firebaseProjectId) {
900
909
  if (!tokenResult.ok || !tokenResult.stdout.trim()) return '';
901
910
  const token = tokenResult.stdout.trim();
902
911
 
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());
912
+ // Use fetch (Node 18+) instead of curl: no shell quoting, identical on every OS,
913
+ // and it does not depend on curl being on PATH (not guaranteed on Windows).
914
+ const res = await fetch(
915
+ `https://identitytoolkit.googleapis.com/admin/v2/projects/${firebaseProjectId}/defaultSupportedIdpConfigs/google.com`,
916
+ {
917
+ headers: {
918
+ Authorization: `Bearer ${token}`,
919
+ 'x-goog-user-project': firebaseProjectId,
920
+ },
921
+ }
922
+ );
923
+ if (!res.ok) return '';
924
+ const data = await res.json();
914
925
  return data.clientSecret || '';
915
926
  } catch (_) {
916
927
  return '';
@@ -746,6 +746,9 @@ 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.adjustingOrgPolicy': 'Enabling organization permissions for push (may take 1-2 min)…',
750
+ 'new.fcm.policyLifted': 'Enabled service account key creation for this project (required for push notifications)',
751
+ '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
752
  'new.sha1.registering': 'Registering SHA-1 for Google Sign-In (Android)…',
750
753
  'new.sha1.failed': 'SHA-1 not added automatically: {error}',
751
754
  'new.sha1.manual': 'Add it manually so Google Sign-In works on Android:',
@@ -746,6 +746,9 @@ 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.adjustingOrgPolicy': 'Habilitando permisos de la organización para el push (puede tardar 1-2 min)…',
750
+ 'new.fcm.policyLifted': 'Habilitada la creación de claves de cuenta de servicio en este proyecto (necesario para las notificaciones push)',
751
+ '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
752
  'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
750
753
  'new.sha1.failed': 'SHA-1 no añadido automáticamente: {error}',
751
754
  'new.sha1.manual': 'Agregalo manualmente para que Google Sign-In funcione en Android:',
@@ -746,6 +746,9 @@ 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.adjustingOrgPolicy': 'Liberando permissões da organização para o push (pode levar 1-2 min)…',
750
+ 'new.fcm.policyLifted': 'Liberada a criação de chaves de service account neste projeto (necessário para as notificações push)',
751
+ '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
752
  'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
750
753
  'new.sha1.failed': 'SHA-1 não adicionado automaticamente: {error}',
751
754
  '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.3",
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"