From b5d35c678dabc13a4d19458a3db7c4f44cbbf2c9 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 15:54:52 +0000 Subject: [PATCH 01/20] ci: tweak flow names --- .github/workflows/release.yml | 4 ++-- .github/workflows/test-dev.yml | 2 +- .github/workflows/test-master.yml | 2 +- go.mod | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7152ac..4a82f15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: jobs: test: runs-on: ubuntu-latest - name: Run Tests + name: Tests steps: - uses: actions/checkout@v6 @@ -21,7 +21,7 @@ jobs: release: needs: test runs-on: ubuntu-latest - name: Build release binaries + name: Build and Release steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 288b094..774e116 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -10,7 +10,7 @@ on: jobs: test: runs-on: ubuntu-latest - name: Run Tests + name: Tests steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test-master.yml b/.github/workflows/test-master.yml index b9c15c8..d96eef0 100644 --- a/.github/workflows/test-master.yml +++ b/.github/workflows/test-master.yml @@ -10,7 +10,7 @@ on: jobs: test: runs-on: ubuntu-latest - name: Run Tests + name: Tests steps: - uses: actions/checkout@v6 diff --git a/go.mod b/go.mod index e364bc3..9bdd139 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/magefile/mage v1.15.0 github.com/mitchellh/cli v1.1.5 github.com/mitchellh/gox v1.0.1 + github.com/pkg/sftp v1.13.10 github.com/posener/complete v1.2.3 github.com/schollz/progressbar/v3 v3.19.0 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.48.0 gotest.tools/gotestsum v1.13.0 ) @@ -39,12 +41,10 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/iochan v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pkg/sftp v1.13.10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect From 4d91b1fdaa8466dc65b9fb3f330c8f1b0adae550 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 18:23:36 +0000 Subject: [PATCH 02/20] feat: refactor mitchellh/cli into spf13/cobra --- command/base.go | 116 ---------------------------------------- command/base_helpers.go | 53 ------------------ command/command.go | 32 ++--------- command/root.go | 38 +++++++++++++ command/run.go | 110 +++++++++++++++++++++---------------- go.mod | 6 ++- go.sum | 11 +++- 7 files changed, 117 insertions(+), 249 deletions(-) delete mode 100644 command/base.go delete mode 100644 command/base_helpers.go create mode 100644 command/root.go diff --git a/command/base.go b/command/base.go deleted file mode 100644 index 0af025e..0000000 --- a/command/base.go +++ /dev/null @@ -1,116 +0,0 @@ -package command - -import ( - "bytes" - "fmt" - "os" - "strings" - - "github.com/hmerritt/reactenv/ui" - - "github.com/jessevdk/go-flags" - "github.com/posener/complete" -) - -// Slice of all flag names -var FlagNames = []string{flagStrict.Name, flagForce.Name} - -// Slice of global flag names -var FlagNamesGlobal = []string{flagStrict.Name, flagForce.Name} - -// Master command type which is present in all commands -// -// Used to standardize UI output -type BaseCommand struct { - UI *ui.Ui -} - -func GetBaseCommand() *BaseCommand { - return &BaseCommand{ - UI: ui.GetUi(), - } -} - -type Flag struct { - Name string - Usage string - Default interface{} - Value interface{} - Completion complete.Predictor -} - -type FlagMap map[string]*Flag - -func (fm *FlagMap) Get(flagName string) *Flag { - fl, ok := (*fm)[flagName] - if ok { - return fl - } - return nil -} - -// Help builds usage string for all flags in a FlagMap -func (fm *FlagMap) Help() string { - var out bytes.Buffer - - for _, flag := range *fm { - fmt.Fprintf(&out, " --%s \n %s\n\n", flag.Name, flag.Usage) - } - - return strings.TrimRight(out.String(), "\n") -} - -// Parse CLI args to FlagMap -func (fm *FlagMap) Parse(UI *ui.Ui, args []string) []string { - // Struct used to parse flags - var opts struct { - Strict bool `short:"s" long:"strict"` - Force bool `short:"f" long:"force"` - } - - // Parse flags from `args'. - args, err := flags.ParseArgs(&opts, flagSingleToDoubleDash(args)) - - if err != nil { - UI.Error("Unable to parse flag from the arguments entered '" + fmt.Sprint(args[0]) + "'") - UI.Warn("Flags are entered with double dashes '--', for example '--strict'") - os.Exit(1) - } - - updateFmWithOps := func(flagName string, value interface{}) { - // Check if flag name exists in fm - _, ok := (*fm)[flagName] - - // Update 'fm' if flag exists in map. - if ok { - (*fm)[flagName].Value = value - } - } - - updateFmWithOps("strict", opts.Strict) - updateFmWithOps("force", opts.Force) - - return args -} - -// flag definitions - -// flag --strict -// -// Stop after any errors when deploying -var flagStrict = Flag{ - Name: "strict", - Usage: "Stop after any errors or warnings.", - Default: false, - Value: false, -} - -// flag --force -// -// Prevents CLI prompts asking confirmation -var flagForce = Flag{ - Name: "force", - Usage: "Bypasses CLI prompts without asking for confirmation.", - Default: false, - Value: false, -} diff --git a/command/base_helpers.go b/command/base_helpers.go deleted file mode 100644 index 6b5005a..0000000 --- a/command/base_helpers.go +++ /dev/null @@ -1,53 +0,0 @@ -package command - -import ( - "fmt" -) - -// Populate map of select flags (defaults to ALL flags) -// -// Commands can choose which flags they need -func GetFlagMap(which []string) *FlagMap { - // Convert which slice to a map - // - // This improves performace as map lookups are O(1) - whichMap := make(map[string]struct{}, len(which)) - for _, i := range which { - whichMap[i] = struct{}{} - } - - // Create flag map - fm := make(FlagMap, len(which)) - - addToMap := func(fl *Flag) { - // Check if flag name exists in whichMap - _, ok := whichMap[fl.Name] - - // Add to 'fm' if. - // 'whichMap' map is empty, - // or flag exists in map. - if len(whichMap) == 0 || ok { - fm[fl.Name] = fl - } - } - - addToMap(&flagStrict) - addToMap(&flagForce) - - return &fm -} - -// Detect long flags entered with one dash '-' -// and add a dash to prevent a panic when parsing -// -// -strict -> --strict -func flagSingleToDoubleDash(args []string) []string { - for i, arg := range args { - for _, fl := range FlagNames { - if arg == fmt.Sprintf("-%s", fl) { - args[i] = fmt.Sprintf("--%s", fl) - } - } - } - return args -} diff --git a/command/command.go b/command/command.go index 71e7a48..f6ae56c 100644 --- a/command/command.go +++ b/command/command.go @@ -1,38 +1,12 @@ package command import ( - "fmt" "os" - - "github.com/hmerritt/reactenv/version" - - "github.com/mitchellh/cli" ) func Run() { - // Initiate new CLI app - app := cli.NewCLI("reactenv", version.GetVersion().VersionNumber()) - app.Args = os.Args[1:] - - // Feed active commands to CLI app - app.Commands = map[string]cli.CommandFactory{ - "run": func() (cli.Command, error) { - return &RunCommand{ - BaseCommand: GetBaseCommand(), - }, nil - }, - } - - // Run app - exitStatus, err := app.Run() - if err != nil { - os.Stderr.WriteString(fmt.Sprint(err)) - } - - // Exit without an error if no arguments were passed - if len(app.Args) == 0 { - os.Exit(0) + rootCmd := NewRootCommand() + if err := rootCmd.Execute(); err != nil { + os.Exit(1) } - - os.Exit(exitStatus) } diff --git a/command/root.go b/command/root.go new file mode 100644 index 0000000..236d5a1 --- /dev/null +++ b/command/root.go @@ -0,0 +1,38 @@ +package command + +import ( + "github.com/hmerritt/reactenv/ui" + "github.com/hmerritt/reactenv/version" + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + uiInstance := ui.GetUi() + showVersion := false + + // Setup root CLI + rootCmd := &cobra.Command{ + Use: "reactenv", + Short: "Inject environment variables into a built react app", + Run: func(cmd *cobra.Command, args []string) { + if showVersion { + uiInstance.Output(version.GetVersion().VersionNumber()) + return + } + _ = cmd.Help() + }, + SilenceUsage: true, + } + + // Completion + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.SetHelpCommand(&cobra.Command{Use: "help", Hidden: true}) + + // Flags + rootCmd.Flags().BoolVar(&showVersion, "version", false, "Show version") + + // Commands + rootCmd.AddCommand(NewRunCommand(uiInstance)) + + return rootCmd +} diff --git a/command/run.go b/command/run.go index 87f7714..5fc7df1 100644 --- a/command/run.go +++ b/command/run.go @@ -1,25 +1,26 @@ -package command - -import ( - "fmt" - "os" - "regexp" - "strings" - - "github.com/hmerritt/reactenv/reactenv" - "github.com/hmerritt/reactenv/ui" -) - -type RunCommand struct { - *BaseCommand -} - -func (c *RunCommand) Synopsis() string { - return "Inject environment variables into a built react app" -} - -func (c *RunCommand) Help() string { - jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) +package command + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/hmerritt/reactenv/reactenv" + "github.com/hmerritt/reactenv/ui" + "github.com/spf13/cobra" +) + +type RunCommand struct { + UI *ui.Ui +} + +func (c *RunCommand) Synopsis() string { + return "Inject environment variables into a built react app" +} + +func (c *RunCommand) Help() string { + jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) helpText := fmt.Sprintf(` Usage: reactenv run [options] PATH @@ -34,23 +35,38 @@ Example: ├── login.lazy-b839zm%s └── user.lazy-c7942lh%s <- Runs on all %s files in PATH `, jsInfo, jsInfo, jsInfo, jsInfo) - - return strings.TrimSpace(helpText) -} - -func (c *RunCommand) Flags() *FlagMap { - return GetFlagMap(FlagNamesGlobal) -} - -func (c *RunCommand) Run(args []string) int { - duration := ui.InitDuration(c.UI) - - args = c.Flags().Parse(c.UI, args) - - if len(args) == 0 { - c.UI.Error("No asset PATH entered.") - c.exitWithHelp() - } + + return strings.TrimSpace(helpText) +} + +func NewRunCommand(ui *ui.Ui) *cobra.Command { + run := &RunCommand{ + UI: ui, + } + + cmd := &cobra.Command{ + Use: "run PATH", + Short: run.Synopsis(), + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + run.Run(args) + }, + } + + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + run.UI.Output(run.Help()) + }) + + return cmd +} + +func (c *RunCommand) Run(args []string) int { + duration := ui.InitDuration(c.UI) + + if len(args) == 0 { + c.UI.Error("No asset PATH entered.") + c.exitWithHelp() + } pathToAssets := args[0] @@ -141,11 +157,11 @@ func (c *RunCommand) Run(args []string) int { renv.ReplaceOccurrences() - duration.In(c.UI.SuccessColor, fmt.Sprintf("Injected all environment variables")) - return 0 -} - -func (c *RunCommand) exitWithHelp() { - c.UI.Output("\nSee 'reactenv run --help'.") - os.Exit(1) -} + duration.In(c.UI.SuccessColor, fmt.Sprintf("Injected all environment variables")) + return 0 +} + +func (c *RunCommand) exitWithHelp() { + c.UI.Output("\nSee 'reactenv run --help'.") + os.Exit(1) +} diff --git a/go.mod b/go.mod index 9bdd139..ea87579 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,12 @@ go 1.25.7 require ( github.com/briandowns/spinner v1.23.2 github.com/fatih/color v1.18.0 - github.com/jessevdk/go-flags v1.6.1 github.com/magefile/mage v1.15.0 github.com/mitchellh/cli v1.1.5 github.com/mitchellh/gox v1.0.1 github.com/pkg/sftp v1.13.10 - github.com/posener/complete v1.2.3 github.com/schollz/progressbar/v3 v3.19.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 gotest.tools/gotestsum v1.13.0 @@ -34,6 +33,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -42,9 +42,11 @@ require ( github.com/mitchellh/iochan v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index ecd2aa4..ae0a2a9 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2 github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -54,8 +55,8 @@ github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= -github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -97,6 +98,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -105,6 +107,10 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -112,6 +118,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= From 39e65f1c0e34e38e1cd73f824761261b0298e89f Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 18:35:39 +0000 Subject: [PATCH 03/20] feat: completion command for shell autocomplete --- command/completion.go | 31 +++++++++++ command/root.go | 3 +- command/run.go | 126 +++++++++++++++++++++--------------------- 3 files changed, 96 insertions(+), 64 deletions(-) create mode 100644 command/completion.go diff --git a/command/completion.go b/command/completion.go new file mode 100644 index 0000000..3ccfb36 --- /dev/null +++ b/command/completion.go @@ -0,0 +1,31 @@ +package command + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func NewCommandCompletion() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: "Generate shell completion scripts for your shell.", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(cmd.OutOrStdout()) + case "zsh": + return cmd.Root().GenZshCompletion(cmd.OutOrStdout()) + case "fish": + return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout()) + default: + return fmt.Errorf("unknown shell: %s", args[0]) + } + }, + } +} diff --git a/command/root.go b/command/root.go index 236d5a1..90c4d0e 100644 --- a/command/root.go +++ b/command/root.go @@ -32,7 +32,8 @@ func NewRootCommand() *cobra.Command { rootCmd.Flags().BoolVar(&showVersion, "version", false, "Show version") // Commands - rootCmd.AddCommand(NewRunCommand(uiInstance)) + rootCmd.AddCommand(NewCommandRun(uiInstance)) + rootCmd.AddCommand(NewCommandCompletion()) return rootCmd } diff --git a/command/run.go b/command/run.go index 5fc7df1..dd6e60a 100644 --- a/command/run.go +++ b/command/run.go @@ -1,26 +1,26 @@ -package command - -import ( - "fmt" - "os" - "regexp" - "strings" - - "github.com/hmerritt/reactenv/reactenv" - "github.com/hmerritt/reactenv/ui" - "github.com/spf13/cobra" -) - -type RunCommand struct { - UI *ui.Ui -} - -func (c *RunCommand) Synopsis() string { - return "Inject environment variables into a built react app" -} - -func (c *RunCommand) Help() string { - jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) +package command + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/hmerritt/reactenv/reactenv" + "github.com/hmerritt/reactenv/ui" + "github.com/spf13/cobra" +) + +type RunCommand struct { + UI *ui.Ui +} + +func (c *RunCommand) Synopsis() string { + return "Inject environment variables into a built react app" +} + +func (c *RunCommand) Help() string { + jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) helpText := fmt.Sprintf(` Usage: reactenv run [options] PATH @@ -35,38 +35,38 @@ Example: ├── login.lazy-b839zm%s └── user.lazy-c7942lh%s <- Runs on all %s files in PATH `, jsInfo, jsInfo, jsInfo, jsInfo) - - return strings.TrimSpace(helpText) -} - -func NewRunCommand(ui *ui.Ui) *cobra.Command { - run := &RunCommand{ - UI: ui, - } - - cmd := &cobra.Command{ - Use: "run PATH", - Short: run.Synopsis(), - Args: cobra.ArbitraryArgs, - Run: func(cmd *cobra.Command, args []string) { - run.Run(args) - }, - } - - cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - run.UI.Output(run.Help()) - }) - - return cmd -} - -func (c *RunCommand) Run(args []string) int { - duration := ui.InitDuration(c.UI) - - if len(args) == 0 { - c.UI.Error("No asset PATH entered.") - c.exitWithHelp() - } + + return strings.TrimSpace(helpText) +} + +func NewCommandRun(ui *ui.Ui) *cobra.Command { + run := &RunCommand{ + UI: ui, + } + + cmd := &cobra.Command{ + Use: "run PATH", + Short: run.Synopsis(), + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + run.Run(args) + }, + } + + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + run.UI.Output(run.Help()) + }) + + return cmd +} + +func (c *RunCommand) Run(args []string) int { + duration := ui.InitDuration(c.UI) + + if len(args) == 0 { + c.UI.Error("No asset PATH entered.") + c.exitWithHelp() + } pathToAssets := args[0] @@ -157,11 +157,11 @@ func (c *RunCommand) Run(args []string) int { renv.ReplaceOccurrences() - duration.In(c.UI.SuccessColor, fmt.Sprintf("Injected all environment variables")) - return 0 -} - -func (c *RunCommand) exitWithHelp() { - c.UI.Output("\nSee 'reactenv run --help'.") - os.Exit(1) -} + duration.In(c.UI.SuccessColor, fmt.Sprintf("Injected all environment variables")) + return 0 +} + +func (c *RunCommand) exitWithHelp() { + c.UI.Output("\nSee 'reactenv run --help'.") + os.Exit(1) +} From 1123320206df8bde037d43cceb7c9cbfab787e90 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 18:35:58 +0000 Subject: [PATCH 04/20] fix: skip printing version for completion command --- version/version.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/version/version.go b/version/version.go index 7ae172d..a2877f6 100644 --- a/version/version.go +++ b/version/version.go @@ -3,6 +3,7 @@ package version import ( "bytes" "fmt" + "os" ) // VersionInfo @@ -90,6 +91,13 @@ func (c *VersionInfo) FullVersionNumber(rev bool) string { } func PrintTitle() { + // Check arguments, and skip when: + // - `completion` command (output needs to be piped to the shell) + args := os.Args[1:] + if len(args) > 0 && args[0] == "completion" { + return + } + // Get version info versionStruct := GetVersion() From 9fbfad6aee3346947285d7c62a0d185b16b40cc7 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 20:47:01 +0000 Subject: [PATCH 05/20] fix: update matcher regex --- command/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/command/run.go b/command/run.go index dd6e60a..9669e79 100644 --- a/command/run.go +++ b/command/run.go @@ -76,7 +76,7 @@ func (c *RunCommand) Run(args []string) int { } // @TODO: Add flag to specify matcher - fileMatchExpression := `.*\.js` + fileMatchExpression := `.*\.js$` _, err := regexp.Compile(fileMatchExpression) if err != nil { From 1959867db62fdf3368b3c6db3015caabfa7b8586 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 20:47:59 +0000 Subject: [PATCH 06/20] fix: prevent reactenv matches including invalid $ char --- reactenv/reactenv.go | 4 +-- reactenv/reactenv_test.go | 61 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/reactenv/reactenv.go b/reactenv/reactenv.go index b48ec6a..bfb07be 100644 --- a/reactenv/reactenv.go +++ b/reactenv/reactenv.go @@ -162,7 +162,7 @@ func FindAllOccurrenceBytePositions(data []byte, prefix []byte) [][]int { firstByte := data[current] isValidStart := (firstByte >= 'a' && firstByte <= 'z') || (firstByte >= 'A' && firstByte <= 'Z') || - firstByte == '_' || firstByte == '$' + firstByte == '_' if !isValidStart { // Abort: The character following the dot is invalid @@ -180,7 +180,7 @@ func FindAllOccurrenceBytePositions(data []byte, prefix []byte) [][]int { isValid := (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || - b == '_' || b == '$' + b == '_' if !isValid { break diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go index 61c2f97..9ace64d 100644 --- a/reactenv/reactenv_test.go +++ b/reactenv/reactenv_test.go @@ -233,9 +233,64 @@ func TestFindAllOccurrenceBytePositions(t *testing.T) { expected: nil, // The function should bypass this entirely. }, { - name: "Acceptance of leading dollar sign and underscore", - input: "__reactenv.$VALID __reactenv._VALID", - expected: [][]int{{0, 17}, {18, 35}}, + name: "Rejection of leading dollar sign", + input: "__reactenv.$INVALID", + expected: nil, + }, + { + name: "Rejection of leading percent sign", + input: "__reactenv.%INVALID", + expected: nil, + }, + { + name: "Rejection of leading exclamation mark", + input: "__reactenv.!INVALID", + expected: nil, + }, + { + name: "Rejection of leading ampersand", + input: "__reactenv.&INVALID", + expected: nil, + }, + { + name: "Rejection of leading asterisk", + input: "__reactenv.*INVALID", + expected: nil, + }, + { + name: "Rejection of leading open parenthesis", + input: "__reactenv.(INVALID", + expected: nil, + }, + { + name: "Rejection of leading close parenthesis", + input: "__reactenv.)INVALID", + expected: nil, + }, + { + name: "Rejection of leading open square bracket", + input: "__reactenv.[INVALID", + expected: nil, + }, + { + name: "Rejection of leading close square bracket", + input: "__reactenv.]INVALID", + expected: nil, + }, + { + name: "Rejection of leading open curly brace", + input: "__reactenv.{INVALID", + expected: nil, + }, + { + name: "Rejection of leading close curly brace", + input: "__reactenv.}INVALID", + expected: nil, + }, + { + name: "Acceptance of leading underscore", + input: "__reactenv._VALID", + expected: [][]int{{0, 17}}, }, { name: "Early termination upon encountering invalid characters", From 57fd071c80e26e86ca83e35bf637a61a666f206d Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 20:48:55 +0000 Subject: [PATCH 07/20] feat: search for files recursively --- README.md | 4 +- command/run.go | 4 +- reactenv/reactenv.go | 51 ++++++++++++++++++------ reactenv/reactenv_test.go | 83 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4b0c54d..b19d5d9 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ It uses the current host enviroment variables and will replace all matches in th All you need to do is run `reactenv run ` and it will do it's thing: ```sh -# Inject environment variables into all `.js` files in `dist` directory -$ reactenv run dist +# Inject environment variables into all `.js` files in `dist` directory (recursively) +$ reactenv run dist ``` After running `reactenv`, your app is ready to be deployed and served! diff --git a/command/run.go b/command/run.go index 9669e79..68efd62 100644 --- a/command/run.go +++ b/command/run.go @@ -33,7 +33,7 @@ Example: ├── index.css ├── index-csxw0qbp%s ├── login.lazy-b839zm%s - └── user.lazy-c7942lh%s <- Runs on all %s files in PATH + └── user.lazy-c7942lh%s <- Runs on all %s files in PATH (recursively) `, jsInfo, jsInfo, jsInfo, jsInfo) return strings.TrimSpace(helpText) @@ -132,7 +132,7 @@ func (c *RunCommand) Run(args []string) int { fmt.Sprintf( " - %4dx in %s", len(fileOccurrencesTotal.Occurrences), - (*renv.Files[fileIndex]).Name(), + renv.FileRelPaths[fileIndex], ), ) } diff --git a/reactenv/reactenv.go b/reactenv/reactenv.go index bfb07be..83e33a5 100644 --- a/reactenv/reactenv.go +++ b/reactenv/reactenv.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path" + "path/filepath" "regexp" "strings" @@ -26,6 +27,8 @@ type Reactenv struct { FilesMatchTotal int // Files with occurrences (not every matched file will have an occurrence, so this may be less than `FilesMatchTotal`) Files []*fs.DirEntry + // Relative paths (from Dir) for each file in Files. + FileRelPaths []string // Total individual occurrences count OccurrencesTotal int @@ -52,6 +55,7 @@ func NewReactenv(ui *ui.Ui) *Reactenv { UI: ui, Dir: "", Files: make([]*fs.DirEntry, 0), + FileRelPaths: make([]string, 0), OccurrencesTotal: 0, OccurrencesByFile: make([]*FileOccurrences, 0), OccurrenceKeys: make(OccurrenceKeys), @@ -63,11 +67,7 @@ func NewReactenv(ui *ui.Ui) *Reactenv { func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { r.Dir = dir r.Files = make([]*fs.DirEntry, 0) - files, err := os.ReadDir(r.Dir) - - if err != nil { - return err - } + r.FileRelPaths = make([]string, 0) fileMatcher, err := regexp.Compile(fileMatchExpression) @@ -75,11 +75,37 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { return err } - for _, file := range files { - if fileMatcher.MatchString(file.Name()) && !file.IsDir() { - fileEntry := file + err = filepath.WalkDir(r.Dir, func(walkPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if entry.IsDir() { + // Prevent scanning of node_modules directory. If necessary, you can bypass this by + // pointing run reactenv to the node_modules directory directly (e.g. `reactenv run node_modules`). + if entry.Name() == "node_modules" && walkPath != r.Dir { + return filepath.SkipDir + } + return nil + } + + if fileMatcher.MatchString(entry.Name()) { + relPath, err := filepath.Rel(r.Dir, walkPath) + if err != nil { + relPath = entry.Name() + } + relPath = filepath.ToSlash(relPath) + + fileEntry := entry r.Files = append(r.Files, &fileEntry) + r.FileRelPaths = append(r.FileRelPaths, relPath) } + + return nil + }) + + if err != nil { + return err } r.FilesMatchTotal = len(r.Files) @@ -90,7 +116,7 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { // Run a callback for each File func (r *Reactenv) FilesWalk(fileCb func(fileIndex int, file fs.DirEntry, filePath string) error) error { for fileIndex, file := range r.Files { - filePath := path.Join(r.Dir, (*file).Name()) + filePath := path.Join(r.Dir, r.FileRelPaths[fileIndex]) err := fileCb(fileIndex, *file, filePath) if err != nil { return err @@ -103,7 +129,7 @@ func (r *Reactenv) FilesWalk(fileCb func(fileIndex int, file fs.DirEntry, filePa // Run a callback for each File, passing in the file contents func (r *Reactenv) FilesWalkContents(fileCb func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error) error { for fileIndex, file := range r.Files { - filePath := path.Join(r.Dir, (*file).Name()) + filePath := path.Join(r.Dir, r.FileRelPaths[fileIndex]) fileContents, err := os.ReadFile(filePath) if err != nil { @@ -204,6 +230,7 @@ func (r *Reactenv) FindOccurrences() error { // Prep for removing files with no occurrences newFiles := make([]*fs.DirEntry, 0, len(r.Files)) + newFileRelPaths := make([]string, 0, len(r.Files)) newOccurrencesByFile := make([]*FileOccurrences, 0) fileIndexesToRemove := make(map[int]int, 0) @@ -219,7 +246,7 @@ func (r *Reactenv) FindOccurrences() error { for _, occurrence := range fileOccurrences { occurrenceText := string(fileContents[occurrence[0]:occurrence[1]]) - envName := strings.Replace(occurrenceText, "__reactenv.", "", 1) + envName := strings.Replace(occurrenceText, string(prefix), "", 1) envValue, envExists := os.LookupEnv(envName) r.OccurrencesByFile[fileIndex].Occurrences = append(r.OccurrencesByFile[fileIndex].Occurrences, Occurrence{ @@ -250,11 +277,13 @@ func (r *Reactenv) FindOccurrences() error { for fileIndex, file := range r.Files { if _, ok := fileIndexesToRemove[fileIndex]; !ok { newFiles = append(newFiles, file) + newFileRelPaths = append(newFileRelPaths, r.FileRelPaths[fileIndex]) newOccurrencesByFile = append(newOccurrencesByFile, r.OccurrencesByFile[fileIndex]) } } r.Files = newFiles + r.FileRelPaths = newFileRelPaths r.OccurrencesByFile = newOccurrencesByFile } diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go index 9ace64d..8708724 100644 --- a/reactenv/reactenv_test.go +++ b/reactenv/reactenv_test.go @@ -40,6 +40,7 @@ func TestReactenvFindFilesMatchesFilesAndIgnoresDirs(t *testing.T) { require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") require.Len(t, renv.Files, 2, "Files length") + require.Len(t, renv.FileRelPaths, 2, "FileRelPaths length") found := map[string]bool{} for _, file := range renv.Files { @@ -52,6 +53,77 @@ func TestReactenvFindFilesMatchesFilesAndIgnoresDirs(t *testing.T) { } require.Equal(t, expected, found, "matched files") + + relFound := map[string]bool{} + for _, relPath := range renv.FileRelPaths { + relFound[relPath] = true + } + + require.Equal(t, expected, relFound, "matched relative paths") +} + +func TestReactenvFindFilesMatchesFilesRecursively(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "root.js") + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + deepDir := filepath.Join(nestedDir, "deep") + require.NoError(t, os.Mkdir(deepDir, 0755), "create deep dir") + writeTestFile(t, deepDir, "deep.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") + require.Equal(t, 3, renv.FilesMatchTotal, "FilesMatchTotal") + require.Len(t, renv.Files, 3, "Files length") + require.Len(t, renv.FileRelPaths, 3, "FileRelPaths length") + + expected := map[string]bool{ + "root.js": true, + "nested/nested.js": true, + "nested/deep/deep.js": true, + } + + found := map[string]bool{} + for _, relPath := range renv.FileRelPaths { + found[relPath] = true + } + + require.Equal(t, expected, found, "matched relative paths") +} + +func TestReactenvFindFilesSkipsNodeModules(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "root.js") + + nodeModulesDir := filepath.Join(tempDir, "node_modules") + require.NoError(t, os.Mkdir(nodeModulesDir, 0755), "create node_modules dir") + writeTestFile(t, nodeModulesDir, "ignored.js") + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") + + expected := map[string]bool{ + "root.js": true, + "nested/nested.js": true, + } + + found := map[string]bool{} + for _, relPath := range renv.FileRelPaths { + found[relPath] = true + } + + require.Equal(t, expected, found, "matched relative paths") } func TestReactenvFindFilesReturnsErrorForMissingDir(t *testing.T) { @@ -426,6 +498,7 @@ func TestReactenvFindOccurrencesFiltersFilesWithoutMatches(t *testing.T) { require.Equal(t, 3, renv.OccurrencesTotal) require.Len(t, renv.Files, 2) + require.Len(t, renv.FileRelPaths, 2) require.Len(t, renv.OccurrencesByFile, 2) counts := map[string]int{} @@ -438,6 +511,13 @@ func TestReactenvFindOccurrencesFiltersFilesWithoutMatches(t *testing.T) { "c.js": 2, }, counts) + paths := map[string]bool{} + for _, relPath := range renv.FileRelPaths { + paths[relPath] = true + } + + require.Equal(t, map[string]bool{"a.js": true, "c.js": true}, paths) + expectedKeys := map[string]bool{ "A": true, "B": true, @@ -463,6 +543,7 @@ func TestReactenvFindOccurrencesNoMatchesClearsFiles(t *testing.T) { require.Equal(t, 0, renv.OccurrencesTotal) require.Empty(t, renv.Files) + require.Empty(t, renv.FileRelPaths) require.Empty(t, renv.OccurrencesByFile) require.Empty(t, renv.OccurrenceKeys) require.Empty(t, renv.OccurrenceKeysReplacement) @@ -484,6 +565,7 @@ func TestReactenvFindOccurrencesResetsStateOnRepeat(t *testing.T) { require.Equal(t, map[string]bool{"ONE": true}, renv.OccurrenceKeys) require.Equal(t, map[string]string{"ONE": "1"}, renv.OccurrenceKeysReplacement) require.Len(t, renv.Files, 1) + require.Len(t, renv.FileRelPaths, 1) require.Len(t, renv.OccurrencesByFile, 1) require.NoError(t, os.WriteFile(filePath, []byte("no occurrences"), 0644)) @@ -492,6 +574,7 @@ func TestReactenvFindOccurrencesResetsStateOnRepeat(t *testing.T) { require.Equal(t, 0, renv.OccurrencesTotal) require.Empty(t, renv.Files) + require.Empty(t, renv.FileRelPaths) require.Empty(t, renv.OccurrencesByFile) require.Empty(t, renv.OccurrenceKeys) require.Empty(t, renv.OccurrenceKeysReplacement) From 979d4cdcc097a9307acd894856b258c36f251ab3 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 20:58:23 +0000 Subject: [PATCH 08/20] fix: improve tests --- npm/reactenv-darwin-arm64/package.json | 2 +- npm/reactenv-darwin-x64/package.json | 2 +- npm/reactenv-linux-arm64/package.json | 2 +- npm/reactenv-linux-x64/package.json | 2 +- npm/reactenv-win32-x64/package.json | 2 +- npm/reactenv/package.json | 2 +- reactenv/reactenv_test.go | 74 ++++++++++++++++++++++---- version/version_base.go | 2 +- 8 files changed, 71 insertions(+), 17 deletions(-) diff --git a/npm/reactenv-darwin-arm64/package.json b/npm/reactenv-darwin-arm64/package.json index ee25301..83bfbe4 100644 --- a/npm/reactenv-darwin-arm64/package.json +++ b/npm/reactenv-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-arm64", - "version": "0.1.96", + "version": "0.1.106", "description": "The macOS ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-darwin-x64/package.json b/npm/reactenv-darwin-x64/package.json index c9f7b03..4040113 100644 --- a/npm/reactenv-darwin-x64/package.json +++ b/npm/reactenv-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-x64", - "version": "0.1.96", + "version": "0.1.106", "description": "The macOS 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-arm64/package.json b/npm/reactenv-linux-arm64/package.json index b395215..cc215aa 100644 --- a/npm/reactenv-linux-arm64/package.json +++ b/npm/reactenv-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-arm64", - "version": "0.1.96", + "version": "0.1.106", "description": "The Linux ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-x64/package.json b/npm/reactenv-linux-x64/package.json index 27bc591..eb74003 100644 --- a/npm/reactenv-linux-x64/package.json +++ b/npm/reactenv-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-x64", - "version": "0.1.96", + "version": "0.1.106", "description": "The Linux 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-win32-x64/package.json b/npm/reactenv-win32-x64/package.json index 680394d..71c19a0 100644 --- a/npm/reactenv-win32-x64/package.json +++ b/npm/reactenv-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-win32-x64", - "version": "0.1.96", + "version": "0.1.106", "description": "The Windows 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv/package.json b/npm/reactenv/package.json index 1414e73..c974773 100644 --- a/npm/reactenv/package.json +++ b/npm/reactenv/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli", - "version": "0.1.96", + "version": "0.1.106", "description": "reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "bin": { diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go index 8708724..b37ce5d 100644 --- a/reactenv/reactenv_test.go +++ b/reactenv/reactenv_test.go @@ -112,6 +112,36 @@ func TestReactenvFindFilesSkipsNodeModules(t *testing.T) { renv := NewReactenv(nil) require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") + require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := map[string]bool{ + "root.js": true, + "nested/nested.js": true, + } + + found := map[string]bool{} + for _, relPath := range renv.FileRelPaths { + found[relPath] = true + } + + require.Equal(t, expected, found, "matched relative paths") +} + +func TestReactenvFindFilesAllowsRootNodeModules(t *testing.T) { + tempDir := t.TempDir() + + nodeModulesDir := filepath.Join(tempDir, "node_modules") + require.NoError(t, os.Mkdir(nodeModulesDir, 0755), "create node_modules dir") + writeTestFile(t, nodeModulesDir, "root.js") + + nestedDir := filepath.Join(nodeModulesDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(nodeModulesDir, `.*\.js$`), "FindFiles returned error") + require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") expected := map[string]bool{ "root.js": true, @@ -140,7 +170,7 @@ func TestReactenvFindFilesReturnsErrorForBadRegex(t *testing.T) { require.Error(t, renv.FindFiles(tempDir, `[`), "expected error for invalid regex") } -func TestReactenvFilesWalkCallsCallbackInOrder(t *testing.T) { +func TestReactenvFilesWalkCallsCallback(t *testing.T) { tempDir := t.TempDir() writeTestFile(t, tempDir, "b.js") @@ -167,12 +197,19 @@ func TestReactenvFilesWalkCallsCallbackInOrder(t *testing.T) { }) require.NoError(t, err) - expected := []walkCall{ - {index: 0, name: "a.js", filePath: path.Join(tempDir, "a.js")}, - {index: 1, name: "b.js", filePath: path.Join(tempDir, "b.js")}, + require.Len(t, calls, 2) + + found := map[string]string{} + for _, call := range calls { + found[call.name] = call.filePath + } + + expected := map[string]string{ + "a.js": path.Join(tempDir, "a.js"), + "b.js": path.Join(tempDir, "b.js"), } - require.Equal(t, expected, calls) + require.Equal(t, expected, found) } func TestReactenvFilesWalkStopsOnError(t *testing.T) { @@ -196,7 +233,7 @@ func TestReactenvFilesWalkStopsOnError(t *testing.T) { require.Equal(t, 1, callCount) } -func TestReactenvFilesWalkContentsCallsCallbackInOrderWithContents(t *testing.T) { +func TestReactenvFilesWalkContentsCallsCallbackWithContents(t *testing.T) { tempDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tempDir, "b.js"), []byte("beta"), 0644)) @@ -225,12 +262,27 @@ func TestReactenvFilesWalkContentsCallsCallbackInOrderWithContents(t *testing.T) }) require.NoError(t, err) - expected := []walkCall{ - {index: 0, name: "a.js", filePath: path.Join(tempDir, "a.js"), contents: "alpha"}, - {index: 1, name: "b.js", filePath: path.Join(tempDir, "b.js"), contents: "beta"}, + require.Len(t, calls, 2) + + type callDetails struct { + filePath string + contents string + } + + found := map[string]callDetails{} + for _, call := range calls { + found[call.name] = callDetails{ + filePath: call.filePath, + contents: call.contents, + } + } + + expected := map[string]callDetails{ + "a.js": {filePath: path.Join(tempDir, "a.js"), contents: "alpha"}, + "b.js": {filePath: path.Join(tempDir, "b.js"), contents: "beta"}, } - require.Equal(t, expected, calls) + require.Equal(t, expected, found) } func TestReactenvFilesWalkContentsStopsOnError(t *testing.T) { @@ -602,10 +654,12 @@ func FuzzReactenvFindOccurrences(f *testing.F) { expectedTotal := len(matches) require.Equal(t, expectedTotal, renv.OccurrencesTotal) + require.Equal(t, len(renv.Files), len(renv.FileRelPaths)) require.Equal(t, len(renv.Files), len(renv.OccurrencesByFile)) if expectedTotal == 0 { require.Empty(t, renv.Files) + require.Empty(t, renv.FileRelPaths) require.Empty(t, renv.OccurrencesByFile) require.Empty(t, renv.OccurrenceKeys) return diff --git a/version/version_base.go b/version/version_base.go index c98b00f..0edb357 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -14,7 +14,7 @@ var ( // The compilation date. This will be filled in by the compiler. BuildDate string - Version = "0.1.96" + Version = "0.1.106" VersionPrerelease = "" VersionMetadata = "" ) From ee919768ecf964b14e48a4151dd66bfe62700c66 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 21:10:37 +0000 Subject: [PATCH 09/20] fix: enforce order of files slice --- reactenv/reactenv.go | 29 +++++++++-- reactenv/reactenv_test.go | 103 ++++++++++---------------------------- 2 files changed, 51 insertions(+), 81 deletions(-) diff --git a/reactenv/reactenv.go b/reactenv/reactenv.go index 83e33a5..e485494 100644 --- a/reactenv/reactenv.go +++ b/reactenv/reactenv.go @@ -8,6 +8,7 @@ import ( "path" "path/filepath" "regexp" + "sort" "strings" "github.com/hmerritt/reactenv/ui" @@ -75,6 +76,13 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { return err } + type fileMatch struct { + entry fs.DirEntry + relPath string + } + + matches := make([]fileMatch, 0) + err = filepath.WalkDir(r.Dir, func(walkPath string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -96,9 +104,10 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { } relPath = filepath.ToSlash(relPath) - fileEntry := entry - r.Files = append(r.Files, &fileEntry) - r.FileRelPaths = append(r.FileRelPaths, relPath) + matches = append(matches, fileMatch{ + entry: entry, + relPath: relPath, + }) } return nil @@ -108,7 +117,19 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { return err } - r.FilesMatchTotal = len(r.Files) + // Enforce deterministic sorting of matches + sort.Slice(matches, func(i, j int) bool { + return matches[i].relPath < matches[j].relPath + }) + + // Populate `Reactenv.Files` and `Reactenv.FileRelPaths` with sorted matches + for _, match := range matches { + fileEntry := match.entry + r.Files = append(r.Files, &fileEntry) + r.FileRelPaths = append(r.FileRelPaths, match.relPath) + } + + r.FilesMatchTotal = len(matches) return nil } diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go index b37ce5d..7cdac06 100644 --- a/reactenv/reactenv_test.go +++ b/reactenv/reactenv_test.go @@ -42,24 +42,10 @@ func TestReactenvFindFilesMatchesFilesAndIgnoresDirs(t *testing.T) { require.Len(t, renv.Files, 2, "Files length") require.Len(t, renv.FileRelPaths, 2, "FileRelPaths length") - found := map[string]bool{} - for _, file := range renv.Files { - found[(*file).Name()] = true - } - - expected := map[string]bool{ - "alpha.js": true, - "beta.js": true, - } - - require.Equal(t, expected, found, "matched files") - - relFound := map[string]bool{} - for _, relPath := range renv.FileRelPaths { - relFound[relPath] = true - } + expected := []string{"alpha.js", "beta.js"} - require.Equal(t, expected, relFound, "matched relative paths") + require.Equal(t, expected, []string{(*renv.Files[0]).Name(), (*renv.Files[1]).Name()}) + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") } func TestReactenvFindFilesMatchesFilesRecursively(t *testing.T) { @@ -82,18 +68,13 @@ func TestReactenvFindFilesMatchesFilesRecursively(t *testing.T) { require.Len(t, renv.Files, 3, "Files length") require.Len(t, renv.FileRelPaths, 3, "FileRelPaths length") - expected := map[string]bool{ - "root.js": true, - "nested/nested.js": true, - "nested/deep/deep.js": true, + expected := []string{ + "nested/deep/deep.js", + "nested/nested.js", + "root.js", } - found := map[string]bool{} - for _, relPath := range renv.FileRelPaths { - found[relPath] = true - } - - require.Equal(t, expected, found, "matched relative paths") + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") } func TestReactenvFindFilesSkipsNodeModules(t *testing.T) { @@ -114,17 +95,12 @@ func TestReactenvFindFilesSkipsNodeModules(t *testing.T) { require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") - expected := map[string]bool{ - "root.js": true, - "nested/nested.js": true, + expected := []string{ + "nested/nested.js", + "root.js", } - found := map[string]bool{} - for _, relPath := range renv.FileRelPaths { - found[relPath] = true - } - - require.Equal(t, expected, found, "matched relative paths") + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") } func TestReactenvFindFilesAllowsRootNodeModules(t *testing.T) { @@ -143,17 +119,12 @@ func TestReactenvFindFilesAllowsRootNodeModules(t *testing.T) { require.NoError(t, renv.FindFiles(nodeModulesDir, `.*\.js$`), "FindFiles returned error") require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") - expected := map[string]bool{ - "root.js": true, - "nested/nested.js": true, + expected := []string{ + "nested/nested.js", + "root.js", } - found := map[string]bool{} - for _, relPath := range renv.FileRelPaths { - found[relPath] = true - } - - require.Equal(t, expected, found, "matched relative paths") + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") } func TestReactenvFindFilesReturnsErrorForMissingDir(t *testing.T) { @@ -170,7 +141,7 @@ func TestReactenvFindFilesReturnsErrorForBadRegex(t *testing.T) { require.Error(t, renv.FindFiles(tempDir, `[`), "expected error for invalid regex") } -func TestReactenvFilesWalkCallsCallback(t *testing.T) { +func TestReactenvFilesWalkCallsCallbackInOrder(t *testing.T) { tempDir := t.TempDir() writeTestFile(t, tempDir, "b.js") @@ -197,19 +168,12 @@ func TestReactenvFilesWalkCallsCallback(t *testing.T) { }) require.NoError(t, err) - require.Len(t, calls, 2) - - found := map[string]string{} - for _, call := range calls { - found[call.name] = call.filePath + expected := []walkCall{ + {index: 0, name: "a.js", filePath: path.Join(tempDir, "a.js")}, + {index: 1, name: "b.js", filePath: path.Join(tempDir, "b.js")}, } - expected := map[string]string{ - "a.js": path.Join(tempDir, "a.js"), - "b.js": path.Join(tempDir, "b.js"), - } - - require.Equal(t, expected, found) + require.Equal(t, expected, calls) } func TestReactenvFilesWalkStopsOnError(t *testing.T) { @@ -233,7 +197,7 @@ func TestReactenvFilesWalkStopsOnError(t *testing.T) { require.Equal(t, 1, callCount) } -func TestReactenvFilesWalkContentsCallsCallbackWithContents(t *testing.T) { +func TestReactenvFilesWalkContentsCallsCallbackInOrderWithContents(t *testing.T) { tempDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tempDir, "b.js"), []byte("beta"), 0644)) @@ -262,27 +226,12 @@ func TestReactenvFilesWalkContentsCallsCallbackWithContents(t *testing.T) { }) require.NoError(t, err) - require.Len(t, calls, 2) - - type callDetails struct { - filePath string - contents string - } - - found := map[string]callDetails{} - for _, call := range calls { - found[call.name] = callDetails{ - filePath: call.filePath, - contents: call.contents, - } - } - - expected := map[string]callDetails{ - "a.js": {filePath: path.Join(tempDir, "a.js"), contents: "alpha"}, - "b.js": {filePath: path.Join(tempDir, "b.js"), contents: "beta"}, + expected := []walkCall{ + {index: 0, name: "a.js", filePath: path.Join(tempDir, "a.js"), contents: "alpha"}, + {index: 1, name: "b.js", filePath: path.Join(tempDir, "b.js"), contents: "beta"}, } - require.Equal(t, expected, found) + require.Equal(t, expected, calls) } func TestReactenvFilesWalkContentsStopsOnError(t *testing.T) { From f647e1b6877039752fc3654852568871949c743d Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 21:42:23 +0000 Subject: [PATCH 10/20] fix: refactor root command logic --- command/command.go | 32 ++++++++++++++++++++++++++++++++ command/completion.go | 31 ------------------------------- command/root.go | 39 --------------------------------------- 3 files changed, 32 insertions(+), 70 deletions(-) delete mode 100644 command/completion.go delete mode 100644 command/root.go diff --git a/command/command.go b/command/command.go index f6ae56c..bc895bd 100644 --- a/command/command.go +++ b/command/command.go @@ -2,11 +2,43 @@ package command import ( "os" + + "github.com/hmerritt/reactenv/ui" + "github.com/hmerritt/reactenv/version" + "github.com/spf13/cobra" ) +var Ui = ui.GetUi() + func Run() { rootCmd := NewRootCommand() if err := rootCmd.Execute(); err != nil { os.Exit(1) } } + +func NewRootCommand() *cobra.Command { + showVersion := false + + // Setup root CLI + rootCmd := &cobra.Command{ + Use: "reactenv", + Short: "Inject environment variables into a built react app", + Run: func(cmd *cobra.Command, args []string) { + if showVersion { + Ui.Output(version.GetVersion().VersionNumber()) + return + } + _ = cmd.Help() + }, + SilenceUsage: true, + } + + // Flags + rootCmd.Flags().BoolVar(&showVersion, "version", false, "Show version") + + // Commands + rootCmd.AddCommand(NewCommandRun()) + + return rootCmd +} diff --git a/command/completion.go b/command/completion.go deleted file mode 100644 index 3ccfb36..0000000 --- a/command/completion.go +++ /dev/null @@ -1,31 +0,0 @@ -package command - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -func NewCommandCompletion() *cobra.Command { - return &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion scripts", - Long: "Generate shell completion scripts for your shell.", - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - RunE: func(cmd *cobra.Command, args []string) error { - switch args[0] { - case "bash": - return cmd.Root().GenBashCompletion(cmd.OutOrStdout()) - case "zsh": - return cmd.Root().GenZshCompletion(cmd.OutOrStdout()) - case "fish": - return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) - case "powershell": - return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout()) - default: - return fmt.Errorf("unknown shell: %s", args[0]) - } - }, - } -} diff --git a/command/root.go b/command/root.go deleted file mode 100644 index 90c4d0e..0000000 --- a/command/root.go +++ /dev/null @@ -1,39 +0,0 @@ -package command - -import ( - "github.com/hmerritt/reactenv/ui" - "github.com/hmerritt/reactenv/version" - "github.com/spf13/cobra" -) - -func NewRootCommand() *cobra.Command { - uiInstance := ui.GetUi() - showVersion := false - - // Setup root CLI - rootCmd := &cobra.Command{ - Use: "reactenv", - Short: "Inject environment variables into a built react app", - Run: func(cmd *cobra.Command, args []string) { - if showVersion { - uiInstance.Output(version.GetVersion().VersionNumber()) - return - } - _ = cmd.Help() - }, - SilenceUsage: true, - } - - // Completion - rootCmd.CompletionOptions.DisableDefaultCmd = true - rootCmd.SetHelpCommand(&cobra.Command{Use: "help", Hidden: true}) - - // Flags - rootCmd.Flags().BoolVar(&showVersion, "version", false, "Show version") - - // Commands - rootCmd.AddCommand(NewCommandRun(uiInstance)) - rootCmd.AddCommand(NewCommandCompletion()) - - return rootCmd -} From 3f1263d8b6de6eda8bf8a8a67626edf44692db2f Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 21:42:53 +0000 Subject: [PATCH 11/20] fix: skip printing version for version flag --- version/version.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/version/version.go b/version/version.go index a2877f6..9207758 100644 --- a/version/version.go +++ b/version/version.go @@ -91,10 +91,11 @@ func (c *VersionInfo) FullVersionNumber(rev bool) string { } func PrintTitle() { - // Check arguments, and skip when: - // - `completion` command (output needs to be piped to the shell) + // Skip printing title and version, usually because the output needs to be pipe-able, when: + // - `completion` command + // - `--version` flag args := os.Args[1:] - if len(args) > 0 && args[0] == "completion" { + if len(args) > 0 && (args[0] == "completion" || args[0] == "--version") { return } From 84e782f0110226af012eb6ce38145062ad1c001b Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 21:43:06 +0000 Subject: [PATCH 12/20] fix: use global ui instance --- command/run.go | 84 +++++++++++++------------- npm/reactenv-darwin-arm64/package.json | 2 +- npm/reactenv-darwin-x64/package.json | 2 +- npm/reactenv-linux-arm64/package.json | 2 +- npm/reactenv-linux-x64/package.json | 2 +- npm/reactenv-win32-x64/package.json | 2 +- npm/reactenv/package.json | 2 +- version/version_base.go | 2 +- 8 files changed, 50 insertions(+), 48 deletions(-) diff --git a/command/run.go b/command/run.go index 68efd62..db67fd6 100644 --- a/command/run.go +++ b/command/run.go @@ -11,38 +11,40 @@ import ( "github.com/spf13/cobra" ) -type RunCommand struct { - UI *ui.Ui -} +type RunCommand struct{} func (c *RunCommand) Synopsis() string { return "Inject environment variables into a built react app" } func (c *RunCommand) Help() string { - jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) + jsInfo := Ui.Colorize(".js", Ui.InfoColor) helpText := fmt.Sprintf(` Usage: reactenv run [options] PATH Inject environment variables into a built react app. Example: - $ reactenv run ./dist/assets - - dist/assets - ├── index.css + $ reactenv run ./dist + + dist/ + ├── login/ + │ ├── login.css + │ └── login.lazy-b839zm%s + ├── user/ + │ ├── user.css + │ └── user.lazy-c7942lh%s <- Runs on all %s files in PATH (recursively) + ├── index.html ├── index-csxw0qbp%s - ├── login.lazy-b839zm%s - └── user.lazy-c7942lh%s <- Runs on all %s files in PATH (recursively) + ├── robots.txt + └── sitemap.xml `, jsInfo, jsInfo, jsInfo, jsInfo) return strings.TrimSpace(helpText) } -func NewCommandRun(ui *ui.Ui) *cobra.Command { - run := &RunCommand{ - UI: ui, - } +func NewCommandRun() *cobra.Command { + run := &RunCommand{} cmd := &cobra.Command{ Use: "run PATH", @@ -54,24 +56,24 @@ func NewCommandRun(ui *ui.Ui) *cobra.Command { } cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - run.UI.Output(run.Help()) + Ui.Output(run.Help()) }) return cmd } func (c *RunCommand) Run(args []string) int { - duration := ui.InitDuration(c.UI) + duration := ui.InitDuration(Ui) if len(args) == 0 { - c.UI.Error("No asset PATH entered.") + Ui.Error("No asset PATH entered.") c.exitWithHelp() } pathToAssets := args[0] if _, err := os.Stat(pathToAssets); os.IsNotExist(err) { - c.UI.Error(fmt.Sprintf("File PATH '%s' does not exist.", pathToAssets)) + Ui.Error(fmt.Sprintf("File PATH '%s' does not exist.", pathToAssets)) c.exitWithHelp() } @@ -80,45 +82,45 @@ func (c *RunCommand) Run(args []string) int { _, err := regexp.Compile(fileMatchExpression) if err != nil { - c.UI.Error(fmt.Sprintf("File match expression '%s' is not valid.\n", fileMatchExpression)) - c.UI.Error(fmt.Sprintf("%v", err)) + Ui.Error(fmt.Sprintf("File match expression '%s' is not valid.\n", fileMatchExpression)) + Ui.Error(fmt.Sprintf("%v", err)) c.exitWithHelp() } - renv := reactenv.NewReactenv(c.UI) + renv := reactenv.NewReactenv(Ui) err = renv.FindFiles(pathToAssets, fileMatchExpression) if err != nil { - c.UI.Error(fmt.Sprintf("Error reading files in PATH '%s'.\n", pathToAssets)) - c.UI.Error(fmt.Sprintf("%v", err)) + Ui.Error(fmt.Sprintf("Error reading files in PATH '%s'.\n", pathToAssets)) + Ui.Error(fmt.Sprintf("%v", err)) os.Exit(1) } if len(renv.Files) == 0 { - c.UI.Error(fmt.Sprintf("No files found in path '%s' using matcher '%s'", pathToAssets, fileMatchExpression)) + Ui.Error(fmt.Sprintf("No files found in path '%s' using matcher '%s'", pathToAssets, fileMatchExpression)) os.Exit(1) } err = renv.FindOccurrences() if err != nil { - c.UI.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets)) - c.UI.Error(fmt.Sprintf("%v", err)) + Ui.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets)) + Ui.Error(fmt.Sprintf("%v", err)) os.Exit(1) } if renv.OccurrencesTotal == 0 { - c.UI.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets), 0)) - c.UI.Warn(ui.WrapAtLength("Possible causes:", 4)) - c.UI.Warn(ui.WrapAtLength(" - reactenv has already ran on these files", 4)) - c.UI.Warn(ui.WrapAtLength(" - Environment variables were not replaced with `__reactenv.` during build", 4)) - c.UI.Warn("") - duration.In(c.UI.WarnColor, "") + Ui.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets), 0)) + Ui.Warn(ui.WrapAtLength("Possible causes:", 4)) + Ui.Warn(ui.WrapAtLength(" - reactenv has already ran on these files", 4)) + Ui.Warn(ui.WrapAtLength(" - Environment variables were not replaced with `__reactenv.` during build", 4)) + Ui.Warn("") + duration.In(Ui.WarnColor, "") return 1 } - c.UI.Output( + Ui.Output( fmt.Sprintf( "Found %d reactenv environment %s in %d/%d matching files:", renv.OccurrencesTotal, @@ -128,7 +130,7 @@ func (c *RunCommand) Run(args []string) int { ), ) for fileIndex, fileOccurrencesTotal := range renv.OccurrencesByFile { - c.UI.Output( + Ui.Output( fmt.Sprintf( " - %4dx in %s", len(fileOccurrencesTotal.Occurrences), @@ -136,9 +138,9 @@ func (c *RunCommand) Run(args []string) int { ), ) } - c.UI.Output("") + Ui.Output("") - c.UI.Output(fmt.Sprintf("Environment %s checklist (ticked if value has been set):", ui.Pluralize("variable", renv.OccurrencesTotal))) + Ui.Output(fmt.Sprintf("Environment %s checklist (ticked if value has been set):", ui.Pluralize("variable", renv.OccurrencesTotal))) envValuesMissing := 0 for occurrenceKey := range renv.OccurrenceKeys { check := "✅" @@ -146,22 +148,22 @@ func (c *RunCommand) Run(args []string) int { check = "❌" envValuesMissing++ } - c.UI.Output(fmt.Sprintf(" - %4s %s", check, occurrenceKey)) + Ui.Output(fmt.Sprintf(" - %4s %s", check, occurrenceKey)) } - c.UI.Output("") + Ui.Output("") if envValuesMissing > 0 { - c.UI.Error(fmt.Sprintf("Environment %s not set. See above checklist for missing values.", ui.Pluralize("variable", envValuesMissing))) + Ui.Error(fmt.Sprintf("Environment %s not set. See above checklist for missing values.", ui.Pluralize("variable", envValuesMissing))) os.Exit(1) } renv.ReplaceOccurrences() - duration.In(c.UI.SuccessColor, fmt.Sprintf("Injected all environment variables")) + duration.In(Ui.SuccessColor, fmt.Sprintf("Injected all environment variables")) return 0 } func (c *RunCommand) exitWithHelp() { - c.UI.Output("\nSee 'reactenv run --help'.") + Ui.Output("\nSee 'reactenv run --help'.") os.Exit(1) } diff --git a/npm/reactenv-darwin-arm64/package.json b/npm/reactenv-darwin-arm64/package.json index 83bfbe4..4c6865d 100644 --- a/npm/reactenv-darwin-arm64/package.json +++ b/npm/reactenv-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-arm64", - "version": "0.1.106", + "version": "0.1.110", "description": "The macOS ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-darwin-x64/package.json b/npm/reactenv-darwin-x64/package.json index 4040113..962f6cc 100644 --- a/npm/reactenv-darwin-x64/package.json +++ b/npm/reactenv-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-x64", - "version": "0.1.106", + "version": "0.1.110", "description": "The macOS 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-arm64/package.json b/npm/reactenv-linux-arm64/package.json index cc215aa..e52d277 100644 --- a/npm/reactenv-linux-arm64/package.json +++ b/npm/reactenv-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-arm64", - "version": "0.1.106", + "version": "0.1.110", "description": "The Linux ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-x64/package.json b/npm/reactenv-linux-x64/package.json index eb74003..7ff8262 100644 --- a/npm/reactenv-linux-x64/package.json +++ b/npm/reactenv-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-x64", - "version": "0.1.106", + "version": "0.1.110", "description": "The Linux 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-win32-x64/package.json b/npm/reactenv-win32-x64/package.json index 71c19a0..74d3e8b 100644 --- a/npm/reactenv-win32-x64/package.json +++ b/npm/reactenv-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-win32-x64", - "version": "0.1.106", + "version": "0.1.110", "description": "The Windows 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv/package.json b/npm/reactenv/package.json index c974773..5b9b82c 100644 --- a/npm/reactenv/package.json +++ b/npm/reactenv/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli", - "version": "0.1.106", + "version": "0.1.110", "description": "reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "bin": { diff --git a/version/version_base.go b/version/version_base.go index 0edb357..39131da 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -14,7 +14,7 @@ var ( // The compilation date. This will be filled in by the compiler. BuildDate string - Version = "0.1.106" + Version = "0.1.110" VersionPrerelease = "" VersionMetadata = "" ) From ac98c0489ddfd17ca2b060507a2212d263338759 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 21:48:31 +0000 Subject: [PATCH 13/20] fix: exclude version package from coverage --- .github/actions/go-setup/action.yml | 1 + .github/actions/test/action.yml | 4 +++- version/version.go | 1 + version/version_base.go | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/actions/go-setup/action.yml b/.github/actions/go-setup/action.yml index 648179e..d8db4b8 100644 --- a/.github/actions/go-setup/action.yml +++ b/.github/actions/go-setup/action.yml @@ -12,4 +12,5 @@ runs: shell: bash run: | go install github.com/magefile/mage + go install github.com/hexira/go-ignore-cov@latest mage -v bootstrap diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 7635c8b..5dcf515 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -6,4 +6,6 @@ runs: steps: - name: Run Tests shell: bash - run: mage -v test + run: | + mage -v test + go-ignore-cov --file cover.out diff --git a/version/version.go b/version/version.go index 9207758..c1072e1 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,4 @@ +//coverage:ignore file package version import ( diff --git a/version/version_base.go b/version/version_base.go index 39131da..c204d2c 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -1,3 +1,4 @@ +//coverage:ignore file package version const ( From efd26d8e2e5b196030bd451111d93660c60fa672 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:10:30 +0000 Subject: [PATCH 14/20] feat: match flag to fully customise target files --- README.md | 12 +- command/run.go | 354 +++++++++++++------------ go.mod | 1 + go.sum | 2 + npm/reactenv-darwin-arm64/package.json | 2 +- npm/reactenv-darwin-x64/package.json | 2 +- npm/reactenv-linux-arm64/package.json | 2 +- npm/reactenv-linux-x64/package.json | 2 +- npm/reactenv-win32-x64/package.json | 2 +- npm/reactenv/package.json | 2 +- reactenv/reactenv.go | 117 +++++++- reactenv/reactenv_matcher_test.go | 120 +++++++++ reactenv/reactenv_test.go | 68 ++++- version/version_base.go | 2 +- 14 files changed, 498 insertions(+), 190 deletions(-) create mode 100644 reactenv/reactenv_matcher_test.go diff --git a/README.md b/README.md index b19d5d9..c1f0183 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,16 @@ After building your app, you should have a final bundle with all environment var It uses the current host enviroment variables and will replace all matches in the bundle. (support for `.env` files is coming soon). -All you need to do is run `reactenv run ` and it will do it's thing: - -```sh +All you need to do is run `reactenv run ` and it will do it's thing: + +```sh # Inject environment variables into all `.js` files in `dist` directory (recursively) $ reactenv run dist -``` + +# Override the file matcher (regex or glob, matching relative paths) +$ reactenv run --match "glob:**/*.mjs" dist +$ reactenv run --match "regex:^assets/.*\\.js$" dist +``` After running `reactenv`, your app is ready to be deployed and served! diff --git a/command/run.go b/command/run.go index db67fd6..910d710 100644 --- a/command/run.go +++ b/command/run.go @@ -1,169 +1,185 @@ -package command - -import ( - "fmt" - "os" - "regexp" - "strings" - - "github.com/hmerritt/reactenv/reactenv" - "github.com/hmerritt/reactenv/ui" - "github.com/spf13/cobra" -) - -type RunCommand struct{} - -func (c *RunCommand) Synopsis() string { - return "Inject environment variables into a built react app" -} - -func (c *RunCommand) Help() string { - jsInfo := Ui.Colorize(".js", Ui.InfoColor) - helpText := fmt.Sprintf(` -Usage: reactenv run [options] PATH - -Inject environment variables into a built react app. - -Example: - $ reactenv run ./dist - - dist/ - ├── login/ - │ ├── login.css - │ └── login.lazy-b839zm%s - ├── user/ - │ ├── user.css - │ └── user.lazy-c7942lh%s <- Runs on all %s files in PATH (recursively) - ├── index.html - ├── index-csxw0qbp%s - ├── robots.txt - └── sitemap.xml -`, jsInfo, jsInfo, jsInfo, jsInfo) - - return strings.TrimSpace(helpText) -} - -func NewCommandRun() *cobra.Command { - run := &RunCommand{} - - cmd := &cobra.Command{ - Use: "run PATH", - Short: run.Synopsis(), - Args: cobra.ArbitraryArgs, - Run: func(cmd *cobra.Command, args []string) { - run.Run(args) - }, - } - - cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - Ui.Output(run.Help()) - }) - - return cmd -} - -func (c *RunCommand) Run(args []string) int { - duration := ui.InitDuration(Ui) - - if len(args) == 0 { - Ui.Error("No asset PATH entered.") - c.exitWithHelp() - } - - pathToAssets := args[0] - - if _, err := os.Stat(pathToAssets); os.IsNotExist(err) { - Ui.Error(fmt.Sprintf("File PATH '%s' does not exist.", pathToAssets)) - c.exitWithHelp() - } - - // @TODO: Add flag to specify matcher - fileMatchExpression := `.*\.js$` - _, err := regexp.Compile(fileMatchExpression) - - if err != nil { - Ui.Error(fmt.Sprintf("File match expression '%s' is not valid.\n", fileMatchExpression)) - Ui.Error(fmt.Sprintf("%v", err)) - c.exitWithHelp() - } - - renv := reactenv.NewReactenv(Ui) - - err = renv.FindFiles(pathToAssets, fileMatchExpression) - - if err != nil { - Ui.Error(fmt.Sprintf("Error reading files in PATH '%s'.\n", pathToAssets)) - Ui.Error(fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(renv.Files) == 0 { - Ui.Error(fmt.Sprintf("No files found in path '%s' using matcher '%s'", pathToAssets, fileMatchExpression)) - os.Exit(1) - } - - err = renv.FindOccurrences() - - if err != nil { - Ui.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets)) - Ui.Error(fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if renv.OccurrencesTotal == 0 { - Ui.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets), 0)) - Ui.Warn(ui.WrapAtLength("Possible causes:", 4)) - Ui.Warn(ui.WrapAtLength(" - reactenv has already ran on these files", 4)) - Ui.Warn(ui.WrapAtLength(" - Environment variables were not replaced with `__reactenv.` during build", 4)) - Ui.Warn("") - duration.In(Ui.WarnColor, "") - return 1 - } - - Ui.Output( - fmt.Sprintf( - "Found %d reactenv environment %s in %d/%d matching files:", - renv.OccurrencesTotal, - ui.Pluralize("variable", renv.OccurrencesTotal), - len(renv.Files), - renv.FilesMatchTotal, - ), - ) - for fileIndex, fileOccurrencesTotal := range renv.OccurrencesByFile { - Ui.Output( - fmt.Sprintf( - " - %4dx in %s", - len(fileOccurrencesTotal.Occurrences), - renv.FileRelPaths[fileIndex], - ), - ) - } - Ui.Output("") - - Ui.Output(fmt.Sprintf("Environment %s checklist (ticked if value has been set):", ui.Pluralize("variable", renv.OccurrencesTotal))) - envValuesMissing := 0 - for occurrenceKey := range renv.OccurrenceKeys { - check := "✅" - if _, ok := renv.OccurrenceKeysReplacement[occurrenceKey]; !ok { - check = "❌" - envValuesMissing++ - } - Ui.Output(fmt.Sprintf(" - %4s %s", check, occurrenceKey)) - } - Ui.Output("") - - if envValuesMissing > 0 { - Ui.Error(fmt.Sprintf("Environment %s not set. See above checklist for missing values.", ui.Pluralize("variable", envValuesMissing))) - os.Exit(1) - } - - renv.ReplaceOccurrences() - - duration.In(Ui.SuccessColor, fmt.Sprintf("Injected all environment variables")) - return 0 -} - -func (c *RunCommand) exitWithHelp() { - Ui.Output("\nSee 'reactenv run --help'.") - os.Exit(1) -} +package command + +import ( + "fmt" + "os" + "strings" + + "github.com/hmerritt/reactenv/reactenv" + "github.com/hmerritt/reactenv/ui" + "github.com/spf13/cobra" +) + +type RunCommand struct { + FileMatchPattern string +} + +const defaultFileMatchPattern = `.*\.js$` + +func (c *RunCommand) Synopsis() string { + return "Inject environment variables into a bundled react app" +} + +func (c *RunCommand) Help() string { + jsInfo := Ui.Colorize(".js", Ui.InfoColor) + helpText := fmt.Sprintf(` +Usage: reactenv run [options] PATH + +Inject environment variables into a built react app + +Usage: + reactenv run PATH [flags] + +Flags: + -h, --help help for run + --match string File match pattern (regex or glob) (default ".*\\.js$") + +Examples: + $ reactenv run --match "glob:**/*.mjs" ./dist + $ reactenv run --match "regex:^assets/.*\\.js$" ./dist + $ reactenv run ./dist + + dist/ + ├── login/ + │ ├── login.css + │ └── login.lazy-b839zm%s + ├── user/ + │ ├── user.css + │ └── user.lazy-c7942lh%s <- Runs on all %s files in PATH (recursively) + ├── index.html + ├── index-csxw0qbp%s + ├── robots.txt + └── sitemap.xml +`, jsInfo, jsInfo, jsInfo, jsInfo) + + return strings.TrimSpace(helpText) +} + +func NewCommandRun() *cobra.Command { + run := &RunCommand{} + + cmd := &cobra.Command{ + Use: "run PATH", + Short: run.Synopsis(), + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + run.Run(args) + }, + } + + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + Ui.Output(run.Help()) + }) + + cmd.Flags().StringVar(&run.FileMatchPattern, "match", defaultFileMatchPattern, "File match pattern (regex or glob)") + + return cmd +} + +func (c *RunCommand) Run(args []string) int { + duration := ui.InitDuration(Ui) + + if len(args) == 0 { + Ui.Error("No asset PATH entered.") + c.exitWithHelp() + } + + pathToAssets := args[0] + + fileMatchPattern := c.FileMatchPattern + + if _, err := os.Stat(pathToAssets); os.IsNotExist(err) { + Ui.Error(fmt.Sprintf("File PATH '%s' does not exist.", pathToAssets)) + c.exitWithHelp() + } + + renv := reactenv.NewReactenv(Ui) + + err := renv.FindFiles(pathToAssets, fileMatchPattern) + + if err != nil { + if matchErr, ok := err.(*reactenv.FileMatchError); ok { + Ui.Error(fmt.Sprintf("File match pattern '%s' is not valid.", matchErr.Pattern)) + if matchErr.AutoRegexErr != nil { + Ui.Error(fmt.Sprintf("Regex error: %v", matchErr.AutoRegexErr)) + Ui.Error(fmt.Sprintf("Glob error: %v", matchErr.Err)) + } else { + Ui.Error(fmt.Sprintf("%v", matchErr.Err)) + } + c.exitWithHelp() + } + Ui.Error(fmt.Sprintf("Error reading files in PATH '%s'.\n", pathToAssets)) + Ui.Error(fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(renv.Files) == 0 { + Ui.Error(fmt.Sprintf("No files found in path '%s' using matcher '%s'", pathToAssets, fileMatchPattern)) + os.Exit(1) + } + + err = renv.FindOccurrences() + + if err != nil { + Ui.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchPattern, pathToAssets)) + Ui.Error(fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if renv.OccurrencesTotal == 0 { + Ui.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchPattern, pathToAssets), 0)) + Ui.Warn(ui.WrapAtLength("Possible causes:", 4)) + Ui.Warn(ui.WrapAtLength(" - reactenv has already ran on these files", 4)) + Ui.Warn(ui.WrapAtLength(" - Environment variables were not replaced with `__reactenv.` during build", 4)) + Ui.Warn("") + duration.In(Ui.WarnColor, "") + return 1 + } + + Ui.Output( + fmt.Sprintf( + "Found %d reactenv environment %s in %d/%d matching files:", + renv.OccurrencesTotal, + ui.Pluralize("variable", renv.OccurrencesTotal), + len(renv.Files), + renv.FilesMatchTotal, + ), + ) + for fileIndex, fileOccurrencesTotal := range renv.OccurrencesByFile { + Ui.Output( + fmt.Sprintf( + " - %4dx in %s", + len(fileOccurrencesTotal.Occurrences), + renv.FileRelPaths[fileIndex], + ), + ) + } + Ui.Output("") + + Ui.Output(fmt.Sprintf("Environment %s checklist (ticked if value has been set):", ui.Pluralize("variable", renv.OccurrencesTotal))) + envValuesMissing := 0 + for occurrenceKey := range renv.OccurrenceKeys { + check := "✅" + if _, ok := renv.OccurrenceKeysReplacement[occurrenceKey]; !ok { + check = "❌" + envValuesMissing++ + } + Ui.Output(fmt.Sprintf(" - %4s %s", check, occurrenceKey)) + } + Ui.Output("") + + if envValuesMissing > 0 { + Ui.Error(fmt.Sprintf("Environment %s not set. See above checklist for missing values.", ui.Pluralize("variable", envValuesMissing))) + os.Exit(1) + } + + renv.ReplaceOccurrences() + + duration.In(Ui.SuccessColor, fmt.Sprintf("Injected all environment variables")) + return 0 +} + +func (c *RunCommand) exitWithHelp() { + Ui.Output("\nSee 'reactenv run --help'.") + os.Exit(1) +} diff --git a/go.mod b/go.mod index ea87579..08636b6 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/briandowns/spinner v1.23.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/fatih/color v1.18.0 github.com/magefile/mage v1.15.0 github.com/mitchellh/cli v1.1.5 diff --git a/go.sum b/go.sum index ae0a2a9..4967821 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE5 github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= diff --git a/npm/reactenv-darwin-arm64/package.json b/npm/reactenv-darwin-arm64/package.json index 4c6865d..9d46bcd 100644 --- a/npm/reactenv-darwin-arm64/package.json +++ b/npm/reactenv-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-arm64", - "version": "0.1.110", + "version": "0.1.112", "description": "The macOS ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-darwin-x64/package.json b/npm/reactenv-darwin-x64/package.json index 962f6cc..61a9db8 100644 --- a/npm/reactenv-darwin-x64/package.json +++ b/npm/reactenv-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-x64", - "version": "0.1.110", + "version": "0.1.112", "description": "The macOS 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-arm64/package.json b/npm/reactenv-linux-arm64/package.json index e52d277..815c571 100644 --- a/npm/reactenv-linux-arm64/package.json +++ b/npm/reactenv-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-arm64", - "version": "0.1.110", + "version": "0.1.112", "description": "The Linux ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-x64/package.json b/npm/reactenv-linux-x64/package.json index 7ff8262..9b97554 100644 --- a/npm/reactenv-linux-x64/package.json +++ b/npm/reactenv-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-x64", - "version": "0.1.110", + "version": "0.1.112", "description": "The Linux 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-win32-x64/package.json b/npm/reactenv-win32-x64/package.json index 74d3e8b..cffb3e2 100644 --- a/npm/reactenv-win32-x64/package.json +++ b/npm/reactenv-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-win32-x64", - "version": "0.1.110", + "version": "0.1.112", "description": "The Windows 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv/package.json b/npm/reactenv/package.json index 5b9b82c..b59cc8a 100644 --- a/npm/reactenv/package.json +++ b/npm/reactenv/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli", - "version": "0.1.110", + "version": "0.1.112", "description": "reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "bin": { diff --git a/reactenv/reactenv.go b/reactenv/reactenv.go index e485494..55b9bf2 100644 --- a/reactenv/reactenv.go +++ b/reactenv/reactenv.go @@ -11,6 +11,7 @@ import ( "sort" "strings" + "github.com/bmatcuk/doublestar/v4" "github.com/hmerritt/reactenv/ui" ) @@ -18,6 +19,29 @@ const ( REACTENV_PREFIX = "__reactenv" ) +const ( + fileMatchModeAuto = "auto" + fileMatchModeRegex = "regex" + fileMatchModeGlob = "glob" +) + +type FileMatchError struct { + Pattern string + Mode string + Err error + AutoRegexErr error +} + +func (e *FileMatchError) Error() string { + if e == nil { + return "" + } + if e.Mode == fileMatchModeAuto && e.AutoRegexErr != nil { + return fmt.Sprintf("file match pattern '%s' is not valid as regex or glob: regex error: %v; glob error: %v", e.Pattern, e.AutoRegexErr, e.Err) + } + return fmt.Sprintf("file match pattern '%s' is not valid for %s: %v", e.Pattern, e.Mode, e.Err) +} + type Reactenv struct { UI *ui.Ui @@ -64,14 +88,14 @@ func NewReactenv(ui *ui.Ui) *Reactenv { } } -// Populates `Reactenv.Files` with all files that match `fileMatchExpression` +// Populates `Reactenv.Files` with all files that match `fileMatchExpression`. +// Patterns support regex or glob (auto-detected, with optional "regex:" / "glob:" prefixes). func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { r.Dir = dir r.Files = make([]*fs.DirEntry, 0) r.FileRelPaths = make([]string, 0) - fileMatcher, err := regexp.Compile(fileMatchExpression) - + fileMatcher, _, err := buildFileMatcher(fileMatchExpression) if err != nil { return err } @@ -97,13 +121,13 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { return nil } - if fileMatcher.MatchString(entry.Name()) { - relPath, err := filepath.Rel(r.Dir, walkPath) - if err != nil { - relPath = entry.Name() - } - relPath = filepath.ToSlash(relPath) + relPath, err := filepath.Rel(r.Dir, walkPath) + if err != nil { + relPath = entry.Name() + } + relPath = filepath.ToSlash(relPath) + if fileMatcher(relPath) { matches = append(matches, fileMatch{ entry: entry, relPath: relPath, @@ -134,6 +158,81 @@ func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { return nil } +func buildFileMatcher(pattern string) (func(string) bool, string, error) { + rawPattern := pattern + mode := fileMatchModeAuto + + if strings.HasPrefix(pattern, "regex:") { + mode = fileMatchModeRegex + pattern = strings.TrimPrefix(pattern, "regex:") + } else if strings.HasPrefix(pattern, "glob:") { + mode = fileMatchModeGlob + pattern = strings.TrimPrefix(pattern, "glob:") + } + + switch mode { + case fileMatchModeRegex: + matcher, err := buildRegexMatcher(rawPattern, pattern) + if err != nil { + return nil, mode, err + } + return matcher, mode, nil + case fileMatchModeGlob: + matcher, err := buildGlobMatcher(rawPattern, pattern) + if err != nil { + return nil, mode, err + } + return matcher, mode, nil + default: + matcher, err := buildRegexMatcher(rawPattern, pattern) + if err == nil { + return matcher, fileMatchModeRegex, nil + } + + globMatcher, globErr := buildGlobMatcher(rawPattern, pattern) + if globErr == nil { + return globMatcher, fileMatchModeGlob, nil + } + + return nil, fileMatchModeAuto, &FileMatchError{ + Pattern: rawPattern, + Mode: fileMatchModeAuto, + Err: globErr, + AutoRegexErr: err, + } + } +} + +func buildRegexMatcher(rawPattern string, pattern string) (func(string) bool, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, &FileMatchError{ + Pattern: rawPattern, + Mode: fileMatchModeRegex, + Err: err, + } + } + + return func(relPath string) bool { + return re.MatchString(relPath) + }, nil +} + +func buildGlobMatcher(rawPattern string, pattern string) (func(string) bool, error) { + if _, err := doublestar.Match(pattern, ""); err != nil { + return nil, &FileMatchError{ + Pattern: rawPattern, + Mode: fileMatchModeGlob, + Err: err, + } + } + + return func(relPath string) bool { + match, err := doublestar.Match(pattern, relPath) + return err == nil && match + }, nil +} + // Run a callback for each File func (r *Reactenv) FilesWalk(fileCb func(fileIndex int, file fs.DirEntry, filePath string) error) error { for fileIndex, file := range r.Files { diff --git a/reactenv/reactenv_matcher_test.go b/reactenv/reactenv_matcher_test.go new file mode 100644 index 0000000..94439db --- /dev/null +++ b/reactenv/reactenv_matcher_test.go @@ -0,0 +1,120 @@ +package reactenv + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildFileMatcherModes(t *testing.T) { + t.Run("AutoRegex", func(t *testing.T) { + matcher, mode, err := buildFileMatcher(`^nested/.*\.js$`) + require.NoError(t, err) + require.Equal(t, fileMatchModeRegex, mode) + require.True(t, matcher("nested/app.js")) + require.False(t, matcher("root.js")) + }) + + t.Run("AutoGlob", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("**/*.js") + require.NoError(t, err) + require.Equal(t, fileMatchModeGlob, mode) + require.True(t, matcher("nested/app.js")) + require.True(t, matcher("app.js")) + require.False(t, matcher("nested/app.css")) + }) + + t.Run("RegexPrefix", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("regex:^assets/.*\\.js$") + require.NoError(t, err) + require.Equal(t, fileMatchModeRegex, mode) + require.True(t, matcher("assets/app.js")) + require.False(t, matcher("app.js")) + }) + + t.Run("GlobPrefix", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("glob:*.js") + require.NoError(t, err) + require.Equal(t, fileMatchModeGlob, mode) + require.True(t, matcher("app.js")) + require.False(t, matcher("nested/app.js")) + }) + + t.Run("AutoPrefersRegexWhenBothValid", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("app.js") + require.NoError(t, err) + require.Equal(t, fileMatchModeRegex, mode) + require.True(t, matcher("nested/app.js")) + }) +} + +func TestBuildFileMatcherAutoInvalid(t *testing.T) { + _, mode, err := buildFileMatcher("[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeAuto, mode) + require.Equal(t, fileMatchModeAuto, matchErr.Mode) + require.NotNil(t, matchErr.AutoRegexErr) + require.NotNil(t, matchErr.Err) +} + +func TestBuildFileMatcherPrefixInvalid(t *testing.T) { + t.Run("RegexPrefix", func(t *testing.T) { + _, mode, err := buildFileMatcher("regex:[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeRegex, mode) + require.Equal(t, fileMatchModeRegex, matchErr.Mode) + require.NotNil(t, matchErr.Err) + }) + + t.Run("GlobPrefix", func(t *testing.T) { + _, mode, err := buildFileMatcher("glob:[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeGlob, mode) + require.Equal(t, fileMatchModeGlob, matchErr.Mode) + require.NotNil(t, matchErr.Err) + }) +} + +func TestBuildRegexMatcherMatches(t *testing.T) { + matcher, err := buildRegexMatcher("regex:^assets/.*\\.js$", `^assets/.*\.js$`) + require.NoError(t, err) + require.True(t, matcher("assets/app.js")) + require.False(t, matcher("app.js")) +} + +func TestBuildRegexMatcherInvalid(t *testing.T) { + _, err := buildRegexMatcher("[", "[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeRegex, matchErr.Mode) + require.NotNil(t, matchErr.Err) +} + +func TestBuildGlobMatcherMatches(t *testing.T) { + matcher, err := buildGlobMatcher("glob:**/*.js", "**/*.js") + require.NoError(t, err) + require.True(t, matcher("nested/app.js")) + require.True(t, matcher("app.js")) + require.False(t, matcher("nested/app.css")) +} + +func TestBuildGlobMatcherInvalid(t *testing.T) { + _, err := buildGlobMatcher("[", "[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeGlob, matchErr.Mode) + require.NotNil(t, matchErr.Err) +} diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go index 7cdac06..3b0c101 100644 --- a/reactenv/reactenv_test.go +++ b/reactenv/reactenv_test.go @@ -77,6 +77,65 @@ func TestReactenvFindFilesMatchesFilesRecursively(t *testing.T) { require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") } +func TestReactenvFindFilesAutoFallsBackToGlob(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, "**/*.js"), "FindFiles returned error") + require.Equal(t, 1, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesRegexMatchesRelativePath(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "root.js") + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `^nested/.*\.js$`), "FindFiles returned error") + require.Equal(t, 1, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesSupportsGlobPrefix(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, "glob:**/*.js"), "FindFiles returned error") + require.Equal(t, 1, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + func TestReactenvFindFilesSkipsNodeModules(t *testing.T) { tempDir := t.TempDir() @@ -138,7 +197,14 @@ func TestReactenvFindFilesReturnsErrorForBadRegex(t *testing.T) { renv := NewReactenv(nil) tempDir := t.TempDir() - require.Error(t, renv.FindFiles(tempDir, `[`), "expected error for invalid regex") + err := renv.FindFiles(tempDir, `[`) + require.Error(t, err, "expected error for invalid matcher") + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeAuto, matchErr.Mode) + require.NotNil(t, matchErr.AutoRegexErr) + require.NotNil(t, matchErr.Err) } func TestReactenvFilesWalkCallsCallbackInOrder(t *testing.T) { diff --git a/version/version_base.go b/version/version_base.go index c204d2c..1b6af0c 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -15,7 +15,7 @@ var ( // The compilation date. This will be filled in by the compiler. BuildDate string - Version = "0.1.110" + Version = "0.1.112" VersionPrerelease = "" VersionMetadata = "" ) From 2ff07a22376113dada285bd8da2831904edec9c8 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:28:53 +0000 Subject: [PATCH 15/20] chore: print coverage file locally --- magefile.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/magefile.go b/magefile.go index 1246633..48e086e 100644 --- a/magefile.go +++ b/magefile.go @@ -64,6 +64,16 @@ func TestWatch() error { }) } +// Prints coverage report +func Coverage() error { + log := NewLogger() + defer log.End() + return RunSync([][]string{ + {"go-ignore-cov", "--file", "cover.out"}, + {"go", "tool", "cover", "-func", "cover.out"}, + }) +} + func Bench() error { log := NewLogger() defer log.End() From ae4e1644edf6500dc3344a4f6b615f9fa5511031 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:34:41 +0000 Subject: [PATCH 16/20] chore: ignore ui helpers from test coverage --- ui/progress_bar.go | 1 + ui/spinner.go | 1 + ui/ui.go | 1 + 3 files changed, 3 insertions(+) diff --git a/ui/progress_bar.go b/ui/progress_bar.go index e4e81bc..5707564 100644 --- a/ui/progress_bar.go +++ b/ui/progress_bar.go @@ -1,3 +1,4 @@ +//coverage:ignore file package ui import ( diff --git a/ui/spinner.go b/ui/spinner.go index b84aec7..461e3a1 100644 --- a/ui/spinner.go +++ b/ui/spinner.go @@ -1,3 +1,4 @@ +//coverage:ignore file package ui import ( diff --git a/ui/ui.go b/ui/ui.go index d5876bf..6d09c6c 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,3 +1,4 @@ +//coverage:ignore file package ui import ( From 42b913c1848d1394f3f78ed8b4e8d4ee2bdedc36 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:40:54 +0000 Subject: [PATCH 17/20] test: run command integration --- command/run_integration_test.go | 142 ++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 command/run_integration_test.go diff --git a/command/run_integration_test.go b/command/run_integration_test.go new file mode 100644 index 0000000..41d769d --- /dev/null +++ b/command/run_integration_test.go @@ -0,0 +1,142 @@ +package command + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + runCommandHelperEnv = "REACTENV_RUN_HELPER" + runCommandHelperPath = "REACTENV_RUN_PATH" +) + +func TestRunCommandRunSuccessDefaultMatcher(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + ignoredTxt := filepath.Join(nestedDir, "ignored.txt") + ignoredCSS := filepath.Join(tempDir, "style.css") + ignoredJS := filepath.Join(tempDir, "misc.js") + + writeFile(t, rootJS, `const api="__reactenv.API_URL";`) + writeFile(t, nestedJS, `const name="__reactenv.NAME";`) + writeFile(t, ignoredTxt, `__reactenv.IGNORED`) + writeFile(t, ignoredCSS, "body { color: red; }") + writeFile(t, ignoredJS, "no matches") + + t.Setenv("API_URL", "https://example.com") + t.Setenv("NAME", "app-name") + + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + exitCode := cmd.Run([]string{tempDir}) + require.Equal(t, 0, exitCode) + + require.Equal(t, `const api="https://example.com";`, readFile(t, rootJS)) + require.Equal(t, `const name="app-name";`, readFile(t, nestedJS)) + require.Equal(t, "__reactenv.IGNORED", readFile(t, ignoredTxt)) + require.Equal(t, "body { color: red; }", readFile(t, ignoredCSS)) + require.Equal(t, "no matches", readFile(t, ignoredJS)) +} + +func TestRunCommandRunSuccessCustomMatcher(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + targetJS := filepath.Join(nestedDir, "only.js") + skipJS := filepath.Join(nestedDir, "skip.js") + + writeFile(t, rootJS, `const root="__reactenv.ROOT";`) + writeFile(t, targetJS, `const nested="__reactenv.NESTED";`) + writeFile(t, skipJS, `const skip="__reactenv.SKIP";`) + + t.Setenv("ROOT", "root") + t.Setenv("NESTED", "nested") + t.Setenv("SKIP", "skip") + + cmd := &RunCommand{FileMatchPattern: "glob:nested/only.js"} + exitCode := cmd.Run([]string{tempDir}) + require.Equal(t, 0, exitCode) + + require.Equal(t, `const nested="nested";`, readFile(t, targetJS)) + require.Equal(t, `const root="__reactenv.ROOT";`, readFile(t, rootJS)) + require.Equal(t, `const skip="__reactenv.SKIP";`, readFile(t, skipJS)) +} + +func TestRunCommandRunMissingEnvExits(t *testing.T) { + if os.Getenv(runCommandHelperEnv) == "1" { + helperPath := os.Getenv(runCommandHelperPath) + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + cmd.Run([]string{helperPath}) + return + } + + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + + originalRoot := `const missing="__reactenv.MISSING";` + originalNested := `const present="__reactenv.PRESENT";` + + writeFile(t, rootJS, originalRoot) + writeFile(t, nestedJS, originalNested) + + cmd := exec.Command(os.Args[0], "-test.run=TestRunCommandRunMissingEnvExits") + env := append([]string{}, os.Environ()...) + env = filterOutEnv(env, "MISSING=") + env = append(env, runCommandHelperEnv+"=1", runCommandHelperPath+"="+tempDir, "PRESENT=present") + cmd.Env = env + + err := cmd.Run() + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) + + require.Equal(t, originalRoot, readFile(t, rootJS)) + require.Equal(t, originalNested, readFile(t, nestedJS)) +} + +func writeFile(t *testing.T, path string, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0644), "write file %s", path) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err, "read file %s", path) + return string(content) +} + +func filterOutEnv(env []string, prefixes ...string) []string { + filtered := make([]string, 0, len(env)) + for _, entry := range env { + skip := false + for _, prefix := range prefixes { + if strings.HasPrefix(entry, prefix) { + skip = true + break + } + } + if !skip { + filtered = append(filtered, entry) + } + } + return filtered +} From b7627e8ffa9804471b4bf8dcadbdd8e9732b8a4e Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:41:03 +0000 Subject: [PATCH 18/20] chore: add coverage to readme --- README.md | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c1f0183..44ae040 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # reactENV -[![](https://img.shields.io/npm/v/%40reactenv%2Fcli)](https://www.npmjs.com/package/@reactenv/cli) +[![](https://img.shields.io/npm/v/%40reactenv%2Fcli)](https://www.npmjs.com/package/@reactenv/cli) [![Coverage Status](https://coveralls.io/repos/github/hmerritt/reactenv/badge.svg?branch=master)](https://coveralls.io/github/hmerritt/reactenv?branch=master) Inject environment variables into a **bundled** react app (after `build`). @@ -10,23 +10,23 @@ Useful for creating generic Docker images. Build your app once and add build fil ### Features ⚡ -- No runtime overhead -- No app code changes required -- Injection is strict by default, and will error if any values are missing -- Blazing fast environment variable injection (~1ms for a basic react app) -- (Optional) Bundler plugins to automate processing `process.env` values during build - - [Webpack plugin `@reactenv/webpack`](https://github.com/hmerritt/reactenv/tree/master/npm/plugin-webpack) +- No runtime overhead +- No app code changes required +- Injection is strict by default, and will error if any values are missing +- Blazing fast environment variable injection (~1ms for a basic react app) +- (Optional) Bundler plugins to automate processing `process.env` values during build + - [Webpack plugin `@reactenv/webpack`](https://github.com/hmerritt/reactenv/tree/master/npm/plugin-webpack) https://github.com/user-attachments/assets/c51465c9-d828-45e5-b469-a95e743d7d02 ### Jump to: -- [Install](#install) -- [Usage](#usage) -- [Example](#example) -- [Reasoning](#reasoning) -- [Aims](#aims) -- [Licence](#licence) +- [Install](#install) +- [Usage](#usage) +- [Example](#example) +- [Reasoning](#reasoning) +- [Aims](#aims) +- [Licence](#licence) ## Install @@ -66,16 +66,16 @@ After building your app, you should have a final bundle with all environment var It uses the current host enviroment variables and will replace all matches in the bundle. (support for `.env` files is coming soon). -All you need to do is run `reactenv run ` and it will do it's thing: - -```sh -# Inject environment variables into all `.js` files in `dist` directory (recursively) -$ reactenv run dist - -# Override the file matcher (regex or glob, matching relative paths) -$ reactenv run --match "glob:**/*.mjs" dist -$ reactenv run --match "regex:^assets/.*\\.js$" dist -``` +All you need to do is run `reactenv run ` and it will do it's thing: + +```sh +# Inject environment variables into all `.js` files in `dist` directory (recursively) +$ reactenv run dist + +# Override the file matcher (regex or glob, matching relative paths) +$ reactenv run --match "glob:**/*.mjs" dist +$ reactenv run --match "regex:^assets/.*\\.js$" dist +``` After running `reactenv`, your app is ready to be deployed and served! @@ -181,10 +181,10 @@ I'm aware that this solution has it's drawbacks and I don't recommend it for eve Since this is being ran **after** a build, this program needs to be 100% reliable. If somthing does go wrong, it catches and reports it so a failed build does not end up in production. -- Fast -- Reliable -- Easy to **debug** -- Simple to use +- Fast +- Reliable +- Easy to **debug** +- Simple to use ## Developing From e931b7f6e049e01a356c8ade4cfa5197ee8cb8ae Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:41:31 +0000 Subject: [PATCH 19/20] chore: bump version --- npm/reactenv-darwin-arm64/package.json | 2 +- npm/reactenv-darwin-x64/package.json | 2 +- npm/reactenv-linux-arm64/package.json | 2 +- npm/reactenv-linux-x64/package.json | 2 +- npm/reactenv-win32-x64/package.json | 2 +- npm/reactenv/package.json | 2 +- version/version_base.go | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/npm/reactenv-darwin-arm64/package.json b/npm/reactenv-darwin-arm64/package.json index 9d46bcd..535dfd5 100644 --- a/npm/reactenv-darwin-arm64/package.json +++ b/npm/reactenv-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-arm64", - "version": "0.1.112", + "version": "0.1.116", "description": "The macOS ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-darwin-x64/package.json b/npm/reactenv-darwin-x64/package.json index 61a9db8..b7adbbb 100644 --- a/npm/reactenv-darwin-x64/package.json +++ b/npm/reactenv-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-x64", - "version": "0.1.112", + "version": "0.1.116", "description": "The macOS 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-arm64/package.json b/npm/reactenv-linux-arm64/package.json index 815c571..e9e528b 100644 --- a/npm/reactenv-linux-arm64/package.json +++ b/npm/reactenv-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-arm64", - "version": "0.1.112", + "version": "0.1.116", "description": "The Linux ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-x64/package.json b/npm/reactenv-linux-x64/package.json index 9b97554..a093e67 100644 --- a/npm/reactenv-linux-x64/package.json +++ b/npm/reactenv-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-x64", - "version": "0.1.112", + "version": "0.1.116", "description": "The Linux 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-win32-x64/package.json b/npm/reactenv-win32-x64/package.json index cffb3e2..dc47f5d 100644 --- a/npm/reactenv-win32-x64/package.json +++ b/npm/reactenv-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-win32-x64", - "version": "0.1.112", + "version": "0.1.116", "description": "The Windows 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv/package.json b/npm/reactenv/package.json index b59cc8a..b7e0874 100644 --- a/npm/reactenv/package.json +++ b/npm/reactenv/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli", - "version": "0.1.112", + "version": "0.1.116", "description": "reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "bin": { diff --git a/version/version_base.go b/version/version_base.go index 1b6af0c..6b3ea28 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -15,7 +15,7 @@ var ( // The compilation date. This will be filled in by the compiler. BuildDate string - Version = "0.1.112" + Version = "0.1.116" VersionPrerelease = "" VersionMetadata = "" ) From 3ef3f4766b3df36b42c8e4d643360d720bcc6205 Mon Sep 17 00:00:00 2001 From: hmerritt Date: Thu, 12 Feb 2026 22:45:34 +0000 Subject: [PATCH 20/20] test: improve integration tests for run --- command/run_integration_test.go | 168 ++++++++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 10 deletions(-) diff --git a/command/run_integration_test.go b/command/run_integration_test.go index 41d769d..bc7729c 100644 --- a/command/run_integration_test.go +++ b/command/run_integration_test.go @@ -12,9 +12,18 @@ import ( const ( runCommandHelperEnv = "REACTENV_RUN_HELPER" + runCommandHelperMode = "REACTENV_RUN_MODE" runCommandHelperPath = "REACTENV_RUN_PATH" ) +const ( + runHelperModeMissingArgs = "missing-args" + runHelperModeInvalidMatch = "invalid-match" + runHelperModeMissingEnv = "missing-env" + runHelperModeNoFiles = "no-files" + runHelperModePartialWrite = "partial-write" +) + func TestRunCommandRunSuccessDefaultMatcher(t *testing.T) { tempDir := t.TempDir() @@ -75,10 +84,7 @@ func TestRunCommandRunSuccessCustomMatcher(t *testing.T) { } func TestRunCommandRunMissingEnvExits(t *testing.T) { - if os.Getenv(runCommandHelperEnv) == "1" { - helperPath := os.Getenv(runCommandHelperPath) - cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} - cmd.Run([]string{helperPath}) + if handleRunCommandHelper(t) { return } @@ -96,13 +102,101 @@ func TestRunCommandRunMissingEnvExits(t *testing.T) { writeFile(t, rootJS, originalRoot) writeFile(t, nestedJS, originalNested) - cmd := exec.Command(os.Args[0], "-test.run=TestRunCommandRunMissingEnvExits") - env := append([]string{}, os.Environ()...) - env = filterOutEnv(env, "MISSING=") - env = append(env, runCommandHelperEnv+"=1", runCommandHelperPath+"="+tempDir, "PRESENT=present") - cmd.Env = env + err := runCommandHelperProcess("TestRunCommandRunMissingEnvExits", runHelperModeMissingEnv, tempDir, "PRESENT=present", "MISSING=") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) + + require.Equal(t, originalRoot, readFile(t, rootJS)) + require.Equal(t, originalNested, readFile(t, nestedJS)) +} + +func TestRunCommandRunMissingArgsExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } - err := cmd.Run() + err := runCommandHelperProcess("TestRunCommandRunMissingArgsExits", runHelperModeMissingArgs, "", "") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestRunCommandRunInvalidMatchExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + writeFile(t, filepath.Join(tempDir, "root.js"), `const api="__reactenv.API_URL";`) + + err := runCommandHelperProcess("TestRunCommandRunInvalidMatchExits", runHelperModeInvalidMatch, tempDir, "API_URL=https://example.com") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestRunCommandRunNoFilesFoundExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + writeFile(t, filepath.Join(tempDir, "style.css"), "body { color: red; }") + + err := runCommandHelperProcess("TestRunCommandRunNoFilesFoundExits", runHelperModeNoFiles, tempDir) + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestRunCommandRunNoOccurrencesReturnsOne(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + + writeFile(t, rootJS, "const api='no matches';") + writeFile(t, nestedJS, "const name='still none';") + + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + exitCode := cmd.Run([]string{tempDir}) + require.Equal(t, 1, exitCode) + + require.Equal(t, "const api='no matches';", readFile(t, rootJS)) + require.Equal(t, "const name='still none';", readFile(t, nestedJS)) +} + +func TestRunCommandRunMissingEnvNoPartialWrites(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + otherJS := filepath.Join(nestedDir, "other.js") + + originalRoot := `const missing="__reactenv.MISSING";` + originalNested := `const present="__reactenv.PRESENT";` + originalOther := `const another="__reactenv.ANOTHER";` + + writeFile(t, rootJS, originalRoot) + writeFile(t, nestedJS, originalNested) + writeFile(t, otherJS, originalOther) + + err := runCommandHelperProcess("TestRunCommandRunMissingEnvNoPartialWrites", runHelperModePartialWrite, tempDir, "PRESENT=present", "ANOTHER=another", "MISSING=") var exitErr *exec.ExitError require.ErrorAs(t, err, &exitErr) @@ -110,6 +204,60 @@ func TestRunCommandRunMissingEnvExits(t *testing.T) { require.Equal(t, originalRoot, readFile(t, rootJS)) require.Equal(t, originalNested, readFile(t, nestedJS)) + require.Equal(t, originalOther, readFile(t, otherJS)) +} + +func handleRunCommandHelper(t *testing.T) bool { + t.Helper() + + if os.Getenv(runCommandHelperEnv) != "1" { + return false + } + + mode := os.Getenv(runCommandHelperMode) + helperPath := os.Getenv(runCommandHelperPath) + + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + + switch mode { + case runHelperModeMissingArgs: + cmd.Run([]string{}) + case runHelperModeInvalidMatch: + cmd.FileMatchPattern = "[" + cmd.Run([]string{helperPath}) + case runHelperModeMissingEnv: + cmd.Run([]string{helperPath}) + case runHelperModeNoFiles: + cmd.Run([]string{helperPath}) + case runHelperModePartialWrite: + cmd.Run([]string{helperPath}) + default: + t.Fatalf("unknown helper mode: %s", mode) + } + + return true +} + +func runCommandHelperProcess(testName string, mode string, dir string, envOverrides ...string) error { + cmd := exec.Command(os.Args[0], "-test.run="+testName) + env := append([]string{}, os.Environ()...) + env = append(env, runCommandHelperEnv+"=1", runCommandHelperMode+"="+mode) + if dir != "" { + env = append(env, runCommandHelperPath+"="+dir) + } + + if len(envOverrides) > 0 { + for _, override := range envOverrides { + if strings.HasSuffix(override, "=") { + env = filterOutEnv(env, override) + continue + } + env = append(env, override) + } + } + + cmd.Env = env + return cmd.Run() } func writeFile(t *testing.T, path string, content string) {