diff --git a/.changeset/pnpm-sync-project-root.md b/.changeset/pnpm-sync-project-root.md new file mode 100644 index 0000000..608b2d4 --- /dev/null +++ b/.changeset/pnpm-sync-project-root.md @@ -0,0 +1,4 @@ +--- +'@bomb.sh/tools': patch +--- +Fixes `bsh sync` writing its output into the pnpm store instead of the project root. The project is now resolved from the invocation directory (`INIT_CWD`, falling back to `cwd`), so `skills/` symlinks, the `AGENTS.md` section, and the `.gitignore` entries land in the project that ran the command under pnpm's default isolated `node_modules` layout diff --git a/src/commands/sync.test.ts b/src/commands/sync.test.ts index 86ce5ed..130eb42 100644 --- a/src/commands/sync.test.ts +++ b/src/commands/sync.test.ts @@ -1,8 +1,8 @@ import { lstat, readlink } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; -import { createFixture } from '../test-utils/index.ts'; -import { copySkills } from './sync.ts'; +import { createFixture, createMocks } from '../test-utils/index.ts'; +import { copySkills, findParentPackage } from './sync.ts'; describe('copySkills', () => { it('symlinks each skill into the destination', async () => { @@ -54,3 +54,28 @@ describe('copySkills', () => { expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build'); }); }); + +describe('findParentPackage', () => { + it('resolves the project root from INIT_CWD, not from this package', async () => { + const fixture = await createFixture({ + project: { + 'package.json': '{ "name": "my-app" }', + nested: {}, + }, + }); + createMocks({ env: { INIT_CWD: fileURLToPath(new URL('project/nested/', fixture.root)) } }); + + const found = await findParentPackage(); + + expect(found).toBe(fileURLToPath(new URL('project/package.json', fixture.root))); + }); + + it('returns null when invoked inside @bomb.sh/tools itself', async () => { + const fixture = await createFixture({ + 'package.json': '{ "name": "@bomb.sh/tools" }', + }); + createMocks({ env: { INIT_CWD: fileURLToPath(fixture.root) } }); + + expect(await findParentPackage()).toBe(null); + }); +}); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 47d39bf..bfc83e3 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,7 +1,7 @@ import { readlink, rm, symlink } from 'node:fs/promises'; import { findPackageJSON } from 'node:module'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; -import { platform } from 'node:process'; +import { cwd, env, platform } from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { NodeHfs } from '@humanfs/node'; import { parse } from 'ultramatter'; @@ -15,7 +15,7 @@ const GITIGNORE_START = '# bsh:skills'; const GITIGNORE_END = '# /bsh:skills'; export async function sync(_ctx: CommandContext): Promise { - const parentPkg = findParentPackage(); + const parentPkg = await findParentPackage(); if (!parentPkg) { console.info('Skipping sync — no parent project found (running inside @bomb.sh/tools?)'); return; @@ -174,16 +174,28 @@ function parseFrontmatter(content: string): SkillInfo | undefined { return { name, description }; } -function findParentPackage(): string | null { - const ownPkg = findPackageJSON(import.meta.url); - if (!ownPkg) return null; - - let cursor = dirname(dirname(ownPkg)); - while (cursor !== dirname(cursor)) { - const candidate = findPackageJSON(pathToFileURL(`${cursor}/`)); - if (!candidate) return null; - if (candidate !== ownPkg) return candidate; - cursor = dirname(dirname(candidate)); +/** + * Locate the consuming project's package.json. The project root must come + * from where the command was invoked, never from this package's physical + * location: under pnpm's isolated layout, import.meta.url resolves through + * the node_modules symlink into node_modules/.pnpm//, and walking up + * from there lands in the store, not the user's project. INIT_CWD (set by + * pnpm/npm to the directory the script was run from) is preferred because + * package scripts may rewrite cwd. Returns null when no project is found or + * when invoked inside @bomb.sh/tools itself. + */ +export async function findParentPackage(): Promise { + const startDir = env.INIT_CWD ?? cwd(); + const candidate = findPackageJSON(pathToFileURL(`${startDir}/`)); + if (!candidate) return null; + + const text = await hfs.text(pathToFileURL(candidate)); + if (!text) return null; + try { + const pkg = JSON.parse(text) as { name?: string }; + if (pkg.name === '@bomb.sh/tools') return null; + } catch { + return null; } - return null; + return candidate; }