Skip to content

Commit 043382c

Browse files
intel352claude
andcommitted
merge: resolve conflicts with main (PR #329 merged)
Both branches made equivalent fixes to shared wfctl files. Resolved by taking main's versions for shared code (plugin_install, lockfile, registry_source, generator, docs) while preserving engine-branch-only additions (integrity, autofetch, engine auto-fetch, config). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 29f63ff + 67ca0e4 commit 043382c

13 files changed

Lines changed: 853 additions & 80 deletions

cmd/wfctl/multi_registry.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry {
2929
case "github":
3030
sources = append(sources, NewGitHubRegistrySource(sc))
3131
case "static":
32-
src, err := NewStaticRegistrySource(sc)
33-
if err != nil {
34-
fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", err)
32+
staticSrc, staticErr := NewStaticRegistrySource(sc)
33+
if staticErr != nil {
34+
fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", staticErr)
3535
continue
3636
}
37-
sources = append(sources, src)
37+
sources = append(sources, staticSrc)
3838
default:
3939
// Skip unknown types
4040
fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name)

cmd/wfctl/multi_registry_test.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func TestLoadRegistryConfigFromFile(t *testing.T) {
184184
}
185185

186186
func TestLoadRegistryConfigDefault(t *testing.T) {
187-
// Test DefaultRegistryConfig directly to avoid picking up user config files.
187+
// Test DefaultRegistryConfig directly.
188188
cfg := DefaultRegistryConfig()
189189
if len(cfg.Registries) != 2 {
190190
t.Fatalf("expected 2 registries (static + github fallback), got %d", len(cfg.Registries))
@@ -197,6 +197,25 @@ func TestLoadRegistryConfigDefault(t *testing.T) {
197197
}
198198
}
199199

200+
func TestLoadRegistryConfigFallback(t *testing.T) {
201+
// LoadRegistryConfig with no valid config files should fall back to
202+
// DefaultRegistryConfig. Isolate from real config by changing both
203+
// CWD and HOME to a temp dir.
204+
origDir, _ := os.Getwd()
205+
tmpDir := t.TempDir()
206+
_ = os.Chdir(tmpDir)
207+
t.Setenv("HOME", tmpDir)
208+
t.Cleanup(func() { _ = os.Chdir(origDir) })
209+
210+
cfg, err := LoadRegistryConfig(filepath.Join(tmpDir, "nonexistent.yaml"))
211+
if err != nil {
212+
t.Fatalf("LoadRegistryConfig: %v", err)
213+
}
214+
if len(cfg.Registries) != 2 {
215+
t.Fatalf("expected 2 registries (default fallback), got %d", len(cfg.Registries))
216+
}
217+
}
218+
200219
func TestSaveAndLoadRegistryConfig(t *testing.T) {
201220
dir := t.TempDir()
202221
cfgPath := filepath.Join(dir, "wfctl", "config.yaml")

cmd/wfctl/plugin_install.go

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -74,26 +74,26 @@ func runPluginInstall(args []string) error {
7474
directURL := fs.String("url", "", "Install from a direct download URL (tar.gz archive)")
7575
localPath := fs.String("local", "", "Install from a local plugin directory")
7676
fs.Usage = func() {
77-
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>@<ver> Install specific version\n wfctl plugin install --url <url> Install from direct URL\n wfctl plugin install --local <dir> Install from local directory\n wfctl plugin install Install all from .wfctl.yaml\n\nOptions:\n")
77+
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")
7878
fs.PrintDefaults()
7979
}
8080
if err := fs.Parse(args); err != nil {
8181
return err
8282
}
8383

84-
// Validate mutual exclusivity of install modes.
85-
modes := 0
84+
// Enforce mutual exclusivity: at most one of --url, --local, or positional args.
85+
exclusiveCount := 0
8686
if *directURL != "" {
87-
modes++
87+
exclusiveCount++
8888
}
8989
if *localPath != "" {
90-
modes++
90+
exclusiveCount++
9191
}
9292
if fs.NArg() > 0 {
93-
modes++
93+
exclusiveCount++
9494
}
95-
if modes > 1 {
96-
return fmt.Errorf("specify only one of: <name>, --url, or --local")
95+
if exclusiveCount > 1 {
96+
return fmt.Errorf("--url, --local, and <name> are mutually exclusive; specify only one")
9797
}
9898

9999
if *directURL != "" {
@@ -110,7 +110,8 @@ func runPluginInstall(args []string) error {
110110
}
111111

112112
nameArg := fs.Arg(0)
113-
pluginName, _ := parseNameVersion(nameArg)
113+
rawName, _ := parseNameVersion(nameArg)
114+
pluginName := normalizePluginName(rawName)
114115

115116
cfg, err := LoadRegistryConfig(*cfgPath)
116117
if err != nil {
@@ -164,13 +165,13 @@ func runPluginInstall(args []string) error {
164165

165166
// Update .wfctl.yaml lockfile if name@version was provided.
166167
if _, ver := parseNameVersion(nameArg); ver != "" {
167-
pluginName = normalizePluginName(pluginName)
168-
binaryChecksum := ""
168+
// Hash the installed binary (not the archive) so verifyInstalledChecksum matches.
169169
binaryPath := filepath.Join(pluginDirVal, pluginName, pluginName)
170-
if cs, hashErr := hashFileSHA256(binaryPath); hashErr == nil {
171-
binaryChecksum = cs
170+
sha, hashErr := hashFileSHA256(binaryPath)
171+
if hashErr != nil {
172+
fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr)
172173
}
173-
updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, binaryChecksum)
174+
updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, sha)
174175
}
175176

176177
return nil
@@ -515,34 +516,26 @@ func installFromURL(url, pluginDir string) error {
515516
}
516517

517518
if err := ensurePluginBinary(destDir, pluginName); err != nil {
518-
return fmt.Errorf("could not normalize binary name: %w", err)
519+
return fmt.Errorf("normalize binary name: %w", err)
519520
}
520521

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

526-
binaryChecksum, hashErr := hashFileSHA256(filepath.Join(destDir, pluginName))
527+
// Hash the installed binary (not the archive) so that verifyInstalledChecksum matches.
528+
binaryPath := filepath.Join(destDir, pluginName)
529+
checksum, hashErr := hashFileSHA256(binaryPath)
527530
if hashErr != nil {
528-
fmt.Fprintf(os.Stderr, "warning: could not compute binary checksum: %v\n", hashErr)
531+
return fmt.Errorf("hash installed binary for lockfile: %w", hashErr)
529532
}
530-
updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, "", binaryChecksum)
533+
updateLockfileWithChecksum(pluginName, pj.Version, pj.Repository, "", checksum)
531534

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

536-
// hashFileSHA256 computes the SHA-256 hex digest of the file at path.
537-
func hashFileSHA256(path string) (string, error) {
538-
data, err := os.ReadFile(path)
539-
if err != nil {
540-
return "", fmt.Errorf("hash file %s: %w", path, err)
541-
}
542-
h := sha256.Sum256(data)
543-
return hex.EncodeToString(h[:]), nil
544-
}
545-
546539
// verifyInstalledChecksum reads the plugin binary and verifies its SHA-256 checksum.
547540
func verifyInstalledChecksum(pluginDir, pluginName, expectedSHA256 string) error {
548541
binaryPath := filepath.Join(pluginDir, pluginName)
@@ -597,11 +590,13 @@ func installFromLocal(srcDir, pluginDir string) error {
597590
return err
598591
}
599592

600-
binaryChecksum, hashErr := hashFileSHA256(filepath.Join(destDir, pluginName))
593+
// Update lockfile with binary checksum for consistency with other install paths.
594+
installedBinary := filepath.Join(destDir, pluginName)
595+
sha, hashErr := hashFileSHA256(installedBinary)
601596
if hashErr != nil {
602-
fmt.Fprintf(os.Stderr, "warning: could not compute binary checksum: %v\n", hashErr)
597+
fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr)
603598
}
604-
updateLockfileWithChecksum(pluginName, pj.Version, "", "", binaryChecksum)
599+
updateLockfileWithChecksum(pluginName, pj.Version, "", "", sha)
605600

606601
fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir)
607602
return nil
@@ -698,6 +693,16 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) {
698693
return parts[1], repoName, nil
699694
}
700695

696+
// hashFileSHA256 returns the hex-encoded SHA-256 hash of the file at path.
697+
func hashFileSHA256(path string) (string, error) {
698+
data, err := os.ReadFile(path)
699+
if err != nil {
700+
return "", fmt.Errorf("hash file %s: %w", path, err)
701+
}
702+
h := sha256.Sum256(data)
703+
return hex.EncodeToString(h[:]), nil
704+
}
705+
701706
// extractTarGz decompresses and extracts a .tar.gz archive into destDir.
702707
// It guards against path traversal (zip-slip) attacks.
703708
func extractTarGz(data []byte, destDir string) error {

cmd/wfctl/plugin_install_new_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func TestInstallFromURL(t *testing.T) {
6060
pjContent := minimalPluginJSON(pluginName, "1.2.3")
6161

6262
tarball := buildPluginTarGz(t, pluginName, binaryContent, pjContent)
63+
// The lockfile records the SHA-256 of the installed binary, not the archive.
6364
binaryChecksum := sha256Hex(binaryContent)
6465

6566
// Serve tarball from a local httptest server.
@@ -115,7 +116,7 @@ func TestInstallFromURL(t *testing.T) {
115116
t.Fatalf("lockfile missing entry for %q; entries: %v", pluginName, lf.Plugins)
116117
}
117118
if entry.SHA256 != binaryChecksum {
118-
t.Errorf("lockfile checksum: got %q, want %q", entry.SHA256, binaryChecksum)
119+
t.Errorf("lockfile checksum: got %q, want %q (should be binary hash, not archive hash)", entry.SHA256, binaryChecksum)
119120
}
120121
if entry.Version != "1.2.3" {
121122
t.Errorf("lockfile version: got %q, want %q", entry.Version, "1.2.3")
@@ -432,8 +433,8 @@ func TestCopyFile(t *testing.T) {
432433
if err != nil {
433434
t.Fatalf("stat dest: %v", err)
434435
}
435-
if info.Mode() != wantMode {
436-
t.Errorf("mode: got %v, want %v", info.Mode(), wantMode)
436+
if info.Mode().Perm()&wantMode != wantMode {
437+
t.Errorf("mode: got %v, want at least %v", info.Mode().Perm(), wantMode)
437438
}
438439
}
439440

cmd/wfctl/plugin_lockfile.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,8 @@ func installFromLockfile(pluginDir, cfgPath string) error {
9090
if entry.SHA256 != "" {
9191
pluginInstallDir := filepath.Join(pluginDir, name)
9292
if verifyErr := verifyInstalledChecksum(pluginInstallDir, name, entry.SHA256); verifyErr != nil {
93-
fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v\n", name, verifyErr)
94-
if removeErr := os.RemoveAll(pluginInstallDir); removeErr != nil {
95-
fmt.Fprintf(os.Stderr, "warning: could not remove plugin dir: %v\n", removeErr)
96-
}
93+
fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v — removing plugin\n", name, verifyErr)
94+
_ = os.RemoveAll(pluginInstallDir)
9795
failed = append(failed, name)
9896
continue
9997
}
@@ -106,6 +104,7 @@ func installFromLockfile(pluginDir, cfgPath string) error {
106104
}
107105

108106
// updateLockfileWithChecksum adds or updates a plugin entry in .wfctl.yaml with SHA-256 checksum.
107+
// The sha256Hash must be the hash of the installed binary, not the download archive.
109108
// Silently no-ops if the lockfile cannot be read or written (install still succeeds).
110109
func updateLockfileWithChecksum(pluginName, version, repository, registry, sha256Hash string) {
111110
lf, err := loadPluginLockfile(wfctlYAMLPath)
@@ -118,8 +117,8 @@ func updateLockfileWithChecksum(pluginName, version, repository, registry, sha25
118117
lf.Plugins[pluginName] = PluginLockEntry{
119118
Version: version,
120119
Repository: repository,
121-
SHA256: sha256Hash,
122120
Registry: registry,
121+
SHA256: sha256Hash,
123122
}
124123
_ = lf.Save(wfctlYAMLPath)
125124
}

cmd/wfctl/registry_source.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,10 @@ type StaticRegistrySource struct {
149149
}
150150

151151
// NewStaticRegistrySource creates a new static-URL-backed registry source.
152+
// Returns an error if the URL is empty.
152153
func NewStaticRegistrySource(cfg RegistrySourceConfig) (*StaticRegistrySource, error) {
153154
if cfg.URL == "" {
154-
return nil, fmt.Errorf("static registry %q requires a URL", cfg.Name)
155+
return nil, fmt.Errorf("registry %q: url is required for type=static", cfg.Name)
155156
}
156157
return &StaticRegistrySource{name: cfg.Name, baseURL: strings.TrimSuffix(cfg.URL, "/"), token: cfg.Token}, nil
157158
}
@@ -204,13 +205,8 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult
204205
strings.Contains(strings.ToLower(e.Name), q) ||
205206
strings.Contains(strings.ToLower(e.Description), q) {
206207
results = append(results, PluginSearchResult{
207-
PluginSummary: PluginSummary{ //nolint:staticcheck // S1016: explicit fields for clarity across struct tag boundaries
208-
Name: e.Name,
209-
Version: e.Version,
210-
Description: e.Description,
211-
Tier: e.Tier,
212-
},
213-
Source: s.name,
208+
PluginSummary: PluginSummary(e),
209+
Source: s.name,
214210
})
215211
}
216212
}

cmd/wfctl/registry_source_test.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ type errSentinel string
7373

7474
func (e errSentinel) Error() string { return string(e) }
7575

76+
// mustNewStaticRegistrySource is a test helper that calls NewStaticRegistrySource
77+
// and fails the test if an error is returned.
78+
func mustNewStaticRegistrySource(t *testing.T, cfg RegistrySourceConfig) *StaticRegistrySource {
79+
t.Helper()
80+
src, err := NewStaticRegistrySource(cfg)
81+
if err != nil {
82+
t.Fatalf("NewStaticRegistrySource: %v", err)
83+
}
84+
return src
85+
}
86+
7687
// ---------------------------------------------------------------------------
7788

7889
// TestStaticRegistrySource_FetchManifest verifies that FetchManifest fetches
@@ -93,7 +104,7 @@ func TestStaticRegistrySource_FetchManifest(t *testing.T) {
93104
srv := buildStaticRegistryServer(t, nil, manifests)
94105
defer srv.Close()
95106

96-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
107+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
97108
Name: "test-static",
98109
URL: srv.URL,
99110
})
@@ -116,7 +127,7 @@ func TestStaticRegistrySource_FetchManifest_NotFound(t *testing.T) {
116127
srv := buildStaticRegistryServer(t, nil, map[string]*RegistryManifest{})
117128
defer srv.Close()
118129

119-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
130+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
120131
Name: "test-static",
121132
URL: srv.URL,
122133
})
@@ -139,7 +150,7 @@ func TestStaticRegistrySource_ListPlugins(t *testing.T) {
139150
srv := buildStaticRegistryServer(t, index, nil)
140151
defer srv.Close()
141152

142-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
153+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
143154
Name: "test-static",
144155
URL: srv.URL,
145156
})
@@ -174,7 +185,7 @@ func TestStaticRegistrySource_SearchPlugins_AllWithEmptyQuery(t *testing.T) {
174185
srv := buildStaticRegistryServer(t, index, nil)
175186
defer srv.Close()
176187

177-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
188+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
178189
Name: "test-static",
179190
URL: srv.URL,
180191
})
@@ -200,7 +211,7 @@ func TestStaticRegistrySource_SearchPlugins_Filtering(t *testing.T) {
200211
srv := buildStaticRegistryServer(t, index, nil)
201212
defer srv.Close()
202213

203-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
214+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
204215
Name: "test-static",
205216
URL: srv.URL,
206217
})
@@ -256,7 +267,7 @@ func TestStaticRegistrySource_SearchPlugins_SourceName(t *testing.T) {
256267
defer srv.Close()
257268

258269
const registryName = "my-static-registry"
259-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
270+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
260271
Name: registryName,
261272
URL: srv.URL,
262273
})
@@ -284,7 +295,7 @@ func TestStaticRegistrySource_TrailingSlashStripped(t *testing.T) {
284295
defer srv.Close()
285296

286297
// Pass URL with trailing slash.
287-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
298+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
288299
Name: "test",
289300
URL: srv.URL + "/",
290301
})
@@ -300,11 +311,23 @@ func TestStaticRegistrySource_TrailingSlashStripped(t *testing.T) {
300311

301312
// TestStaticRegistrySource_Name verifies that the registry name is returned correctly.
302313
func TestStaticRegistrySource_Name(t *testing.T) {
303-
src, _ := NewStaticRegistrySource(RegistrySourceConfig{
314+
src := mustNewStaticRegistrySource(t, RegistrySourceConfig{
304315
Name: "my-registry",
305316
URL: "https://example.com",
306317
})
307318
if src.Name() != "my-registry" {
308319
t.Errorf("Name: got %q, want %q", src.Name(), "my-registry")
309320
}
310321
}
322+
323+
// TestStaticRegistrySource_EmptyURL verifies that NewStaticRegistrySource returns
324+
// an error when the URL is empty.
325+
func TestStaticRegistrySource_EmptyURL(t *testing.T) {
326+
_, err := NewStaticRegistrySource(RegistrySourceConfig{
327+
Name: "no-url-registry",
328+
URL: "",
329+
})
330+
if err == nil {
331+
t.Fatal("expected error for empty URL, got nil")
332+
}
333+
}

0 commit comments

Comments
 (0)