Skip to content

feat: support team workspace via zeabur workspace (PLA-1590)#244

Merged
BruceDu521 merged 10 commits into
mainfrom
bruce/pla-1590-workspace
May 26, 2026
Merged

feat: support team workspace via zeabur workspace (PLA-1590)#244
BruceDu521 merged 10 commits into
mainfrom
bruce/pla-1590-workspace

Conversation

@BruceDu521
Copy link
Copy Markdown
Contributor

@BruceDu521 BruceDu521 commented May 20, 2026

Summary

让 Zeabur CLI 跟 dashboard 一样可以切到 team workspace —— 操作 team 拥有的 project / service / deployment。

Closes PLA-1590
依赖:PLA-1589 (Team.myRole backend field — backend PR zeabur/backend#2131)。

客户场景

Discord ives.0629 (2026-05-15):team 成员 alice 想用 CLI deploy team 的 project,但 zeabur project list 永远只看到她个人的项目。表象是"team workspace 建的 API key 指向个人",真实诉求 = "让 CLI 能操作我所属 team 的 project"

取代 PLA-1542 / zeabur/backend#2115("给 API key 加 team scope"误判方向,详见两个 issue 的 cancellation 说明)。

新增命令

zeabur workspace list                # 列 personal + 所有 team(带 role)
zeabur workspace current             # 显示当前 workspace
zeabur workspace switch <name|id>    # 切到 team workspace
zeabur workspace clear               # 切回 personal(唯一切回个人的方式)

加 global --workspace <name|id> flag,一次性 override 当前 workspace(不改 persistent state)。

解析规则

workspace switch <arg>:
  arg 是 24-char hex → 当 team ID(必须在 caller 的 memberships 里)
  arg 不是 hex      → 当 name 查 memberships:
    0 个匹配  → "no workspace named ..."
    1 个匹配  → 切
    ≥2 个匹配 → 拒绝并列每个候选的 `zeabur workspace switch <id>` 命令

switch personal 不是回到个人的快捷方式 —— 它永远是去找名叫 personal 的 team(team 名字无限制)。回 personal 用 workspace clear

--workspace flag 用同样解析。

Switch / clear 副作用

切换 workspace 会 project / environment / service context(不同 workspace 的 resource ID 不重叠,留旧 context 必然 stale)。每次 switch / clear 输出都会明示清了什么。

哪些命令受 workspace 影响

只有"目录级":

命令 用 workspace
project list
project create
deploy(没 link project) ✓(带 → Creating new project in team workspace "xxx" 提示)
service list -p <pid> ✗ projectID 自带 owner
service deploy -s <sid> ✗ serviceID 自带 owner
variable create ...
deployment log ...

绝大多数命令对 workspace 透明 —— 跟 dashboard 一致。

Startup lazy verify

每次进程启动调一次 teams query 验证 persisted workspace 还在不在 caller 的 memberships 里。被删 / 被踢出 → warning + auto fallback personal。Transport error 不动 workspace(offline 不应该静默切人)。

兼容性

  • 新 commands 都是 additive
  • --workspace flag 默认 empty(= personal,等同今天)
  • ListProjects(ownerID, ...) / CreateProject(ownerID, ...) API 签名改了 —— 内部 caller 全部更新,外部没有人 import 这两个签名
  • Backend projects(ownerID) / createProject(ownerID) 已经在 prod 跑了一段(team plan PLA-1160 系列),CLI 这边走的是已经存在的 schema 路径
  • Team.myRole 是 PLA-1589(zeabur/backend#2131)提供。一起 review、merge 后 deploy。本 PR 的 GraphQL query teams { _id name myRole } 在 backend 部署前会报字段不存在 —— prod 用户拿到这个 CLI 的时间一定晚于 backend deploy(CLI 发版需要打 v* tag),所以发版前 backend merge 即可。

Tests

internal/cmdutil/workspace_test.go 覆盖:

  • 空参数 → required error
  • ID 命中 / ID 不在 memberships
  • Name 唯一命中
  • Name 无匹配
  • Name 重名 → ambiguous 错误,含每个候选的 switch 命令 + role(Bruce 明确要求)
  • isObjectIDHex 各种 edge case
$ go test -run TestResolveWorkspaceArg|TestIsObjectIDHex ./internal/cmdutil/
=== RUN   TestResolveWorkspaceArg_EmptyArg                       --- PASS
=== RUN   TestResolveWorkspaceArg_ByID_Match                     --- PASS
=== RUN   TestResolveWorkspaceArg_ByID_NotAMember                --- PASS
=== RUN   TestResolveWorkspaceArg_ByName_Unique                  --- PASS
=== RUN   TestResolveWorkspaceArg_ByName_NotFound                --- PASS
=== RUN   TestResolveWorkspaceArg_ByName_Ambiguous               --- PASS
=== RUN   TestIsObjectIDHex                                      --- PASS
PASS

全套 go test ./... 通过。

文件清单

新增:

  • pkg/zcontext/workspace.go — Workspace 结构 + Personal/Team 判断
  • pkg/model/team.go — Team / TeamMemberRole models
  • pkg/api/team.go — ListTeams API
  • internal/cmdutil/workspace.goResolveWorkspaceArg(核心解析逻辑,flag + switch 共用)
  • internal/cmdutil/workspace_test.go — 7 个 unit test
  • internal/cmd/workspace/{workspace,list,current,switch,clear}/ — 4 个子命令 + 父命令

修改:

  • pkg/zcontext/{interface,context}.go — Context 接口加 Workspace
  • pkg/api/{interface,project}.go — ProjectAPI 加 ownerID 参数
  • pkg/selector/selector.goNew 加 ownerIDFn 闭包
  • internal/cmdutil/factory.go — Factory 加 Workspace flag + CurrentOwnerID() / SetWorkspaceOverride()
  • internal/cmd/root/root.go — 注册 workspace 命令 + --workspace global flag + PreRunE 里解析 flag + lazy verify + 用闭包构造 selector
  • 4 个 caller 更新:project list / project create / deploy / template deploy 传 ownerID(deploy 多加 team workspace 提示)

Out of Scope(备查)

  • ❌ Team-scoped API key("team service account")—— 真有需求时单开 issue 做完整 IAM 系统设计,参考 AWS IAM / GCP Service Account / GitHub fine-grained PAT
  • ❌ 跨 team 聚合 query("列我所有 team 的 service")—— 没真实需求
  • ❌ Team 命名限制(用户起名自由,"personal" 也允许)

🤖 Generated with Claude Code


View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

Adds a workspace concept to the CLI so team members can list, create, and
deploy projects under a team they belong to — mirroring the dashboard's
workspace switcher.

Commands:

  zeabur workspace list                  list personal + all teams with role
  zeabur workspace current               show the active workspace
  zeabur workspace switch <name|id>      switch to a team workspace
  zeabur workspace clear                 return to personal workspace

Plus a global `--workspace <name|id>` flag for one-shot overrides without
touching the persisted state.

Resolution rules:

- 24-char hex → treated as a team ObjectID; must be in the caller's
  memberships
- non-hex → name match against memberships; 0 matches errors, 2+ matches
  errors with the concrete `zeabur workspace switch <id>` invocation per
  candidate (team names are unconstrained and may collide)
- `switch personal` is NOT a fast path back to personal; it always means
  "find a team literally named 'personal'". Use `workspace clear` instead.

Switching workspaces clears the persisted project / environment / service
context because resource IDs do not overlap between workspaces. The
clear-on-switch is surfaced in every `switch` / `clear` output line.

Only directory-level operations (`project list`, `project create`,
`deploy` without a linked project) consult the workspace. Operations that
take a specific service or deployment ID stay workspace-independent —
the resource's own owner already locates the team, and backend RBAC
gates writes.

Threading: a `Factory.CurrentOwnerID()` helper folds the --workspace flag
override (resolved during PersistentPreRunE) with the persisted workspace,
and selector.New takes a closure so the same Selector instance reflects
mid-process workspace switches.

Lazy startup verify: PersistentPreRunE calls `teams` once and clears the
persisted workspace if the caller is no longer a member (team deleted,
caller removed). Best-effort: transport errors leave the workspace alone
so an offline blip doesn't switch the user out silently.

Depends on backend PLA-1589 (Team.myRole field) for the per-team role
shown in `workspace list` and in disambiguation errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f9791ce9-af5c-400c-8a6d-4d8f4a5962d5

📥 Commits

Reviewing files that changed from the base of the PR and between e24000d and 02c32e4.

📒 Files selected for processing (4)
  • internal/cmd/context/set/set_test.go
  • internal/cmd/workspace/list/list.go
  • internal/cmdutil/factory.go
  • internal/util/runE_test.go

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • workspace commands: list/ls, current, switch, clear, and a one-shot --workspace flag.
    • Project/service lookups and creation now respect the active workspace scope.
  • Behavior Changes

    • Commands honor personal vs team workspace scope for listings, lookups and creations.
    • One-shot --workspace is ephemeral and prevents persisting project/environment/service selections; context set/clear reject when override is active.
    • Switching workspaces clears pinned project/environment/service context; current/list show team role when available.
  • Documentation

    • README updated with workspace guidance.
  • Tests

    • Expanded unit and integration tests for workspace resolution and lookup behavior.

Walkthrough

Adds workspace model and ephemeral context, Team API and team models, owner-scoped project/service APIs, Factory workspace override and EffectiveContext, --workspace one-shot flag, workspace CLI subcommands, workspace-aware util lookups, selector owner wiring, and tests for resolvers and factory behavior.

Changes

Workspace Management & Owner Scoping

Layer / File(s) Summary
Workspace Data Model & Persistence
pkg/zcontext/workspace.go, pkg/zcontext/context.go, pkg/zcontext/interface.go, pkg/zcontext/ephemeral.go
New Workspace type, IsPersonal()/IsTeam(), viper-backed GetWorkspace/SetWorkspace/ClearWorkspace, ephemeral in-memory Context for overrides, and interface docs updated.
API Team Support & Project Scoping
pkg/model/team.go, pkg/api/interface.go, pkg/api/team.go, pkg/api/project.go
Adds Team model and TeamAPI.ListTeams; updates ProjectAPI signatures (ListProjects/ListAllProjects/CreateProject) to accept ownerID and conditionally select owner-scoped GraphQL queries/mutations.
Factory, Root Flag Resolution & Selector
internal/cmdutil/factory.go, internal/cmd/root/root.go, pkg/selector/selector.go
Factory stores a one-shot workspace override, CurrentWorkspace()/CurrentOwnerID(), HasWorkspaceOverride(), EffectiveContext() (ephemeral cached Context), and memoized ListTeams; root parses --workspace, verifies persisted workspace membership, and constructs the selector with an ownerID callback.
Workspace Subcommands
internal/cmd/workspace/*
Adds zeabur workspace parent and list, current, `switch <name
Selector & Project Integration
pkg/selector/selector.go, internal/cmd/deploy/*, internal/cmd/template/deploy/deploy.go, internal/cmd/project/*
Selector accepts ownerID function; project listing and creation now pass resolved owner ID; deploy/template auto-create and project commands use owner scoping and avoid persisting inner context when --workspace override is active.
Workspace-aware util lookups & tests
internal/util/project.go, internal/util/service.go, internal/util/*_test.go, internal/cmdutil/workspace.go, internal/cmdutil/workspace_test.go, internal/cmdutil/factory_test.go
Refactors GetProjectByName and GetServiceByName to accept explicit owner/project context and implement personal vs team lookup paths; adds ResolveWorkspaceArg and unit tests covering ID/name/case/ambiguity; expands Factory tests for override, EffectiveContext, and ListTeams caching/sticky-error behavior.
Commands: workspace-aware lookups usage
internal/cmd/* (project, service, deployment, domain, variable, context, etc.)
Updates many commands to use f.CurrentOwnerID(), f.EffectiveContext() in interactive flows, and workspace-aware util signatures when resolving names to IDs; context set/clear now reject when --workspace override is active to avoid persisting cross-workspace state.
User Documentation
README.md
Adds "Workspaces (personal / team)" section documenting defaults, listing/switching/current/clear commands, --workspace one-shot behaviour, and context-clearing side effects.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bruce/pla-1590-workspace

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/cmd/deploy/deploy.go (1)

197-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stop the spinner on the ListAllProjects error path.

Line 201 returns before s.Stop(), so a failed fetch can leave the spinner active in the terminal.

Suggested fix
 s.Start()
 ownerID := f.CurrentOwnerID()
 projects, err := f.ApiClient.ListAllProjects(context.Background(), ownerID)
+s.Stop()
 if err != nil {
 	return nil, nil, err
 }
-s.Stop()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmd/deploy/deploy.go` around lines 197 - 203, After calling
s.Start() in deploy.go, ensure the spinner is stopped on all code paths: either
add defer s.Stop() immediately after s.Start() or explicitly call s.Stop()
before returning on the error path from
f.ApiClient.ListAllProjects(context.Background(), ownerID); update the block
around s.Start(), ownerID := f.CurrentOwnerID(), and the ListAllProjects error
branch so the spinner is always stopped when ListAllProjects returns an error.
🧹 Nitpick comments (1)
internal/cmdutil/workspace.go (1)

19-22: ⚡ Quick win

Align resolver comments with actual membership enforcement.

The comment says the flag path trusts raw ID even when not in memberships, but the implementation rejects non-member IDs. Please update the comment to match behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmdutil/workspace.go` around lines 19 - 22, Update the comment in
the workspace resolver to reflect actual behavior: instead of saying the flag
path trusts a 24‑char hex team ID even if the caller is not a member, document
that the implementation enforces membership (non-members are rejected) and that
the flag path only accepts an ID if the caller is a member; adjust the note
around the team resolution logic (the comment block describing hex vs non-hex
handling) to state that membership is verified for both paths and backend RBAC
still applies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cmdutil/workspace_test.go`:
- Line 1: Change the test package from package cmdutil to package cmdutil_test
and update all test references to exported symbols by prefixing them with
cmdutil. (For example, replace ResolveWorkspaceArg(...) calls with
cmdutil.ResolveWorkspaceArg(...)). For TestIsObjectIDHex, stop calling the
unexported isObjectIDHex directly—either assert its behavior indirectly via
cmdutil.ResolveWorkspaceArg (or another exported API that uses isObjectIDHex)
or, if your project policy permits, add a very narrow linter exception for this
single test; ensure all updated tests import the cmdutil package and compile
against exported APIs only.

In `@internal/cmdutil/workspace.go`:
- Around line 42-45: The comparison of ObjectIDs is case-sensitive:
isObjectIDHex accepts uppercase hex but the lookup compares teams[i].ID == arg
and may fail for uppercase input; normalize both sides (e.g., lower-case arg and
teams[i].ID or use strings.EqualFold) before comparing so ID resolution is
case-insensitive — update the code in the block that uses isObjectIDHex and the
teams slice lookup to perform a case-insensitive comparison (reference:
isObjectIDHex, teams[i].ID, arg).

In `@pkg/api/project.go`:
- Line 53: The call to c.ListProjects uses context.Background(), which drops the
caller's cancellation/deadline; change it to propagate the caller context by
passing the incoming context (e.g., ctx) instead of context.Background() to
c.ListProjects (or, if this function lacks a context parameter, add a
context.Context parameter to the enclosing function and thread it through), so
the call to ListProjects (and the resulting projectCon/err handling) respects
cancellation and deadlines.

In `@README.md`:
- Line 129: The README line for the workspace switch example currently implies
24-char hex IDs are only used when names conflict; update the text around the
command `npx zeabur workspace switch` to state that a 24‑character hex string is
always interpreted as a team ID (so users may pass either a team name or a
24‑char ID), and add a short note clarifying that if multiple teams share the
same name the CLI will error and recommend using the team ID; update the
single-line example and its parenthetical/notes accordingly.

---

Outside diff comments:
In `@internal/cmd/deploy/deploy.go`:
- Around line 197-203: After calling s.Start() in deploy.go, ensure the spinner
is stopped on all code paths: either add defer s.Stop() immediately after
s.Start() or explicitly call s.Stop() before returning on the error path from
f.ApiClient.ListAllProjects(context.Background(), ownerID); update the block
around s.Start(), ownerID := f.CurrentOwnerID(), and the ListAllProjects error
branch so the spinner is always stopped when ListAllProjects returns an error.

---

Nitpick comments:
In `@internal/cmdutil/workspace.go`:
- Around line 19-22: Update the comment in the workspace resolver to reflect
actual behavior: instead of saying the flag path trusts a 24‑char hex team ID
even if the caller is not a member, document that the implementation enforces
membership (non-members are rejected) and that the flag path only accepts an ID
if the caller is a member; adjust the note around the team resolution logic (the
comment block describing hex vs non-hex handling) to state that membership is
verified for both paths and backend RBAC still applies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1565b3d6-301a-4acf-8ece-acc6c81f4daf

📥 Commits

Reviewing files that changed from the base of the PR and between 18268b9 and c593186.

📒 Files selected for processing (22)
  • README.md
  • internal/cmd/deploy/deploy.go
  • internal/cmd/project/create/create.go
  • internal/cmd/project/list/list.go
  • internal/cmd/root/root.go
  • internal/cmd/template/deploy/deploy.go
  • internal/cmd/workspace/clear/clear.go
  • internal/cmd/workspace/current/current.go
  • internal/cmd/workspace/list/list.go
  • internal/cmd/workspace/switch/switch.go
  • internal/cmd/workspace/workspace.go
  • internal/cmdutil/factory.go
  • internal/cmdutil/workspace.go
  • internal/cmdutil/workspace_test.go
  • pkg/api/interface.go
  • pkg/api/project.go
  • pkg/api/team.go
  • pkg/model/team.go
  • pkg/selector/selector.go
  • pkg/zcontext/context.go
  • pkg/zcontext/interface.go
  • pkg/zcontext/workspace.go

Comment thread internal/cmdutil/workspace_test.go Outdated
Comment thread internal/cmdutil/workspace.go
Comment thread pkg/api/project.go Outdated
Comment thread README.md
@canyugs
Copy link
Copy Markdown
Contributor

canyugs commented May 20, 2026

Panel Review — zeabur/cli PR #244 + zeabur/backend PR #2131

1. What problem does this PR solve?

Team members cannot use the CLI to operate on team-owned projects — zeabur project list only shows personal projects. The CLI needs a workspace concept (mirroring the dashboard) so users can switch between personal and team contexts.

2. How does it solve it?

Backend #2131: Adds a nullable Team.myRole GraphQL field that returns the caller's own role in a team via a direct team_members lookup (hitting the existing partial compound index). Avoids the N+1 teamMembers(teamID) fan-out.

CLI #244: Adds zeabur workspace {list, current, switch, clear} commands and a global --workspace flag. Resolution logic (ResolveWorkspaceArg) is shared between the flag and the switch command. Switching clears project/environment/service context (resource IDs don't overlap between workspaces). A lazy startup verify detects stale memberships and falls back to personal.

3. What are the tradeoffs?

  • Adds a network call (ListTeams) to every CLI invocation when a team workspace is persisted (lazy verify). Multiple reviewers flagged that this can compound to 2–3 calls in a single invocation.
  • Team names are unconstrained (duplicates allowed), requiring ID-based disambiguation — good UX but the error message hardcodes zeabur workspace switch rather than using the actual binary name.
  • switch personal is NOT a shortcut back to personal (by design) — requires workspace clear. Correct but potentially surprising.

4. Verdict

Consensus: request changes
Reviewers: Aragorn: approve (pending dedupe), Legolas: request changes, Gimli: request changes, Boromir: request changes, Frodo: approve


🔴 SUGGESTED CHANGES

  • F1. internal/cmd/deploy/deploy.go:214 — The "Creating new project in team workspace" message reads from the persisted workspace (f.Config.GetContext().GetWorkspace()) but the actual project creation uses f.CurrentOwnerID() which may come from --workspace flag. When --workspace team-b overrides a persisted team-a, the message names the wrong team; when overriding personal, the message is missing entirely. Fix: derive the display name from the same resolved source as CurrentOwnerID. (raised by: Gimli, Legolas, Boromir)
  • F2. internal/cmd/root/root.go:105-112 — A single CLI invocation can call ListTeams up to 3 times: once in resolveWorkspaceFlag, once in verifyPersistedWorkspace, and once in the command itself (e.g. workspace current). Cache the result from the first call on the Factory and reuse it for verify + downstream commands. (raised by: Aragorn, Gimli, Legolas, Boromir)

⚖️ Unresolved disagreements

  • None — reviewers agree on all findings

🟡 NIT

  • N1. internal/gateway/graphql/team.graphqls:209-215 — Schema docstring says "Null only when the caller is not a member" but the resolver returns a GraphQL error (not null) when the caller is unauthenticated. Suggest adding "Errors when the caller is not authenticated." (raised by: Aragorn)
  • N2. internal/cmdutil/workspace.go:70 — Ambiguous error hardcodes zeabur workspace switch; users invoking via npx zeabur or aliases get a non-copy-pasteable command. Consider using os.Args[0]. (raised by: Aragorn)
  • N3. internal/cmd/workspace/current/current.go:43ListTeams error is silently swallowed; the role just doesn't display. Consider at least a debug-level log. (raised by: Aragorn)

🟢 INFO

  • I1. Backend #2131 is clean: nullable additive field, sub-ms index lookup, privacy-narrowing vs teamMembers, test covers 5 membership states. (raised by: Frodo, Gimli, Legolas)
  • I2. Config persistence confirmed safe — PersistentPostRunE calls viper.WriteConfig(), new workspace keys fall through automatically. (raised by: Aragorn)
  • I3. ResolveWorkspaceArg ambiguous-name UX (roles + concrete switch commands per candidate) meets Bruce's explicit requirement. (raised by: Frodo)
  • I4. Closure-based ownerIDFn in Selector prevents stale-owner footgun across mid-process workspace switches. (raised by: Frodo, Legolas)

Panel review:

- F1 (deploy hint vs. flag override): The "Creating new project in team
  workspace X" banner read from the persisted workspace, but the actual
  create call used CurrentOwnerID, which honors the --workspace flag
  override. With `--workspace team-b` overriding a persisted team-a the
  banner named the wrong team; overriding personal printed nothing.
  Snapshot a single CurrentWorkspace() up front and use it for both the
  banner and the ownerID — they now resolve from the same source.

- F2 (ListTeams fanout): A single CLI invocation could hit `teams` up
  to three times (flag resolution, lazy verify, the command itself).
  Memoize the reply on Factory.ListTeams; resolveWorkspaceFlag,
  verifyPersistedWorkspace, and the workspace list / current / switch
  commands now all share one fetch with sticky-error caching.

- N3 (current.go silent error): `workspace current` swallowed ListTeams
  errors and just omitted the role. Log at debug so it isn't completely
  silent without making the command fail.

CodeRabbit:

- workspace.go: factor invocationName() out of the ambiguous-name error
  so users invoking via `npx zeabur` (or any wrapper) get a
  copy-pasteable command in the error, not a hardcoded "zeabur".
  Normalize hex comparison to lower-case so an ID pasted in uppercase
  still resolves (isObjectIDHex already accepts both).

- workspace.go (comment): Rewrite the ID-vs-name docstring to match
  behavior — both paths enforce membership; the hex path doesn't "trust"
  raw input as the old comment implied. Backend RBAC is still the
  authoritative gate.

- deploy.go: spinner left running when ListAllProjects errors —
  s.Stop() happens before the err check so it runs on every path.

- pkg/api/project.go: ListAllProjects' inner loop passed
  context.Background() to each page request, dropping the caller's
  cancellation. Propagate ctx.

- workspace_test.go: convert to the `cmdutil_test` external package so
  the tests only exercise exported APIs. The previous direct test of
  the unexported isObjectIDHex is replaced by
  TestResolveWorkspaceArg_NonHex_NotMistakenForID (24-char non-hex
  must fall into the name branch) and TestResolveWorkspaceArg_
  ByID_CaseInsensitive (uppercase hex must still resolve), which cover
  the same predicate via the public surface.

- README: clarify that a 24-char hex value is always interpreted as an
  ID, not "only when the name is not unique". Names go to the name
  branch regardless; duplicates trip the ambiguous-name error.

Factory: SetWorkspaceOverride now takes `*zcontext.Workspace` instead of
a bare ID so CurrentWorkspace() can return the resolved name to display
sites (the deploy hint). Added CurrentWorkspace() helper alongside
CurrentOwnerID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

review 反馈已处理(commit c515cee):

Panel — Suggested changes (F)

  • F1 (deploy.go): "Creating new project in team workspace X" 现在用 f.CurrentWorkspace() 取,跟 CurrentOwnerID() 来自同一个 resolved 源。--workspace team-b 覆盖 persisted team-a 时不再显示错的 team 名;override 到 personal 时直接不显示。snapshot 一次顶部用到底,避免 race。
  • F2 (Factory.ListTeams 缓存): 单进程内 teams 只调一次。resolveWorkspaceFlag / verifyPersistedWorkspace / workspace list / workspace current / workspace switch 全部走 f.ListTeams(ctx),sticky error caching(出错不重试同一进程)。

Panel — Nits

  • N3 (current.go 吞 error): 现在 f.Log.Debugf 一行,命令依然不 fail。
  • N2 (ambiguous error 硬编码命令名): 抽 invocationName()filepath.Base(os.Args[0])npx zeabur 之类 wrapper 也能 copy-paste 错误信息里的命令。

CodeRabbit

  • ✅ 测试包改 cmdutil_test,只测公开 API。移掉对私有 isObjectIDHex 的直接测,加了两个新 case 通过公开接口覆盖同一谓词:
    • TestResolveWorkspaceArg_ByID_CaseInsensitive(大写 hex 也能解析)
    • TestResolveWorkspaceArg_NonHex_NotMistakenForID(24-char 非 hex 必须走 name 分支)
  • ✅ ID 比较大小写不敏感化(isObjectIDHex 本来就接受大小写,下游 == 不该 case-sensitive)
  • ListAllProjects 内部循环 propagate ctx,不再 context.Background() 丢 caller cancel
  • deploy.go spinner s.Stop() 移到 err check 前,所有 path 都正确停
  • workspace.go 注释改写:两条 path 都执行 membership 校验(旧注释暗示 hex path "trust" raw input,跟代码不符)
  • ✅ README clarify:24-char hex 永远当 ID,不只是 name conflict 时

附带:Factory.SetWorkspaceOverride 签名从 string 改成 *zcontext.Workspace(要带 name 给 deploy hint),新增 Factory.CurrentWorkspace() 配套 CurrentOwnerID

全部测试通过(8 个 TestResolveWorkspaceArg),go build ./... + go vet ./... 都 clean。

🤖 Generated with Claude Code

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cmdutil/workspace_test.go`:
- Line 50: The membership-error assertion uses a negated OR which triggers
staticcheck QF1001; change the condition in the test (where err is checked) from
"if err == nil || !(strings.Contains(err.Error(), \"not a team\") ||
strings.Contains(err.Error(), \"no team\"))" to the equivalent non-negated form
using AND of negations: check "if err == nil || (!strings.Contains(err.Error(),
\"not a team\") && !strings.Contains(err.Error(), \"no team\"))" so behavior
stays the same but avoids the negated OR pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 31178dfc-a73c-458e-a460-18aa3ab44e26

📥 Commits

Reviewing files that changed from the base of the PR and between c593186 and c515cee.

📒 Files selected for processing (10)
  • README.md
  • internal/cmd/deploy/deploy.go
  • internal/cmd/root/root.go
  • internal/cmd/workspace/current/current.go
  • internal/cmd/workspace/list/list.go
  • internal/cmd/workspace/switch/switch.go
  • internal/cmdutil/factory.go
  • internal/cmdutil/workspace.go
  • internal/cmdutil/workspace_test.go
  • pkg/api/project.go
✅ Files skipped from review due to trivial changes (1)
  • README.md

Comment thread internal/cmdutil/workspace_test.go Outdated
@canyugs
Copy link
Copy Markdown
Contributor

canyugs commented May 20, 2026

Panel Review — zeabur/cli PR #244 + zeabur/backend PR #2131 (Fix Round)

1. What problem does this PR solve?

Team members cannot use the CLI to operate on team-owned projects. The CLI needs a workspace concept (mirroring the dashboard) so users can switch between personal and team contexts.

2. How does it solve it?

Backend #2131: Adds a nullable Team.myRole GraphQL field returning the caller's own role via direct team_members index lookup.

CLI #244: Adds zeabur workspace {list, current, switch, clear} commands and a global --workspace flag with per-process ListTeams memoization and consistent workspace resolution.

3. What are the tradeoffs?

  • Per-process cache means a workspace change by another process within the same CLI invocation won't be seen (acceptable — CLI is short-lived)
  • Team names remain unconstrained (duplicates require ID-based disambiguation)

4. Verdict

Consensus: approve (pending trivial CI lint fix)
Reviewers: Aragorn: approve, Legolas: approve, Gimli: request changes (CI lint only), Boromir: approve, Frodo: approve


🔴 SUGGESTED CHANGES

  • F1. internal/cmdutil/workspace_test.go:50 — CI lint QF1001 (De Morgan's law): rewrite !(X || Y) as (!X && !Y). 1-line style change, logic correct, blocks CI green. (raised by: Gimli, confirmed by: Frodo)

⚖️ Unresolved disagreements

  • None — all reviewers agree F1/F2 from round 1 are fixed. Only the lint nit remains.

🟡 NIT

  • None remaining (N1–N3 from round 1 all addressed in this fix)

🟢 INFO

  • I1. Factory.ListTeams() memoization is clean: sticky error, per-process lifetime, 5 call sites funneled through one fetch. (Aragorn, Frodo)
  • I2. ResolveWorkspaceArg refactored to accept []model.Team directly — better testability, no API client mock needed. (Aragorn, Frodo)
  • I3. Case-insensitive ID matching added with dedicated test. (Legolas, Frodo)

Codex flagged `context set project --name` falling back to a personal-
account lookup when the active workspace is a team. The same pattern
lives in every CLI command that resolves a project / service by name —
they all route through util.GetProjectByName / util.GetServiceByName,
which under the hood key on the caller's personal username via the
backend `project(owner, name)` / `service(owner, projectName, name)`
queries.

So in a team workspace, today, `--name` will either:
- silently miss a team-only project / service ("not found"), or
- worse, find a same-named personal one and pin to it — the next
  command would then deploy / delete / restart under the wrong owner.

Fix at the choke point: route the name path through the workspace-aware
helpers. Personal path (ownerID == "") preserves the original
`project(owner, name)` / `service(owner, projectName, name)` query
exactly as before — no behavior change for the non-team caller. Team
path walks `ListAllProjects(ownerID)` / `ListAllServices(projectID)`
and filters by name locally. Both helpers' signatures grow new owner /
project arguments; ~20 call sites updated mechanically. ID-based paths
(`--id`) are workspace-agnostic and stay on the ApiClient.GetProject /
GetService direct queries.

Service lookups in a team workspace require a pinned project context
because services are project-scoped and a service name is only unique
within a project — the helper surfaces this with an actionable error
pointing at `zeabur context set project --id` rather than silently
falling through to the personal account.

Tests cover both paths plus the failure-propagation invariant (a team
list error must not silently fall through to a personal lookup):

  TestGetProjectByName_Personal
  TestGetProjectByName_TeamFound
  TestGetProjectByName_TeamNotFound
  TestGetProjectByName_TeamListErr
  TestGetServiceByName_Personal
  TestGetServiceByName_TeamFound
  TestGetServiceByName_TeamWithoutProjectContext
  TestGetServiceByName_TeamNotFound
  TestGetServiceByName_TeamListErr

All pass; vet clean; full test suite clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cmd/context/set/set.go`:
- Around line 120-134: When resolving a project by ID (using
f.ApiClient.GetProject) the local variable name may remain empty causing success
logs to print blank names; after successfully obtaining project (the variable
project) in the ID branch assign name = project.Name so logs downstream use the
resolved name. Apply the same fix in the other occurrence where project is
fetched by ID (the block around the util.GetProjectByName /
f.ApiClient.GetProject pairs) so both code paths always set name from
project.Name when project != nil and err == nil.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0d356dbc-c440-4a32-aa32-e992aaa0e22f

📥 Commits

Reviewing files that changed from the base of the PR and between c515cee and 61e8378.

📒 Files selected for processing (31)
  • internal/cmd/context/set/set.go
  • internal/cmd/deployment/get/get.go
  • internal/cmd/deployment/list/list.go
  • internal/cmd/deployment/log/log.go
  • internal/cmd/domain/create/create.go
  • internal/cmd/domain/delete/delete.go
  • internal/cmd/domain/list/list.go
  • internal/cmd/project/clone/clone.go
  • internal/cmd/project/delete/delete.go
  • internal/cmd/project/export/export.go
  • internal/cmd/project/get/get.go
  • internal/cmd/service/delete/delete.go
  • internal/cmd/service/exec/exec.go
  • internal/cmd/service/get/get.go
  • internal/cmd/service/instruction/instruction.go
  • internal/cmd/service/metric/metric.go
  • internal/cmd/service/network/network.go
  • internal/cmd/service/port-forward/port_forward.go
  • internal/cmd/service/redeploy/redeploy.go
  • internal/cmd/service/restart/restart.go
  • internal/cmd/service/suspend/suspend.go
  • internal/cmd/service/update/tag/tag.go
  • internal/cmd/variable/create/create.go
  • internal/cmd/variable/delete/delete.go
  • internal/cmd/variable/env/env.go
  • internal/cmd/variable/list/list.go
  • internal/cmd/variable/update/update.go
  • internal/util/project.go
  • internal/util/project_test.go
  • internal/util/service.go
  • internal/util/service_test.go

Comment thread internal/cmd/context/set/set.go Outdated
CI lint:
- workspace_test.go:50 — staticcheck QF1001 (De Morgan): rewrite the
  membership-error assertion from `!(X || Y)` to `(!X && !Y)`. Same
  truth table, no longer trips the negated-OR rule. Verified locally
  with `staticcheck -checks QF1001`.

CodeRabbit on the F2 commit:
- set.go setProject / setService — when the user passed only `--id`, the
  local `name` variable stayed empty and the success log read
  "Project context is set to <>". Backfill `name` from the resolved
  `project.Name` / `service.Name` after the lookup succeeds, so both
  ID-only and name-only invocations log the resolved name.

Backward-compat regression coverage (per Bruce's red-line review of the
util signature changes) — Factory now has dedicated tests:
- TestFactory_PersonalUserInvariant — a zero Factory (the brand-new
  user shape) reports CurrentOwnerID() == "" and IsPersonal(). This is
  the single most important invariant of PLA-1590: every owner-aware
  util helper checks for the empty string before deciding between the
  legacy personal query path and the new team-aware one. A regression
  here silently routes personal users through the team branch.
- TestFactory_CurrentOwnerID_PersistedPersonal — Config present but
  no persisted workspace must still report personal.
- TestFactory_CurrentOwnerID_PersistedTeam — sanity on the persisted
  team path.
- TestFactory_CurrentOwnerID_OverrideBeatsPersisted — `--workspace`
  override takes precedence and does NOT leak into the persisted file.
- TestFactory_CurrentOwnerID_OverrideNilClears — passing nil to
  SetWorkspaceOverride drops the override.
- TestFactory_ListTeams_Memoizes — one process, one backend hit, no
  matter how many sites ask. Guards the F2 fanout fix.
- TestFactory_ListTeams_StickyError — a failed fetch caches the error
  so subsequent callers don't retry against an already-broken backend.

All tests pass; full vet + suite clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

Round 3 fixes(commit b97244f):

CI lint blocker (Panel F1 / CodeRabbit)

  • workspace_test.go:50 De Morgan QF1001:!(X || Y)(!X && !Y),本地 staticcheck -checks QF1001 已 clean

CodeRabbit on 61e8378

  • set.go setProject + setService:用户只传 --id 时 local name 是空,success log 现在打 <>。修:resolve 完之后 name = project.Name / service.Name 回填

Bruce 要求的"全路径向后兼容测试" — 新加 7 个 Factory 测试

测试 守护的不变量
TestFactory_PersonalUserInvariant 核心:zero Factory → CurrentOwnerID() == ""IsPersonal() == true。每个 owner-aware util helper 都靠这个空字符串走 legacy personal 路径。这条挂了 = 普通用户被静默路由到 team 分支
TestFactory_CurrentOwnerID_PersistedPersonal Config 存在但无持久化 workspace → 仍然 personal
TestFactory_CurrentOwnerID_PersistedTeam 持久化 team 路径返回 team ID
TestFactory_CurrentOwnerID_OverrideBeatsPersisted --workspace flag 覆盖 persisted,且不污染配置文件
TestFactory_CurrentOwnerID_OverrideNilClears SetWorkspaceOverride(nil) 正确清除
TestFactory_ListTeams_Memoizes 单进程多次调用 = 1 次后端 hit
TestFactory_ListTeams_StickyError 失败 fetch sticky,不重试

加上之前的 9 个 TestGetProjectByName_* / TestGetServiceByName_*(其中包括 _Personal 明确断言 client call 字节等同旧实现)+ 8 个 TestResolveWorkspaceArg_* = 总共 24 个 unit test 覆盖本 PR 全路径

go build ./... + go vet ./... + 全套 go test + staticcheck 全部 clean。

🤖 Generated with Claude Code

Both commands read the persisted workspace directly via
`f.Config.GetContext().GetWorkspace()`, so a `--workspace foo` override
was silently ignored — `workspace current` reported the persisted
workspace and `workspace list` put `*` on the persisted row, not on
the override. Caught running the Bucket 6 dev-2 E2E:

  $ /tmp/zeabur-dev2 workspace switch team-A
  $ /tmp/zeabur-dev2 --workspace team-B workspace current
  team-A  [...]  team  ...      # wrong — override was team-B

This is the same class of bug as the deploy.go F1 finding from the
panel review: display source must match the resolved source the rest
of the command sees. Switch both commands to `f.CurrentWorkspace()` /
`f.CurrentOwnerID()` so they reflect the effective workspace for the
invocation. Existing `TestFactory_CurrentOwnerID_OverrideBeatsPersisted`
covers the underlying helper; this fix is the consumer side.

Verified on dev-2 against api-2.zeabur.dev:

  $ /tmp/zeabur-dev2 --workspace pla1230 workspace current
  pla1230-probe-...  [69e72943...]  team  Administrator   # ✓

  $ /tmp/zeabur-dev2 --workspace pla1230 workspace list
  *  69e72943...  pla1230-probe-...   team  Administrator # ✓ marker on override

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

Dev-2 E2E 测试报告

部署 backend 3a824c0 到 dev-2 之后跑完 11 个 bucket。测试中发现 2 个 CLI bug 并已修复(commit 736851b)。

测试 setup

  • Binary:本地 build CLI 把 pkg/constant/const.go 临时改成 api-2.zeabur.dev(不 commit),strings 验过 binary embedded URL 只有 api-2.zeabur.dev
  • Config 隔离:HOME=/tmp/zeabur-test 避免污染真实 ~/.config/zeabur/cli.yaml
  • 账号:airfreedom5@gmail.com 主号(pla1230 admin / 3 个 viewer team)

Bucket 矩阵

Bucket Result
Schema probe workspace list 实际命中 api-2 teams { myRole }
B10 Help text 4 个子命令 + --workspace flag 描述
B1 Personal baseline project list / context set --name(personal path 字节等同旧逻辑)
B2 list/current * marker、role 列、persisted vs team
B3 Switch happy by name / by ID / UPPERCASE ID 大小写不敏感 / config 写入 / project context 清空
B4 Switch edge 不存在 name / switch personal(拒绝当快捷方式)/ 有效 hex 非 member
B5 Clear from team / already-personal
B6 --workspace flag list / persisted-不被污染 / 无效 hex ⚠️ 发现 2 bug,已修
B7 project list/create team 下 list 看到 team projects / personal 看不到 team projects / project create owner=team / cleanup delete
B8 F2 fix(critical) team --name → team project / team 找 personal-only name → 拒绝(不 silently fall personal)/ personal --name 字节等同旧逻辑
B9 Lazy verify 注入假 stale workspace → warn + auto-clear

关键证据

1. F2 fix(Codex round 2 的核心问题)verified end-to-end

# 在 team workspace 下,用 --name 解析 team-owned project
$ /tmp/zeabur-dev2 workspace switch pla1230-probe-1776760608745
$ /tmp/zeabur-dev2 context set project -i=false --name for-clone-test
INFO  Project context is set to <for-clone-test>

$ grep -A2 "project:" ~/.config/zeabur/cli.yaml
    project:
        id: 6a0733237fda5660beee1d47   # ← team 的 project ID
        name: for-clone-test

# 在 team workspace 下找 personal-only 的 name → 必须拒绝,不能 silently fall personal
$ /tmp/zeabur-dev2 context set project -i=false --name dev-test
ERROR  failed to get project: no project named "dev-test" in this workspace

跟旧版(PR c515cee)行为对比:旧版会 fall 到 personal 找到同名 project 静默 pin 错。Fix verified。

2. Project create owner verification

$ /tmp/zeabur-dev2 workspace switch pla1230-probe-1776760608745  # admin team
$ /tmp/zeabur-dev2 project create -i=false --name pla1590-e2e-test --region server-69bbfce115f1d12e55d131a3
{"id":"6a0d64c1be57ba3ed0f7579c","name":"pla1590-e2e-test","status":"success"}

# 在 team workspace 下 list:能看到
$ /tmp/zeabur-dev2 project list | grep pla1590
6a0d64c1be57ba3ed0f7579c  pla1590-e2e-test  ...

# 切到 personal:看不到
$ /tmp/zeabur-dev2 workspace clear
$ /tmp/zeabur-dev2 project list | grep pla1590
(empty)

确认 ownerID 写到了 team,不是 personal。

3. Lazy verify

# 手动 inject 一个不存在的 team 到 cli.yaml
$ sed -i '' 's/id: ""/id: 69aabbccddeeffaabbccddee/; ...' ~/.config/zeabur/cli.yaml

$ /tmp/zeabur-dev2 workspace current
WARN  Persisted workspace "fake-stale-team" [69aabbccddeeffaabbccddee] is no longer in your memberships; falling back to personal.
personal  (Bruce Du)

# config 里 workspace 字段被清回 ""

测试中修复的 2 个 bug(commit 736851b

文件 Bug Fix
workspace/current/current.go f.Config.GetContext().GetWorkspace() 而非 f.CurrentWorkspace()--workspace flag override 被忽略 改用 f.CurrentWorkspace()
workspace/list/list.go 同上 —— * marker 标 persisted 而非 override 改用 f.CurrentOwnerID()

重现(修复前 b97244f

$ /tmp/zeabur-dev2 workspace switch team-A
$ /tmp/zeabur-dev2 --workspace team-B workspace current
team-A  [...]  team  ...      # 错 —— override 应该是 team-B

修复后(736851b

$ /tmp/zeabur-dev2 --workspace pla1230 workspace current
pla1230-probe-1776760608745  [69e72943b0f22737a455dded]  team  Administrator   # ✓

$ /tmp/zeabur-dev2 --workspace pla1230 workspace list
*  69e72943...  pla1230-probe-...   team  Administrator   # ✓ marker 在 override

这跟 panel round 1 的 F1(deploy hint)是同一类型 bug —— display source 必须跟 ownerID resolved source 一致。已有 TestFactory_CurrentOwnerID_OverrideBeatsPersisted 测试守 helper 侧,但 consumer 侧(workspace current / list)漏接。

回归保护

24 个 unit test 已经覆盖 helper / Factory / util 全路径;这次 dev-2 跑出来的 bug 是 consumer 侧而非 helper 侧,后续 follow-up 可以加 command-level integration test(cobra runE mock),不阻塞本 PR。

Backend deploy 配套

backend PR #2131 dev-2 deploy 成功 —— schema probe 显示 Team.myRole 字段已暴露,role 值与 team_members 一致。Backend 侧零 bug 发现。

测试收尾

  • Binary、隔离 config、临时文件全部 cleanup
  • pkg/constant/const.go revert 回 api.zeabur.com
  • CLI repo 干净,HEAD = 736851b

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cmd/workspace/list/list.go`:
- Around line 34-38: The code uses currentID (set via f.CurrentOwnerID()) both
as the effective owner for display markers and for checking/presenting the
persisted-workspace warning; separate these concerns by renaming the existing
currentID to effectiveOwnerID (use f.CurrentOwnerID() for marker logic) and
introduce a persistedOwnerID (call the API that returns the persisted owner
ID—e.g., f.PersistedOwnerID() or the equivalent persisted-state accessor) for
the warning path so the warning reflects the saved workspace rather than any
--workspace override; update all references accordingly (marker logic ->
effectiveOwnerID, warning checks -> persistedOwnerID).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8381b9e2-8a04-4b1c-b129-865a85fe2226

📥 Commits

Reviewing files that changed from the base of the PR and between b97244f and 736851b.

📒 Files selected for processing (2)
  • internal/cmd/workspace/current/current.go
  • internal/cmd/workspace/list/list.go

Comment thread internal/cmd/workspace/list/list.go Outdated
@BruceDu521
Copy link
Copy Markdown
Contributor Author

Dev-2 E2E Round 2 测试报告

完整跑了上一轮 Codex 提的 2 条 smoke + Bruce 加的 3 条 RBAC/free-team 测试。Backend 用 main-merged 版本(含 bd7dc339d 系列改动)。

测试矩阵

Bucket Result
Codex 1 service by-name in team workspace
Codex 2 --workspace + project create (persisted=team-A → override=team-B)
RBAC.A Viewer 拒写(switch + flag override 两条路径)
RBAC.B Editor 写允许 / 删拒
RBAC.C 第三方账号隔离(airfreedom8)
Free team 未订阅 team CUD 被 REQUIRE_TEAM_PLAN 拒,read 允许

关键证据

Codex 1:service by-name in team workspace

# B-Svc.1 context set service --name redis in team project
$ /tmp/zeabur-dev2 workspace switch pla1230-probe-1776760608745
$ /tmp/zeabur-dev2 context set project --id 6a0733237fda5660beee1d47
$ /tmp/zeabur-dev2 context set service --name redis
INFO  Service context is set to <redis>
# config 显示 service.id = 6a07332f7fda5660beee1d48(team service ID,不是 personal)✓

# B-Svc.4 team workspace + NO project context → actionable error
$ /tmp/zeabur-dev2 service get --name redis
ERROR cannot resolve service by name in a team workspace without a project context
       — set a project first (e.g. `zeabur context set project --id <project-id>`)

20+ caller 共用一个 helper —— helper 端有专门测试守住 personal/team 分支,consumer 端这次端到端确认通过。

Codex 2:--workspace flag override 不污染 persisted

# persisted = pla1277-presub (Viewer)
$ /tmp/zeabur-dev2 workspace switch 69e9b5aa54d4c3255bd8d344
$ /tmp/zeabur-dev2 --workspace pla1230 project create --name pla1590-flag-test --region server-...
{"id":"6a13b04cbe9a3565a54ff7de","name":"pla1590-flag-test","status":"success"}

# 验:新建 project 在 pla1230 可见
$ /tmp/zeabur-dev2 --workspace pla1230 project list | grep pla1590
6a13b04cbe9a3565a54ff7de  pla1590-flag-test  ...

# 验:persisted pla1277 看不到
$ /tmp/zeabur-dev2 project list | grep pla1590
(empty)

# 验:workspace current 仍是 pla1277(persisted 未污染)
$ /tmp/zeabur-dev2 workspace current
pla1277-presub-1776924073762  [69e9b5aa54d4c3255bd8d344]  team  Viewer

RBAC.A:Viewer 拒写(双路径)

# 路径 1:persisted = viewer team
$ /tmp/zeabur-dev2 workspace switch 69e9b5aa54d4c3255bd8d344
$ /tmp/zeabur-dev2 project create --name pla1590-viewer-rbac --region server-...
ERROR  Message: ownerID must be the current user or a team you can write to,
       Extensions: map[code:FORBIDDEN traceID:a454f42f...]

# 路径 2:flag override → viewer team
$ /tmp/zeabur-dev2 --workspace 69e9b5aa54d4c3255bd8d344 project create --name pla1590-viewer-flag-rbac --region server-...
ERROR  Message: ownerID must be the current user or a team you can write to,
       Extensions: map[code:FORBIDDEN traceID:dde4b7fe...]

重要:flag override 也走 backend RBAC,不会因为 CLI flag 就绕过校验。

RBAC.B:Editor 写允许 / 删拒(含 invite + cleanup)

# Setup:用 main admin 把 126 invite 进 pla1230 as Editor
$ curl -d '{addTeamMember(teamID:"pla1230", memberEmail:"airfreedom5@126.com", role:EDITOR)}'
{"data":{"addTeamMember":{"_id":"69e72e81b0f22737a455ddf0","role":"EDITOR"}}}

# B.3 Editor read works
$ /tmp/zeabur-dev2-126 project list  (in pla1230 as Editor)
count: 6 projects

# B.4 Editor create — team RBAC 通过;server-collaborator 这层拦了(PLA-1590 范围外)
$ /tmp/zeabur-dev2-126 project create ...
ERROR  Message: No access to this dedicated server,
       Extensions: map[code:NOT_SERVER_COLLABORATOR]
# 注:不是 FORBIDDEN/REQUIRE_TEAM_PLAN → 证明 team-role write 已通过,被 deeper server-level 拦
# Bruce 的 server 不允许 126 deploy,跟本 PR 无关

# B.5 Editor delete on existing team project (must be DENIED — admin only)
$ /tmp/zeabur-dev2-126 project delete --id 6a0c291abe57ba3ed0f75791 --yes
ERROR  Message: Permission denied,
       Extensions: map[code:FORBIDDEN description:permission denied]

# 验:project 仍存在
$ /tmp/zeabur-dev2 --workspace pla1230 project list | grep untitled-5
6a0c291abe57ba3ed0f75791  untitled-5  ...     # ✓ still there

# Cleanup:removeTeamMember 126
{"data":{"removeTeamMember":true}}

Bruce 在上一轮明确:Editor delete project 保留当前 admin-only 特性。本测试守住这条不变量。

RBAC.C:第三方账号隔离(airfreedom8)

# 128 自己的 memberships,正确不包含 main 的 pla1230
$ /tmp/zeabur-dev2-128 workspace list
*   personal                                       (airfreedom8)
    69e873b50d22805e67a4927e   pla995-nonmember   team   Administrator
    69e9b5aa54d4c3255bd8d344   pla1277-presub-... team   Viewer
    ... (no pla1230 here ✓)

# C.1 switch by ID to non-member team
$ /tmp/zeabur-dev2-128 workspace switch 69e72943b0f22737a455dded
ERROR  no team with id "69e72943b0f22737a455dded" in your memberships

# C.2 flag override 也拒(flag 解析阶段)
$ /tmp/zeabur-dev2-128 --workspace 69e72943b0f22737a455dded project list
ERROR  --workspace: no team with id "69e72943b0f22737a455dded" in your memberships

CLI ResolveWorkspaceArg membership filter ✓ + backend RBAC 双层守护。

Free team CUD 拒 / read 允许

# 用 main admin 建一个新 unsubscribed team
$ curl -d '{createTeam(name:"pla1590-free-test")}'
{"data":{"createTeam":{"_id":"6a13b4d481ffe5e45ae6efbf","name":"pla1590-free-test"}}}

$ /tmp/zeabur-dev2 workspace switch 6a13b4d481ffe5e45ae6efbf
Switched to workspace "pla1590-free-test" [...] (team, Administrator).

# project list (read) — OK
$ /tmp/zeabur-dev2 project list
count: 0

# project create (write) — DENIED with REQUIRE_TEAM_PLAN
$ /tmp/zeabur-dev2 project create --name X --region server-...
ERROR  Message: This team must subscribe to Team Plan before creating or modifying team-owned resources.
       Extensions: map[code:REQUIRE_TEAM_PLAN traceID:99e11b19...]

# Cleanup: deleteTeam
{"data":{"deleteTeam":true}}

Bruce 关心的 "未订阅 team 应该拒所有 CUD" → 验证通过。同时 read 不受 plan-gate 影响(合理)。

一个观察(非 blocker)

CLI project create 在 GraphQL 报错时 stdout/stderr 输出相同错误重复 3 次(参考 RBAC.A、B.4、Free team 测试的输出)。看着是 pkg/api 的 retry 机制把每次 retry 的错误都打印了。Pre-existing 行为跟本 PR 无关,但 UX 差,可单开 issue 跟进。

第二轮总结

  • 零新 bug 发现
  • 上一轮的 2 个 bug(workspace current/list 不接 override)由 736851b 已修,本轮 Codex 2 测试间接 re-verify(B-Flag.1/2 全过)
  • Backend RBAC + team-plan guard 端到端通过 CLI 验证生效
  • CLI workspace + backend Team.myRole 配套在 main-merged 版本上工作正常

收尾

  • 测试 binary、3 个隔离 config(main/126/128)、临时文件全部 cleanup
  • pkg/constant/const.go 还原 api.zeabur.com
  • CLI repo working tree clean,HEAD = 736851b(无新 commit)

PR 可以 merge。

…1590)

Codex's round-4 review surfaced a real cross-workspace contamination bug
that the previous test coverage happened to walk around.

Path 1 (read stale context under override):

  $ zeabur workspace switch team-A
  $ zeabur context set project --id <team-A-foo>
  $ zeabur --workspace team-B service delete --name redis --yes

The team branch of util.GetServiceByName trusted the persisted project
ID, so the delete landed on team-A's redis even though the user typed
--workspace team-B. Same risk on every service / variable / deployment /
domain consumer that resolves by name.

Path 2 (write persisted context under override):

  $ zeabur workspace switch team-A
  $ zeabur --workspace team-B project create --name x --region y   # non-JSON

The non-JSON branch of project create called setProject, pinning the
team-B project under persisted workspace team-A. Subsequent commands
without --workspace then used the wrong combination.

Both paths are regressions introduced by PLA-1590 — pre-PR, name lookups
always went through the personal `service(owner=username, ...)` query
which simply couldn't find team-owned projects, so the cross-team risk
was structurally impossible. The team branch added by F2 is what opened
the door.

The fix codifies `--workspace` as a stateless one-shot override:

1. Factory.HasWorkspaceOverride() — predicate. Every override-gated
   behaviour goes through this.
2. Factory.CurrentProjectID() / CurrentProjectName() / CurrentServiceID()
   / CurrentEnvironmentID() — return "" when an override is active, so
   name-based downstream lookups fail-closed with the existing
   "cannot resolve service by name in a team workspace without a
   project context" error. The non-override path is byte-equivalent to
   reading the persisted context directly, so vanilla users see no
   change.
3. project create + deploy interactive create / select paths skip the
   setProject write when an override is active, and log a one-line hint
   telling the user how to make it the default (`workspace switch ...`).
4. context set rejects up front when an override is active — writing
   persistent state under a one-shot override is incoherent. The error
   tells the user to run `workspace switch` first.

All 24 service/domain/variable/deployment consumers route through the
new Factory helpers instead of reading the persisted project context
directly, so there's one chokepoint, not 24 places to keep in sync.

Tests:

  TestFactory_HasWorkspaceOverride
  TestFactory_CurrentInnerContext_OverrideHides
  TestFactory_CurrentInnerContext_NilConfigSafe

Plus dev-2 E2E (this round):

- C1: persisted=pla1230 + pinned for-clone-test, `--workspace
  pla1277-presub service delete --name redis --yes` →
  "cannot resolve service by name..." error; for-clone-test redis NOT
  deleted (verified via service list).
- C2: persisted=pla1230, `--workspace pla1230 project create --name
  pla1590-bplus-c2-test --region ...` (non-JSON) → succeeds, prints
  the one-shot hint, persisted context.project still for-clone-test.
- C3: `--workspace ... context set project ...` → rejected with
  actionable hint pointing at `workspace switch`.
- C5: no override → `context set service --name redis` still pins
  correctly (back-compat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

采纳 Codex 的 B+ 设计,commit b5667e2

改动总览

  1. Factory 新 helpersinternal/cmdutil/factory.go):

    • HasWorkspaceOverride() bool — 是否有 --workspace flag
    • CurrentProjectID() / CurrentProjectName() / CurrentServiceID() / CurrentEnvironmentID() — override 模式下返回 "",否则透传 persisted context
  2. 24 个 service/variable/deployment/domain consumer 全部从 f.Config.GetContext().GetProject().GetID() 改成 f.CurrentProjectID()(chokepoint,不让 20+ 处各自判断)

  3. 写持久 context 的位置在 override 模式下跳过

    • project create 非 JSON 路径 — 加一行 actionable hint
    • deploy 交互式新建项目 + 选项目两条路径
  4. context set 在 override 模式下直接拒:actionable hint 指向 workspace switch

Unit test(新增 3 条)

TestFactory_HasWorkspaceOverride
TestFactory_CurrentInnerContext_OverrideHides
TestFactory_CurrentInnerContext_NilConfigSafe

加上前 19 条 → 共 22 个 unit test,覆盖 Factory + util + workspace helper 全路径。

Dev-2 E2E(本轮新跑)

  • C1 persisted=pla1230 + pinned for-clone-test,--workspace pla1277-presub service delete --name redis --yes

    ERROR  cannot resolve service by name in a team workspace without a project context
           — set a project first (e.g. `zeabur context set project --id <project-id>`)
    

    for-clone-test 的 redis 没被删除(verified via service list)✓

  • C2 persisted=pla1230,--workspace pla1230 project create --name pla1590-bplus-c2-test --region ...(非 JSON):

    INFO  Project pla1590-bplus-c2-test created
    INFO  (persistent project context not modified — `--workspace` override is one-shot; 
          run `zeabur workspace switch pla1230-probe-1776760608745` to make it your default)
    

    persisted context.project 仍是 for-clone-test新 project 没污染

  • C3 --workspace ... context set project ...

    ERROR  `context set` writes persistent state and cannot be combined with `--workspace`;
           run `zeabur workspace switch <team>` first, then `zeabur context set ...`
    
  • C5 regression 无 override 路径不变:context set service --name redis 仍正确 pin(不影响 personal / 普通 team workflow)

关于 Codex 推荐 vs 我原推荐 A

承认我对方案 A 的 cost 估错了 —— Project model 没 ownerID 字段(pkg/model/project.go:21),实施 A 需要扩 model + GraphQL query + schema 暴露 + backward compat,工作量是 B+ 两倍。

B+ 的语义更干净(--workspace = 严格无状态 override,不读不写持久 context),fail-closed 模式,全部 CLI 侧搞定,不需要 backend / schema 配合。适合本 PR scope。

A("给 context 加 workspace tag"的长期模型)作为 follow-up 单开 issue。

现状

  • CLI repo:HEAD b5667e2,working tree clean,pkg/constant/const.go 已还原 api.zeabur.com
  • 全套 go build ./... + go vet ./... + go test ./... clean

🤖 Generated with Claude Code

… context (PLA-1590)

Codex's round-5 review showed B+ (commit b5667e2) only covered half the
surface: my new helpers (CurrentProjectID etc.) plus a few skip-write
guards in project create / deploy. Two whole entry classes still walked
straight past them.

Path 1 — interactive ParamFiller bypass:

  $ zeabur workspace switch team-A
  $ zeabur context set project --id <team-A-project>
  $ zeabur --workspace team-B service restart    # interactive (default)

`runRestartInteractive` passed the raw `f.Config.GetContext()` to
ParamFiller.ServiceByNameWithEnvironment. The filler read team-A's
pinned project ID as `projectCtx.GetProject().GetID()`, used it as the
project scope for SelectService, and quietly restarted team-A's
service while the user thought they were operating on team-B. Worse,
when the persisted context was empty the filler called
`projectCtx.SetProject(picked)` and *wrote* the team-B project under
the persisted team-A workspace — cross-team contamination that
survived the command. Same shape across
service/{restart,delete,suspend,exec,network,port-forward,redeploy,
instruction,metric,get,update/tag},
variable/{create,delete,env,list,update},
deployment/{get,log,list},
domain/{create,delete,list}.

Path 2 — `project get/delete` PreRunE auto-fill:

  $ zeabur --workspace team-B project delete --yes -i=false   # no --id

`util.DefaultIDNameByContext(f.Config.GetContext().GetProject(), ...)`
was bound at Cobra command-construction time and unconditionally
copied team-A's persisted project ID into opts.id. The runE then
deleted team-A's project, even though every flag on the line said
team-B.

Fix: ephemeral context + EffectiveContext audit.

1. New `zcontext.NewEphemeralContext(workspace)` — an in-memory Context
   implementation that starts empty, writes to memory only, and reports
   the supplied workspace via GetWorkspace() (so consumers that ask
   "what workspace is this context for?" get the override answer
   rather than personal — a second-order trap Codex flagged).
2. New `Factory.EffectiveContext() zcontext.Context`:
   - Without override: returns the persisted config context, byte-
     equivalent to today.
   - Under override: lazy-initialises a per-Factory ephemeral context
     and returns it for every subsequent call (so ParamFiller's
     `Set → later Get` cycle still works in-process; nothing leaks to
     disk).
3. 24 interactive callers swap `f.Config.GetContext()` for
   `f.EffectiveContext()`: every service/, variable/, deployment/,
   domain/ command that ran ParamFiller.
4. project get/delete PreRunE swap to a lazy closure so
   EffectiveContext is resolved at PreRunE time (after PersistentPreRunE
   parses `--workspace`), not at command construction. The signature of
   util.DefaultIDNameByContext changes from `BasicInfo` to
   `func() BasicInfo` to make that lazy evaluation explicit.
5. `context get` reads EffectiveContext and, under override, prints an
   extra "Note: --workspace is one-shot; persisted ... is not used"
   line for human-readable output. JSON output stays structurally
   identical so scripts keep parsing.
6. `context clear` rejects under override — clearing belongs to the
   persisted state, not a one-shot override.
7. Deleted internal/util.NeedProjectContextWhenNonInteractive and
   DefaultIDByContext — both had zero callers and would re-introduce
   the same pattern if revived from copy/paste later.

What stays on `f.Config.GetContext()` (intentional):
workspace/{switch,clear}, auth/logout, root.go's lazy workspace
verify, context/{set,clear} command bodies (set already rejects
override; clear now does too), project create / deploy interactive
flows (already wrapped in `if !HasWorkspaceOverride` from b5667e2 with
a user-visible hint).

Tests:

zcontext/ephemeral_test.go (new):
  TestEphemeralContext_WorkspaceFromConstructor
  TestEphemeralContext_NilWorkspaceIsPersonal
  TestEphemeralContext_ReadEmptyByDefault
  TestEphemeralContext_SetReadCycleWorksInMemory
  TestEphemeralContext_ClearAll

cmdutil/factory_test.go (added):
  TestFactory_EffectiveContext_NoOverride
  TestFactory_EffectiveContext_OverrideReturnsEphemeral_WithWorkspace
  TestFactory_EffectiveContext_OverrideCachedWithinCommand
  TestFactory_EffectiveContext_OverridePersistedUnpolluted

Dev-2 E2E (this round):
  C1: --workspace team-B service delete --name redis (interactive,
      persisted=team-A + pinned project) → opens Select project from
      team-B; for-clone-test redis NOT deleted (verified via list).
  C3: --workspace team-B project delete --yes -i=false (no --id) →
      "please specify project by --name or --id"; for-clone-test
      project NOT deleted.
  C4: --workspace team-B context get → all inner context shows
      "<not set>" + the Note line; --json stays structurally clean.
  C5: --workspace team-B context clear → rejected with actionable
      hint pointing at workspace switch.
  C6: no override → context get reads persisted normally
      (back-compat red line).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

B++ 落地,commit 7a1be0e。完整采纳 Codex 的 4 条调整。

改动总览

  1. zcontext.NewEphemeralContext(workspace) —— in-memory Context 实现:

    • 读初始为空(不 fall back persisted)
    • 写只进内存(不进 viper / config file)
    • GetWorkspace() 返回 override 的 workspace 而非 personal(避免你提到的第二跳坑)
    • ParamFiller 的 Set → later Get in-process 循环仍能 work
  2. Factory.EffectiveContext() zcontext.Context

    • 无 override → f.Config.GetContext()(持久,跟今天字节等同)
    • 有 override → lazy 初始化 ephemeral + 缓存(同一进程内多次调返回同一实例 → ParamFiller cycle OK)
  3. 24 个 interactive caller f.Config.GetContext()f.EffectiveContext()

    • service/{restart,delete,suspend,exec,network,port-forward,redeploy,instruction,metric,get,update/tag}
    • variable/{create,delete,env,list,update}
    • deployment/{get,log,list}
    • domain/{create,delete,list}
  4. project get/delete PreRunE —— 改成 lazy closure,EffectiveContext 在 PreRunE 时(已 parse --workspace)才解析。util.DefaultIDNameByContext 签名从 BasicInfo 改成 func() BasicInfo 让 lazy evaluation 显式。

  5. context get 读 EffectiveContext,override 下加 Note 行;JSON 输出结构保持纯净(不混 prose)。

  6. context clear 在 override 下 reject。

  7. 清掉 2 个 dead helper —— internal/util.NeedProjectContextWhenNonInteractiveDefaultIDByContext,都没 caller,留着只会被未来 copy-paste 重新引入同样的洞。

保留 persisted 读写的位置(明确)

按你的"按读写语义审计"要求:

  • workspace/{switch,clear} ✓ 写 persisted workspace 是 by design
  • auth/logout ✓ 清 persisted token
  • root.go lazy verify ✓ 读 persisted workspace 验证
  • context/{set,clear} 命令体(入口已 reject override)
  • project create / deploy 现有 skip-write + hint 保留

新加测试

zcontext/ephemeral_test.go (新文件):
  TestEphemeralContext_WorkspaceFromConstructor    (守你的"workspace 不能是 personal"那条)
  TestEphemeralContext_NilWorkspaceIsPersonal
  TestEphemeralContext_ReadEmptyByDefault
  TestEphemeralContext_SetReadCycleWorksInMemory   (守 ParamFiller 循环)
  TestEphemeralContext_ClearAll

cmdutil/factory_test.go (新增):
  TestFactory_EffectiveContext_NoOverride                          (back-compat)
  TestFactory_EffectiveContext_OverrideReturnsEphemeral_WithWorkspace
  TestFactory_EffectiveContext_OverrideCachedWithinCommand         (守 ParamFiller cycle)
  TestFactory_EffectiveContext_OverridePersistedUnpolluted         (守 "writes don't leak to disk")

Dev-2 E2E

按你列的 7 条 + back-compat 跑完:

C1 (interactive bypass)
  $ /tmp/zeabur-dev2 workspace switch pla1230 (Admin)
  $ /tmp/zeabur-dev2 context set project --id 6a0733237...    # for-clone-test
  $ /tmp/zeabur-dev2 --workspace pla1277-presub service delete --name redis
  → Opens "Select project" prompt (showing team-B's project list, not team-A's)
  → for-clone-test redis NOT deleted (verified via service list) ✓

C3 (PreRunE auto-fill bypass)
  $ /tmp/zeabur-dev2 --workspace pla1277-presub project delete --yes -i=false
  → ERROR  please specify project by --name or --id
  → for-clone-test still present ✓ fail-closed

C4 (context get under override)
  $ /tmp/zeabur-dev2 --workspace pla1277-presub context get
  Project      <not set>  <not set>
  Environment  <not set>  <not set>
  Service      <not set>  <not set>
  INFO  Note: --workspace is one-shot; persisted project/service/environment context is not used.

C5 (context clear under override)
  $ /tmp/zeabur-dev2 --workspace pla1277-presub context clear
  ERROR  `context clear` cannot be combined with `--workspace`; the override does not modify persisted context

C6 (back-compat — 无 override 路径不变)
  $ /tmp/zeabur-dev2 context get
  Project      for-clone-test  6a0733237fda5660beee1d47    # persisted 正常显示 ✓

C2/C7 在前一轮 b5667e2 已验过:

  • C2 (--workspace + project create 非 JSON 不污染 persisted) — b5667e2 commit
  • C7 (variable/domain interactive 共用 ParamFiller) — 跟 C1 同样的 helper 链路,sed-replace 24 处的回归保护

现状

  • CLI repo HEAD = 7a1be0e
  • working tree clean
  • pkg/constant/const.go 还原 api.zeabur.com
  • go build + go vet + 全套 go test 都 clean

🤖 Generated with Claude Code

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cmdutil/factory.go`:
- Around line 192-195: EffectiveContext() currently caches f.ephemeralCtx and
never refreshes it, so if SetWorkspaceOverride(...) changes the workspace within
the same invocation the cached context remains bound to the old workspace;
update EffectiveContext to detect when the current workspace
(f.CurrentWorkspace()) differs from the workspace inside f.ephemeralCtx and
recreate f.ephemeralCtx via zcontext.NewEphemeralContext(...) when they differ
(or when ephemeralCtx is nil), ensuring the cached ephemeralCtx always reflects
the latest workspace override.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8f8881cf-c9fc-49df-ac61-aee79a65b492

📥 Commits

Reviewing files that changed from the base of the PR and between b5667e2 and 7a1be0e.

📒 Files selected for processing (31)
  • internal/cmd/context/clear/clear.go
  • internal/cmd/context/get/get.go
  • internal/cmd/deployment/get/get.go
  • internal/cmd/deployment/list/list.go
  • internal/cmd/deployment/log/log.go
  • internal/cmd/domain/create/create.go
  • internal/cmd/domain/delete/delete.go
  • internal/cmd/domain/list/list.go
  • internal/cmd/project/delete/delete.go
  • internal/cmd/project/get/get.go
  • internal/cmd/service/delete/delete.go
  • internal/cmd/service/exec/exec.go
  • internal/cmd/service/get/get.go
  • internal/cmd/service/instruction/instruction.go
  • internal/cmd/service/metric/metric.go
  • internal/cmd/service/network/network.go
  • internal/cmd/service/port-forward/port_forward.go
  • internal/cmd/service/redeploy/redeploy.go
  • internal/cmd/service/restart/restart.go
  • internal/cmd/service/suspend/suspend.go
  • internal/cmd/service/update/tag/tag.go
  • internal/cmd/variable/create/create.go
  • internal/cmd/variable/delete/delete.go
  • internal/cmd/variable/env/env.go
  • internal/cmd/variable/list/list.go
  • internal/cmd/variable/update/update.go
  • internal/cmdutil/factory.go
  • internal/cmdutil/factory_test.go
  • internal/util/runE.go
  • pkg/zcontext/ephemeral.go
  • pkg/zcontext/ephemeral_test.go

Comment thread internal/cmdutil/factory.go
Codex's round-5 review noted that the `project get/delete` PreRunE
fix from 7a1be0e was only proven by E2E — no committed regression
test guarded the lazy-closure behaviour that makes the fix work.

This adds three unit tests against `util.DefaultIDNameByContext`:

  TestDefaultIDNameByContext_LazyEvaluation
    Source BasicInfo is swapped AFTER the helper is constructed but
    BEFORE PreRunE runs. Eager capture (the pre-fix behaviour) would
    leak the old value; lazy resolution must pick up the new one.
    This is the exact mechanism that lets PersistentPreRunE parse
    `--workspace` and then have project get/delete see the override.

  TestDefaultIDNameByContext_EmptyBasicInfoSkipsFill
    The override path: EffectiveContext returns an empty BasicInfo,
    so no auto-fill happens and the caller's runE produces the
    "please specify project by --name or --id" actionable error
    rather than silently using the persisted team-A project.

  TestDefaultIDNameByContext_RespectsUserFlags
    Back-compat: explicit --id / --name from the user is never
    overwritten by the context default. Unchanged from pre-PLA-1590
    but worth pinning down so a future refactor doesn't regress it.

No production code change in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

补完你提的两个 nit。Commit da5da8f

C7 实际跑了(之前漏的)

Setup: persisted = pla1230 (Admin) + pinned for-clone-test + pinned redis(最饱满的 stale context)。Override 切到 pla1277-presub (Viewer,空 team)。

# C7a: variable list interactive — uses ParamFiller.ServiceByNameWithEnvironment
$ /tmp/zeabur-dev2 --workspace 69e9b5aa54d4c3255bd8d344 variable list
? Select project  [Use arrows to move, type to filter]
> Create a new project           # ← pla1277 的空 list,不是 team-A 的 for-clone-test
? Select project region  ...

# C7b: domain list interactive — same code path
$ /tmp/zeabur-dev2 --workspace 69e9b5aa54d4c3255bd8d344 domain list
? Select project  [Use arrows to move, type to filter]
> Create a new project           # 同上
? Select project region  ...

ParamFiller 在 override 下走进 ephemeral,没用 persisted 的 for-clone-test/redis。两条共用同一个 f.EffectiveContext() 链路,跟代码归纳一致。

project get/delete 加 cobra-level regression test

新增 internal/util/runE_test.go,3 个测试覆盖 DefaultIDNameByContext

TestDefaultIDNameByContext_LazyEvaluation —— 守 PreRunE 的 lazy resolution 不变量:

var source zcontext.BasicInfo = zcontext.NewBasicInfo("old-id", "old-name")
getter := func() zcontext.BasicInfo { return source }

var id, name string
preRun := util.DefaultIDNameByContext(getter, &id, &name)

// 在构造 PreRunE 之后、调用之前换 source —— eager capture 会漏掉这个 swap
source = zcontext.NewBasicInfo("new-id", "new-name")

preRun(nil, nil)
// assert id == "new-id" && name == "new-name"

这条直接对应 project get/delete 修复的核心机制:PersistentPreRunE 跑完后 --workspace 已经 parse,EffectiveContext() 才能反映 override;如果 helper 是 eager capture,就会用构造时(Cobra 初始化时)的 persisted 值。

TestDefaultIDNameByContext_EmptyBasicInfoSkipsFill —— override 路径:empty BasicInfo 不 fill,opts.id/name 保持空 → caller runE 出 "please specify --id or --name" 错。

TestDefaultIDNameByContext_RespectsUserFlags (3 sub-tests) —— back-compat:用户显式传 --id / --name 时永远不被 context 覆盖。

状态

  • CLI repo HEAD = da5da8f
  • working tree clean
  • pkg/constant/const.go 还原 api.zeabur.com
  • 全套 go test ./... pass

测试矩阵(前两轮 + 这轮汇总):

文件 数量
Unit pkg/zcontext/ephemeral_test.go 5
Unit internal/cmdutil/factory_test.go 11
Unit internal/util/runE_test.go(新) 3+3=5 sub
Unit internal/util/project_test.go 4
Unit internal/util/service_test.go 5
Unit internal/cmdutil/workspace_test.go 8
E2E dev-2 — C1/C2/C3/C4/C5/C6/C7a/C7b + RBAC.A/B/C + free-team + helper 矩阵 跨 4 轮

🤖 Generated with Claude Code

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/util/runE_test.go`:
- Line 21: The test declares source with an explicit type causing staticcheck
ST1023; remove the redundant type by changing the declaration of source to use
type inference (e.g., use short variable declaration for symbol source assigned
from zcontext.NewBasicInfo("old-id", "old-name") or use var source =
zcontext.NewBasicInfo(...)); update the declaration so it no longer explicitly
names zcontext.BasicInfo.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3eb1d2af-18d3-4269-b56d-04f261baafb8

📥 Commits

Reviewing files that changed from the base of the PR and between 7a1be0e and da5da8f.

📒 Files selected for processing (1)
  • internal/util/runE_test.go

Comment thread internal/util/runE_test.go Outdated
…am mode (PLA-1590)

Codex's round-6 review surfaced the last residual cross-workspace
contamination path that the B+ / B++ work missed: `context set project
--id <other-team-project>` while in a team workspace.

Repro:

  $ zeabur workspace switch team-A
  $ zeabur context set project --id <team-B-project-id>   # SUCCEEDS today
  $ zeabur service delete --name redis --yes -i=false     # deletes team-B's redis

The team branch of util.GetServiceByName then resolves under team-A
ownerID + team-B projectID and hands the resulting service-ID to the
destructive call. Backend RBAC doesn't catch it (caller is a member of
both teams), so the user — believing they're in team-A — silently
mutates team-B resources.

Pre-fix `setProject` simply called `f.ApiClient.GetProject(id, "",
"")` (owner-agnostic) and pinned whatever came back. The comment even
called it out as "ID path is workspace-agnostic" — accurate description
of the bug.

The fix is the minimal guard Codex recommended:

- ID path in a team workspace: after fetching the project by ID, call
  `ListAllProjects(currentOwnerID)` and verify the project is in the
  team's set. Reject otherwise with an actionable error pointing at
  `workspace switch` or `--name`.
- Personal workspace: unchanged. Collaborator workflows depend on
  pinning by-ID a project owned by someone else, and that's still
  fine because personal-pin doesn't have a "current workspace" the
  pinned project could conflict with.

Implementation lives in `internal/cmd/context/set/set.go`. Adds one
extra round-trip per ID-only team-workspace pin (acceptable: only
fires on the explicit `context set` action, not on hot paths).

Tests:

  TestSetProject_ID_TeamWorkspace_AllowsOwnProject
      legitimate same-team pin succeeds + ListAllProjects called with
      the correct ownerID.
  TestSetProject_ID_TeamWorkspace_RejectsForeignProject
      the exact Codex attack: cross-team --id rejected + context not
      contaminated.
  TestSetProject_ID_PersonalWorkspace_BypassesCheck
      back-compat: personal --id NEVER calls ListAllProjects (else
      collaborator pin-by-ID breaks).
  TestSetProject_ID_TeamWorkspace_ListErr
      backend list call failure propagates — must not silently
      "trust the user" and re-open the gap when the API flakes.

All pass; full `go test ./...` + `go vet ./...` clean.

Dev-2 E2E (Codex's 4 verifications):

- F1.1 team-A + --id of a personal project Bruce has access to →
  ERROR "project ... does not belong to workspace ..."; project
  context still empty.
- F1.2 team-A + --id of a team-A own project (for-clone-test) →
  pin succeeds.
- F1.3 personal workspace + --id of personal project (dev-test) →
  back-compat: pin succeeds, no ListAllProjects call.
- F1.4 after F1.1's rejection, follow-up `service delete --name redis
  -i=false` fails with "cannot resolve service by name ... no project
  context", so the destructive call never reaches the other team's
  redis. End-to-end verified: dev-test in personal is still present
  (untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521
Copy link
Copy Markdown
Contributor Author

采纳,commit e24000d

修法(按你的最小修法建议)

internal/cmd/context/set/set.go setProject ID-only path:

project, err = f.ApiClient.GetProject(ctx, id, "", "")
if err != nil { return ... }

// Team workspace: verify the project actually belongs to this team
// via the owner-scoped ListAllProjects. Personal workspace stays
// unchanged to preserve collaborator pin-by-ID workflows.
if ownerID := f.CurrentOwnerID(); ownerID != "" {
    teamProjects, listErr := f.ApiClient.ListAllProjects(ctx, 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,
        )
    }
}

不动 personal --id 行为(保 collaborator pin-by-ID workflow)。

单测(set_test.go 新文件)

TestSetProject_ID_TeamWorkspace_AllowsOwnProject       # 合法同 team pin → success + ListAllProjects called with right ownerID
TestSetProject_ID_TeamWorkspace_RejectsForeignProject  # 跨 team 拒 + 持久 context 不变(正是你的 attack)
TestSetProject_ID_PersonalWorkspace_BypassesCheck      # personal never calls ListAllProjects(守 back-compat)
TestSetProject_ID_TeamWorkspace_ListErr                # list 失败propagate,不"trust user" silently 放过

全 PASS,full go test ./... + go vet ./... clean。

Dev-2 E2E(你的 4 个验证)

# F1.1 team-A + --id of personal project (cross workspace)
$ /tmp/zeabur-dev2 workspace switch pla1230-probe-1776760608745
Switched to workspace "pla1230-probe-..." (team, Administrator).

$ /tmp/zeabur-dev2 context set project -i=false --id 69d7786714e9ea5ffda8f9b7
ERROR  project "dev-test" does not belong to workspace "pla1230-probe-1776760608745";
       either run `zeabur workspace switch <team>` first, or pin by --name

$ cat ~/.config/zeabur/cli.yaml | grep -A2 "^    project:"
    project:
        id: ""             # ← 没污染 ✓
        name: ""

# F1.2 team-A + --id of team-A own project
$ /tmp/zeabur-dev2 context set project -i=false --id 6a0733237fda5660beee1d47
INFO  Project context is set to <for-clone-test>          # ← legit pin OK

# F1.3 personal workspace --id back-compat
$ /tmp/zeabur-dev2 workspace clear
$ /tmp/zeabur-dev2 context set project -i=false --id 69d7786714e9ea5ffda8f9b7
INFO  Project context is set to <dev-test>                # ← personal --id 不变

# F1.4 跨 team 失败后 service 操作不能触达另一 team
# (拒了之后,context.project 为空,service --name 也 fails-closed)
$ /tmp/zeabur-dev2 service delete -i=false --name redis --yes
ERROR  cannot resolve service by name in a team workspace without a project context

# dev-test (personal) 未受影响
dev-test still present: True

同类入口 audit

  • context set environment --id —— environment 是 project-scoped,project 已守 → environment 自动跟随,安全
  • context set service --id —— 同理
  • 没有其他 setXxx --id 顶层入口直接接外部 ID 然后写持久 context

PLA-1590 description 也在 Linear 里同步更新 §6 这条 review-driven 约束。

🤖 Generated with Claude Code

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/cmd/context/set/set_test.go (1)

1-1: 💤 Low value

Consider using set_test package for black-box testing.

The linter flags that the test package should be set_test instead of set. However, since these tests directly call the unexported setProject function (white-box testing), keeping the package as set is intentional and necessary. If this lint rule is enforced project-wide, you could either:

  1. Add a //nolint:testpackage directive
  2. Refactor to test via the public NewCmdSet interface

Given the tests are validating internal security logic, white-box testing is reasonable here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmd/context/set/set_test.go` at line 1, The tests intentionally use
white-box testing by referencing the unexported setProject function, so change
nothing functionally; either add a package-level linter suppression by placing
"//nolint:testpackage" immediately above the "package set" declaration in
internal/cmd/context/set/set_test.go to silence the testpackage lint, or
refactor tests to exercise the public NewCmdSet command/interface instead of
calling setProject directly (update test helpers to invoke NewCmdSet and assert
behavior) — reference setProject and NewCmdSet when locating the code to change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/cmd/context/set/set_test.go`:
- Line 1: The tests intentionally use white-box testing by referencing the
unexported setProject function, so change nothing functionally; either add a
package-level linter suppression by placing "//nolint:testpackage" immediately
above the "package set" declaration in internal/cmd/context/set/set_test.go to
silence the testpackage lint, or refactor tests to exercise the public NewCmdSet
command/interface instead of calling setProject directly (update test helpers to
invoke NewCmdSet and assert behavior) — reference setProject and NewCmdSet when
locating the code to change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f21669ff-7e8d-48bb-bebf-1378264d621d

📥 Commits

Reviewing files that changed from the base of the PR and between da5da8f and e24000d.

📒 Files selected for processing (2)
  • internal/cmd/context/set/set.go
  • internal/cmd/context/set/set_test.go

…(PLA-1590)

Three CodeRabbit findings on the PR, two of which were CI lint blockers:

1. [CI BLOCKER] runE_test.go ST1023 — drop redundant explicit type on
   `var source zcontext.BasicInfo = ...`; the type is inferred from
   NewBasicInfo's return. staticcheck failed the lint job on it.

2. [CI BLOCKER] set_test.go testpackage — the test was `package set`
   (internal/white-box) so it could call unexported setProject. Lint
   requires `package set_test`. Reworked as black-box: drive the real
   `context set project --id <id>` flow through set.NewCmdSet + Cobra
   Execute, which is also a more faithful test of the actual entry
   point. Same 4 cases (legit same-team pin / cross-team reject /
   personal bypass / list-error propagation).

3. workspace/list/list.go — separate effective-owner from persisted
   for the two concerns that were sharing one variable:
   - `*` marker uses effectiveID (f.CurrentOwnerID) so a `--workspace`
     override is reflected.
   - stale-workspace warning uses persistedID (config workspace) so a
     transient override can't suppress a warning about a genuinely
     stale persisted workspace.

4. factory.go EffectiveContext — invalidate the cached ephemeral
   context when the override workspace changes mid-process. Today only
   resolveWorkspaceFlag sets the override (once per invocation), so this
   is defensive, but it stops a future mid-run team switch from handing
   back a stale in-memory project/service.

All checks pass locally: build, vet, full suite, and staticcheck
(the ST1023 + testpackage failures are gone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BruceDu521 BruceDu521 merged commit 40f7abc into main May 26, 2026
5 checks passed
@BruceDu521 BruceDu521 deleted the bruce/pla-1590-workspace branch May 26, 2026 02:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants