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 {
|
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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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