diff --git a/docs/components/Cloudsmith.mdx b/docs/components/Cloudsmith.mdx
index 0d2ed68ba3..71731d6f04 100644
--- a/docs/components/Cloudsmith.mdx
+++ b/docs/components/Cloudsmith.mdx
@@ -19,6 +19,8 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+
@@ -35,6 +37,8 @@ SuperPlane authenticates to Cloudsmith using a service account API key, which is
4. Paste the API key below.
5. To give the service access to any repository, click on your Repository and then **Settings** → **Access control → Privileges for specific services**, and add the service with the **Admin** privilege.
+> **Note:** The **Promote Package** action (copy or move a package between repositories) requires **Admin** privilege on **both** the source and destination repositories. Make sure the service account has been granted Admin access to every repository it needs to promote packages to or from.
+
## On Package Created
@@ -330,6 +334,157 @@ Returns the repository object including:
}
```
+
+
+## List Packages
+
+**Component key:** `cloudsmith.listPackages`
+
+The List Packages component fetches all packages in a Cloudsmith repository and optionally filters them by sync status, quarantine state, or vulnerability scan result.
+
+### Use Cases
+
+- **Release auditing**: List all fully synchronized packages before a release gate
+- **Quarantine monitoring**: Enumerate quarantined packages for a security review workflow
+- **Vulnerability triage**: Retrieve packages with detected vulnerabilities and route them to a remediation step
+- **Inventory**: Collect a complete snapshot of packages in a repository for reporting
+
+### Configuration
+
+- **Repository** (required): The repository to list packages from, in the form `owner/repository`.
+- **Sync Status** (optional): Filter by package synchronization state (`Any`, `Fully Synchronised`, `Awaiting Sync`, `Sync Failed`).
+- **Quarantine Status** (optional): Filter by quarantine state (`Any`, `Quarantined`, `Not Quarantined`).
+- **Vulnerability Status** (optional): Filter by security scan result (`Any`, `No Vulnerabilities`, `Vulnerabilities Found`).
+
+### Output
+
+Emits a single payload containing a `packages` array. Each entry includes:
+- **display_name** / **format**: Package display name and format
+- **status_str** / **stage_str**: Human-readable status and sync stage
+- **is_quarantined** / **policy_violated**: Quarantine and policy state
+- **description** / **license**: Package description and license
+- **slug_perm** / **repository**: Permanent identifier and repository slug
+- **tags**: Package tags
+
+### Example Output
+
+```json
+{
+ "data": {
+ "packages": [
+ {
+ "description": "Example application container image",
+ "display_name": "example-app",
+ "format": "docker",
+ "is_quarantined": false,
+ "license": "MIT",
+ "policy_violated": false,
+ "repository": "example-repo",
+ "security_scan_status": "No Vulnerabilities Found",
+ "slug_perm": "example-pkg-id-1",
+ "stage_str": "Fully Synchronised",
+ "status_str": "Available",
+ "tags": {}
+ },
+ {
+ "description": "Example application container image (previous release)",
+ "display_name": "example-app",
+ "format": "docker",
+ "is_quarantined": true,
+ "license": "MIT",
+ "policy_violated": true,
+ "repository": "example-repo",
+ "security_scan_status": "Scan Detected Vulnerabilities",
+ "slug_perm": "example-pkg-id-2",
+ "stage_str": "Fully Synchronised",
+ "status_str": "Available",
+ "tags": {}
+ }
+ ]
+ },
+ "timestamp": "2026-01-15T14:35:00Z",
+ "type": "cloudsmith.packages.listed"
+}
+```
+
+
+
+## Promote Package
+
+**Component key:** `cloudsmith.promotePackage`
+
+The Promote Package component copies or moves a package from a source repository to a destination repository within the same Cloudsmith namespace.
+
+### Use Cases
+
+- **Promotion pipelines**: Move a package from staging to production after all checks pass
+- **Multi-environment distribution**: Copy a package to multiple target repositories simultaneously
+- **Artifact archiving**: Move packages from active repositories to archive repositories
+- **Release management**: Promote a vetted version from a dev channel to a release channel
+
+### Configuration
+
+- **Source Repository** (required): The repository that currently holds the package, in the form `owner/repository`.
+- **Package** (required): The unique package identifier (`slug_perm`). Supports expressions — use `{{ $['On Package Created'].data.slug_perm }}` to reference an upstream trigger.
+- **Destination Repository** (required): The target repository to promote the package into, in the form `owner/repository`.
+- **Mode** (required): Whether to `copy` (keep the original) or `move` (remove from source).
+
+### Output
+
+Returns the promoted package as it appears in the destination repository, including:
+- **name** / **version**: Package name and version
+- **format**: Package format (e.g., `docker`, `python`, `debian`)
+- **repository** / **namespace**: Where the package now lives
+- **self_webapp_url**: URL to the promoted package in the Cloudsmith web app
+- **slug_perm**: Permanent identifier of the package in the destination
+- **uploaded_at**: Original upload timestamp
+
+### Example Output
+
+```json
+{
+ "data": {
+ "cdn_url": "https://dl.cloudsmith.io/basic/example-owner/example-prod-repo/docker/example-app.manifest.json",
+ "checksum_md5": "00000000000000000000000000000000",
+ "checksum_sha1": "0000000000000000000000000000000000000000",
+ "checksum_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
+ "checksum_sha512": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ "display_name": "example-app",
+ "format": "docker",
+ "is_quarantined": false,
+ "is_sync_awaiting": false,
+ "is_sync_completed": true,
+ "is_sync_failed": false,
+ "is_sync_in_flight": false,
+ "is_sync_in_progress": false,
+ "name": "example-app",
+ "namespace": "example-owner",
+ "policy_violated": false,
+ "repository": "example-prod-repo",
+ "security_scan_status": "No Vulnerabilities Found",
+ "self_html_url": "https://cloudsmith.io/~example-owner/repos/example-prod-repo/packages/detail/docker/example-app/1.2.0/",
+ "self_url": "https://api.cloudsmith.io/v1/packages/example-owner/example-prod-repo/example-pkg-prod-id/",
+ "self_webapp_url": "https://app.cloudsmith.com/example-owner/r/example-prod-repo/docker/example-app/1.2.0/example-pkg-prod-id",
+ "size": 54525952,
+ "size_str": "52.0 MB",
+ "slug": "example-app-xyz9",
+ "slug_perm": "example-pkg-prod-id",
+ "stage": 9,
+ "stage_str": "Fully Synchronised",
+ "status": 2,
+ "status_str": "Available",
+ "sync_progress": 100,
+ "tags": {},
+ "tags_immutable": {},
+ "uploaded_at": "2026-01-15T14:30:00Z",
+ "uploader": "example-user",
+ "version": "1.2.0"
+ },
+ "timestamp": "2026-01-15T15:00:00Z",
+ "type": "cloudsmith.package.promoted"
+}
+```
+
## Resync Package
diff --git a/pkg/integrations/cloudsmith/client.go b/pkg/integrations/cloudsmith/client.go
index 3e4ea1dd36..30d3cef17d 100644
--- a/pkg/integrations/cloudsmith/client.go
+++ b/pkg/integrations/cloudsmith/client.go
@@ -312,10 +312,20 @@ const packagePageSize = 100
// ListPackages returns all packages in the given repository, following pagination.
func (c *Client) ListPackages(owner, repo string) ([]Package, error) {
+ return c.ListPackagesWithFilters(owner, repo, "")
+}
+
+// ListPackagesWithFilters returns packages filtered by a Lucene-style query string,
+// following pagination. An empty query returns all packages.
+func (c *Client) ListPackagesWithFilters(owner, repo, query string) ([]Package, error) {
var all []Package
for page := 1; ; page++ {
requestURL := fmt.Sprintf("%s/packages/%s/%s/?page=%d&page_size=%d", c.BaseURL, url.PathEscape(owner), url.PathEscape(repo), page, packagePageSize)
+ if query != "" {
+ requestURL += "&query=" + url.QueryEscape(query)
+ }
+
responseBody, err := c.execRequest(http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
@@ -336,6 +346,51 @@ func (c *Client) ListPackages(owner, repo string) ([]Package, error) {
return all, nil
}
+// CopyPackage copies a package to a destination repository within the same namespace.
+// Returns the copied package in the destination.
+func (c *Client) CopyPackage(owner, repo, identifier, destinationRepo string) (*Package, error) {
+ payload, err := json.Marshal(map[string]string{"destination": destinationRepo})
+ if err != nil {
+ return nil, fmt.Errorf("error encoding request: %v", err)
+ }
+
+ requestURL := fmt.Sprintf("%s/packages/%s/%s/%s/copy/", c.BaseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(identifier))
+ responseBody, err := c.execRequest(http.MethodPost, requestURL, bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+
+ var pkg Package
+ if err := json.Unmarshal(responseBody, &pkg); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &pkg, nil
+}
+
+// MovePackage moves a package to a destination repository within the same namespace.
+// The package is removed from the source repository after a successful move.
+// Returns the moved package in the destination.
+func (c *Client) MovePackage(owner, repo, identifier, destinationRepo string) (*Package, error) {
+ payload, err := json.Marshal(map[string]string{"destination": destinationRepo})
+ if err != nil {
+ return nil, fmt.Errorf("error encoding request: %v", err)
+ }
+
+ requestURL := fmt.Sprintf("%s/packages/%s/%s/%s/move/", c.BaseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(identifier))
+ responseBody, err := c.execRequest(http.MethodPost, requestURL, bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+
+ var pkg Package
+ if err := json.Unmarshal(responseBody, &pkg); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &pkg, nil
+}
+
var errInvalidRepositoryID = errors.New("must be in the form 'owner/repository'")
// parseRepositoryID splits a Cloudsmith repository identifier of the form
diff --git a/pkg/integrations/cloudsmith/cloudsmith.go b/pkg/integrations/cloudsmith/cloudsmith.go
index 7f1e17e573..f50862ea31 100644
--- a/pkg/integrations/cloudsmith/cloudsmith.go
+++ b/pkg/integrations/cloudsmith/cloudsmith.go
@@ -51,6 +51,8 @@ SuperPlane authenticates to Cloudsmith using a service account API key, which is
3. Click on **Create Service** and copy the generated API key.
4. Paste the API key below.
5. To give the service access to any repository, click on your Repository and then **Settings** → **Access control → Privileges for specific services**, and add the service with the **Admin** privilege.
+
+> **Note:** The **Promote Package** action (copy or move a package between repositories) requires **Admin** privilege on **both** the source and destination repositories. Make sure the service account has been granted Admin access to every repository it needs to promote packages to or from.
`
}
@@ -74,6 +76,8 @@ func (c *Cloudsmith) Actions() []core.Action {
&ResyncPackage{},
&TagPackage{},
&DeletePackage{},
+ &ListPackages{},
+ &PromotePackage{},
}
}
diff --git a/pkg/integrations/cloudsmith/example.go b/pkg/integrations/cloudsmith/example.go
index b1ed38e597..01f9bacb05 100644
--- a/pkg/integrations/cloudsmith/example.go
+++ b/pkg/integrations/cloudsmith/example.go
@@ -55,6 +55,26 @@ func (r *ResyncPackage) ExampleOutput() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleOutputResyncPackageOnce, exampleOutputResyncPackageBytes, &exampleOutputResyncPackage)
}
+//go:embed example_output_list_packages.json
+var exampleOutputListPackagesBytes []byte
+
+var exampleOutputListPackagesOnce sync.Once
+var exampleOutputListPackages map[string]any
+
+func (l *ListPackages) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputListPackagesOnce, exampleOutputListPackagesBytes, &exampleOutputListPackages)
+}
+
+//go:embed example_output_promote_package.json
+var exampleOutputPromotePackageBytes []byte
+
+var exampleOutputPromotePackageOnce sync.Once
+var exampleOutputPromotePackage map[string]any
+
+func (p *PromotePackage) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputPromotePackageOnce, exampleOutputPromotePackageBytes, &exampleOutputPromotePackage)
+}
+
func (t *TagPackage) ExampleOutput() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleOutputTagPackageOnce, exampleOutputTagPackageBytes, &exampleOutputTagPackage)
}
diff --git a/pkg/integrations/cloudsmith/example_output_list_packages.json b/pkg/integrations/cloudsmith/example_output_list_packages.json
new file mode 100644
index 0000000000..4808596cc5
--- /dev/null
+++ b/pkg/integrations/cloudsmith/example_output_list_packages.json
@@ -0,0 +1,36 @@
+{
+ "data": {
+ "packages": [
+ {
+ "description": "Example application container image",
+ "display_name": "example-app",
+ "format": "docker",
+ "is_quarantined": false,
+ "license": "MIT",
+ "policy_violated": false,
+ "repository": "example-repo",
+ "security_scan_status": "No Vulnerabilities Found",
+ "slug_perm": "example-pkg-id-1",
+ "stage_str": "Fully Synchronised",
+ "status_str": "Available",
+ "tags": {}
+ },
+ {
+ "description": "Example application container image (previous release)",
+ "display_name": "example-app",
+ "format": "docker",
+ "is_quarantined": true,
+ "license": "MIT",
+ "policy_violated": true,
+ "repository": "example-repo",
+ "security_scan_status": "Scan Detected Vulnerabilities",
+ "slug_perm": "example-pkg-id-2",
+ "stage_str": "Fully Synchronised",
+ "status_str": "Available",
+ "tags": {}
+ }
+ ]
+ },
+ "timestamp": "2026-01-15T14:35:00Z",
+ "type": "cloudsmith.packages.listed"
+}
diff --git a/pkg/integrations/cloudsmith/example_output_promote_package.json b/pkg/integrations/cloudsmith/example_output_promote_package.json
new file mode 100644
index 0000000000..9f57118ada
--- /dev/null
+++ b/pkg/integrations/cloudsmith/example_output_promote_package.json
@@ -0,0 +1,41 @@
+{
+ "data": {
+ "cdn_url": "https://dl.cloudsmith.io/basic/example-owner/example-prod-repo/docker/example-app.manifest.json",
+ "checksum_md5": "00000000000000000000000000000000",
+ "checksum_sha1": "0000000000000000000000000000000000000000",
+ "checksum_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
+ "checksum_sha512": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ "display_name": "example-app",
+ "format": "docker",
+ "is_quarantined": false,
+ "is_sync_awaiting": false,
+ "is_sync_completed": true,
+ "is_sync_failed": false,
+ "is_sync_in_flight": false,
+ "is_sync_in_progress": false,
+ "name": "example-app",
+ "namespace": "example-owner",
+ "policy_violated": false,
+ "repository": "example-prod-repo",
+ "security_scan_status": "No Vulnerabilities Found",
+ "self_html_url": "https://cloudsmith.io/~example-owner/repos/example-prod-repo/packages/detail/docker/example-app/1.2.0/",
+ "self_url": "https://api.cloudsmith.io/v1/packages/example-owner/example-prod-repo/example-pkg-prod-id/",
+ "self_webapp_url": "https://app.cloudsmith.com/example-owner/r/example-prod-repo/docker/example-app/1.2.0/example-pkg-prod-id",
+ "size": 54525952,
+ "size_str": "52.0 MB",
+ "slug": "example-app-xyz9",
+ "slug_perm": "example-pkg-prod-id",
+ "stage": 9,
+ "stage_str": "Fully Synchronised",
+ "status": 2,
+ "status_str": "Available",
+ "sync_progress": 100,
+ "tags": {},
+ "tags_immutable": {},
+ "uploaded_at": "2026-01-15T14:30:00Z",
+ "uploader": "example-user",
+ "version": "1.2.0"
+ },
+ "timestamp": "2026-01-15T15:00:00Z",
+ "type": "cloudsmith.package.promoted"
+}
diff --git a/pkg/integrations/cloudsmith/list_packages.go b/pkg/integrations/cloudsmith/list_packages.go
new file mode 100644
index 0000000000..64a30d7e7a
--- /dev/null
+++ b/pkg/integrations/cloudsmith/list_packages.go
@@ -0,0 +1,280 @@
+package cloudsmith
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type ListPackages struct{}
+
+type ListPackagesSpec struct {
+ Repository string `json:"repository" mapstructure:"repository"`
+ SyncStatus string `json:"syncStatus" mapstructure:"syncStatus"`
+ QuarantineStatus string `json:"quarantineStatus" mapstructure:"quarantineStatus"`
+ VulnerabilityStatus string `json:"vulnerabilityStatus" mapstructure:"vulnerabilityStatus"`
+}
+
+// TrimmedPackage holds the subset of package fields emitted in the list result.
+type TrimmedPackage struct {
+ Description string `json:"description"`
+ DisplayName string `json:"display_name"`
+ Format string `json:"format"`
+ IsQuarantined bool `json:"is_quarantined"`
+ License string `json:"license"`
+ PolicyViolated bool `json:"policy_violated"`
+ Repository string `json:"repository"`
+ SecurityScanStatus string `json:"security_scan_status"`
+ SlugPerm string `json:"slug_perm"`
+ StageStr string `json:"stage_str"`
+ StatusStr string `json:"status_str"`
+ Tags map[string]any `json:"tags"`
+}
+
+// ListPackagesResult is the single payload emitted by ListPackages.Execute.
+type ListPackagesResult struct {
+ Packages []TrimmedPackage `json:"packages"`
+}
+
+func (l *ListPackages) Name() string {
+ return "cloudsmith.listPackages"
+}
+
+func (l *ListPackages) Label() string {
+ return "List Packages"
+}
+
+func (l *ListPackages) Description() string {
+ return "List packages in a Cloudsmith repository with optional filtering by sync status, quarantine, and vulnerability"
+}
+
+func (l *ListPackages) Documentation() string {
+ return `The List Packages component fetches all packages in a Cloudsmith repository and optionally filters them by sync status, quarantine state, or vulnerability scan result.
+
+## Use Cases
+
+- **Release auditing**: List all fully synchronized packages before a release gate
+- **Quarantine monitoring**: Enumerate quarantined packages for a security review workflow
+- **Vulnerability triage**: Retrieve packages with detected vulnerabilities and route them to a remediation step
+- **Inventory**: Collect a complete snapshot of packages in a repository for reporting
+
+## Configuration
+
+- **Repository** (required): The repository to list packages from, in the form ` + "`owner/repository`" + `.
+- **Sync Status** (optional): Filter by package synchronization state (` + "`Any`" + `, ` + "`Fully Synchronised`" + `, ` + "`Awaiting Sync`" + `, ` + "`Sync Failed`" + `).
+- **Quarantine Status** (optional): Filter by quarantine state (` + "`Any`" + `, ` + "`Quarantined`" + `, ` + "`Not Quarantined`" + `).
+- **Vulnerability Status** (optional): Filter by security scan result (` + "`Any`" + `, ` + "`No Vulnerabilities`" + `, ` + "`Vulnerabilities Found`" + `).
+
+## Output
+
+Emits a single payload containing a ` + "`packages`" + ` array. Each entry includes:
+- **display_name** / **format**: Package display name and format
+- **status_str** / **stage_str**: Human-readable status and sync stage
+- **is_quarantined** / **policy_violated**: Quarantine and policy state
+- **description** / **license**: Package description and license
+- **slug_perm** / **repository**: Permanent identifier and repository slug
+- **tags**: Package tags`
+}
+
+func (l *ListPackages) Icon() string {
+ return "list"
+}
+
+func (l *ListPackages) Color() string {
+ return "gray"
+}
+
+func (l *ListPackages) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (l *ListPackages) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "repository",
+ Label: "Repository",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The repository to list packages from",
+ Placeholder: "Select repository",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "repository",
+ UseNameAsValue: false,
+ },
+ },
+ },
+ {
+ Name: "syncStatus",
+ Label: "Sync Status",
+ Type: configuration.FieldTypeSelect,
+ Required: false,
+ Description: "Filter packages by their synchronization state",
+ Default: "any",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Any", Value: "any"},
+ {Label: "Fully Synchronised", Value: "fully_synchronised"},
+ {Label: "Awaiting Sync", Value: "awaiting"},
+ {Label: "Sync Failed", Value: "failed"},
+ },
+ },
+ },
+ },
+ {
+ Name: "quarantineStatus",
+ Label: "Quarantine Status",
+ Type: configuration.FieldTypeSelect,
+ Required: false,
+ Description: "Filter packages by their quarantine state",
+ Default: "any",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Any", Value: "any"},
+ {Label: "Quarantined", Value: "quarantined"},
+ {Label: "Not Quarantined", Value: "not_quarantined"},
+ },
+ },
+ },
+ },
+ {
+ Name: "vulnerabilityStatus",
+ Label: "Vulnerability Status",
+ Type: configuration.FieldTypeSelect,
+ Required: false,
+ Description: "Filter packages by their security scan result",
+ Default: "any",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Any", Value: "any"},
+ {Label: "No Vulnerabilities", Value: "no_vulnerabilities"},
+ {Label: "Vulnerabilities Found", Value: "vulnerabilities_found"},
+ },
+ },
+ },
+ },
+ }
+}
+
+func (l *ListPackages) Setup(ctx core.SetupContext) error {
+ spec := ListPackagesSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ if spec.Repository == "" {
+ return errors.New("repository is required")
+ }
+
+ return resolveRepositoryMetadata(ctx, spec.Repository)
+}
+
+func (l *ListPackages) Execute(ctx core.ExecutionContext) error {
+ spec := ListPackagesSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ owner, repo, err := parseRepositoryID(spec.Repository)
+ if err != nil {
+ return fmt.Errorf("invalid repository %q: %w", spec.Repository, err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ query := buildPackageQuery(spec)
+ packages, err := client.ListPackagesWithFilters(owner, repo, query)
+ if err != nil {
+ return fmt.Errorf("failed to list packages: %v", err)
+ }
+
+ trimmed := make([]TrimmedPackage, len(packages))
+ for i, pkg := range packages {
+ trimmed[i] = TrimmedPackage{
+ Description: pkg.Description,
+ DisplayName: pkg.DisplayName,
+ Format: pkg.Format,
+ IsQuarantined: pkg.IsQuarantined,
+ License: pkg.License,
+ PolicyViolated: pkg.PolicyViolated,
+ Repository: pkg.Repository,
+ SecurityScanStatus: pkg.SecurityScanStatus,
+ SlugPerm: pkg.SlugPerm,
+ StageStr: pkg.StageStr,
+ StatusStr: pkg.StatusStr,
+ Tags: pkg.Tags,
+ }
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "cloudsmith.packages.listed",
+ []any{ListPackagesResult{Packages: trimmed}},
+ )
+}
+
+// buildPackageQuery constructs a Cloudsmith Lucene-style query string from the spec filters.
+func buildPackageQuery(spec ListPackagesSpec) string {
+ var parts []string
+
+ switch spec.SyncStatus {
+ case "fully_synchronised":
+ parts = append(parts, "is_sync_completed:true")
+ case "awaiting":
+ parts = append(parts, "is_sync_awaiting:true")
+ case "failed":
+ parts = append(parts, "is_sync_failed:true")
+ }
+
+ switch spec.QuarantineStatus {
+ case "quarantined":
+ parts = append(parts, "is_quarantined:true")
+ case "not_quarantined":
+ parts = append(parts, "is_quarantined:false")
+ }
+
+ switch spec.VulnerabilityStatus {
+ case "no_vulnerabilities":
+ parts = append(parts, "security_scan_status:\"No Vulnerabilities Found\"")
+ case "vulnerabilities_found":
+ parts = append(parts, "security_scan_status:\"Scan Detected Vulnerabilities\"")
+ }
+
+ return strings.Join(parts, " AND ")
+}
+
+func (l *ListPackages) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (l *ListPackages) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (l *ListPackages) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (l *ListPackages) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (l *ListPackages) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (l *ListPackages) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/cloudsmith/list_packages_test.go b/pkg/integrations/cloudsmith/list_packages_test.go
new file mode 100644
index 0000000000..af7c4d20ff
--- /dev/null
+++ b/pkg/integrations/cloudsmith/list_packages_test.go
@@ -0,0 +1,191 @@
+package cloudsmith
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__ListPackages__Setup(t *testing.T) {
+ component := &ListPackages{}
+
+ t.Run("missing repository returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{},
+ Metadata: &contexts.MetadataContext{},
+ })
+
+ require.ErrorContains(t, err, "repository is required")
+ })
+
+ t.Run("empty repository returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{"repository": ""},
+ Metadata: &contexts.MetadataContext{},
+ })
+
+ require.ErrorContains(t, err, "repository is required")
+ })
+
+ t.Run("expression repository is stored without API call", func(t *testing.T) {
+ metadataCtx := &contexts.MetadataContext{}
+
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "repository": "{{ $.trigger.data.repository }}",
+ },
+ Metadata: metadataCtx,
+ })
+
+ require.NoError(t, err)
+ metadata, ok := metadataCtx.Metadata.(RepositoryNodeMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "{{ $.trigger.data.repository }}", metadata.RepositoryName)
+ })
+
+ t.Run("valid repository resolves metadata", func(t *testing.T) {
+ metadataCtx := &contexts.MetadataContext{}
+
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "repository": "acme/production",
+ },
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ okResponse(`{"name":"Production","slug":"production","namespace":"acme"}`),
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ Metadata: metadataCtx,
+ })
+
+ require.NoError(t, err)
+ metadata, ok := metadataCtx.Metadata.(RepositoryNodeMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "Production", metadata.RepositoryName)
+ })
+}
+
+func Test__ListPackages__Execute(t *testing.T) {
+ component := &ListPackages{}
+
+ pkg1JSON := `{"slug":"my-package-1-0-0","slug_perm":"perm1","name":"my-package","version":"1.0.0","format":"docker","status":2,"status_str":"Available","stage":9,"stage_str":"Fully Synchronised","is_quarantined":false,"security_scan_status":"No Vulnerabilities Found","size":52428800,"size_str":"50.0 MB","uploaded_at":"2026-01-01T10:00:00Z"}`
+ pkg2JSON := `{"slug":"my-package-1-1-0","slug_perm":"perm2","name":"my-package","version":"1.1.0","format":"docker","status":2,"status_str":"Available","stage":9,"stage_str":"Fully Synchronised","is_quarantined":false,"security_scan_status":"No Vulnerabilities Found","size":54525952,"size_str":"52.0 MB","uploaded_at":"2026-01-15T14:30:00Z"}`
+ packagesJSON := "[" + pkg1JSON + "," + pkg2JSON + "]"
+
+ t.Run("successful list emits all packages", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "repository": "acme/production",
+ },
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(packagesJSON)),
+ },
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ ExecutionState: executionState,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, executionState.Passed)
+ assert.Equal(t, "default", executionState.Channel)
+ assert.Equal(t, "cloudsmith.packages.listed", executionState.Type)
+ require.Len(t, executionState.Payloads, 1)
+ wrapped, ok := executionState.Payloads[0].(map[string]any)
+ require.True(t, ok)
+ result, ok := wrapped["data"].(ListPackagesResult)
+ require.True(t, ok)
+ assert.Len(t, result.Packages, 2)
+ })
+
+ t.Run("empty repository returns error", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"repository": "no-namespace"},
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ ExecutionState: executionState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repository")
+ })
+
+ t.Run("repository not found returns error", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{"repository": "acme/missing"},
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(strings.NewReader(`{"detail":"Not found."}`)),
+ },
+ },
+ },
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ ExecutionState: executionState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to list packages")
+ })
+
+ t.Run("fully_synchronised filter builds correct query", func(t *testing.T) {
+ spec := ListPackagesSpec{SyncStatus: "fully_synchronised"}
+ assert.Equal(t, "is_sync_completed:true", buildPackageQuery(spec))
+ })
+
+ t.Run("quarantined filter builds correct query", func(t *testing.T) {
+ spec := ListPackagesSpec{QuarantineStatus: "quarantined"}
+ assert.Equal(t, "is_quarantined:true", buildPackageQuery(spec))
+ })
+
+ t.Run("combined filters build AND query", func(t *testing.T) {
+ spec := ListPackagesSpec{
+ SyncStatus: "fully_synchronised",
+ QuarantineStatus: "not_quarantined",
+ VulnerabilityStatus: "no_vulnerabilities",
+ }
+ query := buildPackageQuery(spec)
+ assert.Contains(t, query, "is_sync_completed:true")
+ assert.Contains(t, query, "is_quarantined:false")
+ assert.Contains(t, query, "No Vulnerabilities Found")
+ assert.Contains(t, query, " AND ")
+ })
+
+ t.Run("any filters produce empty query", func(t *testing.T) {
+ spec := ListPackagesSpec{
+ SyncStatus: "any",
+ QuarantineStatus: "any",
+ VulnerabilityStatus: "any",
+ }
+ assert.Equal(t, "", buildPackageQuery(spec))
+ })
+}
+
+func Test__ListPackages__ExampleOutput(t *testing.T) {
+ output := (&ListPackages{}).ExampleOutput()
+ require.NotNil(t, output)
+ assert.Equal(t, "cloudsmith.packages.listed", output["type"])
+ assert.NotEmpty(t, output["timestamp"])
+ assert.NotNil(t, output["data"])
+}
diff --git a/pkg/integrations/cloudsmith/promote_package.go b/pkg/integrations/cloudsmith/promote_package.go
new file mode 100644
index 0000000000..51badd5ba3
--- /dev/null
+++ b/pkg/integrations/cloudsmith/promote_package.go
@@ -0,0 +1,246 @@
+package cloudsmith
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const (
+ PromoteModeMove = "move"
+ PromoteModeCopy = "copy"
+)
+
+type PromotePackage struct{}
+
+type PromotePackageSpec struct {
+ SourceRepository string `json:"sourceRepository" mapstructure:"sourceRepository"`
+ Package string `json:"package" mapstructure:"package"`
+ DestinationRepository string `json:"destinationRepository" mapstructure:"destinationRepository"`
+ Mode string `json:"mode" mapstructure:"mode"`
+}
+
+func (p *PromotePackage) Name() string {
+ return "cloudsmith.promotePackage"
+}
+
+func (p *PromotePackage) Label() string {
+ return "Promote Package"
+}
+
+func (p *PromotePackage) Description() string {
+ return "Copy or move a package from one Cloudsmith repository to another"
+}
+
+func (p *PromotePackage) Documentation() string {
+ return `The Promote Package component copies or moves a package from a source repository to a destination repository within the same Cloudsmith namespace.
+
+## Use Cases
+
+- **Promotion pipelines**: Move a package from staging to production after all checks pass
+- **Multi-environment distribution**: Copy a package to multiple target repositories simultaneously
+- **Artifact archiving**: Move packages from active repositories to archive repositories
+- **Release management**: Promote a vetted version from a dev channel to a release channel
+
+## Configuration
+
+- **Source Repository** (required): The repository that currently holds the package, in the form ` + "`owner/repository`" + `.
+- **Package** (required): The unique package identifier (` + "`slug_perm`" + `). Supports expressions — use ` + "`{{ $['On Package Created'].data.slug_perm }}`" + ` to reference an upstream trigger.
+- **Destination Repository** (required): The target repository to promote the package into, in the form ` + "`owner/repository`" + `.
+- **Mode** (required): Whether to ` + "`copy`" + ` (keep the original) or ` + "`move`" + ` (remove from source).
+
+## Output
+
+Returns the promoted package as it appears in the destination repository, including:
+- **name** / **version**: Package name and version
+- **format**: Package format (e.g., ` + "`docker`" + `, ` + "`python`" + `, ` + "`debian`" + `)
+- **repository** / **namespace**: Where the package now lives
+- **self_webapp_url**: URL to the promoted package in the Cloudsmith web app
+- **slug_perm**: Permanent identifier of the package in the destination
+- **uploaded_at**: Original upload timestamp`
+}
+
+func (p *PromotePackage) Icon() string {
+ return "copy"
+}
+
+func (p *PromotePackage) Color() string {
+ return "blue"
+}
+
+func (p *PromotePackage) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (p *PromotePackage) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "sourceRepository",
+ Label: "Source Repository",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The repository currently holding the package",
+ Placeholder: "Select source repository",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "repository",
+ UseNameAsValue: false,
+ },
+ },
+ },
+ {
+ Name: "package",
+ Label: "Package",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The package to promote",
+ Placeholder: "Select package",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "package",
+ UseNameAsValue: false,
+ Parameters: []configuration.ParameterRef{
+ {
+ Name: "repository",
+ ValueFrom: &configuration.ParameterValueFrom{Field: "sourceRepository"},
+ },
+ },
+ },
+ },
+ },
+ {
+ Name: "destinationRepository",
+ Label: "Destination Repository",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The repository to promote the package into",
+ Placeholder: "Select destination repository",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "repository",
+ UseNameAsValue: false,
+ },
+ },
+ },
+ {
+ Name: "mode",
+ Label: "Mode",
+ Type: configuration.FieldTypeSelect,
+ Required: true,
+ Description: "Copy keeps the original; Move removes it from the source",
+ Default: PromoteModeCopy,
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Copy", Value: PromoteModeCopy, Description: "Copy the package; the original remains in the source repository"},
+ {Label: "Move", Value: PromoteModeMove, Description: "Move the package; it is removed from the source repository"},
+ },
+ },
+ },
+ },
+ }
+}
+
+func (p *PromotePackage) Setup(ctx core.SetupContext) error {
+ spec := PromotePackageSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ if spec.SourceRepository == "" {
+ return errors.New("sourceRepository is required")
+ }
+
+ if spec.Package == "" {
+ return errors.New("package is required")
+ }
+
+ if spec.DestinationRepository == "" {
+ return errors.New("destinationRepository is required")
+ }
+
+ if spec.Mode != PromoteModeCopy && spec.Mode != PromoteModeMove {
+ return fmt.Errorf("mode must be %q or %q, got %q", PromoteModeCopy, PromoteModeMove, spec.Mode)
+ }
+
+ return resolvePackageMetadata(ctx, spec.SourceRepository, spec.Package)
+}
+
+func (p *PromotePackage) Execute(ctx core.ExecutionContext) error {
+ spec := PromotePackageSpec{}
+ if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ owner, sourceRepo, err := parseRepositoryID(spec.SourceRepository)
+ if err != nil {
+ return fmt.Errorf("invalid sourceRepository %q: %w", spec.SourceRepository, err)
+ }
+
+ destOwner, destRepo, err := parseRepositoryID(spec.DestinationRepository)
+ if err != nil {
+ return fmt.Errorf("invalid destinationRepository %q: %w", spec.DestinationRepository, err)
+ }
+
+ if destOwner != owner {
+ return fmt.Errorf("cross-namespace promotion is not supported: source owner %q and destination owner %q must match", owner, destOwner)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ var pkg *Package
+ switch spec.Mode {
+ case PromoteModeMove:
+ pkg, err = client.MovePackage(owner, sourceRepo, spec.Package, destRepo)
+ if err != nil {
+ return fmt.Errorf("failed to move package: %v", err)
+ }
+ default:
+ pkg, err = client.CopyPackage(owner, sourceRepo, spec.Package, destRepo)
+ if err != nil {
+ return fmt.Errorf("failed to copy package: %v", err)
+ }
+ }
+
+ if pkg == nil {
+ return errors.New("promote returned empty response")
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "cloudsmith.package.promoted",
+ []any{pkg},
+ )
+}
+
+func (p *PromotePackage) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (p *PromotePackage) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (p *PromotePackage) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (p *PromotePackage) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (p *PromotePackage) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (p *PromotePackage) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/cloudsmith/promote_package_test.go b/pkg/integrations/cloudsmith/promote_package_test.go
new file mode 100644
index 0000000000..fda1b4a486
--- /dev/null
+++ b/pkg/integrations/cloudsmith/promote_package_test.go
@@ -0,0 +1,305 @@
+package cloudsmith
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__PromotePackage__Setup(t *testing.T) {
+ component := &PromotePackage{}
+
+ t.Run("missing sourceRepository returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ },
+ Metadata: &contexts.MetadataContext{},
+ })
+
+ require.ErrorContains(t, err, "sourceRepository is required")
+ })
+
+ t.Run("missing package returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "destinationRepository": "acme/production",
+ },
+ Metadata: &contexts.MetadataContext{},
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ okResponse(`{"name":"Staging","slug":"staging","namespace":"acme"}`),
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ })
+
+ require.ErrorContains(t, err, "package is required")
+ })
+
+ t.Run("missing destinationRepository returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ },
+ Metadata: &contexts.MetadataContext{},
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ okResponse(`{"name":"Staging","slug":"staging","namespace":"acme"}`),
+ okResponse(`{"slug":"my-package-1-0-0","slug_perm":"perm123","name":"my-package","version":"1.0.0"}`),
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ })
+
+ require.ErrorContains(t, err, "destinationRepository is required")
+ })
+
+ t.Run("missing mode returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ },
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ })
+
+ require.ErrorContains(t, err, "mode must be")
+ })
+
+ t.Run("invalid mode returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ "mode": "promote",
+ },
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ })
+
+ require.ErrorContains(t, err, "mode must be")
+ })
+
+ t.Run("valid configuration resolves package metadata", func(t *testing.T) {
+ metadataCtx := &contexts.MetadataContext{}
+
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ "mode": PromoteModeCopy,
+ },
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ okResponse(`{"name":"Staging","slug":"staging","namespace":"acme"}`),
+ okResponse(`{"slug":"my-package-1-0-0","slug_perm":"perm123","name":"my-package","version":"1.0.0"}`),
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ Metadata: metadataCtx,
+ })
+
+ require.NoError(t, err)
+ metadata, ok := metadataCtx.Metadata.(PackageNodeMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "Staging", metadata.RepositoryName)
+ assert.Equal(t, "perm123", metadata.PackageID)
+ })
+}
+
+func Test__PromotePackage__Execute(t *testing.T) {
+ component := &PromotePackage{}
+
+ promotedPackageJSON := `{
+ "slug": "my-package-1-0-0",
+ "slug_perm": "perm123",
+ "name": "my-package",
+ "version": "1.0.0",
+ "format": "docker",
+ "status": 2,
+ "status_str": "Available",
+ "repository": "production",
+ "namespace": "acme",
+ "size": 52428800,
+ "size_str": "50.0 MB",
+ "self_webapp_url": "https://app.cloudsmith.com/acme/r/production/docker/my-package/1.0.0/perm123"
+ }`
+
+ t.Run("copy mode emits promoted package data", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ "mode": PromoteModeCopy,
+ },
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(promotedPackageJSON)),
+ },
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ ExecutionState: executionState,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, executionState.Passed)
+ assert.Equal(t, "default", executionState.Channel)
+ assert.Equal(t, "cloudsmith.package.promoted", executionState.Type)
+ require.Len(t, executionState.Payloads, 1)
+
+ wrapped, ok := executionState.Payloads[0].(map[string]any)
+ require.True(t, ok)
+ pkg, ok := wrapped["data"].(*Package)
+ require.True(t, ok)
+ assert.Equal(t, "my-package", pkg.Name)
+ assert.Equal(t, "1.0.0", pkg.Version)
+ assert.Equal(t, "production", pkg.Repository)
+ })
+
+ t.Run("move mode emits promoted package data", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ "mode": PromoteModeMove,
+ },
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(promotedPackageJSON)),
+ },
+ },
+ },
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ },
+ ExecutionState: executionState,
+ })
+
+ require.NoError(t, err)
+ assert.True(t, executionState.Passed)
+ assert.Equal(t, "cloudsmith.package.promoted", executionState.Type)
+ require.Len(t, executionState.Payloads, 1)
+ })
+
+ t.Run("invalid sourceRepository format returns error", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "sourceRepository": "invalid",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ "mode": PromoteModeCopy,
+ },
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ ExecutionState: executionState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid sourceRepository")
+ })
+
+ t.Run("invalid destinationRepository format returns error", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "invalid",
+ "mode": PromoteModeCopy,
+ },
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ ExecutionState: executionState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid destinationRepository")
+ })
+
+ t.Run("cross-namespace destination returns error", func(t *testing.T) {
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "other-owner/production",
+ "mode": PromoteModeCopy,
+ },
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}},
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "cross-namespace promotion is not supported")
+ })
+
+ t.Run("API error returns error", func(t *testing.T) {
+ executionState := &contexts.ExecutionStateContext{KVs: map[string]string{}}
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "sourceRepository": "acme/staging",
+ "package": "perm123",
+ "destinationRepository": "acme/production",
+ "mode": PromoteModeCopy,
+ },
+ HTTP: &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusForbidden,
+ Body: io.NopCloser(strings.NewReader(`{"detail":"Permission denied."}`)),
+ },
+ },
+ },
+ Integration: &contexts.IntegrationContext{Configuration: map[string]any{"apiKey": "test-key"}},
+ ExecutionState: executionState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to copy package")
+ })
+}
+
+func Test__PromotePackage__ExampleOutput(t *testing.T) {
+ output := (&PromotePackage{}).ExampleOutput()
+ require.NotNil(t, output)
+ assert.Equal(t, "cloudsmith.package.promoted", output["type"])
+ assert.NotEmpty(t, output["timestamp"])
+ assert.NotNil(t, output["data"])
+}
diff --git a/web_src/src/pages/app/mappers/cloudsmith/index.ts b/web_src/src/pages/app/mappers/cloudsmith/index.ts
index d27f27e6c0..98efff58db 100644
--- a/web_src/src/pages/app/mappers/cloudsmith/index.ts
+++ b/web_src/src/pages/app/mappers/cloudsmith/index.ts
@@ -2,6 +2,8 @@ import type { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from ".
import { buildActionStateRegistry } from "../utils";
import { getRepositoryMapper } from "./get_repository";
import { getPackageMapper } from "./get_package";
+import { listPackagesMapper } from "./list_packages";
+import { promotePackageMapper, promotePackageEventStateRegistry } from "./promote_package";
import { onSecurityScanCompletedTriggerRenderer } from "./on_security_scan_completed";
import { onPackageCreatedTriggerRenderer } from "./on_package_created";
import { resyncPackageMapper } from "./resync_package";
@@ -14,6 +16,8 @@ export const componentMappers: Record = {
resyncPackage: resyncPackageMapper,
tagPackage: tagPackageMapper,
deletePackage: deletePackageMapper,
+ listPackages: listPackagesMapper,
+ promotePackage: promotePackageMapper,
};
export const triggerRenderers: Record = {
@@ -24,6 +28,8 @@ export const triggerRenderers: Record = {
export const eventStateRegistry: Record = {
getRepository: buildActionStateRegistry("fetched"),
getPackage: buildActionStateRegistry("fetched"),
+ listPackages: buildActionStateRegistry("completed"),
+ promotePackage: promotePackageEventStateRegistry,
onSecurityScanCompleted: buildActionStateRegistry("triggered"),
onPackageCreated: buildActionStateRegistry("triggered"),
resyncPackage: buildActionStateRegistry("resynced"),
diff --git a/web_src/src/pages/app/mappers/cloudsmith/list_packages.spec.ts b/web_src/src/pages/app/mappers/cloudsmith/list_packages.spec.ts
new file mode 100644
index 0000000000..9d4eeed2c8
--- /dev/null
+++ b/web_src/src/pages/app/mappers/cloudsmith/list_packages.spec.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import { listPackagesMapper } from "./list_packages";
+import { buildDetailsCtx } from "./test_helpers";
+import type { TrimmedPackageData } from "./types";
+
+describe("listPackagesMapper.getExecutionDetails", () => {
+ it("does not throw when outputs is undefined", () => {
+ const ctx = buildDetailsCtx({ execution: { outputs: undefined } });
+ expect(() => listPackagesMapper.getExecutionDetails(ctx)).not.toThrow();
+ });
+
+ it("does not throw when default array is empty", () => {
+ const ctx = buildDetailsCtx({ execution: { outputs: { default: [] } } });
+ expect(() => listPackagesMapper.getExecutionDetails(ctx)).not.toThrow();
+ });
+
+ it("returns Executed At when packages are missing", () => {
+ const ctx = buildDetailsCtx({
+ execution: { outputs: { default: [buildListPackagesOutput(undefined)] } },
+ });
+ const details = listPackagesMapper.getExecutionDetails(ctx);
+ expect(details["Executed At"]).toBeDefined();
+ expect(details["Packages Found"]).toBeUndefined();
+ });
+
+ it("shows total packages found count", () => {
+ const ctx = buildDetailsCtx({
+ execution: {
+ outputs: {
+ default: [buildListPackagesOutput([buildTrimmedPackage(), buildTrimmedPackage()])],
+ },
+ },
+ });
+ const details = listPackagesMapper.getExecutionDetails(ctx);
+ expect(details["Packages Found"]).toBe("2");
+ });
+
+ it("shows quarantined package count", () => {
+ const ctx = buildDetailsCtx({
+ execution: {
+ outputs: {
+ default: [
+ buildListPackagesOutput([
+ buildTrimmedPackage({ is_quarantined: true }),
+ buildTrimmedPackage({ is_quarantined: false }),
+ buildTrimmedPackage({ is_quarantined: true }),
+ ]),
+ ],
+ },
+ },
+ });
+ const details = listPackagesMapper.getExecutionDetails(ctx);
+ expect(details["Quarantined"]).toBe("2");
+ });
+
+ it("shows vulnerable (security_scan_status) package count", () => {
+ const ctx = buildDetailsCtx({
+ execution: {
+ outputs: {
+ default: [
+ buildListPackagesOutput([
+ buildTrimmedPackage({ security_scan_status: "Scan Detected Vulnerabilities" }),
+ buildTrimmedPackage({ security_scan_status: "No Vulnerabilities Found" }),
+ buildTrimmedPackage({ security_scan_status: "Scan Detected Vulnerabilities" }),
+ ]),
+ ],
+ },
+ },
+ });
+ const details = listPackagesMapper.getExecutionDetails(ctx);
+ expect(details["Vulnerable"]).toBe("2");
+ });
+
+ it("shows zero quarantined and vulnerable when all packages are clean", () => {
+ const ctx = buildDetailsCtx({
+ execution: {
+ outputs: {
+ default: [buildListPackagesOutput([buildTrimmedPackage(), buildTrimmedPackage()])],
+ },
+ },
+ });
+ const details = listPackagesMapper.getExecutionDetails(ctx);
+ expect(details["Quarantined"]).toBe("0");
+ expect(details["Vulnerable"]).toBe("0");
+ });
+
+ it("does not include Format, Status, Security Scan, or Repository URL in details", () => {
+ const ctx = buildDetailsCtx({
+ execution: {
+ outputs: {
+ default: [buildListPackagesOutput([buildTrimmedPackage({ format: "docker", status_str: "Available" })])],
+ },
+ },
+ });
+ const details = listPackagesMapper.getExecutionDetails(ctx);
+ expect(details["Format"]).toBeUndefined();
+ expect(details["Status"]).toBeUndefined();
+ expect(details["Security Scan"]).toBeUndefined();
+ expect(details["Repository URL"]).toBeUndefined();
+ });
+});
+
+function buildTrimmedPackage(overrides?: Partial): TrimmedPackageData {
+ return {
+ display_name: "my-package",
+ format: "docker",
+ is_quarantined: false,
+ policy_violated: false,
+ repository: "production",
+ security_scan_status: "No Vulnerabilities Found",
+ slug_perm: "perm123abc",
+ stage_str: "Fully Synchronised",
+ status_str: "Available",
+ ...overrides,
+ };
+}
+
+function buildListPackagesOutput(packages: TrimmedPackageData[] | undefined) {
+ return {
+ type: "cloudsmith.packages.listed",
+ timestamp: new Date().toISOString(),
+ data: packages !== undefined ? { packages } : undefined,
+ };
+}
diff --git a/web_src/src/pages/app/mappers/cloudsmith/list_packages.ts b/web_src/src/pages/app/mappers/cloudsmith/list_packages.ts
new file mode 100644
index 0000000000..6b751609a8
--- /dev/null
+++ b/web_src/src/pages/app/mappers/cloudsmith/list_packages.ts
@@ -0,0 +1,98 @@
+import type { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import type React from "react";
+import { getBackgroundColorClass } from "@/lib/colors";
+import { getState, getStateMap, getTriggerRenderer } from "..";
+import type {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../types";
+import type { MetadataItem } from "@/ui/metadataList";
+import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import type { ListPackagesConfiguration, ListPackagesData, RepositoryNodeMetadata } from "./types";
+
+export const listPackagesMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+ const componentName = context.componentDefinition.name ?? "cloudsmith";
+
+ return {
+ iconSrc: cloudsmithIcon,
+ collapsedBackground: getBackgroundColorClass(context.componentDefinition.color),
+ collapsed: context.node.isCollapsed,
+ title: context.node.name || context.componentDefinition.label || "Unnamed component",
+ eventSections: lastExecution ? buildEventSections(context.nodes, lastExecution, componentName) : undefined,
+ metadata: buildMetadata(context.node),
+ includeEmptyState: !lastExecution,
+ eventStateMap: getStateMap(componentName),
+ };
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const details: Record = {};
+
+ if (context.execution.createdAt) {
+ details["Executed At"] = new Date(context.execution.createdAt).toLocaleString();
+ }
+
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const firstPayload = outputs?.default?.[0];
+ const result = firstPayload?.data as ListPackagesData | undefined;
+ const packages = result?.packages;
+ if (!packages) return details;
+
+ details["Packages Found"] = String(packages.length);
+
+ const quarantinedCount = packages.filter((p) => p.is_quarantined).length;
+ details["Quarantined"] = String(quarantinedCount);
+
+ const vulnerableCount = packages.filter((p) => p.security_scan_status === "Scan Detected Vulnerabilities").length;
+ details["Vulnerable"] = String(vulnerableCount);
+
+ return details;
+ },
+
+ subtitle(context: SubtitleContext): string | React.ReactNode {
+ if (!context.execution.createdAt) return "";
+ return renderTimeAgo(new Date(context.execution.createdAt));
+ },
+};
+
+function buildMetadata(node: NodeInfo): MetadataItem[] {
+ const items: MetadataItem[] = [];
+ const nodeMetadata = node.metadata as RepositoryNodeMetadata | undefined;
+ const configuration = node.configuration as ListPackagesConfiguration | undefined;
+
+ if (nodeMetadata?.repositoryName) {
+ items.push({ icon: "package", label: nodeMetadata.repositoryName });
+ } else if (configuration?.repository) {
+ items.push({ icon: "package", label: configuration.repository });
+ }
+
+ return items;
+}
+
+function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] {
+ if (!execution.rootEvent || !execution.createdAt || !execution.rootEvent.id) {
+ return [];
+ }
+
+ const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId);
+ const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? "");
+ const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent });
+
+ return [
+ {
+ receivedAt: new Date(execution.createdAt),
+ eventTitle: title,
+ eventSubtitle: renderTimeAgo(new Date(execution.createdAt)),
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent.id,
+ },
+ ];
+}
diff --git a/web_src/src/pages/app/mappers/cloudsmith/promote_package.spec.ts b/web_src/src/pages/app/mappers/cloudsmith/promote_package.spec.ts
new file mode 100644
index 0000000000..185eec1203
--- /dev/null
+++ b/web_src/src/pages/app/mappers/cloudsmith/promote_package.spec.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest";
+import { promotePackageMapper, promotePackageEventStateRegistry } from "./promote_package";
+import { buildDetailsCtx, buildPackageData, buildPackageOutput, buildNode } from "./test_helpers";
+
+describe("promotePackageMapper.getExecutionDetails", () => {
+ it("does not throw when outputs is undefined", () => {
+ const ctx = buildDetailsCtx({ execution: { outputs: undefined } });
+ expect(() => promotePackageMapper.getExecutionDetails(ctx)).not.toThrow();
+ });
+
+ it("does not throw when default array is empty", () => {
+ const ctx = buildDetailsCtx({ execution: { outputs: { default: [] } } });
+ expect(() => promotePackageMapper.getExecutionDetails(ctx)).not.toThrow();
+ });
+
+ it("returns Executed At without package fields when output data is missing", () => {
+ const ctx = buildDetailsCtx({
+ execution: { outputs: { default: [buildPromoteOutput(undefined)] } },
+ });
+ const details = promotePackageMapper.getExecutionDetails(ctx);
+ expect(details["Executed At"]).toBeDefined();
+ expect(details["Package"]).toBeUndefined();
+ });
+
+ it("extracts key promoted package fields", () => {
+ const pkg = buildPackageData({
+ name: "my-package",
+ version: "1.2.0",
+ repository: "production",
+ });
+ const ctx = buildDetailsCtx({
+ execution: { outputs: { default: [buildPromoteOutput(pkg)] } },
+ });
+ const details = promotePackageMapper.getExecutionDetails(ctx);
+ expect(details["Executed At"]).toBeDefined();
+ expect(details["Package"]).toBe("my-package");
+ expect(details["Version"]).toBe("1.2.0");
+ expect(details["Destination"]).toBe("production");
+ });
+
+ it("does not include Status, URL, Size, or Security Scan in details", () => {
+ const ctx = buildDetailsCtx({
+ execution: { outputs: { default: [buildPromoteOutput(buildPackageData())] } },
+ });
+ const details = promotePackageMapper.getExecutionDetails(ctx);
+ expect(details["Status"]).toBeUndefined();
+ expect(details["URL"]).toBeUndefined();
+ expect(details["Size"]).toBeUndefined();
+ expect(details["Security Scan"]).toBeUndefined();
+ });
+});
+
+describe("promotePackageEventStateRegistry.getState", () => {
+ const { getState } = promotePackageEventStateRegistry;
+
+ it("returns copied when mode is copy (default)", () => {
+ const execution = buildExecution({
+ result: "RESULT_PASSED",
+ state: "STATE_FINISHED",
+ configuration: { mode: "copy" },
+ });
+ expect(getState(execution)).toBe("copied");
+ });
+
+ it("returns copied when mode is not set", () => {
+ const execution = buildExecution({ result: "RESULT_PASSED", state: "STATE_FINISHED", configuration: {} });
+ expect(getState(execution)).toBe("copied");
+ });
+
+ it("returns moved when mode is move", () => {
+ const execution = buildExecution({
+ result: "RESULT_PASSED",
+ state: "STATE_FINISHED",
+ configuration: { mode: "move" },
+ });
+ expect(getState(execution)).toBe("moved");
+ });
+
+ it("returns failed for a failed execution", () => {
+ const execution = buildExecution({
+ result: "RESULT_FAILED",
+ state: "STATE_FINISHED",
+ configuration: { mode: "move" },
+ });
+ expect(getState(execution)).toBe("failed");
+ });
+
+ it("has stateMap entries for both copied and moved", () => {
+ expect(promotePackageEventStateRegistry.stateMap["copied"]).toBeDefined();
+ expect(promotePackageEventStateRegistry.stateMap["moved"]).toBeDefined();
+ });
+});
+
+describe("promotePackageMapper metadata", () => {
+ it("shows destination repository from configuration", () => {
+ const labels = getMetadataLabels({ destinationRepository: "acme/production", mode: "copy" });
+ expect(labels).toContain("acme/production");
+ });
+
+ it("shows action label from configuration mode", () => {
+ const labels = getMetadataLabels({ destinationRepository: "acme/production", mode: "move" });
+ expect(labels).toContain("Move");
+ });
+
+ it("shows Copy for copy mode", () => {
+ const labels = getMetadataLabels({ destinationRepository: "acme/production", mode: "copy" });
+ expect(labels).toContain("Copy");
+ });
+});
+
+function getMetadataLabels(configOverrides: Record): (string | unknown)[] {
+ const node = buildNode({
+ configuration: {
+ sourceRepository: "acme/staging",
+ package: "perm123",
+ ...configOverrides,
+ },
+ metadata: {},
+ });
+ const metadata =
+ promotePackageMapper.props({
+ nodes: [node],
+ node,
+ componentDefinition: {
+ name: "cloudsmith.promotePackage",
+ label: "Promote Package",
+ description: "",
+ icon: "copy",
+ color: "blue",
+ },
+ lastExecutions: [],
+ currentUser: undefined,
+ actions: { invokeNodeExecutionHook: async () => {} },
+ }).metadata ?? [];
+ return metadata.map((m) => m.label);
+}
+
+function buildPromoteOutput(data: unknown) {
+ return buildPackageOutput(data, "cloudsmith.package.promoted");
+}
+
+function buildExecution(overrides: { result: string; state: string; configuration: unknown }) {
+ return {
+ id: "exec-1",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ state: overrides.state as never,
+ result: overrides.result as never,
+ resultReason: "RESULT_REASON_OK" as never,
+ resultMessage: "",
+ metadata: {},
+ configuration: overrides.configuration,
+ rootEvent: undefined as never,
+ };
+}
diff --git a/web_src/src/pages/app/mappers/cloudsmith/promote_package.ts b/web_src/src/pages/app/mappers/cloudsmith/promote_package.ts
new file mode 100644
index 0000000000..11ce6540a9
--- /dev/null
+++ b/web_src/src/pages/app/mappers/cloudsmith/promote_package.ts
@@ -0,0 +1,123 @@
+import type { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import { DEFAULT_EVENT_STATE_MAP } from "@/ui/componentBase";
+import type React from "react";
+import { getBackgroundColorClass } from "@/lib/colors";
+import { getState, getStateMap, getTriggerRenderer } from "..";
+import type {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ EventStateRegistry,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../types";
+import { defaultStateFunction } from "../stateRegistry";
+import type { MetadataItem } from "@/ui/metadataList";
+import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import type { PackageData, PackageNodeMetadata, PromotePackageConfiguration } from "./types";
+
+export const promotePackageEventStateRegistry: EventStateRegistry = {
+ stateMap: {
+ ...DEFAULT_EVENT_STATE_MAP,
+ copied: DEFAULT_EVENT_STATE_MAP.success,
+ moved: DEFAULT_EVENT_STATE_MAP.success,
+ },
+ getState: (execution) => {
+ const state = defaultStateFunction(execution);
+ if (state !== "success") return state;
+ const config = execution.configuration as PromotePackageConfiguration | undefined;
+ return config?.mode === "move" ? "moved" : "copied";
+ },
+};
+
+export const promotePackageMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+ const componentName = context.componentDefinition.name ?? "cloudsmith";
+
+ return {
+ iconSrc: cloudsmithIcon,
+ collapsedBackground: getBackgroundColorClass(context.componentDefinition.color),
+ collapsed: context.node.isCollapsed,
+ title: context.node.name || context.componentDefinition.label || "Unnamed component",
+ eventSections: lastExecution ? buildEventSections(context.nodes, lastExecution, componentName) : undefined,
+ metadata: buildMetadata(context.node),
+ includeEmptyState: !lastExecution,
+ eventStateMap: getStateMap(componentName),
+ };
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const details: Record = {};
+
+ if (context.execution.createdAt) {
+ details["Executed At"] = new Date(context.execution.createdAt).toLocaleString();
+ }
+
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const pkg = outputs?.default?.[0]?.data as PackageData | undefined;
+ if (!pkg) return details;
+
+ if (pkg.name) details["Package"] = pkg.name;
+ if (pkg.version) details["Version"] = pkg.version;
+ if (pkg.repository) details["Destination"] = pkg.repository;
+
+ return details;
+ },
+
+ subtitle(context: SubtitleContext): string | React.ReactNode {
+ if (!context.execution.createdAt) return "";
+ return renderTimeAgo(new Date(context.execution.createdAt));
+ },
+};
+
+function buildMetadata(node: NodeInfo): MetadataItem[] {
+ const items: MetadataItem[] = [];
+ const nodeMetadata = node.metadata as PackageNodeMetadata | undefined;
+ const configuration = node.configuration as PromotePackageConfiguration | undefined;
+
+ if (nodeMetadata?.repositoryName) {
+ items.push({ icon: "package", label: nodeMetadata.repositoryName });
+ } else if (configuration?.sourceRepository) {
+ items.push({ icon: "package", label: configuration.sourceRepository });
+ }
+
+ if (nodeMetadata?.packageName) {
+ items.push({ icon: "archive", label: nodeMetadata.packageName });
+ } else if (configuration?.package) {
+ items.push({ icon: "archive", label: configuration.package });
+ }
+
+ if (configuration?.destinationRepository) {
+ items.push({ icon: "arrow-right", label: configuration.destinationRepository });
+ }
+
+ if (configuration?.mode) {
+ items.push({ icon: "copy", label: configuration.mode === "move" ? "Move" : "Copy" });
+ }
+
+ return items;
+}
+
+function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] {
+ if (!execution.rootEvent || !execution.createdAt || !execution.rootEvent.id) {
+ return [];
+ }
+
+ const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId);
+ const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? "");
+ const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent });
+
+ return [
+ {
+ receivedAt: new Date(execution.createdAt),
+ eventTitle: title,
+ eventSubtitle: renderTimeAgo(new Date(execution.createdAt)),
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent.id,
+ },
+ ];
+}
diff --git a/web_src/src/pages/app/mappers/cloudsmith/types.ts b/web_src/src/pages/app/mappers/cloudsmith/types.ts
index 281d810c03..a67227c3bc 100644
--- a/web_src/src/pages/app/mappers/cloudsmith/types.ts
+++ b/web_src/src/pages/app/mappers/cloudsmith/types.ts
@@ -138,3 +138,52 @@ export interface PackageOperationResult {
package?: string;
data?: PackageData;
}
+
+// List Packages
+
+export interface ListPackagesConfiguration {
+ repository?: string;
+ syncStatus?: string;
+ quarantineStatus?: string;
+ vulnerabilityStatus?: string;
+}
+
+export interface TrimmedPackageData {
+ description?: string;
+ display_name?: string;
+ format?: string;
+ is_quarantined?: boolean;
+ license?: string;
+ policy_violated?: boolean;
+ repository?: string;
+ security_scan_status?: string;
+ slug_perm?: string;
+ stage_str?: string;
+ status_str?: string;
+ tags?: Record;
+}
+
+export interface ListPackagesData {
+ packages?: TrimmedPackageData[];
+}
+
+// Promote Package
+
+export interface PromotePackageConfiguration {
+ sourceRepository?: string;
+ package?: string;
+ destinationRepository?: string;
+ mode?: string;
+}
+
+export interface PromotePackageResult {
+ name?: string;
+ version?: string;
+ format?: string;
+ repository?: string;
+ namespace?: string;
+ status_str?: string;
+ stage_str?: string;
+ self_webapp_url?: string;
+ slug_perm?: string;
+}