From 484d02ddb596912ba14efa7de0b9fdf6e6780b8c Mon Sep 17 00:00:00 2001 From: ydflow Date: Sun, 27 Sep 2026 11:20:05 +0800 Subject: [PATCH 1/6] fix(env,hooks,mcp,status): name the entries that are not delivered (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env list, mcp list, hooks list, status and list 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. --- docs/usage-guide.md | 15 ++++---- docs/usage-guide.zh-CN.md | 9 ++--- src/__tests__/env-commands.test.ts | 58 ++++++++++++++++++++++++++++++ src/__tests__/hooks-cmd.test.ts | 29 +++++++++++++-- src/__tests__/mcp-cmd.test.ts | 25 ++++++++++++- src/env-commands.ts | 19 +++++++++- src/hooks-cmd.ts | 5 ++- src/mcp-cmd.ts | 5 ++- src/status.ts | 30 ++++++++++------ 9 files changed, 167 insertions(+), 28 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d80c62cef..b01e905cd 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -976,8 +976,9 @@ projects: repeats. - **Where a value comes from.** `teamai env list`, `teamai mcp list`, `teamai hooks list` and `teamai list --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 @@ -987,7 +988,7 @@ 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, and each pull and the list commands 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 | @@ -995,10 +996,10 @@ There is no automatic migration: move each entry into the namespace file the warning names, and drop the key. 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 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 diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a57baeb7a..943512351 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -897,8 +897,9 @@ projects: - **旧模式**(成员没有角色,且团队没有 `projects.yaml`)只读取根目录文件,行为不变; `teamai doctor` 会列出根文件中重复的名字。 - **值从哪里来。** `teamai env list`、`teamai mcp list`、`teamai hooks list` 与 - `teamai list --source repo` 会给出每个条目的 namespace 以及是否覆盖了 - 根条目;`teamai status` 按 namespace 计数;`teamai doctor` 以提示信息列出每一处覆盖。 + `teamai list --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 时也会保留它。 @@ -907,14 +908,14 @@ projects: | Key | 适用于 | 现在 | |---|---|---| -| `projects:` | env、hooks、MCP | 已移除:该条目不再下发给任何人,每次 pull 都会警告并给出应迁往的文件 | +| `projects:` | env、hooks、MCP | 已移除:该条目不再下发给任何人,每次 pull 和各 list 命令都会警告并给出应迁往的文件 | | `roles:` | env | 已移除,处理方式相同 | | `roles:` | hooks、MCP | 已弃用:在一个次版本内仍像 0.25.0 一样按角色过滤,根文件中以不同 `roles:` 重复的名字也照旧生效;pull 会警告,`teamai doctor` 有一项检查,两者都会列出每个目标文件 | 没有自动迁移:把每个条目移到警告给出的 namespace 文件中,并删掉该 key。 条目若带有其 schema 不认识的其他 key(例如拼错的 `role:`),同样不会下发给任何人; -pull 与 `teamai doctor` 会指出文件、条目和该 key。请改正或删除这个 key。 +pull、各 list 命令与 `teamai doctor` 会指出文件、条目和该 key。请改正或删除这个 key。 较新版本 teamai 新增的 key 对旧版本同样是未知 key,因此团队使用新的条目 key 之前, 请先让所有成员升级。 diff --git a/src/__tests__/env-commands.test.ts b/src/__tests__/env-commands.test.ts index d0ff6f792..c6a757ce3 100644 --- a/src/__tests__/env-commands.test.ts +++ b/src/__tests__/env-commands.test.ts @@ -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,25 @@ scope: 'user', }); }); + it('says an updated variable it cannot deliver is undelivered (#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', {}); + + 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. Remove it in env/env.yaml.', + ); + expect(log.success).toHaveBeenCalledWith('Updated env variable: DB_URL=new'); + }); + // 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 () => { diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 78fe480ad..a9f985dbe 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -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 | [Record, string, string | null])[], builtin?: unknown) { +function hooksYaml(hooks: (Record | [Record, 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', () => { diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index 1bcf990f6..fd1348c89 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -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 { describe('mcpList', () => { beforeEach(() => { + resetWarnOnce(); mockedAutoDetectInit.mockResolvedValue({ localConfig: { repo: { localPath: '/repo' }, scope: 'user', additionalRoles: [] }, teamConfig: { toolPaths: {} }, @@ -98,6 +100,27 @@ describe('mcpList', () => { 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'); + }); }); describe('mcpInject', () => { diff --git a/src/env-commands.ts b/src/env-commands.ts index b3c3432a6..312b96ed1 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -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, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; import type { GlobalOptions, LocalConfig } from './types.js'; import { isSelfMode } from './types.js'; @@ -25,6 +25,9 @@ export async function envList(options: GlobalOptions & { reveal?: boolean }): Pr 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). + reportEntryResolution(resolution); const variables = resolution.entries; if (variables.length === 0) { log.info('No env variables defined'); @@ -102,6 +105,20 @@ 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) { + const one = removed.length === 1; + 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. Remove ${one ? 'it' : 'them'} in ${relativePath}.`, + ); + } } else { const newVar: { key: string; value: string; description?: string } = { key, value }; if (options.description) { diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 766a56925..cad683c77 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -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, reportEntryResolution } from './namespaced-entries.js'; import { log } from './utils/logger.js'; import type { GlobalOptions, HookDef } from './types.js'; import { @@ -142,6 +142,9 @@ export async function hooksList(_options: GlobalOptions): Promise { // 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). + if (teamHooks.kind === 'resolved') reportEntryResolution(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 diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index 5f9a3378f..e1cca035c 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -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, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; import { reconcileMcpForConfig, resolveMcpTargets, @@ -31,6 +31,9 @@ export async function mcpList(_options: GlobalOptions): Promise { 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). + reportEntryResolution(resolution); const servers = resolution.entries; if (servers.length === 0) { diff --git a/src/status.ts b/src/status.ts index 8717f4612..1f7df19ba 100644 --- a/src/status.ts +++ b/src/status.ts @@ -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, reportEntryResolution, resolveEntriesFor, type EntryResolution, type EntryType, } from './namespaced-entries.js'; @@ -103,6 +103,9 @@ export async function status(options: GlobalOptions): Promise { 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). + if (resolution.kind === 'resolved') reportEntryResolution(resolution); }; count('env', await resolveEntriesFor(envEntryReader, localConfig)); @@ -321,17 +324,20 @@ async function printRepoSection( const env = await resolveEntriesFor(envEntryReader, localConfig); if (env.kind === 'failed') { 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}`); + reportEntryResolution(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}`); + } } } } @@ -344,6 +350,7 @@ async function printRepoSection( console.log(` ${describeEntryFailure(mcp.failure)}`); return; } + reportEntryResolution(mcp); if (mcp.entries.length === 0) { console.log(' (none)'); return; @@ -367,6 +374,7 @@ async function printRepoSection( console.log(` ${describeEntryFailure(hooks.failure)}`); return; } + reportEntryResolution(hooks); if (hooks.entries.length === 0) { console.log(' (none)'); return; From c3ec8022ec69aeb694de1a30c3e97ffe231ae9c7 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:25:43 +0800 Subject: [PATCH 2/6] 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. --- src/__tests__/e2e/project-scoped-delivery.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/__tests__/e2e/project-scoped-delivery.test.ts b/src/__tests__/e2e/project-scoped-delivery.test.ts index 2adf04113..315cebd40 100644 --- a/src/__tests__/e2e/project-scoped-delivery.test.ts +++ b/src/__tests__/e2e/project-scoped-delivery.test.ts @@ -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=`. + 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 () => { From b2b3618e1fa933b5f5c4659dead2ef7447e2f93f Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:27:53 +0800 Subject: [PATCH 3/6] 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. --- skill-data/setup/references/manage-admin.md | 12 ++--- src/__tests__/env-commands.test.ts | 50 ++++++++++++++++++++- src/env-commands.ts | 13 ++++-- src/namespaced-entries.ts | 11 +++-- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 0f476c8b9..57f20ba29 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -171,12 +171,14 @@ 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 --source repo`) and `teamai doctor` name the namespace + file each entry belongs in; move it there. - 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 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//models.yaml`, declared under `resources.models`, replaces the root profile with the same `id` for members who have `` active. A member's API key is bound to the profile's gateway origin: diff --git a/src/__tests__/env-commands.test.ts b/src/__tests__/env-commands.test.ts index c6a757ce3..ca50f0923 100644 --- a/src/__tests__/env-commands.test.ts +++ b/src/__tests__/env-commands.test.ts @@ -319,7 +319,7 @@ scope: 'user', }); }); - it('says an updated variable it cannot deliver is undelivered (#822)', async () => { + 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( @@ -331,13 +331,59 @@ scope: 'user', 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. Remove it in env/env.yaml.', + + '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 () => { diff --git a/src/env-commands.ts b/src/env-commands.ts index 312b96ed1..d5edbdc5a 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -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, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; +import { describeEntryFailure, describeOrigin, entryFileAbsolutePath, entryFilePath, entryNamespaceFromFlags, moveTo, reportEntryResolution, resolveEntriesFor, TargetFiles } from './namespaced-entries.js'; import type { GlobalOptions, LocalConfig } from './types.js'; import { isSelfMode } from './types.js'; @@ -112,11 +112,18 @@ export async function envAdd( if (updated.projects !== undefined) removed.push('projects'); if (updated.roles !== undefined) removed.push('roles'); if (removed.length > 0) { - const one = removed.length === 1; + // 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. Remove ${one ? 'it' : 'them'} in ${relativePath}.`, + + `so pull does not deliver it. ${moveTo(files)}`, ); } } else { diff --git a/src/namespaced-entries.ts b/src/namespaced-entries.ts index bc99c6622..2d43608fe 100644 --- a/src/namespaced-entries.ts +++ b/src/namespaced-entries.ts @@ -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 `//` 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 | null = null; private projects: ReturnType | null = null; From 553b10c481bfdbb63fee3679d06ceaf3e60cc9f2 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:33:33 +0800 Subject: [PATCH 4/6] fix(entries): report notices alongside resolution failures --- docs/designs/multi-project-management.md | 7 ++- skill-data/setup/references/manage-admin.md | 11 ++--- src/__tests__/mcp-cmd.test.ts | 6 ++- src/__tests__/status-list.test.ts | 47 ++++++++++++++++++++- src/env-commands.ts | 3 +- src/hooks-cmd.ts | 3 +- src/mcp-cmd.ts | 3 +- src/namespaced-entries.ts | 13 ++++-- src/status.ts | 6 ++- 9 files changed, 83 insertions(+), 16 deletions(-) diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index 51511ee6b..1ba6f5b8d 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -416,12 +416,15 @@ 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 --source repo` warn with the namespace file to move it to, +one per listed id. `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 --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 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 57f20ba29..29fca19d7 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -173,12 +173,13 @@ and push it with git. `teamai doctor` lists each override. - 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, the list commands (`teamai env list`, `teamai mcp list`, `teamai hooks list`, - `teamai list --source repo`) and `teamai doctor` name the namespace - file each entry belongs in; move it there. + `teamai list --source repo`), `teamai status` and + `teamai doctor` name the namespace file each entry belongs in; move it there. - An env, hook or MCP entry with a key its schema does not know (a mistyped `role:`) - also reaches nobody. Pull, the list commands 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//models.yaml`, declared under `resources.models`, replaces the root profile with the same `id` for members who have `` active. A member's API key is bound to the profile's gateway origin: diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index fd1348c89..bcb548f58 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -92,11 +92,15 @@ 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; }); diff --git a/src/__tests__/status-list.test.ts b/src/__tests__/status-list.test.ts index ef5677e4a..4f20cfcdc 100644 --- a/src/__tests__/status-list.test.ts +++ b/src/__tests__/status-list.test.ts @@ -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; 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'); diff --git a/src/env-commands.ts b/src/env-commands.ts index d5edbdc5a..e18100073 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -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, moveTo, reportEntryResolution, resolveEntriesFor, TargetFiles } from './namespaced-entries.js'; +import { describeEntryFailure, describeOrigin, entryFileAbsolutePath, entryFilePath, entryNamespaceFromFlags, moveTo, reportEntryNotices, reportEntryResolution, resolveEntriesFor, TargetFiles } from './namespaced-entries.js'; import type { GlobalOptions, LocalConfig } from './types.js'; import { isSelfMode } from './types.js'; @@ -21,6 +21,7 @@ export async function envList(options: GlobalOptions & { reveal?: boolean }): Pr const resolution = await resolveEntriesFor(envEntryReader, localConfig); if (resolution.kind === 'failed') { + reportEntryNotices(resolution); log.error(describeEntryFailure(resolution.failure)); process.exitCode = 1; return; diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index cad683c77..e06211333 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -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, reportEntryResolution } from './namespaced-entries.js'; +import { describeEntryFailure, describeOrigin, reportEntryNotices, reportEntryResolution } from './namespaced-entries.js'; import { log } from './utils/logger.js'; import type { GlobalOptions, HookDef } from './types.js'; import { @@ -145,6 +145,7 @@ export async function hooksList(_options: GlobalOptions): Promise { // 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). if (teamHooks.kind === 'resolved') reportEntryResolution(teamHooks); + else reportEntryNotices(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 diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index e1cca035c..1c57e7ce7 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; import { mcpEntryReader, teamMcpToDef } from './resources/mcp.js'; -import { describeEntryFailure, describeOrigin, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; +import { describeEntryFailure, describeOrigin, reportEntryNotices, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; import { reconcileMcpForConfig, resolveMcpTargets, @@ -27,6 +27,7 @@ export async function mcpList(_options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); if (resolution.kind === 'failed') { + reportEntryNotices(resolution); log.error(describeEntryFailure(resolution.failure)); process.exitCode = 1; return; diff --git a/src/namespaced-entries.ts b/src/namespaced-entries.ts index 2d43608fe..ba0d2da4b 100644 --- a/src/namespaced-entries.ts +++ b/src/namespaced-entries.ts @@ -526,13 +526,20 @@ export function describeEntryFailure(failure: EntryFailure): string { * stale entries. */ export function reportEntryResolution(resolution: EntryResolution): void { - const messages = resolution.notices.map((notice) => notice.message); - if (resolution.kind === 'failed') messages.push(describeEntryFailure(resolution.failure)); - for (const message of messages) { + reportEntryNotices(resolution); + if (resolution.kind === 'failed') { + const message = describeEntryFailure(resolution.failure); if (warnOnce(message)) log.persist(message); } } +/** Report notices even when the caller displays a resolution failure separately. */ +export function reportEntryNotices(resolution: Pick, 'notices'>): void { + for (const notice of resolution.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): string { if (entry.namespace === null) return 'root'; diff --git a/src/status.ts b/src/status.ts index 1f7df19ba..5db607982 100644 --- a/src/status.ts +++ b/src/status.ts @@ -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, reportEntryResolution, resolveEntriesFor, + describeEntryFailure, describeOrigin, describeOrigins, reportEntryNotices, reportEntryResolution, resolveEntriesFor, type EntryResolution, type EntryType, } from './namespaced-entries.js'; @@ -106,6 +106,7 @@ export async function status(options: GlobalOptions): Promise { // An entry an unknown or removed key takes out of the delivered set is // invisible in the count, so name it here too (#822). if (resolution.kind === 'resolved') reportEntryResolution(resolution); + else reportEntryNotices(resolution); }; count('env', await resolveEntriesFor(envEntryReader, localConfig)); @@ -323,6 +324,7 @@ async function printRepoSection( if (t === 'env') { const env = await resolveEntriesFor(envEntryReader, localConfig); if (env.kind === 'failed') { + reportEntryNotices(env); console.log(` ${describeEntryFailure(env.failure)}`); } else { reportEntryResolution(env); @@ -347,6 +349,7 @@ async function printRepoSection( if (t === 'mcp') { const mcp = await resolveEntriesFor(mcpEntryReader, localConfig); if (mcp.kind === 'failed') { + reportEntryNotices(mcp); console.log(` ${describeEntryFailure(mcp.failure)}`); return; } @@ -371,6 +374,7 @@ async function printRepoSection( if (t === 'hooks') { const { resolution: hooks } = await resolveTeamHookEntries(localConfig); if (hooks.kind === 'failed') { + reportEntryNotices(hooks); console.log(` ${describeEntryFailure(hooks.failure)}`); return; } From 76a120e99d45847c89e9cc67c4ffb17758da81f3 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:41:21 +0800 Subject: [PATCH 5/6] fix(entries): scope list warnings and document env updates --- docs/designs/multi-project-management.md | 3 +++ docs/usage-guide.md | 7 +++++-- docs/usage-guide.zh-CN.md | 6 ++++-- skill-data/setup/references/manage-admin.md | 2 ++ src/__tests__/mcp-cmd.test.ts | 17 +++++++++++++++++ src/env-commands.ts | 6 +++--- src/hooks-cmd.ts | 5 ++--- src/mcp-cmd.ts | 6 +++--- src/namespaced-entries.ts | 12 ++++++++---- src/status.ts | 17 ++++++++--------- 10 files changed, 55 insertions(+), 26 deletions(-) diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index 1ba6f5b8d..2ca976973 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -419,6 +419,9 @@ env, existed only in the 0.26.0 betas: an entry that carries one reaches nobody, and pull, `status`, `env list`, `mcp list`, `hooks list` and `list --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 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b01e905cd..70457095a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -988,15 +988,18 @@ The per-entry keys these files replace: | Key | On | Now | |---|---|---| -| `projects:` | env, hooks, MCP | removed: the entry reaches nobody, and each pull and the list commands warn 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, the list commands and `teamai doctor` name the +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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 943512351..5393237da 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -908,14 +908,16 @@ projects: | Key | 适用于 | 现在 | |---|---|---| -| `projects:` | env、hooks、MCP | 已移除:该条目不再下发给任何人,每次 pull 和各 list 命令都会警告并给出应迁往的文件 | +| `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、各 list 命令与 `teamai doctor` 会指出文件、条目和该 key。请改正或删除这个 key。 +pull、各 list 命令、status 与 `teamai doctor` 会指出文件、条目和该 key。请改正或删除这个 key。 较新版本 teamai 新增的 key 对旧版本同样是未知 key,因此团队使用新的条目 key 之前, 请先让所有成员升级。 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 29fca19d7..6de25dd8c 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -175,6 +175,8 @@ and push it with git. `teamai doctor` lists each override. the list commands (`teamai env list`, `teamai mcp list`, `teamai hooks list`, `teamai list --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, the list commands, `teamai status` and `teamai doctor` name the file, entry and key; correct the key or remove it. diff --git a/src/__tests__/mcp-cmd.test.ts b/src/__tests__/mcp-cmd.test.ts index bcb548f58..64debaf59 100644 --- a/src/__tests__/mcp-cmd.test.ts +++ b/src/__tests__/mcp-cmd.test.ts @@ -125,6 +125,23 @@ describe('mcpList', () => { 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', () => { diff --git a/src/env-commands.ts b/src/env-commands.ts index e18100073..65281e7fe 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -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, moveTo, reportEntryNotices, reportEntryResolution, resolveEntriesFor, TargetFiles } 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,14 +21,14 @@ export async function envList(options: GlobalOptions & { reveal?: boolean }): Pr const resolution = await resolveEntriesFor(envEntryReader, localConfig); if (resolution.kind === 'failed') { - reportEntryNotices(resolution); + 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). - reportEntryResolution(resolution); + reportUndeliveredEntryNotices(resolution); const variables = resolution.entries; if (variables.length === 0) { log.info('No env variables defined'); diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index e06211333..9456b8be4 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -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, reportEntryNotices, reportEntryResolution } 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 { @@ -144,8 +144,7 @@ export async function hooksList(_options: GlobalOptions): Promise { 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). - if (teamHooks.kind === 'resolved') reportEntryResolution(teamHooks); - else reportEntryNotices(teamHooks); + 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 diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index 1c57e7ce7..f24878e12 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; import { mcpEntryReader, teamMcpToDef } from './resources/mcp.js'; -import { describeEntryFailure, describeOrigin, reportEntryNotices, reportEntryResolution, resolveEntriesFor } from './namespaced-entries.js'; +import { describeEntryFailure, describeOrigin, reportUndeliveredEntryNotices, resolveEntriesFor } from './namespaced-entries.js'; import { reconcileMcpForConfig, resolveMcpTargets, @@ -27,14 +27,14 @@ export async function mcpList(_options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); if (resolution.kind === 'failed') { - reportEntryNotices(resolution); + 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). - reportEntryResolution(resolution); + reportUndeliveredEntryNotices(resolution); const servers = resolution.entries; if (servers.length === 0) { diff --git a/src/namespaced-entries.ts b/src/namespaced-entries.ts index ba0d2da4b..cd1347a2a 100644 --- a/src/namespaced-entries.ts +++ b/src/namespaced-entries.ts @@ -526,16 +526,20 @@ export function describeEntryFailure(failure: EntryFailure): string { * stale entries. */ export function reportEntryResolution(resolution: EntryResolution): void { - reportEntryNotices(resolution); + reportNotices(resolution.notices); if (resolution.kind === 'failed') { const message = describeEntryFailure(resolution.failure); if (warnOnce(message)) log.persist(message); } } -/** Report notices even when the caller displays a resolution failure separately. */ -export function reportEntryNotices(resolution: Pick, 'notices'>): void { - for (const notice of resolution.notices) { +/** List/status report only entries omitted from delivery; pull reports every notice. */ +export function reportUndeliveredEntryNotices(resolution: Pick, '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); } } diff --git a/src/status.ts b/src/status.ts index 5db607982..5e89d65bb 100644 --- a/src/status.ts +++ b/src/status.ts @@ -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, reportEntryNotices, reportEntryResolution, resolveEntriesFor, + describeEntryFailure, describeOrigin, describeOrigins, reportUndeliveredEntryNotices, resolveEntriesFor, type EntryResolution, type EntryType, } from './namespaced-entries.js'; @@ -105,8 +105,7 @@ export async function status(options: GlobalOptions): Promise { 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). - if (resolution.kind === 'resolved') reportEntryResolution(resolution); - else reportEntryNotices(resolution); + reportUndeliveredEntryNotices(resolution); }; count('env', await resolveEntriesFor(envEntryReader, localConfig)); @@ -324,10 +323,10 @@ async function printRepoSection( if (t === 'env') { const env = await resolveEntriesFor(envEntryReader, localConfig); if (env.kind === 'failed') { - reportEntryNotices(env); + reportUndeliveredEntryNotices(env); console.log(` ${describeEntryFailure(env.failure)}`); } else { - reportEntryResolution(env); + reportUndeliveredEntryNotices(env); if (env.entries.length === 0) { console.log(' (none)'); } else { @@ -349,11 +348,11 @@ async function printRepoSection( if (t === 'mcp') { const mcp = await resolveEntriesFor(mcpEntryReader, localConfig); if (mcp.kind === 'failed') { - reportEntryNotices(mcp); + reportUndeliveredEntryNotices(mcp); console.log(` ${describeEntryFailure(mcp.failure)}`); return; } - reportEntryResolution(mcp); + reportUndeliveredEntryNotices(mcp); if (mcp.entries.length === 0) { console.log(' (none)'); return; @@ -374,11 +373,11 @@ async function printRepoSection( if (t === 'hooks') { const { resolution: hooks } = await resolveTeamHookEntries(localConfig); if (hooks.kind === 'failed') { - reportEntryNotices(hooks); + reportUndeliveredEntryNotices(hooks); console.log(` ${describeEntryFailure(hooks.failure)}`); return; } - reportEntryResolution(hooks); + reportUndeliveredEntryNotices(hooks); if (hooks.entries.length === 0) { console.log(' (none)'); return; From f5714fddb528d4811ff1c31cb979dd8d8fed81e3 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:48:20 +0800 Subject: [PATCH 6/6] docs(entries): align changelog with list and env warnings --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd169e40d..cb3132ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//`, `agents//`, `learnings//`), 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//` or `mcp//`, 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//` or `mcp//`, that member no longer does (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). ### ✨ Features