feat: Add pmg setup edit command (#235)

* feat: Add pmg setup edit command

* fix: Code review fixes
This commit is contained in:
Abhisek Datta
2026-05-04 16:08:29 +00:00
committed by GitHub
parent e269a2f175
commit 27551d5019
4 changed files with 332 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
package setup
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/shellwords"
"github.com/spf13/cobra"
)
func NewEditCommand() *cobra.Command {
return &cobra.Command{
Use: "edit",
Short: "Open the PMG config file in your default editor",
Long: `Open the PMG config file in your default editor.
The editor is resolved in this order:
1. $VISUAL
2. $EDITOR
3. Platform default (vi on Unix, notepad on Windows)
If the config file does not exist, a template is created first.`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runEdit()
},
}
}
func runEdit() error {
cfg := config.Get()
path := cfg.ConfigFilePath()
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
if err := config.WriteTemplateConfig(); err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
} else if err != nil {
return fmt.Errorf("failed to stat config file %q: %w", path, err)
}
editor, err := resolveEditor()
if err != nil {
return err
}
parts, err := shellwords.Split(editor)
if err != nil {
return fmt.Errorf("invalid editor command %q: %w", editor, err)
}
if len(parts) == 0 {
return fmt.Errorf("editor command is empty")
}
parts = append(parts, path)
c := exec.Command(parts[0], parts[1:]...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("editor %q exited with error: %w", editor, err)
}
return nil
}
func resolveEditor() (string, error) {
if v := strings.TrimSpace(os.Getenv("VISUAL")); v != "" {
return v, nil
}
if v := strings.TrimSpace(os.Getenv("EDITOR")); v != "" {
return v, nil
}
if runtime.GOOS == "windows" {
return "notepad", nil
}
if _, err := exec.LookPath("vi"); err == nil {
return "vi", nil
}
return "", fmt.Errorf("no editor found: set $VISUAL or $EDITOR")
}
+1
View File
@@ -28,6 +28,7 @@ func NewSetupCommand() *cobra.Command {
setupCmd.AddCommand(NewInstallCommand())
setupCmd.AddCommand(NewRemoveCommand())
setupCmd.AddCommand(NewInfoCommand())
setupCmd.AddCommand(NewEditCommand())
return setupCmd
}
+104
View File
@@ -0,0 +1,104 @@
// Package shellwords provides POSIX-like splitting of shell command strings
// into argv tokens. It is intentionally minimal: it handles the cases that
// arise from environment variables like $VISUAL or $EDITOR, where users may
// quote paths with spaces or pass flags. It is not a full shell parser — it
// does not perform variable expansion, command substitution, or globbing.
package shellwords
import (
"fmt"
"strings"
)
// Split splits s into tokens using POSIX-like rules:
//
// - Whitespace (space, tab, newline) separates tokens.
// - Single quotes preserve their contents verbatim; no escapes are honored.
// - Double quotes preserve whitespace; backslash escapes only ", \, $, `.
// - Outside quotes a backslash escapes the next character literally.
// - Empty quoted strings ("" or '') produce an empty token.
//
// Split returns an error for unterminated quoted strings or a trailing
// backslash with nothing to escape.
func Split(s string) ([]string, error) {
var (
out []string
buf strings.Builder
inSingle bool
inDouble bool
escaped bool
hasToken bool
)
flush := func() {
if hasToken {
out = append(out, buf.String())
buf.Reset()
hasToken = false
}
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case escaped:
buf.WriteByte(c)
hasToken = true
escaped = false
case inSingle:
if c == '\'' {
inSingle = false
} else {
buf.WriteByte(c)
}
hasToken = true
case inDouble:
switch c {
case '"':
inDouble = false
case '\\':
if i+1 >= len(s) {
return nil, fmt.Errorf("dangling backslash")
}
next := s[i+1]
// Inside double quotes, backslash only escapes a few
// characters; otherwise it is literal.
if next == '"' || next == '\\' || next == '$' || next == '`' {
buf.WriteByte(next)
i++
} else {
buf.WriteByte(c)
}
default:
buf.WriteByte(c)
}
hasToken = true
default:
switch c {
case '\'':
inSingle = true
hasToken = true
case '"':
inDouble = true
hasToken = true
case '\\':
escaped = true
case ' ', '\t', '\n':
flush()
default:
buf.WriteByte(c)
hasToken = true
}
}
}
if inSingle || inDouble {
return nil, fmt.Errorf("unterminated quoted string")
}
if escaped {
return nil, fmt.Errorf("dangling backslash")
}
flush()
return out, nil
}
+138
View File
@@ -0,0 +1,138 @@
package shellwords
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSplit(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr bool
}{
{
name: "empty string",
input: "",
want: nil,
},
{
name: "whitespace only",
input: " \t\n ",
want: nil,
},
{
name: "single token",
input: "vi",
want: []string{"vi"},
},
{
name: "command with flag",
input: "code --wait",
want: []string{"code", "--wait"},
},
{
name: "collapses repeated whitespace",
input: "nvim -p \t -u NONE",
want: []string{"nvim", "-p", "-u", "NONE"},
},
{
name: "leading and trailing whitespace",
input: " vim ",
want: []string{"vim"},
},
{
name: "double-quoted path with spaces",
input: `"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" --wait`,
want: []string{
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
"--wait",
},
},
{
name: "single-quoted path with spaces",
input: `'/usr/local/bin/my editor' -f`,
want: []string{"/usr/local/bin/my editor", "-f"},
},
{
name: "backslash-escaped space",
input: `/usr/local/bin/my\ editor -f`,
want: []string{"/usr/local/bin/my editor", "-f"},
},
{
name: "double quote inside double quotes via backslash",
input: `code -e "say \"hi\""`,
want: []string{"code", "-e", `say "hi"`},
},
{
name: "single quotes inside double quotes are literal",
input: `vim "it's fine"`,
want: []string{"vim", "it's fine"},
},
{
name: "double quotes inside single quotes are literal",
input: `vim 'a "quote" here'`,
want: []string{"vim", `a "quote" here`},
},
{
name: "backslash inside single quotes is literal",
input: `vim 'a\b'`,
want: []string{"vim", `a\b`},
},
{
name: "non-special backslash in double quotes is literal",
input: `vim "a\b"`,
want: []string{"vim", `a\b`},
},
{
name: "adjacent quoted segments concatenate into one token",
input: `vim "foo"'bar'baz`,
want: []string{"vim", "foobarbaz"},
},
{
name: "empty double-quoted token",
input: `cmd "" arg`,
want: []string{"cmd", "", "arg"},
},
{
name: "empty single-quoted token",
input: `cmd '' arg`,
want: []string{"cmd", "", "arg"},
},
{
name: "unterminated double quote",
input: `vim "abc`,
wantErr: true,
},
{
name: "unterminated single quote",
input: `vim 'abc`,
wantErr: true,
},
{
name: "trailing backslash outside quotes",
input: `vim \`,
wantErr: true,
},
{
name: "trailing backslash inside double quotes",
input: `vim "abc\`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Split(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}