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
81 changes: 81 additions & 0 deletions cmd/appclient/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package appclient

import (
"fmt"

"github.com/major-technology/cli/errors"
"github.com/major-technology/cli/middleware"
"github.com/major-technology/cli/singletons"
"github.com/major-technology/cli/utils"
"github.com/spf13/cobra"
)

var (
flagAddID string
flagAddName string
flagAddJSON bool
// ensurePackage is a variable so tests skip pnpm.
ensurePackage = ensureAppClientPackage
)

var addCmd = &cobra.Command{
Use: "add",
Short: "Add a client for calling another application",
Long: `Generate a fetch client for another application in this organization. Use 'major app list' to find application IDs. The deploy grants access from the generated client.`,
PreRunE: middleware.ChainParent(
middleware.CheckLogin,
middleware.CheckNodeInstalled,
middleware.CheckNodeVersion("22.12"),
middleware.CheckPnpmInstalled,
),
RunE: func(cobraCmd *cobra.Command, args []string) error {
return runAdd(cobraCmd)
},
}

func init() {
addCmd.Flags().StringVar(&flagAddID, "id", "", "Application ID to call")
addCmd.Flags().StringVar(&flagAddName, "name", "", "Client name (defaults to the application name)")
addCmd.Flags().BoolVar(&flagAddJSON, "json", false, "Output in JSON format")
addCmd.MarkFlagRequired("id")
}

func runAdd(cobraCmd *cobra.Command) error {
appInfo, err := utils.GetApplicationInfo("")
if err != nil {
return errors.WrapError("failed to identify application", err)
}

if flagAddID == appInfo.ApplicationID {
return fmt.Errorf("an application cannot add a client for itself")
}

targetInfo, err := singletons.GetAPIClient().GetApplicationInfo(flagAddID)
if err != nil {
return errors.WrapError(fmt.Sprintf("application with ID %q not found or not visible", flagAddID), err)
}

if targetInfo.OrganizationID != appInfo.OrganizationID {
return fmt.Errorf("application with ID %q is not in this organization", flagAddID)
}

name := targetInfo.Name
if flagAddName != "" {
name = flagAddName
}

if err := ensurePackage(cobraCmd, "."); err != nil {
return err
}

if err := runAppClientCLI(cobraCmd, ".", "add", flagAddID, name); err != nil {
return err
}

if flagAddJSON {
return utils.WriteJSON(cobraCmd, map[string]any{"appId": flagAddID, "name": name, "added": true})
}

cobraCmd.Printf("Added client for %s (%s)\n", name, flagAddID)
return nil
}
142 changes: 142 additions & 0 deletions cmd/appclient/add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package appclient

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/major-technology/cli/clients/api"
"github.com/major-technology/cli/clients/workspace"
"github.com/major-technology/cli/singletons"
"github.com/spf13/cobra"
)

const (
testOrgID = "11111111-1111-4111-8111-111111111111"
testAppID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
testTargetID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
otherOrgID = "22222222-2222-4222-8222-222222222222"
testOutsideOrgID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
)

func prepare(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := workspace.Write(dir, workspace.Config{
OrganizationID: testOrgID,
Target: workspace.Target{Kind: "app", ApplicationID: testAppID},
}); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
t.Setenv("MAJOR_TOKEN", "test-injected-token")

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/applications/" + testAppID + "/info":
fmt.Fprintf(w, `{"applicationId":%q,"organizationId":%q,"urlSlug":"a","name":"A","deployStatus":"deployed","appUrl":null}`, testAppID, testOrgID)
case "/applications/" + testTargetID + "/info":
fmt.Fprintf(w, `{"applicationId":%q,"organizationId":%q,"urlSlug":"b","name":"Order Service","deployStatus":"deployed","appUrl":null}`, testTargetID, testOrgID)
case "/applications/" + testOutsideOrgID + "/info":
fmt.Fprintf(w, `{"applicationId":%q,"organizationId":%q,"urlSlug":"c","name":"Other Org App","deployStatus":"deployed","appUrl":null}`, testOutsideOrgID, otherOrgID)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
prev := singletons.GetAPIClient()
singletons.SetAPIClient(api.NewClient(srv.URL))
t.Cleanup(func() { singletons.SetAPIClient(prev) })

origEnsure := ensurePackage
ensurePackage = func(*cobra.Command, string) error { return nil }
t.Cleanup(func() { ensurePackage = origEnsure })
}

func TestAddGeneratesClientForAppInOrg(t *testing.T) {
prepare(t)

var gotArgs []string
orig := runAppClientCLI
runAppClientCLI = func(cmd *cobra.Command, dir string, args ...string) error {
gotArgs = args
return nil
}
t.Cleanup(func() { runAppClientCLI = orig })

flagAddID, flagAddName, flagAddJSON = testTargetID, "", true
cmd := &cobra.Command{}
var out bytes.Buffer
cmd.SetOut(&out)

if err := runAdd(cmd); err != nil {
t.Fatal(err)
}

if len(gotArgs) < 3 || gotArgs[0] != "add" || gotArgs[1] != testTargetID || gotArgs[2] != "Order Service" {
t.Fatalf("args = %v", gotArgs)
}

var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("stdout not JSON: %q", out.String())
}
if got["appId"] != testTargetID || got["added"] != true {
t.Fatalf("json = %v", got)
}
}

func TestAddRejectsAppOutsideOrg(t *testing.T) {
prepare(t)
flagAddID, flagAddName, flagAddJSON = testOutsideOrgID, "", false

if err := runAdd(&cobra.Command{}); err == nil {
t.Fatal("expected an error for an app outside the org")
}
}

func TestAddRejectsUnauthorizedTarget(t *testing.T) {
dir := t.TempDir()
if err := workspace.Write(dir, workspace.Config{
OrganizationID: testOrgID,
Target: workspace.Target{Kind: "app", ApplicationID: testAppID},
}); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
t.Setenv("MAJOR_TOKEN", "test-injected-token")

forbiddenID := "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/applications/" + testAppID + "/info":
fmt.Fprintf(w, `{"applicationId":%q,"organizationId":%q,"urlSlug":"a","name":"A","deployStatus":"deployed","appUrl":null}`, testAppID, testOrgID)
case "/applications/" + forbiddenID + "/info":
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"error":"not_org_builder"}`)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
prev := singletons.GetAPIClient()
singletons.SetAPIClient(api.NewClient(srv.URL))
t.Cleanup(func() { singletons.SetAPIClient(prev) })

origEnsure := ensurePackage
ensurePackage = func(*cobra.Command, string) error { return nil }
t.Cleanup(func() { ensurePackage = origEnsure })

flagAddID, flagAddName, flagAddJSON = forbiddenID, "", false

if err := runAdd(&cobra.Command{}); err == nil {
t.Fatal("expected an error for a target lookup that fails")
}
}
69 changes: 69 additions & 0 deletions cmd/appclient/appclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package appclient

import (
"os"
"os/exec"
"strings"

"github.com/major-technology/cli/errors"
"github.com/major-technology/cli/utils"
"github.com/spf13/cobra"
)

// Cmd represents the app-client command
var Cmd = &cobra.Command{
Use: "app-client",
Short: "Call other apps from this app",
Long: `Generate fetch clients that let the current application call other applications in its organization.`,
Args: utils.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}

func init() {
Cmd.AddCommand(addCmd)
Cmd.AddCommand(removeCmd)
}

// runAppClientCLI runs `pnpm exec major-app-client <args> [--framework <fw>]`.
// A variable so tests can stub the child process.
var runAppClientCLI = func(cmd *cobra.Command, dir string, args ...string) error {
if framework := utils.DetectFramework(dir); framework != "" {
args = append(args, "--framework", framework)
}

pnpmCmd := exec.Command("pnpm", append([]string{"exec", "major-app-client"}, args...)...)
pnpmCmd.Dir = dir
pnpmCmd.Stdout = cmd.ErrOrStderr()
pnpmCmd.Stderr = cmd.ErrOrStderr()

if err := pnpmCmd.Run(); err != nil {
return errors.WrapError("major-app-client "+args[0]+" failed", err)
}

return nil
}

// ensureAppClientPackage adds @major-tech/app-client when package.json lacks it.
func ensureAppClientPackage(cmd *cobra.Command, dir string) error {
data, err := os.ReadFile(dir + "/package.json")
if err != nil {
return errors.WrapError("failed to read package.json", err)
}

if strings.Contains(string(data), `"@major-tech/app-client"`) {
return nil
}

addCmd := exec.Command("pnpm", "add", "@major-tech/app-client")
addCmd.Dir = dir
addCmd.Stdout = cmd.ErrOrStderr()
addCmd.Stderr = cmd.ErrOrStderr()

if err := addCmd.Run(); err != nil {
return errors.WrapError("failed to install @major-tech/app-client", err)
}

return nil
}
75 changes: 75 additions & 0 deletions cmd/appclient/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package appclient

import (
"encoding/json"
"fmt"
"os"

"github.com/major-technology/cli/errors"
"github.com/major-technology/cli/middleware"
"github.com/major-technology/cli/utils"
"github.com/spf13/cobra"
)

var (
flagRemoveID string
flagRemoveJSON bool
)

var removeCmd = &cobra.Command{
Use: "remove",
Short: "Remove a client for another application",
PreRunE: middleware.ChainParent(
middleware.CheckLogin,
middleware.CheckNodeInstalled,
middleware.CheckNodeVersion("22.12"),
middleware.CheckPnpmInstalled,
),
RunE: func(cobraCmd *cobra.Command, args []string) error {
return runRemove(cobraCmd)
},
}

func init() {
removeCmd.Flags().StringVar(&flagRemoveID, "id", "", "Application ID of the client to remove")
removeCmd.Flags().BoolVar(&flagRemoveJSON, "json", false, "Output in JSON format")
removeCmd.MarkFlagRequired("id")
}

func runRemove(cobraCmd *cobra.Command) error {
data, err := os.ReadFile("apps.json")
if err != nil {
return errors.WrapError("failed to read apps.json", err)
}

var entries []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(data, &entries); err != nil {
return errors.WrapError("failed to parse apps.json", err)
}

name := ""
for _, e := range entries {
if e.ID == flagRemoveID {
name = e.Name
break
}
}

if name == "" {
return fmt.Errorf("no client for application %q", flagRemoveID)
}

if err := runAppClientCLI(cobraCmd, ".", "remove", name); err != nil {
return err
}

if flagRemoveJSON {
return utils.WriteJSON(cobraCmd, map[string]any{"appId": flagRemoveID, "removed": true})
}

cobraCmd.Printf("Removed client for %s (%s)\n", name, flagRemoveID)
return nil
}
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/major-technology/cli/clients/config"
mjrToken "github.com/major-technology/cli/clients/token"
"github.com/major-technology/cli/cmd/app"
"github.com/major-technology/cli/cmd/appclient"
cliconfig "github.com/major-technology/cli/cmd/config"
"github.com/major-technology/cli/cmd/demo"
"github.com/major-technology/cli/cmd/mcp"
Expand Down Expand Up @@ -130,6 +131,9 @@ func init() {
resource.Cmd.GroupID = "main"
rootCmd.AddCommand(resource.Cmd)

appclient.Cmd.GroupID = "main"
rootCmd.AddCommand(appclient.Cmd)

vars.Cmd.GroupID = "main"
rootCmd.AddCommand(vars.Cmd)

Expand Down
Loading
Loading