Skip to content

Commit ee9dcfd

Browse files
authored
fix(sync): link skills through the project's node_modules (#68)
* test(sync): cover stable skill link targets * fix(sync): link skills through the project's node_modules import.meta.url resolves to the real path, which under pnpm is a hashed node_modules/.pnpm/<hash>/ directory. Links into it dangle once a reinstall changes the hash.
1 parent 1071d34 commit ee9dcfd

3 files changed

Lines changed: 77 additions & 6 deletions

File tree

‎.changeset/sync-stable-links.md‎

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` creating skill symlinks that break after reinstalling dependencies with pnpm

‎src/commands/sync.test.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { lstat, readlink } from 'node:fs/promises';
1+
import { lstat, readlink, realpath, symlink } from 'node:fs/promises';
22
import { fileURLToPath } from 'node:url';
33
import { describe, it, expect } from 'vitest';
44
import { createFixture, createMocks } from '../test-utils/index.ts';
5-
import { copySkills, findParentPackage, updateAgentsMd } from './sync.ts';
5+
import { copySkills, findParentPackage, resolveSkillsSource, updateAgentsMd } from './sync.ts';
66

77
describe('copySkills', () => {
88
it('symlinks each skill into the destination', async () => {
@@ -82,6 +82,34 @@ describe('copySkills', () => {
8282
]),
8383
);
8484
});
85+
86+
it('prunes stale links that target the real path of the source', async () => {
87+
const fixture = await createFixture({
88+
store: {
89+
skills: {
90+
build: { 'SKILL.md': '---\nname: build\ndescription: Build.\n---\n' },
91+
},
92+
},
93+
project: {
94+
tools: ({ symlink }) => symlink('../store'),
95+
skills: {},
96+
},
97+
});
98+
// Left behind by an older sync that linked through the real path.
99+
await symlink(
100+
`${await realpath(fileURLToPath(new URL('store/skills/', fixture.root)))}/removed`,
101+
fileURLToPath(new URL('project/skills/removed', fixture.root)),
102+
);
103+
104+
await copySkills({
105+
source: new URL('project/tools/skills/', fixture.root),
106+
dest: new URL('project/skills/', fixture.root),
107+
});
108+
109+
await expect(
110+
lstat(fileURLToPath(new URL('project/skills/removed', fixture.root))),
111+
).rejects.toThrow();
112+
});
85113
});
86114

87115
describe('updateAgentsMd', () => {
@@ -104,6 +132,30 @@ describe('updateAgentsMd', () => {
104132
});
105133
});
106134

135+
describe('resolveSkillsSource', () => {
136+
it('prefers the project node_modules path over the real store path', async () => {
137+
const fixture = await createFixture({
138+
store: { tools: { skills: { build: { 'SKILL.md': '' } } } },
139+
project: {
140+
'node_modules/@bomb.sh/tools': ({ symlink }) => symlink('../../../store/tools'),
141+
},
142+
});
143+
const root = new URL('project/', fixture.root);
144+
const fallback = new URL('store/tools/skills/', fixture.root);
145+
146+
expect((await resolveSkillsSource(root, fallback)).href).toBe(
147+
new URL('node_modules/@bomb.sh/tools/skills/', root).href,
148+
);
149+
});
150+
151+
it('falls back when the project has no linked @bomb.sh/tools', async () => {
152+
const fixture = await createFixture({ project: {} });
153+
const fallback = new URL('store/tools/skills/', fixture.root);
154+
155+
expect(await resolveSkillsSource(new URL('project/', fixture.root), fallback)).toBe(fallback);
156+
});
157+
});
158+
107159
describe('findParentPackage', () => {
108160
it('resolves the project root from INIT_CWD, not from this package', async () => {
109161
const fixture = await createFixture({

‎src/commands/sync.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readlink, rm, symlink } from 'node:fs/promises';
1+
import { readlink, realpath, rm, symlink } from 'node:fs/promises';
22
import { findPackageJSON } from 'node:module';
33
import { cwd, env, platform } from 'node:process';
44
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -22,7 +22,7 @@ export async function sync(_ctx: CommandContext): Promise<void> {
2222
}
2323

2424
const root = new URL('./', pathToFileURL(parentPkg));
25-
const source = new URL('../../skills/', import.meta.url);
25+
const source = await resolveSkillsSource(root, new URL('../../skills/', import.meta.url));
2626

2727
if (!(await hfs.isDirectory(source))) {
2828
console.error('Could not locate bundled skills directory.');
@@ -36,6 +36,17 @@ export async function sync(_ctx: CommandContext): Promise<void> {
3636
console.info(`Synced ${skills.length} skills to skills/`);
3737
}
3838

39+
/**
40+
* Prefer linking through the project's `node_modules/@bomb.sh/tools` over
41+
* `import.meta.url`, which resolves to the real path. Under pnpm that is a
42+
* versioned `node_modules/.pnpm/<hash>/` directory, so links into it dangle
43+
* once a reinstall changes the hash.
44+
*/
45+
export async function resolveSkillsSource(root: URL, fallback: URL): Promise<URL> {
46+
const linked = new URL('node_modules/@bomb.sh/tools/skills/', root);
47+
return (await hfs.isDirectory(linked)) ? linked : fallback;
48+
}
49+
3950
interface SkillInfo {
4051
name: string;
4152
description: string;
@@ -89,14 +100,17 @@ async function pruneStaleLinks(options: {
89100
const { dest, source, keep } = options;
90101
if (!(await hfs.isDirectory(dest))) return;
91102

103+
// Links from older syncs may target the real path rather than `source`.
104+
const sources = [source.href, `${pathToFileURL(await realpath(source)).href}/`];
105+
92106
for await (const entry of hfs.list(dest)) {
93107
if (!entry.isSymlink) continue;
94108
if (keep.has(entry.name)) continue;
95109

96110
const linkPath = fileURLToPath(new URL(entry.name, dest));
97111
try {
98-
const target = await readlink(linkPath);
99-
if (resolveLinkTarget(dest, target).href.startsWith(source.href)) {
112+
const { href } = resolveLinkTarget(dest, await readlink(linkPath));
113+
if (sources.some((s) => href.startsWith(s))) {
100114
await hfs.deleteAll(linkPath);
101115
}
102116
} catch {

0 commit comments

Comments
 (0)