Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions cmd/create_project.go
Original file line number Diff line number Diff line change
@@ -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 <name>",
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)
}
78 changes: 78 additions & 0 deletions cmd/create_project_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
47 changes: 47 additions & 0 deletions request/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package request

import (
"context"
"errors"
"fmt"
"net/url"
"os"
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
138 changes: 138 additions & 0 deletions request/api_test.go
Original file line number Diff line number Diff line change
@@ -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(&params)
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)
}
}
11 changes: 11 additions & 0 deletions request/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading