diff --git a/src/commands/crashlytics-events-batchget.ts b/src/commands/crashlytics-events-batchget.ts new file mode 100644 index 00000000000..86687b71053 --- /dev/null +++ b/src/commands/crashlytics-events-batchget.ts @@ -0,0 +1,34 @@ +import * as clc from "colorette"; + +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"; +import { requireAppId, renderEventsTable } from "../crashlytics/utils"; + +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) => { + const appId = requireAppId(options.app); + if (!eventNames || eventNames.length === 0) { + throw new FirebaseError("provide at least one event resource name"); + } + + const result = await batchGetEvents(appId, eventNames); + + if (!result.events || result.events.length === 0) { + logger.info(clc.bold("No events found.")); + } else { + renderEventsTable(result.events); + } + + return result; + }); diff --git a/src/commands/crashlytics-events-list.ts b/src/commands/crashlytics-events-list.ts new file mode 100644 index 00000000000..298ca367384 --- /dev/null +++ b/src/commands/crashlytics-events-list.ts @@ -0,0 +1,50 @@ +import * as clc from "colorette"; + +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"; +import { requireAppId, renderEventsTable } from "../crashlytics/utils"; + +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) => { + const appId = requireAppId(options.app); + 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(appId, filter, pageSize); + + if (!result.events || result.events.length === 0) { + logger.info(clc.bold("No events found.")); + } else { + renderEventsTable(result.events); + } + + return result; + }); diff --git a/src/commands/crashlytics-issues-get.ts b/src/commands/crashlytics-issues-get.ts new file mode 100644 index 00000000000..94d39519e67 --- /dev/null +++ b/src/commands/crashlytics-issues-get.ts @@ -0,0 +1,42 @@ +import * as Table from "cli-table3"; + +import { Command } from "../command"; +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; +} + +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) => { + const appId = requireAppId(options.app); + + const issue = await getIssue(appId, 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..9966c5ca0e3 --- /dev/null +++ b/src/commands/crashlytics-issues-update.ts @@ -0,0 +1,37 @@ +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"; +import { requireAppId } from "../crashlytics/utils"; + +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) => { + const appId = requireAppId(options.app); + 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(appId, 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..7a4e1dac1a1 --- /dev/null +++ b/src/commands/crashlytics-notes-create.ts @@ -0,0 +1,30 @@ +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"; +import { requireAppId } from "../crashlytics/utils"; + +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) => { + const appId = requireAppId(options.app); + if (!options.note) { + throw new FirebaseError("set --note to specify the note content"); + } + + const note = await createNote(appId, 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..a4f7015e8ad --- /dev/null +++ b/src/commands/crashlytics-notes-delete.ts @@ -0,0 +1,21 @@ +import { Command } from "../command"; +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; +} + +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) => { + const appId = requireAppId(options.app); + + 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 new file mode 100644 index 00000000000..950e84dfc21 --- /dev/null +++ b/src/commands/crashlytics-notes-list.ts @@ -0,0 +1,48 @@ +import * as clc from "colorette"; +import * as Table from "cli-table3"; + +import { Command } from "../command"; +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; + 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) => { + const appId = requireAppId(options.app); + + const pageSize = options.pageSize ?? 20; + const notes = await listNotes(appId, 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..817875e1c1a --- /dev/null +++ b/src/commands/crashlytics-reports-get.ts @@ -0,0 +1,209 @@ +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"; +import { requireAppId } from "../crashlytics/utils"; +import { ReportGroup } from "../crashlytics/types"; + +interface CommandOptions extends Options { + app?: string; + pageSize?: number; + issueId?: string; + issueVariantId?: string; + issueType?: string[]; + appVersion?: string[]; + startTime?: string; + endTime?: string; +} + +interface ReportTableConfig { + headers: string[]; + getRow: (group: ReportGroup) => string[]; +} + +const VALID_ISSUE_TYPES = ["FATAL", "NON_FATAL", "ANR"] as const; + +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)", + ) + .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("--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)") + .action(async (report: string, options: CommandOptions) => { + const appId = requireAppId(options.app); + + 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.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( + `Invalid issue type "${issueType}". Must be one of: ${VALID_ISSUE_TYPES.join(", ")}`, + ); + } + } + filter.issueErrorTypes = issueTypes.map((e) => e.toUpperCase()) 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 config = REPORT_CONFIG[reportUpper]; + + const result = await getReport(config.report, appId, validatedFilter, pageSize); + + if (result.groups && result.groups.length > 0) { + logger.info(`\n${result.displayName || reportUpper}`); + logger.info(""); + + 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.")); + } + + 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"); diff --git a/src/crashlytics/utils.ts b/src/crashlytics/utils.ts index 83be6b93154..5e60febe39c 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; @@ -35,3 +39,30 @@ export function parsePlatform(appId: string): PLATFORM_PATH { } throw new FirebaseError(`Only android or ios apps are supported.`); } + +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"], + 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).`); +}