mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: MacOS MDM based Deployment (#277)
* fix: MacOS MDM deployment script * fix: Handle shell alias for bash on macos * fix: Code review fixes * fix: Code review fixes * feat: Add support for global config file * feat: Add support for global config file * fix: Code review fixes * fix: Avoid blocking CLI for analytics flush
This commit is contained in:
+28
-76
@@ -1,8 +1,6 @@
|
||||
package alias
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -154,20 +152,20 @@ func (a *AliasManager) IsInstalled() (bool, error) {
|
||||
}
|
||||
|
||||
for _, shell := range a.config.Shells {
|
||||
configPath := filepath.Join(homeDir, shell.Path())
|
||||
for _, configPath := range shell.CandidateRcFiles(homeDir) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Warnf("Warning: could not read %s (%s)", configPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Warnf("Warning: could not read %s (%s)", shell.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(string(data), a.config.RcFileName) {
|
||||
return true, nil
|
||||
if strings.Contains(string(data), a.config.RcFileName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,10 +188,18 @@ func (a *AliasManager) sourceRcFile() error {
|
||||
return err
|
||||
}
|
||||
|
||||
primary := PrimaryShellName()
|
||||
for _, shell := range a.config.Shells {
|
||||
configPath := filepath.Join(homeDir, shell.Path())
|
||||
if err := a.addSourceLine(configPath, shell.Source(a.rcFileManager.GetRcPath())); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,70 +213,16 @@ func (a *AliasManager) removeSourceLinesFromShells() error {
|
||||
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 {
|
||||
configPath := filepath.Join(homeDir, shell.Path())
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
for _, configPath := range shell.CandidateRcFiles(homeDir) {
|
||||
if err := RewriteFileDroppingLines(configPath, drop); err != nil {
|
||||
log.Warnf("Warning: failed to update %s: %s", configPath, err)
|
||||
}
|
||||
|
||||
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get original file permissions
|
||||
info, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
tempFile, err := os.CreateTemp(filepath.Dir(configPath), ".tmp-"+filepath.Base(configPath))
|
||||
if err != nil {
|
||||
log.Warnf("Warning: failed to create temporary file for %s: %s", configPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
// Write filtered content
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
writer := bufio.NewWriter(tempFile)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip source lines and comment
|
||||
if strings.Contains(line, a.config.RcFileName) ||
|
||||
strings.TrimSpace(line) == strings.TrimSpace(commentForRemovingShellSource) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := writer.WriteString(line + "\n"); err != nil {
|
||||
log.Warnf("Warning: failed to write to temporary file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Flush(); err != nil {
|
||||
log.Warnf("Warning: failed to flush temporary file: %s", err)
|
||||
}
|
||||
|
||||
if err := tempFile.Close(); err != nil {
|
||||
log.Warnf("Warning: failed to close temporary file: %s", err)
|
||||
}
|
||||
|
||||
// Set permissions on temporary file to match original file.
|
||||
if err := os.Chmod(tempPath, info.Mode()); err != nil {
|
||||
log.Warnf("Warning: failed to set permissions on temporary file for %s: %s", configPath, err)
|
||||
}
|
||||
|
||||
// Replace original file
|
||||
if err := os.Rename(tempPath, configPath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
log.Warnf("Warning: failed to update %s: %s", configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package alias
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestAliasManager(t *testing.T) *AliasManager {
|
||||
t.Helper()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
rcm, err := NewDefaultRcFileManager(cfg.RcFileName)
|
||||
require.NoError(t, err)
|
||||
|
||||
return New(cfg, rcm)
|
||||
}
|
||||
|
||||
func TestAliasInstallCreatesPrimaryShellRcFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
|
||||
require.NoError(t, newTestAliasManager(t).Install())
|
||||
|
||||
rc := filepath.Join(home, ".pmg.rc")
|
||||
assert.FileExists(t, rc)
|
||||
data, err := os.ReadFile(rc)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "alias npm='pmg npm'")
|
||||
|
||||
zshrc := filepath.Join(home, ".zshrc")
|
||||
assert.FileExists(t, zshrc)
|
||||
zdata, err := os.ReadFile(zshrc)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(zdata), ".pmg.rc")
|
||||
|
||||
// Shells the user does not use are left untouched.
|
||||
assert.NoFileExists(t, filepath.Join(home, ".bashrc"))
|
||||
assert.NoFileExists(t, filepath.Join(home, ".config", "fish", "config.fish"))
|
||||
}
|
||||
|
||||
func TestAliasInstallWiresExistingNonPrimaryShell(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(home, ".bashrc"), []byte("# bashrc\n"), 0o644))
|
||||
|
||||
require.NoError(t, newTestAliasManager(t).Install())
|
||||
|
||||
bashrc, err := os.ReadFile(filepath.Join(home, ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(bashrc), ".pmg.rc")
|
||||
|
||||
zshrc, err := os.ReadFile(filepath.Join(home, ".zshrc"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(zshrc), ".pmg.rc")
|
||||
}
|
||||
|
||||
func TestAliasInstallIdempotent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
|
||||
mgr := newTestAliasManager(t)
|
||||
require.NoError(t, mgr.Install())
|
||||
require.NoError(t, mgr.Install())
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(home, ".zshrc"))
|
||||
require.NoError(t, err)
|
||||
|
||||
count := 0
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.Contains(line, ".pmg.rc") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count, "source line should appear exactly once")
|
||||
}
|
||||
|
||||
func TestAliasRemove(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
|
||||
mgr := newTestAliasManager(t)
|
||||
require.NoError(t, mgr.Install())
|
||||
require.NoError(t, mgr.Remove())
|
||||
|
||||
assert.NoFileExists(t, filepath.Join(home, ".pmg.rc"))
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(home, ".zshrc"))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(data), ".pmg.rc")
|
||||
}
|
||||
+105
-2
@@ -1,9 +1,20 @@
|
||||
package alias
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type bashShell struct{}
|
||||
|
||||
var _ Shell = &bashShell{}
|
||||
|
||||
// bashLoginFiles are the login startup files in the order bash reads them.
|
||||
var bashLoginFiles = []string{".bash_profile", ".bash_login", ".profile"}
|
||||
|
||||
func NewBashShell() (*bashShell, error) {
|
||||
return &bashShell{}, nil
|
||||
}
|
||||
@@ -20,6 +31,98 @@ func (b bashShell) Name() string {
|
||||
return "bash"
|
||||
}
|
||||
|
||||
func (b bashShell) Path() string {
|
||||
return ".bashrc"
|
||||
func (b bashShell) CandidateRcFiles(homeDir string) []string {
|
||||
files := []string{filepath.Join(homeDir, ".bashrc")}
|
||||
for _, name := range bashLoginFiles {
|
||||
files = append(files, filepath.Join(homeDir, name))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func (b bashShell) InstallRcFiles(homeDir string, create bool) ([]string, error) {
|
||||
return bashInstallRcFiles(homeDir, create, runtime.GOOS)
|
||||
}
|
||||
|
||||
// bashInstallRcFiles resolves where bash should load PMG. bash reads .bashrc for
|
||||
// interactive non-login shells and a login file (.bash_profile, .bash_login, or
|
||||
// .profile) for login shells such as macOS Terminal, so the lines may need to
|
||||
// land in both. goos is a parameter for testability.
|
||||
func bashInstallRcFiles(homeDir string, create bool, goos string) ([]string, error) {
|
||||
bashrc := filepath.Join(homeDir, ".bashrc")
|
||||
bashrcExists := fileExists(bashrc)
|
||||
login := firstExistingFile(homeDir, bashLoginFiles)
|
||||
|
||||
var files []string
|
||||
if bashrcExists {
|
||||
files = append(files, bashrc)
|
||||
}
|
||||
|
||||
// macOS Terminal starts bash as a login shell, which does not read .bashrc.
|
||||
// Create .bash_profile so the lines reach login shells too.
|
||||
if login == "" && create && goos == "darwin" {
|
||||
login = filepath.Join(homeDir, ".bash_profile")
|
||||
if err := ensureFile(login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the login file when it already sources .bashrc, to avoid loading the
|
||||
// lines twice.
|
||||
if login != "" && (!bashrcExists || !referencesBashrc(login)) {
|
||||
files = append(files, login)
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
if !create {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Nothing exists yet: create the canonical file for this OS.
|
||||
target := bashrc
|
||||
if goos == "darwin" {
|
||||
target = filepath.Join(homeDir, ".bash_profile")
|
||||
}
|
||||
|
||||
if err := ensureFile(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []string{target}, nil
|
||||
}
|
||||
|
||||
// bashrcSourceRe matches a real sourcing of a bashrc file: a `source` or `.`
|
||||
// command whose argument ends in `.bashrc` (for example `source ~/.bashrc` or
|
||||
// `. "$HOME/.bashrc"`). Requiring the command keyword avoids matching mere
|
||||
// mentions in comments or unrelated commands.
|
||||
var bashrcSourceRe = regexp.MustCompile(`(^|[\s;&|()])(source|\.)\s+\S*\.bashrc($|[\s;'"&|)])`)
|
||||
|
||||
// referencesBashrc reports whether the file at path actually sources a bashrc
|
||||
// file. It skips comment lines and inline comments so a commented mention does
|
||||
// not wrongly suppress wiring the login file.
|
||||
func referencesBashrc(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if i := strings.Index(line, "#"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
|
||||
if bashrcSourceRe.MatchString(line) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+14
-3
@@ -1,6 +1,9 @@
|
||||
package alias
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type fishShell struct{}
|
||||
|
||||
@@ -22,6 +25,14 @@ func (f fishShell) Name() string {
|
||||
return "fish"
|
||||
}
|
||||
|
||||
func (f fishShell) Path() string {
|
||||
return ".config/fish/config.fish"
|
||||
func (f fishShell) configPath(homeDir string) string {
|
||||
return filepath.Join(homeDir, ".config", "fish", "config.fish")
|
||||
}
|
||||
|
||||
func (f fishShell) CandidateRcFiles(homeDir string) []string {
|
||||
return []string{f.configPath(homeDir)}
|
||||
}
|
||||
|
||||
func (f fishShell) InstallRcFiles(homeDir string, create bool) ([]string, error) {
|
||||
return singleRcFile(f.configPath(homeDir), create)
|
||||
}
|
||||
|
||||
+155
-1
@@ -3,6 +3,8 @@ package alias
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -10,7 +12,16 @@ type Shell interface {
|
||||
Source(rcPath string) string
|
||||
PathExport(binDir string) string
|
||||
Name() string
|
||||
Path() string
|
||||
|
||||
// InstallRcFiles returns the rc files PMG should write its source/PATH lines
|
||||
// into. Existing files are always included. When create is true (the user's
|
||||
// primary shell) and no rc file exists, it creates the canonical one so the
|
||||
// lines have somewhere to live.
|
||||
InstallRcFiles(homeDir string, create bool) ([]string, error)
|
||||
|
||||
// CandidateRcFiles returns every rc file this shell might use, for removal
|
||||
// and install detection. The files need not exist.
|
||||
CandidateRcFiles(homeDir string) []string
|
||||
}
|
||||
|
||||
var commentForRemovingShellSource = "# remove aliases by running `pmg setup remove` or deleting the line"
|
||||
@@ -36,3 +47,146 @@ func DetectShell() (string, error) {
|
||||
|
||||
return shellName, nil
|
||||
}
|
||||
|
||||
// PrimaryShellName returns the user's main shell. It reads $SHELL and falls back
|
||||
// to the OS default (zsh on macOS, bash elsewhere) when $SHELL is unset. The
|
||||
// result decides which shell gets its rc file created when none exists yet.
|
||||
func PrimaryShellName() string {
|
||||
if name, err := DetectShell(); err == nil && name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "zsh"
|
||||
}
|
||||
|
||||
return "bash"
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// ensureFile creates an empty file, and any missing parent directories, when it
|
||||
// does not already exist.
|
||||
func ensureFile(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory for %s: %w", path, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create %s: %w", path, err)
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// firstExistingFile returns the first of names (joined with homeDir) that exists,
|
||||
// or "" when none do.
|
||||
func firstExistingFile(homeDir string, names []string) string {
|
||||
for _, name := range names {
|
||||
path := filepath.Join(homeDir, name)
|
||||
if fileExists(path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// singleRcFile resolves shells that use one rc file (zsh, fish): return it when
|
||||
// present, create it when create is set, otherwise skip.
|
||||
func singleRcFile(path string, create bool) ([]string, error) {
|
||||
if fileExists(path) {
|
||||
return []string{path}, nil
|
||||
}
|
||||
|
||||
if !create {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := ensureFile(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []string{path}, nil
|
||||
}
|
||||
|
||||
// RewriteFileDroppingLines rewrites path, removing every line for which drop
|
||||
// returns true (the line is passed without its trailing newline). It preserves
|
||||
// the rest of the file byte for byte and its permissions, replaces the file
|
||||
// atomically via a temp file, and skips the write when no line is dropped. A
|
||||
// missing file is a no-op. It reads the file in one shot rather than scanning,
|
||||
// so it has no per-line length limit.
|
||||
func RewriteFileDroppingLines(path string, drop func(line string) bool) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(data))
|
||||
|
||||
dropped := false
|
||||
for _, line := range strings.SplitAfter(string(data), "\n") {
|
||||
// SplitAfter keeps the newline on each line; the final element is the
|
||||
// empty remainder after the last newline.
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if drop(strings.TrimRight(line, "\r\n")) {
|
||||
dropped = true
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
|
||||
if !dropped {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeFileAtomic(path, []byte(b.String()), info.Mode())
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to a temp file in the target directory, then
|
||||
// renames it over path so a crash never leaves a half-written file.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
tempFile, err := os.CreateTemp(filepath.Dir(path), ".tmp-"+filepath.Base(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
if _, err := tempFile.Write(data); err != nil {
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Chmod(tempPath, perm); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package alias
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShellPathExport(t *testing.T) {
|
||||
@@ -53,6 +59,207 @@ func TestShellPathExport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryShellName(t *testing.T) {
|
||||
t.Run("from SHELL", func(t *testing.T) {
|
||||
t.Setenv("SHELL", "/usr/bin/fish")
|
||||
assert.Equal(t, "fish", PrimaryShellName())
|
||||
})
|
||||
|
||||
t.Run("falls back to OS default when unset", func(t *testing.T) {
|
||||
t.Setenv("SHELL", "")
|
||||
want := "bash"
|
||||
if runtime.GOOS == "darwin" {
|
||||
want = "zsh"
|
||||
}
|
||||
assert.Equal(t, want, PrimaryShellName())
|
||||
})
|
||||
}
|
||||
|
||||
func TestBashInstallRcFiles(t *testing.T) {
|
||||
const (
|
||||
bashrc = ".bashrc"
|
||||
bashProfile = ".bash_profile"
|
||||
profile = ".profile"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
create bool
|
||||
existing map[string]string
|
||||
wantRel []string
|
||||
wantMade []string
|
||||
}{
|
||||
{
|
||||
name: "darwin bashrc only, primary also creates bash_profile",
|
||||
goos: "darwin",
|
||||
create: true,
|
||||
existing: map[string]string{bashrc: "# bashrc\n"},
|
||||
wantRel: []string{bashrc, bashProfile},
|
||||
wantMade: []string{bashProfile},
|
||||
},
|
||||
{
|
||||
name: "darwin login already sources bashrc is skipped",
|
||||
goos: "darwin",
|
||||
create: true,
|
||||
existing: map[string]string{bashrc: "# bashrc\n", bashProfile: "source ~/.bashrc\n"},
|
||||
wantRel: []string{bashrc},
|
||||
},
|
||||
{
|
||||
name: "darwin login not sourcing bashrc gets both",
|
||||
goos: "darwin",
|
||||
create: true,
|
||||
existing: map[string]string{bashrc: "# bashrc\n", bashProfile: "# profile\n"},
|
||||
wantRel: []string{bashrc, bashProfile},
|
||||
},
|
||||
{
|
||||
name: "darwin login only mentions bashrc in a comment gets both",
|
||||
goos: "darwin",
|
||||
create: true,
|
||||
existing: map[string]string{bashrc: "# bashrc\n", bashProfile: "# see ~/.bashrc\n"},
|
||||
wantRel: []string{bashrc, bashProfile},
|
||||
},
|
||||
{
|
||||
name: "darwin nothing exists, primary creates bash_profile",
|
||||
goos: "darwin",
|
||||
create: true,
|
||||
existing: map[string]string{},
|
||||
wantRel: []string{bashProfile},
|
||||
wantMade: []string{bashProfile},
|
||||
},
|
||||
{
|
||||
name: "darwin nothing exists, non-primary creates nothing",
|
||||
goos: "darwin",
|
||||
create: false,
|
||||
existing: map[string]string{},
|
||||
wantRel: nil,
|
||||
},
|
||||
{
|
||||
name: "linux bashrc only does not create bash_profile",
|
||||
goos: "linux",
|
||||
create: true,
|
||||
existing: map[string]string{bashrc: "# bashrc\n"},
|
||||
wantRel: []string{bashrc},
|
||||
},
|
||||
{
|
||||
name: "linux nothing exists, primary creates bashrc",
|
||||
goos: "linux",
|
||||
create: true,
|
||||
existing: map[string]string{},
|
||||
wantRel: []string{bashrc},
|
||||
wantMade: []string{bashrc},
|
||||
},
|
||||
{
|
||||
name: "existing login file wired even when non-primary",
|
||||
goos: "linux",
|
||||
create: false,
|
||||
existing: map[string]string{profile: "# profile\n"},
|
||||
wantRel: []string{profile},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
for name, content := range tc.existing {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(home, name), []byte(content), 0o644))
|
||||
}
|
||||
|
||||
got, err := bashInstallRcFiles(home, tc.create, tc.goos)
|
||||
require.NoError(t, err)
|
||||
|
||||
want := make([]string, 0, len(tc.wantRel))
|
||||
for _, rel := range tc.wantRel {
|
||||
want = append(want, filepath.Join(home, rel))
|
||||
}
|
||||
assert.ElementsMatch(t, want, got)
|
||||
|
||||
for _, rel := range tc.wantMade {
|
||||
assert.FileExists(t, filepath.Join(home, rel))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencesBashrc(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{"source with tilde", "source ~/.bashrc\n", true},
|
||||
{"dot command", ". ~/.bashrc\n", true},
|
||||
{"quoted home var", "[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\"\n", true},
|
||||
{"conditional dot", "[ -f ~/.bashrc ] && . ~/.bashrc\n", true},
|
||||
{"commented mention", "# see ~/.bashrc for details\n", false},
|
||||
{"inline comment", "echo hi # ~/.bashrc\n", false},
|
||||
{"unrelated command", "cat ~/.bashrc\n", false},
|
||||
{"different file", "source ~/.bashrc-backup\n", false},
|
||||
{"no mention", "export PATH=/usr/bin\n", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "profile")
|
||||
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o644))
|
||||
assert.Equal(t, tc.want, referencesBashrc(path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteFileDroppingLines(t *testing.T) {
|
||||
t.Run("drops matching lines and keeps the rest", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rc")
|
||||
require.NoError(t, os.WriteFile(path, []byte("keep1\ndrop me\nkeep2\n"), 0o644))
|
||||
|
||||
err := RewriteFileDroppingLines(path, func(line string) bool {
|
||||
return strings.Contains(line, "drop")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "keep1\nkeep2\n", string(data))
|
||||
|
||||
info, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0o644), info.Mode().Perm())
|
||||
})
|
||||
|
||||
t.Run("preserves a line longer than the scanner token limit", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rc")
|
||||
longLine := strings.Repeat("x", bufio.MaxScanTokenSize+1024)
|
||||
require.NoError(t, os.WriteFile(path, []byte(longLine+"\nPMG drop\nafter\n"), 0o644))
|
||||
|
||||
err := RewriteFileDroppingLines(path, func(line string) bool {
|
||||
return strings.Contains(line, "PMG drop")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, longLine+"\nafter\n", string(data))
|
||||
})
|
||||
|
||||
t.Run("leaves the file untouched when nothing matches", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rc")
|
||||
original := "line1\nline2"
|
||||
require.NoError(t, os.WriteFile(path, []byte(original), 0o644))
|
||||
|
||||
err := RewriteFileDroppingLines(path, func(string) bool { return false })
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, string(data))
|
||||
})
|
||||
|
||||
t.Run("missing file is a no-op", func(t *testing.T) {
|
||||
err := RewriteFileDroppingLines(filepath.Join(t.TempDir(), "nope"), func(string) bool { return true })
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectShell(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package alias
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
type zshShell struct{}
|
||||
|
||||
var _ Shell = &zshShell{}
|
||||
@@ -20,6 +22,11 @@ func (z zshShell) Name() string {
|
||||
return "zsh"
|
||||
}
|
||||
|
||||
func (z zshShell) Path() string {
|
||||
return ".zshrc"
|
||||
// zsh reads .zshrc for every interactive shell, login or not, so one file covers it.
|
||||
func (z zshShell) CandidateRcFiles(homeDir string) []string {
|
||||
return []string{filepath.Join(homeDir, ".zshrc")}
|
||||
}
|
||||
|
||||
func (z zshShell) InstallRcFiles(homeDir string, create bool) ([]string, error) {
|
||||
return singleRcFile(filepath.Join(homeDir, ".zshrc"), create)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user