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:
Abhisek Datta
2026-05-21 14:32:30 +05:30
committed by GitHub
parent 875cda2e43
commit b15ce33fe4
24 changed files with 1396 additions and 374 deletions
+28 -76
View File
@@ -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)
}
}
+100
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+207
View File
@@ -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
+9 -2
View File
@@ -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)
}