Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
56 changes: 55 additions & 1 deletion cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
19 changes: 18 additions & 1 deletion pkg/util/format.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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])
}
41 changes: 38 additions & 3 deletions pkg/util/zip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
178 changes: 178 additions & 0 deletions pkg/util/zip_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
}