diff --git a/cmd/create_project.go b/cmd/create_project.go new file mode 100644 index 0000000..4d8908b --- /dev/null +++ b/cmd/create_project.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + "errors" + + "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/spf13/cobra" +) + +type projectClient interface { + GetCurrentOrganization(ctx context.Context) (*request.Organization, error) + CreateProject(ctx context.Context, params *request.CreateProjectParams) (*request.Project, error) +} + +type projectClientFactory func(apiKey string) projectClient + +func createProject( + ctx context.Context, + client projectClient, + name string, + appName string, +) (*request.Project, error) { + if appName == "" { + appName = name + } + + // Project creation requires an org ID, but user info already identifies the signed-in builder's current org. + organization, err := client.GetCurrentOrganization(ctx) + if err != nil { + return nil, err + } + + return client.CreateProject(ctx, &request.CreateProjectParams{ + AppName: appName, + Name: name, + OrgId: organization.Id, + }) +} + +func newCreateProjectCmd(newClient projectClientFactory) *cobra.Command { + var appName string + + cmd := &cobra.Command{ + Use: "create:project ", + Short: "Create a project", + Long: "Create a project in your Ampersand organization. " + + "The application display name defaults to the project name.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + apiKey := flags.GetAPIKey() + client := newClient(apiKey) + + project, err := createProject(cmd.Context(), client, args[0], appName) + if err != nil { + if errors.Is(err, clerk.ErrNoSessions) { + logger.FatalErr("Authenticated session has expired, please log in using amp login", err) + } else { + logger.FatalErr("Unable to create project", err) + } + } + + logger.Infof("Project ID: %s, Project Name: %s, App Name: %s", + project.Id, project.Name, project.AppName) + }, + } + + cmd.Flags().StringVar(&appName, "app-name", "", + "Application display name shown during connection flows (defaults to project name)") + + return cmd +} + +var createProjectCmd = newCreateProjectCmd(func(apiKey string) projectClient { //nolint:gochecknoglobals + return request.NewAPIClient("unknown", &apiKey) +}) + +func init() { + rootCmd.AddCommand(createProjectCmd) +} diff --git a/cmd/create_project_test.go b/cmd/create_project_test.go new file mode 100644 index 0000000..385c5a3 --- /dev/null +++ b/cmd/create_project_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/amp-labs/cli/request" +) + +type fakeProjectClient struct { + organization *request.Organization + createdWith *request.CreateProjectParams +} + +func (f *fakeProjectClient) GetCurrentOrganization(_ context.Context) (*request.Organization, error) { + return f.organization, nil +} + +func (f *fakeProjectClient) CreateProject( + _ context.Context, + params *request.CreateProjectParams, +) (*request.Project, error) { + f.createdWith = params + + return &request.Project{ + Id: "project-id", + Name: params.Name, + AppName: params.AppName, + OrgId: params.OrgId, + }, nil +} + +func TestCreateProjectCommandUsesCurrentOrganizationAndDefaultAppName(t *testing.T) { + t.Parallel() + + client := &fakeProjectClient{ + organization: &request.Organization{Id: "org-id"}, + } + cmd := newCreateProjectCmd(func(_ string) projectClient { + return client + }) + cmd.SetArgs([]string{"skills-onboarding-cli-dev"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("execute create project command: %v", err) + } + + want := request.CreateProjectParams{ + AppName: "skills-onboarding-cli-dev", + Name: "skills-onboarding-cli-dev", + OrgId: "org-id", + } + if client.createdWith == nil || *client.createdWith != want { + t.Fatalf("create project params = %#v, want %#v", client.createdWith, want) + } +} + +func TestCreateProjectCommandUsesAppNameFlag(t *testing.T) { + t.Parallel() + + client := &fakeProjectClient{ + organization: &request.Organization{Id: "org-id"}, + } + cmd := newCreateProjectCmd(func(_ string) projectClient { + return client + }) + cmd.SetArgs([]string{"skills-onboarding-cli-dev", "--app-name", "Skills Onboarding CLI"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("execute create project command: %v", err) + } + + if client.createdWith == nil || client.createdWith.AppName != "Skills Onboarding CLI" { + t.Fatalf("app name = %q, want %q", client.createdWith.AppName, "Skills Onboarding CLI") + } +} diff --git a/request/api.go b/request/api.go index e7a9ecd..703afe3 100644 --- a/request/api.go +++ b/request/api.go @@ -2,6 +2,7 @@ package request import ( "context" + "errors" "fmt" "net/url" "os" @@ -14,6 +15,8 @@ import ( var ApiVersion = "v1" //nolint:gochecknoglobals +var ErrNoCurrentOrganization = errors.New("current organization is missing from user info") + type APIClient struct { Root string ProjectId string @@ -129,6 +132,32 @@ func (c *APIClient) GetMyInfo(ctx context.Context) (map[string]any, error) { return myInfo, nil } +func (c *APIClient) GetCurrentOrganization(ctx context.Context) (*Organization, error) { + myInfoURL := c.Root + "/my-info" + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + info := struct { + OrgRole struct { + Org Organization `json:"org"` + } `json:"orgRole"` + }{} + + _, err = c.Client.Get(ctx, myInfoURL, &info, auth) //nolint:bodyclose + if err != nil { + return nil, err + } + + if info.OrgRole.Org.Id == "" { + return nil, ErrNoCurrentOrganization + } + + return &info.OrgRole.Org, nil +} + func (c *APIClient) DeleteIntegration(ctx context.Context, integrationId string) error { delURL := fmt.Sprintf("%s/projects/%s/integrations/%s", c.Root, c.ProjectId, integrationId) @@ -255,6 +284,24 @@ func (c *APIClient) ListProjects(ctx context.Context) ([]*Project, error) { return projects, nil } +func (c *APIClient) CreateProject(ctx context.Context, params *CreateProjectParams) (*Project, error) { + createURL := c.Root + "/projects" + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + var project Project + + _, err = c.Client.Post(ctx, createURL, params, &project, auth) //nolint:bodyclose + if err != nil { + return nil, err + } + + return &project, nil +} + func (c *APIClient) ListDestinations(ctx context.Context) ([]*Destination, error) { listURL := fmt.Sprintf("%s/projects/%s/destinations", c.Root, c.ProjectId) diff --git a/request/api_test.go b/request/api_test.go new file mode 100644 index 0000000..6ddd968 --- /dev/null +++ b/request/api_test.go @@ -0,0 +1,138 @@ +package request + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const ( + testAPIKey = "test-api-key" + testOrgID = "org-id" + testProjectID = "project-id" +) + +func newTestAPIClient(server *httptest.Server) *APIClient { + apiKey := testAPIKey + + return &APIClient{ + Root: server.URL + "/v1", + APIKey: &apiKey, + Client: &Client{Client: server.Client()}, + } +} + +func TestGetCurrentOrganization(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet || req.URL.Path != "/v1/my-info" { + t.Errorf("request = %s %s, want GET /v1/my-info", req.Method, req.URL.Path) + } + + if req.Header.Get("X-Api-Key") != testAPIKey { + t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key")) + } + + writer.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(writer).Encode(map[string]any{ + "orgRole": map[string]any{ + "org": map[string]string{ + "id": testOrgID, + "label": "Test Organization", + }, + }, + }) + if err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer server.Close() + + organization, err := newTestAPIClient(server).GetCurrentOrganization(t.Context()) + if err != nil { + t.Fatalf("get current organization: %v", err) + } + + if organization.Id != testOrgID || organization.Label != "Test Organization" { + t.Fatalf("organization = %#v", organization) + } +} + +func TestGetCurrentOrganizationRequiresOrganization(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(writer).Encode(map[string]any{}) + if err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer server.Close() + + _, err := newTestAPIClient(server).GetCurrentOrganization(t.Context()) + if !errors.Is(err, ErrNoCurrentOrganization) { + t.Fatalf("error = %v, want ErrNoCurrentOrganization", err) + } +} + +func TestCreateProject(t *testing.T) { + t.Parallel() + + wantParams := CreateProjectParams{ + AppName: "Skills Onboarding CLI", + Name: "skills-onboarding-cli-dev", + OrgId: testOrgID, + } + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost || req.URL.Path != "/v1/projects" { + t.Errorf("request = %s %s, want POST /v1/projects", req.Method, req.URL.Path) + } + + if req.Header.Get("X-Api-Key") != testAPIKey { + t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key")) + } + + var params CreateProjectParams + + err := json.NewDecoder(req.Body).Decode(¶ms) + if err != nil { + t.Errorf("decode request: %v", err) + } + + if params != wantParams { + t.Errorf("request body = %#v, want %#v", params, wantParams) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + + err = json.NewEncoder(writer).Encode(Project{ + Id: testProjectID, + AppName: params.AppName, + Name: params.Name, + CreateTime: time.Date(2026, time.August, 18, 12, 0, 0, 0, time.UTC), + OrgId: params.OrgId, + }) + if err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer server.Close() + + project, err := newTestAPIClient(server).CreateProject(t.Context(), &wantParams) + if err != nil { + t.Fatalf("create project: %v", err) + } + + if project.Id != testProjectID || project.Name != wantParams.Name || project.AppName != wantParams.AppName { + t.Fatalf("project = %#v", project) + } +} diff --git a/request/types.go b/request/types.go index 540388c..e79e8ab 100644 --- a/request/types.go +++ b/request/types.go @@ -98,6 +98,17 @@ type Project struct { OrgId string `json:"orgId"` } +type CreateProjectParams struct { + AppName string `json:"appName"` + Name string `json:"name"` + OrgId string `json:"orgId"` +} + +type Organization struct { + Id string `json:"id"` + Label string `json:"label"` +} + type Destination struct { Id string `json:"id"` ProjectId string `json:"projectId"`