Skip to content

Commit 7e2a6e3

Browse files
authored
Fetch active Firebase Project from Studio Workspace when running in Studio (#8904)
1 parent 879f023 commit 7e2a6e3

File tree

5 files changed

+377
-3
lines changed

5 files changed

+377
-3
lines changed

src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ export const cloudRunApiOrigin = () =>
143143
utils.envOverride("CLOUD_RUN_API_URL", "https://run.googleapis.com");
144144
export const serviceUsageOrigin = () =>
145145
utils.envOverride("FIREBASE_SERVICE_USAGE_URL", "https://serviceusage.googleapis.com");
146+
export const studioApiOrigin = () =>
147+
utils.envOverride("FIREBASE_STUDIO_URL", "https://monospace-pa.googleapis.com");
146148

147149
export const githubOrigin = () => utils.envOverride("GITHUB_URL", "https://github.com");
148150
export const githubApiOrigin = () => utils.envOverride("GITHUB_API_URL", "https://api.github.com");

src/command.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import { detectProjectRoot } from "./detectProjectRoot";
1111
import { trackEmulator, trackGA4 } from "./track";
1212
import { selectAccount, setActiveAccount } from "./auth";
1313
import { getProject } from "./management/projects";
14+
import { reconcileStudioFirebaseProject } from "./management/studio";
1415
import { requireAuth } from "./requireAuth";
1516
import { Options } from "./options";
1617
import { useConsoleLoggers } from "./logger";
18+
import { isFirebaseStudio } from "./env";
1719

1820
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1921
type ActionFunction = (...args: any[]) => any;
@@ -338,7 +340,7 @@ export class Command {
338340
setActiveAccount(options, activeAccount);
339341
}
340342

341-
this.applyRC(options);
343+
await this.applyRC(options);
342344
if (options.project) {
343345
await this.resolveProjectIdentifiers(options);
344346
validateProjectId(options.projectId);
@@ -350,12 +352,22 @@ export class Command {
350352
* @param options the command options object.
351353
*/
352354
// eslint-disable-next-line @typescript-eslint/no-explicit-any
353-
private applyRC(options: Options): void {
355+
private async applyRC(options: Options) {
354356
const rc = loadRC(options);
355357
options.rc = rc;
356-
const activeProject = options.projectRoot
358+
let activeProject = options.projectRoot
357359
? (configstore.get("activeProjects") ?? {})[options.projectRoot]
358360
: undefined;
361+
362+
// Only fetch the Studio Workspace project if we're running in Firebase
363+
// Studio. If the user passes the project via --project, it should take
364+
// priority.
365+
// If this is the firebase use command, don't worry about reconciling - the user is changing it anyway
366+
const isUseCommand = process.argv.includes("use");
367+
if (isFirebaseStudio() && !options.project && !isUseCommand) {
368+
activeProject = await reconcileStudioFirebaseProject(options, activeProject);
369+
}
370+
359371
options.project = options.project ?? activeProject;
360372
// support deprecated "firebase" key in firebase.json
361373
if (options.config && !options.project) {

src/commands/use.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as clc from "colorette";
22

33
import { Command } from "../command";
44
import { getProject, listFirebaseProjects, ProjectInfo } from "../management/projects";
5+
import { updateStudioFirebaseProject } from "../management/studio";
56
import { logger } from "../logger";
67
import { Options } from "../options";
78
import { input, select } from "../prompt";
@@ -10,6 +11,7 @@ import { validateProjectId } from "../command";
1011
import * as utils from "../utils";
1112
import { FirebaseError } from "../error";
1213
import { RC } from "../rc";
14+
import { isFirebaseStudio } from "../env";
1315

1416
function listAliases(options: Options) {
1517
if (options.rc.hasProjects) {
@@ -49,6 +51,11 @@ export async function setNewActive(
4951
throw new FirebaseError("Invalid project selection, " + verifyMessage(projectOrAlias));
5052
}
5153

54+
// Only update if running in Firebase Studio
55+
if (isFirebaseStudio()) {
56+
await updateStudioFirebaseProject(resolvedProject);
57+
}
58+
5259
if (aliasOpt) {
5360
// firebase use [project] --alias [alias]
5461
if (!project) {

src/management/studio.spec.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import * as chai from "chai";
2+
import * as sinon from "sinon";
3+
import * as studio from "./studio";
4+
import * as prompt from "../prompt";
5+
import { configstore } from "../configstore";
6+
import { Client } from "../apiv2";
7+
import * as utils from "../utils";
8+
import { Options } from "../options";
9+
import { Config } from "../config";
10+
import { RC } from "../rc";
11+
import { logger } from "../logger";
12+
13+
const expect = chai.expect;
14+
15+
describe("Studio Management", () => {
16+
let sandbox: sinon.SinonSandbox;
17+
let promptStub: sinon.SinonStub;
18+
let clientRequestStub: sinon.SinonStub;
19+
let utilsStub: sinon.SinonStub;
20+
21+
let testOptions: Options;
22+
23+
beforeEach(() => {
24+
sandbox = sinon.createSandbox();
25+
promptStub = sandbox.stub(prompt, "select");
26+
sandbox.stub(configstore, "get");
27+
sandbox.stub(configstore, "set");
28+
clientRequestStub = sandbox.stub(Client.prototype, "request");
29+
utilsStub = sandbox.stub(utils, "makeActiveProject");
30+
const emptyConfig = new Config("{}", {});
31+
testOptions = {
32+
cwd: "",
33+
configPath: "",
34+
only: "",
35+
except: "",
36+
filteredTargets: [],
37+
force: false,
38+
json: false,
39+
nonInteractive: false,
40+
interactive: false,
41+
debug: false,
42+
config: emptyConfig,
43+
rc: new RC(),
44+
};
45+
});
46+
47+
afterEach(() => {
48+
sandbox.restore();
49+
});
50+
51+
describe("reconcileStudioFirebaseProject", () => {
52+
it("should return active project from config if WORKSPACE_SLUG is not set", async () => {
53+
process.env.WORKSPACE_SLUG = "";
54+
const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project");
55+
expect(result).to.equal("cli-project");
56+
expect(clientRequestStub).to.not.have.been.called;
57+
});
58+
59+
it("should return active project from config if getStudioWorkspace fails", async () => {
60+
process.env.WORKSPACE_SLUG = "test-workspace";
61+
clientRequestStub.rejects(new Error("API Error"));
62+
const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project");
63+
expect(result).to.equal("cli-project");
64+
});
65+
66+
it("should update studio with CLI project if studio has no project", async () => {
67+
process.env.WORKSPACE_SLUG = "test-workspace";
68+
clientRequestStub
69+
.onFirstCall()
70+
.resolves({ body: { name: "test-workspace", firebaseProjectId: undefined } });
71+
clientRequestStub.onSecondCall().resolves({ body: {} });
72+
73+
const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project");
74+
75+
expect(result).to.equal("cli-project");
76+
expect(clientRequestStub).to.have.been.calledTwice;
77+
expect(clientRequestStub.secondCall.args[0].body.firebaseProjectId).to.equal("cli-project");
78+
});
79+
80+
it("should update CLI with studio project if CLI has no project", async () => {
81+
process.env.WORKSPACE_SLUG = "test-workspace";
82+
clientRequestStub.resolves({
83+
body: { name: "test-workspace", firebaseProjectId: "studio-project" },
84+
});
85+
86+
const result = await studio.reconcileStudioFirebaseProject(
87+
{ ...testOptions, projectRoot: "/test" },
88+
undefined,
89+
);
90+
91+
expect(result).to.equal("studio-project");
92+
expect(utilsStub).to.have.been.calledOnceWith("/test", "studio-project");
93+
});
94+
95+
it("should prompt user and update studio if user chooses CLI project", async () => {
96+
process.env.WORKSPACE_SLUG = "test-workspace";
97+
clientRequestStub
98+
.onFirstCall()
99+
.resolves({ body: { name: "test-workspace", firebaseProjectId: "studio-project" } });
100+
clientRequestStub.onSecondCall().resolves({ body: {} });
101+
promptStub.resolves(true);
102+
103+
const result = await studio.reconcileStudioFirebaseProject(testOptions, "cli-project");
104+
105+
expect(result).to.equal("cli-project");
106+
expect(promptStub).to.have.been.calledOnce;
107+
expect(clientRequestStub).to.have.been.calledTwice;
108+
expect(clientRequestStub.secondCall.args[0].body.firebaseProjectId).to.equal("cli-project");
109+
});
110+
111+
it("should prompt user and update CLI if user chooses studio project", async () => {
112+
process.env.WORKSPACE_SLUG = "test-workspace";
113+
clientRequestStub.resolves({
114+
body: { name: "test-workspace", firebaseProjectId: "studio-project" },
115+
});
116+
promptStub.resolves(false);
117+
118+
const result = await studio.reconcileStudioFirebaseProject(
119+
{ ...testOptions, projectRoot: "/test" },
120+
"cli-project",
121+
);
122+
123+
expect(result).to.equal("studio-project");
124+
expect(promptStub).to.have.been.calledOnce;
125+
expect(utilsStub).to.have.been.calledOnceWith("/test", "studio-project");
126+
});
127+
128+
it("should do nothing if projects are the same", async () => {
129+
process.env.WORKSPACE_SLUG = "test-workspace";
130+
clientRequestStub.resolves({
131+
body: { name: "test-workspace", firebaseProjectId: "same-project" },
132+
});
133+
134+
const result = await studio.reconcileStudioFirebaseProject(testOptions, "same-project");
135+
136+
expect(result).to.equal("same-project");
137+
expect(promptStub).to.not.have.been.called;
138+
expect(utilsStub).to.not.have.been.called;
139+
});
140+
141+
it("should do nothing if in non-interactive mode", async () => {
142+
process.env.WORKSPACE_SLUG = "test-workspace";
143+
clientRequestStub.resolves({
144+
body: { name: "test-workspace", firebaseProjectId: "studio-project" },
145+
});
146+
147+
const result = await studio.reconcileStudioFirebaseProject(
148+
{ ...testOptions, nonInteractive: true },
149+
"cli-project",
150+
);
151+
152+
expect(result).to.equal("studio-project");
153+
expect(promptStub).to.not.have.been.called;
154+
expect(utilsStub).to.not.have.been.called;
155+
});
156+
});
157+
158+
describe("updateStudioFirebaseProject", () => {
159+
it("should not call api if WORKSPACE_SLUG is not set", async () => {
160+
process.env.WORKSPACE_SLUG = "";
161+
await studio.updateStudioFirebaseProject("new-project");
162+
expect(clientRequestStub).to.not.have.been.called;
163+
});
164+
165+
it("should call api to update project id", async () => {
166+
process.env.WORKSPACE_SLUG = "test-workspace";
167+
clientRequestStub.resolves({ body: {} });
168+
169+
await studio.updateStudioFirebaseProject("new-project");
170+
171+
expect(clientRequestStub).to.have.been.calledOnceWith({
172+
method: "PATCH",
173+
path: `/workspaces/test-workspace`,
174+
responseType: "json",
175+
body: {
176+
firebaseProjectId: "new-project",
177+
},
178+
queryParams: {
179+
updateMask: "workspace.firebaseProjectId",
180+
},
181+
timeout: 30000,
182+
});
183+
});
184+
185+
it("should log error if api call fails", async () => {
186+
process.env.WORKSPACE_SLUG = "test-workspace";
187+
clientRequestStub.rejects(new Error("API Error"));
188+
const errorLogSpy = sandbox.spy(logger, "warn");
189+
190+
await studio.updateStudioFirebaseProject("new-project");
191+
192+
expect(errorLogSpy).to.have.been.calledOnce;
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)