Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/wfctl/multi_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry {
switch sc.Type {
case "github":
sources = append(sources, NewGitHubRegistrySource(sc))
case "static":
staticSrc, staticErr := NewStaticRegistrySource(sc)
if staticErr != nil {
fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", staticErr)
continue
}
sources = append(sources, staticSrc)
default:
// Skip unknown types
fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name)
Expand Down
72 changes: 53 additions & 19 deletions cmd/wfctl/multi_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,28 +103,43 @@ func TestDefaultRegistryConfig(t *testing.T) {
if cfg == nil {
t.Fatal("expected non-nil config")
}
if len(cfg.Registries) != 1 {
t.Fatalf("expected 1 registry, got %d", len(cfg.Registries))
if len(cfg.Registries) != 2 {
t.Fatalf("expected 2 registries, got %d", len(cfg.Registries))
}
// Primary: static registry
r := cfg.Registries[0]
if r.Name != "default" {
t.Errorf("name: got %q, want %q", r.Name, "default")
}
if r.Type != "github" {
t.Errorf("type: got %q, want %q", r.Type, "github")
}
if r.Owner != registryOwner {
t.Errorf("owner: got %q, want %q", r.Owner, registryOwner)
if r.Type != "static" {
t.Errorf("type: got %q, want %q", r.Type, "static")
}
if r.Repo != registryRepo {
t.Errorf("repo: got %q, want %q", r.Repo, registryRepo)
}
if r.Branch != registryBranch {
t.Errorf("branch: got %q, want %q", r.Branch, registryBranch)
if r.URL == "" {
t.Error("expected non-empty URL for static registry")
}
if r.Priority != 0 {
t.Errorf("priority: got %d, want 0", r.Priority)
}
// Fallback: github registry
fb := cfg.Registries[1]
if fb.Name != "github-fallback" {
t.Errorf("fallback name: got %q, want %q", fb.Name, "github-fallback")
}
if fb.Type != "github" {
t.Errorf("fallback type: got %q, want %q", fb.Type, "github")
}
if fb.Owner != registryOwner {
t.Errorf("fallback owner: got %q, want %q", fb.Owner, registryOwner)
}
if fb.Repo != registryRepo {
t.Errorf("fallback repo: got %q, want %q", fb.Repo, registryRepo)
}
if fb.Branch != registryBranch {
t.Errorf("fallback branch: got %q, want %q", fb.Branch, registryBranch)
}
if fb.Priority != 100 {
t.Errorf("fallback priority: got %d, want 100", fb.Priority)
}
}

func TestLoadRegistryConfigFromFile(t *testing.T) {
Expand Down Expand Up @@ -169,16 +184,35 @@ func TestLoadRegistryConfigFromFile(t *testing.T) {
}

func TestLoadRegistryConfigDefault(t *testing.T) {
// Provide a path that does not exist — should fall back to default.
cfg, err := LoadRegistryConfig("/nonexistent/path/config.yaml")
// Test DefaultRegistryConfig directly.
cfg := DefaultRegistryConfig()
if len(cfg.Registries) != 2 {
t.Fatalf("expected 2 registries (static + github fallback), got %d", len(cfg.Registries))
}
if cfg.Registries[0].Type != "static" {
t.Errorf("first registry type: got %q, want %q", cfg.Registries[0].Type, "static")
}
if cfg.Registries[1].Owner != registryOwner {
t.Errorf("fallback owner: got %q, want %q", cfg.Registries[1].Owner, registryOwner)
}
}

func TestLoadRegistryConfigFallback(t *testing.T) {
// LoadRegistryConfig with no valid config files should fall back to
// DefaultRegistryConfig. Isolate from real config by changing both
// CWD and HOME to a temp dir.
origDir, _ := os.Getwd()
tmpDir := t.TempDir()
_ = os.Chdir(tmpDir)
t.Setenv("HOME", tmpDir)
t.Cleanup(func() { _ = os.Chdir(origDir) })

cfg, err := LoadRegistryConfig(filepath.Join(tmpDir, "nonexistent.yaml"))
if err != nil {
t.Fatalf("LoadRegistryConfig: %v", err)
}
if len(cfg.Registries) != 1 {
t.Fatalf("expected 1 registry (default), got %d", len(cfg.Registries))
}
if cfg.Registries[0].Owner != registryOwner {
t.Errorf("owner: got %q, want %q", cfg.Registries[0].Owner, registryOwner)
if len(cfg.Registries) != 2 {
t.Fatalf("expected 2 registries (default fallback), got %d", len(cfg.Registries))
}
}

Expand Down
2 changes: 2 additions & 0 deletions cmd/wfctl/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func runPluginInit(args []string) error {
license := fs.String("license", "", "Plugin license")
output := fs.String("output", "", "Output directory (defaults to plugin name)")
withContract := fs.Bool("contract", false, "Include a contract skeleton")
module := fs.String("module", "", "Go module path (default: github.com/<author>/workflow-plugin-<name>)")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: wfctl plugin init [options] <name>\n\nScaffold a new plugin project.\n\nOptions:\n")
fs.PrintDefaults()
Expand All @@ -94,6 +95,7 @@ func runPluginInit(args []string) error {
License: *license,
OutputDir: *output,
WithContract: *withContract,
GoModule: *module,
}
if err := gen.Generate(opts); err != nil {
return err
Expand Down
175 changes: 172 additions & 3 deletions cmd/wfctl/plugin_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,47 @@ func runPluginInstall(args []string) error {
fs.StringVar(&pluginDirVal, "data-dir", defaultDataDir, "Plugin directory (deprecated, use -plugin-dir)")
cfgPath := fs.String("config", "", "Registry config file path")
registryName := fs.String("registry", "", "Use a specific registry by name")
directURL := fs.String("url", "", "Install from a direct download URL (tar.gz archive)")
localPath := fs.String("local", "", "Install from a local plugin directory")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] <name>[@<version>]\n\nDownload and install a plugin from the registry.\n\nOptions:\n")
fmt.Fprintf(fs.Output(), "Usage: wfctl plugin install [options] [<name>[@<version>]]\n\nInstall a plugin from the registry, a URL, a local directory, or from the lockfile.\n\n wfctl plugin install <name> Install latest from registry\n wfctl plugin install <name>@v1.0.0 Install specific version\n wfctl plugin install --url <url> Install from a direct download URL\n wfctl plugin install --local <dir> Install from a local build directory\n wfctl plugin install Install all plugins from .wfctl.yaml\n\nOptions:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}

// Enforce mutual exclusivity: at most one of --url, --local, or positional args.
exclusiveCount := 0
if *directURL != "" {
exclusiveCount++
}
if *localPath != "" {
exclusiveCount++
}
if fs.NArg() > 0 {
exclusiveCount++
}
if exclusiveCount > 1 {
return fmt.Errorf("--url, --local, and <name> are mutually exclusive; specify only one")
}

if *directURL != "" {
return installFromURL(*directURL, pluginDirVal)
}

if *localPath != "" {
return installFromLocal(*localPath, pluginDirVal)
}

// No args: install all plugins from .wfctl.yaml lockfile.
if fs.NArg() < 1 {
return installFromLockfile(pluginDirVal, *cfgPath)
}

nameArg := fs.Arg(0)
pluginName, _ := parseNameVersion(nameArg)
rawName, _ := parseNameVersion(nameArg)
pluginName := normalizePluginName(rawName)

cfg, err := LoadRegistryConfig(*cfgPath)
if err != nil {
Expand Down Expand Up @@ -139,7 +165,13 @@ func runPluginInstall(args []string) error {

// Update .wfctl.yaml lockfile if name@version was provided.
if _, ver := parseNameVersion(nameArg); ver != "" {
updateLockfile(manifest.Name, manifest.Version, manifest.Repository)
// Hash the installed binary (not the archive) so verifyInstalledChecksum matches.
binaryPath := filepath.Join(pluginDirVal, pluginName, pluginName)
sha, hashErr := hashFileSHA256(binaryPath)
if hashErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr)
}
updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, sha)
}
Comment on lines 165 to 175
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: normalizePluginName is called before writing the lockfile key.


return nil
Expand Down Expand Up @@ -443,6 +475,133 @@ func runPluginInfo(args []string) error {
return nil
}

// installFromURL downloads a plugin tarball from a direct URL and installs it.
func installFromURL(url, pluginDir string) error {
fmt.Fprintf(os.Stderr, "Downloading %s...\n", url)
data, err := downloadURL(url)
if err != nil {
return fmt.Errorf("download: %w", err)
}

tmpDir, err := os.MkdirTemp("", "wfctl-plugin-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)

if err := extractTarGz(data, tmpDir); err != nil {
return fmt.Errorf("extract: %w", err)
}

pjData, err := os.ReadFile(filepath.Join(tmpDir, "plugin.json"))
if err != nil {
return fmt.Errorf("no plugin.json found in archive: %w", err)
}
var pj installedPluginJSON
if err := json.Unmarshal(pjData, &pj); err != nil {
return fmt.Errorf("parse plugin.json: %w", err)
}
if pj.Name == "" {
return fmt.Errorf("plugin.json missing name field")
}

pluginName := normalizePluginName(pj.Name)
destDir := filepath.Join(pluginDir, pluginName)
if err := os.MkdirAll(destDir, 0750); err != nil {
return fmt.Errorf("create plugin dir: %w", err)
}

if err := extractTarGz(data, destDir); err != nil {
return fmt.Errorf("extract to dest: %w", err)
}

if err := ensurePluginBinary(destDir, pluginName); err != nil {
return fmt.Errorf("normalize binary name: %w", err)
}

// Validate the installed plugin (same checks as registry installs).
if verifyErr := verifyInstalledPlugin(destDir, pluginName); verifyErr != nil {
return fmt.Errorf("post-install verification failed: %w", verifyErr)
}

// Hash the installed binary (not the archive) so that verifyInstalledChecksum matches.
binaryPath := filepath.Join(destDir, pluginName)
checksum, hashErr := hashFileSHA256(binaryPath)
if hashErr != nil {
return fmt.Errorf("hash installed binary for lockfile: %w", hashErr)
}
updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, "", checksum)

fmt.Printf("Installed %s v%s to %s\n", pluginName, pj.Version, destDir)
return nil
}

// verifyInstalledChecksum reads the plugin binary and verifies its SHA-256 checksum.
func verifyInstalledChecksum(pluginDir, pluginName, expectedSHA256 string) error {
binaryPath := filepath.Join(pluginDir, pluginName)
data, err := os.ReadFile(binaryPath)
if err != nil {
return fmt.Errorf("read binary %s: %w", binaryPath, err)
}
h := sha256.Sum256(data)
got := hex.EncodeToString(h[:])
if !strings.EqualFold(got, expectedSHA256) {
return fmt.Errorf("binary checksum mismatch: got %s, want %s", got, expectedSHA256)
}
Comment on lines +539 to +550
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Both URL and lockfile paths now hash the installed binary consistently with verifyInstalledChecksum.

return nil
}

// installFromLocal installs a plugin from a local directory.
func installFromLocal(srcDir, pluginDir string) error {
pjPath := filepath.Join(srcDir, "plugin.json")
pjData, err := os.ReadFile(pjPath)
if err != nil {
return fmt.Errorf("read plugin.json in %s: %w", srcDir, err)
}
var pj installedPluginJSON
if err := json.Unmarshal(pjData, &pj); err != nil {
return fmt.Errorf("parse plugin.json: %w", err)
}
if pj.Name == "" {
return fmt.Errorf("plugin.json missing name field")
}

pluginName := normalizePluginName(pj.Name)
destDir := filepath.Join(pluginDir, pluginName)
if err := os.MkdirAll(destDir, 0750); err != nil {
return fmt.Errorf("create plugin dir: %w", err)
}

// Copy plugin.json
if err := copyFile(pjPath, filepath.Join(destDir, "plugin.json"), 0640); err != nil {
return err
}

// Find and copy the binary
srcBinary := filepath.Join(srcDir, pluginName)
if _, err := os.Stat(srcBinary); os.IsNotExist(err) {
fullName := "workflow-plugin-" + pluginName
srcBinary = filepath.Join(srcDir, fullName)
if _, err := os.Stat(srcBinary); os.IsNotExist(err) {
return fmt.Errorf("no plugin binary found in %s (tried %s and %s)", srcDir, pluginName, fullName)
}
}
if err := copyFile(srcBinary, filepath.Join(destDir, pluginName), 0750); err != nil {
return err
}

// Update lockfile with binary checksum for consistency with other install paths.
installedBinary := filepath.Join(destDir, pluginName)
sha, hashErr := hashFileSHA256(installedBinary)
if hashErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr)
}
updateLockfileWithChecksum(pluginName, pj.Version, "", "", sha)

fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir)
return nil
}
Comment on lines +554 to +603

// parseNameVersion splits "name@version" into (name, version). Version is empty if absent.
func parseNameVersion(arg string) (name, ver string) {
if idx := strings.Index(arg, "@"); idx >= 0 {
Expand Down Expand Up @@ -534,6 +693,16 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) {
return parts[1], repoName, nil
}

// hashFileSHA256 returns the hex-encoded SHA-256 hash of the file at path.
func hashFileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("hash file %s: %w", path, err)
}
h := sha256.Sum256(data)
return hex.EncodeToString(h[:]), nil
}

// extractTarGz decompresses and extracts a .tar.gz archive into destDir.
// It guards against path traversal (zip-slip) attacks.
func extractTarGz(data []byte, destDir string) error {
Expand Down
Loading
Loading