From bf043cbf75450ba4209b4ef781bfed756dbd8c23 Mon Sep 17 00:00:00 2001 From: cjea Date: Fri, 3 Oct 2025 15:02:02 -0700 Subject: [PATCH 01/11] WIP --- cli/internal/api/deployments.go | 73 ++++++++- cli/internal/app/app.go | 3 +- cli/internal/app/push.go | 1 + cli/internal/app/status.go | 3 +- cli/internal/app/upload.go | 185 ++++++++++++++++++++++ cli/internal/deploy/config.go | 8 + cli/internal/deploy/read.go | 4 +- cli/internal/deploy/source_reader.go | 12 +- cli/internal/deploy/source_reader_test.go | 2 +- cli/internal/deploy/upload.go | 82 +++++++--- 10 files changed, 337 insertions(+), 36 deletions(-) create mode 100644 cli/internal/app/upload.go diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 8668ed3e2..a013ec5dd 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -55,7 +55,7 @@ func (c *DeploymentsClient) CreateDeployment( req CreateDeploymentRequest, ) (*deployments.CreateDeploymentResult, error) { key := req.APIKey.Reveal() - result, err := c.client.CreateDeployment(ctx, &deployments.CreateDeploymentPayload{ + payload := &deployments.CreateDeploymentPayload{ ApikeyToken: &key, ProjectSlugInput: &req.ProjectSlug, IdempotencyKey: req.IdempotencyKey, @@ -68,7 +68,8 @@ func (c *DeploymentsClient) CreateDeployment( ExternalID: nil, ExternalURL: nil, Packages: nil, - }) + } + result, err := c.client.CreateDeployment(ctx, payload) if err != nil { return nil, fmt.Errorf("failed to create deployment: %w", err) } @@ -104,14 +105,72 @@ func (c *DeploymentsClient) GetLatestDeployment( projectSlug string, ) (*deployments.GetLatestDeploymentResult, error) { key := apiKey.Reveal() - result, err := c.client.GetLatestDeployment(ctx, &deployments.GetLatestDeploymentPayload{ - ApikeyToken: &key, - ProjectSlugInput: &projectSlug, - SessionToken: nil, - }) + result, err := c.client.GetLatestDeployment( + ctx, + &deployments.GetLatestDeploymentPayload{ + ApikeyToken: &key, + ProjectSlugInput: &projectSlug, + SessionToken: nil, + }, + ) if err != nil { return nil, fmt.Errorf("failed to get latest deployment: %w", err) } return result, nil } + +// GetActiveDeployment retrieves the active deployment for a project. +func (c *DeploymentsClient) GetActiveDeployment( + ctx context.Context, + apiKey secret.Secret, + projectSlug string, +) (*deployments.GetActiveDeploymentResult, error) { + key := apiKey.Reveal() + result, err := c.client.GetActiveDeployment( + ctx, + &deployments.GetActiveDeploymentPayload{ + ApikeyToken: &key, + ProjectSlugInput: &projectSlug, + SessionToken: nil, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to get active deployment: %w", err) + } + + return result, nil +} + +// EvolveRequest lists the assets to add to a deployment. +type EvolveRequest struct { + APIKey secret.Secret + ProjectSlug string + DeploymentID string + Assets []*deployments.AddOpenAPIv3DeploymentAssetForm +} + +// Evolve adds assets to an existing deployment. +func (c *DeploymentsClient) Evolve( + ctx context.Context, + req EvolveRequest, +) (*deployments.EvolveResult, error) { + key := req.APIKey.Reveal() + result, err := c.client.Evolve(ctx, &deployments.EvolvePayload{ + ApikeyToken: &key, + ProjectSlugInput: &req.ProjectSlug, + DeploymentID: &req.DeploymentID, + UpsertOpenapiv3Assets: req.Assets, + UpsertFunctions: []*deployments.AddFunctionsForm{}, + ExcludeOpenapiv3Assets: []string{}, + ExcludeFunctions: []string{}, + ExcludePackages: []string{}, + UpsertPackages: []*deployments.AddPackageForm{}, + SessionToken: nil, + }) + if err != nil { + return nil, fmt.Errorf("failed to evolve deployment: %w", err) + } + + return result, nil +} diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index 10b7e8157..8a74144f3 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -23,6 +23,7 @@ func newApp() *cli.App { Version: fmt.Sprintf("%s (%s)", Version, shortSha), Commands: []*cli.Command{ newPushCommand(), + newUploadCommand(), newStatusCommand(), }, Flags: []cli.Flag{ @@ -48,7 +49,7 @@ func newApp() *cli.App { Before: func(c *cli.Context) error { logger := slog.New(o11y.NewLogHandler(&o11y.LogHandlerOptions{ RawLevel: c.String("log-level"), - Pretty: c.Bool("pretty"), + Pretty: c.Bool("log-pretty"), DataDogAttr: true, })) diff --git a/cli/internal/app/push.go b/cli/internal/app/push.go index 1d58f8a38..433f1d802 100644 --- a/cli/internal/app/push.go +++ b/cli/internal/app/push.go @@ -48,6 +48,7 @@ NOTE: Names and slugs must be unique across all sources.`[1:], Usage: "The base URL to use for API calls.", EnvVars: []string{"GRAM_API_URL"}, Value: "https://app.getgram.ai", + Hidden: true, }, &cli.StringFlag{ Name: "api-key", diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index ecf19272e..583a446b5 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -29,6 +29,7 @@ If no deployment ID is provided, shows the status of the latest deployment.`, Usage: "The base URL to use for API calls.", EnvVars: []string{"GRAM_API_URL"}, Value: "https://app.getgram.ai", + Hidden: true, }, &cli.StringFlag{ Name: "api-key", @@ -78,7 +79,7 @@ If no deployment ID is provided, shows the status of the latest deployment.`, var deployment *types.Deployment if deploymentID != "" { - logger.InfoContext(ctx, "Getting deployment status", slog.String("deployment_id", deploymentID)) + logger.DebugContext(ctx, "Getting deployment status", slog.String("deployment_id", deploymentID)) result, err := deploymentsClient.GetDeployment(ctx, apiKey, projectSlug, deploymentID) if err != nil { return fmt.Errorf("failed to get deployment: %w", err) diff --git a/cli/internal/app/upload.go b/cli/internal/app/upload.go new file mode 100644 index 000000000..583a443cb --- /dev/null +++ b/cli/internal/app/upload.go @@ -0,0 +1,185 @@ +package app + +import ( + "fmt" + "log/slog" + "net/url" + "os" + "os/signal" + "syscall" + + "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/secret" + "github.com/urfave/cli/v2" +) + +func newUploadCommand() *cli.Command { + return &cli.Command{ + Name: "upload", + Action: doUpload, + Usage: "Upload an asset to Gram", + Description: ` +Example: + gram upload --type openapiv3 \ + --location https://raw.githubusercontent.com/my/spec.yaml \ + --name "My API" \ + --slug my-api`[1:], + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "api-url", + Usage: "The base URL to use for API calls.", + EnvVars: []string{"GRAM_API_URL"}, + Value: "https://app.getgram.ai", + Hidden: true, + }, + &cli.StringFlag{ + Name: "api-key", + Usage: "Your Gram API key (must be scoped as a 'Provider')", + EnvVars: []string{"GRAM_API_KEY"}, + Required: true, + }, + &cli.StringFlag{ + Name: "project", + Usage: "The Gram project to upload to", + EnvVars: []string{"GRAM_PROJECT"}, + Required: true, + }, + &cli.StringFlag{ + Name: "type", + Usage: "The type of asset to upload", + Required: true, + }, + &cli.StringFlag{ + Name: "location", + Usage: "The location of the asset (file path or URL)", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Usage: "The human-readable name of the asset", + Required: true, + }, + &cli.StringFlag{ + Name: "slug", + Usage: "The URL-friendly slug for the asset", + Required: true, + }, + }, + } +} + +func doUpload(c *cli.Context) error { + ctx, cancel := signal.NotifyContext( + c.Context, + os.Interrupt, + syscall.SIGTERM, + ) + defer cancel() + + logger := PullLogger(ctx) + projectSlug := c.String("project") + + apiURLArg := c.String("api-url") + apiURL, err := url.Parse(apiURLArg) + if err != nil { + return fmt.Errorf("failed to parse API URL '%s': %w", apiURLArg, err) + } + if apiURL.Scheme == "" || apiURL.Host == "" { + return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) + } + + assetsClient := api.NewAssetsClient(&api.AssetsClientOptions{ + Scheme: apiURL.Scheme, + Host: apiURL.Host, + }) + deploymentsClient := api.NewDeploymentsClient( + &api.DeploymentsClientOptions{ + Scheme: apiURL.Scheme, + Host: apiURL.Host, + }, + ) + + logger.DebugContext( + ctx, + "Fetching active deployment", + slog.String("project", projectSlug), + ) + + apiKey := secret.Secret(c.String("api-key")) + active, err := deploymentsClient.GetActiveDeployment( + ctx, + apiKey, + projectSlug, + ) + if err != nil { + return fmt.Errorf("failed to get active deployment: %w", err) + } + + source := deploy.Source{ + Type: deploy.SourceType(c.String("type")), + Location: c.String("location"), + Name: c.String("name"), + Slug: c.String("slug"), + } + + if err := source.Validate(); err != nil { + return fmt.Errorf("invalid source: %w", err) + } + + if active.Deployment == nil { + createReq := deploy.CreateDeploymentRequest{ + APIKey: apiKey, + ProjectSlug: projectSlug, + Config: deploy.NewConfigFromSources(source), + IdempotencyKey: "", + } + _, err = deploy.CreateDeployment( + ctx, + logger, + assetsClient, + deploymentsClient, + createReq, + ) + if err != nil { + return fmt.Errorf("failed to create deployment: %w", err) + } + + return nil + } + + activeDeploymentID := active.Deployment.ID + logger.InfoContext( + ctx, + "Found active deployment", + slog.String("deployment_id", activeDeploymentID), + ) + addReq := deploy.AddAssetsRequest{ + APIKey: apiKey, + ProjectSlug: projectSlug, + DeploymentID: activeDeploymentID, + Sources: []deploy.Source{source}, + } + addResult, err := deploy.AddAssets( + ctx, + logger, + assetsClient, + deploymentsClient, + addReq, + ) + + if err != nil { + return fmt.Errorf("failed to evolve deployment: %w", err) + } + + if addResult.Deployment == nil { + return fmt.Errorf("deployment evolution returned no deployment") + } + + logger.InfoContext( + ctx, + "Uploaded successfully", + slog.String("deployment_id", addResult.Deployment.ID), + ) + return nil +} diff --git a/cli/internal/deploy/config.go b/cli/internal/deploy/config.go index 9ce8e68db..f35002fba 100644 --- a/cli/internal/deploy/config.go +++ b/cli/internal/deploy/config.go @@ -116,6 +116,14 @@ func NewConfig(cfgRdr io.Reader, filename string) (*Config, error) { return &cfg, nil } +func NewConfigFromSources(sources ...Source) *Config { + return &Config{ + SchemaVersion: validSchemaVersions[0], + Type: configTypeDeployment, + Sources: sources, + } +} + // Validate returns an error if the schema version is invalid, if the config // is missing sources, if sources have missing required fields, or if names/slugs are not unique. func (dc Config) Validate() error { diff --git a/cli/internal/deploy/read.go b/cli/internal/deploy/read.go index c5a4ed656..51f91641b 100644 --- a/cli/internal/deploy/read.go +++ b/cli/internal/deploy/read.go @@ -14,7 +14,7 @@ import ( // readLocal reads a source from a local file path. func (sr *SourceReader) readLocal() (io.ReadCloser, int64, error) { - f, err := os.Open(sr.source.Location) + f, err := os.Open(sr.Source.Location) if err != nil { return nil, 0, fmt.Errorf("failed to read local file: %w", err) } @@ -32,7 +32,7 @@ func (sr *SourceReader) readRemote(ctx context.Context) (io.ReadCloser, int64, e ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", sr.source.Location, nil) + req, err := http.NewRequestWithContext(ctx, "GET", sr.Source.Location, nil) if err != nil { return nil, 0, fmt.Errorf("failed to create request: %w", err) } diff --git a/cli/internal/deploy/source_reader.go b/cli/internal/deploy/source_reader.go index 13a82c13c..3440af5e4 100644 --- a/cli/internal/deploy/source_reader.go +++ b/cli/internal/deploy/source_reader.go @@ -16,31 +16,31 @@ const ( // SourceReader reads source content from local files or remote URLs. type SourceReader struct { - source Source + Source Source } // NewSourceReader creates a new SourceReader for the given source. func NewSourceReader(source Source) *SourceReader { return &SourceReader{ - source: source, + Source: source, } } // GetContentType returns the MIME type of the content based on file extension. func (sr *SourceReader) GetContentType() string { - if isRemoteURL(sr.source.Location) { + if isRemoteURL(sr.Source.Location) { // NOTE(cjea): For remote URLs, we'll need to determine content type // differently. For now, default to common OpenAPI types based on // extension. - return getContentTypeFromPath(sr.source.Location) + return getContentTypeFromPath(sr.Source.Location) } - return getContentTypeFromPath(sr.source.Location) + return getContentTypeFromPath(sr.Source.Location) } // Read returns a reader for the asset content and its size. func (sr *SourceReader) Read(ctx context.Context) (io.ReadCloser, int64, error) { - if isRemoteURL(sr.source.Location) { + if isRemoteURL(sr.Source.Location) { return sr.readRemote(ctx) } return sr.readLocal() diff --git a/cli/internal/deploy/source_reader_test.go b/cli/internal/deploy/source_reader_test.go index ad2662959..a50f7b982 100644 --- a/cli/internal/deploy/source_reader_test.go +++ b/cli/internal/deploy/source_reader_test.go @@ -37,7 +37,7 @@ info: reader := NewSourceReader(source) // Test type method - require.Equal(t, "openapiv3", string(reader.source.Type)) + require.Equal(t, "openapiv3", string(reader.Source.Type)) // Test content type method require.Equal(t, "application/yaml", reader.GetContentType()) diff --git a/cli/internal/deploy/upload.go b/cli/internal/deploy/upload.go index c64e12973..551240d6e 100644 --- a/cli/internal/deploy/upload.go +++ b/cli/internal/deploy/upload.go @@ -16,43 +16,43 @@ func isSupportedSourceType(source Source) bool { return source.Type == SourceTypeOpenAPIV3 } -type uploadRequest struct { - apiKey secret.Secret - projectSlug string - sourceReader *SourceReader +type UploadRequest struct { + APIKey secret.Secret + ProjectSlug string + SourceReader *SourceReader } -func (ur *uploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) { - reader, size, err := ur.sourceReader.Read(ctx) +func (ur *UploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) { + reader, size, err := ur.SourceReader.Read(ctx) if err != nil { return nil, 0, fmt.Errorf("failed to read source: %w", err) } return reader, size, nil } -func uploadFromSource( +func Upload( ctx context.Context, logger *slog.Logger, assetsClient *api.AssetsClient, - req *uploadRequest, + req *UploadRequest, ) (*deployments.AddOpenAPIv3DeploymentAssetForm, error) { - rc, length, err := req.sourceReader.Read(ctx) + rc, length, err := req.SourceReader.Read(ctx) if err != nil { return nil, fmt.Errorf("failed to read source: %w", err) } - source := req.sourceReader.source + source := req.SourceReader.Source uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, logger, &api.UploadOpenAPIv3Request{ - APIKey: req.apiKey.Reveal(), - ProjectSlug: req.projectSlug, + APIKey: req.APIKey.Reveal(), + ProjectSlug: req.ProjectSlug, Reader: rc, - ContentType: req.sourceReader.GetContentType(), + ContentType: req.SourceReader.GetContentType(), ContentLength: length, }) if err != nil { msg := "failed to upload asset in project '%s' for source %s: %w" - return nil, fmt.Errorf(msg, req.projectSlug, source.Location, err) + return nil, fmt.Errorf(msg, req.ProjectSlug, source.Location, err) } return &deployments.AddOpenAPIv3DeploymentAssetForm{ @@ -63,6 +63,52 @@ func uploadFromSource( } +// AddAssetsRequest lists the assets to add to a deployment. +type AddAssetsRequest struct { + APIKey secret.Secret + ProjectSlug string + DeploymentID string + Sources []Source +} + +// AddAssets uploads assets and adds them to an existing deployment. +func AddAssets( + ctx context.Context, + logger *slog.Logger, + assetsClient *api.AssetsClient, + deploymentsClient *api.DeploymentsClient, + req AddAssetsRequest, +) (*deployments.EvolveResult, error) { + newAssets := make( + []*deployments.AddOpenAPIv3DeploymentAssetForm, + len(req.Sources), + ) + for idx, source := range req.Sources { + asset, err := Upload(ctx, logger, assetsClient, &UploadRequest{ + APIKey: req.APIKey, + ProjectSlug: req.ProjectSlug, + SourceReader: NewSourceReader(source), + }) + if err != nil { + return nil, fmt.Errorf("failed to upload asset: %w", err) + } + + newAssets[idx] = asset + } + + result, err := deploymentsClient.Evolve(ctx, api.EvolveRequest{ + Assets: newAssets, + APIKey: req.APIKey, + ProjectSlug: req.ProjectSlug, + DeploymentID: req.DeploymentID, + }) + if err != nil { + return nil, fmt.Errorf("failed to evolve deployment: %w", err) + } + + return result, nil +} + // createAssetsForDeployment creates remote assets out of each incoming source. // The returned forms can be submitted to create a deployment. func createAssetsForDeployment( @@ -82,10 +128,10 @@ func createAssetsForDeployment( continue } - asset, err := uploadFromSource(ctx, logger, assetsClient, &uploadRequest{ - apiKey: req.APIKey, - projectSlug: project, - sourceReader: NewSourceReader(source), + asset, err := Upload(ctx, logger, assetsClient, &UploadRequest{ + APIKey: req.APIKey, + ProjectSlug: project, + SourceReader: NewSourceReader(source), }) if err != nil { return nil, err From e3750640ec61051eb1d42f4820ebc9be868785f1 Mon Sep 17 00:00:00 2001 From: cjea Date: Fri, 3 Oct 2025 15:21:53 -0700 Subject: [PATCH 02/11] docs(changeset): Enable asset upload to gram via `gram upload` --- .changeset/lovely-shrimps-show.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lovely-shrimps-show.md diff --git a/.changeset/lovely-shrimps-show.md b/.changeset/lovely-shrimps-show.md new file mode 100644 index 000000000..7b03e28aa --- /dev/null +++ b/.changeset/lovely-shrimps-show.md @@ -0,0 +1,5 @@ +--- +"@gram/cli": minor +--- + +Enable asset upload to gram via `gram upload` From 58a90e07021f11fc0fcca1e89d8bb90170d1aa56 Mon Sep 17 00:00:00 2001 From: cjea Date: Sat, 4 Oct 2025 01:41:43 -0700 Subject: [PATCH 03/11] move upload to separate package --- cli/internal/api/deployments.go | 4 +- cli/internal/app/app.go | 6 +- cli/internal/app/{ => logging}/logging.go | 2 +- cli/internal/app/push.go | 3 +- cli/internal/app/status.go | 3 +- cli/internal/app/upload.go | 185 ------------------ cli/internal/app/upload/upload.go | 103 ++++++++++ cli/internal/app/upload/workflow.go | 226 ++++++++++++++++++++++ cli/internal/deploy/upload.go | 46 ----- 9 files changed, 340 insertions(+), 238 deletions(-) rename cli/internal/app/{ => logging}/logging.go (96%) delete mode 100644 cli/internal/app/upload.go create mode 100644 cli/internal/app/upload/upload.go create mode 100644 cli/internal/app/upload/workflow.go diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index a013ec5dd..233815468 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -144,10 +144,10 @@ func (c *DeploymentsClient) GetActiveDeployment( // EvolveRequest lists the assets to add to a deployment. type EvolveRequest struct { + Assets []*deployments.AddOpenAPIv3DeploymentAssetForm APIKey secret.Secret - ProjectSlug string DeploymentID string - Assets []*deployments.AddOpenAPIv3DeploymentAssetForm + ProjectSlug string } // Evolve adds assets to an existing deployment. diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index 8a74144f3..383f7c279 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -8,6 +8,8 @@ import ( "github.com/urfave/cli/v2" + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/app/upload" "github.com/speakeasy-api/gram/cli/internal/o11y" ) @@ -23,7 +25,7 @@ func newApp() *cli.App { Version: fmt.Sprintf("%s (%s)", Version, shortSha), Commands: []*cli.Command{ newPushCommand(), - newUploadCommand(), + upload.NewCommand(), newStatusCommand(), }, Flags: []cli.Flag{ @@ -53,7 +55,7 @@ func newApp() *cli.App { DataDogAttr: true, })) - ctx := PushLogger(c.Context, logger) + ctx := logging.PushLogger(c.Context, logger) c.Context = ctx return nil }, diff --git a/cli/internal/app/logging.go b/cli/internal/app/logging/logging.go similarity index 96% rename from cli/internal/app/logging.go rename to cli/internal/app/logging/logging.go index d0b171a12..657ffdc36 100644 --- a/cli/internal/app/logging.go +++ b/cli/internal/app/logging/logging.go @@ -1,4 +1,4 @@ -package app +package logging import ( "context" diff --git a/cli/internal/app/push.go b/cli/internal/app/push.go index 433f1d802..9fcefc028 100644 --- a/cli/internal/app/push.go +++ b/cli/internal/app/push.go @@ -12,6 +12,7 @@ import ( "time" "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/app/logging" "github.com/speakeasy-api/gram/cli/internal/deploy" "github.com/speakeasy-api/gram/cli/internal/o11y" "github.com/speakeasy-api/gram/cli/internal/secret" @@ -82,7 +83,7 @@ NOTE: Names and slugs must be unique across all sources.`[1:], ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) defer cancel() - logger := PullLogger(ctx) + logger := logging.PullLogger(ctx) projectSlug := c.String("project") apiURLArg := c.String("api-url") diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index 583a446b5..ec5c2457b 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/app/logging" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/types" "github.com/urfave/cli/v2" @@ -56,7 +57,7 @@ If no deployment ID is provided, shows the status of the latest deployment.`, ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) defer cancel() - logger := PullLogger(ctx) + logger := logging.PullLogger(ctx) projectSlug := c.String("project") deploymentID := c.String("id") jsonOutput := c.Bool("json") diff --git a/cli/internal/app/upload.go b/cli/internal/app/upload.go deleted file mode 100644 index 583a443cb..000000000 --- a/cli/internal/app/upload.go +++ /dev/null @@ -1,185 +0,0 @@ -package app - -import ( - "fmt" - "log/slog" - "net/url" - "os" - "os/signal" - "syscall" - - "github.com/speakeasy-api/gram/cli/internal/api" - "github.com/speakeasy-api/gram/cli/internal/deploy" - "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/urfave/cli/v2" -) - -func newUploadCommand() *cli.Command { - return &cli.Command{ - Name: "upload", - Action: doUpload, - Usage: "Upload an asset to Gram", - Description: ` -Example: - gram upload --type openapiv3 \ - --location https://raw.githubusercontent.com/my/spec.yaml \ - --name "My API" \ - --slug my-api`[1:], - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "api-url", - Usage: "The base URL to use for API calls.", - EnvVars: []string{"GRAM_API_URL"}, - Value: "https://app.getgram.ai", - Hidden: true, - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "Your Gram API key (must be scoped as a 'Provider')", - EnvVars: []string{"GRAM_API_KEY"}, - Required: true, - }, - &cli.StringFlag{ - Name: "project", - Usage: "The Gram project to upload to", - EnvVars: []string{"GRAM_PROJECT"}, - Required: true, - }, - &cli.StringFlag{ - Name: "type", - Usage: "The type of asset to upload", - Required: true, - }, - &cli.StringFlag{ - Name: "location", - Usage: "The location of the asset (file path or URL)", - Required: true, - }, - &cli.StringFlag{ - Name: "name", - Usage: "The human-readable name of the asset", - Required: true, - }, - &cli.StringFlag{ - Name: "slug", - Usage: "The URL-friendly slug for the asset", - Required: true, - }, - }, - } -} - -func doUpload(c *cli.Context) error { - ctx, cancel := signal.NotifyContext( - c.Context, - os.Interrupt, - syscall.SIGTERM, - ) - defer cancel() - - logger := PullLogger(ctx) - projectSlug := c.String("project") - - apiURLArg := c.String("api-url") - apiURL, err := url.Parse(apiURLArg) - if err != nil { - return fmt.Errorf("failed to parse API URL '%s': %w", apiURLArg, err) - } - if apiURL.Scheme == "" || apiURL.Host == "" { - return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) - } - - assetsClient := api.NewAssetsClient(&api.AssetsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - deploymentsClient := api.NewDeploymentsClient( - &api.DeploymentsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }, - ) - - logger.DebugContext( - ctx, - "Fetching active deployment", - slog.String("project", projectSlug), - ) - - apiKey := secret.Secret(c.String("api-key")) - active, err := deploymentsClient.GetActiveDeployment( - ctx, - apiKey, - projectSlug, - ) - if err != nil { - return fmt.Errorf("failed to get active deployment: %w", err) - } - - source := deploy.Source{ - Type: deploy.SourceType(c.String("type")), - Location: c.String("location"), - Name: c.String("name"), - Slug: c.String("slug"), - } - - if err := source.Validate(); err != nil { - return fmt.Errorf("invalid source: %w", err) - } - - if active.Deployment == nil { - createReq := deploy.CreateDeploymentRequest{ - APIKey: apiKey, - ProjectSlug: projectSlug, - Config: deploy.NewConfigFromSources(source), - IdempotencyKey: "", - } - _, err = deploy.CreateDeployment( - ctx, - logger, - assetsClient, - deploymentsClient, - createReq, - ) - if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) - } - - return nil - } - - activeDeploymentID := active.Deployment.ID - logger.InfoContext( - ctx, - "Found active deployment", - slog.String("deployment_id", activeDeploymentID), - ) - addReq := deploy.AddAssetsRequest{ - APIKey: apiKey, - ProjectSlug: projectSlug, - DeploymentID: activeDeploymentID, - Sources: []deploy.Source{source}, - } - addResult, err := deploy.AddAssets( - ctx, - logger, - assetsClient, - deploymentsClient, - addReq, - ) - - if err != nil { - return fmt.Errorf("failed to evolve deployment: %w", err) - } - - if addResult.Deployment == nil { - return fmt.Errorf("deployment evolution returned no deployment") - } - - logger.InfoContext( - ctx, - "Uploaded successfully", - slog.String("deployment_id", addResult.Deployment.ID), - ) - return nil -} diff --git a/cli/internal/app/upload/upload.go b/cli/internal/app/upload/upload.go new file mode 100644 index 000000000..dfbe10972 --- /dev/null +++ b/cli/internal/app/upload/upload.go @@ -0,0 +1,103 @@ +package upload + +import ( + "fmt" + "net/url" + "os" + "os/signal" + "syscall" + + "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/secret" + "github.com/urfave/cli/v2" +) + +func NewCommand() *cli.Command { + return &cli.Command{ + Name: "upload", + Action: doUpload, + Usage: "Upload an asset to Gram", + Description: ` +Example: + gram upload --type openapiv3 \ + --location https://raw.githubusercontent.com/my/spec.yaml \ + --name "My API" \ + --slug my-api`[1:], + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "api-url", + Usage: "The base URL to use for API calls.", + EnvVars: []string{"GRAM_API_URL"}, + Value: "https://app.getgram.ai", + Hidden: true, + }, + &cli.StringFlag{ + Name: "api-key", + Usage: "Your Gram API key (must be scoped as a 'Provider')", + EnvVars: []string{"GRAM_API_KEY"}, + Required: true, + }, + &cli.StringFlag{ + Name: "project", + Usage: "The Gram project to upload to", + EnvVars: []string{"GRAM_PROJECT"}, + Required: true, + }, + &cli.StringFlag{ + Name: "type", + Usage: "The type of asset to upload", + Required: true, + }, + &cli.StringFlag{ + Name: "location", + Usage: "The location of the asset (file path or URL)", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Usage: "The human-readable name of the asset", + Required: true, + }, + &cli.StringFlag{ + Name: "slug", + Usage: "The URL-friendly slug for the asset", + Required: true, + }, + }, + } +} + +func doUpload(c *cli.Context) error { + ctx, cancel := signal.NotifyContext( + c.Context, + os.Interrupt, + syscall.SIGTERM, + ) + defer cancel() + + apiURLStr := c.String("api-url") + apiURL, err := url.Parse(apiURLStr) + if err != nil { + return fmt.Errorf("failed to parse API URL '%s': %w", apiURLStr, err) + } + if apiURL.Scheme == "" || apiURL.Host == "" { + return fmt.Errorf("API URL '%s' must include scheme and host", apiURLStr) + } + + params := workflowParams{ + apiKey: secret.Secret(c.String("api-key")), + apiURL: apiURL, + projectSlug: c.String("project"), + } + + state := newWorkflow(ctx, params). + UploadAssets(ctx, []deploy.Source{parseSource(c)}). + EvolveActiveDeployment(ctx). + OrCreateDeployment(ctx) + + if state.Err != nil { + return fmt.Errorf("failed to upload: %w", state.Err) + } + + return nil +} diff --git a/cli/internal/app/upload/workflow.go b/cli/internal/app/upload/workflow.go new file mode 100644 index 000000000..54b542e1d --- /dev/null +++ b/cli/internal/app/upload/workflow.go @@ -0,0 +1,226 @@ +package upload + +import ( + "context" + "fmt" + "log/slog" + "net/url" + + "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/secret" + "github.com/speakeasy-api/gram/server/gen/deployments" + "github.com/speakeasy-api/gram/server/gen/types" + "github.com/urfave/cli/v2" +) + +type workflowParams struct { + apiKey secret.Secret + apiURL *url.URL + projectSlug string +} + +func (p workflowParams) Validate() error { + if p.projectSlug == "" { + return fmt.Errorf("project slug is required") + } + if p.apiKey.Reveal() == "" { + return fmt.Errorf("API key is required") + } + if p.apiURL == nil { + return fmt.Errorf("API URL is required") + } + return nil +} + +type workflowState struct { + logger *slog.Logger + params workflowParams + assetsClient *api.AssetsClient + deploymentsClient *api.DeploymentsClient + uploadedAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + deployment *types.Deployment + Done bool + Err error +} + +func (s *workflowState) Fail(err error) *workflowState { + s.Err = err + return s +} + +func (s *workflowState) shouldHalt() bool { + return s.Done || s.Err != nil +} + +func newWorkflow( + ctx context.Context, + params workflowParams, +) *workflowState { + state := &workflowState{ + logger: logging.PullLogger(ctx), + params: params, + assetsClient: nil, + deploymentsClient: nil, + uploadedAssets: nil, + deployment: nil, + Done: false, + Err: nil, + } + + if err := params.Validate(); err != nil { + return state.Fail(err) + } + + state.assetsClient = api.NewAssetsClient(&api.AssetsClientOptions{ + Scheme: params.apiURL.Scheme, + Host: params.apiURL.Host, + }) + state.deploymentsClient = api.NewDeploymentsClient( + &api.DeploymentsClientOptions{ + Scheme: params.apiURL.Scheme, + Host: params.apiURL.Host, + }, + ) + + return state +} + +func (s *workflowState) UploadAssets( + ctx context.Context, + sources []deploy.Source, +) *workflowState { + if s.shouldHalt() { + return s + } + + newAssets := make( + []*deployments.AddOpenAPIv3DeploymentAssetForm, + len(sources), + ) + + for idx, source := range sources { + if err := source.Validate(); err != nil { + return s.Fail(fmt.Errorf("invalid source: %w", err)) + } + + asset, err := deploy.Upload( + ctx, + s.logger, + s.assetsClient, + &deploy.UploadRequest{ + APIKey: s.params.apiKey, + ProjectSlug: s.params.projectSlug, + SourceReader: deploy.NewSourceReader(source), + }, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to upload asset: %w", err)) + } + + newAssets[idx] = asset + } + + s.uploadedAssets = newAssets + return s +} + +func (s *workflowState) EvolveActiveDeployment( + ctx context.Context, +) *workflowState { + if s.shouldHalt() { + return s + } + + s.logger.DebugContext( + ctx, + "Fetching active deployment", + slog.String("project", s.params.projectSlug), + ) + + active, err := s.deploymentsClient.GetActiveDeployment( + ctx, + s.params.apiKey, + s.params.projectSlug, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to get active deployment: %w", err)) + } + + if active.Deployment == nil { + return s + } + + depID := active.Deployment.ID + s.logger.InfoContext( + ctx, + "Found active deployment", + slog.String("deployment_id", depID), + ) + + result, err := s.deploymentsClient.Evolve(ctx, api.EvolveRequest{ + Assets: s.uploadedAssets, + APIKey: s.params.apiKey, + DeploymentID: depID, + ProjectSlug: s.params.projectSlug, + }) + if err != nil { + return s.Fail(fmt.Errorf("failed to evolve deployment: %w", err)) + } + + s.logger.InfoContext( + ctx, + "Updated successfully", + slog.String("deployment_id", result.Deployment.ID), + ) + + s.deployment = result.Deployment + s.Done = true + + return s +} + +func (s *workflowState) OrCreateDeployment(ctx context.Context) *workflowState { + if s.shouldHalt() { + return s + } + + if s.deployment != nil { + return s + } + + result, err := s.deploymentsClient.CreateDeployment( + ctx, + api.CreateDeploymentRequest{ + OpenAPIv3Assets: s.uploadedAssets, + + APIKey: s.params.apiKey, + ProjectSlug: s.params.projectSlug, + IdempotencyKey: "", + }, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to create deployment: %w", err)) + } + + s.logger.InfoContext( + ctx, + "Created successfully", + slog.String("deployment_id", result.Deployment.ID), + ) + + s.deployment = result.Deployment + s.Done = true + + return s +} + +func parseSource(c *cli.Context) deploy.Source { + return deploy.Source{ + Type: deploy.SourceType(c.String("type")), + Location: c.String("location"), + Name: c.String("name"), + Slug: c.String("slug"), + } +} diff --git a/cli/internal/deploy/upload.go b/cli/internal/deploy/upload.go index 551240d6e..a8717281d 100644 --- a/cli/internal/deploy/upload.go +++ b/cli/internal/deploy/upload.go @@ -63,52 +63,6 @@ func Upload( } -// AddAssetsRequest lists the assets to add to a deployment. -type AddAssetsRequest struct { - APIKey secret.Secret - ProjectSlug string - DeploymentID string - Sources []Source -} - -// AddAssets uploads assets and adds them to an existing deployment. -func AddAssets( - ctx context.Context, - logger *slog.Logger, - assetsClient *api.AssetsClient, - deploymentsClient *api.DeploymentsClient, - req AddAssetsRequest, -) (*deployments.EvolveResult, error) { - newAssets := make( - []*deployments.AddOpenAPIv3DeploymentAssetForm, - len(req.Sources), - ) - for idx, source := range req.Sources { - asset, err := Upload(ctx, logger, assetsClient, &UploadRequest{ - APIKey: req.APIKey, - ProjectSlug: req.ProjectSlug, - SourceReader: NewSourceReader(source), - }) - if err != nil { - return nil, fmt.Errorf("failed to upload asset: %w", err) - } - - newAssets[idx] = asset - } - - result, err := deploymentsClient.Evolve(ctx, api.EvolveRequest{ - Assets: newAssets, - APIKey: req.APIKey, - ProjectSlug: req.ProjectSlug, - DeploymentID: req.DeploymentID, - }) - if err != nil { - return nil, fmt.Errorf("failed to evolve deployment: %w", err) - } - - return result, nil -} - // createAssetsForDeployment creates remote assets out of each incoming source. // The returned forms can be submitted to create a deployment. func createAssetsForDeployment( From 943efcf610ec29807f9e617c66633eaf7eb2757e Mon Sep 17 00:00:00 2001 From: cjea Date: Sat, 4 Oct 2025 09:50:14 -0700 Subject: [PATCH 04/11] consolidate workflow --- cli/internal/api/assets.go | 2 - cli/internal/api/deployments.go | 32 +++- cli/internal/app/push.go | 135 +++----------- cli/internal/app/status.go | 25 +-- cli/internal/app/upload/upload.go | 71 ++++---- cli/internal/app/upload/workflow.go | 226 ----------------------- cli/internal/deploy/deploy.go | 49 ----- cli/internal/deploy/upload.go | 45 +---- cli/internal/deploy/workflow.go | 273 ++++++++++++++++++++++++++++ 9 files changed, 372 insertions(+), 486 deletions(-) delete mode 100644 cli/internal/app/upload/workflow.go delete mode 100644 cli/internal/deploy/deploy.go create mode 100644 cli/internal/deploy/workflow.go diff --git a/cli/internal/api/assets.go b/cli/internal/api/assets.go index 64b966c1f..2e525542f 100644 --- a/cli/internal/api/assets.go +++ b/cli/internal/api/assets.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log/slog" "github.com/speakeasy-api/gram/server/gen/assets" assets_client "github.com/speakeasy-api/gram/server/gen/http/assets/client" @@ -51,7 +50,6 @@ type UploadOpenAPIv3Request struct { func (c *AssetsClient) UploadOpenAPIv3( ctx context.Context, - logger *slog.Logger, req *UploadOpenAPIv3Request, ) (*assets.UploadOpenAPIv3Result, error) { payload := &assets.UploadOpenAPIv3Form{ diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 233815468..a0579cd46 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -7,6 +7,7 @@ import ( "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/deployments" depl_client "github.com/speakeasy-api/gram/server/gen/http/deployments/client" + "github.com/speakeasy-api/gram/server/gen/types" goahttp "goa.design/goa/v3/http" ) @@ -83,7 +84,7 @@ func (c *DeploymentsClient) GetDeployment( apiKey secret.Secret, projectSlug string, deploymentID string, -) (*deployments.GetDeploymentResult, error) { +) (*types.Deployment, error) { key := apiKey.Reveal() result, err := c.client.GetDeployment(ctx, &deployments.GetDeploymentPayload{ ApikeyToken: &key, @@ -95,7 +96,26 @@ func (c *DeploymentsClient) GetDeployment( return nil, fmt.Errorf("failed to get deployment: %w", err) } - return result, nil + return &types.Deployment{ + ID: result.ID, + OrganizationID: result.OrganizationID, + ProjectID: result.ProjectID, + UserID: result.UserID, + CreatedAt: result.CreatedAt, + Status: result.Status, + IdempotencyKey: result.IdempotencyKey, + GithubRepo: result.GithubRepo, + GithubPr: result.GithubPr, + GithubSha: result.GithubSha, + ExternalID: result.ExternalID, + ExternalURL: result.ExternalURL, + ClonedFrom: result.ClonedFrom, + Openapiv3ToolCount: result.Openapiv3ToolCount, + Openapiv3Assets: result.Openapiv3Assets, + FunctionsToolCount: result.FunctionsToolCount, + FunctionsAssets: result.FunctionsAssets, + Packages: result.Packages, + }, nil } // GetLatestDeployment retrieves the latest deployment for a project. @@ -103,7 +123,7 @@ func (c *DeploymentsClient) GetLatestDeployment( ctx context.Context, apiKey secret.Secret, projectSlug string, -) (*deployments.GetLatestDeploymentResult, error) { +) (*types.Deployment, error) { key := apiKey.Reveal() result, err := c.client.GetLatestDeployment( ctx, @@ -117,7 +137,7 @@ func (c *DeploymentsClient) GetLatestDeployment( return nil, fmt.Errorf("failed to get latest deployment: %w", err) } - return result, nil + return result.Deployment, nil } // GetActiveDeployment retrieves the active deployment for a project. @@ -125,7 +145,7 @@ func (c *DeploymentsClient) GetActiveDeployment( ctx context.Context, apiKey secret.Secret, projectSlug string, -) (*deployments.GetActiveDeploymentResult, error) { +) (*types.Deployment, error) { key := apiKey.Reveal() result, err := c.client.GetActiveDeployment( ctx, @@ -139,7 +159,7 @@ func (c *DeploymentsClient) GetActiveDeployment( return nil, fmt.Errorf("failed to get active deployment: %w", err) } - return result, nil + return result.Deployment, nil } // EvolveRequest lists the assets to add to a deployment. diff --git a/cli/internal/app/push.go b/cli/internal/app/push.go index 9fcefc028..f7aae09e5 100644 --- a/cli/internal/app/push.go +++ b/cli/internal/app/push.go @@ -1,7 +1,6 @@ package app import ( - "context" "fmt" "log/slog" "net/url" @@ -9,14 +8,11 @@ import ( "os/signal" "path/filepath" "syscall" - "time" - "github.com/speakeasy-api/gram/cli/internal/api" "github.com/speakeasy-api/gram/cli/internal/app/logging" "github.com/speakeasy-api/gram/cli/internal/deploy" "github.com/speakeasy-api/gram/cli/internal/o11y" "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/types" "github.com/urfave/cli/v2" ) @@ -95,15 +91,6 @@ NOTE: Names and slugs must be unique across all sources.`[1:], return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) } - assetsClient := api.NewAssetsClient(&api.AssetsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - deploymentsClient := api.NewDeploymentsClient(&api.DeploymentsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - configFilename, err := filepath.Abs(c.String("config")) if err != nil { return fmt.Errorf("failed to resolve deployment file path: %w", err) @@ -122,34 +109,42 @@ NOTE: Names and slugs must be unique across all sources.`[1:], return fmt.Errorf("failed to parseread deployment config: %w", err) } - logger.InfoContext(ctx, "Deploying to project", slog.String("project", projectSlug), slog.String("config", c.String("config"))) - - req := deploy.CreateDeploymentRequest{ - Config: config, - APIKey: secret.Secret(c.String("api-key")), - ProjectSlug: projectSlug, - IdempotencyKey: c.String("idempotency-key"), - } - result, err := deploy.CreateDeployment(ctx, logger, assetsClient, deploymentsClient, req) - if err != nil { - return fmt.Errorf("deployment failed: %w", err) + logger.InfoContext( + ctx, + "Deploying to project", + slog.String("project", projectSlug), + slog.String("config", c.String("config")), + ) + + params := deploy.WorkflowParams{ + APIKey: secret.Secret(c.String("api-key")), + APIURL: apiURL, + ProjectSlug: projectSlug, } + result := deploy.NewWorkflow(ctx, params). + UploadAssets(ctx, config.Sources). + CreateDeployment(ctx, c.String("idempotency-key")) - logger.InfoContext(ctx, "Deployment has begun processing", slog.Any("id", result.Deployment.ID)) - - if c.Bool("skip-poll") { - logger.InfoContext(ctx, "Skipping deployment status polling", slog.String("deployment_id", result.Deployment.ID)) - logger.InfoContext(ctx, "You can check deployment status with", slog.String("command", fmt.Sprintf("gram status --id %s", result.Deployment.ID))) - return nil + if !c.Bool("skip-poll") { + result.Poll(ctx) } - deploymentResult, err := pollDeploymentStatus(ctx, logger, deploymentsClient, req.APIKey, req.ProjectSlug, result.Deployment.ID) - if err != nil { - logger.WarnContext(ctx, "Failed to poll deployment status", slog.String("error", err.Error())) - logger.InfoContext(ctx, "You can check deployment status with", slog.String("command", fmt.Sprintf("gram status %s", result.Deployment.ID))) - return nil + if result.Err != nil { + if result.Deployment != nil { + helpCmd := slog.String("command", + fmt.Sprintf("gram status %s", result.Deployment.ID), + ) + result.Logger.InfoContext( + ctx, + "You can check deployment status with", + helpCmd, + ) + } + + return fmt.Errorf("failed to push deploy: %w", result.Err) } + deploymentResult := result.Deployment switch deploymentResult.Status { case "completed": logger.InfoContext(ctx, "Deployment completed successfully", slog.String("deployment_id", deploymentResult.ID)) @@ -164,73 +159,3 @@ NOTE: Names and slugs must be unique across all sources.`[1:], }, } } - -// pollDeploymentStatus polls for deployment status until it reaches a terminal -// state or times out -func pollDeploymentStatus( - ctx context.Context, - logger *slog.Logger, - client *api.DeploymentsClient, - apiKey secret.Secret, - projectSlug string, - deploymentID string, -) (*types.Deployment, error) { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - logger.InfoContext(ctx, "Polling deployment status...", slog.String("deployment_id", deploymentID)) - - for { - select { - case <-ctx.Done(): - if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("deployment polling timed out after 2 minutes") - } - return nil, fmt.Errorf("deployment polling cancelled: %w", ctx.Err()) - - case <-ticker.C: - result, err := client.GetDeployment(ctx, apiKey, projectSlug, deploymentID) - if err != nil { - return nil, fmt.Errorf("failed to get deployment status: %w", err) - } - - deployment := &types.Deployment{ - ID: result.ID, - OrganizationID: result.OrganizationID, - ProjectID: result.ProjectID, - UserID: result.UserID, - CreatedAt: result.CreatedAt, - Status: result.Status, - IdempotencyKey: result.IdempotencyKey, - GithubRepo: result.GithubRepo, - GithubPr: result.GithubPr, - GithubSha: result.GithubSha, - ExternalID: result.ExternalID, - ExternalURL: result.ExternalURL, - ClonedFrom: result.ClonedFrom, - Openapiv3ToolCount: result.Openapiv3ToolCount, - Openapiv3Assets: result.Openapiv3Assets, - FunctionsToolCount: result.FunctionsToolCount, - FunctionsAssets: result.FunctionsAssets, - Packages: result.Packages, - } - - logger.DebugContext(ctx, "Deployment status check", - slog.String("deployment_id", deploymentID), - slog.String("status", deployment.Status)) - - switch deployment.Status { - case "completed", "failed": - return deployment, nil - case "pending": - continue - default: - logger.WarnContext(ctx, "Unknown deployment status", slog.String("status", deployment.Status)) - continue - } - } - } -} diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index ec5c2457b..2c6f3fa91 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -85,33 +85,14 @@ If no deployment ID is provided, shows the status of the latest deployment.`, if err != nil { return fmt.Errorf("failed to get deployment: %w", err) } - deployment = &types.Deployment{ - ID: result.ID, - OrganizationID: result.OrganizationID, - ProjectID: result.ProjectID, - UserID: result.UserID, - CreatedAt: result.CreatedAt, - Status: result.Status, - IdempotencyKey: result.IdempotencyKey, - GithubRepo: result.GithubRepo, - GithubPr: result.GithubPr, - GithubSha: result.GithubSha, - ExternalID: result.ExternalID, - ExternalURL: result.ExternalURL, - ClonedFrom: result.ClonedFrom, - Openapiv3ToolCount: result.Openapiv3ToolCount, - Openapiv3Assets: result.Openapiv3Assets, - FunctionsToolCount: result.FunctionsToolCount, - FunctionsAssets: result.FunctionsAssets, - Packages: result.Packages, - } + deployment = result } else { logger.InfoContext(ctx, "Getting latest deployment status") result, err := deploymentsClient.GetLatestDeployment(ctx, apiKey, projectSlug) if err != nil { return fmt.Errorf("failed to get latest deployment: %w", err) } - if result.Deployment == nil { + if result == nil { if jsonOutput { fmt.Println("{}") } else { @@ -119,7 +100,7 @@ If no deployment ID is provided, shows the status of the latest deployment.`, } return nil } - deployment = result.Deployment + deployment = result } if jsonOutput { diff --git a/cli/internal/app/upload/upload.go b/cli/internal/app/upload/upload.go index dfbe10972..34c2ad0ce 100644 --- a/cli/internal/app/upload/upload.go +++ b/cli/internal/app/upload/upload.go @@ -14,9 +14,8 @@ import ( func NewCommand() *cli.Command { return &cli.Command{ - Name: "upload", - Action: doUpload, - Usage: "Upload an asset to Gram", + Name: "upload", + Usage: "Upload an asset to Gram", Description: ` Example: gram upload --type openapiv3 \ @@ -64,40 +63,48 @@ Example: Required: true, }, }, - } -} + Action: func(c *cli.Context) error { + ctx, cancel := signal.NotifyContext( + c.Context, + os.Interrupt, + syscall.SIGTERM, + ) + defer cancel() -func doUpload(c *cli.Context) error { - ctx, cancel := signal.NotifyContext( - c.Context, - os.Interrupt, - syscall.SIGTERM, - ) - defer cancel() + apiURLStr := c.String("api-url") + apiURL, err := url.Parse(apiURLStr) + if err != nil || apiURL.Scheme == "" || apiURL.Host == "" { + return fmt.Errorf( + "API URL must include scheme and host: '%s'", + apiURLStr, + ) + } - apiURLStr := c.String("api-url") - apiURL, err := url.Parse(apiURLStr) - if err != nil { - return fmt.Errorf("failed to parse API URL '%s': %w", apiURLStr, err) - } - if apiURL.Scheme == "" || apiURL.Host == "" { - return fmt.Errorf("API URL '%s' must include scheme and host", apiURLStr) - } + params := deploy.WorkflowParams{ + APIKey: secret.Secret(c.String("api-key")), + APIURL: apiURL, + ProjectSlug: c.String("project"), + } - params := workflowParams{ - apiKey: secret.Secret(c.String("api-key")), - apiURL: apiURL, - projectSlug: c.String("project"), - } + result := deploy.NewWorkflow(ctx, params). + UploadAssets(ctx, []deploy.Source{parseSource(c)}). + EvolveActiveDeployment(ctx). + OrCreateDeployment(ctx) - state := newWorkflow(ctx, params). - UploadAssets(ctx, []deploy.Source{parseSource(c)}). - EvolveActiveDeployment(ctx). - OrCreateDeployment(ctx) + if result.Err != nil { + return fmt.Errorf("failed to upload: %w", result.Err) + } - if state.Err != nil { - return fmt.Errorf("failed to upload: %w", state.Err) + return nil + }, } +} - return nil +func parseSource(c *cli.Context) deploy.Source { + return deploy.Source{ + Type: deploy.SourceType(c.String("type")), + Location: c.String("location"), + Name: c.String("name"), + Slug: c.String("slug"), + } } diff --git a/cli/internal/app/upload/workflow.go b/cli/internal/app/upload/workflow.go deleted file mode 100644 index 54b542e1d..000000000 --- a/cli/internal/app/upload/workflow.go +++ /dev/null @@ -1,226 +0,0 @@ -package upload - -import ( - "context" - "fmt" - "log/slog" - "net/url" - - "github.com/speakeasy-api/gram/cli/internal/api" - "github.com/speakeasy-api/gram/cli/internal/app/logging" - "github.com/speakeasy-api/gram/cli/internal/deploy" - "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/deployments" - "github.com/speakeasy-api/gram/server/gen/types" - "github.com/urfave/cli/v2" -) - -type workflowParams struct { - apiKey secret.Secret - apiURL *url.URL - projectSlug string -} - -func (p workflowParams) Validate() error { - if p.projectSlug == "" { - return fmt.Errorf("project slug is required") - } - if p.apiKey.Reveal() == "" { - return fmt.Errorf("API key is required") - } - if p.apiURL == nil { - return fmt.Errorf("API URL is required") - } - return nil -} - -type workflowState struct { - logger *slog.Logger - params workflowParams - assetsClient *api.AssetsClient - deploymentsClient *api.DeploymentsClient - uploadedAssets []*deployments.AddOpenAPIv3DeploymentAssetForm - deployment *types.Deployment - Done bool - Err error -} - -func (s *workflowState) Fail(err error) *workflowState { - s.Err = err - return s -} - -func (s *workflowState) shouldHalt() bool { - return s.Done || s.Err != nil -} - -func newWorkflow( - ctx context.Context, - params workflowParams, -) *workflowState { - state := &workflowState{ - logger: logging.PullLogger(ctx), - params: params, - assetsClient: nil, - deploymentsClient: nil, - uploadedAssets: nil, - deployment: nil, - Done: false, - Err: nil, - } - - if err := params.Validate(); err != nil { - return state.Fail(err) - } - - state.assetsClient = api.NewAssetsClient(&api.AssetsClientOptions{ - Scheme: params.apiURL.Scheme, - Host: params.apiURL.Host, - }) - state.deploymentsClient = api.NewDeploymentsClient( - &api.DeploymentsClientOptions{ - Scheme: params.apiURL.Scheme, - Host: params.apiURL.Host, - }, - ) - - return state -} - -func (s *workflowState) UploadAssets( - ctx context.Context, - sources []deploy.Source, -) *workflowState { - if s.shouldHalt() { - return s - } - - newAssets := make( - []*deployments.AddOpenAPIv3DeploymentAssetForm, - len(sources), - ) - - for idx, source := range sources { - if err := source.Validate(); err != nil { - return s.Fail(fmt.Errorf("invalid source: %w", err)) - } - - asset, err := deploy.Upload( - ctx, - s.logger, - s.assetsClient, - &deploy.UploadRequest{ - APIKey: s.params.apiKey, - ProjectSlug: s.params.projectSlug, - SourceReader: deploy.NewSourceReader(source), - }, - ) - if err != nil { - return s.Fail(fmt.Errorf("failed to upload asset: %w", err)) - } - - newAssets[idx] = asset - } - - s.uploadedAssets = newAssets - return s -} - -func (s *workflowState) EvolveActiveDeployment( - ctx context.Context, -) *workflowState { - if s.shouldHalt() { - return s - } - - s.logger.DebugContext( - ctx, - "Fetching active deployment", - slog.String("project", s.params.projectSlug), - ) - - active, err := s.deploymentsClient.GetActiveDeployment( - ctx, - s.params.apiKey, - s.params.projectSlug, - ) - if err != nil { - return s.Fail(fmt.Errorf("failed to get active deployment: %w", err)) - } - - if active.Deployment == nil { - return s - } - - depID := active.Deployment.ID - s.logger.InfoContext( - ctx, - "Found active deployment", - slog.String("deployment_id", depID), - ) - - result, err := s.deploymentsClient.Evolve(ctx, api.EvolveRequest{ - Assets: s.uploadedAssets, - APIKey: s.params.apiKey, - DeploymentID: depID, - ProjectSlug: s.params.projectSlug, - }) - if err != nil { - return s.Fail(fmt.Errorf("failed to evolve deployment: %w", err)) - } - - s.logger.InfoContext( - ctx, - "Updated successfully", - slog.String("deployment_id", result.Deployment.ID), - ) - - s.deployment = result.Deployment - s.Done = true - - return s -} - -func (s *workflowState) OrCreateDeployment(ctx context.Context) *workflowState { - if s.shouldHalt() { - return s - } - - if s.deployment != nil { - return s - } - - result, err := s.deploymentsClient.CreateDeployment( - ctx, - api.CreateDeploymentRequest{ - OpenAPIv3Assets: s.uploadedAssets, - - APIKey: s.params.apiKey, - ProjectSlug: s.params.projectSlug, - IdempotencyKey: "", - }, - ) - if err != nil { - return s.Fail(fmt.Errorf("failed to create deployment: %w", err)) - } - - s.logger.InfoContext( - ctx, - "Created successfully", - slog.String("deployment_id", result.Deployment.ID), - ) - - s.deployment = result.Deployment - s.Done = true - - return s -} - -func parseSource(c *cli.Context) deploy.Source { - return deploy.Source{ - Type: deploy.SourceType(c.String("type")), - Location: c.String("location"), - Name: c.String("name"), - Slug: c.String("slug"), - } -} diff --git a/cli/internal/deploy/deploy.go b/cli/internal/deploy/deploy.go deleted file mode 100644 index fd8b2b2ed..000000000 --- a/cli/internal/deploy/deploy.go +++ /dev/null @@ -1,49 +0,0 @@ -package deploy - -import ( - "context" - "fmt" - "log/slog" - - "github.com/speakeasy-api/gram/cli/internal/api" - "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/deployments" -) - -type CreateDeploymentRequest struct { - Config *Config - APIKey secret.Secret - ProjectSlug string - IdempotencyKey string -} - -// CreateDeployment creates a remote deployment from the incoming sources. -func CreateDeployment( - ctx context.Context, - logger *slog.Logger, - assetsClient *api.AssetsClient, - deploymentsClient *api.DeploymentsClient, - req CreateDeploymentRequest, -) (*deployments.CreateDeploymentResult, error) { - assets, err := createAssetsForDeployment(ctx, logger, assetsClient, &CreateDeploymentRequest{ - Config: req.Config, - APIKey: req.APIKey, - ProjectSlug: req.ProjectSlug, - IdempotencyKey: req.IdempotencyKey, - }) - if err != nil { - return nil, fmt.Errorf("failed to convert sources to assets: %w", err) - } - - result, err := deploymentsClient.CreateDeployment(ctx, api.CreateDeploymentRequest{ - APIKey: req.APIKey, - ProjectSlug: req.ProjectSlug, - IdempotencyKey: req.IdempotencyKey, - OpenAPIv3Assets: assets, - }) - if err != nil { - return nil, fmt.Errorf("deployment creation failed: %w", err) - } - - return result, nil -} diff --git a/cli/internal/deploy/upload.go b/cli/internal/deploy/upload.go index a8717281d..a869d78b6 100644 --- a/cli/internal/deploy/upload.go +++ b/cli/internal/deploy/upload.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log/slog" "github.com/speakeasy-api/gram/cli/internal/api" "github.com/speakeasy-api/gram/cli/internal/secret" @@ -12,10 +11,6 @@ import ( "github.com/speakeasy-api/gram/server/gen/types" ) -func isSupportedSourceType(source Source) bool { - return source.Type == SourceTypeOpenAPIV3 -} - type UploadRequest struct { APIKey secret.Secret ProjectSlug string @@ -32,7 +27,6 @@ func (ur *UploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) func Upload( ctx context.Context, - logger *slog.Logger, assetsClient *api.AssetsClient, req *UploadRequest, ) (*deployments.AddOpenAPIv3DeploymentAssetForm, error) { @@ -43,7 +37,7 @@ func Upload( source := req.SourceReader.Source - uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, logger, &api.UploadOpenAPIv3Request{ + uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, &api.UploadOpenAPIv3Request{ APIKey: req.APIKey.Reveal(), ProjectSlug: req.ProjectSlug, Reader: rc, @@ -62,40 +56,3 @@ func Upload( }, nil } - -// createAssetsForDeployment creates remote assets out of each incoming source. -// The returned forms can be submitted to create a deployment. -func createAssetsForDeployment( - ctx context.Context, - logger *slog.Logger, - assetsClient *api.AssetsClient, - req *CreateDeploymentRequest, -) ([]*deployments.AddOpenAPIv3DeploymentAssetForm, error) { - sources := req.Config.Sources - project := req.ProjectSlug - assets := make([]*deployments.AddOpenAPIv3DeploymentAssetForm, 0, len(sources)) - - for _, source := range sources { - if !isSupportedSourceType(source) { - msg := "skipping unsupported source type" - logger.WarnContext(ctx, msg, slog.String("type", string(source.Type)), slog.String("location", source.Location)) - continue - } - - asset, err := Upload(ctx, logger, assetsClient, &UploadRequest{ - APIKey: req.APIKey, - ProjectSlug: project, - SourceReader: NewSourceReader(source), - }) - if err != nil { - return nil, err - } - assets = append(assets, asset) - } - - if len(assets) == 0 { - return nil, fmt.Errorf("no valid sources found") - } - - return assets, nil -} diff --git a/cli/internal/deploy/workflow.go b/cli/internal/deploy/workflow.go new file mode 100644 index 000000000..d382b1b7d --- /dev/null +++ b/cli/internal/deploy/workflow.go @@ -0,0 +1,273 @@ +package deploy + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "time" + + "github.com/speakeasy-api/gram/cli/internal/api" + "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/secret" + "github.com/speakeasy-api/gram/server/gen/deployments" + "github.com/speakeasy-api/gram/server/gen/types" +) + +type WorkflowParams struct { + APIKey secret.Secret + APIURL *url.URL + ProjectSlug string +} + +func (p WorkflowParams) Validate() error { + if p.ProjectSlug == "" { + return fmt.Errorf("project slug is required") + } + if p.APIKey.Reveal() == "" { + return fmt.Errorf("API key is required") + } + if p.APIURL == nil { + return fmt.Errorf("API URL is required") + } + return nil +} + +type WorkflowState struct { + Logger *slog.Logger + Params WorkflowParams + AssetsClient *api.AssetsClient + DeploymentsClient *api.DeploymentsClient + NewAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + Deployment *types.Deployment + Err error +} + +func (s *WorkflowState) Fail(err error) *WorkflowState { + s.Err = err + return s +} + +func (s *WorkflowState) shouldHalt() bool { + return s.Err != nil +} + +func NewWorkflow( + ctx context.Context, + params WorkflowParams, +) *WorkflowState { + state := &WorkflowState{ + Logger: logging.PullLogger(ctx), + Params: params, + AssetsClient: nil, + DeploymentsClient: nil, + NewAssets: nil, + Deployment: nil, + Err: nil, + } + + if err := params.Validate(); err != nil { + return state.Fail(err) + } + + state.AssetsClient = api.NewAssetsClient(&api.AssetsClientOptions{ + Scheme: params.APIURL.Scheme, + Host: params.APIURL.Host, + }) + state.DeploymentsClient = api.NewDeploymentsClient( + &api.DeploymentsClientOptions{ + Scheme: params.APIURL.Scheme, + Host: params.APIURL.Host, + }, + ) + + return state +} + +func (s *WorkflowState) UploadAssets( + ctx context.Context, + sources []Source, +) *WorkflowState { + if s.shouldHalt() { + return s + } + + newAssets := make( + []*deployments.AddOpenAPIv3DeploymentAssetForm, + len(sources), + ) + + for idx, source := range sources { + if err := source.Validate(); err != nil { + return s.Fail(fmt.Errorf("invalid source: %w", err)) + } + + upReq := &UploadRequest{ + APIKey: s.Params.APIKey, + ProjectSlug: s.Params.ProjectSlug, + SourceReader: NewSourceReader(source), + } + asset, err := Upload(ctx, s.AssetsClient, upReq) + if err != nil { + return s.Fail(fmt.Errorf("failed to upload asset: %w", err)) + } + + newAssets[idx] = asset + } + + s.NewAssets = newAssets + return s +} + +func (s *WorkflowState) EvolveActiveDeployment( + ctx context.Context, +) *WorkflowState { + if s.shouldHalt() { + return s + } + + active, err := s.DeploymentsClient.GetActiveDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to get active deployment: %w", err)) + } + + if active == nil { + return s + } + + s.Deployment = active + evolved, err := s.DeploymentsClient.Evolve(ctx, api.EvolveRequest{ + Assets: s.NewAssets, + APIKey: s.Params.APIKey, + DeploymentID: s.Deployment.ID, + ProjectSlug: s.Params.ProjectSlug, + }) + if err != nil { + return s.Fail(fmt.Errorf("failed to evolve deployment: %w", err)) + } + + s.Logger.InfoContext( + ctx, + "Updated successfully", + slog.String("deployment_id", evolved.Deployment.ID), + ) + + s.Deployment = evolved.Deployment + + return s +} + +func (s *WorkflowState) OrCreateDeployment(ctx context.Context) *WorkflowState { + if s.shouldHalt() { + return s + } + + if s.Deployment == nil { + return s.CreateDeployment(ctx, "") + } + + return s +} + +func (s *WorkflowState) CreateDeployment( + ctx context.Context, + idem string, +) *WorkflowState { + if s.shouldHalt() { + return s + } + + createReq := api.CreateDeploymentRequest{ + APIKey: s.Params.APIKey, + IdempotencyKey: idem, + OpenAPIv3Assets: s.NewAssets, + ProjectSlug: s.Params.ProjectSlug, + } + result, err := s.DeploymentsClient.CreateDeployment(ctx, createReq) + if err != nil { + return s.Fail(fmt.Errorf("failed to create deployment: %w", err)) + } + + s.Logger.InfoContext( + ctx, + "Created successfully", + slog.String("deployment_id", result.Deployment.ID), + ) + + s.Deployment = result.Deployment + + return s +} + +func (s *WorkflowState) Poll(ctx context.Context) *WorkflowState { + if s.shouldHalt() { + return s + } + + if s.Deployment == nil { + return s.Fail(fmt.Errorf("poll failed: no deployment found")) + } + + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + s.Logger.InfoContext( + ctx, + "Polling deployment status...", + slog.String("deployment_id", s.Deployment.ID), + ) + + for { + select { + case <-ctx.Done(): + if ctx.Err() == context.DeadlineExceeded { + return s.Fail( + fmt.Errorf("deployment polling timed out after 2 minutes"), + ) + } + return s.Fail( + fmt.Errorf("deployment polling cancelled: %w", ctx.Err()), + ) + + case <-ticker.C: + deployment, err := s.DeploymentsClient.GetDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + s.Deployment.ID, + ) + if err != nil { + return s.Fail( + fmt.Errorf("deployment polling failed: %w", err), + ) + } + s.Deployment = deployment + + s.Logger.DebugContext(ctx, "Deployment status check", + slog.String("deployment_id", s.Deployment.ID), + slog.String("status", s.Deployment.Status), + ) + + switch s.Deployment.Status { + case "completed", "failed": + return s + case "pending": + continue + default: + s.Logger.WarnContext( + ctx, + "Unknown deployment status", + slog.String("status", s.Deployment.Status), + ) + continue + } + } + } +} From f5fb66f69889755a77e7c21a382f7586118a683f Mon Sep 17 00:00:00 2001 From: cjea Date: Sat, 4 Oct 2025 17:00:24 -0700 Subject: [PATCH 05/11] implement status with NewWorkflow --- cli/internal/app/push.go | 46 ++++++++++++-------- cli/internal/app/status.go | 70 ++++++++++++++----------------- cli/internal/app/upload/upload.go | 11 +++-- cli/internal/deploy/workflow.go | 62 ++++++++++++++++++--------- 4 files changed, 108 insertions(+), 81 deletions(-) diff --git a/cli/internal/app/push.go b/cli/internal/app/push.go index f7aae09e5..d034b7c7a 100644 --- a/cli/internal/app/push.go +++ b/cli/internal/app/push.go @@ -82,13 +82,13 @@ NOTE: Names and slugs must be unique across all sources.`[1:], logger := logging.PullLogger(ctx) projectSlug := c.String("project") - apiURLArg := c.String("api-url") - apiURL, err := url.Parse(apiURLArg) + apiURL, err := url.Parse(c.String("api-url")) if err != nil { - return fmt.Errorf("failed to parse API URL '%s': %w", apiURLArg, err) - } - if apiURL.Scheme == "" || apiURL.Host == "" { - return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) + return fmt.Errorf( + "failed to parse API URL '%s': %w", + c.String("api-url"), + err, + ) } configFilename, err := filepath.Abs(c.String("config")) @@ -129,30 +129,42 @@ NOTE: Names and slugs must be unique across all sources.`[1:], result.Poll(ctx) } - if result.Err != nil { + if result.Failed() { if result.Deployment != nil { - helpCmd := slog.String("command", - fmt.Sprintf("gram status %s", result.Deployment.ID), + statusCommand := fmt.Sprintf( + "gram status --id %s", + result.Deployment.ID, ) - result.Logger.InfoContext( + + result.Logger.WarnContext( ctx, - "You can check deployment status with", - helpCmd, + "Poll failed.", + slog.String("command", statusCommand), + slog.String("error", result.Err.Error()), ) + return nil } return fmt.Errorf("failed to push deploy: %w", result.Err) } - deploymentResult := result.Deployment - switch deploymentResult.Status { + slogID := slog.String("deployment_id", result.Deployment.ID) + status := result.Deployment.Status + + switch status { case "completed": - logger.InfoContext(ctx, "Deployment completed successfully", slog.String("deployment_id", deploymentResult.ID)) + logger.InfoContext(ctx, "Deployment succeeded", slogID) + return nil case "failed": - logger.ErrorContext(ctx, "Deployment failed", slog.String("deployment_id", deploymentResult.ID)) + logger.ErrorContext(ctx, "Deployment failed", slogID) return fmt.Errorf("deployment failed") default: - logger.InfoContext(ctx, "Deployment is still in progress", slog.String("status", deploymentResult.Status), slog.String("deployment_id", deploymentResult.ID)) + logger.InfoContext( + ctx, + "Deployment is still in progress", + slogID, + slog.String("status", status), + ) } return nil diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index 2c6f3fa91..cccc81c5a 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -3,14 +3,12 @@ package app import ( "encoding/json" "fmt" - "log/slog" "net/url" "os" "os/signal" "syscall" - "github.com/speakeasy-api/gram/cli/internal/api" - "github.com/speakeasy-api/gram/cli/internal/app/logging" + "github.com/speakeasy-api/gram/cli/internal/deploy" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/types" "github.com/urfave/cli/v2" @@ -57,63 +55,52 @@ If no deployment ID is provided, shows the status of the latest deployment.`, ctx, cancel := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) defer cancel() - logger := logging.PullLogger(ctx) projectSlug := c.String("project") deploymentID := c.String("id") jsonOutput := c.Bool("json") - apiURLArg := c.String("api-url") - apiURL, err := url.Parse(apiURLArg) + apiURL, err := url.Parse(c.String("api-url")) if err != nil { - return fmt.Errorf("failed to parse API URL '%s': %w", apiURLArg, err) + return fmt.Errorf( + "failed to parse API URL '%s': %w", + c.String("api-url"), + err, + ) } - if apiURL.Scheme == "" || apiURL.Host == "" { - return fmt.Errorf("API URL '%s' must include scheme and host", apiURLArg) - } - - deploymentsClient := api.NewDeploymentsClient(&api.DeploymentsClientOptions{ - Scheme: apiURL.Scheme, - Host: apiURL.Host, - }) - apiKey := secret.Secret(c.String("api-key")) + params := deploy.WorkflowParams{ + APIKey: secret.Secret(c.String("api-key")), + APIURL: apiURL, + ProjectSlug: projectSlug, + } + result := deploy.NewWorkflow(ctx, params) - var deployment *types.Deployment if deploymentID != "" { - logger.DebugContext(ctx, "Getting deployment status", slog.String("deployment_id", deploymentID)) - result, err := deploymentsClient.GetDeployment(ctx, apiKey, projectSlug, deploymentID) - if err != nil { - return fmt.Errorf("failed to get deployment: %w", err) - } - deployment = result + result.LoadDeploymentByID(ctx, deploymentID) } else { - logger.InfoContext(ctx, "Getting latest deployment status") - result, err := deploymentsClient.GetLatestDeployment(ctx, apiKey, projectSlug) - if err != nil { - return fmt.Errorf("failed to get latest deployment: %w", err) - } - if result == nil { - if jsonOutput { - fmt.Println("{}") - } else { - fmt.Println("No deployments found for this project") - } - return nil - } - deployment = result + result.LoadLatestDeployment(ctx) + } + if result.Failed() { + return fmt.Errorf("failed to get status: %w", result.Err) } if jsonOutput { - return printDeploymentStatusJSON(deployment) + return printDeploymentStatusJSON(result.Deployment) } else { - printDeploymentStatus(deployment) + printDeploymentStatus(result.Deployment) } + return nil }, } } func printDeploymentStatus(deployment *types.Deployment) { + if deployment == nil { + fmt.Println("No deployments found for this project") + return + } + fmt.Printf("Deployment Status\n") fmt.Printf("=================\n\n") @@ -161,6 +148,11 @@ func printDeploymentStatus(deployment *types.Deployment) { } func printDeploymentStatusJSON(deployment *types.Deployment) error { + if deployment == nil { + fmt.Println("{}") + return nil + } + jsonData, err := json.MarshalIndent(deployment, "", " ") if err != nil { return fmt.Errorf("failed to marshal deployment to JSON: %w", err) diff --git a/cli/internal/app/upload/upload.go b/cli/internal/app/upload/upload.go index 34c2ad0ce..83201cbab 100644 --- a/cli/internal/app/upload/upload.go +++ b/cli/internal/app/upload/upload.go @@ -71,15 +71,14 @@ Example: ) defer cancel() - apiURLStr := c.String("api-url") - apiURL, err := url.Parse(apiURLStr) - if err != nil || apiURL.Scheme == "" || apiURL.Host == "" { + apiURL, err := url.Parse(c.String("api-url")) + if err != nil { return fmt.Errorf( - "API URL must include scheme and host: '%s'", - apiURLStr, + "failed to parse API URL '%s': %w", + c.String("api-url"), + err, ) } - params := deploy.WorkflowParams{ APIKey: secret.Secret(c.String("api-key")), APIURL: apiURL, diff --git a/cli/internal/deploy/workflow.go b/cli/internal/deploy/workflow.go index d382b1b7d..78a7806ec 100644 --- a/cli/internal/deploy/workflow.go +++ b/cli/internal/deploy/workflow.go @@ -48,7 +48,7 @@ func (s *WorkflowState) Fail(err error) *WorkflowState { return s } -func (s *WorkflowState) shouldHalt() bool { +func (s *WorkflowState) Failed() bool { return s.Err != nil } @@ -88,7 +88,7 @@ func (s *WorkflowState) UploadAssets( ctx context.Context, sources []Source, ) *WorkflowState { - if s.shouldHalt() { + if s.Failed() { return s } @@ -122,7 +122,7 @@ func (s *WorkflowState) UploadAssets( func (s *WorkflowState) EvolveActiveDeployment( ctx context.Context, ) *WorkflowState { - if s.shouldHalt() { + if s.Failed() { return s } @@ -162,7 +162,7 @@ func (s *WorkflowState) EvolveActiveDeployment( } func (s *WorkflowState) OrCreateDeployment(ctx context.Context) *WorkflowState { - if s.shouldHalt() { + if s.Failed() { return s } @@ -177,7 +177,7 @@ func (s *WorkflowState) CreateDeployment( ctx context.Context, idem string, ) *WorkflowState { - if s.shouldHalt() { + if s.Failed() { return s } @@ -203,8 +203,44 @@ func (s *WorkflowState) CreateDeployment( return s } +func (s *WorkflowState) LoadDeploymentByID( + ctx context.Context, + deploymentID string, +) *WorkflowState { + result, err := s.DeploymentsClient.GetDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + deploymentID, + ) + if err != nil { + return s.Fail( + fmt.Errorf("failed to get deployment '%s': %w", deploymentID, err), + ) + } + + s.Deployment = result + return s +} + +func (s *WorkflowState) LoadLatestDeployment( + ctx context.Context, +) *WorkflowState { + result, err := s.DeploymentsClient.GetLatestDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to get latest deployment: %w", err)) + } + + s.Deployment = result + return s +} + func (s *WorkflowState) Poll(ctx context.Context) *WorkflowState { - if s.shouldHalt() { + if s.Failed() { return s } @@ -237,19 +273,7 @@ func (s *WorkflowState) Poll(ctx context.Context) *WorkflowState { ) case <-ticker.C: - deployment, err := s.DeploymentsClient.GetDeployment( - ctx, - s.Params.APIKey, - s.Params.ProjectSlug, - s.Deployment.ID, - ) - if err != nil { - return s.Fail( - fmt.Errorf("deployment polling failed: %w", err), - ) - } - s.Deployment = deployment - + s.LoadDeploymentByID(ctx, s.Deployment.ID) s.Logger.DebugContext(ctx, "Deployment status check", slog.String("deployment_id", s.Deployment.ID), slog.String("status", s.Deployment.Status), From dab942c366ce0c51d51aafc752afaf3a0c11269f Mon Sep 17 00:00:00 2001 From: cjea Date: Sun, 5 Oct 2025 19:03:29 -0700 Subject: [PATCH 06/11] add logging --- cli/go.mod | 2 +- cli/internal/api/deployments.go | 4 ++ cli/internal/app/status.go | 2 +- cli/internal/app/upload/upload.go | 16 ++++-- cli/internal/deploy/workflow.go | 87 ++++++++++++++++--------------- 5 files changed, 63 insertions(+), 48 deletions(-) diff --git a/cli/go.mod b/cli/go.mod index 796e15028..675292c92 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -7,6 +7,7 @@ replace github.com/speakeasy-api/gram/server => ../server require ( github.com/BurntSushi/toml v1.5.0 github.com/charmbracelet/log v0.4.2 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/speakeasy-api/gram/server v0.0.0-00010101000000-000000000000 github.com/stretchr/testify v1.11.1 @@ -27,7 +28,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index a0579cd46..7594d300b 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/google/uuid" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/deployments" depl_client "github.com/speakeasy-api/gram/server/gen/http/deployments/client" @@ -56,6 +57,9 @@ func (c *DeploymentsClient) CreateDeployment( req CreateDeploymentRequest, ) (*deployments.CreateDeploymentResult, error) { key := req.APIKey.Reveal() + if req.IdempotencyKey == "" { + req.IdempotencyKey = uuid.New().String() + } payload := &deployments.CreateDeploymentPayload{ ApikeyToken: &key, ProjectSlugInput: &req.ProjectSlug, diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index cccc81c5a..1dd00af3e 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -127,7 +127,7 @@ func printDeploymentStatus(deployment *types.Deployment) { fmt.Printf("External URL: %s\n", *deployment.ExternalURL) } - fmt.Printf("\nAssets:\n") + fmt.Printf("\nTools:\n") fmt.Printf(" OpenAPI Tools: %d\n", deployment.Openapiv3ToolCount) fmt.Printf(" Functions: %d\n", deployment.FunctionsToolCount) diff --git a/cli/internal/app/upload/upload.go b/cli/internal/app/upload/upload.go index 83201cbab..06508ce5f 100644 --- a/cli/internal/app/upload/upload.go +++ b/cli/internal/app/upload/upload.go @@ -2,6 +2,7 @@ package upload import ( "fmt" + "log/slog" "net/url" "os" "os/signal" @@ -87,13 +88,22 @@ Example: result := deploy.NewWorkflow(ctx, params). UploadAssets(ctx, []deploy.Source{parseSource(c)}). - EvolveActiveDeployment(ctx). - OrCreateDeployment(ctx) + LoadActiveDeployment(ctx) + if result.Deployment == nil { + result.CreateDeployment(ctx, "") + } else { + result.EvolveDeployment(ctx) + } - if result.Err != nil { + if result.Failed() { return fmt.Errorf("failed to upload: %w", result.Err) } + result.Logger.InfoContext( + ctx, + "upload success", + slog.String("deployment_id", result.Deployment.ID), + ) return nil }, } diff --git a/cli/internal/deploy/workflow.go b/cli/internal/deploy/workflow.go index 78a7806ec..4a1a00467 100644 --- a/cli/internal/deploy/workflow.go +++ b/cli/internal/deploy/workflow.go @@ -33,7 +33,7 @@ func (p WorkflowParams) Validate() error { return nil } -type WorkflowState struct { +type Workflow struct { Logger *slog.Logger Params WorkflowParams AssetsClient *api.AssetsClient @@ -43,20 +43,22 @@ type WorkflowState struct { Err error } -func (s *WorkflowState) Fail(err error) *WorkflowState { +// Fail indicates an unexpected error and halts execution. +func (s *Workflow) Fail(err error) *Workflow { s.Err = err return s } -func (s *WorkflowState) Failed() bool { +// Failed indicates an unexpected error has interrupted the workflow. +func (s *Workflow) Failed() bool { return s.Err != nil } func NewWorkflow( ctx context.Context, params WorkflowParams, -) *WorkflowState { - state := &WorkflowState{ +) *Workflow { + state := &Workflow{ Logger: logging.PullLogger(ctx), Params: params, AssetsClient: nil, @@ -84,14 +86,15 @@ func NewWorkflow( return state } -func (s *WorkflowState) UploadAssets( +func (s *Workflow) UploadAssets( ctx context.Context, sources []Source, -) *WorkflowState { +) *Workflow { if s.Failed() { return s } + s.Logger.InfoContext(ctx, "uploading files") newAssets := make( []*deployments.AddOpenAPIv3DeploymentAssetForm, len(sources), @@ -114,32 +117,25 @@ func (s *WorkflowState) UploadAssets( newAssets[idx] = asset } - s.NewAssets = newAssets return s } -func (s *WorkflowState) EvolveActiveDeployment( +func (s *Workflow) EvolveDeployment( ctx context.Context, -) *WorkflowState { +) *Workflow { if s.Failed() { return s } - active, err := s.DeploymentsClient.GetActiveDeployment( + if s.Deployment == nil { + return s.Fail(fmt.Errorf("update failed: no deployment found")) + } + s.Logger.InfoContext( ctx, - s.Params.APIKey, - s.Params.ProjectSlug, + "updating deployment", + slog.String("deployment_id", s.Deployment.ID), ) - if err != nil { - return s.Fail(fmt.Errorf("failed to get active deployment: %w", err)) - } - - if active == nil { - return s - } - - s.Deployment = active evolved, err := s.DeploymentsClient.Evolve(ctx, api.EvolveRequest{ Assets: s.NewAssets, APIKey: s.Params.APIKey, @@ -152,7 +148,7 @@ func (s *WorkflowState) EvolveActiveDeployment( s.Logger.InfoContext( ctx, - "Updated successfully", + "updated deployment", slog.String("deployment_id", evolved.Deployment.ID), ) @@ -161,26 +157,15 @@ func (s *WorkflowState) EvolveActiveDeployment( return s } -func (s *WorkflowState) OrCreateDeployment(ctx context.Context) *WorkflowState { - if s.Failed() { - return s - } - - if s.Deployment == nil { - return s.CreateDeployment(ctx, "") - } - - return s -} - -func (s *WorkflowState) CreateDeployment( +func (s *Workflow) CreateDeployment( ctx context.Context, idem string, -) *WorkflowState { +) *Workflow { if s.Failed() { return s } + s.Logger.InfoContext(ctx, "creating deployment") createReq := api.CreateDeploymentRequest{ APIKey: s.Params.APIKey, IdempotencyKey: idem, @@ -194,7 +179,7 @@ func (s *WorkflowState) CreateDeployment( s.Logger.InfoContext( ctx, - "Created successfully", + "created new deployment", slog.String("deployment_id", result.Deployment.ID), ) @@ -203,10 +188,10 @@ func (s *WorkflowState) CreateDeployment( return s } -func (s *WorkflowState) LoadDeploymentByID( +func (s *Workflow) LoadDeploymentByID( ctx context.Context, deploymentID string, -) *WorkflowState { +) *Workflow { result, err := s.DeploymentsClient.GetDeployment( ctx, s.Params.APIKey, @@ -223,9 +208,9 @@ func (s *WorkflowState) LoadDeploymentByID( return s } -func (s *WorkflowState) LoadLatestDeployment( +func (s *Workflow) LoadLatestDeployment( ctx context.Context, -) *WorkflowState { +) *Workflow { result, err := s.DeploymentsClient.GetLatestDeployment( ctx, s.Params.APIKey, @@ -239,7 +224,23 @@ func (s *WorkflowState) LoadLatestDeployment( return s } -func (s *WorkflowState) Poll(ctx context.Context) *WorkflowState { +func (s *Workflow) LoadActiveDeployment( + ctx context.Context, +) *Workflow { + result, err := s.DeploymentsClient.GetActiveDeployment( + ctx, + s.Params.APIKey, + s.Params.ProjectSlug, + ) + if err != nil { + return s.Fail(fmt.Errorf("failed to get active deployment: %w", err)) + } + + s.Deployment = result + return s +} + +func (s *Workflow) Poll(ctx context.Context) *Workflow { if s.Failed() { return s } From 70774c001ffe78bc63e7191acab82d47db9ff343 Mon Sep 17 00:00:00 2001 From: cjea Date: Sun, 5 Oct 2025 19:18:34 -0700 Subject: [PATCH 07/11] share common CLI flags --- cli/internal/app/push.go | 23 ++++------------------ cli/internal/app/status.go | 23 ++++------------------ cli/internal/app/upload/upload.go | 23 ++++------------------ cli/internal/flags/flags.go | 32 +++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 57 deletions(-) create mode 100644 cli/internal/flags/flags.go diff --git a/cli/internal/app/push.go b/cli/internal/app/push.go index d034b7c7a..2e21071a9 100644 --- a/cli/internal/app/push.go +++ b/cli/internal/app/push.go @@ -11,6 +11,7 @@ import ( "github.com/speakeasy-api/gram/cli/internal/app/logging" "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/flags" "github.com/speakeasy-api/gram/cli/internal/o11y" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/urfave/cli/v2" @@ -40,25 +41,9 @@ Sample deployment file NOTE: Names and slugs must be unique across all sources.`[1:], Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "api-url", - Usage: "The base URL to use for API calls.", - EnvVars: []string{"GRAM_API_URL"}, - Value: "https://app.getgram.ai", - Hidden: true, - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "Your Gram API key (must be scoped as a 'Provider')", - EnvVars: []string{"GRAM_API_KEY"}, - Required: true, - }, - &cli.StringFlag{ - Name: "project", - Usage: "The Gram project to push to", - EnvVars: []string{"GRAM_PROJECT"}, - Required: true, - }, + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), &cli.PathFlag{ Name: "config", Usage: "Path to the deployment file", diff --git a/cli/internal/app/status.go b/cli/internal/app/status.go index 1dd00af3e..50832cb6b 100644 --- a/cli/internal/app/status.go +++ b/cli/internal/app/status.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/flags" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/speakeasy-api/gram/server/gen/types" "github.com/urfave/cli/v2" @@ -23,25 +24,9 @@ Check the status of a deployment. If no deployment ID is provided, shows the status of the latest deployment.`, Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "api-url", - Usage: "The base URL to use for API calls.", - EnvVars: []string{"GRAM_API_URL"}, - Value: "https://app.getgram.ai", - Hidden: true, - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "Your Gram API key (must be scoped as a 'Provider')", - EnvVars: []string{"GRAM_API_KEY"}, - Required: true, - }, - &cli.StringFlag{ - Name: "project", - Usage: "The Gram project to check status for", - EnvVars: []string{"GRAM_PROJECT"}, - Required: true, - }, + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), &cli.StringFlag{ Name: "id", Usage: "The deployment ID to check status for (if not provided, shows latest deployment)", diff --git a/cli/internal/app/upload/upload.go b/cli/internal/app/upload/upload.go index 06508ce5f..62079f993 100644 --- a/cli/internal/app/upload/upload.go +++ b/cli/internal/app/upload/upload.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/speakeasy-api/gram/cli/internal/deploy" + "github.com/speakeasy-api/gram/cli/internal/flags" "github.com/speakeasy-api/gram/cli/internal/secret" "github.com/urfave/cli/v2" ) @@ -24,25 +25,9 @@ Example: --name "My API" \ --slug my-api`[1:], Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "api-url", - Usage: "The base URL to use for API calls.", - EnvVars: []string{"GRAM_API_URL"}, - Value: "https://app.getgram.ai", - Hidden: true, - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "Your Gram API key (must be scoped as a 'Provider')", - EnvVars: []string{"GRAM_API_KEY"}, - Required: true, - }, - &cli.StringFlag{ - Name: "project", - Usage: "The Gram project to upload to", - EnvVars: []string{"GRAM_PROJECT"}, - Required: true, - }, + flags.APIEndpoint(), + flags.APIKey(), + flags.Project(), &cli.StringFlag{ Name: "type", Usage: "The type of asset to upload", diff --git a/cli/internal/flags/flags.go b/cli/internal/flags/flags.go new file mode 100644 index 000000000..91286ff63 --- /dev/null +++ b/cli/internal/flags/flags.go @@ -0,0 +1,32 @@ +// Package flags defines common flags to keep CLI commands consistent. +package flags + +import "github.com/urfave/cli/v2" + +func APIKey() *cli.StringFlag { + return &cli.StringFlag{ + Name: "api-key", + Usage: "Your Gram API key (must be scoped as a 'Provider')", + EnvVars: []string{"GRAM_API_KEY"}, + Required: true, + } +} + +func APIEndpoint() *cli.StringFlag { + return &cli.StringFlag{ + Name: "api-url", + Usage: "The base URL to use for API calls.", + EnvVars: []string{"GRAM_API_URL"}, + Value: "https://app.getgram.ai", + Hidden: true, + } +} + +func Project() *cli.StringFlag { + return &cli.StringFlag{ + Name: "project", + Usage: "The target Gram project", + EnvVars: []string{"GRAM_PROJECT"}, + Required: true, + } +} From ef318718491b3fdfbfcdda1cc7f476e54fc46178 Mon Sep 17 00:00:00 2001 From: cjea Date: Mon, 6 Oct 2025 01:14:47 -0700 Subject: [PATCH 08/11] remove nesting --- cli/internal/app/app.go | 3 +-- cli/internal/app/{upload => }/upload.go | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) rename cli/internal/app/{upload => }/upload.go (97%) diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index 383f7c279..900951062 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -9,7 +9,6 @@ import ( "github.com/urfave/cli/v2" "github.com/speakeasy-api/gram/cli/internal/app/logging" - "github.com/speakeasy-api/gram/cli/internal/app/upload" "github.com/speakeasy-api/gram/cli/internal/o11y" ) @@ -25,7 +24,7 @@ func newApp() *cli.App { Version: fmt.Sprintf("%s (%s)", Version, shortSha), Commands: []*cli.Command{ newPushCommand(), - upload.NewCommand(), + newUploadCommand(), newStatusCommand(), }, Flags: []cli.Flag{ diff --git a/cli/internal/app/upload/upload.go b/cli/internal/app/upload.go similarity index 97% rename from cli/internal/app/upload/upload.go rename to cli/internal/app/upload.go index 62079f993..54f9f4bdf 100644 --- a/cli/internal/app/upload/upload.go +++ b/cli/internal/app/upload.go @@ -1,4 +1,4 @@ -package upload +package app import ( "fmt" @@ -14,7 +14,7 @@ import ( "github.com/urfave/cli/v2" ) -func NewCommand() *cli.Command { +func newUploadCommand() *cli.Command { return &cli.Command{ Name: "upload", Usage: "Upload an asset to Gram", From af2b9fa2bd6654c5aa8d57ffde2b326ed95649f1 Mon Sep 17 00:00:00 2001 From: cjea Date: Mon, 6 Oct 2025 14:28:23 -0700 Subject: [PATCH 09/11] feat: upload gram functions --- cli/internal/api/assets.go | 28 +++++++++++++++++--- cli/internal/api/deployments.go | 6 +++-- cli/internal/app/upload.go | 6 +++++ cli/internal/deploy/config.go | 20 ++++++++++++-- cli/internal/deploy/upload.go | 32 +++++++++++++++------- cli/internal/deploy/workflow.go | 47 ++++++++++++++++++++++++++------- 6 files changed, 113 insertions(+), 26 deletions(-) diff --git a/cli/internal/api/assets.go b/cli/internal/api/assets.go index 2e525542f..fa312bfae 100644 --- a/cli/internal/api/assets.go +++ b/cli/internal/api/assets.go @@ -40,7 +40,7 @@ func NewAssetsClient(options *AssetsClientOptions) *AssetsClient { return &AssetsClient{client: client} } -type UploadOpenAPIv3Request struct { +type UploadAssetForm struct { APIKey string ProjectSlug string Reader io.ReadCloser @@ -50,8 +50,8 @@ type UploadOpenAPIv3Request struct { func (c *AssetsClient) UploadOpenAPIv3( ctx context.Context, - req *UploadOpenAPIv3Request, -) (*assets.UploadOpenAPIv3Result, error) { + req *UploadAssetForm, +) (*assets.Asset, error) { payload := &assets.UploadOpenAPIv3Form{ ApikeyToken: &req.APIKey, ProjectSlugInput: &req.ProjectSlug, @@ -65,5 +65,25 @@ func (c *AssetsClient) UploadOpenAPIv3( return nil, fmt.Errorf("failed to upload OpenAPI asset: %w", err) } - return result, nil + return result.Asset, nil +} + +func (c *AssetsClient) UploadFunctions( + ctx context.Context, + req *UploadAssetForm, +) (*assets.Asset, error) { + payload := &assets.UploadFunctionsForm{ + ApikeyToken: &req.APIKey, + ProjectSlugInput: &req.ProjectSlug, + SessionToken: nil, + ContentType: req.ContentType, + ContentLength: req.ContentLength, + } + + result, err := c.client.UploadFunctions(ctx, payload, req.Reader) + if err != nil { + return nil, fmt.Errorf("failed to upload OpenAPI asset: %w", err) + } + + return result.Asset, nil } diff --git a/cli/internal/api/deployments.go b/cli/internal/api/deployments.go index 7594d300b..0988074a8 100644 --- a/cli/internal/api/deployments.go +++ b/cli/internal/api/deployments.go @@ -49,6 +49,7 @@ type CreateDeploymentRequest struct { ProjectSlug string IdempotencyKey string OpenAPIv3Assets []*deployments.AddOpenAPIv3DeploymentAssetForm + Functions []*deployments.AddFunctionsForm } // CreateDeployment creates a remote deployment. @@ -65,7 +66,7 @@ func (c *DeploymentsClient) CreateDeployment( ProjectSlugInput: &req.ProjectSlug, IdempotencyKey: req.IdempotencyKey, Openapiv3Assets: req.OpenAPIv3Assets, - Functions: []*deployments.AddFunctionsForm{}, + Functions: req.Functions, SessionToken: nil, GithubRepo: nil, GithubPr: nil, @@ -169,6 +170,7 @@ func (c *DeploymentsClient) GetActiveDeployment( // EvolveRequest lists the assets to add to a deployment. type EvolveRequest struct { Assets []*deployments.AddOpenAPIv3DeploymentAssetForm + Functions []*deployments.AddFunctionsForm APIKey secret.Secret DeploymentID string ProjectSlug string @@ -185,7 +187,7 @@ func (c *DeploymentsClient) Evolve( ProjectSlugInput: &req.ProjectSlug, DeploymentID: &req.DeploymentID, UpsertOpenapiv3Assets: req.Assets, - UpsertFunctions: []*deployments.AddFunctionsForm{}, + UpsertFunctions: req.Functions, ExcludeOpenapiv3Assets: []string{}, ExcludeFunctions: []string{}, ExcludePackages: []string{}, diff --git a/cli/internal/app/upload.go b/cli/internal/app/upload.go index 54f9f4bdf..8aba64a0c 100644 --- a/cli/internal/app/upload.go +++ b/cli/internal/app/upload.go @@ -48,6 +48,11 @@ Example: Usage: "The URL-friendly slug for the asset", Required: true, }, + &cli.StringFlag{ + Name: "runtime", + Usage: "Runtime to use for function execution (required for functions)", + Required: false, + }, }, Action: func(c *cli.Context) error { ctx, cancel := signal.NotifyContext( @@ -100,5 +105,6 @@ func parseSource(c *cli.Context) deploy.Source { Location: c.String("location"), Name: c.String("name"), Slug: c.String("slug"), + Runtime: c.String("runtime"), } } diff --git a/cli/internal/deploy/config.go b/cli/internal/deploy/config.go index f35002fba..3f66b3b33 100644 --- a/cli/internal/deploy/config.go +++ b/cli/internal/deploy/config.go @@ -23,8 +23,11 @@ type SourceType string const ( SourceTypeOpenAPIV3 SourceType = "openapiv3" + SourceTypeFunction SourceType = "function" ) +var AllowedTypes = []SourceType{SourceTypeOpenAPIV3, SourceTypeFunction} + type Source struct { Type SourceType `json:"type" yaml:"type" toml:"type"` @@ -36,12 +39,20 @@ type Source struct { // Slug is the human readable public id of the asset. Slug string `json:"slug" yaml:"slug" toml:"slug"` + + // Runtime is the runtime to use for function execution (required for functions). + // Allowed values are: nodejs:22, python:3.12 + Runtime string `json:"runtime,omitempty" yaml:"runtime,omitempty" toml:"runtime,omitempty"` } // Validate returns an error if the source is missing required fields. func (s Source) Validate() error { if !isSupportedType(s) { - return fmt.Errorf("source has unsupported type %q (allowed types: %s)", s.Type, SourceTypeOpenAPIV3) + return fmt.Errorf( + "source has unsupported type %q (allowed types: %v)", + s.Type, + AllowedTypes, + ) } if s.Location == "" { @@ -53,11 +64,16 @@ func (s Source) Validate() error { if s.Slug == "" { return fmt.Errorf("source is missing required field 'slug'") } + if s.Type == SourceTypeFunction && s.Runtime == "" { + return fmt.Errorf( + "source of type 'function' is missing required field 'runtime' (allowed values: nodejs:22, python:3.12)", + ) + } return nil } func isSupportedType(s Source) bool { - return s.Type == SourceTypeOpenAPIV3 + return slices.Contains(AllowedTypes, s.Type) } type Config struct { diff --git a/cli/internal/deploy/upload.go b/cli/internal/deploy/upload.go index a869d78b6..0552027c3 100644 --- a/cli/internal/deploy/upload.go +++ b/cli/internal/deploy/upload.go @@ -7,7 +7,7 @@ import ( "github.com/speakeasy-api/gram/cli/internal/api" "github.com/speakeasy-api/gram/cli/internal/secret" - "github.com/speakeasy-api/gram/server/gen/deployments" + "github.com/speakeasy-api/gram/server/gen/assets" "github.com/speakeasy-api/gram/server/gen/types" ) @@ -25,34 +25,48 @@ func (ur *UploadRequest) Read(ctx context.Context) (io.ReadCloser, int64, error) return reader, size, nil } +type UploadResponse struct { + AssetID string + Name string + Slug types.Slug + Runtime string +} + func Upload( ctx context.Context, assetsClient *api.AssetsClient, req *UploadRequest, -) (*deployments.AddOpenAPIv3DeploymentAssetForm, error) { +) (*UploadResponse, error) { rc, length, err := req.SourceReader.Read(ctx) if err != nil { return nil, fmt.Errorf("failed to read source: %w", err) } source := req.SourceReader.Source - - uploadRes, err := assetsClient.UploadOpenAPIv3(ctx, &api.UploadOpenAPIv3Request{ + assetForm := &api.UploadAssetForm{ APIKey: req.APIKey.Reveal(), ProjectSlug: req.ProjectSlug, Reader: rc, ContentType: req.SourceReader.GetContentType(), ContentLength: length, - }) + } + + var asset *assets.Asset + switch source.Type { + case SourceTypeOpenAPIV3: + asset, err = assetsClient.UploadOpenAPIv3(ctx, assetForm) + case SourceTypeFunction: + asset, err = assetsClient.UploadFunctions(ctx, assetForm) + } + if err != nil { msg := "failed to upload asset in project '%s' for source %s: %w" return nil, fmt.Errorf(msg, req.ProjectSlug, source.Location, err) } - - return &deployments.AddOpenAPIv3DeploymentAssetForm{ - AssetID: uploadRes.Asset.ID, + return &UploadResponse{ + AssetID: asset.ID, Name: source.Name, Slug: types.Slug(source.Slug), + Runtime: source.Runtime, }, nil - } diff --git a/cli/internal/deploy/workflow.go b/cli/internal/deploy/workflow.go index 4a1a00467..26459737e 100644 --- a/cli/internal/deploy/workflow.go +++ b/cli/internal/deploy/workflow.go @@ -38,7 +38,8 @@ type Workflow struct { Params WorkflowParams AssetsClient *api.AssetsClient DeploymentsClient *api.DeploymentsClient - NewAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + NewOpenAPIAssets []*deployments.AddOpenAPIv3DeploymentAssetForm + NewFunctionAssets []*deployments.AddFunctionsForm Deployment *types.Deployment Err error } @@ -63,7 +64,8 @@ func NewWorkflow( Params: params, AssetsClient: nil, DeploymentsClient: nil, - NewAssets: nil, + NewOpenAPIAssets: nil, + NewFunctionAssets: nil, Deployment: nil, Err: nil, } @@ -94,13 +96,20 @@ func (s *Workflow) UploadAssets( return s } - s.Logger.InfoContext(ctx, "uploading files") - newAssets := make( + s.Logger.InfoContext(ctx, "uploading assets") + + newOpenAPIAssets := make( []*deployments.AddOpenAPIv3DeploymentAssetForm, + 0, + len(sources), + ) + newFunctionAssets := make( + []*deployments.AddFunctionsForm, + 0, len(sources), ) - for idx, source := range sources { + for _, source := range sources { if err := source.Validate(); err != nil { return s.Fail(fmt.Errorf("invalid source: %w", err)) } @@ -115,9 +124,27 @@ func (s *Workflow) UploadAssets( return s.Fail(fmt.Errorf("failed to upload asset: %w", err)) } - newAssets[idx] = asset + switch source.Type { + case SourceTypeOpenAPIV3: + form := &deployments.AddOpenAPIv3DeploymentAssetForm{ + AssetID: asset.AssetID, + Name: asset.Name, + Slug: asset.Slug, + } + newOpenAPIAssets = append(newOpenAPIAssets, form) + case SourceTypeFunction: + form := &deployments.AddFunctionsForm{ + AssetID: asset.AssetID, + Name: asset.Name, + Slug: asset.Slug, + Runtime: asset.Runtime, + } + newFunctionAssets = append(newFunctionAssets, form) + } } - s.NewAssets = newAssets + + s.NewOpenAPIAssets = newOpenAPIAssets + s.NewFunctionAssets = newFunctionAssets return s } @@ -137,7 +164,8 @@ func (s *Workflow) EvolveDeployment( slog.String("deployment_id", s.Deployment.ID), ) evolved, err := s.DeploymentsClient.Evolve(ctx, api.EvolveRequest{ - Assets: s.NewAssets, + Assets: s.NewOpenAPIAssets, + Functions: s.NewFunctionAssets, APIKey: s.Params.APIKey, DeploymentID: s.Deployment.ID, ProjectSlug: s.Params.ProjectSlug, @@ -169,7 +197,8 @@ func (s *Workflow) CreateDeployment( createReq := api.CreateDeploymentRequest{ APIKey: s.Params.APIKey, IdempotencyKey: idem, - OpenAPIv3Assets: s.NewAssets, + OpenAPIv3Assets: s.NewOpenAPIAssets, + Functions: s.NewFunctionAssets, ProjectSlug: s.Params.ProjectSlug, } result, err := s.DeploymentsClient.CreateDeployment(ctx, createReq) From 36c076952a13e5ad0772934ff358e3bf82956ad1 Mon Sep 17 00:00:00 2001 From: cjea Date: Mon, 6 Oct 2025 17:08:12 -0700 Subject: [PATCH 10/11] update tests --- cli/internal/deploy/example_usage_test.go | 126 ++++++++++++++++++---- cli/internal/deploy/source_reader_test.go | 5 + 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/cli/internal/deploy/example_usage_test.go b/cli/internal/deploy/example_usage_test.go index d19592e19..66e2df735 100644 --- a/cli/internal/deploy/example_usage_test.go +++ b/cli/internal/deploy/example_usage_test.go @@ -21,6 +21,7 @@ func TestSource_MissingRequiredFields(t *testing.T) { Location: "/path/to/file", Name: "", Slug: "valid-slug", + Runtime: "nodejs:22", }, wantErr: "source is missing required field 'name'", }, @@ -31,6 +32,7 @@ func TestSource_MissingRequiredFields(t *testing.T) { Location: "/path/to/file", Name: "Valid Name", Slug: "", + Runtime: "nodejs:22", }, wantErr: "source is missing required field 'slug'", }, @@ -41,16 +43,40 @@ func TestSource_MissingRequiredFields(t *testing.T) { Location: "/path/to/file", Name: "", Slug: "", + Runtime: "nodejs:22", }, wantErr: "source is missing required field 'name'", }, { - name: "valid source", + name: "missing runtime for function", + source: Source{ + Type: SourceTypeFunction, + Location: "/path/to/file.zip", + Name: "Example function", + Slug: "example-function", + Runtime: "", + }, + wantErr: "source of type 'function' is missing required field 'runtime'", + }, + { + name: "valid function", + source: Source{ + Type: SourceTypeFunction, + Location: "/path/to/file.zip", + Name: "Example function", + Slug: "example-function", + Runtime: "nodejs:22", + }, + wantErr: "", + }, + { + name: "valid openapiv3", source: Source{ Type: SourceTypeOpenAPIV3, Location: "/path/to/file", Name: "Valid Name", Slug: "valid-slug", + Runtime: "nodejs:22", }, wantErr: "", }, @@ -83,28 +109,86 @@ func TestValidateUniqueNames(t *testing.T) { { name: "unique names", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three", Runtime: "nodejs:22"}, + }, + wantErr: "", + }, + { + name: "duplicate names", + sources: []Source{ + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "Duplicate API", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "Duplicate API", Slug: "api-three", Runtime: "nodejs:22"}, + }, + wantErr: "source names must be unique: ['Duplicate API' (2 times)]", + }, + { + name: "multiple duplicate names", + sources: []Source{ + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "First Duplicate", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "Second Duplicate", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "First Duplicate", Slug: "api-three", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "Second Duplicate", Slug: "api-four", Runtime: "nodejs:22"}, + }, + wantErr: "source names must be unique:", + }, + { + name: "empty sources", + sources: []Source{}, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateUniqueNames(tt.sources) + + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} +func TestFunctionMissingRuntime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sources []Source + wantErr string + }{ + { + name: "", + sources: []Source{ + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three", Runtime: "nodejs:22"}, }, wantErr: "", }, { name: "duplicate names", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "Duplicate API", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "Duplicate API", Slug: "api-three"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "Duplicate API", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "Duplicate API", Slug: "api-three", Runtime: "nodejs:22"}, }, wantErr: "source names must be unique: ['Duplicate API' (2 times)]", }, { name: "multiple duplicate names", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "First Duplicate", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "Second Duplicate", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "First Duplicate", Slug: "api-three"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "Second Duplicate", Slug: "api-four"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "First Duplicate", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "Second Duplicate", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "First Duplicate", Slug: "api-three", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "Second Duplicate", Slug: "api-four", Runtime: "nodejs:22"}, }, wantErr: "source names must be unique:", }, @@ -142,28 +226,28 @@ func TestValidateUniqueSlugs(t *testing.T) { { name: "unique slugs", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "api-one", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "api-three", Runtime: "nodejs:22"}, }, wantErr: "", }, { name: "duplicate slugs", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "duplicate-slug"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "duplicate-slug"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "duplicate-slug", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "api-two", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "duplicate-slug", Runtime: "nodejs:22"}, }, wantErr: "source slugs must be unique: ['duplicate-slug' (2 times)]", }, { name: "multiple duplicate slugs", sources: []Source{ - {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "first-duplicate"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "second-duplicate"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "first-duplicate"}, - {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "API Four", Slug: "second-duplicate"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/one", Name: "API One", Slug: "first-duplicate", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/two", Name: "API Two", Slug: "second-duplicate", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/three", Name: "API Three", Slug: "first-duplicate", Runtime: "nodejs:22"}, + {Type: SourceTypeOpenAPIV3, Location: "/path/four", Name: "API Four", Slug: "second-duplicate", Runtime: "nodejs:22"}, }, wantErr: "source slugs must be unique:", }, diff --git a/cli/internal/deploy/source_reader_test.go b/cli/internal/deploy/source_reader_test.go index a50f7b982..8cb3029e2 100644 --- a/cli/internal/deploy/source_reader_test.go +++ b/cli/internal/deploy/source_reader_test.go @@ -32,6 +32,7 @@ info: Location: testFile, Name: "Test API", Slug: "test-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -73,6 +74,7 @@ func TestSourceReader_JSONFile(t *testing.T) { Location: testFile, Name: "Test JSON API", Slug: "test-json-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -101,6 +103,7 @@ func TestSourceReader_NonexistentFile(t *testing.T) { Location: "/nonexistent/path/file.yaml", Name: "Nonexistent API", Slug: "nonexistent-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -132,6 +135,7 @@ func TestSourceReader_RemoteURL(t *testing.T) { Location: staticServer.URL + "/api-spec.yaml", Name: "Remote API", Slug: "remote-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) @@ -165,6 +169,7 @@ func TestSourceReader_RemoteURL_Error(t *testing.T) { Location: server.URL + "/nonexistent.yaml", Name: "Remote API", Slug: "remote-api", + Runtime: "nodejs:22", } reader := NewSourceReader(source) From 6c9f55840910950394a4c7800255d418cfea4664 Mon Sep 17 00:00:00 2001 From: cjea Date: Tue, 7 Oct 2025 14:11:04 -0700 Subject: [PATCH 11/11] docs(changeset): Support function uploads --- .changeset/better-lines-design.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/better-lines-design.md diff --git a/.changeset/better-lines-design.md b/.changeset/better-lines-design.md new file mode 100644 index 000000000..da34267f3 --- /dev/null +++ b/.changeset/better-lines-design.md @@ -0,0 +1,5 @@ +--- +"@gram/cli": minor +--- + +Support function uploads