diff --git a/cmd/root.go b/cmd/root.go index e7034017..bb4b7b1a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" "github.com/launchdarkly/ldcli/internal/dev_server" @@ -203,6 +204,7 @@ func NewRootCommand( cmd.AddCommand(logincmd.NewLoginCmd(resources.NewClient(version))) cmd.AddCommand(resourcecmd.NewResourcesCmd()) cmd.AddCommand(devcmd.NewDevServerCmd(resources.NewClient(version), analyticsTrackerFn, dev_server.NewClient(version))) + cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd()) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) // add non-generated commands diff --git a/cmd/sourcemaps/sourcemaps.go b/cmd/sourcemaps/sourcemaps.go new file mode 100644 index 00000000..e56b4c4b --- /dev/null +++ b/cmd/sourcemaps/sourcemaps.go @@ -0,0 +1,22 @@ +package sourcemaps + +import ( + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewSourcemapsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sourcemaps", + Short: "Manage sourcemaps", + Long: "Manage sourcemaps for LaunchDarkly error monitoring", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + } + + cmd.AddCommand(NewUploadCmd(resources.NewClient(""))) + + return cmd +} diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go new file mode 100644 index 00000000..06256c0d --- /dev/null +++ b/cmd/sourcemaps/upload.go @@ -0,0 +1,317 @@ +package sourcemaps + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" +) + +const ( + appVersionFlag = "app-version" + pathFlag = "path" + basePathFlag = "base-path" + backendUrlFlag = "backend-url" + + defaultPath = "." + defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" + + getSourceMapUrlsQuery = ` + query GetSourceMapUploadUrls($api_key: String!, $project_id: String!, $paths: [String!]!) { + get_source_map_upload_urls_ld( + api_key: $api_key + project_id: $project_id + paths: $paths + ) + } + ` +) + +type ApiKeyResponse struct { + Data struct { + Credential struct { + ProjectID string `json:"project_id"` + APIKey string `json:"api_key"` + } `json:"ld_credential"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type SourceMapUrlsResponse struct { + Data struct { + GetSourceMapUploadUrls []string `json:"get_source_map_upload_urls_ld"` + } `json:"data"` +} + +type SourceMapFile struct { + Path string + Name string +} + +func NewUploadCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Args: validators.Validate(), + Use: "upload", + Short: "Upload sourcemaps", + Long: "Upload JavaScript sourcemaps to LaunchDarkly for error monitoring", + RunE: runE(client), + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initFlags(cmd) + + return cmd +} + +func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + projectKey := viper.GetString(cliflags.ProjectFlag) + u, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", + projectKey, + ) + res, err := client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", + u, + "application/json", + nil, + nil, + false, + ) + if err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + + var projectResult struct { + ID string `json:"_id"` + } + if err = json.Unmarshal(res, &projectResult); err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + if projectResult.ID == "" { + return fmt.Errorf("project %s not found", projectKey) + } + + appVersion := viper.GetString(appVersionFlag) + path := viper.GetString(pathFlag) + basePath := viper.GetString(basePathFlag) + backendUrl := viper.GetString(backendUrlFlag) + + if backendUrl == "" { + backendUrl = defaultBackendUrl + } + + fmt.Printf("Starting to upload source maps from %s\n", path) + + files, err := getAllSourceMapFiles(path) + if err != nil { + return fmt.Errorf("failed to find sourcemap files: %w", err) + } + + if len(files) == 0 { + return fmt.Errorf("no source maps found in %s, is this the correct path?", path) + } + + s3Keys := make([]string, 0, len(files)) + for _, file := range files { + s3Keys = append(s3Keys, getS3Key(appVersion, basePath, file.Name)) + } + + uploadUrls, err := getSourceMapUploadUrls(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, s3Keys, backendUrl) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + + for i, file := range files { + if err := uploadFile(file.Path, uploadUrls[i], file.Name); err != nil { + return fmt.Errorf("failed to upload file %s: %w", file.Path, err) + } + } + + fmt.Println("Successfully uploaded all sourcemaps") + return nil + } +} + +func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { + var files []SourceMapFile + routeGroupPattern := regexp.MustCompile(`\(.+?\)/`) + + fileInfo, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + files = append(files, SourceMapFile{ + Path: path, + Name: filepath.Base(path), + }) + return files, nil + } + + err = filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() && d.Name() == "node_modules" { + return filepath.SkipDir + } + + if !d.IsDir() && (strings.HasSuffix(filePath, ".js.map") || strings.HasSuffix(filePath, ".js")) { + relPath, err := filepath.Rel(path, filePath) + if err != nil { + return err + } + + files = append(files, SourceMapFile{ + Path: filePath, + Name: relPath, + }) + + routeGroupRemovedPath := routeGroupPattern.ReplaceAllString(relPath, "") + if routeGroupRemovedPath != relPath { + files = append(files, SourceMapFile{ + Path: filePath, + Name: routeGroupRemovedPath, + }) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + if len(files) == 0 { + return nil, fmt.Errorf("no .js.map files found. Please double check that you have generated sourcemaps for your app") + } + + return files, nil +} + +func getS3Key(version, basePath, fileName string) string { + if version == "" { + version = "unversioned" + } + + if basePath != "" && !strings.HasSuffix(basePath, "/") { + basePath = basePath + "/" + } + + return fmt.Sprintf("%s/%s%s", version, basePath, fileName) +} + +func getSourceMapUploadUrls(apiKey, projectID string, paths []string, backendUrl string) ([]string, error) { + variables := map[string]interface{}{ + "api_key": apiKey, + "project_id": projectID, + "paths": paths, + } + + reqBody, err := json.Marshal(map[string]interface{}{ + "query": getSourceMapUrlsQuery, + "variables": variables, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var urlsResp SourceMapUrlsResponse + if err := json.Unmarshal(body, &urlsResp); err != nil { + return nil, err + } + + if len(urlsResp.Data.GetSourceMapUploadUrls) == 0 { + return nil, fmt.Errorf("unable to generate source map upload urls %w", err) + } + + return urlsResp.Data.GetSourceMapUploadUrls, nil +} + +func uploadFile(filePath, uploadUrl, name string) error { + fileContent, err := os.ReadFile(filePath) + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(fileContent)) + if err != nil { + return err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload failed with status code: %d", resp.StatusCode) + } + + fmt.Printf("[LaunchDarkly] Uploaded %s to %s\n", filePath, name) + return nil +} + +func initFlags(cmd *cobra.Command) { + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key") + _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) + _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) + + cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") + _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) + + cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the sourcemaps are") + _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) + + cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded sourcemaps") + _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) + + cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") + _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) +} diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go new file mode 100644 index 00000000..bed12489 --- /dev/null +++ b/cmd/sourcemaps/upload_test.go @@ -0,0 +1,267 @@ +package sourcemaps + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/launchdarkly/ldcli/internal/resources" +) +// Mock resources.Client implementation for testing +type mockResourcesClient struct { + responses map[string][]byte +} + +func (m *mockResourcesClient) MakeUnauthenticatedRequest(method, uri string, body []byte) ([]byte, error) { + return m.MakeRequest("", method, uri, "application/json", nil, body, false) +} + +func (m *mockResourcesClient) MakeRequest(accessToken, method, uri, contentType string, query url.Values, body []byte, isBeta bool) ([]byte, error) { + if response, ok := m.responses[uri]; ok { + return response, nil + } + return nil, fmt.Errorf("mock response not found for URI: %s", uri) +} + +func (m *mockResourcesClient) GetVersion() string { + return "test-version" +} + + + + +func TestGetSourceMapUploadUrls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var requestBody map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&requestBody) + assert.NoError(t, err) + assert.Contains(t, requestBody, "query") + assert.Contains(t, requestBody, "variables") + + variables, ok := requestBody["variables"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "test-api-key", variables["api_key"]) + assert.Equal(t, "project123", variables["project_id"]) + assert.NotNil(t, variables["paths"]) + + response := `{"data":{"get_source_map_upload_urls_ld":["https://example.com/upload1","https://example.com/upload2"]}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer server.Close() + + paths := []string{"path1", "path2"} + urls, err := getSourceMapUploadUrls("test-api-key", "project123", paths, server.URL) + assert.NoError(t, err) + assert.Equal(t, 2, len(urls)) + assert.Equal(t, "https://example.com/upload1", urls[0]) + assert.Equal(t, "https://example.com/upload2", urls[1]) + + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data":{"get_source_map_upload_urls_ld":[]}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer errorServer.Close() + + _, err = getSourceMapUploadUrls("test-api-key", "project123", paths, errorServer.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unable to generate source map upload urls") +} + +func TestGetS3Key(t *testing.T) { + key := getS3Key("v1.0", "base/path", "file.js.map") + assert.Equal(t, "v1.0/base/path/file.js.map", key) + + key = getS3Key("", "base/path", "file.js.map") + assert.Equal(t, "unversioned/base/path/file.js.map", key) + + key = getS3Key("v1.0", "", "file.js.map") + assert.Equal(t, "v1.0/file.js.map", key) + + key = getS3Key("v1.0", "base/path", "file.js.map") + assert.Equal(t, "v1.0/base/path/file.js.map", key) +} + +func TestUploadFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sourcemap-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + tempFile := filepath.Join(tempDir, "test.js.map") + err = os.WriteFile(tempFile, []byte("test content"), 0644) + assert.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, "test content", string(body)) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err = uploadFile(tempFile, server.URL, "test.js.map") + assert.NoError(t, err) + + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer errorServer.Close() + + err = uploadFile(tempFile, errorServer.URL, "test.js.map") + assert.Error(t, err) + assert.Contains(t, err.Error(), "upload failed with status code: 500") +} + +func TestNewUploadCmd(t *testing.T) { + client := resources.NewClient("") + cmd := NewUploadCmd(client) + + assert.Equal(t, "upload", cmd.Use) + assert.Equal(t, "Upload sourcemaps", cmd.Short) + assert.Contains(t, cmd.Long, "LaunchDarkly for error monitoring") + + assert.NotNil(t, cmd.Flags().Lookup("project")) + assert.NotNil(t, cmd.Flags().Lookup(appVersionFlag)) + assert.NotNil(t, cmd.Flags().Lookup(pathFlag)) + assert.NotNil(t, cmd.Flags().Lookup(basePathFlag)) + assert.NotNil(t, cmd.Flags().Lookup(backendUrlFlag)) + + requiredFlags := cmd.Flags().Lookup("project").Annotations["required"] + assert.Equal(t, []string{"true"}, requiredFlags) +} + +func TestGetAllSourceMapFiles(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "sourcemap-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // create some dummy .js.map files + for i := 0; i < 3; i++ { + err = os.WriteFile(filepath.Join(tempDir, fmt.Sprintf("test%d.js.map", i)), []byte("test content"), 0644) + assert.NoError(t, err) + } + + singleFile := fmt.Sprintf("%s/test0.js.map", tempDir) + files, err := getAllSourceMapFiles(singleFile) + assert.NoError(t, err) + assert.Equal(t, 1, len(files)) + assert.Equal(t, "test0.js.map", files[0].Name) + assert.Equal(t, singleFile, files[0].Path) + + dirPath := tempDir + var dirErr error + files, dirErr = getAllSourceMapFiles(dirPath) + assert.NoError(t, dirErr) + assert.GreaterOrEqual(t, len(files), 3) // At least 3 files (test.js.map, test.js, route.js.map) + + for _, file := range files { + assert.NotContains(t, file.Path, "node_modules") + } + + var foundRouteGroup bool + var foundRouteGroupRemoved bool + for _, file := range files { + if file.Path == fmt.Sprintf("%s/test0.js.map", tempDir) { + foundRouteGroup = true + } + if file.Name == "test0.js.map" { + foundRouteGroupRemoved = true + } + } + assert.True(t, foundRouteGroup, "Should find the route group file") + assert.True(t, foundRouteGroupRemoved, "Should find the route group file with group removed") + + _, err = getAllSourceMapFiles("/non-existent-path") + assert.Error(t, err) + + emptyDir, err := os.MkdirTemp("", "empty-dir") + assert.NoError(t, err) + defer os.RemoveAll(emptyDir) + _, err = getAllSourceMapFiles(emptyDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no .js.map files found") +} + + +func TestGetSourceMapUploadUrlsErrors(t *testing.T) { + _, err := getSourceMapUploadUrls("test-key", "project123", []string{"path"}, "://invalid-url") + assert.Error(t, err) + + _, err = getSourceMapUploadUrls("test-key", "project123", []string{"path"}, "http://non-existent-host.invalid") + assert.Error(t, err) + + invalidJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":invalid-json`)) + })) + defer invalidJSONServer.Close() + + _, err = getSourceMapUploadUrls("test-key", "project123", []string{"path"}, invalidJSONServer.URL) + assert.Error(t, err) +} + +func TestRunE(t *testing.T) { + // Create a mock client that returns predefined responses + mockClient := &mockResourcesClient{ + responses: map[string][]byte{ + "/api/v2/projects/test-project": []byte(`{"_id":"project123"}`), + }, + } + + cmd := NewUploadCmd(mockClient) + args := []string{} + + tempDir, err := os.MkdirTemp("", "sourcemap-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + testMapFile := filepath.Join(tempDir, "test0.js.map") + err = os.WriteFile(testMapFile, []byte("{}"), 0644) + assert.NoError(t, err) + + urlsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data":{"get_source_map_upload_urls_ld":["https://example.com/upload"]}}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer urlsServer.Close() + + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer uploadServer.Close() + + runFunc := runE(mockClient) + err = runFunc(cmd, args) + assert.Error(t, err) + + err = cmd.Flags().Set("project", "test-project") + assert.NoError(t, err) + err = cmd.Flags().Set(pathFlag, testMapFile) + assert.NoError(t, err) + err = cmd.Flags().Set(backendUrlFlag, urlsServer.URL) + assert.NoError(t, err) + + err = runFunc(cmd, args) + assert.Error(t, err) +} diff --git a/cmd/templates.go b/cmd/templates.go index b772bafa..221061f9 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -23,6 +23,7 @@ Common resource commands: {{rpad "projects" 29}} List, create, and manage projects {{rpad "members" 29}} Invite new members to an account {{rpad "segments" 29}} List, create, modify, and delete segments + {{rpad "sourcemaps" 29}} Manage sourcemaps for error monitoring {{rpad "..." 29}} To see more resource commands, run 'ldcli resources' Flags: