Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,37 @@ npx zeabur deployment log -t=build --env-id <env-id> --service-name <service-nam
npx zeabur <command> --help
```

## Workspaces (personal / team)

By default, the CLI acts under the personal workspace — the account that logged in. To list or create projects under a team you belong to, switch the workspace:

```shell
# show your personal workspace + all teams you belong to, with your role per team
npx zeabur workspace list

# switch to a team by name (or by 24-char team ID if the name is not unique)
npx zeabur workspace switch acme
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# show the workspace the CLI is currently using
npx zeabur workspace current

# return to the personal workspace
npx zeabur workspace clear
```

Switching a workspace clears the pinned project / environment / service context, because resource IDs do not overlap between workspaces.

The workspace only affects directory-level commands (`project list`, `project create`, `deploy` without a linked project). Commands that take a specific service or deployment ID use that resource's own owner and are workspace-independent — your team's `service restart` works the same regardless of which workspace is active.

For one-off commands that should run under a different workspace without switching the persisted state, use the `--workspace` flag:

```shell
# list projects in the "acme" team without switching workspaces
npx zeabur --workspace acme project list
```

`switch personal` is **not** a way to return to personal — it always looks for a team literally named `personal` (team names are unconstrained). Use `workspace clear` to go back.

## Development Guide

[Development Guide](docs/development_guide.md)
Expand Down
11 changes: 9 additions & 2 deletions internal/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ func selectInteractively(f *cmdutil.Factory, opts *Options) (*model.Service, *mo
spinner.WithSuffix(" Fetching projects ..."),
)
s.Start()
projects, err := f.ApiClient.ListAllProjects(context.Background())
ownerID := f.CurrentOwnerID()
projects, err := f.ApiClient.ListAllProjects(context.Background(), ownerID)
if err != nil {
return nil, nil, err
}
Expand All @@ -207,7 +208,13 @@ func selectInteractively(f *cmdutil.Factory, opts *Options) (*model.Service, *mo
return nil, nil, err
}
if confirm {
project, err := f.ApiClient.CreateProject(context.Background(), "default", nil)
// When the active workspace is a team, make it visible that the
// new project will land under that team — not the personal
// account — so the user isn't surprised by where it shows up.
if ws := f.Config.GetContext().GetWorkspace(); ws.IsTeam() {
f.Log.Infof("→ Creating new project in team workspace %q", ws.Name)
}
project, err := f.ApiClient.CreateProject(context.Background(), ownerID, "default", nil)
if err != nil {
f.Log.Error("Failed to create project: ", err)
return nil, nil, err
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/project/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func runCreateNonInteractive(f *cmdutil.Factory, opts *Options) error {
}

func createProject(f *cmdutil.Factory, projectRegion string, projectName *string) error {
project, err := f.ApiClient.CreateProject(context.Background(), projectRegion, projectName)
project, err := f.ApiClient.CreateProject(context.Background(), f.CurrentOwnerID(), projectRegion, projectName)
if err != nil {
f.Log.Error(err)
return err
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/project/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {

// runList will list all projects page by page
func runList(f *cmdutil.Factory, opts Options) error {
projects, err := f.ApiClient.ListAllProjects(context.Background())
projects, err := f.ApiClient.ListAllProjects(context.Background(), f.CurrentOwnerID())
if err != nil {
return err
}
Expand Down
63 changes: 62 additions & 1 deletion internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
uploadCmd "github.com/zeabur/cli/internal/cmd/upload"
variableCmd "github.com/zeabur/cli/internal/cmd/variable"
versionCmd "github.com/zeabur/cli/internal/cmd/version"
workspaceCmd "github.com/zeabur/cli/internal/cmd/workspace"
"github.com/zeabur/cli/internal/cmdutil"
"github.com/zeabur/cli/pkg/api"
"github.com/zeabur/cli/pkg/config"
Expand Down Expand Up @@ -98,7 +99,21 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman
}
// set up the client
f.ApiClient = api.New(f.Config.GetTokenString())
f.Selector = selector.New(f.ApiClient, f.Log, f.Prompter)

// Resolve the --workspace flag (one-shot override) and lazy-
// verify the persisted workspace. Both steps are best-effort
// — flag errors abort the command (explicit user intent), but
// a verify hiccup (offline / 5xx) only warns; the user still
// gets to run their command. The selector reads the resolved
// owner via a closure on Factory so subsequent calls within
// the same process see flag overrides and switch updates
// without re-instantiating the selector.
if err := resolveWorkspaceFlag(f); err != nil {
return err
}
verifyPersistedWorkspace(f)

f.Selector = selector.New(f.ApiClient, f.Log, f.Prompter, f.CurrentOwnerID)
f.ParamFiller = fill.NewParamFiller(f.Selector)
}

Expand Down Expand Up @@ -145,6 +160,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman
cmd.PersistentFlags().BoolVarP(&f.Interactive, config.KeyInteractive, "i", true, "use interactive mode")
cmd.PersistentFlags().BoolVar(&f.AutoCheckUpdate, config.KeyAutoCheckUpdate, true, "automatically check update")
cmd.PersistentFlags().BoolVar(&f.JSON, "json", false, "output in JSON format")
cmd.PersistentFlags().StringVar(&f.Workspace, "workspace", "",
"one-shot workspace override (team name or ID); to return to personal use 'zeabur workspace clear'")

// Child commands
cmd.AddCommand(deployCmd.NewCmdDeploy(f))
Expand All @@ -164,13 +181,57 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman
cmd.AddCommand(emailCmd.NewCmdEmail(f))
cmd.AddCommand(fileCmd.NewCmdFile(f))
cmd.AddCommand(aihubCmd.NewCmdAIHub(f))
cmd.AddCommand(workspaceCmd.NewCmdWorkspace(f))

// replace default help command with our custom one that supports --all
cmd.SetHelpCommand(helpCmd.NewCmdHelp(cmd))

return cmd, nil
}

// resolveWorkspaceFlag turns the raw --workspace value into a team ObjectID
// and records it on the Factory. Empty flag is a no-op. The keyword
// "personal" is intentionally NOT recognized — `zeabur workspace clear` is
// the only way to address personal, and team names are unconstrained (a
// user-named "personal" team must be reachable). Backend-side RBAC validates
// the resolved ID on every call; resolution here is a UX layer.
func resolveWorkspaceFlag(f *cmdutil.Factory) error {
raw := strings.TrimSpace(f.Workspace)
if raw == "" {
return nil
}
team, err := cmdutil.ResolveWorkspaceArg(context.Background(), f.ApiClient, raw)
if err != nil {
return fmt.Errorf("--workspace: %w", err)
}
f.SetWorkspaceOverride(team.ID)
return nil
}

// verifyPersistedWorkspace warns and falls back to personal when the
// persisted workspace is no longer a team the caller belongs to (team
// deleted, caller removed, etc.). Best-effort: any transport error leaves the
// workspace untouched so an offline blip doesn't silently switch users out.
func verifyPersistedWorkspace(f *cmdutil.Factory) {
ws := f.Config.GetContext().GetWorkspace()
if ws.IsPersonal() {
return
}
teams, err := f.ApiClient.ListTeams(context.Background())
if err != nil {
f.Log.Debugf("workspace verify skipped: %v", err)
return
}
for _, t := range teams {
if t.ID == ws.ID {
return
}
}
f.Log.Warnf("Persisted workspace %q [%s] is no longer in your memberships; falling back to personal.", ws.Name, ws.ID)
f.Config.GetContext().ClearWorkspace()
f.Config.GetContext().ClearAll()
}

// normalizeIDFlag strips a known prefix from a prefixed MongoDB ObjectID flag value.
// e.g. "service-662e24fca7d5abcdef123456" → "662e24fca7d5abcdef123456"
func normalizeIDFlag(flag *pflag.Flag) error {
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/template/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func runDeploy(f *cmdutil.Factory, opts *Options) error {
}

if opts.region != "" && opts.projectID == "" {
project, err := f.ApiClient.CreateProject(context.Background(), opts.region, nil)
project, err := f.ApiClient.CreateProject(context.Background(), f.CurrentOwnerID(), opts.region, nil)
if err != nil {
return fmt.Errorf("create project in region %s: %w", opts.region, err)
}
Expand Down
50 changes: 50 additions & 0 deletions internal/cmd/workspace/clear/clear.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Package clear implements `zeabur workspace clear`. Clear is the ONLY way
// to return to the personal workspace — `workspace switch personal` is
// intentionally interpreted as "find a team literally named personal" because
// team names are unconstrained.
package clear

import (
"fmt"

"github.com/spf13/cobra"

"github.com/zeabur/cli/internal/cmdutil"
)

// NewCmdClear builds `zeabur workspace clear`.
func NewCmdClear(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{
Use: "clear",
Short: "Switch back to the personal workspace",
Long: `Return to the personal workspace.

This is the only way to switch to personal: `+"`workspace switch personal`"+`
always looks for a team literally named "personal". This also clears the
persisted project, environment, and service context because resource IDs do
not overlap between workspaces.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return run(f)
},
}
}

func run(f *cmdutil.Factory) error {
cctx := f.Config.GetContext()
prev := cctx.GetWorkspace()
prevProject := cctx.GetProject()

cctx.ClearWorkspace()
cctx.ClearAll()

if prev.IsPersonal() {
fmt.Println("Already on personal workspace.")
} else {
fmt.Printf("Switched to personal workspace (was: %s).\n", prev.Name)
}
if !prevProject.Empty() {
fmt.Printf("Project context cleared (was: %s).\n", prevProject.GetName())
}
return nil
}
54 changes: 54 additions & 0 deletions internal/cmd/workspace/current/current.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package current implements `zeabur workspace current`.
package current

import (
"context"
"fmt"

"github.com/spf13/cobra"

"github.com/zeabur/cli/internal/cmdutil"
)

// NewCmdCurrent builds `zeabur workspace current`.
func NewCmdCurrent(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{
Use: "current",
Short: "Show the workspace the CLI is currently acting under",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return run(f)
},
}
}

func run(f *cmdutil.Factory) error {
ws := f.Config.GetContext().GetWorkspace()
if ws.IsPersonal() {
label := f.Config.GetUser()
if label == "" {
label = f.Config.GetUsername()
}
if label == "" {
label = "(you)"
}
fmt.Printf("personal (%s)\n", label)
return nil
}

// For a team workspace also fetch the freshest role from the backend so
// `current` reports the live role rather than whatever was cached when
// the workspace was last switched in.
role := ""
teams, err := f.ApiClient.ListTeams(context.Background())
if err == nil {
for _, t := range teams {
if t.ID == ws.ID && t.MyRole != nil {
role = " " + t.MyRole.Display()
break
}
}
}
fmt.Printf("%s [%s] team%s\n", ws.Name, ws.ID, role)
return nil
}
90 changes: 90 additions & 0 deletions internal/cmd/workspace/list/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Package list implements `zeabur workspace list`.
package list

import (
"context"
"fmt"
"os"
"text/tabwriter"

"github.com/spf13/cobra"

"github.com/zeabur/cli/internal/cmdutil"
)

// NewCmdList builds `zeabur workspace list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List the personal workspace and every team the caller belongs to",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return run(f)
},
}
}

func run(f *cmdutil.Factory) error {
teams, err := f.ApiClient.ListTeams(context.Background())
if err != nil {
return fmt.Errorf("list teams: %w", err)
}

currentID := f.Config.GetContext().GetWorkspace().ID

w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)

// Personal always renders first. The 24-space placeholder keeps the
// columns aligned with the team rows that follow.
personalMarker := " "
if currentID == "" {
personalMarker = "*"
}
personalLabel := f.Config.GetUser()
if personalLabel == "" {
personalLabel = f.Config.GetUsername()
}
if personalLabel == "" {
personalLabel = "(you)"
}
fmt.Fprintf(w, "%s\tpersonal\t\t\t(%s)\n", personalMarker, personalLabel)

for _, t := range teams {
marker := " "
if t.ID == currentID {
marker = "*"
}
role := ""
if t.MyRole != nil {
role = t.MyRole.Display()
}
fmt.Fprintf(w, "%s\t%s\t%s\tteam\t%s\n", marker, t.ID, t.Name, role)
}

if err := w.Flush(); err != nil {
return fmt.Errorf("render table: %w", err)
}

if currentID != "" {
// If the persisted workspace is no longer in the membership list
// (e.g. the team was deleted / the caller was removed), surface it
// so the user knows the next command may behave unexpectedly. We
// don't auto-clear here — that's the lazy-verify path in root.
seen := false
for _, t := range teams {
if t.ID == currentID {
seen = true
break
}
}
if !seen {
fmt.Fprintf(os.Stderr,
"\nwarning: the persisted workspace %s is not in your memberships any more — run `zeabur workspace clear` or switch to another workspace.\n",
currentID,
)
}
}

return nil
}
Loading
Loading