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
+4
View File
@@ -86,6 +86,10 @@ If the config file does not exist, a template is created first.`,
func runEdit() error { func runEdit() error {
cfg := appConfig.Get() cfg := appConfig.Get()
if cfg.IsManaged() {
return appConfig.NewManagedConfigError()
}
path := cfg.ConfigFilePath() path := cfg.ConfigFilePath()
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
+5
View File
@@ -43,6 +43,11 @@ func executeSetupInfo() error {
cfg := config.Get() cfg := config.Get()
configEntries := make(map[string]string) configEntries := make(map[string]string)
configEntries["Config File"] = cfg.ConfigFilePath() configEntries["Config File"] = cfg.ConfigFilePath()
configSource := "user"
if cfg.IsManaged() {
configSource = "global (managed)"
}
configEntries["Config Source"] = configSource
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled()) configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled())
configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.Proxy.InstallOnly) configEntries["Proxy Install Only"] = strconv.FormatBool(cfg.Config.Proxy.InstallOnly)
ui.PrintInfoSection("Configuration", configEntries) ui.PrintInfoSection("Configuration", configEntries)
+10 -7
View File
@@ -2,7 +2,6 @@ package setup
import ( import (
"fmt" "fmt"
"os"
"runtime" "runtime"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
@@ -13,9 +12,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var setupRemoveConfigFile = false
setupRemoveConfigFile = false
)
func NewSetupCommand() *cobra.Command { func NewSetupCommand() *cobra.Command {
setupCmd := &cobra.Command{ setupCmd := &cobra.Command{
@@ -51,6 +48,11 @@ func install() error {
return fmt.Errorf("failed to write template config: %w", err) return fmt.Errorf("failed to write template config: %w", err)
} }
if config.Get().IsManaged() {
fmt.Printf("%s %s\n", ui.Colors.Dim(""),
fmt.Sprintf("Using globally managed config: %s", config.Get().ConfigFilePath()))
}
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config written successfully") fmt.Printf("%s %s\n", ui.Colors.Green("✓"), "PMG config written successfully")
fmt.Printf(" %s\n", ui.Colors.Dim(fmt.Sprintf("Config: %s", config.Get().ConfigDir()))) fmt.Printf(" %s\n", ui.Colors.Dim(fmt.Sprintf("Config: %s", config.Get().ConfigDir())))
@@ -92,9 +94,10 @@ func NewRemoveCommand() *cobra.Command {
fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit)) fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit))
if setupRemoveConfigFile { if setupRemoveConfigFile {
config := config.Get() // Only ever remove the per-user file; the globally managed
if err := os.Remove(config.ConfigFilePath()); err != nil && !os.IsNotExist(err) { // config is not ours to delete from a per-user uninstall.
return fmt.Errorf("failed to remove config file %q: %w", config.ConfigFilePath(), err) if err := config.RemoveUserConfigFile(); err != nil {
return err
} }
} }
+121 -8
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -13,6 +14,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/dry/utils" "github.com/safedep/dry/utils"
"github.com/safedep/pmg/usefulerror"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -198,7 +200,8 @@ type RuntimeConfig struct {
// Internal config values computed at runtime and must be accessed via. API // Internal config values computed at runtime and must be accessed via. API
configDir string configDir string
configFilePath string configFilePath string // active config: globally managed file if present, else per-user
userConfigFilePath string // per-user config file, used for writes and removal
eventLogDir string eventLogDir string
sandboxProfileDir string sandboxProfileDir string
sandboxViolationCacheDir string sandboxViolationCacheDir string
@@ -222,11 +225,26 @@ func (r *RuntimeConfig) CloudSyncLastRunPath() string {
return filepath.Join(r.configDir, "cloud-sync.lastrun") return filepath.Join(r.configDir, "cloud-sync.lastrun")
} }
// ConfigFilePath returns the path to the config file. // ConfigFilePath returns the path to the active config file (the globally
// managed file when present, otherwise the per-user file).
func (r *RuntimeConfig) ConfigFilePath() string { func (r *RuntimeConfig) ConfigFilePath() string {
return r.configFilePath return r.configFilePath
} }
// UserConfigFilePath returns the per-user config file path, regardless of
// whether a globally managed config is active.
func (r *RuntimeConfig) UserConfigFilePath() string {
return r.userConfigFilePath
}
// IsManaged reports whether the active config is the globally managed file.
// When true, the per-user file is ignored and config writes are refused. It is
// derived: the active path differs from the per-user path only when the global
// file was chosen.
func (r *RuntimeConfig) IsManaged() bool {
return r.configFilePath != r.userConfigFilePath
}
// EventLogDir returns the path to the event log directory. // EventLogDir returns the path to the event log directory.
func (r *RuntimeConfig) EventLogDir() string { func (r *RuntimeConfig) EventLogDir() string {
return r.eventLogDir return r.eventLogDir
@@ -351,9 +369,14 @@ func initConfig() {
panic(fmt.Errorf("failed to get config directory: %w", err)) panic(fmt.Errorf("failed to get config directory: %w", err))
} }
configFilePath, err := configFilePath() activeConfigPath, err := resolveConfigFile()
if err != nil { if err != nil {
panic(fmt.Errorf("failed to get config file path: %w", err)) panic(fmt.Errorf("failed to resolve config file path: %w", err))
}
userConfigPath, err := userConfigFilePath()
if err != nil {
panic(fmt.Errorf("failed to get user config file path: %w", err))
} }
eventLogDir, err := eventLogDir() eventLogDir, err := eventLogDir()
@@ -372,7 +395,8 @@ func initConfig() {
} }
globalConfig.configDir = configDir globalConfig.configDir = configDir
globalConfig.configFilePath = configFilePath globalConfig.configFilePath = activeConfigPath
globalConfig.userConfigFilePath = userConfigPath
globalConfig.eventLogDir = eventLogDir globalConfig.eventLogDir = eventLogDir
globalConfig.sandboxProfileDir = sandboxProfileDir globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
@@ -409,8 +433,8 @@ func configDir() (string, error) {
return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath), nil return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath), nil
} }
// configFilePath computes the path to the config file. // userConfigFilePath computes the path to the per-user config file.
func configFilePath() (string, error) { func userConfigFilePath() (string, error) {
configDir, err := configDir() configDir, err := configDir()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err) return "", fmt.Errorf("failed to get config directory: %w", err)
@@ -419,6 +443,61 @@ func configFilePath() (string, error) {
return filepath.Join(configDir, pmgConfigFileName), nil return filepath.Join(configDir, pmgConfigFileName), nil
} }
// globalConfigDirOverride replaces the OS-level managed config directory. It
// exists only for tests within this package. There is intentionally no env var
// or flag for it, so a user cannot point the "managed" config at their own file
// and bypass the globally managed config.
var globalConfigDirOverride string
// globalConfigDir returns the OS-level directory for a globally managed config
// file, or "" when the platform has no such location.
func globalConfigDir() string {
if globalConfigDirOverride != "" {
return globalConfigDirOverride
}
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/safedep/pmg"
case "linux":
return "/etc/safedep/pmg"
case "windows":
programData := os.Getenv("PROGRAMDATA")
if programData == "" {
programData = `C:\ProgramData`
}
return filepath.Join(programData, "safedep", "pmg")
}
return ""
}
// globalConfigFilePath returns the path to the globally managed config file, or
// "" when the platform has no global config location.
func globalConfigFilePath() string {
dir := globalConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, pmgConfigFileName)
}
// resolveConfigFile picks the active config file. The globally managed file,
// when present, is authoritative and the per-user file is ignored entirely.
func resolveConfigFile() (string, error) {
if global := globalConfigFilePath(); global != "" && isRegularFile(global) {
return global, nil
}
return userConfigFilePath()
}
func isRegularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
// eventLogDir computes the path to the event log directory. // eventLogDir computes the path to the event log directory.
func eventLogDir() (string, error) { func eventLogDir() (string, error) {
// For rationale on why different directory for Windows, see: // For rationale on why different directory for Windows, see:
@@ -515,7 +594,14 @@ func ConfigureSandbox(mayDownloadPackages bool) {
// If the config file does not exist, the full template is written. // If the config file does not exist, the full template is written.
// If it already exists, missing keys from the template are merged // If it already exists, missing keys from the template are merged
// into the existing config while preserving all user values and comments. // into the existing config while preserving all user values and comments.
//
// When a globally managed config is active, this is a no-op: the per-user
// file is ignored at load time, so creating it would only mislead.
func WriteTemplateConfig() error { func WriteTemplateConfig() error {
if globalConfig.IsManaged() {
return nil
}
configDir, err := configDir() configDir, err := configDir()
if err != nil { if err != nil {
return fmt.Errorf("failed to get config directory: %w", err) return fmt.Errorf("failed to get config directory: %w", err)
@@ -525,7 +611,7 @@ func WriteTemplateConfig() error {
return fmt.Errorf("failed to create config directory: %w", err) return fmt.Errorf("failed to create config directory: %w", err)
} }
configFilePath, err := configFilePath() configFilePath, err := userConfigFilePath()
if err != nil { if err != nil {
return fmt.Errorf("failed to get config file path: %w", err) return fmt.Errorf("failed to get config file path: %w", err)
} }
@@ -549,3 +635,30 @@ func WriteTemplateConfig() error {
return nil return nil
} }
// RemoveUserConfigFile deletes the per-user config file. It never touches the
// globally managed file. A missing file is not an error.
func RemoveUserConfigFile() error {
path, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove config file %q: %w", path, err)
}
return nil
}
// NewManagedConfigError returns the error shown when a user tries to change a
// globally managed configuration. It carries a useful error code and help text
// so the CLI presents it as an expected, actionable failure rather than a bug.
func NewManagedConfigError() error {
msg := fmt.Sprintf("configuration is globally managed (%s) and cannot be changed", globalConfig.configFilePath)
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePermissionDenied).
WithHumanError(msg).
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
Wrap(errors.New(msg))
}
+103
View File
@@ -0,0 +1,103 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// useManagedConfigDir points the globally managed config at dir for the test,
// and restores the default resolution afterwards.
func useManagedConfigDir(t *testing.T, dir string) {
t.Helper()
globalConfigDirOverride = dir
t.Cleanup(func() {
globalConfigDirOverride = ""
initConfig()
})
}
func TestManagedConfigTakesPrecedenceAndIgnoresUserFile(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
// Global file sets paranoid=true (default is false). User file sets
// transitive=false (default is true) and must be ignored entirely.
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("transitive: false\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
cfg := Get()
assert.True(t, cfg.IsManaged())
assert.Equal(t, filepath.Join(globalDir, "config.yml"), cfg.ConfigFilePath())
assert.Equal(t, filepath.Join(userDir, "config.yml"), cfg.UserConfigFilePath())
assert.True(t, cfg.Config.Paranoid, "value should come from the global file")
assert.True(t, cfg.Config.Transitive, "user file must be ignored, so this stays at the template default")
}
func TestManagedConfigFallsBackToUserWhenGlobalAbsent(t *testing.T) {
globalDir := t.TempDir() // no config.yml written here
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
cfg := Get()
assert.False(t, cfg.IsManaged())
assert.Equal(t, filepath.Join(userDir, "config.yml"), cfg.ConfigFilePath())
assert.True(t, cfg.Config.Paranoid, "value should come from the user file")
}
func TestWriteTemplateConfigNoOpWhenManaged(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
require.NoError(t, WriteTemplateConfig())
assert.NoFileExists(t, filepath.Join(userDir, "config.yml"), "managed mode must not create a per-user config")
}
func TestSetConfigValueRefusedWhenManaged(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
err := SetConfigValue("paranoid", "false")
require.Error(t, err)
assert.Contains(t, err.Error(), "globally managed")
assert.NoFileExists(t, filepath.Join(userDir, "config.yml"))
}
func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) {
globalDir := t.TempDir()
userDir := t.TempDir()
globalFile := filepath.Join(globalDir, "config.yml")
userFile := filepath.Join(userDir, "config.yml")
require.NoError(t, os.WriteFile(globalFile, []byte("paranoid: true\n"), 0o644))
require.NoError(t, os.WriteFile(userFile, []byte("transitive: false\n"), 0o644))
useManagedConfigDir(t, globalDir)
t.Setenv("PMG_CONFIG_DIR", userDir)
initConfig()
require.NoError(t, RemoveUserConfigFile())
assert.NoFileExists(t, userFile, "per-user file should be removed")
assert.FileExists(t, globalFile, "globally managed file must be left intact")
}
+3 -4
View File
@@ -13,10 +13,9 @@ import (
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults. // Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
// Cobra flags write directly to the config struct after this function runs. // Cobra flags write directly to the config struct after this function runs.
func loadViperConfig() error { func loadViperConfig() error {
configPath, err := configFilePath() // Use the active config path resolved by initConfig (globally managed file
if err != nil { // when present, otherwise the per-user file).
return fmt.Errorf("failed to get config file path: %w", err) configPath := globalConfig.configFilePath
}
v := viper.New() v := viper.New()
v.SetConfigType("yaml") v.SetConfigType("yaml")
+5 -1
View File
@@ -15,7 +15,11 @@ import (
// It does not update the in-memory config or viper state. Callers that need // It does not update the in-memory config or viper state. Callers that need
// the updated value must re-initialize the config after calling this function. // the updated value must re-initialize the config after calling this function.
func SetConfigValue(key, value string) error { func SetConfigValue(key, value string) error {
configPath, err := configFilePath() if globalConfig.IsManaged() {
return NewManagedConfigError()
}
configPath, err := userConfigFilePath()
if err != nil { if err != nil {
return fmt.Errorf("failed to get config file path: %w", err) return fmt.Errorf("failed to get config file path: %w", err)
} }
+28 -76
View File
@@ -1,8 +1,6 @@
package alias package alias
import ( import (
"bufio"
"bytes"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -154,20 +152,20 @@ func (a *AliasManager) IsInstalled() (bool, error) {
} }
for _, shell := range a.config.Shells { 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) log.Warnf("Warning: could not read %s (%s)", configPath, err)
if err != nil {
if os.IsNotExist(err) {
continue continue
} }
log.Warnf("Warning: could not read %s (%s)", shell.Name(), err) if strings.Contains(string(data), a.config.RcFileName) {
continue 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 return err
} }
primary := PrimaryShellName()
for _, shell := range a.config.Shells { for _, shell := range a.config.Shells {
configPath := filepath.Join(homeDir, shell.Path()) files, err := shell.InstallRcFiles(homeDir, shell.Name() == primary)
if err := a.addSourceLine(configPath, shell.Source(a.rcFileManager.GetRcPath())); err != nil { if err != nil {
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err) 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 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 _, shell := range a.config.Shells {
configPath := filepath.Join(homeDir, shell.Path()) for _, configPath := range shell.CandidateRcFiles(homeDir) {
if err := RewriteFileDroppingLines(configPath, drop); err != nil {
data, err := os.ReadFile(configPath) log.Warnf("Warning: failed to update %s: %s", configPath, err)
if err != nil {
if os.IsNotExist(err) {
continue
} }
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 package alias
import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
)
type bashShell struct{} type bashShell struct{}
var _ Shell = &bashShell{} 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) { func NewBashShell() (*bashShell, error) {
return &bashShell{}, nil return &bashShell{}, nil
} }
@@ -20,6 +31,98 @@ func (b bashShell) Name() string {
return "bash" return "bash"
} }
func (b bashShell) Path() string { func (b bashShell) CandidateRcFiles(homeDir string) []string {
return ".bashrc" 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 package alias
import "fmt" import (
"fmt"
"path/filepath"
)
type fishShell struct{} type fishShell struct{}
@@ -22,6 +25,14 @@ func (f fishShell) Name() string {
return "fish" return "fish"
} }
func (f fishShell) Path() string { func (f fishShell) configPath(homeDir string) string {
return ".config/fish/config.fish" 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 ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"runtime"
"strings" "strings"
) )
@@ -10,7 +12,16 @@ type Shell interface {
Source(rcPath string) string Source(rcPath string) string
PathExport(binDir string) string PathExport(binDir string) string
Name() 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" var commentForRemovingShellSource = "# remove aliases by running `pmg setup remove` or deleting the line"
@@ -36,3 +47,146 @@ func DetectShell() (string, error) {
return shellName, nil 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 package alias
import ( import (
"bufio"
"fmt" "fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestShellPathExport(t *testing.T) { 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) { func TestDetectShell(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
+9 -2
View File
@@ -1,5 +1,7 @@
package alias package alias
import "path/filepath"
type zshShell struct{} type zshShell struct{}
var _ Shell = &zshShell{} var _ Shell = &zshShell{}
@@ -20,6 +22,11 @@ func (z zshShell) Name() string {
return "zsh" return "zsh"
} }
func (z zshShell) Path() string { // zsh reads .zshrc for every interactive shell, login or not, so one file covers it.
return ".zshrc" 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)
} }
+3
View File
@@ -16,6 +16,8 @@ const (
postHogEventEndpoint = "https://us.i.posthog.com" postHogEventEndpoint = "https://us.i.posthog.com"
telemetryDisableEnvKey = "PMG_DISABLE_TELEMETRY" telemetryDisableEnvKey = "PMG_DISABLE_TELEMETRY"
analyticsFlushInterval = 2 * time.Second
) )
var ( var (
@@ -32,6 +34,7 @@ func init() {
client, err := posthog.NewWithConfig(postHogApiKey, posthog.Config{ client, err := posthog.NewWithConfig(postHogApiKey, posthog.Config{
Endpoint: postHogEventEndpoint, Endpoint: postHogEventEndpoint,
Interval: analyticsFlushInterval,
}) })
if err != nil { if err != nil {
log.Fatalf("Failed to initialize posthog client: %v", err) log.Fatalf("Failed to initialize posthog client: %v", err)
+51 -84
View File
@@ -1,8 +1,6 @@
package shim package shim
import ( import (
"bufio"
"bytes"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -91,19 +89,19 @@ func (m *ShimManager) Remove() error {
func (m *ShimManager) IsInstalled() (bool, error) { func (m *ShimManager) IsInstalled() (bool, error) {
for _, shell := range m.config.Shells { for _, shell := range m.config.Shells {
configPath := filepath.Join(m.config.HomeDir, shell.Path()) for _, configPath := range shell.CandidateRcFiles(m.config.HomeDir) {
data, err := os.ReadFile(configPath)
data, err := os.ReadFile(configPath) if err != nil {
if err != nil { if os.IsNotExist(err) {
if os.IsNotExist(err) { continue
}
log.Warnf("Warning: could not read %s (%s)", configPath, err)
continue continue
} }
log.Warnf("Warning: could not read %s (%s)", shell.Name(), err)
continue
}
if strings.Contains(string(data), shimMarker) { if strings.Contains(string(data), shimMarker) {
return true, nil return true, nil
}
} }
} }
@@ -151,93 +149,62 @@ func shellQuote(value string) string {
} }
func (m *ShimManager) addPathToShells() error { func (m *ShimManager) addPathToShells() error {
primary := alias.PrimaryShellName()
for _, shell := range m.config.Shells { for _, shell := range m.config.Shells {
configPath := filepath.Join(m.config.HomeDir, shell.Path()) files, err := shell.InstallRcFiles(m.config.HomeDir, shell.Name() == primary)
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
continue
}
if strings.Contains(string(data), shimMarker) {
continue
}
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err) log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
continue continue
} }
_, err = fmt.Fprintf(f, "\n%s", shell.PathExport(m.config.BinDir)) for _, configPath := range files {
if closeErr := f.Close(); closeErr != nil { m.addPathToFile(configPath, shell)
log.Warnf("Warning: failed to close %s: %s", shell.Name(), closeErr)
}
if err != nil {
log.Warnf("Warning: failed to write PATH export to %s: %s", shell.Name(), err)
} }
} }
return nil return nil
} }
// addPathToFile appends the shell's PATH export to a single config file unless
// it is already present. A missing file is a no-op.
func (m *ShimManager) addPathToFile(configPath string, shell alias.Shell) {
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
log.Warnf("Warning: skipping %s (%s)", configPath, err)
}
return
}
if strings.Contains(string(data), shimMarker) {
return
}
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
log.Warnf("Warning: skipping %s (%s)", configPath, err)
return
}
_, err = fmt.Fprintf(f, "\n%s", shell.PathExport(m.config.BinDir))
if closeErr := f.Close(); closeErr != nil {
log.Warnf("Warning: failed to close %s: %s", configPath, closeErr)
}
if err != nil {
log.Warnf("Warning: failed to write PATH export to %s: %s", configPath, err)
}
}
func (m *ShimManager) removePathFromShells() error { func (m *ShimManager) removePathFromShells() error {
drop := func(line string) bool {
return strings.Contains(line, shimMarker)
}
for _, shell := range m.config.Shells { for _, shell := range m.config.Shells {
configPath := filepath.Join(m.config.HomeDir, shell.Path()) for _, configPath := range shell.CandidateRcFiles(m.config.HomeDir) {
if err := alias.RewriteFileDroppingLines(configPath, drop); err != nil {
data, err := os.ReadFile(configPath) log.Warnf("Warning: failed to update %s: %s", configPath, err)
if err != nil {
if os.IsNotExist(err) {
continue
} }
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
continue
}
info, err := os.Stat(configPath)
if err != nil {
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
continue
}
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()
scanner := bufio.NewScanner(bytes.NewReader(data))
writer := bufio.NewWriter(tempFile)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, shimMarker) {
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)
}
if err := os.Chmod(tempPath, info.Mode()); err != nil {
log.Warnf("Warning: failed to set permissions on temporary file: %s", err)
}
if err := os.Rename(tempPath, configPath); err != nil {
_ = os.Remove(tempPath)
log.Warnf("Warning: failed to update %s: %s", configPath, err)
} }
} }
+28 -1
View File
@@ -197,4 +197,31 @@ func (s *stubShell) PathExport(binDir string) string {
} }
func (s *stubShell) Name() string { return s.name } func (s *stubShell) Name() string { return s.name }
func (s *stubShell) Path() string { return s.path }
func (s *stubShell) CandidateRcFiles(homeDir string) []string {
return []string{filepath.Join(homeDir, s.path)}
}
func (s *stubShell) InstallRcFiles(homeDir string, create bool) ([]string, error) {
path := filepath.Join(homeDir, s.path)
if _, err := os.Stat(path); err == nil {
return []string{path}, nil
}
if !create {
return nil, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
return []string{path}, nil
}
+2 -1
View File
@@ -160,7 +160,6 @@ func main() {
fmt.Println(command.UsageString()) fmt.Println(command.UsageString())
}) })
defer analytics.Close()
defer func() { defer func() {
if err := eventlog.Close(); err != nil { if err := eventlog.Close(); err != nil {
log.Warnf("failed to close eventlog: %v", err) log.Warnf("failed to close eventlog: %v", err)
@@ -178,6 +177,8 @@ func main() {
} }
}() }()
// Analytics are best-effort. Do not flush on exit because the PostHog
// client can block the CLI while draining its queue.
analytics.TrackCommandRun() analytics.TrackCommandRun()
analytics.TrackCI() analytics.TrackCI()
+104
View File
@@ -0,0 +1,104 @@
# PMG MDM Scripts (macOS)
Deploy and remove [PMG](https://github.com/safedep/pmg) on macOS fleets through an MDM (Jamf, Mosyle, Kandji, Intune).
| File | Purpose |
|------|---------|
| `pmg_setup_install_macos.sh` | Install the binary and configure every user (config, aliases, shims, optional cloud sync) |
| `pmg_uninstall_macos.sh` | Remove per-user state, Keychain credentials, and the binary |
| `lib_macos.sh` | Shared helpers. Deploy it alongside the other two. |
| `config.yml` *(optional)* | When present in the package, the install script deploys it as the machine-wide globally managed config |
The install and uninstall scripts source `./lib_macos.sh` from their own directory, so ship all three together. Zip the `mdm/` folder as the MDM payload. Add a `config.yml` to the folder to deploy a globally managed config (see below).
## Execution model
PMG writes two kinds of state, and the scripts handle each:
- **Machine scope**: the `pmg` binary (`/usr/local/bin` or Homebrew). Needs root.
- **User scope**: config (`~/Library/Application Support/safedep/pmg`), aliases (`~/.pmg.rc` and shell rc edits), PATH shims (`~/.pmg/bin`), and login Keychain credentials. Runs as the user. Keychain also needs the user's GUI session.
The scripts detect how the MDM invoked them:
- **As root** (typical MDM): the script installs or removes the binary machine-wide, then runs the per-user steps for every local human account (UID ≥ 500 with a home under `/Users`), each in its own context via `sudo -u`. Keychain steps run in the logged-in user's session via `launchctl asuser`.
- **As the logged-in user** (an MDM "run as current user" payload, or a person running it by hand): the per-user steps cover that user, and machine-scope steps elevate with `sudo`.
Homebrew can't run as root, so the script runs brew commands as the owner of the Homebrew install.
## Install
```sh
# Binary + per-user setup, no cloud sync
sudo ./pmg_setup_install_macos.sh
# Also enable SafeDep Cloud sync (stores credentials in the logged-in user's Keychain)
sudo SAFEDEP_API_KEY=... SAFEDEP_TENANT_ID=... ./pmg_setup_install_macos.sh
```
The install script:
1. Installs or updates `pmg` (Homebrew if present, otherwise the GitHub release tarball with SHA-256 verification).
2. If the package includes a `config.yml`, installs it as the globally managed config (see below).
3. Runs `pmg setup install` for each target user to create aliases and shims (and a per-user config, unless a globally managed config is active).
4. With `SAFEDEP_API_KEY` and `SAFEDEP_TENANT_ID` set, enables cloud sync and stores credentials in the logged-in user's Keychain. It skips users who aren't logged in, since there's no session to write to. Run `pmg cloud login` in their session once they log in.
## Uninstall
```sh
sudo ./pmg_uninstall_macos.sh
```
For each target user, the uninstall script:
1. Runs `pmg setup remove` to strip shell aliases and PATH shims.
2. Deletes the config directory, cache directory, `~/.pmg`, `~/.pmg.rc`, and `~/.local/bin/pmg`.
3. Runs `pmg cloud logout` to clear Keychain credentials for the logged-in user. Other users' credentials clear on their next login.
It then removes the machine-wide binary via `brew uninstall`, or by deleting `/usr/local/bin/pmg` and `/opt/homebrew/bin/pmg`. It also removes the globally managed config if present (set `PMG_KEEP_GLOBAL_CONFIG=1` to keep it).
## Globally managed config
Include a `config.yml` next to the scripts to centrally manage PMG configuration. When that file is present at `/Library/Application Support/safedep/pmg/config.yml`, PMG treats it as authoritative and **ignores every user's own config**. Users cannot override it: `pmg config set` and `pmg config edit` refuse, and the file is root-owned (`0644`), so it is not user-writable.
- The file can be **partial**. Keys it does not set fall back to PMG's built-in defaults, not to user values.
- To enable cloud sync, set `cloud.enabled: true` in the bundled `config.yml`. The install script skips the per-user `pmg config set` (a managed config refuses it) but still stores each logged-in user's credentials in the Keychain.
- Install copies the bundled `config.yml` to the global path *before* configuring users, so each user's setup skips writing a per-user config.
- Re-deploying the package overwrites the global config, keeping it in sync with the package.
- Uninstall removes the global config whenever it is present, regardless of whether the uninstall package ships a `config.yml`. Set `PMG_KEEP_GLOBAL_CONFIG=1` to keep it.
Only the config *file* is global. Per-user runtime state (logs, cloud sync database, sandbox profiles) stays under each user's `~/Library`.
## Environment variables
| Variable | Effect |
|----------|--------|
| `SAFEDEP_API_KEY` | SafeDep Cloud API key (install only; with the tenant ID, enables cloud sync) |
| `SAFEDEP_TENANT_ID` | SafeDep Cloud tenant ID (install only) |
| `PMG_CONFIG_DIR` | Override the config directory location (uninstall cleanup honors it) |
| `PMG_CACHE_DIR` | Override the cache directory location (uninstall cleanup honors it) |
| `PMG_KEEP_GLOBAL_CONFIG` | Uninstall only: when set, keep the globally managed config instead of removing it |
## Jamf example
Upload the `mdm/` folder as a script payload, or a package that drops the three files together, then invoke the entry script. Jamf runs scripts as root, which covers fleet-wide, multi-user deployment:
```sh
#!/bin/sh
cd "$(dirname "$0")"
SAFEDEP_API_KEY="$4" SAFEDEP_TENANT_ID="$5" ./pmg_setup_install_macos.sh
```
`$4` and `$5` are Jamf script parameters. Adjust them to your configuration.
## Limitations
- The scripts can't read or write the Keychain for a user who isn't logged in, since no session exists to reach. They report and skip those users; configure them in their session when they log in. After an uninstall, their credentials clear on next login.
- Machine-scope steps under a non-root invocation need `sudo`. Without passwordless sudo in a non-interactive context, they fail with an error instead of hanging.
- macOS only. The scripts exit on other platforms.
## Development
```sh
# From this directory
shellcheck -x lib_macos.sh pmg_setup_install_macos.sh pmg_uninstall_macos.sh
```
+154
View File
@@ -0,0 +1,154 @@
#!/bin/bash
# lib_macos.sh — shared helpers for the PMG macOS install/uninstall scripts.
#
# MDM tools (Jamf, Mosyle, Kandji, Intune, ...) run scripts either as root or
# already as the logged-in user. These helpers make both work without the caller
# branching on it:
#
# - Machine-scope actions (the pmg binary) go through run_as_root.
# - Per-user actions (config, aliases, shims, Keychain) go through the
# user-scope helpers, which fan out to every local user when run as root and
# act on the current user when run as that user.
#
# Keychain access needs the user's GUI session, so credential steps are gated on
# user_has_session and dispatched via `launchctl asuser`. Homebrew refuses to run
# as root, so brew steps run as the owner of the Homebrew install via run_brew.
#
# This file is sourced by the install/uninstall scripts; deploy them together.
require_macos() {
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: this script is for macOS only" >&2
exit 1
fi
}
log() { echo "==> $*"; }
warn() { echo "==> warning: $*" >&2; }
running_as_root() { [[ "$EUID" -eq 0 ]]; }
# Path to a Homebrew binary (Apple Silicon or Intel), or non-zero if absent.
find_brew() {
local candidate
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
[[ -x "$candidate" ]] && { echo "$candidate"; return 0; }
done
return 1
}
# Run a machine-scope command, elevating only if we are not already root.
run_as_root() {
if running_as_root; then "$@"; else sudo "$@"; fi
}
# brew refuses to run as root, so run it as the owner of the Homebrew install.
run_brew() {
local brew_bin="$1"; shift
local owner; owner=$(stat -f%Su "$brew_bin")
if [[ "$owner" == "$(id -un)" ]]; then
"$brew_bin" "$@"
elif running_as_root && [[ "$owner" != "root" ]]; then
sudo -u "$owner" -H -- "$brew_bin" "$@"
else
warn "cannot run brew as $(id -un); Homebrew is owned by $owner"
return 1
fi
}
# The console (GUI logged-in) user, or non-zero if none / at the login window.
console_user() {
local u
u=$(stat -f%Su /dev/console 2>/dev/null || true)
[[ -z "$u" || "$u" == "root" || "$u" == "loginwindow" ]] && return 1
echo "$u"
}
# Emit "user<TAB>uid<TAB>home" per target user.
# - as root: every local human account (UID >= 500) with a real home.
# - as a user: just the current user (the MDM ran us in user context).
each_target_user() {
if ! running_as_root; then
printf '%s\t%s\t%s\n' "$(id -un)" "$(id -u)" "$HOME"
return
fi
local user uid home
while IFS= read -r user; do
uid=$(dscl . -read "/Users/$user" UniqueID 2>/dev/null | awk '{print $2}')
if ! [[ "$uid" =~ ^[0-9]+$ ]] || [[ "$uid" -lt 500 ]]; then continue; fi
home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null | awk '{print $2}')
[[ -n "$home" && -d "$home" ]] || continue
case "$home" in /Users/*) ;; *) continue ;; esac
printf '%s\t%s\t%s\n' "$user" "$uid" "$home"
done < <(dscl . -list /Users)
}
# True if this user's login Keychain is reachable (they have a live session).
user_has_session() {
if running_as_root; then
[[ "$1" == "$(console_user || true)" ]]
else
[[ "$1" == "$(id -un)" ]]
fi
}
# Run a file-scope command as the given user, with HOME set to their home.
run_user_file() {
local user="$1"; shift
if running_as_root; then sudo -u "$user" -H -- "$@"; else "$@"; fi
}
# Run a session-scope command (e.g. Keychain) inside the user's GUI session.
# Only call when user_has_session "$user" is true.
run_user_session() {
local user="$1"; shift
if running_as_root; then
launchctl asuser "$(id -u "$user")" sudo -u "$user" -H -- "$@"
else
"$@"
fi
}
# Per-user pmg state directories: env overrides win, else the macOS layout.
pmg_config_dir() { echo "${PMG_CONFIG_DIR:-$1/Library/Application Support/safedep/pmg}"; }
pmg_cache_dir() { echo "${PMG_CACHE_DIR:-$1/Library/Caches/safedep/pmg}"; }
# Absolute path to the pmg binary. root's PATH under an MDM is often minimal, so
# fall back to the machine-wide install locations.
resolve_pmg() {
local p
p=$(command -v pmg 2>/dev/null) && { echo "$p"; return 0; }
for p in /usr/local/bin/pmg /opt/homebrew/bin/pmg; do
[[ -x "$p" ]] && { echo "$p"; return 0; }
done
return 1
}
# Machine-wide globally managed config. When this file is present pmg treats it as
# authoritative and ignores every user's config. It must match the path pmg
# resolves on macOS.
readonly GLOBAL_CONFIG_DIR="/Library/Application Support/safedep/pmg"
readonly GLOBAL_CONFIG_FILE="${GLOBAL_CONFIG_DIR}/config.yml"
# install_global_config <src> installs a bundled config.yml as the globally
# managed config: root-owned and 0644 so users can read but not modify it.
install_global_config() {
local src="$1"
log "Installing globally managed config to $GLOBAL_CONFIG_FILE"
run_as_root install -d -m 0755 "$GLOBAL_CONFIG_DIR" || { warn "failed to create $GLOBAL_CONFIG_DIR (need root)"; return 1; }
run_as_root install -m 0644 "$src" "$GLOBAL_CONFIG_FILE" || { warn "failed to install global config (need root)"; return 1; }
}
# remove_global_config removes the globally managed config when present, unless
# PMG_KEEP_GLOBAL_CONFIG is set.
remove_global_config() {
if [[ -n "${PMG_KEEP_GLOBAL_CONFIG:-}" ]]; then
[[ -e "$GLOBAL_CONFIG_FILE" ]] && log "Keeping globally managed config ($GLOBAL_CONFIG_FILE); PMG_KEEP_GLOBAL_CONFIG is set"
return 0
fi
[[ -e "$GLOBAL_CONFIG_FILE" ]] || return 0
log "Removing globally managed config $GLOBAL_CONFIG_FILE"
run_as_root rm -f "$GLOBAL_CONFIG_FILE" || warn "failed to remove global config (need root)"
run_as_root rmdir "$GLOBAL_CONFIG_DIR" 2>/dev/null || true
}
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# pmg_setup_install_macos.sh — Install and configure PMG on a Mac.
#
# Deploy via Jamf or any MDM, alongside lib_macos.sh in the same directory.
# Run as root, it installs the machine-wide binary and configures every local
# user (config, aliases, shims). Cloud credentials are stored in the logged-in
# user's Keychain when SAFEDEP_API_KEY and SAFEDEP_TENANT_ID are set. Run as a
# user, it configures just that user. See lib_macos.sh for the model.
#
# Environment variables:
# SAFEDEP_API_KEY — SafeDep Cloud API key (with tenant ID, enables cloud sync)
# SAFEDEP_TENANT_ID — SafeDep Cloud tenant ID
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib_macos.sh
source "${SCRIPT_DIR}/lib_macos.sh"
require_macos
REPO="safedep/pmg"
CLOUD_API_KEY="${SAFEDEP_API_KEY:-}"
CLOUD_TENANT_ID="${SAFEDEP_TENANT_ID:-}"
install_via_brew() {
local brew_bin="$1"
log "Installing/updating pmg via Homebrew"
if run_brew "$brew_bin" ls --versions safedep/tap/pmg &>/dev/null; then
run_brew "$brew_bin" upgrade safedep/tap/pmg || true
else
run_brew "$brew_bin" install safedep/tap/pmg
fi
}
install_via_release() {
log "Homebrew not found, installing pmg from GitHub releases"
local install_dir="/usr/local/bin" tag asset url checksums_url tmpdir expected actual
tag=$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/${REPO}/releases/latest" | sed 's|.*/||')
[[ -n "$tag" ]] || { echo "Error: could not determine latest release" >&2; exit 1; }
log "Latest release: $tag"
asset="pmg_Darwin_all.tar.gz"
url="https://github.com/${REPO}/releases/download/${tag}/${asset}"
checksums_url="https://github.com/${REPO}/releases/download/${tag}/checksums.txt"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
log "Downloading $asset"
curl -fsSL -o "${tmpdir}/${asset}" "$url"
curl -fsSL -o "${tmpdir}/checksums.txt" "$checksums_url"
expected=$(grep " ${asset}$" "${tmpdir}/checksums.txt" | cut -d' ' -f1)
[[ -n "$expected" ]] || { echo "Error: no checksum entry found for ${asset}" >&2; exit 1; }
actual=$(shasum -a 256 "${tmpdir}/${asset}" | cut -d' ' -f1)
if [[ "$actual" != "$expected" ]]; then
echo "Error: checksum mismatch for ${asset} (expected $expected, got $actual)" >&2
exit 1
fi
log "Checksum verified"
tar -xzf "${tmpdir}/${asset}" -C "${tmpdir}" pmg
run_as_root install -m 755 "${tmpdir}/pmg" "${install_dir}/pmg"
log "Installed pmg $tag to ${install_dir}/pmg"
}
if brew_bin=$(find_brew); then
install_via_brew "$brew_bin"
else
install_via_release
fi
PMG_BIN=$(resolve_pmg) || { echo "Error: pmg not found after install" >&2; exit 1; }
log "pmg installed: $("$PMG_BIN" version 2>/dev/null || echo unknown)"
# Install the globally managed config if the package ships one. Done before the
# per-user loop so each user's `setup install` sees managed mode and skips
# writing a per-user config.
if [[ -f "${SCRIPT_DIR}/config.yml" ]]; then
install_global_config "${SCRIPT_DIR}/config.yml"
fi
if [[ -f "$GLOBAL_CONFIG_FILE" && -n "$CLOUD_API_KEY" && -n "$CLOUD_TENANT_ID" ]]; then
log "Config is globally managed; set 'cloud.enabled: true' in the bundled config.yml to enable sync (per-user config is locked)"
fi
configure_user() {
local user="$1"
log "Configuring pmg for $user"
run_user_file "$user" "$PMG_BIN" setup install || { warn "setup failed for $user"; return; }
[[ -n "$CLOUD_API_KEY" && -n "$CLOUD_TENANT_ID" ]] || return
if ! user_has_session "$user"; then
log " $user is not logged in; run 'pmg cloud login' in their session to enable cloud sync"
return
fi
# When config is globally managed, `cloud.enabled` comes from the global file;
# per-user `config set` is refused. Per-user credentials still go to the Keychain.
if [[ ! -f "$GLOBAL_CONFIG_FILE" ]]; then
run_user_file "$user" "$PMG_BIN" config set cloud.enabled true || warn "could not enable cloud sync for $user"
fi
run_user_session "$user" \
env SAFEDEP_API_KEY="$CLOUD_API_KEY" SAFEDEP_TENANT_ID="$CLOUD_TENANT_ID" "$PMG_BIN" cloud login --from-env \
|| warn "cloud login failed for $user"
}
while IFS=$'\t' read -r user _ _; do
configure_user "$user"
done < <(each_target_user)
log "pmg setup complete"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# pmg_uninstall_macos.sh — Remove PMG from a Mac.
#
# Deploy via Jamf or any MDM, alongside lib_macos.sh in the same directory.
# Run as root, it cleans up every local user's config, aliases, shims, and (for
# the logged-in user) Keychain credentials, then removes the machine-wide binary.
# Run as a user, it cleans up just that user. See lib_macos.sh for the model.
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib_macos.sh
source "${SCRIPT_DIR}/lib_macos.sh"
require_macos
PMG_BIN=$(resolve_pmg || true)
remove_user_state() {
local user="$1" home="$2"
log "Removing pmg state for $user"
if [[ -n "$PMG_BIN" ]]; then
run_user_file "$user" "$PMG_BIN" setup remove \
|| warn "failed to remove aliases/shims for $user"
else
warn "pmg binary not found; shell rc entries for $user may remain"
fi
run_user_file "$user" rm -rf \
"$(pmg_config_dir "$home")" "$(pmg_cache_dir "$home")" \
"$home/.pmg" "$home/.pmg.rc" "$home/.local/bin/pmg" \
|| warn "failed to remove pmg directories for $user"
if [[ -z "$PMG_BIN" ]]; then
return
fi
if user_has_session "$user"; then
run_user_session "$user" "$PMG_BIN" cloud logout \
|| warn "failed to clear Keychain credentials for $user"
else
log " $user is not logged in; Keychain credentials (if any) will clear on next login"
fi
}
while IFS=$'\t' read -r user _ home; do
remove_user_state "$user" "$home"
done < <(each_target_user)
remove_binary() {
local brew_bin
if brew_bin=$(find_brew) && run_brew "$brew_bin" ls --versions safedep/tap/pmg &>/dev/null; then
log "Uninstalling pmg via Homebrew"
run_brew "$brew_bin" uninstall safedep/tap/pmg || warn "brew uninstall failed"
return
fi
# Not brew-managed: remove machine-wide binaries from the locations resolve_pmg
# checks, so a manual install in either prefix is not left behind.
local path
for path in /usr/local/bin/pmg /opt/homebrew/bin/pmg; do
if [[ -e "$path" ]]; then
log "Removing $path"
run_as_root rm -f "$path" || warn "failed to remove $path (need root)"
fi
done
}
remove_binary
remove_global_config
log "pmg uninstall complete"
-128
View File
@@ -1,128 +0,0 @@
#!/bin/bash
# pmg_setup_install.sh — Install/update PMG, configure it, and enable cloud sync.
#
# This script is intended to be packaged and deployed via Jamf or similar MDM tools.
#
# Usage:
# ./pmg_setup_install.sh
# SAFEDEP_API_KEY=... SAFEDEP_TENANT_ID=... ./pmg_setup_install.sh
#
# Environment variables:
# SAFEDEP_API_KEY — SafeDep Cloud API key (enables cloud sync when set with tenant ID)
# SAFEDEP_TENANT_ID — SafeDep Cloud tenant ID
#
# What it does:
# 1. Installs or updates pmg (via Homebrew if available, otherwise from GitHub releases)
# 2. Runs `pmg setup install` to create config, shell aliases, and PATH shims
# 3. Enables cloud sync and stores credentials in macOS Keychain (if both env vars are set)
set -euo pipefail
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: this script is for macOS only" >&2
exit 1
fi
REPO="safedep/pmg"
CLOUD_API_KEY="${SAFEDEP_API_KEY:-}"
CLOUD_TENANT_ID="${SAFEDEP_TENANT_ID:-}"
log() { echo "==> $*"; }
# ── Install or update pmg ───────────────────────────────────────────────────
install_via_brew() {
local brew_bin="$1"
log "Installing/updating pmg via Homebrew"
if "$brew_bin" ls --versions safedep/tap/pmg &>/dev/null; then
log "pmg is already installed, upgrading"
"$brew_bin" upgrade safedep/tap/pmg || true
else
"$brew_bin" install safedep/tap/pmg
fi
}
install_via_release() {
log "Homebrew not found, installing pmg from GitHub releases"
local install_dir="/usr/local/bin"
log "Fetching latest release..."
tag=$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/${REPO}/releases/latest" | sed 's|.*/||')
if [[ -z "$tag" ]]; then
echo "Error: could not determine latest release" >&2
exit 1
fi
log "Latest release: $tag"
asset="pmg_Darwin_all.tar.gz"
url="https://github.com/${REPO}/releases/download/${tag}/${asset}"
checksums_url="https://github.com/${REPO}/releases/download/${tag}/checksums.txt"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
log "Downloading $asset"
curl -fsSL -o "${tmpdir}/${asset}" "$url"
curl -fsSL -o "${tmpdir}/checksums.txt" "$checksums_url"
expected=$(grep " ${asset}$" "${tmpdir}/checksums.txt" | cut -d' ' -f1)
if [[ -z "$expected" ]]; then
echo "Error: no checksum entry found for ${asset}" >&2
exit 1
fi
actual=$(shasum -a 256 "${tmpdir}/${asset}" | cut -d' ' -f1)
if [[ "$actual" != "$expected" ]]; then
echo "Error: checksum mismatch for ${asset}" >&2
echo " expected: $expected" >&2
echo " actual: $actual" >&2
exit 1
fi
log "Checksum verified"
tar -xzf "${tmpdir}/${asset}" -C "${tmpdir}" pmg
if [[ -w "$install_dir" ]]; then
install -m 755 "${tmpdir}/pmg" "${install_dir}/pmg"
else
sudo install -m 755 "${tmpdir}/pmg" "${install_dir}/pmg"
fi
log "Installed pmg $tag to ${install_dir}/pmg"
}
BREW_BIN=""
for candidate in "/opt/homebrew/bin/brew" "/usr/local/bin/brew"; do
if [[ -x "$candidate" ]]; then
BREW_BIN="$candidate"
break
fi
done
if [[ -n "$BREW_BIN" ]]; then
install_via_brew "$BREW_BIN"
else
install_via_release
fi
if ! command -v pmg &>/dev/null; then
echo "Error: pmg not found in PATH after install" >&2
exit 1
fi
log "pmg installed: $(pmg version 2>/dev/null || echo 'unknown')"
# ── Run pmg setup ────────────────────────────────────────────────────────────
log "Running pmg setup install"
pmg setup install
# ── Enable cloud sync ───────────────────────────────────────────────────────
if [[ -n "$CLOUD_API_KEY" && -n "$CLOUD_TENANT_ID" ]]; then
log "Enabling cloud sync"
pmg config set cloud.enabled true
log "Cloud sync enabled in config"
SAFEDEP_API_KEY="$CLOUD_API_KEY" SAFEDEP_TENANT_ID="$CLOUD_TENANT_ID" pmg cloud login --from-env
log "Credentials stored in macOS Keychain"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
log "pmg setup complete!"
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
# pmg_uninstall_macos.sh — Uninstall PMG, remove config, aliases, shims, and cloud credentials.
#
# This script is intended to be deployed via Jamf or similar MDM tools.
#
# Usage:
# ./pmg_uninstall_macos.sh
#
# What it does:
# 1. Removes PMG config, shell aliases, and PATH shims
# 2. Clears cloud credentials from macOS Keychain
# 3. Uninstalls the pmg binary (via Homebrew or direct removal)
set -euo pipefail
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Error: this script is for macOS only" >&2
exit 1
fi
log() { echo "==> $*"; }
# ── Remove config, aliases, and shims ────────────────────────────────────────
if command -v pmg &>/dev/null; then
log "Removing PMG config, aliases, and shims"
pmg setup remove --config-file || true
log "Clearing cloud credentials from keychain"
pmg cloud logout || true
fi
# ── Uninstall pmg binary ────────────────────────────────────────────────────
BREW_BIN=""
for candidate in "/opt/homebrew/bin/brew" "/usr/local/bin/brew"; do
if [[ -x "$candidate" ]]; then
BREW_BIN="$candidate"
break
fi
done
if [[ -n "$BREW_BIN" ]] && "$BREW_BIN" ls --versions safedep/tap/pmg &>/dev/null; then
log "Uninstalling pmg via Homebrew"
"$BREW_BIN" uninstall safedep/tap/pmg
else
log "Removing pmg binary"
if [[ -f "/usr/local/bin/pmg" ]]; then
if [[ -w "/usr/local/bin/pmg" ]]; then
rm -f "/usr/local/bin/pmg"
else
sudo rm -f "/usr/local/bin/pmg"
fi
fi
fi
# ── Done ─────────────────────────────────────────────────────────────────────
log "pmg uninstall complete!"