[READ-ONLY] Mirror of https://github.com/bombshell-dev/tools. Internal CLI to standardize tooling across all Bombshell projects
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(sync): walk up to project root

Nate Moore (Jun 23, 2026, 4:36 AM EDT) 683a5fb9 049fe659

+114 -60
+5
.changeset/fix-sync-project-resolution.md
··· 1 + --- 2 + "@bomb.sh/tools": patch 3 + --- 4 + 5 + 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.
+78 -44
src/commands/sync.test.ts
··· 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'; 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, findProjectRoot } from "./sync.ts"; 6 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 - }); 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 16 17 - const source = new URL('source-skills/', fixture.root); 18 - const dest = new URL('project/skills/', fixture.root); 17 + const source = new URL("source-skills/", fixture.root); 18 + const dest = new URL("project/skills/", fixture.root); 19 19 20 - const skills = await copySkills({ source, dest }); 20 + const skills = await copySkills({ source, dest }); 21 21 22 - expect(skills).toEqual([{ name: 'build', description: 'Build the project.' }]); 22 + expect(skills).toEqual([{ name: "build", description: "Build the project." }]); 23 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); 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 27 28 - // And it must resolve back to the source skill. 29 - expect(await readlink(linkPath)).toBe('../../source-skills/build'); 28 + // And it must resolve back to the source skill. 29 + expect(await readlink(linkPath)).toBe("../../source-skills/build"); 30 30 31 - // Reading through the link reaches the real file. 32 - expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build'); 33 - }); 31 + // Reading through the link reaches the real file. 32 + expect(await fixture.text("project/skills/build/SKILL.md")).toContain("name: build"); 33 + }); 34 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 - }); 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 43 44 - const source = new URL('source-skills/', fixture.root); 45 - const dest = new URL('project/skills/', fixture.root); 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 + }); 57 + 58 + describe("findProjectRoot", () => { 59 + it("finds the nearest package.json walking up from the start directory", async () => { 60 + const fixture = await createFixture({ 61 + "package.json": { name: "my-app", version: "1.0.0" }, 62 + src: { nested: { "index.ts": "export const x = 1" } }, 63 + }); 64 + 65 + const result = await findProjectRoot(fileURLToPath(new URL("src/nested", fixture.root))); 66 + 67 + expect(result).toEqual({ kind: "found", root: fixture.root }); 68 + }); 46 69 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 }); 70 + it("reports self when the nearest package is @bomb.sh/tools", async () => { 71 + const fixture = await createFixture({ 72 + "package.json": { name: "@bomb.sh/tools", version: "1.0.0" }, 73 + }); 50 74 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 - }); 75 + const result = await findProjectRoot(fileURLToPath(fixture.root)); 76 + 77 + expect(result).toEqual({ kind: "self" }); 78 + }); 79 + 80 + it("reports not-found when no package.json exists in any parent", async () => { 81 + const fixture = await createFixture({ 82 + src: { "index.ts": "export const x = 1" }, 83 + }); 84 + 85 + // A nested directory in a fixture that has no package.json anywhere up to /tmp. 86 + const result = await findProjectRoot(fileURLToPath(new URL("src", fixture.root))); 87 + 88 + expect(result).toEqual({ kind: "not-found" }); 89 + }); 56 90 });
+31 -16
src/commands/sync.ts
··· 1 1 import { readlink, rm, symlink } from 'node:fs/promises'; 2 - import { findPackageJSON } from 'node:module'; 3 2 import { dirname, isAbsolute, relative, resolve } from 'node:path'; 4 - import { platform } from 'node:process'; 3 + import { cwd, platform } from 'node:process'; 5 4 import { fileURLToPath, pathToFileURL } from 'node:url'; 6 5 import { NodeHfs } from '@humanfs/node'; 7 6 import { parse } from 'ultramatter'; ··· 15 14 const GITIGNORE_END = '# /bsh:skills'; 16 15 17 16 export async function sync(_ctx: CommandContext): Promise<void> { 18 - const parentPkg = findParentPackage(); 19 - if (!parentPkg) { 20 - console.info('Skipping sync — no parent project found (running inside @bomb.sh/tools?)'); 17 + const project = await findProjectRoot(cwd()); 18 + if (project.kind === 'self') { 19 + console.info('Skipping sync — running inside @bomb.sh/tools.'); 20 + return; 21 + } 22 + if (project.kind === 'not-found') { 23 + console.info(`Skipping sync — no package.json found in ${cwd()} or any parent directory.`); 21 24 return; 22 25 } 23 26 24 - const root = pathToFileURL(`${dirname(parentPkg)}/`); 27 + const root = project.root; 25 28 const source = new URL('../../skills/', import.meta.url); 26 29 27 30 if (!(await hfs.isDirectory(source))) { ··· 174 177 return { name, description }; 175 178 } 176 179 177 - function findParentPackage(): string | null { 178 - const ownPkg = findPackageJSON(import.meta.url); 179 - if (!ownPkg) return null; 180 + type ProjectLookup = { kind: 'found'; root: URL } | { kind: 'self' } | { kind: 'not-found' }; 180 181 181 - let cursor = dirname(dirname(ownPkg)); 182 - while (cursor !== dirname(cursor)) { 183 - const candidate = findPackageJSON(pathToFileURL(`${cursor}/`)); 184 - if (!candidate) return null; 185 - if (candidate !== ownPkg) return candidate; 186 - cursor = dirname(dirname(candidate)); 182 + /** 183 + * Locate the project to sync into by walking up from `start` to the nearest 184 + * `package.json`. 185 + * 186 + * This is based on the working directory the user runs `bsh sync` from — not the 187 + * install location of `@bomb.sh/tools`, which under pnpm resolves to an unrelated 188 + * path inside the virtual store. Returns `self` when the nearest package is 189 + * `@bomb.sh/tools` so the command is a no-op when run inside this repo. 190 + */ 191 + export async function findProjectRoot(start: string): Promise<ProjectLookup> { 192 + let dir = start; 193 + while (true) { 194 + const pkgUrl = new URL('package.json', pathToFileURL(`${dir}/`)); 195 + if (await hfs.isFile(pkgUrl)) { 196 + const pkg = (await hfs.json(pkgUrl)) as { name?: string } | undefined; 197 + if (pkg?.name === '@bomb.sh/tools') return { kind: 'self' }; 198 + return { kind: 'found', root: new URL('./', pkgUrl) }; 199 + } 200 + const parent = dirname(dir); 201 + if (parent === dir) return { kind: 'not-found' }; 202 + dir = parent; 187 203 } 188 - return null; 189 204 }