Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/fix-sync-project-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
144 changes: 89 additions & 55 deletions src/commands/sync.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
49 changes: 32 additions & 17 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,13 +14,17 @@ const GITIGNORE_START = '# bsh:skills';
const GITIGNORE_END = '# /bsh:skills';

export async function sync(_ctx: CommandContext): Promise<void> {
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))) {
Expand Down Expand Up @@ -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<ProjectLookup> {
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;
}
Loading