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
108 changes: 104 additions & 4 deletions command/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import (
"archive/zip"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -638,6 +640,27 @@ func runCreatePackageVersion(path string, packageId string, namespace string, ve
}
}

// The descriptor's ancestorId must be a Package2Version (05i) ID. If a
// Subscriber Package Version (04t) ID was provided, convert it.
if strings.HasPrefix(ancestorId, "04t") {
query := fmt.Sprintf("SELECT Id FROM Package2Version WHERE SubscriberPackageVersionId = '%s'", ancestorId)
result, err := force.Query(query, func(options *lib.QueryOptions) {
options.IsTooling = true
})
if err != nil {
ErrorAndExit("Failed to resolve ancestor version: " + err.Error())
}
if len(result.Records) == 0 {
ErrorAndExit(fmt.Sprintf("No Package2Version found for ancestor: %s", ancestorId))
}
if id, ok := result.Records[0]["Id"].(string); ok {
fmt.Printf("Resolved ancestor %s to Package2Version %s\n", ancestorId, id)
ancestorId = id
} else {
ErrorAndExit("Failed to resolve ancestor Package2Version ID")
}
}

for _, dependency := range dependencies {
if !strings.HasPrefix(dependency, "04t") {
ErrorAndExit(fmt.Sprintf("Invalid dependency ID: %s. Must be a Subscriber Package Version ID (04t)", dependency))
Expand Down Expand Up @@ -686,12 +709,43 @@ func runCreatePackageVersion(path string, packageId string, namespace string, ve

// ForceMetadataFiles() adds package.xml automatically
packageFiles := pb.ForceMetadataFiles()

// The Salesforce CLI always includes an (often empty) Profile type in the
// manifest so that profile settings are recalculated for the package's
// components. Add it when no profiles are present.
if manifest, ok := packageFiles["package.xml"]; ok {
updated, err := addProfileTypeToManifest(manifest)
if err != nil {
ErrorAndExit(err.Error())
}
packageFiles["package.xml"] = updated
}

packageZipBuffer := new(bytes.Buffer)
packageZipWriter := zip.NewWriter(packageZipBuffer)

// Set a proper timestamp (current time)
modTime := time.Now()

// Collect directory entries so the zip matches the layout the Salesforce
// CLI produces (each directory is written as its own zero-length entry).
dirs := collectZipDirectories(packageFiles)

for _, dir := range dirs {
header := &zip.FileHeader{
Name: dir,
Method: zip.Store,
Modified: modTime,
CreatorVersion: 0x14, // Version 2.0 made by (FAT filesystem)
ReaderVersion: 0x0A, // Version 1.0 needed to extract
ExternalAttrs: 0, // FAT attributes
}
header.SetMode(os.ModeDir | 0755)
if _, err := packageZipWriter.CreateHeader(header); err != nil {
ErrorAndExit("Failed to create zip directory entry: " + err.Error())
}
}

for filePath, fileData := range packageFiles {
// Create a zip header with proper settings
header := &zip.FileHeader{
Expand Down Expand Up @@ -783,10 +837,12 @@ func runCreatePackageVersion(path string, packageId string, namespace string, ve

// Add Package2VersionCreateRequest field (second part)
request := map[string]interface{}{
"Package2Id": packageId,
"CalculateCodeCoverage": codeCoverage,
"SkipValidation": skipValidation,
"AsyncValidation": asyncValidation,
"Package2Id": packageId,
"CalculateCodeCoverage": codeCoverage,
"SkipValidation": skipValidation,
"AsyncValidation": asyncValidation,
"CalcTransitiveDependencies": false,
"IsDevUsePkgZipRequested": false,
}
if tag != "" {
request["Tag"] = tag
Expand Down Expand Up @@ -830,6 +886,50 @@ func runCreatePackageVersion(path string, packageId string, namespace string, ve
}
}

// addProfileTypeToManifest ensures the package.xml manifest includes a Profile
// type, matching the Salesforce CLI which always adds one (often empty) so that
// profile settings are recalculated for the package's components. The manifest
// is re-marshaled so the Profile <types> element is correctly ordered before
// <version>. If a Profile type is already present the manifest is returned
// unchanged.
func addProfileTypeToManifest(manifest []byte) ([]byte, error) {
var p lib.Package
if err := xml.Unmarshal(manifest, &p); err != nil {
return nil, fmt.Errorf("Failed to parse package.xml: %w", err)
}
for _, t := range p.Types {
if t.Name == "Profile" {
return manifest, nil
}
}
p.Types = append(p.Types, lib.MetaType{Name: "Profile"})
byteXml, err := xml.MarshalIndent(p, "", " ")
if err != nil {
return nil, fmt.Errorf("Failed to marshal package.xml: %w", err)
}
return append([]byte(xml.Header), byteXml...), nil
}

// collectZipDirectories returns the sorted set of directory entries (each with a
// trailing slash) implied by the file paths, so the package zip mirrors the
// layout the Salesforce CLI produces, which writes each directory as its own
// zero-length entry.
func collectZipDirectories(files lib.ForceMetadataFiles) []string {
dirSet := make(map[string]bool)
for filePath := range files {
parts := strings.Split(filePath, "/")
for i := 1; i < len(parts); i++ {
dirSet[strings.Join(parts[:i], "/")+"/"] = true
}
}
dirs := make([]string, 0, len(dirSet))
for dir := range dirSet {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
return dirs
}

func buildPackageVersionDescriptor(versionName string, versionNumber string, versionDescription string, packageId string, ancestorId string, dependencies []string) map[string]interface{} {
descriptor := map[string]interface{}{
"versionName": versionName,
Expand Down
96 changes: 96 additions & 0 deletions command/package_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package command

import (
"encoding/xml"
"reflect"
"strings"
"testing"

"github.com/ForceCLI/force/lib"
)

func Test_package_version_create_command_requires_flags(t *testing.T) {
Expand Down Expand Up @@ -125,6 +129,98 @@ func Test_buildPackageVersionDescriptor_omits_dependencies_when_empty(t *testing
}
}

func Test_addProfileTypeToManifest_appends_profile_type_when_absent(t *testing.T) {
manifest := []byte(xml.Header + `<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>GoBridge</members>
<name>ApexClass</name>
</types>
<version>67.0</version>
</Package>`)

updated, err := addProfileTypeToManifest(manifest)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}

var p lib.Package
if err := xml.Unmarshal(updated, &p); err != nil {
t.Fatalf("Failed to parse updated manifest: %s", err)
}

if len(p.Types) != 2 {
t.Fatalf("Expected 2 types, got %d", len(p.Types))
}
if p.Types[1].Name != "Profile" {
t.Errorf("Expected Profile type to be appended, got %q", p.Types[1].Name)
}
if len(p.Types[1].Members) != 0 {
t.Errorf("Expected Profile type to have no members, got %v", p.Types[1].Members)
}
if p.Version != "67.0" {
t.Errorf("Expected version to be preserved, got %q", p.Version)
}

// The Profile <types> element must precede <version> to satisfy the
// Package schema's element ordering.
if typesIdx, versionIdx := strings.LastIndex(string(updated), "<types>"), strings.Index(string(updated), "<version>"); typesIdx > versionIdx {
t.Errorf("Expected all <types> elements before <version>, got types at %d and version at %d", typesIdx, versionIdx)
}
}

func Test_addProfileTypeToManifest_leaves_manifest_unchanged_when_profile_present(t *testing.T) {
manifest := []byte(xml.Header + `<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>Admin</members>
<name>Profile</name>
</types>
<version>67.0</version>
</Package>`)

updated, err := addProfileTypeToManifest(manifest)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}

if !reflect.DeepEqual(updated, manifest) {
t.Errorf("Expected manifest to be unchanged.\ngot=%s\nwant=%s", updated, manifest)
}
}

func Test_addProfileTypeToManifest_returns_error_for_invalid_xml(t *testing.T) {
if _, err := addProfileTypeToManifest([]byte("not xml")); err == nil {
t.Error("Expected an error for invalid package.xml")
}
}

func Test_collectZipDirectories_returns_sorted_directory_entries(t *testing.T) {
files := lib.ForceMetadataFiles{
"package.xml": nil,
"classes/GoBridge.cls": nil,
"classes/GoBridge.cls-meta.xml": nil,
"objects/Thunder_Settings__c.object": nil,
"lwc/go/go.js": nil,
"lwc/thunder/thunder.js": nil,
}

got := collectZipDirectories(files)
want := []string{"classes/", "lwc/", "lwc/go/", "lwc/thunder/", "objects/"}

if !reflect.DeepEqual(got, want) {
t.Errorf("Unexpected directories.\ngot=%v\nwant=%v", got, want)
}
}

func Test_collectZipDirectories_returns_empty_when_no_subdirectories(t *testing.T) {
files := lib.ForceMetadataFiles{
"package.xml": nil,
}

if got := collectZipDirectories(files); len(got) != 0 {
t.Errorf("Expected no directories, got %v", got)
}
}

func Test_package_version_create_command_accepts_one_argument(t *testing.T) {
cmd := packageVersionCreateCmd

Expand Down
Loading