feat: add pmg config get/set/edit CLI commands (#262)

This commit is contained in:
Sahil Bansal
2026-05-16 09:55:54 +05:30
committed by GitHub
parent 4de9f84c0c
commit c78287e5a0
12 changed files with 781 additions and 24 deletions
+57 -5
View File
@@ -1,6 +1,7 @@
package setup package config
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -8,12 +9,63 @@ import (
"runtime" "runtime"
"strings" "strings"
"github.com/safedep/pmg/config" appConfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/shellwords" "github.com/safedep/pmg/internal/shellwords"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func NewEditCommand() *cobra.Command { func NewConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "View and modify PMG configuration",
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newGetCommand())
cmd.AddCommand(newSetCommand())
cmd.AddCommand(newEditCommand())
return cmd
}
func newGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <key>",
Short: "Get a config value by dot-notation key (output is JSON)",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
value, err := appConfig.GetConfigValue(args[0])
if err != nil {
return err
}
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal value: %w", err)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
},
}
}
func newSetCommand() *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
Short: "Set a config value by dot-notation key",
Args: cobra.ExactArgs(2),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return appConfig.SetConfigValue(args[0], args[1])
},
}
}
func newEditCommand() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "edit", Use: "edit",
Short: "Open the PMG config file in your default editor", Short: "Open the PMG config file in your default editor",
@@ -33,11 +85,11 @@ If the config file does not exist, a template is created first.`,
} }
func runEdit() error { func runEdit() error {
cfg := config.Get() cfg := appConfig.Get()
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) {
if err := config.WriteTemplateConfig(); err != nil { if err := appConfig.WriteTemplateConfig(); err != nil {
return fmt.Errorf("failed to create config file: %w", err) return fmt.Errorf("failed to create config file: %w", err)
} }
} else if err != nil { } else if err != nil {
-1
View File
@@ -30,7 +30,6 @@ func NewSetupCommand() *cobra.Command {
setupCmd.AddCommand(NewInstallCommand()) setupCmd.AddCommand(NewInstallCommand())
setupCmd.AddCommand(NewRemoveCommand()) setupCmd.AddCommand(NewRemoveCommand())
setupCmd.AddCommand(NewInfoCommand()) setupCmd.AddCommand(NewInfoCommand())
setupCmd.AddCommand(NewEditCommand())
return setupCmd return setupCmd
} }
+2
View File
@@ -12,6 +12,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/spf13/viper"
) )
const ( const (
@@ -178,6 +179,7 @@ type RuntimeConfig struct {
configDir string configDir string
configFilePath string configFilePath string
eventLogDir string eventLogDir string
viper *viper.Viper
} }
// CloudSyncDBPath returns the path to the cloud sync WAL database. // CloudSyncDBPath returns the path to the cloud sync WAL database.
+1 -1
View File
@@ -166,4 +166,4 @@ cloud:
enabled: false enabled: false
# Endpoint ID is not required. By default, it falls back to the machine's hostname. # Endpoint ID is not required. By default, it falls back to the machine's hostname.
# Set it only if you want to explicitly override the identifier for this endpoint. # Set it only if you want to explicitly override the identifier for this endpoint.
# endpoint_id: "my-machine" endpoint_id: ""
+7 -2
View File
@@ -45,6 +45,7 @@ func loadViperConfig() error {
} }
globalConfig.Config = merged globalConfig.Config = merged
globalConfig.viper = v
// Resolve proxy config: new proxy section > legacy flat keys. // Resolve proxy config: new proxy section > legacy flat keys.
// Viper can't distinguish "value from template" vs "value from user config" // Viper can't distinguish "value from template" vs "value from user config"
@@ -80,10 +81,14 @@ func hasProxySectionInFile(path string) bool {
// over legacy config file keys to respect the documented precedence order. // over legacy config file keys to respect the documented precedence order.
func applyProxyLegacyFallback(v *viper.Viper) { func applyProxyLegacyFallback(v *viper.Viper) {
if os.Getenv("PMG_PROXY_ENABLED") == "" && v.IsSet("proxy_mode") { if os.Getenv("PMG_PROXY_ENABLED") == "" && v.IsSet("proxy_mode") {
globalConfig.Config.Proxy.Enabled = v.GetBool("proxy_mode") val := v.GetBool("proxy_mode")
globalConfig.Config.Proxy.Enabled = val
v.Set("proxy.enabled", val)
} }
if os.Getenv("PMG_PROXY_INSTALL_ONLY") == "" && v.IsSet("proxy_install_only") { if os.Getenv("PMG_PROXY_INSTALL_ONLY") == "" && v.IsSet("proxy_install_only") {
globalConfig.Config.Proxy.InstallOnly = v.GetBool("proxy_install_only") val := v.GetBool("proxy_install_only")
globalConfig.Config.Proxy.InstallOnly = val
v.Set("proxy.install_only", val)
} }
} }
+32
View File
@@ -9,6 +9,38 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestLegacyProxyFallbackSyncsToViper(t *testing.T) {
t.Run("proxy_mode synced to viper for GetConfigValue", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("proxy_mode: false\n"), 0o644)
require.NoError(t, err)
initConfig()
val, err := GetConfigValue("proxy.enabled")
require.NoError(t, err)
assert.Equal(t, false, val)
})
t.Run("proxy_install_only synced to viper for GetConfigValue", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("proxy_install_only: true\n"), 0o644)
require.NoError(t, err)
initConfig()
val, err := GetConfigValue("proxy.install_only")
require.NoError(t, err)
assert.Equal(t, true, val)
})
}
func TestNewEnvVarNotOverriddenByLegacyConfigFile(t *testing.T) { func TestNewEnvVarNotOverriddenByLegacyConfigFile(t *testing.T) {
t.Run("PMG_PROXY_ENABLED wins over proxy_mode in config file", func(t *testing.T) { t.Run("PMG_PROXY_ENABLED wins over proxy_mode in config file", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
+219
View File
@@ -0,0 +1,219 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
"github.com/goccy/go-yaml/token"
)
// SetConfigValue updates a config value in the YAML config file on disk.
// 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 err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
if err := ensureConfigFileExists(configPath); err != nil {
return err
}
fi, err := os.Stat(configPath)
if err != nil {
return fmt.Errorf("failed to stat config file: %w", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
result, err := setValueInYAML(data, key, value)
if err != nil {
return fmt.Errorf("failed to set config value: %w", err)
}
if err := os.WriteFile(configPath, result, fi.Mode()); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
func GetConfigValue(key string) (any, error) {
if key == "" {
return nil, fmt.Errorf("key cannot be empty")
}
v := Get().viper
if v == nil {
return nil, fmt.Errorf("config not initialized")
}
if !v.IsSet(key) {
return nil, fmt.Errorf("unknown config key: %s", key)
}
return v.Get(key), nil
}
func setValueInYAML(data []byte, key, value string) ([]byte, error) {
if key == "" {
return nil, fmt.Errorf("key cannot be empty")
}
file, err := parser.ParseBytes(data, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
if len(file.Docs) == 0 {
return nil, fmt.Errorf("no documents found in YAML")
}
root, ok := file.Docs[0].Body.(*ast.MappingNode)
if !ok {
return nil, fmt.Errorf("root is not a mapping node")
}
segments := strings.Split(key, ".")
if err := setValueAtPath(root, segments, value); err != nil {
return nil, err
}
return []byte(file.String()), nil
}
func setValueAtPath(node *ast.MappingNode, segments []string, value string) error {
if len(segments) == 0 {
return fmt.Errorf("key cannot be empty")
}
target := segments[0]
for _, mv := range node.Values {
if mv.Key.String() != target {
continue
}
if len(segments) == 1 {
return replaceScalarValue(mv, value)
}
childMapping, ok := mv.Value.(*ast.MappingNode)
if !ok {
return fmt.Errorf("key not found: intermediate key %q is not a mapping", target)
}
return setValueAtPath(childMapping, segments[1:], value)
}
return fmt.Errorf("key not found: %q", target)
}
func replaceScalarValue(mv *ast.MappingValueNode, value string) error {
if mv.Value == nil {
return fmt.Errorf("cannot set value: %q has no existing value", mv.Key.String())
}
pos := mv.Value.GetToken().Position
switch mv.Value.(type) {
case *ast.MappingNode:
return fmt.Errorf("cannot set value on non-scalar node: %q is a mapping", mv.Key.String())
case *ast.SequenceNode:
return fmt.Errorf("cannot set value on non-scalar node: %q is a sequence", mv.Key.String())
case *ast.StringNode:
newNode, err := createStringNode(value, pos)
if err != nil {
return err
}
return mv.Replace(newNode)
case *ast.BoolNode:
if value != "true" && value != "false" {
return fmt.Errorf("invalid value %q for %q: expected true or false", value, mv.Key.String())
}
case *ast.IntegerNode:
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
return fmt.Errorf("invalid value %q for %q: expected an integer", value, mv.Key.String())
}
default:
return fmt.Errorf("unsupported node type for key %q: %T", mv.Key.String(), mv.Value)
}
newNode, err := createScalarNode(value, pos)
if err != nil {
return err
}
return mv.Replace(newNode)
}
func createStringNode(value string, pos *token.Position) (ast.Node, error) {
newPos := &token.Position{
Line: pos.Line,
Column: pos.Column,
Offset: pos.Offset,
}
if needsQuoting(value) {
tk := token.New(value, value, newPos)
tk.Type = token.DoubleQuoteType
return ast.String(tk), nil
}
tk := token.String(value, value, newPos)
return ast.String(tk), nil
}
func needsQuoting(value string) bool {
if value == "" {
return true
}
if value == "true" || value == "false" || value == "null" ||
value == "True" || value == "False" || value == "yes" || value == "no" {
return true
}
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
return true
}
if _, err := strconv.ParseFloat(value, 64); err == nil {
return true
}
return false
}
func createScalarNode(value string, pos *token.Position) (ast.Node, error) {
newPos := &token.Position{
Line: pos.Line,
Column: pos.Column,
Offset: pos.Offset,
}
if value == "true" || value == "false" {
tk := token.New(value, value, newPos)
return ast.Bool(tk), nil
}
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
tk := token.New(value, value, newPos)
return ast.Integer(tk), nil
}
tk := token.String(value, value, newPos)
return ast.String(tk), nil
}
func ensureConfigFileExists(path string) error {
_, err := os.Stat(path)
if err == nil {
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("failed to stat config file %q: %w", path, err)
}
return WriteTemplateConfig()
}
+435
View File
@@ -0,0 +1,435 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
"github.com/goccy/go-yaml/token"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_setValueInYAML(t *testing.T) {
tests := []struct {
name string
input string
key string
value string
expected string
wantErr string
}{
{
name: "set top-level bool to true",
input: "transitive: true\nparanoid: false\n",
key: "paranoid",
value: "true",
expected: "transitive: true\nparanoid: true\n",
},
{
name: "set top-level bool to false",
input: "transitive: true\nparanoid: true\n",
key: "paranoid",
value: "false",
expected: "transitive: true\nparanoid: false\n",
},
{
name: "set top-level integer",
input: "transitive_depth: 5\n",
key: "transitive_depth",
value: "10",
expected: "transitive_depth: 10\n",
},
{
name: "set top-level string",
input: "verbosity: normal\n",
key: "verbosity",
value: "verbose",
expected: "verbosity: verbose\n",
},
{
name: "set nested key",
input: "cloud:\n enabled: false\n endpoint_id: \"\"\n",
key: "cloud.enabled",
value: "true",
expected: "cloud:\n enabled: true\n endpoint_id: \"\"\n",
},
{
name: "set deeply nested key",
input: "dependency_cooldown:\n enabled: true\n days: 5\n",
key: "dependency_cooldown.days",
value: "10",
expected: "dependency_cooldown:\n enabled: true\n days: 10\n",
},
{
name: "preserve comments",
input: "# Important setting\ntransitive: true\n# Paranoid mode\nparanoid: false\n",
key: "paranoid",
value: "true",
expected: "# Important setting\ntransitive: true\n# Paranoid mode\nparanoid: true\n",
},
{
name: "set string with spaces",
input: "verbosity: normal\n",
key: "verbosity",
value: "my custom value",
expected: "verbosity: my custom value\n",
},
{
name: "set same value is idempotent",
input: "paranoid: false\n",
key: "paranoid",
value: "false",
expected: "paranoid: false\n",
},
{
name: "error on empty key",
input: "transitive: true\n",
key: "",
value: "false",
wantErr: "key cannot be empty",
},
{
name: "error on nonexistent key",
input: "transitive: true\n",
key: "nonexistent",
value: "false",
wantErr: "key not found",
},
{
name: "error on nonexistent nested key",
input: "cloud:\n enabled: false\n",
key: "cloud.nonexistent",
value: "true",
wantErr: "key not found",
},
{
name: "error when setting non-leaf node",
input: "cloud:\n enabled: false\n",
key: "cloud",
value: "true",
wantErr: "cannot set value on non-scalar node",
},
{
name: "error when intermediate key is not a mapping",
input: "transitive: true\n",
key: "transitive.nested",
value: "true",
wantErr: "intermediate key",
},
{
name: "error on sequence node target",
input: "trusted_packages:\n - purl: pkg:npm/foo\n reason: test\n",
key: "trusted_packages",
value: "true",
wantErr: "cannot set value on non-scalar node",
},
{
name: "error on invalid bool value",
input: "paranoid: false\n",
key: "paranoid",
value: "falce",
wantErr: "invalid value",
},
{
name: "error on non-integer for integer field",
input: "transitive_depth: 5\n",
key: "transitive_depth",
value: "abc",
wantErr: "invalid value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := setValueInYAML([]byte(tt.input), tt.key, tt.value)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, string(result))
})
}
}
func Test_createScalarNode(t *testing.T) {
pos := &token.Position{Line: 1, Column: 1, Offset: 0}
tests := []struct {
name string
value string
expectedType ast.NodeType
}{
{name: "true is bool", value: "true", expectedType: ast.BoolType},
{name: "false is bool", value: "false", expectedType: ast.BoolType},
{name: "positive int", value: "42", expectedType: ast.IntegerType},
{name: "zero is int", value: "0", expectedType: ast.IntegerType},
{name: "negative int", value: "-5", expectedType: ast.IntegerType},
{name: "plain string", value: "hello", expectedType: ast.StringType},
{name: "float-like is string", value: "3.14", expectedType: ast.StringType},
{name: "True (capitalized) is string", value: "True", expectedType: ast.StringType},
{name: "empty string", value: "", expectedType: ast.StringType},
{name: "numeric-prefix string", value: "123abc", expectedType: ast.StringType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node, err := createScalarNode(tt.value, pos)
require.NoError(t, err)
assert.Equal(t, tt.expectedType, node.Type())
})
}
}
func TestSetConfigValue(t *testing.T) {
t.Run("creates config from template and sets value", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
initConfig()
err := SetConfigValue("paranoid", "true")
require.NoError(t, err)
data, err := os.ReadFile(filepath.Join(tmpDir, "config.yml"))
require.NoError(t, err)
assert.Contains(t, string(data), "paranoid: true")
})
t.Run("updates existing config preserving other values", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
initConfig()
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("transitive: true\nparanoid: false\n"), 0o644)
require.NoError(t, err)
err = SetConfigValue("paranoid", "true")
require.NoError(t, err)
data, err := os.ReadFile(configPath)
require.NoError(t, err)
assert.Contains(t, string(data), "transitive: true")
assert.Contains(t, string(data), "paranoid: true")
})
t.Run("returns error for nonexistent key", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
initConfig()
err := os.WriteFile(filepath.Join(tmpDir, "config.yml"), []byte("paranoid: false\n"), 0o644)
require.NoError(t, err)
err = SetConfigValue("nonexistent", "true")
require.Error(t, err)
assert.Contains(t, err.Error(), "key not found")
})
}
func TestGetConfigValue(t *testing.T) {
configYAML := "paranoid: true\ntransitive: false\ntransitive_depth: 10\nverbosity: verbose\n" +
"cloud:\n enabled: true\n endpoint_id: ep-123\n" +
"dependency_cooldown:\n enabled: true\n days: 7\n" +
"proxy:\n enabled: false\n install_only: true\n" +
"sandbox:\n enabled: true\n enforce_always: false\n"
setupConfig := func(t *testing.T) {
t.Helper()
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
err := os.WriteFile(filepath.Join(tmpDir, "config.yml"), []byte(configYAML), 0o644)
require.NoError(t, err)
initConfig()
}
tests := []struct {
name string
key string
expected any
wantErr string
}{
{name: "top-level bool true", key: "paranoid", expected: true},
{name: "top-level bool false", key: "transitive", expected: false},
{name: "top-level integer", key: "transitive_depth", expected: 10},
{name: "top-level string", key: "verbosity", expected: "verbose"},
{name: "nested bool", key: "cloud.enabled", expected: true},
{name: "nested string", key: "cloud.endpoint_id", expected: "ep-123"},
{name: "nested integer", key: "dependency_cooldown.days", expected: 7},
{name: "nested bool under proxy", key: "proxy.enabled", expected: false},
{name: "nested bool under proxy install_only", key: "proxy.install_only", expected: true},
{name: "nested bool under sandbox", key: "sandbox.enabled", expected: true},
{name: "nested bool under sandbox enforce_always", key: "sandbox.enforce_always", expected: false},
{name: "error on empty key", key: "", wantErr: "key cannot be empty"},
{name: "error on unknown top-level key", key: "totally_bogus", wantErr: "unknown config key"},
{name: "error on unknown nested key", key: "cloud.nonexistent", wantErr: "unknown config key"},
{name: "error on too-deep key", key: "cloud.enabled.deep", wantErr: "unknown config key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setupConfig(t)
val, err := GetConfigValue(tt.key)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, val)
})
}
t.Run("returns nested object as map", func(t *testing.T) {
setupConfig(t)
val, err := GetConfigValue("cloud")
require.NoError(t, err)
m, ok := val.(map[string]any)
require.True(t, ok, "expected map[string]any, got %T", val)
assert.Equal(t, true, m["enabled"])
assert.Equal(t, "ep-123", m["endpoint_id"])
})
t.Run("returns defaults when no config file exists", func(t *testing.T) {
t.Setenv("PMG_CONFIG_DIR", "/tmp/pmg-test/random-does-not-exist")
initConfig()
val, err := GetConfigValue("transitive")
require.NoError(t, err)
assert.Equal(t, true, val)
val, err = GetConfigValue("paranoid")
require.NoError(t, err)
assert.Equal(t, false, val)
})
t.Run("env var overrides config file", func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
t.Setenv("PMG_PARANOID", "true")
err := os.WriteFile(filepath.Join(tmpDir, "config.yml"), []byte("paranoid: false\n"), 0o644)
require.NoError(t, err)
initConfig()
val, err := GetConfigValue("paranoid")
require.NoError(t, err)
// Viper returns env var values as strings
assert.Equal(t, "true", val)
})
}
func TestSetStringFieldPreservesType(t *testing.T) {
tests := []struct {
name string
input string
key string
value string
}{
{
name: "string field set to bool-like value stays string",
input: "verbosity: normal\n",
key: "verbosity",
value: "true",
},
{
name: "string field set to integer-like value stays string",
input: "verbosity: normal\n",
key: "verbosity",
value: "42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := setValueInYAML([]byte(tt.input), tt.key, tt.value)
require.NoError(t, err)
file, err := parser.ParseBytes(result, parser.ParseComments)
require.NoError(t, err)
root := file.Docs[0].Body.(*ast.MappingNode)
for _, mv := range root.Values {
if mv.Key.String() == tt.key {
assert.Equal(t, ast.StringType, mv.Value.Type(),
"expected StringType but got %s", mv.Value.Type())
return
}
}
t.Fatalf("key %q not found in result", tt.key)
})
}
}
func Test_needsQuoting(t *testing.T) {
tests := []struct {
value string
expected bool
}{
{"true", true},
{"false", true},
{"True", true},
{"False", true},
{"yes", true},
{"no", true},
{"null", true},
{"42", true},
{"-5", true},
{"0", true},
{"3.14", true},
{"hello", false},
{"normal", false},
{"verbose", false},
{"", true},
{"123abc", false},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
assert.Equal(t, tt.expected, needsQuoting(tt.value))
})
}
}
func TestSetThenGetRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte("paranoid: false\ntransitive_depth: 5\nverbosity: normal\n"), 0o644)
require.NoError(t, err)
initConfig()
err = SetConfigValue("paranoid", "true")
require.NoError(t, err)
err = SetConfigValue("transitive_depth", "20")
require.NoError(t, err)
err = SetConfigValue("verbosity", "silent")
require.NoError(t, err)
// Reload global config from file to pick up changes
initConfig()
val, err := GetConfigValue("paranoid")
require.NoError(t, err)
assert.Equal(t, true, val)
val, err = GetConfigValue("transitive_depth")
require.NoError(t, err)
assert.Equal(t, 20, val)
val, err = GetConfigValue("verbosity")
require.NoError(t, err)
assert.Equal(t, "silent", val)
}
+22 -1
View File
@@ -15,9 +15,23 @@ pmg setup info
To edit configuration file: To edit configuration file:
```bash ```bash
pmg setup edit pmg config edit
``` ```
To get a config value (output is JSON):
```bash
pmg config get paranoid
pmg config get cloud.enabled
```
To set a config value:
```bash
pmg config set paranoid true
pmg config set transitive_depth 10
pmg config set cloud.enabled true
```
See [config template](../config/config.template.yml) for the configuration schema. See [config template](../config/config.template.yml) for the configuration schema.
## Environment Variables ## Environment Variables
@@ -58,3 +72,10 @@ PMG_PROXY_INSTALL_ONLY=true pmg npm install express
3. Config file (`config.yml`) 3. Config file (`config.yml`)
4. Built-in defaults 4. Built-in defaults
**Limitation**
- `config set` can only update keys that are present and uncommented in the config file.
If a key is commented out (e.g. `# endpoint_id: "my-machine"`) or missing entirely, `set` will
return a "key not found" error. To fix this, uncomment or add the key manually via `pmg config edit`,
or run `pmg setup install` to merge missing template keys into your config.
+1 -1
View File
@@ -8,6 +8,7 @@ require (
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/elazarl/goproxy v1.8.1 github.com/elazarl/goproxy v1.8.1
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/goccy/go-yaml v1.19.2
github.com/google/osv-scalibr v0.2.1 github.com/google/osv-scalibr v0.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jedib0t/go-pretty/v6 v6.7.9 github.com/jedib0t/go-pretty/v6 v6.7.9
@@ -50,7 +51,6 @@ require (
github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
+2
View File
@@ -8,6 +8,7 @@ import (
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/pmg/cmd/cloud" "github.com/safedep/pmg/cmd/cloud"
configCmd "github.com/safedep/pmg/cmd/config"
"github.com/safedep/pmg/cmd/executors" "github.com/safedep/pmg/cmd/executors"
landlockCmd "github.com/safedep/pmg/cmd/landlock" landlockCmd "github.com/safedep/pmg/cmd/landlock"
"github.com/safedep/pmg/cmd/npm" "github.com/safedep/pmg/cmd/npm"
@@ -136,6 +137,7 @@ func main() {
cmd.AddCommand(setup.NewSetupCommand()) cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand()) cmd.AddCommand(setup.NewRemoveCommand())
cmd.AddCommand(cloud.NewCloudCommand()) cmd.AddCommand(cloud.NewCloudCommand())
cmd.AddCommand(configCmd.NewConfigCommand())
if subcmd := landlockCmd.NewLandlockSandboxExecCommand(); subcmd != nil { if subcmd := landlockCmd.NewLandlockSandboxExecCommand(); subcmd != nil {
cmd.AddCommand(subcmd) cmd.AddCommand(subcmd)
+3 -13
View File
@@ -117,21 +117,11 @@ pmg setup install
# ── Enable cloud sync ─────────────────────────────────────────────────────── # ── Enable cloud sync ───────────────────────────────────────────────────────
if [[ -n "$CLOUD_API_KEY" && -n "$CLOUD_TENANT_ID" ]]; then if [[ -n "$CLOUD_API_KEY" && -n "$CLOUD_TENANT_ID" ]]; then
log "Enabling cloud sync" log "Enabling cloud sync"
pmg config set cloud.enabled true
CONFIG_FILE="${HOME}/Library/Application Support/safedep/pmg/config.yml" log "Cloud sync enabled in config"
if [[ -f "$CONFIG_FILE" ]]; then
awk '
/^cloud:/ { in_cloud=1 }
in_cloud && /^ enabled: false/ { sub(/enabled: false/, "enabled: true"); in_cloud=0 }
/^[a-z]/ && !/^cloud:/ { in_cloud=0 }
{ print }
' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
log "Cloud sync enabled in config"
fi
SAFEDEP_API_KEY="$CLOUD_API_KEY" SAFEDEP_TENANT_ID="$CLOUD_TENANT_ID" pmg cloud login --from-env SAFEDEP_API_KEY="$CLOUD_API_KEY" SAFEDEP_TENANT_ID="$CLOUD_TENANT_ID" pmg cloud login --from-env
log "Credentials stored securely" log "Credentials stored in macOS Keychain"
fi fi
# ── Done ───────────────────────────────────────────────────────────────────── # ── Done ─────────────────────────────────────────────────────────────────────