From d5e9009922c0186029d67c5063e2bab4d43490c1 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sun, 30 Aug 2026 01:27:04 -0400 Subject: [PATCH 1/3] fix(flow): open the Workbench in the team the backend applies An account with no saved default team could create a Flow through the gateway's server-side default team, but `oo flow open` refused to build the team-scoped Workbench link because the CLI only ever read a team name from _auth.toml_. That saved name was also just the name the team had at login, so a renamed team produced a link the console could not resolve. Commands that need the team by name (`oo flow`, `oo team current`, `oo auth status`) now spend one request on it instead of trusting the file: a saved default is refreshed through its id with `GET /v1/teams/{id}`, a name-only legacy default is completed through the memberships, and with nothing saved `GET /v1/me/default-team` reports the team the gateway applies, so the control requests and the Workbench link of one invocation always act for the same team. `oo login` adopts the default from that route too instead of picking the first `system_created` membership, which is not the gateway's rule. Header-only commands are untouched, since the gateway resolves them by id and applies the same default, and the shared retrying fetcher would otherwise make every run wait on relation-control. Nothing from these lookups is persisted, a failed lookup falls back to the previous behaviour, a deleted or inaccessible saved default is reported (and refused by `oo flow`) with the remedy, and CLI user errors crossing the Open Flow host boundary now carry their translated text, because the command artifact prints whatever message the host throws. Closes #356 Signed-off-by: Kevin Cui --- __tests__/helpers.ts | 27 ++ docs/commands.md | 71 ++-- docs/commands.zh-CN.md | 54 +-- .../commands/auth/index.cli.test.ts | 263 ++++++++++++-- src/application/commands/auth/login.ts | 44 ++- src/application/commands/auth/status.ts | 16 +- src/application/commands/flow.cli.test.ts | 335 ++++++++++++++++-- src/application/commands/flow.ts | 42 ++- src/application/commands/team/current.ts | 45 ++- .../commands/team/identity.test.ts | 253 +++++++++++++ src/application/commands/team/identity.ts | 148 ++++++-- .../commands/team/index.cli.test.ts | 148 +++++++- src/application/commands/team/shared.test.ts | 88 ++++- src/application/commands/team/shared.ts | 64 +++- .../commands/telemetry-decisions.test.ts | 4 +- src/i18n/catalog.ts | 18 + 16 files changed, 1446 insertions(+), 174 deletions(-) diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 2e8dd65c..e0f30131 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -135,6 +135,8 @@ export interface PrintedAuthLoginOptions { stdoutHasColors?: boolean; teamsResponse?: unknown; teamsStatus?: number; + defaultTeamResponse?: unknown; + defaultTeamStatus?: number; } // Default membership answered by the login-flow teams request: the backend @@ -151,6 +153,18 @@ export const defaultLoginTeamsResponse = { ], } as const; +// Default answer of `GET /v1/me/default-team`: the bare team object the +// backend reports as the account's server-side default team, matching the +// `system_created` membership above. +export const defaultLoginDefaultTeamResponse = { + id: "team-system-1", + name: "alice-team", + avatar: "", + creator_user_id: "user-1", + system_created: true, + deleted: false, +} as const; + export const defaultAuthEndpoint = "oomol.com"; export function createPlatformScope( @@ -610,6 +624,19 @@ export async function runPrintedAuthLogin( ); } + if ( + request.method === "GET" + && requestUrl.host === `relation-control.${accountEndpoint}` + && requestUrl.pathname === "/v1/me/default-team" + ) { + return new Response( + JSON.stringify( + options.defaultTeamResponse ?? defaultLoginDefaultTeamResponse, + ), + { status: options.defaultTeamStatus ?? 200 }, + ); + } + throw new Error(`Unexpected auth login request: ${request.method} ${requestUrl}`); }, stdout: { diff --git a/docs/commands.md b/docs/commands.md index 797ed384..a68b7fd6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -94,9 +94,14 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). `OO_TEAM_ID` is set, and ignored by connector commands when the connector target is self-hosted; variables commands always honor it. - Team-aware commands resolve their team identity with this precedence: - `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > the active account's default team. - With nothing selected they send no team selection and the server applies its - own default team; there is no private, per-user scope. + `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > the active account's default team + > the server-side default team. Commands that need the team by name + (`oo flow`, `oo team current`, `oo auth status`) never trust the saved name: + they refresh the saved default through its team id (so a renamed team keeps + working) and ask the backend which team it applies when nothing is saved. + Every other command sends the saved selection as is and the server resolves + it by id, applying that same default team when nothing is selected. There + is no private, per-user scope. - `OO_SKILLS_SYNC_DISABLED`: A truthy value disables the startup managed-skill synchronization, so the CLI writes no skill files into agent home directories such as `~/.agents` or `~/.claude`. @@ -202,7 +207,14 @@ requiring network access. URL without launching a browser, for scripts and agent-hosted previews. Omitting `flow` opens the Flow catalog; providing it opens that Flow's design page. The Hosted URL carries a short-lived, one-time browser sign-in code for - the effective CLI account and requires a Team. A self-hosted URL points + the effective CLI account and is scoped to the effective team under its + current name: the account's default team, refreshed through its team id so + a renamed team still opens, or the server-side default team the backend + reports when none is saved (the same team that invocation's Cloud requests + act for). When the saved default team no longer exists or is no longer + accessible, the command exits `1` and says so; when nothing is saved and the + backend reports no default team, it fails and points to `oo login` / + `oo team use `. A self-hosted URL points directly to the Server Workbench; the browser establishes its own operator session, and the operator token is never placed in the URL. - Host telemetry records this delegation only as top-level command `flow`, its @@ -301,16 +313,17 @@ with an existing API key, then save the authenticated account. memberships and persists a default team identity on the saved account. Without `--team`, a stored default that is still one of the memberships is kept (and gains its team id); - otherwise the backend-provisioned default team (`system_created`) is - adopted. When neither exists — the membership list carries no - `system_created` team (an older backend) or is empty — nothing is persisted, - no default-team line is printed, and login still exits `0`. Otherwise the + otherwise the server-side default team is adopted as the backend reports + it (the team a request without a team selection acts for). When the backend + reports none — the account has created no team — nothing is persisted, no + default-team line is printed, and login still exits `0`. Otherwise the success output prints the resulting default (`Default team identity: `), and when the account belongs to more than one team it also prints how many teams the account has (naming at most five, truncating the rest with an ellipsis) and that `oo team use ` switches - the default. When the membership request fails and no `--team` was given, - login still exits `0` and prints that the default team is unchanged. When + the default. When the membership request or the default-team lookup fails + and no `--team` was given, login still exits `0` and prints that the default + team is unchanged. When `OO_TEAM_ID` / `OO_TEAM_NAME` is set, the default is still saved but a hint notes that the env override keeps outranking it. - Notes: when a self-hosted connector is configured (`oo connector login`), it @@ -418,12 +431,14 @@ Show every saved auth account and validate the API key of the active one. - When a default team identity is in effect, the `oo auth status --json` output — specifically its `logged-in` shape above — carries an optional top-level - `team` field. `source` says which mechanism selected it (`env_id`, `env_name` - or `account`), and `status` reports the team lookup: + `team` field. `source` says which mechanism selected it (`env_id`, + `env_name`, `account`, or `backend_default` for the server-side default team + the backend reported because no default team is saved), and `status` reports + the team lookup: ```json { - "team": { "name": "acme", "id": null, "source": "account", "status": null } + "team": { "name": "acme", "id": "team-7", "source": "account", "status": "valid" } } ``` @@ -438,7 +453,10 @@ Show every saved auth account and validate the API key of the active one. } ``` - `status` is `null` whenever no lookup was attempted (the `account` source). + `status` is `valid` for `backend_default`, and for the `account` source it + reports whether the saved default could be confirmed through its id + (`valid`, or the reason it could not); the name shown is the team's current + name, not the one saved at login. `null` means no lookup was attempted. For an env-selected identity it is one of `valid`, `not_a_member`, `not_found`, `deleted`, `request_failed`, `request_failed_sandbox`, or `no_credential`. The looked-up half is filled only when `status` is `valid` @@ -594,8 +612,8 @@ one up. `oo auth login` (and its `oo login` alias) persists the default automatically: it keeps the account's still-valid stored default, otherwise adopts the -backend-provisioned `system_created` team (when one exists), and accepts -`--team ` to pick one explicitly. +server-side default team the backend reports (when it reports one), and +accepts `--team ` to pick one explicitly. ### `oo team list` @@ -636,13 +654,20 @@ applies its own default team. own. - Options: `--format=json` and `--json` print a JSON object. - Output: JSON is `{ "team": , "teamId": , "source": - <"env_id"|"env_name"|"account"|null>, "status": }`. `source` - says which mechanism selects the team, and is `null` when no default team is - saved (commands then use the server-side default team). `status` reports the - team lookup: `null` - whenever none was attempted (the `account` source, or `--dry-run`-style - offline paths), otherwise one of `valid`, `not_a_member`, `not_found`, - `deleted`, `request_failed`, `request_failed_sandbox`, or `no_credential`. + <"env_id"|"env_name"|"account"|"backend_default"|null>, "status": + }`. `source` says which mechanism selects the team: + `backend_default` when no default team is saved and the backend reported the + server-side default team it applies, `null` when it reported none (the + account has created no team) or that lookup failed. `team` is the team's + current name: a saved default is refreshed through its id (a name-only + default through the memberships), so a renamed team is reported under its + new name while the saved values stay as they are. `status` reports the + team lookup: `null` whenever none was attempted (`--dry-run`-style offline + paths), `valid` for `backend_default`, otherwise one of `valid`, + `not_a_member`, `not_found`, `deleted`, `request_failed`, + `request_failed_sandbox`, or `no_credential`; for the `account` source a + non-`valid` status keeps the saved name and id in the output, and the text + output appends the reason. - Output: under `OO_TEAM_ID` / `OO_TEAM_NAME` text output shows ` ()` once both halves are known. If the lookup does not succeed the env-supplied value is still shown and the reason is appended; the command still exits `0`. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 915401b3..a32edaa5 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -77,9 +77,12 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 `OO_TEAM_ID` 时会被忽略;connector 目标为自部署服务时,只有 connector 命令会忽略它,variables 命令始终遵循它。 - 团队相关命令按以下优先级解析团队身份: - `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > 当前账号保存的默认团队。 - 没有选中任何团队时不发送团队选择,由服务端套用它自己的默认团队;不存在 - 按用户私有的作用域。 + `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > 当前账号保存的默认团队 > + 服务端默认团队。需要团队名称的命令 (`oo flow`、`oo team current`、 + `oo auth status`) 不信任本地保存的名称:有保存的默认团队时按团队 id 向后端 + 刷新名称 (团队改名后仍可用),没有保存时向后端询问它套用的默认团队;其他 + 命令原样发送保存的选择,由服务端按 id 解析,没有选择时套用同一个默认团队。 + 不存在按用户私有的作用域。 - `OO_SKILLS_SYNC_DISABLED`:设为真值会禁用启动时的 managed skill 同步, 使 CLI 不会向 `~/.agents`、`~/.claude` 等代理主目录写入任何 skill 文件。 - `OO_NO_SELF_UPDATE`:设为真值会禁用 `oo update`、`oo install` 和 @@ -162,7 +165,11 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 - `oo flow open [flow]` 会在系统浏览器打开所选部署的 Workbench,并同时输出 URL; `oo flow workbench [flow]` 只输出相同 URL,不打开浏览器,适用于脚本和 Agent 内置预览。省略 `flow` 时打开 Flow 列表,提供 `flow` 时打开对应 Flow 的设计页。 - Hosted URL 会携带当前 CLI 账号的短期一次性网页登录 code,并要求已选择 Team。 + Hosted URL 会携带当前 CLI 账号的短期一次性网页登录 code,并限定在生效团队的 + 当前名称下:账号保存的默认团队会按团队 id 刷新名称 (改名后仍可打开),未保存时 + 为后端报告的服务端默认团队 (与本次调用的 Cloud 请求作用于同一个团队)。保存的 + 默认团队已不存在或已无权访问时命令以 `1` 退出并说明原因;未保存且后端未报告 + 默认团队时命令失败,并提示使用 `oo login` / `oo team use `。 自部署 URL 直接指向 Server Workbench,由浏览器自行建立 operator session; operator token 不会写入 URL。 - 宿主 telemetry 只把本次委托记录为顶层命令 `flow`,并记录成功/失败和耗时; @@ -245,14 +252,13 @@ oo flow 与三种登录方式均可组合。 - 默认团队:登录成功后,CLI 会获取账号的团队成员关系并把默认团队身份持久化到 已保存的账号上。未传 `--team` 时,若账号已保存的默认团队仍在成员关系中则 - 保留(并补齐其团队 id);否则采用 - 后端为每个账号创建的默认团队(`system_created`)。两者都不存在时——成员 - 关系中没有 `system_created` 团队(旧版后端)或列表为空——不会持久化任何 - 内容,也不打印默认团队行,登录仍以 `0` 退出。否则成功输出会打印生效的 + 保留(并补齐其团队 id);否则采用后端报告的服务端默认团队 (即不带团队 + 选择的请求所作用的团队)。后端未报告默认团队时 (账号未创建过任何团队), + 不会持久化任何内容,也不打印默认团队行,登录仍以 `0` 退出。否则成功输出会打印生效的 默认团队(`当前默认团队身份:`);当账号拥有多个团队时,还会打印团队 数量(最多列出 5 个名称,其余以省略号截断)以及使用 `oo team use ` - 切换的提示。未传 `--team` 且成员关系请求失败时,登录仍以 `0` 退出,并提示 - 默认团队保持不变。设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 时默认值仍会被保存, + 切换的提示。未传 `--team` 且成员关系请求或默认团队查询失败时,登录仍以 `0` + 退出,并提示默认团队保持不变。设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 时默认值仍会被保存, 但会提示 env 覆盖的优先级依然更高。 - 说明:如果已配置自部署 Connector(`oo connector login`),登录后 connector 相关命令仍会继续使用它;成功输出会打印一行提示,说明可运行 @@ -346,11 +352,12 @@ oo flow - 当存在默认团队身份时,`oo auth status --json` 的输出——即上面的 `logged-in` 形态——会携带一个可选的顶层 `team` 字段。`source` 表示由哪种机制选中 - (`env_id`、`env_name` 或 `account`),`status` 报告团队查询的结果: + (`env_id`、`env_name`、`account`,或 `backend_default`,即未保存默认团队时 + 后端报告的服务端默认团队),`status` 报告团队查询的结果: ```json { - "team": { "name": "acme", "id": null, "source": "account", "status": null } + "team": { "name": "acme", "id": "team-7", "source": "account", "status": "valid" } } ``` @@ -365,7 +372,9 @@ oo flow } ``` - 未尝试查询时 `status` 为 `null`(`account` 来源)。env 选定的身份下取值为 + `backend_default` 来源下 `status` 恒为 `valid`;`account` 来源下它报告按 id + 刷新是否确认了保存的默认团队 (`valid`,或无法确认的原因),显示的是团队当前 + 的名称而不是登录时保存的名称。未尝试查询时为 `null`。env 选定的身份下取值为 `valid`、`not_a_member`、`not_found`、`deleted`、`request_failed`、 `request_failed_sandbox` 或 `no_credential` 之一。只有 `status` 为 `valid` 时查询补全的那一半才有值——`env_id` 下补全名称,`env_name` 下补全 id—— @@ -492,8 +501,8 @@ oo flow 本地状态,且只在有账号可用时才额外查询团队名称来丰富输出。 `oo auth login`(及其别名 `oo login`)会自动持久化该默认值:账号上仍然有效的 -默认团队会被保留,否则采用后端创建的 `system_created` 默认团队 -(存在时);也可通过 `--team ` 显式指定。 +默认团队会被保留,否则采用后端报告的服务端默认团队 (后端有报告时);也可通过 +`--team ` 显式指定。 ### `oo team list` @@ -526,11 +535,16 @@ oo flow 提供的值。 - 选项:`--format=json` 与 `--json` 输出 JSON 对象。 - 输出:JSON 为 `{ "team": , "teamId": , "source": - <"env_id"|"env_name"|"account"|null>, "status": }`。`source` - 表示团队由哪种机制选定;未保存默认团队 (命令使用服务端默认团队) 时为 `null`。`status` - 报告团队查询的结果:未尝试查询时为 `null`(account 来源,或 `--dry-run` 这类 - 离线路径),否则为 `valid`、`not_a_member`、`not_found`、`deleted`、 - `request_failed`、`request_failed_sandbox` 或 `no_credential` 之一。 + <"env_id"|"env_name"|"account"|"backend_default"|null>, "status": + }`。`source` 表示团队由哪种机制选定:未保存默认团队且后端 + 报告了它套用的服务端默认团队时为 `backend_default`;后端未报告 (账号未创建 + 过任何团队) 或该查询失败时为 `null`。`team` 是团队当前的名称:有保存的默认 + 团队时按 id 刷新 (只有名称的默认团队则通过成员关系补全),因此改名后的团队 + 会以新名称报告,本地保存的值保持不变。`status` 报告团队查询的结果:未尝试 + 查询时为 `null`(`--dry-run` 这类离线路径),`backend_default` 来源恒为 + `valid`,否则为 `valid`、`not_a_member`、`not_found`、`deleted`、 + `request_failed`、`request_failed_sandbox` 或 `no_credential` 之一;account + 来源下非 `valid` 的状态会保留保存的名称与 id,文本输出末尾附上原因。 - 输出:`OO_TEAM_ID` / `OO_TEAM_NAME` 生效且两项信息都已知时,文本输出显示 `<名称>()`。查询未成功时仍会显示 env 提供的值并附上原因,命令依然以 `0` 退出。 diff --git a/src/application/commands/auth/index.cli.test.ts b/src/application/commands/auth/index.cli.test.ts index 880bfd95..92d56b65 100644 --- a/src/application/commands/auth/index.cli.test.ts +++ b/src/application/commands/auth/index.cli.test.ts @@ -12,6 +12,7 @@ import { createConnectionRefusedError, createFailedToOpenSocketError, defaultAuthEndpoint, + defaultLoginDefaultTeamResponse, defaultLoginTeamsResponse, expectTelemetryFreeOfTeamIdentity, findLoginUrl, @@ -324,6 +325,10 @@ describe("auth CLI", () => { ); } + if (isDefaultTeamRequest(request, defaultAuthEndpoint)) { + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + } + throw new Error(`Unexpected auth fast login request: ${request.method} ${requestUrl}`); }, }, @@ -332,7 +337,7 @@ describe("auth CLI", () => { const content = await readLatestLogContent(sandbox); expect(result.exitCode).toBe(0); - expect(requests).toHaveLength(2); + expect(requests).toHaveLength(3); expect(result.stdout).not.toContain("Open this login URL"); expect(result.stdout).not.toContain("Enter this code"); expect(result.stdout).not.toContain("Waiting for the device login"); @@ -401,6 +406,10 @@ describe("auth CLI", () => { ); } + if (isDefaultTeamRequest(request, defaultAuthEndpoint)) { + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + } + throw new Error(`Unexpected auth api key login request: ${request.method} ${requestUrl}`); }, }, @@ -409,7 +418,7 @@ describe("auth CLI", () => { const content = await readLatestLogContent(sandbox); expect(result.exitCode).toBe(0); - expect(requests).toHaveLength(2); + expect(requests).toHaveLength(3); expect(result.stdout).not.toContain("Open this login URL"); expect(result.stdout).not.toContain("Waiting for the device login"); expect(createCliSnapshot(result)).toEqual({ @@ -481,6 +490,10 @@ describe("auth CLI", () => { ); } + if (isDefaultTeamRequest(request, endpoint)) { + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + } + throw new Error(`Unexpected auth api key login request: ${request.method} ${requestUrl}`); }, }, @@ -488,7 +501,7 @@ describe("auth CLI", () => { const authFileContent = await readFile(authFilePath, "utf8"); expect(result.exitCode).toBe(0); - expect(requests).toHaveLength(2); + expect(requests).toHaveLength(3); expect(result.stdout).toContain("Logged in to oomol.dev"); expect(authFileContent).toContain("endpoint = \"oomol.dev\""); } @@ -717,19 +730,17 @@ describe("auth CLI", () => { }, ); + // The server-default team lookup finds no team here (unparseable + // body or 401), so the status output keeps the bare server default. expect(validStatus.exitCode).toBe(0); - expect(validRequests).toHaveLength(1); - expect(validRequests[0]?.url).toBe("https://api.oomol.com/v1/users/profile"); - expect(validRequests[0]?.headers.get("Authorization")).toBe("secret-1"); + expectStatusRequests(validRequests, { endpoint: "oomol.com", apiKey: "secret-1" }); expect(invalidStatus.exitCode).toBe(0); expect({ invalidStatus: createCliSnapshot(invalidStatus), validStatus: createCliSnapshot(validStatus), }).toMatchSnapshot(); - expect(invalidRequests).toHaveLength(1); - expect(invalidRequests[0]?.url).toBe("https://api.oomol.com/v1/users/profile"); - expect(invalidRequests[0]?.headers.get("Authorization")).toBe("secret-1"); + expectStatusRequests(invalidRequests, { endpoint: "oomol.com", apiKey: "secret-1" }); expect(await readFile(authFilePath, "utf8")).toContain("api_key = \"secret-1\""); } finally { @@ -854,9 +865,9 @@ describe("auth CLI", () => { expect(result.stdout).toContain("[active]"); // Only Alice (the active account) gets the [active] marker. expect(result.stdout).not.toContain("Bob [active]"); - // Only the active account is validated. - expect(requests).toHaveLength(1); - expect(requests[0]?.headers.get("Authorization")).toBe("secret-1"); + // Only the active account is validated, and only its credential + // asks for the server-default team. + expectStatusRequests(requests, { endpoint: "oomol.com", apiKey: "secret-1" }); // API key values must never leak to stdout. expect(result.stdout).not.toContain("secret-1"); expect(result.stdout).not.toContain("secret-2"); @@ -1777,10 +1788,9 @@ describe("auth CLI OO_API_KEY override", () => { ); expect(result.exitCode).toBe(0); - expect(requests).toHaveLength(1); - // The saved account's key and host must not be used for validation. - expect(requests[0]!.url).toBe("https://api.oomol.dev/v1/users/profile"); - expect(requests[0]!.headers.get("Authorization")).toBe("env-key-1"); + // The saved account's key and host must not be used for validation + // or for the server-default team lookup. + expectStatusRequests(requests, { endpoint: "oomol.dev", apiKey: "env-key-1" }); } finally { await sandbox.cleanup(); @@ -2117,9 +2127,7 @@ describe("auth CLI OO_ENDPOINT override", () => { // A bare OO_ENDPOINT redirects every other command, so status must // not keep reporting (and validating against) the saved endpoint. expect(result.stdout).toContain("Logged in to oomol.dev account Alice"); - expect(requests).toHaveLength(1); - expect(requests[0]!.url).toBe("https://api.oomol.dev/v1/users/profile"); - expect(requests[0]!.headers.get("Authorization")).toBe("secret-1"); + expectStatusRequests(requests, { endpoint: "oomol.dev", apiKey: "secret-1" }); } finally { await sandbox.cleanup(); @@ -2509,7 +2517,7 @@ describe("auth CLI login default team", () => { } }); - test("changes nothing when no system-created team matches", async () => { + test("changes nothing when the backend reports no default team", async () => { const sandbox = await createCliSandbox(); try { @@ -2519,11 +2527,13 @@ describe("auth CLI login default team", () => { teamsResponse: { teams: [{ id: "team-2", name: "beta", role: "member", system_created: false }], }, + defaultTeamStatus: 404, }); const authContent = await readAuthContent(sandbox); - // No membership carries system_created, so the stale stored - // default is left alone instead of being replaced or cleared. + // The account created no team, so the backend has no default to + // adopt and the stale stored default is left alone instead of + // being replaced or cleared. expect(result.exitCode).toBe(0); expect(result.stdout).not.toContain("Default team identity:"); expect(authContent).toContain("team = \"ghost\""); @@ -2541,6 +2551,7 @@ describe("auth CLI login default team", () => { try { const result = await runPrintedAuthLogin(sandbox, "secret-1", { teamsResponse: { teams: [] }, + defaultTeamStatus: 404, }); expect(result.exitCode).toBe(0); @@ -2558,6 +2569,32 @@ describe("auth CLI login default team", () => { } }); + test("keeps login successful when the default team lookup cannot be parsed", async () => { + const sandbox = await createCliSandbox(); + + try { + const result = await runPrintedAuthLogin(sandbox, "secret-1", { + defaultTeamResponse: "boom", + }); + + // The memberships loaded but the backend's default could not be + // read, so nothing is persisted and the reader is told which + // command sets a default by hand. + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Could not determine your default team"); + expect(result.stdout).not.toContain("Default team identity:"); + expect(await readAuthContent(sandbox)).not.toContain("\nteam = "); + expect(readCommandTelemetryProperties(sandbox, "auth.login")) + .toMatchObject({ + team_count_bucket: "1-5", + team_selection: "unresolved", + }); + } + finally { + await sandbox.cleanup(); + } + }); + test("replaces a stale stored team with the system-created default", async () => { const sandbox = await createCliSandbox(); @@ -2747,23 +2784,22 @@ describe("auth CLI status default team", () => { try { await writeAuthFileWithDefaultTeam(sandbox, "acme"); - const fetcher = async (): Promise => - new Response(null, { status: 200 }); + const fetcher = createAuthStatusFetcher([]); const textResult = await sandbox.run(["auth", "status"], { fetcher }); const jsonResult = await sandbox.run( ["auth", "info", "--json"], { fetcher }, ); expect(textResult.exitCode).toBe(0); - expect(textResult.stdout).toContain("- Default team: acme"); + expect(textResult.stdout).toContain("- Default team: acme (team-7)"); expect(jsonResult.exitCode).toBe(0); - // The account default already carries its name, so no lookup runs - // and there is no status to report. + // A name-only default (migrated from the legacy setting) is + // completed through the memberships, which also confirms it. expect(parseAuthStatusTeam(jsonResult.stdout)).toEqual({ name: "acme", - id: null, + id: "team-7", source: "account", - status: null, + status: "valid", }); // Only the source enum reaches telemetry, never the team name. @@ -2773,9 +2809,75 @@ describe("auth CLI status default team", () => { ); expect(telemetryProperties).toMatchObject({ team_source: "account", - team_status: "none", + team_status: "valid", + }); + expectTelemetryFreeOfTeamIdentity(telemetryProperties, ["acme", "team-7"]); + } + finally { + await sandbox.cleanup(); + } + }); + + // The saved name is only the name the team had at login: the row shows + // the current one, looked up by the saved id. + test("reports a renamed default team under its current name", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "old-name", { teamId: "team-42" }); + + const requests: Request[] = []; + const fetcher = createAuthStatusFetcher(requests); + const textResult = await sandbox.run(["auth", "status"], { fetcher }); + const jsonResult = await sandbox.run(["auth", "status", "--json"], { fetcher }); + + expect(textResult.exitCode).toBe(0); + expect(textResult.stdout).toContain("- Default team: platform (team-42)"); + expect(textResult.stdout).not.toContain("old-name"); + expect(parseAuthStatusTeam(jsonResult.stdout)).toEqual({ + name: "platform", + id: "team-42", + source: "account", + status: "valid", + }); + expect(requests.map(request => new URL(request.url).pathname).sort()).toEqual([ + "/v1/teams/team-42", + "/v1/teams/team-42", + "/v1/users/profile", + "/v1/users/profile", + ]); + // The current name is reported, never written back: the saved + // default is refreshed by `oo login` and `oo team use` only. + expect(await readFile(join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "auth.toml"), "utf8")) + .toContain("team = \"old-name\""); + } + finally { + await sandbox.cleanup(); + } + }); + + test("reports why a saved default team could not be confirmed", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "acme", { teamId: "team-42" }); + + const fetcher = createAuthStatusFetcher([], { teamHttpStatus: 410 }); + const textResult = await sandbox.run(["auth", "status"], { fetcher }); + const jsonResult = await sandbox.run(["auth", "status", "--json"], { fetcher }); + + // Reporting never fails the command; the saved values stay + // visible with the reason appended. + expect(textResult.exitCode).toBe(0); + expect(textResult.stdout).toContain( + "- Default team: acme (team-42) — this team has been deleted", + ); + expect(parseAuthStatusTeam(jsonResult.stdout)).toEqual({ + name: "acme", + id: "team-42", + source: "account", + status: "deleted", }); - expectTelemetryFreeOfTeamIdentity(telemetryProperties, ["acme"]); } finally { await sandbox.cleanup(); @@ -3191,19 +3293,30 @@ describe("auth web CLI", () => { }); }); -// Answers every request `auth status` can make: the API key check and the -// team lookup in either direction. Recording every request is what lets a test -// assert the command's request count, which is part of its documented -// contract. +// Answers every request `auth status` can make: the API key check, the team +// lookup in either direction, and the server-default team lookup. Recording +// every request is what lets a test assert the command's request count, which +// is part of its documented contract. function createAuthStatusFetcher( requests: Request[], - options: { teamHttpStatus?: number } = {}, + options: { teamHttpStatus?: number; defaultTeamHttpStatus?: number } = {}, ): Fetcher { return async (input, init) => { const request = toRequest(input, init); requests.push(request); const pathname = new URL(request.url).pathname; + if (pathname === "/v1/me/default-team") { + const defaultTeamHttpStatus = options.defaultTeamHttpStatus ?? 200; + + return new Response( + defaultTeamHttpStatus === 200 + ? JSON.stringify(defaultLoginDefaultTeamResponse) + : "", + { status: defaultTeamHttpStatus }, + ); + } + if (pathname === "/v1/me/teams") { return new Response(JSON.stringify({ teams: [ @@ -3237,6 +3350,58 @@ function createAuthStatusFetcher( }; } +describe("auth status server-side default team", () => { + test("reports the team the backend applies when no default team is saved", async () => { + const sandbox = await createCliSandbox(); + + try { + const authFilePath = await writeAuthFile(sandbox); + + const fetcher = createAuthStatusFetcher([]); + const jsonResult = await sandbox.run(["auth", "status", "--json"], { fetcher }); + const textResult = await sandbox.run(["auth", "status"], { fetcher }); + + expect(jsonResult.exitCode).toBe(0); + expect(parseAuthStatusTeam(jsonResult.stdout)).toEqual({ + name: "alice-team", + id: "team-system-1", + source: "backend_default", + status: "valid", + }); + expect(textResult.exitCode).toBe(0); + expect(textResult.stdout).toContain( + "alice-team (team-system-1) (server default, no saved team)", + ); + // Reporting the backend's answer never saves it: only `oo login` + // and `oo team use` persist a default team. + expect(await readFile(authFilePath, "utf8")).not.toContain("\nteam = "); + } + finally { + await sandbox.cleanup(); + } + }); + + test("keeps the bare server default when the backend reports no default team", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const fetcher = createAuthStatusFetcher([], { defaultTeamHttpStatus: 404 }); + const jsonResult = await sandbox.run(["auth", "status", "--json"], { fetcher }); + const textResult = await sandbox.run(["auth", "status"], { fetcher }); + + expect(jsonResult.exitCode).toBe(0); + expect(parseAuthStatusTeam(jsonResult.stdout)).toBeUndefined(); + expect(textResult.stdout).toContain("server default (no saved team)"); + expect(textResult.stdout).not.toContain("alice-team"); + } + finally { + await sandbox.cleanup(); + } + }); +}); + function parseAuthStatusTeam(stdout: string): unknown { return (JSON.parse(stdout) as { team?: unknown }).team; } @@ -3251,3 +3416,29 @@ async function createAuthenticatedCliSandbox(): Promise { return sandbox; } + +// `auth status` makes exactly two requests, both with the active credential: +// the key validation and the server-default team lookup. Sorted, because they +// go out concurrently and their order is not part of the contract. +function expectStatusRequests( + requests: readonly Request[], + expected: { endpoint: string; apiKey: string }, +): void { + expect(requests.map(request => request.url).sort()).toEqual([ + `https://api.${expected.endpoint}/v1/users/profile`, + `https://relation-control.${expected.endpoint}/v1/me/default-team`, + ]); + + for (const request of requests) { + expect(request.headers.get("Authorization")).toBe(expected.apiKey); + } +} + +// The server-default team lookup every login makes after the memberships. +function isDefaultTeamRequest(request: Request, endpoint: string): boolean { + const requestUrl = new URL(request.url); + + return request.method === "GET" + && requestUrl.host === `relation-control.${endpoint}` + && requestUrl.pathname === "/v1/me/default-team"; +} diff --git a/src/application/commands/auth/login.ts b/src/application/commands/auth/login.ts index e9f98ea1..06e04e20 100644 --- a/src/application/commands/auth/login.ts +++ b/src/application/commands/auth/login.ts @@ -25,7 +25,7 @@ import { createWriterColors } from "../../terminal-colors.ts"; import { writeLine } from "../shared/output.ts"; import { resolveSelfHostedConnectorTolerantly } from "../shared/self-hosted-connector.ts"; import { resolveTeamIdentity } from "../team/identity.ts"; -import { listMemberTeams } from "../team/shared.ts"; +import { fetchDefaultTeam, listMemberTeams } from "../team/shared.ts"; import { formatAuthStrong, writeAuthBlock, @@ -52,8 +52,9 @@ const authLoginCommandInputSchema = z.object({ // Which mechanism decided the default team after login. `flag` is an explicit // `--team`, `kept_existing` preserves the account's still-valid default, -// `system_default` adopts the backend-provisioned team, `none` found nothing -// to adopt, and `unresolved` means the membership request failed. +// `system_default` adopts the backend's server-side default team, `none` +// found nothing to adopt (the account has created no team), and `unresolved` +// means the membership or default-team request failed. type LoginTeamSelection = "flag" | "kept_existing" | "none" | "system_default" | "unresolved"; @@ -276,6 +277,7 @@ async function applyLoginTeamIdentity( } const selection = await resolveLoginTeamSelection( + account, storedTeam, requestedTeam, teams, @@ -287,6 +289,13 @@ async function applyLoginTeamIdentity( team_selection: selection.kind, }); + if (selection.kind === "unresolved") { + writeLine( + context.stdout, + context.translator.t("auth.login.teamDefaultUnresolved"), + ); + } + if (selection.team !== undefined) { writeLine( context.stdout, @@ -335,10 +344,11 @@ async function applyLoginTeamIdentity( // Picks the default team and persists it when it changes. Precedence: // an explicit `--team` (must be a membership), then the account's still-valid // stored default (a re-login must not clobber a deliberate `oo team use`), -// then the backend-provisioned `system_created` team. A stored default the -// account can no longer use is replaced rather than kept; a renamed one is -// not stale, which is why the match runs on the stored id. +// then the server-side default team as the backend reports it. A stored +// default the account can no longer use is replaced rather than kept; a +// renamed one is not stale, which is why the match runs on the stored id. async function resolveLoginTeamSelection( + account: AuthAccount, storedTeam: AccountDefaultTeam | undefined, requestedTeam: string | undefined, teams: readonly TeamView[], @@ -367,14 +377,26 @@ async function resolveLoginTeamSelection( return { kind: "kept_existing", team: keptTeam.name }; } - const systemTeam = teams.find(team => team.systemCreated); + // Asked of the backend rather than picked from the listing, so the saved + // default equals the team a header-less request lands on (see + // fetchDefaultTeam for why the listing cannot answer this). + const lookup = await fetchDefaultTeam(account, context); - if (systemTeam === undefined) { + if (lookup.status === "none") { return { kind: "none" }; } - await persistLoginTeamIdentity(systemTeam, context); - return { kind: "system_default", team: systemTeam.name }; + if (lookup.status !== "valid") { + context.logger.warn( + { status: lookup.status }, + "Login default team lookup failed; default team unchanged.", + ); + + return { kind: "unresolved" }; + } + + await persistLoginTeamIdentity(lookup.team, context); + return { kind: "system_default", team: lookup.team.name }; } // The account's stored default, as the selection needs it. Reads the account @@ -410,7 +432,7 @@ function findStoredTeam( } async function persistLoginTeamIdentity( - team: TeamView, + team: Pick, context: CliExecutionContext, ): Promise { await writeDefaultTeam(context, { id: team.id, name: team.name }); diff --git a/src/application/commands/auth/status.ts b/src/application/commands/auth/status.ts index ac9f179a..7b7b027c 100644 --- a/src/application/commands/auth/status.ts +++ b/src/application/commands/auth/status.ts @@ -171,6 +171,7 @@ export const authStatusCommand: CliCommandDefinition = { account: identity?.account, defaultTeam, resolveAgainstBackend: true, + resolveCurrentName: true, }, context, ), @@ -343,8 +344,10 @@ function writeSelfHostedConnectorText( } // Renders the default-team detail row. The value spells out the identity in -// effect: the account default by name, an env override with the variable that -// supplies it, or the server-side default when no default team is saved. +// effect: the account default under its current name (with the reason when +// the refresh could not confirm it), an env override with the variable that +// supplies it, the server-side default by name when the backend reported one, +// or the bare server-side default when it reported none. function formatStatusTeamDetail( context: CliExecutionContext, teamIdentity: TeamIdentity | undefined, @@ -360,6 +363,15 @@ function formatStatusTeamDetail( const teamValue = formatTeamIdentityValue(teamIdentity, context.translator); + if (teamIdentity.source === "backend_default") { + return { + label, + value: context.translator.t("auth.status.teamBackendDefault", { + team: teamValue, + }), + }; + } + return { label, value: appendTeamIdentityStatus( diff --git a/src/application/commands/flow.cli.test.ts b/src/application/commands/flow.cli.test.ts index 954145af..deaadc52 100644 --- a/src/application/commands/flow.cli.test.ts +++ b/src/application/commands/flow.cli.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "bun:test"; import { createCliSandbox, createTemporaryDirectory, + defaultLoginDefaultTeamResponse, readLatestLogContent, toRequest, writeAuthFile, @@ -106,19 +107,28 @@ describe("flow CLI", () => { requests.push(request); - return new Response(null, { status: 202 }); + return new URL(request.url).host === "relation-control.oomol.dev" + ? new Response(JSON.stringify({ + id: "team-1", + name: "platform", + role: "member", + system_created: false, + })) + : new Response(null, { status: 202 }); }, }); expect(result.exitCode).toBe(0); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe("https://open-flow.oomol.dev/v1/projects?limit=10"); - expect(requests[0]?.method).toBe("POST"); - expect(requests[0]?.headers.get("authorization")).toBe("dev-secret"); - expect(requests[0]?.headers.get("x-oo-team-name")).toBe("platform"); - expect(requests[0]?.headers.get("x-oo-team-id")).toBe("team-1"); - expect(requests[0]?.headers.get("x-oomol-token")).toBeNull(); - expect(await requests[0]?.text()).toBe("{\"name\":\"Example\"}"); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.dev/v1/teams/team-1", + "https://open-flow.oomol.dev/v1/projects?limit=10", + ]); + expect(requests[1]?.method).toBe("POST"); + expect(requests[1]?.headers.get("authorization")).toBe("dev-secret"); + expect(requests[1]?.headers.get("x-oo-team-name")).toBe("platform"); + expect(requests[1]?.headers.get("x-oo-team-id")).toBe("team-1"); + expect(requests[1]?.headers.get("x-oomol-token")).toBeNull(); + expect(await requests[1]?.text()).toBe("{\"name\":\"Example\"}"); } finally { await Promise.all([ @@ -243,16 +253,31 @@ describe("flow CLI", () => { const requests: Request[] = []; const result = await sandbox.run(["flow", "project", "list"], { fetcher: async (input, init) => { - requests.push(toRequest(input, init)); - return new Response(null, { status: 204 }); + const request = toRequest(input, init); + + requests.push(request); + + return new URL(request.url).host === "relation-control.oomol.dev" + ? new Response(JSON.stringify({ + id: "team-dev", + name: "development", + role: "member", + system_created: false, + })) + : new Response(null, { status: 204 }); }, }); expect(result.exitCode).toBe(0); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe("https://open-flow.oomol.dev/v1/projects"); + // The selected account's default team is refreshed with that + // account's credential and endpoint, then the request goes out. + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.dev/v1/teams/team-dev", + "https://open-flow.oomol.dev/v1/projects", + ]); expect(requests[0]?.headers.get("authorization")).toBe("dev-secret"); - expect(requests[0]?.headers.get("x-oo-team-id")).toBe("team-dev"); + expect(requests[1]?.headers.get("authorization")).toBe("dev-secret"); + expect(requests[1]?.headers.get("x-oo-team-id")).toBe("team-dev"); expect(await Bun.file(authPath).text()).toStartWith("id = \"user-prod\""); } finally { @@ -281,16 +306,20 @@ describe("flow CLI", () => { ]); sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; - let requestCount = 0; + const requests: Request[] = []; const result = await sandbox.run(["flow", "project", "list"], { - fetcher: async () => { - requestCount += 1; - return new Response(null, { status: 200 }); + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + return new Response(null, { status: 404 }); }, }); + // Resolving the session asks for the server-default team (the + // account has none saved); the forged request itself never leaves. expect(result.exitCode).toBe(0); - expect(requestCount).toBe(0); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/me/default-team", + ]); expect(Reflect.get(globalThis, captureKey)).toBe( "Open Flow requests must target the configured /v1/ gateway.", ); @@ -332,13 +361,27 @@ describe("flow CLI", () => { sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; const requests: Request[] = []; + const signInRequests: Request[] = []; const result = await sandbox.run(["--debug", "flow", "workbench", "flow/1"], { fetcher: async (input, init) => { - requests.push(toRequest(input, init)); + const request = toRequest(input, init); + + requests.push(request); + + if (new URL(request.url).host === "relation-control.oomol.dev") { + return new Response(JSON.stringify({ + id: "team-1", + name: "platform/team", + role: "member", + system_created: false, + })); + } + + signInRequests.push(request); return new Response(JSON.stringify({ expires_in: 300, - session_code: `workbench-code-${requests.length}`, + session_code: `workbench-code-${signInRequests.length}`, })); }, }); @@ -348,10 +391,14 @@ describe("flow CLI", () => { catalogUrl: "https://api.oomol.dev/v1/auth/session_code/exchange?redirect=https%3A%2F%2Fconsole.oomol.dev%2Fteam%2Fplatform%252Fteam%2Fflows&session_code=workbench-code-1", flowUrl: "https://api.oomol.dev/v1/auth/session_code/exchange?redirect=https%3A%2F%2Fconsole.oomol.dev%2Fteam%2Fplatform%252Fteam%2Fflows%2Fflow%252F1%2Fdesign&session_code=workbench-code-2", }); - expect(requests).toHaveLength(2); - expect(requests[0]?.method).toBe("POST"); - expect(requests[0]?.url).toBe("https://api.oomol.dev/v1/auth/session_code"); - expect(requests[0]?.headers.get("authorization")).toBe("Bearer dev-secret"); + // One refresh of the saved default, then one sign-in code per link. + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.dev/v1/teams/team-1", + "https://api.oomol.dev/v1/auth/session_code", + "https://api.oomol.dev/v1/auth/session_code", + ]); + expect(signInRequests[0]?.method).toBe("POST"); + expect(signInRequests[0]?.headers.get("authorization")).toBe("Bearer dev-secret"); expect(await readLatestLogContent(sandbox)).not.toContain("workbench-code"); } finally { @@ -363,6 +410,244 @@ describe("flow CLI", () => { } }); + // The regression guard for a Flow that could be created but not opened: + // with no saved team, both host paths must act for the one team the + // backend reports, resolved once per invocation. + test("acts for the server-side default team on Cloud requests and the Workbench deep link when no team is saved", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-default-team-${Bun.randomUUIDv7()}`; + + try { + await writeAuthFile(sandbox); + await writeCommandEntry(commandDirectory, [ + "const cloudResponse = await host.cloudRequest('/v1/projects', { method: 'GET' });", + "const flowUrl = await host.getWorkbenchUrl('flow/1');", + `Reflect.set(globalThis, ${JSON.stringify(captureKey)}, { flowUrl, status: cloudResponse.status });`, + "return 0;", + ]); + sandbox.env.OO_ENDPOINT = "oomol.dev"; + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const requests: Request[] = []; + const result = await sandbox.run(["flow", "workbench", "flow/1"], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + const requestUrl = new URL(request.url); + + requests.push(request); + + if (requestUrl.host === "relation-control.oomol.dev") { + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + } + + if (requestUrl.host === "api.oomol.dev") { + return new Response(JSON.stringify({ + expires_in: 300, + session_code: "workbench-code-1", + })); + } + + return new Response(null, { status: 202 }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.dev/v1/me/default-team", + "https://open-flow.oomol.dev/v1/projects", + "https://api.oomol.dev/v1/auth/session_code", + ]); + expect(requests[1]?.headers.get("x-oo-team-name")).toBe("alice-team"); + expect(requests[1]?.headers.get("x-oo-team-id")).toBe("team-system-1"); + expect(Reflect.get(globalThis, captureKey)).toEqual({ + flowUrl: "https://api.oomol.dev/v1/auth/session_code/exchange?redirect=https%3A%2F%2Fconsole.oomol.dev%2Fteam%2Falice-team%2Fflows%2Fflow%252F1%2Fdesign&session_code=workbench-code-1", + status: 202, + }); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + // The saved name is refreshed by id before it reaches a header or the + // Workbench link: a rename keeps working, a deleted team is refused with + // the remedy already translated, because the command artifact can only + // print the message of whatever the host throws. + test("acts for a renamed default team under its current name", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-renamed-team-${Bun.randomUUIDv7()}`; + + try { + await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "dev-secret", + endpoint: "oomol.com", + team: "old-name", + teamId: "team-1", + }, + ], + }); + await writeCommandEntry(commandDirectory, [ + "const cloudResponse = await host.cloudRequest('/v1/projects', { method: 'GET' });", + "const flowUrl = await host.getWorkbenchUrl('flow/1');", + `Reflect.set(globalThis, ${JSON.stringify(captureKey)}, { flowUrl, status: cloudResponse.status });`, + "return 0;", + ]); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const requests: Request[] = []; + const result = await sandbox.run(["flow", "workbench", "flow/1"], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + const requestUrl = new URL(request.url); + + requests.push(request); + + if (requestUrl.host === "relation-control.oomol.com") { + return new Response(JSON.stringify({ + id: "team-1", + name: "new-name", + role: "creator", + system_created: false, + })); + } + + if (requestUrl.host === "api.oomol.com") { + return new Response(JSON.stringify({ + expires_in: 300, + session_code: "workbench-code-1", + })); + } + + return new Response(null, { status: 202 }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/teams/team-1", + "https://open-flow.oomol.com/v1/projects", + "https://api.oomol.com/v1/auth/session_code", + ]); + expect(requests[1]?.headers.get("x-oo-team-name")).toBe("new-name"); + expect(requests[1]?.headers.get("x-oo-team-id")).toBe("team-1"); + expect(Reflect.get(globalThis, captureKey)).toEqual({ + flowUrl: "https://api.oomol.com/v1/auth/session_code/exchange?redirect=https%3A%2F%2Fconsole.oomol.com%2Fteam%2Fnew-name%2Fflows%2Fflow%252F1%2Fdesign&session_code=workbench-code-1", + status: 202, + }); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("refuses a saved default team that no longer exists before any Cloud request", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-deleted-team-${Bun.randomUUIDv7()}`; + + try { + await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "dev-secret", + endpoint: "oomol.com", + team: "acme", + teamId: "team-1", + }, + ], + }); + await writeCommandEntry(commandDirectory, [ + "try {", + " await host.cloudRequest('/v1/projects');", + " return 9;", + "} catch (error) {", + ` Reflect.set(globalThis, ${JSON.stringify(captureKey)}, { message: error.message, name: error.name });`, + " return 0;", + "}", + ]); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const requests: Request[] = []; + const result = await sandbox.run(["flow", "project", "list"], { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response("{}", { status: 410 }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/teams/team-1", + ]); + // Translated at the host boundary, and still a CLI user error for + // the host's own handler. + expect(Reflect.get(globalThis, captureKey)).toEqual({ + message: "The saved default team \"acme\" cannot be used: this team has been deleted. Run `oo team use ` to pick another team, or `oo login` to refresh the default.", + name: "CliUserError", + }); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("explains the missing team when the backend reports no default team", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-no-team-${Bun.randomUUIDv7()}`; + + try { + await writeAuthFile(sandbox); + await writeCommandEntry(commandDirectory, [ + "try {", + " await host.getWorkbenchUrl();", + " return 9;", + "} catch (error) {", + ` Reflect.set(globalThis, ${JSON.stringify(captureKey)}, error instanceof Error ? error.message : String(error));`, + " return 0;", + "}", + ]); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const result = await sandbox.run(["flow", "workbench"], { + fetcher: async () => new Response("", { status: 404 }), + }); + + expect(result.exitCode).toBe(0); + expect(Reflect.get(globalThis, captureKey)).toBe( + "No team is available to open the Open Flow Workbench for this account. Run `oo login` again, or select one with `oo team use `.", + ); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + test("returns a direct Workbench deep link for Open Flow Server", async () => { const sandbox = await createCliSandbox(); const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); diff --git a/src/application/commands/flow.ts b/src/application/commands/flow.ts index c4414544..77d3abe8 100644 --- a/src/application/commands/flow.ts +++ b/src/application/commands/flow.ts @@ -205,10 +205,16 @@ export async function runOpenFlowCommand( }); } + // Resolved once per invocation and shared by both host methods, so the + // control requests and the Workbench deep link act for the same team. let sessionPromise: Promise | undefined; + const resolveHostSession = (): Promise => + (sessionPromise ??= resolveOpenFlowSession(context).catch((error: unknown) => { + throw translateHostError(error, context); + })); const host: OpenFlowCommandHost = { async cloudRequest(path, init = {}) { - const session = await (sessionPromise ??= resolveOpenFlowSession(context)); + const session = await resolveHostSession(); const url = new URL(path, session.origin); if ( @@ -245,7 +251,7 @@ export async function runOpenFlowCommand( return await context.fetcher(url, { ...init, headers }); }, async getWorkbenchUrl(flowId) { - const session = await (sessionPromise ??= resolveOpenFlowSession(context)); + const session = await resolveHostSession(); if (session.kind === "server") { const pathname @@ -256,8 +262,13 @@ export async function runOpenFlowCommand( return new URL(pathname, session.origin).href; } + // Reached only when the backend reported no default team either + // (the account has created none) or that lookup failed. if (session.teamName === undefined) { - throw new TypeError("Select a Team before opening the Open Flow Workbench."); + throw translateHostError( + new CliUserError("errors.flow.teamRequired", 1), + context, + ); } const flowPath = `/team/${encodeURIComponent(session.teamName)}/flows`; @@ -292,6 +303,23 @@ export async function runOpenFlowCommand( return exitCode; } +// The command artifact reports any error a host method throws as +// `flow.unexpected: ` and exits 1 — it cannot translate a CLI error +// key — so a CLI user error crossing the host boundary carries its translated +// text in the message. It stays a CliUserError: when nothing wraps it (a +// host-side failure before delegation), the CLI's own handler still keys off +// `key` and `exitCode`. +function translateHostError( + error: unknown, + context: Pick, +): unknown { + if (error instanceof CliUserError) { + error.message = context.translator.t(error.key, error.params); + } + + return error; +} + async function resolveOpenFlowSession( context: Pick< CliExecutionContext, @@ -347,12 +375,20 @@ async function resolveOpenFlowSession( : account.team === undefined ? undefined : { id: account.teamId ?? null, name: account.team }; + // The Workbench deep link is team-scoped by the team's current name, so + // the session asks the backend for it: a saved default is refreshed by + // id (the saved name goes stale on rename), and with nothing saved the + // backend reports which team it applies anyway. Resolved once per + // invocation, the same identity backs both the control requests and the + // deep link, so a Flow created through this session always opens in the + // team it was created in. const identity = requireValidTeamIdentity( await resolveTeamIdentity( { account, defaultTeam, resolveAgainstBackend: true, + resolveCurrentName: true, }, context, ), diff --git a/src/application/commands/team/current.ts b/src/application/commands/team/current.ts index 6af208fa..d9d274bb 100644 --- a/src/application/commands/team/current.ts +++ b/src/application/commands/team/current.ts @@ -16,12 +16,15 @@ import { } from "./identity.ts"; // `source` says which mechanism selects the team: the OO_TEAM_ID / -// OO_TEAM_NAME env override, the account's saved default, or none (the -// server-side default team). `team` carries the name and `teamId` the id. +// OO_TEAM_NAME env override, the account's saved default, the server-side +// default team the backend reported (`backend_default`), or none when the +// backend had no default to report. `team` carries the name and `teamId` the +// id. // // `status` reports how the backend lookup ended and is `null` whenever none // was attempted — env-selected identities are looked up in whichever -// direction they are missing, a saved default never is. +// direction they are missing, the backend default is always looked up, a +// saved default never is. interface TeamCurrentJsonPayload { team: string | null; teamId: string | null; @@ -31,15 +34,18 @@ interface TeamCurrentJsonPayload { // Reports the team identity that team-aware commands use when no `--team` // flag is given: the OO_TEAM_ID / OO_TEAM_NAME env override when -// set, otherwise the active account's saved default team. +// set, otherwise the active account's saved default team, otherwise the +// server-side default team the backend reports. // // An env-selected identity starts out with only the dimension the variable // supplies, which tells a reader nothing about the most common // misconfiguration there is — a team the account cannot actually use. Those -// identities, and only those, spend one request to complete and validate the -// other dimension. The account default stays offline, as does an -// unauthenticated run: having no account skips the lookup rather than failing -// the command, so reading the local default never requires a login. +// identities spend one request to complete and validate the other dimension, +// a saved default spends one to come back under its current name (the saved +// name goes stale on rename), and an account with no saved default spends one +// to learn which team the gateway applies. An unauthenticated run stays +// offline: having no account skips the lookup rather than failing the +// command, so reading the local default never requires a login. export const teamCurrentCommand: CliCommandDefinition = { name: "current", summaryKey: "commands.team.current.summary", @@ -52,7 +58,12 @@ export const teamCurrentCommand: CliCommandDefinition = { resolveIdentity(context), ]); const identity = await resolveTeamIdentity( - { account, defaultTeam, resolveAgainstBackend: true }, + { + account, + defaultTeam, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, context, ); @@ -83,7 +94,21 @@ export const teamCurrentCommand: CliCommandDefinition = { if (identity.source === "account") { writeLine( context.stdout, - context.translator.t("team.current.text.accountDefault", { + appendTeamIdentityStatus( + context.translator.t("team.current.text.accountDefault", { + team: teamValue, + }), + identity, + context.translator, + ), + ); + return; + } + + if (identity.source === "backend_default") { + writeLine( + context.stdout, + context.translator.t("team.current.text.backendDefault", { team: teamValue, }), ); diff --git a/src/application/commands/team/identity.test.ts b/src/application/commands/team/identity.test.ts index b4ba544d..ddd61b22 100644 --- a/src/application/commands/team/identity.test.ts +++ b/src/application/commands/team/identity.test.ts @@ -6,6 +6,7 @@ import pino from "pino"; import { createFailedToOpenSocketError, + defaultLoginDefaultTeamResponse, expectCliUserError, toRequest, } from "../../../../__tests__/helpers.ts"; @@ -183,8 +184,231 @@ describe("resolveTeamIdentity precedence", () => { }); }); +describe("resolveTeamIdentity server-side default tier", () => { + test("asks the backend for the server-side default team when nothing local selects one", async () => { + const requests: Request[] = []; + + expect(await resolveTeamIdentity( + { + account: testAccount, + defaultTeam: undefined, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, + createContext({}, async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + }), + )).toEqual({ + name: "alice-team", + id: "team-system-1", + source: "backend_default", + status: "valid", + }); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/me/default-team", + ]); + expect(requests[0]?.headers.get("authorization")).toBe("api-secret-1"); + }); + + // The lookup is opt-in and last: header-only execution paths get the same + // server-side default from the gateway without spending a request, and + // anything above it in the ladder wins without one. + test.each([ + { + case: "the caller does not opt in", + input: { account: testAccount, resolveAgainstBackend: true }, + expected: undefined, + }, + { + case: "the resolution is offline", + input: { + account: testAccount, + resolveAgainstBackend: false, + resolveCurrentName: true, + }, + expected: undefined, + }, + { + case: "no account can authenticate it", + input: { + account: undefined, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, + expected: undefined, + }, + ])("skips the lookup when $case", async ({ input, expected }) => { + let requested = false; + + expect(await resolveTeamIdentity( + { defaultTeam: undefined, ...input }, + createContext({}, async () => { + requested = true; + + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + }), + )).toEqual(expected); + expect(requested).toBe(false); + }); + + // Neither answer may fail the run: the header-less request worked before + // this tier existed and keeps working through the gateway's own default. + test.each([ + { + case: "the backend reports no default team", + fetcher: (async () => new Response("", { status: 404 })) satisfies Fetcher, + }, + { + case: "the lookup fails", + fetcher: (async () => { + throw new Error("connection reset"); + }) satisfies Fetcher, + }, + ])("resolves to no team identity when $case", async ({ fetcher }) => { + expect(await resolveTeamIdentity( + { + account: testAccount, + defaultTeam: undefined, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, + createContext({}, fetcher), + )).toBeUndefined(); + }); +}); + // Both env directions get the same policy: complete the missing dimension, // validate through the backend, record the outcome as a status. +// The saved name is only the name the team had when it was saved; callers +// that need the current one refresh it through the saved id. +describe("resolveTeamIdentity current-name refresh", () => { + const savedDefault = { id: "team-1", name: "old-name" }; + + test("refreshes a saved default through its id", async () => { + const requests: Request[] = []; + + expect(await resolveTeamIdentity( + { + account: testAccount, + defaultTeam: savedDefault, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, + createContext({}, async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + id: "team-1", + name: "acme", + role: "creator", + system_created: false, + })); + }), + )).toEqual({ + name: "acme", + id: "team-1", + source: "account", + status: "valid", + }); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/teams/team-1", + ]); + expect(requests[0]?.headers.get("authorization")).toBe("api-secret-1"); + }); + + // The saved values stay in the identity so a report can still show them; + // the status carries what the backend said instead. + test.each([ + { + case: "the refresh is refused", + fetcher: (async () => new Response("{}", { status: 410 })) satisfies Fetcher, + status: "deleted", + }, + { + case: "the refresh fails", + fetcher: (async () => { + throw new Error("connection reset"); + }) satisfies Fetcher, + status: "request_failed", + }, + ])("keeps the saved values with the reason when $case", async ({ fetcher, status }) => { + expect(await resolveTeamIdentity( + { + account: testAccount, + defaultTeam: savedDefault, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, + createContext({}, fetcher), + )).toEqual({ + name: "old-name", + id: "team-1", + source: "account", + status, + }); + }); + + // A default migrated from the legacy setting has no id to refresh by, so + // the memberships complete it — and answer whether the name is still + // one of the account's teams. + test.each([ + { + case: "completes a name-only default through the memberships", + name: "acme", + expected: { name: "acme", id: "team-1", source: "account", status: "valid" }, + }, + { + case: "reports a name-only default that is no longer among the memberships", + name: "renamed-away", + expected: { name: "renamed-away", id: null, source: "account", status: "not_a_member" }, + }, + ])("$case", async ({ name, expected }) => { + const requests: Request[] = []; + + expect(await resolveTeamIdentity( + { + account: testAccount, + defaultTeam: { id: null, name }, + resolveAgainstBackend: true, + resolveCurrentName: true, + }, + createContext({}, async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify(teamsResponse)); + }), + )).toEqual(expected); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/me/teams", + ]); + }); + + test("reports the saved default as is when the caller does not need the current name", async () => { + let requested = false; + + expect(await resolveTeamIdentity( + { + account: testAccount, + defaultTeam: savedDefault, + resolveAgainstBackend: true, + }, + createContext({}, async () => { + requested = true; + + return new Response("{}"); + }), + )).toEqual({ + name: "old-name", + id: "team-1", + source: "account", + status: null, + }); + expect(requested).toBe(false); + }); +}); + describe("resolveTeamIdentity env validation", () => { test("resolves OO_TEAM_ID to its team name through the singular team route", async () => { const requests: Request[] = []; @@ -509,6 +733,25 @@ describe("requireValidTeamIdentity", () => { expect(error.exitCode).toBe(1); }); + test("rejects a saved default the refresh could not confirm with the account remedy", () => { + const error = expectCliUserError(() => requireValidTeamIdentity( + { + name: "acme", + id: "team-1", + source: "account", + status: "deleted", + }, + createGateContext(), + )); + + expect(error.key).toBe("errors.team.accountDefaultNotAccessible"); + expect(error.exitCode).toBe(1); + expect(error.params).toEqual({ + reason: "this team has been deleted", + team: "acme", + }); + }); + test("rejects an env name that is not accessible", () => { const error = expectCliUserError(() => requireValidTeamIdentity( { @@ -690,6 +933,16 @@ describe("teamSourceForTelemetry", () => { expected: "account", identity: { id: null, name: "acme", source: "account", status: null } as const, }, + { + case: "the backend's default team", + expected: "backend_default", + identity: { + id: "team-system-1", + name: "alice-team", + source: "backend_default", + status: "valid", + } as const, + }, ])("reports $expected for $case", ({ expected, identity }) => { expect(teamSourceForTelemetry(identity)).toBe(expected); }); diff --git a/src/application/commands/team/identity.ts b/src/application/commands/team/identity.ts index 79a0facb..5acd0e36 100644 --- a/src/application/commands/team/identity.ts +++ b/src/application/commands/team/identity.ts @@ -21,13 +21,15 @@ import { z } from "zod"; import { readDefaultTeam } from "../../auth/default-team.ts"; import { readTrimmedEnv } from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; -import { fetchTeamById, fetchTeamByName } from "./shared.ts"; +import { fetchDefaultTeam, fetchTeamById, fetchTeamByName } from "./shared.ts"; // Which mechanism selects the identity. `flag` is a per-run `--team`; `env_id` // and `env_name` name the variable that won; `account` is the default team -// saved on the active account. Recorded as privacy-safe telemetry; it never -// carries the team name or id itself. -export type TeamIdentitySource = "account" | "env_id" | "env_name" | "flag"; +// saved on the active account; `backend_default` is the server-side default +// team the backend reported because nothing local selected one. Recorded as +// privacy-safe telemetry; it never carries the team name or id itself. +export type TeamIdentitySource + = "account" | "backend_default" | "env_id" | "env_name" | "flag"; // `no_credential` is the one status the backend cannot produce: it means the // lookup never ran because no account was available to authenticate it. @@ -37,9 +39,10 @@ export interface TeamIdentity { name: string | null; id: string | null; source: TeamIdentitySource; - // How the backend lookup ended; `null` when no lookup was attempted. - // Only env-selected identities are ever looked up — a flag or a stored - // name is the gateway's to judge, so those stay `null` on every path. + // How the backend lookup ended; `null` when no lookup was attempted. A + // flag is the gateway's to judge and stays `null` on every path; an + // env-selected identity is always looked up; a saved default is looked up + // only for callers that need its current name (`resolveCurrentName`). status: TeamNameStatus | null; // The env variable supplying the override, for user-facing hints; absent // for the flag and account sources. @@ -52,11 +55,12 @@ type ResolveTeamIdentityContext = Pick< >; /** - * Resolves the team identity from the per-run flags, the env override, and - * the account default — one ladder for every team-aware command: - * `--team` > OO_TEAM_ID > OO_TEAM_NAME > the account default > none - * (undefined, the server-side default team). A higher tier fully replaces the - * lower ones, and a trimmed-empty flag or stored name counts as unset. + * Resolves the team identity from the per-run flags, the env override, the + * account default, and the backend's own default — one ladder for every + * team-aware command: + * `--team` > OO_TEAM_ID > OO_TEAM_NAME > the account default > the + * server-side default team > none (undefined). A higher tier fully replaces + * the lower ones, and a trimmed-empty flag or stored name counts as unset. * * With `resolveAgainstBackend: true`, an env-selected identity is completed * and validated through the lookup matching its direction (id-to-name via the @@ -66,16 +70,26 @@ type ResolveTeamIdentityContext = Pick< * requireValidTeamIdentity. `account` may be undefined (reads must work * without a login), which downgrades the lookup to `no_credential`. * + * With `resolveCurrentName: true` as well, the caller needs the team's + * current name — a Workbench deep link, a report of which team is in effect + * — and spends one request to get it. A saved default is refreshed through + * its id, because the stored name is only the name the team had when it was + * saved and goes stale on rename; a name-only default (migrated from the + * legacy setting) is completed through the memberships instead; and with + * nothing saved the backend is asked for the server-side default team it + * applies to a header-less request. Execution paths that only send headers + * leave it off: the gateway resolves by id and applies the same default + * itself, so the lookup would buy them nothing and cost every run a request + * (retried on failure) against a service they do not otherwise depend on. + * The server-default lookup can only add an identity: with no answer (no + * team created, or a failed request) the resolver returns undefined and the + * gateway keeps applying its default. + * * With `resolveAgainstBackend: false` (`--dry-run`, offline reporting), the * resolution is fully offline and `status` stays null. */ export async function resolveTeamIdentity( - input: { - account: Pick | undefined; - defaultTeam: AccountDefaultTeam | undefined; - teamFlag?: string; - resolveAgainstBackend: boolean; - }, + input: ResolveTeamIdentityInput, context: ResolveTeamIdentityContext, ): Promise { const teamFlag = normalizeTeamValue(input.teamFlag); @@ -91,9 +105,10 @@ export async function resolveTeamIdentity( } const defaultTeamName = normalizeTeamValue(input.defaultTeam?.name); + const account = currentNameCredential(input); if (defaultTeamName !== undefined) { - return { + const stored: TeamIdentity = { name: defaultTeamName, // The stored id, when the default was saved by a command that had // the membership listing in hand. A default migrated from the @@ -102,6 +117,87 @@ export async function resolveTeamIdentity( source: "account", status: null, }; + + return account === undefined + ? stored + : refreshAccountTeamIdentity(stored, account, context); + } + + return account === undefined + ? undefined + : resolveBackendDefaultTeamIdentity(account, context); +} + +interface ResolveTeamIdentityInput { + account: Pick | undefined; + defaultTeam: AccountDefaultTeam | undefined; + teamFlag?: string; + resolveAgainstBackend: boolean; + resolveCurrentName?: boolean; +} + +// The credential the current-name lookups run with, or undefined when the +// caller did not ask for the name, the resolution is offline, or no account +// can authenticate a lookup — all three mean "report what is stored". +function currentNameCredential( + input: ResolveTeamIdentityInput, +): Pick | undefined { + return input.resolveAgainstBackend && input.resolveCurrentName === true + ? input.account + : undefined; +} + +// Brings a saved default up to date. The id is the stable key, so the name +// comes back current even after a rename, and a team that is gone or no +// longer accessible surfaces as a status instead of a stale name that the +// gateway or the console would reject. A name-only default has no id to +// refresh by, so it is completed through the memberships, which also answers +// whether that name still belongs to the account. +async function refreshAccountTeamIdentity( + stored: TeamIdentity, + account: Pick, + context: ResolveTeamIdentityContext, +): Promise { + const lookup = stored.id === null + ? await fetchTeamByName(account, stored.name ?? "", context) + : await fetchTeamById(account, stored.id, context); + + if (lookup.status !== "valid") { + return { ...stored, status: lookup.status }; + } + + return { + ...stored, + name: lookup.team.name, + id: lookup.team.id, + status: "valid", + }; +} + +// The bottom tier. Never persisted here — saving a default stays with +// `oo login` and `oo team use`, so a read-only command does not write the +// auth file and an `OO_API_KEY` identity (which has nowhere to persist) still +// resolves. +async function resolveBackendDefaultTeamIdentity( + account: Pick, + context: ResolveTeamIdentityContext, +): Promise { + const lookup = await fetchDefaultTeam(account, context); + + if (lookup.status === "valid") { + return { + name: lookup.team.name, + id: lookup.team.id, + source: "backend_default", + status: "valid", + }; + } + + if (lookup.status !== "none") { + context.logger.warn( + { status: lookup.status }, + "Default team lookup could not complete; proceeding without a team selection.", + ); } return undefined; @@ -148,6 +244,17 @@ export function requireValidTeamIdentity( }); } + // A saved default that the refresh could not confirm: the fix is + // to pick another team or log in again, not to edit a variable. + if (identity.source === "account") { + throw new CliUserError("errors.team.accountDefaultNotAccessible", 1, { + reason: context.translator.t( + teamNameStatusTranslationKeys[identity.status], + ), + team: identity.name ?? identity.id ?? "", + }); + } + throw new CliUserError("errors.team.envIdNotAccessible", 1, { reason: context.translator.t( teamNameStatusTranslationKeys[identity.status], @@ -199,7 +306,8 @@ type ResolveAccountTeamIdentityContext = Pick< * the credential backing any env-team lookup. * * `undefined` means no team header is sent, which lets the gateway apply the - * server-side default team; it is not a private, per-user scope. + * server-side default team; it is not a private, per-user scope. These + * commands only send headers, so they leave the server-default lookup off. * * `resolveAgainstBackend: false` (`--dry-run`) keeps the resolution fully * offline. diff --git a/src/application/commands/team/index.cli.test.ts b/src/application/commands/team/index.cli.test.ts index 462cb0bf..a94287bb 100644 --- a/src/application/commands/team/index.cli.test.ts +++ b/src/application/commands/team/index.cli.test.ts @@ -7,6 +7,7 @@ import { describe, expect, test } from "bun:test"; import { createCliSandbox, + defaultLoginDefaultTeamResponse, expectTelemetryFreeOfTeamIdentity, toRequest, writeAuthFile, @@ -148,31 +149,138 @@ describe("teamCommand CLI", () => { try { await writeAuthFileWithDefaultTeam(sandbox, "acme"); - const jsonResult = await sandbox.run(["team", "current", "--json"]); - const textResult = await sandbox.run(["team", "current"]); + // A name-only default is completed through the memberships. + const fetcher: Fetcher = async () => new Response(JSON.stringify(teamsResponse)); + const jsonResult = await sandbox.run(["team", "current", "--json"], { fetcher }); + const textResult = await sandbox.run(["team", "current"], { fetcher }); expect(jsonResult.exitCode).toBe(0); expect(JSON.parse(jsonResult.stdout)).toEqual({ team: "acme", - teamId: null, + teamId: "team-1", source: "account", - status: null, + status: "valid", }); - expect(textResult.stdout).toContain("acme"); + expect(textResult.stdout).toContain("Default team identity: acme (team-1)"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("shows a renamed default team under its current name via current", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "old-name", { teamId: "team-1" }); + + const requests: Request[] = []; + const fetcher: Fetcher = async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + id: "team-1", + name: "acme", + role: "creator", + system_created: false, + })); + }; + const jsonResult = await sandbox.run(["team", "current", "--json"], { fetcher }); + const textResult = await sandbox.run(["team", "current"], { fetcher }); + + expect(jsonResult.exitCode).toBe(0); + expect(JSON.parse(jsonResult.stdout)).toEqual({ + team: "acme", + teamId: "team-1", + source: "account", + status: "valid", + }); + expect(textResult.stdout).toContain("Default team identity: acme (team-1)"); + expect(textResult.stdout).not.toContain("old-name"); + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/teams/team-1", + "https://relation-control.oomol.com/v1/teams/team-1", + ]); + } + finally { + await sandbox.cleanup(); + } + }); + + test("keeps reporting a saved default team the refresh could not confirm", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "acme", { teamId: "team-1" }); + + const fetcher: Fetcher = async () => new Response("{}", { status: 404 }); + const jsonResult = await sandbox.run(["team", "current", "--json"], { fetcher }); + const textResult = await sandbox.run(["team", "current"], { fetcher }); + + expect(jsonResult.exitCode).toBe(0); + expect(JSON.parse(jsonResult.stdout)).toEqual({ + team: "acme", + teamId: "team-1", + source: "account", + status: "not_found", + }); + expect(textResult.stdout).toContain( + "Default team identity: acme (team-1) — no team exists with this id", + ); } finally { await sandbox.cleanup(); } }); - test("reports the server-side default team via current when no default is configured", async () => { + test("reports the backend's default team via current when no default is configured", async () => { const sandbox = await createCliSandbox(); try { await writeAuthFile(sandbox); - const jsonResult = await sandbox.run(["team", "current", "--json"]); - const textResult = await sandbox.run(["team", "current"]); + const requests: Request[] = []; + const fetcher: Fetcher = async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify(defaultLoginDefaultTeamResponse)); + }; + const jsonResult = await sandbox.run(["team", "current", "--json"], { fetcher }); + const textResult = await sandbox.run(["team", "current"], { fetcher }); + + expect(jsonResult.exitCode).toBe(0); + expect(JSON.parse(jsonResult.stdout)).toEqual({ + team: "alice-team", + teamId: "team-system-1", + source: "backend_default", + status: "valid", + }); + expect(textResult.stdout).toContain( + "team-aware commands use the server-side default team: alice-team (team-system-1)", + ); + // One lookup per run, authenticated with the account key. + expect(requests.map(request => request.url)).toEqual([ + "https://relation-control.oomol.com/v1/me/default-team", + "https://relation-control.oomol.com/v1/me/default-team", + ]); + expect(requests[0]?.headers.get("authorization")).toBe("secret-1"); + // The reported default is never written back. + expect(await readAuthFileContent(sandbox)).not.toContain("\nteam = "); + } + finally { + await sandbox.cleanup(); + } + }); + + test("reports the bare server-side default team via current when the backend has none", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const fetcher: Fetcher = async () => new Response("", { status: 404 }); + const jsonResult = await sandbox.run(["team", "current", "--json"], { fetcher }); + const textResult = await sandbox.run(["team", "current"], { fetcher }); expect(jsonResult.exitCode).toBe(0); expect(JSON.parse(jsonResult.stdout)).toEqual({ @@ -182,6 +290,7 @@ describe("teamCommand CLI", () => { status: null, }); expect(textResult.stdout).toContain("server-side default team"); + expect(textResult.stdout).not.toContain("alice-team"); } finally { await sandbox.cleanup(); @@ -202,17 +311,24 @@ describe("teamCommand CLI", () => { return new Response(JSON.stringify(teamsResponse)); }, }); - const currentResult = await sandbox.run(["team", "current", "--json"]); + const currentResult = await sandbox.run(["team", "current", "--json"], { + fetcher: async () => new Response(JSON.stringify({ + id: "team-2", + name: "beta", + role: "member", + system_created: false, + })), + }); expect(useResult.exitCode).toBe(0); expect(requests).toHaveLength(1); // `oo team use` holds the membership listing, so the stored - // default carries the team id and `current` reports it offline. + // default carries the team id, which `current` refreshes by. expect(JSON.parse(currentResult.stdout)).toEqual({ team: "beta", teamId: "team-2", source: "account", - status: null, + status: "valid", }); } finally { @@ -552,18 +668,22 @@ describe("legacy default team migration", () => { // A command with nothing to do with identity still migrates, // because the migration runs in the bootstrap. const versionResult = await sandbox.run(["--version"]); - const currentResult = await sandbox.run(["team", "current", "--json"]); + const currentResult = await sandbox.run(["team", "current", "--json"], { + fetcher: async () => new Response(JSON.stringify(teamsResponse)), + }); expect(versionResult.exitCode).toBe(0); expect(await readAuthFileContent(sandbox)).toContain("team = \"acme\""); expect(await readSettingsFileContent(sandbox)).not.toContain( "\nteam = ", ); + // The migrated default carries the name alone; `current` completes + // it through the memberships. expect(JSON.parse(currentResult.stdout)).toEqual({ team: "acme", - teamId: null, + teamId: "team-1", source: "account", - status: null, + status: "valid", }); } finally { diff --git a/src/application/commands/team/shared.test.ts b/src/application/commands/team/shared.test.ts index a5e1760e..3330a1d1 100644 --- a/src/application/commands/team/shared.test.ts +++ b/src/application/commands/team/shared.test.ts @@ -5,11 +5,17 @@ import pino from "pino"; import { createFailedToOpenSocketError, + defaultLoginDefaultTeamResponse, expectCliUserError, toRequest, } from "../../../../__tests__/helpers.ts"; import { createTranslator } from "../../../i18n/translator.ts"; -import { fetchTeamById, fetchTeamByName, listMemberTeams } from "./shared.ts"; +import { + fetchDefaultTeam, + fetchTeamById, + fetchTeamByName, + listMemberTeams, +} from "./shared.ts"; const testAccount = { apiKey: "api-secret-1", @@ -285,6 +291,86 @@ describe("fetchTeamById", () => { }); }); +describe("fetchDefaultTeam", () => { + test("requests the default-team route with the account api key and maps the bare team", async () => { + const requests: Request[] = []; + const result = await fetchDefaultTeam( + testAccount, + createRequestContext({ + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + ...defaultLoginDefaultTeamResponse, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + })); + }, + }), + ); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + "https://relation-control.oomol.com/v1/me/default-team", + ); + expect(requests[0]?.headers.get("authorization")).toBe("api-secret-1"); + expect(requests[0]?.headers.get("x-oo-team-name")).toBeNull(); + expect(result).toEqual({ + status: "valid", + team: { id: "team-system-1", name: "alice-team" }, + }); + }); + + test("reports an account that created no team as none", async () => { + const result = await fetchDefaultTeam( + testAccount, + createRequestContext({ + fetcher: async () => new Response( + JSON.stringify({ error: "not found" }), + { status: 404 }, + ), + }), + ); + + expect(result).toEqual({ status: "none" }); + }); + + test("reports any other non-success status as a failed lookup rather than throwing", async () => { + const result = await fetchDefaultTeam( + testAccount, + createRequestContext({ + fetcher: async () => new Response("", { status: 500 }), + }), + ); + + expect(result).toEqual({ status: "request_failed" }); + }); + + test("reports a sandbox-blocked request separately from a plain failure", async () => { + const result = await fetchDefaultTeam( + testAccount, + createRequestContext({ + fetcher: async () => { + throw createFailedToOpenSocketError("network is restricted"); + }, + }), + ); + + expect(result).toEqual({ status: "request_failed_sandbox" }); + }); + + test("treats a malformed success body as a failed lookup rather than throwing", async () => { + const result = await fetchDefaultTeam( + testAccount, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ id: "team-system-1" })), + }), + ); + + expect(result).toEqual({ status: "request_failed" }); + }); +}); + describe("fetchTeamByName", () => { test("matches the name against the membership listing", async () => { const requests: Request[] = []; diff --git a/src/application/commands/team/shared.ts b/src/application/commands/team/shared.ts index d85459c2..377ed966 100644 --- a/src/application/commands/team/shared.ts +++ b/src/application/commands/team/shared.ts @@ -66,8 +66,11 @@ export type TeamLookupStatus | "not_a_member" | "not_found" | "deleted" - | "request_failed" - | "request_failed_sandbox"; + | TeamLookupFailureStatus; + +// The two ways a lookup ends without the backend having answered: the shared +// half of every lookup result type. +type TeamLookupFailureStatus = "request_failed" | "request_failed_sandbox"; export type TeamLookupResult = | { status: "valid"; team: TeamView } @@ -150,18 +153,65 @@ export async function fetchTeamByName( ); } -// Shared interpretation skeleton of the two team lookups, on top of the probe +// `GET /v1/me/default-team` answers with a bare team object (no `role`) and +// `404` when the account has created no team. Only the identity is read: the +// callers persist or act for the team by id and name. +const defaultTeamResponseSchema = teamResponseItemSchema.pick({ id: true, name: true }); + +// How the default-team lookup ended. `none` is a definite backend answer — +// the account has created no team, so a request without a team selection runs +// as personal — and is the one outcome callers act on differently from a +// lookup that could not run. +export type DefaultTeamLookupResult + = | { status: "valid"; team: Pick } + | { status: "none" | TeamLookupFailureStatus }; + +// Resolves the team the gateway applies to a request that carries no team +// selection. Backed by +// `GET https://relation-control.{endpoint}/v1/me/default-team`, which +// evaluates the same rule the gateway consults, so the CLI never has to +// reconstruct that rule from the membership listing (where a naive "first +// `system_created` team" pick can land on a team someone else created). +// +// Same never-throw contract as the other lookups: every failure is a status. +export async function fetchDefaultTeam( + account: Pick, + context: Pick, +): Promise { + return lookupTeam( + account, + "default", + "/v1/me/default-team", + (bodyText, status) => { + if (status === 404) { + return { status: "none" }; + } + + if (status !== 200) { + return { status: "request_failed" }; + } + + return { + status: "valid", + team: defaultTeamResponseSchema.parse(parseLookupBody(bodyText)), + }; + }, + context, + ); +} + +// Shared interpretation skeleton of the team lookups, on top of the probe // seam. Any failure the interpreter does not classify — non-JSON bodies, // schema mismatches, an unreadable body — comes back as a request-failed // status instead of an error, which is what keeps the lookups' never-throw // contract honest. -async function lookupTeam( +async function lookupTeam( account: Pick, - direction: "id" | "name", + direction: "default" | "id" | "name", path: string, - interpret: (bodyText: string | undefined, status: number) => TeamLookupResult, + interpret: (bodyText: string | undefined, status: number) => T, context: Pick, -): Promise { +): Promise { const probe = await probeOo({ authorization: account.apiKey, context, diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index 5a8f7319..e4e1b0e8 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -106,7 +106,7 @@ const commandTelemetryDecisions = { "team_source", "team_status", ], - reason: "Records bounded saved-account count, which credential source is in effect, which mechanism selects the default team (enum only), and how the team name lookup ended (valid/not_a_member/not_found/deleted/request_failed/request_failed_sandbox/no_credential/none), without account or team identity.", + reason: "Records bounded saved-account count, which credential source is in effect, which mechanism selects the default team (env_id/env_name/account/backend_default/none, enum only), and how the team name lookup ended (valid/not_a_member/not_found/deleted/request_failed/request_failed_sandbox/no_credential/none), without account or team identity.", }, "auth.switch": { kind: "properties", @@ -319,7 +319,7 @@ const commandTelemetryDecisions = { "team.current": { kind: "properties", properties: ["has_configured_team", "team_source", "team_status"], - reason: "Records whether the account has a saved default team, which mechanism selects the effective team (env_id/env_name/account/none), and how the team name lookup ended (valid/not_a_member/not_found/deleted/request_failed/request_failed_sandbox/no_credential/none), without the team name or id.", + reason: "Records whether the account has a saved default team, which mechanism selects the effective team (env_id/env_name/account/backend_default/none), and how the team name lookup ended (valid/not_a_member/not_found/deleted/request_failed/request_failed_sandbox/no_credential/none), without the team name or id.", }, "team.use": { kind: "properties", diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 7ad6fd4a..0da9b9dc 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -19,6 +19,8 @@ export const enMessages = { "You belong to {count} teams: {teams}. Switch with `oo team use `.", "auth.login.teamUnresolved": "Could not load your teams, so the default team identity is unchanged. Run `oo team list` to inspect them later.", + "auth.login.teamDefaultUnresolved": + "Could not determine your default team, so the default team identity is unchanged. Set one with `oo team use `.", "auth.logout.envOverrideNoop": "Nothing was logged out: the active credential comes from OO_API_KEY, not from a saved account.", "auth.logout.success": "Logged out the current account.", @@ -46,6 +48,7 @@ export const enMessages = { "auth.status.team": "Default team", "auth.status.teamEnvOverride": "{team} (via {envVar})", "auth.status.teamServerDefault": "server default (no saved team)", + "auth.status.teamBackendDefault": "{team} (server default, no saved team)", "auth.switch.envOverrideNoop": "Nothing was switched: the active credential comes from OO_API_KEY, not from a saved account.", "auth.switch.success": "Switched active account for {endpoint} to {name}", @@ -161,6 +164,8 @@ export const enMessages = { "OO_OPEN_FLOW_URL and OO_OPEN_FLOW_TOKEN must be set together.", "errors.flow.serverOriginInvalid": "OO_OPEN_FLOW_URL must be an HTTP(S) origin without credentials, a path, query, or fragment.", + "errors.flow.teamRequired": + "No team is available to open the Open Flow Workbench for this account. Run `oo login` again, or select one with `oo team use `.", "commands.info.description": "Print CLI environment details, persisted store paths, and detected skill agents.", "commands.info.summary": "Show CLI environment info", @@ -400,6 +405,8 @@ export const enMessages = { "The team id \"{teamId}\" from OO_TEAM_ID cannot be used: {reason}. Run `oo team list` to see the teams you can use.", "errors.team.envNameNotAccessible": "The active account cannot access the team \"{team}\" from OO_TEAM_NAME. Run `oo team list` to see the teams you can use.", + "errors.team.accountDefaultNotAccessible": + "The saved default team \"{team}\" cannot be used: {reason}. Run `oo team use ` to pick another team, or `oo login` to refresh the default.", "errors.team.invalidResponse": "The team list response body is unsupported.", "errors.team.nameEmpty": "The team name must not be empty.", @@ -1292,6 +1299,8 @@ export const enMessages = { "team.current.text.accountDefault": "Default team identity: {team}", "team.current.text.serverDefault": "No default team saved; team-aware commands use the server-side default team.", + "team.current.text.backendDefault": + "No default team saved; team-aware commands use the server-side default team: {team}", "team.current.text.envId": "Team identity comes from the OO_TEAM_ID environment variable: {team}", "team.current.text.envName": @@ -1411,6 +1420,8 @@ export const zhMessages = { "你共有 {count} 个团队:{teams}。可使用 `oo team use ` 切换。", "auth.login.teamUnresolved": "无法获取团队列表,默认团队身份保持不变。可稍后运行 `oo team list` 查看。", + "auth.login.teamDefaultUnresolved": + "无法确定默认团队,默认团队身份保持不变。可使用 `oo team use ` 设置。", "auth.status.accountActive": "激活", "auth.status.accountId": "账号 ID", "auth.status.accountsLabel": "账号列表", @@ -1433,6 +1444,7 @@ export const zhMessages = { "auth.status.team": "默认团队", "auth.status.teamEnvOverride": "{team}(来自 {envVar})", "auth.status.teamServerDefault": "服务端默认团队(未保存默认团队)", + "auth.status.teamBackendDefault": "{team} (服务端默认团队,未保存默认团队)", "auth.switch.envOverrideNoop": "没有切换任何账号:当前生效的凭证来自 OO_API_KEY,而不是保存的账号。", "auth.switch.success": "已将 {endpoint} 的当前激活账号切换为 {name}", @@ -1539,6 +1551,8 @@ export const zhMessages = { "必须同时设置 OO_OPEN_FLOW_URL 和 OO_OPEN_FLOW_TOKEN。", "errors.flow.serverOriginInvalid": "OO_OPEN_FLOW_URL 必须是不含凭据、路径、查询参数或片段的 HTTP(S) origin。", + "errors.flow.teamRequired": + "当前账号没有可用于打开 Open Flow Workbench 的团队。请重新运行 `oo login`,或使用 `oo team use ` 选择一个团队。", "commands.info.description": "打印 CLI 运行环境信息、本地存储路径以及检测到的 skill 代理。", "commands.info.summary": "显示 CLI 环境信息", @@ -1751,6 +1765,8 @@ export const zhMessages = { "无法使用 OO_TEAM_ID 指定的团队 id “{teamId}”:{reason}。运行 `oo team list` 查看可用的团队。", "errors.team.envNameNotAccessible": "当前活动账号无法访问 OO_TEAM_NAME 指定的团队 “{team}”。运行 `oo team list` 查看可用的团队。", + "errors.team.accountDefaultNotAccessible": + "无法使用已保存的默认团队 “{team}”:{reason}。运行 `oo team use ` 选择其他团队,或重新运行 `oo login` 刷新默认团队。", "errors.team.invalidResponse": "团队列表返回了不受支持的响应内容。", "errors.team.nameEmpty": "团队名称不能为空。", @@ -2635,6 +2651,8 @@ export const zhMessages = { "team.current.text.accountDefault": "默认团队身份:{team}", "team.current.text.serverDefault": "未保存默认团队;团队相关命令使用服务端默认团队。", + "team.current.text.backendDefault": + "未保存默认团队;团队相关命令使用服务端默认团队:{team}", "team.current.text.envId": "团队身份来自 OO_TEAM_ID 环境变量:{team}", "team.current.text.envName": From 2b9a578114dafe291ed44a0c39bf73937887e4e5 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sun, 30 Aug 2026 02:35:09 -0400 Subject: [PATCH 2/3] docs(commands): describe the saved-team refresh consistently The `oo auth status` and `oo team current` sections still said the saved default team stays offline and that only an env override triggers a lookup, and the `auth status` JSON `source` list omitted `backend_default`. They now describe the refresh through the saved id, the backend default, and the request count the same way the team identity section does. Signed-off-by: Kevin Cui --- docs/commands.md | 39 +++++++++++++++++++++++---------------- docs/commands.zh-CN.md | 29 +++++++++++++++++------------ 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index a68b7fd6..7090b2ff 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -380,11 +380,14 @@ Show every saved auth account and validate the API key of the active one. the active account is not a member of the team, no team exists with that id, the team has been deleted, or the lookup could not be completed. A failed lookup never changes the exit code and never affects the reported `API key - status`. The account's default team already names its team and is never - looked up. + status`. The account's saved default team is looked up the same way, through + its saved id (a name-only default through the memberships), so the row shows + the team's current name and appends the reason when the saved default could + not be confirmed; with no default saved, the server-side default team the + backend reports is shown instead. - `oo auth status` therefore sends at most two requests: the API key check, plus - the team lookup when `OO_TEAM_ID` / `OO_TEAM_NAME` is in effect. The two are - independent and are sent concurrently. + one team lookup (the env-selected team, the saved default, or the server-side + default). The two are independent and are sent concurrently. - API key values are never written to stdout in text or JSON output. - When a self-hosted connector is configured (`oo connector login` or `OO_CONNECTOR_URL`), text output adds a self-hosted connector block showing @@ -501,13 +504,16 @@ Show every saved auth account and validate the API key of the active one. output and API key validation, but does not rewrite this field. - `team` is present only on the `logged-in` shape and only when a default team identity is in effect. `source` is `account` (the saved default), - `env_id` (`OO_TEAM_ID`), or `env_name` (`OO_TEAM_NAME`). An env-selected - identity spends one request to complete and validate its missing half, so - on success it carries both `name` and `id`; when the lookup does not - succeed, the env-supplied half is kept and `status` says why. The `account` - source stays offline; its `id` is `null` until a command that already holds - the membership listing (`oo team list`, `oo team use`, `oo auth login`) - fills it in. + `env_id` (`OO_TEAM_ID`), `env_name` (`OO_TEAM_NAME`), or `backend_default` + (the server-side default team the backend reported because none is saved). + An env-selected identity spends one request to complete and validate its + missing half, so on success it carries both `name` and `id`; when the + lookup does not succeed, the env-supplied half is kept and `status` says + why. The `account` source spends one request as well, refreshing the saved + default through its id (or completing a name-only default through the + memberships), so `name` is the team's current name and `id` is filled in on + success; when that lookup does not succeed, the saved values are kept and + `status` says why. - `missingAccountId` appears only when the auth file records an active id that is no longer present in `accounts[]`. - `connector` is present only when a self-hosted connector is configured @@ -643,12 +649,13 @@ environment override when set, otherwise the active account's default team. When neither is set, the commands send no team selection and the server applies its own default team. -- Sends one request only when `OO_TEAM_ID` or `OO_TEAM_NAME` supplies the - identity, to complete and validate the missing half: an id resolves to its - team name, a name to its id through the account's team memberships — the +- Sends one request to report the team by its current name: an env-selected + identity completes and validates its missing half (an id resolves to its + team name, a name to its id through the account's team memberships, the same check connector commands apply, so what this command reports is what a - run would use. The account's default already names its team and stays - offline. + run would use), a saved default is refreshed through its saved id (a + name-only default through the memberships), and with nothing saved the + backend reports the server-side default team it applies. - Works without an OOMOL account. When no account is configured the lookup is skipped rather than failing, and the env-supplied value is reported on its own. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index a32edaa5..b4032574 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -304,9 +304,11 @@ oo flow `<名称>()`。查询未成功时仍会显示 env 提供的值,并附上原因:当前账号 不是该团队的成员、不存在该 id 对应的团队、该团队已被删除、或无法完成查询。 查询失败既不会改变退出码,也不会影响所报告的 `API key status`。 - 账号保存的默认团队本身就带名称,不会发起查询。 -- 因此 `oo auth status` 最多发送 2 次请求:API key 校验,以及 `OO_TEAM_ID` / - `OO_TEAM_NAME` 生效时的团队查询。两者相互独立,并发发出。 + 账号保存的默认团队也按同样方式查询:通过保存的 id (只有名称的默认团队则通过 + 成员关系) 刷新,因此该行显示的是团队当前名称,无法确认时附上原因;未保存默认 + 团队时则显示后端报告的服务端默认团队。 +- 因此 `oo auth status` 最多发送 2 次请求:API key 校验,以及 1 次团队查询 + (env 选定的团队、保存的默认团队或服务端默认团队)。两者相互独立,并发发出。 - 文本和 JSON 输出都永远不会包含 API key 实际内容。 - 当配置了自部署 Connector(`oo connector login` 或 `OO_CONNECTOR_URL`)时, 文本输出会额外显示一个自部署 Connector 区块,包含服务地址、是否已配置令牌 @@ -413,12 +415,14 @@ oo flow `OO_ENDPOINT`(不设 `OO_API_KEY`)会重定向文本输出与 API key 校验所用的 endpoint,但不会改写该字段。 - `team` 仅在 `logged-in` 形态且存在默认团队身份时出现。`source` 为 - `account`(账号保存的默认值)、`env_id`(`OO_TEAM_ID`)或 - `env_name`(`OO_TEAM_NAME`)。env 选定的身份会发送 1 次请求补全并校验 + `account`(账号保存的默认值)、`env_id`(`OO_TEAM_ID`)、 + `env_name`(`OO_TEAM_NAME`)或 `backend_default` (未保存默认团队时后端 + 报告的服务端默认团队)。env 选定的身份会发送 1 次请求补全并校验 缺失的那一半,因此成功时同时携带 `name` 与 `id`;查询未成功时保留 env - 提供的那一半,并由 `status` 说明原因。`account` 来源保持离线,其 `id` 在 - 某个已持有成员关系列表的命令(`oo team list`、`oo team use`、 - `oo auth login`)补齐之前为 `null`。 + 提供的那一半,并由 `status` 说明原因。`account` 来源同样发送 1 次请求, + 按保存的 id 刷新 (只有名称的默认团队则通过成员关系补全),因此 `name` 是 + 团队当前名称,成功时 `id` 也会补齐;查询未成功时保留保存的值,并由 + `status` 说明原因。 - `missingAccountId` 仅在 auth file 记录的 active id 已不存在于 `accounts[]` 时出现。 - `connector` 仅在配置了自部署 Connector 时出现,报告已配置的自部署 @@ -527,10 +531,11 @@ oo flow 否则为当前账号保存的默认团队。两者都未设置时,命令不发送团队选择,由服务端 套用它自己的默认团队。 -- 仅当身份来自 `OO_TEAM_ID` 或 `OO_TEAM_NAME` 时才发送 1 次请求,补全并校验 - 缺失的那一半:id 解析出团队名称,名称通过账号的团队成员关系解析出 id——这 - 与 connector 命令执行前的检查相同,因此该命令报告的身份就是实际运行会使用 - 的身份。账号保存的默认团队本身就带名称,保持离线。 +- 发送 1 次请求,以团队当前名称报告身份:env 选定的身份补全并校验缺失的那一半 + (id 解析出团队名称,名称通过账号的团队成员关系解析出 id,这与 connector 命令 + 执行前的检查相同,因此该命令报告的身份就是实际运行会使用的身份),保存的默认 + 团队按保存的 id 刷新 (只有名称的默认团队则通过成员关系补全),未保存时由后端 + 报告它套用的服务端默认团队。 - 无 OOMOL 账号时同样可用:此时跳过查询而不是让命令失败,只单独展示 env 提供的值。 - 选项:`--format=json` 与 `--json` 输出 JSON 对象。 From 43a0dc781fc7200a2c3f3cc9a0bfb8feb21860c4 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Sun, 30 Aug 2026 02:44:11 -0400 Subject: [PATCH 3/3] docs(commands): keep the team lookup wording to the CLI contract The `oo auth status` and `oo team current` sections described how a saved default is refreshed (by id, or through the memberships) and reused the connector precheck wording, which is implementation detail the command docs should not carry. They now state only what is observable: the team is reported under its current name, the lookup happens once and only when an account is available to make it, and `status` says why when it could not be confirmed. Signed-off-by: Kevin Cui --- docs/commands.md | 39 ++++++++++++++++++--------------------- docs/commands.zh-CN.md | 29 +++++++++++++---------------- 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 7090b2ff..ba31086c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -380,14 +380,14 @@ Show every saved auth account and validate the API key of the active one. the active account is not a member of the team, no team exists with that id, the team has been deleted, or the lookup could not be completed. A failed lookup never changes the exit code and never affects the reported `API key - status`. The account's saved default team is looked up the same way, through - its saved id (a name-only default through the memberships), so the row shows - the team's current name and appends the reason when the saved default could - not be confirmed; with no default saved, the server-side default team the - backend reports is shown instead. + status`. The account's saved default team is looked up the same way, so the + row shows the team's current name and appends the reason when the saved + default could not be confirmed; with no default saved, the server-side + default team the backend reports is shown instead. - `oo auth status` therefore sends at most two requests: the API key check, plus one team lookup (the env-selected team, the saved default, or the server-side - default). The two are independent and are sent concurrently. + default) when an account is available to make it. The two are independent + and are sent concurrently. - API key values are never written to stdout in text or JSON output. - When a self-hosted connector is configured (`oo connector login` or `OO_CONNECTOR_URL`), text output adds a self-hosted connector block showing @@ -506,14 +506,12 @@ Show every saved auth account and validate the API key of the active one. team identity is in effect. `source` is `account` (the saved default), `env_id` (`OO_TEAM_ID`), `env_name` (`OO_TEAM_NAME`), or `backend_default` (the server-side default team the backend reported because none is saved). - An env-selected identity spends one request to complete and validate its - missing half, so on success it carries both `name` and `id`; when the - lookup does not succeed, the env-supplied half is kept and `status` says - why. The `account` source spends one request as well, refreshing the saved - default through its id (or completing a name-only default through the - memberships), so `name` is the team's current name and `id` is filled in on - success; when that lookup does not succeed, the saved values are kept and - `status` says why. + An env-selected identity is looked up to complete its missing half, so on + success it carries both `name` and `id`; when the lookup does not succeed, + the env-supplied half is kept and `status` says why. The `account` source + is looked up as well, so `name` is the team's current name and `id` is + filled in on success; when that lookup does not succeed, the saved values + are kept and `status` says why. - `missingAccountId` appears only when the auth file records an active id that is no longer present in `accounts[]`. - `connector` is present only when a self-hosted connector is configured @@ -649,13 +647,12 @@ environment override when set, otherwise the active account's default team. When neither is set, the commands send no team selection and the server applies its own default team. -- Sends one request to report the team by its current name: an env-selected - identity completes and validates its missing half (an id resolves to its - team name, a name to its id through the account's team memberships, the - same check connector commands apply, so what this command reports is what a - run would use), a saved default is refreshed through its saved id (a - name-only default through the memberships), and with nothing saved the - backend reports the server-side default team it applies. +- Sends one request, when an OOMOL account is available to make it, to report + the team by its current name: an env-selected identity completes its missing + half (the name for an id, the id for a name), a saved default is refreshed + to its current name, and with nothing saved the backend reports the + server-side default team it applies. What this command reports is what a + run would use. - Works without an OOMOL account. When no account is configured the lookup is skipped rather than failing, and the env-supplied value is reported on its own. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index b4032574..06bcc2cb 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -304,11 +304,11 @@ oo flow `<名称>()`。查询未成功时仍会显示 env 提供的值,并附上原因:当前账号 不是该团队的成员、不存在该 id 对应的团队、该团队已被删除、或无法完成查询。 查询失败既不会改变退出码,也不会影响所报告的 `API key status`。 - 账号保存的默认团队也按同样方式查询:通过保存的 id (只有名称的默认团队则通过 - 成员关系) 刷新,因此该行显示的是团队当前名称,无法确认时附上原因;未保存默认 - 团队时则显示后端报告的服务端默认团队。 -- 因此 `oo auth status` 最多发送 2 次请求:API key 校验,以及 1 次团队查询 - (env 选定的团队、保存的默认团队或服务端默认团队)。两者相互独立,并发发出。 + 账号保存的默认团队同样会查询,因此该行显示的是团队当前名称,无法确认时附上 + 原因;未保存默认团队时则显示后端报告的服务端默认团队。 +- 因此 `oo auth status` 最多发送 2 次请求:API key 校验,以及有账号可用于查询 + 时的 1 次团队查询 (env 选定的团队、保存的默认团队或服务端默认团队)。两者相互 + 独立,并发发出。 - 文本和 JSON 输出都永远不会包含 API key 实际内容。 - 当配置了自部署 Connector(`oo connector login` 或 `OO_CONNECTOR_URL`)时, 文本输出会额外显示一个自部署 Connector 区块,包含服务地址、是否已配置令牌 @@ -417,12 +417,10 @@ oo flow - `team` 仅在 `logged-in` 形态且存在默认团队身份时出现。`source` 为 `account`(账号保存的默认值)、`env_id`(`OO_TEAM_ID`)、 `env_name`(`OO_TEAM_NAME`)或 `backend_default` (未保存默认团队时后端 - 报告的服务端默认团队)。env 选定的身份会发送 1 次请求补全并校验 - 缺失的那一半,因此成功时同时携带 `name` 与 `id`;查询未成功时保留 env - 提供的那一半,并由 `status` 说明原因。`account` 来源同样发送 1 次请求, - 按保存的 id 刷新 (只有名称的默认团队则通过成员关系补全),因此 `name` 是 - 团队当前名称,成功时 `id` 也会补齐;查询未成功时保留保存的值,并由 - `status` 说明原因。 + 报告的服务端默认团队)。env 选定的身份会被查询补全缺失的那一半,因此成功时 + 同时携带 `name` 与 `id`;查询未成功时保留 env 提供的那一半,并由 `status` + 说明原因。`account` 来源同样会被查询,因此 `name` 是团队当前名称,成功时 + `id` 也会补齐;查询未成功时保留保存的值,并由 `status` 说明原因。 - `missingAccountId` 仅在 auth file 记录的 active id 已不存在于 `accounts[]` 时出现。 - `connector` 仅在配置了自部署 Connector 时出现,报告已配置的自部署 @@ -531,11 +529,10 @@ oo flow 否则为当前账号保存的默认团队。两者都未设置时,命令不发送团队选择,由服务端 套用它自己的默认团队。 -- 发送 1 次请求,以团队当前名称报告身份:env 选定的身份补全并校验缺失的那一半 - (id 解析出团队名称,名称通过账号的团队成员关系解析出 id,这与 connector 命令 - 执行前的检查相同,因此该命令报告的身份就是实际运行会使用的身份),保存的默认 - 团队按保存的 id 刷新 (只有名称的默认团队则通过成员关系补全),未保存时由后端 - 报告它套用的服务端默认团队。 +- 有 OOMOL 账号可用于查询时发送 1 次请求,以团队当前名称报告身份:env 选定的 + 身份补全缺失的那一半 (id 对应的名称,或名称对应的 id),保存的默认团队刷新为 + 当前名称,未保存时由后端报告它套用的服务端默认团队。该命令报告的身份就是 + 实际运行会使用的身份。 - 无 OOMOL 账号时同样可用:此时跳过查询而不是让命令失败,只单独展示 env 提供的值。 - 选项:`--format=json` 与 `--json` 输出 JSON 对象。