mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
fix(docs): remove stale local documents during pull (#817)
This commit is contained in:
+3
-1
@@ -1721,7 +1721,7 @@ teamai remove rules <name> --force # Skip the prompt, for scripts and CI
|
||||
|
||||
`teamai doctor` exits with code 0 only when every check passes, and code 1 when any check fails. Before initialization, it reports the missing configuration without assuming a Git provider. The same checks run at the end of a manual `teamai pull`, minus the provider ones and minus any check that pull already reported in its own words on that run. A check marked informational — currently only `No stale env blocks left behind` — still counts toward `doctor`'s exit code, but a pull does not fold its failure into `Pull finished, but N check(s) failed`: a leftover file from an earlier install is cleanup, not a sign this pull broke anything, so it is still named but on its own, gentler line.
|
||||
|
||||
Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. `<tool> is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to <tool>` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable — `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs bundle against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing.
|
||||
Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. `<tool> is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to <tool>` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable — `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs bundle against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing. It also reports extra non-hidden local files as stale, including when the team bundle is empty. Hidden local files are preserved and do not fail this check.
|
||||
|
||||
`Rules delivered to <tool>` and `Agents delivered to <tool>` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under.
|
||||
|
||||
@@ -2004,6 +2004,8 @@ sharing:
|
||||
retries: 3 # optional; retry attempts on failure (default 3)
|
||||
```
|
||||
|
||||
`teamai pull` mirrors the team's non-hidden `docs/` files into `sharing.docs.localDir`: documents deleted from the team repo are also deleted locally, even when the last document or the entire team directory is removed. Empty stale directories are removed; hidden files and directories are preserved. Use a dedicated docs destination, since local-only drafts are also removed. A destination that overlaps the team repo or contains the home/project root is rejected; if it is already the team's `docs/` directory, no copying or cleanup is needed. File/directory type changes at the same path are handled using staged replacements; failed replacements restore the conflicting local entries. If a directory to be replaced contains hidden local entries, move those entries first; the sync refuses to discard them. A failed copy stops cleanup. `teamai pull --dry-run` previews the sync without changing files; use `teamai pull --force` to clean residue from a revision already synced by an older CLI.
|
||||
|
||||
### config.yaml (local config)
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -1658,7 +1658,7 @@ teamai remove rules <name> --force # 跳过确认,用于脚本和 CI
|
||||
|
||||
仅当所有检查通过时,`teamai doctor` 才以状态码 0 退出;任一检查失败时以状态码 1 退出。尚未初始化时,它只报告缺少配置,不会臆测 Git 托管平台。手动执行 `teamai pull` 结束时会运行同一批检查(不含托管平台相关的检查,也不含本次 pull 已经自行报告过的检查)。被标记为 informational 的检查——目前只有 `No stale env blocks left behind`——仍会计入 `doctor` 的退出码,但 pull 不会把它的失败并入 `Pull finished, but N check(s) failed`:早期安装留下的遗留文件属于清理事项,不代表这次 pull 弄坏了什么,因此依旧会被点名,只是单独用一行更轻的提示呈现。
|
||||
|
||||
除了托管平台、clone、配置和 hook 检查之外,`doctor` 还会验证落到本机上的内容。`<tool> is installed` 在 `enabledAgents` 列出了不会收到任何内容的工具时失败——这正是 pull 报告成功、而该工具什么都没收到的情况。它使用与同步相同的解析逻辑,因此像 OpenClaw 这样把 skills 放在 workspace 目录而非工具根目录的工具,会在同步真正写入的位置被判断。工具已安装时也会作为通过项报告,因此 `--json` 无论哪种情况都会为每个已启用工具给出一条记录。pull 结束时的检查只覆盖它从当前目录解析出的那个 scope;其他 scope 请在对应目录下运行 `teamai doctor`。`Skills delivered to <tool>` 会把角色命名空间、标签订阅与排除规则解析出的 skill 集合,与每个已安装工具磁盘上的内容比对:从未送达的 skill 与送达但不可读的 skill 会分别报告——后者指 `SKILL.md` 缺失、frontmatter 无法解析,或其 `name` 与目录名不一致,导致 agent 永远发现不了它。`Team docs delivered` 将 docs 包与 `sharing.docs.localDir` 比对(它只有一个目标目录,而非每个工具一个);每个应有的文档都必须是可读取的文件,因此占用了该名字的目录或断链接也算缺失。
|
||||
除了托管平台、clone、配置和 hook 检查之外,`doctor` 还会验证落到本机上的内容。`<tool> is installed` 在 `enabledAgents` 列出了不会收到任何内容的工具时失败——这正是 pull 报告成功、而该工具什么都没收到的情况。它使用与同步相同的解析逻辑,因此像 OpenClaw 这样把 skills 放在 workspace 目录而非工具根目录的工具,会在同步真正写入的位置被判断。工具已安装时也会作为通过项报告,因此 `--json` 无论哪种情况都会为每个已启用工具给出一条记录。pull 结束时的检查只覆盖它从当前目录解析出的那个 scope;其他 scope 请在对应目录下运行 `teamai doctor`。`Skills delivered to <tool>` 会把角色命名空间、标签订阅与排除规则解析出的 skill 集合,与每个已安装工具磁盘上的内容比对:从未送达的 skill 与送达但不可读的 skill 会分别报告——后者指 `SKILL.md` 缺失、frontmatter 无法解析,或其 `name` 与目录名不一致,导致 agent 永远发现不了它。`Team docs delivered` 将 docs 包与 `sharing.docs.localDir` 比对(它只有一个目标目录,而非每个工具一个);每个应有的文档都必须是可读取的文件,因此占用了该名字的目录或断链接也算缺失。它还会将本地多余的非隐藏文件报告为过期文档,即使团队文档已经删空也会检查;本地隐藏文件会保留,不会使检查失败。
|
||||
|
||||
`Rules delivered to <tool>` 与 `Agents delivered to <tool>` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。
|
||||
|
||||
@@ -1917,6 +1917,8 @@ sharing:
|
||||
retries: 3 # 可选,失败重试次数(默认 3)
|
||||
```
|
||||
|
||||
`teamai pull` 将团队 `docs/` 中的非隐藏文件镜像同步到 `sharing.docs.localDir`:团队库删除的文档,本地也会一并删除,包括删除最后一篇文档或整个团队文档目录的情况。过期的空目录也会删除,隐藏文件和隐藏目录会保留。请使用专用文档目录,因为仅存在于本地的草稿也会删除。目标目录若与团队仓库重叠,或包含主目录/项目根目录,会被拒绝同步;若目标本身就是团队的 `docs/`,则无需复制或清理。同名路径的文件/目录类型变化会先准备替换内容,替换失败时恢复冲突的本地条目。若待替换目录含本地隐藏条目,需先移走这些条目;同步不会丢弃它们。复制失败时不会继续清理。`teamai pull --dry-run` 只预览同步,不修改文件;对于旧版 CLI 已同步过的版本,可用 `teamai pull --force` 清理历史残留。
|
||||
|
||||
### config.yaml(本地配置)
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -105,6 +105,9 @@ teamai recall <q> # Search what the team has already learned
|
||||
Every other command, every flag, and the flags `--help` hides live in the
|
||||
generated reference below. Read it instead of guessing a flag.
|
||||
|
||||
`teamai pull` mirrors non-hidden docs into `sharing.docs.localDir`, removing stale
|
||||
and local-only documents. Use a dedicated directory; preview with `--dry-run`.
|
||||
|
||||
## References
|
||||
|
||||
In the files below, `{SKILL_DIR}` is the directory `teamai skill path core` prints; a reference file you open on its own writes that directory as `SKILL_DIR` in braces.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fse from 'fs-extra';
|
||||
import YAML from 'yaml';
|
||||
|
||||
const cli = fileURLToPath(new URL('../../dist/index.js', import.meta.url));
|
||||
let sandbox: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (sandbox) await fse.remove(sandbox);
|
||||
});
|
||||
|
||||
it('real pull prunes deleted docs from a Git remote, including deletion of the last doc (#794)', async () => {
|
||||
sandbox = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-docs-prune-e2e-'));
|
||||
const home = path.join(sandbox, 'home');
|
||||
const remote = path.join(sandbox, 'remote');
|
||||
const clone = path.join(home, '.teamai', 'team-repo');
|
||||
const destination = path.join(home, '.teamai', 'docs');
|
||||
const env = {
|
||||
...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: '0',
|
||||
GIT_CONFIG_GLOBAL: path.join(sandbox, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_AUTHOR_NAME: 'TeamAI Test', GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'TeamAI Test', GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: 'pipe' });
|
||||
const pull = (...args: string[]) => execFileSync(process.execPath, [cli, 'pull', ...args], {
|
||||
cwd: home, env, encoding: 'utf8', stdio: 'pipe', timeout: 30_000,
|
||||
});
|
||||
const docsCheck = () => {
|
||||
// Other doctor checks may fail in this deliberately minimal fixture.
|
||||
const result = spawnSync(process.execPath, [cli, 'doctor', '--json'], {
|
||||
cwd: home, env, encoding: 'utf8', timeout: 30_000,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
const report = JSON.parse(result.stdout);
|
||||
return report.checks.find((check: { name: string }) => check.name === 'Team docs delivered');
|
||||
};
|
||||
const commit = () => {
|
||||
git(remote, 'add', '-A');
|
||||
git(remote, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Update team docs');
|
||||
};
|
||||
|
||||
await fse.ensureDir(home);
|
||||
await fse.outputFile(path.join(remote, 'teamai.yaml'), YAML.stringify({
|
||||
team: 'docs-prune-test', repo: remote, provider: 'git',
|
||||
sharing: { docs: { localDir: '~/.teamai/docs' } },
|
||||
toolPaths: { claude: { skills: '.claude/skills', rules: '.claude/rules' } },
|
||||
}));
|
||||
await fse.outputFile(path.join(remote, 'docs', 'guide.md'), 'original');
|
||||
await fse.outputFile(path.join(remote, 'docs', 'old', 'retired.md'), 'retired');
|
||||
git(remote, 'init', '-q', '-b', 'main');
|
||||
commit();
|
||||
git(sandbox, 'clone', '-q', remote, clone);
|
||||
await fse.outputFile(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({
|
||||
repo: { localPath: clone, remote }, username: 'tester', scope: 'user', updatePolicy: 'skip',
|
||||
}));
|
||||
await fse.ensureDir(path.join(home, '.claude'));
|
||||
|
||||
expect(pull()).toContain('Synced 2 docs');
|
||||
expect(await fse.readFile(path.join(destination, 'old', 'retired.md'), 'utf8')).toBe('retired');
|
||||
|
||||
// The same path changes type in both directions across real Git revisions.
|
||||
await fse.remove(path.join(remote, 'docs', 'old'));
|
||||
await fse.outputFile(path.join(remote, 'docs', 'old'), 'replacement file');
|
||||
commit();
|
||||
expect(pull()).toContain('Synced 2 docs');
|
||||
expect(await fse.readFile(path.join(destination, 'old'), 'utf8')).toBe('replacement file');
|
||||
expect(docsCheck().ok).toBe(true);
|
||||
|
||||
await fse.remove(path.join(remote, 'docs', 'old'));
|
||||
await fse.outputFile(path.join(remote, 'docs', 'old', 'retired.md'), 'replacement directory');
|
||||
commit();
|
||||
expect(pull()).toContain('Synced 2 docs');
|
||||
expect(await fse.readFile(path.join(destination, 'old', 'retired.md'), 'utf8')).toBe('replacement directory');
|
||||
expect(docsCheck().ok).toBe(true);
|
||||
|
||||
await fse.outputFile(path.join(destination, '.keep'), 'local metadata');
|
||||
await fse.outputFile(path.join(destination, 'draft.md'), 'local-only');
|
||||
await fse.remove(path.join(remote, 'docs', 'old'));
|
||||
await fse.outputFile(path.join(remote, 'docs', 'guide.md'), 'updated');
|
||||
commit();
|
||||
|
||||
expect(pull()).toContain('Synced 1 docs');
|
||||
expect(await fse.readFile(path.join(destination, 'guide.md'), 'utf8')).toBe('updated');
|
||||
expect(await fse.pathExists(path.join(destination, 'old'))).toBe(false);
|
||||
expect(await fse.pathExists(path.join(destination, 'draft.md'))).toBe(false);
|
||||
expect(await fse.readFile(path.join(destination, '.keep'), 'utf8')).toBe('local metadata');
|
||||
expect(docsCheck().ok).toBe(true);
|
||||
|
||||
await fse.remove(path.join(remote, 'docs'));
|
||||
commit();
|
||||
// --dry-run deliberately does not fetch; update the cached clone for the preview.
|
||||
git(clone, 'pull', '--ff-only');
|
||||
const staleCheck = docsCheck();
|
||||
expect(staleCheck.ok).toBe(false);
|
||||
expect(staleCheck.fix).toContain('Stale docs');
|
||||
expect(staleCheck.fix).toContain('guide.md');
|
||||
expect(pull('--dry-run')).toContain('Would sync 0 docs and remove stale local docs');
|
||||
expect(await fse.pathExists(path.join(destination, 'guide.md'))).toBe(true);
|
||||
expect(pull()).toContain('Synced 0 docs');
|
||||
expect(await fse.readdir(destination)).toEqual(['.keep']);
|
||||
expect(docsCheck()).toBeUndefined();
|
||||
expect(pull()).toContain('Already synced');
|
||||
}, 120_000);
|
||||
@@ -59,3 +59,164 @@ describe('DocsHandler nested documents', () => {
|
||||
expect(await handler.scanTeamForPull(teamConfig, localConfig)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocsHandler pruning (#794)', () => {
|
||||
let root: string;
|
||||
let source: string;
|
||||
let destination: string;
|
||||
let team: TeamaiConfig;
|
||||
let local: LocalConfig;
|
||||
const handler = new DocsHandler();
|
||||
const sync = () => handler.pullItem({
|
||||
type: 'docs', name: 'docs', relativePath: 'docs/', sourcePath: source,
|
||||
}, team, local);
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-docs-prune-'));
|
||||
source = path.join(root, 'repo', 'docs');
|
||||
destination = path.join(root, 'home', 'docs');
|
||||
await fse.ensureDir(source);
|
||||
await fse.ensureDir(destination);
|
||||
vi.stubEnv('HOME', path.join(root, 'home'));
|
||||
team = { sharing: { docs: { localDir: destination } } } as TeamaiConfig;
|
||||
local = { scope: 'user', repo: { localPath: path.join(root, 'repo') } } as LocalConfig;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await fse.remove(root);
|
||||
});
|
||||
|
||||
it('mirrors the team bundle by default, removing existing local residue', async () => {
|
||||
await fse.outputFile(path.join(source, 'guide.md'), 'new');
|
||||
await fse.outputFile(path.join(destination, 'draft.md'), 'local');
|
||||
await sync();
|
||||
expect(await fse.pathExists(path.join(destination, 'draft.md'))).toBe(false);
|
||||
expect(await fse.readFile(path.join(destination, 'guide.md'), 'utf8')).toBe('new');
|
||||
});
|
||||
|
||||
it('copies updates and removes deleted and renamed files, including nested directories', async () => {
|
||||
await fse.outputFile(path.join(source, 'guide.md'), 'old');
|
||||
await fse.outputFile(path.join(source, 'nested', 'old.md'), 'old');
|
||||
await sync();
|
||||
await fse.remove(path.join(source, 'nested'));
|
||||
await fse.outputFile(path.join(source, 'guide.md'), 'new');
|
||||
await fse.outputFile(path.join(source, 'renamed', 'new.md'), 'new');
|
||||
await sync();
|
||||
expect(await fse.readFile(path.join(destination, 'guide.md'), 'utf8')).toBe('new');
|
||||
expect(await fse.readFile(path.join(destination, 'renamed', 'new.md'), 'utf8')).toBe('new');
|
||||
expect(await fse.pathExists(path.join(destination, 'nested'))).toBe(false);
|
||||
await sync();
|
||||
expect((await fse.readdir(destination)).sort()).toEqual(['guide.md', 'renamed']);
|
||||
});
|
||||
|
||||
it.each(['guide', 'nested/guide'])('mirrors directory/file transitions at %s', async (name) => {
|
||||
await fse.outputFile(path.join(source, name, 'old.md'), 'old directory');
|
||||
await sync();
|
||||
await fse.remove(path.join(source, name));
|
||||
await fse.outputFile(path.join(source, name), 'new file');
|
||||
await sync();
|
||||
expect(await fse.readFile(path.join(destination, name), 'utf8')).toBe('new file');
|
||||
|
||||
await fse.remove(path.join(source, name));
|
||||
await fse.outputFile(path.join(source, name, 'new.md'), 'new directory');
|
||||
await sync();
|
||||
expect(await fse.readFile(path.join(destination, name, 'new.md'), 'utf8')).toBe('new directory');
|
||||
expect(await fse.readdir(path.join(destination, name))).toEqual(['new.md']);
|
||||
expect((await fse.readdir(destination)).some(entry => entry.startsWith('.teamai-docs-'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['copy', 'rename'])('preserves conflicting entries when replacement %s fails', async (failure) => {
|
||||
await fse.outputFile(path.join(destination, 'guide', 'old.md'), 'old directory');
|
||||
await fse.outputFile(path.join(destination, 'api'), 'old file');
|
||||
await fse.outputFile(path.join(destination, 'stale.md'), 'stale');
|
||||
await fse.outputFile(path.join(source, 'guide'), 'new file');
|
||||
await fse.outputFile(path.join(source, 'api', 'new.md'), 'new directory');
|
||||
if (failure === 'copy') {
|
||||
vi.spyOn(fse, 'copy').mockRejectedValueOnce(new Error('copy failed'));
|
||||
} else {
|
||||
const rename = fse.rename.bind(fse);
|
||||
vi.spyOn(fse, 'rename')
|
||||
.mockImplementationOnce((from, to) => rename(from, to))
|
||||
.mockImplementationOnce((from, to) => rename(from, to))
|
||||
.mockImplementationOnce((from, to) => rename(from, to))
|
||||
// The first replacement succeeded; installing the second one fails.
|
||||
.mockRejectedValueOnce(new Error('rename failed'));
|
||||
}
|
||||
await expect(sync()).rejects.toThrow(`${failure} failed`);
|
||||
expect(await fse.readFile(path.join(destination, 'guide', 'old.md'), 'utf8')).toBe('old directory');
|
||||
expect(await fse.readFile(path.join(destination, 'api'), 'utf8')).toBe('old file');
|
||||
expect(await fse.readFile(path.join(destination, 'stale.md'), 'utf8')).toBe('stale');
|
||||
expect((await fse.readdir(destination)).sort()).toEqual(['api', 'guide', 'stale.md']);
|
||||
});
|
||||
|
||||
it('refuses to replace a directory containing hidden local entries', async () => {
|
||||
await fse.outputFile(path.join(destination, 'guide', 'nested', '.keep'), 'private');
|
||||
await fse.outputFile(path.join(source, 'guide'), 'new file');
|
||||
await expect(sync()).rejects.toThrow('hidden local entries');
|
||||
expect(await fse.readFile(path.join(destination, 'guide', 'nested', '.keep'), 'utf8')).toBe('private');
|
||||
});
|
||||
|
||||
it('replaces a directory link without modifying its target', async () => {
|
||||
const outside = path.join(root, 'outside');
|
||||
await fse.outputFile(path.join(outside, 'keep.md'), 'outside');
|
||||
await fse.symlink(outside, path.join(destination, 'guide'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await fse.outputFile(path.join(source, 'guide', 'new.md'), 'new directory');
|
||||
await sync();
|
||||
expect((await fse.lstat(path.join(destination, 'guide'))).isSymbolicLink()).toBe(false);
|
||||
expect(await fse.readFile(path.join(destination, 'guide', 'new.md'), 'utf8')).toBe('new directory');
|
||||
expect(await fse.readdir(outside)).toEqual(['keep.md']);
|
||||
});
|
||||
|
||||
it.each(['missing', 'empty', 'hidden-only'])('prunes a %s team bundle while retaining hidden local files', async (state) => {
|
||||
await fse.outputFile(path.join(destination, 'old', 'guide.md'), 'old');
|
||||
await fse.outputFile(path.join(destination, 'old', '.keep'), 'local');
|
||||
await fse.outputFile(path.join(destination, '.private', 'draft.md'), 'local');
|
||||
if (state === 'missing') await fse.remove(source);
|
||||
if (state === 'hidden-only') await fse.outputFile(path.join(source, '.private', 'team.md'), 'hidden');
|
||||
await sync();
|
||||
expect(await fse.pathExists(path.join(destination, 'old', 'guide.md'))).toBe(false);
|
||||
expect(await fse.readFile(path.join(destination, 'old', '.keep'), 'utf8')).toBe('local');
|
||||
expect(await fse.readFile(path.join(destination, '.private', 'draft.md'), 'utf8')).toBe('local');
|
||||
expect(await fse.pathExists(path.join(destination, '.private', 'team.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unlinks stale directory links without traversing their targets', async () => {
|
||||
const outside = path.join(root, 'outside');
|
||||
await fse.outputFile(path.join(outside, 'keep.md'), 'local');
|
||||
await fse.symlink(outside, path.join(destination, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await sync();
|
||||
expect(await fse.pathExists(path.join(destination, 'linked'))).toBe(false);
|
||||
expect(await fse.readFile(path.join(outside, 'keep.md'), 'utf8')).toBe('local');
|
||||
});
|
||||
|
||||
it('propagates a copy failure without pruning', async () => {
|
||||
await fse.outputFile(path.join(source, 'guide.md'), 'new');
|
||||
await fse.outputFile(path.join(destination, 'old.md'), 'old');
|
||||
vi.spyOn(fse, 'copy').mockRejectedValueOnce(new Error('copy failed'));
|
||||
await expect(sync()).rejects.toThrow('copy failed');
|
||||
expect(await fse.readFile(path.join(destination, 'old.md'), 'utf8')).toBe('old');
|
||||
});
|
||||
|
||||
it('does not treat an unreadable source as an empty bundle', async () => {
|
||||
await fse.outputFile(path.join(destination, 'old.md'), 'old');
|
||||
vi.spyOn(fse, 'readdir').mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' }));
|
||||
await expect(sync()).rejects.toThrow('denied');
|
||||
expect(await fse.pathExists(path.join(destination, 'old.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the source untouched when localDir already points to team docs', async () => {
|
||||
team.sharing.docs.localDir = source;
|
||||
await fse.outputFile(path.join(source, 'guide.md'), 'team');
|
||||
await sync();
|
||||
expect(await fse.readFile(path.join(source, 'guide.md'), 'utf8')).toBe('team');
|
||||
});
|
||||
|
||||
it.each(['repo', 'home', '.'])('rejects an unsafe destination: %s', async (dir) => {
|
||||
team.sharing.docs.localDir = path.join(root, dir);
|
||||
await fse.outputFile(path.join(source, 'guide.md'), 'team');
|
||||
await expect(sync()).rejects.toThrow('dedicated localDir');
|
||||
expect(await fse.readFile(path.join(source, 'guide.md'), 'utf8')).toBe('team');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -353,6 +353,74 @@ describe('doctor — skills delivered on disk', () => {
|
||||
it('asks nothing when the team repo ships no docs', async () => {
|
||||
expect(await docsCheck()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports missing and stale docs together without changing local files', async () => {
|
||||
await writeTeamDoc('guide.md');
|
||||
const stale = path.join(homeDir, 'team-docs', 'old', 'retired.md');
|
||||
await fse.outputFile(stale, 'stale');
|
||||
const check = await docsCheck();
|
||||
expect(await check!.check()).toBe(false);
|
||||
expect(check!.fix).toContain('Missing from');
|
||||
expect(check!.fix).toContain('guide.md');
|
||||
expect(check!.fix).toContain('Stale docs');
|
||||
expect(check!.fix).toContain('old/retired.md');
|
||||
expect(await fse.readFile(stale, 'utf8')).toBe('stale');
|
||||
});
|
||||
|
||||
it.each(['missing', 'empty', 'hidden-only'])('detects stale docs when the team bundle is %s', async (state) => {
|
||||
if (state === 'empty') await fse.ensureDir(path.join(repoPath, 'docs'));
|
||||
if (state === 'hidden-only') await writeTeamDoc('.keep');
|
||||
await fse.outputFile(path.join(homeDir, 'team-docs', 'old.md'), 'stale');
|
||||
const check = await docsCheck();
|
||||
expect(await check!.check()).toBe(false);
|
||||
expect(check!.fix).toContain('Stale docs');
|
||||
expect(check!.fix).toContain('old.md');
|
||||
expect(check!.fix).not.toContain('Missing from');
|
||||
expect(check!.fix).toContain('teamai pull --force');
|
||||
});
|
||||
|
||||
it.each(['missing', 'empty'])('reports stale empty directories when the team bundle is %s', async (state) => {
|
||||
if (state === 'empty') await fse.ensureDir(path.join(repoPath, 'docs'));
|
||||
const empty = path.join(homeDir, 'team-docs', 'old', 'nested');
|
||||
await fse.ensureDir(empty);
|
||||
await fse.outputFile(path.join(homeDir, 'team-docs', 'private', '.keep'), 'hidden');
|
||||
const check = await docsCheck();
|
||||
expect(await check!.check()).toBe(false);
|
||||
expect(check!.fix).toContain('old/nested/');
|
||||
expect(check!.fix).not.toContain('private');
|
||||
expect(await fse.pathExists(empty)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty directory that still exists in the team bundle', async () => {
|
||||
await fse.ensureDir(path.join(repoPath, 'docs', 'empty'));
|
||||
await fse.ensureDir(path.join(homeDir, 'team-docs', 'empty'));
|
||||
expect(await docsCheck()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores hidden local docs and hidden subdirectories', async () => {
|
||||
await fse.outputFile(path.join(homeDir, 'team-docs', '.draft.md'), 'hidden');
|
||||
await fse.outputFile(path.join(homeDir, 'team-docs', 'old', '.private', 'draft.md'), 'hidden');
|
||||
expect(await docsCheck()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a stale directory link without traversing its target', async () => {
|
||||
const outside = path.join(tempDir, 'outside');
|
||||
await fse.outputFile(path.join(outside, 'keep.md'), 'outside');
|
||||
await fse.ensureDir(path.join(homeDir, 'team-docs'));
|
||||
await fse.symlink(outside, path.join(homeDir, 'team-docs', 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const check = await docsCheck();
|
||||
expect(await check!.check()).toBe(false);
|
||||
expect(check!.fix).toContain('linked');
|
||||
expect(check!.fix).not.toContain('keep.md');
|
||||
expect(await fse.readFile(path.join(outside, 'keep.md'), 'utf8')).toBe('outside');
|
||||
});
|
||||
|
||||
it('reports a destination that cannot be inspected instead of throwing', async () => {
|
||||
await fse.outputFile(path.join(homeDir, 'team-docs'), 'not a directory');
|
||||
const check = await docsCheck();
|
||||
expect(await check!.check()).toBe(false);
|
||||
expect(check!.fix).toContain('Could not inspect the docs mirror');
|
||||
});
|
||||
});
|
||||
|
||||
// The command whose job is reporting bad state must not stack-trace on it.
|
||||
|
||||
@@ -39,6 +39,15 @@ vi.mock('../utils/logger.js', () => ({
|
||||
setStderrOnly: vi.fn(),
|
||||
}));
|
||||
|
||||
// This suite isolates hook/config checks from disk delivery. Docs now compare
|
||||
// both trees even when the team bundle is empty; real files are covered in
|
||||
// doctor-delivery.test.ts, just as for the other resource walkers above.
|
||||
vi.mock('../resources/docs.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../resources/docs.js')>()),
|
||||
listDocFiles: vi.fn().mockResolvedValue([]),
|
||||
resolveDocsDestination: vi.fn().mockReturnValue('/tmp/team-docs'),
|
||||
}));
|
||||
|
||||
// Mock the tgit provider to avoid side effects
|
||||
vi.mock('../providers/tgit/index.js', () => ({
|
||||
isGfInstalled: vi.fn().mockResolvedValue(true),
|
||||
|
||||
@@ -64,7 +64,7 @@ vi.mock('../update.js', () => ({
|
||||
}));
|
||||
|
||||
import { pull } from '../pull.js';
|
||||
import { loadLocalConfigForScope, loadTeamConfig } from '../config.js';
|
||||
import { detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from '../config.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
import type { TeamaiConfig, LocalConfig } from '../types.js';
|
||||
|
||||
@@ -74,8 +74,12 @@ describe('pull reports what reached the tool directory (#585)', () => {
|
||||
let repoPath: string;
|
||||
let teamConfig: TeamaiConfig;
|
||||
let localConfig: LocalConfig;
|
||||
let ioSpy: { mockRestore(): void } | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(detectProjectConfig).mockResolvedValue(null);
|
||||
vi.mocked(saveStateForScope).mockClear();
|
||||
vi.mocked(loadStateForScope).mockResolvedValue({ lastPull: null, lastPullRev: null } as Awaited<ReturnType<typeof loadStateForScope>>);
|
||||
vi.mocked(log.success).mockClear();
|
||||
vi.mocked(log.warn).mockClear();
|
||||
vi.mocked(log.info).mockClear();
|
||||
@@ -127,6 +131,8 @@ describe('pull reports what reached the tool directory (#585)', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
ioSpy?.mockRestore();
|
||||
ioSpy = undefined;
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
await fse.remove(tmpDir);
|
||||
@@ -137,6 +143,105 @@ describe('pull reports what reached the tool directory (#585)', () => {
|
||||
return vi.mocked(log.success).mock.calls.map(([msg]) => String(msg));
|
||||
}
|
||||
|
||||
it.each(['copy', 'prune', 'unsafe destination', 'unreadable source', 'realpath'])(
|
||||
'does not report a successful docs sync after %s fails, and retries on the next pull', async (failure) => {
|
||||
// Simulate a force-pull of an already-synced revision: failure must clear
|
||||
// even that marker, otherwise the following ordinary pull skips the retry.
|
||||
const state = await loadStateForScope(localConfig);
|
||||
state.lastPullRev = 'abc1234';
|
||||
state.lastPullTargets = [];
|
||||
await fse.outputFile(path.join(homeDir, 'docs', 'stale.md'), 'stale');
|
||||
await fse.outputFile(path.join(repoPath, 'env', 'env.yaml'), 'variables:\n - key: DOCS_TEST\n value: delivered\n');
|
||||
if (failure === 'copy') ioSpy = vi.spyOn(fse, 'copy').mockRejectedValueOnce(new Error('copy failed'));
|
||||
if (failure === 'prune') ioSpy = vi.spyOn(fse, 'unlink').mockRejectedValueOnce(new Error('prune failed'));
|
||||
if (failure === 'unsafe destination') teamConfig.sharing.docs.localDir = homeDir;
|
||||
// A file in place of the source directory makes its scan fail without
|
||||
// relying on Unix permissions (the suite also runs on Windows).
|
||||
if (failure === 'unreadable source') {
|
||||
await fse.remove(path.join(repoPath, 'docs'));
|
||||
await fse.writeFile(path.join(repoPath, 'docs'), 'not a directory');
|
||||
}
|
||||
if (failure === 'realpath') ioSpy = vi.spyOn(fse, 'realpath').mockRejectedValueOnce(new Error('realpath failed'));
|
||||
|
||||
await pull({ silent: true, force: true });
|
||||
|
||||
expect(successLines().filter(msg => /Synced \d+ docs/.test(msg))).toEqual([]);
|
||||
expect(vi.mocked(log.warn).mock.calls.flat()).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Failed to sync docs:'),
|
||||
]));
|
||||
expect(await fse.readFile(path.join(homeDir, 'docs', 'stale.md'), 'utf8')).toBe('stale');
|
||||
expect(state.lastPullRev).toBeNull();
|
||||
expect(saveStateForScope).toHaveBeenCalledWith(expect.objectContaining({ lastPullRev: null }), localConfig);
|
||||
// A docs failure must not prevent the next resource type from syncing.
|
||||
expect(successLines().some(msg => msg.includes('Synced 1 env variable(s)'))).toBe(true);
|
||||
|
||||
ioSpy?.mockRestore();
|
||||
ioSpy = undefined;
|
||||
teamConfig.sharing.docs.localDir = 'docs';
|
||||
if (failure === 'unreadable source') {
|
||||
await fse.remove(path.join(repoPath, 'docs'));
|
||||
await fse.outputFile(path.join(repoPath, 'docs', 'guide.md'), '# Guide\n');
|
||||
}
|
||||
await pull({ silent: true });
|
||||
expect(successLines()).toContain('[user] Synced 1 docs');
|
||||
expect(await fse.pathExists(path.join(homeDir, 'docs', 'stale.md'))).toBe(false);
|
||||
expect(state.lastPullRev).toBe('abc1234');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['user', 'project', 'none'])('aggregates inherited scope completion when docs fail in %s', async (failure) => {
|
||||
const projectRoot = path.join(tmpDir, 'project');
|
||||
await fse.ensureDir(projectRoot);
|
||||
vi.mocked(detectProjectConfig).mockResolvedValue({
|
||||
...localConfig, scope: 'project', projectRoot, inheritUserScope: true,
|
||||
});
|
||||
if (failure !== 'none') {
|
||||
await fse.outputFile(path.join(failure === 'user' ? homeDir : projectRoot, 'docs'), 'blocks docs directory');
|
||||
}
|
||||
const outcome = { completed: false };
|
||||
await pull({ silent: true, force: true }, outcome);
|
||||
expect(outcome.completed).toBe(failure === 'none');
|
||||
for (const scope of ['user', 'project']) {
|
||||
const succeeded = scope !== failure;
|
||||
expect(successLines().includes(`[${scope}] Synced 1 docs`)).toBe(succeeded);
|
||||
if (!succeeded) {
|
||||
expect(vi.mocked(log.warn).mock.calls.flat()).toContainEqual(expect.stringContaining(`[${scope}] Failed to sync docs:`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['empty', 'missing'])('prunes only stale empty directories when the team bundle is %s', async (state) => {
|
||||
await fse.remove(path.join(repoPath, 'docs'));
|
||||
if (state === 'empty') await fse.ensureDir(path.join(repoPath, 'docs'));
|
||||
const destination = path.join(homeDir, 'docs');
|
||||
await fse.ensureDir(path.join(destination, 'old', 'nested'));
|
||||
await fse.outputFile(path.join(destination, 'private', '.keep'), 'hidden');
|
||||
await pull({ silent: true, dryRun: true });
|
||||
expect(await fse.pathExists(path.join(destination, 'old', 'nested'))).toBe(true);
|
||||
expect(vi.mocked(log.info).mock.calls.flat()).toContain('[user] [dry-run] Would sync 0 docs and remove stale local docs');
|
||||
await pull({ silent: true, force: true });
|
||||
expect(await fse.pathExists(path.join(destination, 'old'))).toBe(false);
|
||||
expect(await fse.readFile(path.join(destination, 'private', '.keep'), 'utf8')).toBe('hidden');
|
||||
expect(successLines()).toContain('[user] Synced 0 docs');
|
||||
});
|
||||
|
||||
it.each(['empty', 'missing'])('prunes docs through pull when the team bundle is %s (#794)', async (state) => {
|
||||
await pull({ silent: true, force: true });
|
||||
await fse.remove(path.join(repoPath, 'docs'));
|
||||
if (state === 'empty') await fse.ensureDir(path.join(repoPath, 'docs'));
|
||||
await pull({ silent: true, force: true });
|
||||
expect(await fse.pathExists(path.join(homeDir, 'docs', 'guide.md'))).toBe(false);
|
||||
expect(successLines()).toContain('[user] Synced 0 docs');
|
||||
});
|
||||
|
||||
it('previews pruning without deleting files during a dry run', async () => {
|
||||
await fse.outputFile(path.join(homeDir, 'docs', 'stale.md'), 'local');
|
||||
await fse.remove(path.join(repoPath, 'docs'));
|
||||
await pull({ silent: true, dryRun: true });
|
||||
expect(await fse.readFile(path.join(homeDir, 'docs', 'stale.md'), 'utf8')).toBe('local');
|
||||
expect(vi.mocked(log.info).mock.calls.flat()).toContain('[user] [dry-run] Would sync 0 docs and remove stale local docs');
|
||||
});
|
||||
|
||||
it('claims no skills synced when no tool directory exists', async () => {
|
||||
await pull({ silent: true });
|
||||
|
||||
|
||||
+24
-12
@@ -659,22 +659,31 @@ async function envDeliveryProblems(
|
||||
|
||||
/**
|
||||
* The docs bundle has one destination rather than one per tool: `DocsHandler`
|
||||
* copies the whole `docs/` tree into `sharing.docs.localDir`. So this check
|
||||
* mirrors the visible `docs/` tree into `sharing.docs.localDir`. So this check
|
||||
* compares the two trees, file by file, rather than asking each tool.
|
||||
*/
|
||||
export async function buildDocsCheck(ctx: DoctorContext): Promise<Check[]> {
|
||||
const { localConfig, teamConfig } = ctx;
|
||||
if (!teamConfig) return [];
|
||||
|
||||
const { DocsHandler, resolveDocsDestination } = await import('./resources/docs.js');
|
||||
const handler = new DocsHandler();
|
||||
const [item] = await handler.scanTeamForPull(teamConfig, localConfig);
|
||||
if (!item) return [];
|
||||
|
||||
const { listDocFiles, listStaleDocDirectories, resolveDocsDestination } = await import('./resources/docs.js');
|
||||
const dest = resolveDocsDestination(teamConfig, localConfig);
|
||||
const teamFiles = (await listFilesRecursive(item.sourcePath))
|
||||
// Same filter DocsHandler.pullItem copies with: dotfiles never travel.
|
||||
.filter((file) => file.split('/').every((segment) => !segment.startsWith('.')));
|
||||
let teamFiles: string[];
|
||||
let localFiles: string[];
|
||||
let staleDirectories: string[];
|
||||
try {
|
||||
teamFiles = await listDocFiles(path.join(localConfig.repo.localPath, 'docs'));
|
||||
localFiles = await listDocFiles(dest);
|
||||
staleDirectories = await listStaleDocDirectories(path.join(localConfig.repo.localPath, 'docs'), dest);
|
||||
} catch (error) {
|
||||
return [{
|
||||
name: 'Team docs delivered', source: 'local', check: async () => false,
|
||||
fix: `Could not inspect the docs mirror: ${(error as Error).message}. Check directory access, then run \`teamai pull --force\`.`,
|
||||
}];
|
||||
}
|
||||
if (teamFiles.length === 0 && localFiles.length === 0 && staleDirectories.length === 0) return [];
|
||||
const expected = new Set(teamFiles);
|
||||
const stale = [...localFiles.filter(file => !expected.has(file)), ...staleDirectories];
|
||||
|
||||
// isFile, not merely "something is there": a directory sitting on the
|
||||
// expected name, or a symlink with nothing behind it, would satisfy a plain
|
||||
@@ -687,8 +696,11 @@ export async function buildDocsCheck(ctx: DoctorContext): Promise<Check[]> {
|
||||
return [{
|
||||
name: 'Team docs delivered',
|
||||
source: 'local',
|
||||
check: async () => missing.length === 0,
|
||||
fix: `Missing from ${dest}: ${nameList(missing)}. Run \`teamai pull --force\`: a plain `
|
||||
+ 'pull skips a scope whose team repo has not changed, so it cannot restore these.',
|
||||
check: async () => missing.length === 0 && stale.length === 0,
|
||||
fix: [
|
||||
...(missing.length ? [`Missing from ${dest}: ${nameList(missing)}.`] : []),
|
||||
...(stale.length ? [`Stale docs in ${dest}: ${nameList(stale)}.`] : []),
|
||||
'Run `teamai pull --force` to restore the docs mirror; a plain pull skips an already-synced revision.',
|
||||
].join(' '),
|
||||
}];
|
||||
}
|
||||
|
||||
+45
-21
@@ -12,6 +12,7 @@ import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSa
|
||||
import { reconcilePlacementRecords } from './utils/pending-push.js';
|
||||
import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js';
|
||||
import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js';
|
||||
import { listStaleDocDirectories, resolveDocsDestination } from './resources/docs.js';
|
||||
import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js';
|
||||
import { skillsDirForTool } from './resources/skills.js';
|
||||
import { ruleFileExtensionForTool } from './resources/rule-format.js';
|
||||
@@ -784,7 +785,7 @@ async function pullForScope(
|
||||
revisionField?: 'lastPullRev' | 'lastInheritedPullRev';
|
||||
} = {},
|
||||
/** Set to `{ completed: true }` on a real (non-dry-run) sync. See pull(). */
|
||||
result?: { completed: boolean },
|
||||
result?: { completed: boolean; docsSyncFailed: boolean },
|
||||
): Promise<void> {
|
||||
const scopeLabel = localConfig.scope;
|
||||
const revisionField = policy.revisionField ?? 'lastPullRev';
|
||||
@@ -1126,6 +1127,7 @@ async function pullForScope(
|
||||
|
||||
// Step 2: Sync each resource type
|
||||
let totalSynced = 0;
|
||||
let docsSyncFailed = false;
|
||||
let desiredSkillNames: Set<string> | null = null;
|
||||
let knownRepoSkillNames: Set<string> | null = null;
|
||||
// name → team-repo source dir, for the data-safety check in Step 3b cleanup.
|
||||
@@ -1155,6 +1157,38 @@ async function pullForScope(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'docs') {
|
||||
const docsHandler = handler as DocsHandler;
|
||||
// An empty/missing team bundle still needs to remove stale local docs.
|
||||
const item: ResourceItem = {
|
||||
name: 'docs', type: 'docs',
|
||||
sourcePath: path.join(localConfig.repo.localPath, 'docs'), relativePath: 'docs/',
|
||||
};
|
||||
try {
|
||||
const fileCount = await docsHandler.countDocFiles(item.sourcePath);
|
||||
const destination = resolveDocsDestination(freshConfig, localConfig);
|
||||
if (fileCount === 0 && await docsHandler.countDocFiles(destination) === 0
|
||||
&& (await listStaleDocDirectories(item.sourcePath, destination)).length === 0) continue;
|
||||
if (options.dryRun) {
|
||||
log.info(`[${scopeLabel}] [dry-run] Would sync ${fileCount} docs and remove stale local docs`);
|
||||
} else {
|
||||
await docsHandler.pullItem(item, freshConfig, localConfig);
|
||||
log.success(`[${scopeLabel}] Synced ${fileCount} docs`);
|
||||
}
|
||||
totalSynced += fileCount;
|
||||
} catch (e) {
|
||||
docsSyncFailed = true;
|
||||
if (result) result.docsSyncFailed = true;
|
||||
log.warn(`[${scopeLabel}] Failed to sync docs: ${(e as Error).message}`);
|
||||
if (!options.dryRun) {
|
||||
const state = await loadStateForScope(localConfig);
|
||||
state[revisionField] = null;
|
||||
await saveStateForScope(state, localConfig);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skills: directory (role namespace) first, then tags, union of both
|
||||
let items: ResourceItem[];
|
||||
let skippedByTags = 0;
|
||||
@@ -1221,20 +1255,6 @@ async function pullForScope(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'docs') {
|
||||
const docsHandler = handler as DocsHandler;
|
||||
const fileCount = await docsHandler.countDocFiles(items[0].sourcePath);
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info(`[${scopeLabel}] [dry-run] Would sync ${fileCount} docs`);
|
||||
} else {
|
||||
await docsHandler.pullItem(items[0], freshConfig, localConfig);
|
||||
log.success(`[${scopeLabel}] Synced ${fileCount} docs`);
|
||||
}
|
||||
totalSynced += fileCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect existing local resource names before pulling
|
||||
const existingNames = await getExistingLocalNames(type, items, freshConfig, localConfig);
|
||||
|
||||
@@ -1350,7 +1370,7 @@ async function pullForScope(
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSynced === 0) {
|
||||
if (totalSynced === 0 && !docsSyncFailed) {
|
||||
log.info(`[${scopeLabel}] No resources to sync`);
|
||||
}
|
||||
|
||||
@@ -1414,7 +1434,8 @@ async function pullForScope(
|
||||
// Record the revision only after every resource and knowledge phase has had
|
||||
// a chance to run. Inherited pulls use an independent marker so a partial,
|
||||
// safe sync can never suppress a later full user-scope pull.
|
||||
if (!options.dryRun) {
|
||||
// A failed docs mirror must be retried even when the team revision is unchanged.
|
||||
if (!options.dryRun && !docsSyncFailed) {
|
||||
const state = await loadStateForScope(localConfig);
|
||||
if (revisionField === 'lastPullRev') {
|
||||
state.lastPull = new Date().toISOString();
|
||||
@@ -1486,7 +1507,7 @@ async function pullForScope(
|
||||
// A real sync ran to completion for this scope. The "Already synced" fast path
|
||||
// and every error/skip path return before here, and dry-run is excluded so a
|
||||
// preview never reports completion (#702 follow-up).
|
||||
if (result && !options.dryRun) result.completed = true;
|
||||
if (result && !options.dryRun && !docsSyncFailed) result.completed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1899,6 +1920,8 @@ export async function pull(
|
||||
// not repeat it. Owned here rather than at module scope so nothing survives
|
||||
// into another call.
|
||||
const reported = new Set<string>();
|
||||
// A later successful scope must not hide an earlier docs failure (or vice versa).
|
||||
const syncResult = { completed: false, docsSyncFailed: false };
|
||||
|
||||
// Whether HOME's settings.json still has the pre-dispatch hook format. Read now
|
||||
// (HOME-only, no shared clone), but the actual reinject runs later under the
|
||||
@@ -1986,12 +2009,12 @@ export async function pull(
|
||||
await pullForScope(inheritedUserConfig, options, reported, {
|
||||
resourceTypes: ['skills', 'rules', 'docs', 'agents'],
|
||||
revisionField: 'lastInheritedPullRev',
|
||||
}, result);
|
||||
}, syncResult);
|
||||
}
|
||||
} else {
|
||||
activeUserConfig = loadedUserConfig;
|
||||
if (await lockScope(activeUserConfig)) {
|
||||
await pullForScope(activeUserConfig, options, reported, {}, result);
|
||||
await pullForScope(activeUserConfig, options, reported, {}, syncResult);
|
||||
}
|
||||
}
|
||||
} else if (inheritUserScope) {
|
||||
@@ -2008,7 +2031,7 @@ export async function pull(
|
||||
if (projectConfig) {
|
||||
try {
|
||||
if (await lockScope(projectConfig)) {
|
||||
await pullForScope(projectConfig, options, reported, {}, result);
|
||||
await pullForScope(projectConfig, options, reported, {}, syncResult);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn(`Project-scope pull error: ${(e as Error).message}`);
|
||||
@@ -2164,6 +2187,7 @@ export async function pull(
|
||||
// transient branch is how a diagnostic invents a failure.
|
||||
await reportPostPullChecks(options, reported, contended.size > 0);
|
||||
} finally {
|
||||
if (result) result.completed = syncResult.completed && !syncResult.docsSyncFailed;
|
||||
const releaseSyncLocks = async () => {
|
||||
for (const lock of heldLocks.values()) await releaseLock(lock);
|
||||
};
|
||||
|
||||
+153
-13
@@ -2,7 +2,7 @@ import path from 'node:path';
|
||||
import fse from 'fs-extra';
|
||||
import { ResourceHandler } from './base.js';
|
||||
import { resolveBaseDir, type ResourceItem, type TeamaiConfig, type LocalConfig } from '../types.js';
|
||||
import { expandHome, listFilesRecursive } from '../utils/fs.js';
|
||||
import { expandHome } from '../utils/fs.js';
|
||||
import { log } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
@@ -19,6 +19,139 @@ export function resolveDocsDestination(teamConfig: TeamaiConfig, localConfig: Lo
|
||||
return path.isAbsolute(expanded) ? expanded : path.resolve(resolveBaseDir(localConfig), expanded);
|
||||
}
|
||||
|
||||
/** Only absence means an empty bundle; permission and I/O errors must stop pruning. */
|
||||
async function readEntries(dir: string) {
|
||||
try {
|
||||
return await fse.readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Files in the docs mirror, including links themselves but never their targets. */
|
||||
export async function listDocFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const entry of await readEntries(expandHome(dir))) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await listDocFiles(path.join(dir, entry.name));
|
||||
files.push(...nested.map(file => `${entry.name}/${file}`));
|
||||
} else {
|
||||
files.push(entry.name);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Empty leaf directories that pruning would remove; never follow links or hidden entries. */
|
||||
export async function listStaleDocDirectories(source: string | undefined, destination: string): Promise<string[]> {
|
||||
const sourceEntries = new Map((source ? await readEntries(source) : []).map(entry => [entry.name, entry]));
|
||||
const stale: string[] = [];
|
||||
for (const entry of await readEntries(destination)) {
|
||||
if (entry.name.startsWith('.') || !entry.isDirectory()) continue;
|
||||
const sourceEntry = sourceEntries.get(entry.name);
|
||||
if (sourceEntry && !sourceEntry.isDirectory()) continue;
|
||||
const target = path.join(destination, entry.name);
|
||||
if (!sourceEntry && (await readEntries(target)).length === 0) {
|
||||
stale.push(`${entry.name}/`);
|
||||
} else {
|
||||
const nested = await listStaleDocDirectories(sourceEntry ? path.join(source!, entry.name) : undefined, target);
|
||||
stale.push(...nested.map(dir => `${entry.name}/${dir}`));
|
||||
}
|
||||
}
|
||||
return stale;
|
||||
}
|
||||
|
||||
/** Remove stale visible entries without following local symlinks or removing dotfiles. */
|
||||
async function pruneDocs(source: string | undefined, destination: string): Promise<void> {
|
||||
const sourceEntries = new Map((source ? await readEntries(source) : []).map(e => [e.name, e]));
|
||||
for (const entry of await readEntries(destination)) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const target = path.join(destination, entry.name);
|
||||
const sourceEntry = sourceEntries.get(entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (sourceEntry && !sourceEntry.isDirectory()) continue;
|
||||
await pruneDocs(sourceEntry ? path.join(source!, entry.name) : undefined, target);
|
||||
// A stale directory containing hidden local files must survive.
|
||||
if (!sourceEntry && (await fse.readdir(target)).length === 0) await fse.rmdir(target);
|
||||
} else if (!sourceEntry) {
|
||||
await fse.unlink(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function containsPath(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function hasHiddenEntries(dir: string): Promise<boolean> {
|
||||
for (const entry of await readEntries(dir)) {
|
||||
if (entry.name.startsWith('.')) return true;
|
||||
if (entry.isDirectory() && await hasHiddenEntries(path.join(dir, entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Find replacements without traversing destination links or touching either tree. */
|
||||
async function findDocConflicts(source: string, destination: string): Promise<Array<{ source: string; target: string }>> {
|
||||
const conflicts: Array<{ source: string; target: string }> = [];
|
||||
const localEntries = new Map((await readEntries(destination)).map(entry => [entry.name, entry]));
|
||||
for (const entry of await readEntries(source)) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const local = localEntries.get(entry.name);
|
||||
if (!local) continue;
|
||||
const src = path.join(source, entry.name);
|
||||
const target = path.join(destination, entry.name);
|
||||
if (local.isSymbolicLink() || entry.isSymbolicLink() || local.isDirectory() !== entry.isDirectory()) {
|
||||
if (local.isDirectory() && await hasHiddenEntries(target)) {
|
||||
throw new Error(`Cannot replace ${target}: it contains hidden local entries. Move them before retrying.`);
|
||||
}
|
||||
conflicts.push({ source: src, target });
|
||||
} else if (entry.isDirectory()) {
|
||||
conflicts.push(...await findDocConflicts(src, target));
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
async function copyDocs(source: string, destination: string): Promise<void> {
|
||||
const conflicts = await findDocConflicts(source, destination);
|
||||
const staging = conflicts.length ? await fse.mkdtemp(path.join(destination, '.teamai-docs-')) : undefined;
|
||||
const moved: Array<{ target: string; backup: string }> = [];
|
||||
const visible = (src: string) => !path.basename(src).startsWith('.');
|
||||
try {
|
||||
// Prepare replacements while the old entries are still in place. A copy
|
||||
// failure must not remove the directory/file it was meant to replace.
|
||||
for (const [index, conflict] of conflicts.entries()) {
|
||||
await fse.copy(conflict.source, path.join(staging!, `new-${index}`), { filter: visible });
|
||||
}
|
||||
const replacedSources = new Set(conflicts.map(conflict => conflict.source));
|
||||
await fse.copy(source, destination, {
|
||||
overwrite: true,
|
||||
filter: src => visible(src) && !replacedSources.has(src),
|
||||
});
|
||||
// Copying has finished before any rename: no copy worker can write into
|
||||
// a conflicting path while it is being replaced or restored.
|
||||
for (const [index, conflict] of conflicts.entries()) {
|
||||
const backup = path.join(staging!, `old-${index}`);
|
||||
await fse.rename(conflict.target, backup);
|
||||
moved.push({ target: conflict.target, backup });
|
||||
await fse.rename(path.join(staging!, `new-${index}`), conflict.target);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const { target, backup } of moved.reverse()) {
|
||||
await fse.remove(target);
|
||||
await fse.rename(backup, target);
|
||||
}
|
||||
// If restoration itself fails, leave the backup directory for recovery.
|
||||
if (staging) await fse.remove(staging);
|
||||
throw error;
|
||||
}
|
||||
if (staging) await fse.remove(staging);
|
||||
}
|
||||
|
||||
export class DocsHandler extends ResourceHandler {
|
||||
readonly type = 'docs' as const;
|
||||
|
||||
@@ -41,8 +174,7 @@ export class DocsHandler extends ResourceHandler {
|
||||
}
|
||||
|
||||
async countDocFiles(sourcePath: string): Promise<number> {
|
||||
const files = await listFilesRecursive(sourcePath);
|
||||
return files.filter(f => f.split('/').every(segment => !segment.startsWith('.'))).length;
|
||||
return (await listDocFiles(sourcePath)).length;
|
||||
}
|
||||
|
||||
async pushItem(_item: ResourceItem, _teamConfig: TeamaiConfig, _localConfig: LocalConfig): Promise<void> {
|
||||
@@ -50,20 +182,28 @@ export class DocsHandler extends ResourceHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync docs from team repo to local docs directory.
|
||||
* Mirror non-hidden docs from the team repo to the dedicated local directory.
|
||||
*/
|
||||
async pullItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise<void> {
|
||||
const localDocsDir = resolveDocsDestination(teamConfig, localConfig);
|
||||
try {
|
||||
const src = expandHome(item.sourcePath);
|
||||
await fse.copy(src, localDocsDir, {
|
||||
overwrite: true,
|
||||
filter: (srcPath: string) => !path.basename(srcPath).startsWith('.'),
|
||||
});
|
||||
log.debug(`Synced docs → ${localDocsDir}`);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to sync docs: ${(e as Error).message}`);
|
||||
const src = expandHome(item.sourcePath);
|
||||
// Validate the source before touching the destination, including an empty bundle.
|
||||
const entries = await readEntries(src);
|
||||
await fse.ensureDir(localDocsDir);
|
||||
const destination = await fse.realpath(localDocsDir);
|
||||
const repo = await fse.realpath(localConfig.repo.localPath);
|
||||
const base = await fse.realpath(resolveBaseDir(localConfig));
|
||||
// In single-repo mode the configured docs directory may already be the source.
|
||||
if (destination === path.join(repo, 'docs')) return;
|
||||
if (containsPath(destination, base) || containsPath(destination, repo) || containsPath(repo, destination)) {
|
||||
throw new Error('Docs pruning requires a dedicated localDir that does not overlap the team repo or contain the home or project root.');
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
await copyDocs(src, localDocsDir);
|
||||
}
|
||||
// Copy first: a failed copy must not trigger deletion of the previous bundle.
|
||||
await pruneDocs(src, localDocsDir);
|
||||
log.debug(`Synced docs → ${localDocsDir}`);
|
||||
}
|
||||
|
||||
async removeItem(_name: string, _teamConfig: TeamaiConfig, _localConfig: LocalConfig): Promise<string[]> {
|
||||
|
||||
Reference in New Issue
Block a user