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
4 changes: 4 additions & 0 deletions .changeset/pnpm-sync-project-root.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 27 additions & 2 deletions src/commands/sync.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
38 changes: 25 additions & 13 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +15,7 @@ const GITIGNORE_START = '# bsh:skills';
const GITIGNORE_END = '# /bsh:skills';

export async function sync(_ctx: CommandContext): Promise<void> {
const parentPkg = findParentPackage();
const parentPkg = await findParentPackage();
if (!parentPkg) {
console.info('Skipping sync — no parent project found (running inside @bomb.sh/tools?)');
return;
Expand Down Expand Up @@ -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/<hash>/, 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<string | null> {
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;
}
Loading