Skip to content

Commit 370b308

Browse files
committed
Add remove sub-command
1 parent 27e7606 commit 370b308

File tree

9 files changed

+308
-1
lines changed

9 files changed

+308
-1
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ Or the Vim extension from GitHub:
8585
./code-marketplace add https://github.com/VSCodeVim/Vim/releases/download/v1.24.1/vim-1.24.1.vsix --extensions-dir ./extensions
8686
```
8787

88+
## Removing extensions
89+
90+
Extensions can be removed from the marketplace by ID and version (or use `--all`
91+
to remove all versions).
92+
93+
```
94+
./code-marketplace remove ms-python.python-2022.14.0 --extensions-dir ./extensions
95+
./code-marketplace remove ms-python.python --all --extensions-dir ./extensions
96+
```
97+
8898
## Usage in code-server
8999

90100
```

api/api_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ func (s *fakeStorage) AddExtension(ctx context.Context, source string) (*storage
3030
return nil, errors.New("not implemented")
3131
}
3232

33+
func (s *fakeStorage) RemoveExtension(ctx context.Context, id string, all bool) ([]string, error) {
34+
return nil, errors.New("not implemented")
35+
}
36+
3337
func (s *fakeStorage) FileServer() http.Handler {
3438
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
3539
if r.URL.Path == "/nonexistent" {

cli/remove.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
"cdr.dev/slog"
12+
"cdr.dev/slog/sloggers/sloghuman"
13+
14+
"github.com/coder/code-marketplace/storage"
15+
)
16+
17+
func remove() *cobra.Command {
18+
var (
19+
extdir string
20+
all bool
21+
)
22+
23+
cmd := &cobra.Command{
24+
Use: "remove <id>",
25+
Short: "Remove an extension from the marketplace",
26+
Example: strings.Join([]string{
27+
" marketplace remove publisher.extension-1.0.0 --extensions-dir ./extensions",
28+
" marketplace remove publisher.extension --all --extensions-dir ./extensions",
29+
}, "\n"),
30+
Args: cobra.ExactArgs(1),
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
ctx, cancel := context.WithCancel(cmd.Context())
33+
defer cancel()
34+
35+
verbose, err := cmd.Flags().GetBool("verbose")
36+
if err != nil {
37+
return err
38+
}
39+
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
40+
if verbose {
41+
logger = logger.Leveled(slog.LevelDebug)
42+
}
43+
44+
extdir, err = filepath.Abs(extdir)
45+
if err != nil {
46+
return err
47+
}
48+
49+
// Always local storage for now.
50+
store := &storage.Local{
51+
ExtDir: extdir,
52+
Logger: logger,
53+
}
54+
55+
removed, err := store.RemoveExtension(ctx, args[0], all)
56+
if err != nil {
57+
return err
58+
}
59+
60+
removedCount := len(removed)
61+
pluralVersions := "versions"
62+
if removedCount == 1 {
63+
pluralVersions = "version"
64+
}
65+
summary := []string{
66+
fmt.Sprintf("Removed %d %s", removedCount, pluralVersions),
67+
}
68+
for _, id := range removed {
69+
summary = append(summary, fmt.Sprintf(" - %s", id))
70+
}
71+
72+
_, err = fmt.Fprintln(cmd.OutOrStdout(), strings.Join(summary, "\n"))
73+
return err
74+
},
75+
}
76+
77+
cmd.Flags().BoolVar(&all, "all", false, "Whether to delete all versions of the extension.")
78+
cmd.Flags().StringVar(&extdir, "extensions-dir", "", "The path to extensions.")
79+
_ = cmd.MarkFlagRequired("extensions-dir")
80+
81+
return cmd
82+
}

cli/remove_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/code-marketplace/cli"
10+
)
11+
12+
func TestRemove(t *testing.T) {
13+
t.Parallel()
14+
15+
cmd := cli.Root()
16+
cmd.SetArgs([]string{"remove", "--help"})
17+
buf := new(bytes.Buffer)
18+
cmd.SetOut(buf)
19+
20+
err := cmd.Execute()
21+
require.NoError(t, err)
22+
23+
output := buf.String()
24+
require.Contains(t, output, "Remove an extension", "has help")
25+
}

cli/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func Root() *cobra.Command {
1616
}, "\n"),
1717
}
1818

19-
cmd.AddCommand(add(), server(), version())
19+
cmd.AddCommand(add(), remove(), server(), version())
2020

2121
cmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
2222

database/database_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func (s *memoryStorage) AddExtension(ctx context.Context, source string) (*stora
2424
return nil, errors.New("not implemented")
2525
}
2626

27+
func (s *memoryStorage) RemoveExtension(ctx context.Context, id string, all bool) ([]string, error) {
28+
return nil, errors.New("not implemented")
29+
}
30+
2731
func (s *memoryStorage) FileServer() http.Handler {
2832
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
2933
http.Error(rw, "not implemented", http.StatusNotImplemented)

storage/local.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@ package storage
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
910
"os"
1011
"path/filepath"
12+
"regexp"
1113
"sort"
1214
"strings"
1315

1416
"golang.org/x/mod/semver"
17+
"golang.org/x/xerrors"
1518

1619
"cdr.dev/slog"
20+
21+
"github.com/coder/code-marketplace/util"
1722
)
1823

1924
// Local implements Storage. It stores extensions locally on disk.
@@ -87,6 +92,64 @@ func (s *Local) AddExtension(ctx context.Context, source string) (*Extension, er
8792
return ext, nil
8893
}
8994

95+
func (s *Local) RemoveExtension(ctx context.Context, id string, all bool) ([]string, error) {
96+
re := regexp.MustCompile(`^([^.]+)\.([^-]+)-?(.*)$`)
97+
match := re.FindAllStringSubmatch(id, -1)
98+
if match == nil {
99+
return nil, xerrors.Errorf("expected ID in the format <publisher>.<name> or <publisher>.<name>-<version> but got invalid ID \"%s\"", id)
100+
}
101+
102+
// Get the directory to delete.
103+
publisher := match[0][1]
104+
extension := match[0][2]
105+
version := match[0][3]
106+
dir := filepath.Join(s.ExtDir, publisher, extension)
107+
if !all {
108+
dir = filepath.Join(dir, version)
109+
}
110+
111+
// We could avoid an error if extensions already do not exist but since we are
112+
// explicitly being asked to remove an extension the extension not being there
113+
// to be removed could be considered an error.
114+
_, err := os.Stat(dir)
115+
if err != nil {
116+
if errors.Is(err, os.ErrNotExist) {
117+
return nil, xerrors.Errorf("%s does not exist", id)
118+
}
119+
return nil, err
120+
}
121+
122+
allVersions := s.getDirNames(ctx, dir)
123+
versionCount := len(allVersions)
124+
125+
// TODO: Probably should use a custom error instance since knowledge of --all
126+
// is weird here.
127+
if version != "" && all {
128+
return nil, xerrors.Errorf("cannot specify both --all and version %s", version)
129+
} else if version == "" && !all {
130+
return nil, xerrors.Errorf(
131+
"use %s-<version> to target a specific version or pass --all to delete %s of %s",
132+
id,
133+
util.Plural(versionCount, "version", ""),
134+
id,
135+
)
136+
}
137+
138+
err = os.RemoveAll(dir)
139+
if err != nil {
140+
return nil, err
141+
}
142+
143+
var versions []string
144+
if all {
145+
versions = allVersions
146+
} else {
147+
versions = []string{version}
148+
}
149+
sort.Sort(sort.Reverse(semver.ByVersion(versions)))
150+
return versions, nil
151+
}
152+
90153
func (s *Local) FileServer() http.Handler {
91154
return http.FileServer(http.Dir(s.ExtDir))
92155
}

storage/storage.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ type Storage interface {
102102
// into the extension storage directory and returns details about the added
103103
// extension. The source may be an URI or a local file path.
104104
AddExtension(ctx context.Context, source string) (*Extension, error)
105+
// RemoveExtension removes the extension by id (publisher, name, and version)
106+
// or all versions if all is true (in which case the id should omit the
107+
// version) and returns the IDs removed.
108+
RemoveExtension(ctx context.Context, name string, all bool) ([]string, error)
105109
// FileServer provides a handler for fetching extension repository files from
106110
// a client.
107111
FileServer() http.Handler

storage/storage_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func TestFileServer(t *testing.T) {
4646
require.Equal(t, "bar", string(body))
4747
}
4848

49+
// addExtension adds the provided test extension to the provided directory..
4950
func addExtension(t *testing.T, ext testutil.Extension, extdir, version string) *storage.VSIXManifest {
5051
dir := filepath.Join(extdir, ext.Publisher, ext.Name, version)
5152
err := os.MkdirAll(dir, 0o755)
@@ -512,3 +513,117 @@ func TestAddExtension(t *testing.T) {
512513
}
513514
})
514515
}
516+
517+
func TestRemoveExtension(t *testing.T) {
518+
t.Parallel()
519+
520+
tests := []struct {
521+
all bool
522+
error string
523+
expected []string
524+
name string
525+
remove string
526+
}{
527+
{
528+
name: "OK",
529+
expected: []string{"a"},
530+
remove: fmt.Sprintf("%s.%s-a", testutil.Extensions[0].Publisher, testutil.Extensions[0].Name),
531+
},
532+
{
533+
name: "NoVersionMatch",
534+
error: "does not exist",
535+
remove: fmt.Sprintf("%s.%s-d", testutil.Extensions[0].Publisher, testutil.Extensions[0].Name),
536+
},
537+
{
538+
name: "NoPublisherMatch",
539+
error: "does not exist",
540+
remove: "test-test.test-test",
541+
},
542+
{
543+
name: "NoExtensionMatch",
544+
error: "does not exist",
545+
remove: "foo.test-test",
546+
},
547+
{
548+
name: "MultipleDots",
549+
error: "does not exist",
550+
remove: "foo.bar-test.test",
551+
},
552+
{
553+
name: "EmptyID",
554+
error: "invalid ID",
555+
remove: "",
556+
},
557+
{
558+
name: "MissingPublisher",
559+
error: "invalid ID",
560+
remove: ".qux-bar",
561+
},
562+
{
563+
name: "MissingExtension",
564+
error: "invalid ID",
565+
remove: "foo.-baz",
566+
},
567+
{
568+
name: "MissingExtensionAndVersion",
569+
error: "invalid ID",
570+
remove: "foo.",
571+
},
572+
{
573+
name: "MissingPublisherAndVersion",
574+
error: "invalid ID",
575+
remove: ".qux",
576+
},
577+
{
578+
name: "InvalidID",
579+
error: "invalid ID",
580+
remove: "publisher-version",
581+
},
582+
{
583+
name: "MissingVersion",
584+
error: "target a specific version or pass --all",
585+
remove: fmt.Sprintf("%s.%s", testutil.Extensions[0].Publisher, testutil.Extensions[0].Name),
586+
},
587+
{
588+
name: "All",
589+
expected: []string{"a", "b", "c"},
590+
all: true,
591+
remove: fmt.Sprintf("%s.%s", testutil.Extensions[0].Publisher, testutil.Extensions[0].Name),
592+
},
593+
{
594+
name: "AllWithVersion",
595+
error: "cannot specify both",
596+
all: true,
597+
remove: fmt.Sprintf("%s.%s-a", testutil.Extensions[0].Publisher, testutil.Extensions[0].Name),
598+
},
599+
}
600+
601+
for _, test := range tests {
602+
test := test
603+
t.Run(test.name, func(t *testing.T) {
604+
t.Parallel()
605+
606+
extdir := t.TempDir()
607+
ext := testutil.Extensions[0]
608+
addExtension(t, ext, extdir, "a")
609+
addExtension(t, ext, extdir, "b")
610+
addExtension(t, ext, extdir, "c")
611+
612+
ext = testutil.Extensions[1]
613+
addExtension(t, ext, extdir, "a")
614+
addExtension(t, ext, extdir, "b")
615+
addExtension(t, ext, extdir, "c")
616+
617+
s := &storage.Local{ExtDir: extdir}
618+
619+
removed, err := s.RemoveExtension(context.Background(), test.remove, test.all)
620+
if test.error != "" {
621+
require.Error(t, err)
622+
require.Regexp(t, test.error, err.Error())
623+
} else {
624+
require.NoError(t, err)
625+
}
626+
require.ElementsMatch(t, test.expected, removed)
627+
})
628+
}
629+
}

0 commit comments

Comments
 (0)