Fix NPX issue with TailwindCSS v4

This allows the `tailwindcss` CLI binary to live in the `PATH` for NPM-less projects.

Fixes #13221
This commit is contained in:
Bjørn Erik Pedersen 2025-01-07 09:13:16 +01:00
parent f024a5050e
commit cfa0801815
9 changed files with 94 additions and 25 deletions

View file

@ -26,7 +26,10 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/bep/logg"
"github.com/cli/safeexec" "github.com/cli/safeexec"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/config/security"
) )
@ -86,7 +89,7 @@ var WithEnviron = func(env []string) func(c *commandeer) {
} }
// New creates a new Exec using the provided security config. // New creates a new Exec using the provided security config.
func New(cfg security.Config, workingDir string) *Exec { func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec {
var baseEnviron []string var baseEnviron []string
for _, v := range os.Environ() { for _, v := range os.Environ() {
k, _ := config.SplitEnvVar(v) k, _ := config.SplitEnvVar(v)
@ -98,7 +101,9 @@ func New(cfg security.Config, workingDir string) *Exec {
return &Exec{ return &Exec{
sc: cfg, sc: cfg,
workingDir: workingDir, workingDir: workingDir,
infol: log.InfoCommand("exec"),
baseEnviron: baseEnviron, baseEnviron: baseEnviron,
newNPXRunnerCache: maps.NewCache[string, func(arg ...any) (Runner, error)](),
} }
} }
@ -124,10 +129,12 @@ func SafeCommand(name string, arg ...string) (*exec.Cmd, error) {
type Exec struct { type Exec struct {
sc security.Config sc security.Config
workingDir string workingDir string
infol logg.LevelLogger
// os.Environ filtered by the Exec.OsEnviron whitelist filter. // os.Environ filtered by the Exec.OsEnviron whitelist filter.
baseEnviron []string baseEnviron []string
newNPXRunnerCache *maps.Cache[string, func(arg ...any) (Runner, error)]
npxInit sync.Once npxInit sync.Once
npxAvailable bool npxAvailable bool
} }
@ -155,25 +162,86 @@ func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner,
return cm.command(arg...) return cm.command(arg...)
} }
type binaryLocation int
func (b binaryLocation) String() string {
switch b {
case binaryLocationNodeModules:
return "node_modules/.bin"
case binaryLocationNpx:
return "npx"
case binaryLocationPath:
return "PATH"
}
return "unknown"
}
const (
binaryLocationNodeModules binaryLocation = iota + 1
binaryLocationNpx
binaryLocationPath
)
// Npx will in order: // Npx will in order:
// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory. // 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory.
// 2. If not found, and npx is available, run npx --no-install <name> <args>. // 2. If not found, and npx is available, run npx --no-install <name> <args>.
// 3. Fall back to the PATH. // 3. Fall back to the PATH.
// If name is "tailwindcss", we will try the PATH as the second option.
func (e *Exec) Npx(name string, arg ...any) (Runner, error) { func (e *Exec) Npx(name string, arg ...any) (Runner, error) {
// npx is slow, so first try the common case. if err := e.sc.CheckAllowedExec(name); err != nil {
return nil, err
}
newRunner, err := e.newNPXRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) {
type tryFunc func() func(...any) (Runner, error)
tryFuncs := map[binaryLocation]tryFunc{
binaryLocationNodeModules: func() func(...any) (Runner, error) {
nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name) nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
_, err := safeexec.LookPath(nodeBinFilename) _, err := safeexec.LookPath(nodeBinFilename)
if err == nil { if err != nil {
return e.new(name, nodeBinFilename, arg...) return nil
} }
return func(arg2 ...any) (Runner, error) {
return e.new(name, nodeBinFilename, arg2...)
}
},
binaryLocationNpx: func() func(...any) (Runner, error) {
e.checkNpx() e.checkNpx()
if e.npxAvailable { if !e.npxAvailable {
r, err := e.npx(name, arg...) return nil
if err == nil { }
return r, nil return func(arg2 ...any) (Runner, error) {
return e.npx(name, arg2...)
}
},
binaryLocationPath: func() func(...any) (Runner, error) {
if _, err := safeexec.LookPath(name); err != nil {
return nil
}
return func(arg2 ...any) (Runner, error) {
return e.New(name, arg2...)
}
},
}
locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath}
if name == "tailwindcss" {
// See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253
locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx}
}
for _, loc := range locations {
if f := tryFuncs[loc](); f != nil {
e.infol.Logf("resolve %q using %s", name, loc)
return f, nil
} }
} }
return e.New(name, arg...) return nil, &NotFoundError{name: name, method: fmt.Sprintf("in %s", locations[len(locations)-1])}
})
if err != nil {
return nil, err
}
return newRunner(arg...)
} }
const ( const (

View file

@ -470,7 +470,7 @@ func (l *configLoader) loadModules(configs *Configs, ignoreModuleDoesNotExist bo
ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s)) ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s))
} }
ex := hexec.New(conf.Security, workingDir) ex := hexec.New(conf.Security, workingDir, l.Logger)
hook := func(m *modules.ModulesConfig) error { hook := func(m *modules.ModulesConfig) error {
for _, tc := range m.AllModules { for _, tc := range m.AllModules {

2
deps/deps.go vendored
View file

@ -188,7 +188,7 @@ func (d *Deps) Init() error {
} }
if d.ExecHelper == nil { if d.ExecHelper == nil {
d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir()) d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir(), d.Log)
} }
if d.MemCache == nil { if d.MemCache == nil {

View file

@ -729,7 +729,7 @@ func (s *IntegrationTestBuilder) initBuilder() error {
sc := security.DefaultConfig sc := security.DefaultConfig
sc.Exec.Allow, err = security.NewWhitelist("npm") sc.Exec.Allow, err = security.NewWhitelist("npm")
s.Assert(err, qt.IsNil) s.Assert(err, qt.IsNil)
ex := hexec.New(sc, s.Cfg.WorkingDir) ex := hexec.New(sc, s.Cfg.WorkingDir, loggers.NewDefault())
command, err := ex.New("npm", "install") command, err := ex.New("npm", "install")
s.Assert(err, qt.IsNil) s.Assert(err, qt.IsNil)
s.Assert(command.Run(), qt.IsNil) s.Assert(command.Run(), qt.IsNil)

View file

@ -838,7 +838,7 @@ func (s *sitesBuilder) NpmInstall() hexec.Runner {
var err error var err error
sc.Exec.Allow, err = security.NewWhitelist("npm") sc.Exec.Allow, err = security.NewWhitelist("npm")
s.Assert(err, qt.IsNil) s.Assert(err, qt.IsNil)
ex := hexec.New(sc, s.workingDir) ex := hexec.New(sc, s.workingDir, loggers.NewDefault())
command, err := ex.New("npm", "install") command, err := ex.New("npm", "install")
s.Assert(err, qt.IsNil) s.Assert(err, qt.IsNil)
return command return command

View file

@ -313,7 +313,7 @@ allow = ['asciidoctor']
converter.ProviderConfig{ converter.ProviderConfig{
Logger: loggers.NewDefault(), Logger: loggers.NewDefault(),
Conf: conf, Conf: conf,
Exec: hexec.New(securityConfig, ""), Exec: hexec.New(securityConfig, "", loggers.NewDefault()),
}, },
) )
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)

View file

@ -34,7 +34,7 @@ func TestConvert(t *testing.T) {
var err error var err error
sc.Exec.Allow, err = security.NewWhitelist("pandoc") sc.Exec.Allow, err = security.NewWhitelist("pandoc")
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)
p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, ""), Logger: loggers.NewDefault()}) p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, "", loggers.NewDefault()), Logger: loggers.NewDefault()})
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)
conv, err := p.New(converter.DocumentContext{}) conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)

View file

@ -36,7 +36,7 @@ func TestConvert(t *testing.T) {
p, err := Provider.New( p, err := Provider.New(
converter.ProviderConfig{ converter.ProviderConfig{
Logger: loggers.NewDefault(), Logger: loggers.NewDefault(),
Exec: hexec.New(sc, ""), Exec: hexec.New(sc, "", loggers.NewDefault()),
}) })
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)
conv, err := p.New(converter.DocumentContext{}) conv, err := p.New(converter.DocumentContext{})

View file

@ -22,6 +22,7 @@ import (
"testing" "testing"
"github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/hexec"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/config/security"
"github.com/gohugoio/hugo/hugofs/glob" "github.com/gohugoio/hugo/hugofs/glob"
@ -61,7 +62,7 @@ github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/h
WorkingDir: workingDir, WorkingDir: workingDir,
ThemesDir: themesDir, ThemesDir: themesDir,
PublishDir: publishDir, PublishDir: publishDir,
Exec: hexec.New(security.DefaultConfig, ""), Exec: hexec.New(security.DefaultConfig, "", loggers.NewDefault()),
} }
withConfig(&ccfg) withConfig(&ccfg)