From 5f4d8f5d28c9adf194f4980dd3a99674ab61f8fe Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 11:21:02 +0200 Subject: [PATCH 1/8] Add CLI commands for Crashlytics --- src/commands/crashlytics-events-batchget.ts | 52 ++++++ src/commands/crashlytics-events-list.ts | 68 ++++++++ src/commands/crashlytics-issues-get.ts | 46 +++++ src/commands/crashlytics-issues-update.ts | 40 +++++ src/commands/crashlytics-notes-create.ts | 33 ++++ src/commands/crashlytics-notes-delete.ts | 25 +++ src/commands/crashlytics-notes-list.ts | 52 ++++++ src/commands/crashlytics-reports-get.ts | 182 ++++++++++++++++++++ src/commands/index.ts | 12 ++ 9 files changed, 510 insertions(+) create mode 100644 src/commands/crashlytics-events-batchget.ts create mode 100644 src/commands/crashlytics-events-list.ts create mode 100644 src/commands/crashlytics-issues-get.ts create mode 100644 src/commands/crashlytics-issues-update.ts create mode 100644 src/commands/crashlytics-notes-create.ts create mode 100644 src/commands/crashlytics-notes-delete.ts create mode 100644 src/commands/crashlytics-notes-list.ts create mode 100644 src/commands/crashlytics-reports-get.ts diff --git a/src/commands/crashlytics-events-batchget.ts b/src/commands/crashlytics-events-batchget.ts new file mode 100644 index 00000000000..5279a7fc6e8 --- /dev/null +++ b/src/commands/crashlytics-events-batchget.ts @@ -0,0 +1,52 @@ +import * as clc from "colorette"; +import * as Table from "cli-table3"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import { logger } from "../logger"; +import { requireAuth } from "../requireAuth"; +import { batchGetEvents } from "../crashlytics/events"; + +interface CommandOptions extends Options { + app?: string; +} + +export const command = new Command("crashlytics:events:batchget ") + .description("get specific Crashlytics events by resource name") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .action(async (eventNames: string[], options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + if (!eventNames || eventNames.length === 0) { + throw new FirebaseError("provide at least one event resource name"); + } + + const result = await batchGetEvents(options.app, eventNames); + + if (!result.events || result.events.length === 0) { + logger.info(clc.bold("No events found.")); + } else { + const table = new Table({ + head: ["Time", "Device", "OS", "Version", "Issue"], + style: { head: ["green"] }, + }); + for (const event of result.events) { + table.push([ + event.eventTime ? new Date(event.eventTime).toLocaleString() : "-", + event.device?.marketingName || event.device?.model || "-", + event.operatingSystem?.displayName || "-", + event.version?.displayName || "-", + event.issue?.title || event.issue?.id || "-", + ]); + } + logger.info(table.toString()); + logger.info(`\n${result.events.length} event(s).`); + } + + return result; + }); diff --git a/src/commands/crashlytics-events-list.ts b/src/commands/crashlytics-events-list.ts new file mode 100644 index 00000000000..5a536194000 --- /dev/null +++ b/src/commands/crashlytics-events-list.ts @@ -0,0 +1,68 @@ +import * as clc from "colorette"; +import * as Table from "cli-table3"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import { logger } from "../logger"; +import { requireAuth } from "../requireAuth"; +import { listEvents } from "../crashlytics/events"; +import { EventFilter } from "../crashlytics/filters"; + +interface CommandOptions extends Options { + app?: string; + issueId?: string; + issueVariantId?: string; + pageSize?: number; +} + +export const command = new Command("crashlytics:events:list") + .description("list recent Crashlytics events for an issue") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .option("--issue-id ", "filter by issue id") + .option("--issue-variant-id ", "filter by issue variant id") + .option("--page-size ", "number of events to return", 1) + .action(async (options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + if (!options.issueId && !options.issueVariantId) { + throw new FirebaseError("set --issue-id or --issue-variant-id to filter events"); + } + + const filter: EventFilter = {}; + if (options.issueId) { + filter.issueId = options.issueId; + } + if (options.issueVariantId) { + filter.issueVariantId = options.issueVariantId; + } + + const pageSize = options.pageSize ?? 1; + const result = await listEvents(options.app, filter, pageSize); + + if (!result.events || result.events.length === 0) { + logger.info(clc.bold("No events found.")); + } else { + const table = new Table({ + head: ["Time", "Device", "OS", "Version", "Issue"], + style: { head: ["green"] }, + }); + for (const event of result.events) { + table.push([ + event.eventTime ? new Date(event.eventTime).toLocaleString() : "-", + event.device?.marketingName || event.device?.model || "-", + event.operatingSystem?.displayName || "-", + event.version?.displayName || "-", + event.issue?.title || event.issue?.id || "-", + ]); + } + logger.info(table.toString()); + logger.info(`\n${result.events.length} event(s).`); + } + + return result; + }); diff --git a/src/commands/crashlytics-issues-get.ts b/src/commands/crashlytics-issues-get.ts new file mode 100644 index 00000000000..2e5bfc31560 --- /dev/null +++ b/src/commands/crashlytics-issues-get.ts @@ -0,0 +1,46 @@ +import * as Table from "cli-table3"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import { logger } from "../logger"; +import { requireAuth } from "../requireAuth"; +import { getIssue } from "../crashlytics/issues"; + +interface CommandOptions extends Options { + app?: string; +} + +export const command = new Command("crashlytics:issues:get ") + .description("get details for a Crashlytics issue") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .action(async (issueId: string, options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + + const issue = await getIssue(options.app, issueId); + + // Display formatted output + const table = new Table(); + table.push( + { ID: issue.id || "-" }, + { Title: issue.title || "-" }, + { Subtitle: issue.subtitle || "-" }, + { Type: issue.errorType || "-" }, + { State: issue.state || "-" }, + { "First Seen": issue.firstSeenVersion || "-" }, + { "Last Seen": issue.lastSeenVersion || "-" }, + { Variants: issue.variants?.length?.toString() || "0" }, + ); + logger.info(table.toString()); + + if (issue.uri) { + logger.info(`\nConsole: ${issue.uri}`); + } + + return issue; + }); diff --git a/src/commands/crashlytics-issues-update.ts b/src/commands/crashlytics-issues-update.ts new file mode 100644 index 00000000000..5c3be69ba93 --- /dev/null +++ b/src/commands/crashlytics-issues-update.ts @@ -0,0 +1,40 @@ +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import * as utils from "../utils"; +import { requireAuth } from "../requireAuth"; +import { updateIssue } from "../crashlytics/issues"; +import { State } from "../crashlytics/types"; + +interface CommandOptions extends Options { + app?: string; + state?: string; +} + +export const command = new Command("crashlytics:issues:update ") + .description("update the state of a Crashlytics issue") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .option("--state ", "the new state for the issue (OPEN or CLOSED)") + .action(async (issueId: string, options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + if (!options.state) { + throw new FirebaseError("set --state to OPEN or CLOSED"); + } + + const stateUpper = options.state.toUpperCase(); + if (stateUpper !== "OPEN" && stateUpper !== "CLOSED") { + throw new FirebaseError("--state must be OPEN or CLOSED"); + } + + const state = stateUpper as State; + const issue = await updateIssue(options.app, issueId, state); + + utils.logLabeledSuccess("crashlytics", `Issue ${issueId} is now ${String(issue.state)}`); + + return issue; + }); diff --git a/src/commands/crashlytics-notes-create.ts b/src/commands/crashlytics-notes-create.ts new file mode 100644 index 00000000000..399ac49e0c1 --- /dev/null +++ b/src/commands/crashlytics-notes-create.ts @@ -0,0 +1,33 @@ +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import * as utils from "../utils"; +import { requireAuth } from "../requireAuth"; +import { createNote } from "../crashlytics/notes"; + +interface CommandOptions extends Options { + app?: string; + note?: string; +} + +export const command = new Command("crashlytics:notes:create ") + .description("add a note to a Crashlytics issue") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .option("--note ", "the note text to add to the issue") + .action(async (issueId: string, options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + if (!options.note) { + throw new FirebaseError("set --note to specify the note content"); + } + + const note = await createNote(options.app, issueId, options.note); + + utils.logLabeledSuccess("crashlytics", `Created note on issue ${issueId}`); + + return note; + }); diff --git a/src/commands/crashlytics-notes-delete.ts b/src/commands/crashlytics-notes-delete.ts new file mode 100644 index 00000000000..60e0714665f --- /dev/null +++ b/src/commands/crashlytics-notes-delete.ts @@ -0,0 +1,25 @@ +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import * as utils from "../utils"; +import { requireAuth } from "../requireAuth"; +import { deleteNote } from "../crashlytics/notes"; + +interface CommandOptions extends Options { + app?: string; +} + +export const command = new Command("crashlytics:notes:delete ") + .description("delete a note from a Crashlytics issue") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .action(async (issueId: string, noteId: string, options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + + await deleteNote(options.app, issueId, noteId); + utils.logLabeledSuccess("crashlytics", `Deleted note ${noteId} from issue ${issueId}`); + }); diff --git a/src/commands/crashlytics-notes-list.ts b/src/commands/crashlytics-notes-list.ts new file mode 100644 index 00000000000..3af3c224c44 --- /dev/null +++ b/src/commands/crashlytics-notes-list.ts @@ -0,0 +1,52 @@ +import * as clc from "colorette"; +import * as Table from "cli-table3"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import { logger } from "../logger"; +import { requireAuth } from "../requireAuth"; +import { listNotes } from "../crashlytics/notes"; + +interface CommandOptions extends Options { + app?: string; + pageSize?: number; +} + +export const command = new Command("crashlytics:notes:list ") + .description("list notes for a Crashlytics issue") + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .option("--page-size ", "number of notes to return", 20) + .action(async (issueId: string, options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + + const pageSize = options.pageSize ?? 20; + const notes = await listNotes(options.app, issueId, pageSize); + + if (notes.length === 0) { + logger.info(clc.bold("No notes found.")); + } else { + const table = new Table({ + head: ["Author", "Created", "Note"], + style: { head: ["green"] }, + colWidths: [30, 25, 50], + wordWrap: true, + }); + for (const note of notes) { + table.push([ + note.author || "-", + note.createTime ? new Date(note.createTime).toLocaleString() : "-", + note.body || "-", + ]); + } + logger.info(table.toString()); + logger.info(`\n${notes.length} note(s).`); + } + + return notes; + }); diff --git a/src/commands/crashlytics-reports-get.ts b/src/commands/crashlytics-reports-get.ts new file mode 100644 index 00000000000..a37ac14feb7 --- /dev/null +++ b/src/commands/crashlytics-reports-get.ts @@ -0,0 +1,182 @@ +import * as clc from "colorette"; +import * as Table from "cli-table3"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import { Options } from "../options"; +import { logger } from "../logger"; +import { requireAuth } from "../requireAuth"; +import { getReport, CrashlyticsReport } from "../crashlytics/reports"; +import { EventFilter, validateEventFilters } from "../crashlytics/filters"; + +interface CommandOptions extends Options { + app?: string; + pageSize?: number; + issueId?: string; + issueVariantId?: string; + errorType?: string[]; + appVersion?: string[]; + startTime?: string; + endTime?: string; +} + +const VALID_REPORTS = [ + "TOP_ISSUES", + "TOP_VARIANTS", + "TOP_VERSIONS", + "TOP_OPERATING_SYSTEMS", + "TOP_ANDROID_DEVICES", + "TOP_APPLE_DEVICES", +]; + +const REPORT_NAME_MAP: Record = { + TOP_ISSUES: CrashlyticsReport.TOP_ISSUES, + TOP_VARIANTS: CrashlyticsReport.TOP_VARIANTS, + TOP_VERSIONS: CrashlyticsReport.TOP_VERSIONS, + TOP_OPERATING_SYSTEMS: CrashlyticsReport.TOP_OPERATING_SYSTEMS, + TOP_ANDROID_DEVICES: CrashlyticsReport.TOP_ANDROID_DEVICES, + TOP_APPLE_DEVICES: CrashlyticsReport.TOP_APPLE_DEVICES, +}; + +export const command = new Command("crashlytics:reports:get ") + .description( + "get a Crashlytics report (TOP_ISSUES, TOP_VARIANTS, TOP_VERSIONS, TOP_OPERATING_SYSTEMS, TOP_ANDROID_DEVICES, TOP_APPLE_DEVICES)", + ) + .before(requireAuth) + .option("--app ", "the app id of your Firebase app") + .option("--page-size ", "number of rows to return", 10) + .option("--issue-id ", "filter by issue id") + .option("--issue-variant-id ", "filter by issue variant id") + .option("--error-type ", "filter by error type (FATAL, NON_FATAL, ANR)") + .option("--app-version ", "filter by app version display names") + .option("--start-time ", "filter start time (ISO 8601 format)") + .option("--end-time ", "filter end time (ISO 8601 format)") + .action(async (report: string, options: CommandOptions) => { + if (!options.app) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + + const reportUpper = report.toUpperCase(); + if (!VALID_REPORTS.includes(reportUpper)) { + throw new FirebaseError(`Invalid report type. Must be one of: ${VALID_REPORTS.join(", ")}`); + } + + const filter: EventFilter = {}; + if (options.issueId) { + filter.issueId = options.issueId; + } + if (options.issueVariantId) { + filter.issueVariantId = options.issueVariantId; + } + if (options.errorType) { + filter.issueErrorTypes = options.errorType as ("FATAL" | "NON_FATAL" | "ANR")[]; + } + if (options.appVersion) { + filter.versionDisplayNames = options.appVersion; + } + if (options.startTime) { + filter.intervalStartTime = options.startTime; + } + if (options.endTime) { + filter.intervalEndTime = options.endTime; + } + + const validatedFilter = validateEventFilters(filter); + const pageSize = options.pageSize ?? 10; + const reportType = REPORT_NAME_MAP[reportUpper]; + + const result = await getReport(reportType, options.app, validatedFilter, pageSize); + + // Display table output + if (result.groups && result.groups.length > 0) { + logger.info(`\n${result.displayName || reportUpper}`); + logger.info(""); + + if (reportUpper === "TOP_ISSUES") { + const table = new Table({ + head: ["Issue", "Type", "Events", "Users", "State"], + style: { head: ["green"] }, + }); + for (const group of result.groups) { + const issue = group.issue; + const metrics = group.metrics?.[0]; + table.push([ + issue?.title || issue?.id || "-", + issue?.errorType || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + issue?.state || "-", + ]); + } + logger.info(table.toString()); + } else if (reportUpper === "TOP_VARIANTS") { + const table = new Table({ + head: ["Variant ID", "Events", "Users"], + style: { head: ["green"] }, + }); + for (const group of result.groups) { + const variant = group.variant; + const metrics = group.metrics?.[0]; + table.push([ + variant?.id || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]); + } + logger.info(table.toString()); + } else if (reportUpper === "TOP_VERSIONS") { + const table = new Table({ + head: ["Version", "Events", "Users"], + style: { head: ["green"] }, + }); + for (const group of result.groups) { + const version = group.version; + const metrics = group.metrics?.[0]; + table.push([ + version?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]); + } + logger.info(table.toString()); + } else if (reportUpper === "TOP_OPERATING_SYSTEMS") { + const table = new Table({ + head: ["Operating System", "Events", "Users"], + style: { head: ["green"] }, + }); + for (const group of result.groups) { + const os = group.operatingSystem; + const metrics = group.metrics?.[0]; + table.push([ + os?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]); + } + logger.info(table.toString()); + } else if (reportUpper === "TOP_ANDROID_DEVICES" || reportUpper === "TOP_APPLE_DEVICES") { + const table = new Table({ + head: ["Device", "Events", "Users"], + style: { head: ["green"] }, + }); + for (const group of result.groups) { + const device = group.device; + const metrics = group.metrics?.[0]; + table.push([ + device?.marketingName || device?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]); + } + logger.info(table.toString()); + } + + logger.info(`\n${result.groups.length} result(s).`); + } else { + logger.info(clc.bold("No results found.")); + } + + return result; + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index e768111b17a..0d986c930d9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -62,6 +62,18 @@ export function load(client: CLIClient): CLIClient { client.crashlytics.mappingfile = {}; client.crashlytics.mappingfile.generateid = loadCommand("crashlytics-mappingfile-generateid"); client.crashlytics.mappingfile.upload = loadCommand("crashlytics-mappingfile-upload"); + client.crashlytics.issues = {}; + client.crashlytics.issues.get = loadCommand("crashlytics-issues-get"); + client.crashlytics.issues.update = loadCommand("crashlytics-issues-update"); + client.crashlytics.notes = {}; + client.crashlytics.notes.create = loadCommand("crashlytics-notes-create"); + client.crashlytics.notes.list = loadCommand("crashlytics-notes-list"); + client.crashlytics.notes.delete = loadCommand("crashlytics-notes-delete"); + client.crashlytics.reports = {}; + client.crashlytics.reports.get = loadCommand("crashlytics-reports-get"); + client.crashlytics.events = {}; + client.crashlytics.events.list = loadCommand("crashlytics-events-list"); + client.crashlytics.events.batchget = loadCommand("crashlytics-events-batchget"); client.database = {}; client.database.get = loadCommand("database-get"); client.database.import = loadCommand("database-import"); From 0d2687778a1db1819928c2645a9d5e5edac88dfe Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:12:01 +0200 Subject: [PATCH 2/8] CR: Add validation for --error-type option Add runtime validation for the --error-type option values to prevent invalid error types from being passed to the API. The validation: - Checks that each provided error type is one of: FATAL, NON_FATAL, ANR - Throws a descriptive FirebaseError if an invalid type is provided - Normalizes input to uppercase for case-insensitive matching --- src/commands/crashlytics-reports-get.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/commands/crashlytics-reports-get.ts b/src/commands/crashlytics-reports-get.ts index a37ac14feb7..5be8a2529e9 100644 --- a/src/commands/crashlytics-reports-get.ts +++ b/src/commands/crashlytics-reports-get.ts @@ -29,6 +29,8 @@ const VALID_REPORTS = [ "TOP_APPLE_DEVICES", ]; +const VALID_ERROR_TYPES = ["FATAL", "NON_FATAL", "ANR"] as const; + const REPORT_NAME_MAP: Record = { TOP_ISSUES: CrashlyticsReport.TOP_ISSUES, TOP_VARIANTS: CrashlyticsReport.TOP_VARIANTS, @@ -71,7 +73,19 @@ export const command = new Command("crashlytics:reports:get ") filter.issueVariantId = options.issueVariantId; } if (options.errorType) { - filter.issueErrorTypes = options.errorType as ("FATAL" | "NON_FATAL" | "ANR")[]; + for (const errorType of options.errorType) { + const errorTypeUpper = errorType.toUpperCase(); + if (!VALID_ERROR_TYPES.includes(errorTypeUpper as (typeof VALID_ERROR_TYPES)[number])) { + throw new FirebaseError( + `Invalid error type "${errorType}". Must be one of: ${VALID_ERROR_TYPES.join(", ")}`, + ); + } + } + filter.issueErrorTypes = options.errorType.map((e) => e.toUpperCase()) as ( + | "FATAL" + | "NON_FATAL" + | "ANR" + )[]; } if (options.appVersion) { filter.versionDisplayNames = options.appVersion; From 72cacce3cea5493157805e48c1a378a0b04bc14a Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:14:17 +0200 Subject: [PATCH 3/8] CR: Extract --app validation to shared requireAppId helper Consolidate duplicated --app validation logic into a reusable helper function in src/crashlytics/utils.ts. This reduces code duplication across 8 command files and provides a single source of truth for the validation error message. --- src/commands/crashlytics-events-batchget.ts | 9 +++------ src/commands/crashlytics-events-list.ts | 9 +++------ src/commands/crashlytics-issues-get.ts | 10 +++------- src/commands/crashlytics-issues-update.ts | 9 +++------ src/commands/crashlytics-notes-create.ts | 9 +++------ src/commands/crashlytics-notes-delete.ts | 10 +++------- src/commands/crashlytics-notes-list.ts | 10 +++------- src/commands/crashlytics-reports-get.ts | 9 +++------ src/crashlytics/utils.ts | 15 +++++++++++++++ 9 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src/commands/crashlytics-events-batchget.ts b/src/commands/crashlytics-events-batchget.ts index 5279a7fc6e8..751053bf5db 100644 --- a/src/commands/crashlytics-events-batchget.ts +++ b/src/commands/crashlytics-events-batchget.ts @@ -7,6 +7,7 @@ import { Options } from "../options"; import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { batchGetEvents } from "../crashlytics/events"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -17,16 +18,12 @@ export const command = new Command("crashlytics:events:batchget " .before(requireAuth) .option("--app ", "the app id of your Firebase app") .action(async (eventNames: string[], options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); if (!eventNames || eventNames.length === 0) { throw new FirebaseError("provide at least one event resource name"); } - const result = await batchGetEvents(options.app, eventNames); + const result = await batchGetEvents(appId, eventNames); if (!result.events || result.events.length === 0) { logger.info(clc.bold("No events found.")); diff --git a/src/commands/crashlytics-events-list.ts b/src/commands/crashlytics-events-list.ts index 5a536194000..830a40db72e 100644 --- a/src/commands/crashlytics-events-list.ts +++ b/src/commands/crashlytics-events-list.ts @@ -8,6 +8,7 @@ import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { listEvents } from "../crashlytics/events"; import { EventFilter } from "../crashlytics/filters"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -24,11 +25,7 @@ export const command = new Command("crashlytics:events:list") .option("--issue-variant-id ", "filter by issue variant id") .option("--page-size ", "number of events to return", 1) .action(async (options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); if (!options.issueId && !options.issueVariantId) { throw new FirebaseError("set --issue-id or --issue-variant-id to filter events"); } @@ -42,7 +39,7 @@ export const command = new Command("crashlytics:events:list") } const pageSize = options.pageSize ?? 1; - const result = await listEvents(options.app, filter, pageSize); + const result = await listEvents(appId, filter, pageSize); if (!result.events || result.events.length === 0) { logger.info(clc.bold("No events found.")); diff --git a/src/commands/crashlytics-issues-get.ts b/src/commands/crashlytics-issues-get.ts index 2e5bfc31560..94d39519e67 100644 --- a/src/commands/crashlytics-issues-get.ts +++ b/src/commands/crashlytics-issues-get.ts @@ -1,11 +1,11 @@ import * as Table from "cli-table3"; import { Command } from "../command"; -import { FirebaseError } from "../error"; import { Options } from "../options"; import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { getIssue } from "../crashlytics/issues"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -16,13 +16,9 @@ export const command = new Command("crashlytics:issues:get ") .before(requireAuth) .option("--app ", "the app id of your Firebase app") .action(async (issueId: string, options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); - const issue = await getIssue(options.app, issueId); + const issue = await getIssue(appId, issueId); // Display formatted output const table = new Table(); diff --git a/src/commands/crashlytics-issues-update.ts b/src/commands/crashlytics-issues-update.ts index 5c3be69ba93..9966c5ca0e3 100644 --- a/src/commands/crashlytics-issues-update.ts +++ b/src/commands/crashlytics-issues-update.ts @@ -5,6 +5,7 @@ import * as utils from "../utils"; import { requireAuth } from "../requireAuth"; import { updateIssue } from "../crashlytics/issues"; import { State } from "../crashlytics/types"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -17,11 +18,7 @@ export const command = new Command("crashlytics:issues:update ") .option("--app ", "the app id of your Firebase app") .option("--state ", "the new state for the issue (OPEN or CLOSED)") .action(async (issueId: string, options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); if (!options.state) { throw new FirebaseError("set --state to OPEN or CLOSED"); } @@ -32,7 +29,7 @@ export const command = new Command("crashlytics:issues:update ") } const state = stateUpper as State; - const issue = await updateIssue(options.app, issueId, state); + const issue = await updateIssue(appId, issueId, state); utils.logLabeledSuccess("crashlytics", `Issue ${issueId} is now ${String(issue.state)}`); diff --git a/src/commands/crashlytics-notes-create.ts b/src/commands/crashlytics-notes-create.ts index 399ac49e0c1..7a4e1dac1a1 100644 --- a/src/commands/crashlytics-notes-create.ts +++ b/src/commands/crashlytics-notes-create.ts @@ -4,6 +4,7 @@ import { Options } from "../options"; import * as utils from "../utils"; import { requireAuth } from "../requireAuth"; import { createNote } from "../crashlytics/notes"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -16,16 +17,12 @@ export const command = new Command("crashlytics:notes:create ") .option("--app ", "the app id of your Firebase app") .option("--note ", "the note text to add to the issue") .action(async (issueId: string, options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); if (!options.note) { throw new FirebaseError("set --note to specify the note content"); } - const note = await createNote(options.app, issueId, options.note); + const note = await createNote(appId, issueId, options.note); utils.logLabeledSuccess("crashlytics", `Created note on issue ${issueId}`); diff --git a/src/commands/crashlytics-notes-delete.ts b/src/commands/crashlytics-notes-delete.ts index 60e0714665f..a4f7015e8ad 100644 --- a/src/commands/crashlytics-notes-delete.ts +++ b/src/commands/crashlytics-notes-delete.ts @@ -1,9 +1,9 @@ import { Command } from "../command"; -import { FirebaseError } from "../error"; import { Options } from "../options"; import * as utils from "../utils"; import { requireAuth } from "../requireAuth"; import { deleteNote } from "../crashlytics/notes"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -14,12 +14,8 @@ export const command = new Command("crashlytics:notes:delete " .before(requireAuth) .option("--app ", "the app id of your Firebase app") .action(async (issueId: string, noteId: string, options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); - await deleteNote(options.app, issueId, noteId); + await deleteNote(appId, issueId, noteId); utils.logLabeledSuccess("crashlytics", `Deleted note ${noteId} from issue ${issueId}`); }); diff --git a/src/commands/crashlytics-notes-list.ts b/src/commands/crashlytics-notes-list.ts index 3af3c224c44..950e84dfc21 100644 --- a/src/commands/crashlytics-notes-list.ts +++ b/src/commands/crashlytics-notes-list.ts @@ -2,11 +2,11 @@ import * as clc from "colorette"; import * as Table from "cli-table3"; import { Command } from "../command"; -import { FirebaseError } from "../error"; import { Options } from "../options"; import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { listNotes } from "../crashlytics/notes"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -19,14 +19,10 @@ export const command = new Command("crashlytics:notes:list ") .option("--app ", "the app id of your Firebase app") .option("--page-size ", "number of notes to return", 20) .action(async (issueId: string, options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); const pageSize = options.pageSize ?? 20; - const notes = await listNotes(options.app, issueId, pageSize); + const notes = await listNotes(appId, issueId, pageSize); if (notes.length === 0) { logger.info(clc.bold("No notes found.")); diff --git a/src/commands/crashlytics-reports-get.ts b/src/commands/crashlytics-reports-get.ts index 5be8a2529e9..5b44cff810f 100644 --- a/src/commands/crashlytics-reports-get.ts +++ b/src/commands/crashlytics-reports-get.ts @@ -8,6 +8,7 @@ import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { getReport, CrashlyticsReport } from "../crashlytics/reports"; import { EventFilter, validateEventFilters } from "../crashlytics/filters"; +import { requireAppId } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -54,11 +55,7 @@ export const command = new Command("crashlytics:reports:get ") .option("--start-time ", "filter start time (ISO 8601 format)") .option("--end-time ", "filter end time (ISO 8601 format)") .action(async (report: string, options: CommandOptions) => { - if (!options.app) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } + const appId = requireAppId(options.app); const reportUpper = report.toUpperCase(); if (!VALID_REPORTS.includes(reportUpper)) { @@ -101,7 +98,7 @@ export const command = new Command("crashlytics:reports:get ") const pageSize = options.pageSize ?? 10; const reportType = REPORT_NAME_MAP[reportUpper]; - const result = await getReport(reportType, options.app, validatedFilter, pageSize); + const result = await getReport(reportType, appId, validatedFilter, pageSize); // Display table output if (result.groups && result.groups.length > 0) { diff --git a/src/crashlytics/utils.ts b/src/crashlytics/utils.ts index 83be6b93154..c091a9b9eaa 100644 --- a/src/crashlytics/utils.ts +++ b/src/crashlytics/utils.ts @@ -4,6 +4,21 @@ import { crashlyticsApiOrigin } from "../api"; export const TIMEOUT = 10000; +/** + * Validates that the --app option is provided. + * @param appId - The app ID from command options. + * @returns The validated app ID. + * @throws FirebaseError if the app ID is not provided. + */ +export function requireAppId(appId: string | undefined): string { + if (!appId) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + return appId; +} + export const CRASHLYTICS_API_CLIENT = new Client({ urlPrefix: crashlyticsApiOrigin(), apiVersion: "v1alpha", From e44f1615d8ab73550332a39f80e6436b93113f43 Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:15:44 +0200 Subject: [PATCH 4/8] CR: Extract event table rendering to shared renderEventsTable helper Move duplicated event table rendering logic from crashlytics-events-list and crashlytics-events-batchget commands into a shared helper function. This reduces code duplication and ensures consistent table formatting across event display commands. --- src/commands/crashlytics-events-batchget.ts | 19 ++------------- src/commands/crashlytics-events-list.ts | 19 ++------------- src/crashlytics/utils.ts | 26 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/commands/crashlytics-events-batchget.ts b/src/commands/crashlytics-events-batchget.ts index 751053bf5db..86687b71053 100644 --- a/src/commands/crashlytics-events-batchget.ts +++ b/src/commands/crashlytics-events-batchget.ts @@ -1,5 +1,4 @@ import * as clc from "colorette"; -import * as Table from "cli-table3"; import { Command } from "../command"; import { FirebaseError } from "../error"; @@ -7,7 +6,7 @@ import { Options } from "../options"; import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { batchGetEvents } from "../crashlytics/events"; -import { requireAppId } from "../crashlytics/utils"; +import { requireAppId, renderEventsTable } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -28,21 +27,7 @@ export const command = new Command("crashlytics:events:batchget " if (!result.events || result.events.length === 0) { logger.info(clc.bold("No events found.")); } else { - const table = new Table({ - head: ["Time", "Device", "OS", "Version", "Issue"], - style: { head: ["green"] }, - }); - for (const event of result.events) { - table.push([ - event.eventTime ? new Date(event.eventTime).toLocaleString() : "-", - event.device?.marketingName || event.device?.model || "-", - event.operatingSystem?.displayName || "-", - event.version?.displayName || "-", - event.issue?.title || event.issue?.id || "-", - ]); - } - logger.info(table.toString()); - logger.info(`\n${result.events.length} event(s).`); + renderEventsTable(result.events); } return result; diff --git a/src/commands/crashlytics-events-list.ts b/src/commands/crashlytics-events-list.ts index 830a40db72e..298ca367384 100644 --- a/src/commands/crashlytics-events-list.ts +++ b/src/commands/crashlytics-events-list.ts @@ -1,5 +1,4 @@ import * as clc from "colorette"; -import * as Table from "cli-table3"; import { Command } from "../command"; import { FirebaseError } from "../error"; @@ -8,7 +7,7 @@ import { logger } from "../logger"; import { requireAuth } from "../requireAuth"; import { listEvents } from "../crashlytics/events"; import { EventFilter } from "../crashlytics/filters"; -import { requireAppId } from "../crashlytics/utils"; +import { requireAppId, renderEventsTable } from "../crashlytics/utils"; interface CommandOptions extends Options { app?: string; @@ -44,21 +43,7 @@ export const command = new Command("crashlytics:events:list") if (!result.events || result.events.length === 0) { logger.info(clc.bold("No events found.")); } else { - const table = new Table({ - head: ["Time", "Device", "OS", "Version", "Issue"], - style: { head: ["green"] }, - }); - for (const event of result.events) { - table.push([ - event.eventTime ? new Date(event.eventTime).toLocaleString() : "-", - event.device?.marketingName || event.device?.model || "-", - event.operatingSystem?.displayName || "-", - event.version?.displayName || "-", - event.issue?.title || event.issue?.id || "-", - ]); - } - logger.info(table.toString()); - logger.info(`\n${result.events.length} event(s).`); + renderEventsTable(result.events); } return result; diff --git a/src/crashlytics/utils.ts b/src/crashlytics/utils.ts index c091a9b9eaa..4e45fe7ccee 100644 --- a/src/crashlytics/utils.ts +++ b/src/crashlytics/utils.ts @@ -1,6 +1,10 @@ +import * as Table from "cli-table3"; + import { FirebaseError } from "../error"; import { Client } from "../apiv2"; import { crashlyticsApiOrigin } from "../api"; +import { logger } from "../logger"; +import { Event } from "./types"; export const TIMEOUT = 10000; @@ -50,3 +54,25 @@ export function parsePlatform(appId: string): PLATFORM_PATH { } throw new FirebaseError(`Only android or ios apps are supported.`); } + +/** + * Renders a list of Crashlytics events as a table and logs the count. + * @param events - Array of events to render. + */ +export function renderEventsTable(events: Event[]): void { + const table = new Table({ + head: ["Time", "Device", "OS", "Version", "Issue"], + style: { head: ["green"] }, + }); + for (const event of events) { + table.push([ + event.eventTime ? new Date(event.eventTime).toLocaleString() : "-", + event.device?.marketingName || event.device?.model || "-", + event.operatingSystem?.displayName || "-", + event.version?.displayName || "-", + event.issue?.title || event.issue?.id || "-", + ]); + } + logger.info(table.toString()); + logger.info(`\n${events.length} event(s).`); +} From 53345c823956e9f557307df54162a65a4171a062 Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:16:58 +0200 Subject: [PATCH 5/8] CR: Refactor report rendering with configuration-driven approach Replace large if/else if structure with a configuration object that maps report types to their table headers and row extraction functions. This improves maintainability by consolidating report-specific logic into a declarative structure, making it easier to add new report types or modify existing ones. --- src/commands/crashlytics-reports-get.ts | 205 +++++++++++++----------- 1 file changed, 109 insertions(+), 96 deletions(-) diff --git a/src/commands/crashlytics-reports-get.ts b/src/commands/crashlytics-reports-get.ts index 5b44cff810f..60f56bb957c 100644 --- a/src/commands/crashlytics-reports-get.ts +++ b/src/commands/crashlytics-reports-get.ts @@ -9,6 +9,7 @@ import { requireAuth } from "../requireAuth"; import { getReport, CrashlyticsReport } from "../crashlytics/reports"; import { EventFilter, validateEventFilters } from "../crashlytics/filters"; import { requireAppId } from "../crashlytics/utils"; +import { ReportGroup } from "../crashlytics/types"; interface CommandOptions extends Options { app?: string; @@ -21,26 +22,110 @@ interface CommandOptions extends Options { endTime?: string; } -const VALID_REPORTS = [ - "TOP_ISSUES", - "TOP_VARIANTS", - "TOP_VERSIONS", - "TOP_OPERATING_SYSTEMS", - "TOP_ANDROID_DEVICES", - "TOP_APPLE_DEVICES", -]; +interface ReportTableConfig { + headers: string[]; + getRow: (group: ReportGroup) => string[]; +} const VALID_ERROR_TYPES = ["FATAL", "NON_FATAL", "ANR"] as const; -const REPORT_NAME_MAP: Record = { - TOP_ISSUES: CrashlyticsReport.TOP_ISSUES, - TOP_VARIANTS: CrashlyticsReport.TOP_VARIANTS, - TOP_VERSIONS: CrashlyticsReport.TOP_VERSIONS, - TOP_OPERATING_SYSTEMS: CrashlyticsReport.TOP_OPERATING_SYSTEMS, - TOP_ANDROID_DEVICES: CrashlyticsReport.TOP_ANDROID_DEVICES, - TOP_APPLE_DEVICES: CrashlyticsReport.TOP_APPLE_DEVICES, +const REPORT_CONFIG: Record = { + TOP_ISSUES: { + report: CrashlyticsReport.TOP_ISSUES, + table: { + headers: ["Issue", "Type", "Events", "Users", "State"], + getRow: (group) => { + const issue = group.issue; + const metrics = group.metrics?.[0]; + return [ + issue?.title || issue?.id || "-", + issue?.errorType || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + issue?.state || "-", + ]; + }, + }, + }, + TOP_VARIANTS: { + report: CrashlyticsReport.TOP_VARIANTS, + table: { + headers: ["Variant ID", "Events", "Users"], + getRow: (group) => { + const variant = group.variant; + const metrics = group.metrics?.[0]; + return [ + variant?.id || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]; + }, + }, + }, + TOP_VERSIONS: { + report: CrashlyticsReport.TOP_VERSIONS, + table: { + headers: ["Version", "Events", "Users"], + getRow: (group) => { + const version = group.version; + const metrics = group.metrics?.[0]; + return [ + version?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]; + }, + }, + }, + TOP_OPERATING_SYSTEMS: { + report: CrashlyticsReport.TOP_OPERATING_SYSTEMS, + table: { + headers: ["Operating System", "Events", "Users"], + getRow: (group) => { + const os = group.operatingSystem; + const metrics = group.metrics?.[0]; + return [ + os?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]; + }, + }, + }, + TOP_ANDROID_DEVICES: { + report: CrashlyticsReport.TOP_ANDROID_DEVICES, + table: { + headers: ["Device", "Events", "Users"], + getRow: (group) => { + const device = group.device; + const metrics = group.metrics?.[0]; + return [ + device?.marketingName || device?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]; + }, + }, + }, + TOP_APPLE_DEVICES: { + report: CrashlyticsReport.TOP_APPLE_DEVICES, + table: { + headers: ["Device", "Events", "Users"], + getRow: (group) => { + const device = group.device; + const metrics = group.metrics?.[0]; + return [ + device?.marketingName || device?.displayName || "-", + metrics?.eventsCount?.toLocaleString() || "0", + metrics?.impactedUsersCount?.toLocaleString() || "0", + ]; + }, + }, + }, }; +const VALID_REPORTS = Object.keys(REPORT_CONFIG); + export const command = new Command("crashlytics:reports:get ") .description( "get a Crashlytics report (TOP_ISSUES, TOP_VARIANTS, TOP_VERSIONS, TOP_OPERATING_SYSTEMS, TOP_ANDROID_DEVICES, TOP_APPLE_DEVICES)", @@ -96,94 +181,22 @@ export const command = new Command("crashlytics:reports:get ") const validatedFilter = validateEventFilters(filter); const pageSize = options.pageSize ?? 10; - const reportType = REPORT_NAME_MAP[reportUpper]; + const config = REPORT_CONFIG[reportUpper]; - const result = await getReport(reportType, appId, validatedFilter, pageSize); + const result = await getReport(config.report, appId, validatedFilter, pageSize); - // Display table output if (result.groups && result.groups.length > 0) { logger.info(`\n${result.displayName || reportUpper}`); logger.info(""); - if (reportUpper === "TOP_ISSUES") { - const table = new Table({ - head: ["Issue", "Type", "Events", "Users", "State"], - style: { head: ["green"] }, - }); - for (const group of result.groups) { - const issue = group.issue; - const metrics = group.metrics?.[0]; - table.push([ - issue?.title || issue?.id || "-", - issue?.errorType || "-", - metrics?.eventsCount?.toLocaleString() || "0", - metrics?.impactedUsersCount?.toLocaleString() || "0", - issue?.state || "-", - ]); - } - logger.info(table.toString()); - } else if (reportUpper === "TOP_VARIANTS") { - const table = new Table({ - head: ["Variant ID", "Events", "Users"], - style: { head: ["green"] }, - }); - for (const group of result.groups) { - const variant = group.variant; - const metrics = group.metrics?.[0]; - table.push([ - variant?.id || "-", - metrics?.eventsCount?.toLocaleString() || "0", - metrics?.impactedUsersCount?.toLocaleString() || "0", - ]); - } - logger.info(table.toString()); - } else if (reportUpper === "TOP_VERSIONS") { - const table = new Table({ - head: ["Version", "Events", "Users"], - style: { head: ["green"] }, - }); - for (const group of result.groups) { - const version = group.version; - const metrics = group.metrics?.[0]; - table.push([ - version?.displayName || "-", - metrics?.eventsCount?.toLocaleString() || "0", - metrics?.impactedUsersCount?.toLocaleString() || "0", - ]); - } - logger.info(table.toString()); - } else if (reportUpper === "TOP_OPERATING_SYSTEMS") { - const table = new Table({ - head: ["Operating System", "Events", "Users"], - style: { head: ["green"] }, - }); - for (const group of result.groups) { - const os = group.operatingSystem; - const metrics = group.metrics?.[0]; - table.push([ - os?.displayName || "-", - metrics?.eventsCount?.toLocaleString() || "0", - metrics?.impactedUsersCount?.toLocaleString() || "0", - ]); - } - logger.info(table.toString()); - } else if (reportUpper === "TOP_ANDROID_DEVICES" || reportUpper === "TOP_APPLE_DEVICES") { - const table = new Table({ - head: ["Device", "Events", "Users"], - style: { head: ["green"] }, - }); - for (const group of result.groups) { - const device = group.device; - const metrics = group.metrics?.[0]; - table.push([ - device?.marketingName || device?.displayName || "-", - metrics?.eventsCount?.toLocaleString() || "0", - metrics?.impactedUsersCount?.toLocaleString() || "0", - ]); - } - logger.info(table.toString()); + const table = new Table({ + head: config.table.headers, + style: { head: ["green"] }, + }); + for (const group of result.groups) { + table.push(config.table.getRow(group)); } - + logger.info(table.toString()); logger.info(`\n${result.groups.length} result(s).`); } else { logger.info(clc.bold("No results found.")); From 00a48780fc4b3e9cddb349f253d2b4ebd8b73756 Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:18:59 +0200 Subject: [PATCH 6/8] CR: Rename --error-type to --issue-type for consistency Rename the CLI option and related variable names: - --error-type -> --issue-type - VALID_ERROR_TYPES -> VALID_ISSUE_TYPES - errorType -> issueType in CommandOptions interface --- src/commands/crashlytics-reports-get.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/commands/crashlytics-reports-get.ts b/src/commands/crashlytics-reports-get.ts index 60f56bb957c..c4c5a363d1c 100644 --- a/src/commands/crashlytics-reports-get.ts +++ b/src/commands/crashlytics-reports-get.ts @@ -16,7 +16,7 @@ interface CommandOptions extends Options { pageSize?: number; issueId?: string; issueVariantId?: string; - errorType?: string[]; + issueType?: string[]; appVersion?: string[]; startTime?: string; endTime?: string; @@ -27,7 +27,7 @@ interface ReportTableConfig { getRow: (group: ReportGroup) => string[]; } -const VALID_ERROR_TYPES = ["FATAL", "NON_FATAL", "ANR"] as const; +const VALID_ISSUE_TYPES = ["FATAL", "NON_FATAL", "ANR"] as const; const REPORT_CONFIG: Record = { TOP_ISSUES: { @@ -135,7 +135,7 @@ export const command = new Command("crashlytics:reports:get ") .option("--page-size ", "number of rows to return", 10) .option("--issue-id ", "filter by issue id") .option("--issue-variant-id ", "filter by issue variant id") - .option("--error-type ", "filter by error type (FATAL, NON_FATAL, ANR)") + .option("--issue-type ", "filter by issue type (FATAL, NON_FATAL, ANR)") .option("--app-version ", "filter by app version display names") .option("--start-time ", "filter start time (ISO 8601 format)") .option("--end-time ", "filter end time (ISO 8601 format)") @@ -154,16 +154,16 @@ export const command = new Command("crashlytics:reports:get ") if (options.issueVariantId) { filter.issueVariantId = options.issueVariantId; } - if (options.errorType) { - for (const errorType of options.errorType) { - const errorTypeUpper = errorType.toUpperCase(); - if (!VALID_ERROR_TYPES.includes(errorTypeUpper as (typeof VALID_ERROR_TYPES)[number])) { + if (options.issueType) { + for (const issueType of options.issueType) { + const issueTypeUpper = issueType.toUpperCase(); + if (!VALID_ISSUE_TYPES.includes(issueTypeUpper as (typeof VALID_ISSUE_TYPES)[number])) { throw new FirebaseError( - `Invalid error type "${errorType}". Must be one of: ${VALID_ERROR_TYPES.join(", ")}`, + `Invalid issue type "${issueType}". Must be one of: ${VALID_ISSUE_TYPES.join(", ")}`, ); } } - filter.issueErrorTypes = options.errorType.map((e) => e.toUpperCase()) as ( + filter.issueErrorTypes = options.issueType.map((e) => e.toUpperCase()) as ( | "FATAL" | "NON_FATAL" | "ANR" From 7fa31e9ee71f4b815696a509394aae27f10f5043 Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:20:56 +0200 Subject: [PATCH 7/8] CR: Reorder functions in crashlytics/utils.ts --- src/crashlytics/utils.ts | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/crashlytics/utils.ts b/src/crashlytics/utils.ts index 4e45fe7ccee..5e60febe39c 100644 --- a/src/crashlytics/utils.ts +++ b/src/crashlytics/utils.ts @@ -8,21 +8,6 @@ import { Event } from "./types"; export const TIMEOUT = 10000; -/** - * Validates that the --app option is provided. - * @param appId - The app ID from command options. - * @returns The validated app ID. - * @throws FirebaseError if the app ID is not provided. - */ -export function requireAppId(appId: string | undefined): string { - if (!appId) { - throw new FirebaseError( - "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", - ); - } - return appId; -} - export const CRASHLYTICS_API_CLIENT = new Client({ urlPrefix: crashlyticsApiOrigin(), apiVersion: "v1alpha", @@ -55,10 +40,15 @@ export function parsePlatform(appId: string): PLATFORM_PATH { throw new FirebaseError(`Only android or ios apps are supported.`); } -/** - * Renders a list of Crashlytics events as a table and logs the count. - * @param events - Array of events to render. - */ +export function requireAppId(appId: string | undefined): string { + if (!appId) { + throw new FirebaseError( + "set --app to a valid Firebase application id, e.g. 1:00000000:android:0000000", + ); + } + return appId; +} + export function renderEventsTable(events: Event[]): void { const table = new Table({ head: ["Time", "Device", "OS", "Version", "Issue"], From 849114ce4bfd7a71b913fe89e76fe9b72480ab66 Mon Sep 17 00:00:00 2001 From: Igor Makarov Date: Thu, 1 Jan 2026 12:29:59 +0200 Subject: [PATCH 8/8] CR: Fix --issue-type option to handle single values Commander.js returns a string for single values and an array for multiple values with variadic options. Normalize to array before iterating to prevent iterating over characters of a string. --- src/commands/crashlytics-reports-get.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/crashlytics-reports-get.ts b/src/commands/crashlytics-reports-get.ts index c4c5a363d1c..817875e1c1a 100644 --- a/src/commands/crashlytics-reports-get.ts +++ b/src/commands/crashlytics-reports-get.ts @@ -155,7 +155,10 @@ export const command = new Command("crashlytics:reports:get ") filter.issueVariantId = options.issueVariantId; } if (options.issueType) { - for (const issueType of options.issueType) { + const issueTypes = Array.isArray(options.issueType) + ? options.issueType + : [options.issueType]; + for (const issueType of issueTypes) { const issueTypeUpper = issueType.toUpperCase(); if (!VALID_ISSUE_TYPES.includes(issueTypeUpper as (typeof VALID_ISSUE_TYPES)[number])) { throw new FirebaseError( @@ -163,7 +166,7 @@ export const command = new Command("crashlytics:reports:get ") ); } } - filter.issueErrorTypes = options.issueType.map((e) => e.toUpperCase()) as ( + filter.issueErrorTypes = issueTypes.map((e) => e.toUpperCase()) as ( | "FATAL" | "NON_FATAL" | "ANR"