mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: add pmg config get/set/edit CLI commands (#262)
This commit is contained in:
@@ -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 {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user