From 683a5fb97016cf725ee0ddcdd6c98b2e0220a791 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 23 Jun 2026 04:36:21 -0400 Subject: [PATCH] fix(sync): walk up to project root --- .changeset/fix-sync-project-resolution.md | 5 + src/commands/sync.test.ts | 144 +++++++++++++--------- src/commands/sync.ts | 49 +++++--- 3 files changed, 126 insertions(+), 72 deletions(-) create mode 100644 .changeset/fix-sync-project-resolution.md diff --git a/.changeset/fix-sync-project-resolution.md b/.changeset/fix-sync-project-resolution.md new file mode 100644 index 0000000..34ac9b7 --- /dev/null +++ b/.changeset/fix-sync-project-resolution.md @@ -0,0 +1,5 @@ +--- +"@bomb.sh/tools": patch +--- + +Fixes `bsh sync` failing to find the project to sync into. It now resolves the project from your current directory rather than the package's install location. diff --git a/src/commands/sync.test.ts b/src/commands/sync.test.ts index 86ce5ed..3999fcd 100644 --- a/src/commands/sync.test.ts +++ b/src/commands/sync.test.ts @@ -1,56 +1,90 @@ -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'; - -describe('copySkills', () => { - it('symlinks each skill into the destination', async () => { - const fixture = await createFixture({ - 'source-skills': { - build: { - 'SKILL.md': '---\nname: build\ndescription: Build the project.\n---\nbody', - }, - }, - }); - - const source = new URL('source-skills/', fixture.root); - const dest = new URL('project/skills/', fixture.root); - - const skills = await copySkills({ source, dest }); - - expect(skills).toEqual([{ name: 'build', description: 'Build the project.' }]); - - // The destination entry must be a symlink, not a copy. - const linkPath = fileURLToPath(new URL('build', dest)); - expect((await lstat(linkPath)).isSymbolicLink()).toBe(true); - - // And it must resolve back to the source skill. - expect(await readlink(linkPath)).toBe('../../source-skills/build'); - - // Reading through the link reaches the real file. - expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build'); - }); - - it('is idempotent and never touches the source on re-sync', async () => { - const fixture = await createFixture({ - 'source-skills': { - build: { - 'SKILL.md': '---\nname: build\ndescription: Build the project.\n---\nbody', - }, - }, - }); - - const source = new URL('source-skills/', fixture.root); - const dest = new URL('project/skills/', fixture.root); - - const first = await copySkills({ source, dest }); - // Re-running must not throw and must yield the same result. - const second = await copySkills({ source, dest }); - - expect(second).toEqual(first); - // The source skill files must survive a re-sync. - expect(await fixture.text('source-skills/build/SKILL.md')).toContain('name: build'); - expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build'); - }); +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, findProjectRoot } from "./sync.ts"; + +describe("copySkills", () => { + it("symlinks each skill into the destination", async () => { + const fixture = await createFixture({ + "source-skills": { + build: { + "SKILL.md": "---\nname: build\ndescription: Build the project.\n---\nbody", + }, + }, + }); + + const source = new URL("source-skills/", fixture.root); + const dest = new URL("project/skills/", fixture.root); + + const skills = await copySkills({ source, dest }); + + expect(skills).toEqual([{ name: "build", description: "Build the project." }]); + + // The destination entry must be a symlink, not a copy. + const linkPath = fileURLToPath(new URL("build", dest)); + expect((await lstat(linkPath)).isSymbolicLink()).toBe(true); + + // And it must resolve back to the source skill. + expect(await readlink(linkPath)).toBe("../../source-skills/build"); + + // Reading through the link reaches the real file. + expect(await fixture.text("project/skills/build/SKILL.md")).toContain("name: build"); + }); + + it("is idempotent and never touches the source on re-sync", async () => { + const fixture = await createFixture({ + "source-skills": { + build: { + "SKILL.md": "---\nname: build\ndescription: Build the project.\n---\nbody", + }, + }, + }); + + const source = new URL("source-skills/", fixture.root); + const dest = new URL("project/skills/", fixture.root); + + const first = await copySkills({ source, dest }); + // Re-running must not throw and must yield the same result. + const second = await copySkills({ source, dest }); + + expect(second).toEqual(first); + // The source skill files must survive a re-sync. + expect(await fixture.text("source-skills/build/SKILL.md")).toContain("name: build"); + expect(await fixture.text("project/skills/build/SKILL.md")).toContain("name: build"); + }); +}); + +describe("findProjectRoot", () => { + it("finds the nearest package.json walking up from the start directory", async () => { + const fixture = await createFixture({ + "package.json": { name: "my-app", version: "1.0.0" }, + src: { nested: { "index.ts": "export const x = 1" } }, + }); + + const result = await findProjectRoot(fileURLToPath(new URL("src/nested", fixture.root))); + + expect(result).toEqual({ kind: "found", root: fixture.root }); + }); + + it("reports self when the nearest package is @bomb.sh/tools", async () => { + const fixture = await createFixture({ + "package.json": { name: "@bomb.sh/tools", version: "1.0.0" }, + }); + + const result = await findProjectRoot(fileURLToPath(fixture.root)); + + expect(result).toEqual({ kind: "self" }); + }); + + it("reports not-found when no package.json exists in any parent", async () => { + const fixture = await createFixture({ + src: { "index.ts": "export const x = 1" }, + }); + + // A nested directory in a fixture that has no package.json anywhere up to /tmp. + const result = await findProjectRoot(fileURLToPath(new URL("src", fixture.root))); + + expect(result).toEqual({ kind: "not-found" }); + }); }); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 47d39bf..4789fc5 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,7 +1,6 @@ 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, platform } from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { NodeHfs } from '@humanfs/node'; import { parse } from 'ultramatter'; @@ -15,13 +14,17 @@ const GITIGNORE_START = '# bsh:skills'; const GITIGNORE_END = '# /bsh:skills'; export async function sync(_ctx: CommandContext): Promise { - const parentPkg = findParentPackage(); - if (!parentPkg) { - console.info('Skipping sync — no parent project found (running inside @bomb.sh/tools?)'); + const project = await findProjectRoot(cwd()); + if (project.kind === 'self') { + console.info('Skipping sync — running inside @bomb.sh/tools.'); + return; + } + if (project.kind === 'not-found') { + console.info(`Skipping sync — no package.json found in ${cwd()} or any parent directory.`); return; } - const root = pathToFileURL(`${dirname(parentPkg)}/`); + const root = project.root; const source = new URL('../../skills/', import.meta.url); if (!(await hfs.isDirectory(source))) { @@ -174,16 +177,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)); +type ProjectLookup = { kind: 'found'; root: URL } | { kind: 'self' } | { kind: 'not-found' }; + +/** + * Locate the project to sync into by walking up from `start` to the nearest + * `package.json`. + * + * This is based on the working directory the user runs `bsh sync` from — not the + * install location of `@bomb.sh/tools`, which under pnpm resolves to an unrelated + * path inside the virtual store. Returns `self` when the nearest package is + * `@bomb.sh/tools` so the command is a no-op when run inside this repo. + */ +export async function findProjectRoot(start: string): Promise { + let dir = start; + while (true) { + const pkgUrl = new URL('package.json', pathToFileURL(`${dir}/`)); + if (await hfs.isFile(pkgUrl)) { + const pkg = (await hfs.json(pkgUrl)) as { name?: string } | undefined; + if (pkg?.name === '@bomb.sh/tools') return { kind: 'self' }; + return { kind: 'found', root: new URL('./', pkgUrl) }; + } + const parent = dirname(dir); + if (parent === dir) return { kind: 'not-found' }; + dir = parent; } - return null; }