diff --git a/src/api.ts b/src/api.ts index c4235643e91..c7118000dde 100755 --- a/src/api.ts +++ b/src/api.ts @@ -143,6 +143,8 @@ export const cloudRunApiOrigin = () => utils.envOverride("CLOUD_RUN_API_URL", "https://run.googleapis.com"); export const serviceUsageOrigin = () => utils.envOverride("FIREBASE_SERVICE_USAGE_URL", "https://serviceusage.googleapis.com"); +export const studioApiOrigin = () => + utils.envOverride("FIREBASE_STUDIO_URL", "https://monospace-pa.googleapis.com"); export const githubOrigin = () => utils.envOverride("GITHUB_URL", "https://github.com"); export const githubApiOrigin = () => utils.envOverride("GITHUB_API_URL", "https://api.github.com"); diff --git a/src/command.ts b/src/command.ts index 325654c7953..b32e03ff540 100644 --- a/src/command.ts +++ b/src/command.ts @@ -11,9 +11,11 @@ import { detectProjectRoot } from "./detectProjectRoot"; import { trackEmulator, trackGA4 } from "./track"; import { selectAccount, setActiveAccount } from "./auth"; import { getProject } from "./management/projects"; +import { reconcileStudioFirebaseProject } from "./management/studio"; import { requireAuth } from "./requireAuth"; import { Options } from "./options"; import { useConsoleLoggers } from "./logger"; +import { isFirebaseStudio } from "./env"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ActionFunction = (...args: any[]) => any; @@ -338,7 +340,7 @@ export class Command { setActiveAccount(options, activeAccount); } - this.applyRC(options); + await this.applyRC(options); if (options.project) { await this.resolveProjectIdentifiers(options); validateProjectId(options.projectId); @@ -350,12 +352,22 @@ export class Command { * @param options the command options object. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private applyRC(options: Options): void { + private async applyRC(options: Options) { const rc = loadRC(options); options.rc = rc; - const activeProject = options.projectRoot + let activeProject = options.projectRoot ? (configstore.get("activeProjects") ?? {})[options.projectRoot] : undefined; + + // Only fetch the Studio Workspace project if we're running in Firebase + // Studio. If the user passes the project via --project, it should take + // priority. + // If this is the firebase use command, don't worry about reconciling - the user is changing it anyway + const isUseCommand = process.argv.includes("use"); + if (isFirebaseStudio() && !options.project && !isUseCommand) { + activeProject = await reconcileStudioFirebaseProject(options, activeProject); + } + options.project = options.project ?? activeProject; // support deprecated "firebase" key in firebase.json if (options.config && !options.project) { diff --git a/src/commands/use.ts b/src/commands/use.ts index 939c478b891..a8e2bc905ea 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -2,6 +2,7 @@ import * as clc from "colorette"; import { Command } from "../command"; import { getProject, listFirebaseProjects, ProjectInfo } from "../management/projects"; +import { updateStudioFirebaseProject } from "../management/studio"; import { logger } from "../logger"; import { Options } from "../options"; import { input, select } from "../prompt"; @@ -10,6 +11,7 @@ import { validateProjectId } from "../command"; import * as utils from "../utils"; import { FirebaseError } from "../error"; import { RC } from "../rc"; +import { isFirebaseStudio } from "../env"; function listAliases(options: Options) { if (options.rc.hasProjects) { @@ -49,6 +51,11 @@ export async function setNewActive( throw new FirebaseError("Invalid project selection, " + verifyMessage(projectOrAlias)); } + // Only update if running in Firebase Studio + if (isFirebaseStudio()) { + await updateStudioFirebaseProject(resolvedProject); + } + if (aliasOpt) { // firebase use [project] --alias [alias] if (!project) { diff --git a/src/management/studio.spec.ts b/src/management/studio.spec.ts new file mode 100644 index 00000000000..6e97005cd80 --- /dev/null +++ b/src/management/studio.spec.ts @@ -0,0 +1,195 @@ +import * as chai from "chai"; +import * as sinon from "sinon"; +import * as studio from "./studio"; +import * as prompt from "../prompt"; +import { configstore } from "../configstore"; +import { Client } from "../apiv2"; +import * as utils from "../utils"; +import { Options } from "../options"; +import { Config } from "../config"; +import { RC } from "../rc"; +import { logger } from "../logger"; + +const expect = chai.expect; + +describe("Studio Management", () => { + let sandbox: sinon.SinonSandbox; + let promptStub: sinon.SinonStub; + let clientRequestStub: sinon.SinonStub; + let utilsStub: sinon.SinonStub; + + let testOptions: Options; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + promptStub = sandbox.stub(prompt, "select"); + sandbox.stub(configstore, "get"); + sandbox.stub(configstore, "set"); + clientRequestStub = sandbox.stub(Client.prototype, "request"); + utilsStub = sandbox.stub(utils, "makeActiveProject"); + const emptyConfig = new Config("{}", {}); + testOptions = { + cwd: "", + configPath: "", + only: "", + except: "", + filteredTargets: [], + force: false, + json: false, + nonInteractive: false, + interactive: false, + debug: false, + config: emptyConfig, + rc: new RC(), + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("reconcileStudioFirebaseProject", () => { + it("should return active project from config if WORKSPACE_SLUG is not set", async () => { + process.env.WORKSPACE_SLUG = ""; + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + expect(result).to.equal("cli-project"); + expect(clientRequestStub).to.not.have.been.called; + }); + + it("should return active project from config if getStudioWorkspace fails", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.rejects(new Error("API Error")); + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + expect(result).to.equal("cli-project"); + }); + + it("should update studio with CLI project if studio has no project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub + .onFirstCall() + .resolves({ body: { name: "test-workspace", firebaseProjectId: undefined } }); + clientRequestStub.onSecondCall().resolves({ body: {} }); + + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + + expect(result).to.equal("cli-project"); + expect(clientRequestStub).to.have.been.calledTwice; + expect(clientRequestStub.secondCall.args[0].body.firebaseProjectId).to.equal("cli-project"); + }); + + it("should update CLI with studio project if CLI has no project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "studio-project" }, + }); + + const result = await studio.reconcileStudioFirebaseProject( + { ...testOptions, projectRoot: "/test" }, + undefined, + ); + + expect(result).to.equal("studio-project"); + expect(utilsStub).to.have.been.calledOnceWith("/test", "studio-project"); + }); + + it("should prompt user and update studio if user chooses CLI project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub + .onFirstCall() + .resolves({ body: { name: "test-workspace", firebaseProjectId: "studio-project" } }); + clientRequestStub.onSecondCall().resolves({ body: {} }); + promptStub.resolves(true); + + const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project"); + + expect(result).to.equal("cli-project"); + expect(promptStub).to.have.been.calledOnce; + expect(clientRequestStub).to.have.been.calledTwice; + expect(clientRequestStub.secondCall.args[0].body.firebaseProjectId).to.equal("cli-project"); + }); + + it("should prompt user and update CLI if user chooses studio project", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "studio-project" }, + }); + promptStub.resolves(false); + + const result = await studio.reconcileStudioFirebaseProject( + { ...testOptions, projectRoot: "/test" }, + "cli-project", + ); + + expect(result).to.equal("studio-project"); + expect(promptStub).to.have.been.calledOnce; + expect(utilsStub).to.have.been.calledOnceWith("/test", "studio-project"); + }); + + it("should do nothing if projects are the same", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "same-project" }, + }); + + const result = await studio.reconcileStudioFirebaseProject(testOptions, "same-project"); + + expect(result).to.equal("same-project"); + expect(promptStub).to.not.have.been.called; + expect(utilsStub).to.not.have.been.called; + }); + + it("should do nothing if in non-interactive mode", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ + body: { name: "test-workspace", firebaseProjectId: "studio-project" }, + }); + + const result = await studio.reconcileStudioFirebaseProject( + { ...testOptions, nonInteractive: true }, + "cli-project", + ); + + expect(result).to.equal("studio-project"); + expect(promptStub).to.not.have.been.called; + expect(utilsStub).to.not.have.been.called; + }); + }); + + describe("updateStudioFirebaseProject", () => { + it("should not call api if WORKSPACE_SLUG is not set", async () => { + process.env.WORKSPACE_SLUG = ""; + await studio.updateStudioFirebaseProject("new-project"); + expect(clientRequestStub).to.not.have.been.called; + }); + + it("should call api to update project id", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.resolves({ body: {} }); + + await studio.updateStudioFirebaseProject("new-project"); + + expect(clientRequestStub).to.have.been.calledOnceWith({ + method: "PATCH", + path: `/workspaces/test-workspace`, + responseType: "json", + body: { + firebaseProjectId: "new-project", + }, + queryParams: { + updateMask: "workspace.firebaseProjectId", + }, + timeout: 30000, + }); + }); + + it("should log error if api call fails", async () => { + process.env.WORKSPACE_SLUG = "test-workspace"; + clientRequestStub.rejects(new Error("API Error")); + const errorLogSpy = sandbox.spy(logger, "warn"); + + await studio.updateStudioFirebaseProject("new-project"); + + expect(errorLogSpy).to.have.been.calledOnce; + }); + }); +}); diff --git a/src/management/studio.ts b/src/management/studio.ts new file mode 100644 index 00000000000..fb8e6d78727 --- /dev/null +++ b/src/management/studio.ts @@ -0,0 +1,158 @@ +import { Client } from "../apiv2"; +import * as prompt from "../prompt"; +import * as api from "../api"; +import { logger } from "../logger"; +import * as utils from "../utils"; +import { Options } from "../options"; +import { configstore } from "../configstore"; + +const TIMEOUT_MILLIS = 30000; + +const studioClient = new Client({ + urlPrefix: api.studioApiOrigin(), + apiVersion: "v1", +}); + +/** + * Reconciles the active project in your Studio Workspace when running the CLI + * in Firebase Studio. + * @param activeProjectFromConfig The project ID saved in configstore + * @return A promise that resolves with the reconciled active project + */ +export async function reconcileStudioFirebaseProject( + options: Options, + activeProjectFromConfig: string | undefined, +): Promise { + const studioWorkspace = await getStudioWorkspace(); + // Fail gracefully and resolve with the existing configs + if (!studioWorkspace) { + return activeProjectFromConfig; + } + // If Studio has no project, update Studio if the CLI has one + if (!studioWorkspace.firebaseProjectId) { + if (activeProjectFromConfig) { + await updateStudioFirebaseProject(activeProjectFromConfig); + } + return activeProjectFromConfig; + } + // If the CLI has no project, update the CLI with what Studio has + if (!activeProjectFromConfig) { + await writeStudioProjectToConfigStore(options, studioWorkspace.firebaseProjectId); + return studioWorkspace.firebaseProjectId; + } + // If both have an active project, allow the user to choose + if (studioWorkspace.firebaseProjectId !== activeProjectFromConfig && !options.nonInteractive) { + const choices = [ + { + name: `Set ${studioWorkspace.firebaseProjectId} from Firebase Studio as my active project in both places`, + value: false as any, + }, + { + name: `Set ${activeProjectFromConfig} from Firebase CLI as my active project in both places`, + value: true as any, + }, + ]; + const useCliProject = await prompt.select({ + message: + "Found different active Firebase Projects in the Firebase CLI and your Firebase Studio Workspace. Which project would you like to set as your active project?", + choices, + }); + if (useCliProject) { + await updateStudioFirebaseProject(activeProjectFromConfig); + return activeProjectFromConfig; + } else { + await writeStudioProjectToConfigStore(options, studioWorkspace.firebaseProjectId); + return studioWorkspace.firebaseProjectId; + } + } + // Otherwise, Studio and the CLI agree + return studioWorkspace.firebaseProjectId; +} + +export interface StudioWorkspace { + name: string; + firebaseProjectId: string | undefined; +} + +async function getStudioWorkspace(): Promise { + const workspaceId = process.env.WORKSPACE_SLUG; + if (!workspaceId) { + logger.error( + `Failed to fetch Firebase Project from Studio Workspace because WORKSPACE_SLUG environment variable is empty`, + ); + return undefined; + } + try { + const res = await studioClient.request({ + method: "GET", + path: `/workspaces/${workspaceId}`, + timeout: TIMEOUT_MILLIS, + }); + return res.body; + } catch (err: any) { + let message = err.message; + if (err.original) { + message += ` (original: ${err.original.message})`; + } + logger.error(`Failed to fetch Firebase Project from current Studio Workspace: ${message}`); + // We're going to fail gracefully so that the caller can handle the error + return undefined; + } +} + +async function writeStudioProjectToConfigStore(options: Options, studioProjectId: string) { + if (options.projectRoot) { + logger.info( + `Updating Firebase CLI active project to match Studio Workspace '${studioProjectId}'`, + ); + utils.makeActiveProject(options.projectRoot, studioProjectId); + recordStudioProjectSyncTime(); + } +} + +/** + * Sets the active project for the current Firebase Studio Workspace + * @param projectId The project ID saved in spanner + * @return A promise that resolves when complete + */ +export async function updateStudioFirebaseProject(projectId: string): Promise { + logger.info(`Updating Studio Workspace active project to match Firebase CLI '${projectId}'`); + const workspaceId = process.env.WORKSPACE_SLUG; + if (!workspaceId) { + logger.error( + `Failed to update Firebase Project for Studio Workspace because WORKSPACE_SLUG environment variable is empty`, + ); + return; + } + try { + await studioClient.request({ + method: "PATCH", + path: `/workspaces/${workspaceId}`, + responseType: "json", + body: { + firebaseProjectId: projectId, + }, + queryParams: { + updateMask: "workspace.firebaseProjectId", + }, + timeout: TIMEOUT_MILLIS, + }); + } catch (err: any) { + let message = err.message; + if (err.original) { + message += ` (original: ${err.original.message})`; + } + logger.warn( + `Failed to update active Firebase Project for current Studio Workspace: ${message}`, + ); + } + recordStudioProjectSyncTime(); +} + +/** + * Records the last time we synced the Studio project in Configstore. + * This is important to trigger a file watcher in Firebase Studio that keeps the UI in sync. + */ +function recordStudioProjectSyncTime() { + configstore.set("firebaseStudioProjectLastSynced", Date.now()); +}