Skip to content

Commit dd6abfa

Browse files
fix(sync): macos symlink + multiple sync runs (#41)
* fix(sync): add tests * fix(sync): symlink on macos + relink * Update .changeset/sync-symlink-trailing-slash.md Co-authored-by: paul valladares <85648028+dreyfus92@users.noreply.github.com> --------- Co-authored-by: paul valladares <85648028+dreyfus92@users.noreply.github.com>
1 parent 22f87d0 commit dd6abfa

3 files changed

Lines changed: 69 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bomb.sh/tools": patch
3+
---
4+
5+
Fixes `bsh sync` crashing when creating skill symlinks, and makes re-running it safe. It no longer errors or removes already-synced skills.

src/commands/sync.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { lstat, readlink } from 'node:fs/promises';
2+
import { fileURLToPath } from 'node:url';
3+
import { describe, it, expect } from 'vitest';
4+
import { createFixture } from '../test-utils/index.ts';
5+
import { copySkills } from './sync.ts';
6+
7+
describe('copySkills', () => {
8+
it('symlinks each skill into the destination', async () => {
9+
const fixture = await createFixture({
10+
'source-skills': {
11+
build: {
12+
'SKILL.md': '---\nname: build\ndescription: Build the project.\n---\nbody',
13+
},
14+
},
15+
});
16+
17+
const source = new URL('source-skills/', fixture.root);
18+
const dest = new URL('project/skills/', fixture.root);
19+
20+
const skills = await copySkills({ source, dest });
21+
22+
expect(skills).toEqual([{ name: 'build', description: 'Build the project.' }]);
23+
24+
// The destination entry must be a symlink, not a copy.
25+
const linkPath = fileURLToPath(new URL('build', dest));
26+
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true);
27+
28+
// And it must resolve back to the source skill.
29+
expect(await readlink(linkPath)).toBe('../../source-skills/build');
30+
31+
// Reading through the link reaches the real file.
32+
expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build');
33+
});
34+
35+
it('is idempotent and never touches the source on re-sync', async () => {
36+
const fixture = await createFixture({
37+
'source-skills': {
38+
build: {
39+
'SKILL.md': '---\nname: build\ndescription: Build the project.\n---\nbody',
40+
},
41+
},
42+
});
43+
44+
const source = new URL('source-skills/', fixture.root);
45+
const dest = new URL('project/skills/', fixture.root);
46+
47+
const first = await copySkills({ source, dest });
48+
// Re-running must not throw and must yield the same result.
49+
const second = await copySkills({ source, dest });
50+
51+
expect(second).toEqual(first);
52+
// The source skill files must survive a re-sync.
53+
expect(await fixture.text('source-skills/build/SKILL.md')).toContain('name: build');
54+
expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build');
55+
});
56+
});

src/commands/sync.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readlink, symlink } from 'node:fs/promises';
1+
import { readlink, rm, symlink } from 'node:fs/promises';
22
import { findPackageJSON } from 'node:module';
33
import { dirname, isAbsolute, relative, resolve } from 'node:path';
44
import { platform } from 'node:process';
@@ -41,7 +41,7 @@ interface SkillInfo {
4141
description: string;
4242
}
4343

44-
async function copySkills(options: { source: URL; dest: URL }): Promise<SkillInfo[]> {
44+
export async function copySkills(options: { source: URL; dest: URL }): Promise<SkillInfo[]> {
4545
const { source, dest } = options;
4646
const skills: SkillInfo[] = [];
4747

@@ -60,12 +60,15 @@ async function copySkills(options: { source: URL; dest: URL }): Promise<SkillInf
6060

6161
for (const name of keep) {
6262
const srcDir = new URL(`${name}/`, source);
63-
const destDir = new URL(`${name}/`, dest);
6463

65-
await hfs.deleteAll(destDir);
64+
// Use a path without a trailing slash. macOS rejects a trailing-slash link
65+
// path with ENOENT, and `rm` on a trailing-slash directory symlink follows
66+
// the link and deletes the source rather than unlinking the symlink itself.
67+
const linkPath = resolve(destDirPath, name);
68+
await rm(linkPath, { recursive: true, force: true });
6669

6770
const target = relative(destDirPath, fileURLToPath(srcDir));
68-
await symlink(target, fileURLToPath(destDir), linkType);
71+
await symlink(target, linkPath, linkType);
6972

7073
const content = await hfs.text(new URL('SKILL.md', srcDir));
7174
if (content) {

0 commit comments

Comments
 (0)