Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,41 @@ 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 — pass either the team name OR its 24-char ObjectID.
# A 24-char hex value is always interpreted as an ID; anything else is
# looked up by name. If multiple teams share a name the CLI errors out and
# prints the per-candidate `workspace switch <id>` invocation so you can
# pick by ID (team names are unconstrained, so duplicates are possible).
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
48 changes: 42 additions & 6 deletions internal/cmd/context/set/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/spf13/cobra"

"github.com/zeabur/cli/internal/cmdutil"
"github.com/zeabur/cli/internal/util"
"github.com/zeabur/cli/pkg/model"
"github.com/zeabur/cli/pkg/zcontext"
)

Expand Down Expand Up @@ -115,13 +117,28 @@ func setProject(f *cmdutil.Factory, id, name string, shouldCheck bool) error {
}

if shouldCheck {
ctx := context.Background()
project, err := f.ApiClient.GetProject(ctx, id, f.Config.GetUsername(), name)
var (
project *model.Project
err error
)
if id != "" {
// ID path is workspace-agnostic — `project(_id)` resolves the
// owner from the project itself, so this works for both
// personal and team-owned projects.
project, err = f.ApiClient.GetProject(context.Background(), id, "", "")
} else {
// Name path: must respect the active workspace, otherwise a
// team workspace silently looks up the project under the
// caller's personal account.
project, err = util.GetProjectByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), name)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if err != nil {
return fmt.Errorf("failed to get project: %w", err)
}
f.Config.GetContext().SetProject(zcontext.NewBasicInfo(project.ID, project.Name))

// User may have passed only --id; backfill the local `name` so the
// success log below names the resolved project instead of "<>".
name = project.Name
} else {
f.Config.GetContext().SetProject(zcontext.NewBasicInfo(id, name))
}
Expand Down Expand Up @@ -197,13 +214,32 @@ func setService(f *cmdutil.Factory, id, name string, shouldCheck bool) error {
}

if shouldCheck {
ctx := context.Background()
service, err := f.ApiClient.GetService(ctx, id, f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), name)
var (
service *model.Service
err error
)
if id != "" {
service, err = f.ApiClient.GetService(context.Background(), id, "", "", "")
} else {
// Same workspace-aware lookup as setProject — `service(owner,
// projectName, name)` keys on the caller's personal username
// and would miss a team-owned project.
service, err = util.GetServiceByName(
f.ApiClient,
f.CurrentOwnerID(),
f.Config.GetUsername(),
f.Config.GetContext().GetProject().GetName(),
f.Config.GetContext().GetProject().GetID(),
name,
)
}
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
f.Config.GetContext().SetService(zcontext.NewBasicInfo(service.ID, service.Name))

// User may have passed only --id; backfill the local `name` so the
// success log below names the resolved service instead of "<>".
name = service.Name
} else {
f.Config.GetContext().SetService(zcontext.NewBasicInfo(id, name))
}
Expand Down
17 changes: 14 additions & 3 deletions internal/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,19 +195,30 @@ func selectInteractively(f *cmdutil.Factory, opts *Options) (*model.Service, *mo
spinner.WithSuffix(" Fetching projects ..."),
)
s.Start()
projects, err := f.ApiClient.ListAllProjects(context.Background())
// Snapshot the active workspace so the hint below names the same team
// the create call actually files the project under. Reading the
// persisted workspace here would race the --workspace flag override.
ws := f.CurrentWorkspace()
ownerID := ws.ID
projects, err := f.ApiClient.ListAllProjects(context.Background(), ownerID)
s.Stop()
if err != nil {
return nil, nil, err
}
s.Stop()

if len(projects) == 0 {
confirm, err := f.Prompter.Confirm("No projects found. Would you like to create one now?", true)
if err != nil {
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.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/deployment/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func runGetNonInteractive(f *cmdutil.Factory, opts *Options) (err error) {

// Resolve service ID from name
if opts.serviceID == "" && opts.serviceName != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName)
service, err := util.GetServiceByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), f.Config.GetContext().GetProject().GetID(), opts.serviceName)
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/deployment/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func runListInteractive(f *cmdutil.Factory, opts *Options) error {
func runListNonInteractive(f *cmdutil.Factory, opts *Options) error {
// Resolve service ID from name
if opts.serviceID == "" && opts.serviceName != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName)
service, err := util.GetServiceByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), f.Config.GetContext().GetProject().GetID(), opts.serviceName)
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/deployment/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func runLogInteractive(f *cmdutil.Factory, opts *Options) error {
func runLogNonInteractive(f *cmdutil.Factory, opts *Options) (err error) {
// Resolve serviceID from serviceName first
if opts.serviceID == "" && opts.serviceName != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.serviceName)
service, err := util.GetServiceByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), f.Config.GetContext().GetProject().GetID(), opts.serviceName)
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/domain/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ func runCreateDomainInteractive(f *cmdutil.Factory, opts *Options) error {

func runCreateDomainNonInteractive(f *cmdutil.Factory, opts *Options) error {
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
service, err := util.GetServiceByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), f.Config.GetContext().GetProject().GetID(), opts.name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/domain/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func runDeleteDomainInteractive(f *cmdutil.Factory, opts *Options) error {

func runDeleteDomainNonInteractive(f *cmdutil.Factory, opts *Options) error {
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
service, err := util.GetServiceByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), f.Config.GetContext().GetProject().GetID(), opts.name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/domain/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func runListDomainsInteractive(f *cmdutil.Factory, opts *Options) error {

func runListDomainsNonInteractive(f *cmdutil.Factory, opts *Options) error {
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
service, err := util.GetServiceByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), f.Config.GetContext().GetProject().GetName(), f.Config.GetContext().GetProject().GetID(), opts.name)
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/project/clone/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func runCloneInteractive(f *cmdutil.Factory, opts *Options) error {
}
opts.ProjectID = projectInfo.GetID()
} else if opts.ProjectID == "" && opts.ProjectName != "" {
project, err := util.GetProjectByName(f.Config, f.ApiClient, opts.ProjectName)
project, err := util.GetProjectByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), opts.ProjectName)
if err != nil {
return err
}
Expand Down Expand Up @@ -132,7 +132,7 @@ func runCloneNonInteractive(f *cmdutil.Factory, opts *Options) error {

// Resolve project ID from name if needed
if opts.ProjectID == "" && opts.ProjectName != "" {
project, err := util.GetProjectByName(f.Config, f.ApiClient, opts.ProjectName)
project, err := util.GetProjectByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), opts.ProjectName)
if err != nil {
return 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/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func runDeleteNonInteractive(f *cmdutil.Factory, opts *Options) error {
}

if opts.id == "" && opts.name != "" {
project, err := util.GetProjectByName(f.Config, f.ApiClient, opts.name)
project, err := util.GetProjectByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), opts.name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/project/export/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func runExport(f *cmdutil.Factory, opts Options) error {
}

if opts.ProjectID == "" && opts.ProjectName != "" {
project, err := util.GetProjectByName(f.Config, f.ApiClient, opts.ProjectName)
project, err := util.GetProjectByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), opts.ProjectName)
if err != nil {
return fmt.Errorf("get project %s failed: %w", opts.ProjectName, err)
}
Expand Down
18 changes: 15 additions & 3 deletions internal/cmd/project/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/zeabur/cli/internal/util"

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

type Options struct {
Expand Down Expand Up @@ -57,9 +58,20 @@ func runGetNonInteractive(f *cmdutil.Factory, opts *Options) error {
return err
}

ownerName := f.Config.GetUsername()

project, err := f.ApiClient.GetProject(context.Background(), opts.id, ownerName, opts.name)
var (
project *model.Project
err error
)
if opts.id != "" {
// ID path resolves the owner from the project itself — works for
// both personal and team-owned projects.
project, err = f.ApiClient.GetProject(context.Background(), opts.id, "", "")
} else {
// Name path must respect the active workspace; the backend
// `project(owner, name)` query keys on the caller's personal
// username and would miss team-owned projects.
project, err = util.GetProjectByName(f.ApiClient, f.CurrentOwnerID(), f.Config.GetUsername(), opts.name)
}
if err != nil {
return fmt.Errorf("failed to get project: %w", 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
Loading