Skip to content

Fetch active Firebase Project from Studio Workspace when running in Studio #8904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Aug 4, 2025
Merged
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,32 @@

let commandScopes = new Set<string>();

export const authProxyOrigin = () =>

Check warning on line 8 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_AUTHPROXY_URL", "https://auth.firebase.tools");
// "In this context, the client secret is obviously not treated as a secret"
// https://developers.google.com/identity/protocols/OAuth2InstalledApp
export const clientId = () =>

Check warning on line 12 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride(
"FIREBASE_CLIENT_ID",
"563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com",
);
export const clientSecret = () =>

Check warning on line 17 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_CLIENT_SECRET", "j9iVZfS8kkCEFUPaAeJV0sAi");
export const cloudbillingOrigin = () =>

Check warning on line 19 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_CLOUDBILLING_URL", "https://cloudbilling.googleapis.com");
export const cloudloggingOrigin = () =>

Check warning on line 21 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_CLOUDLOGGING_URL", "https://logging.googleapis.com");
export const cloudMonitoringOrigin = () =>

Check warning on line 23 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("CLOUD_MONITORING_URL", "https://monitoring.googleapis.com");
export const containerRegistryDomain = () =>

Check warning on line 25 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io");

export const developerConnectOrigin = () =>

Check warning on line 28 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("DEVELOPERCONNECT_URL", "https://developerconnect.googleapis.com");
export const developerConnectP4SADomain = () =>

Check warning on line 30 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("DEVELOPERCONNECT_P4SA_DOMAIN", "gcp-sa-devconnect.iam.gserviceaccount.com");

export const artifactRegistryDomain = () =>

Check warning on line 33 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("ARTIFACT_REGISTRY_DOMAIN", "https://artifactregistry.googleapis.com");
export const appDistributionOrigin = () =>
utils.envOverride(
Expand Down Expand Up @@ -143,6 +143,8 @@
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");
Expand Down
18 changes: 15 additions & 3 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions src/commands/use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
195 changes: 195 additions & 0 deletions src/management/studio.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
});
Loading
Loading