Skip to content

Commit d363167

Browse files
authored
feat: add setup & remove cmd for better UX (#43)
* feat: add setup & remove cmd for better UX * refactor(alias): improve separation of concerns and fix shell sourcing * chore: remove extra/unused folder * refactor: introduce separate files for shells * fix: use temp files for safe shell config modification
1 parent 3f85282 commit d363167

File tree

7 files changed

+386
-0
lines changed

7 files changed

+386
-0
lines changed

cmd/setup/setup.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package setup
2+
3+
import (
4+
"github.com/safedep/pmg/internal/alias"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func NewSetupCommand() *cobra.Command {
9+
setupCmd := &cobra.Command{
10+
Use: "setup",
11+
Short: "Manage PMG shell aliases and integration",
12+
Long: "Setup and manage PMG shell aliases that allow you to use 'npm', 'pnpm', 'pip' commands through PMG's security wrapper.",
13+
RunE: func(cmd *cobra.Command, args []string) error {
14+
return cmd.Help()
15+
},
16+
}
17+
18+
setupCmd.AddCommand(NewInstallCommand())
19+
setupCmd.AddCommand(NewRemoveCommand())
20+
21+
return setupCmd
22+
}
23+
24+
func NewInstallCommand() *cobra.Command {
25+
return &cobra.Command{
26+
Use: "install",
27+
Short: "Install PMG aliases for package managers (npm, pnpm, pip)",
28+
Long: "Creates ~/.pmg.rc with package manager aliases and sources it in your shell config files (.bashrc, .zshrc, config.fish)",
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
config := alias.DefaultConfig()
31+
rcFileManager, err := alias.NewDefaultRcFileManager(config.RcFileName)
32+
if err != nil {
33+
return err
34+
}
35+
aliasManager := alias.New(*config, rcFileManager)
36+
return aliasManager.Install()
37+
},
38+
}
39+
}
40+
41+
func NewRemoveCommand() *cobra.Command {
42+
return &cobra.Command{
43+
Use: "remove",
44+
Short: "Removes pmg aliases from the user's shell config file.",
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
config := alias.DefaultConfig()
47+
rcFileManager, err := alias.NewDefaultRcFileManager(config.RcFileName)
48+
if err != nil {
49+
return err
50+
}
51+
aliasManager := alias.New(*config, rcFileManager)
52+
return aliasManager.Remove()
53+
},
54+
}
55+
}

internal/alias/alias.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package alias
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/safedep/dry/log"
12+
)
13+
14+
// AliasManager manages shell aliases for package managers.
15+
type AliasManager struct {
16+
config AliasConfig
17+
rcFileManager RcFileManager
18+
}
19+
20+
// AliasConfig holds configuration for alias management.
21+
type AliasConfig struct {
22+
RcFileName string
23+
PackageManagers []string
24+
Shells []Shell
25+
}
26+
27+
// RcFileManager handles creation and removal of RC files.
28+
type RcFileManager interface {
29+
Create(aliases []string) (string, error)
30+
Remove() error
31+
GetRcPath() string
32+
}
33+
34+
// DefaultRcFileManager implements RcFileManager for managing the RC file.
35+
type DefaultRcFileManager struct {
36+
HomeDir string
37+
RcFileName string
38+
}
39+
40+
// NewDefaultRcFileManager creates a new DefaultRcFileManager.
41+
func NewDefaultRcFileManager(rcFileName string) (*DefaultRcFileManager, error) {
42+
homeDir, err := os.UserHomeDir()
43+
if err != nil {
44+
return nil, err
45+
}
46+
return &DefaultRcFileManager{
47+
HomeDir: homeDir,
48+
RcFileName: rcFileName,
49+
}, nil
50+
}
51+
52+
// Create creates the RC file with the given aliases.
53+
func (m *DefaultRcFileManager) Create(aliases []string) (string, error) {
54+
rcPath := m.GetRcPath()
55+
f, err := os.Create(rcPath)
56+
if err != nil {
57+
return "", err
58+
}
59+
defer f.Close()
60+
61+
for _, alias := range aliases {
62+
if _, err := f.WriteString(alias); err != nil {
63+
return "", fmt.Errorf("failed to write alias: %w", err)
64+
}
65+
}
66+
return rcPath, nil
67+
}
68+
69+
// Remove deletes the RC file.
70+
func (m *DefaultRcFileManager) Remove() error {
71+
rcPath := m.GetRcPath()
72+
if err := os.Remove(rcPath); err != nil && !os.IsNotExist(err) {
73+
return fmt.Errorf("could not delete %s: %w", rcPath, err)
74+
}
75+
return nil
76+
}
77+
78+
// GetRcPath returns the full path to the RC file.
79+
func (m *DefaultRcFileManager) GetRcPath() string {
80+
return filepath.Join(m.HomeDir, m.RcFileName)
81+
}
82+
83+
// DefaultConfig returns the default configuration for alias management.
84+
func DefaultConfig() *AliasConfig {
85+
var shells []Shell
86+
87+
fishShell, _ := NewFishShell()
88+
zshShell, _ := NewZshShell()
89+
bashShell, _ := NewBashShell()
90+
91+
shells = append(shells, fishShell, zshShell, bashShell)
92+
93+
return &AliasConfig{
94+
RcFileName: ".pmg.rc",
95+
PackageManagers: []string{"npm", "pip", "pnpm"},
96+
Shells: shells,
97+
}
98+
}
99+
100+
// New creates a new AliasManager with the given configuration and RC file manager.
101+
func New(config AliasConfig, rcFileManager RcFileManager) *AliasManager {
102+
return &AliasManager{
103+
config: config,
104+
rcFileManager: rcFileManager,
105+
}
106+
}
107+
108+
// Install creates the RC file with aliases and sources it in shell configurations.
109+
func (a *AliasManager) Install() error {
110+
aliases := a.buildAliases()
111+
rcPath, err := a.rcFileManager.Create(aliases)
112+
if err != nil {
113+
return fmt.Errorf("failed to create alias file: %w", err)
114+
}
115+
116+
err = a.sourceRcFile()
117+
if err != nil {
118+
return fmt.Errorf("failed to update shell configs: %w", err)
119+
}
120+
121+
fmt.Println("✅ PMG aliases installed successfully!")
122+
fmt.Printf("📁 Created: %s\n", rcPath)
123+
fmt.Println("💡 Restart your terminal or source your shell to use the new aliases")
124+
125+
return nil
126+
}
127+
128+
// Remove deletes the RC file and removes source lines from shell configurations.
129+
func (a *AliasManager) Remove() error {
130+
if err := a.rcFileManager.Remove(); err != nil {
131+
log.Warnf("Warning: %v", err)
132+
}
133+
134+
if err := a.removeSourceLinesFromShells(); err != nil {
135+
return fmt.Errorf("failed to clean shell configs: %w", err)
136+
}
137+
138+
fmt.Println("✅ PMG aliases and shell config changes removed.")
139+
return nil
140+
}
141+
142+
// buildAliases creates the alias strings for all configured package managers.
143+
func (a *AliasManager) buildAliases() []string {
144+
aliases := make([]string, 0, len(a.config.PackageManagers))
145+
for _, pm := range a.config.PackageManagers {
146+
aliases = append(aliases, fmt.Sprintf("alias %s='pmg %s'\n", pm, pm))
147+
}
148+
return aliases
149+
}
150+
151+
// sourceRcFile adds source lines to all shell configuration files.
152+
func (a *AliasManager) sourceRcFile() error {
153+
homeDir, err := os.UserHomeDir()
154+
if err != nil {
155+
return err
156+
}
157+
158+
for _, shell := range a.config.Shells {
159+
configPath := filepath.Join(homeDir, shell.Path())
160+
if err := a.addSourceLine(configPath, shell.Source(a.rcFileManager.GetRcPath())); err != nil {
161+
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
162+
}
163+
}
164+
165+
return nil
166+
}
167+
168+
// removeSourceLinesFromShells removes source lines from all shell configuration files.
169+
func (a *AliasManager) removeSourceLinesFromShells() error {
170+
homeDir, err := os.UserHomeDir()
171+
if err != nil {
172+
return err
173+
}
174+
175+
for _, shell := range a.config.Shells {
176+
configPath := filepath.Join(homeDir, shell.Path())
177+
178+
data, err := os.ReadFile(configPath)
179+
if err != nil {
180+
if os.IsNotExist(err) {
181+
continue
182+
}
183+
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
184+
continue
185+
}
186+
187+
// Get original file permissions
188+
info, err := os.Stat(configPath)
189+
if err != nil {
190+
continue
191+
}
192+
193+
// Create temp file
194+
tempFile, err := os.CreateTemp(filepath.Dir(configPath), ".tmp-"+filepath.Base(configPath))
195+
if err != nil {
196+
continue
197+
}
198+
tempPath := tempFile.Name()
199+
200+
// Write filtered content
201+
scanner := bufio.NewScanner(bytes.NewReader(data))
202+
writer := bufio.NewWriter(tempFile)
203+
204+
for scanner.Scan() {
205+
line := scanner.Text()
206+
207+
// Skip source lines and comment
208+
if strings.Contains(line, a.config.RcFileName) ||
209+
strings.TrimSpace(line) == strings.TrimSpace(commentForRemovingShellSource) {
210+
continue
211+
}
212+
213+
writer.WriteString(line + "\n")
214+
}
215+
216+
writer.Flush()
217+
tempFile.Close()
218+
219+
// Replace original file
220+
os.Chmod(tempPath, info.Mode())
221+
if err := os.Rename(tempPath, configPath); err != nil {
222+
os.Remove(tempPath) // cleanup on failure
223+
log.Warnf("Warning: failed to update %s: %s", configPath, err)
224+
}
225+
}
226+
227+
return nil
228+
}
229+
230+
// addSourceLine adds a source line to the specified shell configuration file.
231+
func (a *AliasManager) addSourceLine(configPath, sourceLine string) error {
232+
// Read existing content - only proceed if file exists
233+
data, err := os.ReadFile(configPath)
234+
if err != nil {
235+
return err // file doesn't exist or can't read, skip
236+
}
237+
238+
if strings.Contains(string(data), a.config.RcFileName) {
239+
return nil // already sourced, skip
240+
}
241+
242+
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0644)
243+
if err != nil {
244+
return err
245+
}
246+
defer f.Close()
247+
248+
_, err = f.WriteString(fmt.Sprintf("\n%s", sourceLine))
249+
return err
250+
}

internal/alias/bash.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package alias
2+
3+
type bashShell struct{}
4+
5+
var _ Shell = &bashShell{}
6+
7+
func NewBashShell() (*bashShell, error) {
8+
return &bashShell{}, nil
9+
}
10+
11+
func (b bashShell) Source(rcPath string) string {
12+
return defaultShellSource(rcPath)
13+
}
14+
15+
func (b bashShell) Name() string {
16+
return "bash"
17+
}
18+
19+
func (b bashShell) Path() string {
20+
return ".bashrc"
21+
}

internal/alias/fish.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package alias
2+
3+
type fishShell struct{}
4+
5+
var _ Shell = &fishShell{}
6+
7+
func NewFishShell() (*fishShell, error) {
8+
return &fishShell{}, nil
9+
}
10+
11+
func (f fishShell) Source(rcPath string) string {
12+
return defaultShellSource(rcPath)
13+
}
14+
15+
func (f fishShell) Name() string {
16+
return "fish"
17+
}
18+
19+
func (f fishShell) Path() string {
20+
return ".config/fish/config.fish"
21+
}

internal/alias/shells.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package alias
2+
3+
import "fmt"
4+
5+
type Shell interface {
6+
Source(rcPath string) string
7+
Name() string
8+
Path() string
9+
}
10+
11+
var commentForRemovingShellSource = "# remove aliases by running `pmg setup remove` or deleting the line"
12+
13+
func defaultShellSource(rcPath string) string {
14+
return fmt.Sprintf("%s \n[ -f %s ] && source %s # PMG source aliases\n", commentForRemovingShellSource, rcPath, rcPath)
15+
}

internal/alias/zsh.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package alias
2+
3+
type zshShell struct{}
4+
5+
var _ Shell = &zshShell{}
6+
7+
func NewZshShell() (*zshShell, error) {
8+
return &zshShell{}, nil
9+
}
10+
11+
func (z zshShell) Source(rcPath string) string {
12+
return defaultShellSource(rcPath)
13+
}
14+
15+
func (z zshShell) Name() string {
16+
return "zsh"
17+
}
18+
19+
func (z zshShell) Path() string {
20+
return ".zshrc"
21+
}

0 commit comments

Comments
 (0)