Files
zzet--gortex/cmd/gortex/cloud.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

201 lines
5.7 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/daemon"
)
// cloud is the umbrella command for all cloud-side flows that run
// from the local CLI: login, list, logout. These commands talk to
// the gortex-cloud control plane (API) — they never run cloud code
// in-process. The two repos are decoupled at the module boundary.
var cloudCmd = &cobra.Command{
Use: "cloud",
Short: "Manage Gortex Cloud connections (login, list, logout)",
}
var (
cloudEndpoint string
cloudWorkspace string
cloudToken string
)
var cloudLoginCmd = &cobra.Command{
Use: "login",
Short: "Connect this machine to a Gortex Cloud workspace",
Long: `Add a [[server]] entry to ~/.gortex/servers.toml pointing at the
gortex-cloud control plane (mcp.gortex.dev/v1 by default) for one
workspace. The token is the gtx_live_… string the admin console
prints once at issuance.
Example:
gortex cloud login --endpoint https://mcp.gortex.dev/v1 \
--workspace tuck --token gtx_live_abc...
`,
RunE: runCloudLogin,
}
var cloudListCmd = &cobra.Command{
Use: "list",
Short: "Show every cloud workspace this machine is connected to",
RunE: runCloudList,
}
var cloudLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove a cloud workspace from ~/.gortex/servers.toml",
RunE: runCloudLogout,
}
func init() {
cloudLoginCmd.Flags().StringVar(&cloudEndpoint, "endpoint", "https://mcp.gortex.dev/v1", "cloud control-plane base URL")
cloudLoginCmd.Flags().StringVar(&cloudWorkspace, "workspace", "", "workspace slug to bind this token to (required)")
cloudLoginCmd.Flags().StringVar(&cloudToken, "token", "", "API token (gtx_live_… or gtx_test_…) issued in the admin console (required)")
_ = cloudLoginCmd.MarkFlagRequired("workspace")
_ = cloudLoginCmd.MarkFlagRequired("token")
cloudLogoutCmd.Flags().StringVar(&cloudWorkspace, "workspace", "", "workspace slug to disconnect (required)")
_ = cloudLogoutCmd.MarkFlagRequired("workspace")
cloudCmd.AddCommand(cloudLoginCmd, cloudListCmd, cloudLogoutCmd)
rootCmd.AddCommand(cloudCmd)
}
func runCloudLogin(_ *cobra.Command, _ []string) error {
cloudToken = strings.TrimSpace(cloudToken)
if cloudToken == "" {
return errors.New("--token is required")
}
if !strings.HasPrefix(cloudToken, "gtx_live_") && !strings.HasPrefix(cloudToken, "gtx_test_") {
return errors.New("--token must start with gtx_live_ or gtx_test_")
}
if cloudWorkspace == "" {
return errors.New("--workspace is required")
}
// Verify the token by hitting /v1/workspaces/repos against the
// endpoint. If the call returns 401 we surface it before
// persisting any config.
if err := pingCloudEndpoint(cloudEndpoint, cloudToken); err != nil {
return fmt.Errorf("verify cloud connection: %w", err)
}
path := daemon.ServersConfigPath()
cfg, err := daemon.LoadServersConfig(path)
if err != nil {
return fmt.Errorf("load servers config: %w", err)
}
slug := "cloud-" + cloudWorkspace
for i, s := range cfg.Server {
if s.Slug == slug {
cfg.Server = append(cfg.Server[:i], cfg.Server[i+1:]...)
break
}
}
if err := cfg.AddServer(daemon.ServerEntry{
Slug: slug,
URL: cloudEndpoint,
AuthToken: cloudToken,
Workspaces: []string{cloudWorkspace},
}); err != nil {
return fmt.Errorf("add server: %w", err)
}
if err := cfg.Save(path); err != nil {
return fmt.Errorf("save servers config: %w", err)
}
fmt.Fprintf(os.Stdout, "logged in: workspace=%s endpoint=%s slug=%s\n", cloudWorkspace, cloudEndpoint, slug)
fmt.Fprintf(os.Stdout, "next: gortex daemon start (or already running)\n")
return nil
}
func runCloudList(_ *cobra.Command, _ []string) error {
cfg, err := daemon.LoadServersConfig(daemon.ServersConfigPath())
if err != nil {
return err
}
if len(cfg.Server) == 0 {
fmt.Fprintln(os.Stdout, "no [[server]] entries — run `gortex cloud login` to add one")
return nil
}
fmt.Fprintf(os.Stdout, "%-24s %-46s %s\n", "SLUG", "ENDPOINT", "WORKSPACES")
for _, s := range cfg.Server {
fmt.Fprintf(os.Stdout, "%-24s %-46s %s\n", s.Slug, s.URL, strings.Join(s.Workspaces, ","))
}
return nil
}
func runCloudLogout(_ *cobra.Command, _ []string) error {
path := daemon.ServersConfigPath()
cfg, err := daemon.LoadServersConfig(path)
if err != nil {
return err
}
slug := "cloud-" + cloudWorkspace
out := cfg.Server[:0]
removed := false
for _, s := range cfg.Server {
if s.Slug == slug {
removed = true
continue
}
out = append(out, s)
}
cfg.Server = out
if !removed {
return fmt.Errorf("no entry with slug %q found", slug)
}
if err := cfg.Save(path); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "logged out: %s\n", slug)
return nil
}
func pingCloudEndpoint(endpoint, token string) error {
if !strings.HasSuffix(endpoint, "/") {
endpoint += "/"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
strings.TrimSuffix(endpoint, "/")+"/workspaces/repos", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
c := &http.Client{Timeout: 10 * time.Second}
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusUnauthorized {
return errors.New("token rejected (401)")
}
// Show a short tail of the response to help debug.
if len(body) > 200 {
body = body[:200]
}
return fmt.Errorf("unexpected %d: %s", resp.StatusCode, bytes.TrimSpace(body))
}
// Keep encoding/json import — `gortex cloud login` will grow to
// parse a /v1/me response in a follow-up; declaring it now keeps
// the import section stable for that change.
var _ = json.RawMessage(nil)