Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
- CloudSQL instances created with `firebase dataconnect:connect` now default to Postgres 17.
- Fixed an issue with deploying indexes to Firestore Enterprise edition databases where explicit `__name__` fields could be incorrectly handled.
- The `experimental:mcp` command has been renamed to `mcp`. The old name is now an alias.
- `firebase_update_environment` MCP tool supports accepting Gemini in Firebase Terms of Service.
- Fixed a bug when `firebase init dataconnect` failed to create a React app when launched from VS Code extension (#9171).
- Improved the clarity of the `firebase apptesting:execute` command when you have zero or multiple apps.
- `firebase dataconnect:sql:migrate` now supports Cloud SQL instances with only private IPs. The command must be run in the same VPC of the instance to work. (##9200)
- Fixed an issue where `firebase deploy --only firestore` would fail with 403 errors on projects that never had a database created.
- Added `remoteconfig:experiments:get`, `remoteconfig:experiments:list`, and `remoteconfig:experiments:delete` commands to manage Remote Config experiments.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this CHANGELOG is out of date and is pulling in things that went out in the last release. Can you change this to only include lines 9, 10, and 11?

- Added `remoteconfig:rollouts:get`, `remoteconfig:rollouts:list`, and `remoteconfig:rollouts:delete` commands to manage Remote Config rollouts.
- Fix Functions MCP log tool to normalize sort order and surface Cloud Logging error details (#9247)
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,17 @@ Detailed doc is [here](https://firebase.google.com/docs/cli/auth).

### Remote Config Commands

| Command | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| **remoteconfig:get** | Get a Firebase project's Remote Config template. |
| **remoteconfig:versions:list** | Get a list of the most recent Firebase Remote Config template versions that have been published. |
| **remoteconfig:rollback** | Roll back a project's published Remote Config template to the version provided by `--version_number` flag. |
| Command | Description |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **remoteconfig:get** | Get a Firebase project's Remote Config template. |
| **remoteconfig:versions:list** | Get a list of the most recent Firebase Remote Config template versions that have been published. |
| **remoteconfig:rollback** | Roll back a project's published Remote Config template to the version provided by `--version_number` flag. |
| **remoteconfig:experiments:get** | Get a Remote Config experiment. |
| **remoteconfig:experiments:list** | Get a list of Remote Config experiments |
| **remoteconfig:experiments:delete** | Delete a Remote Config experiment. |
| **remoteconfig:rollouts:get** | Get a Remote Config rollout. |
| **remoteconfig:rollouts:list** | Get a list of Remote Config rollouts. |
| **remoteconfig:rollouts:delete** | Delete a Remote Config rollout. |

Use `firebase:deploy --only remoteconfig` to update and publish a project's Firebase Remote Config template.

Expand Down
8 changes: 8 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
/**
* Loads all commands for our parser.
*/
export function load(client: any): any {

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
function loadCommand(name: string) {

Check warning on line 6 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const t0 = process.hrtime.bigint();
const { command: cmd } = require(`./${name}`);

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Require statement not part of import statement

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
cmd.register(client);

Check warning on line 9 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 9 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .register on an `any` value
const t1 = process.hrtime.bigint();
const diffMS = (t1 - t0) / BigInt(1e6);
if (diffMS > 75) {
Expand All @@ -14,7 +14,7 @@
// console.error(`Loading ${name} took ${diffMS}ms`);
}

return cmd.runner();

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .runner on an `any` value

Check warning on line 17 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

const t0 = process.hrtime.bigint();
Expand Down Expand Up @@ -213,6 +213,14 @@
client.remoteconfig.rollback = loadCommand("remoteconfig-rollback");
client.remoteconfig.versions = {};
client.remoteconfig.versions.list = loadCommand("remoteconfig-versions-list");
client.remoteconfig.rollouts = {};
client.remoteconfig.rollouts.get = loadCommand("remoteconfig-rollouts-get");
client.remoteconfig.rollouts.list = loadCommand("remoteconfig-rollouts-list");
client.remoteconfig.rollouts.delete = loadCommand("remoteconfig-rollouts-delete");
client.remoteconfig.experiments = {};
client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get");
client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list");
client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete");
client.serve = loadCommand("serve");
client.setup = {};
client.setup.emulators = {};
Expand Down
33 changes: 33 additions & 0 deletions src/commands/remoteconfig-experiments-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
import * as rcExperiment from "../remoteconfig/deleteExperiment";
import { getExperiment, parseExperiment } from "../remoteconfig/getExperiment";
import { confirm } from "../prompt";

export const command = new Command("remoteconfig:experiments:delete <experimentId>")
.description("delete a Remote Config experiment.")
.before(requireAuth)
.before(requirePermissions, [
"firebaseabt.experiments.delete",
"firebaseanalytics.resources.googleAnalyticsEdit",
])
.action(async (experimentId: string, options: Options) => {
const projectNumber: string = await needProjectNumber(options);
const experiment = await getExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId);
logger.info(parseExperiment(experiment));
const confirmDeletion = await confirm({
message: "Are you sure you want to delete this experiment? This cannot be undone.",
default: false,
});
if (!confirmDeletion) {
return;
}
logger.info(
await rcExperiment.deleteExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId),
);
});
23 changes: 23 additions & 0 deletions src/commands/remoteconfig-experiments-get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
import * as rcExperiment from "../remoteconfig/getExperiment";

export const command = new Command("remoteconfig:experiments:get <experimentId>")
.description("get a Remote Config experiment.")
.before(requireAuth)
.before(requirePermissions, ["firebaseabt.experiments.get"])
.action(async (experimentId: string, options: Options) => {
const projectNumber: string = await needProjectNumber(options);
const experiment: GetExperimentResult = await rcExperiment.getExperiment(
projectNumber,
NAMESPACE_FIREBASE,
experimentId,
);
logger.info(rcExperiment.parseExperiment(experiment));
return experiment;
});
51 changes: 51 additions & 0 deletions src/commands/remoteconfig-experiments-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as rcExperiment from "../remoteconfig/listExperiments";
import {
DEFAULT_PAGE_SIZE,
ListExperimentOptions,
ListExperimentsResult,
NAMESPACE_FIREBASE,
} from "../remoteconfig/interfaces";
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";

export const command = new Command("remoteconfig:experiments:list")
.description("get a list of Remote Config experiments")
.option(
"--pageSize <pageSize>",
"Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments",
)
.option(
"--pageToken <pageToken>",
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.",
)
.option(
"--filter <filter>",
"Filters experiments by their full resource name. Format: `name:projects/{project_number}/namespaces/{namespace}/experiments/{experiment_id}`",
)
.before(requireAuth)
.before(requirePermissions, [
"firebaseabt.experiments.list",
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze",
])
.action(async (options: Options) => {
const projectNumber = await needProjectNumber(options);
const listExperimentOptions: ListExperimentOptions = {
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like to avoid this sort of casting by adding a specialized RemoteConfigOptions type that is fulfilled by Options. Here's an example from hosting, would love something similar here: https://github.com/firebase/firebase-tools/blob/master/src/hosting/options.ts

Applies for all the commands.

pageToken: options.pageToken as string,
filter: options.filter as string,
};
const { experiments, nextPageToken }: ListExperimentsResult =
await rcExperiment.listExperiments(projectNumber, NAMESPACE_FIREBASE, listExperimentOptions);
logger.info(rcExperiment.parseExperimentList(experiments ?? []));
if (nextPageToken) {
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`);
}
return {
experiments,
nextPageToken,
};
});
31 changes: 31 additions & 0 deletions src/commands/remoteconfig-rollouts-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
import * as rcRollout from "../remoteconfig/deleteRollout";
import { getRollout, parseRolloutIntoTable } from "../remoteconfig/getRollout";
import { confirm } from "../prompt";

export const command = new Command("remoteconfig:rollouts:delete <rolloutId>")
.description("delete a Remote Config rollout.")
.before(requireAuth)
.before(requirePermissions, [
"cloud.configs.update",
"firebaseanalytics.resources.googleAnalyticsEdit",
])
.action(async (rolloutId: string, options: Options) => {
const projectNumber: string = await needProjectNumber(options);
const rollout = await getRollout(projectNumber, NAMESPACE_FIREBASE, rolloutId);
logger.info(parseRolloutIntoTable(rollout));
const confirmDeletion = await confirm({
message: "Are you sure you want to delete this rollout? This cannot be undone.",
default: false,
});
if (!confirmDeletion) {
return;
}
logger.info(await rcRollout.deleteRollout(projectNumber, NAMESPACE_FIREBASE, rolloutId));
});
23 changes: 23 additions & 0 deletions src/commands/remoteconfig-rollouts-get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import { RemoteConfigRollout, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
import * as rcRollout from "../remoteconfig/getRollout";

export const command = new Command("remoteconfig:rollouts:get <rolloutId>")
.description("get a Remote Config rollout")
.before(requireAuth)
.before(requirePermissions, ["cloud.configs.get"])
.action(async (rolloutId: string, options: Options) => {
const projectNumber: string = await needProjectNumber(options);
const rollout: RemoteConfigRollout = await rcRollout.getRollout(
projectNumber,
NAMESPACE_FIREBASE,
rolloutId,
);
logger.info(rcRollout.parseRolloutIntoTable(rollout));
return rollout;
});
54 changes: 54 additions & 0 deletions src/commands/remoteconfig-rollouts-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Command } from "../command";
import { Options } from "../options";
import { requireAuth } from "../requireAuth";
import { requirePermissions } from "../requirePermissions";
import { logger } from "../logger";
import { needProjectNumber } from "../projectUtils";
import {
ListRollouts,
NAMESPACE_FIREBASE,
ListRolloutOptions,
DEFAULT_PAGE_SIZE,
} from "../remoteconfig/interfaces";
import * as rcRollout from "../remoteconfig/listRollouts";

export const command = new Command("remoteconfig:rollouts:list")
.description("get a list of Remote Config rollouts.")
.option(
"--pageSize <pageSize>",
"Maximum number of rollouts to return per page. Defaults to 10. Pass '0' to fetch all rollouts",
)
.option(
"--pageToken <pageToken>",
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.",
)
.option(
"--filter <filter>",
"Filters rollouts by their full resource name. Format: `name:projects/{project_id}/namespaces/{namespace}/rollouts/{rollout_id}`",
)
.before(requireAuth)
.before(requirePermissions, [
"cloud.configs.get",
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze",
])
.action(async (options: Options) => {
const projectNumber = await needProjectNumber(options);
const listRolloutOptions: ListRolloutOptions = {
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE,
pageToken: options.pageToken as string,
filter: options.filter as string,
};
const { rollouts, nextPageToken }: ListRollouts = await rcRollout.listRollouts(
projectNumber,
NAMESPACE_FIREBASE,
listRolloutOptions,
);
logger.info(rcRollout.parseRolloutList(rollouts ?? []));
if (nextPageToken) {
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`);
}
return {
rollouts,
nextPageToken,
};
});
66 changes: 66 additions & 0 deletions src/remoteconfig/deleteExperiment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect } from "chai";
import * as nock from "nock";
import * as clc from "colorette";

import { remoteConfigApiOrigin } from "../api";
import { FirebaseError } from "../error";
import { deleteExperiment } from "./deleteExperiment";
import { NAMESPACE_FIREBASE } from "./interfaces";

const PROJECT_ID = "12345679";
const EXPERIMENT_ID = "1";

describe("Remote Config Experiment Delete", () => {
afterEach(() => {
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
nock.cleanAll();
});

it("should delete an experiment successfully", async () => {
nock(remoteConfigApiOrigin())
.delete(
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
)
.reply(200);

await expect(
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
).to.eventually.equal(clc.bold(`Successfully deleted experiment ${clc.yellow(EXPERIMENT_ID)}`));
});

it("should throw FirebaseError if experiment is running", async () => {
const errorMessage = `Experiment ${EXPERIMENT_ID} is currently running and cannot be deleted. If you want to delete this experiment, stop it at https://console.firebase.google.com/project/${PROJECT_ID}/config/experiment/results/${EXPERIMENT_ID}`;
nock(remoteConfigApiOrigin())
.delete(
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
)
.reply(400, {
error: {
message: errorMessage,
},
});

await expect(
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
).to.be.rejectedWith(FirebaseError, errorMessage);
});

it("should throw FirebaseError if an internal error occurred", async () => {
nock(remoteConfigApiOrigin())
.delete(
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
)
.reply(500, {
error: {
message: "Internal server error",
},
});

await expect(
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
).to.be.rejectedWith(
FirebaseError,
`Failed to delete Remote Config experiment with ID ${EXPERIMENT_ID} for project ${PROJECT_ID}. Error: Request to https://firebaseremoteconfig.googleapis.com/v1/projects/12345679/namespaces/firebase/experiments/1 had HTTP Error: 500, Internal server error`,
);
});
});
48 changes: 48 additions & 0 deletions src/remoteconfig/deleteExperiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as clc from "colorette";

import { remoteConfigApiOrigin } from "../api";
import { Client } from "../apiv2";
import { FirebaseError, getErrMsg, getError } from "../error";
import { consoleUrl } from "../utils";

const TIMEOUT = 30000;

const apiClient = new Client({
urlPrefix: remoteConfigApiOrigin(),
apiVersion: "v1",
});

/**
* Deletes a Remote Config experiment.
* @param projectId The ID of the project.
* @param namespace The namespace under which the experiment is created.
* @param experimentId The ID of the experiment to retrieve.
* @return A promise that resolves to the experiment object.
*/
export async function deleteExperiment(
projectId: string,
namespace: string,
experimentId: string,
): Promise<string> {
try {
await apiClient.request<void, void>({
method: "DELETE",
path: `projects/${projectId}/namespaces/${namespace}/experiments/${experimentId}`,
timeout: TIMEOUT,
});
return clc.bold(`Successfully deleted experiment ${clc.yellow(experimentId)}`);
} catch (err: unknown) {
const error: Error = getError(err);
if (error.message.includes("is running and cannot be deleted")) {
const rcConsoleUrl = consoleUrl(projectId, `/config/experiment/results/${experimentId}`);
throw new FirebaseError(
`Experiment ${experimentId} is currently running and cannot be deleted. If you want to delete this experiment, stop it at ${rcConsoleUrl}`,
{ original: error },
);
}
throw new FirebaseError(
`Failed to delete Remote Config experiment with ID ${experimentId} for project ${projectId}. Error: ${getErrMsg(err)}`,
{ original: error },
);
}
}
Loading
Loading