From 72ab8ef01a25a3cc2a14874c38e21c447b45f140 Mon Sep 17 00:00:00 2001 From: Anton Date: Sat, 1 Jul 2023 07:02:24 +0500 Subject: [PATCH] initial version --- .github/workflows/build.yml | 34 +++ .github/workflows/publish.yml | 32 +++ .gitignore | 2 + .goreleaser.yml | 2 + LICENSE | 21 ++ README.md | 116 +++++++++ cmd/cmd.go | 237 ++++++++++++++++++ cmd/help.go | 36 +++ cmd/info.go | 47 ++++ cmd/init.go | 31 +++ cmd/install.go | 34 +++ cmd/install_test.go | 54 ++++ cmd/list.go | 44 ++++ cmd/testdata/hello.json | 19 ++ .../sqlite-hello-v0.1.0-linux-x86_64.tar.gz | Bin 0 -> 2553 bytes .../sqlite-hello-v0.1.0-macos-aarch64.tar.gz | Bin 0 -> 1449 bytes .../sqlite-hello-v0.1.0-macos-x86_64.tar.gz | Bin 0 -> 1110 bytes .../sqlite-hello-v0.1.0-windows-x86_64.zip | Bin 0 -> 31589 bytes cmd/uninstall.go | 39 +++ cmd/uninstall_test.go | 36 +++ cmd/update.go | 98 ++++++++ docs/spec-file.md | 3 + go.mod | 5 + go.sum | 2 + internal/assets/assets.go | 175 +++++++++++++ internal/fileio/fileio.go | 52 ++++ internal/github/github.go | 23 ++ internal/httpx/httpx.go | 72 ++++++ internal/metadata/metadata.go | 77 ++++++ internal/metadata/package.go | 193 ++++++++++++++ main.go | 52 ++++ 31 files changed, 1536 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/cmd.go create mode 100644 cmd/help.go create mode 100644 cmd/info.go create mode 100644 cmd/init.go create mode 100644 cmd/install.go create mode 100644 cmd/install_test.go create mode 100644 cmd/list.go create mode 100644 cmd/testdata/hello.json create mode 100644 cmd/testdata/sqlite-hello-v0.1.0-linux-x86_64.tar.gz create mode 100644 cmd/testdata/sqlite-hello-v0.1.0-macos-aarch64.tar.gz create mode 100644 cmd/testdata/sqlite-hello-v0.1.0-macos-x86_64.tar.gz create mode 100644 cmd/testdata/sqlite-hello-v0.1.0-windows-x86_64.zip create mode 100644 cmd/uninstall.go create mode 100644 cmd/uninstall_test.go create mode 100644 cmd/update.go create mode 100644 docs/spec-file.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/assets/assets.go create mode 100644 internal/fileio/fileio.go create mode 100644 internal/github/github.go create mode 100644 internal/httpx/httpx.go create mode 100644 internal/metadata/metadata.go create mode 100644 internal/metadata/package.go create mode 100644 main.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9fabcd0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: build + +on: + push: + branches: [main] + paths: + - .github/** + - cmd/** + - internal/** + - main.go + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + + - name: Install dependencies + run: go get . + + - name: Build + run: go build -v . + + - name: Test + run: go test -v ./... diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9dcbbb3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: publish + +on: + push: + tags: + - "*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + name: Release and publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + + - name: Install dependencies + run: go get . + + - name: Release and publish + uses: goreleaser/goreleaser-action@v4 + with: + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2ef30c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.sqlpkg/ +sqlpkg \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..228ed70 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,2 @@ +builds: + - binary: sqlpkg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae3c7e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023+ Anton Zhiyanov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb7ef75 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# The (unofficial) SQLite package manager + +`sqlpkg` manages SQLite extensions, just like `pip` does with Python packages or `brew` does with macOS programs. + +It works primarily with the [SQLite package registry](https://sqlpkg.org/), but is not limited to it. You can install SQLite extensions from GitHub repositories or other websites. All you need is a package spec file (more on that later). + +Please note that `sqlpkg` is new and a bit rough around the edges. + +## Downloading and installing + +`sqlpkg` is a binary executable file (`sqlpkg.exe` on Windows, `sqlpkg` on Linux/macOS). Download it from the link below and put it somewhere in your `PATH` ([what's that?](https://stackoverflow.com/a/41895179/19962064)), so you can run it from anyhwere on your computer. + +[**Download**](https://github.com/nalgeon/sqlpkg-cli/releases/latest) + +Then run it from the command line (terminal) as described below. + +## Installing packages + +Install a package from the registry: + +``` +sqlpkg install nalgeon/stats +``` + +`nalgeon/stats` is the ID of the extension as shown in the registry. + +Install a package from a GitHub repository (it should have a package spec file): + +``` +sqlpkg install github.com/nalgeon/sqlean +``` + +Install a package from a spec file somewhere on the Internet: + +``` +sqlpkg install https://antonz.org/downloads/stats.json +``` + +Install a package from a local spec file: + +``` +sqlpkg install ./stats.json +``` + +## Package location + +By default, `sqlpkg` installs all extensions in the home folder: + +- `%USERPROFILE%\.sqlpkg` on Windows +- `~/.sqlpkg` on Linux/macOS + +So for our `nalgeon/stats` extension it might be: + +- `C:\Users\anton\.sqlpkg\nalgeon\stats\stats.dll` on Windows +- `/home/anton/.sqlpkg/nalgeon/stats/stats.so` on Linux +- `/Users/anton/.sqlpkg/nalgeon/stats/stats.dylib` on macOS + +## Other commands + +`sqlpkg` provides other basic commands you would expect from a package manager. + +### `update` + +``` +sqlpkg update +``` + +Updates all installed packages to the latest versions. + +### `list` + +``` +sqlpkg list +``` + +Lists installed packages. + +### `info` + +``` +sqlpkg info nalgeon/stats +``` + +Displays package information. Works with both local and remote packages. + +### `uninstall` + +``` +sqlpkg uninstall nalgeon/stats +``` + +Uninstalls a previously installed package. + +## Using a local repository + +By default, `sqlpkg` installs all extensions in the home folder. If you are writing a Python (JavaScript, Go, ...) application — you may prefer to put them in the project folder (think virtual environment in Python or `node_modules` in JavaScript). + +To do that, run the `init` command: + +``` +sqlpkg init +``` + +It will create an `.sqlpkg` folder in the current directory. After that, all other commands run from the same directory will use it instead of the home folder. + +## Package spec file + +The package spec file describes a particular package so that `sqlpkg` can work with it. It is usually created by the package author, so if you are a `sqlpkg` user, you don't need to worry about that. + +If you _are_ a package author, who wants your package to be installable by `sqlpkg`, learn how to create a spec file using [this guide](docs/spec-file.md) (coming soon). + +That's all for now. Now try some packages! + +[⬇️ Download](https://github.com/nalgeon/sqlpkg-cli/releases/latest) • +[✨ Explore](https://sqlpkg.org/) • +[🚀 Follow](https://twitter.com/ohmypy) diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..7605224 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,237 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/nalgeon/sqlpkg-cli/internal/assets" + "github.com/nalgeon/sqlpkg-cli/internal/fileio" + "github.com/nalgeon/sqlpkg-cli/internal/metadata" + "golang.org/x/mod/semver" +) + +// workDir is the current working directory. +var workDir string + +// userHomeDir is the user's home directory. +var userHomeDir string + +// init determines the working directory. +// It is either the .sqlpkg directory (if present) or ~/.sqlpkg otherwise. +func init() { + if fileio.Exists(metadata.DirName) { + workDir = "." + return + } + var err error + userHomeDir, err = os.UserHomeDir() + if err != nil { + workDir = "." + return + } + workDir = userHomeDir +} + +type command struct { + pkg *metadata.Package + dir string + err error +} + +// readMetadata reads package metadata. +func (cmd *command) readMetadata(path string) { + if cmd.err != nil { + return + } + + var err error + cmd.pkg, err = metadata.Read(path) + if err != nil { + cmd.err = fmt.Errorf("failed to read package metadata: %w", err) + return + } + cmd.pkg.ExpandVars() + debug("found package metadata at %s", cmd.pkg.Path) + debug("read package %s, version = %s", cmd.pkg.FullName(), cmd.pkg.Version) +} + +// isInstalled checks if there is a local package installed. +func (cmd *command) isInstalled() bool { + path := metadata.Path(workDir, cmd.pkg.Owner, cmd.pkg.Name) + return fileio.Exists(path) +} + +// hasNewVersion checks if the remote package is newer than the local one. +func (cmd *command) hasNewVersion() bool { + if cmd.err != nil { + return true + } + + oldPath := metadata.Path(workDir, cmd.pkg.Owner, cmd.pkg.Name) + if !fileio.Exists(oldPath) { + return true + } + + oldPkg, err := metadata.ReadLocal(oldPath) + if err != nil { + cmd.err = err + return true + } + debug("local package version = %s", oldPkg.Version) + + if oldPkg.Version == "" { + // not explicitly versioned, always assume there is a later version + return true + } + + if oldPkg.Version == cmd.pkg.Version { + return false + } + + if semver.Compare(oldPkg.Version, cmd.pkg.Version) < 0 { + return false + } + + return true +} + +// buildAssetPath constructs an URL to download package asset. +func (cmd *command) buildAssetPath() *metadata.AssetPath { + if cmd.err != nil { + return nil + } + debug("checking remote asset for platform %s-%s", runtime.GOOS, runtime.GOARCH) + debug("asset base path = %s", cmd.pkg.Assets.Path) + + var err error + assetPath, err := cmd.pkg.AssetPath(runtime.GOOS, runtime.GOARCH) + if err != nil { + cmd.err = fmt.Errorf("unsupported platform: %s-%s", runtime.GOOS, runtime.GOARCH) + return nil + } + + if !assetPath.Exists() { + cmd.err = fmt.Errorf("asset does not exist: %s", assetPath) + return nil + } + + return assetPath +} + +// downloadAsset downloads package asset. +func (cmd *command) downloadAsset(assetPath *metadata.AssetPath) *assets.Asset { + if cmd.err != nil { + return nil + } + + debug("downloading %s", assetPath) + cmd.dir = metadata.Dir(os.TempDir(), cmd.pkg.Owner, cmd.pkg.Name) + err := fileio.CreateDir(cmd.dir) + if err != nil { + cmd.err = fmt.Errorf("failed to create temp directory: %w", err) + return nil + } + + var asset *assets.Asset + if assetPath.IsRemote { + asset, err = assets.Download(cmd.dir, assetPath.Value) + } else { + asset, err = assets.Copy(cmd.dir, assetPath.Value) + } + if err != nil { + cmd.err = fmt.Errorf("failed to download asset: %w", err) + return nil + } + + sizeKb := float64(asset.Size) / 1024 + debug("downloaded %s (%.2f Kb)", asset.Name, sizeKb) + return asset +} + +// unpackAsset unpacks package asset. +func (cmd *command) unpackAsset(asset *assets.Asset) { + if cmd.err != nil { + return + } + + assetPath := filepath.Join(cmd.dir, asset.Name) + nFiles, err := assets.Unpack(assetPath, cmd.pkg.Assets.Pattern) + if err != nil { + cmd.err = fmt.Errorf("failed to unpack asset: %w", err) + return + } + if nFiles == 0 { + debug("not an archive, skipping unpack: %s", asset.Name) + return + } + err = os.Remove(assetPath) + if err != nil { + cmd.err = fmt.Errorf("failed to delete asset after unpacking: %w", err) + } + debug("unpacked %d files from %s", nFiles, asset.Name) +} + +// installFiles installes unpacked package files. +func (cmd *command) installFiles() { + if cmd.err != nil { + return + } + + pkgDir := metadata.Dir(workDir, cmd.pkg.Owner, cmd.pkg.Name) + err := fileio.MoveDir(cmd.dir, pkgDir) + if err != nil { + cmd.err = fmt.Errorf("failed to copy downloaded files: %w", err) + return + } + + err = cmd.pkg.Save(pkgDir) + if err != nil { + cmd.err = fmt.Errorf("failed to write package metadata: %w", err) + return + } + + cmd.dir = pkgDir +} + +// getDirByFullName expands an owner-name package pair to a full package dir. +func getDirByFullName(fullName string) (string, error) { + parts := strings.Split(fullName, "/") + if len(parts) != 2 { + return "", errors.New("invalid package name") + } + path := metadata.Dir(workDir, parts[0], parts[1]) + return path, nil +} + +// getPathFullName expands an owner-name package pair to a full sqlpkg.json path. +func getPathByFullName(fullName string) (string, error) { + parts := strings.Split(fullName, "/") + if len(parts) != 2 { + return "", errors.New("invalid package name") + } + path := metadata.Path(workDir, parts[0], parts[1]) + return path, nil +} + +var IsVerbose bool + +// log prints a message to the screen. +func log(message string, args ...any) { + if len(args) == 0 { + fmt.Println(message) + } else { + fmt.Printf(message+"\n", args...) + } +} + +// debug prints a message to the screen if the verbose mode is on. +func debug(message string, args ...any) { + if !IsVerbose { + return + } + log(".."+message, args...) +} diff --git a/cmd/help.go b/cmd/help.go new file mode 100644 index 0000000..2d2be39 --- /dev/null +++ b/cmd/help.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "text/tabwriter" +) + +const helpHelp = "usage: sqlpkg help" + +var commandsHelp = map[string]string{ + "init": "Create a local repository", + "install": "Install a package", + "uninstall": "Uninstall a package", + "update": "Update installed packages", + "list": "List installed packages", + "info": "Display package information", +} + +// Help prints available commands. +func Help(args []string) error { + if len(args) != 0 { + return errors.New(helpHelp) + } + + log("`sqlpkg` is an SQLite package manager. Use it to install or update SQLite extensions.") + log("Commands:") + w := tabwriter.NewWriter(os.Stdout, 0, 4, 0, ' ', 0) + for cmd, descr := range commandsHelp { + fmt.Fprintln(w, cmd, "\t", descr) + } + w.Flush() + + return nil +} diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..258e6ed --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "errors" + "strings" +) + +const infoHelp = "usage: sqlpkg info package" + +// Info prints information about the package (installed or not). +func Info(args []string) error { + if len(args) != 1 { + return errors.New(infoHelp) + } + + path := args[0] + + cmd := new(command) + cmd.readMetadata(path) + if cmd.err != nil { + return errors.New("package not found") + } + + if cmd.pkg.Description != "" { + log(cmd.pkg.Description) + } + if cmd.pkg.Repository != "" { + log(cmd.pkg.Repository) + } + if len(cmd.pkg.Authors) != 0 { + authors := strings.Join(cmd.pkg.Authors, ", ") + log("by %s", authors) + } + if cmd.pkg.Version != "" { + log("version: %s", cmd.pkg.Version) + } + if cmd.pkg.License != "" { + log("license: %s", cmd.pkg.License) + } + if cmd.isInstalled() { + log("✓ installed") + } else { + log("✘ not installed") + } + + return nil +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..e317044 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/nalgeon/sqlpkg-cli/internal/fileio" + "github.com/nalgeon/sqlpkg-cli/internal/metadata" +) + +const initHelp = "usage: sqlpkg init" + +// Init creates an empty local package repository. +func Init(args []string) error { + if len(args) != 0 { + return errors.New(initHelp) + } + + if fileio.Exists(metadata.DirName) { + return errors.New(".sqlpkg dir already exists") + } + + err := os.Mkdir(metadata.DirName, 0755) + if err != nil { + return fmt.Errorf("failed to create a local repository: %w", err) + } + + log("✓ created a local repository") + return nil +} diff --git a/cmd/install.go b/cmd/install.go new file mode 100644 index 0000000..04e21fa --- /dev/null +++ b/cmd/install.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "errors" +) + +const installHelp = "usage: sqlpkg install package" + +// Install installs a new package or updates an existing one. +func Install(args []string) error { + if len(args) != 1 { + return errors.New(installHelp) + } + + path := args[0] + log("> installing %s...", path) + + cmd := new(command) + cmd.readMetadata(path) + if !cmd.hasNewVersion() { + log("✓ already at the latest version") + return nil + } + assetPath := cmd.buildAssetPath() + asset := cmd.downloadAsset(assetPath) + cmd.unpackAsset(asset) + cmd.installFiles() + if cmd.err != nil { + return cmd.err + } + + log("✓ installed package %s to %s", cmd.pkg.FullName(), cmd.dir) + return cmd.err +} diff --git a/cmd/install_test.go b/cmd/install_test.go new file mode 100644 index 0000000..8abd40a --- /dev/null +++ b/cmd/install_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nalgeon/sqlpkg-cli/internal/fileio" +) + +func TestInstall(t *testing.T) { + workDir = "." + repoDir := setupRepo(t) + + pkgDir := filepath.Join(repoDir, "asg017", "hello") + args := []string{filepath.Join(workDir, "testdata", "hello.json")} + IsVerbose = true + err := Install(args) + if err != nil { + t.Fatalf("installation error: %v", err) + } + + if !fileio.Exists(pkgDir) { + t.Fatalf("package dir does not exist: %v", pkgDir) + } + + specPath := filepath.Join(pkgDir, "sqlpkg.json") + if !fileio.Exists(specPath) { + t.Fatalf("spec file does not exist: %v", specPath) + } + + assets, _ := filepath.Glob(filepath.Join(pkgDir, "hello0.*")) + if len(assets) == 0 { + t.Fatal("asset files do not exist") + } + + teardownRepo(t, repoDir) +} + +func setupRepo(t *testing.T) string { + repoDir := filepath.Join(workDir, ".sqlpkg") + err := os.RemoveAll(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return repoDir +} + +func teardownRepo(t *testing.T, repoDir string) { + err := os.RemoveAll(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..34c480b --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "text/tabwriter" + + "github.com/nalgeon/sqlpkg-cli/internal/metadata" +) + +const listHelp = "usage: sqlpkg list" + +// List prints all installed packages. +func List(args []string) error { + if len(args) != 0 { + return errors.New(listHelp) + } + + pattern := fmt.Sprintf("%s/%s/*/*/%s", workDir, metadata.DirName, metadata.FileName) + paths, _ := filepath.Glob(pattern) + + if len(paths) == 0 { + log("no packages installed") + return nil + } + + if workDir == "." { + log("(local repository)") + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 0, ' ', 0) + for _, path := range paths { + pkg, err := metadata.ReadLocal(path) + if err != nil { + return fmt.Errorf("invalid package spec: %s", path) + } + fmt.Fprintln(w, pkg.FullName(), "\t", pkg.Description) + } + w.Flush() + + return nil +} diff --git a/cmd/testdata/hello.json b/cmd/testdata/hello.json new file mode 100644 index 0000000..1cce599 --- /dev/null +++ b/cmd/testdata/hello.json @@ -0,0 +1,19 @@ +{ + "owner": "asg017", + "name": "hello", + "version": "v0.1.0", + "repository": "https://github.com/asg017/sqlite-hello", + "authors": ["Alex Garcia"], + "description": "The smallest possible \"hello, {username}\" extension.", + "keywords": ["sqlite-hello"], + "license": "MIT", + "assets": { + "path": "./testdata", + "files": { + "darwin-amd64": "sqlite-hello-{version}-macos-x86_64.tar.gz", + "darwin-arm64": "sqlite-hello-{version}-macos-aarch64.tar.gz", + "linux-amd64": "sqlite-hello-{version}-linux-x86_64.tar.gz", + "windows-amd64": "sqlite-hello-{version}-windows-x86_64.tar.gz" + } + } +} diff --git a/cmd/testdata/sqlite-hello-v0.1.0-linux-x86_64.tar.gz b/cmd/testdata/sqlite-hello-v0.1.0-linux-x86_64.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..463d14fe3e2236a5f30eca6361159be4b4e3b81c GIT binary patch literal 2553 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 P00936w-J_&0E7SlMz=fn literal 0 HcmV?d00001 diff --git a/cmd/testdata/sqlite-hello-v0.1.0-macos-aarch64.tar.gz b/cmd/testdata/sqlite-hello-v0.1.0-macos-aarch64.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..f9fa9eace9de37602afaae701bf45d9112f5aa78 GIT binary patch literal 1449 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 aNo&BNU6~*MSm&mz)|gmgzp8UjN9Qln2RxPl literal 0 HcmV?d00001 diff --git a/cmd/testdata/sqlite-hello-v0.1.0-windows-x86_64.zip b/cmd/testdata/sqlite-hello-v0.1.0-windows-x86_64.zip new file mode 100644 index 0000000000000000000000000000000000000000..1dd599c9e70cb1ea4d4386da8868b9f4cddb0a52 GIT binary patch literal 31589 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 literal 0 HcmV?d00001 diff --git a/cmd/uninstall.go b/cmd/uninstall.go new file mode 100644 index 0000000..3fdde5e --- /dev/null +++ b/cmd/uninstall.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/nalgeon/sqlpkg-cli/internal/fileio" +) + +const uninstallHelp = "usage: sqlpkg uninstall package" + +// Uninstall deletes the specified package. +func Uninstall(args []string) error { + if len(args) != 1 { + return errors.New(uninstallHelp) + } + + fullName := args[0] + dir, err := getDirByFullName(fullName) + if err != nil { + return err + } + + log("> uninstalling %s...", fullName) + debug("checking dir: %s", dir) + if !fileio.Exists(dir) { + return errors.New("package is not installed") + } + + debug("deleting dir: %s", dir) + err = os.RemoveAll(dir) + if err != nil { + return fmt.Errorf("uninstall failed: %w", err) + } + + log("✓ uninstalled package %s", fullName) + return nil +} diff --git a/cmd/uninstall_test.go b/cmd/uninstall_test.go new file mode 100644 index 0000000..f94d5e4 --- /dev/null +++ b/cmd/uninstall_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/nalgeon/sqlpkg-cli/internal/fileio" +) + +func TestUninstall(t *testing.T) { + workDir = "." + repoDir := setupRepo(t) + install(t, repoDir) + + IsVerbose = true + args := []string{"asg017/hello"} + err := Uninstall(args) + if err != nil { + t.Fatalf("uninstallation error: %v", err) + } + + pkgDir := filepath.Join(repoDir, "asg017", "hello") + if fileio.Exists(pkgDir) { + t.Fatalf("package dir still exists: %v", pkgDir) + } + + teardownRepo(t, repoDir) +} + +func install(t *testing.T, repoDir string) { + args := []string{filepath.Join(workDir, "testdata", "hello.json")} + err := Install(args) + if err != nil { + t.Fatalf("installation error: %v", err) + } +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..da514ae --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/nalgeon/sqlpkg-cli/internal/metadata" +) + +const updateHelp = "usage: sqlpkg update [package]" + +// UpdateAll updates installed packages to latest versions. +func UpdateAll(args []string) error { + if len(args) != 0 { + return errors.New(updateHelp) + } + + pattern := fmt.Sprintf("%s/%s/*/*/%s", workDir, metadata.DirName, metadata.FileName) + paths, _ := filepath.Glob(pattern) + + if len(paths) == 0 { + fmt.Println("no packages installed") + return nil + } + + count := 0 + for _, path := range paths { + pkg, err := metadata.ReadLocal(path) + if err != nil { + log("! invalid package %s: %s", path, err) + continue + } + + log("> updating %s...", pkg.FullName()) + updated, err := updatePackage(pkg) + if err != nil { + log("! error updating %s: %s", pkg.FullName(), err) + continue + } + if !updated { + log("✓ already at the latest version") + continue + } + log("✓ updated package %s to %s", pkg.FullName(), pkg.Version) + count += 1 + } + + log("updated %d packages", count) + return nil +} + +// Update updates a specific package to the latest version. +func Update(args []string) error { + if len(args) != 1 { + return errors.New(updateHelp) + } + + fullName := args[0] + path, err := getPathByFullName(fullName) + if err != nil { + return err + } + + pkg, err := metadata.ReadLocal(path) + if err != nil { + return fmt.Errorf("invalid package: %w", err) + } + + log("> updating %s...", pkg.FullName()) + updated, err := updatePackage(pkg) + if err != nil { + return fmt.Errorf("failed to update: %w", err) + } + + if updated { + log("✓ updated package %s to %s", pkg.FullName(), pkg.Version) + } else { + log("✓ already at the latest version") + } + return nil +} + +// updatePackage updates a package. +// Returns true if the package was actually updated, false otherwise +// (already at the latest version or encountered an error). +func updatePackage(pkg *metadata.Package) (bool, error) { + cmd := new(command) + cmd.readMetadata(pkg.FullName()) + if !cmd.hasNewVersion() { + return false, nil + } + assetUrl := cmd.buildAssetPath() + asset := cmd.downloadAsset(assetUrl) + cmd.unpackAsset(asset) + cmd.installFiles() + return cmd.err != nil, cmd.err +} diff --git a/docs/spec-file.md b/docs/spec-file.md new file mode 100644 index 0000000..d56c556 --- /dev/null +++ b/docs/spec-file.md @@ -0,0 +1,3 @@ +## Creating a spec file for an SQLite extension + +Coming soon. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4197e78 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/nalgeon/sqlpkg-cli + +go 1.20 + +require golang.org/x/mod v0.11.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b9ed6f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100644 index 0000000..fb2bfe7 --- /dev/null +++ b/internal/assets/assets.go @@ -0,0 +1,175 @@ +// Package assets manages package assets (hmm). +package assets + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/nalgeon/sqlpkg-cli/internal/fileio" + "github.com/nalgeon/sqlpkg-cli/internal/httpx" +) + +// An Asset is an archive of package files for a specific platform. +type Asset struct { + Name string + Size int64 + Checksum []byte +} + +// Download downloads an asset from the remote url to the local dir. +func Download(dir, rawURL string) (asset *Asset, err error) { + url, err := url.Parse(rawURL) + if err != nil { + return nil, errors.New("invalid url") + } + + name := filepath.Base(url.Path) + file, err := os.Create(filepath.Join(dir, name)) + if err != nil { + return nil, err + } + defer file.Close() + + body, err := httpx.GetBody(rawURL, "application/octet-stream") + if err != nil { + return nil, err + } + defer body.Close() + + size, err := io.Copy(file, body) + if err != nil { + return nil, err + } + return &Asset{name, size, nil}, nil +} + +// Copy copies an asset from the local path to the local dir. +func Copy(dir, path string) (asset *Asset, err error) { + _, name := filepath.Split(path) + dstPath := filepath.Join(dir, name) + size, err := fileio.CopyFile(path, dstPath) + if err != nil { + return nil, err + } + return &Asset{name, int64(size), nil}, nil +} + +// Unpack unpacks an asset from the given path to the same dir +// where the asset resides. If pattern is provided, unpacks +// only the files that match it. +func Unpack(path, pattern string) (int, error) { + dir, _ := filepath.Split(path) + if strings.HasSuffix(path, ".zip") { + return unpackZip(path, pattern, dir) + } + if strings.HasSuffix(path, ".tar.gz") { + return unpackTarGz(path, pattern, dir) + } + return 0, nil +} + +// unpackZip unpackes a zip archive. +func unpackZip(path, pattern, dir string) (int, error) { + archive, err := zip.OpenReader(path) + if err != nil { + return 0, err + } + defer archive.Close() + + count := 0 + for _, f := range archive.File { + if f.FileInfo().IsDir() { + // ignore dirs + continue + } + if pattern != "" { + matched, _ := filepath.Match(pattern, f.Name) + if !matched { + continue + } + } + + dstPath := filepath.Join(dir, f.Name) + dstFile, err := os.Create(dstPath) + if err != nil { + return 0, err + } + + file, err := f.Open() + if err != nil { + return 0, err + } + + _, err = io.Copy(dstFile, file) + if err != nil { + file.Close() + return 0, err + } + dstFile.Close() + count += 1 + } + + return count, nil +} + +// unpackTarGz unpackes a .tar.gz archive. +func unpackTarGz(path, pattern, dir string) (int, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + + gzip, err := gzip.NewReader(file) + if err != nil { + return 0, err + } + defer gzip.Close() + + rdr := tar.NewReader(gzip) + + count := 0 + for { + header, err := rdr.Next() + + if err == io.EOF { + return count, nil + } + if err != nil { + return 0, err + } + + if header.Typeflag != tar.TypeReg { + // ignore dirs + continue + } + + if pattern != "" { + matched, _ := filepath.Match(pattern, header.Name) + if !matched { + continue + } + } + + dstPath := filepath.Join(dir, header.Name) + dstFile, err := os.Create(dstPath) + if err != nil { + return 0, err + } + + // copy over contents + _, err = io.Copy(dstFile, rdr) + if err != nil { + return 0, err + } + + dstFile.Close() + count += 1 + } +} diff --git a/internal/fileio/fileio.go b/internal/fileio/fileio.go new file mode 100644 index 0000000..cc7a1de --- /dev/null +++ b/internal/fileio/fileio.go @@ -0,0 +1,52 @@ +// Package fileio provides high-level file operations. +package fileio + +import "os" + +// CreateDir creates an empty directory. +// If the directory already exists, deletes it and creates a new one. +func CreateDir(dir string) error { + err := os.RemoveAll(dir) + if err != nil { + return err + } + err = os.MkdirAll(dir, 0755) + if err != nil { + return err + } + return nil +} + +// MoveDir moves the source directory to the destination. +// If the destination already exists, deletes it before moving the source. +func MoveDir(src, dst string) error { + err := os.MkdirAll(dst, 0755) + if err != nil { + return err + } + err = os.RemoveAll(dst) + if err != nil { + return err + } + err = os.Rename(src, dst) + if err != nil { + return err + } + return nil +} + +// CopyFile copies a single file from source to destination. +func CopyFile(src, dst string) (int, error) { + data, err := os.ReadFile(src) + if err != nil { + return 0, err + } + err = os.WriteFile(dst, data, 0644) + return len(data), err +} + +// Exists checks if the specified path exists. +func Exists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..7792713 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,23 @@ +package github + +import ( + "fmt" + + "github.com/nalgeon/sqlpkg-cli/internal/httpx" +) + +const base_url = "https://api.github.com" + +type Release struct { + TagName string `json:"tag_name"` +} + +func GetLatestVersion(owner, repo string) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", base_url, owner, repo) + var rel Release + err := httpx.GetJSON(url, &rel) + if err != nil { + return "", err + } + return rel.TagName, nil +} diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go new file mode 100644 index 0000000..92238fd --- /dev/null +++ b/internal/httpx/httpx.go @@ -0,0 +1,72 @@ +// Package httpx provides high-level HTTP operations. +package httpx + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +var client = http.Client{Timeout: 3 * time.Second} + +// Exists checks if the specified url exists. +func Exists(url string) bool { + resp, err := http.Head(url) + if err != nil { + return false + } + return resp.StatusCode == http.StatusOK +} + +// GetBody issues a GET request with an Accept header and returns the response body. +func GetBody(url string, accept string) (io.ReadCloser, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Add("Accept", accept) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("got http status %d", resp.StatusCode) + } + + return resp.Body, nil +} + +// GetJSON issues a GET request and decodes the response as JSON into the val. +func GetJSON(url string, val any) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Add("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("got http status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + err = json.Unmarshal(body, val) + if err != nil { + return err + } + + return nil +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go new file mode 100644 index 0000000..a7aa6f2 --- /dev/null +++ b/internal/metadata/metadata.go @@ -0,0 +1,77 @@ +// Package metadata manages the package metadata (the sqlpkg.json file). +package metadata + +import ( + "errors" + "fmt" + "net/url" + "regexp" +) + +// e.g. github.com/nalgeon/sqlean +var reGithub = regexp.MustCompile(`^github.com/[\w\-_.]+/[\w\-_.]+$`) + +// e.g. nalgeon/sqlean +var reOwnerName = regexp.MustCompile(`^[\w\-_.]+/[\w\-_.]+$`) + +// Read retrieves package metadata from the specified path. +// Path can be one of the following: +// - owner-name pair: nalgeon/sqlean +// - github repo: github.com/nalgeon/sqlean +// - custom url: https://antonz.org/stuff/whatever/sqlean.json +// - local path: /Users/anton/Desktop/sqlean.json +func Read(path string) (pkg *Package, err error) { + errs := []error{} + paths := expandPath(path) + for _, path := range paths { + readFunc := inferReader(path) + pkg, err = readFunc(path) + if err == nil { + pkg.Path = path + return pkg, nil + } else { + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + } + } + return pkg, errors.Join(errs...) +} + +// expandPath generates possible paths to the package metadata file. +func expandPath(path string) []string { + if reGithub.MatchString(path) { + // try reading from the main branch of the github repository + return []string{fmt.Sprintf("https://%s/raw/main/%s", path, FileName)} + } + if reOwnerName.MatchString(path) { + // can be a local path or an owner-name pair, which in turn can point + // to the author's repo or to the sqlpkg's registry + return []string{ + path, + fmt.Sprintf("https://github.com/%s/raw/main/%s", path, FileName), + fmt.Sprintf("https://github.com/nalgeon/sqlpkg/raw/main/pkg/%s.json", path), + } + } + return []string{path} +} + +// inferReader returns a proper reader function for a path, +// which can be a local file path or a remote url path. +func inferReader(path string) ReadFunc { + if isURL(path) { + return ReadRemote + } else { + return ReadLocal + } +} + +// isURL checks if the path is an url. +func isURL(path string) bool { + u, err := url.Parse(path) + if err != nil { + return false + } + if u.Scheme == "" { + return false + } + return true +} diff --git a/internal/metadata/package.go b/internal/metadata/package.go new file mode 100644 index 0000000..25a1995 --- /dev/null +++ b/internal/metadata/package.go @@ -0,0 +1,193 @@ +package metadata + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/nalgeon/sqlpkg-cli/internal/fileio" + "github.com/nalgeon/sqlpkg-cli/internal/httpx" +) + +// DirName is the name of the folder with packages +const DirName = ".sqlpkg" + +// FileName is the package metadata filename. +const FileName = "sqlpkg.json" + +// downloadBase determines default asset url for known providers. +var downloadBase = map[string]string{ + "github.com": "{repository}/releases/download/{version}", +} + +// An Asset describes a local file path or a remote URL. +type AssetPath struct { + Value string + IsRemote bool +} + +// Exists checks if the asset actually exists at the said path. +func (p *AssetPath) Exists() bool { + if p.IsRemote { + return httpx.Exists(p.Value) + } else { + return fileio.Exists(p.Value) + } +} + +// Join appends a filename to the path. +func (p *AssetPath) Join(fileName string) *AssetPath { + if p.IsRemote { + return &AssetPath{p.Value + "/" + fileName, true} + } else { + return &AssetPath{filepath.Join(p.Value, fileName), false} + } +} + +func (p *AssetPath) MarshalText() ([]byte, error) { + return []byte(p.Value), nil +} + +func (p *AssetPath) UnmarshalText(text []byte) error { + p.Value = string(text) + p.IsRemote = isURL(p.Value) || strings.HasPrefix(p.Value, "{repository}") + return nil +} + +func (p *AssetPath) String() string { + return p.Value +} + +// A Package describes the package metadata. +type Package struct { + Owner string `json:"owner"` + Name string `json:"name"` + Version string `json:"version"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + Authors []string `json:"authors"` + License string `json:"license"` + Description string `json:"description"` + Keywords []string `json:"keywords"` + Symbols []string `json:"symbols"` + Assets `json:"assets"` + + Path string `json:"-"` +} + +// Assets are archives of package files, each for a specific platform. +type Assets struct { + Path *AssetPath `json:"path"` + Pattern string `json:"pattern"` + Files map[string]string `json:"files"` +} + +// FullName is an owner-name pair that uniquely identifies the package. +func (p *Package) FullName() string { + return p.Owner + "/" + p.Name +} + +// ExpandVars substitutes variables in Assets with real values. +func (p *Package) ExpandVars() { + if p.Assets.Path == nil || p.Assets.Path.Value == "" { + p.Assets.Path = &AssetPath{inferAssetUrl(p.Repository), true} + } + p.Assets.Path.Value = stringFormat(p.Assets.Path.Value, map[string]any{ + "repository": p.Repository, + "owner": p.Owner, + "name": p.Name, + "version": p.Version, + }) + for platform, file := range p.Assets.Files { + p.Assets.Files[platform] = stringFormat(file, map[string]any{ + "version": p.Version, + }) + } +} + +// AssetPath determines the package url for a specific platform (OS + architecture). +func (p *Package) AssetPath(os, arch string) (*AssetPath, error) { + platform := os + "-" + arch + asset, ok := p.Assets.Files[platform] + if !ok { + return nil, errors.New("platform is not supported") + } + if p.Assets.Path == nil || p.Assets.Path.Value == "" { + return nil, errors.New("asset path is not set") + } + path := p.Assets.Path.Join(asset) + return path, nil + +} + +// Save writes the package metadata file to the specified directory. +func (p *Package) Save(dir string) error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + path := filepath.Join(dir, FileName) + return os.WriteFile(path, data, 0644) +} + +// Dir returns the package directory. +func Dir(basePath, owner, name string) string { + return filepath.Join(basePath, DirName, owner, name) +} + +// Path returns the path to the package metadata file. +func Path(basePath, owner, name string) string { + return filepath.Join(basePath, DirName, owner, name, FileName) +} + +// A ReadFunc if a function that reads package metadata from a given path. +type ReadFunc func(path string) (*Package, error) + +// ReadLocal reads package metadata from a local file. +func ReadLocal(path string) (*Package, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var pkg Package + err = json.Unmarshal(data, &pkg) + if err != nil { + return nil, err + } + return &pkg, nil +} + +// ReadRemote reads package metadata from a remote url. +func ReadRemote(url string) (*Package, error) { + var pkg Package + err := httpx.GetJSON(url, &pkg) + if err != nil { + return nil, err + } + return &pkg, nil +} + +// inferAssetUrl determines an asset url given the package repository url. +func inferAssetUrl(repoUrl string) string { + url, err := url.Parse(repoUrl) + if err != nil { + return "" + } + return downloadBase[url.Hostname()] +} + +// stringFormat formats a string according to the map of values. +// E.g. stringFormat("hello, {name}", map[string]string{"name": "world"}) +// -> "hello, world" +func stringFormat(s string, mapping map[string]any) string { + args := make([]string, 0, len(mapping)*2) + for key, val := range mapping { + args = append(args, "{"+key+"}") + args = append(args, fmt.Sprint(val)) + } + return strings.NewReplacer(args...).Replace(s) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4c44abb --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/nalgeon/sqlpkg-cli/cmd" +) + +func execCommand() error { + if len(os.Args) < 2 { + return cmd.Help(nil) + } + + flag.BoolVar(&cmd.IsVerbose, "v", false, "verbose output") + flag.Parse() + + args := flag.Args() + command, args := args[0], args[1:] + + switch command { + case "init": + return cmd.Init(args) + case "install": + return cmd.Install(args) + case "uninstall": + return cmd.Uninstall(args) + case "update": + if len(args) == 1 { + return cmd.Update(args) + } + return cmd.UpdateAll(args) + case "list": + return cmd.List(args) + case "info": + return cmd.Info(args) + case "help": + return cmd.Help(args) + default: + return errors.New("unknown command") + } +} + +func main() { + err := execCommand() + if err != nil { + fmt.Println("!", err) + os.Exit(1) + } +}