Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 43 additions & 52 deletions src/__tests__/wiki-bugfix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
* `REMOVABLE_TYPES` didn't include 'wiki'.
* BUG #4 — `teamai pull` didn't honor wiki tombstones: a `wiki/.removed`
* entry in the team repo never deleted the local copy.
*
* NOTE (refactored for shared wiki location):
* With the wiki refactoring, BUG #4 no longer iterates through per-tool
* wiki directories. Instead, pull.ts has a dedicated wiki tombstone cleanup
* block that removes pages from the shared wiki location (~/.teamai/wiki/).
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
Expand All @@ -38,10 +43,9 @@ vi.mock('../utils/logger.js', () => ({

import { filterExistingTopLevelPaths } from '../push.js';
import { WikiHandler } from '../resources/wiki.js';
import { ResourceHandler } from '../resources/base.js';
import { resolveBaseDir } from '../types.js';
import { remove as removeFile } from '../utils/fs.js';
import type { TeamaiConfig, LocalConfig, ResourceType } from '../types.js';
import { getTeamaiHome } from '../types.js';
import type { TeamaiConfig, LocalConfig } from '../types.js';

// ─────────────────────────────────────────────────────────────────────────
// BUG #1 — filterExistingTopLevelPaths
Expand Down Expand Up @@ -107,6 +111,8 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => {
try {
const repoPath = path.join(tmpDir, 'team-repo');
await fse.ensureDir(path.join(repoPath, 'wiki'));
const teamaiHome = path.join(tmpDir, '.teamai');
await fse.ensureDir(path.join(teamaiHome, 'wiki'));

const teamConfig: TeamaiConfig = {
team: 'test',
Expand All @@ -124,7 +130,6 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => {
claude: {
skills: '.claude/skills',
rules: '.claude/rules',
wiki: '.claude/wiki',
},
},
} as TeamaiConfig;
Expand All @@ -133,7 +138,8 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => {
username: 'tester',
updatePolicy: 'auto',
additionalRoles: [],
scope: 'user',
scope: 'project',
projectRoot: tmpDir,
} as LocalConfig;

const removed = await handler.removeItem('entities/alpha', teamConfig, localConfig);
Expand All @@ -153,18 +159,18 @@ describe('remove.ts REMOVABLE_TYPES (BUG #3 regression)', () => {
// ─────────────────────────────────────────────────────────────────────────
describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => {
let tmpDir: string;
let homeDir: string;
let repoPath: string;
let teamaiHome: string;
let teamConfig: TeamaiConfig;
let localConfig: LocalConfig;

beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pullts-'));
homeDir = path.join(tmpDir, 'home');
repoPath = path.join(tmpDir, 'team-repo');
teamaiHome = path.join(tmpDir, '.teamai');

await fse.ensureDir(path.join(repoPath, 'wiki'));
await fse.ensureDir(path.join(homeDir, '.claude', 'wiki', 'entities'));
vi.stubEnv('HOME', homeDir);
await fse.ensureDir(path.join(teamaiHome, 'wiki', 'entities'));

teamConfig = {
team: 'test',
Expand All @@ -182,7 +188,6 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => {
claude: {
skills: '.claude/skills',
rules: '.claude/rules',
wiki: '.claude/wiki',
},
},
} as TeamaiConfig;
Expand All @@ -192,20 +197,20 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => {
username: 'tester',
updatePolicy: 'auto',
additionalRoles: [],
scope: 'user',
scope: 'project',
projectRoot: tmpDir,
} as LocalConfig;
});

afterEach(async () => {
vi.unstubAllEnvs();
await fse.remove(tmpDir);
});

/**
* Reproduces the exact loop pull.ts runs after the fix (tombstoneTypes
* now includes wiki with toolPathField='wiki'). We keep the check at
* this layer instead of end-to-end because pullForScope touches git,
* spinners, and file-system fetches that are costly to mock.
* Reproduces the new wiki tombstone cleanup logic in pull.ts.
* With the refactoring, wiki tombstones are cleaned up from the shared
* ~/.teamai/wiki/ location (or <projectRoot>/.teamai/wiki/ for project scope)
* instead of iterating through per-tool directories.
*/
it('removes local wiki pages that are listed in wiki/.removed', async () => {
// Simulate a team member running `teamai remove wiki entities/alpha`:
Expand All @@ -214,36 +219,21 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => {
path.join(repoPath, 'wiki', '.removed'),
'entities/alpha\n',
);
// Local copy that should be cleaned up.
const localAlpha = path.join(homeDir, '.claude', 'wiki', 'entities', 'alpha.md');
// Local copy in shared wiki location that should be cleaned up.
const localAlpha = path.join(teamaiHome, 'wiki', 'entities', 'alpha.md');
await fse.writeFile(localAlpha, '# alpha');

// Mimic pull.ts tombstone loop with the fixed config
const tombstoneTypes: {
type: ResourceType;
ext?: string;
toolPathField: 'rules' | 'skills' | 'wiki';
}[] = [
{ type: 'rules', ext: '.md', toolPathField: 'rules' },
{ type: 'skills', toolPathField: 'skills' },
{ type: 'wiki', ext: '.md', toolPathField: 'wiki' },
];

// Mimic pull.ts wiki tombstone cleanup with the new architecture
const handler = new WikiHandler();
const baseDir = resolveBaseDir(localConfig);
const wikiTombstones = await handler.readTombstones(localConfig);

for (const { type, ext, toolPathField } of tombstoneTypes) {
if (type !== 'wiki') continue;
const tombstones = await handler.readTombstones(localConfig);
for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) {
const dir = toolPath[toolPathField];
if (!dir) continue;
if (!await ResourceHandler.isToolInstalled(dir, baseDir)) continue;
for (const name of tombstones) {
const p = path.join(baseDir, dir, ext ? `${name}${ext}` : name);
if (await fse.pathExists(p)) {
await removeFile(p);
}
if (wikiTombstones.size > 0) {
const sharedWikiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot);
const wikiDir = path.join(sharedWikiHome, 'wiki');
for (const name of wikiTombstones) {
const wikiPath = path.join(wikiDir, `${name}.md`);
if (await fse.pathExists(wikiPath)) {
await removeFile(wikiPath);
}
}
}
Expand All @@ -256,21 +246,22 @@ describe('pull tombstone cleanup for wiki (BUG #4 regression)', () => {
path.join(repoPath, 'wiki', '.removed'),
'entities/alpha\n',
);
const localAlpha = path.join(homeDir, '.claude', 'wiki', 'entities', 'alpha.md');
const localBeta = path.join(homeDir, '.claude', 'wiki', 'entities', 'beta.md');
const localAlpha = path.join(teamaiHome, 'wiki', 'entities', 'alpha.md');
const localBeta = path.join(teamaiHome, 'wiki', 'entities', 'beta.md');
await fse.writeFile(localAlpha, '# alpha');
await fse.writeFile(localBeta, '# beta');

const handler = new WikiHandler();
const baseDir = resolveBaseDir(localConfig);
const tombstones = await handler.readTombstones(localConfig);
const wikiTombstones = await handler.readTombstones(localConfig);

for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) {
if (!toolPath.wiki) continue;
if (!await ResourceHandler.isToolInstalled(toolPath.wiki, baseDir)) continue;
for (const name of tombstones) {
const p = path.join(baseDir, toolPath.wiki, `${name}.md`);
if (await fse.pathExists(p)) await removeFile(p);
if (wikiTombstones.size > 0) {
const sharedWikiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot);
const wikiDir = path.join(sharedWikiHome, 'wiki');
for (const name of wikiTombstones) {
const wikiPath = path.join(wikiDir, `${name}.md`);
if (await fse.pathExists(wikiPath)) {
await removeFile(wikiPath);
}
}
}

Expand Down
40 changes: 19 additions & 21 deletions src/__tests__/wiki-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,18 @@ import type { TeamaiConfig, LocalConfig } from '../types.js';

describe('WikiHandler', () => {
let tmpDir: string;
let homeDir: string;
let teamaiHome: string;
let handler: WikiHandler;
let teamConfig: TeamaiConfig;
let localConfig: LocalConfig;

beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-wiki-test-'));
homeDir = path.join(tmpDir, 'home');
teamaiHome = path.join(tmpDir, '.teamai');

const repoPath = path.join(tmpDir, 'team-repo');
await fse.ensureDir(path.join(repoPath, 'wiki'));
await fse.ensureDir(path.join(homeDir, '.claude-internal', 'wiki'));

vi.stubEnv('HOME', homeDir);
await fse.ensureDir(path.join(teamaiHome, 'wiki'));

handler = new WikiHandler();

Expand All @@ -49,14 +47,14 @@ describe('WikiHandler', () => {
env: { injectShellProfile: true },
},
toolPaths: {
'claude-internal': {
skills: '.claude-internal/skills',
rules: '.claude-internal/rules',
wiki: '.claude-internal/wiki',
claude: {
skills: '.claude/skills',
rules: '.claude/rules',
},
},
};

// Use project scope with projectRoot so getTeamaiHome returns the test tmpDir
localConfig = {
repo: {
localPath: repoPath,
Expand All @@ -65,19 +63,19 @@ describe('WikiHandler', () => {
username: 'testuser',
updatePolicy: 'auto',
additionalRoles: [],
scope: 'user',
scope: 'project',
projectRoot: tmpDir,
};
});

afterEach(async () => {
vi.unstubAllEnvs();
await fse.remove(tmpDir);
});

describe('scanLocalForPush', () => {
it('detects new wiki page not in team repo', async () => {
// Create local wiki page
const localWiki = path.join(homeDir, '.claude-internal', 'wiki');
// Create local wiki page in shared location
const localWiki = path.join(teamaiHome, 'wiki');
await fse.ensureDir(path.join(localWiki, 'entities'));
await fse.writeFile(
path.join(localWiki, 'entities', 'test-module.md'),
Expand All @@ -102,7 +100,7 @@ describe('WikiHandler', () => {
);

// Create same page locally with different content
const localWiki = path.join(homeDir, '.claude-internal', 'wiki');
const localWiki = path.join(teamaiHome, 'wiki');
await fse.ensureDir(path.join(localWiki, 'entities'));
await fse.writeFile(
path.join(localWiki, 'entities', 'test-module.md'),
Expand All @@ -124,7 +122,7 @@ describe('WikiHandler', () => {
await fse.ensureDir(path.join(teamWiki, 'entities'));
await fse.writeFile(path.join(teamWiki, 'entities', 'test-module.md'), content);

const localWiki = path.join(homeDir, '.claude-internal', 'wiki');
const localWiki = path.join(teamaiHome, 'wiki');
await fse.ensureDir(path.join(localWiki, 'entities'));
await fse.writeFile(path.join(localWiki, 'entities', 'test-module.md'), content);

Expand All @@ -134,7 +132,7 @@ describe('WikiHandler', () => {
});

it('excludes _metadata.json from push', async () => {
const localWiki = path.join(homeDir, '.claude-internal', 'wiki');
const localWiki = path.join(teamaiHome, 'wiki');
await fse.writeFile(
path.join(localWiki, '_metadata.json'),
'{"version":1}',
Expand Down Expand Up @@ -185,7 +183,7 @@ describe('WikiHandler', () => {

describe('pushItem', () => {
it('copies wiki page to team repo', async () => {
const localWiki = path.join(homeDir, '.claude-internal', 'wiki');
const localWiki = path.join(teamaiHome, 'wiki');
await fse.ensureDir(path.join(localWiki, 'entities'));
const sourcePath = path.join(localWiki, 'entities', 'test.md');
await fse.writeFile(sourcePath, '# Test');
Expand All @@ -209,7 +207,7 @@ describe('WikiHandler', () => {
});

describe('pullItem', () => {
it('copies wiki page to local tool directory', async () => {
it('copies wiki page to shared wiki location', async () => {
const teamWiki = path.join(localConfig.repo.localPath, 'wiki');
await fse.ensureDir(path.join(teamWiki, 'entities'));
const sourcePath = path.join(teamWiki, 'entities', 'test.md');
Expand All @@ -226,15 +224,15 @@ describe('WikiHandler', () => {
localConfig,
);

const dest = path.join(homeDir, '.claude-internal', 'wiki', 'entities', 'test.md');
const dest = path.join(teamaiHome, 'wiki', 'entities', 'test.md');
expect(await fse.pathExists(dest)).toBe(true);
expect(await fse.readFile(dest, 'utf-8')).toBe('# Test from team');
});
});

describe('rebuildMetadata', () => {
it('rebuilds metadata from wiki pages', async () => {
const wikiDir = path.join(tmpDir, 'test-wiki');
const wikiDir = path.join(teamaiHome, 'wiki');
await fse.ensureDir(path.join(wikiDir, 'entities'));
await fse.writeFile(
path.join(wikiDir, 'entities', 'foo.md'),
Expand All @@ -245,7 +243,7 @@ describe('WikiHandler', () => {
'---\ntitle: Bar\ncategory: entity\ntags: [util]\nupdated: 2026-04-08\n---\n\n# Bar\n\n## Related\n\n## Backlinks\n',
);

await WikiHandler.rebuildMetadata(wikiDir);
await WikiHandler.rebuildMetadata(localConfig);

const metadataPath = path.join(wikiDir, '_metadata.json');
expect(await fse.pathExists(metadataPath)).toBe(true);
Expand Down
22 changes: 20 additions & 2 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,10 @@ async function pullForScope(
const tombstoneTypes: {
type: ResourceType;
ext?: string;
toolPathField: 'rules' | 'skills' | 'wiki';
toolPathField: 'rules' | 'skills';
}[] = [
{ type: 'rules', ext: '.md', toolPathField: 'rules' },
{ type: 'skills', toolPathField: 'skills' },
{ type: 'wiki', ext: '.md', toolPathField: 'wiki' },
];

const baseDir = resolveBaseDir(localConfig);
Expand All @@ -445,6 +444,25 @@ async function pullForScope(
}
}

// Wiki tombstone cleanup: wiki is now in shared location, not per-tool
try {
const wikiHandler = getHandler('wiki');
const wikiTombstones = await wikiHandler.readTombstones(localConfig);
if (wikiTombstones.size > 0) {
const teamaiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot);
const wikiDir = path.join(teamaiHome, 'wiki');
for (const name of wikiTombstones) {
const wikiPath = path.join(wikiDir, `${name}.md`);
if (await pathExists(wikiPath)) {
await remove(wikiPath);
log.debug(`[${scopeLabel}] Cleaned up tombstoned wiki ${name} from shared wiki`);
}
}
}
} catch (e) {
log.debug(`[${scopeLabel}] Wiki tombstone cleanup skipped: ${(e as Error).message}`);
}

if (roleContext) {
await cleanupInactiveNamespaceSkills(
freshConfig,
Expand Down
Loading
Loading