diff --git a/api/metadata/client.go b/api/metadata/client.go
new file mode 100644
index 0000000..ce13700
--- /dev/null
+++ b/api/metadata/client.go
@@ -0,0 +1,466 @@
+package metadata
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// DefaultAPIVersion is the default Salesforce API version.
+const DefaultAPIVersion = "v62.0"
+
+// Client is a Salesforce Metadata API client.
+type Client struct {
+ httpClient *http.Client
+ instanceURL string
+ apiVersion string
+ baseURL string
+}
+
+// ClientConfig contains configuration for creating a new Metadata API client.
+type ClientConfig struct {
+ InstanceURL string
+ HTTPClient *http.Client
+ APIVersion string
+}
+
+// New creates a new Metadata API client.
+func New(cfg ClientConfig) (*Client, error) {
+ if cfg.InstanceURL == "" {
+ return nil, fmt.Errorf("instance URL is required")
+ }
+ if cfg.HTTPClient == nil {
+ return nil, fmt.Errorf("HTTP client is required")
+ }
+
+ instanceURL := strings.TrimSuffix(cfg.InstanceURL, "/")
+ apiVersion := cfg.APIVersion
+ if apiVersion == "" {
+ apiVersion = DefaultAPIVersion
+ }
+
+ return &Client{
+ httpClient: cfg.HTTPClient,
+ instanceURL: instanceURL,
+ apiVersion: apiVersion,
+ baseURL: fmt.Sprintf("%s/services/data/%s", instanceURL, apiVersion),
+ }, nil
+}
+
+// doRequest performs an HTTP request and returns the response body.
+func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
+ var bodyReader io.Reader
+ if body != nil {
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+ bodyReader = bytes.NewReader(jsonBody)
+ }
+
+ fullURL := path
+ if !strings.HasPrefix(path, "http") {
+ fullURL = c.baseURL + path
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ return respBody, nil
+}
+
+// Get performs a GET request.
+func (c *Client) Get(ctx context.Context, path string) ([]byte, error) {
+ return c.doRequest(ctx, http.MethodGet, path, nil)
+}
+
+// Post performs a POST request.
+func (c *Client) Post(ctx context.Context, path string, body interface{}) ([]byte, error) {
+ return c.doRequest(ctx, http.MethodPost, path, body)
+}
+
+// DescribeMetadata returns available metadata types.
+// Uses the Tooling API to get metadata type information.
+func (c *Client) DescribeMetadata(ctx context.Context) (*DescribeMetadataResult, error) {
+ // Use Tooling API's describe endpoint for metadata types
+ path := "/tooling/describe"
+ body, err := c.Get(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse the describe result to extract metadata types
+ var describeResult struct {
+ Sobjects []struct {
+ Name string `json:"name"`
+ Createable bool `json:"createable"`
+ Updateable bool `json:"updateable"`
+ Deletable bool `json:"deletable"`
+ Queryable bool `json:"queryable"`
+ KeyPrefix string `json:"keyPrefix"`
+ } `json:"sobjects"`
+ }
+
+ if err := json.Unmarshal(body, &describeResult); err != nil {
+ return nil, fmt.Errorf("failed to parse describe result: %w", err)
+ }
+
+ // Filter and convert to MetadataType
+ // Common metadata types that can be queried via Tooling API
+ metadataTypeNames := map[string]bool{
+ "ApexClass": true,
+ "ApexTrigger": true,
+ "ApexComponent": true,
+ "ApexPage": true,
+ "AuraDefinition": true,
+ "LightningComponentBundle": true,
+ "StaticResource": true,
+ "CustomObject": true,
+ "CustomField": true,
+ "ValidationRule": true,
+ "WorkflowRule": true,
+ "Flow": true,
+ "FlowDefinition": true,
+ }
+
+ result := &DescribeMetadataResult{
+ MetadataObjects: make([]MetadataType, 0),
+ }
+
+ for _, obj := range describeResult.Sobjects {
+ if metadataTypeNames[obj.Name] {
+ result.MetadataObjects = append(result.MetadataObjects, MetadataType{
+ XMLName: obj.Name,
+ })
+ }
+ }
+
+ return result, nil
+}
+
+// ListMetadata lists components of a specific metadata type.
+func (c *Client) ListMetadata(ctx context.Context, metadataType string) ([]MetadataComponent, error) {
+ // Query the Tooling API for the metadata type
+ var soql string
+ switch metadataType {
+ case "ApexClass":
+ soql = "SELECT Id, Name, NamespacePrefix, CreatedById, LastModifiedById, LastModifiedDate FROM ApexClass ORDER BY Name"
+ case "ApexTrigger":
+ soql = "SELECT Id, Name, NamespacePrefix, TableEnumOrId, CreatedById, LastModifiedById, LastModifiedDate FROM ApexTrigger ORDER BY Name"
+ case "ApexPage":
+ soql = "SELECT Id, Name, NamespacePrefix, MasterLabel, CreatedById, LastModifiedById, LastModifiedDate FROM ApexPage ORDER BY Name"
+ case "ApexComponent":
+ soql = "SELECT Id, Name, NamespacePrefix, MasterLabel, CreatedById, LastModifiedById, LastModifiedDate FROM ApexComponent ORDER BY Name"
+ case "StaticResource":
+ soql = "SELECT Id, Name, NamespacePrefix, ContentType, CreatedById, LastModifiedById, LastModifiedDate FROM StaticResource ORDER BY Name"
+ case "AuraDefinitionBundle":
+ soql = "SELECT Id, DeveloperName, NamespacePrefix, MasterLabel, CreatedById, LastModifiedById, LastModifiedDate FROM AuraDefinitionBundle ORDER BY DeveloperName"
+ case "LightningComponentBundle":
+ soql = "SELECT Id, DeveloperName, NamespacePrefix, MasterLabel FROM LightningComponentBundle ORDER BY DeveloperName"
+ default:
+ return nil, fmt.Errorf("unsupported metadata type: %s", metadataType)
+ }
+
+ path := fmt.Sprintf("/tooling/query?q=%s", url.QueryEscape(soql))
+ body, err := c.Get(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+
+ var queryResult struct {
+ TotalSize int `json:"totalSize"`
+ Done bool `json:"done"`
+ Records []map[string]interface{} `json:"records"`
+ }
+
+ if err := json.Unmarshal(body, &queryResult); err != nil {
+ return nil, fmt.Errorf("failed to parse query result: %w", err)
+ }
+
+ components := make([]MetadataComponent, 0, len(queryResult.Records))
+ for _, rec := range queryResult.Records {
+ comp := MetadataComponent{
+ Type: metadataType,
+ }
+
+ if id, ok := rec["Id"].(string); ok {
+ comp.ID = id
+ }
+
+ // Handle different name fields
+ if name, ok := rec["Name"].(string); ok {
+ comp.FullName = name
+ } else if name, ok := rec["DeveloperName"].(string); ok {
+ comp.FullName = name
+ }
+
+ if ns, ok := rec["NamespacePrefix"].(string); ok {
+ comp.NamespacePrefix = ns
+ }
+
+ components = append(components, comp)
+ }
+
+ return components, nil
+}
+
+// Deploy deploys metadata to the org.
+func (c *Client) Deploy(ctx context.Context, zipData []byte, options DeployOptions) (*DeployResult, error) {
+ // Encode zip as base64
+ zipBase64 := base64.StdEncoding.EncodeToString(zipData)
+
+ request := DeployRequest{
+ ZipFile: zipBase64,
+ DeployOptions: options,
+ }
+
+ body, err := c.Post(ctx, "/metadata/deployRequest", request)
+ if err != nil {
+ return nil, err
+ }
+
+ var result DeployResult
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("failed to parse deploy result: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GetDeployStatus gets the status of a deployment.
+func (c *Client) GetDeployStatus(ctx context.Context, deployID string, includeDetails bool) (*DeployResult, error) {
+ path := fmt.Sprintf("/metadata/deployRequest/%s", deployID)
+ if includeDetails {
+ path += "?includeDetails=true"
+ }
+
+ body, err := c.Get(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+
+ var result DeployResult
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("failed to parse deploy status: %w", err)
+ }
+
+ return &result, nil
+}
+
+// CreateZipFromDirectory creates a zip file from a directory.
+func CreateZipFromDirectory(sourceDir string) ([]byte, error) {
+ buf := new(bytes.Buffer)
+ zipWriter := zip.NewWriter(buf)
+
+ err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Get relative path
+ relPath, err := filepath.Rel(sourceDir, path)
+ if err != nil {
+ return err
+ }
+
+ // Skip the root directory itself
+ if relPath == "." {
+ return nil
+ }
+
+ // Use forward slashes for zip paths
+ zipPath := filepath.ToSlash(relPath)
+
+ if info.IsDir() {
+ // Add directory entry
+ _, err := zipWriter.Create(zipPath + "/")
+ return err
+ }
+
+ // Add file
+ writer, err := zipWriter.Create(zipPath)
+ if err != nil {
+ return err
+ }
+
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ _, err = io.Copy(writer, file)
+ return err
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ if err := zipWriter.Close(); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+// ExtractZipToDirectory extracts a zip file to a directory.
+func ExtractZipToDirectory(zipData []byte, destDir string) error {
+ reader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
+ if err != nil {
+ return fmt.Errorf("failed to read zip: %w", err)
+ }
+
+ for _, file := range reader.File {
+ // Construct destination path
+ destPath := filepath.Join(destDir, file.Name)
+
+ // Check for zip slip vulnerability
+ if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) {
+ return fmt.Errorf("illegal file path: %s", file.Name)
+ }
+
+ if file.FileInfo().IsDir() {
+ if err := os.MkdirAll(destPath, 0755); err != nil {
+ return err
+ }
+ continue
+ }
+
+ // Create parent directories
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ return err
+ }
+
+ // Extract file
+ if err := extractFile(file, destPath); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// extractFile extracts a single file from a zip archive.
+func extractFile(file *zip.File, destPath string) error {
+ destFile, err := os.Create(destPath)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ srcFile, err := file.Open()
+ if err != nil {
+ return err
+ }
+ defer srcFile.Close()
+
+ _, err = io.Copy(destFile, srcFile)
+ return err
+}
+
+// Retrieve retrieves metadata from the org.
+// Note: The REST Metadata API retrieve endpoint is not fully supported in all orgs.
+// For complex retrieves, consider using the Tooling API to get individual components.
+func (c *Client) Retrieve(ctx context.Context, metadataType, componentName string) ([]byte, error) {
+ // For simple cases, we can retrieve component body via Tooling API
+ var soql string
+ switch metadataType {
+ case "ApexClass":
+ soql = fmt.Sprintf("SELECT Id, Name, Body FROM ApexClass WHERE Name = '%s'", componentName)
+ case "ApexTrigger":
+ soql = fmt.Sprintf("SELECT Id, Name, Body FROM ApexTrigger WHERE Name = '%s'", componentName)
+ case "ApexPage":
+ soql = fmt.Sprintf("SELECT Id, Name, Markup FROM ApexPage WHERE Name = '%s'", componentName)
+ case "ApexComponent":
+ soql = fmt.Sprintf("SELECT Id, Name, Markup FROM ApexComponent WHERE Name = '%s'", componentName)
+ default:
+ return nil, fmt.Errorf("direct retrieve not supported for type: %s (use sf CLI for complex retrieves)", metadataType)
+ }
+
+ path := fmt.Sprintf("/tooling/query?q=%s", url.QueryEscape(soql))
+ body, err := c.Get(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+
+ var queryResult struct {
+ Records []map[string]interface{} `json:"records"`
+ }
+
+ if err := json.Unmarshal(body, &queryResult); err != nil {
+ return nil, fmt.Errorf("failed to parse query result: %w", err)
+ }
+
+ if len(queryResult.Records) == 0 {
+ return nil, fmt.Errorf("%s not found: %s", metadataType, componentName)
+ }
+
+ // Get the body/markup field
+ rec := queryResult.Records[0]
+ var content string
+ if body, ok := rec["Body"].(string); ok {
+ content = body
+ } else if markup, ok := rec["Markup"].(string); ok {
+ content = markup
+ } else {
+ return nil, fmt.Errorf("no content found for %s: %s", metadataType, componentName)
+ }
+
+ return []byte(content), nil
+}
+
+// RetrieveAll retrieves all components of a type from the org.
+func (c *Client) RetrieveAll(ctx context.Context, metadataType string) (map[string][]byte, error) {
+ // First list all components
+ components, err := c.ListMetadata(ctx, metadataType)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make(map[string][]byte)
+ for _, comp := range components {
+ // Skip managed package components
+ if comp.NamespacePrefix != "" {
+ continue
+ }
+
+ content, err := c.Retrieve(ctx, metadataType, comp.FullName)
+ if err != nil {
+ // Log error but continue with other components
+ continue
+ }
+ results[comp.FullName] = content
+ }
+
+ return results, nil
+}
diff --git a/api/metadata/client_test.go b/api/metadata/client_test.go
new file mode 100644
index 0000000..fbdfacf
--- /dev/null
+++ b/api/metadata/client_test.go
@@ -0,0 +1,382 @@
+package metadata
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNew(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg ClientConfig
+ wantErr bool
+ }{
+ {
+ name: "valid config",
+ cfg: ClientConfig{
+ InstanceURL: "https://test.salesforce.com",
+ HTTPClient: http.DefaultClient,
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing instance URL",
+ cfg: ClientConfig{
+ HTTPClient: http.DefaultClient,
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing HTTP client",
+ cfg: ClientConfig{
+ InstanceURL: "https://test.salesforce.com",
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := New(tt.cfg)
+ if tt.wantErr {
+ assert.Error(t, err)
+ assert.Nil(t, client)
+ } else {
+ assert.NoError(t, err)
+ assert.NotNil(t, client)
+ }
+ })
+ }
+}
+
+func TestDescribeMetadata(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/tooling/describe")
+
+ response := struct {
+ Sobjects []struct {
+ Name string `json:"name"`
+ Createable bool `json:"createable"`
+ Updateable bool `json:"updateable"`
+ Deletable bool `json:"deletable"`
+ Queryable bool `json:"queryable"`
+ } `json:"sobjects"`
+ }{
+ Sobjects: []struct {
+ Name string `json:"name"`
+ Createable bool `json:"createable"`
+ Updateable bool `json:"updateable"`
+ Deletable bool `json:"deletable"`
+ Queryable bool `json:"queryable"`
+ }{
+ {Name: "ApexClass", Createable: true, Updateable: true, Deletable: true, Queryable: true},
+ {Name: "ApexTrigger", Createable: true, Updateable: true, Deletable: true, Queryable: true},
+ {Name: "SomeOtherObject", Createable: true, Updateable: true, Deletable: true, Queryable: true},
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ result, err := client.DescribeMetadata(context.Background())
+ require.NoError(t, err)
+
+ // Should only include known metadata types
+ assert.GreaterOrEqual(t, len(result.MetadataObjects), 2)
+
+ found := false
+ for _, obj := range result.MetadataObjects {
+ if obj.XMLName == "ApexClass" {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "ApexClass should be in metadata types")
+}
+
+func TestListMetadata(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/tooling/query")
+ assert.Contains(t, r.URL.RawQuery, "ApexClass")
+
+ response := struct {
+ TotalSize int `json:"totalSize"`
+ Done bool `json:"done"`
+ Records []map[string]interface{} `json:"records"`
+ }{
+ TotalSize: 2,
+ Done: true,
+ Records: []map[string]interface{}{
+ {"Id": "01p000000000001", "Name": "MyController", "NamespacePrefix": nil},
+ {"Id": "01p000000000002", "Name": "MyHelper", "NamespacePrefix": nil},
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ components, err := client.ListMetadata(context.Background(), "ApexClass")
+ require.NoError(t, err)
+
+ assert.Len(t, components, 2)
+ assert.Equal(t, "MyController", components[0].FullName)
+ assert.Equal(t, "ApexClass", components[0].Type)
+}
+
+func TestListMetadataUnsupportedType(t *testing.T) {
+ client, err := New(ClientConfig{
+ InstanceURL: "https://test.salesforce.com",
+ HTTPClient: http.DefaultClient,
+ })
+ require.NoError(t, err)
+
+ _, err = client.ListMetadata(context.Background(), "UnsupportedType")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported")
+}
+
+func TestRetrieve(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ response := struct {
+ Records []map[string]interface{} `json:"records"`
+ }{
+ Records: []map[string]interface{}{
+ {
+ "Id": "01p000000000001",
+ "Name": "MyController",
+ "Body": "public class MyController { }",
+ },
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ content, err := client.Retrieve(context.Background(), "ApexClass", "MyController")
+ require.NoError(t, err)
+
+ assert.Equal(t, "public class MyController { }", string(content))
+}
+
+func TestRetrieveNotFound(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ response := struct {
+ Records []map[string]interface{} `json:"records"`
+ }{
+ Records: []map[string]interface{}{},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ _, err = client.Retrieve(context.Background(), "ApexClass", "NonExistent")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestDeploy(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/metadata/deployRequest")
+ assert.Equal(t, http.MethodPost, r.Method)
+
+ var request DeployRequest
+ _ = json.NewDecoder(r.Body).Decode(&request)
+ assert.NotEmpty(t, request.ZipFile)
+
+ result := DeployResult{
+ ID: "0Af000000000001",
+ Status: "Pending",
+ Done: false,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(result)
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ // Create a simple zip
+ buf := new(bytes.Buffer)
+ zipWriter := zip.NewWriter(buf)
+ writer, _ := zipWriter.Create("test.txt")
+ _, _ = writer.Write([]byte("test content"))
+ _ = zipWriter.Close()
+
+ result, err := client.Deploy(context.Background(), buf.Bytes(), DeployOptions{CheckOnly: true})
+ require.NoError(t, err)
+
+ assert.Equal(t, "0Af000000000001", result.ID)
+ assert.Equal(t, "Pending", result.Status)
+}
+
+func TestGetDeployStatus(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/metadata/deployRequest/0Af000000000001")
+
+ result := DeployResult{
+ ID: "0Af000000000001",
+ Status: "Succeeded",
+ Done: true,
+ Success: true,
+ NumberComponentsTotal: 5,
+ NumberComponentsDeployed: 5,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(result)
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ result, err := client.GetDeployStatus(context.Background(), "0Af000000000001", false)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Succeeded", result.Status)
+ assert.True(t, result.Done)
+ assert.True(t, result.Success)
+ assert.Equal(t, 5, result.NumberComponentsDeployed)
+}
+
+func TestCreateZipFromDirectory(t *testing.T) {
+ // Create temp directory with files
+ tmpDir := t.TempDir()
+ subDir := filepath.Join(tmpDir, "classes")
+ require.NoError(t, os.MkdirAll(subDir, 0755))
+
+ testFile := filepath.Join(subDir, "MyClass.cls")
+ require.NoError(t, os.WriteFile(testFile, []byte("public class MyClass {}"), 0644))
+
+ metaFile := filepath.Join(subDir, "MyClass.cls-meta.xml")
+ require.NoError(t, os.WriteFile(metaFile, []byte(""), 0644))
+
+ zipData, err := CreateZipFromDirectory(tmpDir)
+ require.NoError(t, err)
+ assert.NotEmpty(t, zipData)
+
+ // Verify zip contents
+ reader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
+ require.NoError(t, err)
+
+ fileNames := make([]string, 0)
+ for _, f := range reader.File {
+ fileNames = append(fileNames, f.Name)
+ }
+
+ assert.Contains(t, fileNames, "classes/MyClass.cls")
+ assert.Contains(t, fileNames, "classes/MyClass.cls-meta.xml")
+}
+
+func TestExtractZipToDirectory(t *testing.T) {
+ // Create a test zip
+ buf := new(bytes.Buffer)
+ zipWriter := zip.NewWriter(buf)
+
+ writer, _ := zipWriter.Create("classes/MyClass.cls")
+ _, _ = writer.Write([]byte("public class MyClass {}"))
+
+ writer, _ = zipWriter.Create("classes/MyClass.cls-meta.xml")
+ _, _ = writer.Write([]byte(""))
+
+ require.NoError(t, zipWriter.Close())
+
+ // Extract to temp directory
+ destDir := t.TempDir()
+ err := ExtractZipToDirectory(buf.Bytes(), destDir)
+ require.NoError(t, err)
+
+ // Verify extracted files
+ content, err := os.ReadFile(filepath.Join(destDir, "classes", "MyClass.cls"))
+ require.NoError(t, err)
+ assert.Equal(t, "public class MyClass {}", string(content))
+
+ content, err = os.ReadFile(filepath.Join(destDir, "classes", "MyClass.cls-meta.xml"))
+ require.NoError(t, err)
+ assert.Equal(t, "", string(content))
+}
+
+func TestExtractZipToDirectoryZipSlip(t *testing.T) {
+ // Create a malicious zip with path traversal
+ buf := new(bytes.Buffer)
+ zipWriter := zip.NewWriter(buf)
+
+ // Try to escape the destination directory
+ writer, _ := zipWriter.Create("../../../etc/passwd")
+ _, _ = writer.Write([]byte("malicious"))
+
+ require.NoError(t, zipWriter.Close())
+
+ destDir := t.TempDir()
+ err := ExtractZipToDirectory(buf.Bytes(), destDir)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "illegal file path")
+}
+
+func TestAPIError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(`[{"errorCode": "INVALID_SESSION_ID", "message": "Session expired"}]`))
+ }))
+ defer server.Close()
+
+ client, err := New(ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ _, err = client.DescribeMetadata(context.Background())
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "401")
+}
diff --git a/api/metadata/types.go b/api/metadata/types.go
new file mode 100644
index 0000000..c169275
--- /dev/null
+++ b/api/metadata/types.go
@@ -0,0 +1,146 @@
+// Package metadata provides a client for the Salesforce Metadata API.
+package metadata
+
+import "time"
+
+// MetadataType represents a metadata type available in the org.
+type MetadataType struct {
+ XMLName string `json:"xmlName"`
+ DirectoryName string `json:"directoryName"`
+ Suffix string `json:"suffix"`
+ InFolder bool `json:"inFolder"`
+ MetaFile bool `json:"metaFile"`
+ ChildNames []string `json:"childXmlNames,omitempty"`
+}
+
+// MetadataComponent represents a metadata component in the org.
+type MetadataComponent struct {
+ ID string `json:"id,omitempty"`
+ Type string `json:"type"`
+ FullName string `json:"fullName"`
+ FileName string `json:"fileName,omitempty"`
+ NamespacePrefix string `json:"namespacePrefix,omitempty"`
+ Creatable bool `json:"creatable"`
+ Updateable bool `json:"updateable"`
+ Deletable bool `json:"deletable"`
+ LastModifiedBy string `json:"lastModifiedById,omitempty"`
+ LastModifiedDate time.Time `json:"lastModifiedDate,omitempty"`
+}
+
+// DeployRequest represents a request to deploy metadata.
+type DeployRequest struct {
+ // Zip file as base64-encoded string
+ ZipFile string `json:"zipFile"`
+ // DeployOptions contains deployment options
+ DeployOptions DeployOptions `json:"deployOptions,omitempty"`
+}
+
+// DeployOptions contains options for deployment.
+type DeployOptions struct {
+ AllowMissingFiles bool `json:"allowMissingFiles,omitempty"`
+ AutoUpdatePackage bool `json:"autoUpdatePackage,omitempty"`
+ CheckOnly bool `json:"checkOnly,omitempty"`
+ IgnoreWarnings bool `json:"ignoreWarnings,omitempty"`
+ PerformRetrieve bool `json:"performRetrieve,omitempty"`
+ PurgeOnDelete bool `json:"purgeOnDelete,omitempty"`
+ RollbackOnError bool `json:"rollbackOnError,omitempty"`
+ SinglePackage bool `json:"singlePackage,omitempty"`
+ TestLevel string `json:"testLevel,omitempty"` // NoTestRun, RunSpecifiedTests, RunLocalTests, RunAllTestsInOrg
+ RunTests []string `json:"runTests,omitempty"`
+}
+
+// DeployResult represents the result of a deployment.
+type DeployResult struct {
+ ID string `json:"id"`
+ Status string `json:"status"` // Pending, InProgress, Succeeded, Failed, Canceling, Canceled
+ Done bool `json:"done"`
+ Success bool `json:"success"`
+ CheckOnly bool `json:"checkOnly"`
+ IgnoreWarnings bool `json:"ignoreWarnings"`
+ NumberComponentsTotal int `json:"numberComponentsTotal"`
+ NumberComponentsDeployed int `json:"numberComponentsDeployed"`
+ NumberComponentErrors int `json:"numberComponentErrors"`
+ NumberTestsTotal int `json:"numberTestsTotal"`
+ NumberTestsCompleted int `json:"numberTestsCompleted"`
+ NumberTestErrors int `json:"numberTestErrors"`
+ StartDate time.Time `json:"startDate,omitempty"`
+ CompletedDate time.Time `json:"completedDate,omitempty"`
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ ErrorStatusCode string `json:"errorStatusCode,omitempty"`
+ StateDetail string `json:"stateDetail,omitempty"`
+ DeployDetails *DeployDetails `json:"details,omitempty"`
+}
+
+// DeployDetails contains detailed information about deployment results.
+type DeployDetails struct {
+ ComponentSuccesses []ComponentResult `json:"componentSuccesses,omitempty"`
+ ComponentFailures []ComponentResult `json:"componentFailures,omitempty"`
+ RunTestResult *RunTestResult `json:"runTestResult,omitempty"`
+}
+
+// ComponentResult represents the result of deploying a single component.
+type ComponentResult struct {
+ ComponentType string `json:"componentType"`
+ FullName string `json:"fullName"`
+ FileName string `json:"fileName,omitempty"`
+ Success bool `json:"success"`
+ Changed bool `json:"changed"`
+ Created bool `json:"created"`
+ Deleted bool `json:"deleted"`
+ Problem string `json:"problem,omitempty"`
+ ProblemType string `json:"problemType,omitempty"`
+ LineNumber int `json:"lineNumber,omitempty"`
+ ColumnNumber int `json:"columnNumber,omitempty"`
+}
+
+// RunTestResult contains test execution results from deployment.
+type RunTestResult struct {
+ NumTestsRun int `json:"numTestsRun"`
+ NumFailures int `json:"numFailures"`
+ TotalTime int `json:"totalTime"` // milliseconds
+}
+
+// RetrieveRequest represents a request to retrieve metadata.
+type RetrieveRequest struct {
+ APIVersion string `json:"apiVersion"`
+ SinglePackage bool `json:"singlePackage"`
+ PackageNames []string `json:"packageNames,omitempty"`
+ Unpackaged *Package `json:"unpackaged,omitempty"`
+}
+
+// Package represents a package.xml structure.
+type Package struct {
+ Types []PackageType `json:"types"`
+ Version string `json:"version"`
+}
+
+// PackageType represents a type section in package.xml.
+type PackageType struct {
+ Members []string `json:"members"`
+ Name string `json:"name"`
+}
+
+// RetrieveResult represents the result of a retrieve operation.
+type RetrieveResult struct {
+ ID string `json:"id"`
+ Status string `json:"status"` // Pending, InProgress, Succeeded, Failed
+ Done bool `json:"done"`
+ Success bool `json:"success"`
+ ZipFile string `json:"zipFile,omitempty"` // Base64-encoded zip
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ ErrorStatusCode string `json:"errorStatusCode,omitempty"`
+}
+
+// DescribeMetadataResult represents the result of describing metadata.
+type DescribeMetadataResult struct {
+ MetadataObjects []MetadataType `json:"metadataObjects"`
+ OrganizationNamespace string `json:"organizationNamespace"`
+ PartialSaveAllowed bool `json:"partialSaveAllowed"`
+ TestRequired bool `json:"testRequired"`
+}
+
+// ListMetadataQuery represents a query for listing metadata.
+type ListMetadataQuery struct {
+ Type string `json:"type"`
+ Folder string `json:"folder,omitempty"`
+}
diff --git a/cmd/sfdc/main.go b/cmd/sfdc/main.go
index a98baef..3cc373a 100644
--- a/cmd/sfdc/main.go
+++ b/cmd/sfdc/main.go
@@ -13,6 +13,7 @@ import (
"github.com/open-cli-collective/salesforce-cli/internal/cmd/initcmd"
"github.com/open-cli-collective/salesforce-cli/internal/cmd/limitscmd"
"github.com/open-cli-collective/salesforce-cli/internal/cmd/logcmd"
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/metadatacmd"
"github.com/open-cli-collective/salesforce-cli/internal/cmd/objectcmd"
"github.com/open-cli-collective/salesforce-cli/internal/cmd/querycmd"
"github.com/open-cli-collective/salesforce-cli/internal/cmd/recordcmd"
@@ -57,5 +58,8 @@ func run() error {
logcmd.Register(rootCmd, opts)
coveragecmd.Register(rootCmd, opts)
+ // Metadata API commands
+ metadatacmd.Register(rootCmd, opts)
+
return rootCmd.Execute()
}
diff --git a/internal/cmd/metadatacmd/deploy.go b/internal/cmd/metadatacmd/deploy.go
new file mode 100644
index 0000000..9d36262
--- /dev/null
+++ b/internal/cmd/metadatacmd/deploy.go
@@ -0,0 +1,178 @@
+package metadatacmd
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/open-cli-collective/salesforce-cli/api/metadata"
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/root"
+)
+
+func newDeployCommand(opts *root.Options) *cobra.Command {
+ var (
+ sourceDir string
+ checkOnly bool
+ testLevel string
+ wait bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "deploy",
+ Short: "Deploy metadata to the org",
+ Long: `Deploy metadata from a local directory to the org.
+
+The source directory should be in the standard Salesforce metadata format
+(e.g., containing package.xml and subdirectories for each metadata type).
+
+For complex deployments, use the official Salesforce CLI (sf).
+
+Examples:
+ sfdc metadata deploy --source ./src
+ sfdc metadata deploy --source ./src --check-only
+ sfdc metadata deploy --source ./src --test-level RunLocalTests
+ sfdc metadata deploy --source ./src --wait`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if sourceDir == "" {
+ return fmt.Errorf("--source is required")
+ }
+ return runDeploy(cmd.Context(), opts, sourceDir, checkOnly, testLevel, wait)
+ },
+ }
+
+ cmd.Flags().StringVar(&sourceDir, "source", "", "Source directory (required)")
+ cmd.Flags().BoolVar(&checkOnly, "check-only", false, "Validate without deploying")
+ cmd.Flags().StringVar(&testLevel, "test-level", "", "Test level: NoTestRun, RunLocalTests, RunAllTestsInOrg")
+ cmd.Flags().BoolVar(&wait, "wait", false, "Wait for deployment to complete")
+
+ return cmd
+}
+
+func runDeploy(ctx context.Context, opts *root.Options, sourceDir string, checkOnly bool, testLevel string, wait bool) error {
+ client, err := opts.MetadataClient()
+ if err != nil {
+ return fmt.Errorf("failed to create metadata client: %w", err)
+ }
+
+ v := opts.View()
+
+ // Create zip from source directory
+ v.Info("Creating deployment package from %s...", sourceDir)
+ zipData, err := metadata.CreateZipFromDirectory(sourceDir)
+ if err != nil {
+ return fmt.Errorf("failed to create deployment package: %w", err)
+ }
+
+ // Configure deployment options
+ deployOpts := metadata.DeployOptions{
+ CheckOnly: checkOnly,
+ RollbackOnError: true,
+ SinglePackage: true,
+ }
+ if testLevel != "" {
+ deployOpts.TestLevel = testLevel
+ }
+
+ // Start deployment
+ action := "Deploying"
+ if checkOnly {
+ action = "Validating"
+ }
+ v.Info("%s to org...", action)
+
+ result, err := client.Deploy(ctx, zipData, deployOpts)
+ if err != nil {
+ return fmt.Errorf("failed to start deployment: %w", err)
+ }
+
+ v.Info("Deployment ID: %s", result.ID)
+
+ if !wait {
+ v.Info("Deployment started. Use 'sfdc metadata deploy-status %s' to check status.", result.ID)
+ return nil
+ }
+
+ // Poll for completion
+ v.Info("Waiting for deployment to complete...")
+
+ for {
+ status, err := client.GetDeployStatus(ctx, result.ID, true)
+ if err != nil {
+ return fmt.Errorf("failed to get deployment status: %w", err)
+ }
+
+ if status.Done {
+ return displayDeployResult(opts, status)
+ }
+
+ v.Info("Status: %s (%d/%d components)...",
+ status.Status,
+ status.NumberComponentsDeployed,
+ status.NumberComponentsTotal)
+
+ time.Sleep(3 * time.Second)
+ }
+}
+
+func displayDeployResult(opts *root.Options, result *metadata.DeployResult) error {
+ v := opts.View()
+
+ if opts.Output == "json" {
+ return v.JSON(result)
+ }
+
+ // Summary
+ if result.Success {
+ v.Success("Deployment succeeded!")
+ } else {
+ v.Error("Deployment failed!")
+ }
+
+ v.Info("\nComponents: %d deployed, %d errors",
+ result.NumberComponentsDeployed,
+ result.NumberComponentErrors)
+
+ if result.NumberTestsTotal > 0 {
+ v.Info("Tests: %d completed, %d errors",
+ result.NumberTestsCompleted,
+ result.NumberTestErrors)
+ }
+
+ // Show component failures
+ if result.DeployDetails != nil && len(result.DeployDetails.ComponentFailures) > 0 {
+ v.Error("\nComponent failures:")
+ for _, failure := range result.DeployDetails.ComponentFailures {
+ fmt.Fprintf(opts.Stderr, " %s.%s: %s\n",
+ failure.ComponentType,
+ failure.FullName,
+ failure.Problem)
+ if failure.LineNumber > 0 {
+ fmt.Fprintf(opts.Stderr, " at line %d, column %d\n",
+ failure.LineNumber,
+ failure.ColumnNumber)
+ }
+ }
+ }
+
+ // Show error message
+ if result.ErrorMessage != "" {
+ v.Error("\nError: %s", result.ErrorMessage)
+ }
+
+ if !result.Success {
+ var parts []string
+ if result.NumberComponentErrors > 0 {
+ parts = append(parts, fmt.Sprintf("%d component error(s)", result.NumberComponentErrors))
+ }
+ if result.NumberTestErrors > 0 {
+ parts = append(parts, fmt.Sprintf("%d test error(s)", result.NumberTestErrors))
+ }
+ return fmt.Errorf("deployment failed: %s", strings.Join(parts, ", "))
+ }
+
+ return nil
+}
diff --git a/internal/cmd/metadatacmd/list.go b/internal/cmd/metadatacmd/list.go
new file mode 100644
index 0000000..d353b99
--- /dev/null
+++ b/internal/cmd/metadatacmd/list.go
@@ -0,0 +1,83 @@
+package metadatacmd
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/root"
+)
+
+func newListCommand(opts *root.Options) *cobra.Command {
+ var metadataType string
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List components of a metadata type",
+ Long: `List components of a specific metadata type.
+
+Supported types:
+ ApexClass, ApexTrigger, ApexPage, ApexComponent, StaticResource,
+ AuraDefinitionBundle, LightningComponentBundle
+
+Examples:
+ sfdc metadata list --type ApexClass
+ sfdc metadata list --type ApexTrigger
+ sfdc metadata list --type ApexClass -o json`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if metadataType == "" {
+ return fmt.Errorf("--type is required")
+ }
+ return runList(cmd.Context(), opts, metadataType)
+ },
+ }
+
+ cmd.Flags().StringVar(&metadataType, "type", "", "Metadata type (required)")
+
+ return cmd
+}
+
+func runList(ctx context.Context, opts *root.Options, metadataType string) error {
+ client, err := opts.MetadataClient()
+ if err != nil {
+ return fmt.Errorf("failed to create metadata client: %w", err)
+ }
+
+ components, err := client.ListMetadata(ctx, metadataType)
+ if err != nil {
+ return fmt.Errorf("failed to list metadata: %w", err)
+ }
+
+ v := opts.View()
+
+ if len(components) == 0 {
+ v.Info("No %s components found", metadataType)
+ return nil
+ }
+
+ if opts.Output == "json" {
+ return v.JSON(components)
+ }
+
+ headers := []string{"ID", "Name", "Namespace"}
+ rows := make([][]string, 0, len(components))
+ for _, comp := range components {
+ ns := comp.NamespacePrefix
+ if ns == "" {
+ ns = "-"
+ }
+ rows = append(rows, []string{
+ comp.ID,
+ comp.FullName,
+ ns,
+ })
+ }
+
+ if err := v.Table(headers, rows); err != nil {
+ return err
+ }
+ v.Info("\n%d component(s)", len(components))
+ return nil
+}
diff --git a/internal/cmd/metadatacmd/metadata.go b/internal/cmd/metadatacmd/metadata.go
new file mode 100644
index 0000000..94e5f38
--- /dev/null
+++ b/internal/cmd/metadatacmd/metadata.go
@@ -0,0 +1,38 @@
+// Package metadatacmd provides commands for metadata operations.
+package metadatacmd
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/root"
+)
+
+// Register registers the metadata command with the root command.
+func Register(parent *cobra.Command, opts *root.Options) {
+ parent.AddCommand(NewCommand(opts))
+}
+
+// NewCommand creates the metadata command.
+func NewCommand(opts *root.Options) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "metadata",
+ Short: "Metadata operations",
+ Long: `Manage Salesforce metadata.
+
+This is a thin wrapper for basic metadata operations. For complex
+workflows, use the official Salesforce CLI (sf).
+
+Examples:
+ sfdc metadata types # List metadata types
+ sfdc metadata list --type ApexClass # List Apex classes
+ sfdc metadata retrieve --type ApexClass # Retrieve all classes
+ sfdc metadata deploy --source ./src # Deploy from directory`,
+ }
+
+ cmd.AddCommand(newTypesCommand(opts))
+ cmd.AddCommand(newListCommand(opts))
+ cmd.AddCommand(newRetrieveCommand(opts))
+ cmd.AddCommand(newDeployCommand(opts))
+
+ return cmd
+}
diff --git a/internal/cmd/metadatacmd/metadata_test.go b/internal/cmd/metadatacmd/metadata_test.go
new file mode 100644
index 0000000..f04a692
--- /dev/null
+++ b/internal/cmd/metadatacmd/metadata_test.go
@@ -0,0 +1,425 @@
+package metadatacmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/open-cli-collective/salesforce-cli/api/metadata"
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/root"
+)
+
+func TestMetadataTypes(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ response := struct {
+ Sobjects []struct {
+ Name string `json:"name"`
+ } `json:"sobjects"`
+ }{
+ Sobjects: []struct {
+ Name string `json:"name"`
+ }{
+ {Name: "ApexClass"},
+ {Name: "ApexTrigger"},
+ {Name: "CustomObject"},
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"types"})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ require.NoError(t, err)
+
+ output := stdout.String()
+ assert.Contains(t, output, "ApexClass")
+ assert.Contains(t, output, "ApexTrigger")
+}
+
+func TestMetadataTypesJSON(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ response := struct {
+ Sobjects []struct {
+ Name string `json:"name"`
+ } `json:"sobjects"`
+ }{
+ Sobjects: []struct {
+ Name string `json:"name"`
+ }{
+ {Name: "ApexClass"},
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "json",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"types"})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ require.NoError(t, err)
+
+ output := stdout.String()
+ var result []metadata.MetadataType
+ err = json.Unmarshal([]byte(output), &result)
+ require.NoError(t, err)
+}
+
+func TestMetadataList(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/tooling/query")
+ assert.Contains(t, r.URL.RawQuery, "ApexClass")
+
+ response := struct {
+ TotalSize int `json:"totalSize"`
+ Done bool `json:"done"`
+ Records []map[string]interface{} `json:"records"`
+ }{
+ TotalSize: 2,
+ Done: true,
+ Records: []map[string]interface{}{
+ {"Id": "01p000000000001", "Name": "MyController"},
+ {"Id": "01p000000000002", "Name": "MyHelper"},
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"list", "--type", "ApexClass"})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ require.NoError(t, err)
+
+ output := stdout.String()
+ assert.Contains(t, output, "MyController")
+ assert.Contains(t, output, "MyHelper")
+ assert.Contains(t, output, "2 component(s)")
+}
+
+func TestMetadataListMissingType(t *testing.T) {
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: "https://test.salesforce.com",
+ HTTPClient: http.DefaultClient,
+ })
+ require.NoError(t, err)
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"list"})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "--type is required")
+}
+
+func TestMetadataRetrieve(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ response := struct {
+ Records []map[string]interface{} `json:"records"`
+ }{
+ Records: []map[string]interface{}{
+ {
+ "Id": "01p000000000001",
+ "Name": "MyController",
+ "Body": "public class MyController { }",
+ },
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ tmpDir := t.TempDir()
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"retrieve", "--type", "ApexClass", "--name", "MyController", "--output", tmpDir})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ require.NoError(t, err)
+
+ // Verify file was created
+ content, err := os.ReadFile(filepath.Join(tmpDir, "MyController.cls"))
+ require.NoError(t, err)
+ assert.Equal(t, "public class MyController { }", string(content))
+}
+
+func TestMetadataRetrieveMissingFlags(t *testing.T) {
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: "https://test.salesforce.com",
+ HTTPClient: http.DefaultClient,
+ })
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ args []string
+ wantErr string
+ }{
+ {
+ name: "missing type",
+ args: []string{"retrieve", "--output", "./src"},
+ wantErr: "--type is required",
+ },
+ {
+ name: "missing output",
+ args: []string{"retrieve", "--type", "ApexClass"},
+ wantErr: "--output is required",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs(tt.args)
+ cmd.SetOut(stdout)
+
+ err := cmd.Execute()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestMetadataDeploy(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, "/metadata/deployRequest") && r.Method == http.MethodPost {
+ result := metadata.DeployResult{
+ ID: "0Af000000000001",
+ Status: "Pending",
+ Done: false,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(result)
+ }
+ }))
+ defer server.Close()
+
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ // Create temp source directory with a file
+ tmpDir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "classes"), 0755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, "classes", "MyClass.cls"),
+ []byte("public class MyClass {}"),
+ 0644,
+ ))
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"deploy", "--source", tmpDir})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ require.NoError(t, err)
+
+ output := stdout.String()
+ assert.Contains(t, output, "0Af000000000001")
+ assert.Contains(t, output, "Deployment started")
+}
+
+func TestMetadataDeployCheckOnly(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ var request metadata.DeployRequest
+ _ = json.NewDecoder(r.Body).Decode(&request)
+ assert.True(t, request.DeployOptions.CheckOnly)
+
+ result := metadata.DeployResult{
+ ID: "0Af000000000001",
+ Status: "Pending",
+ Done: false,
+ CheckOnly: true,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(result)
+ }
+ }))
+ defer server.Close()
+
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: server.URL,
+ HTTPClient: server.Client(),
+ })
+ require.NoError(t, err)
+
+ tmpDir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "classes"), 0755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(tmpDir, "classes", "MyClass.cls"),
+ []byte("public class MyClass {}"),
+ 0644,
+ ))
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"deploy", "--source", tmpDir, "--check-only"})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ require.NoError(t, err)
+
+ output := stdout.String()
+ assert.Contains(t, output, "Validating")
+}
+
+func TestMetadataDeployMissingSource(t *testing.T) {
+ client, err := metadata.New(metadata.ClientConfig{
+ InstanceURL: "https://test.salesforce.com",
+ HTTPClient: http.DefaultClient,
+ })
+ require.NoError(t, err)
+
+ stdout := &bytes.Buffer{}
+ opts := &root.Options{
+ Output: "table",
+ Stdout: stdout,
+ Stderr: &bytes.Buffer{},
+ }
+ opts.SetMetadataClient(client)
+
+ cmd := NewCommand(opts)
+ cmd.SetArgs([]string{"deploy"})
+ cmd.SetOut(stdout)
+
+ err = cmd.Execute()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "--source is required")
+}
+
+func TestGetFileExtension(t *testing.T) {
+ tests := []struct {
+ metadataType string
+ want string
+ }{
+ {"ApexClass", ".cls"},
+ {"ApexTrigger", ".trigger"},
+ {"ApexPage", ".page"},
+ {"ApexComponent", ".component"},
+ {"CustomObject", ".txt"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.metadataType, func(t *testing.T) {
+ got := getFileExtension(tt.metadataType)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/cmd/metadatacmd/retrieve.go b/internal/cmd/metadatacmd/retrieve.go
new file mode 100644
index 0000000..ee77ae5
--- /dev/null
+++ b/internal/cmd/metadatacmd/retrieve.go
@@ -0,0 +1,126 @@
+package metadatacmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/root"
+)
+
+func newRetrieveCommand(opts *root.Options) *cobra.Command {
+ var (
+ metadataType string
+ name string
+ outputDir string
+ )
+
+ cmd := &cobra.Command{
+ Use: "retrieve",
+ Short: "Retrieve metadata from the org",
+ Long: `Retrieve metadata components from the org.
+
+Supported types for direct retrieve:
+ ApexClass, ApexTrigger, ApexPage, ApexComponent
+
+For complex retrieves with package.xml, use the official Salesforce CLI (sf).
+
+Examples:
+ sfdc metadata retrieve --type ApexClass --name MyController --output ./src
+ sfdc metadata retrieve --type ApexClass --output ./src # all classes
+ sfdc metadata retrieve --type ApexTrigger --output ./src`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if metadataType == "" {
+ return fmt.Errorf("--type is required")
+ }
+ if outputDir == "" {
+ return fmt.Errorf("--output is required")
+ }
+ return runRetrieve(cmd.Context(), opts, metadataType, name, outputDir)
+ },
+ }
+
+ cmd.Flags().StringVar(&metadataType, "type", "", "Metadata type (required)")
+ cmd.Flags().StringVar(&name, "name", "", "Component name (optional, retrieves all if not specified)")
+ cmd.Flags().StringVarP(&outputDir, "output", "f", "", "Output directory (required)")
+
+ return cmd
+}
+
+func runRetrieve(ctx context.Context, opts *root.Options, metadataType, name, outputDir string) error {
+ client, err := opts.MetadataClient()
+ if err != nil {
+ return fmt.Errorf("failed to create metadata client: %w", err)
+ }
+
+ v := opts.View()
+
+ // Create output directory
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+
+ // Get file extension for the metadata type
+ ext := getFileExtension(metadataType)
+
+ if name != "" {
+ // Retrieve single component
+ v.Info("Retrieving %s: %s", metadataType, name)
+
+ content, err := client.Retrieve(ctx, metadataType, name)
+ if err != nil {
+ return fmt.Errorf("failed to retrieve: %w", err)
+ }
+
+ filename := filepath.Join(outputDir, name+ext)
+ if err := os.WriteFile(filename, content, 0644); err != nil {
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+
+ v.Success("Retrieved to %s", filename)
+ return nil
+ }
+
+ // Retrieve all components
+ v.Info("Retrieving all %s components...", metadataType)
+
+ components, err := client.RetrieveAll(ctx, metadataType)
+ if err != nil {
+ return fmt.Errorf("failed to retrieve: %w", err)
+ }
+
+ if len(components) == 0 {
+ v.Info("No %s components found to retrieve", metadataType)
+ return nil
+ }
+
+ for compName, content := range components {
+ filename := filepath.Join(outputDir, compName+ext)
+ if err := os.WriteFile(filename, content, 0644); err != nil {
+ v.Error("Failed to write %s: %v", compName, err)
+ continue
+ }
+ }
+
+ v.Success("Retrieved %d component(s) to %s", len(components), outputDir)
+ return nil
+}
+
+func getFileExtension(metadataType string) string {
+ switch metadataType {
+ case "ApexClass":
+ return ".cls"
+ case "ApexTrigger":
+ return ".trigger"
+ case "ApexPage":
+ return ".page"
+ case "ApexComponent":
+ return ".component"
+ default:
+ return ".txt"
+ }
+}
diff --git a/internal/cmd/metadatacmd/types.go b/internal/cmd/metadatacmd/types.go
new file mode 100644
index 0000000..f612ff6
--- /dev/null
+++ b/internal/cmd/metadatacmd/types.go
@@ -0,0 +1,69 @@
+package metadatacmd
+
+import (
+ "context"
+ "fmt"
+ "sort"
+
+ "github.com/spf13/cobra"
+
+ "github.com/open-cli-collective/salesforce-cli/internal/cmd/root"
+)
+
+func newTypesCommand(opts *root.Options) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "types",
+ Short: "List available metadata types",
+ Long: `List metadata types available in the org.
+
+Examples:
+ sfdc metadata types
+ sfdc metadata types -o json`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runTypes(cmd.Context(), opts)
+ },
+ }
+
+ return cmd
+}
+
+func runTypes(ctx context.Context, opts *root.Options) error {
+ client, err := opts.MetadataClient()
+ if err != nil {
+ return fmt.Errorf("failed to create metadata client: %w", err)
+ }
+
+ result, err := client.DescribeMetadata(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to describe metadata: %w", err)
+ }
+
+ v := opts.View()
+
+ if len(result.MetadataObjects) == 0 {
+ v.Info("No metadata types found")
+ return nil
+ }
+
+ if opts.Output == "json" {
+ return v.JSON(result.MetadataObjects)
+ }
+
+ // Sort by name for consistent output
+ sort.Slice(result.MetadataObjects, func(i, j int) bool {
+ return result.MetadataObjects[i].XMLName < result.MetadataObjects[j].XMLName
+ })
+
+ headers := []string{"Type Name"}
+ rows := make([][]string, 0, len(result.MetadataObjects))
+ for _, mt := range result.MetadataObjects {
+ rows = append(rows, []string{mt.XMLName})
+ }
+
+ if err := v.Table(headers, rows); err != nil {
+ return err
+ }
+ v.Info("\n%d type(s)", len(result.MetadataObjects))
+ return nil
+}
diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go
index 71ba5f3..e2cdd55 100644
--- a/internal/cmd/root/root.go
+++ b/internal/cmd/root/root.go
@@ -11,6 +11,7 @@ import (
"github.com/open-cli-collective/salesforce-cli/api"
"github.com/open-cli-collective/salesforce-cli/api/bulk"
+ "github.com/open-cli-collective/salesforce-cli/api/metadata"
"github.com/open-cli-collective/salesforce-cli/api/tooling"
"github.com/open-cli-collective/salesforce-cli/internal/auth"
"github.com/open-cli-collective/salesforce-cli/internal/config"
@@ -34,6 +35,8 @@ type Options struct {
testBulkClient *bulk.Client
// testToolingClient is used for testing; if set, ToolingClient() returns this instead
testToolingClient *tooling.Client
+ // testMetadataClient is used for testing; if set, MetadataClient() returns this instead
+ testMetadataClient *metadata.Client
}
// View returns a configured View instance
@@ -128,6 +131,29 @@ func (o *Options) SetToolingClient(client *tooling.Client) {
o.testToolingClient = client
}
+// MetadataClient creates a new Metadata API client from config
+func (o *Options) MetadataClient() (*metadata.Client, error) {
+ if o.testMetadataClient != nil {
+ return o.testMetadataClient, nil
+ }
+
+ instanceURL, httpClient, err := o.loadClientConfig()
+ if err != nil {
+ return nil, err
+ }
+
+ return metadata.New(metadata.ClientConfig{
+ InstanceURL: instanceURL,
+ HTTPClient: httpClient,
+ APIVersion: o.APIVersion,
+ })
+}
+
+// SetMetadataClient sets a test metadata client (for testing only)
+func (o *Options) SetMetadataClient(client *metadata.Client) {
+ o.testMetadataClient = client
+}
+
// NewCmd creates the root command and returns the options struct
func NewCmd() (*cobra.Command, *Options) {
opts := &Options{