kasy-cli 1.31.2 → 1.31.4

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,7 +1908,9 @@ 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;
@@ -1990,7 +1996,9 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1990
1996
  if (backend === 'api' && answers.firebaseProjectId) {
1991
1997
  const fcmSpinner = ui.timedSpinner();
1992
1998
  fcmSpinner.start(tr('new.fcm.generating'));
1993
- 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
+ });
1994
2002
  fcmSpinner.stop(tr('new.fcm.generating'));
1995
2003
  if (fcmResult.ok) {
1996
2004
  try {
@@ -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 });
@@ -391,7 +409,12 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
391
409
  // `kasy new` without needing `kasy deploy` first. Fast (<30s), billing-free.
392
410
  // Without this the project gets Firebase's default deny-all rules, causing
393
411
  // every Firestore read to throw permission-denied and the app to log the user out.
394
- if (firebaseProjectId) {
412
+ //
413
+ // FIREBASE BACKEND ONLY. Supabase/API projects do have a Firebase project, but
414
+ // ONLY for FCM push — there is no Firestore there, so deploying firestore:rules
415
+ // is incoherent (it targets a database that doesn't exist) and just hangs for
416
+ // minutes. The data layer for those backends is Supabase/the REST API.
417
+ if (backend === 'firebase' && firebaseProjectId) {
395
418
  onProgress('firestore-rules');
396
419
  const rulesResult = await deployFirestoreRules(targetDir, firebaseProjectId);
397
420
  steps.push({ name: 'firestore-rules', ok: rulesResult.ok, detail: rulesResult.ok ? null : rulesResult.error });
@@ -110,6 +110,72 @@ async function allowServiceAccountKeyCreation(projectId) {
110
110
  return { ok: true };
111
111
  }
112
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
+
113
179
  /**
114
180
  * Generates a new private key for the Firebase Admin SDK service account and returns
115
181
  * the JSON content as a string. The temporary key file is deleted immediately after reading.
@@ -122,12 +188,13 @@ async function allowServiceAccountKeyCreation(projectId) {
122
188
  * @param {string} projectId - Firebase/GCP project ID
123
189
  * @returns {{ ok: boolean, json?: string, saEmail?: string, policyAdjusted?: boolean, errorKind?: string, error?: string }}
124
190
  */
125
- async function createFcmServiceAccountKey(projectId) {
191
+ async function createFcmServiceAccountKey(projectId, { onProgress } = {}) {
126
192
  if (!projectId || !projectId.trim()) {
127
193
  return { ok: false, error: 'projectId is required' };
128
194
  }
129
195
 
130
196
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
197
+ const notify = (stage) => { try { if (onProgress) onProgress(stage); } catch (_) {} };
131
198
 
132
199
  // On a brand-new project the Firebase Admin SDK service account is provisioned
133
200
  // asynchronously after Firebase is added, and the IAM role grant also needs a
@@ -161,42 +228,64 @@ async function createFcmServiceAccountKey(projectId) {
161
228
  ` --project=${projectId.trim()}`
162
229
  );
163
230
 
164
- // Right after granting the FCM admin role (and on a brand-new project), the
165
- // IAM permission takes a moment to propagate so the first key-create often
166
- // fails with "permission still propagating". Back off and retry a few times
167
- // 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.
168
234
  let keyResult;
169
- let policyAdjusted = false;
170
- const maxAttempts = 6;
171
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
235
+ for (let attempt = 1; attempt <= 3; attempt++) {
172
236
  keyResult = await createKey();
173
- if (keyResult.ok) break;
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 };
237
+ if (keyResult.ok || !isKeyCreationBlockedByOrgPolicy(keyResult)) break;
238
+ if (attempt < 3) await sleep(5000);
239
+ }
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
+ }
186
264
  }
187
- policyAdjusted = true;
188
265
  }
189
- if (attempt < maxAttempts) await sleep(15000);
190
- continue;
191
266
  }
192
- if (attempt < maxAttempts) await sleep(7000);
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
+ }
193
277
  }
194
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
+
195
284
  if (!keyResult.ok) {
196
285
  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.
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.
200
289
  if (!policyAdjusted && isKeyCreationBlockedByOrgPolicy(keyResult)) {
201
290
  return { ok: false, errorKind: 'orgPolicyBlocked', error: keyResult.error };
202
291
  }
@@ -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) {
@@ -746,6 +746,7 @@ 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)…',
749
750
  'new.fcm.policyLifted': 'Enabled service account key creation for this project (required for push notifications)',
750
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)',
751
752
  'new.sha1.registering': 'Registering SHA-1 for Google Sign-In (Android)…',
@@ -746,6 +746,7 @@ 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)…',
749
750
  'new.fcm.policyLifted': 'Habilitada la creación de claves de cuenta de servicio en este proyecto (necesario para las notificaciones push)',
750
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)',
751
752
  'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
@@ -746,6 +746,7 @@ 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)…',
749
750
  'new.fcm.policyLifted': 'Liberada a criação de chaves de service account neste projeto (necessário para as notificações push)',
750
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)',
751
752
  'new.sha1.registering': 'Registrando SHA-1 para Google Sign-In (Android)…',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.2",
3
+ "version": "1.31.4",
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"