diff --git a/cmd/appclient/add.go b/cmd/appclient/add.go new file mode 100644 index 0000000..386f703 --- /dev/null +++ b/cmd/appclient/add.go @@ -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 +} diff --git a/cmd/appclient/add_test.go b/cmd/appclient/add_test.go new file mode 100644 index 0000000..2f3e8a2 --- /dev/null +++ b/cmd/appclient/add_test.go @@ -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") + } +} diff --git a/cmd/appclient/appclient.go b/cmd/appclient/appclient.go new file mode 100644 index 0000000..34de235 --- /dev/null +++ b/cmd/appclient/appclient.go @@ -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 [--framework ]`. +// 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 +} diff --git a/cmd/appclient/remove.go b/cmd/appclient/remove.go new file mode 100644 index 0000000..fa6fc37 --- /dev/null +++ b/cmd/appclient/remove.go @@ -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 +} diff --git a/cmd/root.go b/cmd/root.go index 23ff13c..4e295eb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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) diff --git a/plugins/major-build/skills/app-builder/SKILL.md b/plugins/major-build/skills/app-builder/SKILL.md index b10711e..5e36302 100644 --- a/plugins/major-build/skills/app-builder/SKILL.md +++ b/plugins/major-build/skills/app-builder/SKILL.md @@ -66,10 +66,11 @@ Run `major app theme get` in the app workspace before frontend work. It returns ## Debugging & agent-triggering playbooks -Two playbooks — use the relevant one before you start: +Use whichever applies before you start: - Load the `debug-issue` skill whenever you're investigating a failure, regression, or broken/blank/errored behavior in the app (covers the preview, app errors, logs, and browser inspection). - Read [references/using-agents.md](references/using-agents.md) (in this skill's directory) when wiring the app's runtime code to trigger Major agents (run / sendMessage / stop / approvals; `sandbox_add-agent-client` generates the typed client, same pattern as resource clients). +- To call another app in the org, run `major app-client add --id ` (ids from `major app list`), then `await Fetch('/path')` from server code. ## Recurring work