2023-05-27 19:31:40 +00:00
package main
import (
"bufio"
"bytes"
"errors"
"flag"
"fmt"
"io"
2024-01-19 19:20:24 +00:00
"log/slog"
2023-05-27 19:31:40 +00:00
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"text/template"
2025-04-07 14:32:15 +00:00
"github.com/acaloiaro/go-envparse"
2023-05-27 19:31:40 +00:00
)
2024-12-12 19:42:19 +00:00
const VERSION = "2.14.1"
const BUILD_DATE = "2024-12-12T19:42:19+00:00"
2024-01-19 19:20:24 +00:00
2023-05-27 19:31:40 +00:00
type exampleFlag map [ string ] string
func ( e exampleFlag ) String ( ) string {
var str string
for key , val := range e {
str = fmt . Sprintf ( ` %s --example="%s=%s" ` , str , key , val )
}
return str
}
func ( e exampleFlag ) Set ( value string ) ( err error ) {
2024-01-19 19:20:24 +00:00
key , value , found := strings . Cut ( value , "=" )
if ! found {
2023-05-27 19:31:40 +00:00
err = errors . New ( "examples must be provided in KEY=VALUE format" )
return
}
2024-01-19 19:20:24 +00:00
e [ key ] = value
2023-05-27 19:31:40 +00:00
return nil
}
2023-09-26 13:51:15 +00:00
var (
2024-01-19 19:20:24 +00:00
examplesFlag = make ( exampleFlag )
debugFlag bool
2024-10-25 20:18:58 +00:00
skipGitAddFlag bool
2023-09-26 13:51:15 +00:00
envFileFlag string
sampleFileFlag string
2024-01-19 19:20:24 +00:00
versionFlag bool
2023-09-26 13:51:15 +00:00
)
2023-05-27 19:31:40 +00:00
func init ( ) {
2024-01-19 19:20:24 +00:00
flag . StringVar ( & envFileFlag , "env-file" , ".env" , "set the path to your env file: ess -env-file=.env_file [sync|install]" )
flag . StringVar ( & sampleFileFlag , "sample-file" , "env.sample" , "set the path to your sample file: ess -sample-file=env_var.sample [sync|install]" )
flag . BoolVar ( & debugFlag , "debug" , false , "print debug logs: ess --debug [sync|install]" )
2024-10-25 20:18:58 +00:00
flag . BoolVar ( & skipGitAddFlag , "skip-git-add" , false , "skip doing 'git add' on generated sample file after sync" )
2024-01-19 19:20:24 +00:00
flag . Var ( examplesFlag , "example" , "set example values for samples: ess --example=BAR=\"my bar value\" [sync|install]" )
flag . BoolVar ( & versionFlag , "version" , false , "print the current ess version: ess --version" )
2023-05-27 19:31:40 +00:00
flag . Usage = func ( ) {
cmd := os . Args [ 0 ]
fmt . Fprintf ( os . Stderr , "Usage of %s:\n" , cmd )
fmt . Fprintf ( os . Stderr , "%s [-flags] %s \n\n" , cmd , "[install|sync]" )
flag . PrintDefaults ( )
}
flag . Parse ( )
2024-01-19 19:20:24 +00:00
if versionFlag {
fmt . Fprintf ( os . Stdout , "ess version: %s built at: %s\n" , VERSION , BUILD_DATE )
os . Exit ( 0 )
}
if debugFlag {
slog . SetDefault ( slog . New ( slog . NewTextHandler ( os . Stdout , & slog . HandlerOptions { Level : slog . LevelDebug } ) ) )
}
2023-05-27 19:31:40 +00:00
}
func main ( ) {
command := "sync"
args := flag . Args ( )
if len ( args ) > 0 {
command = args [ 0 ]
}
projectPath , gitDirPath := gitDirPaths ( )
switch command {
case "sync" :
sync ( projectPath )
2024-10-25 20:18:58 +00:00
if skipGitAddFlag {
return
}
2023-05-27 19:31:40 +00:00
cmd := exec . Command ( "git" , "add" , filepath . Join ( projectPath , sampleFileFlag ) )
2024-01-19 19:20:24 +00:00
slog . Debug ( "running git command" , "args" , cmd . Args )
2023-05-27 19:31:40 +00:00
err := cmd . Run ( )
if err != nil {
fmt . Printf ( "unable to add sample file '%s' to git: %v" , sampleFileFlag , err )
os . Exit ( 1 )
}
case "install" :
2024-01-19 19:20:24 +00:00
slog . Debug ( "installing hook to" , "path" , gitDirPath )
2023-05-27 19:31:40 +00:00
err := installHook ( gitDirPath )
if err != nil {
fmt . Println ( "unable to install pre-commit hook:" , err )
os . Exit ( 1 )
}
default :
2023-09-26 13:51:15 +00:00
fmt . Printf ( "ess: unknown command '%s'\n\n" , command )
2023-05-27 19:31:40 +00:00
flag . Usage ( )
2024-01-19 19:20:24 +00:00
os . Exit ( 1 )
2023-05-27 19:31:40 +00:00
}
}
func sync ( dir string ) {
envFilePath := filepath . Join ( dir , envFileFlag )
sampleFilePath := filepath . Join ( dir , sampleFileFlag )
2024-01-19 19:20:24 +00:00
slog . Debug ( "syncing env file with sample" , "env_file" , envFilePath , "sample_file" , sampleFilePath )
2023-05-27 19:31:40 +00:00
envFileReader , err := os . Open ( envFilePath )
if err != nil {
fmt . Printf ( "env file '%s' was not found. skipping sync.\n" , envFilePath )
os . Exit ( 0 )
}
2024-01-20 00:23:56 +00:00
envFile , err := envparse . ParsePermissive ( envFileReader )
2023-05-27 19:31:40 +00:00
if err != nil {
2024-01-20 00:23:56 +00:00
fmt . Fprintf ( os . Stderr , "unable to parse env file (%s): %v" , envFilePath , err )
2023-05-27 19:31:40 +00:00
os . Exit ( 1 )
}
scrubEnvFile ( envFile , examplesFlag )
err = writeSampleFile ( envFile , envFilePath , sampleFilePath )
if err != nil {
fmt . Println ( err )
os . Exit ( 1 )
}
2024-01-19 19:20:24 +00:00
slog . Debug ( "sample file written" , "sample_file" , sampleFilePath )
2023-05-27 19:31:40 +00:00
}
func writeSampleFile ( sampleFileContent map [ string ] string , envFilePath , sampleFilePath string ) ( err error ) {
sampleFile , err := os . Create ( sampleFilePath )
if err != nil {
err = fmt . Errorf ( "unable to create sample file: %w" , err )
return
}
defer sampleFile . Close ( )
sampleWriter := bufio . NewWriter ( sampleFile )
envFile , err := os . Open ( envFilePath )
if err != nil {
err = fmt . Errorf ( "unable to read env file: %w" , err )
return
}
defer envFile . Close ( )
scanner := bufio . NewScanner ( envFile )
// interate through the env file line-by-line, searching for environment variables
// any line that starts with a key from `sampleFileContents` followed by an equal sign `=` is considered
// a line that contains a secret
for scanner . Scan ( ) {
envFileLine := scanner . Text ( )
scrubbedLine := replaceSecrets ( envFileLine , sampleFileContent )
sampleWriter . WriteString ( fmt . Sprintf ( "%s\n" , scrubbedLine ) )
}
sampleWriter . Flush ( )
return
}
func scrubEnvFile ( envFile map [ string ] string , examples map [ string ] string ) {
2024-01-19 19:20:24 +00:00
slog . Debug ( "scrubbing env file of secrets" )
2023-05-27 19:31:40 +00:00
for envFileKey := range envFile {
exampleVal , ok := examples [ envFileKey ]
if ok {
envFile [ envFileKey ] = exampleVal
} else {
envFile [ envFileKey ] = fmt . Sprintf ( "<%s>" , envFileKey )
}
}
}
// replaceSecrets replaces the content of env file entries (lines) with a new line that is scrubbed of secrets
//
// any line containing a variable name followed by and equal sign is considered to contain a secret
func replaceSecrets ( envFileEntry string , sampleFileContent map [ string ] string ) ( newLine string ) {
var r * regexp . Regexp
newLine = envFileEntry
for secretKey , secretPlaceholder := range sampleFileContent {
r = regexp . MustCompile ( fmt . Sprintf ( "(%s.*=.*)" , secretKey ) )
if r . MatchString ( envFileEntry ) {
2024-01-19 19:20:24 +00:00
slog . Debug ( "replacing secrets with sample values" , "secret_key" , secretKey , "placeholder" , secretPlaceholder )
2023-05-27 19:31:40 +00:00
newLine = r . ReplaceAllString ( envFileEntry , fmt . Sprintf ( "%s=%s" , secretKey , secretPlaceholder ) )
}
}
return
}
// gitDirPath returns the path the the GIT_DIR
func gitDirPaths ( ) ( projectPath , gitDir string ) {
var outb , errb bytes . Buffer
cmd := exec . Command ( "git" , "rev-parse" , "--show-toplevel" )
cmd . Stdout = & outb
cmd . Stderr = & errb
err := cmd . Run ( )
if err != nil {
fmt . Println ( "unable to find project directory:" , err )
os . Exit ( 1 )
}
projectPath = strings . TrimRight ( outb . String ( ) , "\r\n" )
outb . Reset ( )
errb . Reset ( )
cmd = exec . Command ( "git" , "rev-parse" , "--git-dir" )
cmd . Stdout = & outb
cmd . Stderr = & errb
err = cmd . Run ( )
if err != nil {
fmt . Println ( "unable to find GIT_DIR:" , err )
os . Exit ( 1 )
}
gitDir = strings . TrimRight ( outb . String ( ) , "\r\n" )
gitDir = filepath . Join ( projectPath , gitDir )
return
}
2023-06-12 09:31:33 +00:00
var preCommitScriptTemplate = ` { { if . Appending } } { { . OldPreCommitScript } }
2023-09-26 13:51:15 +00:00
# Below code generated by ess https : //github.com/acaloiaro/ess
2023-06-12 09:31:33 +00:00
{ { else } } # ! / usr / bin / env bash
2023-09-26 13:51:15 +00:00
# File generated by ess https : //github.com/acaloiaro/ess
2023-05-27 19:31:40 +00:00
2023-06-12 09:31:33 +00:00
{ { end } } ARGS = ( { { . Args } } )
2023-06-08 16:37:11 +00:00
2023-09-26 13:51:15 +00:00
exec $ ( dirname -- "${BASH_SOURCE[0]}" ) / { { . PreCommitHooksDir } } / 0 - ess "${ARGS[@]}"
2023-05-27 19:31:40 +00:00
`
2023-09-26 13:51:15 +00:00
// installHook installs the ess git hook to 'dir'
2023-05-27 19:31:40 +00:00
func installHook ( gitDirPath string ) ( err error ) {
hooksScriptDirName := "pre-commit-hooks.d"
hooksDirName := filepath . Join ( gitDirPath , "hooks" )
hooksScriptDirPath := filepath . Join ( hooksDirName , hooksScriptDirName )
preCommitHookScriptPath := filepath . Join ( hooksDirName , "pre-commit" )
2023-06-12 09:31:33 +00:00
appendFlag := false
var oldPreCommitScript [ ] byte
_ , err = os . Stat ( preCommitHookScriptPath )
if ! os . IsNotExist ( err ) {
fmt . Printf ( "A pre-commit hook already exists. Would you like to cancel [c], overwrite [o], or append [a] the existing pre-commit hook script? [a, c, o]: " )
var response string
fmt . Scanln ( & response )
if response == "c" || ( response != "o" && response != "a" ) {
2024-01-19 19:20:24 +00:00
slog . Debug ( "user declined to overwrite the existing pre-commit hook" )
2023-06-12 09:31:33 +00:00
os . Exit ( 0 )
}
if response == "a" {
2024-01-19 19:20:24 +00:00
slog . Debug ( "will append pre-commit hook script to existing script" , "script_path" , preCommitHookScriptPath )
2023-06-12 09:31:33 +00:00
// read the existing content of the pre-commit file
oldPreCommitScript , err = os . ReadFile ( preCommitHookScriptPath )
if err != nil {
return err
}
appendFlag = true
}
}
2023-05-27 19:31:40 +00:00
// Create a directory to place pre-commit-hook executable within
if _ , err = os . Stat ( hooksScriptDirPath ) ; os . IsNotExist ( err ) {
2023-08-01 13:56:46 +00:00
if err = os . MkdirAll ( hooksScriptDirPath , os . ModePerm ) ; err != nil {
2023-05-27 19:31:40 +00:00
return
}
}
2024-01-19 19:20:24 +00:00
slog . Debug ( "hook will be installed in" , "dir_path" , hooksScriptDirPath )
2023-09-26 13:51:15 +00:00
preCommitHookPath := filepath . Join ( hooksScriptDirPath , "0-ess" )
preCommitHook , err := os . OpenFile ( preCommitHookPath , os . O_RDWR | os . O_CREATE | os . O_TRUNC , 0 o755 )
2023-05-27 19:31:40 +00:00
if err != nil {
2023-06-08 16:37:11 +00:00
return
2023-05-27 19:31:40 +00:00
}
executablePath , err := exec . LookPath ( os . Args [ 0 ] )
if err != nil {
2023-09-26 13:51:15 +00:00
fmt . Println ( "unable to find 'ess' in $PATH:" , err )
2023-05-27 19:31:40 +00:00
os . Exit ( 1 )
}
executable , err := os . Open ( executablePath )
if err != nil {
return
}
// copy the currently running executable wholsale into the pre commit hooks directory
io . Copy ( preCommitHook , executable )
2023-06-12 09:31:33 +00:00
tmpl , err := template . New ( "pre-commit-script" ) . Parse ( preCommitScriptTemplate )
if err != nil {
return err
}
args := fmt . Sprintf ( "--env-file=%s --sample-file=%s %s" , envFileFlag , sampleFileFlag , examplesFlag )
2023-09-26 13:51:15 +00:00
buff := bytes . NewBufferString ( "" )
2023-06-12 09:31:33 +00:00
templateValues := struct {
Args string
PreCommitHooksDir string
Appending bool
OldPreCommitScript string
} {
Args : args ,
PreCommitHooksDir : hooksScriptDirName ,
Appending : appendFlag ,
OldPreCommitScript : string ( oldPreCommitScript ) ,
}
2023-06-08 16:37:11 +00:00
2024-01-19 19:20:24 +00:00
slog . Debug ( "hook metadata" , "data" , templateValues )
2023-06-12 09:31:33 +00:00
err = tmpl . Execute ( buff , templateValues )
if err != nil {
return err
}
2023-06-08 16:37:11 +00:00
2023-09-26 13:51:15 +00:00
preCommitHookScript , err := os . OpenFile ( preCommitHookScriptPath , os . O_RDWR | os . O_CREATE | os . O_TRUNC , 0 o755 )
2023-06-12 09:31:33 +00:00
if err != nil {
return err
}
defer preCommitHookScript . Close ( )
2023-06-08 16:37:11 +00:00
2023-06-12 09:31:33 +00:00
_ , err = buff . WriteTo ( preCommitHookScript )
if err != nil {
return err
2023-05-27 19:31:40 +00:00
}
2023-09-26 13:51:15 +00:00
fmt . Println ( "ess pre-commit hook installed!" )
2023-06-08 16:37:11 +00:00
return nil
2023-05-27 19:31:40 +00:00
}