diff --git a/modules/har/pkg/har/migrate/adapter/har/client.go b/modules/har/pkg/har/migrate/adapter/har/client.go index 9dbeccd..f9059c2 100644 --- a/modules/har/pkg/har/migrate/adapter/har/client.go +++ b/modules/har/pkg/har/migrate/adapter/har/client.go @@ -16,6 +16,7 @@ import ( "github.com/harness/cli/modules/har/pkg/har/migrate/adapter/har/arapi" "github.com/harness/cli/modules/har/pkg/har/migrate/adapter/har/arpkg" "github.com/harness/cli/modules/har/pkg/har/migrate/types" + "github.com/harness/cli/modules/har/pkg/har/migrate/util" "github.com/google/uuid" retryablehttp "github.com/hashicorp/go-retryablehttp" @@ -29,6 +30,7 @@ type xAPIKeyTransport struct { func (t *xAPIKeyTransport) RoundTrip(req *http2.Request) (*http2.Response, error) { req = req.Clone(req.Context()) req.Header.Set("x-api-key", t.token) + req.Header.Set("User-Agent", util.UserAgentString()) return t.base.RoundTrip(req) } @@ -53,6 +55,7 @@ func newClient(reg *types.RegistryConfig) *client { withXApiKey := func(c *arapi.Client) error { c.RequestEditors = append(c.RequestEditors, func(ctx context.Context, req *http2.Request) error { req.Header.Set("x-api-key", token) + req.Header.Set("User-Agent", util.UserAgentString()) return nil }) return nil @@ -60,6 +63,7 @@ func newClient(reg *types.RegistryConfig) *client { withXApiKeyPkg := func(c *arpkg.Client) error { c.RequestEditors = append(c.RequestEditors, func(ctx context.Context, req *http2.Request) error { req.Header.Set("x-api-key", token) + req.Header.Set("User-Agent", util.UserAgentString()) return nil }) return nil diff --git a/modules/har/pkg/har/migrate/adapter/harbor/client.go b/modules/har/pkg/har/migrate/adapter/harbor/client.go index 8d75ede..5235aa9 100644 --- a/modules/har/pkg/har/migrate/adapter/harbor/client.go +++ b/modules/har/pkg/har/migrate/adapter/harbor/client.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/harness/cli/modules/har/pkg/har/migrate/types" + "github.com/harness/cli/modules/har/pkg/har/migrate/util" ) const ( @@ -43,6 +44,7 @@ func (t *basicTransport) RoundTrip(req *http.Request) (*http.Response, error) { if t.username != "" { req.SetBasicAuth(t.username, t.password) } + req.Header.Set("User-Agent", util.UserAgentString()) return t.base.RoundTrip(req) } diff --git a/modules/har/pkg/har/migrate/adapter/jfrog/client.go b/modules/har/pkg/har/migrate/adapter/jfrog/client.go index 8013985..57ce83d 100644 --- a/modules/har/pkg/har/migrate/adapter/jfrog/client.go +++ b/modules/har/pkg/har/migrate/adapter/jfrog/client.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/harness/cli/modules/har/pkg/har/migrate/types" + "github.com/harness/cli/modules/har/pkg/har/migrate/util" ) type bearerTransport struct { @@ -19,6 +20,7 @@ type bearerTransport struct { func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("Authorization", "Bearer "+t.token) + req.Header.Set("User-Agent", util.UserAgentString()) return t.base.RoundTrip(req) } @@ -145,6 +147,13 @@ func (c *client) GetFile(registry string, path string) (io.ReadCloser, http.Head return nil, nil, fmt.Errorf("failed to create request for file '%s': %w", path, err) } + // Prevent JFrog from updating the artifact's download stats during migration; + // without this, every migration pass resets last-download timestamps and breaks + // downloadedAfter date filtering on subsequent runs. + q := req.URL.Query() + q.Set("skipUpdateStats", "true") + req.URL.RawQuery = q.Encode() + resp, err := c.client.Do(req) if err != nil { return nil, nil, fmt.Errorf("failed to download file '%s': %w", path, err) diff --git a/modules/har/pkg/har/migrate/adapter/nexus/client.go b/modules/har/pkg/har/migrate/adapter/nexus/client.go index ab08256..689639d 100644 --- a/modules/har/pkg/har/migrate/adapter/nexus/client.go +++ b/modules/har/pkg/har/migrate/adapter/nexus/client.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/harness/cli/modules/har/pkg/har/migrate/types" + "github.com/harness/cli/modules/har/pkg/har/migrate/util" ) type basicTransport struct { @@ -23,6 +24,7 @@ func (t *basicTransport) RoundTrip(req *http.Request) (*http.Response, error) { if t.username != "" { req.SetBasicAuth(t.username, t.password) } + req.Header.Set("User-Agent", util.UserAgentString()) return t.base.RoundTrip(req) } diff --git a/modules/har/pkg/har/migrate/migratable/package.go b/modules/har/pkg/har/migrate/migratable/package.go index dc58be7..8e9042b 100644 --- a/modules/har/pkg/har/migrate/migratable/package.go +++ b/modules/har/pkg/har/migrate/migratable/package.go @@ -165,6 +165,7 @@ func (r *Package) Pre(ctx context.Context) error { crane.WithJobs(r.config.Concurrency), crane.WithNoClobber(!r.config.Overwrite), crane.WithAuthFromKeychain(keyChain), + crane.WithUserAgent(util.UserAgentString()), } if r.srcAdapter.GetConfig().Insecure { craneOpts = append(craneOpts, crane.Insecure) @@ -256,7 +257,7 @@ func (r *Package) Migrate(ctx context.Context) error { } craneOpts := []crane.Option{ - crane.WithUserAgent("harness-cli"), + crane.WithUserAgent(util.UserAgentString()), crane.WithContext(ctx), crane.WithJobs(r.config.Concurrency), crane.WithNoClobber(!r.config.Overwrite), @@ -648,7 +649,7 @@ func (r *Package) pushChart(ctx context.Context, chartPath string, dstRef string craneOpts := []remote.Option{ remote.WithContext(ctx), - remote.WithUserAgent("harness-cli"), + remote.WithUserAgent(util.UserAgentString()), remote.WithAuthFromKeychain(keyChain), } diff --git a/modules/har/pkg/har/migrate/util/useragent.go b/modules/har/pkg/har/migrate/util/useragent.go new file mode 100644 index 0000000..9e5065b --- /dev/null +++ b/modules/har/pkg/har/migrate/util/useragent.go @@ -0,0 +1,17 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + "runtime" + + "github.com/harness/cli/pkg/hbase" +) + +// UserAgentString returns the User-Agent value sent with all outgoing migrate +// HTTP requests, e.g. "harness-cli/3.1.2 (darwin/arm64)". +func UserAgentString() string { + return fmt.Sprintf("harness-cli/%s (%s/%s)", hbase.Version, runtime.GOOS, runtime.GOARCH) +} diff --git a/modules/har/pkg/har/push_npm.go b/modules/har/pkg/har/push_npm.go index b5b0135..c09975c 100644 --- a/modules/har/pkg/har/push_npm.go +++ b/modules/har/pkg/har/push_npm.go @@ -10,8 +10,10 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/harness/cli/pkg/cmdctx" + "github.com/pterm/pterm" ) // npmMinimalPackageJSON holds the fields we need from package.json. @@ -263,8 +265,25 @@ func pushNpmArtifact(ctx *cmdctx.Ctx) error { return fmt.Errorf("encoding upload payload: %w", err) } - // --- 5. POST to the npm upload endpoint -------------------------------- - subpath := fmt.Sprintf("%s/npm/%s", registry, name) + // --- 5. PUT to the npm upload endpoint --------------------------------- + // Scoped packages (@scope/name) route to a separate path segment on the + // server. Both scoped and unscoped use PUT per the HAR API spec. + var subpath string + if strings.HasPrefix(name, "@") { + if !strings.Contains(name, "/") { + pterm.Error.Println("Invalid scoped package name format") + return fmt.Errorf("invalid scoped package name: %s (scoped packages must be in format @scope/package)", name) + } + parts := strings.SplitN(name[1:], "/", 2) + if len(parts) != 2 { + pterm.Error.Println("Invalid scoped package name format") + return fmt.Errorf("invalid scoped package name: %s", name) + } + pterm.Info.Printf("Uploading scoped package @%s/%s\n", parts[0], parts[1]) + subpath = fmt.Sprintf("%s/npm/@%s/%s", registry, parts[0], parts[1]) + } else { + subpath = fmt.Sprintf("%s/npm/%s", registry, name) + } uploadURL, err := buildPkgURL(ctx.Auth.RegistryURL, ctx.Auth.AccountID, subpath) if err != nil { return err @@ -272,7 +291,7 @@ func pushNpmArtifact(ctx *cmdctx.Ctx) error { fmt.Fprintf(os.Stderr, "Uploading npm package %s@%s to registry %s ...\n", pkg.Name, pkg.Version, registry) - req, err := http.NewRequest("POST", uploadURL, bytes.NewReader(bodyBytes)) + req, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("building request: %w", err) }