diff --git a/command/package.go b/command/package.go index c20ee06e..f0fd8505 100644 --- a/command/package.go +++ b/command/package.go @@ -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" @@ -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)) @@ -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{ @@ -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 @@ -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 element is correctly ordered before +// . 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, diff --git a/command/package_test.go b/command/package_test.go index 003f658a..4322fc57 100644 --- a/command/package_test.go +++ b/command/package_test.go @@ -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) { @@ -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 + ` + + GoBridge + ApexClass + + 67.0 +`) + + 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 element must precede to satisfy the + // Package schema's element ordering. + if typesIdx, versionIdx := strings.LastIndex(string(updated), ""), strings.Index(string(updated), ""); typesIdx > versionIdx { + t.Errorf("Expected all elements before , 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 + ` + + Admin + Profile + + 67.0 +`) + + 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