@@ -446,9 +446,160 @@ function fromEntries<V>(itr: Iterable<[string, V]>): Record<string, V> {
446
446
return obj ;
447
447
}
448
448
449
+ function isLikelySecret ( key : string ) : boolean {
450
+ const secretPatterns = [
451
+ / \b a p i [ _ - ] ? k e y \b / i,
452
+ / \b s e c r e t \b / i,
453
+ / \b p a s s w ( o r d | d ) \b / i,
454
+ / \b p r i v a t e [ _ - ] ? k e y \b / i,
455
+ / _ t o k e n $ / i,
456
+ / _ a u t h $ / i,
457
+ / _ c r e d e n t i a l $ / i,
458
+ / \b k e y \b / i,
459
+ / \b t o k e n \b / i,
460
+ / \b a u t h \b / i,
461
+ / \b c r e d e n t i a l \b / i
462
+ ] ;
463
+
464
+ return secretPatterns . some ( pattern => pattern . test ( key ) ) ;
465
+ }
466
+
467
+ function getEnhancedComment ( origKey : string , value : string ) : string {
468
+ const parts = [ `from ${ origKey } ` ] ;
469
+
470
+ // Add type hint
471
+ if ( value === "true" || value === "false" ) {
472
+ parts . push ( "[boolean]" ) ;
473
+ } else if ( ! isNaN ( Number ( value ) ) && value !== "" ) {
474
+ parts . push ( "[number]" ) ;
475
+ } else if ( value . includes ( "," ) ) {
476
+ parts . push ( "[possible list]" ) ;
477
+ }
478
+
479
+ // Add secret warning
480
+ if ( isLikelySecret ( origKey ) ) {
481
+ parts . push ( "⚠️ LIKELY SECRET" ) ;
482
+ }
483
+
484
+ return parts . length > 1 ? ` # ${ parts . join ( " " ) } ` : ` # ${ parts [ 0 ] } ` ;
485
+ }
486
+
487
+ function escape ( s : string ) : string {
488
+ // Escape newlines, tabs, backslashes and quotes
489
+ return s . replace ( / [ \n \r \t \v \\ " ' ] / g, ( ch ) => {
490
+ const escapeMap : Record < string , string > = {
491
+ "\n" : "\\n" ,
492
+ "\r" : "\\r" ,
493
+ "\t" : "\\t" ,
494
+ "\v" : "\\v" ,
495
+ "\\" : "\\\\" ,
496
+ '"' : '\\"' ,
497
+ "'" : "\\'" ,
498
+ } ;
499
+ return escapeMap [ ch ] ;
500
+ } ) ;
501
+ }
502
+
503
+ function enhancedToDotenvFormat ( envs : configExport . EnvMap [ ] , header = "" ) : string {
504
+ const lines = envs . map ( ( { newKey, value, origKey } ) => {
505
+ const comment = getEnhancedComment ( origKey , value ) ;
506
+ return `${ newKey } ="${ escape ( value ) } "${ comment } ` ;
507
+ } ) ;
508
+
509
+ // Calculate max line length for alignment
510
+ const maxLineLen = Math . max ( ...lines . map ( l => l . indexOf ( " #" ) ) ) ;
511
+ const alignedLines = lines . map ( line => {
512
+ const commentIndex = line . indexOf ( " #" ) ;
513
+ const padding = " " . repeat ( Math . max ( 0 , maxLineLen - commentIndex ) ) ;
514
+ return line . replace ( " #" , padding + " #" ) ;
515
+ } ) ;
516
+
517
+ return `${ header } \n${ alignedLines . join ( '\n' ) } ` ;
518
+ }
519
+
520
+ function addMigrationHints ( envs : configExport . EnvMap [ ] ) : string {
521
+ const hints : string [ ] = [ ] ;
522
+
523
+ const secrets = envs . filter ( e => isLikelySecret ( e . origKey ) ) ;
524
+ const booleans = envs . filter ( e => e . value === "true" || e . value === "false" ) ;
525
+ const numbers = envs . filter ( e => ! isNaN ( Number ( e . value ) ) && e . value !== "" ) ;
526
+
527
+ if ( secrets . length > 0 ) {
528
+ hints . push ( `# 🔐 Migration hint: ${ secrets . length } potential secrets detected.
529
+ # Consider using defineSecret() for: ${ secrets . map ( s => s . newKey ) . join ( ", " ) }
530
+ # Run: firebase functions:secrets:set ${ secrets [ 0 ] . newKey } \n` ) ;
531
+ }
532
+
533
+ if ( booleans . length > 0 ) {
534
+ hints . push ( `# 📊 Migration hint: ${ booleans . length } boolean values detected.
535
+ # Consider using defineBoolean() for: ${ booleans . map ( b => b . newKey ) . join ( ", " ) } \n` ) ;
536
+ }
537
+
538
+ if ( numbers . length > 0 ) {
539
+ hints . push ( `# 🔢 Migration hint: ${ numbers . length } numeric values detected.
540
+ # Consider using defineInt() for: ${ numbers . map ( n => n . newKey ) . join ( ", " ) } \n` ) ;
541
+ }
542
+
543
+ if ( hints . length > 0 ) {
544
+ hints . push ( `# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n` ) ;
545
+ }
546
+
547
+ return hints . join ( '\n' ) ;
548
+ }
549
+
550
+ function validateConfigValues ( pInfos : configExport . ProjectConfigInfo [ ] ) : string [ ] {
551
+ const warnings : string [ ] = [ ] ;
552
+
553
+ for ( const pInfo of pInfos ) {
554
+ if ( ! pInfo . envs ) continue ;
555
+
556
+ for ( const env of pInfo . envs ) {
557
+ // Check for multiline values
558
+ if ( env . value . includes ( '\n' ) ) {
559
+ warnings . push ( `${ env . origKey } : Contains newlines (will be escaped)` ) ;
560
+ }
561
+
562
+ // Check for very long values
563
+ if ( env . value . length > 1000 ) {
564
+ warnings . push ( `${ env . origKey } : Very long value (${ env . value . length } chars)` ) ;
565
+ }
566
+
567
+ // Check for empty values
568
+ if ( env . value === '' ) {
569
+ warnings . push ( `${ env . origKey } : Empty value` ) ;
570
+ }
571
+ }
572
+ }
573
+
574
+ return warnings ;
575
+ }
576
+
577
+ function showExportSummary ( pInfos : configExport . ProjectConfigInfo [ ] , filesToWrite : Record < string , string > ) : void {
578
+ const totalConfigs = pInfos . reduce ( ( sum , p ) => sum + ( p . envs ?. length || 0 ) , 0 ) ;
579
+ const filesCreated = Object . keys ( filesToWrite ) . length ;
580
+
581
+ logger . info ( "\n📊 Export Summary:" ) ;
582
+ logger . info ( ` ✓ ${ totalConfigs } config values exported` ) ;
583
+ logger . info ( ` ✓ ${ filesCreated } files created` ) ;
584
+
585
+ const secrets = pInfos . flatMap ( p =>
586
+ ( p . envs || [ ] ) . filter ( e => isLikelySecret ( e . origKey ) )
587
+ ) ;
588
+
589
+ if ( secrets . length > 0 ) {
590
+ logger . info ( ` ⚠️ ${ secrets . length } potential secrets exported` ) ;
591
+ logger . info ( `\n💡 Next steps:` ) ;
592
+ logger . info ( ` 1. Review .env files for sensitive values` ) ;
593
+ logger . info ( ` 2. Move secrets to Firebase: firebase functions:secrets:set` ) ;
594
+ logger . info ( ` 3. Update your code to use the params API` ) ;
595
+ logger . info ( ` 4. Run 'firebase functions:config:export --prompt' for migration help` ) ;
596
+ }
597
+ }
598
+
449
599
export const command = new Command ( "functions:config:export" )
450
600
. description ( "export environment config as environment variables in dotenv format (or generate AI migration prompt with --prompt)" )
451
601
. option ( "--prompt" , "Generate an AI migration prompt instead of exporting to .env files" )
602
+ . option ( "--dry-run" , "Preview the export without writing files" )
452
603
. before ( requirePermissions , [
453
604
"runtimeconfig.configs.list" ,
454
605
"runtimeconfig.configs.get" ,
@@ -526,16 +677,78 @@ export const command = new Command("functions:config:export")
526
677
attempts += 1 ;
527
678
}
528
679
680
+ // Check for secrets and warn user
681
+ const secretsFound : string [ ] = [ ] ;
682
+ for ( const pInfo of pInfos ) {
683
+ if ( pInfo . envs ) {
684
+ for ( const env of pInfo . envs ) {
685
+ if ( isLikelySecret ( env . origKey ) ) {
686
+ secretsFound . push ( `${ env . origKey } → ${ env . newKey } ` ) ;
687
+ }
688
+ }
689
+ }
690
+ }
691
+
692
+ if ( secretsFound . length > 0 && ! options . dryRun ) {
693
+ logWarning (
694
+ "⚠️ The following configs appear to be secrets and will be exported to .env files:\n" +
695
+ secretsFound . map ( s => ` - ${ s } ` ) . join ( '\n' ) +
696
+ "\n\nConsider using Firebase Functions secrets instead: firebase functions:secrets:set"
697
+ ) ;
698
+
699
+ const proceed = await confirm ( {
700
+ message : "Continue exporting these potentially sensitive values?" ,
701
+ default : false
702
+ } ) ;
703
+
704
+ if ( ! proceed ) {
705
+ throw new FirebaseError ( "Export cancelled by user" ) ;
706
+ }
707
+ }
708
+
709
+ // Validate config values and show warnings
710
+ const valueWarnings = validateConfigValues ( pInfos ) ;
711
+ if ( valueWarnings . length > 0 ) {
712
+ logWarning ( "⚠️ Value warnings:\n" + valueWarnings . map ( w => ` - ${ w } ` ) . join ( '\n' ) ) ;
713
+ }
714
+
529
715
const header = `# Exported firebase functions:config:export command on ${ new Date ( ) . toLocaleDateString ( ) } ` ;
530
- const dotEnvs = pInfos . map ( ( pInfo ) => configExport . toDotenvFormat ( pInfo . envs ! , header ) ) ;
531
- const filenames = pInfos . map ( configExport . generateDotenvFilename ) ;
532
- const filesToWrite = fromEntries ( zip ( filenames , dotEnvs ) ) ;
716
+
717
+ // Generate enhanced .env files with migration hints
718
+ const filesToWrite : Record < string , string > = { } ;
719
+
720
+ for ( const pInfo of pInfos ) {
721
+ if ( ! pInfo . envs || pInfo . envs . length === 0 ) continue ;
722
+
723
+ const filename = configExport . generateDotenvFilename ( pInfo ) ;
724
+ const migrationHints = addMigrationHints ( pInfo . envs ) ;
725
+ const envContent = enhancedToDotenvFormat ( pInfo . envs , header ) ;
726
+
727
+ filesToWrite [ filename ] = migrationHints ? `${ header } \n${ migrationHints } \n${ envContent } ` : envContent ;
728
+ }
729
+
730
+ // Add default files
533
731
filesToWrite [ ".env.local" ] =
534
732
`${ header } \n# .env.local file contains environment variables for the Functions Emulator.\n` ;
535
733
filesToWrite [ ".env" ] =
536
- `${ header } # .env file contains environment variables that applies to all projects.\n` ;
734
+ `${ header } \n # .env file contains environment variables that applies to all projects.\n` ;
537
735
538
- for ( const [ filename , content ] of Object . entries ( filesToWrite ) ) {
539
- await options . config . askWriteProjectFile ( path . join ( functionsDir , filename ) , content ) ;
736
+ if ( options . dryRun ) {
737
+ logger . info ( "🔍 DRY RUN MODE - No files will be written\n" ) ;
738
+
739
+ for ( const [ filename , content ] of Object . entries ( filesToWrite ) ) {
740
+ console . log ( clc . bold ( clc . cyan ( `=== ${ filename } ===` ) ) ) ;
741
+ console . log ( content ) ;
742
+ console . log ( ) ;
743
+ }
744
+
745
+ logger . info ( "✅ Dry run complete. Use without --dry-run to write files." ) ;
746
+ } else {
747
+ for ( const [ filename , content ] of Object . entries ( filesToWrite ) ) {
748
+ await options . config . askWriteProjectFile ( path . join ( functionsDir , filename ) , content ) ;
749
+ }
750
+
751
+ // Show export summary
752
+ showExportSummary ( pInfos , filesToWrite ) ;
540
753
}
541
754
} ) ;
0 commit comments