Files
pmg/internal/alias/alias.go
T
Sahil BansalandGitHub 083f82dd79 feat: Add pmg setup doctor command (#290)
* feat: add doctor check runner core types and logic

* feat: add doctor checks for config, binary, directory, and aliases

* feat: add doctor checks for sandbox and security features

* feat: add protection verification check using test malicious packages

* feat: add summarized package manager availability check

* feat: add pmg setup doctor command with compact output

* feat: add PATH shim verification to doctor command

* fix: improve doctor command UX and alias detection

- Capitalize all check messages for consistent output
- Dim passing checks, color warn/fail for visual clarity
- Silence empty Cobra error output on doctor failure
- Remove redundant pmg binary check (self-evident)
- Fix alias IsInstalled to skip commented-out source lines
- Improve protection failure message

* refactor: remove package manager availability check from doctor

* fix: handle os.RemoveAll error in doctor protection check

* refactor: use table layout for setup doctor, extract shared table renderer

Move renderTable, truncate, and visibleWidth helpers from cmd/sandbox
to internal/ui so both sandbox and setup doctor share them. Rewrite
setup doctor output to use the same table structure as sandbox doctor.
Fix VisibleWidth to count runes instead of bytes for correct alignment
with multi-byte UTF-8 characters.

* docs: add pmg setup doctor to README, remove manual verification step

* refactor: use constants for check names, rename and inline doctor helpers

Address PR review comments: extract check name constants, rename
CheckConfigFile to CheckFileExists and CheckDirectoryWritable to
CheckDirectoryExists for reusability, inline trivial wrappers
(CheckSandbox, CheckSecurityFeature, CheckProxyMode), and add
fix hints for all checks with correct config keys.

* refactor: inline simple doctor checks into command layer

* fix: skip protection check when aliases and shims are inactive

Protection checks now fail immediately when shell aliases and shims
are both inactive, instead of falsely passing by running through the
pmg binary directly. Also clean up summary messages to remove
redundant fix hints and truncated paths.
2026-05-26 12:21:39 +05:30

264 lines
6.6 KiB
Go

package alias
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
)
// AliasManager manages shell aliases for package managers.
type AliasManager struct {
config AliasConfig
rcFileManager RcFileManager
}
// AliasConfig holds configuration for alias management.
type AliasConfig struct {
RcFileName string
PackageManagers []string
Shells []Shell
}
// RcFileManager handles creation and removal of RC files.
type RcFileManager interface {
Create(aliases []string) (string, error)
Remove() error
GetRcPath() string
}
// DefaultRcFileManager implements RcFileManager for managing the RC file.
type defaultRcFileManager struct {
HomeDir string
RcFileName string
}
var _ RcFileManager = &defaultRcFileManager{}
// NewDefaultRcFileManager creates a new DefaultRcFileManager.
func NewDefaultRcFileManager(rcFileName string) (*defaultRcFileManager, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
return &defaultRcFileManager{
HomeDir: homeDir,
RcFileName: rcFileName,
}, nil
}
// Create creates the RC file with the given aliases.
func (m *defaultRcFileManager) Create(aliases []string) (string, error) {
rcPath := m.GetRcPath()
f, err := os.Create(rcPath)
if err != nil {
return "", err
}
defer func() {
if err := f.Close(); err != nil {
log.Warnf("failed to close rc file %s: %v", rcPath, err)
}
}()
for _, alias := range aliases {
if _, err := f.WriteString(alias); err != nil {
return "", fmt.Errorf("failed to write alias: %w", err)
}
}
return rcPath, nil
}
// Remove deletes the RC file.
func (m *defaultRcFileManager) Remove() error {
rcPath := m.GetRcPath()
if err := os.Remove(rcPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("could not delete %s: %w", rcPath, err)
}
return nil
}
// GetRcPath returns the full path to the RC file.
func (m *defaultRcFileManager) GetRcPath() string {
return filepath.Join(m.HomeDir, m.RcFileName)
}
// DefaultConfig returns the default configuration for alias management.
func DefaultConfig() AliasConfig {
var shells []Shell
fishShell, _ := NewFishShell()
zshShell, _ := NewZshShell()
bashShell, _ := NewBashShell()
shells = append(shells, fishShell, zshShell, bashShell)
return AliasConfig{
RcFileName: ".pmg.rc",
PackageManagers: []string{"npm", "pip", "pip3", "pnpm", "bun", "uv", "yarn", "poetry", "npx", "pnpx"},
Shells: shells,
}
}
// New creates a new AliasManager with the given configuration and RC file manager.
func New(config AliasConfig, rcFileManager RcFileManager) *AliasManager {
return &AliasManager{
config: config,
rcFileManager: rcFileManager,
}
}
// Install creates the RC file with aliases and sources it in shell configurations.
func (a *AliasManager) Install() error {
aliases := a.buildAliases()
_, err := a.rcFileManager.Create(aliases)
if err != nil {
return fmt.Errorf("failed to create alias file: %w", err)
}
err = a.sourceRcFile()
if err != nil {
return fmt.Errorf("failed to update shell configs: %w", err)
}
return nil
}
// Remove deletes the RC file and removes source lines from shell configurations.
func (a *AliasManager) Remove() error {
if err := a.rcFileManager.Remove(); err != nil {
log.Warnf("Warning: %v", err)
}
if err := a.removeSourceLinesFromShells(); err != nil {
return fmt.Errorf("failed to clean shell configs: %w", err)
}
return nil
}
// GetRcPath returns the path to the alias RC file managed by AliasManager.
func (a *AliasManager) GetRcPath() string {
return a.rcFileManager.GetRcPath()
}
// IsInstalled checks if the PMG aliases are sourced in any of the shell config files.
func (a *AliasManager) IsInstalled() (bool, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return false, err
}
for _, shell := range a.config.Shells {
for _, configPath := range shell.CandidateRcFiles(homeDir) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
log.Warnf("Warning: could not read %s (%s)", configPath, err)
continue
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, a.config.RcFileName) {
return true, nil
}
}
}
}
return false, nil
}
// buildAliases creates the alias strings for all configured package managers.
func (a *AliasManager) buildAliases() []string {
aliases := make([]string, 0, len(a.config.PackageManagers))
for _, pm := range a.config.PackageManagers {
aliases = append(aliases, fmt.Sprintf("alias %s='pmg %s'\n", pm, pm))
}
return aliases
}
// sourceRcFile adds source lines to all shell configuration files.
func (a *AliasManager) sourceRcFile() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
primary := PrimaryShellName()
for _, shell := range a.config.Shells {
files, err := shell.InstallRcFiles(homeDir, shell.Name() == primary)
if err != nil {
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
continue
}
for _, configPath := range files {
if err := a.addSourceLine(configPath, shell.Source(a.rcFileManager.GetRcPath())); err != nil {
log.Warnf("Warning: skipping %s (%s)", configPath, err)
}
}
}
return nil
}
// removeSourceLinesFromShells removes source lines from all shell configuration files.
func (a *AliasManager) removeSourceLinesFromShells() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
drop := func(line string) bool {
return strings.Contains(line, a.config.RcFileName) ||
strings.TrimSpace(line) == strings.TrimSpace(commentForRemovingShellSource)
}
for _, shell := range a.config.Shells {
for _, configPath := range shell.CandidateRcFiles(homeDir) {
if err := RewriteFileDroppingLines(configPath, drop); err != nil {
log.Warnf("Warning: failed to update %s: %s", configPath, err)
}
}
}
return nil
}
// addSourceLine adds a source line to the specified shell configuration file.
func (a *AliasManager) addSourceLine(configPath, sourceLine string) error {
// Read existing content - only proceed if file exists
data, err := os.ReadFile(configPath)
if err != nil {
return err // file doesn't exist or can't read, skip
}
if strings.Contains(string(data), a.config.RcFileName) {
return nil // already sourced, skip
}
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer func() {
if err := f.Close(); err != nil {
log.Warnf("failed to close config file %s: %v", configPath, err)
}
}()
_, err = fmt.Fprintf(f, "\n%s", sourceLine)
return err
}