diff --git a/pkg/commands/npm_manager.go b/pkg/commands/npm_manager.go index 4ed8239..d702097 100644 --- a/pkg/commands/npm_manager.go +++ b/pkg/commands/npm_manager.go @@ -43,10 +43,6 @@ func (m *NpmManager) IsLinked(name string, path string) (bool, error) { globalPath := filepath.Join(m.NpmRoot, name) fileInfo, err := os.Lstat(globalPath) if err != nil { - if err == os.ErrNotExist { - return false, nil - } - // swallowing error. For some reason we're getting 'no such file or directory' here despite checking for os.ErrNotExist return false, nil } @@ -56,7 +52,13 @@ func (m *NpmManager) IsLinked(name string, path string) (bool, error) { if err != nil { return false, err } - if linkedPath == path { + // The symlink target may be relative. Resolve it against the + // directory containing the symlink to get an absolute path. + if !filepath.IsAbs(linkedPath) { + linkedPath = filepath.Join(filepath.Dir(globalPath), linkedPath) + } + linkedPath = filepath.Clean(linkedPath) + if linkedPath == filepath.Clean(path) { return true, nil } } @@ -174,6 +176,55 @@ func (m *NpmManager) GetDeps(currentPkg *Package, previousDeps []*Dependency) ([ return deps, nil } +// GetLinkedPackagePaths scans node_modules for symlinked entries and returns +// a set of their resolved absolute target paths. This catches links that +// exist in node_modules but are not declared in package.json (e.g. created +// via `npm link `). +func (m *NpmManager) GetLinkedPackagePaths(currentPkg *Package) map[string]bool { + result := map[string]bool{} + nodeModulesPath := filepath.Join(currentPkg.Path, "node_modules") + + scanDir := func(dir string) { + entries, err := ioutil.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if entry.Mode()&os.ModeSymlink == 0 { + continue + } + fullPath := filepath.Join(dir, entry.Name()) + resolved, err := filepath.EvalSymlinks(fullPath) + if err != nil { + continue + } + result[resolved] = true + } + } + + // Scan top-level entries in node_modules + entries, err := ioutil.ReadDir(nodeModulesPath) + if err != nil { + return result + } + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, "@") { + // Scoped package: scan the scope directory for symlinked packages + scanDir(filepath.Join(nodeModulesPath, name)) + } else if entry.Mode()&os.ModeSymlink != 0 { + fullPath := filepath.Join(nodeModulesPath, name) + resolved, err := filepath.EvalSymlinks(fullPath) + if err != nil { + continue + } + result[resolved] = true + } + } + + return result +} + func (m *NpmManager) GetTarballs(currentPkg *Package) ([]*Tarball, error) { // would be nice if I had a guarantee on the directory I was checking but this should do paths, err := filepath.Glob("*.tgz") diff --git a/pkg/gui/dependencies_panel.go b/pkg/gui/dependencies_panel.go index d4616ef..0e092fd 100644 --- a/pkg/gui/dependencies_panel.go +++ b/pkg/gui/dependencies_panel.go @@ -41,9 +41,12 @@ func (gui *Gui) handleDepSelect(g *gocui.Gui, v *gocui.View) error { return nil } -// linkPathMap returns the set of link paths of the current package's dependencies +// linkPathMap returns the set of resolved symlink target paths from the current +// package's node_modules. This includes both deps declared in package.json and +// links created via `npm link ` that aren't in package.json. func (gui *Gui) linkPathMap() map[string]bool { - linkPathMap := map[string]bool{} + linkPathMap := gui.NpmManager.GetLinkedPackagePaths(gui.currentPackage()) + // Also include deps that we already know are linked (from package.json parsing) for _, dep := range gui.State.Deps { if dep.Linked() { linkPathMap[dep.LinkPath] = true diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index d7b424c..9f8181e 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -176,7 +176,7 @@ func GetKeyDisplay(key interface{}) string { keyInt = int(key) } - return string(keyInt) + return string(rune(keyInt)) } func (gui *Gui) getKey(name string) interface{} { diff --git a/pkg/gui/packages_panel.go b/pkg/gui/packages_panel.go index 6c6f372..201371f 100644 --- a/pkg/gui/packages_panel.go +++ b/pkg/gui/packages_panel.go @@ -131,18 +131,25 @@ func (gui *Gui) handleLinkPackage() error { return nil } - var cmdStr string if selectedPkg == gui.currentPackage() { return gui.surfaceError(errors.New("Cannot link a package to itself")) } + var cmdStr string if gui.linkPathMap()[selectedPkg.Path] { cmdStr = fmt.Sprintf("npm unlink --no-save %s", selectedPkg.Config.Name) } else { - if !selectedPkg.LinkedGlobally { - cmdStr = fmt.Sprintf("npm link %s", selectedPkg.Path) - } else { + if selectedPkg.LinkedGlobally { + // Already globally linked: just create a local symlink by name. + // This only writes to the local node_modules, no global write needed. cmdStr = fmt.Sprintf("npm link %s", selectedPkg.Config.Name) + } else { + return gui.surfaceError( + fmt.Errorf( + "%s is not globally linked. Select it and press 'L' to globally link it first", + selectedPkg.Config.Name, + ), + ) } } @@ -156,7 +163,9 @@ func (gui *Gui) handleGlobalLinkPackage(pkg *commands.Package) error { var cmdStr string if pkg.LinkedGlobally { - cmdStr = "npm unlink" + // In npm v7+, 'npm unlink' is an alias for 'npm uninstall' (local). + // To remove a global link, use 'npm rm -g '. + cmdStr = fmt.Sprintf("npm rm -g %s", pkg.Config.Name) } else { cmdStr = "npm link" } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 1575e4c..a41e63a 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package gui @@ -176,7 +177,8 @@ func (gui *Gui) newPtyTask(viewName string, commandView *commands.CommandView, c ptmx, err := pty.Start(commandView.Cmd) if err != nil { - // swallowing for now (actually continue to swallow this) + gui.Log.Errorf("pty.Start error for command '%s': %v", cmdStr, err) + fmt.Fprint(view, utils.ColoredString(fmt.Sprintf("Error starting command: %v", err), color.FgRed)) return } diff --git a/vendor/github.com/creack/pty/run.go b/vendor/github.com/creack/pty/run.go index 959be26..33928ec 100644 --- a/vendor/github.com/creack/pty/run.go +++ b/vendor/github.com/creack/pty/run.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package pty @@ -19,17 +20,20 @@ func Start(c *exec.Cmd) (pty *os.File, err error) { // and c.Stderr, calls c.Start, and returns the File of the tty's // corresponding pty. // -// This will resize the pty to the specified size before starting the command +// This will resize the pty to the specified size before starting the command. +// +// Updated for Go 1.20+ compatibility: Ctty must reference a valid FD in the +// child process. We pass the tty as an ExtraFiles entry so it gets FD 3 in +// the child, and set Ctty = 3. func StartWithSize(c *exec.Cmd, sz *Winsize) (pty *os.File, err error) { pty, tty, err := Open() if err != nil { return nil, err } - defer tty.Close() if sz != nil { - err = Setsize(pty, sz) - if err != nil { - pty.Close() + if err = Setsize(pty, sz); err != nil { + _ = pty.Close() + _ = tty.Close() return nil, err } } @@ -42,16 +46,26 @@ func StartWithSize(c *exec.Cmd, sz *Winsize) (pty *os.File, err error) { if c.Stdin == nil { c.Stdin = tty } + + // Pass the tty as an extra file so it has a known FD in the child. + // ExtraFiles FDs start at 3 (after stdin=0, stdout=1, stderr=2). + // The child FD = 3 + len(c.ExtraFiles) before appending. + cttyFd := 3 + len(c.ExtraFiles) + c.ExtraFiles = append(c.ExtraFiles, tty) + if c.SysProcAttr == nil { c.SysProcAttr = &syscall.SysProcAttr{} } c.SysProcAttr.Setctty = true c.SysProcAttr.Setsid = true - c.SysProcAttr.Ctty = int(tty.Fd()) + c.SysProcAttr.Ctty = cttyFd + err = c.Start() + // Close the tty in the parent after Start, regardless of success/failure. + _ = tty.Close() if err != nil { - pty.Close() + _ = pty.Close() return nil, err } - return pty, err + return pty, nil }