diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index f907b6c..538f24a 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -125,6 +125,8 @@ export interface AuthAccountFixture { readonly name: string; readonly apiKey: string; readonly endpoint: string; + readonly team?: string; + readonly teamId?: string; } export interface PrintedAuthLoginOptions { @@ -449,6 +451,10 @@ export async function writeAuthFile( `name = "${account.name}"`, `api_key = "${account.apiKey}"`, `endpoint = "${account.endpoint}"`, + ...(account.team === undefined ? [] : [`team = "${account.team}"`]), + ...(account.teamId === undefined + ? [] + : [`team_id = "${account.teamId}"`]), "", ]), ].join("\n"); @@ -458,6 +464,29 @@ export async function writeAuthFile( return filePath; } +// Seeds a saved account whose default team is already chosen — the state +// every team-aware command reads. Written straight into auth.toml because the +// commands that would otherwise set it (`oo team use`, `oo auth login`) need a +// membership request of their own. +export async function writeAuthFileWithDefaultTeam( + sandbox: CliSandbox, + team: string, + options: { teamId?: string } = {}, +): Promise { + return await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "secret-1", + endpoint: defaultAuthEndpoint, + team, + ...(options.teamId === undefined ? {} : { teamId: options.teamId }), + }, + ], + }); +} + export function createConnectorTargetFixture( overrides: Partial = {}, ): OomolConnectorTarget { diff --git a/contrib/skills/shared/oo/references/connector-execution.md b/contrib/skills/shared/oo/references/connector-execution.md index a01cb0a..dd8ab70 100644 --- a/contrib/skills/shared/oo/references/connector-execution.md +++ b/contrib/skills/shared/oo/references/connector-execution.md @@ -170,9 +170,9 @@ the user said: - If the user does not mention any team, add nothing extra. The run then uses the team selected by the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables - when set, then the `identity.team` config default when one is set, and their - personal identity otherwise — omitting the flags does not force a personal - run. Check `oo team current` when you need to know which one applies. + when set, then the default team saved on their account when one is set, and + their personal identity otherwise — omitting the flags does not force a + personal run. Check `oo team current` when you need to know which one applies. - If the user asks to run as a specific team (for example "run this as Acme" or "use my Acme team"), add `--team ""`, using the team name the user gave: diff --git a/docs/commands.md b/docs/commands.md index c7b0436..9cc01b4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -28,10 +28,13 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). - `OO_API_KEY`: Run execution commands with this API key without an interactive login. When set, the CLI builds an in-memory account and does not read, require, or write `auth.toml`, and it takes precedence over any saved account. - Because no saved account can be in effect while it is set, `oo auth logout` - and `oo auth switch` become no-ops that leave `auth.toml` untouched, and - `oo auth login` still saves the account but reports that this variable - outranks it. `oo auth status` reports the identity this variable provides. + Because no saved account can be in effect while it is set, `oo auth logout`, + `oo auth switch`, `oo team use`, and `oo team clear` become no-ops that leave + `auth.toml` untouched, and `oo auth login` still saves the account but reports + that this variable outranks it. `oo auth status` reports the identity this + variable provides. No saved default team applies either: the key may belong to + a different account, so commands run under the personal identity unless + `OO_TEAM_ID` or `OO_TEAM_NAME` selects a team. - `OO_ENDPOINT`: Base endpoint domain (for example `oomol.com` or `oomol.dev`) used to derive every service URL for execution commands. It pairs with `OO_API_KEY`, overrides the endpoint of a saved account (including the @@ -52,7 +55,7 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). - `OO_TEAM_ID`: Run connector commands (`oo connector run`, `oo connector proxy`, `oo connector apps`, `oo connector search` / `oo search`) under the team with this id. It takes precedence - over `OO_TEAM_NAME` and the `identity.team` config default; the per-run + over `OO_TEAM_NAME` and the account's default team; the per-run `--team` and `--personal` flags still outrank it. Before execution the CLI validates the id and resolves its team name (one extra request per invocation), so requests carry both the name and the id; an id the account @@ -69,8 +72,8 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). request and skips the lookup entirely, so it stays offline. Ignored when `OO_TEAM_ID` is set or the connector target is self-hosted. - Connector commands resolve their team identity with this precedence: - `--personal` / `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > the `identity.team` - config default > your personal identity. + `--personal` / `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > the active account's + default team > your personal identity. - `OO_SKILLS_SYNC_DISABLED`: A truthy value disables the startup managed-skill synchronization and legacy-cleanup side effects, so the CLI writes no skill files into agent home directories such as `~/.agents` or `~/.claude`. @@ -129,13 +132,14 @@ with an existing API key, then save the authenticated account. validates the key against the account profile and saves the account without a device-login URL or polling. Exits with an error if the key is invalid or expired. `--api-key` and `--session-token` cannot be combined. - - `--team `: Set the default team identity (the `identity.team` config - key) to the named team after login. The name must be one of the account's + - `--team `: Set the account's default team identity to the named team + after login. The name must be one of the account's team memberships; otherwise the command exits `1` (the account itself is still saved). Works with all three login methods. - Default team: after a successful login the CLI fetches the account's team - memberships and persists a default team identity. Without `--team`, a - configured `identity.team` that is still one of the memberships is kept; + 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, @@ -192,7 +196,9 @@ Show every saved auth account and validate the API key of the active one. - The active-identity block also shows a `Default team` line, resolved the same way `oo team current` resolves it: the `OO_TEAM_ID` / `OO_TEAM_NAME` env override when set (annotated with the variable that supplies it), otherwise - the `identity.team` config default, otherwise `personal (no default team)`. + the active account's default team, otherwise `personal (no default team)`. + With `OO_API_KEY` set the line always reads `personal (no default team)` + unless an `OO_TEAM_*` variable selects one. - When `OO_TEAM_ID` or `OO_TEAM_NAME` supplies the identity, the missing half is looked up — the id resolves to its name, the name to its id through the account's team memberships — and the line shows ` ()`. If the @@ -200,8 +206,8 @@ 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 `identity.team` config default already names its team and is - never looked up. + status`. The account's default team already names its team and is never + looked up. - `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. @@ -252,11 +258,11 @@ 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 `config`), and `status` reports the team lookup: + or `account`), and `status` reports the team lookup: ```json { - "team": { "name": "acme", "id": null, "source": "config", "status": null } + "team": { "name": "acme", "id": null, "source": "account", "status": null } } ``` @@ -271,7 +277,7 @@ Show every saved auth account and validate the API key of the active one. } ``` - `status` is `null` whenever no lookup was attempted (the `config` source). + `status` is `null` whenever no lookup was attempted (the `account` source). 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` @@ -315,12 +321,14 @@ Show every saved auth account and validate the API key of the active one. `OO_ENDPOINT` (without `OO_API_KEY`) redirects the endpoint used for text 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 `config` (the `identity.team` - 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 `config` source stays offline, so its `id` is always `null`. + 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. - `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 @@ -369,20 +377,27 @@ Alias for `oo auth logout`. ## Teams Team identity lets connector commands (`oo connector run`, `oo connector proxy`, -`oo connector apps`) act as a team instead of your personal account, selected -per run with `--team `, per environment with `OO_TEAM_ID` / -`OO_TEAM_NAME`, or as a default with the `identity.team` config key -(precedence in that order). These commands help discover which teams your -account can use and manage that default. +`oo connector apps`) act as a team instead of your personal account. One ladder +selects it: the per-run `--personal` (force your personal identity) or `--team +` first, then the `OO_TEAM_ID` / `OO_TEAM_NAME` environment overrides, +then the default saved on the active account. These commands help discover +which teams your account can use and manage that default. + +The default team belongs to the saved account, so switching accounts with +`oo auth switch` switches the default with it, and `oo auth logout` removes it +along with the account. An installation that predates account-scoped defaults +carried a single global `identity.team` setting; the CLI moves that value onto +the active account on the next run and removes the setting. Under `OO_API_KEY` +no saved default applies at all — use `OO_TEAM_ID` / `OO_TEAM_NAME` there. `oo team list` and `oo team use` query OOMOL for your team memberships, so they require an OOMOL account and are unavailable when only a self-hosted connector is configured. `oo team current` and `oo team clear` work regardless: they read -and write local settings, and `oo team current` only enriches its output with a +and write local state, and `oo team current` only enriches its output with a team name when an account is available to look one up. `oo auth login` (and its `oo login` alias) persists the default automatically: -it keeps a still-valid configured `identity.team`, otherwise adopts the +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. @@ -396,41 +411,45 @@ read-only. `current`. `role` is `creator` or `member`. `current` is `true` for the team connector commands use by default: the team selected by `OO_TEAM_ID` (matched by id) or `OO_TEAM_NAME` (matched by name) when set, otherwise the - team matching the `identity.team` default. + team matching the account's default. - Output: pass the `name` value to `--team ` (or `oo team use `), and the `id` value to `OO_TEAM_ID`. - Output: text output prints one column-aligned row per team and marks the current default. When the account has no teams, it reports that connector commands run under your personal identity. +- Behavior: when the account's default team was stored without its id, this + command fills the id in from the listing it already fetched. No extra + request is sent, and a failure to write is ignored. ### `oo team current` Show the team identity used by connector commands when no `--team` / `--personal` flag is given: the `OO_TEAM_ID` / `OO_TEAM_NAME` environment -override when set, otherwise the `identity.team` config default. +override when set, otherwise the active account's 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 same check connector commands apply, so what this command reports is what a - run would use. A config default already names its team and stays offline. + run would use. The account's default already names its team and stays + offline. - 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. - Options: `--format=json` and `--json` print a JSON object. - Output: JSON is `{ "team": , "teamId": , "source": - <"env_id"|"env_name"|"config"|null>, "status": }`. `source` says - which mechanism selects the team, and is `null` when connector commands run - under your personal identity. `status` reports the team lookup: `null` - whenever none was attempted (the config 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"|null>, "status": }`. `source` + says which mechanism selects the team, and is `null` when connector commands + run under your personal identity. `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`. - 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`. - Output: text output names the environment variable when one is set, and - notes that a configured `identity.team` default is not in use while the - override is active. + notes that the account's default team is not in use while the override is + active. ### `oo team use ` @@ -440,21 +459,25 @@ access it. - Arguments: `` is the team name, as shown by `oo team list`. - Behavior: the name is validated against the teams the account can access; an inaccessible name is rejected with exit `1` and the default is left unchanged. - On success it is persisted to the `identity.team` config key. + On success the team name and id are saved on the active account. - Behavior: when `OO_TEAM_ID` / `OO_TEAM_NAME` is set, the default is still saved, but the output reports that the environment variable keeps outranking it until unset. +- Behavior: with `OO_API_KEY` set the command saves nothing, exits `0`, and + reports that this variable has no saved default team. ### `oo team clear` -Clear the persisted default team identity (`identity.team`). Connector commands -then run under your personal identity, unless `OO_TEAM_ID` / `OO_TEAM_NAME` -still selects a team — this command removes only the config default and does -not affect the environment override. This command is offline. +Clear the active account's default team identity. Connector commands then run +under your personal identity, unless `OO_TEAM_ID` / `OO_TEAM_NAME` still +selects a team — this command removes only the saved default and does not +affect the environment override. This command is offline. -- Behavior: removes the `identity.team` config key. When no default is - configured it reports that connector commands already run under your personal +- Behavior: removes the default team from the active account. When no default + is saved it reports that connector commands already run under your personal identity. +- Behavior: with `OO_API_KEY` set the command clears nothing, exits `0`, and + reports that this variable already runs under your personal identity. - Behavior: when `OO_TEAM_ID` / `OO_TEAM_NAME` is set, the output reports that the environment variable still selects a team for connector commands, so clearing the default does not switch them to your personal identity. Unset @@ -524,7 +547,7 @@ List persisted configuration values that are currently set. Read one persisted configuration value. - Arguments: `` is the configuration key. Supported values: - `lang`, `file.download.out_dir`, `telemetry.enabled`, `identity.team`. + `lang`, `file.download.out_dir`, `telemetry.enabled`. ### `oo config path` @@ -535,7 +558,7 @@ Print the path to the persisted configuration file. Persist one configuration value. - Arguments: `` is the configuration key. Supported values: - `lang`, `file.download.out_dir`, `telemetry.enabled`, `identity.team`. + `lang`, `file.download.out_dir`, `telemetry.enabled`. - Arguments: `` is the value for the selected key. - Value rules: for `lang`, supported values are `en` and `zh`. - Value rules: for `file.download.out_dir`, use any non-empty path string. Relative @@ -546,18 +569,13 @@ Persist one configuration value. are rejected. Setting `telemetry.enabled` to `false` also attempts to purge pending telemetry events immediately and the current `config set` invocation is not recorded as telemetry. -- Value rules: for `identity.team`, use any non-empty team name. - It sets the default team identity used by `oo connector run`, - `oo connector proxy`, `oo connector apps`, and `oo connector search` / - `oo search` when neither `--team` nor `--personal` is passed and no - `OO_TEAM_ID` / `OO_TEAM_NAME` environment variable is set. ### `oo config unset ` Remove one persisted configuration value. - Arguments: `` is the configuration key. Supported values: - `lang`, `file.download.out_dir`, `telemetry.enabled`, `identity.team`. + `lang`, `file.download.out_dir`, `telemetry.enabled`. ## Telemetry @@ -847,7 +865,7 @@ Search connector actions with free-form text. - Options: `--team ` reports each result's `authenticated` state under the given team identity instead of your personal identity. When omitted, the effective identity follows `OO_TEAM_ID` / `OO_TEAM_NAME`, then the - `identity.team` config default, otherwise your personal identity. + active account's default team, otherwise your personal identity. - Options: `--personal` reports `authenticated` under your personal identity and ignores the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables and any configured default team. It cannot be combined with `--team`. @@ -923,7 +941,7 @@ Validate input data and run one connector action. - Options: `--team ` runs the action under the given team identity instead of your personal identity. When omitted, the action runs under the team selected by `OO_TEAM_ID` / `OO_TEAM_NAME` if set, then the - `identity.team` config default, otherwise your personal identity. + active account's default team, otherwise your personal identity. - Options: `--personal` runs the action under your personal identity and ignores the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables and any configured default team. It cannot be combined with `--team`. @@ -948,7 +966,7 @@ Validate input data and run one connector action. - Notes: while waiting for an async result action in text mode, interactive terminals show progress on stderr. JSON output does not include progress text. - Notes: against a self-hosted connector, `--team` is rejected with - exit `2`, a configured `identity.team` default and the `OO_TEAM_ID` / + exit `2`, the account's default team and the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables are ignored, and `--personal` is accepted. `--wait` and `--wait-result` fail with the existing unsupported errors because the self-hosted runtime does not expose the async lifecycle @@ -964,8 +982,8 @@ read-only. that one service. - Options: `--team ` lists connected apps under the given team identity instead of your personal identity. When omitted, the listing uses the team - selected by `OO_TEAM_ID` / `OO_TEAM_NAME` if set, then the `identity.team` - config default, otherwise your personal identity. + selected by `OO_TEAM_ID` / `OO_TEAM_NAME` if set, then the account's default + team, otherwise your personal identity. - Options: `--personal` lists connected apps under your personal identity and ignores the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables and any configured default team. It cannot be combined with `--team`. @@ -983,7 +1001,7 @@ read-only. - Notes: use the listed `connectionName` value with `oo connector run --connection-name `. - Notes: against a self-hosted connector, `--team` is rejected with exit - `2`, a configured `identity.team` default and the `OO_TEAM_ID` / + `2`, the account's default team and the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables are ignored, and `--personal` is accepted. @@ -1015,7 +1033,7 @@ Proxy a provider API request through a connected connector app. - Options: `--team ` runs the proxy request under the given team identity instead of your personal identity. When omitted, the request runs under the team selected by `OO_TEAM_ID` / `OO_TEAM_NAME` if set, then the - `identity.team` config default, otherwise your personal identity. + active account's default team, otherwise your personal identity. - Options: `--personal` runs the proxy request under your personal identity and ignores the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables and any configured default team. It cannot be combined with `--team`. @@ -1030,7 +1048,7 @@ Proxy a provider API request through a connected connector app. cache. Use it when the selected connector supports proxy execution and no purpose-built connector action is available. - Notes: against a self-hosted connector, `--team` is rejected with - exit `2` and a configured `identity.team` default and the `OO_TEAM_ID` / + exit `2` and the account's default team and the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables are ignored. Proxy execution depends on server support; the open-source runtime currently returns an error. @@ -1089,7 +1107,7 @@ Search connector actions with one free-form query. - Options: `--team ` reports each result's `authenticated` state under the given team identity instead of your personal identity. When omitted, the effective identity follows `OO_TEAM_ID` / `OO_TEAM_NAME`, then the - `identity.team` config default, otherwise your personal identity. + active account's default team, otherwise your personal identity. - Options: `--personal` reports `authenticated` under your personal identity and ignores the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables and any configured default team. It cannot be combined with `--team`. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index d3f3015..a49bd51 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -24,9 +24,11 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 - `OO_LOG_DIR`:覆盖 debug 日志目录。优先级高于所有平台默认值。 - `OO_API_KEY`:使用该 API key 执行命令,无需交互式登录。设置后 CLI 会构造一个 内存账号,不读取、不要求、也不写入 `auth.toml`,且优先级高于任何已保存的账号。 - 由于设置它之后任何已保存账号都不会生效,`oo auth logout` 与 `oo auth switch` - 会成为空操作并保持 `auth.toml` 不变;`oo auth login` 仍会保存账号,但会说明该 - 变量的优先级高于它。`oo auth status` 会展示该变量提供的身份。 + 由于设置它之后任何已保存账号都不会生效,`oo auth logout`、`oo auth switch`、 + `oo team use` 与 `oo team clear` 会成为空操作并保持 `auth.toml` 不变; + `oo auth login` 仍会保存账号,但会说明该变量的优先级高于它。`oo auth status` + 会展示该变量提供的身份。已保存的默认团队同样不会生效:该 key 可能属于另一个 + 账号,因此除非设置了 `OO_TEAM_ID` 或 `OO_TEAM_NAME`,命令都以个人身份运行。 - `OO_ENDPOINT`:基础域名(例如 `oomol.com` 或 `oomol.dev`),用于派生执行命令的 所有服务 URL。它与 `OO_API_KEY` 搭配使用,会覆盖已保存账号的 endpoint(包括 `oo auth status` 展示与校验所用的 endpoint),并决定 `oo auth login` 校验所用的 @@ -44,7 +46,7 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 - `OO_TEAM_ID`:让 connector 命令(`oo connector run`、`oo connector proxy`、 `oo connector apps`、`oo connector search` / `oo search`)以该 id 对应的团队 身份运行。优先级高于 `OO_TEAM_NAME` - 和 `identity.team` 配置默认值;每次运行的 `--team` 与 `--personal` + 和账号保存的默认团队;每次运行的 `--team` 与 `--personal` 标志仍然优先于它。执行前 CLI 会校验该 id 并解析出团队名称(每次调用多一个 请求),因此请求会同时携带名称与 id;账号无法使用的 id——不是成员、团队 不存在、团队已删除——以退出码 `1` 失败。若查询本身无法完成,则只按原样 @@ -57,7 +59,7 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 `OO_TEAM_ID` 或 connector 目标为自部署服务时会被忽略。 - Connector 相关命令按以下优先级解析团队身份: `--personal` / `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > - `identity.team` 配置默认值 > 个人身份。 + 当前账号保存的默认团队 > 个人身份。 - `OO_SKILLS_SYNC_DISABLED`:设为真值会禁用启动时的 managed skill 同步与 legacy 清理副作用,使 CLI 不会向 `~/.agents`、`~/.claude` 等代理主目录写入任何 skill 文件。 @@ -106,11 +108,12 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 - `--api-key `:使用已有 API key 登录。CLI 会通过账号 profile 校验该 key, 校验通过后直接保存账号,不会打印 device-login URL 或轮询;若 key 无效或已过期则以 错误退出。`--api-key` 与 `--session-token` 不能同时使用。 - - `--team `:登录后将默认团队身份(配置项 `identity.team`)设置为指定 - 团队。该名称必须是当前账号的团队成员关系之一,否则命令以 `1` 退出(账号 - 本身仍会被保存)。与三种登录方式均可组合。 -- 默认团队:登录成功后,CLI 会获取账号的团队成员关系并持久化默认团队身份。 - 未传 `--team` 时,若已配置的 `identity.team` 仍在成员关系中则保留;否则采用 + - `--team `:登录后将账号的默认团队身份设置为指定团队。该名称必须是 + 当前账号的团队成员关系之一,否则命令以 `1` 退出(账号本身仍会被保存)。 + 与三种登录方式均可组合。 +- 默认团队:登录成功后,CLI 会获取账号的团队成员关系并把默认团队身份持久化到 + 已保存的账号上。未传 `--team` 时,若账号已保存的默认团队仍在成员关系中则 + 保留(并补齐其团队 id);否则采用 后端为每个账号创建的默认团队(`system_created`)。两者都不存在时——成员 关系中没有 `system_created` 团队(旧版后端)或列表为空——不会持久化任何 内容,也不打印默认团队行,登录仍以 `0` 退出。否则成功输出会打印生效的 @@ -155,14 +158,15 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 发出的请求数。 - 当前身份区块还会显示一行「默认团队」,其解析方式与 `oo team current` 相同:设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 时显示 env 覆盖值(并标注来源 - 变量),否则显示 `identity.team` 配置默认值,都未设置时显示个人身份 - (未设置默认团队)。 + 变量),否则显示当前账号保存的默认团队,都未设置时显示个人身份 + (未设置默认团队)。设置了 `OO_API_KEY` 时,除非 `OO_TEAM_*` 变量选择了 + 团队,否则该行恒为个人身份。 - 当身份来自 `OO_TEAM_ID` 或 `OO_TEAM_NAME` 时,会查询补全缺失的那一半——id 解析出名称,名称通过账号的团队成员关系解析出 id——该行显示为 `<名称>()`。查询未成功时仍会显示 env 提供的值,并附上原因:当前账号 不是该团队的成员、不存在该 id 对应的团队、该团队已被删除、或无法完成查询。 查询失败既不会改变退出码,也不会影响所报告的 `API key status`。 - `identity.team` 配置默认值本身就带名称,不会发起查询。 + 账号保存的默认团队本身就带名称,不会发起查询。 - 因此 `oo auth status` 最多发送 2 次请求:API key 校验,以及 `OO_TEAM_ID` / `OO_TEAM_NAME` 生效时的团队查询。两者相互独立,并发发出。 - 文本和 JSON 输出都永远不会包含 API key 实际内容。 @@ -210,11 +214,11 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 - 当存在默认团队身份时,`oo auth status --json` 的输出——即上面的 `logged-in` 形态——会携带一个可选的顶层 `team` 字段。`source` 表示由哪种机制选中 - (`env_id`、`env_name` 或 `config`),`status` 报告团队查询的结果: + (`env_id`、`env_name` 或 `account`),`status` 报告团队查询的结果: ```json { - "team": { "name": "acme", "id": null, "source": "config", "status": null } + "team": { "name": "acme", "id": null, "source": "account", "status": null } } ``` @@ -229,7 +233,7 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 } ``` - 未尝试查询时 `status` 为 `null`(`config` 来源)。env 选定的身份下取值为 + 未尝试查询时 `status` 为 `null`(`account` 来源)。env 选定的身份下取值为 `valid`、`not_a_member`、`not_found`、`deleted`、`request_failed`、 `request_failed_sandbox` 或 `no_credential` 之一。只有 `status` 为 `valid` 时查询补全的那一半才有值——`env_id` 下补全名称,`env_name` 下补全 id—— @@ -268,11 +272,12 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 `OO_ENDPOINT`(不设 `OO_API_KEY`)会重定向文本输出与 API key 校验所用的 endpoint,但不会改写该字段。 - `team` 仅在 `logged-in` 形态且存在默认团队身份时出现。`source` 为 - `config`(`identity.team` 默认值)、`env_id`(`OO_TEAM_ID`)或 + `account`(账号保存的默认值)、`env_id`(`OO_TEAM_ID`)或 `env_name`(`OO_TEAM_NAME`)。env 选定的身份会发送 1 次请求补全并校验 缺失的那一半,因此成功时同时携带 `name` 与 `id`;查询未成功时保留 env - 提供的那一半,并由 `status` 说明原因。`config` 来源保持离线,其 `id` - 恒为 `null`。 + 提供的那一半,并由 `status` 说明原因。`account` 来源保持离线,其 `id` 在 + 某个已持有成员关系列表的命令(`oo team list`、`oo team use`、 + `oo auth login`)补齐之前为 `null`。 - `missingAccountId` 仅在 auth file 记录的 active id 已不存在于 `accounts[]` 时出现。 - `connector` 仅在配置了自部署 Connector 时出现,报告已配置的自部署 @@ -313,18 +318,24 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 ## 团队 团队身份让 connector 命令(`oo connector run`、`oo connector proxy`、`oo connector -apps`)以某个团队身份运行,而非个人账号:既可用每次运行的 `--team ` -指定,也可用环境变量 `OO_TEAM_ID` / `OO_TEAM_NAME` 指定,还可用配置项 -`identity.team` 设为默认(优先级依此排序)。下列命令用于发现当前账号可用的 +apps`)以某个团队身份运行,而非个人账号。它由同一条优先级阶梯选出:先是每次运行的 +`--personal`(强制个人身份)或 `--team `,其次是环境变量 `OO_TEAM_ID` / +`OO_TEAM_NAME`,最后是保存在当前账号上的默认团队。下列命令用于发现当前账号可用的 团队并管理该默认值。 +默认团队属于已保存的账号:用 `oo auth switch` 切换账号时默认团队随之切换, +`oo auth logout` 会连同账号一起移除它。旧版本把默认团队保存在全局配置项 +`identity.team` 中;CLI 会在下一次运行时把该值迁移到当前账号并删除该配置项。 +设置了 `OO_API_KEY` 时不会应用任何已保存的默认团队——请改用 `OO_TEAM_ID` / +`OO_TEAM_NAME`。 + `oo team list` 与 `oo team use` 需要向 OOMOL 查询团队成员关系,因此需要 OOMOL 账号;当仅配置了自部署 Connector 时不可用。`oo team current` 与 `oo team clear` -不受此限制:它们读写本地配置,`oo team current` 只在有账号可用时才额外查询 +不受此限制:它们读写本地状态,`oo team current` 只在有账号可用时才额外查询 团队名称来丰富输出。 -`oo auth login`(及其别名 `oo login`)会自动持久化该默认值:仍然有效的 -`identity.team` 配置会被保留,否则采用后端创建的 `system_created` 默认团队 +`oo auth login`(及其别名 `oo login`)会自动持久化该默认值:账号上仍然有效的 +默认团队会被保留,否则采用后端创建的 `system_created` 默认团队 (存在时);也可通过 `--team ` 显式指定。 ### `oo team list` @@ -335,36 +346,38 @@ apps`)以某个团队身份运行,而非个人账号:既可用每次运行 - 输出:JSON 条目包含稳定的 CLI 字段 `name`、`id`、`role`、`current`。`role` 为 `creator` 或 `member`。`current` 对 connector 命令默认使用的团队为 `true`: 设置了 `OO_TEAM_ID`(按 id 匹配)或 `OO_TEAM_NAME`(按名称匹配)时为 - env 指定的团队,否则为与 `identity.team` 默认值匹配的团队。 + env 指定的团队,否则为与账号默认团队匹配的团队。 - 输出:将 `name` 的值传给 `--team `(或 `oo team use `),将 `id` 的值传给 `OO_TEAM_ID`。 - 输出:文本输出为每个团队打印一行列对齐的记录,并标出当前默认团队。当账号没有任何 团队时,会提示 connector 命令以个人身份运行。 +- 行为:当账号保存的默认团队缺少团队 id 时,该命令会用刚获取的成员关系列表补齐 + 它,不发送额外请求;写入失败会被忽略。 ### `oo team current` 显示未传 `--team` / `--personal` 时 connector 命令使用的团队身份:设置了 -`OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量时为 env 指定的团队,否则为 -`identity.team` 配置默认值。 +`OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量时为 env 指定的团队,否则为当前账号 +保存的默认团队。 - 仅当身份来自 `OO_TEAM_ID` 或 `OO_TEAM_NAME` 时才发送 1 次请求,补全并校验 缺失的那一半:id 解析出团队名称,名称通过账号的团队成员关系解析出 id——这 与 connector 命令执行前的检查相同,因此该命令报告的身份就是实际运行会使用 - 的身份。配置默认值本身就带名称,保持离线。 + 的身份。账号保存的默认团队本身就带名称,保持离线。 - 无 OOMOL 账号时同样可用:此时跳过查询而不是让命令失败,只单独展示 env 提供的值。 - 选项:`--format=json` 与 `--json` 输出 JSON 对象。 - 输出:JSON 为 `{ "team": , "teamId": , "source": - <"env_id"|"env_name"|"config"|null>, "status": }`。`source` + <"env_id"|"env_name"|"account"|null>, "status": }`。`source` 表示团队由哪种机制选定;connector 命令以个人身份运行时为 `null`。`status` - 报告团队查询的结果:未尝试查询时为 `null`(config 来源,或 `--dry-run` 这类 + 报告团队查询的结果:未尝试查询时为 `null`(account 来源,或 `--dry-run` 这类 离线路径),否则为 `valid`、`not_a_member`、`not_found`、`deleted`、 `request_failed`、`request_failed_sandbox` 或 `no_credential` 之一。 - 输出:`OO_TEAM_ID` / `OO_TEAM_NAME` 生效且两项信息都已知时,文本输出显示 `<名称>()`。查询未成功时仍会显示 env 提供的值并附上原因,命令依然以 `0` 退出。 -- 输出:设置了环境变量时,文本输出会指明变量名,并说明已配置的 - `identity.team` 默认值在覆盖生效期间不会被使用。 +- 输出:设置了环境变量时,文本输出会指明变量名,并说明账号保存的默认团队在 + 覆盖生效期间不会被使用。 ### `oo team use ` @@ -372,17 +385,21 @@ apps`)以某个团队身份运行,而非个人账号:既可用每次运行 - 参数:`` 为团队名称,取值见 `oo team list`。 - 行为:会用账号可访问的团队校验该名称;无法访问的名称以退出码 `1` 拒绝,且默认值保持 - 不变。成功时持久化到配置项 `identity.team`。 + 不变。成功时把团队名称与 id 保存到当前账号上。 - 行为:设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 时,默认值仍会保存,但输出会说明该 环境变量在取消之前持续优先于它。 +- 行为:设置了 `OO_API_KEY` 时命令不保存任何内容,以 `0` 退出,并说明该变量没有 + 可保存的默认团队。 ### `oo team clear` -清除持久化的默认团队身份(`identity.team`)。之后 connector 命令以个人身份 -运行;但若 `OO_TEAM_ID` / `OO_TEAM_NAME` 仍在选择团队则不然——该命令只移除 -配置默认值,不影响环境变量覆盖。该命令离线运行。 +清除当前账号保存的默认团队身份。之后 connector 命令以个人身份运行;但若 +`OO_TEAM_ID` / `OO_TEAM_NAME` 仍在选择团队则不然——该命令只移除已保存的 +默认值,不影响环境变量覆盖。该命令离线运行。 -- 行为:移除配置项 `identity.team`。当未配置默认值时,会提示 connector 命令 +- 行为:从当前账号移除默认团队。当未保存默认值时,会提示 connector 命令 + 本就以个人身份运行。 +- 行为:设置了 `OO_API_KEY` 时命令不清除任何内容,以 `0` 退出,并说明该变量 本就以个人身份运行。 - 行为:设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 时,输出会说明该环境变量仍会为 connector 命令选择团队,因此清除默认值并不会让它们切换到个人身份;需要取消 @@ -446,7 +463,7 @@ apps`)以某个团队身份运行,而非个人账号:既可用每次运行 读取一个持久化配置值。 - 参数:`` 为配置键。目前支持 - `lang`、`file.download.out_dir`、`telemetry.enabled`、`identity.team`。 + `lang`、`file.download.out_dir`、`telemetry.enabled`。 ### `oo config path` @@ -457,7 +474,7 @@ apps`)以某个团队身份运行,而非个人账号:既可用每次运行 写入一个持久化配置值。 - 参数:`` 为配置键。目前支持 - `lang`、`file.download.out_dir`、`telemetry.enabled`、`identity.team`。 + `lang`、`file.download.out_dir`、`telemetry.enabled`。 - 参数:`` 为对应配置值。 - 取值规则:当 `` 为 `lang` 时,支持的值为 `en` 和 `zh`。 - 取值规则:当 `` 为 `file.download.out_dir` 时,支持任意非空路径字符串。 @@ -467,7 +484,6 @@ apps`)以某个团队身份运行,而非个人账号:既可用每次运行 `1`、`0`、`True`、`yes` 等其他 boolean-like 写法会被拒绝。设置为 `false` 时, CLI 还会立即尝试清空待发送 telemetry 事件,并且本次 `config set` 调用自身不会被记录为 telemetry。 -- 取值规则:当 `` 为 `identity.team` 时,支持任意非空的团队名称。 它设置 `oo connector run`、`oo connector proxy`、`oo connector apps` 和 `oo connector search` / `oo search` 在未传 `--team` 或 `--personal`、且未 设置 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量时使用的默认团队身份。 @@ -477,7 +493,7 @@ apps`)以某个团队身份运行,而非个人账号:既可用每次运行 删除一个持久化配置值。 - 参数:`` 为配置键。目前支持 - `lang`、`file.download.out_dir`、`telemetry.enabled`、`identity.team`。 + `lang`、`file.download.out_dir`、`telemetry.enabled`。 ## Telemetry @@ -714,7 +730,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 选项:`--format=json` 和 `--json` 会输出匹配 action 条目的 JSON 数组。 - 选项:`--team ` 以指定团队身份(而非个人身份)报告每条结果的 `authenticated` 状态。省略时有效身份依次取 `OO_TEAM_ID` / `OO_TEAM_NAME`、 - `identity.team` 配置默认值,最后回退到个人身份。 + 当前账号保存的默认团队,最后回退到个人身份。 - 选项:`--personal` 以个人身份报告 `authenticated`,忽略 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量与任何已配置的默认团队;不能与 `--team` 同用。 - 输出:每条结果都会包含 `authenticated`。 @@ -781,7 +797,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- action。只有选中 action 的 schema 声明了异步 submit lifecycle 时,这个选项才有效。 - 选项:`--team ` 以指定团队身份运行该 action,而非个人身份。省略时,若设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 则使用 env 选定的团队,其次使用 - `identity.team` 配置默认值,否则使用个人身份。 + 当前账号保存的默认团队,否则使用个人身份。 - 选项:`--personal` 以个人身份运行该 action,并忽略 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量和已配置的默认团队。不能与 `--team` 同时使用。 - 选项:`--format=json` 和 `--json` 会输出 JSON 对象。 @@ -801,7 +817,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 说明:text 模式下等待 async result action 时,交互式终端会在 stderr 显示进度。JSON 输出不会混入进度文本。 - 说明:面向自部署 Connector 时,传入 `--team` 会被拒绝(exit `2`), - 已配置的 `identity.team` 默认值和 `OO_TEAM_ID` / `OO_TEAM_NAME` + 账号保存的默认团队和 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量会被忽略,`--personal` 仍可使用。 由于自部署 runtime 不提供异步 lifecycle contract,`--wait` 和 `--wait-result` 会以现有的“不支持”错误失败。 @@ -814,7 +830,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- 该服务的 app。 - 选项:`--team ` 以指定团队身份列出已连接的 app,而非个人身份。省略时,若设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 则按 env 选定的团队列出,其次按 - `identity.team` 配置默认值列出,否则按个人身份列出。 + 当前账号保存的默认团队列出,否则按个人身份列出。 - 选项:`--personal` 以个人身份列出已连接的 app,并忽略 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量和已配置的默认团队。该选项不能与 `--team` 同时使用。 - 选项:`--format=json` 和 `--json` 会输出 JSON 数组。 @@ -828,7 +844,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 说明:可将列出的 `connectionName` 值传给 `oo connector run --connection-name `。 - 说明:对自部署 Connector,`--team` 会以退出码 `2` 拒绝,已配置的 - `identity.team` 默认值和 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量会被忽略, + 账号保存的默认团队和 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量会被忽略, `--personal` 可正常使用。 ### `oo connector proxy ` @@ -856,7 +872,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- `"hello"`。 - 选项:`--team ` 以指定团队身份运行该 proxy 请求,而非个人身份。省略时,若设置了 `OO_TEAM_ID` / `OO_TEAM_NAME` 则使用 env 选定的团队,其次使用 - `identity.team` 配置默认值,否则使用个人身份。 + 当前账号保存的默认团队,否则使用个人身份。 - 选项:`--personal` 以个人身份运行该 proxy 请求,并忽略 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量和已配置的默认团队。不能与 `--team` 同时使用。 - 选项:`--format=json` 和 `--json` 会输出 JSON 对象。 @@ -868,7 +884,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 说明:`oo connector proxy` 不使用 connector action schema 或 schema cache。 当选中的 connector 支持 proxy execution 且没有专用 connector action 时使用。 - 说明:面向自部署 Connector 时,传入 `--team` 会被拒绝(exit `2`), - 已配置的 `identity.team` 默认值和 `OO_TEAM_ID` / `OO_TEAM_NAME` + 账号保存的默认团队和 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量会被忽略。proxy execution 取决于服务端支持;开源 runtime 目前会返回错误。 @@ -915,7 +931,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 选项:`--format=json` 和 `--json` 会输出匹配 action 条目的 JSON 数组。 - 选项:`--team ` 以指定团队身份(而非个人身份)报告每条结果的 `authenticated` 状态。省略时有效身份依次取 `OO_TEAM_ID` / `OO_TEAM_NAME`、 - `identity.team` 配置默认值,最后回退到个人身份。 + 当前账号保存的默认团队,最后回退到个人身份。 - 选项:`--personal` 以个人身份报告 `authenticated`,忽略 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量与任何已配置的默认团队;不能与 `--team` 同用。 - 输出:每条结果都会包含 `authenticated`。 diff --git a/docs/self-hosted-connector.md b/docs/self-hosted-connector.md index 4b83f03..5b2c952 100644 --- a/docs/self-hosted-connector.md +++ b/docs/self-hosted-connector.md @@ -179,7 +179,7 @@ adapts as follows: - **Team identity is not supported.** `--team` is rejected with exit code `2` on `oo connector run`, `oo connector proxy`, and `oo connector apps`, and any - configured `identity.team` default and the `OO_TEAM_ID` / `OO_TEAM_NAME` + saved account default team and the `OO_TEAM_ID` / `OO_TEAM_NAME` environment variables are ignored. `--personal` is accepted (it is already the effective behavior). - **Async lifecycle waiting is unavailable.** `--wait` and `--wait-result` fail diff --git a/docs/self-hosted-connector.zh-CN.md b/docs/self-hosted-connector.zh-CN.md index d42b675..a3cb5dc 100644 --- a/docs/self-hosted-connector.zh-CN.md +++ b/docs/self-hosted-connector.zh-CN.md @@ -159,7 +159,7 @@ oo connector run gmail --action send_email --data '@payload.json' 自部署运行时暴露的能力比 OOMOL 服务更小,CLI 会做如下适配: - **不支持团队身份。** `oo connector run`、`oo connector proxy` 与 `oo connector - apps` 上的 `--team` 会以退出码 `2` 被拒绝,任何已配置的 `identity.team` 默认值 + apps` 上的 `--team` 会以退出码 `2` 被拒绝,账号保存的默认团队 和 `OO_TEAM_ID` / `OO_TEAM_NAME` 环境变量都会被忽略;`--personal` 可以使用 (它本就是实际行为)。 - **无法等待异步生命周期。** `--wait` 与 `--wait-result` 会以既有的「不支持」 diff --git a/src/application/auth/default-team.test.ts b/src/application/auth/default-team.test.ts new file mode 100644 index 0000000..303828c --- /dev/null +++ b/src/application/auth/default-team.test.ts @@ -0,0 +1,400 @@ +import type { AuthStore } from "../contracts/auth-store.ts"; +import type { CliExecutionContext, CliTelemetryPropertyValue } from "../contracts/cli.ts"; +import type { AuthFile } from "../schemas/auth.ts"; +import type { AppSettings } from "../schemas/settings.ts"; + +import { describe, expect, test } from "bun:test"; +import pino from "pino"; + +import { + createAuthStore, + createRecordingTelemetry, + createSettingsStore, +} from "../../../__tests__/helpers.ts"; +import { + clearDefaultTeam, + migrateLegacyDefaultTeam, + readDefaultTeam, + writeDefaultTeam, +} from "./default-team.ts"; + +const account = { + apiKey: "persisted-key", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", +}; + +describe("readDefaultTeam", () => { + test("returns the account default with both dimensions", async () => { + const { context } = createDefaultTeamContext({ + authFile: { + auth: [{ ...account, team: "acme", teamId: "team-1" }], + id: "user-1", + }, + }); + + await expect(readDefaultTeam(context)).resolves.toEqual({ + id: "team-1", + name: "acme", + }); + }); + + test("reports a missing team id as null", async () => { + const { context } = createDefaultTeamContext({ + authFile: { auth: [{ ...account, team: "acme" }], id: "user-1" }, + }); + + await expect(readDefaultTeam(context)).resolves.toEqual({ + id: null, + name: "acme", + }); + }); + + test("falls back to the legacy setting when the account has no default", async () => { + const { context } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await expect(readDefaultTeam(context)).resolves.toEqual({ + id: null, + name: "legacy-team", + }); + }); + + test("prefers the account default over the legacy setting", async () => { + const { context } = createDefaultTeamContext({ + authFile: { auth: [{ ...account, team: "acme" }], id: "user-1" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await expect(readDefaultTeam(context)).resolves.toMatchObject({ + name: "acme", + }); + }); + + test("still honours the legacy setting when auth.toml cannot be read", async () => { + const { context } = createDefaultTeamContext({ + authStore: createUnreadableAuthStore(), + settings: { identity: { team: "legacy-team" } }, + }); + + await expect(readDefaultTeam(context)).resolves.toEqual({ + id: null, + name: "legacy-team", + }); + }); + + test("resolves to personal when nothing is stored", async () => { + const { context } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + }); + + await expect(readDefaultTeam(context)).resolves.toBeUndefined(); + }); + + test("resolves to personal under OO_API_KEY without touching any store", async () => { + const { context } = createDefaultTeamContext({ + authStore: createThrowingAuthStore(), + env: { OO_API_KEY: "env-key" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await expect(readDefaultTeam(context)).resolves.toBeUndefined(); + }); +}); + +describe("writeDefaultTeam", () => { + test("stores the default on the active account and drops the legacy value", async () => { + const { context, readAuthFile, readSettings } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + settings: { identity: { team: "legacy-team" }, lang: "en" }, + }); + + await expect(writeDefaultTeam(context, { id: "team-1", name: "acme" })) + .resolves + .toBe(true); + + expect((await readAuthFile()).auth[0]).toMatchObject({ + team: "acme", + teamId: "team-1", + }); + expect(await readSettings()).toEqual({ lang: "en" }); + }); + + test("reports that nothing was stored when no account is active", async () => { + const { context } = createDefaultTeamContext({ + authFile: { auth: [], id: "" }, + }); + + await expect(writeDefaultTeam(context, { id: null, name: "acme" })) + .resolves + .toBe(false); + }); + + test("does not rewrite the file when the default is already stored", async () => { + const { context } = createDefaultTeamContext({ + authStore: createUnwritableAuthStore({ + auth: [{ ...account, team: "acme", teamId: "team-1" }], + id: "user-1", + }), + }); + + // Re-selecting the same team and backfilling an id that is already + // there both land here; the unwritable store proves neither writes. + await expect(writeDefaultTeam(context, { id: "team-1", name: "acme" })) + .resolves + .toBe(true); + }); +}); + +describe("clearDefaultTeam", () => { + test("clears the account default", async () => { + const { context, readAuthFile } = createDefaultTeamContext({ + authFile: { + auth: [{ ...account, team: "acme", teamId: "team-1" }], + id: "user-1", + }, + }); + + await expect(clearDefaultTeam(context)).resolves.toBe(true); + expect((await readAuthFile()).auth[0]).toEqual(account); + }); + + test("clears a legacy value that no account could hold", async () => { + const { context, readSettings } = createDefaultTeamContext({ + authFile: { auth: [], id: "" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await expect(clearDefaultTeam(context)).resolves.toBe(true); + expect(await readSettings()).toEqual({}); + }); + + test("reports nothing to clear for an account already on the personal identity", async () => { + const { context } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + }); + + await expect(clearDefaultTeam(context)).resolves.toBe(false); + }); + + test("still clears the legacy value when auth.toml cannot be read", async () => { + const { context, readSettings } = createDefaultTeamContext({ + authStore: createUnreadableAuthStore(), + settings: { identity: { team: "legacy-team" } }, + }); + + // readDefaultTeam keeps honouring the legacy value in this state, so + // clearing has to reach it too — otherwise the one default still in + // effect is the one that cannot be cleared. + await expect(clearDefaultTeam(context)).resolves.toBe(true); + expect(await readSettings()).toEqual({}); + }); + + test("reports the legacy removal as a migration", async () => { + const { context, recordedProperties } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await clearDefaultTeam(context); + + expect(recordedProperties).toEqual([{ team_default_migrated: true }]); + }); +}); + +describe("migrateLegacyDefaultTeam", () => { + test("moves the legacy value onto the active account and deletes it", async () => { + const { + context, + readAuthFile, + readSettings, + recordedProperties, + } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + settings: { identity: { team: "legacy-team" }, lang: "en" }, + }); + + await runMigration(context); + + expect((await readAuthFile()).auth[0]).toMatchObject({ team: "legacy-team" }); + expect((await readAuthFile()).auth[0]?.teamId).toBeUndefined(); + expect(await readSettings()).toEqual({ lang: "en" }); + expect(recordedProperties).toEqual([{ team_default_migrated: true }]); + }); + + test("only migrates the account the file points at", async () => { + const { context, readAuthFile } = createDefaultTeamContext({ + authFile: { + auth: [account, { ...account, id: "user-2", name: "Bob" }], + id: "user-1", + }, + settings: { identity: { team: "legacy-team" } }, + }); + + await runMigration(context); + + expect((await readAuthFile()).auth[0]).toMatchObject({ team: "legacy-team" }); + expect((await readAuthFile()).auth[1]?.team).toBeUndefined(); + }); + + test("keeps an account default and deletes only the stale legacy value", async () => { + const { context, readAuthFile, readSettings } = createDefaultTeamContext({ + authFile: { + auth: [{ ...account, team: "acme", teamId: "team-1" }], + id: "user-1", + }, + settings: { identity: { team: "legacy-team" } }, + }); + + await runMigration(context); + + expect((await readAuthFile()).auth[0]).toMatchObject({ + team: "acme", + teamId: "team-1", + }); + expect(await readSettings()).toEqual({}); + }); + + test("leaves the legacy value in place when no account can hold it", async () => { + const { context, readSettings, recordedProperties } = createDefaultTeamContext({ + authFile: { auth: [], id: "" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await runMigration(context); + + expect(await readSettings()).toEqual({ identity: { team: "legacy-team" } }); + expect(recordedProperties).toEqual([]); + }); + + test("does nothing at all under OO_API_KEY", async () => { + const { context, readSettings } = createDefaultTeamContext({ + authStore: createThrowingAuthStore(), + env: { OO_API_KEY: "env-key" }, + settings: { identity: { team: "legacy-team" } }, + }); + + await runMigration(context); + + expect(await readSettings()).toEqual({ identity: { team: "legacy-team" } }); + }); + + test("swallows a failing auth write and keeps the legacy value", async () => { + const { context, readSettings } = createDefaultTeamContext({ + authStore: createUnwritableAuthStore({ + auth: [account], + id: "user-1", + }), + settings: { identity: { team: "legacy-team" } }, + }); + + await expect(runMigration(context)).resolves.toBeUndefined(); + expect(await readSettings()).toEqual({ identity: { team: "legacy-team" } }); + }); + + test("records nothing when there is no legacy value to migrate", async () => { + const { context, recordedProperties } = createDefaultTeamContext({ + authFile: { auth: [account], id: "user-1" }, + }); + + await runMigration(context); + + expect(recordedProperties).toEqual([]); + }); +}); + +// Mirrors the bootstrap, which hands the migration the settings it has +// already read for its own reasons. +async function runMigration(context: DefaultTeamContext): Promise { + await migrateLegacyDefaultTeam(context, await context.settingsStore.read()); +} + +type DefaultTeamContext = Pick< + CliExecutionContext, + "authStore" | "env" | "logger" | "settingsStore" | "telemetry" +>; + +function createDefaultTeamContext( + overrides: { + authFile?: AuthFile; + authStore?: AuthStore; + env?: Record; + settings?: AppSettings; + } = {}, +): { + context: DefaultTeamContext; + readAuthFile: () => Promise; + readSettings: () => Promise; + recordedProperties: Record[]; +} { + const authStore = overrides.authStore + ?? createAuthStore(overrides.authFile ?? { auth: [], id: "" }); + const settingsStore = createSettingsStore(overrides.settings ?? {}); + const { recordedProperties, telemetry } = createRecordingTelemetry(); + + return { + context: { + authStore, + env: overrides.env ?? {}, + logger: pino({ enabled: false }), + settingsStore, + telemetry, + }, + readAuthFile: async () => (await authStore.readTolerantState()).authFile, + readSettings: () => settingsStore.read(), + recordedProperties, + }; +} + +function createThrowingAuthStore(): AuthStore { + const fail = (): never => { + throw new Error("auth.toml must not be accessed when OO_API_KEY is set"); + }; + + return { + getFilePath: () => "/should-not-be-read/auth.toml", + read: async () => fail(), + readTolerantState: async () => fail(), + write: async () => fail(), + update: async () => fail(), + }; +} + +// Mirrors the file store on a corrupt auth.toml: the tolerant read reports an +// empty file, and the strict read fails. +function createUnreadableAuthStore(): AuthStore { + return { + getFilePath: () => "/unreadable/auth.toml", + read: async () => { + throw new Error("auth.toml is corrupt"); + }, + readTolerantState: async () => ({ + authFile: { auth: [], id: "" }, + fileState: "corrupt", + }), + write: async () => { + throw new Error("auth.toml is corrupt"); + }, + update: async () => { + throw new Error("auth.toml is corrupt"); + }, + }; +} + +// A readable auth.toml on a filesystem that refuses the write. +function createUnwritableAuthStore(authFile: AuthFile): AuthStore { + return { + getFilePath: () => "/read-only/auth.toml", + read: async () => authFile, + readTolerantState: async () => ({ authFile, fileState: "ok" }), + write: async () => { + throw new Error("auth.toml is not writable"); + }, + update: async () => { + throw new Error("auth.toml is not writable"); + }, + }; +} diff --git a/src/application/auth/default-team.ts b/src/application/auth/default-team.ts new file mode 100644 index 0000000..d6f6de1 --- /dev/null +++ b/src/application/auth/default-team.ts @@ -0,0 +1,249 @@ +// --------------------------------------------------------------------------- +// The account default team: which team the active account acts for when no +// per-run flag and no env override says otherwise. +// +// A default team belongs to an account, not to the installation — membership +// is per account, so a single global setting inevitably lends one account's +// team to another. This module is the only owner of that answer: where it is +// stored, how a pre-account installation's value is migrated, and the one +// rule that OO_API_KEY has no persisted default at all. +// +// The compatibility layer here — the legacy read, the migration, and the +// settings-file passthrough that keeps an unmigrated value alive — is +// temporary. It comes out once the `team_default_migrated` telemetry property +// stops appearing, and the three parts go together. +// --------------------------------------------------------------------------- + +import type { CliExecutionContext } from "../contracts/cli.ts"; +import type { AppSettings } from "../schemas/settings.ts"; + +import { + clearAccountDefaultTeam, + getCurrentAuthAccount, + setAccountDefaultTeam, +} from "../schemas/auth.ts"; +import { + getLegacyIdentityTeam, + unsetLegacyIdentityTeam, +} from "../schemas/settings.ts"; +import { buildEnvApiKeyAccount } from "./identity.ts"; + +/** + * The persisted default team of the active account. `id` is null for a + * default that only ever had a name — one migrated from the legacy global + * setting, until a command that holds the membership listing backfills it. + */ +export interface AccountDefaultTeam { + id: string | null; + name: string; +} + +type ReadDefaultTeamContext = Pick< + CliExecutionContext, + "authStore" | "env" | "settingsStore" +>; + +type WriteDefaultTeamContext = Pick< + CliExecutionContext, + "authStore" | "settingsStore" | "telemetry" +>; + +type MigrateDefaultTeamContext = Pick< + CliExecutionContext, + "authStore" | "env" | "logger" | "settingsStore" | "telemetry" +>; + +/** + * Reads the default team in effect for the active account, or undefined for + * the personal identity. + * + * With OO_API_KEY set there is no persisted default at all: the credential + * may belong to an entirely different account, and lending it a saved + * account's team is exactly the mix-up account scoping exists to prevent. + * Automated callers pin a team with OO_TEAM_ID / OO_TEAM_NAME instead. + * + * The legacy global setting is still consulted when the active account has no + * default of its own, so an installation whose migration could not complete — + * an unreadable auth.toml, no saved account — keeps resolving the team it + * always did. This read never writes; the migration owns that. + */ +export async function readDefaultTeam( + context: ReadDefaultTeamContext, +): Promise { + if (buildEnvApiKeyAccount(context.env) !== undefined) { + return undefined; + } + + // Tolerant: resolving a default team must never create auth.toml, and a + // corrupt one must fall through to the legacy value rather than fail a + // command whose own output does not depend on the saved accounts. + const { authFile } = await context.authStore.readTolerantState(); + const account = getCurrentAuthAccount(authFile); + + if (account?.team !== undefined) { + return { id: account.teamId ?? null, name: account.team }; + } + + const legacyTeam = getLegacyIdentityTeam(await context.settingsStore.read()); + + return legacyTeam === undefined ? undefined : { id: null, name: legacyTeam }; +} + +/** + * Records the default team on the active account. Returns false when no saved + * account can hold it, which is how a caller learns its write would have been + * silently dropped. + * + * Deliberately does not check for an OO_API_KEY override: `oo auth login` + * saves an account even while that variable outranks it, and the default team + * of the account it just saved belongs with it. Callers whose own write would + * be pointless under the override (`oo team use`) check for it themselves and + * report an overridden write. + */ +export async function writeDefaultTeam( + context: WriteDefaultTeamContext, + team: AccountDefaultTeam, +): Promise { + const authFile = await context.authStore.read(); + const account = getCurrentAuthAccount(authFile); + + if (account === undefined) { + return false; + } + + // Re-selecting the team already stored, and the id backfill on an account + // whose id is already there, both land here; neither is worth a file + // rewrite. + if ( + account.team !== team.name + || (account.teamId ?? null) !== team.id + ) { + await context.authStore.write( + setAccountDefaultTeam(authFile, account.id, team), + ); + } + + await dropLegacyIdentityTeam(context); + + return true; +} + +/** + * Clears the default team. The legacy value goes too: leaving it behind would + * let the next read resurrect the team this call just cleared. + * + * Returns whether anything was actually cleared, so the caller can tell + * "cleared" from "already personal" without reading the stores itself. + * + * The auth read is tolerant, unlike the one in `writeDefaultTeam`: clearing is + * the one write here that runs without resolving an identity first, so it is + * what a user with an unreadable auth.toml reaches for — and the legacy value + * `readDefaultTeam` still honours in that state has to stay clearable. An + * unreadable file yields no account, so nothing overwrites it. + */ +export async function clearDefaultTeam( + context: WriteDefaultTeamContext, +): Promise { + const { authFile } = await context.authStore.readTolerantState(); + const account = getCurrentAuthAccount(authFile); + const nextAuthFile = account === undefined + ? authFile + : clearAccountDefaultTeam(authFile, account.id); + const clearedAccountTeam = nextAuthFile !== authFile; + + if (clearedAccountTeam) { + await context.authStore.write(nextAuthFile); + } + + return await dropLegacyIdentityTeam(context) || clearedAccountTeam; +} + +/** + * Moves the legacy global default team onto the active account, then deletes + * it. Runs once per invocation before any command, so the value is gone + * before anything can read it from the old place — no command needs to know + * the legacy setting exists. + * + * Best effort by construction: it runs ahead of every command, including ones + * with nothing to do with identity, so no failure here may change what a + * command does or which exit code it returns. Having no account to migrate + * onto is not a failure — the value stays put and `readDefaultTeam` keeps + * honouring it until an account exists. + */ +export async function migrateLegacyDefaultTeam( + context: MigrateDefaultTeamContext, + settings: AppSettings, +): Promise { + if (buildEnvApiKeyAccount(context.env) !== undefined) { + return; + } + + try { + // The caller's own settings read answers "is there anything to move", + // so the overwhelmingly common case — no legacy value — costs nothing + // beyond this check, and the bootstrap needs to know nothing about the + // legacy setting itself. + const legacyTeam = getLegacyIdentityTeam(settings); + + if (legacyTeam === undefined) { + return; + } + + const { authFile } = await context.authStore.readTolerantState(); + const account = getCurrentAuthAccount(authFile); + + if (account === undefined) { + return; + } + + // An account that already chose a default keeps it; only the stale + // legacy value goes. This is also what makes a half-finished + // migration self-healing on the next run. + if (account.team === undefined) { + await context.authStore.write( + setAccountDefaultTeam(authFile, account.id, { + id: null, + name: legacyTeam, + }), + ); + } + + await dropLegacyIdentityTeam(context); + + context.logger.info( + { teamDefaultMigrated: true }, + "Legacy default team identity migrated into the active account.", + ); + } + catch (error) { + context.logger.warn( + { err: error }, + "Legacy default team identity migration did not complete; the legacy setting is left in place.", + ); + } +} + +// Deletes the legacy setting, skipping the file write when there is nothing +// to delete. Returns whether a value was removed. +// +// This is the single point where a legacy value stops existing, so it is also +// where the removal is reported: the telemetry property means "this +// invocation found and consumed a legacy value", whether the bootstrap +// migration, `oo team use`, or `oo team clear` was the one to reach it. Any +// path that deleted the value without recording it would make the property +// under-report, and under-reporting is what decides when the compatibility +// layer is safe to delete. +async function dropLegacyIdentityTeam( + context: Pick, +): Promise { + const settings = await context.settingsStore.read(); + + if (getLegacyIdentityTeam(settings) === undefined) { + return false; + } + + await context.settingsStore.update(unsetLegacyIdentityTeam); + context.telemetry?.recordProperties({ team_default_migrated: true }); + + return true; +} diff --git a/src/application/bootstrap/run-cli.ts b/src/application/bootstrap/run-cli.ts index 6626193..e0cb170 100644 --- a/src/application/bootstrap/run-cli.ts +++ b/src/application/bootstrap/run-cli.ts @@ -34,6 +34,7 @@ import { resolvePreferredLocale, } from "../../i18n/locale.ts"; import { createTranslator } from "../../i18n/translator.ts"; +import { migrateLegacyDefaultTeam } from "../auth/default-team.ts"; import { createCliCatalog } from "../commands/catalog.ts"; import { synchronizeManagedSkillsForAvailableHosts } from "../commands/skills/auto-sync.ts"; import { APP_NAME } from "../config/app-config.ts"; @@ -239,6 +240,25 @@ export async function executeCli(invocation: CliInvocation): Promise { const settings = await initializedStores.settingsStore.read(); settingsForTelemetry = settings; + // Every command runs behind this, not a hand-maintained list of the + // team-aware ones, so no future command can be added that quietly + // skips it. It is handed the settings already read above and returns + // immediately when there is nothing to move. + await migrateLegacyDefaultTeam( + { + authStore: initializedStores.authStore, + env: invocation.env, + logger, + settingsStore: initializedStores.settingsStore, + telemetry: { + directoryPath: storePaths.telemetryDirectory, + recordProperties: telemetryRecorder.recordProperties, + suppressCurrentInvocation: telemetryRecorder.suppress, + }, + }, + settings, + ); + translator = createTranslator( resolvePreferredLocale({ cliFlag: parsedCliLanguage, diff --git a/src/application/commands/auth/index.cli.test.ts b/src/application/commands/auth/index.cli.test.ts index 1697516..66b5c30 100644 --- a/src/application/commands/auth/index.cli.test.ts +++ b/src/application/commands/auth/index.cli.test.ts @@ -19,6 +19,7 @@ import { runPrintedAuthLogin, toRequest, writeAuthFile, + writeAuthFileWithDefaultTeam, writeConnectorFile, } from "../../../../__tests__/helpers.ts"; import { APP_NAME } from "../../config/app-config.ts"; @@ -2215,20 +2216,18 @@ function readCommandTelemetryProperties( } describe("auth CLI login default team", () => { - function readSettingsFilePath(sandbox: { + // The default team is stored on the saved account, so every assertion in + // this block reads auth.toml rather than settings.toml. + function readAuthFilePath(sandbox: { env: Record; }): string { - return join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "settings.toml"); + return join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "auth.toml"); } - // The settings file may not exist yet when login never persisted a - // default team; treat a missing file as empty content. - async function readOptionalSettingsContent(sandbox: { + async function readAuthContent(sandbox: { env: Record; }): Promise { - const settingsFile = Bun.file(readSettingsFilePath(sandbox)); - - return (await settingsFile.exists()) ? await settingsFile.text() : ""; + return await readFile(readAuthFilePath(sandbox), "utf8"); } test("adopts the system-created team and persists it as the default", async () => { @@ -2236,10 +2235,7 @@ describe("auth CLI login default team", () => { try { const result = await runPrintedAuthLogin(sandbox, "secret-1"); - const settingsContent = await readFile( - readSettingsFilePath(sandbox), - "utf8", - ); + const authContent = await readAuthContent(sandbox); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); @@ -2247,7 +2243,8 @@ describe("auth CLI login default team", () => { "Default team identity: alice-team", ); expect(result.stdout).not.toContain("You belong to"); - expect(settingsContent).toContain("team = \"alice-team\""); + expect(authContent).toContain("team = \"alice-team\""); + expect(authContent).toContain("team_id = \"team-system-1\""); // Only the selection enum and the bounded count reach telemetry. const telemetryProperties = readCommandTelemetryProperties( @@ -2323,10 +2320,7 @@ describe("auth CLI login default team", () => { ], }, }); - const settingsContent = await readFile( - readSettingsFilePath(sandbox), - "utf8", - ); + const authContent = await readAuthContent(sandbox); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Default team identity: beta"); @@ -2334,7 +2328,7 @@ describe("auth CLI login default team", () => { "You belong to 2 teams: alice-team, beta. Switch with `oo team use `.", ); expect(result.stdout).not.toContain("…"); - expect(settingsContent).toContain("team = \"beta\""); + expect(authContent).toContain("team = \"beta\""); const telemetryProperties = readCommandTelemetryProperties( sandbox, @@ -2377,9 +2371,7 @@ describe("auth CLI login default team", () => { expect(await readFile(authFilePath, "utf8")).toContain( "id = \"user-1\"", ); - expect(await readOptionalSettingsContent(sandbox)) - .not - .toContain("\nteam = "); + expect(await readAuthContent(sandbox)).not.toContain("\nteam = "); } finally { await sandbox.cleanup(); @@ -2407,11 +2399,7 @@ describe("auth CLI login default team", () => { expect(await readFile(authFilePath, "utf8")).toContain( "id = \"user-1\"", ); - // The commented settings template mentions `# team = ...`, so only - // an uncommented assignment counts as a persisted default. - expect(await readOptionalSettingsContent(sandbox)) - .not - .toContain("\nteam = "); + expect(await readAuthContent(sandbox)).not.toContain("\nteam = "); } finally { await sandbox.cleanup(); @@ -2439,11 +2427,11 @@ describe("auth CLI login default team", () => { } }); - test("keeps a still-valid configured team on re-login", async () => { + test("keeps the account's still-valid default team on re-login", async () => { const sandbox = await createCliSandbox(); try { - await sandbox.run(["config", "set", "identity.team", "beta"]); + await writeAuthFileWithDefaultTeam(sandbox, "beta"); const result = await runPrintedAuthLogin(sandbox, "secret-1", { teamsResponse: { @@ -2458,14 +2446,14 @@ describe("auth CLI login default team", () => { ], }, }); - const settingsContent = await readFile( - readSettingsFilePath(sandbox), - "utf8", - ); + const authContent = await readAuthContent(sandbox); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Default team identity: beta"); - expect(settingsContent).toContain("team = \"beta\""); + expect(authContent).toContain("team = \"beta\""); + // The kept default gains the id it was missing, because this + // login already fetched the membership listing. + expect(authContent).toContain("team_id = \"team-2\""); const telemetryProperties = readCommandTelemetryProperties( sandbox, @@ -2473,7 +2461,7 @@ describe("auth CLI login default team", () => { ); expect(telemetryProperties).toMatchObject({ team_count_bucket: "1-5", - team_selection: "kept_config", + team_selection: "kept_existing", }); } finally { @@ -2481,27 +2469,64 @@ describe("auth CLI login default team", () => { } }); + test("keeps a renamed default team by its stored id on re-login", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "beta", { + teamId: "team-2", + }); + + const result = await runPrintedAuthLogin(sandbox, "secret-1", { + teamsResponse: { + teams: [ + { + id: "team-system-1", + name: "alice-team", + role: "creator", + system_created: true, + }, + // Same team, renamed since it was chosen. + { id: "team-2", name: "beta-corp", role: "member", system_created: false }, + ], + }, + }); + const authContent = await readAuthContent(sandbox); + + // Matching on the stale name would read this as "no longer a + // membership" and silently replace a deliberate `oo team use` with + // the system-created default. + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Default team identity: beta-corp"); + expect(authContent).toContain("team = \"beta-corp\""); + expect(authContent).toContain("team_id = \"team-2\""); + expect(authContent).not.toContain("alice-team"); + expect(readCommandTelemetryProperties(sandbox, "auth.login")) + .toMatchObject({ team_selection: "kept_existing" }); + } + finally { + await sandbox.cleanup(); + } + }); + test("changes nothing when no system-created team matches", async () => { const sandbox = await createCliSandbox(); try { - await sandbox.run(["config", "set", "identity.team", "ghost"]); + await writeAuthFileWithDefaultTeam(sandbox, "ghost"); const result = await runPrintedAuthLogin(sandbox, "secret-1", { teamsResponse: { teams: [{ id: "team-2", name: "beta", role: "member", system_created: false }], }, }); - const settingsContent = await readFile( - readSettingsFilePath(sandbox), - "utf8", - ); + const authContent = await readAuthContent(sandbox); - // No membership carries system_created, so the stale configured + // No membership carries system_created, so 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(settingsContent).toContain("team = \"ghost\""); + expect(authContent).toContain("team = \"ghost\""); expect(readCommandTelemetryProperties(sandbox, "auth.login")) .toMatchObject({ team_selection: "none" }); } @@ -2521,9 +2546,7 @@ describe("auth CLI login default team", () => { expect(result.exitCode).toBe(0); expect(result.stdout).not.toContain("Default team identity:"); expect(result.stdout).not.toContain("You belong to"); - expect(await readOptionalSettingsContent(sandbox)) - .not - .toContain("\nteam = "); + expect(await readAuthContent(sandbox)).not.toContain("\nteam = "); expect(readCommandTelemetryProperties(sandbox, "auth.login")) .toMatchObject({ team_count_bucket: "0", @@ -2535,24 +2558,21 @@ describe("auth CLI login default team", () => { } }); - test("replaces a stale configured team with the system-created default", async () => { + test("replaces a stale stored team with the system-created default", async () => { const sandbox = await createCliSandbox(); try { - await sandbox.run(["config", "set", "identity.team", "ghost"]); + await writeAuthFileWithDefaultTeam(sandbox, "ghost"); const result = await runPrintedAuthLogin(sandbox, "secret-1"); - const settingsContent = await readFile( - readSettingsFilePath(sandbox), - "utf8", - ); + const authContent = await readAuthContent(sandbox); expect(result.exitCode).toBe(0); expect(result.stdout).toContain( "Default team identity: alice-team", ); - expect(settingsContent).toContain("team = \"alice-team\""); - expect(settingsContent).not.toContain("team = \"ghost\""); + expect(authContent).toContain("team = \"alice-team\""); + expect(authContent).not.toContain("team = \"ghost\""); } finally { await sandbox.cleanup(); @@ -2571,9 +2591,7 @@ describe("auth CLI login default team", () => { expect(result.stdout).toContain( "Could not load your teams, so the default team identity is unchanged.", ); - expect(await readOptionalSettingsContent(sandbox)) - .not - .toContain("\nteam = "); + expect(await readAuthContent(sandbox)).not.toContain("\nteam = "); // No membership data resolved, so no count bucket is recorded. const telemetryProperties = readCommandTelemetryProperties( @@ -2607,8 +2625,7 @@ describe("auth CLI login default team", () => { ); // The default is still persisted; only its effect is deferred // while the env override is set. - expect(await readFile(readSettingsFilePath(sandbox), "utf8")) - .toContain("team = \"alice-team\""); + expect(await readAuthContent(sandbox)).toContain("team = \"alice-team\""); } finally { await sandbox.cleanup(); @@ -2683,8 +2700,7 @@ describe("auth CLI login default team", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Default team identity: beta"); - expect(await readFile(readSettingsFilePath(sandbox), "utf8")) - .toContain("team = \"beta\""); + expect(await readAuthContent(sandbox)).toContain("team = \"beta\""); } finally { await sandbox.cleanup(); @@ -2729,8 +2745,7 @@ describe("auth CLI status default team", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); const fetcher = async (): Promise => new Response(null, { status: 200 }); @@ -2742,12 +2757,12 @@ describe("auth CLI status default team", () => { expect(textResult.exitCode).toBe(0); expect(textResult.stdout).toContain("- Default team: acme"); expect(jsonResult.exitCode).toBe(0); - // A config default already carries its name, so no lookup runs and - // there is no status to report. + // The account default already carries its name, so no lookup runs + // and there is no status to report. expect(parseAuthStatusTeam(jsonResult.stdout)).toEqual({ name: "acme", id: null, - source: "config", + source: "account", status: null, }); @@ -2757,7 +2772,7 @@ describe("auth CLI status default team", () => { "auth.status", ); expect(telemetryProperties).toMatchObject({ - team_source: "config", + team_source: "account", team_status: "none", }); expectTelemetryFreeOfTeamIdentity(telemetryProperties, ["acme"]); @@ -2767,13 +2782,13 @@ describe("auth CLI status default team", () => { } }); - test("reports the default team alongside the OO_API_KEY identity", async () => { + test("reports the personal identity under OO_API_KEY despite a saved default", async () => { const sandbox = await createCliSandbox(); sandbox.env.OO_API_KEY = "env-key-1"; try { - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); const fetcher = async (): Promise => new Response(null, { status: 200 }); @@ -2787,18 +2802,19 @@ describe("auth CLI status default team", () => { envOverride?: unknown; }; - // The team default is orthogonal to the credential source, so the - // env-credential block reports it too. + // OO_API_KEY may be a different account's credential, so no saved + // default applies to it; pinning a team there means OO_TEAM_ID / + // OO_TEAM_NAME. expect(textResult.exitCode).toBe(0); - expect(textResult.stdout).toContain("- Default team: acme"); + expect(textResult.stdout).toContain( + "- Default team: personal (no default team)", + ); expect(payload.status).toBe("logged-in"); expect(payload.envOverride).toBeDefined(); - expect(parseAuthStatusTeam(jsonResult.stdout)).toEqual({ - name: "acme", - id: null, - source: "config", - status: null, - }); + expect(parseAuthStatusTeam(jsonResult.stdout)).toBeUndefined(); + + expect(readCommandTelemetryProperties(sandbox, "auth.status")) + .toMatchObject({ team_source: "none", team_status: "none" }); } finally { await sandbox.cleanup(); @@ -2811,8 +2827,7 @@ describe("auth CLI status default team", () => { sandbox.env.OO_TEAM_ID = "team-42"; try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); const requests: Request[] = []; const fetcher = createAuthStatusFetcher(requests); diff --git a/src/application/commands/auth/login.ts b/src/application/commands/auth/login.ts index 4e653ff..de508d4 100644 --- a/src/application/commands/auth/login.ts +++ b/src/application/commands/auth/login.ts @@ -1,12 +1,14 @@ +import type { AccountDefaultTeam } from "../../auth/default-team.ts"; import type { CliCommandDefinition, CliExecutionContext, } from "../../contracts/cli.ts"; -import type { AuthAccount } from "../../schemas/auth.ts"; +import type { AuthAccount, AuthFile } from "../../schemas/auth.ts"; import type { TeamView } from "../team/shared.ts"; import { z } from "zod"; +import { writeDefaultTeam } from "../../auth/default-team.ts"; import { buildEnvApiKeyAccount, resolveLoginEndpoint, @@ -17,11 +19,7 @@ import { startAuthLoginSession, } from "../../auth/login-flow.ts"; import { CliUserError } from "../../contracts/cli.ts"; -import { upsertAuthAccount } from "../../schemas/auth.ts"; -import { - getConfiguredIdentityTeam, - setIdentityTeam, -} from "../../schemas/settings.ts"; +import { getCurrentAuthAccount, upsertAuthAccount } from "../../schemas/auth.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; import { writeLine } from "../shared/output.ts"; @@ -53,11 +51,11 @@ const authLoginCommandInputSchema = z.object({ ); // Which mechanism decided the default team after login. `flag` is an explicit -// `--team`, `kept_config` preserves a still-valid `identity.team`, +// `--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. type LoginTeamSelection - = "flag" | "kept_config" | "none" | "system_default" | "unresolved"; + = "flag" | "kept_existing" | "none" | "system_default" | "unresolved"; // How many team names the multi-team hint spells out before truncating with // an ellipsis. @@ -147,7 +145,12 @@ export const authLoginCommand: CliCommandDefinition = { ); } - await applyLoginTeamIdentity(account, input.team, context); + await applyLoginTeamIdentity( + account, + readStoredDefaultTeam(nextAuthFile), + input.team, + context, + ); // Connector routing does not change with this login: a configured // self-hosted connector keeps handling connector commands, which is @@ -237,13 +240,14 @@ function mapLoginInputError( return new CliUserError("errors.auth.sessionTokenRequired", 2); } -// Persists the default team identity (`identity.team`) right after login so -// later commands run as a team by default. An explicit `--team` must fail -// loudly (the caller asked for exactly that team), while the implicit flow is +// Persists the account's default team identity right after login so later +// commands run as a team by default. An explicit `--team` must fail loudly +// (the caller asked for exactly that team), while the implicit flow is // tolerant: login already succeeded, so a failed membership request only // prints a hint instead of flipping the exit code. async function applyLoginTeamIdentity( account: AuthAccount, + storedTeam: AccountDefaultTeam | undefined, requestedTeam: string | undefined, context: CliExecutionContext, ): Promise { @@ -272,6 +276,7 @@ async function applyLoginTeamIdentity( } const selection = await resolveLoginTeamSelection( + storedTeam, requestedTeam, teams, context, @@ -309,7 +314,9 @@ async function applyLoginTeamIdentity( const effectiveIdentity = await resolveTeamIdentity( { account: undefined, - configuredTeam: selection.team, + defaultTeam: selection.team === undefined + ? undefined + : { id: null, name: selection.team }, resolveAgainstBackend: false, }, context, @@ -326,35 +333,38 @@ async function applyLoginTeamIdentity( } // Picks the default team and persists it when it changes. Precedence: -// an explicit `--team` (must be a membership), then a still-valid configured -// `identity.team` (a re-login must not clobber a deliberate `oo team use`), -// then the backend-provisioned `system_created` team. A stale configured team -// is replaced rather than kept: after switching accounts the old name is not -// usable anyway. +// 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. async function resolveLoginTeamSelection( + storedTeam: AccountDefaultTeam | undefined, requestedTeam: string | undefined, teams: readonly TeamView[], context: CliExecutionContext, ): Promise<{ kind: LoginTeamSelection; team?: string }> { if (requestedTeam !== undefined) { - if (!teams.some(team => team.name === requestedTeam)) { + const team = teams.find(candidate => candidate.name === requestedTeam); + + if (team === undefined) { throw new CliUserError("errors.team.notAccessible", 1, { team: requestedTeam, }); } - await persistLoginTeamIdentity(requestedTeam, context); - return { kind: "flag", team: requestedTeam }; + await persistLoginTeamIdentity(team, context); + return { kind: "flag", team: team.name }; } - const settings = await context.settingsStore.read(); - const configuredTeam = getConfiguredIdentityTeam(settings); + const keptTeam = findStoredTeam(teams, storedTeam); - if ( - configuredTeam !== undefined - && teams.some(team => team.name === configuredTeam) - ) { - return { kind: "kept_config", team: configuredTeam }; + if (keptTeam !== undefined) { + // Re-persisted because the stored record may be incomplete or stale: + // this login has the membership listing in hand, so it backfills a + // missing id and refreshes a name the team has since changed. + await persistLoginTeamIdentity(keptTeam, context); + return { kind: "kept_existing", team: keptTeam.name }; } const systemTeam = teams.find(team => team.systemCreated); @@ -363,17 +373,47 @@ async function resolveLoginTeamSelection( return { kind: "none" }; } - await persistLoginTeamIdentity(systemTeam.name, context); + await persistLoginTeamIdentity(systemTeam, context); return { kind: "system_default", team: systemTeam.name }; } +// The account's stored default, as the selection needs it. Reads the account +// the upsert just saved, which carries the team fields forward from before the +// login. +function readStoredDefaultTeam( + authFile: AuthFile, +): AccountDefaultTeam | undefined { + const account = getCurrentAuthAccount(authFile); + + return account?.team === undefined + ? undefined + : { id: account.teamId ?? null, name: account.team }; +} + +// Finds the stored default among the memberships, by id whenever one is +// stored. A renamed team keeps its id, so matching the stale name instead +// would read a team the account still belongs to as gone and silently replace +// a deliberate `oo team use` with the system-created default. Only a default +// that never had an id — one migrated from the legacy global setting — falls +// back to matching by name. +function findStoredTeam( + teams: readonly TeamView[], + storedTeam: AccountDefaultTeam | undefined, +): TeamView | undefined { + if (storedTeam === undefined) { + return undefined; + } + + return storedTeam.id === null + ? teams.find(team => team.name === storedTeam.name) + : teams.find(team => team.id === storedTeam.id); +} + async function persistLoginTeamIdentity( - teamName: string, + team: TeamView, context: CliExecutionContext, ): Promise { - await context.settingsStore.update(settings => - setIdentityTeam(settings, teamName), - ); + await writeDefaultTeam(context, { id: team.id, name: team.name }); context.logger.info( { teamConfigured: true }, "Default team identity persisted after login.", diff --git a/src/application/commands/auth/status.ts b/src/application/commands/auth/status.ts index f7bebce..813d172 100644 --- a/src/application/commands/auth/status.ts +++ b/src/application/commands/auth/status.ts @@ -11,8 +11,8 @@ import type { TeamNameStatus, } from "../team/identity.ts"; import { z } from "zod"; +import { readDefaultTeam } from "../../auth/default-team.ts"; import { resolveIdentity } from "../../auth/identity.ts"; -import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; import { probeOo } from "../shared/oo-request.ts"; @@ -83,11 +83,11 @@ interface AuthStatusJsonEnvOverride { // The default team identity in effect for team-scoped commands, resolved the // same way `oo team current` resolves it: the OO_TEAM_ID / OO_TEAM_NAME env -// override outranks the `identity.team` config default. +// override outranks the account's saved default. // // `status` reports how the backend lookup ended and is `null` when none was // attempted — only env-selected identities are looked up (both directions), -// a config name never is. `envVar` is deliberately not part of this payload: +// a saved name never is. `envVar` is deliberately not part of this payload: // it is a hint for the text renderer, not a fact about the identity. interface AuthStatusJsonTeam { name: string | null; @@ -136,7 +136,7 @@ export const authStatusCommand: CliCommandDefinition = { // command is how status ends up naming the wrong account, the wrong // endpoint, and validating the wrong key. const { authFile, fileState, identity } = await resolveStatusState(context); - const settings = await context.settingsStore.read(); + const defaultTeam = await readDefaultTeam(context); // Status is the first command a user runs to diagnose auth problems, // so an unreadable auth.toml must not take the report down — it is @@ -169,7 +169,7 @@ export const authStatusCommand: CliCommandDefinition = { resolveTeamIdentity( { account: identity?.account, - configuredTeam: getConfiguredIdentityTeam(settings), + defaultTeam, resolveAgainstBackend: true, }, context, @@ -343,7 +343,7 @@ function writeSelfHostedConnectorText( } // Renders the default-team detail row. The value spells out the identity in -// effect: the config default by name, an env override with the variable that +// effect: the account default by name, an env override with the variable that // supplies it, or the personal fallback when no default team is set. function formatStatusTeamDetail( context: CliExecutionContext, diff --git a/src/application/commands/config/__snapshots__/index.cli.test.ts.snap b/src/application/commands/config/__snapshots__/index.cli.test.ts.snap index 657b0cd..fac8f15 100644 --- a/src/application/commands/config/__snapshots__/index.cli.test.ts.snap +++ b/src/application/commands/config/__snapshots__/index.cli.test.ts.snap @@ -201,48 +201,6 @@ exports[`config CLI supports the file download output directory config key 1`] = } `; -exports[`config CLI supports the identity team config key 1`] = ` -{ - "get": { - "exitCode": 0, - "stderr": "", - "stdout": -"acme -" -, - }, - "getAfterUnset": { - "exitCode": 0, - "stderr": "", - "stdout": "", - }, - "list": { - "exitCode": 0, - "stderr": "", - "stdout": -"identity.team=acme -" -, - }, - "set": { - "exitCode": 0, - "stderr": "", - "stdout": -"Set identity.team to acme. -" -, - }, - "unset": { - "exitCode": 0, - "stderr": "", - "stdout": -"Removed identity.team. -" -, - }, -} -`; - exports[`config CLI renders config list help with configured wording 1`] = ` { "chineseConfigHelp": { diff --git a/src/application/commands/config/index.cli.test.ts b/src/application/commands/config/index.cli.test.ts index d8ede21..8aef027 100644 --- a/src/application/commands/config/index.cli.test.ts +++ b/src/application/commands/config/index.cli.test.ts @@ -125,63 +125,29 @@ describe("config CLI", () => { } }); - test("supports the identity team config key", async () => { + test("no longer accepts the retired identity team config key", async () => { const sandbox = await createCliSandbox(); try { + // The default team moved onto the account (auth.toml), so this key + // is gone rather than redirected: writing it here would record a + // default that belongs to no account in particular. const setResult = await sandbox.run([ "config", "set", "identity.team", "acme", ]); - const listResult = await sandbox.run(["config", "list"]); const getResult = await sandbox.run([ "config", "get", "identity.team", ]); - const unsetResult = await sandbox.run([ - "config", - "unset", - "identity.team", - ]); - const getAfterUnsetResult = await sandbox.run([ - "config", - "get", - "identity.team", - ]); - - expect({ - get: createCliSnapshot(getResult), - getAfterUnset: createCliSnapshot(getAfterUnsetResult), - list: createCliSnapshot(listResult), - set: createCliSnapshot(setResult), - unset: createCliSnapshot(unsetResult), - }).toMatchSnapshot(); - expect(setResult.stdout).toContain("Set identity.team to acme."); - expect(listResult.stdout).toContain("identity.team=acme"); - expect(getResult.stdout).toContain("acme"); - expect(getAfterUnsetResult.stdout).not.toContain("acme"); - } - finally { - await sandbox.cleanup(); - } - }); - - test("rejects an empty identity team config value", async () => { - const sandbox = await createCliSandbox(); - - try { - const result = await sandbox.run([ - "config", - "set", - "identity.team", - " ", - ]); + const listResult = await sandbox.run(["config", "list"]); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("Invalid identity.team value"); + expect(setResult.exitCode).toBe(2); + expect(getResult.exitCode).toBe(2); + expect(listResult.stdout).not.toContain("identity.team"); } finally { await sandbox.cleanup(); diff --git a/src/application/commands/config/shared.ts b/src/application/commands/config/shared.ts index 5dca8af..88dbec6 100644 --- a/src/application/commands/config/shared.ts +++ b/src/application/commands/config/shared.ts @@ -4,14 +4,11 @@ import { z } from "zod"; import { CliUserError } from "../../contracts/cli.ts"; import { getConfiguredFileDownloadOutDir, - getConfiguredIdentityTeam, getConfiguredTelemetryEnabled, localeSchema, setFileDownloadOutDir, - setIdentityTeam, setTelemetryEnabled, unsetFileDownloadOutDir, - unsetIdentityTeam, unsetTelemetryEnabled, } from "../../schemas/settings.ts"; @@ -36,7 +33,6 @@ function createValueErrorFactory(translationKey: string) { } const fileDownloadOutDirConfigKey = "file.download.out_dir" as const; -const identityTeamConfigKey = "identity.team" as const; export const telemetryEnabledConfigKey = "telemetry.enabled" as const; export const configDefinitions = { @@ -116,27 +112,6 @@ export const configDefinitions = { return unsetTelemetryEnabled(settings); }, } satisfies ConfigDefinition, - [identityTeamConfigKey]: { - createInvalidValueError: createValueErrorFactory("errors.config.invalidIdentityTeamValue"), - getValue(settings: AppSettings): string | undefined { - return getConfiguredIdentityTeam(settings); - }, - parseRawValue(rawValue: string): ParsedConfigValue | undefined { - const value = rawValue.trim(); - - if (value === "") { - return undefined; - } - - return { - apply: settings => setIdentityTeam(settings, value), - renderedValue: value, - }; - }, - unsetValue(settings: AppSettings): AppSettings { - return unsetIdentityTeam(settings); - }, - } satisfies ConfigDefinition, } as const; export type ConfigKey = keyof typeof configDefinitions; diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index 60c447e..5e850d2 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -14,6 +14,7 @@ import { readLatestLogContent, toRequest, writeAuthFile, + writeAuthFileWithDefaultTeam, writeConnectorFile, } from "../../../../__tests__/helpers.ts"; import { SqliteCacheStore } from "../../../adapters/cache/sqlite-cache.ts"; @@ -62,6 +63,37 @@ describe("connectorCommand CLI", () => { } }); + test("connector search sends both headers for an account default carrying an id", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "acme", { + teamId: "team-1", + }); + + const requests: Request[] = []; + const result = await sandbox.run( + ["connector", "search", "send mail"], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return createConnectorSearchResponse([]); + }, + }, + ); + + expect(result.exitCode).toBe(0); + // A stored id reaches the gateway on the default path, matching + // what an OO_TEAM_NAME run already sends once its lookup resolves. + expect(requests[0]?.headers.get("x-oo-team-name")).toBe("acme"); + expect(requests[0]?.headers.get("x-oo-team-id")).toBe("team-1"); + } + finally { + await sandbox.cleanup(); + } + }); + test("supports connector search with text output without caching schema-less results", async () => { const sandbox = await createCliSandbox(); diff --git a/src/application/commands/connector/session.test.ts b/src/application/commands/connector/session.test.ts index e67485b..e361fc9 100644 --- a/src/application/commands/connector/session.test.ts +++ b/src/application/commands/connector/session.test.ts @@ -183,7 +183,7 @@ describe("resolveConnectorSession identity ladder", () => { expect(session.identity).toEqual({ name: "acme", id: null, - source: "config", + source: "account", status: null, }); expect(context.requests).toHaveLength(0); diff --git a/src/application/commands/connector/session.ts b/src/application/commands/connector/session.ts index 69c5dd3..c007ece 100644 --- a/src/application/commands/connector/session.ts +++ b/src/application/commands/connector/session.ts @@ -6,17 +6,17 @@ // ladder with its execution gate, and the identity telemetry. // // The self-hosted runtime is single-user and has no team concept: an explicit -// --team is a hard error, while a configured `identity.team` default or a -// team env override is silently ignored so a shared config does not break -// self-hosted usage. This module is the only place that rule exists. +// --team is a hard error, while the account's default team or a team env +// override is silently ignored so a shared config does not break self-hosted +// usage. This module is the only place that rule exists. import type { CliExecutionContext, CliOptionDefinition } from "../../contracts/cli.ts"; import type { TeamIdentity } from "../team/identity.ts"; import type { ConnectorTarget } from "./target.ts"; import { z } from "zod"; +import { readDefaultTeam } from "../../auth/default-team.ts"; import { CliUserError } from "../../contracts/cli.ts"; -import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; import { requireValidTeamIdentity, resolveTeamIdentity, @@ -49,8 +49,8 @@ export type ConnectorSessionContext = Pick< * Guards fire before any resolution: combining the two flags or passing a * blank `--team` is a usage error, and a self-hosted target rejects `--team` * outright. The team identity then resolves through the one ladder - * (`--personal` > `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > `identity.team` > - * personal) with the target's credential backing the env lookups, and + * (`--personal` > `--team` > `OO_TEAM_ID` > `OO_TEAM_NAME` > the account + * default > personal) with the target's credential backing the env lookups, and * `requireValidTeamIdentity` gates execution on the outcome. * * `resolveAgainstBackend: false` (a dry run) keeps the resolution fully @@ -81,7 +81,6 @@ export async function resolveConnectorSession( throw new CliUserError("errors.connector.teamUnsupported", 2); } - const settings = await context.settingsStore.read(); const identity = target.kind === "self_hosted" ? undefined : requireValidTeamIdentity( @@ -94,7 +93,7 @@ export async function resolveConnectorSession( apiKey: target.authorization, endpoint: target.accountEndpoint, }, - configuredTeam: getConfiguredIdentityTeam(settings), + defaultTeam: await readDefaultTeam(context), teamFlag, personalFlag: options.personal === true, resolveAgainstBackend: options.resolveAgainstBackend !== false, diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 2ac76f9..90f8c9b 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -297,7 +297,7 @@ describe("connector shared requests", () => { identity: { name: "acme", id: null, - source: "config", + source: "account", status: null, }, inputData: { @@ -514,7 +514,7 @@ describe("connector shared requests", () => { identity: { name: "acme", id: null, - source: "config", + source: "account", status: null, }, proxyRequest: { diff --git a/src/application/commands/team/clear.ts b/src/application/commands/team/clear.ts index 99c3542..f91e83a 100644 --- a/src/application/commands/team/clear.ts +++ b/src/application/commands/team/clear.ts @@ -1,40 +1,48 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; +import { clearDefaultTeam } from "../../auth/default-team.ts"; import { - getConfiguredIdentityTeam, - unsetIdentityTeam, -} from "../../schemas/settings.ts"; + buildEnvApiKeyAccount, + reportOverriddenWrite, +} from "../../auth/identity.ts"; import { writeLine } from "../shared/output.ts"; import { resolveTeamIdentity } from "./identity.ts"; -// Clears the default team identity (config `identity.team`), returning -// connector commands to the personal identity. Offline: it only rewrites local -// settings. When OO_TEAM_ID / OO_TEAM_NAME is set, the env override keeps -// selecting a team regardless of the cleared default, so the output says so -// instead of promising a personal identity. +// Clears the active account's default team identity, returning connector +// commands to the personal identity. Offline: it only rewrites local state. +// When OO_TEAM_ID / OO_TEAM_NAME is set, the env override keeps selecting a +// team regardless of the cleared default, so the output says so instead of +// promising a personal identity. export const teamClearCommand: CliCommandDefinition = { name: "clear", summaryKey: "commands.team.clear.summary", descriptionKey: "commands.team.clear.description", inputSchema: z.object({}), handler: async (_input, context) => { - const settings = await context.settingsStore.read(); - const hadConfiguredTeam - = getConfiguredIdentityTeam(settings) !== undefined; - // Offline resolution with no config default: what remains is exactly + // Nothing persisted is in effect under OO_API_KEY, so there is nothing + // this command could clear that a later command would notice. + if (buildEnvApiKeyAccount(context.env) !== undefined) { + reportOverriddenWrite(context, { + summaryKey: "team.clear.envOverrideNoop", + }); + return; + } + + // Offline resolution with no account default: what remains is exactly // the env override that would keep selecting a team after the clear, // with `envVar` naming it for the hint. const envIdentity = await resolveTeamIdentity( { account: undefined, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: false, }, context, ); + const hadDefaultTeam = await clearDefaultTeam(context); - if (!hadConfiguredTeam) { + if (!hadDefaultTeam) { writeLine( context.stdout, envIdentity?.envVar === undefined @@ -49,8 +57,6 @@ export const teamClearCommand: CliCommandDefinition = { return; } - await context.settingsStore.update(unsetIdentityTeam); - context.logger.info( { teamConfigured: false }, "Default team identity cleared.", diff --git a/src/application/commands/team/current.ts b/src/application/commands/team/current.ts index d429b0a..dd6bd98 100644 --- a/src/application/commands/team/current.ts +++ b/src/application/commands/team/current.ts @@ -5,8 +5,8 @@ import type { TeamNameStatus, } from "./identity.ts"; import { z } from "zod"; +import { readDefaultTeam } from "../../auth/default-team.ts"; import { resolveIdentity } from "../../auth/identity.ts"; -import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; import { writeLine } from "../shared/output.ts"; import { appendTeamIdentityStatus, @@ -16,12 +16,12 @@ import { } from "./identity.ts"; // `source` says which mechanism selects the team: the OO_TEAM_ID / -// OO_TEAM_NAME env override, the `identity.team` config default, or none +// OO_TEAM_NAME env override, the account's saved default, or none // (personal). `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 config name never is. +// direction they are missing, a saved default never is. interface TeamCurrentJsonPayload { team: string | null; teamId: string | null; @@ -31,13 +31,13 @@ interface TeamCurrentJsonPayload { // Reports the team identity that connector commands use when no `--team` / // `--personal` flag is given: the OO_TEAM_ID / OO_TEAM_NAME env override when -// set, otherwise the `identity.team` config default. +// set, otherwise the active account's saved default team. // // 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 config default stays offline, as does an +// 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. export const teamCurrentCommand: CliCommandDefinition = { @@ -47,18 +47,17 @@ export const teamCurrentCommand: CliCommandDefinition = { output: "standard", inputSchema: z.object({}), handler: async (_input, context) => { - const [settings, { account }] = await Promise.all([ - context.settingsStore.read(), + const [defaultTeam, { account }] = await Promise.all([ + readDefaultTeam(context), resolveIdentity(context), ]); - const configuredTeam = getConfiguredIdentityTeam(settings); const identity = await resolveTeamIdentity( - { account, configuredTeam, resolveAgainstBackend: true }, + { account, defaultTeam, resolveAgainstBackend: true }, context, ); context.telemetry?.recordProperties({ - has_configured_team: configuredTeam !== undefined, + has_configured_team: defaultTeam !== undefined, team_source: identity?.source ?? "none", team_status: teamNameStatusForTelemetry(identity), }); @@ -81,10 +80,10 @@ export const teamCurrentCommand: CliCommandDefinition = { const teamValue = formatTeamIdentityValue(identity, context.translator); - if (identity.source === "config") { + if (identity.source === "account") { writeLine( context.stdout, - context.translator.t("team.current.text.configured", { + context.translator.t("team.current.text.accountDefault", { team: teamValue, }), ); @@ -107,15 +106,15 @@ export const teamCurrentCommand: CliCommandDefinition = { ), ); - // The config default is still on disk and takes over the moment the + // The saved default is still on disk and takes over the moment the // variable is unset, so saying so here heads off a later "my default // did not apply" report. - if (configuredTeam !== undefined && identity.envVar !== undefined) { + if (defaultTeam !== undefined && identity.envVar !== undefined) { writeLine( context.stdout, - context.translator.t("team.current.text.configIgnored", { + context.translator.t("team.current.text.accountDefaultIgnored", { envVar: identity.envVar, - team: configuredTeam, + team: defaultTeam.name, }), ); } diff --git a/src/application/commands/team/identity.test.ts b/src/application/commands/team/identity.test.ts index e03cc0c..65824fc 100644 --- a/src/application/commands/team/identity.test.ts +++ b/src/application/commands/team/identity.test.ts @@ -42,7 +42,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext({}), @@ -55,7 +55,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: "config-team", + defaultTeam: { id: null, name: "config-team" }, teamFlag: "flag-team", personalFlag: true, resolveAgainstBackend: true, @@ -78,7 +78,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: "config-team", + defaultTeam: { id: null, name: "config-team" }, teamFlag: "flag-team", resolveAgainstBackend: true, }, @@ -104,7 +104,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: "config-team", + defaultTeam: { id: null, name: "config-team" }, teamFlag, resolveAgainstBackend: true, }, @@ -112,7 +112,7 @@ describe("resolveTeamIdentity precedence", () => { )).toEqual({ name: "config-team", id: null, - source: "config", + source: "account", status: null, }); }); @@ -121,7 +121,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: " ", + defaultTeam: { id: null, name: " " }, resolveAgainstBackend: true, }, createContext({}), @@ -134,7 +134,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: "acme", + defaultTeam: { id: null, name: "acme" }, resolveAgainstBackend: true, }, createContext({}, async () => { @@ -145,7 +145,7 @@ describe("resolveTeamIdentity precedence", () => { )).toEqual({ name: "acme", id: null, - source: "config", + source: "account", status: null, }); expect(requested).toBe(false); @@ -155,7 +155,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: "acme", + defaultTeam: { id: null, name: "acme" }, resolveAgainstBackend: true, }, createContext( @@ -169,18 +169,18 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: "acme", + defaultTeam: { id: null, name: "acme" }, resolveAgainstBackend: true, }, createContext({ OO_TEAM_ID: " ", OO_TEAM_NAME: "" }), - )).toMatchObject({ source: "config", name: "acme" }); + )).toMatchObject({ source: "account", name: "acme" }); }); test("trims the env-supplied value", async () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: false, }, createContext({ OO_TEAM_ID: " team-1 " }), @@ -191,7 +191,7 @@ describe("resolveTeamIdentity precedence", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: false, }, createContext({ OO_TEAM_ID: " ", OO_TEAM_NAME: "acme" }), @@ -211,7 +211,7 @@ describe("resolveTeamIdentity env validation", () => { const identity = await resolveTeamIdentity( { account: testAccount, - configuredTeam: "acme", + defaultTeam: { id: null, name: "acme" }, resolveAgainstBackend: true, }, createContext({ OO_TEAM_ID: "team-1" }, async (input, init) => { @@ -240,7 +240,7 @@ describe("resolveTeamIdentity env validation", () => { const identity = await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext({ OO_TEAM_NAME: "beta" }, async (input, init) => { @@ -268,7 +268,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext( @@ -288,7 +288,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext( @@ -308,7 +308,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext( @@ -322,7 +322,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext( @@ -342,7 +342,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext({ OO_TEAM_NAME: "beta" }, async () => { @@ -381,7 +381,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: undefined, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: true, }, createContext(env, async () => { @@ -423,7 +423,7 @@ describe("resolveTeamIdentity env validation", () => { expect(await resolveTeamIdentity( { account: testAccount, - configuredTeam: undefined, + defaultTeam: undefined, resolveAgainstBackend: false, }, createContext(env, async () => { @@ -445,7 +445,7 @@ describe("requireValidTeamIdentity", () => { }, { case: "a config identity", - identity: { name: "acme", id: null, source: "config", status: null }, + identity: { name: "acme", id: null, source: "account", status: null }, }, { case: "a validated env identity", @@ -606,7 +606,7 @@ describe("formatTeamIdentityValue", () => { }, ])("falls back to the $case", ({ expected, identity }) => { expect(formatTeamIdentityValue( - { ...identity, source: "config", status: null }, + { ...identity, source: "account", status: null }, translator, )).toBe(expected); }); @@ -652,7 +652,7 @@ describe("teamNameStatusForTelemetry", () => { identity: { name: "acme", id: null, - source: "config" as const, + source: "account" as const, status: null, }, expected: "none", diff --git a/src/application/commands/team/identity.ts b/src/application/commands/team/identity.ts index e6c1f93..56af42e 100644 --- a/src/application/commands/team/identity.ts +++ b/src/application/commands/team/identity.ts @@ -2,7 +2,7 @@ // is real. // // Three modules used to each encode the "--personal > --team > OO_TEAM_ID > -// OO_TEAM_NAME > identity.team > personal" ladder and validate only the +// OO_TEAM_NAME > the account default > personal" ladder and validate only the // direction they happened to resolve, which is how `oo auth status` could // vouch for an identity `oo connector run` rejected — and vice versa. Every // team-aware command now resolves through this module, so the ladder, the @@ -12,6 +12,7 @@ // resolveTeamIdentity returns undefined for it, whether it was picked // explicitly (`--personal`) or by nothing else selecting a team. +import type { AccountDefaultTeam } from "../../auth/default-team.ts"; import type { CliExecutionContext } from "../../contracts/cli.ts"; import type { AuthAccount } from "../../schemas/auth.ts"; @@ -21,10 +22,10 @@ import { CliUserError } from "../../contracts/cli.ts"; import { 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; `config` is the persisted -// `identity.team` default. Recorded as privacy-safe telemetry; it never +// 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 = "config" | "env_id" | "env_name" | "flag"; +export type TeamIdentitySource = "account" | "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. @@ -35,11 +36,11 @@ export interface TeamIdentity { 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 config name - // is the gateway's to judge, so those stay `null` on every path. + // 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. status: TeamNameStatus | null; // The env variable supplying the override, for user-facing hints; absent - // for the flag and config sources. + // for the flag and account sources. envVar?: string; } @@ -50,10 +51,10 @@ type ResolveTeamIdentityContext = Pick< /** * Resolves the team identity from the per-run flags, the env override, and - * the configured default — one ladder for every team-aware command: - * `--personal` > `--team` > OO_TEAM_ID > OO_TEAM_NAME > `identity.team` > + * the account default — one ladder for every team-aware command: + * `--personal` > `--team` > OO_TEAM_ID > OO_TEAM_NAME > the account default > * personal (undefined). A higher tier fully replaces the lower ones, and a - * trimmed-empty flag or config value counts as unset. + * 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 @@ -69,7 +70,7 @@ type ResolveTeamIdentityContext = Pick< export async function resolveTeamIdentity( input: { account: Pick | undefined; - configuredTeam: string | undefined; + defaultTeam: AccountDefaultTeam | undefined; teamFlag?: string; personalFlag?: boolean; resolveAgainstBackend: boolean; @@ -92,10 +93,18 @@ export async function resolveTeamIdentity( return resolveEnvTeamIdentity(envOverride, input, context); } - const configuredTeam = normalizeTeamValue(input.configuredTeam); - - if (configuredTeam !== undefined) { - return { name: configuredTeam, id: null, source: "config", status: null }; + const defaultTeamName = normalizeTeamValue(input.defaultTeam?.name); + + if (defaultTeamName !== undefined) { + return { + 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 + // legacy global setting carries the name alone. + id: input.defaultTeam?.id ?? null, + source: "account", + status: null, + }; } return undefined; @@ -256,7 +265,7 @@ async function resolveEnvTeamIdentity( }; } -// One empty-value policy for the flag and config tiers: a trimmed-empty team +// One empty-value policy for the flag and account tiers: a trimmed-empty team // name behaves exactly like an unset one, matching how the env readers treat // blank variables. function normalizeTeamValue(value: string | undefined): string | undefined { @@ -268,7 +277,7 @@ function normalizeTeamValue(value: string | undefined): string | undefined { // --------------------------------------------------------------------------- // The env override tier. OO_TEAM_ID carries the stable team id, OO_TEAM_NAME // the team name, for embedded and automated callers that pin the team without -// touching the `identity.team` config default. Private to the resolver: which +// touching the account default. Private to the resolver: which // variable won, and what it means, surfaces only through the TeamIdentity // record (`source`, `envVar`). // --------------------------------------------------------------------------- diff --git a/src/application/commands/team/index.cli.test.ts b/src/application/commands/team/index.cli.test.ts index ddd7bf9..817cd5c 100644 --- a/src/application/commands/team/index.cli.test.ts +++ b/src/application/commands/team/index.cli.test.ts @@ -1,3 +1,4 @@ +import type { CliSandbox } from "../../../../__tests__/helpers.ts"; import type { Fetcher } from "../../contracts/cli.ts"; import { join } from "node:path"; @@ -9,6 +10,7 @@ import { expectTelemetryFreeOfTeamIdentity, toRequest, writeAuthFile, + writeAuthFileWithDefaultTeam, } from "../../../../__tests__/helpers.ts"; import { APP_NAME } from "../../config/app-config.ts"; import { @@ -40,8 +42,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); const requests: Request[] = []; const result = await sandbox.run(["team", "list", "--json"], { @@ -145,8 +146,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); const jsonResult = await sandbox.run(["team", "current", "--json"]); const textResult = await sandbox.run(["team", "current"]); @@ -155,7 +155,7 @@ describe("teamCommand CLI", () => { expect(JSON.parse(jsonResult.stdout)).toEqual({ team: "acme", teamId: null, - source: "config", + source: "account", status: null, }); expect(textResult.stdout).toContain("acme"); @@ -206,10 +206,12 @@ describe("teamCommand CLI", () => { 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. expect(JSON.parse(currentResult.stdout)).toEqual({ team: "beta", - teamId: null, - source: "config", + teamId: "team-2", + source: "account", status: null, }); } @@ -246,8 +248,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); const clearResult = await sandbox.run(["team", "clear"]); const currentResult = await sandbox.run(["team", "current", "--json"]); @@ -285,8 +286,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "beta"]); + await writeAuthFileWithDefaultTeam(sandbox, "beta"); sandbox.env.OO_TEAM_ID = "team-1"; const requests: Request[] = []; @@ -423,8 +423,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); sandbox.env.OO_TEAM_NAME = "beta"; const requests: Request[] = []; @@ -485,8 +484,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); sandbox.env.OO_TEAM_ID = "team-2"; const result = await sandbox.run(["team", "list", "--json"], { @@ -530,8 +528,7 @@ describe("teamCommand CLI", () => { const sandbox = await createCliSandbox(); try { - await writeAuthFile(sandbox); - await sandbox.run(["config", "set", "identity.team", "acme"]); + await writeAuthFileWithDefaultTeam(sandbox, "acme"); sandbox.env.OO_TEAM_ID = "team-1"; const clearResult = await sandbox.run(["team", "clear"]); @@ -556,8 +553,213 @@ describe("teamCommand CLI", () => { await sandbox.cleanup(); } }); + + test("backfills the team id of a default that only has a name", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFileWithDefaultTeam(sandbox, "acme"); + + const listResult = await sandbox.run(["team", "list"], { + fetcher: async () => new Response(JSON.stringify(teamsResponse)), + }); + const currentResult = await sandbox.run(["team", "current", "--json"]); + + expect(listResult.exitCode).toBe(0); + // Listing already fetched the memberships, so the stored default + // gains its id without any extra request. + expect(JSON.parse(currentResult.stdout)).toMatchObject({ + team: "acme", + teamId: "team-1", + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("refuses to save a default team under OO_API_KEY", async () => { + const sandbox = await createCliSandbox(); + + sandbox.env.OO_API_KEY = "env-key-1"; + + try { + await writeAuthFile(sandbox); + + let requested = false; + const useResult = await sandbox.run(["team", "use", "beta"], { + fetcher: async () => { + requested = true; + + return new Response(JSON.stringify(teamsResponse)); + }, + }); + const authContent = await readAuthFileContent(sandbox); + + expect(useResult.exitCode).toBe(0); + expect(useResult.stdout).toContain("OO_API_KEY"); + // No membership request and no write: there is no account under + // OO_API_KEY that could hold the default. + expect(requested).toBe(false); + expect(authContent).not.toContain("\nteam = "); + } + finally { + await sandbox.cleanup(); + } + }); + + test("reports that clearing does nothing under OO_API_KEY", async () => { + const sandbox = await createCliSandbox(); + + sandbox.env.OO_API_KEY = "env-key-1"; + + try { + await writeAuthFileWithDefaultTeam(sandbox, "acme"); + + const result = await sandbox.run(["team", "clear"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("OO_API_KEY"); + // The saved account keeps its default; it simply does not apply + // while OO_API_KEY supplies the credential. + expect(await readAuthFileContent(sandbox)).toContain("team = \"acme\""); + } + finally { + await sandbox.cleanup(); + } + }); +}); + +describe("legacy default team migration", () => { + test("moves the legacy setting onto the account on any command", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + await writeLegacySettingsTeam(sandbox, "acme"); + + // 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"]); + + expect(versionResult.exitCode).toBe(0); + expect(await readAuthFileContent(sandbox)).toContain("team = \"acme\""); + expect(await readSettingsFileContent(sandbox)).not.toContain( + "\nteam = ", + ); + expect(JSON.parse(currentResult.stdout)).toEqual({ + team: "acme", + teamId: null, + source: "account", + status: null, + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("keeps honouring the legacy setting while no account can hold it", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeLegacySettingsTeam(sandbox, "acme"); + + const currentResult = await sandbox.run(["team", "current", "--json"]); + + // Nothing to migrate onto, so the value stays put and still + // resolves rather than silently becoming a personal identity. + expect(await readSettingsFileContent(sandbox)).toContain( + "team = \"acme\"", + ); + expect(JSON.parse(currentResult.stdout)).toMatchObject({ + team: "acme", + source: "account", + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("leaves the legacy setting alone under OO_API_KEY", async () => { + const sandbox = await createCliSandbox(); + + sandbox.env.OO_API_KEY = "env-key-1"; + + try { + await writeAuthFile(sandbox); + await writeLegacySettingsTeam(sandbox, "acme"); + + const currentResult = await sandbox.run(["team", "current", "--json"]); + + // OO_API_KEY runs as a personal identity, and the untouched value + // still applies the moment the variable is unset. + expect(JSON.parse(currentResult.stdout)).toEqual({ + team: null, + teamId: null, + source: null, + status: null, + }); + expect(await readSettingsFileContent(sandbox)).toContain( + "team = \"acme\"", + ); + expect(await readAuthFileContent(sandbox)).not.toContain("\nteam = "); + } + finally { + await sandbox.cleanup(); + } + }); + + test("survives an unrelated settings write before migrating", async () => { + const sandbox = await createCliSandbox(); + + sandbox.env.OO_API_KEY = "env-key-1"; + + try { + await writeAuthFile(sandbox); + await writeLegacySettingsTeam(sandbox, "acme"); + + // Under OO_API_KEY the migration is skipped, so this write is the + // one that would otherwise erase a value nothing has moved yet. + const configResult = await sandbox.run(["config", "set", "lang", "zh"]); + + expect(configResult.exitCode).toBe(0); + expect(await readSettingsFileContent(sandbox)).toContain( + "team = \"acme\"", + ); + } + finally { + await sandbox.cleanup(); + } + }); }); +// Writes a settings.toml carrying the retired global default team, the state +// an installation that predates the account-scoped default starts from. +async function writeLegacySettingsTeam( + sandbox: CliSandbox, + team: string, +): Promise { + await Bun.write( + resolveStoreFilePath(sandbox, "settings.toml"), + `[identity]\nteam = "${team}"\n`, + ); +} + +async function readAuthFileContent(sandbox: CliSandbox): Promise { + return await Bun.file(resolveStoreFilePath(sandbox, "auth.toml")).text(); +} + +async function readSettingsFileContent(sandbox: CliSandbox): Promise { + return await Bun.file(resolveStoreFilePath(sandbox, "settings.toml")).text(); +} + +function resolveStoreFilePath(sandbox: CliSandbox, fileName: string): string { + return join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, fileName); +} + // Answers the singular team route with the first fixture team and records what // was asked, so a test can assert both the resolved name and the route used. function createTeamLookupFetcher(requests: Request[]): Fetcher { diff --git a/src/application/commands/team/list.ts b/src/application/commands/team/list.ts index 128daf5..cbc55d4 100644 --- a/src/application/commands/team/list.ts +++ b/src/application/commands/team/list.ts @@ -1,12 +1,13 @@ +import type { AccountDefaultTeam } from "../../auth/default-team.ts"; import type { CliCommandDefinition, CliExecutionContext } from "../../contracts/cli.ts"; -import type { TerminalColors } from "../../terminal-colors.ts"; +import type { TerminalColors } from "../../terminal-colors.ts"; import type { TeamIdentity } from "./identity.ts"; -import type { TeamRole, TeamView } from "./shared.ts"; +import type { TeamRole, TeamView } from "./shared.ts"; import { z } from "zod"; +import { readDefaultTeam, writeDefaultTeam } from "../../auth/default-team.ts"; import { requireIdentity } from "../../auth/identity.ts"; -import { getConfiguredIdentityTeam } from "../../schemas/settings.ts"; import { bucketTelemetryCount } from "../../telemetry/buckets.ts"; import { createWriterColors } from "../../terminal-colors.ts"; import { resolveTeamIdentity } from "./identity.ts"; @@ -27,21 +28,20 @@ export const teamListCommand: CliCommandDefinition = { inputSchema: z.object({}), handler: async (_input, context) => { const { account } = await requireIdentity(context); - const settings = await context.settingsStore.read(); + const defaultTeam = await readDefaultTeam(context); // The listing itself is the membership set, so the identity resolves // offline and each row is matched against the record locally. const identity = await resolveTeamIdentity( - { - account, - configuredTeam: getConfiguredIdentityTeam(settings), - resolveAgainstBackend: false, - }, + { account, defaultTeam, resolveAgainstBackend: false }, context, ); const isCurrent = (team: TeamView): boolean => isCurrentTeamRow(team, identity); const teams = await listMemberTeams(account, context); + + await backfillDefaultTeamId(context, defaultTeam, teams); + const output = teams.map(team => createTeamListItem(team, isCurrent)); context.telemetry?.recordProperties({ @@ -60,10 +60,47 @@ export const teamListCommand: CliCommandDefinition = { }, }; +// Completes a default team that only has a name. This command already holds +// the membership listing, so the id costs nothing here — which is the whole +// reason the backfill lives in this command rather than in a lookup of its +// own. A default migrated from the legacy global setting is the case that +// needs it. +// +// Best effort: listing teams is a read, and it must not start failing because +// the file happens to be unwritable. +async function backfillDefaultTeamId( + context: Pick< + CliExecutionContext, + "authStore" | "logger" | "settingsStore" | "telemetry" + >, + defaultTeam: AccountDefaultTeam | undefined, + teams: readonly TeamView[], +): Promise { + if (defaultTeam === undefined || defaultTeam.id !== null) { + return; + } + + const match = teams.find(team => team.name === defaultTeam.name); + + if (match === undefined) { + return; + } + + try { + await writeDefaultTeam(context, { id: match.id, name: match.name }); + } + catch (error) { + context.logger.debug( + { err: error }, + "Default team id backfill did not complete.", + ); + } +} + // Decides which row is the effective default for connector commands. An // offline identity carries exactly the dimension its source supplies — the id -// under OO_TEAM_ID, the name under OO_TEAM_NAME or the config default — so -// the row is matched on whichever one is known. +// under OO_TEAM_ID, the name under OO_TEAM_NAME, and whichever the account +// default stored — so the row is matched on whichever one is known. function isCurrentTeamRow( team: TeamView, identity: TeamIdentity | undefined, @@ -97,9 +134,9 @@ interface TeamListColumn { } // Renders the team listing as a color-coded, column-aligned table. The -// effective default (the OO_TEAM_ID / OO_TEAM_NAME env override, or -// `identity.team`) is marked so callers can see at a glance which team -// `oo connector run` uses without `--team`. +// effective default (the OO_TEAM_ID / OO_TEAM_NAME env override, or the +// account's saved default) is marked so callers can see at a glance which +// team `oo connector run` uses without `--team`. export function formatTeamsAsText( teams: readonly TeamListItem[], translator: TeamListTranslator, diff --git a/src/application/commands/team/use.ts b/src/application/commands/team/use.ts index 9120b7f..24e5327 100644 --- a/src/application/commands/team/use.ts +++ b/src/application/commands/team/use.ts @@ -1,9 +1,13 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; import { z } from "zod"; -import { requireIdentity } from "../../auth/identity.ts"; +import { writeDefaultTeam } from "../../auth/default-team.ts"; +import { + buildEnvApiKeyAccount, + reportOverriddenWrite, + requireIdentity, +} from "../../auth/identity.ts"; import { CliUserError } from "../../contracts/cli.ts"; -import { setIdentityTeam } from "../../schemas/settings.ts"; import { writeLine } from "../shared/output.ts"; import { resolveTeamIdentity } from "./identity.ts"; import { listMemberTeams } from "./shared.ts"; @@ -12,10 +16,11 @@ interface TeamUseInput { name: string; } -// Sets the default team identity (config `identity.team`) after confirming the -// account is actually a member of it. The membership check is the value over a -// bare `oo config set identity.team`: it rejects typos and stale names up front -// instead of surfacing them later on a connector run. +// Sets the default team identity on the active account after confirming the +// account is actually a member of it. The membership check is the value this +// command adds over editing auth.toml by hand: it rejects typos and stale +// names up front instead of surfacing them later on a connector run, and it +// is what supplies the team id stored alongside the name. export const teamUseCommand: CliCommandDefinition = { name: "use", summaryKey: "commands.team.use.summary", @@ -37,18 +42,27 @@ export const teamUseCommand: CliCommandDefinition = { throw new CliUserError("errors.team.nameEmpty", 2); } + // The default team belongs to a saved account, and OO_API_KEY has + // none: its credential may not even be the same account. Writing here + // would record a default no command under this variable ever reads. + if (buildEnvApiKeyAccount(context.env) !== undefined) { + reportOverriddenWrite(context, { + summaryKey: "team.use.envOverrideNoop", + }); + return; + } + const { account } = await requireIdentity(context); const teams = await listMemberTeams(account, context); + const team = teams.find(candidate => candidate.name === name); - if (!teams.some(team => team.name === name)) { + if (team === undefined) { throw new CliUserError("errors.team.notAccessible", 1, { team: name, }); } - await context.settingsStore.update(settings => - setIdentityTeam(settings, name), - ); + await writeDefaultTeam(context, { id: team.id, name: team.name }); context.logger.info( { teamConfigured: true }, @@ -59,15 +73,16 @@ export const teamUseCommand: CliCommandDefinition = { context.translator.t("team.use.success", { team: name }), ); - // Mirrors `oo auth login` under OO_API_KEY: the default is saved, but - // the env override keeps outranking it, so say so instead of letting - // the success line imply the new default is in effect. The offline - // resolution answers "which identity is actually in effect now" — - // `envVar` is set exactly when an env override won over the default. + // Mirrors `oo auth login` under OO_TEAM_ID / OO_TEAM_NAME: the default + // is saved, but the env override keeps outranking it, so say so + // instead of letting the success line imply the new default is in + // effect. The offline resolution answers "which identity is actually + // in effect now" — `envVar` is set exactly when an env override won + // over the default. const effectiveIdentity = await resolveTeamIdentity( { account: undefined, - configuredTeam: name, + defaultTeam: { id: team.id, name: team.name }, resolveAgainstBackend: false, }, context, diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index 3bdae06..817b9ea 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -65,6 +65,14 @@ const forbiddenTelemetryDecisionPropertySuffixes = [ "_username", ] as const; +// Properties recorded outside any single command handler. The bootstrap runs +// ahead of every command, so a property it records can land on any command +// event and cannot be declared per command; it is documented here once and +// held to the same privacy rules. +const globalTelemetryDecisionProperties = { + team_default_migrated: "Records that this invocation moved the retired global default team onto the active account, so the compatibility layer can be removed once the value stops appearing. A boolean only, never the team name or id.", +} as const; + const commandTelemetryDecisions = { "auth": { kind: "generic", @@ -150,7 +158,7 @@ const commandTelemetryDecisions = { "list_scope", "result_count_bucket", ], - reason: "Records bounded connector app list size, the connector target kind (oomol/self_hosted), the identity source (personal/flag/env_id/env_name/config), and whether the listing was scoped to all apps or one service, without app ids, connection names, account labels, team names or ids, or server URLs.", + reason: "Records bounded connector app list size, the connector target kind (oomol/self_hosted), the identity source (personal/flag/env_id/env_name/account), and whether the listing was scoped to all apps or one service, without app ids, connection names, account labels, team names or ids, or server URLs.", }, "connector.login": { kind: "properties", @@ -176,7 +184,7 @@ const commandTelemetryDecisions = { "wait", "wait_result", ], - reason: "Records connector product dimensions, bucketed payload size, async wait modes, stable error code, identity source (personal/flag/env_id/env_name/config), and none/connectionName selector mode without the team name/id or connection name value.", + reason: "Records connector product dimensions, bucketed payload size, async wait modes, stable error code, identity source (personal/flag/env_id/env_name/account), and none/connectionName selector mode without the team name/id or connection name value.", }, "connector.proxy": { kind: "properties", @@ -189,7 +197,7 @@ const commandTelemetryDecisions = { "identity_source", "method", ], - reason: "Records connector proxy bucketed payload size, method enum, identity source (personal/flag/env_id/env_name/config), stable error code, and HTTP status without service name, endpoint, headers, body, or team name/id.", + reason: "Records connector proxy bucketed payload size, method enum, identity source (personal/flag/env_id/env_name/account), stable error code, and HTTP status without service name, endpoint, headers, body, or team name/id.", }, "connector.search": { kind: "properties", @@ -199,7 +207,7 @@ const commandTelemetryDecisions = { "query_length_bucket", "result_count_bucket", ], - reason: "Records query and result buckets, the connector target kind (oomol/self_hosted), and the identity source (personal/flag/env_id/env_name/config) whose connected apps set the authenticated flag, without query text, team name/id, or server URLs.", + reason: "Records query and result buckets, the connector target kind (oomol/self_hosted), and the identity source (personal/flag/env_id/env_name/account) whose connected apps set the authenticated flag, without query text, team name/id, or server URLs.", }, "connector.schema": { kind: "properties", @@ -298,15 +306,17 @@ const commandTelemetryDecisions = { "team.current": { kind: "properties", properties: ["has_configured_team", "team_source", "team_status"], - reason: "Records whether a default team identity is configured, which mechanism selects the effective team (env_id/env_name/config/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/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: "generic", - reason: "Generic command telemetry is enough; the team name is never recorded.", + kind: "properties", + properties: ["credential_source"], + reason: "Records whether OO_API_KEY made the command a no-op (it has no saved account to hold a default team); the team name is never recorded.", }, "team.clear": { - kind: "generic", - reason: "Generic command telemetry is enough; no team details are recorded.", + kind: "properties", + properties: ["credential_source"], + reason: "Records whether OO_API_KEY made the command a no-op; no team details are recorded.", }, "search": { kind: "properties", @@ -316,7 +326,7 @@ const commandTelemetryDecisions = { "query_length_bucket", "result_count_bucket", ], - reason: "Records query and result buckets, the connector target kind (oomol/self_hosted), and the identity source (personal/flag/env_id/env_name/config) whose connected apps set the authenticated flag, without query text, team name/id, or server URLs.", + reason: "Records query and result buckets, the connector target kind (oomol/self_hosted), and the identity source (personal/flag/env_id/env_name/account) whose connected apps set the authenticated flag, without query text, team name/id, or server URLs.", }, "skills": { kind: "generic", @@ -618,6 +628,39 @@ describe("command telemetry decisions", () => { } }); + test("keeps globally recorded telemetry properties privacy-safe", () => { + const baseTelemetryPropertyNames = createBaseTelemetryPropertyNames(); + + for (const property of Object.keys(globalTelemetryDecisionProperties)) { + expect( + readForbiddenTelemetryDecisionPropertyReason( + property, + baseTelemetryPropertyNames, + ), + `global telemetry property ${property} must be privacy-safe.`, + ).toBeUndefined(); + } + }); + + test("never documents a global property as a command-specific one", () => { + const globalProperties = new Set( + Object.keys(globalTelemetryDecisionProperties), + ); + + for (const [path, decision] of Object.entries(commandTelemetryDecisions)) { + if (decision.kind !== "properties") { + continue; + } + + for (const property of decision.properties) { + expect( + globalProperties.has(property), + `${path} must not redeclare the globally recorded ${property}.`, + ).toBe(false); + } + } + }); + test("keeps command telemetry decision properties privacy-safe", () => { const baseTelemetryPropertyNames = createBaseTelemetryPropertyNames(); diff --git a/src/application/schemas/auth.test.ts b/src/application/schemas/auth.test.ts index bb8e7dc..8c30750 100644 --- a/src/application/schemas/auth.test.ts +++ b/src/application/schemas/auth.test.ts @@ -3,8 +3,11 @@ import type { AuthFile } from "./auth.ts"; import { describe, expect, test } from "bun:test"; import { authTomlFileSchema, + clearAccountDefaultTeam, getNextAuthAccount, renderAuthFile, + setAccountDefaultTeam, + upsertAuthAccount, } from "./auth.ts"; function createAuthFile(overrides: Partial = {}): AuthFile { @@ -109,6 +112,159 @@ describe("authTomlFileSchema", () => { id: "user-1", }); }); + + test("parses the account default team", () => { + expect(authTomlFileSchema.parse({ + auth: [ + { + api_key: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + team: "acme", + team_id: "team-1", + }, + ], + id: "user-1", + }).auth[0]).toEqual({ + apiKey: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + team: "acme", + teamId: "team-1", + }); + }); + + test("treats a blank default team as unset", () => { + expect(authTomlFileSchema.parse({ + auth: [ + { + api_key: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + team: " ", + team_id: "", + }, + ], + id: "user-1", + }).auth[0]).toEqual({ + apiKey: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + }); + }); + + test("ignores unknown account keys instead of rejecting the file", () => { + expect(authTomlFileSchema.parse({ + auth: [ + { + api_key: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + written_by_a_newer_cli: "value", + }, + ], + id: "user-1", + }).auth[0]).toEqual({ + apiKey: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + }); + }); +}); + +describe("setAccountDefaultTeam", () => { + test("stores the name and id on the named account only", () => { + const next = setAccountDefaultTeam(createAuthFile(), "user-2", { + id: "team-1", + name: "acme", + }); + + expect(next.auth[1]).toEqual({ + apiKey: "secret-2", + endpoint: "oomol.com", + id: "user-2", + name: "Bob", + team: "acme", + teamId: "team-1", + }); + expect(next.auth[0]?.team).toBeUndefined(); + }); + + test("drops a stale id when the new default carries none", () => { + const withId = setAccountDefaultTeam(createAuthFile(), "user-1", { + id: "team-1", + name: "acme", + }); + const next = setAccountDefaultTeam(withId, "user-1", { + id: null, + name: "contoso", + }); + + expect(next.auth[0]?.team).toBe("contoso"); + expect(next.auth[0]?.teamId).toBeUndefined(); + }); + + test("leaves the file untouched when the account is unknown", () => { + const authFile = createAuthFile(); + + expect(setAccountDefaultTeam(authFile, "missing-user", { + id: null, + name: "acme", + })).toBe(authFile); + }); +}); + +describe("clearAccountDefaultTeam", () => { + test("removes both team fields", () => { + const withTeam = setAccountDefaultTeam(createAuthFile(), "user-1", { + id: "team-1", + name: "acme", + }); + const next = clearAccountDefaultTeam(withTeam, "user-1"); + + expect(next.auth[0]).toEqual({ + apiKey: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + }); + }); + + test("leaves the file untouched when no default team is stored", () => { + const authFile = createAuthFile(); + + expect(clearAccountDefaultTeam(authFile, "user-1")).toBe(authFile); + }); +}); + +describe("upsertAuthAccount", () => { + test("keeps the stored default team when the account logs in again", () => { + const withTeam = setAccountDefaultTeam(createAuthFile(), "user-1", { + id: "team-1", + name: "acme", + }); + const next = upsertAuthAccount(withTeam, { + apiKey: "rotated-secret", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + }); + + expect(next.auth[0]).toEqual({ + apiKey: "rotated-secret", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + team: "acme", + teamId: "team-1", + }); + }); }); describe("renderAuthFile", () => { @@ -136,4 +292,60 @@ describe("renderAuthFile", () => { ].join("\n"), ); }); + + test("renders the account default team when one is stored", () => { + expect(renderAuthFile({ + auth: [ + { + apiKey: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + team: "acme", + teamId: "team-1", + }, + ], + id: "user-1", + })).toBe( + [ + "id = \"user-1\"", + "", + "[[auth]]", + "id = \"user-1\"", + "name = \"Alice\"", + "api_key = \"secret-1\"", + "endpoint = \"oomol.com\"", + "team = \"acme\"", + "team_id = \"team-1\"", + "", + ].join("\n"), + ); + }); + + test("omits the team id line when only the name is known", () => { + expect(renderAuthFile({ + auth: [ + { + apiKey: "secret-1", + endpoint: "oomol.com", + id: "user-1", + name: "Alice", + team: "acme", + }, + ], + id: "user-1", + })).toBe( + [ + "id = \"user-1\"", + "", + "[[auth]]", + "id = \"user-1\"", + "name = \"Alice\"", + "api_key = \"secret-1\"", + "endpoint = \"oomol.com\"", + "team = \"acme\"", + "", + ].join("\n"), + ); + }); }); diff --git a/src/application/schemas/auth.ts b/src/application/schemas/auth.ts index 8670ab9..e7f2803 100644 --- a/src/application/schemas/auth.ts +++ b/src/application/schemas/auth.ts @@ -6,6 +6,11 @@ export const authAccountSchema = z.object({ endpoint: z.string().min(1), id: z.string().min(1), name: z.string().min(1), + // The account's default team identity. `team` is the name every backend + // understands; `teamId` is the exact value and may be absent on accounts + // whose default was migrated from the legacy global setting. + team: z.string().min(1).optional(), + teamId: z.string().min(1).optional(), }).strict(); export const authFileSchema = z.object({ @@ -13,25 +18,39 @@ export const authFileSchema = z.object({ id: z.string(), }).strict(); +// The default team fields, as they appear on disk. They are read leniently on +// purpose: a hand-edited blank value must degrade to "no default team", not +// brick every command that needs a credential. +const authAccountTeamTomlShape = { + team: z.string().optional(), + team_id: z.string().optional(), +}; + +// Neither branch is strict: an auth.toml written by a newer CLI carries keys +// this version has never heard of, and rejecting the whole file over one of +// them would report the saved accounts as corrupt. const authAccountTomlSchema = z.union([ z.object({ api_key: z.string().min(1), endpoint: z.string().min(1), id: z.string().min(1), name: z.string().min(1), - }).strict(), + ...authAccountTeamTomlShape, + }), z.object({ // Support legacy auth.toml files that used uppercase account ids. ID: z.string().min(1), api_key: z.string().min(1), endpoint: z.string().min(1), name: z.string().min(1), - }).strict(), + ...authAccountTeamTomlShape, + }), ]).transform(account => ({ apiKey: account.api_key, endpoint: account.endpoint, id: "id" in account ? account.id : account.ID, name: account.name, + ...optionalTeamFields(account.team, account.team_id), })); export const authTomlFileSchema = z.object({ @@ -59,11 +78,25 @@ export function renderAuthFile(authFile: AuthFile): string { renderTomlLine("api_key", account.apiKey), renderTomlLine("endpoint", account.endpoint), ); + + if (account.team !== undefined) { + lines.push(renderTomlLine("team", account.team)); + } + + if (account.teamId !== undefined) { + lines.push(renderTomlLine("team_id", account.teamId)); + } } return `${lines.join("\n")}\n`; } +/** + * Saves an account, preserving the default team already stored for it. A + * re-login sends a freshly built account with no team fields, and dropping + * them there would silently reset a team identity the user deliberately + * chose; the login flow decides what the default should become afterwards. + */ export function upsertAuthAccount( authFile: AuthFile, account: AuthAccount, @@ -79,14 +112,53 @@ export function upsertAuthAccount( }; } + const existingAccount = authFile.auth[existingIndex]!; + const mergedAccount: AuthAccount = { + ...account, + ...optionalTeamFields( + account.team ?? existingAccount.team, + account.teamId ?? existingAccount.teamId, + ), + }; + return { auth: authFile.auth.map((currentAccount, index) => - index === existingIndex ? account : currentAccount, + index === existingIndex ? mergedAccount : currentAccount, ), id: account.id, }; } +/** + * Records the default team identity on one account. The id is stored only + * when known, so a default migrated from the legacy global setting (which + * only ever held a name) does not carry a stale id from a previous choice. + */ +export function setAccountDefaultTeam( + authFile: AuthFile, + accountId: string, + team: { id: string | null; name: string }, +): AuthFile { + return mapAuthAccount(authFile, accountId, account => ({ + ...withoutTeamFields(account), + ...optionalTeamFields(team.name, team.id ?? undefined), + })); +} + +/** Removes the default team identity from one account. */ +export function clearAccountDefaultTeam( + authFile: AuthFile, + accountId: string, +): AuthFile { + return mapAuthAccount(authFile, accountId, (account) => { + if (account.team === undefined && account.teamId === undefined) { + return account; + } + + return withoutTeamFields(account); + }); +} + export function removeCurrentAuthAccount(authFile: AuthFile): AuthFile { return { auth: authFile.auth.filter(account => account.id !== authFile.id), @@ -134,3 +206,59 @@ export function getCurrentAuthAccount( function renderTomlLine(key: string, value: string): string { return stringifyToml({ [key]: value }).trimEnd(); } + +// Replaces one account in place, returning the original file when the account +// is unknown or the mapping changed nothing, so callers can skip a write. +function mapAuthAccount( + authFile: AuthFile, + accountId: string, + map: (account: AuthAccount) => AuthAccount, +): AuthFile { + const index = authFile.auth.findIndex(account => account.id === accountId); + + if (index === -1) { + return authFile; + } + + const account = authFile.auth[index]!; + const nextAccount = map(account); + + if (nextAccount === account) { + return authFile; + } + + return { + ...authFile, + auth: authFile.auth.map((currentAccount, currentIndex) => + currentIndex === index ? nextAccount : currentAccount, + ), + }; +} + +// Everything about an account except its default team. Written as a removal +// rather than a list of the fields to keep, so a field added to the account +// later survives a team write instead of being silently dropped by it. +function withoutTeamFields(account: AuthAccount): AuthAccount { + const { team: _team, teamId: _teamId, ...rest } = account; + + return rest; +} + +// One trim-and-drop rule for both team fields, so a blank value on disk, a +// blank value from a caller, and an absent value all mean the same thing. +function optionalTeamFields( + team: string | undefined, + teamId: string | undefined, +): { team?: string; teamId?: string } { + const trimmedTeam = team?.trim(); + const trimmedTeamId = teamId?.trim(); + + return { + ...(trimmedTeam === undefined || trimmedTeam === "" + ? {} + : { team: trimmedTeam }), + ...(trimmedTeamId === undefined || trimmedTeamId === "" + ? {} + : { teamId: trimmedTeamId }), + }; +} diff --git a/src/application/schemas/settings.test.ts b/src/application/schemas/settings.test.ts index 9c6e1ea..ed60219 100644 --- a/src/application/schemas/settings.test.ts +++ b/src/application/schemas/settings.test.ts @@ -4,11 +4,13 @@ import { parse as parseToml } from "smol-toml"; import { addDismissedSkillRecommendations, getDismissedSkillRecommendations, + getLegacyIdentityTeam, isSkillRecommendationsMuted, removeDismissedSkillRecommendations, renderSettingsFile, setSkillRecommendationsMuted, settingsFileReadSchema, + unsetLegacyIdentityTeam, } from "./settings.ts"; describe("skill recommendation settings", () => { @@ -82,3 +84,32 @@ describe("skill recommendation settings", () => { expect(parsed.skills).toBeUndefined(); }); }); + +describe("legacy identity.team setting", () => { + test("leaves no trace in a fresh settings file", () => { + expect(renderSettingsFile({})).not.toContain("identity"); + }); + + test("preserves a legacy value across an unrelated settings write", () => { + const rendered = renderSettingsFile({ + identity: { team: "acme" }, + lang: "zh", + }); + const parsed = settingsFileReadSchema.parse(parseToml(rendered)); + + expect(getLegacyIdentityTeam(parsed)).toBe("acme"); + }); + + test("unsetting prunes the whole identity section", () => { + const settings = unsetLegacyIdentityTeam({ identity: { team: "acme" } }); + + expect(getLegacyIdentityTeam(settings)).toBeUndefined(); + expect(renderSettingsFile(settings)).not.toContain("identity"); + }); + + test("unsetting an absent value returns the same settings", () => { + const settings = { lang: "en" } as const; + + expect(unsetLegacyIdentityTeam(settings)).toBe(settings); + }); +}); diff --git a/src/application/schemas/settings.ts b/src/application/schemas/settings.ts index aeec655..667a956 100644 --- a/src/application/schemas/settings.ts +++ b/src/application/schemas/settings.ts @@ -32,8 +32,11 @@ const telemetrySettingsShape = { const telemetrySettingsReadSchema = z.object(telemetrySettingsShape); const telemetrySettingsSchema = z.object(telemetrySettingsShape).strict(); +// Legacy home of the default team identity, kept readable so an installation +// that predates the account-scoped default still resolves one. Nothing writes +// it any more: it is migrated into the active account on the next run and +// deleted from here (see auth/default-team.ts, which owns both halves). const identitySettingsShape = { - // The default team used to authenticate `oo connector run`. team: z.string().trim().min(1).optional(), }; @@ -109,14 +112,6 @@ const defaultSettingsCommentBlocks = [ "# muted = false", "# dismissed = [\"oo-gmail\"]", ], - [ - "# identity.team sets the default team used to authenticate `oo connector run`.", - "# Default: unset (runs under your personal identity).", - "# Supported values: any non-empty team name.", - "# Override per run with `--team `, or force personal with `--personal`.", - "# [identity]", - "# team = \"acme\"", - ], ] as const; export function renderSettingsFile(settings: AppSettings): string { @@ -143,6 +138,10 @@ export function renderSettingsFile(settings: AppSettings): string { }; } + // Written back verbatim rather than dropped: the value is legacy, but + // until the migration has moved it into an account it is still the only + // record of the user's default team, and an unrelated settings write + // (`oo config set lang`, `oo telemetry disable`) must not erase it. if (parsedSettings.identity?.team !== undefined) { persistedSettings.identity = { team: parsedSettings.identity.team, @@ -243,26 +242,18 @@ export function unsetTelemetryEnabled( return deleteNestedProperty(settings, ["telemetry", "enabled"]); } -export function getConfiguredIdentityTeam( +/** + * Reads the legacy global default team. Only the migration consumes this; + * every other caller asks the account for its default team. + */ +export function getLegacyIdentityTeam( settings: AppSettings, ): string | undefined { return settings.identity?.team; } -export function setIdentityTeam( - settings: AppSettings, - value: string, -): AppSettings { - return { - ...settings, - identity: { - ...settings.identity, - team: value, - }, - }; -} - -export function unsetIdentityTeam( +/** Drops the legacy global default team once it has been migrated. */ +export function unsetLegacyIdentityTeam( settings: AppSettings, ): AppSettings { if (settings.identity?.team === undefined) { diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 0917c0f..d867ab5 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -528,8 +528,6 @@ export const enMessages = { "Invalid file.download.out_dir value: {value}. Use a non-empty path.", "errors.config.invalidTelemetryEnabledValue": "Invalid telemetry.enabled value: {value}. Use true or false.", - "errors.config.invalidIdentityTeamValue": - "Invalid identity.team value: {value}. Use a non-empty team name.", "errors.skills.invalidName": "Unsupported skill: {value}. Use {choices}.", "errors.skills.invalidPath": @@ -1238,15 +1236,15 @@ export const enMessages = { "team.list.text.default": "Default", "team.list.text.noTeams": "The active account has no teams; connector commands run under your personal identity.", - "team.current.text.configured": "Default team identity: {team}", + "team.current.text.accountDefault": "Default team identity: {team}", "team.current.text.personal": "No default team; connector commands run under your personal identity.", "team.current.text.envId": "Team identity comes from the OO_TEAM_ID environment variable: {team}", "team.current.text.envName": "Team identity comes from the OO_TEAM_NAME environment variable: {team}", - "team.current.text.configIgnored": - "The `identity.team` config default ({team}) is not in use while {envVar} is set.", + "team.current.text.accountDefaultIgnored": + "The default team saved on your account ({team}) is not in use while {envVar} is set.", "team.identity.nameWithId": "{name} ({teamId})", "team.identity.statusSuffix": "{value} — {reason}", "team.identity.status.notAMember": @@ -1259,6 +1257,8 @@ export const enMessages = { "team.identity.status.noCredential": "log in to look up the team", "team.use.success": "Set the default team identity to {team}.", + "team.use.envOverrideNoop": + "Nothing was saved: the active credential comes from OO_API_KEY, which has no saved default team. Pin a team with OO_TEAM_ID or OO_TEAM_NAME instead.", "team.use.envOverrideHint": "Connector commands keep using the team from {envVar}, not this default. Unset {envVar} to use the saved default.", "team.clear.success": @@ -1269,6 +1269,8 @@ export const enMessages = { "Cleared the default team identity, but {envVar} still selects the team for connector commands. Unset {envVar} to run under your personal identity.", "team.clear.alreadyPersonalEnvHint": "No default team was set, but {envVar} still selects a team for connector commands.", + "team.clear.envOverrideNoop": + "Nothing was cleared: the active credential comes from OO_API_KEY, which already runs under your personal identity unless OO_TEAM_ID or OO_TEAM_NAME selects a team.", "connector.run.text.dryRunPassed": "Validation passed.", "connector.login.manageTokens": "Manage runtime tokens at {accessUrl}", "connector.login.noToken": @@ -1837,8 +1839,6 @@ export const zhMessages = { "无效的 file.download.out_dir 值:{value}。请使用非空路径。", "errors.config.invalidTelemetryEnabledValue": "无效的 telemetry.enabled 值:{value}。请使用 true 或 false。", - "errors.config.invalidIdentityTeamValue": - "无效的 identity.team 值:{value}。请使用非空的团队名称。", "errors.skills.invalidName": "不支持的 skill:{value}。请使用 {choices}。", "errors.skills.invalidPath": @@ -2538,15 +2538,15 @@ export const zhMessages = { "team.list.text.default": "默认", "team.list.text.noTeams": "当前活动账号没有任何团队;connector 命令以个人身份运行。", - "team.current.text.configured": "默认团队身份:{team}", + "team.current.text.accountDefault": "默认团队身份:{team}", "team.current.text.personal": "未设置默认团队;connector 命令以个人身份运行。", "team.current.text.envId": "团队身份来自 OO_TEAM_ID 环境变量:{team}", "team.current.text.envName": "团队身份来自 OO_TEAM_NAME 环境变量:{team}", - "team.current.text.configIgnored": - "设置了 {envVar} 时,`identity.team` 配置的默认值({team})不会生效。", + "team.current.text.accountDefaultIgnored": + "设置了 {envVar} 时,账号保存的默认团队({team})不会生效。", "team.identity.nameWithId": "{name}({teamId})", "team.identity.statusSuffix": "{value} —— {reason}", "team.identity.status.notAMember": "当前账号不是该团队的成员", @@ -2557,6 +2557,8 @@ export const zhMessages = { "无法查询团队信息:沙箱拦截了该请求", "team.identity.status.noCredential": "登录后才能查询团队信息", "team.use.success": "已将默认团队身份设置为 {team}。", + "team.use.envOverrideNoop": + "未保存任何内容:当前凭据来自 OO_API_KEY,它没有保存的默认团队。如需固定团队,请使用 OO_TEAM_ID 或 OO_TEAM_NAME。", "team.use.envOverrideHint": "connector 命令仍会使用 {envVar} 指定的团队,而不是这个默认值。取消 {envVar} 后保存的默认值才会生效。", "team.clear.success": @@ -2567,6 +2569,8 @@ export const zhMessages = { "已清除默认团队身份,但 {envVar} 仍会为 connector 命令选择团队。取消 {envVar} 后才会以个人身份运行。", "team.clear.alreadyPersonalEnvHint": "未设置默认团队,但 {envVar} 仍会为 connector 命令选择团队。", + "team.clear.envOverrideNoop": + "未清除任何内容:当前凭据来自 OO_API_KEY,除非 OO_TEAM_ID 或 OO_TEAM_NAME 选择了团队,否则它本就以个人身份运行。", "connector.run.text.dryRunPassed": "校验通过。", "connector.login.manageTokens": "可在 {accessUrl} 管理 Runtime Token。", "connector.login.noToken":