feat: Add support for sandbox edit command (#385)

* feat: Add support for sandbox edit command

* docs: Update sandbox docs

* fix(editor): address review — neutral failure wording, skip sh-script tests on Windows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): avoid pnpm init in pm-e2e — devEngines.packageManager crashes subsequent pnpm add

Same workaround and rationale as the PNPM job in pmg-e2e.yml. Latest pnpm
(unpinned pnpm/action-setup) writes devEngines.packageManager with
onFail:download on init, and the next add fails with "Cannot use 'in'
operator to search for 'integrity' in undefined".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abhisek Datta
2026-07-18 22:53:39 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent bafd3c0654
commit 695a1d739d
8 changed files with 478 additions and 47 deletions
+2 -46
View File
@@ -5,12 +5,9 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
appConfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/shellwords"
"github.com/safedep/pmg/internal/editor"
"github.com/spf13/cobra"
)
@@ -100,46 +97,5 @@ func runEdit() error {
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")
return editor.Open(path)
}
+1
View File
@@ -28,6 +28,7 @@ func NewProfileCommand() *cobra.Command {
cmd.AddCommand(newProfileListCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileShowCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileInitCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileEditCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileLintCommand(defaultRegistryFactory))
cmd.AddCommand(newProfileDiffCommand(defaultRegistryFactory))
return cmd
+88
View File
@@ -0,0 +1,88 @@
package sandbox
import (
"fmt"
"io"
"github.com/safedep/pmg/errcodes"
"github.com/safedep/pmg/internal/editor"
pmgsandbox "github.com/safedep/pmg/sandbox"
"github.com/spf13/cobra"
)
func newProfileEditCommand(factory registryFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "edit <name>",
Short: "Open a user sandbox profile in $VISUAL / $EDITOR and validate the result",
Example: " pmg sandbox profile edit pnpm-custom",
Args: cobra.ExactArgs(1),
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
err := runProfileEdit(cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], factory)
if err != nil {
return sandboxErrorExit(cmd, err)
}
return err
},
}
return cmd
}
func runProfileEdit(out, errOut io.Writer, name string, factory registryFactory) error {
registry, err := factory()
if err != nil {
return registryInitError(err)
}
path, shadowed, err := findEditableUserProfile(registry, name)
if err != nil {
return err
}
if shadowed {
if _, err := fmt.Fprintf(errOut,
"Warning: user profile %q is shadowed by a built-in profile of the same name — pmg resolves the built-in, so edits here have no effect.\n",
name); err != nil {
return err
}
}
if err := editor.Open(path); err != nil {
return wrapUseful(err, errcodes.InvalidArgument,
"Set $VISUAL or $EDITOR to a working editor command and retry.")
}
policy, err := registry.LoadCustomProfile(path)
if err != nil {
return profileLoadError(err)
}
issues := filterInfo(pmgsandbox.LintProfile(policy))
return renderLintHuman(out, path, issues)
}
func findEditableUserProfile(registry pmgsandbox.ProfileRegistry, name string) (string, bool, error) {
profiles, err := registry.ListUserProfiles()
if err != nil {
return "", false, wrapUseful(err, ioErrorCode(err, errcodes.Unknown),
"Failed to enumerate user sandbox profiles. Check the user profile directory permissions.")
}
for _, p := range profiles {
if p.Name == name {
return p.Path, p.Shadowed, nil
}
}
if _, ok := registry.BuiltinProfileYAML(name); ok {
return "", false, invalidArgumentError(
fmt.Sprintf("cannot edit built-in profile %q: built-in profiles are embedded in the pmg binary", name),
fmt.Sprintf("Create an editable copy with `pmg sandbox profile init %s-custom --from %s`.", name, name),
)
}
return "", false, notFoundError(
fmt.Sprintf("no user sandbox profile named %q", name),
"Use `pmg sandbox profile list` to see available profiles, or scaffold one with `pmg sandbox profile init`.",
)
}
+190
View File
@@ -0,0 +1,190 @@
package sandbox
import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/errcodes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const editTestProfile = `name: myprof
description: test profile
inherits: npm-restrictive
package_managers:
- npm
filesystem:
allow_write:
- ${CWD}/extra
`
func writeEditTestProfile(t *testing.T, dir, name string) string {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0o755))
path := filepath.Join(dir, name+".yml")
require.NoError(t, os.WriteFile(path, []byte(editTestProfile), 0o644))
return path
}
func writeEditorScript(t *testing.T, body string) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("editor scripts require /bin/sh")
}
path := filepath.Join(t.TempDir(), "editor.sh")
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755))
return path
}
func runEditCmd(t *testing.T, dir string, args ...string) (string, string, error) {
t.Helper()
cmd := newProfileEditCommand(newTestRegistryFactory(t, dir))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func TestProfileEdit_HappyPath(t *testing.T) {
dir := t.TempDir()
path := writeEditTestProfile(t, dir, "myprof")
editor := writeEditorScript(t, `printf ' - ${CWD}/more\n' >> "$1"`)
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", editor)
stdout, stderr, err := runEditCmd(t, dir, "myprof")
require.NoError(t, err, "stderr: %s", stderr)
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), "${CWD}/more")
assert.Contains(t, stdout, "Profile Lint")
}
func TestProfileEdit_VisualTakesPrecedenceOverEditor(t *testing.T) {
dir := t.TempDir()
path := writeEditTestProfile(t, dir, "myprof")
visual := writeEditorScript(t, `printf ' - ${CWD}/from-visual\n' >> "$1"`)
editor := writeEditorScript(t, `printf ' - ${CWD}/from-editor\n' >> "$1"`)
t.Setenv("VISUAL", visual)
t.Setenv("EDITOR", editor)
_, _, err := runEditCmd(t, dir, "myprof")
require.NoError(t, err)
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), "from-visual")
assert.NotContains(t, string(data), "from-editor")
}
func TestProfileEdit_BuiltinRefused(t *testing.T) {
dir := t.TempDir()
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", writeEditorScript(t, `exit 0`))
_, _, err := runEditCmd(t, dir, "npm-restrictive")
require.Error(t, err)
assert.Contains(t, err.Error(), "built-in")
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, errcodes.InvalidArgument, usefulErr.Code())
assert.Contains(t, usefulErr.Help(), "pmg sandbox profile init")
}
func TestProfileEdit_UnknownProfile(t *testing.T) {
dir := t.TempDir()
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", writeEditorScript(t, `exit 0`))
_, _, err := runEditCmd(t, dir, "no-such-profile")
require.Error(t, err)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, errcodes.NotFound, usefulErr.Code())
}
func TestProfileEdit_EditorFailure(t *testing.T) {
dir := t.TempDir()
path := writeEditTestProfile(t, dir, "myprof")
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", writeEditorScript(t, `exit 7`))
_, _, err := runEditCmd(t, dir, "myprof")
require.Error(t, err)
assert.Contains(t, err.Error(), "editor")
data, readErr := os.ReadFile(path)
require.NoError(t, readErr)
assert.Equal(t, editTestProfile, string(data))
}
func TestProfileEdit_InvalidAfterEditKeepsFile(t *testing.T) {
dir := t.TempDir()
path := writeEditTestProfile(t, dir, "myprof")
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", writeEditorScript(t, `printf 'name: [broken\n' > "$1"`))
_, _, err := runEditCmd(t, dir, "myprof")
require.Error(t, err)
usefulErr, ok := usefulerror.AsUsefulError(err)
require.True(t, ok)
assert.Equal(t, errcodes.InvalidArgument, usefulErr.Code())
data, readErr := os.ReadFile(path)
require.NoError(t, readErr)
assert.Contains(t, string(data), "name: [broken")
}
func TestProfileEdit_LintWarningsShown(t *testing.T) {
dir := t.TempDir()
writeEditTestProfile(t, dir, "myprof")
editor := writeEditorScript(t, `printf ' - /**\n' >> "$1"`)
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", editor)
stdout, _, err := runEditCmd(t, dir, "myprof")
require.NoError(t, err)
assert.Contains(t, stdout, "WARN")
}
func TestProfileEdit_ShadowedProfileWarns(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(dir, 0o755))
shadow := `name: npm
package_managers:
- npm
filesystem:
allow_write:
- ${CWD}/extra
`
path := filepath.Join(dir, "npm.yml")
require.NoError(t, os.WriteFile(path, []byte(shadow), 0o644))
editor := writeEditorScript(t, `printf ' - ${CWD}/more\n' >> "$1"`)
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", editor)
_, stderr, err := runEditCmd(t, dir, "npm")
require.NoError(t, err)
assert.Contains(t, stderr, "shadowed")
data, readErr := os.ReadFile(path)
require.NoError(t, readErr)
assert.Contains(t, string(data), "${CWD}/more")
}