diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0e704b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Build and test + run: | + go build + go test -v ./... \ No newline at end of file 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 af38d06..774a6f6 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" @@ -13,17 +14,24 @@ import ( type createCmdFlags struct { app string - EnvironmentVariables []string - Secrets []string - RevisionName string + name string + visibility string + environmentVariables []string + secrets []string + revisionName string + init bool } 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"` } type createResp struct { + AppUrl string `json:"app_url"` + ID string `json:"id"` } func init() { @@ -36,78 +44,105 @@ 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 `), - Run: func(cmd *cobra.Command, args []string) { - if createCmdFlags.app == "" { - fmt.Println("Error: --app flag is required") - return + RunE: func(cmd *cobra.Command, args []string) error { + client, err := api.DefaultRESTClient() + if err != nil { + return fmt.Errorf("failed creating REST client: %v", err) } - // Construct the request body - requestBody := createReq{ - EnvironmentVariables: map[string]string{}, - Secrets: map[string]string{}, + resp, err := runCreate(client, createCmdFlags) + if err != nil { + return err } - 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 - } + fmt.Printf("App created: %s\n", resp.AppUrl) + if resp.ID != "" { + fmt.Printf("ID: %s\n", resp.ID) } + return nil + }, + } - 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 - } - } + 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") + rootCmd.AddCommand(createCmd) +} - body, err := json.Marshal(requestBody) - if err != nil { - fmt.Printf("Error marshalling request body: %v\n", err) - return - } +func runCreate(client restClient, flags createCmdFlags) (createResp, error) { + if flags.app == "" && flags.name == "" { + return createResp{}, fmt.Errorf("either --app or --name flag is required") + } - 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() - } - - client, err := api.DefaultRESTClient() - if err != nil { - fmt.Println(err) - return - } - var response string - err = client.Put(createUrl, bytes.NewReader(body), &response) - if err != nil { - fmt.Printf("Error creating app: %v\n", err) - return - } + requestBody := createReq{ + Name: flags.name, + Visibility: flags.visibility, + EnvironmentVariables: map[string]string{}, + Secrets: map[string]string{}, + } - fmt.Printf("App created: %s\n", response) // TODO pretty print details - }, + for _, pair := range flags.environmentVariables { + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + requestBody.EnvironmentVariables[parts[0]] = parts[1] + } else { + return createResp{}, fmt.Errorf("invalid environment variable format (%s). Must be in the form 'key=value'", pair) + } } - createCmd.Flags().StringVarP(&createCmdFlags.app, "app", "a", "", "The app to create") - 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") - rootCmd.AddCommand(createCmd) + for _, pair := range flags.secrets { + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + requestBody.Secrets[parts[0]] = parts[1] + } else { + 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 createResp{}, fmt.Errorf("error marshalling request body: %v", err) + } + + var createUrl string + 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 len(params) > 0 { + createUrl += "?" + params.Encode() + } + + response := createResp{} + err = client.Put(createUrl, bytes.NewReader(body), &response) + if err != nil { + return createResp{}, fmt.Errorf("error creating app: %v", err) + } + + if flags.init { + 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, fmt.Errorf("error initializing config: %v", err) + } + } + + return response, nil } diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..f101cba --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,247 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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, "either --app or --name 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) + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com"}, resp) + return nil + }, + } + + 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://my-app.example.com", resp.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, _ io.Reader, resp interface{}) error { + capturedPath = path + buildCreateResponse(createResp{AppUrl: "https://my-app.example.com"}, resp) + return nil + }, + } + + _, 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + client := &mockRESTClient{ + putFunc: func(_ string, _ io.Reader, resp interface{}) error { + buildCreateResponse(createResp{AppUrl: "https://init-app.example.com", ID: "init-app-id"}, resp) + return nil + }, + } + + resp, err := runCreate(client, createCmdFlags{app: "init-app", init: true}) + require.NoError(t, err) + 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-id") +} + +func TestRunCreate_EnvVarWithEqualsInValue(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", 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"]) +} + +func TestRunCreate_WithName(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-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) + + var req createReq + json.Unmarshal(capturedBody, &req) + 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + var capturedBody []byte + client := &mockRESTClient{ + 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 + }, + } + + _, 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 { + 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 5051e85..f3b0630 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{ @@ -26,42 +24,49 @@ func init() { `), Example: heredoc.Doc(` $ gh runtime delete --app my-app - # => Deletes the app named 'my-app' + # => Deletes the app with ID '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 }, } - 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) } + +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..ea98b31 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 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 with ID 'my-app'. + + $ gh runtime deploy --dir ./dist --config runtime.config.json + # => Deploys using app ID from the config file. + + $ gh runtime deploy --dir ./dist + # => 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() + 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 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") + + 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..1fb97ee --- /dev/null +++ b/cmd/deploy_test.go @@ -0,0 +1,146 @@ +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, 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"}) + require.ErrorContains(t, err, "--dir flag is required") +} + +func TestRunDeploy_NoAppName(t *testing.T) { + tmp := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + require.NoError(t, 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, 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"}) + require.ErrorContains(t, err, "does not exist") +} + +func TestRunDeploy_Success(t *testing.T) { + tmp := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + require.NoError(t, os.MkdirAll(deployDir, 0755)) + require.NoError(t, 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + 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{ + 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + 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"}) + require.ErrorContains(t, err, "error deploying app") +} + +func TestRunDeploy_WithConfigFile(t *testing.T) { + tmp := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + deployDir := filepath.Join(tmp, "dist") + 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") + require.NoError(t, 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..fdf611b 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -27,52 +27,61 @@ 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 { - 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 }, } - 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) } + +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..2b644bf --- /dev/null +++ b/cmd/get_test.go @@ -0,0 +1,110 @@ +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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + configPath := filepath.Join(tmp, "my-config.json") + require.NoError(t, 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmp)) + defer os.Chdir(origDir) + + require.NoError(t, 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 7c555cb..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(` @@ -43,60 +43,65 @@ 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") - } - - // Determine the identifier to use for the API call - 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 runInit(client, initCmdFlags) + }, + } - // Create runtime config - configStruct := config.RuntimeConfig{ - App: initCmdFlags.app, - } + 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) +} - 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) - } - } - } +func runInit(client restClient, flags initCmdFlags) error { + if flags.app == "" { + return fmt.Errorf("--app flag is required") + } - configBytes, err := json.MarshalIndent(configStruct, "", " ") - if err != nil { - return fmt.Errorf("error creating configuration: %v", err) - } + 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, + } - err = os.WriteFile(configPath, configBytes, 0644) + 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 writing configuration file: %v", err) + return fmt.Errorf("error creating directory '%s': %v", outputDir, err) } + } + } - fmt.Printf("Successfully initialized local project for Spark app '%s' at '%s'\n", identifier, configPath) - return nil - }, + configBytes, err := json.MarshalIndent(configStruct, "", " ") + if err != nil { + return fmt.Errorf("error creating configuration: %v", err) } - initCmd.Flags().StringVarP(&initCmdFlags.app, "app", "a", "", "The app name 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) + 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..a57d306 --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,86 @@ +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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, 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, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, 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, 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"}) + 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) + } +} 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") }