fix(env,hooks,mcp,status): name the entries that are not delivered (#822) (#851)

* fix(env,hooks,mcp,status): name the entries that are not delivered (#822)

env list, mcp list, hooks list, status and list <env|hooks|mcp> resolve the
entry types to show what reaches this directory, but dropped the resolution
notices: an entry an unknown key (a mistyped role:) or a removed key (roles:
on env, projects:) takes out of the delivered set was silently missing from
the list, and status counted around it. pull and doctor report these; now the
list commands do too, via reportEntryResolution.

env add on a variable carrying a removed per-entry key kept reporting plain
'Updated' — #833 taught it to warn for keys the schema does not know, but a
removed key is in the shape on purpose (so it can be detected), so it stayed
silent. Warn the same way for those.

* test(e2e): match the delivered DEVOPS_ONLY form, not its name in the notice

env list now reports the withheld per-entry `roles:` variable by name
(#822), so the whole-output not.toContain('DEVOPS_ONLY') assertion tripped
on the delivery notice itself. The variable stays out of the delivered
list; match the listed form `DEVOPS_ONLY=` instead.

* fix(env): point env add at the namespace file, not a key drop

The review of #851 found the update-path warning told users to remove a
per-entry `roles:`/`projects:` key in place, which delivers a root-scoped
secret to the whole team. The remediation now reuses `moveTo`, the same
remedy pull's notice names, so it points at the namespace file to move the
entry into (with the manifest declaration to add when nothing declares it).

`moveTo` and `TargetFiles` move from module-private to exported for this.

The review also found skill-data/setup/references/manage-admin.md still
said only pull and doctor report undelivered entries, while this branch
made the list commands and status report them too. It now names them, as
docs/usage-guide.md does.

* fix(entries): report notices alongside resolution failures

* fix(entries): scope list warnings and document env updates

* docs(entries): align changelog with list and env warnings

---------

Co-authored-by: ydflow <ydflow@users.noreply.github.com>
Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
This commit is contained in:
ydflow
2026-09-29 23:15:08 +08:00
committed by GitHub
co-authored by ydflow ydflow
parent ed95357fe8
commit 3a9a24a6f4
15 changed files with 344 additions and 46 deletions
+2 -2
View File
@@ -6,10 +6,10 @@ All notable changes to this project will be documented in this file. See [standa
### 💥 Breaking Changes
- An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull` and `teamai doctor` name the file, the entry and the key. `teamai env add`, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)).
- An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)).
- `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills/<namespace>/`, `agents/<namespace>/`, `learnings/<namespace>/`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together.
- **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
- Per-entry scoping of env variables, hooks and MCP servers gives way to namespace files (see Features). `projects:` on an `env/env.yaml` variable, a `hooks/hooks.yaml` hook or an `mcp/mcp.yaml` server, and `roles:` on an env variable, existed only in the 0.26.0 betas and are removed: such an entry now reaches nobody, and each pull warns with the namespace file to move it to, one per listed id, so a project-only value never falls through to the whole team. `roles:` on hooks and MCP servers, which 0.25.0 shipped, is deprecated: it keeps filtering for one more minor release as 0.25.0 did, a name repeated in one file under different `roles:` included, pull warns once per run and `teamai doctor` has a check, both naming every target file. There is no automatic migration; move each entry into the file the warning names and drop the key. A member with no role in a team with `roles.yaml` received every `roles:`-scoped hook and server; once they move into `hooks/<ns>/` or `mcp/<ns>/`, that member no longer does (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
- Per-entry scoping of env variables, hooks and MCP servers gives way to namespace files (see Features). `projects:` on an `env/env.yaml` variable, a `hooks/hooks.yaml` hook or an `mcp/mcp.yaml` server, and `roles:` on an env variable, existed only in the 0.26.0 betas and are removed: such an entry now reaches nobody, and pull, the list commands and status warn with the namespace file to move it to, one per listed id, so a project-only value never falls through to the whole team. `teamai env add` also warns when it updates an existing variable carrying either removed key, and preserves the key. `roles:` on hooks and MCP servers, which 0.25.0 shipped, is deprecated: it keeps filtering for one more minor release as 0.25.0 did, a name repeated in one file under different `roles:` included, pull warns once per run and `teamai doctor` has a check, both naming every target file. There is no automatic migration; move each entry into the file the warning names and drop the key. A member with no role in a team with `roles.yaml` received every `roles:`-scoped hook and server; once they move into `hooks/<ns>/` or `mcp/<ns>/`, that member no longer does (for [#707](https://github.com/Tencent/teamai-cli/issues/707)).
### ✨ Features
+8 -2
View File
@@ -418,12 +418,18 @@ declares one of the new axes.
The per-entry keys go away. `projects:` on env, hooks and MCP, and `roles:` on
env, existed only in the 0.26.0 betas: an entry that carries one reaches nobody,
and pull warns with the namespace file to move it to, one per listed id.
and pull, `status`, `env list`, `mcp list`, `hooks list` and
`list <env|hooks|mcp> --source repo` warn with the namespace file to move it to,
one per listed id.
When `teamai env add` updates a variable still carrying one of these removed
keys, it preserves the key and warns that pull will not deliver the variable,
naming the namespace file to move it to.
`roles:` on hooks and MCP shipped in 0.25.0 and keeps filtering for one more
minor release; pull warns once per run and `doctor` has an informational check,
both naming every target file. Model profiles are strict, so a per-entry key
fails the file. An env, hook or MCP entry with any other key its schema does not
know, such as a mistyped `role:`, reaches nobody too, and pull and `doctor` name
know, such as a mistyped `role:`, reaches nobody too. Pull, `status`, `env list`,
`mcp list`, `hooks list`, `list <env|hooks|mcp> --source repo` and `doctor` name
the file, the entry and the key (#822); `env add`, `env remove` and `remove mcp`
keep such a key when they rewrite the file. A key that a later version adds is
unknown to this one as well, so an entry that uses it is not delivered to a member
+11 -7
View File
@@ -995,8 +995,9 @@ projects:
repeats.
- **Where a value comes from.** `teamai env list`, `teamai mcp list`,
`teamai hooks list` and `teamai list <env|hooks|mcp> --source repo` show each
entry's namespace and whether it overrides the root; `teamai status` counts per
namespace; `teamai doctor` lists each override as a note.
entry's namespace and whether it overrides the root, and name every entry that
is not delivered, with why; `teamai status` counts per namespace and names
them too; `teamai doctor` lists each override as a note.
- **Upgrade every member first.** teamai 0.25.0 and the 0.26.0 betas reject a
`resources:` key they do not know, so declaring `env`, `hooks` or `mcp` breaks
their pull. From this version on, an unknown `resources:` key only warns, and
@@ -1006,18 +1007,21 @@ The per-entry keys these files replace:
| Key | On | Now |
|---|---|---|
| `projects:` | env, hooks, MCP | removed: the entry reaches nobody, and each pull warns with the file to move it to |
| `projects:` | env, hooks, MCP | removed: the entry reaches nobody; pull, the list commands and status warn with the file to move it to |
| `roles:` | env | removed, the same way |
| `roles:` | hooks, MCP | deprecated: still filters for one minor release, as in 0.25.0, including a name the root file repeats under different `roles:`; pull warns and `teamai doctor` has a check, both naming every target file |
There is no automatic migration: move each entry into the namespace file the
warning names, and drop the key.
When `teamai env add` updates an existing variable that still carries a removed
per-entry `projects:` or `roles:` key, it keeps that key and warns that pull
will not deliver the variable, naming the namespace file to move it to.
An entry with any other key its schema does not know, such as a mistyped `role:`,
reaches nobody as well, and pull and `teamai doctor` name the file, the entry and
the key. Correct the key or remove it. A key that a later teamai version adds is
unknown to an older one too, so upgrade every member before the team uses a new
entry key.
reaches nobody as well, and pull, the list commands, status and `teamai doctor` name the
file, the entry and the key. Correct the key or remove it. A key that a later
teamai version adds is unknown to an older one too, so upgrade every member
before the team uses a new entry key.
A hooks or MCP file that has none of its top-level keys, such as `server:` for
`servers:`, is treated like a file that does not parse: pull keeps the installed
+7 -4
View File
@@ -910,8 +910,9 @@ projects:
- **旧模式**(成员没有角色,且团队没有 `projects.yaml`)只读取根目录文件,行为不变;
`teamai doctor` 会列出根文件中重复的名字。
- **值从哪里来。** `teamai env list`、`teamai mcp list`、`teamai hooks list` 与
`teamai list <env|hooks|mcp> --source repo` 会给出每个条目的 namespace 以及是否覆盖了
根条目;`teamai status` 按 namespace 计数;`teamai doctor` 以提示信息列出每一处覆盖。
`teamai list <env|hooks|mcp> --source repo` 会给出每个条目的 namespace、是否覆盖了
根条目,并指出每个未下发的条目及其原因;`teamai status` 按 namespace 计数并同样
指出它们;`teamai doctor` 以提示信息列出每一处覆盖。
- **先让所有成员升级。** teamai 0.25.0 与 0.26.0 beta 会拒绝不认识的 `resources:` key,
声明 `env`、`hooks` 或 `mcp` 会让这些版本的 pull 失败。从本版本起,未知的
`resources:` key 只会给出警告,`teamai roles` 与 `teamai projects` 保存 manifest 时也会保留它。
@@ -920,14 +921,16 @@ projects:
| Key | 适用于 | 现在 |
|---|---|---|
| `projects:` | env、hooks、MCP | 已移除:该条目不再下发给任何人,每次 pull 都会警告并给出应迁往的文件 |
| `projects:` | env、hooks、MCP | 已移除:该条目不再下发给任何人;pull、各 list 命令和 status 都会警告并给出应迁往的文件 |
| `roles:` | env | 已移除,处理方式相同 |
| `roles:` | hooks、MCP | 已弃用:在一个次版本内仍像 0.25.0 一样按角色过滤,根文件中以不同 `roles:` 重复的名字也照旧生效;pull 会警告,`teamai doctor` 有一项检查,两者都会列出每个目标文件 |
没有自动迁移:把每个条目移到警告给出的 namespace 文件中,并删掉该 key。
如果 `teamai env add` 更新的已有变量仍带有已移除的按条目 `projects:` 或 `roles:` key,
命令会保留该 key,并警告 pull 不会下发这个变量,同时指出应迁往的 namespace 文件。
条目若带有其 schema 不认识的其他 key(例如拼错的 `role:`),同样不会下发给任何人;
pull 与 `teamai doctor` 会指出文件、条目和该 key。请改正或删除这个 key。
pull、各 list 命令、status 与 `teamai doctor` 会指出文件、条目和该 key。请改正或删除这个 key。
较新版本 teamai 新增的 key 对旧版本同样是未知 key,因此团队使用新的条目 key 之前,
请先让所有成员升级。
+10 -5
View File
@@ -171,12 +171,17 @@ and push it with git. `teamai doctor` lists each override.
state is kept. Fix the file the warning names. A hooks or MCP file with none of
its top-level keys (`server:` for `servers:`) counts as one that does not parse.
- Per-entry `projects:` (and `roles:` on env) no longer works: such an entry reaches
nobody. `roles:` on hooks and MCP still filters for one more minor release. Pull
and `teamai doctor` name the namespace file each entry belongs in; move it there.
nobody. `roles:` on hooks and MCP still filters for one more minor release. Pull,
the list commands (`teamai env list`, `teamai mcp list`, `teamai hooks list`,
`teamai list <env|hooks|mcp> --source repo`), `teamai status` and
`teamai doctor` name the namespace file each entry belongs in; move it there.
When `teamai env add` updates a variable carrying either removed key, it keeps
the key and warns that pull will not deliver the variable, naming that file.
- An env, hook or MCP entry with a key its schema does not know (a mistyped `role:`)
also reaches nobody. Pull and `teamai doctor` name the file, entry and key; correct
the key or remove it. A key a later teamai version adds is unknown to an older one,
so upgrade every member before the team uses a new entry key.
also reaches nobody. Pull, the list commands, `teamai status` and
`teamai doctor` name the file, entry and key; correct the key or remove it.
A key a later teamai version adds is unknown to an older one, so upgrade every
member before the team uses a new entry key.
- Team model profiles work the same way: `models/<ns>/models.yaml`, declared under
`resources.models`, replaces the root profile with the same `id` for members who
have `<ns>` active. A member's API key is bound to the profile's gateway origin:
@@ -343,7 +343,10 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI (
const envList = await runCLI(['env', 'list'], projectRoot, home);
expect(envList.code, envList.output).toBe(0);
expect(envList.output).toMatch(/BILLING_URL=\S+ {2}\(billing\)/);
expect(envList.output).not.toContain('DEVOPS_ONLY');
// The delivery notice for the withheld per-entry `roles:` key names
// DEVOPS_ONLY in its warning; the variable itself must stay out of the
// delivered list, where it would print as `DEVOPS_ONLY=<masked>`.
expect(envList.output).not.toMatch(/DEVOPS_ONLY=/);
}, 60_000);
it('warns about per-entry projects:, naming the namespace file to move the entry to', async () => {
+104
View File
@@ -23,6 +23,7 @@ vi.mock('../utils/logger.js', () => ({
error: vi.fn(),
debug: vi.fn(),
dim: vi.fn(),
persist: vi.fn(),
},
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
@@ -37,6 +38,7 @@ vi.mock('../utils/logger.js', () => ({
import { envList, envAdd, envRemove } from '../env-commands.js';
import { requireInit } from '../config.js';
import { log } from '../utils/logger.js';
import { resetWarnOnce } from '../utils/warn-once.js';
import { pullRepo } from '../utils/git.js';
import type { TeamaiConfig, LocalConfig } from '../types.js';
@@ -82,6 +84,9 @@ scope: 'user',
vi.mocked(log.success).mockClear();
vi.mocked(log.error).mockClear();
vi.mocked(log.dim).mockClear();
vi.mocked(log.warn).mockClear();
vi.mocked(log.persist).mockClear();
resetWarnOnce();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
@@ -189,6 +194,40 @@ scope: 'user',
expect(log.dim).toHaveBeenCalledWith(expect.stringContaining('My API endpoint'));
});
it('names the variable an unknown key takes out of the delivered set (#822)', async () => {
await fse.writeFile(
path.join(repoPath, 'env', 'env.yaml'),
YAML.stringify({
variables: [
{ key: 'GOOD_URL', value: 'https://good.example' },
{ key: 'CACHE_TTL', value: '60', role: ['frontend'] },
],
}),
);
await envList({});
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('env/env.yaml: variable "CACHE_TTL" has unknown key `role:`, so this entry is not delivered.'));
const allOutput = consoleSpy.mock.calls.map(c => c[0]).join('\n');
expect(allOutput).toContain('GOOD_URL');
expect(allOutput).not.toContain('CACHE_TTL');
});
it('names the variable a removed per-entry key takes out of the delivered set (#822)', async () => {
await fse.writeFile(
path.join(repoPath, 'env', 'env.yaml'),
YAML.stringify({
variables: [
{ key: 'DB_URL', value: 'postgres://db', roles: ['legacy'] },
],
}),
);
await envList({});
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('env/env.yaml: variable "DB_URL" is scoped with per-entry `roles:`, which this version no longer reads, so it reaches nobody.'));
});
});
// ─── envAdd ──────────────────────────────────────────────
@@ -280,6 +319,71 @@ scope: 'user',
});
});
it('says an updated variable it cannot deliver is undelivered, naming the namespace file to move it to (#822)', async () => {
// `roles:` on env is no longer read, so this update reaches nobody —
// "Updated" alone would read as success.
await fse.writeFile(
path.join(repoPath, 'env', 'env.yaml'),
YAML.stringify({
variables: [{ key: 'DB_URL', value: 'old', roles: ['legacy'] }],
}),
);
await envAdd('DB_URL', 'new', {});
// The remedy has to name the namespace file, as pull's notice does:
// dropping the key in env/env.yaml would deliver the secret to everyone.
// No role or project declares `legacy`, so the notice says which
// declaration makes env/legacy/env.yaml reach it.
expect(log.warn).toHaveBeenCalledWith(
'env/env.yaml: variable "DB_URL" is scoped with per-entry `roles:`, which this version no longer reads, '
+ 'so pull does not deliver it. Move it to env/legacy/env.yaml (declare env: [legacy] for role legacy '
+ 'in manifest/roles.yaml) and drop the key.',
);
expect(log.success).toHaveBeenCalledWith('Updated env variable: DB_URL=new');
});
// A role that declares the namespace names the file alone, as pull does.
it('names the declared namespace file an updated variable belongs in (#822)', async () => {
await fse.outputFile(path.join(repoPath, 'manifest', 'roles.yaml'), YAML.stringify({
version: 1,
roles: [{ id: 'legacy', description: '', resources: { knowledge: [], skills: [], env: ['legacy'] } }],
}));
await fse.writeFile(
path.join(repoPath, 'env', 'env.yaml'),
YAML.stringify({
variables: [{ key: 'DB_URL', value: 'old', roles: ['legacy'] }],
}),
);
await envAdd('DB_URL', 'new', {});
expect(log.warn).toHaveBeenCalledWith(
'env/env.yaml: variable "DB_URL" is scoped with per-entry `roles:`, which this version no longer reads, '
+ 'so pull does not deliver it. Move it to env/legacy/env.yaml and drop the key.',
);
expect(log.success).toHaveBeenCalledWith('Updated env variable: DB_URL=new');
});
// The same guidance pull gives when no role or project declares the id:
// the namespace file the entry belongs in, with the declaration to add.
it('names the namespace file to declare when no role declares the removed key\'s id (#822)', async () => { await fse.writeFile(
path.join(repoPath, 'env', 'env.yaml'),
YAML.stringify({
variables: [{ key: 'DB_URL', value: 'old', projects: ['checkout'], roles: ['legacy'] }],
}),
);
await envAdd('DB_URL', 'new', {});
expect(log.warn).toHaveBeenCalledWith(
'env/env.yaml: variable "DB_URL" is scoped with per-entry `projects:` and `roles:`, which this version '
+ 'no longer reads, so pull does not deliver it. Copy it into each of env/checkout/env.yaml (declare env: '
+ '[checkout] for project checkout in manifest/projects.yaml), env/legacy/env.yaml (declare env: [legacy] '
+ 'for role legacy in manifest/roles.yaml) and drop the key.',
);
});
// A variable with a misspelled `roles:` reaches nobody (#822); a rewrite
// that drops the key would deliver it to the whole team.
it('preserves a key env does not know on a variable it updates', async () => {
+27 -2
View File
@@ -35,6 +35,7 @@ vi.mock('../utils/logger.js', () => ({
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
persist: vi.fn(),
},
}));
@@ -45,6 +46,7 @@ import { getHookStatus, reconcileHooks, reconcileHooksToAllTools, reconcileTeamH
import { resolveTeamHookEntries } from '../resources/hooks.js';
import { log } from '../utils/logger.js';
import { hooksInject, hooksRemove, hooksList } from '../hooks-cmd.js';
import { resetWarnOnce } from '../utils/warn-once.js';
import { TeamaiConfigSchema } from '../types.js';
const mockedAutoDetectInit = autoDetectInit as Mock;
@@ -60,13 +62,13 @@ const mockedParseTeamHooks = resolveTeamHookEntries as Mock;
* The resolved team hooks (B), as `[hook, source, replaces]` or a bare hook
* from hooks/hooks.yaml, plus the optional builtin override.
*/
function hooksYaml(hooks: (Record<string, unknown> | [Record<string, unknown>, string, string | null])[], builtin?: unknown) {
function hooksYaml(hooks: (Record<string, unknown> | [Record<string, unknown>, string, string | null])[], builtin?: unknown, notices?: { kind: 'unknown-key' | 'removed-key' | 'deprecated-roles' | 'file-note'; message: string }[]) {
const entries = hooks.map((hook) => {
const [entry, source, replaces] = Array.isArray(hook) ? hook : [hook, 'hooks/hooks.yaml', null];
const namespace = source === 'hooks/hooks.yaml' ? null : source.split('/')[1];
return { entry, name: entry.id, source, namespace, replaces };
});
return { resolution: { kind: 'resolved', entries, active: [], notices: [], repeated: [] }, builtin: { known: true, override: builtin } };
return { resolution: { kind: 'resolved', entries, active: [], notices: notices ?? [], repeated: [] }, builtin: { known: true, override: builtin } };
}
const mockedLog = log as unknown as { info: Mock; success: Mock; warn: Mock; error: Mock; debug: Mock };
@@ -273,6 +275,29 @@ describe('hooksList', () => {
expect(text).toContain('[lint] Stop → npm run lint:checkout (tools: all) from checkout, overrides root');
expect(text).toContain('[orders] Stop → echo orders (tools: all) from checkout');
});
it('names the hook an unknown key takes out of the delivered set (#822)', async () => {
resetWarnOnce();
mockedParseTeamHooks.mockResolvedValue(hooksYaml([
{ id: 'good-hook', event: 'SessionStart', command: 'echo good', description: 'ok' },
], undefined, [{
kind: 'unknown-key',
message: 'hooks/hooks.yaml: hook "scoped-hook" has unknown key `role:`, so this entry is not delivered. '
+ 'Correct the key or remove it.',
}]));
const out: string[] = [];
const spy = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
try {
await hooksList({});
} finally {
spy.mockRestore();
}
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('hook "scoped-hook" has unknown key `role:`, so this entry is not delivered.'));
const text = out.join('\n');
expect(text).toContain('[good-hook] SessionStart');
expect(text).not.toContain('scoped-hook]');
});
});
describe('hooksList', () => {
+46 -2
View File
@@ -17,13 +17,14 @@ vi.mock('../utils/fs.js', () => ({
readJson: vi.fn().mockResolvedValue(null),
}));
vi.mock('../utils/logger.js', () => ({
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), persist: vi.fn() },
}));
import { autoDetectInit } from '../config.js';
import { resolveEntriesFor } from '../namespaced-entries.js';
import { mcpInject, mcpList } from '../mcp-cmd.js';
import { reconcileMcpForConfig } from '../mcp-reconcile.js';
import { resetWarnOnce } from '../utils/warn-once.js';
const mockedAutoDetectInit = autoDetectInit as Mock;
const mockedResolve = resolveEntriesFor as Mock;
@@ -58,6 +59,7 @@ async function listOutput(): Promise<string> {
describe('mcpList', () => {
beforeEach(() => {
resetWarnOnce();
mockedAutoDetectInit.mockResolvedValue({
localConfig: { repo: { localPath: '/repo' }, scope: 'user', additionalRoles: [] },
teamConfig: { toolPaths: {} },
@@ -90,14 +92,56 @@ describe('mcpList', () => {
it('reports a set that cannot be resolved instead of listing part of it', async () => {
mockedResolve.mockResolvedValue({
kind: 'failed',
notices: [],
notices: [{
kind: 'unknown-key',
message: 'mcp/mcp.yaml: server "hidden" has unknown key `role:`, so this entry is not delivered.',
}],
failure: { kind: 'two-namespaces', type: 'mcp', name: 'db', first: 'mcp/checkout/mcp.yaml', second: 'mcp/billing/mcp.yaml' },
});
const { log } = await import('../utils/logger.js');
await listOutput();
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('server "hidden" has unknown key `role:`'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('server "db" is defined in both mcp/checkout/mcp.yaml and mcp/billing/mcp.yaml'));
process.exitCode = 0;
});
it('names the server a removed per-entry key takes out of the delivered set (#822)', async () => {
mockedResolve.mockResolvedValue({
...resolved([
[{ name: 'good_server', transport: 'stdio', command: 'echo' }, 'mcp/mcp.yaml', null],
]),
notices: [{
kind: 'removed-key' as const,
message: 'mcp/mcp.yaml: server "scoped_server" is scoped with per-entry `projects:`, '
+ 'which this version no longer reads, so it reaches nobody. '
+ 'It lists no id: remove it, or move it to the namespace file it is meant for.',
}],
});
const { log } = await import('../utils/logger.js');
const text = await listOutput();
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(
'server "scoped_server" is scoped with per-entry `projects:`, which this version no longer reads, so it reaches nobody.',
));
expect(text).toContain('good_server');
expect(text).not.toContain('scoped_server');
});
it('leaves delivered deprecated-role notices to pull and doctor', async () => {
mockedResolve.mockResolvedValue({
...resolved([[
{ name: 'scoped_server', transport: 'http', url: 'https://example.com/mcp', roles: ['worker'] },
'mcp/mcp.yaml', null,
]]),
notices: [{
kind: 'deprecated-roles',
message: 'mcp/mcp.yaml: server "scoped_server" uses deprecated per-entry `roles:`.',
}],
});
const { log } = await import('../utils/logger.js');
vi.mocked(log.warn).mockClear();
await listOutput();
expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining('deprecated per-entry `roles:`'));
});
});
describe('mcpInject', () => {
+46 -1
View File
@@ -23,12 +23,14 @@ vi.mock('../utils/logger.js', () => ({
error: vi.fn(),
debug: vi.fn(),
dim: vi.fn(),
persist: vi.fn(),
},
}));
import { list, status } from '../status.js';
import type { TeamaiConfig, LocalConfig } from '../types.js';
import { log } from '../utils/logger.js';
import { resetWarnOnce } from '../utils/warn-once.js';
function makeTeamConfig(): TeamaiConfig {
return {
@@ -61,10 +63,12 @@ describe('teamai list / status resource coverage', () => {
let tmpDir: string;
let homeDir: string;
let repoPath: string;
let localConfig: LocalConfig;
let lines: string[];
let spy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
resetWarnOnce();
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-list-'));
homeDir = path.join(tmpDir, 'home');
repoPath = path.join(tmpDir, 'repo');
@@ -102,7 +106,7 @@ describe('teamai list / status resource coverage', () => {
);
await fse.writeFile(path.join(repoPath, 'agents', 'reviewer.md'), '# Reviewer\n');
const localConfig: LocalConfig = {
localConfig = {
repo: { localPath: repoPath, remote: 'https://example.com/repo.git' },
username: 'u',
updatePolicy: 'auto',
@@ -157,6 +161,47 @@ describe('teamai list / status resource coverage', () => {
expect(out).toMatch(/mcp:\s*1/);
});
it('names an undelivered MCP entry even when another entry makes resolution fail', async () => {
localConfig.primaryRole = 'worker';
await fse.outputFile(path.join(repoPath, 'manifest', 'roles.yaml'), [
'version: 1',
'roles:',
' - id: worker',
' resources:',
' knowledge: []',
' skills: []',
' agents: []',
' mcp: [one, two]',
].join('\n'));
await fse.writeFile(path.join(repoPath, 'mcp', 'mcp.yaml'), [
'servers:',
' - name: hidden',
' transport: http',
' url: https://example.com/hidden',
' role: worker',
].join('\n'));
for (const namespace of ['one', 'two']) {
await fse.outputFile(path.join(repoPath, 'mcp', namespace, 'mcp.yaml'), [
'servers:',
' - name: duplicate',
' transport: http',
` url: https://example.com/${namespace}`,
].join('\n'));
}
vi.mocked(log.warn).mockClear();
await status({});
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('server "hidden" has unknown key `role:`'));
expect(lines.join('\n')).toContain('mcp: 0 (cannot be resolved; run `teamai doctor`)');
resetWarnOnce();
vi.mocked(log.warn).mockClear();
lines.length = 0;
await list('mcp', { source: 'repo' });
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('server "hidden" has unknown key `role:`'));
expect(lines.join('\n')).toContain('server "duplicate" is defined in both');
});
it('status counts nested rule files', async () => {
await fse.ensureDir(path.join(repoPath, 'rules', 'common'));
await fse.writeFile(path.join(repoPath, 'rules', 'common', 'example.md'), '# Rule\n');
+26 -1
View File
@@ -3,7 +3,7 @@ import { pullRepo } from './utils/git.js';
import { pathExists } from './utils/fs.js';
import { log, spinner } from './utils/logger.js';
import { EnvHandler, maskEnvValue, ENV_KEY_RE, envEntryReader, unknownEnvVariableKeys, type EnvYaml } from './resources/env.js';
import { describeEntryFailure, describeOrigin, entryFileAbsolutePath, entryFilePath, entryNamespaceFromFlags, resolveEntriesFor } from './namespaced-entries.js';
import { describeEntryFailure, describeOrigin, entryFileAbsolutePath, entryFilePath, entryNamespaceFromFlags, moveTo, reportUndeliveredEntryNotices, resolveEntriesFor, TargetFiles } from './namespaced-entries.js';
import type { GlobalOptions, LocalConfig } from './types.js';
import { isSelfMode } from './types.js';
@@ -21,10 +21,14 @@ export async function envList(options: GlobalOptions & { reveal?: boolean }): Pr
const resolution = await resolveEntriesFor(envEntryReader, localConfig);
if (resolution.kind === 'failed') {
reportUndeliveredEntryNotices(resolution);
log.error(describeEntryFailure(resolution.failure));
process.exitCode = 1;
return;
}
// An entry an unknown or removed key takes out of the delivered set never
// appears in the list below, so say why it is missing (#822).
reportUndeliveredEntryNotices(resolution);
const variables = resolution.entries;
if (variables.length === 0) {
log.info('No env variables defined');
@@ -102,6 +106,27 @@ export async function envAdd(
+ `Correct the ${one ? 'key' : 'keys'} or remove ${one ? 'it' : 'them'} in ${relativePath}.`,
);
}
// Same for a removed per-entry key, which the schema keeps so it can be
// detected rather than stripped: `roles:` on env and `projects:` reach nobody.
const updated = envConfig.variables[existingIdx];
const removed: string[] = [];
if (updated.projects !== undefined) removed.push('projects');
if (updated.roles !== undefined) removed.push('roles');
if (removed.length > 0) {
// The remediation has to name the namespace file, as pull's notice does:
// dropping a root-scoped key where it sits would deliver the secret to
// everyone — the outcome the per-entry key was scoping against.
const targets = new TargetFiles(repoPath, 'env');
const files: string[] = [];
for (const key of removed) {
files.push(...await targets.forIds(key as 'roles' | 'projects', updated[key as 'roles' | 'projects'] ?? []));
}
log.warn(
`${relativePath}: variable "${key}" is scoped with per-entry `
+ `${removed.map((k) => `\`${k}:\``).join(' and ')}, which this version no longer reads, `
+ `so pull does not deliver it. ${moveTo(files)}`,
);
}
} else {
const newVar: { key: string; value: string; description?: string } = { key, value };
if (options.description) {
+4 -1
View File
@@ -3,7 +3,7 @@ import { autoDetectInit } from './config.js';
import { reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, getHookStatus, hasInstalledCodexTrustGatedTool, codexTrustReminder, type HookStatus } from './hooks.js';
import { applyBuiltinOverride, installedBuiltinHookDefs } from './builtin-hooks.js';
import { resolveTeamHookEntries } from './resources/hooks.js';
import { describeEntryFailure, describeOrigin } from './namespaced-entries.js';
import { describeEntryFailure, describeOrigin, reportUndeliveredEntryNotices } from './namespaced-entries.js';
import { log } from './utils/logger.js';
import type { GlobalOptions, HookDef } from './types.js';
import {
@@ -143,6 +143,9 @@ export async function hooksList(_options: GlobalOptions): Promise<void> {
// reconcile engine applies it, so the listing must too or it shows hooks
// that were just removed from the settings files.
const { resolution: teamHooks, builtin } = await resolveTeamHookEntries(localConfig);
// A hook an unknown or removed key takes out of the delivered set never
// appears in the team-hooks section below, so say why it is missing (#822).
reportUndeliveredEntryNotices(teamHooks);
const builtinOverride = builtin.known ? builtin.override : undefined;
const rows: HookListRow[] = [];
// One settings file is one install, so list it once, for the target that owns
+5 -1
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import { autoDetectInit } from './config.js';
import { mcpEntryReader, teamMcpToDef } from './resources/mcp.js';
import { describeEntryFailure, describeOrigin, resolveEntriesFor } from './namespaced-entries.js';
import { describeEntryFailure, describeOrigin, reportUndeliveredEntryNotices, resolveEntriesFor } from './namespaced-entries.js';
import {
reconcileMcpForConfig,
resolveMcpTargets,
@@ -28,10 +28,14 @@ export async function mcpList(_options: GlobalOptions): Promise<void> {
const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: true });
const resolution = await resolveEntriesFor(mcpEntryReader, localConfig);
if (resolution.kind === 'failed') {
reportUndeliveredEntryNotices(resolution);
log.error(describeEntryFailure(resolution.failure));
process.exitCode = 1;
return;
}
// A server an unknown or removed key takes out of the delivered set never
// appears in the list below, so say why it is missing (#822).
reportUndeliveredEntryNotices(resolution);
const servers = resolution.entries;
if (servers.length === 0) {
+22 -6
View File
@@ -423,19 +423,24 @@ async function keepScopedEntry(
return roles === null || scope.roles.some((role) => roles.includes(role));
}
function moveTo(files: string[]): string {
/**
* Where an entry carrying a removed per-entry key belongs: the namespace files
* its listed ids declare, or the removal. Shared with the write path (`env
* add`), whose remediation has to name the same file — telling a user to drop
* a root-scoped key where it sits would deliver the value to the whole team.
*/
export function moveTo(files: readonly string[]): string {
if (files.length === 0) return 'It lists no id: remove it, or move it to the namespace file it is meant for.';
if (files.length === 1) return `Move it to ${files[0]} and drop the key.`;
return `Copy it into each of ${files.join(', ')} and drop the key.`;
}
/**
* The namespace files an id's entries belong in: the namespaces its role or
* project declares for the type, or `<type>/<id>/` with the declaration to add
* when it declares none. The manifests are read at most once, and only when an
* entry carries a per-entry key.
*/
class TargetFiles {
export class TargetFiles {
private roles: ReturnType<typeof loadRolesManifestIfPresent> | null = null;
private projects: ReturnType<typeof loadProjectsManifest> | null = null;
@@ -521,13 +526,24 @@ export function describeEntryFailure(failure: EntryFailure): string {
* stale entries.
*/
export function reportEntryResolution(resolution: EntryResolution<unknown>): void {
const messages = resolution.notices.map((notice) => notice.message);
if (resolution.kind === 'failed') messages.push(describeEntryFailure(resolution.failure));
for (const message of messages) {
reportNotices(resolution.notices);
if (resolution.kind === 'failed') {
const message = describeEntryFailure(resolution.failure);
if (warnOnce(message)) log.persist(message);
}
}
/** List/status report only entries omitted from delivery; pull reports every notice. */
export function reportUndeliveredEntryNotices(resolution: Pick<EntryResolution<unknown>, 'notices'>): void {
reportNotices(resolution.notices.filter((notice) => notice.kind === 'unknown-key' || notice.kind === 'removed-key'));
}
function reportNotices(notices: readonly EntryNotice[]): void {
for (const notice of notices) {
if (warnOnce(notice.message)) log.persist(notice.message);
}
}
/** Where an entry comes from, for the list commands, `status` and `doctor`. */
export function describeOrigin(entry: ResolvedEntry<unknown>): string {
if (entry.namespace === null) return 'root';
+22 -11
View File
@@ -23,7 +23,7 @@ import { mcpEntryReader } from './resources/mcp.js';
import { resolveTeamHookEntries } from './resources/hooks.js';
import { envEntryReader } from './resources/env.js';
import {
describeEntryFailure, describeOrigin, describeOrigins, resolveEntriesFor,
describeEntryFailure, describeOrigin, describeOrigins, reportUndeliveredEntryNotices, resolveEntriesFor,
type EntryResolution, type EntryType,
} from './namespaced-entries.js';
@@ -109,6 +109,9 @@ export async function status(options: GlobalOptions): Promise<void> {
counts[type] = resolution.kind === 'resolved' ? resolution.entries.length : 0;
if (resolution.kind === 'failed') origins[type] = ' (cannot be resolved; run `teamai doctor`)';
else if (resolution.entries.some((entry) => entry.namespace !== null)) origins[type] = ` (${describeOrigins(resolution.entries)})`;
// An entry an unknown or removed key takes out of the delivered set is
// invisible in the count, so name it here too (#822).
reportUndeliveredEntryNotices(resolution);
};
count('env', await resolveEntriesFor(envEntryReader, localConfig));
@@ -327,18 +330,22 @@ async function printRepoSection(
if (t === 'env') {
const env = await resolveEntriesFor(envEntryReader, localConfig);
if (env.kind === 'failed') {
reportUndeliveredEntryNotices(env);
console.log(` ${describeEntryFailure(env.failure)}`);
} else if (env.entries.length === 0) {
console.log(' (none)');
} else {
if (options.reveal) {
process.stderr.write('[warn] Env values will be shown in plaintext\n');
}
for (const v of env.entries) {
const display = options.reveal ? v.entry.value : maskEnvValue(v.entry.value);
console.log(` ${v.name}=${display} (${describeOrigin(v)})`);
if (options.verbose && v.entry.description) {
console.log(` ${v.entry.description}`);
reportUndeliveredEntryNotices(env);
if (env.entries.length === 0) {
console.log(' (none)');
} else {
if (options.reveal) {
process.stderr.write('[warn] Env values will be shown in plaintext\n');
}
for (const v of env.entries) {
const display = options.reveal ? v.entry.value : maskEnvValue(v.entry.value);
console.log(` ${v.name}=${display} (${describeOrigin(v)})`);
if (options.verbose && v.entry.description) {
console.log(` ${v.entry.description}`);
}
}
}
}
@@ -348,9 +355,11 @@ async function printRepoSection(
if (t === 'mcp') {
const mcp = await resolveEntriesFor(mcpEntryReader, localConfig);
if (mcp.kind === 'failed') {
reportUndeliveredEntryNotices(mcp);
console.log(` ${describeEntryFailure(mcp.failure)}`);
return;
}
reportUndeliveredEntryNotices(mcp);
if (mcp.entries.length === 0) {
console.log(' (none)');
return;
@@ -371,9 +380,11 @@ async function printRepoSection(
if (t === 'hooks') {
const { resolution: hooks } = await resolveTeamHookEntries(localConfig);
if (hooks.kind === 'failed') {
reportUndeliveredEntryNotices(hooks);
console.log(` ${describeEntryFailure(hooks.failure)}`);
return;
}
reportUndeliveredEntryNotices(hooks);
if (hooks.entries.length === 0) {
console.log(' (none)');
return;