diff --git a/cmd/browsers.go b/cmd/browsers.go index f3de63c..3539dba 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -1940,7 +1940,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions tempZipPath := filepath.Join(os.TempDir(), fmt.Sprintf("kernel-ext-%s.zip", extName)) pterm.Info.Printf("Zipping %s as %s...\n", extPath, extName) - if err := util.ZipDirectory(extPath, tempZipPath); err != nil { + if err := util.ZipDirectory(extPath, tempZipPath, nil); err != nil { pterm.Error.Printf("Failed to zip %s: %v\n", extPath, err) return nil } diff --git a/cmd/deploy.go b/cmd/deploy.go index 72301b8..e826eb0 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -236,7 +236,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { } tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_%d.zip", time.Now().UnixNano())) logger.Debug("compressing files", logger.Args("sourceDir", sourceDir, "tmpFile", tmpFile)) - if err := util.ZipDirectory(sourceDir, tmpFile); err != nil { + if err := util.ZipDirectory(sourceDir, tmpFile, nil); err != nil { if spinner != nil { spinner.Fail("Failed to compress files") } diff --git a/cmd/extensions.go b/cmd/extensions.go index 9b063b5..ca7e211 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -18,6 +18,29 @@ import ( "github.com/spf13/cobra" ) +const ( + MaxExtensionSizeBytes = 50 * 1024 * 1024 // 50MB +) + +// defaultExtensionExclusions contains patterns for files that are not needed +// when zipping Chrome extensions +var defaultExtensionExclusions = util.ZipOptions{ + ExcludeDirectories: []string{ + "node_modules", + ".git", + "__tests__", + "coverage", + }, + ExcludeFilenamePatterns: []string{ + "*.test.js", + "*.test.ts", + "*.spec.js", + "*.spec.ts", + "*.log", + "*.swp", + }, +} + // ExtensionsService defines the subset of the Kernel SDK extension client that we use. type ExtensionsService interface { List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.ExtensionListResponse, err error) @@ -294,26 +317,57 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return fmt.Errorf("directory %s does not exist", absDir) } + // Pre-flight size check + if in.Output != "json" { + pterm.Info.Println("Analyzing extension directory...") + } + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) + if in.Output != "json" { pterm.Info.Println("Zipping extension directory...") } - if err := util.ZipDirectory(absDir, tmpFile); err != nil { + + if err := util.ZipDirectory(absDir, tmpFile, &defaultExtensionExclusions); err != nil { pterm.Error.Println("Failed to zip directory") return err } defer os.Remove(tmpFile) + fileInfo, err := os.Stat(tmpFile) + if err != nil { + return fmt.Errorf("failed to stat zip: %w", err) + } + + if in.Output != "json" { + pterm.Success.Printf("Created bundle: %s\n", util.FormatBytes(fileInfo.Size())) + } + + if fileInfo.Size() > MaxExtensionSizeBytes { + pterm.Error.Printf("Extension bundle is too large: %s (max: 50MB)\n", + util.FormatBytes(fileInfo.Size())) + pterm.Info.Println("\nSuggestions to reduce size:") + pterm.Info.Println(" 1. Ensure you're building the extension for production") + pterm.Info.Println(" 2. Remove unnecessary assets (large images, videos)") + pterm.Info.Println(" 3. Check manifest.json references only needed files") + return fmt.Errorf("bundle exceeds maximum size") + } + f, err := os.Open(tmpFile) if err != nil { return fmt.Errorf("failed to open temp zip: %w", err) } defer f.Close() + if in.Output != "json" { + pterm.Info.Println("Uploading extension...") + } + params := kernel.ExtensionUploadParams{File: f} if in.Name != "" { params.Name = kernel.Opt(in.Name) } + item, err := e.extensions.Upload(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} diff --git a/pkg/util/format.go b/pkg/util/format.go index 7f64590..6682655 100644 --- a/pkg/util/format.go +++ b/pkg/util/format.go @@ -1,6 +1,9 @@ package util -import "strings" +import ( + "fmt" + "strings" +) // OrDash returns the string if non-empty, otherwise returns "-". func OrDash(s string) string { @@ -29,3 +32,17 @@ func JoinOrDash(items ...string) string { } return strings.Join(items, ", ") } + +// FormatBytes formats bytes in a human-readable format +func FormatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/pkg/util/zip.go b/pkg/util/zip.go index 468721d..111bca0 100644 --- a/pkg/util/zip.go +++ b/pkg/util/zip.go @@ -11,8 +11,16 @@ import ( "github.com/boyter/gocodewalker" ) +// ZipOptions which directories and files to exclude from the zip +type ZipOptions struct { + // ExcludeDirectories: exact directory names to exclude (case-sensitive) + ExcludeDirectories []string + // ExcludeFilenamePatterns: glob patterns for filename exclusion (e.g., "*.test.js") + ExcludeFilenamePatterns []string +} + // ZipDirectory compresses the given source directory into the destination file path. -func ZipDirectory(srcDir, destZip string) error { +func ZipDirectory(srcDir, destZip string, opts *ZipOptions) error { zipFile, err := os.Create(destZip) if err != nil { return err @@ -28,9 +36,16 @@ func ZipDirectory(srcDir, destZip string) error { // Include hidden files (to match previous behaviour) but still respect .gitignore rules walker.IncludeHidden = true - // Start walking in a separate goroutine so we can process files as they arrive + // Apply directory exclusions to walker + if opts != nil { + walker.ExcludeDirectory = append(walker.ExcludeDirectory, opts.ExcludeDirectories...) + } + + defer walker.Terminate() + + errChan := make(chan error, 1) go func() { - _ = walker.Start() + errChan <- walker.Start() }() // Track directories we've already added to the zip archive so we don't duplicate entries @@ -44,6 +59,22 @@ func ZipDirectory(srcDir, destZip string) error { } relPath = filepath.ToSlash(relPath) + // Check against pattern-based exclusions if provided + if opts != nil && len(opts.ExcludeFilenamePatterns) > 0 { + filename := filepath.Base(f.Location) + shouldExclude := false + for _, pattern := range opts.ExcludeFilenamePatterns { + matched, err := filepath.Match(pattern, filename) + if err == nil && matched { + shouldExclude = true + break + } + } + if shouldExclude { + continue + } + } + // Ensure parent directories exist in the archive if dir := filepath.Dir(relPath); dir != "." && dir != "" { // Walk up the directory tree ensuring each level exists @@ -115,6 +146,10 @@ func ZipDirectory(srcDir, destZip string) error { } } + if err := <-errChan; err != nil { + return fmt.Errorf("directory walk failed: %w", err) + } + return nil } diff --git a/pkg/util/zip_test.go b/pkg/util/zip_test.go new file mode 100644 index 0000000..6fbeb37 --- /dev/null +++ b/pkg/util/zip_test.go @@ -0,0 +1,178 @@ +package util + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestZipDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "zip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + files := map[string]string{ + "manifest.json": `{"name": "test", "version": "1.0"}`, + "background.js": "console.log('background');", + "content.js": "console.log('content');", + "icons/icon.png": "fake-png-data", + "node_modules/dep/foo.js": "should be excluded", + "test.test.js": "should be excluded", + } + + for path, content := range files { + fullPath := filepath.Join(tmpDir, path) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file %s: %v", fullPath, err) + } + } + + tmpZip, err := os.CreateTemp("", "test-zip-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip.Close() + defer os.Remove(tmpZip.Name()) + + // Test with exclusions + t.Run("with exclusions", func(t *testing.T) { + opts := &ZipOptions{ + ExcludeDirectories: []string{"node_modules"}, + ExcludeFilenamePatterns: []string{"*.test.js"}, + } + if err := ZipDirectory(tmpDir, tmpZip.Name(), opts); err != nil { + t.Fatalf("ZipDirectory failed: %v", err) + } + + // Verify the zip contents + r, err := zip.OpenReader(tmpZip.Name()) + if err != nil { + t.Fatalf("Failed to open zip: %v", err) + } + defer r.Close() + + expectedFiles := map[string]bool{ + "manifest.json": false, + "background.js": false, + "content.js": false, + "icons/": false, + "icons/icon.png": false, + } + + for _, f := range r.File { + if f.FileInfo().IsDir() { + expectedFiles[f.Name] = true + } else { + if _, ok := expectedFiles[f.Name]; ok { + expectedFiles[f.Name] = true + } else { + t.Errorf("Unexpected file found in zip: %s", f.Name) + } + } + } + + for name, found := range expectedFiles { + if !found && name != "icons/" { + t.Errorf("Expected file not found in zip: %s", name) + } + } + }) + + // Test without exclusions (nil opts) + t.Run("without exclusions", func(t *testing.T) { + tmpZip2, err := os.CreateTemp("", "test-zip-no-exclude-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip2.Close() + defer os.Remove(tmpZip2.Name()) + + if err := ZipDirectory(tmpDir, tmpZip2.Name(), nil); err != nil { + t.Fatalf("ZipDirectory failed: %v", err) + } + + // Verify all files are included (no exclusions) + r, err := zip.OpenReader(tmpZip2.Name()) + if err != nil { + t.Fatalf("Failed to open zip: %v", err) + } + defer r.Close() + + fileCount := 0 + for _, f := range r.File { + if !f.FileInfo().IsDir() { + fileCount++ + } + } + if fileCount <= 4 { + t.Errorf("Expected more than 4 files when exclusions are disabled, got %d", fileCount) + } + }) +} + +func TestUnzip(t *testing.T) { + tmpZip, err := os.CreateTemp("", "test-unzip-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip.Close() + defer os.Remove(tmpZip.Name()) + + zw, err := os.Create(tmpZip.Name()) + if err != nil { + t.Fatalf("Failed to open zip for writing: %v", err) + } + zipWriter := zip.NewWriter(zw) + + testFiles := map[string]string{ + "file1.txt": "content of file 1", + "subdir/file2.txt": "content of file 2", + } + + if _, err := zipWriter.Create("subdir/"); err != nil { + t.Fatalf("Failed to create dir entry: %v", err) + } + + for name, content := range testFiles { + w, err := zipWriter.Create(name) + if err != nil { + t.Fatalf("Failed to create zip entry %s: %v", name, err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatalf("Failed to write zip entry %s: %v", name, err) + } + } + zipWriter.Close() + zw.Close() + + // Unzip to temp directory + destDir, err := os.MkdirTemp("", "test-unzip-dest-*") + if err != nil { + t.Fatalf("Failed to create dest dir: %v", err) + } + defer os.RemoveAll(destDir) + + if err := Unzip(tmpZip.Name(), destDir); err != nil { + t.Fatalf("Unzip failed: %v", err) + } + + // Verify extracted files + for name, expectedContent := range testFiles { + path := filepath.Join(destDir, name) + content, err := os.ReadFile(path) + if err != nil { + t.Errorf("Failed to read extracted file %s: %v", name, err) + continue + } + if string(content) != expectedContent { + t.Errorf("File %s: expected %q, got %q", name, expectedContent, string(content)) + } + } +}