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:
@@ -86,6 +86,10 @@ If the config file does not exist, a template is created first.`,
|
||||
|
||||
func runEdit() error {
|
||||
cfg := appConfig.Get()
|
||||
if cfg.IsManaged() {
|
||||
return appConfig.NewManagedConfigError()
|
||||
}
|
||||
|
||||
path := cfg.ConfigFilePath()
|
||||
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -43,6 +43,11 @@ func executeSetupInfo() error {
|
||||
cfg := config.Get()
|
||||
configEntries := make(map[string]string)
|
||||
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 Install Only"] = strconv.FormatBool(cfg.Config.Proxy.InstallOnly)
|
||||
ui.PrintInfoSection("Configuration", configEntries)
|
||||
|
||||
+10
-7
@@ -2,7 +2,6 @@ package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
@@ -13,9 +12,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
setupRemoveConfigFile = false
|
||||
)
|
||||
var setupRemoveConfigFile = false
|
||||
|
||||
func NewSetupCommand() *cobra.Command {
|
||||
setupCmd := &cobra.Command{
|
||||
@@ -51,6 +48,11 @@ func install() error {
|
||||
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" {
|
||||
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())))
|
||||
@@ -92,9 +94,10 @@ func NewRemoveCommand() *cobra.Command {
|
||||
fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit))
|
||||
|
||||
if setupRemoveConfigFile {
|
||||
config := config.Get()
|
||||
if err := os.Remove(config.ConfigFilePath()); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove config file %q: %w", config.ConfigFilePath(), err)
|
||||
// Only ever remove the per-user file; the globally managed
|
||||
// config is not ours to delete from a per-user uninstall.
|
||||
if err := config.RemoveUserConfigFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+121
-8
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/usefulerror"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -198,7 +200,8 @@ type RuntimeConfig struct {
|
||||
|
||||
// Internal config values computed at runtime and must be accessed via. API
|
||||
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
|
||||
sandboxProfileDir string
|
||||
sandboxViolationCacheDir string
|
||||
@@ -222,11 +225,26 @@ func (r *RuntimeConfig) CloudSyncLastRunPath() string {
|
||||
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 {
|
||||
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.
|
||||
func (r *RuntimeConfig) EventLogDir() string {
|
||||
return r.eventLogDir
|
||||
@@ -351,9 +369,14 @@ func initConfig() {
|
||||
panic(fmt.Errorf("failed to get config directory: %w", err))
|
||||
}
|
||||
|
||||
configFilePath, err := configFilePath()
|
||||
activeConfigPath, err := resolveConfigFile()
|
||||
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()
|
||||
@@ -372,7 +395,8 @@ func initConfig() {
|
||||
}
|
||||
|
||||
globalConfig.configDir = configDir
|
||||
globalConfig.configFilePath = configFilePath
|
||||
globalConfig.configFilePath = activeConfigPath
|
||||
globalConfig.userConfigFilePath = userConfigPath
|
||||
globalConfig.eventLogDir = eventLogDir
|
||||
globalConfig.sandboxProfileDir = sandboxProfileDir
|
||||
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
|
||||
@@ -409,8 +433,8 @@ func configDir() (string, error) {
|
||||
return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath), nil
|
||||
}
|
||||
|
||||
// configFilePath computes the path to the config file.
|
||||
func configFilePath() (string, error) {
|
||||
// userConfigFilePath computes the path to the per-user config file.
|
||||
func userConfigFilePath() (string, error) {
|
||||
configDir, err := configDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get config directory: %w", err)
|
||||
@@ -419,6 +443,61 @@ func configFilePath() (string, error) {
|
||||
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.
|
||||
func eventLogDir() (string, error) {
|
||||
// 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 it already exists, missing keys from the template are merged
|
||||
// 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 {
|
||||
if globalConfig.IsManaged() {
|
||||
return nil
|
||||
}
|
||||
|
||||
configDir, err := configDir()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
configFilePath, err := configFilePath()
|
||||
configFilePath, err := userConfigFilePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config file path: %w", err)
|
||||
}
|
||||
@@ -549,3 +635,30 @@ func WriteTemplateConfig() error {
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -13,10 +13,9 @@ import (
|
||||
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
|
||||
// Cobra flags write directly to the config struct after this function runs.
|
||||
func loadViperConfig() error {
|
||||
configPath, err := configFilePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config file path: %w", err)
|
||||
}
|
||||
// Use the active config path resolved by initConfig (globally managed file
|
||||
// when present, otherwise the per-user file).
|
||||
configPath := globalConfig.configFilePath
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
+5
-1
@@ -15,7 +15,11 @@ import (
|
||||
// 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.
|
||||
func SetConfigValue(key, value string) error {
|
||||
configPath, err := configFilePath()
|
||||
if globalConfig.IsManaged() {
|
||||
return NewManagedConfigError()
|
||||
}
|
||||
|
||||
configPath, err := userConfigFilePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config file path: %w", err)
|
||||
}
|
||||
|
||||
+21
-69
@@ -1,8 +1,6 @@
|
||||
package alias
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -154,15 +152,14 @@ 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
|
||||
}
|
||||
|
||||
log.Warnf("Warning: could not read %s (%s)", shell.Name(), err)
|
||||
log.Warnf("Warning: could not read %s (%s)", configPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -170,6 +167,7 @@ func (a *AliasManager) IsInstalled() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, 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,72 +213,18 @@ 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
|
||||
}
|
||||
|
||||
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)
|
||||
for _, configPath := range shell.CandidateRcFiles(homeDir) {
|
||||
if err := RewriteFileDroppingLines(configPath, drop); err != nil {
|
||||
log.Warnf("Warning: failed to update %s: %s", configPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
postHogEventEndpoint = "https://us.i.posthog.com"
|
||||
|
||||
telemetryDisableEnvKey = "PMG_DISABLE_TELEMETRY"
|
||||
|
||||
analyticsFlushInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -32,6 +34,7 @@ func init() {
|
||||
|
||||
client, err := posthog.NewWithConfig(postHogApiKey, posthog.Config{
|
||||
Endpoint: postHogEventEndpoint,
|
||||
Interval: analyticsFlushInterval,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize posthog client: %v", err)
|
||||
|
||||
+36
-69
@@ -1,8 +1,6 @@
|
||||
package shim
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -91,14 +89,13 @@ func (m *ShimManager) Remove() error {
|
||||
|
||||
func (m *ShimManager) IsInstalled() (bool, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
log.Warnf("Warning: could not read %s (%s)", shell.Name(), err)
|
||||
log.Warnf("Warning: could not read %s (%s)", configPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -106,6 +103,7 @@ func (m *ShimManager) IsInstalled() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
@@ -151,95 +149,64 @@ func shellQuote(value string) string {
|
||||
}
|
||||
|
||||
func (m *ShimManager) addPathToShells() error {
|
||||
primary := alias.PrimaryShellName()
|
||||
for _, shell := range m.config.Shells {
|
||||
configPath := filepath.Join(m.config.HomeDir, shell.Path())
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
files, err := shell.InstallRcFiles(m.config.HomeDir, shell.Name() == primary)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, configPath := range files {
|
||||
m.addPathToFile(configPath, shell)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
continue
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
log.Warnf("Warning: skipping %s (%s)", shell.Name(), err)
|
||||
continue
|
||||
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", shell.Name(), closeErr)
|
||||
log.Warnf("Warning: failed to close %s: %s", configPath, closeErr)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("Warning: failed to write PATH export to %s: %s", shell.Name(), err)
|
||||
log.Warnf("Warning: failed to write PATH export to %s: %s", configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ShimManager) removePathFromShells() error {
|
||||
drop := func(line string) bool {
|
||||
return strings.Contains(line, shimMarker)
|
||||
}
|
||||
|
||||
for _, shell := range m.config.Shells {
|
||||
configPath := filepath.Join(m.config.HomeDir, shell.Path())
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
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)
|
||||
for _, configPath := range shell.CandidateRcFiles(m.config.HomeDir) {
|
||||
if err := alias.RewriteFileDroppingLines(configPath, drop); err != nil {
|
||||
log.Warnf("Warning: failed to update %s: %s", configPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -197,4 +197,31 @@ func (s *stubShell) PathExport(binDir string) string {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ func main() {
|
||||
fmt.Println(command.UsageString())
|
||||
})
|
||||
|
||||
defer analytics.Close()
|
||||
defer func() {
|
||||
if err := eventlog.Close(); err != nil {
|
||||
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.TrackCI()
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
Executable
+113
@@ -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"
|
||||
Executable
+72
@@ -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"
|
||||
@@ -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!"
|
||||
@@ -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!"
|
||||
Reference in New Issue
Block a user