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
49 changes: 48 additions & 1 deletion cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import (
"github.com/spf13/cobra"
)

const (
// MaxExtensionSize is the maximum allowed size for extension bundles from API (50MB)
MaxExtensionSize = 50 * 1024 * 1024
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: consider renaming to MaxExtensionSizeBytes to be more explicit about units

)

// 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,31 +299,73 @@ 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 {

// Use new extension-specific zip function
stats, err := util.ZipExtensionDirectory(absDir, tmpFile, &util.ExtensionZipOptions{
Copy link
Contributor

Choose a reason for hiding this comment

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

since this is in a generic util package, consider enhancing the existing ZipDirectory function with options rather than creating a new ZipExtensionDirectory function. this would be more consistent with the existing api.

ExcludeDefaults: false, // Apply defaults
Copy link
Contributor

Choose a reason for hiding this comment

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

ExcludeDefaults: false is confusing - at first glance i'd expect true to mean "apply defaults". consider renaming to something like ApplyDefaults (default true) or SkipDefaults (default false) to make the intent clearer.

})
if err != nil {
pterm.Error.Println("Failed to zip directory")
return err
}
defer os.Remove(tmpFile)

// Get zip file size
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 (%d files)\n",
util.FormatBytes(fileInfo.Size()), stats.FilesIncluded)
}

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: obvious comment, can remove

// Final size validation

if fileInfo.Size() > MaxExtensionSize {
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")
}

// Open file for upload
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: obvious comment, can remove

f, err := os.Open(tmpFile)
if err != nil {
return fmt.Errorf("failed to open temp zip: %w", err)
}
defer f.Close()
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: obvious comment, can remove


// Upload
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}
}

// Display results
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: obvious comment, can remove

if in.Output == "json" {
return util.PrintPrettyJSON(item)
}
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])
}
223 changes: 223 additions & 0 deletions pkg/util/zip_extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package util

import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"

"github.com/boyter/gocodewalker"
)

// DefaultExtensionExclusions contains patterns for files that are not needed
// Focus on large directories and files that provide meaningful size reduction.
// These will be passed to gocodewalker's ExcludeDirectory and ExcludeFilename fields.
var DefaultExtensionExclusions = struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

to make this more generic: extract DefaultExtensionExclusions to define a type ZipDirectoryOptions (with fields ExcludeDirectory and ExcludeFilenamePatterns). move the DefaultExtensionExclusions value itself to cmd/extensions.go. keep the type definition in util. then make ZipDirectoryOptions an optional argument to the zip directory method. this way util has no extension-specific code and the extensions command can use the generic method with its own defaults.

// ExcludeDirectory: exact directory names (case-sensitive)
ExcludeDirectory []string
// ExcludeFilenamePatterns: patterns for filename matching (handled manually)
ExcludeFilenamePatterns []string
}{
ExcludeDirectory: []string{
// Dependencies
"node_modules",

// Version control
".git",

// Test/Coverage
"__tests__",
"coverage",
},

ExcludeFilenamePatterns: []string{
// Test files
"*.test.js",
"*.test.ts",
"*.spec.js",
"*.spec.ts",

// Log files
"*.log",

// Temp files
"*.swp",
},
}

// ExtensionZipOptions configures extension-specific zipping behavior
type ExtensionZipOptions struct {
ExcludeDefaults bool // If true, don't apply default exclusions
}

// ZipStats tracks statistics about the zipping operation
type ZipStats struct {
mu sync.Mutex
FilesIncluded int
FilesExcluded int
BytesIncluded int64
BytesExcluded int64
}

func (s *ZipStats) AddIncluded(bytes int64) {
s.mu.Lock()
defer s.mu.Unlock()
Copy link
Contributor

Choose a reason for hiding this comment

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

verify mutex usage is correct here - should be using s.mu.Unlock() not s.mutex.Unlock()

s.FilesIncluded++
s.BytesIncluded += bytes
}

func (s *ZipStats) AddExcluded(bytes int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.FilesExcluded++
s.BytesExcluded += bytes
}

// ZipExtensionDirectory zips a Chrome extension directory with smart defaults
// that automatically exclude development files (node_modules, .git, etc.)
func ZipExtensionDirectory(srcDir, destZip string, opts *ExtensionZipOptions) (*ZipStats, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

there's already a ZipDirectory function in pkg/util/zip.go that does almost exactly the same thing (used by deploy and browsers commands). this duplicates a lot of code. consider enhancing the existing ZipDirectory with options and stats support rather than creating a new function. also note that the existing ZipDirectory has a potential goroutine leak (doesn't call walker.Terminate()).

if opts == nil {
opts = &ExtensionZipOptions{}
}

stats := &ZipStats{}

zipFile, err := os.Create(destZip)
if err != nil {
return nil, err
}
defer zipFile.Close()

zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()

// Configure file walker with exclusions
fileQueue := make(chan *gocodewalker.File, 256)
walker := gocodewalker.NewFileWalker(srcDir, fileQueue)
walker.IncludeHidden = true

// Apply default exclusions unless disabled
if !opts.ExcludeDefaults {
walker.ExcludeDirectory = append(walker.ExcludeDirectory, DefaultExtensionExclusions.ExcludeDirectory...)
walker.ExcludeFilename = append(walker.ExcludeFilename, DefaultExtensionExclusions.ExcludeFilenamePatterns...)
}

// Ensure walker is terminated on any return path to prevent goroutine leak
defer walker.Terminate()

// Track walker errors
errChan := make(chan error, 1)
go func() {
errChan <- walker.Start()
}()

// Track directories we've added to avoid duplicates
dirsAdded := make(map[string]struct{})

// Process files from walker
for f := range fileQueue {
relPath, err := filepath.Rel(srcDir, f.Location)
if err != nil {
return stats, err
}
relPath = filepath.ToSlash(relPath)

// Check against pattern-based exclusions (if defaults are enabled)
shouldExclude := false
if !opts.ExcludeDefaults {
// Check filename against patterns only if defaults are enabled
filename := filepath.Base(f.Location)
for _, pattern := range DefaultExtensionExclusions.ExcludeFilenamePatterns {
matched, err := filepath.Match(pattern, filename)
if err == nil && matched {
shouldExclude = true
break
}
}
}

if shouldExclude {
continue
}

// Ensure parent directories exist in archive
if dir := filepath.Dir(relPath); dir != "." && dir != "" {
segments := strings.Split(dir, "/")
var current string
for _, segment := range segments {
if current == "" {
current = segment
} else {
current = current + "/" + segment
}
if _, exists := dirsAdded[current+"/"]; !exists {
if _, err := zipWriter.Create(current + "/"); err != nil {
return stats, err
}
dirsAdded[current+"/"] = struct{}{}
}
}
}

// Get file info
fileInfo, err := os.Lstat(f.Location)
if err != nil {
return stats, err
}

// Handle symlinks
if fileInfo.Mode()&os.ModeSymlink != 0 {
linkTarget, err := os.Readlink(f.Location)
if err != nil {
return stats, err
}

hdr := &zip.FileHeader{
Name: relPath,
Method: zip.Store,
}
hdr.SetMode(os.ModeSymlink | 0777)

zipFileWriter, err := zipWriter.CreateHeader(hdr)
if err != nil {
return stats, err
}
if _, err := zipFileWriter.Write([]byte(linkTarget)); err != nil {
return stats, err
}
stats.AddIncluded(int64(len(linkTarget)))
} else {
// Regular file
zipFileWriter, err := zipWriter.Create(relPath)
if err != nil {
return stats, err
}

file, err := os.Open(f.Location)
if err != nil {
return stats, err
}

written, err := io.Copy(zipFileWriter, file)
closeErr := file.Close()
if closeErr != nil {
return stats, closeErr
}
if err != nil {
return stats, err
}

stats.AddIncluded(written)
}
}

// Check if walker had an error
if err := <-errChan; err != nil {
return stats, fmt.Errorf("directory walk failed: %w", err)
}

return stats, nil
}
Loading