diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ffa9bd0696..0dd9db9ca89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ - Add a confirmation in `firebase init dataconnect` before asking for app idea description. (#9282) +- Update dataconnect:\* commands to use flags for --service & --location (#9312) diff --git a/src/commands/dataconnect-sdk-generate.ts b/src/commands/dataconnect-sdk-generate.ts index 741b74370b4..204f25c0f09 100644 --- a/src/commands/dataconnect-sdk-generate.ts +++ b/src/commands/dataconnect-sdk-generate.ts @@ -4,24 +4,33 @@ import { Command } from "../command"; import { Options } from "../options"; import { DataConnectEmulator } from "../emulator/dataconnectEmulator"; import { needProjectId } from "../projectUtils"; -import { loadAll } from "../dataconnect/load"; +import { pickServices } from "../dataconnect/load"; import { logger } from "../logger"; import { getProjectDefaultAccount } from "../auth"; import { logLabeledSuccess } from "../utils"; import { ServiceInfo } from "../dataconnect/types"; -type GenerateOptions = Options & { watch?: boolean }; +type GenerateOptions = Options & { watch?: boolean; service?: string; location?: string }; export const command = new Command("dataconnect:sdk:generate") - .description("generate typed SDKs for your Data Connect connectors") + .description("generate typed SDKs to use Data Connect in your apps") + .option( + "--service ", + "the serviceId of the Data Connect service. If not provided, generates SDKs for all services.", + ) + .option("--location ", "the location of the Data Connect service to disambiguate") .option( "--watch", "watch for changes to your connector GQL files and regenerate your SDKs when updates occur", ) .action(async (options: GenerateOptions) => { const projectId = needProjectId(options); - - const serviceInfos = await loadAll(projectId, options.config); + const serviceInfos = await pickServices( + projectId, + options.config, + options.service, + options.location, + ); const serviceInfosWithSDKs = serviceInfos.filter((serviceInfo) => serviceInfo.connectorInfo.some((c) => { return ( diff --git a/src/commands/dataconnect-sql-diff.ts b/src/commands/dataconnect-sql-diff.ts index 73035ad52e8..de12631d884 100644 --- a/src/commands/dataconnect-sql-diff.ts +++ b/src/commands/dataconnect-sql-diff.ts @@ -3,29 +3,38 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { ensureApis } from "../dataconnect/ensureApis"; import { requirePermissions } from "../requirePermissions"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { diffSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; -export const command = new Command("dataconnect:sql:diff [serviceId]") +type DiffOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:diff") .description( - "display the differences between a local Data Connect schema and your CloudSQL database's current schema", + "display the differences between the local Data Connect schema and your CloudSQL database's schema", ) + .option("--service ", "the serviceId of the Data Connect service") + .option("--location ", "the location of the Data Connect service to disambiguate") .before(requirePermissions, [ "firebasedataconnect.services.list", "firebasedataconnect.schemas.list", "firebasedataconnect.schemas.update", ]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { + .action(async (options: DiffOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const diffs = await diffSchema( options, serviceInfo.schema, serviceInfo.dataConnectYaml.schema.datasource.postgresql?.schemaValidation, ); - return { projectId, serviceId, diffs }; + return { projectId, diffs }; }); diff --git a/src/commands/dataconnect-sql-grant.ts b/src/commands/dataconnect-sql-grant.ts index 30a25da9e9e..808fcd1ca9e 100644 --- a/src/commands/dataconnect-sql-grant.ts +++ b/src/commands/dataconnect-sql-grant.ts @@ -3,7 +3,7 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { ensureApis } from "../dataconnect/ensureApis"; import { requirePermissions } from "../requirePermissions"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { grantRoleToUserInSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { FirebaseError } from "../error"; @@ -12,45 +12,57 @@ import { iamUserIsCSQLAdmin } from "../gcp/cloudsql/cloudsqladmin"; const allowedRoles = Object.keys(fdcSqlRoleMap); -export const command = new Command("dataconnect:sql:grant [serviceId]") +type GrantOptions = Options & { + role?: string; + email?: string; + service?: string; + location?: string; +}; + +export const command = new Command("dataconnect:sql:grant") .description("grants the SQL role to the provided user or service account ") .option("-R, --role ", "The SQL role to grant. One of: owner, writer, or reader.") .option( "-E, --email ", "The email of the user or service account we would like to grant the role to.", ) + .option("--service ", "the serviceId of the Data Connect service") + .option("--location ", "the location of the Data Connect service to disambiguate") .before(requirePermissions, ["firebasedataconnect.services.list"]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { - const role = options.role as string; - const email = options.email as string; - if (!role) { + .action(async (options: GrantOptions) => { + if (!options.role) { throw new FirebaseError( "-R, --role is required. Run the command with -h for more info.", ); } - if (!email) { + if (!options.email) { throw new FirebaseError( "-E, --email is required. Run the command with -h for more info.", ); } - if (!allowedRoles.includes(role.toLowerCase())) { + if (!allowedRoles.includes(options.role.toLowerCase())) { throw new FirebaseError(`Role should be one of ${allowedRoles.join(" | ")}.`); } + const projectId = needProjectId(options); + await ensureApis(projectId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service as string | undefined, + options.location as string | undefined, + ); + // Make sure current user can perform this action. const userIsCSQLAdmin = await iamUserIsCSQLAdmin(options); if (!userIsCSQLAdmin) { throw new FirebaseError( - `Only users with 'roles/cloudsql.admin' can grant SQL roles. If you do not have this role, ask your database administrator to run this command or manually grant ${role} to ${email}`, + `Only users with 'roles/cloudsql.admin' can grant SQL roles. If you do not have this role, ask your database administrator to run this command or manually grant ${options.role} to ${options.email}`, ); } - const projectId = needProjectId(options); - await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); - await grantRoleToUserInSchema(options, serviceInfo.schema); - return { projectId, serviceId }; + return { projectId }; }); diff --git a/src/commands/dataconnect-sql-migrate.ts b/src/commands/dataconnect-sql-migrate.ts index bcc72943327..636b89d4988 100644 --- a/src/commands/dataconnect-sql-migrate.ts +++ b/src/commands/dataconnect-sql-migrate.ts @@ -1,7 +1,7 @@ import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { FirebaseError } from "../error"; import { migrateSchema } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; @@ -9,8 +9,12 @@ import { requirePermissions } from "../requirePermissions"; import { ensureApis } from "../dataconnect/ensureApis"; import { logLabeledSuccess } from "../utils"; -export const command = new Command("dataconnect:sql:migrate [serviceId]") +type MigrateOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:migrate") .description("migrate your CloudSQL database's schema to match your local Data Connect schema") + .option("--service ", "the serviceId of the Data Connect service") + .option("--location ", "the location of the Data Connect service to disambiguate") .before(requirePermissions, [ "firebasedataconnect.services.list", "firebasedataconnect.schemas.list", @@ -19,10 +23,15 @@ export const command = new Command("dataconnect:sql:migrate [serviceId]") ]) .before(requireAuth) .withForce("execute any required database changes without prompting") - .action(async (serviceId: string, options: Options) => { + .action(async (options: MigrateOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const instanceId = serviceInfo.dataConnectYaml.schema.datasource.postgresql?.cloudSql.instanceId; if (!instanceId) { @@ -44,5 +53,5 @@ export const command = new Command("dataconnect:sql:migrate [serviceId]") } else { logLabeledSuccess("dataconnect", "Database schema is already up to date!"); } - return { projectId, serviceId, diffs }; + return { projectId, diffs }; }); diff --git a/src/commands/dataconnect-sql-setup.ts b/src/commands/dataconnect-sql-setup.ts index 164a1d55d53..4627e997b9c 100644 --- a/src/commands/dataconnect-sql-setup.ts +++ b/src/commands/dataconnect-sql-setup.ts @@ -1,7 +1,6 @@ import { Command } from "../command"; import { Options } from "../options"; import { needProjectId } from "../projectUtils"; -import { pickService } from "../dataconnect/load"; import { FirebaseError } from "../error"; import { requireAuth } from "../requireAuth"; import { requirePermissions } from "../requirePermissions"; @@ -10,9 +9,14 @@ import { setupSQLPermissions, getSchemaMetadata } from "../gcp/cloudsql/permissi import { DEFAULT_SCHEMA } from "../gcp/cloudsql/permissions"; import { getIdentifiers, ensureServiceIsConnectedToCloudSql } from "../dataconnect/schemaMigration"; import { setupIAMUsers } from "../gcp/cloudsql/connect"; +import { pickOneService } from "../dataconnect/load"; -export const command = new Command("dataconnect:sql:setup [serviceId]") +type SetupOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:setup") .description("set up your CloudSQL database") + .option("--service ", "the serviceId of the Data Connect service") + .option("--location ", "the location of the Data Connect service to disambiguate") .before(requirePermissions, [ "firebasedataconnect.services.list", "firebasedataconnect.schemas.list", @@ -20,10 +24,15 @@ export const command = new Command("dataconnect:sql:setup [serviceId]") "cloudsql.instances.connect", ]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { + .action(async (options: SetupOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const instanceId = serviceInfo.dataConnectYaml.schema.datasource.postgresql?.cloudSql.instanceId; if (!instanceId) { diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index 6023fb44905..85eb716ed21 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -7,7 +7,7 @@ import { Options } from "../options"; import { needProjectId } from "../projectUtils"; import { ensureApis } from "../dataconnect/ensureApis"; import { requirePermissions } from "../requirePermissions"; -import { pickService } from "../dataconnect/load"; +import { pickOneService } from "../dataconnect/load"; import { getIdentifiers } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { getIAMUser } from "../gcp/cloudsql/connect"; @@ -81,16 +81,25 @@ async function mainShellLoop(conn: pg.PoolClient) { } } -export const command = new Command("dataconnect:sql:shell [serviceId]") +type ShellOptions = Options & { service?: string; location?: string }; + +export const command = new Command("dataconnect:sql:shell") .description( "start a shell connected directly to your Data Connect service's linked CloudSQL instance", ) + .option("--service ", "the serviceId of the Data Connect service") + .option("--location ", "the location of the Data Connect service to disambiguate") .before(requirePermissions, ["firebasedataconnect.services.list", "cloudsql.instances.connect"]) .before(requireAuth) - .action(async (serviceId: string, options: Options) => { + .action(async (options: ShellOptions) => { const projectId = needProjectId(options); await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); + const serviceInfo = await pickOneService( + projectId, + options.config, + options.service, + options.location, + ); const { instanceId, databaseId } = getIdentifiers(serviceInfo.schema); const { user: username } = await getIAMUser(options); const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); @@ -134,5 +143,5 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") await pool.end(); connector.close(); - return { projectId, serviceId }; + return { projectId }; }); diff --git a/src/dataconnect/load.ts b/src/dataconnect/load.ts index 4f021746e22..33671e79caa 100644 --- a/src/dataconnect/load.ts +++ b/src/dataconnect/load.ts @@ -15,44 +15,56 @@ import { import { readFileFromDirectory, wrappedSafeLoad } from "../utils"; import { DataConnectMultiple } from "../firebaseConfig"; -// pickService reads firebase.json and returns all services with a given serviceId. -// If serviceID is not provided and there is a single service, return that. -export async function pickService( +/** Picks exactly one Data Connect service based on flags. */ +export async function pickOneService( projectId: string, config: Config, - serviceId?: string, + service?: string, + location?: string, ): Promise { + const services = await pickServices(projectId, config, service, location); + if (services.length > 1) { + const serviceIds = services.map( + (i) => `${i.dataConnectYaml.location}:${i.dataConnectYaml.serviceId}`, + ); + throw new FirebaseError( + `Multiple services matched. Please specify a service and location. Matched services: ${serviceIds.join( + ", ", + )}`, + ); + } + return services[0]; +} + +/** Picks Data Connect services based on flags. */ +export async function pickServices( + projectId: string, + config: Config, + serviceId?: string, + location?: string, +): Promise { const serviceInfos = await loadAll(projectId, config); if (serviceInfos.length === 0) { throw new FirebaseError( "No Data Connect services found in firebase.json." + `\nYou can run ${clc.bold("firebase init dataconnect")} to add a Data Connect service.`, ); - } else if (serviceInfos.length === 1) { - if (serviceId && serviceId !== serviceInfos[0].dataConnectYaml.serviceId) { - throw new FirebaseError( - `No service named ${serviceId} declared in firebase.json. Found ${serviceInfos[0].dataConnectYaml.serviceId}.` + - `\nYou can run ${clc.bold("firebase init dataconnect")} to add this Data Connect service.`, - ); - } - return serviceInfos[0]; - } else { - if (!serviceId) { - throw new FirebaseError( - "Multiple Data Connect services found in firebase.json. Please specify a service ID to use.", - ); - } - // TODO: handle cases where there are services with the same ID in 2 locations. - const maybe = serviceInfos.find((i) => i.dataConnectYaml.serviceId === serviceId); - if (!maybe) { - const serviceIds = serviceInfos.map((i) => i.dataConnectYaml.serviceId); - throw new FirebaseError( - `No service named ${serviceId} declared in firebase.json. Found ${serviceIds.join(", ")}.` + - `\nYou can run ${clc.bold("firebase init dataconnect")} to add this Data Connect service.`, - ); - } - return maybe; } + + const matchingServices = serviceInfos.filter( + (i) => + (!serviceId || i.dataConnectYaml.serviceId === serviceId) && + (!location || i.dataConnectYaml.location === location), + ); + if (matchingServices.length === 0) { + const serviceIds = serviceInfos.map( + (i) => `${i.dataConnectYaml.location}:${i.dataConnectYaml.serviceId}`, + ); + throw new FirebaseError( + `No service matched service in firebase.json. Available services: ${serviceIds.join(", ")}`, + ); + } + return matchingServices; } /** diff --git a/src/mcp/tools/dataconnect/compile.ts b/src/mcp/tools/dataconnect/compile.ts index e5904385051..bec43f68357 100644 --- a/src/mcp/tools/dataconnect/compile.ts +++ b/src/mcp/tools/dataconnect/compile.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { tool } from "../../tool"; -import { pickService } from "../../../dataconnect/load"; import { compileErrors } from "../../util/dataconnect/compile"; +import { pickServices } from "../../../dataconnect/load"; export const compile = tool( { @@ -17,7 +17,13 @@ export const compile = tool( .string() .optional() .describe( - "The Firebase Data Connect service ID to look for. If omitted, builds all services defined in `firebase.json`.", + `Data Connect Service ID to disambiguate if there are multiple Data Connect services.`, + ), + location_id: z + .string() + .optional() + .describe( + `Data Connect Service location ID to disambiguate among multiple Data Connect services.`, ), }), annotations: { @@ -29,15 +35,26 @@ export const compile = tool( requiresAuth: false, }, }, - async ({ service_id, error_filter }, { projectId, config }) => { - const serviceInfo = await pickService(projectId, config, service_id || undefined); - const errors = await compileErrors(serviceInfo.sourceDirectory, error_filter); - if (errors) + async ({ service_id, location_id, error_filter }, { projectId, config }) => { + const serviceInfos = await pickServices( + projectId, + config, + service_id || undefined, + location_id || undefined, + ); + const errors = ( + await Promise.all( + serviceInfos.map(async (serviceInfo) => { + return await compileErrors(serviceInfo.sourceDirectory, error_filter); + }), + ) + ).flat(); + if (errors.length > 0) return { content: [ { type: "text", - text: `The following errors were encountered while compiling Data Connect from directory \`${serviceInfo.sourceDirectory}\`:\n\n${errors}`, + text: `The following errors were encountered while compiling Data Connect:\n\n${errors.join("\n")}`, }, ], isError: true, diff --git a/src/mcp/tools/dataconnect/execute.ts b/src/mcp/tools/dataconnect/execute.ts index 6143c7df01f..b541a0b64f9 100644 --- a/src/mcp/tools/dataconnect/execute.ts +++ b/src/mcp/tools/dataconnect/execute.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { tool } from "../../tool"; import * as dataplane from "../../../dataconnect/dataplaneClient"; -import { pickService } from "../../../dataconnect/load"; +import { pickOneService } from "../../../dataconnect/load"; import { graphqlResponseToToolResponse, parseVariables } from "../../util/dataconnect/converter"; import { getDataConnectEmulatorClient } from "../../util/dataconnect/emulator"; import { Client } from "../../../apiv2"; @@ -17,11 +17,18 @@ export const execute = tool( You can use the \`dataconnect_generate_operation\` tool to generate a query. Example Data Connect schema and example queries can be found in files ending in \`.graphql\` or \`.gql\`. `), - service_id: z.string().optional() - .describe(`Data Connect Service ID to dis-ambulate if there are multiple. -It's only necessary if there are multiple dataconnect sources in \`firebase.json\`. -You can find candidate service_id in \`dataconnect.yaml\` -`), + service_id: z + .string() + .optional() + .describe( + `Data Connect Service ID to disambiguate if there are multiple Data Connect services.`, + ), + location_id: z + .string() + .optional() + .describe( + `Data Connect Service location ID to disambiguate among multiple Data Connect services.`, + ), variables_json: z .string() .optional() @@ -55,13 +62,19 @@ You can find candidate service_id in \`dataconnect.yaml\` { query, service_id, + location_id, variables_json: unparsedVariables, use_emulator, auth_token_json: unparsedAuthToken, }, { projectId, config, host }, ) => { - const serviceInfo = await pickService(projectId, config, service_id || undefined); + const serviceInfo = await pickOneService( + projectId, + config, + service_id || undefined, + location_id || undefined, + ); let apiClient: Client; if (use_emulator) { apiClient = await getDataConnectEmulatorClient(host); diff --git a/src/mcp/tools/dataconnect/generate_operation.ts b/src/mcp/tools/dataconnect/generate_operation.ts index 731afc7adc7..309907e83a3 100644 --- a/src/mcp/tools/dataconnect/generate_operation.ts +++ b/src/mcp/tools/dataconnect/generate_operation.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { tool } from "../../tool"; import { toContent } from "../../util"; import { generateOperation } from "../../../gemini/fdcExperience"; -import { pickService } from "../../../dataconnect/load"; +import { pickOneService } from "../../../dataconnect/load"; export const generate_operation = tool( { @@ -20,7 +20,13 @@ export const generate_operation = tool( .string() .optional() .describe( - "Optional: Uses the service ID from the firebase.json file if nothing provided. The service ID of the deployed Firebase resource.", + `Data Connect Service ID to disambiguate if there are multiple Data Connect services.`, + ), + location_id: z + .string() + .optional() + .describe( + `Data Connect Service location ID to disambiguate among multiple Data Connect services.`, ), }), annotations: { @@ -33,8 +39,13 @@ export const generate_operation = tool( requiresGemini: true, }, }, - async ({ prompt, service_id }, { projectId, config }) => { - const serviceInfo = await pickService(projectId, config, service_id || undefined); + async ({ prompt, service_id, location_id }, { projectId, config }) => { + const serviceInfo = await pickOneService( + projectId, + config, + service_id || undefined, + location_id || undefined, + ); const schema = await generateOperation(prompt, serviceInfo.serviceName, projectId); return toContent(schema); },