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{