Skip to content

Commit

Permalink
feat: world forge organization, project, deploy (#85)
Browse files Browse the repository at this point in the history
Closes: WORLD-1162, WORLD-1163

## Overview

Add world forge auth, create and select organization, create and select project, deploy and destroy in cloud

## Brief Changelog

Added world forge feature:

**world forge login**

**world forge organization**
- world forge organization create
- world forge organization switch

**world forge project**
- world forge project create
- world forge project switch

**world forge deployment**
- world forge deployment deploy
- world forge deployment destroy

## Testing and Verifying

Manually Tested:

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/5aa4d995-7c5b-43c6-8071-6a6b42ead55c.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/4c278ed4-59de-46d3-bbf6-4d63105ff4e4.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/bb7b0ca8-94a1-4808-803a-8f691a6c3adf.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/a4c75b7f-51c6-4d94-a127-71042741d924.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/95c94748-256f-4e2d-bce7-03c0b48c963f.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/ace1f063-37be-4041-a007-cdea1a61ee1c.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/efc5fdc9-74c1-4e93-84ae-b454a8ed2868.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/d294d025-5451-49d3-9d0e-2cf11d10deef.png)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Release Notes

- **New Features**
	- Introduced a command-line interface (CLI) for managing World Forge projects, including user authentication and organization management.
	- Added functionality for user login via web browser and token retrieval.
	- Implemented project management features, including project creation and selection.
	- Added repository validation based on URLs for Git hosting services.
	- Enhanced configuration management with a new structure and broader settings.
	- Introduced deployment and destruction commands for project management.
	- Added utility functions for improved user feedback during organization and project selection.
	- Added comprehensive test suite for validating functionalities related to organizations and projects.

- **Bug Fixes**
	- Improved error handling for various functions to enhance user feedback.

- **Documentation**
	- Updated configuration management structure for better clarity and usability.

- **Chores**
	- Integrated the `forge` subcommand into the existing command structure.
	- Simplified version command output by removing environment context handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
zulkhair committed Jan 7, 2025
1 parent caa3fc3 commit f8326d1
Show file tree
Hide file tree
Showing 14 changed files with 2,864 additions and 48 deletions.
176 changes: 176 additions & 0 deletions cmd/world/forge/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package forge

import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"time"

"github.com/google/uuid"
"github.com/rotisserie/eris"
"github.com/tidwall/gjson"

"pkg.world.dev/world-cli/common/globalconfig"
)

var (
requestTimeout = 2 * time.Second
httpClient = &http.Client{
Timeout: requestTimeout,
}
)

var generateKey = func() string {
return strings.ReplaceAll(uuid.NewString(), "-", "")
}

// Change from function to variable
var openBrowser = func(url string) error {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
fmt.Printf("Could not automatically open browser. Please visit this URL:\n%s\n", url)
}
if err != nil {
fmt.Printf("Failed to open browser automatically. Please visit this URL:\n%s\n", url)
}
return nil
}

var getInput = func() (string, error) {
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return "", eris.Wrap(err, "Failed to read input")
}
return strings.TrimSpace(input), nil
}

// sendRequest sends an HTTP request with auth token and returns the response body
func sendRequest(ctx context.Context, method, url string, body interface{}) ([]byte, error) {
var bodyReader io.Reader

if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, eris.Wrap(err, "Failed to marshal request body")
}
bodyReader = bytes.NewReader(jsonBody)
}

// Get credential from config
cred, err := globalconfig.GetGlobalConfig()
if err != nil {
return nil, eris.Wrap(err, "Failed to get credential")
}

// Create request
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, eris.Wrap(err, "Failed to create request")
}

// Add authorization header
req.Header.Add("Authorization", "Bearer "+cred.Credential.Token)

// Add content-type header for requests with body
if body != nil {
req.Header.Set("Content-Type", "application/json")
}

// Make request with timeout
resp, err := httpClient.Do(req)
if err != nil {
return nil, eris.Wrap(err, "Failed to make request")
}
defer resp.Body.Close()

// Check status code
if resp.StatusCode != http.StatusOK {
// if 401 show message to login again
if resp.StatusCode == http.StatusUnauthorized {
return nil, eris.New("Unauthorized. Please login again using 'world forge login' command")
}
return nil, eris.Errorf("Unexpected status code: %d", resp.StatusCode)
}

// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, eris.Wrap(err, "Failed to read response body")
}

return respBody, nil
}

func parseResponse[T any](body []byte) (*T, error) {
result := gjson.GetBytes(body, "data")
if !result.Exists() {
return nil, eris.New("Missing data field in response")
}

var data T
if err := json.Unmarshal([]byte(result.Raw), &data); err != nil {
return nil, eris.Wrap(err, "Failed to parse response")
}

return &data, nil
}

func printNoSelectedOrganization() {
fmt.Println("You don't have any organization selected.")
fmt.Println("Use 'world forge organization switch' to select one.")
fmt.Println()
}

func printNoSelectedProject() {
fmt.Println("You don't have any project selected.")
fmt.Println("Use 'world forge project switch' to select one.")
fmt.Println()
}

func printNoProjectsInOrganization() {
fmt.Println("You don't have any projects in this organization yet.")
fmt.Println("Use 'world forge project create' to create one.")
fmt.Println()
}

func isAlphanumeric(s string) bool {
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}

func checkLogin() bool {
cred, err := globalconfig.GetGlobalConfig()
if err != nil {
fmt.Println("You are not logged in. Please login first")
fmt.Println("Use 'world forge login' to login")
return false
}

if cred.Credential.Token == "" {
fmt.Println("You are not logged in. Please login first")
fmt.Println("Use 'world forge login' to login")
return false
}

return true
}
130 changes: 130 additions & 0 deletions cmd/world/forge/deployment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package forge

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/rotisserie/eris"

"pkg.world.dev/world-cli/common/globalconfig"
)

// Deploy a project
func deploy(ctx context.Context) error {
globalConfig, err := globalconfig.GetGlobalConfig()
if err != nil {
return eris.Wrap(err, "Failed to get global config")
}

projectID := globalConfig.ProjectID
organizationID := globalConfig.OrganizationID

if organizationID == "" {
printNoSelectedOrganization()
return nil
}

if projectID == "" {
printNoSelectedProject()
return nil
}

// Get organization details
org, err := getSelectedOrganization(ctx)
if err != nil {
return eris.Wrap(err, "Failed to get organization details")
}

// Get project details
prj, err := getSelectedProject(ctx)
if err != nil {
return eris.Wrap(err, "Failed to get project details")
}

fmt.Println("Deployment Details")
fmt.Println("-----------------")
fmt.Printf("Organization: %s\n", org.Name)
fmt.Printf("Org Slug: %s\n", org.Slug)
fmt.Printf("Project: %s\n", prj.Name)
fmt.Printf("Project Slug: %s\n", prj.Slug)
fmt.Printf("Repository: %s\n\n", prj.RepoURL)

deployURL := fmt.Sprintf("%s/api/organization/%s/project/%s/deploy", baseURL, organizationID, projectID)
_, err = sendRequest(ctx, http.MethodPost, deployURL, nil)
if err != nil {
return eris.Wrap(err, "Failed to deploy project")
}

fmt.Println("\n✨ Your deployment is being processed! ✨")
fmt.Println("\nTo check the status of your deployment, run:")
fmt.Println(" $ 'world forge deployment status'")

return nil
}

// Destroy a project
func destroy(ctx context.Context) error {
globalConfig, err := globalconfig.GetGlobalConfig()
if err != nil {
return eris.Wrap(err, "Failed to get global config")
}

projectID := globalConfig.ProjectID
organizationID := globalConfig.OrganizationID

if organizationID == "" {
printNoSelectedOrganization()
return nil
}

if projectID == "" {
printNoSelectedProject()
return nil
}

// Get organization details
org, err := getSelectedOrganization(ctx)
if err != nil {
return eris.Wrap(err, "Failed to get organization details")
}

// Get project details
prj, err := getSelectedProject(ctx)
if err != nil {
return eris.Wrap(err, "Failed to get project details")
}

fmt.Println("Project Details")
fmt.Println("-----------------")
fmt.Printf("Organization: %s\n", org.Name)
fmt.Printf("Org Slug: %s\n", org.Slug)
fmt.Printf("Project: %s\n", prj.Name)
fmt.Printf("Project Slug: %s\n", prj.Slug)
fmt.Printf("Repository: %s\n\n", prj.RepoURL)

fmt.Print("Are you sure you want to destroy this project? (y/N): ")
response, err := getInput()
if err != nil {
return eris.Wrap(err, "Failed to read response")
}

response = strings.ToLower(strings.TrimSpace(response))
if response != "y" {
fmt.Println("Destroy cancelled")
return nil
}

destroyURL := fmt.Sprintf("%s/api/organization/%s/project/%s/destroy", baseURL, organizationID, projectID)
_, err = sendRequest(ctx, http.MethodPost, destroyURL, nil)
if err != nil {
return eris.Wrap(err, "Failed to destroy project")
}

fmt.Println("\n🗑️ Your destroy request is being processed!")
fmt.Println("\nTo check the status of your destroy request, run:")
fmt.Println(" $ 'world forge deployment status'")

return nil
}
Loading

0 comments on commit f8326d1

Please sign in to comment.