1+ import * as archiver from "archiver" ;
2+ import * as fs from "fs" ;
3+ import * as path from "path" ;
4+ import * as tmp from "tmp" ;
5+ import { statSync } from "fs-extra" ;
6+ import { readdirRecursive } from "../fsAsync" ;
7+ import { Command } from "../command" ;
8+ import { FirebaseError } from "../error" ;
9+ import { logLabeledBullet , logLabeledWarning } from "../utils" ;
10+ import { needProjectId } from "../projectUtils" ;
11+ import * as gcs from "../gcp/storage" ;
12+ import { getProjectNumber } from "../getProjectNumber" ;
13+ import { Options } from "../options" ;
14+
15+ interface CommandOptions extends Options {
16+ app ?: string ;
17+ bucketLocation ?: string ;
18+ appVersion ?: string ;
19+ }
20+
21+ export const command = new Command ( "crashlytics:sourcemap:upload <mappingFiles>" )
22+ . description ( "upload javascript source maps to de-minify stack traces" )
23+ . option ( "--app <appID>" , "the app id of your Firebase app" )
24+ . option ( "--bucket-location <bucketLocation>" , "the location of the Google Cloud Storage bucket (default: \"US-CENTRAL1\"" )
25+ . option ( "--app-version <appVersion>" , "the version of your Firebase app (defaults to Git commit hash, if available)" )
26+ . action ( async ( mappingFiles : string , options : CommandOptions ) => {
27+ const app = getGoogleAppID ( options ) ;
28+ const debug = ! ! options . debug ;
29+
30+ // App version
31+ const appVersion = getAppVersion ( options ) ;
32+
33+ // Get project identifiers
34+ const projectId = needProjectId ( options ) ;
35+ const projectNumber = await getProjectNumber ( options ) ;
36+
37+ // Upsert default GCS bucket
38+ const bucketName = await upsertBucket ( projectId , projectNumber , options ) ;
39+
40+ // Find and upload mapping files
41+ const rootDir = options . projectRoot ?? process . cwd ( ) ;
42+ const filePath = path . relative ( rootDir , mappingFiles ) ;
43+ let fstat : fs . Stats ;
44+ try {
45+ fstat = statSync ( filePath )
46+ } catch ( e ) {
47+ throw new FirebaseError (
48+ "provide a valid file path or directory to mapping file(s), e.g. app/build/outputs/app.js.map or app/build/outputs" ,
49+ ) ;
50+ }
51+ if ( fstat . isFile ( ) ) {
52+ await uploadMap ( mappingFiles , bucketName , appVersion , options ) ;
53+ } else if ( fstat . isDirectory ( ) ) {
54+ logLabeledBullet ( "crashlytics" , "Looking for mapping files in your directory..." ) ;
55+ const files = ( await readdirRecursive ( { path : filePath , ignore : [ "node_modules" , ".git" ] } ) )
56+ . filter ( f => f . name . endsWith ( '.js.map' ) ) ;
57+ for ( const file of files ) {
58+ await uploadMap ( file . name , bucketName , appVersion , options ) ;
59+ }
60+ } else {
61+ throw new FirebaseError (
62+ "provide a valid file path or directory to mapping file(s), e.g. app/build/outputs/app.js.map or app/build/outputs" ,
63+ ) ;
64+ }
65+
66+ // TODO: notify Firebase Telemetry service of the new mapping file
67+ } ) ;
68+
69+ function getGoogleAppID ( options : CommandOptions ) : string {
70+ if ( ! options . app ) {
71+ throw new FirebaseError (
72+ "set --app <appId> to a valid Firebase application id, e.g. 1:00000000:android:0000000" ,
73+ ) ;
74+ }
75+ return options . app ;
76+ }
77+
78+ function getAppVersion ( options : CommandOptions ) : string {
79+ // TODO: implement app version lookup
80+ return "default" ;
81+ }
82+
83+ async function upsertBucket ( projectId : string , projectNumber : string , options : CommandOptions ) : Promise < string > {
84+ let loc : string = 'US-CENTRAL1' ;
85+ if ( options . bucketLocation ) {
86+ loc = ( options . bucketLocation as string ) . toUpperCase ( ) ;
87+ } else {
88+ logLabeledBullet ( "crashlytics" , "No Google Cloud Storage bucket location specified. Defaulting to US-CENTRAL1." ) ;
89+ }
90+
91+ const baseName = `firebasecrashlytics-sourcemaps-${ projectNumber } -${ loc . toLowerCase ( ) } ` ;
92+ return await gcs . upsertBucket ( {
93+ product : "crashlytics" ,
94+ createMessage : `Creating Cloud Storage bucket in ${ loc } to store Crashlytics source maps at ${ baseName } ...` ,
95+ projectId,
96+ req : {
97+ baseName,
98+ purposeLabel : `crashlytics-sourcemaps-${ loc . toLowerCase ( ) } ` ,
99+ location : loc ,
100+ lifecycle : {
101+ rule : [
102+ {
103+ action : {
104+ type : "Delete" ,
105+ } ,
106+ condition : {
107+ age : 30 ,
108+ } ,
109+ } ,
110+ ] ,
111+ } ,
112+ } ,
113+ } ) ;
114+ }
115+
116+ async function uploadMap ( mappingFile : string , bucketName : string , appVersion : string , options : CommandOptions ) {
117+ logLabeledBullet ( "crashlytics" , `Found mapping file ${ mappingFile } ...` ) ;
118+ try {
119+ const tmpArchive = await createArchive ( mappingFile , options ) ;
120+ const gcsFile = `${ options . app } -${ appVersion } -${ normalizeFileName ( mappingFile ) } .zip`
121+
122+ const { bucket, object } = await gcs . uploadObject (
123+ {
124+ file : gcsFile ,
125+ stream : fs . createReadStream ( tmpArchive ) ,
126+ } ,
127+ bucketName ,
128+ ) ;
129+ logLabeledBullet ( "crashlytics" , `Uploaded to gs://${ bucket } /${ object } ` ) ;
130+ } catch ( e ) {
131+ logLabeledWarning ( "crashlytics" , `Failed to upload mapping file ${ mappingFile } :\n${ e } ` ) ;
132+ }
133+ }
134+
135+ async function createArchive ( mappingFile : string , options : CommandOptions ) : Promise < string > {
136+ const tmpName = normalizeFileName ( mappingFile ) ;
137+ const tmpFile = tmp . fileSync ( { prefix : `${ tmpName } -` , postfix : ".zip" } ) . name ;
138+ const fileStream = fs . createWriteStream ( tmpFile , {
139+ flags : "w" ,
140+ encoding : "binary" ,
141+ } ) ;
142+ const archive = archiver ( "zip" ) ;
143+ const rootDir = options . projectRoot ?? process . cwd ( ) ;
144+ const name = path . relative ( rootDir , mappingFile ) ;
145+ archive . file ( name , { name : 'mapping.js.map' } ) ;
146+ await pipeAsync ( archive , fileStream ) ;
147+ return tmpFile ;
148+ }
149+
150+ async function pipeAsync ( from : archiver . Archiver , to : fs . WriteStream ) : Promise < void > {
151+ from . pipe ( to ) ;
152+ await from . finalize ( ) ;
153+ return new Promise ( ( resolve , reject ) => {
154+ to . on ( "finish" , resolve ) ;
155+ to . on ( "error" , reject ) ;
156+ } ) ;
157+ }
158+
159+ function normalizeFileName ( fileName : string ) : string {
160+ return fileName . replaceAll ( / \/ / g, '-' ) ;
161+ }
0 commit comments