Skip to content

Commit 844c7fe

Browse files
committed
Add "add" sub-command
1 parent fa1996c commit 844c7fe

File tree

11 files changed

+563
-44
lines changed

11 files changed

+563
-44
lines changed

README.md

Lines changed: 22 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Marketplace for use in editors like
55
[code-server](https://github.com/cdr/code-server).
66

77
This marketplace reads extensions from file storage and provides an API for
8-
editors to consume. It does not have a frontend or any mechanisms for adding or
9-
updating extensions in the marketplace.
8+
editors to consume. It does not have a frontend or any mechanisms for extension
9+
authors to add or update extensions in the marketplace.
1010

1111
## Deployment
1212

@@ -26,34 +26,6 @@ reject connecting to the API.
2626
The `/healthz` endpoint can be used to determine if the marketplace is ready to
2727
receive requests.
2828

29-
## File Storage
30-
31-
Extensions must be both copied as a vsix and extracted to the following path:
32-
33-
```
34-
<extensions-dir>/<publisher>/<extension name>/<version>/
35-
```
36-
37-
For example:
38-
39-
```
40-
extensions
41-
|-- ms-python
42-
| `-- python
43-
| `-- 2022.14.0
44-
| |-- [Content_Types].xml
45-
| |-- extension
46-
| |-- extension.vsixmanifest
47-
| `-- ms-python.python-2022.14.0.vsix
48-
`-- vscodevim
49-
`-- vim
50-
`-- 1.23.2
51-
|-- [Content_Types].xml
52-
|-- extension
53-
|-- extension.vsixmanifest
54-
`-- vscodevim.vim-1.23.2.vsix
55-
```
56-
5729
## Usage in code-server
5830

5931
```
@@ -72,31 +44,38 @@ or both the `X-Forwarded-Host` and `X-Forwarded-Proto` headers.
7244
The marketplace does not support being hosted behind a base path; it must be
7345
proxied at the root of your domain.
7446

75-
## Getting extensions
47+
## Adding extensions
48+
49+
Extensions can be added to the marketplace by file or URL. The extensions
50+
directory does not need to be created beforehand.
51+
52+
```
53+
./code-marketplace add extension.vsix --extensions-dir ./extensions
54+
./code-marketplace add https://domain.tld/extension.vsix --extensions-dir ./extensions
55+
```
56+
57+
Extensions listed as dependencies must also be added.
58+
59+
If the extension is part of an extension pack the other extensions in the pack
60+
can also be added but doing so is optional.
7661

7762
If an extension is open source you can get it from one of three locations:
7863

7964
1. GitHub releases (if the extension publishes releases to GitHub).
8065
2. Open VSX (if the extension is published to Open VSX).
8166
3. Building from source.
8267

83-
For example to download the Python extension from Open VSX:
68+
For example to add the Python extension from Open VSX:
8469

8570
```
86-
mkdir -p extensions/ms-python/python/2022.14.0
87-
wget https://open-vsx.org/api/ms-python/python/2022.14.0/file/ms-python.python-2022.14.0.vsix
88-
unzip ms-python.python-2022.14.0.vsix -d extensions/ms-python/python/2022.14.0
89-
mv ms-python.python-2022.14.0.vsix extensions/ms-python/python/2022.14.0
71+
./code-marketplace add https://open-vsx.org/api/ms-python/python/2022.14.0/file/ms-python.python-2022.14.0.vsix --extensions-dir ./extensions
9072
```
9173

92-
Make sure to both extract the contents *and* copy/move the `.vsix` file.
93-
94-
If an extension has dependencies those must be added as well. An extension's
95-
dependencies can be found in the extension's `package.json` under
96-
`extensionDependencies`.
74+
Or the Vim extension from GitHub:
9775

98-
Extensions under `extensionPack` in the extension's `package.json` can be added
99-
as well although doing so is not required.
76+
```
77+
./code-marketplace add https://github.com/VSCodeVim/Vim/releases/download/v1.24.1/vim-1.24.1.vsix --extensions-dir ./extensions
78+
```
10079

10180
## Development
10281

api/api_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import (
2626

2727
type fakeStorage struct{}
2828

29+
func (s *fakeStorage) AddExtension(ctx context.Context, source string) (string, error) {
30+
return "", errors.New("not implemented")
31+
}
32+
2933
func (s *fakeStorage) FileServer() http.Handler {
3034
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
3135
if r.URL.Path == "/nonexistent" {

cli/add.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
10+
"cdr.dev/slog"
11+
"cdr.dev/slog/sloggers/sloghuman"
12+
13+
"github.com/coder/code-marketplace/storage"
14+
)
15+
16+
func add() *cobra.Command {
17+
var (
18+
extdir string
19+
)
20+
21+
cmd := &cobra.Command{
22+
Use: "add <source>",
23+
Short: "Add an extension to the marketplace",
24+
Args: cobra.ExactArgs(1),
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
ctx, cancel := context.WithCancel(cmd.Context())
27+
defer cancel()
28+
29+
verbose, err := cmd.Flags().GetBool("verbose")
30+
if err != nil {
31+
return err
32+
}
33+
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
34+
if verbose {
35+
logger = logger.Leveled(slog.LevelDebug)
36+
}
37+
38+
extdir, err = filepath.Abs(extdir)
39+
if err != nil {
40+
return err
41+
}
42+
43+
// Always local storage for now.
44+
store := &storage.Local{
45+
ExtDir: extdir,
46+
Logger: logger,
47+
}
48+
49+
dest, err := store.AddExtension(ctx, args[0])
50+
if err != nil {
51+
return err
52+
}
53+
54+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Extension unpacked to %s\n", dest)
55+
return nil
56+
},
57+
}
58+
59+
cmd.Flags().StringVar(&extdir, "extensions-dir", "", "The path to extensions.")
60+
_ = cmd.MarkFlagRequired("extensions-dir")
61+
62+
return cmd
63+
}

cli/add_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 TestAdd(t *testing.T) {
13+
t.Parallel()
14+
15+
cmd := cli.Root()
16+
cmd.SetArgs([]string{"add", "--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, "Add an extension", "has help")
25+
}

cli/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func Root() *cobra.Command {
1313
Example: " marketplace server --extensions-dir /path/to/extensions",
1414
}
1515

16-
cmd.AddCommand(server(), version())
16+
cmd.AddCommand(add(), server(), version())
1717

1818
cmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
1919

database/database_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package database_test
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/http"
78
"net/url"
@@ -19,6 +20,10 @@ import (
1920

2021
type memoryStorage struct{}
2122

23+
func (s *memoryStorage) AddExtension(ctx context.Context, source string) (string, error) {
24+
return "", errors.New("not implemented")
25+
}
26+
2227
func (s *memoryStorage) FileServer() http.Handler {
2328
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
2429
http.Error(rw, "not implemented", http.StatusNotImplemented)

storage/local.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package storage
22

33
import (
4+
"bytes"
45
"context"
6+
"fmt"
7+
"io"
58
"net/http"
69
"os"
710
"path/filepath"
@@ -18,6 +21,57 @@ type Local struct {
1821
Logger slog.Logger
1922
}
2023

24+
func (s *Local) AddExtension(ctx context.Context, source string) (string, error) {
25+
vsixBytes, err := readVSIX(ctx, source)
26+
if err != nil {
27+
return "", err
28+
}
29+
30+
mr, err := GetZipFileReader(vsixBytes, "extension.vsixmanifest")
31+
if err != nil {
32+
return "", err
33+
}
34+
defer mr.Close()
35+
36+
manifest, err := parseVSIXManifest(mr)
37+
if err != nil {
38+
return "", err
39+
}
40+
41+
err = validateManifest(manifest)
42+
if err != nil {
43+
return "", err
44+
}
45+
46+
// Extract the zip to the correct path.
47+
identity := manifest.Metadata.Identity
48+
dir := filepath.Join(s.ExtDir, identity.Publisher, identity.ID, identity.Version)
49+
err = ExtractZip(vsixBytes, func(name string) (io.Writer, error) {
50+
path := filepath.Join(dir, name)
51+
err := os.MkdirAll(filepath.Dir(path), 0o755)
52+
if err != nil {
53+
return nil, err
54+
}
55+
return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
56+
})
57+
if err != nil {
58+
return "", nil
59+
}
60+
61+
// Copy the VSIX itself as well.
62+
vsixName := fmt.Sprintf("%s.%s-%s.vsix", identity.Publisher, identity.ID, identity.Version)
63+
dst, err := os.OpenFile(filepath.Join(dir, vsixName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
64+
if err != nil {
65+
return "", nil
66+
}
67+
_, err = io.Copy(dst, bytes.NewReader(vsixBytes))
68+
if err != nil {
69+
return "", nil
70+
}
71+
72+
return dir, nil
73+
}
74+
2175
func (s *Local) FileServer() http.Handler {
2276
return http.FileServer(http.Dir(s.ExtDir))
2377
}

storage/storage.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"os"
10+
"strings"
11+
12+
"golang.org/x/xerrors"
913
)
1014

1115
const VSIXAssetType = "Microsoft.VisualStudio.Services.VSIXPackage"
@@ -76,6 +80,10 @@ type VSIXAsset struct {
7680

7781
// TODO: Add Artifactory implementation of Storage.
7882
type Storage interface {
83+
// AddExtension adds the extension found at the specified source by copying it
84+
// into the extension storage directory and returns the location of the new
85+
// extension. The source may be an URI or a local file path.
86+
AddExtension(ctx context.Context, source string) (string, error)
7987
// FileServer provides a handler for fetching extension repository files from
8088
// a client.
8189
FileServer() http.Handler
@@ -112,3 +120,44 @@ func parseVSIXManifest(reader io.Reader) (*VSIXManifest, error) {
112120

113121
return vm, nil
114122
}
123+
124+
// validateManifest checks a manifest for issues.
125+
func validateManifest(manifest *VSIXManifest) error {
126+
if manifest == nil {
127+
return xerrors.Errorf("vsix did not contain a manifest")
128+
}
129+
identity := manifest.Metadata.Identity
130+
if identity.Publisher == "" {
131+
return xerrors.Errorf("manifest did not contain a publisher")
132+
} else if identity.ID == "" {
133+
return xerrors.Errorf("manifest did not contain an ID")
134+
} else if identity.Version == "" {
135+
return xerrors.Errorf("manifest did not contain a version")
136+
}
137+
138+
return nil
139+
}
140+
141+
// readVSIX reads the bytes of a VSIX from the specified source. The source
142+
// might be a URI or a local file path.
143+
func readVSIX(ctx context.Context, source string) ([]byte, error) {
144+
if !strings.HasPrefix(source, "http://") && !strings.HasPrefix(source, "https://") {
145+
// Assume it is a local file path.
146+
return os.ReadFile(source)
147+
}
148+
149+
resp, err := http.Get(source)
150+
if err != nil {
151+
return nil, err
152+
}
153+
defer resp.Body.Close()
154+
155+
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
156+
return nil, xerrors.Errorf("error retrieving vsix: status code %d", resp.StatusCode)
157+
}
158+
159+
return io.ReadAll(&io.LimitedReader{
160+
R: resp.Body,
161+
N: 100 * 1000 * 1000, // 100 MB
162+
})
163+
}

0 commit comments

Comments
 (0)