mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
co-authored by
Claude Fable 5
parent
bafd3c0654
commit
695a1d739d
+2
-46
@@ -5,12 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
appConfig "github.com/safedep/pmg/config"
|
appConfig "github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/internal/shellwords"
|
"github.com/safedep/pmg/internal/editor"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,46 +97,5 @@ func runEdit() error {
|
|||||||
return fmt.Errorf("failed to stat config file %q: %w", path, err)
|
return fmt.Errorf("failed to stat config file %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
editor, err := resolveEditor()
|
return editor.Open(path)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func NewProfileCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(newProfileListCommand(defaultRegistryFactory))
|
cmd.AddCommand(newProfileListCommand(defaultRegistryFactory))
|
||||||
cmd.AddCommand(newProfileShowCommand(defaultRegistryFactory))
|
cmd.AddCommand(newProfileShowCommand(defaultRegistryFactory))
|
||||||
cmd.AddCommand(newProfileInitCommand(defaultRegistryFactory))
|
cmd.AddCommand(newProfileInitCommand(defaultRegistryFactory))
|
||||||
|
cmd.AddCommand(newProfileEditCommand(defaultRegistryFactory))
|
||||||
cmd.AddCommand(newProfileLintCommand(defaultRegistryFactory))
|
cmd.AddCommand(newProfileLintCommand(defaultRegistryFactory))
|
||||||
cmd.AddCommand(newProfileDiffCommand(defaultRegistryFactory))
|
cmd.AddCommand(newProfileDiffCommand(defaultRegistryFactory))
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -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`.",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -158,6 +158,9 @@ pmg sandbox profile list
|
|||||||
# Scaffold a user profile that inherits from a built-in
|
# Scaffold a user profile that inherits from a built-in
|
||||||
pmg sandbox profile init my-npm --from npm-restrictive
|
pmg sandbox profile init my-npm --from npm-restrictive
|
||||||
|
|
||||||
|
# Open a user profile in $VISUAL / $EDITOR, then validate and lint it
|
||||||
|
pmg sandbox profile edit my-npm
|
||||||
|
|
||||||
# Show a profile, or its fully resolved policy
|
# Show a profile, or its fully resolved policy
|
||||||
pmg sandbox profile show npm-restrictive
|
pmg sandbox profile show npm-restrictive
|
||||||
pmg sandbox profile show npm-restrictive --resolved
|
pmg sandbox profile show npm-restrictive --resolved
|
||||||
@@ -170,6 +173,24 @@ pmg sandbox profile lint ./my-profile.yml --strict
|
|||||||
pmg sandbox profile diff npm-restrictive pypi-restrictive
|
pmg sandbox profile diff npm-restrictive pypi-restrictive
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pmg sandbox profile edit` resolves the profile name to its file under the user profile
|
||||||
|
directory, opens it in your editor, and re-validates and lints the result when the editor
|
||||||
|
exits. Built-in profiles are embedded in the binary and cannot be edited; create an editable
|
||||||
|
copy with `pmg sandbox profile init <new-name> --from <builtin>`.
|
||||||
|
|
||||||
|
To activate a user profile for a package manager, reference it by name in `config.yml` — no
|
||||||
|
policy template is needed for profiles in the user profile directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
policies:
|
||||||
|
pnpm:
|
||||||
|
enabled: true
|
||||||
|
profile: my-pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Policy templates (see below) are only needed to override a built-in profile name or to load a
|
||||||
|
policy file from outside the user profile directory.
|
||||||
|
|
||||||
### Sandbox Debug Commands
|
### Sandbox Debug Commands
|
||||||
|
|
||||||
Use these commands when a sandboxed package manager command fails and you need to inspect why.
|
Use these commands when a sandboxed package manager command fails and you need to inspect why.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Package editor resolves and launches the user's preferred text editor.
|
||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/internal/shellwords"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolve returns the editor command from $VISUAL, then $EDITOR, then a
|
||||||
|
// platform default (vi on Unix, notepad on Windows).
|
||||||
|
func Resolve() (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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open launches the resolved editor on path, attached to the current
|
||||||
|
// terminal, and waits for it to exit. Multi-word values like "code --wait"
|
||||||
|
// are split with POSIX-like quoting rules, never through a shell.
|
||||||
|
func Open(path string) error {
|
||||||
|
editor, err := Resolve()
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(parts[0], append(parts[1:], path)...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("editor %q failed: %w", editor, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeScript(t *testing.T, dir, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("editor scripts require /bin/sh")
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "editor.sh")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_VisualWins(t *testing.T) {
|
||||||
|
t.Setenv("VISUAL", "visual-editor")
|
||||||
|
t.Setenv("EDITOR", "other-editor")
|
||||||
|
|
||||||
|
editor, err := Resolve()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "visual-editor", editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EditorFallback(t *testing.T) {
|
||||||
|
t.Setenv("VISUAL", "")
|
||||||
|
t.Setenv("EDITOR", "fallback-editor")
|
||||||
|
|
||||||
|
editor, err := Resolve()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "fallback-editor", editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv("VISUAL", " vim ")
|
||||||
|
t.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
editor, err := Resolve()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "vim", editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_PlatformDefault(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unix default only")
|
||||||
|
}
|
||||||
|
t.Setenv("VISUAL", "")
|
||||||
|
t.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
editor, err := Resolve()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "vi", editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_RunsEditorWithPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "target.txt")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("before\n"), 0o644))
|
||||||
|
|
||||||
|
t.Setenv("VISUAL", "")
|
||||||
|
t.Setenv("EDITOR", writeScript(t, dir, `echo edited >> "$1"`))
|
||||||
|
|
||||||
|
require.NoError(t, Open(target))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "before\nedited\n", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_QuotedMultiWordEditor(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
dir := filepath.Join(base, "dir with space")
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
out := filepath.Join(base, "argv.txt")
|
||||||
|
script := writeScript(t, dir, `printf '%s\n' "$@" > `+"'"+out+"'")
|
||||||
|
|
||||||
|
t.Setenv("VISUAL", "'"+script+"' --wait")
|
||||||
|
t.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
require.NoError(t, Open("/some/target"))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "--wait\n/some/target\n", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_EditorExitNonZero(t *testing.T) {
|
||||||
|
t.Setenv("VISUAL", "")
|
||||||
|
t.Setenv("EDITOR", writeScript(t, t.TempDir(), `exit 3`))
|
||||||
|
|
||||||
|
err := Open("/some/target")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "editor")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen_InvalidQuoting(t *testing.T) {
|
||||||
|
t.Setenv("VISUAL", "'unterminated")
|
||||||
|
t.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
err := Open("/some/target")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid editor command")
|
||||||
|
}
|
||||||
+4
-1
@@ -76,7 +76,10 @@ function testPackageManager(pm) {
|
|||||||
try {
|
try {
|
||||||
// Initialize project
|
// Initialize project
|
||||||
test(`${pm}: Initialize project`, () => {
|
test(`${pm}: Initialize project`, () => {
|
||||||
const initCmd = pm === 'npm' ? 'npm init -y' : 'pnpm init';
|
// npm init even for pnpm: `pnpm init` writes devEngines.packageManager
|
||||||
|
// with onFail:download, and the next `pnpm add` crashes with
|
||||||
|
// "Cannot use 'in' operator to search for 'integrity' in undefined".
|
||||||
|
const initCmd = 'npm init -y';
|
||||||
const result = exec(initCmd, { cwd: testDir, env });
|
const result = exec(initCmd, { cwd: testDir, env });
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.log(` ❌ FAIL: ${result.error}`);
|
console.log(` ❌ FAIL: ${result.error}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user