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.
- package/lib/commands/check.js +3 -1
- package/lib/commands/new.js +20 -4
- package/lib/scaffold/backends/supabase/deploy.js +56 -47
- package/lib/scaffold/generate.js +34 -16
- package/lib/scaffold/shared/fcm-service-account.js +170 -15
- package/lib/scaffold/shared/post-build.js +28 -17
- package/lib/utils/i18n/messages-en.js +3 -0
- package/lib/utils/i18n/messages-es.js +3 -0
- package/lib/utils/i18n/messages-pt.js +3 -0
- package/package.json +1 -1
package/lib/commands/check.js
CHANGED
|
@@ -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) {
|
package/lib/commands/new.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/lib/scaffold/generate.js
CHANGED
|
@@ -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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
detail:
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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 <=
|
|
126
|
-
keyResult = await
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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:',
|