chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
// Package pluginmgr implements the `cliamp plugins` CLI subcommands:
|
||||
// list, install, and remove.
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/appdir"
|
||||
"github.com/bjarneo/cliamp/internal/fileutil"
|
||||
"github.com/bjarneo/cliamp/internal/plugintrust"
|
||||
"github.com/bjarneo/cliamp/luaplugin"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
const maxPluginSize = 1 << 20 // 1 MB
|
||||
|
||||
const metadataTimeout = 250 * time.Millisecond
|
||||
|
||||
var (
|
||||
input io.Reader = os.Stdin
|
||||
output io.Writer = os.Stdout
|
||||
)
|
||||
|
||||
// pluginInfo holds metadata extracted from a plugin's register() call.
|
||||
type pluginInfo struct {
|
||||
file string
|
||||
name string
|
||||
version string
|
||||
description string
|
||||
typ string
|
||||
permissions []string
|
||||
path string
|
||||
trust string
|
||||
err error
|
||||
}
|
||||
|
||||
// List prints all installed plugins with their metadata.
|
||||
func List() error {
|
||||
dir, err := appdir.PluginDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plugins, err := scanPlugins(dir)
|
||||
if err != nil {
|
||||
fmt.Println("No plugins installed.")
|
||||
return nil
|
||||
}
|
||||
if len(plugins) == 0 {
|
||||
fmt.Println("No plugins installed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
manifest, trustErr := plugintrust.Load(dir)
|
||||
if trustErr != nil {
|
||||
return trustErr
|
||||
}
|
||||
for i := range plugins {
|
||||
switch err := plugintrust.Verify(manifest, strings.TrimSuffix(plugins[i].file, "/"), plugins[i].path); {
|
||||
case err == nil:
|
||||
plugins[i].trust = "trusted"
|
||||
case err == plugintrust.ErrHashMismatch:
|
||||
plugins[i].trust = "changed"
|
||||
default:
|
||||
plugins[i].trust = "untrusted"
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate column widths.
|
||||
nameW, typeW, verW := 4, 4, 7 // "NAME", "TYPE", "VERSION"
|
||||
for _, p := range plugins {
|
||||
if len(p.name) > nameW {
|
||||
nameW = len(p.name)
|
||||
}
|
||||
if len(p.typ) > typeW {
|
||||
typeW = len(p.typ)
|
||||
}
|
||||
if len(p.version) > verW {
|
||||
verW = len(p.version)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(output, "%-*s %-*s %-*s %-9s %s\n", nameW, "NAME", typeW, "TYPE", verW, "VERSION", "TRUST", "DESCRIPTION")
|
||||
for _, p := range plugins {
|
||||
fmt.Fprintf(output, "%-*s %-*s %-*s %-9s %s\n", nameW, p.name, typeW, p.typ, verW, p.version, p.trust, p.description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Install downloads a plugin from the given source and saves it to the plugins directory.
|
||||
func Install(source string, assumeYes ...bool) error {
|
||||
urls, name, err := resolveSource(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := appdir.PluginDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("creating plugins directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("securing plugins directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if already installed (file or directory).
|
||||
dest := filepath.Join(dir, name+".lua")
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return fmt.Errorf("plugin %q already exists at %s (remove it first with: cliamp plugins remove %s)", name, dest, name)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
||||
return fmt.Errorf("plugin %q already exists as directory (remove it first with: cliamp plugins remove %s)", name, name)
|
||||
}
|
||||
|
||||
// Try each candidate URL.
|
||||
var body []byte
|
||||
for _, u := range urls {
|
||||
fmt.Printf("Trying %s...\n", u)
|
||||
b, err := download(u)
|
||||
if err == nil {
|
||||
body = b
|
||||
break
|
||||
}
|
||||
}
|
||||
if body == nil {
|
||||
return fmt.Errorf("could not download plugin from any of: %s", strings.Join(urls, ", "))
|
||||
}
|
||||
|
||||
info := extractMetadataSource(string(body))
|
||||
if info.err != nil {
|
||||
return fmt.Errorf("inspect plugin metadata: %w", info.err)
|
||||
}
|
||||
h := sha256.Sum256(body)
|
||||
hash := hex.EncodeToString(h[:])
|
||||
fmt.Fprintf(output, "Source: %s\nSHA-256: %s\nDeclared permissions: %s\nImplicit access: unrestricted reads; allowlisted writes; public HTTP\n",
|
||||
source, hash, displayPermissions(info.permissions))
|
||||
yes := len(assumeYes) > 0 && assumeYes[0]
|
||||
if !yes {
|
||||
fmt.Fprint(output, "Trust and install this plugin? [y/N] ")
|
||||
answer, err := bufio.NewReader(input).ReadString('\n')
|
||||
if err != nil && len(answer) == 0 {
|
||||
return errors.New("approval required; rerun with --yes for non-interactive installation")
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
return errors.New("plugin installation not approved")
|
||||
}
|
||||
}
|
||||
|
||||
if err := fileutil.WriteFileAtomic(dest, body, 0o600); err != nil {
|
||||
return fmt.Errorf("writing plugin: %w", err)
|
||||
}
|
||||
if _, err := plugintrust.Approve(dir, name, dest); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return fmt.Errorf("recording plugin trust: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installed %s → %s\n", name, dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trust approves the current contents of an installed plugin.
|
||||
func Trust(name string, assumeYes bool) error {
|
||||
if err := validateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := appdir.PluginDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, name+".lua")
|
||||
if st, statErr := os.Stat(path); statErr != nil {
|
||||
path = filepath.Join(dir, name, "init.lua")
|
||||
} else if st.IsDir() {
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
info := extractMetadata(path)
|
||||
if info.err != nil {
|
||||
return fmt.Errorf("inspect plugin metadata: %w", info.err)
|
||||
}
|
||||
hash, err := plugintrust.HashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(output, "Plugin: %s\nSHA-256: %s\nDeclared permissions: %s\nImplicit access: unrestricted reads; allowlisted writes; public HTTP\n",
|
||||
name, hash, displayPermissions(info.permissions))
|
||||
if !assumeYes {
|
||||
fmt.Fprint(output, "Trust this plugin content? [y/N] ")
|
||||
answer, readErr := bufio.NewReader(input).ReadString('\n')
|
||||
if readErr != nil && len(answer) == 0 {
|
||||
return errors.New("approval required; rerun with --yes")
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
return errors.New("plugin trust not approved")
|
||||
}
|
||||
}
|
||||
_, err = plugintrust.Approve(dir, name, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateName(name string) error {
|
||||
if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\\`) {
|
||||
return fmt.Errorf("invalid plugin name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayPermissions(perms []string) string {
|
||||
if len(perms) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(perms, ", ")
|
||||
}
|
||||
|
||||
// Remove deletes a plugin by name.
|
||||
func Remove(name string) error {
|
||||
if err := validateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := appdir.PluginDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try single file first, then directory.
|
||||
filePath := filepath.Join(dir, name+".lua")
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("removing plugin: %w", err)
|
||||
}
|
||||
fmt.Printf("Removed %s\n", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
dirPath := filepath.Join(dir, name)
|
||||
if info, err := os.Stat(dirPath); err == nil && info.IsDir() {
|
||||
if err := os.RemoveAll(dirPath); err != nil {
|
||||
return fmt.Errorf("removing plugin directory: %w", err)
|
||||
}
|
||||
fmt.Printf("Removed %s\n", dirPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
func download(url string) ([]byte, error) {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPluginSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxPluginSize {
|
||||
return nil, fmt.Errorf("plugin too large (max %d bytes)", maxPluginSize)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("empty response body")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// scanPlugins reads the plugin directory and extracts metadata from each plugin
|
||||
// using a lightweight Lua VM.
|
||||
func scanPlugins(dir string) ([]pluginInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var plugins []pluginInfo
|
||||
for _, e := range entries {
|
||||
var path, file string
|
||||
if e.IsDir() {
|
||||
init := filepath.Join(dir, e.Name(), "init.lua")
|
||||
if _, err := os.Stat(init); err != nil {
|
||||
continue
|
||||
}
|
||||
path = init
|
||||
file = e.Name() + "/"
|
||||
} else if strings.HasSuffix(e.Name(), ".lua") {
|
||||
path = filepath.Join(dir, e.Name())
|
||||
file = e.Name()
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
info := extractMetadata(path)
|
||||
info.file = file
|
||||
info.path = path
|
||||
if info.name == "" {
|
||||
info.name = strings.TrimSuffix(e.Name(), ".lua")
|
||||
}
|
||||
plugins = append(plugins, info)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// extractMetadata runs a Lua file in a minimal VM to capture the plugin.register() call.
|
||||
func extractMetadata(path string) pluginInfo {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return pluginInfo{err: err}
|
||||
}
|
||||
return extractMetadataSource(string(data))
|
||||
}
|
||||
|
||||
func extractMetadataSource(source string) pluginInfo {
|
||||
L := lua.NewState(lua.Options{SkipOpenLibs: false})
|
||||
defer L.Close()
|
||||
|
||||
// Apply the same sandbox as the runtime: extractMetadata runs DoFile on
|
||||
// the whole plugin file (not just register()), so top-level code must not
|
||||
// have access to os.execute/io/dofile when merely listing plugins.
|
||||
luaplugin.Sandbox(L)
|
||||
|
||||
var info pluginInfo
|
||||
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
|
||||
defer cancel()
|
||||
L.SetContext(ctx)
|
||||
defer L.RemoveContext()
|
||||
|
||||
// Stub out plugin.register() to capture metadata without side effects.
|
||||
pluginTbl := L.NewTable()
|
||||
L.SetField(pluginTbl, "register", L.NewFunction(func(L *lua.LState) int {
|
||||
opts := L.CheckTable(1)
|
||||
if v := opts.RawGetString("name"); v != lua.LNil {
|
||||
info.name = v.String()
|
||||
}
|
||||
if v := opts.RawGetString("version"); v != lua.LNil {
|
||||
info.version = v.String()
|
||||
}
|
||||
if v := opts.RawGetString("description"); v != lua.LNil {
|
||||
info.description = v.String()
|
||||
}
|
||||
if v := opts.RawGetString("type"); v != lua.LNil {
|
||||
info.typ = v.String()
|
||||
}
|
||||
if v := opts.RawGetString("permissions"); v != lua.LNil {
|
||||
tbl, ok := v.(*lua.LTable)
|
||||
if !ok {
|
||||
info.err = errors.New("permissions must be an array")
|
||||
} else {
|
||||
known := map[string]bool{"control": true, "exec": true, "keymap": true}
|
||||
tbl.ForEach(func(_, value lua.LValue) {
|
||||
permission := value.String()
|
||||
if !known[permission] && info.err == nil {
|
||||
info.err = fmt.Errorf("unknown permission %q", permission)
|
||||
}
|
||||
info.permissions = append(info.permissions, permission)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Return a dummy object with stub on/config methods.
|
||||
obj := L.NewTable()
|
||||
noop := L.NewFunction(func(L *lua.LState) int {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
})
|
||||
L.SetField(obj, "on", noop)
|
||||
L.SetField(obj, "config", noop)
|
||||
L.Push(obj)
|
||||
return 1
|
||||
}))
|
||||
L.SetGlobal("plugin", pluginTbl)
|
||||
|
||||
// No cliamp API is installed: metadata inspection happens before trust.
|
||||
if err := L.DoString(source); err != nil && info.name == "" {
|
||||
info.err = err
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// redirectTransport rewrites every request's Host to point at target.
|
||||
type redirectTransport struct {
|
||||
target *url.URL
|
||||
rt http.RoundTripper
|
||||
}
|
||||
|
||||
func (t redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
clone.URL.Scheme = t.target.Scheme
|
||||
clone.URL.Host = t.target.Host
|
||||
clone.Host = t.target.Host
|
||||
return t.rt.RoundTrip(clone)
|
||||
}
|
||||
|
||||
func installTestClient(t *testing.T, serverURL string) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
old := httpClient
|
||||
httpClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: redirectTransport{target: u, rt: http.DefaultTransport},
|
||||
}
|
||||
t.Cleanup(func() { httpClient = old })
|
||||
}
|
||||
|
||||
func withTempHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
return home
|
||||
}
|
||||
|
||||
func TestScanPluginsEmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
plugins, err := scanPlugins(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanPlugins: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected 0 plugins in empty dir, got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginsSingleFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `plugin.register({
|
||||
name = "hello",
|
||||
version = "1.2",
|
||||
description = "says hi",
|
||||
type = "visualizer",
|
||||
})
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "hello.lua"), []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
plugins, err := scanPlugins(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanPlugins: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("got %d plugins, want 1", len(plugins))
|
||||
}
|
||||
got := plugins[0]
|
||||
if got.name != "hello" {
|
||||
t.Errorf("name = %q, want hello", got.name)
|
||||
}
|
||||
if got.version != "1.2" {
|
||||
t.Errorf("version = %q, want 1.2", got.version)
|
||||
}
|
||||
if got.description != "says hi" {
|
||||
t.Errorf("description = %q, want 'says hi'", got.description)
|
||||
}
|
||||
if got.typ != "visualizer" {
|
||||
t.Errorf("typ = %q, want visualizer", got.typ)
|
||||
}
|
||||
if got.file != "hello.lua" {
|
||||
t.Errorf("file = %q, want hello.lua", got.file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginsFallsBackToFilename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Plugin without register() call — name should default to filename.
|
||||
if err := os.WriteFile(filepath.Join(dir, "nameless.lua"), []byte(`-- nothing`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
plugins, err := scanPlugins(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanPlugins: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("got %d plugins, want 1", len(plugins))
|
||||
}
|
||||
if plugins[0].name != "nameless" {
|
||||
t.Errorf("name = %q, want 'nameless' (filename fallback)", plugins[0].name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginsDirectoryEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a directory plugin: <dir>/myplug/init.lua
|
||||
sub := filepath.Join(dir, "myplug")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "init.lua"), []byte(`plugin.register({ name = "myplug", version = "0.1" })`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
plugins, err := scanPlugins(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanPlugins: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("got %d plugins, want 1", len(plugins))
|
||||
}
|
||||
if plugins[0].file != "myplug/" {
|
||||
t.Errorf("file = %q, want 'myplug/'", plugins[0].file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginsIgnoresNonLua(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("nothing"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
plugins, err := scanPlugins(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scanPlugins: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("non-lua file should be ignored, got %+v", plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNoPlugins(t *testing.T) {
|
||||
withTempHome(t)
|
||||
if err := List(); err != nil {
|
||||
t.Errorf("List on empty dir should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginsShowsInstalled(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
plugDir := filepath.Join(home, ".config", "cliamp", "plugins")
|
||||
if err := os.MkdirAll(plugDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(plugDir, "x.lua"), []byte(`plugin.register({ name = "x", version = "1.0" })`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
// List writes to stdout — we just verify it doesn't error.
|
||||
if err := List(); err != nil {
|
||||
t.Errorf("List: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFile(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
plugDir := filepath.Join(home, ".config", "cliamp", "plugins")
|
||||
if err := os.MkdirAll(plugDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(plugDir, "foo.lua")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := Remove("foo"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("plugin should be removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDirectory(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
plugDir := filepath.Join(home, ".config", "cliamp", "plugins")
|
||||
nested := filepath.Join(plugDir, "bar", "init.lua")
|
||||
if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(nested, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := Remove("bar"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(nested)); !os.IsNotExist(err) {
|
||||
t.Errorf("plugin dir should be removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveMissing(t *testing.T) {
|
||||
withTempHome(t)
|
||||
err := Remove("ghost")
|
||||
if err == nil {
|
||||
t.Error("Remove non-existent plugin should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error = %q, want to mention 'not found'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFromRawURL(t *testing.T) {
|
||||
withTempHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/example.lua") {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`plugin.register({ name = "example", version = "1" })`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
installTestClient(t, srv.URL)
|
||||
|
||||
if err := Install(srv.URL+"/example.lua", true); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
|
||||
// Verify the installed file exists.
|
||||
home, _ := os.UserHomeDir()
|
||||
dest := filepath.Join(home, ".config", "cliamp", "plugins", "example.lua")
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Errorf("installed plugin missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAlreadyExists(t *testing.T) {
|
||||
home := withTempHome(t)
|
||||
plugDir := filepath.Join(home, ".config", "cliamp", "plugins")
|
||||
if err := os.MkdirAll(plugDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
existing := filepath.Join(plugDir, "mypl.lua")
|
||||
if err := os.WriteFile(existing, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`-- ok`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
installTestClient(t, srv.URL)
|
||||
|
||||
err := Install(srv.URL+"/mypl.lua", true)
|
||||
if err == nil {
|
||||
t.Fatal("Install over existing plugin should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Errorf("error = %q, want to mention 'already exists'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAllURLsFail(t *testing.T) {
|
||||
withTempHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
installTestClient(t, srv.URL)
|
||||
|
||||
err := Install(srv.URL+"/nonexistent.lua", true)
|
||||
if err == nil {
|
||||
t.Error("Install with all failing URLs should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTooLarge(t *testing.T) {
|
||||
withTempHome(t)
|
||||
big := strings.Repeat("x", maxPluginSize+1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(big))
|
||||
}))
|
||||
defer srv.Close()
|
||||
installTestClient(t, srv.URL)
|
||||
|
||||
err := Install(srv.URL+"/huge.lua", true)
|
||||
if err == nil {
|
||||
t.Error("Install of oversized plugin should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
wantErr string
|
||||
}{
|
||||
{"404", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "missing", http.StatusNotFound)
|
||||
}, "HTTP"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(tt.handler)
|
||||
defer srv.Close()
|
||||
installTestClient(t, srv.URL)
|
||||
|
||||
_, err := download(srv.URL + "/x.lua")
|
||||
if err == nil {
|
||||
t.Fatal("download should have errored")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveSource parses a plugin source string into a download URL and plugin name.
|
||||
//
|
||||
// Supported formats:
|
||||
//
|
||||
// user/repo → GitHub (HEAD)
|
||||
// user/repo@v1.0 → GitHub (tag)
|
||||
// gitlab:user/repo → GitLab (HEAD)
|
||||
// gitlab:user/repo@v1.0 → GitLab (tag)
|
||||
// codeberg:user/repo → Codeberg (main)
|
||||
// codeberg:user/repo@v1 → Codeberg (tag)
|
||||
// https://example.com/plugin.lua → raw URL
|
||||
func resolveSource(source string) (urls []string, name string, err error) {
|
||||
// Raw URL.
|
||||
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
||||
u, err := url.Parse(source)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
base := path.Base(u.Path)
|
||||
name = strings.TrimSuffix(base, ".lua")
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return nil, "", fmt.Errorf("cannot derive a plugin name from URL %q; it should end in <name>.lua", source)
|
||||
}
|
||||
return []string{source}, name, nil
|
||||
}
|
||||
|
||||
// Detect forge prefix.
|
||||
forge := "github"
|
||||
repo := source
|
||||
if prefix, rest, ok := strings.Cut(source, ":"); ok {
|
||||
switch prefix {
|
||||
case "gitlab", "codeberg":
|
||||
forge = prefix
|
||||
repo = rest
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unknown forge prefix %q (supported: gitlab, codeberg)", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Split repo@tag.
|
||||
ref := ""
|
||||
if r, tag, ok := strings.Cut(repo, "@"); ok {
|
||||
repo = r
|
||||
ref = tag
|
||||
}
|
||||
|
||||
// Validate user/repo format.
|
||||
parts := strings.SplitN(repo, "/", 3)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, "", fmt.Errorf("invalid source %q (expected user/repo)", source)
|
||||
}
|
||||
name = parts[1]
|
||||
|
||||
// Convention: repos are named cliamp-plugin-<name> with entry point <name>.lua.
|
||||
short := strings.TrimPrefix(name, "cliamp-plugin-")
|
||||
u, err := buildForgeURL(forge, repo, ref, short)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return []string{u}, short, nil
|
||||
}
|
||||
|
||||
func buildForgeURL(forge, repo, ref, entrypoint string) (string, error) {
|
||||
var base string
|
||||
switch forge {
|
||||
case "github":
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
base = fmt.Sprintf("https://raw.githubusercontent.com/%s/%s", repo, ref)
|
||||
case "gitlab":
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
base = fmt.Sprintf("https://gitlab.com/%s/-/raw/%s", repo, ref)
|
||||
case "codeberg":
|
||||
if ref == "" {
|
||||
base = fmt.Sprintf("https://codeberg.org/%s/raw/branch/main", repo)
|
||||
} else {
|
||||
base = fmt.Sprintf("https://codeberg.org/%s/raw/tag/%s", repo, ref)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported forge %q", forge)
|
||||
}
|
||||
|
||||
return base + "/" + entrypoint + ".lua", nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveSourceGitHub(t *testing.T) {
|
||||
urls, name, err := resolveSource("user/cliamp-plugin-cool")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "cool" {
|
||||
t.Fatalf("name = %q, want %q", name, "cool")
|
||||
}
|
||||
want := "https://raw.githubusercontent.com/user/cliamp-plugin-cool/HEAD/cool.lua"
|
||||
if len(urls) != 1 || urls[0] != want {
|
||||
t.Fatalf("urls = %v, want [%s]", urls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceGitHubWithTag(t *testing.T) {
|
||||
urls, name, err := resolveSource("user/cliamp-plugin-cool@v1.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "cool" {
|
||||
t.Fatalf("name = %q, want %q", name, "cool")
|
||||
}
|
||||
want := "https://raw.githubusercontent.com/user/cliamp-plugin-cool/v1.0/cool.lua"
|
||||
if urls[0] != want {
|
||||
t.Fatalf("url = %q, want %q", urls[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceGitLab(t *testing.T) {
|
||||
urls, name, err := resolveSource("gitlab:user/cliamp-plugin-vis")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "vis" {
|
||||
t.Fatalf("name = %q, want %q", name, "vis")
|
||||
}
|
||||
want := "https://gitlab.com/user/cliamp-plugin-vis/-/raw/HEAD/vis.lua"
|
||||
if urls[0] != want {
|
||||
t.Fatalf("url = %q, want %q", urls[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceCodeberg(t *testing.T) {
|
||||
urls, name, err := resolveSource("codeberg:user/cliamp-plugin-eq")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "eq" {
|
||||
t.Fatalf("name = %q, want %q", name, "eq")
|
||||
}
|
||||
want := "https://codeberg.org/user/cliamp-plugin-eq/raw/branch/main/eq.lua"
|
||||
if urls[0] != want {
|
||||
t.Fatalf("url = %q, want %q", urls[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceCodebergWithTag(t *testing.T) {
|
||||
urls, _, err := resolveSource("codeberg:user/cliamp-plugin-eq@v2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://codeberg.org/user/cliamp-plugin-eq/raw/tag/v2/eq.lua"
|
||||
if urls[0] != want {
|
||||
t.Fatalf("url = %q, want %q", urls[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceRawURL(t *testing.T) {
|
||||
urls, name, err := resolveSource("https://example.com/my-plugin.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "my-plugin" {
|
||||
t.Fatalf("name = %q, want %q", name, "my-plugin")
|
||||
}
|
||||
if urls[0] != "https://example.com/my-plugin.lua" {
|
||||
t.Fatalf("url = %q", urls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceInvalid(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"justname",
|
||||
"too/many/parts",
|
||||
"/nope",
|
||||
"bitbucket:user/repo",
|
||||
}
|
||||
|
||||
for _, src := range tests {
|
||||
_, _, err := resolveSource(src)
|
||||
if err == nil {
|
||||
t.Errorf("resolveSource(%q) should return error", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceNonPrefixedRepo(t *testing.T) {
|
||||
urls, name, err := resolveSource("user/my-equalizer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "my-equalizer" {
|
||||
t.Fatalf("name = %q, want %q", name, "my-equalizer")
|
||||
}
|
||||
want := "https://raw.githubusercontent.com/user/my-equalizer/HEAD/my-equalizer.lua"
|
||||
if urls[0] != want {
|
||||
t.Fatalf("url = %q, want %q", urls[0], want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Unsetenv("CLIAMP_CONFIG_DIR")
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user