ensureStorageBucket method
Ensure the default Firebase Storage bucket exists for projectId.
Bucket-naming history (matters for what we probe):
• Pre-Sept 2024: every Firebase project got <projectId>.appspot.com
as the default bucket, auto-provisioned at project creation.
• Post-Sept 2024: new projects get <projectId>.firebasestorage.app
instead, and the bucket is NOT auto-created — the user must
click "Get Started" in the Firebase Storage console once.
• Both name patterns are reserved Google domains, so a direct
gcloud storage buckets create against either fails with
"domain ownership verification required" — only the Firebase
Console (and Firebase backend) can create them.
Strategy: probe both naming conventions for an existing bucket. If
one exists, return it (success). If neither does, surface the
"needs Firebase Storage init in console" path with the direct link
— don't try to gcloud storage buckets create because that path
is dead for default Firebase buckets and only produces confusing
"verify domain ownership" errors that the user has no way to
resolve.
Note: a freshly-linked Firebase Storage bucket is reachable via
gcloud storage buckets describe immediately, so this probe sees
it the moment the user finishes the console click-through.
Implementation
Future<StorageInitResult> ensureStorageBucket({
String location = 'US',
}) async {
if (projectId.isEmpty) {
return const StorageInitResult(
existed: false,
created: false,
bucketName: '',
message: 'No Firebase project ID configured',
);
}
// Modern projects get .firebasestorage.app, legacy projects got
// .appspot.com. Probe both — whichever exists is the default bucket.
final List<String> candidateBuckets = <String>[
'$projectId.firebasestorage.app',
'$projectId.appspot.com',
];
String? lastDescribeStderr;
for (final String bucketName in candidateBuckets) {
final String bucketUri = 'gs://$bucketName';
info('Checking default Storage bucket $bucketUri...');
final ProcessResult describe = await _runner.run('gcloud', <String>[
'storage',
'buckets',
'describe',
bucketUri,
'--format=json',
]);
if (describe.success) {
info('Default Storage bucket already exists: $bucketUri');
return StorageInitResult(
existed: true,
created: false,
bucketName: bucketName,
);
}
lastDescribeStderr = describe.stderr;
}
// Neither candidate exists. Both `.firebasestorage.app` and
// `.appspot.com` are reserved Google domains — `gcloud storage
// buckets create` against either would fail with a "verify domain
// ownership at search.google.com/search-console" 403, which the
// user has no way to resolve (they don't own the domain). The
// ONLY path that creates these buckets is the Firebase Console's
// "Get Started" click-through, which calls a Firebase backend
// that has implicit ownership.
//
// So: surface the console URL and let the orchestrator hand the
// user off with a clear message, rather than burning a `buckets
// create` call that's guaranteed to fail.
final String defaultName = '$projectId.firebasestorage.app';
final String stderr = lastDescribeStderr ?? '';
if (FirebaseBillingService.isBillingAbsentError(stderr)) {
return StorageInitResult(
existed: false,
created: false,
bucketName: defaultName,
message:
'Billing is not enabled on $projectId. Either:\n'
' • Upgrade to Blaze: ${FirebaseBillingService.upgradeUrl(projectId)}\n'
' • OR open ${getStartedUrl(projectId)} and click "Get Started" '
'(works on Spark — Firebase will create gs://$defaultName for you).',
needsFirebaseInit: true,
getStartedUrl: getStartedUrl(projectId),
);
}
if (_isApiNotEnabledError(stderr, 'firebasestorage.googleapis.com') ||
_isApiNotEnabledError(stderr, 'storage.googleapis.com')) {
return StorageInitResult(
existed: false,
created: false,
bucketName: defaultName,
message:
'Firebase Storage API is not enabled for $projectId. Enable it via:\n'
' • gcloud services enable firebasestorage.googleapis.com --project=$projectId\n'
' • or open: ${apiEnableUrl(projectId, 'firebasestorage.googleapis.com')}',
needsFirebaseInit: true,
getStartedUrl: getStartedUrl(projectId),
);
}
// Default bucket isn't provisioned. The only working path is the
// console click-through. Make the message explicit and actionable
// — the user gets a direct link, the exact button label, and the
// expected outcome.
return StorageInitResult(
existed: false,
created: false,
bucketName: defaultName,
message:
'Default Storage bucket has not been provisioned for $projectId.\n'
'This is a one-time, one-click setup that only the Firebase\n'
'Console can perform (the bucket name uses a reserved Google\n'
'domain, so neither `gcloud` nor `firebase` CLI can create it).\n\n'
' 1. Open: ${getStartedUrl(projectId)}\n'
' 2. Click the "Get started" button.\n'
' 3. Choose "Start in production mode" → Next.\n'
' 4. Pick a location (recommend matching your Firestore region;\n'
' `us-central1` is a safe default).\n'
' 5. Click "Done" and wait ~30 seconds for provisioning.\n'
' 6. Re-run `oracular` — Step 5.7 will detect the new bucket\n'
' (`gs://$defaultName`) automatically and continue.',
needsFirebaseInit: true,
getStartedUrl: getStartedUrl(projectId),
);
}