From 27aa4091c6f283ffbfa626bf693467371b988c38 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Thu, 26 Mar 2026 19:23:23 +0000 Subject: [PATCH 01/10] Fix error message on unmarshalling response --- cmd/create.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index af38d06..4409484 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -4,8 +4,9 @@ import ( "bytes" "encoding/json" "fmt" - "strings" "net/url" + "strings" + "github.com/MakeNowJust/heredoc" "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" @@ -24,6 +25,7 @@ type createReq struct { } type createResp struct { + AppUrl string `json:"app_url"` } func init() { @@ -94,14 +96,14 @@ func init() { fmt.Println(err) return } - var response string + response := createResp{} err = client.Put(createUrl, bytes.NewReader(body), &response) if err != nil { fmt.Printf("Error creating app: %v\n", err) return } - fmt.Printf("App created: %s\n", response) // TODO pretty print details + fmt.Printf("App created: %s\n", response.AppUrl) // TODO pretty print details }, } From 9c2a85575b70d4b7f7d7a13e5179fdd205fe6a24 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Thu, 26 Mar 2026 19:27:09 +0000 Subject: [PATCH 02/10] --init parameter on create command --- cmd/create.go | 10 ++++++++ cmd/init.go | 65 +++++++++++++++++++++++++++------------------------ 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 4409484..3aa6eb7 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -17,6 +17,7 @@ type createCmdFlags struct { EnvironmentVariables []string Secrets []string RevisionName string + Init bool } type createReq struct { @@ -104,6 +105,14 @@ func init() { } fmt.Printf("App created: %s\n", response.AppUrl) // TODO pretty print details + + if createCmdFlags.Init { + err = writeRuntimeConfig(createCmdFlags.app, "") + if err != nil { + fmt.Printf("Error initializing config: %v\n", err) + return + } + } }, } @@ -111,5 +120,6 @@ func init() { createCmd.Flags().StringSliceVarP(&createCmdFlags.EnvironmentVariables, "env", "e", []string{}, "Environment variables to set on the app in the form 'key=value'") createCmd.Flags().StringSliceVarP(&createCmdFlags.Secrets, "secret", "s", []string{}, "Secrets to set on the app in the form 'key=value'") createCmd.Flags().StringVarP(&createCmdFlags.RevisionName, "revision-name", "r", "", "The revision name to use for the app") + createCmd.Flags().BoolVar(&createCmdFlags.Init, "init", false, "Initialize a runtime.config.json file in the current directory after creating the app") rootCmd.AddCommand(createCmd) } diff --git a/cmd/init.go b/cmd/init.go index 7c555cb..6f6305d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -21,6 +21,39 @@ type appResponse struct { AppUrl string `json:"app_url"` } +// writeRuntimeConfig writes a runtime.config.json file for the given app. +// If outPath is empty, it defaults to "runtime.config.json" in the current directory. +func writeRuntimeConfig(app string, outPath string) error { + configStruct := config.RuntimeConfig{ + App: app, + } + + configPath := "runtime.config.json" + if outPath != "" { + configPath = outPath + outputDir := filepath.Dir(configPath) + if outputDir != "." { + err := os.MkdirAll(outputDir, 0755) + if err != nil { + return fmt.Errorf("error creating directory '%s': %v", outputDir, err) + } + } + } + + configBytes, err := json.MarshalIndent(configStruct, "", " ") + if err != nil { + return fmt.Errorf("error creating configuration: %v", err) + } + + err = os.WriteFile(configPath, configBytes, 0644) + if err != nil { + return fmt.Errorf("error writing configuration file: %v", err) + } + + fmt.Printf("Successfully initialized local project for Spark app '%s' at '%s'\n", app, configPath) + return nil +} + func init() { initCmdFlags := initCmdFlags{} initCmd := &cobra.Command{ @@ -47,7 +80,6 @@ func init() { return fmt.Errorf("--app flag is required") } - // Determine the identifier to use for the API call identifier := initCmdFlags.app getUrl := fmt.Sprintf("runtime/%s/deployment", identifier) @@ -63,36 +95,7 @@ func init() { return fmt.Errorf("app '%s' does not exist or is not accessible: %v", identifier, err) } - // Create runtime config - configStruct := config.RuntimeConfig{ - App: initCmdFlags.app, - } - - configPath := "runtime.config.json" - if initCmdFlags.out != "" { - configPath = initCmdFlags.out - // Create directory if it doesn't exist - outputDir := filepath.Dir(configPath) - if outputDir != "." { - err = os.MkdirAll(outputDir, 0755) - if err != nil { - return fmt.Errorf("error creating directory '%s': %v", outputDir, err) - } - } - } - - configBytes, err := json.MarshalIndent(configStruct, "", " ") - if err != nil { - return fmt.Errorf("error creating configuration: %v", err) - } - - err = os.WriteFile(configPath, configBytes, 0644) - if err != nil { - return fmt.Errorf("error writing configuration file: %v", err) - } - - fmt.Printf("Successfully initialized local project for Spark app '%s' at '%s'\n", identifier, configPath) - return nil + return writeRuntimeConfig(initCmdFlags.app, initCmdFlags.out) }, } From e38aa0720088082d3986c9b3ce71ef7053ba8a3d Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 18:25:57 +0000 Subject: [PATCH 03/10] Add tests and refactor a bit to support that --- cmd/client.go | 13 ++++ cmd/create.go | 131 ++++++++++++++++--------------- cmd/create_test.go | 113 +++++++++++++++++++++++++++ cmd/delete.go | 57 +++++++------- cmd/delete_test.go | 52 +++++++++++++ cmd/deploy.go | 188 +++++++++++++++++++++++---------------------- cmd/deploy_test.go | 139 +++++++++++++++++++++++++++++++++ cmd/get.go | 45 ++++++----- cmd/get_test.go | 107 ++++++++++++++++++++++++++ cmd/init.go | 98 +++++++++++------------ cmd/init_test.go | 83 ++++++++++++++++++++ cmd/mock_test.go | 115 +++++++++++++++++++++++++++ 12 files changed, 890 insertions(+), 251 deletions(-) create mode 100644 cmd/client.go create mode 100644 cmd/create_test.go create mode 100644 cmd/delete_test.go create mode 100644 cmd/deploy_test.go create mode 100644 cmd/get_test.go create mode 100644 cmd/init_test.go create mode 100644 cmd/mock_test.go diff --git a/cmd/client.go b/cmd/client.go new file mode 100644 index 0000000..31a0d6d --- /dev/null +++ b/cmd/client.go @@ -0,0 +1,13 @@ +package cmd + +import "io" + +// restClient is the subset of api.RESTClient methods needed by the various commands. +type restClient interface { + Get(path string, resp interface{}) error + Delete(path string, resp interface{}) error + Do(method string, path string, body io.Reader, resp interface{}) error + Patch(path string, body io.Reader, resp interface{}) error + Post(path string, body io.Reader, resp interface{}) error + Put(path string, body io.Reader, resp interface{}) error +} diff --git a/cmd/create.go b/cmd/create.go index 3aa6eb7..cb998c1 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -41,78 +41,19 @@ func init() { $ gh runtime create --app my-app --env key1=value1 --env key2=value2 --secret key3=value3 --secret key4=value4 # => Creates the app named 'my-app' `), - Run: func(cmd *cobra.Command, args []string) { - if createCmdFlags.app == "" { - fmt.Println("Error: --app flag is required") - return - } - - // Construct the request body - requestBody := createReq{ - EnvironmentVariables: map[string]string{}, - Secrets: map[string]string{}, - } - - for _, pair := range createCmdFlags.EnvironmentVariables { - parts := strings.SplitN(pair, "=", 2) - if len(parts) == 2 { - key := parts[0] - value := parts[1] - requestBody.EnvironmentVariables[key] = value - } else { - fmt.Printf("Error: Invalid environment variable format (%s). Must be in the form 'key=value'\n", pair) - return - } - } - - for _, pair := range createCmdFlags.Secrets { - parts := strings.SplitN(pair, "=", 2) - if len(parts) == 2 { - key := parts[0] - value := parts[1] - requestBody.Secrets[key] = value - } else { - fmt.Printf("Error: Invalid secret format (%s). Must be in the form 'key=value'\n", pair) - return - } - } - - body, err := json.Marshal(requestBody) - if err != nil { - fmt.Printf("Error marshalling request body: %v\n", err) - return - } - - createUrl := fmt.Sprintf("runtime/%s/deployment", createCmdFlags.app) - params := url.Values{} - if createCmdFlags.RevisionName != "" { - params.Add("revision_name", createCmdFlags.RevisionName) - } - if len(params) > 0 { - createUrl += "?" + params.Encode() - } - + RunE: func(cmd *cobra.Command, args []string) error { client, err := api.DefaultRESTClient() if err != nil { - fmt.Println(err) - return + return fmt.Errorf("failed creating REST client: %v", err) } - response := createResp{} - err = client.Put(createUrl, bytes.NewReader(body), &response) + + appUrl, err := runCreate(client, createCmdFlags) if err != nil { - fmt.Printf("Error creating app: %v\n", err) - return + return err } - fmt.Printf("App created: %s\n", response.AppUrl) // TODO pretty print details - - if createCmdFlags.Init { - err = writeRuntimeConfig(createCmdFlags.app, "") - if err != nil { - fmt.Printf("Error initializing config: %v\n", err) - return - } - } + fmt.Printf("App created: %s\n", appUrl) + return nil }, } @@ -123,3 +64,61 @@ func init() { createCmd.Flags().BoolVar(&createCmdFlags.Init, "init", false, "Initialize a runtime.config.json file in the current directory after creating the app") rootCmd.AddCommand(createCmd) } + +func runCreate(client restClient, flags createCmdFlags) (string, error) { + if flags.app == "" { + return "", fmt.Errorf("--app flag is required") + } + + requestBody := createReq{ + EnvironmentVariables: map[string]string{}, + Secrets: map[string]string{}, + } + + for _, pair := range flags.EnvironmentVariables { + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + requestBody.EnvironmentVariables[parts[0]] = parts[1] + } else { + return "", fmt.Errorf("invalid environment variable format (%s). Must be in the form 'key=value'", pair) + } + } + + for _, pair := range flags.Secrets { + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + requestBody.Secrets[parts[0]] = parts[1] + } else { + return "", fmt.Errorf("invalid secret format (%s). Must be in the form 'key=value'", pair) + } + } + + body, err := json.Marshal(requestBody) + if err != nil { + return "", fmt.Errorf("error marshalling request body: %v", err) + } + + createUrl := fmt.Sprintf("runtime/%s/deployment", flags.app) + params := url.Values{} + if flags.RevisionName != "" { + params.Add("revision_name", flags.RevisionName) + } + if len(params) > 0 { + createUrl += "?" + params.Encode() + } + + response := createResp{} + err = client.Put(createUrl, bytes.NewReader(body), &response) + if err != nil { + return "", fmt.Errorf("error creating app: %v", err) + } + + if flags.Init { + err = writeRuntimeConfig(flags.app, "") + if err != nil { + return response.AppUrl, fmt.Errorf("error initializing config: %v", err) + } + } + + return response.AppUrl, nil +} diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..f3539e9 --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunCreate_NoApp(t *testing.T) { + client := &mockRESTClient{} + _, err := runCreate(client, createCmdFlags{}) + require.ErrorContains(t, err, "--app flag is required") +} + +func TestRunCreate_InvalidEnvVarFormat(t *testing.T) { + client := &mockRESTClient{} + _, err := runCreate(client, createCmdFlags{app: "my-app", EnvironmentVariables: []string{"BADFORMAT"}}) + require.ErrorContains(t, err, "invalid environment variable format") +} + +func TestRunCreate_InvalidSecretFormat(t *testing.T) { + client := &mockRESTClient{} + _, err := runCreate(client, createCmdFlags{app: "my-app", Secrets: []string{"NOSEPARATOR"}}) + require.ErrorContains(t, err, "invalid secret format") +} + +func TestRunCreate_APIError(t *testing.T) { + client := &mockRESTClient{ + putFunc: mockPutError("server error"), + } + _, err := runCreate(client, createCmdFlags{app: "my-app", EnvironmentVariables: []string{"K=V"}}) + require.ErrorContains(t, err, "error creating app") +} + +func TestRunCreate_Success(t *testing.T) { + var capturedPath string + var capturedBody []byte + client := &mockRESTClient{ + putFunc: func(path string, body io.Reader, resp interface{}) error { + capturedPath = path + capturedBody, _ = io.ReadAll(body) + return json.Unmarshal([]byte(`{"app_url":"https://new-app.example.com"}`), resp) + }, + } + + appUrl, err := runCreate(client, createCmdFlags{ + app: "my-app", + EnvironmentVariables: []string{"KEY1=val1", "KEY2=val2"}, + Secrets: []string{"SECRET=sval"}, + }) + require.NoError(t, err) + assert.Equal(t, "https://new-app.example.com", appUrl) + assert.Equal(t, "runtime/my-app/deployment", capturedPath) + + var req createReq + json.Unmarshal(capturedBody, &req) + assert.Equal(t, "val1", req.EnvironmentVariables["KEY1"]) + assert.Equal(t, "sval", req.Secrets["SECRET"]) +} + +func TestRunCreate_WithRevisionName(t *testing.T) { + var capturedPath string + client := &mockRESTClient{ + putFunc: func(path string, body io.Reader, resp interface{}) error { + capturedPath = path + return json.Unmarshal([]byte(`{"app_url":"https://app.example.com"}`), resp) + }, + } + + _, err := runCreate(client, createCmdFlags{app: "my-app", RevisionName: "v2"}) + require.NoError(t, err) + assert.Contains(t, capturedPath, "revision_name=v2") +} + +func TestRunCreate_WithInit(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{ + putFunc: mockPutResponse(`{"app_url":"https://init-app.example.com"}`), + } + + appUrl, err := runCreate(client, createCmdFlags{app: "init-app", Init: true}) + require.NoError(t, err) + assert.Equal(t, "https://init-app.example.com", appUrl) + + data, err := os.ReadFile("runtime.config.json") + require.NoError(t, err, "expected runtime.config.json to be created") + assert.Contains(t, string(data), "init-app") +} + +func TestRunCreate_EnvVarWithEqualsInValue(t *testing.T) { + var capturedBody []byte + client := &mockRESTClient{ + putFunc: func(path string, body io.Reader, resp interface{}) error { + capturedBody, _ = io.ReadAll(body) + return json.Unmarshal([]byte(`{"app_url":"https://app.example.com"}`), resp) + }, + } + + _, err := runCreate(client, createCmdFlags{app: "my-app", EnvironmentVariables: []string{"KEY=val=with=equals"}}) + require.NoError(t, err) + + var req createReq + json.Unmarshal(capturedBody, &req) + assert.Equal(t, "val=with=equals", req.EnvironmentVariables["KEY"]) +} diff --git a/cmd/delete.go b/cmd/delete.go index 5051e85..9d78d92 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "net/url" + "github.com/MakeNowJust/heredoc" "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" @@ -13,9 +14,6 @@ type deleteCmdFlags struct { revisionName string } -type deleteResp struct { -} - func init() { deleteCmdFlags := deleteCmdFlags{} deleteCmd := &cobra.Command{ @@ -28,36 +26,19 @@ func init() { $ gh runtime delete --app my-app # => Deletes the app named 'my-app' `), - Run: func(cmd *cobra.Command, args []string) { - if deleteCmdFlags.app == "" { - fmt.Println("Error: --app flag is required") - return - } - - deleteUrl := fmt.Sprintf("runtime/%s/deployment", deleteCmdFlags.app) - params := url.Values{} - if deleteCmdFlags.revisionName != "" { - params.Add("revision_name", deleteCmdFlags.revisionName) - } - if len(params) > 0 { - deleteUrl += "?" + params.Encode() - } - + RunE: func(cmd *cobra.Command, args []string) error { client, err := api.DefaultRESTClient() if err != nil { - fmt.Println(err) - return + return fmt.Errorf("failed creating REST client: %v", err) } - var response string - err = client.Delete(deleteUrl, &response) + + response, err := runDelete(client, deleteCmdFlags) if err != nil { - // print err and response - fmt.Printf("Error deleting app: %v\n", err) - fmt.Printf("Response: %v\n", response) - return + return err } fmt.Printf("App deleted: %s\n", response) + return nil }, } @@ -65,3 +46,27 @@ func init() { deleteCmd.Flags().StringVarP(&deleteCmdFlags.revisionName, "revision-name", "r", "", "The revision name to use for the app") rootCmd.AddCommand(deleteCmd) } + +func runDelete(client restClient, flags deleteCmdFlags) (string, error) { + if flags.app == "" { + return "", fmt.Errorf("--app flag is required") + } + + deleteUrl := fmt.Sprintf("runtime/%s/deployment", flags.app) + params := url.Values{} + if flags.revisionName != "" { + params.Add("revision_name", flags.revisionName) + } + if len(params) > 0 { + deleteUrl += "?" + params.Encode() + } + + var response string + err := client.Delete(deleteUrl, &response) + if err != nil { + return response, fmt.Errorf("error deleting app: %v", err) + } + + // Actual response on success is empty body so return the ID + return flags.app, nil +} diff --git a/cmd/delete_test.go b/cmd/delete_test.go new file mode 100644 index 0000000..cf55ad3 --- /dev/null +++ b/cmd/delete_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunDelete_NoApp(t *testing.T) { + client := &mockRESTClient{} + _, err := runDelete(client, deleteCmdFlags{}) + require.ErrorContains(t, err, "--app flag is required") +} + +func TestRunDelete_Success(t *testing.T) { + var capturedPath string + client := &mockRESTClient{ + deleteFunc: func(path string, resp interface{}) error { + capturedPath = path + return nil + }, + } + + response, err := runDelete(client, deleteCmdFlags{app: "my-app"}) + require.NoError(t, err) + assert.Equal(t, "runtime/my-app/deployment", capturedPath) + assert.Equal(t, "my-app", response) +} + +func TestRunDelete_WithRevisionName(t *testing.T) { + var capturedPath string + client := &mockRESTClient{ + deleteFunc: func(path string, resp interface{}) error { + capturedPath = path + return nil + }, + } + + _, err := runDelete(client, deleteCmdFlags{app: "my-app", revisionName: "v2"}) + require.NoError(t, err) + assert.Contains(t, capturedPath, "revision_name=v2") +} + +func TestRunDelete_APIError(t *testing.T) { + client := &mockRESTClient{ + deleteFunc: mockDeleteError("not found"), + } + + _, err := runDelete(client, deleteCmdFlags{app: "my-app"}) + require.ErrorContains(t, err, "error deleting app") +} diff --git a/cmd/deploy.go b/cmd/deploy.go index 10b4b6a..a4bc885 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -23,6 +23,101 @@ type deployCmdFlags struct { config string } +func init() { + deployCmdFlags := deployCmdFlags{} + deployCmd := &cobra.Command{ + Use: "deploy", + Short: "Deploy app to GitHub Runtime", + Long: heredoc.Doc(` + Deploys a directory to a GitHub Runtime app. + You can specify the app name using --app flag, --config flag to read from a runtime config file, + or it will automatically read from runtime.config.json in the current directory if it exists. + `), + Example: heredoc.Doc(` + $ gh runtime deploy --dir ./dist --app my-app [--sha ] + # => Deploys the contents of the 'dist' directory to the app named 'my-app'. + + $ gh runtime deploy --dir ./dist --config runtime.config.json + # => Deploys using app name from the config file. + + $ gh runtime deploy --dir ./dist + # => Deploys using app name from runtime.config.json in current directory (if it exists). + `), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := api.DefaultRESTClient() + if err != nil { + return fmt.Errorf("error creating REST client: %v", err) + } + + return runDeploy(client, deployCmdFlags) + }, + } + deployCmd.Flags().StringVarP(&deployCmdFlags.dir, "dir", "d", "", "The directory to deploy") + deployCmd.Flags().StringVarP(&deployCmdFlags.app, "app", "a", "", "The app to deploy") + deployCmd.Flags().StringVarP(&deployCmdFlags.config, "config", "c", "", "Path to runtime config file") + deployCmd.Flags().StringVarP(&deployCmdFlags.revisionName, "revision-name", "r", "", "The revision name to deploy") + deployCmd.Flags().StringVarP(&deployCmdFlags.sha, "sha", "s", "", "SHA of the app being deployed") + + rootCmd.AddCommand(deployCmd) +} + +func runDeploy(client restClient, flags deployCmdFlags) error { + if flags.dir == "" { + return fmt.Errorf("--dir flag is required") + } + + appName, err := config.ResolveAppName(flags.app, flags.config) + if err != nil { + return err + } + + if _, err := os.Stat(flags.dir); os.IsNotExist(err) { + return fmt.Errorf("directory '%s' does not exist", flags.dir) + } + + _, err = os.ReadDir(flags.dir) + if err != nil { + return fmt.Errorf("error reading directory '%s': %v", flags.dir, err) + } + + zipPath := fmt.Sprintf("%s.zip", flags.dir) + err = zipDirectory(flags.dir, zipPath) + if err != nil { + return fmt.Errorf("error zipping directory '%s': %v", flags.dir, err) + } + defer os.Remove(zipPath) + + deploymentsUrl := fmt.Sprintf("runtime/%s/deployment/bundle", appName) + params := url.Values{} + + if flags.revisionName != "" { + params.Add("revision_name", flags.revisionName) + } + + if flags.sha != "" { + params.Add("revision", flags.sha) + } + + if len(params) > 0 { + deploymentsUrl += "?" + params.Encode() + } + + fmt.Printf("Deploying app to %s\n", deploymentsUrl) + + body, err := os.ReadFile(zipPath) + if err != nil { + return fmt.Errorf("error reading zip file '%s': %v", zipPath, err) + } + + err = client.Post(deploymentsUrl, bytes.NewReader(body), nil) + if err != nil { + return fmt.Errorf("error deploying app: %v", err) + } + + fmt.Printf("Successfully deployed app\n") + return nil +} + func zipDirectory(sourceDir, destinationZip string) error { zipFile, err := os.Create(destinationZip) if err != nil { @@ -86,96 +181,3 @@ func zipDirectory(sourceDir, destinationZip string) error { return nil } - -func init() { - deployCmdFlags := deployCmdFlags{} - deployCmd := &cobra.Command{ - Use: "deploy", - Short: "Deploy app to GitHub Runtime", - Long: heredoc.Doc(` - Deploys a directory to a GitHub Runtime app. - You can specify the app name using --app flag, --config flag to read from a runtime config file, - or it will automatically read from runtime.config.json in the current directory if it exists. - `), - Example: heredoc.Doc(` - $ gh runtime deploy --dir ./dist --app my-app [--sha ] - # => Deploys the contents of the 'dist' directory to the app named 'my-app'. - - $ gh runtime deploy --dir ./dist --config runtime.config.json - # => Deploys using app name from the config file. - - $ gh runtime deploy --dir ./dist - # => Deploys using app name from runtime.config.json in current directory (if it exists). - `), - RunE: func(cmd *cobra.Command, args []string) error { - if deployCmdFlags.dir == "" { - return fmt.Errorf("--dir flag is required") - } - - appName, err := config.ResolveAppName(deployCmdFlags.app, deployCmdFlags.config) - if err != nil { - return err - } - - if _, err := os.Stat(deployCmdFlags.dir); os.IsNotExist(err) { - return fmt.Errorf("directory '%s' does not exist", deployCmdFlags.dir) - } - - _, err = os.ReadDir(deployCmdFlags.dir) - if err != nil { - return fmt.Errorf("error reading directory '%s': %v", deployCmdFlags.dir, err) - } - - // Zip the directory - zipPath := fmt.Sprintf("%s.zip", deployCmdFlags.dir) - err = zipDirectory(deployCmdFlags.dir, zipPath) - if err != nil { - return fmt.Errorf("error zipping directory '%s': %v", deployCmdFlags.dir, err) - } - defer os.Remove(zipPath) - - client, err := api.DefaultRESTClient() - if err != nil { - return fmt.Errorf("error creating REST client: %v", err) - } - - deploymentsUrl := fmt.Sprintf("runtime/%s/deployment/bundle", appName) - params := url.Values{} - - if deployCmdFlags.revisionName != "" { - params.Add("revision_name", deployCmdFlags.revisionName) - } - - if deployCmdFlags.sha != "" { - params.Add("revision", deployCmdFlags.sha) - } - - if len(params) > 0 { - deploymentsUrl += "?" + params.Encode() - } - - fmt.Printf("Deploying app to %s\n", deploymentsUrl) - - // body is the full zip RAW - body, err := os.ReadFile(zipPath) - if err != nil { - return fmt.Errorf("error reading zip file '%s': %v", zipPath, err) - } - - err = client.Post(deploymentsUrl, bytes.NewReader(body), nil) - if err != nil { - return fmt.Errorf("error deploying app: %v", err) - } - - fmt.Printf("Successfully deployed app\n") - return nil - }, - } - deployCmd.Flags().StringVarP(&deployCmdFlags.dir, "dir", "d", "", "The directory to deploy") - deployCmd.Flags().StringVarP(&deployCmdFlags.app, "app", "a", "", "The app to deploy") - deployCmd.Flags().StringVarP(&deployCmdFlags.config, "config", "c", "", "Path to runtime config file") - deployCmd.Flags().StringVarP(&deployCmdFlags.revisionName, "revision-name", "r", "", "The revision name to deploy") - deployCmd.Flags().StringVarP(&deployCmdFlags.sha, "sha", "s", "", "SHA of the app being deployed") - - rootCmd.AddCommand(deployCmd) -} diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go new file mode 100644 index 0000000..4343222 --- /dev/null +++ b/cmd/deploy_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunDeploy_NoDir(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{} + err := runDeploy(client, deployCmdFlags{app: "my-app"}) + require.ErrorContains(t, err, "--dir flag is required") +} + +func TestRunDeploy_NoAppName(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + os.MkdirAll(deployDir, 0755) + + client := &mockRESTClient{} + err := runDeploy(client, deployCmdFlags{dir: deployDir}) + require.ErrorContains(t, err, "--app flag is required") +} + +func TestRunDeploy_DirNotExist(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{} + err := runDeploy(client, deployCmdFlags{dir: "/nonexistent/path", app: "my-app"}) + require.ErrorContains(t, err, "does not exist") +} + +func TestRunDeploy_Success(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + os.MkdirAll(deployDir, 0755) + os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644) + + var capturedPath string + client := &mockRESTClient{ + postFunc: func(path string, body io.Reader, resp interface{}) error { + capturedPath = path + return nil + }, + } + + err := runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app"}) + require.NoError(t, err) + assert.Equal(t, "runtime/my-app/deployment/bundle", capturedPath) + require.NoFileExists(t, deployDir+".zip") +} + +func TestRunDeploy_WithRevisionAndSha(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + os.MkdirAll(deployDir, 0755) + os.WriteFile(filepath.Join(deployDir, "app.js"), []byte("console.log('hi')"), 0644) + + var capturedPath string + client := &mockRESTClient{ + postFunc: func(path string, body io.Reader, resp interface{}) error { + capturedPath = path + return nil + }, + } + + err := runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app", revisionName: "v2", sha: "abc123"}) + require.NoError(t, err) + assert.Contains(t, capturedPath, "revision_name=v2") + assert.Contains(t, capturedPath, "revision=abc123") +} + +func TestRunDeploy_APIError(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + os.MkdirAll(deployDir, 0755) + os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644) + + client := &mockRESTClient{ + postFunc: mockPostError("upload failed"), + } + + err := runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app"}) + require.ErrorContains(t, err, "error deploying app") +} + +func TestRunDeploy_WithConfigFile(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + os.MkdirAll(deployDir, 0755) + os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644) + + configPath := filepath.Join(tmp, "my-config.json") + os.WriteFile(configPath, []byte(`{"app":"config-deploy-app"}`), 0644) + + var capturedPath string + client := &mockRESTClient{ + postFunc: func(path string, body io.Reader, resp interface{}) error { + capturedPath = path + return nil + }, + } + + err := runDeploy(client, deployCmdFlags{dir: deployDir, config: configPath}) + require.NoError(t, err) + assert.Contains(t, capturedPath, "config-deploy-app") +} diff --git a/cmd/get.go b/cmd/get.go index c337313..7ca803e 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -41,32 +41,17 @@ func init() { # => Retrieves details using app name from runtime.config.json in current directory (if it exists). `), RunE: func(cmd *cobra.Command, args []string) error { - appName, err := config.ResolveAppName(getCmdFlags.app, getCmdFlags.config) - if err != nil { - return err - } - - getUrl := fmt.Sprintf("runtime/%s/deployment", appName) - params := url.Values{} - if getCmdFlags.revisionName != "" { - params.Add("revision_name", getCmdFlags.revisionName) - } - if len(params) > 0 { - getUrl += "?" + params.Encode() - } - client, err := api.DefaultRESTClient() if err != nil { return fmt.Errorf("failed creating REST client: %v", err) } - response := serverResponse{} - err = client.Get(getUrl, &response) + appUrl, err := runGet(client, getCmdFlags) if err != nil { - return fmt.Errorf("retrieving app details: %v", err) + return err } - fmt.Printf("%s\n", response.AppUrl) + fmt.Printf("%s\n", appUrl) return nil }, } @@ -76,3 +61,27 @@ func init() { getCmd.Flags().StringVarP(&getCmdFlags.revisionName, "revision-name", "r", "", "The revision name to use for the app") rootCmd.AddCommand(getCmd) } + +func runGet(client restClient, flags getCmdFlags) (string, error) { + appName, err := config.ResolveAppName(flags.app, flags.config) + if err != nil { + return "", err + } + + getUrl := fmt.Sprintf("runtime/%s/deployment", appName) + params := url.Values{} + if flags.revisionName != "" { + params.Add("revision_name", flags.revisionName) + } + if len(params) > 0 { + getUrl += "?" + params.Encode() + } + + response := serverResponse{} + err = client.Get(getUrl, &response) + if err != nil { + return "", fmt.Errorf("retrieving app details: %v", err) + } + + return response.AppUrl, nil +} diff --git a/cmd/get_test.go b/cmd/get_test.go new file mode 100644 index 0000000..abe2bea --- /dev/null +++ b/cmd/get_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunGet_NoAppName(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{} + _, err := runGet(client, getCmdFlags{}) + require.ErrorContains(t, err, "--app flag is required") +} + +func TestRunGet_Success(t *testing.T) { + var capturedPath string + client := &mockRESTClient{ + getFunc: func(path string, resp interface{}) error { + capturedPath = path + return json.Unmarshal([]byte(`{"app_url":"https://my-app.example.com"}`), resp) + }, + } + + appUrl, err := runGet(client, getCmdFlags{app: "my-app"}) + require.NoError(t, err) + assert.Equal(t, "runtime/my-app/deployment", capturedPath) + assert.Equal(t, "https://my-app.example.com", appUrl) +} + +func TestRunGet_WithRevisionName(t *testing.T) { + var capturedPath string + client := &mockRESTClient{ + getFunc: func(path string, resp interface{}) error { + capturedPath = path + return json.Unmarshal([]byte(`{"app_url":"https://my-app-v2.example.com"}`), resp) + }, + } + + appUrl, err := runGet(client, getCmdFlags{app: "my-app", revisionName: "v2"}) + require.NoError(t, err) + assert.Contains(t, capturedPath, "runtime/my-app/deployment") + assert.Contains(t, capturedPath, "revision_name=v2") + assert.Equal(t, "https://my-app-v2.example.com", appUrl) +} + +func TestRunGet_APIError(t *testing.T) { + client := &mockRESTClient{ + getFunc: mockGetError("server error 500"), + } + + _, err := runGet(client, getCmdFlags{app: "my-app"}) + require.ErrorContains(t, err, "retrieving app details") +} + +func TestRunGet_WithConfigFile(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + configPath := filepath.Join(tmp, "my-config.json") + os.WriteFile(configPath, []byte(`{"app":"config-app"}`), 0644) + + var capturedPath string + client := &mockRESTClient{ + getFunc: func(path string, resp interface{}) error { + capturedPath = path + return json.Unmarshal([]byte(`{"app_url":"https://config-app.example.com"}`), resp) + }, + } + + appUrl, err := runGet(client, getCmdFlags{config: configPath}) + require.NoError(t, err) + assert.Equal(t, capturedPath, "runtime/config-app/deployment") + assert.Equal(t, "https://config-app.example.com", appUrl) +} + +func TestRunGet_DefaultConfigFile(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + os.WriteFile(filepath.Join(tmp, "runtime.config.json"), []byte(`{"app":"default-app"}`), 0644) + + var capturedPath string + client := &mockRESTClient{ + getFunc: func(path string, resp interface{}) error { + capturedPath = path + return json.Unmarshal([]byte(`{"app_url":"https://default-app.example.com"}`), resp) + }, + } + + appUrl, err := runGet(client, getCmdFlags{}) + require.NoError(t, err) + assert.Equal(t, capturedPath, "runtime/default-app/deployment") + assert.Equal(t, "https://default-app.example.com", appUrl) +} diff --git a/cmd/init.go b/cmd/init.go index 6f6305d..985081c 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -21,39 +21,6 @@ type appResponse struct { AppUrl string `json:"app_url"` } -// writeRuntimeConfig writes a runtime.config.json file for the given app. -// If outPath is empty, it defaults to "runtime.config.json" in the current directory. -func writeRuntimeConfig(app string, outPath string) error { - configStruct := config.RuntimeConfig{ - App: app, - } - - configPath := "runtime.config.json" - if outPath != "" { - configPath = outPath - outputDir := filepath.Dir(configPath) - if outputDir != "." { - err := os.MkdirAll(outputDir, 0755) - if err != nil { - return fmt.Errorf("error creating directory '%s': %v", outputDir, err) - } - } - } - - configBytes, err := json.MarshalIndent(configStruct, "", " ") - if err != nil { - return fmt.Errorf("error creating configuration: %v", err) - } - - err = os.WriteFile(configPath, configBytes, 0644) - if err != nil { - return fmt.Errorf("error writing configuration file: %v", err) - } - - fmt.Printf("Successfully initialized local project for Spark app '%s' at '%s'\n", app, configPath) - return nil -} - func init() { initCmdFlags := initCmdFlags{} initCmd := &cobra.Command{ @@ -76,26 +43,12 @@ func init() { # => Creates configuration with a custom filename `), RunE: func(cmd *cobra.Command, args []string) error { - if initCmdFlags.app == "" { - return fmt.Errorf("--app flag is required") - } - - identifier := initCmdFlags.app - - getUrl := fmt.Sprintf("runtime/%s/deployment", identifier) - client, err := api.DefaultRESTClient() if err != nil { return fmt.Errorf("failed creating REST client: %v", err) } - response := appResponse{} - err = client.Get(getUrl, &response) - if err != nil { - return fmt.Errorf("app '%s' does not exist or is not accessible: %v", identifier, err) - } - - return writeRuntimeConfig(initCmdFlags.app, initCmdFlags.out) + return runInit(client, initCmdFlags) }, } @@ -103,3 +56,52 @@ func init() { initCmd.Flags().StringVarP(&initCmdFlags.out, "out", "o", "", "The output path for the runtime.config.json file (default: runtime.config.json in current directory)") rootCmd.AddCommand(initCmd) } + +func runInit(client restClient, flags initCmdFlags) error { + if flags.app == "" { + return fmt.Errorf("--app flag is required") + } + + getUrl := fmt.Sprintf("runtime/%s/deployment", flags.app) + + response := appResponse{} + err := client.Get(getUrl, &response) + if err != nil { + return fmt.Errorf("app '%s' does not exist or is not accessible: %v", flags.app, err) + } + + return writeRuntimeConfig(flags.app, flags.out) +} + +// writeRuntimeConfig writes a runtime.config.json file for the given app. +// If outPath is empty, it defaults to "runtime.config.json" in the current directory. +func writeRuntimeConfig(app string, outPath string) error { + configStruct := config.RuntimeConfig{ + App: app, + } + + configPath := "runtime.config.json" + if outPath != "" { + configPath = outPath + outputDir := filepath.Dir(configPath) + if outputDir != "." { + err := os.MkdirAll(outputDir, 0755) + if err != nil { + return fmt.Errorf("error creating directory '%s': %v", outputDir, err) + } + } + } + + configBytes, err := json.MarshalIndent(configStruct, "", " ") + if err != nil { + return fmt.Errorf("error creating configuration: %v", err) + } + + err = os.WriteFile(configPath, configBytes, 0644) + if err != nil { + return fmt.Errorf("error writing configuration file: %v", err) + } + + fmt.Printf("Successfully initialized local project for Spark app '%s' at '%s'\n", app, configPath) + return nil +} diff --git a/cmd/init_test.go b/cmd/init_test.go new file mode 100644 index 0000000..5f72898 --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunInit_NoApp(t *testing.T) { + client := &mockRESTClient{} + err := runInit(client, initCmdFlags{}) + require.ErrorContains(t, err, "--app flag is required") +} + +func TestRunInit_AppNotAccessible(t *testing.T) { + client := &mockRESTClient{ + getFunc: mockGetError("404 not found"), + } + + err := runInit(client, initCmdFlags{app: "bad-app"}) + require.ErrorContains(t, err, "does not exist or is not accessible") +} + +func TestRunInit_Success_DefaultPath(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + var capturedPath string + client := &mockRESTClient{ + getFunc: func(path string, resp interface{}) error { + capturedPath = path + return json.Unmarshal([]byte(`{"app_url":"https://my-app.example.com"}`), resp) + }, + } + + err := runInit(client, initCmdFlags{app: "my-app"}) + require.NoError(t, err) + assert.Equal(t, "runtime/my-app/deployment", capturedPath) + + data, err := os.ReadFile("runtime.config.json") + require.NoError(t, err, "expected runtime.config.json to be created") + assert.Contains(t, string(data), "my-app") +} + +func TestRunInit_Success_CustomOutPath(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{ + getFunc: mockGetResponse(`{"app_url":"https://my-app.example.com"}`), + } + + outPath := filepath.Join(tmp, "subdir", "custom-config.json") + err := runInit(client, initCmdFlags{app: "my-app", out: outPath}) + require.NoError(t, err) + + data, err := os.ReadFile(outPath) + require.NoError(t, err, "expected config file at custom path") + assert.Contains(t, string(data), "my-app") +} + +func TestRunInit_VerifiesAPIBeforeWriting(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{ + getFunc: mockGetError("not found"), + } + + err := runInit(client, initCmdFlags{app: "bad-app"}) + require.Error(t, err) + require.NoFileExists(t, "runtime.config.json") +} diff --git a/cmd/mock_test.go b/cmd/mock_test.go new file mode 100644 index 0000000..4e8f250 --- /dev/null +++ b/cmd/mock_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" +) + +// mockRESTClient implements the restClient interface for testing. +type mockRESTClient struct { + getFunc func(path string, resp interface{}) error + putFunc func(path string, body io.Reader, resp interface{}) error + deleteFunc func(path string, resp interface{}) error + postFunc func(path string, body io.Reader, resp interface{}) error + patchFunc func(path string, body io.Reader, resp interface{}) error + doFunc func(method string, path string, body io.Reader, resp interface{}) error +} + +func (m *mockRESTClient) Get(path string, resp interface{}) error { + if m.getFunc != nil { + return m.getFunc(path, resp) + } + return fmt.Errorf("Get not implemented") +} + +func (m *mockRESTClient) Put(path string, body io.Reader, resp interface{}) error { + if m.putFunc != nil { + return m.putFunc(path, body, resp) + } + return fmt.Errorf("Put not implemented") +} + +func (m *mockRESTClient) Delete(path string, resp interface{}) error { + if m.deleteFunc != nil { + return m.deleteFunc(path, resp) + } + return fmt.Errorf("Delete not implemented") +} + +func (m *mockRESTClient) Post(path string, body io.Reader, resp interface{}) error { + if m.postFunc != nil { + return m.postFunc(path, body, resp) + } + return fmt.Errorf("Post not implemented") +} + +func (m *mockRESTClient) Patch(path string, body io.Reader, resp interface{}) error { + if m.patchFunc != nil { + return m.patchFunc(path, body, resp) + } + return fmt.Errorf("Patch not implemented") +} + +func (m *mockRESTClient) Do(method string, path string, body io.Reader, resp interface{}) error { + if m.doFunc != nil { + return m.doFunc(method, path, body, resp) + } + return fmt.Errorf("Do not implemented") +} + +// mockGetResponse is a helper that configures the mock to return a JSON-decoded response. +func mockGetResponse(jsonBody string) func(path string, resp interface{}) error { + return func(path string, resp interface{}) error { + return json.Unmarshal([]byte(jsonBody), resp) + } +} + +// mockGetError is a helper that configures the mock to return an error. +func mockGetError(errMsg string) func(path string, resp interface{}) error { + return func(path string, resp interface{}) error { + return fmt.Errorf("%s", errMsg) + } +} + +// mockPutResponse is a helper that configures the mock to return a JSON-decoded response for Put. +func mockPutResponse(jsonBody string) func(path string, body io.Reader, resp interface{}) error { + return func(path string, body io.Reader, resp interface{}) error { + return json.Unmarshal([]byte(jsonBody), resp) + } +} + +// mockPutError is a helper that configures the mock to return an error for Put. +func mockPutError(errMsg string) func(path string, body io.Reader, resp interface{}) error { + return func(path string, body io.Reader, resp interface{}) error { + return fmt.Errorf("%s", errMsg) + } +} + +// mockDeleteResponse is a helper for Delete. +func mockDeleteResponse(jsonBody string) func(path string, resp interface{}) error { + return func(path string, resp interface{}) error { + return json.Unmarshal([]byte(jsonBody), resp) + } +} + +// mockDeleteError is a helper that configures the mock to return an error for Delete. +func mockDeleteError(errMsg string) func(path string, resp interface{}) error { + return func(path string, resp interface{}) error { + return fmt.Errorf("%s", errMsg) + } +} + +// mockPostSuccess is a helper for Post returning nil. +func mockPostSuccess() func(path string, body io.Reader, resp interface{}) error { + return func(path string, body io.Reader, resp interface{}) error { + return nil + } +} + +// mockPostError is a helper that configures the mock to return an error for Post. +func mockPostError(errMsg string) func(path string, body io.Reader, resp interface{}) error { + return func(path string, body io.Reader, resp interface{}) error { + return fmt.Errorf("%s", errMsg) + } +} From a3840c23c9b13f468049f5a144d2ecdcf6f8d38c Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 19:01:05 +0000 Subject: [PATCH 04/10] Wire in --app vs --name handling --- cmd/create.go | 48 +++++++++++------ cmd/create_test.go | 106 +++++++++++++++++++++++++++++++++----- cmd/delete.go | 4 +- cmd/deploy.go | 10 ++-- cmd/get.go | 10 ++-- cmd/init.go | 4 +- internal/config/config.go | 6 +-- 7 files changed, 143 insertions(+), 45 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index cb998c1..22e595b 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -14,6 +14,7 @@ import ( type createCmdFlags struct { app string + name string EnvironmentVariables []string Secrets []string RevisionName string @@ -27,6 +28,7 @@ type createReq struct { type createResp struct { AppUrl string `json:"app_url"` + ID string `json:"id"` } func init() { @@ -39,7 +41,10 @@ func init() { `), Example: heredoc.Doc(` $ gh runtime create --app my-app --env key1=value1 --env key2=value2 --secret key3=value3 --secret key4=value4 - # => Creates the app named 'my-app' + # => Creates the app with the ID 'my-app' + + $ gh runtime create --name my-new-app + # => Creates a new app with the given name `), RunE: func(cmd *cobra.Command, args []string) error { client, err := api.DefaultRESTClient() @@ -47,17 +52,21 @@ func init() { return fmt.Errorf("failed creating REST client: %v", err) } - appUrl, err := runCreate(client, createCmdFlags) + resp, err := runCreate(client, createCmdFlags) if err != nil { return err } - fmt.Printf("App created: %s\n", appUrl) + fmt.Printf("App created: %s\n", resp.AppUrl) + if resp.ID != "" { + fmt.Printf("ID: %s\n", resp.ID) + } return nil }, } - createCmd.Flags().StringVarP(&createCmdFlags.app, "app", "a", "", "The app to create") + createCmd.Flags().StringVarP(&createCmdFlags.app, "app", "a", "", "The app ID to create") + createCmd.Flags().StringVarP(&createCmdFlags.name, "name", "n", "", "The name for the new app") createCmd.Flags().StringSliceVarP(&createCmdFlags.EnvironmentVariables, "env", "e", []string{}, "Environment variables to set on the app in the form 'key=value'") createCmd.Flags().StringSliceVarP(&createCmdFlags.Secrets, "secret", "s", []string{}, "Secrets to set on the app in the form 'key=value'") createCmd.Flags().StringVarP(&createCmdFlags.RevisionName, "revision-name", "r", "", "The revision name to use for the app") @@ -65,9 +74,9 @@ func init() { rootCmd.AddCommand(createCmd) } -func runCreate(client restClient, flags createCmdFlags) (string, error) { - if flags.app == "" { - return "", fmt.Errorf("--app flag is required") +func runCreate(client restClient, flags createCmdFlags) (createResp, error) { + if flags.app == "" && flags.name == "" { + return createResp{}, fmt.Errorf("either --app or --name flag is required") } requestBody := createReq{ @@ -80,7 +89,7 @@ func runCreate(client restClient, flags createCmdFlags) (string, error) { if len(parts) == 2 { requestBody.EnvironmentVariables[parts[0]] = parts[1] } else { - return "", fmt.Errorf("invalid environment variable format (%s). Must be in the form 'key=value'", pair) + return createResp{}, fmt.Errorf("invalid environment variable format (%s). Must be in the form 'key=value'", pair) } } @@ -89,16 +98,22 @@ func runCreate(client restClient, flags createCmdFlags) (string, error) { if len(parts) == 2 { requestBody.Secrets[parts[0]] = parts[1] } else { - return "", fmt.Errorf("invalid secret format (%s). Must be in the form 'key=value'", pair) + return createResp{}, fmt.Errorf("invalid secret format (%s). Must be in the form 'key=value'", pair) } } body, err := json.Marshal(requestBody) if err != nil { - return "", fmt.Errorf("error marshalling request body: %v", err) + return createResp{}, fmt.Errorf("error marshalling request body: %v", err) + } + + var createUrl string + if flags.name != "" { + createUrl = "runtime" + } else { + createUrl = fmt.Sprintf("runtime/%s/deployment", flags.app) } - createUrl := fmt.Sprintf("runtime/%s/deployment", flags.app) params := url.Values{} if flags.RevisionName != "" { params.Add("revision_name", flags.RevisionName) @@ -110,15 +125,18 @@ func runCreate(client restClient, flags createCmdFlags) (string, error) { response := createResp{} err = client.Put(createUrl, bytes.NewReader(body), &response) if err != nil { - return "", fmt.Errorf("error creating app: %v", err) + return createResp{}, fmt.Errorf("error creating app: %v", err) } if flags.Init { - err = writeRuntimeConfig(flags.app, "") + if response.ID == "" { + return response, fmt.Errorf("error initializing config: server did not return an app ID") + } + err = writeRuntimeConfig(response.ID, "") if err != nil { - return response.AppUrl, fmt.Errorf("error initializing config: %v", err) + return response, fmt.Errorf("error initializing config: %v", err) } } - return response.AppUrl, nil + return response, nil } diff --git a/cmd/create_test.go b/cmd/create_test.go index f3539e9..32c42a1 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -10,10 +10,20 @@ import ( "github.com/stretchr/testify/require" ) -func TestRunCreate_NoApp(t *testing.T) { +func buildCreateResponse(r createResp, resp interface{}) { + if r.AppUrl == "" { + r.AppUrl = "https://test-app.example.com" + } + if r.ID == "" { + r.ID = "test-app-id" + } + *resp.(*createResp) = r +} + +func TestRunCreate_NoAppOrName(t *testing.T) { client := &mockRESTClient{} _, err := runCreate(client, createCmdFlags{}) - require.ErrorContains(t, err, "--app flag is required") + require.ErrorContains(t, err, "either --app or --name flag is required") } func TestRunCreate_InvalidEnvVarFormat(t *testing.T) { @@ -43,17 +53,18 @@ func TestRunCreate_Success(t *testing.T) { putFunc: func(path string, body io.Reader, resp interface{}) error { capturedPath = path capturedBody, _ = io.ReadAll(body) - return json.Unmarshal([]byte(`{"app_url":"https://new-app.example.com"}`), resp) + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com"}, resp) + return nil }, } - appUrl, err := runCreate(client, createCmdFlags{ + resp, err := runCreate(client, createCmdFlags{ app: "my-app", EnvironmentVariables: []string{"KEY1=val1", "KEY2=val2"}, Secrets: []string{"SECRET=sval"}, }) require.NoError(t, err) - assert.Equal(t, "https://new-app.example.com", appUrl) + assert.Equal(t, "https://my-app.example.com", resp.AppUrl) assert.Equal(t, "runtime/my-app/deployment", capturedPath) var req createReq @@ -65,9 +76,10 @@ func TestRunCreate_Success(t *testing.T) { func TestRunCreate_WithRevisionName(t *testing.T) { var capturedPath string client := &mockRESTClient{ - putFunc: func(path string, body io.Reader, resp interface{}) error { + putFunc: func(path string, _ io.Reader, resp interface{}) error { capturedPath = path - return json.Unmarshal([]byte(`{"app_url":"https://app.example.com"}`), resp) + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com"}, resp) + return nil }, } @@ -83,24 +95,28 @@ func TestRunCreate_WithInit(t *testing.T) { defer os.Chdir(origDir) client := &mockRESTClient{ - putFunc: mockPutResponse(`{"app_url":"https://init-app.example.com"}`), + putFunc: func(_ string, _ io.Reader, resp interface{}) error { + buildCreateResponse(createResp{AppUrl: "https://init-app.example.com", ID: "init-app-id"}, resp) + return nil + }, } - appUrl, err := runCreate(client, createCmdFlags{app: "init-app", Init: true}) + resp, err := runCreate(client, createCmdFlags{app: "init-app", Init: true}) require.NoError(t, err) - assert.Equal(t, "https://init-app.example.com", appUrl) + assert.Equal(t, "https://init-app.example.com", resp.AppUrl) data, err := os.ReadFile("runtime.config.json") require.NoError(t, err, "expected runtime.config.json to be created") - assert.Contains(t, string(data), "init-app") + assert.Contains(t, string(data), "init-app-id") } func TestRunCreate_EnvVarWithEqualsInValue(t *testing.T) { var capturedBody []byte client := &mockRESTClient{ - putFunc: func(path string, body io.Reader, resp interface{}) error { + putFunc: func(_ string, body io.Reader, resp interface{}) error { capturedBody, _ = io.ReadAll(body) - return json.Unmarshal([]byte(`{"app_url":"https://app.example.com"}`), resp) + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com"}, resp) + return nil }, } @@ -111,3 +127,67 @@ func TestRunCreate_EnvVarWithEqualsInValue(t *testing.T) { json.Unmarshal(capturedBody, &req) assert.Equal(t, "val=with=equals", req.EnvironmentVariables["KEY"]) } + +func TestRunCreate_WithName(t *testing.T) { + var capturedPath string + client := &mockRESTClient{ + putFunc: func(path string, _ io.Reader, resp interface{}) error { + capturedPath = path + buildCreateResponse(createResp{AppUrl: "https://my-new-app.example.com", ID: "abc-123"}, resp) + return nil + }, + } + + resp, err := runCreate(client, createCmdFlags{name: "my-new-app"}) + require.NoError(t, err) + assert.Equal(t, "https://my-new-app.example.com", resp.AppUrl) + assert.Equal(t, "abc-123", resp.ID) + assert.Equal(t, "runtime", capturedPath) +} + +func TestRunCreate_WithNameAndInit(t *testing.T) { + tmp := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmp) + defer os.Chdir(origDir) + + client := &mockRESTClient{ + putFunc: func(_ string, _ io.Reader, resp interface{}) error { + buildCreateResponse(createResp{AppUrl: "https://named-app.example.com", ID: "def-456"}, resp) + return nil + }, + } + + _, err := runCreate(client, createCmdFlags{name: "named-app", Init: true}) + require.NoError(t, err) + + data, err := os.ReadFile("runtime.config.json") + require.NoError(t, err) + assert.Contains(t, string(data), "def-456") +} + +func TestRunCreate_ResponseWithID(t *testing.T) { + client := &mockRESTClient{ + putFunc: func(_ string, _ io.Reader, resp interface{}) error { + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com", ID: "xyz-789"}, resp) + return nil + }, + } + + resp, err := runCreate(client, createCmdFlags{app: "my-app"}) + require.NoError(t, err) + assert.Equal(t, "https://my-app.example.com", resp.AppUrl) + assert.Equal(t, "xyz-789", resp.ID) +} + +func TestRunCreate_InitWithoutIDInResponse(t *testing.T) { + client := &mockRESTClient{ + putFunc: func(_ string, _ io.Reader, resp interface{}) error { + b, _ := json.Marshal(createResp{AppUrl: "https://my-app.example.com"}) + return json.Unmarshal(b, resp) + }, + } + + _, err := runCreate(client, createCmdFlags{app: "my-app", Init: true}) + require.ErrorContains(t, err, "server did not return an app ID") +} diff --git a/cmd/delete.go b/cmd/delete.go index 9d78d92..f3b0630 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -24,7 +24,7 @@ func init() { `), Example: heredoc.Doc(` $ gh runtime delete --app my-app - # => Deletes the app named 'my-app' + # => Deletes the app with ID 'my-app' `), RunE: func(cmd *cobra.Command, args []string) error { client, err := api.DefaultRESTClient() @@ -42,7 +42,7 @@ func init() { }, } - deleteCmd.Flags().StringVarP(&deleteCmdFlags.app, "app", "a", "", "The app to delete") + deleteCmd.Flags().StringVarP(&deleteCmdFlags.app, "app", "a", "", "The app ID to delete") deleteCmd.Flags().StringVarP(&deleteCmdFlags.revisionName, "revision-name", "r", "", "The revision name to use for the app") rootCmd.AddCommand(deleteCmd) } diff --git a/cmd/deploy.go b/cmd/deploy.go index a4bc885..ea98b31 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -30,18 +30,18 @@ func init() { Short: "Deploy app to GitHub Runtime", Long: heredoc.Doc(` Deploys a directory to a GitHub Runtime app. - You can specify the app name using --app flag, --config flag to read from a runtime config file, + You can specify the app ID using --app flag, --config flag to read from a runtime config file, or it will automatically read from runtime.config.json in the current directory if it exists. `), Example: heredoc.Doc(` $ gh runtime deploy --dir ./dist --app my-app [--sha ] - # => Deploys the contents of the 'dist' directory to the app named 'my-app'. + # => Deploys the contents of the 'dist' directory to the app with ID 'my-app'. $ gh runtime deploy --dir ./dist --config runtime.config.json - # => Deploys using app name from the config file. + # => Deploys using app ID from the config file. $ gh runtime deploy --dir ./dist - # => Deploys using app name from runtime.config.json in current directory (if it exists). + # => Deploys using app ID from runtime.config.json in current directory (if it exists). `), RunE: func(cmd *cobra.Command, args []string) error { client, err := api.DefaultRESTClient() @@ -53,7 +53,7 @@ func init() { }, } deployCmd.Flags().StringVarP(&deployCmdFlags.dir, "dir", "d", "", "The directory to deploy") - deployCmd.Flags().StringVarP(&deployCmdFlags.app, "app", "a", "", "The app to deploy") + deployCmd.Flags().StringVarP(&deployCmdFlags.app, "app", "a", "", "The app ID to deploy") deployCmd.Flags().StringVarP(&deployCmdFlags.config, "config", "c", "", "Path to runtime config file") deployCmd.Flags().StringVarP(&deployCmdFlags.revisionName, "revision-name", "r", "", "The revision name to deploy") deployCmd.Flags().StringVarP(&deployCmdFlags.sha, "sha", "s", "", "SHA of the app being deployed") diff --git a/cmd/get.go b/cmd/get.go index 7ca803e..fdf611b 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -27,18 +27,18 @@ func init() { Short: "Get details of a GitHub Runtime app", Long: heredoc.Doc(` Get details of a GitHub Runtime app. - You can specify the app name using --app flag, --config flag to read from a runtime config file, + You can specify the app ID using --app flag, --config flag to read from a runtime config file, or it will automatically read from runtime.config.json in the current directory if it exists. `), Example: heredoc.Doc(` $ gh runtime get --app my-app - # => Retrieves details of the app named 'my-app' + # => Retrieves details of the app with ID 'my-app' $ gh runtime get --config runtime.config.json - # => Retrieves details using app name from the config file. + # => Retrieves details using app ID from the config file. $ gh runtime get - # => Retrieves details using app name from runtime.config.json in current directory (if it exists). + # => Retrieves details using app ID from runtime.config.json in current directory (if it exists). `), RunE: func(cmd *cobra.Command, args []string) error { client, err := api.DefaultRESTClient() @@ -56,7 +56,7 @@ func init() { }, } - getCmd.Flags().StringVarP(&getCmdFlags.app, "app", "a", "", "The app to retrieve details for") + getCmd.Flags().StringVarP(&getCmdFlags.app, "app", "a", "", "The app ID to retrieve details for") getCmd.Flags().StringVarP(&getCmdFlags.config, "config", "c", "", "Path to runtime config file") getCmd.Flags().StringVarP(&getCmdFlags.revisionName, "revision-name", "r", "", "The revision name to use for the app") rootCmd.AddCommand(getCmd) diff --git a/cmd/init.go b/cmd/init.go index 985081c..347e0ef 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -29,7 +29,7 @@ func init() { Long: heredoc.Doc(` Initialize a local project to connect it to a GitHub Spark app. This creates a runtime.config.json configuration file that binds your local project - to a remote Spark app. You must specify an app name to validate the app exists. + to a remote Spark app. You must specify an app ID to validate the app exists. Optionally specify an output path where the runtime.config.json file should be created. `), Example: heredoc.Doc(` @@ -52,7 +52,7 @@ func init() { }, } - initCmd.Flags().StringVarP(&initCmdFlags.app, "app", "a", "", "The app name to initialize") + initCmd.Flags().StringVarP(&initCmdFlags.app, "app", "a", "", "The app ID to initialize") initCmd.Flags().StringVarP(&initCmdFlags.out, "out", "o", "", "The output path for the runtime.config.json file (default: runtime.config.json in current directory)") rootCmd.AddCommand(initCmd) } diff --git a/internal/config/config.go b/internal/config/config.go index 4d4f605..a45a10c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,11 +27,11 @@ func ReadRuntimeConfig(configPath string) (string, error) { return config.App, nil } -// ResolveAppName resolves the app name using the priority order: +// ResolveAppName resolves the app ID using the priority order: // 1. appFlag (--app) if provided // 2. configPath (--config) if provided // 3. runtime.config.json in current directory if it exists -// Returns an error if no app name can be resolved +// Returns an error if no app ID can be resolved func ResolveAppName(appFlag, configPath string) (string, error) { // Priority 1: Use --app flag if provided if appFlag != "" { @@ -52,6 +52,6 @@ func ResolveAppName(appFlag, configPath string) (string, error) { return appName, nil } - // No app name could be resolved + // No app ID could be resolved return "", fmt.Errorf("--app flag is required, --config must be specified, or runtime.config.json must exist in current directory") } From d3a5439fb6bf6b7a39eed1fc8fc1c25187d34cf8 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 19:09:59 +0000 Subject: [PATCH 05/10] Wire through visibility and name to respective values on API call --- cmd/create.go | 8 +++++++- cmd/create_test.go | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 22e595b..e0fc092 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -15,6 +15,7 @@ import ( type createCmdFlags struct { app string name string + visibility string EnvironmentVariables []string Secrets []string RevisionName string @@ -22,6 +23,8 @@ type createCmdFlags struct { } type createReq struct { + Name string `json:"friendly_name,omitempty"` + Visibility string `json:"visibility,omitempty"` EnvironmentVariables map[string]string `json:"environment_variables"` Secrets map[string]string `json:"secrets"` } @@ -66,7 +69,8 @@ func init() { } createCmd.Flags().StringVarP(&createCmdFlags.app, "app", "a", "", "The app ID to create") - createCmd.Flags().StringVarP(&createCmdFlags.name, "name", "n", "", "The name for the new app") + createCmd.Flags().StringVarP(&createCmdFlags.name, "name", "n", "", "The name for the app") + createCmd.Flags().StringVarP(&createCmdFlags.visibility, "visibility", "v", "", "The visibility of the app (e.g. 'only_owner' or 'github')") createCmd.Flags().StringSliceVarP(&createCmdFlags.EnvironmentVariables, "env", "e", []string{}, "Environment variables to set on the app in the form 'key=value'") createCmd.Flags().StringSliceVarP(&createCmdFlags.Secrets, "secret", "s", []string{}, "Secrets to set on the app in the form 'key=value'") createCmd.Flags().StringVarP(&createCmdFlags.RevisionName, "revision-name", "r", "", "The revision name to use for the app") @@ -80,6 +84,8 @@ func runCreate(client restClient, flags createCmdFlags) (createResp, error) { } requestBody := createReq{ + Name: flags.name, + Visibility: flags.visibility, EnvironmentVariables: map[string]string{}, Secrets: map[string]string{}, } diff --git a/cmd/create_test.go b/cmd/create_test.go index 32c42a1..20762d5 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -130,9 +130,11 @@ func TestRunCreate_EnvVarWithEqualsInValue(t *testing.T) { func TestRunCreate_WithName(t *testing.T) { var capturedPath string + var capturedBody []byte client := &mockRESTClient{ - putFunc: func(path string, _ io.Reader, resp interface{}) error { + putFunc: func(path string, body io.Reader, resp interface{}) error { capturedPath = path + capturedBody, _ = io.ReadAll(body) buildCreateResponse(createResp{AppUrl: "https://my-new-app.example.com", ID: "abc-123"}, resp) return nil }, @@ -143,6 +145,10 @@ func TestRunCreate_WithName(t *testing.T) { assert.Equal(t, "https://my-new-app.example.com", resp.AppUrl) assert.Equal(t, "abc-123", resp.ID) assert.Equal(t, "runtime", capturedPath) + + var req createReq + json.Unmarshal(capturedBody, &req) + assert.Equal(t, "my-new-app", req.Name) } func TestRunCreate_WithNameAndInit(t *testing.T) { @@ -151,8 +157,10 @@ func TestRunCreate_WithNameAndInit(t *testing.T) { os.Chdir(tmp) defer os.Chdir(origDir) + var capturedBody []byte client := &mockRESTClient{ - putFunc: func(_ string, _ io.Reader, resp interface{}) error { + putFunc: func(_ string, body io.Reader, resp interface{}) error { + capturedBody, _ = io.ReadAll(body) buildCreateResponse(createResp{AppUrl: "https://named-app.example.com", ID: "def-456"}, resp) return nil }, @@ -161,11 +169,33 @@ func TestRunCreate_WithNameAndInit(t *testing.T) { _, err := runCreate(client, createCmdFlags{name: "named-app", Init: true}) require.NoError(t, err) + var req createReq + json.Unmarshal(capturedBody, &req) + assert.Equal(t, "named-app", req.Name) + data, err := os.ReadFile("runtime.config.json") require.NoError(t, err) assert.Contains(t, string(data), "def-456") } +func TestRunCreate_WithVisibility(t *testing.T) { + var capturedBody []byte + client := &mockRESTClient{ + putFunc: func(_ string, body io.Reader, resp interface{}) error { + capturedBody, _ = io.ReadAll(body) + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com"}, resp) + return nil + }, + } + + _, err := runCreate(client, createCmdFlags{app: "my-app", visibility: "github"}) + require.NoError(t, err) + + var req createReq + json.Unmarshal(capturedBody, &req) + assert.Equal(t, "github", req.Visibility) +} + func TestRunCreate_ResponseWithID(t *testing.T) { client := &mockRESTClient{ putFunc: func(_ string, _ io.Reader, resp interface{}) error { From 776c0789a96197a21681276bf784ab08a0c5dbfb Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 19:14:11 +0000 Subject: [PATCH 06/10] Run test in CI --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0c7d91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + id-token: write # for setup-goproxy + contents: read + packages: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Build and test + run: | + go build + go test -v ./... \ No newline at end of file From ed67f25a419711c4d5c85d929a03529250711257 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 19:17:26 +0000 Subject: [PATCH 07/10] More steps for CI --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0c7d91..48a9e1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,15 @@ jobs: build-and-test: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v6 + + - name: OIDC Setup for goproxy + uses: github/setup-goproxy@v1.1.0 + + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: Build and test run: | go build From 607d339c0d0d74984952d665def7c91398a6e242 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 19:19:23 +0000 Subject: [PATCH 08/10] Don't need goproxy --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48a9e1f..c0e704b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ on: workflow_dispatch: permissions: - id-token: write # for setup-goproxy contents: read packages: read @@ -17,9 +16,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: OIDC Setup for goproxy - uses: github/setup-goproxy@v1.1.0 - - uses: actions/setup-go@v6 with: go-version-file: 'go.mod' From 04ffb090400fc46117ed056075c215b4f6b7fef0 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 23:02:17 +0000 Subject: [PATCH 09/10] Fix problem with URL selection --- cmd/create.go | 32 ++++++++++++++++---------------- cmd/create_test.go | 42 ++++++++++++++++++++++++++++++++---------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index e0fc092..774a6f6 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -16,10 +16,10 @@ type createCmdFlags struct { app string name string visibility string - EnvironmentVariables []string - Secrets []string - RevisionName string - Init bool + environmentVariables []string + secrets []string + revisionName string + init bool } type createReq struct { @@ -71,10 +71,10 @@ func init() { createCmd.Flags().StringVarP(&createCmdFlags.app, "app", "a", "", "The app ID to create") createCmd.Flags().StringVarP(&createCmdFlags.name, "name", "n", "", "The name for the app") createCmd.Flags().StringVarP(&createCmdFlags.visibility, "visibility", "v", "", "The visibility of the app (e.g. 'only_owner' or 'github')") - createCmd.Flags().StringSliceVarP(&createCmdFlags.EnvironmentVariables, "env", "e", []string{}, "Environment variables to set on the app in the form 'key=value'") - createCmd.Flags().StringSliceVarP(&createCmdFlags.Secrets, "secret", "s", []string{}, "Secrets to set on the app in the form 'key=value'") - createCmd.Flags().StringVarP(&createCmdFlags.RevisionName, "revision-name", "r", "", "The revision name to use for the app") - createCmd.Flags().BoolVar(&createCmdFlags.Init, "init", false, "Initialize a runtime.config.json file in the current directory after creating the app") + createCmd.Flags().StringSliceVarP(&createCmdFlags.environmentVariables, "env", "e", []string{}, "Environment variables to set on the app in the form 'key=value'") + createCmd.Flags().StringSliceVarP(&createCmdFlags.secrets, "secret", "s", []string{}, "Secrets to set on the app in the form 'key=value'") + createCmd.Flags().StringVarP(&createCmdFlags.revisionName, "revision-name", "r", "", "The revision name to use for the app") + createCmd.Flags().BoolVar(&createCmdFlags.init, "init", false, "Initialize a runtime.config.json file in the current directory after creating the app") rootCmd.AddCommand(createCmd) } @@ -90,7 +90,7 @@ func runCreate(client restClient, flags createCmdFlags) (createResp, error) { Secrets: map[string]string{}, } - for _, pair := range flags.EnvironmentVariables { + for _, pair := range flags.environmentVariables { parts := strings.SplitN(pair, "=", 2) if len(parts) == 2 { requestBody.EnvironmentVariables[parts[0]] = parts[1] @@ -99,7 +99,7 @@ func runCreate(client restClient, flags createCmdFlags) (createResp, error) { } } - for _, pair := range flags.Secrets { + for _, pair := range flags.secrets { parts := strings.SplitN(pair, "=", 2) if len(parts) == 2 { requestBody.Secrets[parts[0]] = parts[1] @@ -114,15 +114,15 @@ func runCreate(client restClient, flags createCmdFlags) (createResp, error) { } var createUrl string - if flags.name != "" { - createUrl = "runtime" - } else { + if flags.app != "" { createUrl = fmt.Sprintf("runtime/%s/deployment", flags.app) + } else { + createUrl = "runtime" } params := url.Values{} - if flags.RevisionName != "" { - params.Add("revision_name", flags.RevisionName) + if flags.revisionName != "" { + params.Add("revision_name", flags.revisionName) } if len(params) > 0 { createUrl += "?" + params.Encode() @@ -134,7 +134,7 @@ func runCreate(client restClient, flags createCmdFlags) (createResp, error) { return createResp{}, fmt.Errorf("error creating app: %v", err) } - if flags.Init { + if flags.init { if response.ID == "" { return response, fmt.Errorf("error initializing config: server did not return an app ID") } diff --git a/cmd/create_test.go b/cmd/create_test.go index 20762d5..d55f447 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -28,13 +28,13 @@ func TestRunCreate_NoAppOrName(t *testing.T) { func TestRunCreate_InvalidEnvVarFormat(t *testing.T) { client := &mockRESTClient{} - _, err := runCreate(client, createCmdFlags{app: "my-app", EnvironmentVariables: []string{"BADFORMAT"}}) + _, err := runCreate(client, createCmdFlags{app: "my-app", environmentVariables: []string{"BADFORMAT"}}) require.ErrorContains(t, err, "invalid environment variable format") } func TestRunCreate_InvalidSecretFormat(t *testing.T) { client := &mockRESTClient{} - _, err := runCreate(client, createCmdFlags{app: "my-app", Secrets: []string{"NOSEPARATOR"}}) + _, err := runCreate(client, createCmdFlags{app: "my-app", secrets: []string{"NOSEPARATOR"}}) require.ErrorContains(t, err, "invalid secret format") } @@ -42,7 +42,7 @@ func TestRunCreate_APIError(t *testing.T) { client := &mockRESTClient{ putFunc: mockPutError("server error"), } - _, err := runCreate(client, createCmdFlags{app: "my-app", EnvironmentVariables: []string{"K=V"}}) + _, err := runCreate(client, createCmdFlags{app: "my-app", environmentVariables: []string{"K=V"}}) require.ErrorContains(t, err, "error creating app") } @@ -60,8 +60,8 @@ func TestRunCreate_Success(t *testing.T) { resp, err := runCreate(client, createCmdFlags{ app: "my-app", - EnvironmentVariables: []string{"KEY1=val1", "KEY2=val2"}, - Secrets: []string{"SECRET=sval"}, + environmentVariables: []string{"KEY1=val1", "KEY2=val2"}, + secrets: []string{"SECRET=sval"}, }) require.NoError(t, err) assert.Equal(t, "https://my-app.example.com", resp.AppUrl) @@ -83,7 +83,7 @@ func TestRunCreate_WithRevisionName(t *testing.T) { }, } - _, err := runCreate(client, createCmdFlags{app: "my-app", RevisionName: "v2"}) + _, err := runCreate(client, createCmdFlags{app: "my-app", revisionName: "v2"}) require.NoError(t, err) assert.Contains(t, capturedPath, "revision_name=v2") } @@ -101,7 +101,7 @@ func TestRunCreate_WithInit(t *testing.T) { }, } - resp, err := runCreate(client, createCmdFlags{app: "init-app", Init: true}) + resp, err := runCreate(client, createCmdFlags{app: "init-app", init: true}) require.NoError(t, err) assert.Equal(t, "https://init-app.example.com", resp.AppUrl) @@ -120,7 +120,7 @@ func TestRunCreate_EnvVarWithEqualsInValue(t *testing.T) { }, } - _, err := runCreate(client, createCmdFlags{app: "my-app", EnvironmentVariables: []string{"KEY=val=with=equals"}}) + _, err := runCreate(client, createCmdFlags{app: "my-app", environmentVariables: []string{"KEY=val=with=equals"}}) require.NoError(t, err) var req createReq @@ -151,6 +151,28 @@ func TestRunCreate_WithName(t *testing.T) { assert.Equal(t, "my-new-app", req.Name) } +func TestRunCreate_WithNameAndApp(t *testing.T) { + var capturedPath string + var capturedBody []byte + client := &mockRESTClient{ + putFunc: func(path string, body io.Reader, resp interface{}) error { + capturedPath = path + capturedBody, _ = io.ReadAll(body) + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com", ID: "app-123"}, resp) + return nil + }, + } + + resp, err := runCreate(client, createCmdFlags{app: "my-app", name: "my-new-name"}) + require.NoError(t, err) + assert.Equal(t, "https://my-app.example.com", resp.AppUrl) + assert.Equal(t, "runtime/my-app/deployment", capturedPath) + + var req createReq + json.Unmarshal(capturedBody, &req) + assert.Equal(t, "my-new-name", req.Name) +} + func TestRunCreate_WithNameAndInit(t *testing.T) { tmp := t.TempDir() origDir, _ := os.Getwd() @@ -166,7 +188,7 @@ func TestRunCreate_WithNameAndInit(t *testing.T) { }, } - _, err := runCreate(client, createCmdFlags{name: "named-app", Init: true}) + _, err := runCreate(client, createCmdFlags{name: "named-app", init: true}) require.NoError(t, err) var req createReq @@ -218,6 +240,6 @@ func TestRunCreate_InitWithoutIDInResponse(t *testing.T) { }, } - _, err := runCreate(client, createCmdFlags{app: "my-app", Init: true}) + _, err := runCreate(client, createCmdFlags{app: "my-app", init: true}) require.ErrorContains(t, err, "server did not return an app ID") } From 5fc95fca3cd50ef0a2ad61ba06071beed3497a97 Mon Sep 17 00:00:00 2001 From: "Jason R. Clark" Date: Fri, 27 Mar 2026 23:06:55 +0000 Subject: [PATCH 10/10] Be better about test setup errors --- cmd/create_test.go | 12 ++++---- cmd/deploy_test.go | 69 +++++++++++++++++++++++++--------------------- cmd/get_test.go | 21 ++++++++------ cmd/init_test.go | 21 ++++++++------ 4 files changed, 69 insertions(+), 54 deletions(-) diff --git a/cmd/create_test.go b/cmd/create_test.go index d55f447..f101cba 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -90,8 +90,9 @@ func TestRunCreate_WithRevisionName(t *testing.T) { func TestRunCreate_WithInit(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) client := &mockRESTClient{ @@ -175,8 +176,9 @@ func TestRunCreate_WithNameAndApp(t *testing.T) { func TestRunCreate_WithNameAndInit(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) var capturedBody []byte @@ -188,7 +190,7 @@ func TestRunCreate_WithNameAndInit(t *testing.T) { }, } - _, err := runCreate(client, createCmdFlags{name: "named-app", init: true}) + _, err = runCreate(client, createCmdFlags{name: "named-app", init: true}) require.NoError(t, err) var req createReq diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 4343222..1fb97ee 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -12,49 +12,53 @@ import ( func TestRunDeploy_NoDir(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) client := &mockRESTClient{} - err := runDeploy(client, deployCmdFlags{app: "my-app"}) + err = runDeploy(client, deployCmdFlags{app: "my-app"}) require.ErrorContains(t, err, "--dir flag is required") } func TestRunDeploy_NoAppName(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) deployDir := filepath.Join(tmp, "dist") - os.MkdirAll(deployDir, 0755) + require.NoError(t, os.MkdirAll(deployDir, 0755)) client := &mockRESTClient{} - err := runDeploy(client, deployCmdFlags{dir: deployDir}) + err = runDeploy(client, deployCmdFlags{dir: deployDir}) require.ErrorContains(t, err, "--app flag is required") } func TestRunDeploy_DirNotExist(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) client := &mockRESTClient{} - err := runDeploy(client, deployCmdFlags{dir: "/nonexistent/path", app: "my-app"}) + err = runDeploy(client, deployCmdFlags{dir: "/nonexistent/path", app: "my-app"}) require.ErrorContains(t, err, "does not exist") } func TestRunDeploy_Success(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) deployDir := filepath.Join(tmp, "dist") - os.MkdirAll(deployDir, 0755) - os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644) + require.NoError(t, os.MkdirAll(deployDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644)) var capturedPath string client := &mockRESTClient{ @@ -64,7 +68,7 @@ func TestRunDeploy_Success(t *testing.T) { }, } - err := runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app"}) + err = runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app"}) require.NoError(t, err) assert.Equal(t, "runtime/my-app/deployment/bundle", capturedPath) require.NoFileExists(t, deployDir+".zip") @@ -72,13 +76,14 @@ func TestRunDeploy_Success(t *testing.T) { func TestRunDeploy_WithRevisionAndSha(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) deployDir := filepath.Join(tmp, "dist") - os.MkdirAll(deployDir, 0755) - os.WriteFile(filepath.Join(deployDir, "app.js"), []byte("console.log('hi')"), 0644) + require.NoError(t, os.MkdirAll(deployDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(deployDir, "app.js"), []byte("console.log('hi')"), 0644)) var capturedPath string client := &mockRESTClient{ @@ -88,7 +93,7 @@ func TestRunDeploy_WithRevisionAndSha(t *testing.T) { }, } - err := runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app", revisionName: "v2", sha: "abc123"}) + err = runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app", revisionName: "v2", sha: "abc123"}) require.NoError(t, err) assert.Contains(t, capturedPath, "revision_name=v2") assert.Contains(t, capturedPath, "revision=abc123") @@ -96,34 +101,36 @@ func TestRunDeploy_WithRevisionAndSha(t *testing.T) { func TestRunDeploy_APIError(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) deployDir := filepath.Join(tmp, "dist") - os.MkdirAll(deployDir, 0755) - os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644) + require.NoError(t, os.MkdirAll(deployDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644)) client := &mockRESTClient{ postFunc: mockPostError("upload failed"), } - err := runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app"}) + err = runDeploy(client, deployCmdFlags{dir: deployDir, app: "my-app"}) require.ErrorContains(t, err, "error deploying app") } func TestRunDeploy_WithConfigFile(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) deployDir := filepath.Join(tmp, "dist") - os.MkdirAll(deployDir, 0755) - os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644) + require.NoError(t, os.MkdirAll(deployDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(deployDir, "index.html"), []byte(""), 0644)) configPath := filepath.Join(tmp, "my-config.json") - os.WriteFile(configPath, []byte(`{"app":"config-deploy-app"}`), 0644) + require.NoError(t, os.WriteFile(configPath, []byte(`{"app":"config-deploy-app"}`), 0644)) var capturedPath string client := &mockRESTClient{ @@ -133,7 +140,7 @@ func TestRunDeploy_WithConfigFile(t *testing.T) { }, } - err := runDeploy(client, deployCmdFlags{dir: deployDir, config: configPath}) + err = runDeploy(client, deployCmdFlags{dir: deployDir, config: configPath}) require.NoError(t, err) assert.Contains(t, capturedPath, "config-deploy-app") } diff --git a/cmd/get_test.go b/cmd/get_test.go index abe2bea..2b644bf 100644 --- a/cmd/get_test.go +++ b/cmd/get_test.go @@ -12,12 +12,13 @@ import ( func TestRunGet_NoAppName(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) client := &mockRESTClient{} - _, err := runGet(client, getCmdFlags{}) + _, err = runGet(client, getCmdFlags{}) require.ErrorContains(t, err, "--app flag is required") } @@ -63,12 +64,13 @@ func TestRunGet_APIError(t *testing.T) { func TestRunGet_WithConfigFile(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) configPath := filepath.Join(tmp, "my-config.json") - os.WriteFile(configPath, []byte(`{"app":"config-app"}`), 0644) + require.NoError(t, os.WriteFile(configPath, []byte(`{"app":"config-app"}`), 0644)) var capturedPath string client := &mockRESTClient{ @@ -86,11 +88,12 @@ func TestRunGet_WithConfigFile(t *testing.T) { func TestRunGet_DefaultConfigFile(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) - os.WriteFile(filepath.Join(tmp, "runtime.config.json"), []byte(`{"app":"default-app"}`), 0644) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "runtime.config.json"), []byte(`{"app":"default-app"}`), 0644)) var capturedPath string client := &mockRESTClient{ diff --git a/cmd/init_test.go b/cmd/init_test.go index 5f72898..a57d306 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -27,8 +27,9 @@ func TestRunInit_AppNotAccessible(t *testing.T) { func TestRunInit_Success_DefaultPath(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) var capturedPath string @@ -39,7 +40,7 @@ func TestRunInit_Success_DefaultPath(t *testing.T) { }, } - err := runInit(client, initCmdFlags{app: "my-app"}) + err = runInit(client, initCmdFlags{app: "my-app"}) require.NoError(t, err) assert.Equal(t, "runtime/my-app/deployment", capturedPath) @@ -50,8 +51,9 @@ func TestRunInit_Success_DefaultPath(t *testing.T) { func TestRunInit_Success_CustomOutPath(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) client := &mockRESTClient{ @@ -59,7 +61,7 @@ func TestRunInit_Success_CustomOutPath(t *testing.T) { } outPath := filepath.Join(tmp, "subdir", "custom-config.json") - err := runInit(client, initCmdFlags{app: "my-app", out: outPath}) + err = runInit(client, initCmdFlags{app: "my-app", out: outPath}) require.NoError(t, err) data, err := os.ReadFile(outPath) @@ -69,15 +71,16 @@ func TestRunInit_Success_CustomOutPath(t *testing.T) { func TestRunInit_VerifiesAPIBeforeWriting(t *testing.T) { tmp := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmp) + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) defer os.Chdir(origDir) client := &mockRESTClient{ getFunc: mockGetError("not found"), } - err := runInit(client, initCmdFlags{app: "bad-app"}) + err = runInit(client, initCmdFlags{app: "bad-app"}) require.Error(t, err) require.NoFileExists(t, "runtime.config.json") }