Skip to content
Merged
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
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
14 changes: 14 additions & 0 deletions internal/cmd/context/clear/clear.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package clear

import (
"fmt"

"github.com/spf13/cobra"

"github.com/zeabur/cli/internal/cmdutil"
Expand All @@ -24,6 +26,18 @@ func NewCmdClear(f *cmdutil.Factory) *cobra.Command {
}

func runClear(f *cmdutil.Factory, opts *Options) error {
// `context clear` modifies the persisted inner context. Under a
// `--workspace` override the persisted state belongs to a (potentially)
// different workspace than the user thinks they're in, so silently
// clearing it would surprise them. Reject up front and tell them to
// switch first if they really mean to wipe the persisted context
// (PLA-1590 B+).
if f.HasWorkspaceOverride() {
return fmt.Errorf(
"`context clear` cannot be combined with `--workspace`; the override does not modify persisted context",
)
}

confirm := true

if f.Interactive {
Expand Down
19 changes: 16 additions & 3 deletions internal/cmd/context/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
}

func runGet(f *cmdutil.Factory, opts *Options) error {
project := f.Config.GetContext().GetProject()
environment := f.Config.GetContext().GetEnvironment()
service := f.Config.GetContext().GetService()
// Use the effective context so `--workspace` override truthfully shows
// "(not set)" for inner context — displaying the persisted team-A
// project under an override to team-B would mislead the user into
// thinking it's available there (PLA-1590 B+).
ctx := f.EffectiveContext()
project := ctx.GetProject()
environment := ctx.GetEnvironment()
service := ctx.GetService()

header := []string{"Context", "Name", "ID"}
data := [][]string{
Expand Down Expand Up @@ -56,5 +61,13 @@ func runGet(f *cmdutil.Factory, opts *Options) error {

f.Printer.Table(header, data)

// Human-readable mode also tells the user *why* everything is unset when
// they're running under a `--workspace` override, so they don't
// misread it as a config bug. JSON mode stays structurally clean
// (no prose mixed into the payload) so scripts keep parsing it.
if f.HasWorkspaceOverride() {
f.Log.Info("Note: --workspace is one-shot; persisted project/service/environment context is not used.")
}

return nil
}
99 changes: 91 additions & 8 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 @@ -66,6 +68,20 @@ func NewCmdSet(f *cmdutil.Factory) *cobra.Command {
}

func runSet(f *cmdutil.Factory, opts *Options) error {
// `context set` writes persistent state — it cannot be combined with the
// one-shot `--workspace` override, because the persisted context is
// always interpreted relative to the persisted workspace, never the
// override. Mixing the two would leave a project / service / environment
// from team B pinned under persisted workspace A, which then causes
// silent cross-workspace operations on subsequent commands. Reject up
// front with an actionable hint (PLA-1590 B+).
if f.HasWorkspaceOverride() {
return fmt.Errorf(
"`context set` writes persistent state and cannot be combined with `--workspace`; " +
"run `zeabur workspace switch <team>` first, then `zeabur context set ...`",
)
}

if f.Interactive {
return runSetInteractive(f, opts)
}
Expand Down Expand Up @@ -115,13 +131,61 @@ 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)
if err != nil {
return fmt.Errorf("failed to get project: %w", err)
var (
project *model.Project
err error
)
if id != "" {
// Backend `project(_id)` is owner-agnostic: it'll happily return a
// project from a different team as long as the caller has read
// access (e.g. they're a member of both teams). That makes
// `context set project --id <other-team-project>` a back-door
// cross-workspace contamination path — once pinned, subsequent
// name-based service / variable / etc. commands resolve under
// `<current-team>` ownerID but `<other-team>` projectID, and
// happily delete / restart the wrong team's services.
//
// For team workspaces, verify the project actually belongs to
// the current team via the owner-scoped ListAllProjects.
// Personal workspace keeps its legacy behaviour because
// collaborator workflows depend on pinning by-ID a project the
// caller doesn't own (PLA-1590 cross-workspace guard).
project, err = f.ApiClient.GetProject(context.Background(), id, "", "")
if err != nil {
return fmt.Errorf("failed to get project: %w", err)
}
if ownerID := f.CurrentOwnerID(); ownerID != "" {
teamProjects, listErr := f.ApiClient.ListAllProjects(context.Background(), ownerID)
if listErr != nil {
return fmt.Errorf("verify project workspace membership: %w", listErr)
}
belongs := false
for _, p := range teamProjects {
if p.ID == id {
belongs = true
break
}
}
if !belongs {
return fmt.Errorf(
"project %q does not belong to workspace %q; either run `zeabur workspace switch <team>` first, or pin by --name",
project.Name, f.CurrentWorkspace().Name,
)
}
}
} 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)
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 +261,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
Loading