UNPKG

firebase-tools

Version:
604 lines (602 loc) 27.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.newUniqueId = exports.toDNSCompatibleId = exports.actuate = exports.askQuestions = exports.FDC_DEFAULT_REGION = void 0; const path_1 = require("path"); const clc = require("colorette"); const fs = require("fs-extra"); const prompt_1 = require("../../../prompt"); const provisionCloudSql_1 = require("../../../dataconnect/provisionCloudSql"); const freeTrial_1 = require("../../../dataconnect/freeTrial"); const cloudsql = require("../../../gcp/cloudsql/cloudsqladmin"); const ensureApis_1 = require("../../../dataconnect/ensureApis"); const experiments = require("../../../experiments"); const client_1 = require("../../../dataconnect/client"); const types_1 = require("../../../dataconnect/types"); const names_1 = require("../../../dataconnect/names"); const logger_1 = require("../../../logger"); const templates_1 = require("../../../templates"); const utils_1 = require("../../../utils"); Object.defineProperty(exports, "newUniqueId", { enumerable: true, get: function () { return utils_1.newUniqueId; } }); const cloudbilling_1 = require("../../../gcp/cloudbilling"); const sdk = require("./sdk"); const fdcExperience_1 = require("../../../gemini/fdcExperience"); const configstore_1 = require("../../../configstore"); const track_1 = require("../../../track"); const experiments_1 = require("../../../experiments"); exports.FDC_DEFAULT_REGION = "us-east4"; const DATACONNECT_YAML_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/dataconnect.yaml"); const DATACONNECT_WEBHOOKS_YAML_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/dataconnect-fdcwebhooks.yaml"); const SECONDARY_SCHEMA_YAML_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/secondary_schema.yaml"); const CONNECTOR_YAML_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/connector.yaml"); const SCHEMA_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/schema.gql"); const QUERIES_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/queries.gql"); const MUTATIONS_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/mutations.gql"); const SEED_DATA_TEMPLATE = (0, templates_1.readTemplateSync)("init/dataconnect/seed_data.gql"); const templateServiceInfo = { schemaGql: [{ path: "schema.gql", content: SCHEMA_TEMPLATE }], connectors: [ { id: "example", path: "./example", files: [ { path: "queries.gql", content: QUERIES_TEMPLATE, }, { path: "mutations.gql", content: MUTATIONS_TEMPLATE, }, ], }, ], seedDataGql: SEED_DATA_TEMPLATE, }; async function askQuestions(setup) { const info = { flow: "", appDescription: "", serviceId: "", locationId: "", cloudSqlInstanceId: "", cloudSqlDatabase: "", shouldProvisionCSQL: false, }; if (setup.projectId) { await (0, ensureApis_1.ensureApis)(setup.projectId); await promptForExistingServices(setup, info); if (!info.serviceGql) { if (!configstore_1.configstore.get("gemini")) { (0, utils_1.logBullet)("Learn more about Gemini in Firebase and how it uses your data: https://firebase.google.com/docs/gemini-in-firebase#how-gemini-in-firebase-uses-your-data"); } const wantToGenerate = await (0, prompt_1.confirm)({ message: "Do you want to generate schema and queries with Gemini?", default: false, }); if (wantToGenerate) { configstore_1.configstore.set("gemini", true); await (0, ensureApis_1.ensureGIFApiTos)(setup.projectId); info.appDescription = await (0, prompt_1.input)({ message: `Describe your app idea:`, validate: async (s) => { if (s.length > 0) { return true; } return "Please enter a description for your app idea."; }, }); } } await promptForCloudSQL(setup, info); } setup.featureInfo = setup.featureInfo || {}; setup.featureInfo.dataconnect = info; await sdk.askQuestions(setup); } exports.askQuestions = askQuestions; async function actuate(setup, config, options) { var _a, _b, _c; const dir = config.get("dataconnect.source", "dataconnect"); const dataDir = config.get("emulators.dataconnect.dataDir", `${dir}/.dataconnect/pgliteData`); config.set("emulators.dataconnect.dataDir", dataDir); const info = (_a = setup.featureInfo) === null || _a === void 0 ? void 0 : _a.dataconnect; if (!info) { throw new Error("Data Connect feature RequiredInfo is not provided"); } info.serviceId = info.serviceId || defaultServiceId(); info.cloudSqlInstanceId = info.cloudSqlInstanceId || `${info.serviceId.toLowerCase()}-fdc`; info.locationId = info.locationId || exports.FDC_DEFAULT_REGION; info.cloudSqlDatabase = info.cloudSqlDatabase || `fdcdb`; const startTime = Date.now(); try { await actuateWithInfo(setup, config, info, options); await sdk.actuate(setup, config); } finally { const sdkInfo = (_b = setup.featureInfo) === null || _b === void 0 ? void 0 : _b.dataconnectSdk; const source = ((_c = setup.featureInfo) === null || _c === void 0 ? void 0 : _c.dataconnectSource) || "init"; void (0, track_1.trackGA4)("dataconnect_init", Object.assign({ source, flow: info.flow.substring(1), project_status: setup.projectId ? (await (0, cloudbilling_1.isBillingEnabled)(setup)) ? info.shouldProvisionCSQL ? "blaze_provisioned_csql" : "blaze" : "spark" : "missing" }, (sdkInfo ? sdk.initAppCounters(sdkInfo) : {})), Date.now() - startTime); } if (info.appDescription) { setup.instructions.push(`You can visualize the Data Connect Schema in Firebase Console: https://console.firebase.google.com/project/${setup.projectId}/dataconnect/locations/${info.locationId}/services/${info.serviceId}/schema`); } setup.instructions.push(`Install the Data Connect VS Code Extensions. You can explore Data Connect Query on local pgLite and Cloud SQL Postgres Instance.`); } exports.actuate = actuate; async function actuateWithInfo(setup, config, info, options) { var _a; const projectId = setup.projectId; if (!projectId) { info.flow += "_save_template"; return await writeFiles(config, info, templateServiceInfo, options); } await (0, ensureApis_1.ensureApis)(projectId, true); if (info.shouldProvisionCSQL) { await (0, provisionCloudSql_1.setupCloudSql)({ projectId: projectId, location: info.locationId, instanceId: info.cloudSqlInstanceId, databaseId: info.cloudSqlDatabase, requireGoogleMlIntegration: false, source: ((_a = setup.featureInfo) === null || _a === void 0 ? void 0 : _a.dataconnectSource) || "init", }); } const serviceName = `projects/${projectId}/locations/${info.locationId}/services/${info.serviceId}`; if (!info.appDescription) { if (!info.serviceGql) { await downloadService(info, serviceName); } if (info.serviceGql) { info.flow += "_save_downloaded"; return await writeFiles(config, info, info.serviceGql, options); } info.flow += "_save_template"; return await writeFiles(config, info, templateServiceInfo, options); } const serviceAlreadyExists = !(await (0, client_1.createService)(projectId, info.locationId, info.serviceId)); const schemaGql = await (0, utils_1.promiseWithSpinner)(() => (0, fdcExperience_1.generateSchema)(info.appDescription, projectId), "Generating the Data Connect Schema..."); const schemaFiles = [{ path: "schema.gql", content: schemaGql }]; if (serviceAlreadyExists) { (0, utils_1.logLabeledError)("dataconnect", `Data Connect Service ${serviceName} already exists. Skip saving them...`); info.flow += "_save_gemini_service_already_exists"; return await writeFiles(config, info, { schemaGql: schemaFiles, connectors: [] }, options); } await (0, utils_1.promiseWithSpinner)(async () => { const [saveSchemaGql, waitForCloudSQLProvision] = schemasDeploySequence(projectId, info, schemaFiles, info.shouldProvisionCSQL); await (0, client_1.upsertSchema)(saveSchemaGql); if (waitForCloudSQLProvision) { void (0, client_1.upsertSchema)(waitForCloudSQLProvision); } }, "Saving the Data Connect Schema..."); try { const [operationGql, seedDataGql] = await (0, utils_1.promiseWithSpinner)(() => Promise.all([ (0, fdcExperience_1.generateOperation)(fdcExperience_1.PROMPT_GENERATE_CONNECTOR, serviceName, projectId), (0, fdcExperience_1.generateOperation)(fdcExperience_1.PROMPT_GENERATE_SEED_DATA, serviceName, projectId), ]), "Generating the Data Connect Operations..."); const connectors = [ { id: "example", path: "./example", files: [ { path: "queries.gql", content: operationGql, }, ], }, ]; info.flow += "_save_gemini"; await writeFiles(config, info, { schemaGql: schemaFiles, connectors: connectors, seedDataGql: seedDataGql }, options); } catch (err) { (0, utils_1.logLabeledError)("dataconnect", `Operation Generation failed...`); info.flow += "_save_gemini_operation_error"; await writeFiles(config, info, { schemaGql: schemaFiles, connectors: [] }, options); throw err; } } function schemasDeploySequence(projectId, info, schemaFiles, linkToCloudSql) { const serviceName = `projects/${projectId}/locations/${info.locationId}/services/${info.serviceId}`; if (!linkToCloudSql) { return [ { name: `${serviceName}/schemas/${types_1.MAIN_SCHEMA_ID}`, datasources: [{ postgresql: {} }], source: { files: schemaFiles, }, }, ]; } return [ { name: `${serviceName}/schemas/${types_1.MAIN_SCHEMA_ID}`, datasources: [ { postgresql: { database: info.cloudSqlDatabase, cloudSql: { instance: `projects/${projectId}/locations/${info.locationId}/instances/${info.cloudSqlInstanceId}`, }, schemaValidation: "NONE", }, }, ], source: { files: schemaFiles, }, }, { name: `${serviceName}/schemas/${types_1.MAIN_SCHEMA_ID}`, datasources: [ { postgresql: { database: info.cloudSqlDatabase, cloudSql: { instance: `projects/${projectId}/locations/${info.locationId}/instances/${info.cloudSqlInstanceId}`, }, schemaMigration: "MIGRATE_COMPATIBLE", }, }, ], source: { files: schemaFiles, }, }, ]; } async function writeFiles(config, info, serviceGql, options) { var _a, _b; const dir = config.get("dataconnect.source") || "dataconnect"; const subbedDataconnectYaml = subDataconnectYamlValues(Object.assign(Object.assign({}, info), { connectorDirs: serviceGql.connectors.map((c) => c.path) }), (_a = serviceGql.secondarySchemaGqls) === null || _a === void 0 ? void 0 : _a.map((sch) => ({ id: sch.id, uri: sch.uri }))); config.set("dataconnect", { source: dir }); await config.askWriteProjectFile((0, path_1.join)(dir, "dataconnect.yaml"), subbedDataconnectYaml, !!options.force, true); if (serviceGql.seedDataGql) { await config.askWriteProjectFile((0, path_1.join)(dir, "seed_data.gql"), serviceGql.seedDataGql, !!options.force); } if (serviceGql.schemaGql.length) { for (const f of serviceGql.schemaGql) { await config.askWriteProjectFile((0, path_1.join)(dir, "schema", f.path), f.content, !!options.force); } } else { fs.ensureFileSync((0, path_1.join)(dir, "schema", "schema.gql")); } if ((_b = serviceGql.secondarySchemaGqls) === null || _b === void 0 ? void 0 : _b.length) { for (const sch of serviceGql.secondarySchemaGqls) { for (const f of sch.files) { await config.askWriteProjectFile((0, path_1.join)(dir, `schema_${sch.id}`, f.path), f.content, !!options.force); } } } for (const c of serviceGql.connectors) { await writeConnectorFiles(config, c, options); } } async function writeConnectorFiles(config, connectorInfo, options) { const subbedConnectorYaml = subConnectorYamlValues({ connectorId: connectorInfo.id }); const dir = config.get("dataconnect.source") || "dataconnect"; await config.askWriteProjectFile((0, path_1.join)(dir, connectorInfo.path, "connector.yaml"), subbedConnectorYaml, !!options.force, true); for (const f of connectorInfo.files) { await config.askWriteProjectFile((0, path_1.join)(dir, connectorInfo.path, f.path), f.content, !!options.force); } } function subDataconnectYamlValues(replacementValues, secondarySchemas) { const replacements = { serviceId: "__serviceId__", locationId: "__location__", cloudSqlDatabase: "__cloudSqlDatabase__", cloudSqlInstanceId: "__cloudSqlInstanceId__", connectorDirs: "__connectorDirs__", secondarySchemaId: "__secondarySchemaId__", secondarySchemaSource: "__secondarySchemaSource__", secondarySchemaUri: "__secondarySchemaUri__", }; let replaced = experiments.isEnabled("fdcwebhooks") ? DATACONNECT_WEBHOOKS_YAML_TEMPLATE : DATACONNECT_YAML_TEMPLATE; if (secondarySchemas && secondarySchemas.length > 0) { let secondaryReplaced = ""; for (const schema of secondarySchemas) { secondaryReplaced += SECONDARY_SCHEMA_YAML_TEMPLATE; secondaryReplaced = secondaryReplaced.replace(replacements.secondarySchemaId, JSON.stringify(schema.id)); secondaryReplaced = secondaryReplaced.replace(replacements.secondarySchemaSource, `"./schema_${schema.id}"`); secondaryReplaced = secondaryReplaced.replace(replacements.secondarySchemaUri, JSON.stringify(schema.uri)); } replaced = replaced.replace("#__secondarySchemaPlaceholder__\n", secondaryReplaced); } else { replaced = replaced.replace("#__secondarySchemaPlaceholder__\n", ""); } for (const [k, v] of Object.entries(replacementValues)) { replaced = replaced.replace(replacements[k], JSON.stringify(v)); } return replaced; } function subConnectorYamlValues(replacementValues) { const replacements = { connectorId: "__connectorId__", }; let replaced = CONNECTOR_YAML_TEMPLATE; for (const [k, v] of Object.entries(replacementValues)) { replaced = replaced.replace(replacements[k], JSON.stringify(v)); } return replaced; } async function promptForExistingServices(setup, info) { if (!setup.projectId) { return; } const existingServices = await (0, client_1.listAllServices)(setup.projectId); if (!existingServices.length) { return; } const choice = await chooseExistingService(existingServices); if (!choice) { const existingServiceIds = existingServices.map((s) => s.name.split("/").pop()); info.serviceId = (0, utils_1.newUniqueId)(defaultServiceId(), existingServiceIds); info.flow += "_pick_new_service"; return; } info.flow += "_pick_existing_service"; const serviceName = (0, names_1.parseServiceName)(choice.name); info.serviceId = serviceName.serviceId; info.locationId = serviceName.location; await downloadService(info, choice.name); } async function downloadService(info, serviceName) { var _a, _b, _c, _d, _e, _f, _g; let schemas = []; try { schemas = await (0, client_1.listSchemas)(serviceName, [ "schemas.name", "schemas.datasources", "schemas.source", ]); } catch (err) { if (err.status !== 404) { throw err; } } if (!schemas.length) { return; } info.serviceGql = { schemaGql: [], connectors: [ { id: "example", path: "./example", files: [], }, ], }; for (const sch of schemas) { if ((0, types_1.isMainSchema)(sch)) { const primaryDatasource = sch.datasources.find((d) => d.postgresql); if ((_b = (_a = primaryDatasource === null || primaryDatasource === void 0 ? void 0 : primaryDatasource.postgresql) === null || _a === void 0 ? void 0 : _a.cloudSql) === null || _b === void 0 ? void 0 : _b.instance) { const instanceName = (0, names_1.parseCloudSQLInstanceName)(primaryDatasource.postgresql.cloudSql.instance); info.cloudSqlInstanceId = instanceName.instanceId; } info.cloudSqlDatabase = (_d = (_c = primaryDatasource === null || primaryDatasource === void 0 ? void 0 : primaryDatasource.postgresql) === null || _c === void 0 ? void 0 : _c.database) !== null && _d !== void 0 ? _d : ""; if ((_e = sch.source.files) === null || _e === void 0 ? void 0 : _e.length) { info.serviceGql.schemaGql = sch.source.files; } } else { if (!info.serviceGql.secondarySchemaGqls) { info.serviceGql.secondarySchemaGqls = []; } info.serviceGql.secondarySchemaGqls.push({ id: sch.name.split("/").pop(), files: sch.source.files || [], uri: (_g = (_f = sch.datasources[0].httpGraphql) === null || _f === void 0 ? void 0 : _f.uri) !== null && _g !== void 0 ? _g : "", }); } } const connectors = await (0, client_1.listConnectors)(serviceName, [ "connectors.name", "connectors.source.files", ]); if (connectors.length) { info.serviceGql.connectors = connectors.map((c) => { const id = c.name.split("/").pop(); return { id, path: connectors.length === 1 ? "./example" : `./${id}`, files: c.source.files || [], }; }); } } async function chooseExistingService(existing) { const fdcConnector = (0, utils_1.envOverride)("FDC_CONNECTOR", ""); const fdcService = (0, utils_1.envOverride)("FDC_SERVICE", ""); const serviceEnvVar = fdcConnector || fdcService; if (serviceEnvVar) { const [serviceLocationFromEnvVar, serviceIdFromEnvVar] = serviceEnvVar.split("/"); const serviceFromEnvVar = existing.find((s) => { const serviceName = (0, names_1.parseServiceName)(s.name); return (serviceName.serviceId === serviceIdFromEnvVar && serviceName.location === serviceLocationFromEnvVar); }); if (serviceFromEnvVar) { (0, utils_1.logBullet)(`Picking up the existing service ${clc.bold(serviceLocationFromEnvVar + "/" + serviceIdFromEnvVar)}.`); return serviceFromEnvVar; } const envVarName = fdcConnector ? "FDC_CONNECTOR" : "FDC_SERVICE"; (0, utils_1.logWarning)(`Unable to pick up an existing service based on ${envVarName}=${serviceEnvVar}.`); } const choices = existing.map((s) => { const serviceName = (0, names_1.parseServiceName)(s.name); return { name: `${serviceName.location}/${serviceName.serviceId}`, value: s, }; }); choices.push({ name: "Create a new service", value: undefined }); return await (0, prompt_1.select)({ message: "Your project already has existing services. Which would you like to set up local files for?", choices, }); } async function promptForCloudSQL(setup, info) { if (!setup.projectId) { return; } const instrumentlessTrialEnabled = (0, experiments_1.isEnabled)("fdcift"); const billingEnabled = await (0, cloudbilling_1.isBillingEnabled)(setup); const freeTrialUsed = await (0, freeTrial_1.checkFreeTrialInstanceUsed)(setup.projectId); const freeTrialAvailable = !freeTrialUsed && (billingEnabled || instrumentlessTrialEnabled); if (!billingEnabled && !instrumentlessTrialEnabled) { setup.instructions.push((0, freeTrial_1.upgradeInstructions)(setup.projectId || "your-firebase-project", false)); return; } if (freeTrialUsed) { (0, utils_1.logLabeledWarning)("dataconnect", "CloudSQL no cost trial has already been used on this project."); } else if (instrumentlessTrialEnabled || billingEnabled) { (0, utils_1.logLabeledSuccess)("dataconnect", "CloudSQL no cost trial available!"); } if (info.cloudSqlInstanceId === "") { const instances = await cloudsql.listInstances(setup.projectId); let choices = instances.map((i) => { var _a; let display = `${i.name} (${i.region})`; if (((_a = i.settings.userLabels) === null || _a === void 0 ? void 0 : _a["firebase-data-connect"]) === "ft") { display += " (no cost trial)"; } return { name: display, value: i.name, location: i.region }; }); choices = choices.filter((c) => info.locationId === "" || info.locationId === c.location); if (choices.length) { if (freeTrialAvailable) { choices.push({ name: "Create a new free trial instance", value: "", location: "" }); } else { choices.push({ name: `Create a new CloudSQL instance${billingEnabled ? "" : " (requires billing account)"}`, value: "", location: "", }); } info.cloudSqlInstanceId = await (0, prompt_1.select)({ message: `Which CloudSQL instance would you like to use?`, choices, }); if (info.cloudSqlInstanceId !== "") { info.flow += "_pick_existing_csql"; info.locationId = choices.find((c) => c.value === info.cloudSqlInstanceId).location; } else { info.flow += "_pick_new_csql"; if (!billingEnabled && freeTrialUsed) { setup.instructions.push((0, freeTrial_1.upgradeInstructions)(setup.projectId || "your-firebase-project", true)); return; } info.cloudSqlInstanceId = await (0, prompt_1.input)({ message: `What ID would you like to use for your new CloudSQL instance?`, default: (0, utils_1.newUniqueId)(`${defaultServiceId().toLowerCase()}-fdc`, instances.map((i) => i.name)), }); } } } if (info.locationId === "") { await promptForLocation(setup, info); info.shouldProvisionCSQL = await (0, prompt_1.confirm)({ message: `Would you like to provision your ${freeTrialAvailable ? "free trial " : ""}Cloud SQL instance and database now?`, default: true, }); } if (info.cloudSqlInstanceId !== "" && info.cloudSqlDatabase === "") { try { const dbs = await cloudsql.listDatabases(setup.projectId, info.cloudSqlInstanceId); const existing = dbs.map((d) => d.name); info.cloudSqlDatabase = (0, utils_1.newUniqueId)("fdcdb", existing); } catch (err) { logger_1.logger.debug(`[dataconnect] Cannot list databases during init: ${err}`); } } return; } async function promptForLocation(setup, info) { if (info.locationId === "") { const choices = await locationChoices(setup); info.locationId = await (0, prompt_1.select)({ message: "What location would you like to use?", choices, default: exports.FDC_DEFAULT_REGION, }); } } async function locationChoices(setup) { if (setup.projectId) { const locations = await (0, client_1.listLocations)(setup.projectId); return locations.map((l) => { return { name: l, value: l }; }); } else { return [ { name: "asia-east1", value: "asia-east1" }, { name: "asia-east2", value: "asia-east2" }, { name: "asia-northeast1", value: "asia-northeast1" }, { name: "asia-northeast2", value: "asia-northeast2" }, { name: "asia-northeast3", value: "asia-northeast3" }, { name: "asia-south1", value: "asia-south1" }, { name: "asia-southeast1", value: "asia-southeast1" }, { name: "asia-southeast2", value: "asia-southeast2" }, { name: "australia-southeast1", value: "australia-southeast1" }, { name: "australia-southeast2", value: "australia-southeast2" }, { name: "europe-central2", value: "europe-central2" }, { name: "europe-north1", value: "europe-north1" }, { name: "europe-southwest1", value: "europe-southwest1" }, { name: "europe-west1", value: "europe-west1" }, { name: "europe-west2", value: "europe-west2" }, { name: "europe-west3", value: "europe-west3" }, { name: "europe-west4", value: "europe-west4" }, { name: "europe-west6", value: "europe-west6" }, { name: "europe-west8", value: "europe-west8" }, { name: "europe-west9", value: "europe-west9" }, { name: "me-west1", value: "me-west1" }, { name: "northamerica-northeast1", value: "northamerica-northeast1" }, { name: "northamerica-northeast2", value: "northamerica-northeast2" }, { name: "southamerica-east1", value: "southamerica-east1" }, { name: "southamerica-west1", value: "southamerica-west1" }, { name: "us-central1", value: "us-central1" }, { name: "us-east1", value: "us-east1" }, { name: "us-east4", value: "us-east4" }, { name: "us-south1", value: "us-south1" }, { name: "us-west1", value: "us-west1" }, { name: "us-west2", value: "us-west2" }, { name: "us-west3", value: "us-west3" }, { name: "us-west4", value: "us-west4" }, ]; } } function defaultServiceId() { return toDNSCompatibleId((0, path_1.basename)(process.cwd())); } function toDNSCompatibleId(id) { id = (0, path_1.basename)(id) .toLowerCase() .replaceAll(/[^a-z0-9-]/g, "") .slice(0, 63); while (id.endsWith("-") && id.length) { id = id.slice(0, id.length - 1); } while (id.startsWith("-") && id.length) { id = id.slice(1, id.length); } return id || "app"; } exports.toDNSCompatibleId = toDNSCompatibleId;