Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/dogfood-debt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@bomb.sh/tools': minor
---

Makes `bsh` pass its own lint and widens the test fixture API

- The shared oxlint config now exempts `*.config.*` files and oxlint JS plugins (`rules/**`) from `import/no-default-export` — config files and plugins legitimately require default exports.
- `Fixture.write()` and `Fixture.append()` in `@bomb.sh/tools/test-utils` now accept plain strings (previously typed `Uint8Array`-only, which contradicted the runtime behavior).
- `bsh test` raises the default vitest `testTimeout` to 15s — integration tests spawn real oxlint/knip binaries, which can exceed 5s on a loaded machine.
- Internal: replaces all `node:path` usage with URL APIs, consolidates the duplicate knip config (`package.json#knip` was silently ignored in favor of `knip.jsonc`), and throws a coded `ToolsError` instead of a generic `Error`.
12 changes: 11 additions & 1 deletion knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@
"src/index.ts",
"src/test-utils/*.ts"
],
"ignore": [
"rules/**"
],
"ignoreDependencies": [
"@tanstack/intent"
"@bomb.sh/args",
"@tanstack/intent",
"@typescript/native-preview",
"oxfmt",
"oxlint",
"publint",
"tsdown",
"vitest"
]
}
8 changes: 8 additions & 0 deletions oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
"correctness": "error",
"suspicious": "warn"
},
"overrides": [
{
"files": ["**/*.config.{ts,mts,js,mjs,cjs}", "rules/**/*.{js,ts}"],
"rules": {
"import/no-default-export": "off"
}
}
],
"rules": {
"no-restricted-imports": [
"error",
Expand Down
16 changes: 1 addition & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,5 @@
"node": "22.14.0"
},
"packageManager": "pnpm@10.34.3",
"pnpm": {},
"knip": {
"ignore": [
"rules/**"
],
"ignoreDependencies": [
"@bomb.sh/args",
"@typescript/native-preview",
"oxfmt",
"oxlint",
"publint",
"tsdown",
"vitest"
]
}
"pnpm": {}
}
5 changes: 3 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { cwd } from 'node:process';
import { pathToFileURL } from 'node:url';
import { x } from 'tinyexec';
Expand All @@ -10,7 +9,9 @@ export async function init(ctx: CommandContext) {
const cwdUrl = pathToFileURL(`${cwd()}/`);
// `.` scaffolds into the current directory; otherwise clone into a new `./<name>/`.
const inPlace = _name === '.';
const name = inPlace ? basename(cwd()) : _name;
const name = inPlace
? decodeURIComponent(cwdUrl.pathname.split('/').filter(Boolean).pop() ?? '.')
: _name;
const target = inPlace ? '.' : name;
const dest = inPlace ? cwdUrl : new URL(`./${name}/`, cwdUrl);
const gigetArgs = ['giget@latest', 'gh:bombshell-dev/template', target];
Expand Down
15 changes: 5 additions & 10 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { readlink, rm, symlink } from 'node:fs/promises';
import { findPackageJSON } from 'node:module';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import { cwd, env, platform } from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { NodeHfs } from '@humanfs/node';
import { parse } from 'ultramatter';
import type { CommandContext } from '../context.ts';
import { relativeUrlPath, resolveLinkTarget } from '../utils.ts';

const hfs = new NodeHfs();

Expand All @@ -21,7 +21,7 @@ export async function sync(_ctx: CommandContext): Promise<void> {
return;
}

const root = pathToFileURL(`${dirname(parentPkg)}/`);
const root = new URL('./', pathToFileURL(parentPkg));
const source = new URL('../../skills/', import.meta.url);

if (!(await hfs.isDirectory(source))) {
Expand Down Expand Up @@ -55,7 +55,6 @@ export async function copySkills(options: { source: URL; dest: URL }): Promise<S
await hfs.createDirectory(dest);
await pruneStaleLinks({ dest, source, keep });

const destDirPath = fileURLToPath(dest);
const linkType = platform === 'win32' ? 'junction' : 'dir';

for (const name of keep) {
Expand All @@ -64,10 +63,10 @@ export async function copySkills(options: { source: URL; dest: URL }): Promise<S
// Use a path without a trailing slash. macOS rejects a trailing-slash link
// path with ENOENT, and `rm` on a trailing-slash directory symlink follows
// the link and deletes the source rather than unlinking the symlink itself.
const linkPath = resolve(destDirPath, name);
const linkPath = fileURLToPath(new URL(name, dest));
await rm(linkPath, { recursive: true, force: true });

const target = relative(destDirPath, fileURLToPath(srcDir));
const target = relativeUrlPath(dest, srcDir);
await symlink(target, linkPath, linkType);

const content = await hfs.text(new URL('SKILL.md', srcDir));
Expand All @@ -90,18 +89,14 @@ async function pruneStaleLinks(options: {
const { dest, source, keep } = options;
if (!(await hfs.isDirectory(dest))) return;

const destPath = fileURLToPath(dest);
const sourcePath = fileURLToPath(source);

for await (const entry of hfs.list(dest)) {
if (!entry.isSymlink) continue;
if (keep.has(entry.name)) continue;

const linkPath = fileURLToPath(new URL(entry.name, dest));
try {
const target = await readlink(linkPath);
const absTarget = isAbsolute(target) ? target : resolve(destPath, target);
if (absTarget.startsWith(sourcePath)) {
if (resolveLinkTarget(dest, target).href.startsWith(source.href)) {
await hfs.deleteAll(linkPath);
}
} catch {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { x } from 'tinyexec';
import type { CommandContext } from '../context.ts';
import { local } from '../utils.ts';
import { local, ToolsError } from '../utils.ts';

function resolveConfig(): string {
// Built output (.mjs) or source (.ts)
Expand All @@ -11,7 +11,7 @@ function resolveConfig(): string {
const path = fileURLToPath(url);
if (existsSync(path)) return path;
}
throw new Error('Could not resolve vitest.config file');
throw new ToolsError('Could not resolve vitest.config file', 'VITEST_CONFIG_NOT_FOUND');
}

export async function test(ctx: CommandContext) {
Expand Down
13 changes: 9 additions & 4 deletions src/test-utils/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { mkdtemp, symlink as fsSymlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { NodeHfs } from '@humanfs/node';
import type { HfsImpl } from '@humanfs/types';
Expand All @@ -9,6 +8,10 @@ import { expect, onTestFinished } from 'vitest';
interface ScopedHfsImpl extends Required<HfsImpl> {
text(file: string | URL): Promise<string | undefined>;
json(file: string | URL): Promise<unknown | undefined>;
/** Strings are UTF-8 encoded automatically. */
write(file: string | URL, contents: string | Uint8Array): Promise<void>;
/** Strings are UTF-8 encoded automatically. */
append(file: string | URL, contents: string | Uint8Array): Promise<void>;
}

/**
Expand Down Expand Up @@ -103,13 +106,15 @@ function isFileTree(value: unknown): value is FileTree {
function scopeHfs(inner: NodeHfs, base: URL): ScopedHfsImpl {
const r = (p: string | URL) => new URL(`./${p}`, base);
const r2 = (a: string | URL, b: string | URL) => [r(a), r(b)] as const;
const encoder = new TextEncoder();
const bytes = (c: string | Uint8Array) => (typeof c === 'string' ? encoder.encode(c) : c);

return {
text: (p: string | URL) => inner.text(r(p)),
json: (p: string | URL) => inner.json(r(p)),
bytes: (p) => inner.bytes(r(p)),
write: (p, c) => inner.write(r(p), c),
append: (p, c) => inner.append(r(p), c),
write: (p, c) => inner.write(r(p), bytes(c)),
append: (p, c) => inner.append(r(p), bytes(c)),
isFile: (p) => inner.isFile(r(p)),
isDirectory: (p) => inner.isDirectory(r(p)),
createDirectory: (p) => inner.createDirectory(r(p)),
Expand Down Expand Up @@ -155,7 +160,7 @@ export async function createFixture(files: FileTree): Promise<Fixture> {
.replace(/^-|-$/g, '');
const root = new URL(`${prefix}-`, `file://${tmpdir()}/`);
const path = await mkdtemp(fileURLToPath(root));
const base = pathToFileURL(path + sep);
const base = new URL(`${pathToFileURL(path).href}/`);

const inner = new NodeHfs();
const scoped = scopeHfs(inner, base);
Expand Down
3 changes: 3 additions & 0 deletions src/test-utils/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: ['dist/**', 'node_modules/**'],
// oxlint/knip spawn real binaries in integration tests; 5s is not
// enough headroom on a loaded machine.
testTimeout: 15_000,
env: {
FORCE_COLOR: '1',
},
Expand Down
45 changes: 44 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';

export function local(file: string) {
return fileURLToPath(new URL(`../node_modules/.bin/${file}`, import.meta.url));
}

/** Error class for `bsh` failures. Carries a stable, greppable code. */
export class ToolsError extends Error {
code: string;

constructor(message: string, code: string) {
super(message);
this.name = 'ToolsError';
this.code = code;
}
}

/**
* Compute a relative path from one directory URL to another, for APIs that
* require a string path (e.g. `fs.symlink` targets). POSIX-style — forward
* slashes are accepted by every platform Node supports for this purpose.
*/
export function relativeUrlPath(from: URL, to: URL): string {
const fromParts = from.pathname.split('/').filter(Boolean);
const toParts = to.pathname.split('/').filter(Boolean);
let common = 0;
while (
common < fromParts.length &&
common < toParts.length &&
fromParts[common] === toParts[common]
) {
common++;
}
const ups = fromParts.length - common;
const parts = [...Array<string>(ups).fill('..'), ...toParts.slice(common)];
return parts.map(decodeURIComponent).join('/');
}

/**
* Resolve a symlink target (as returned by `fs.readlink`, which may be
* relative to the link's directory) to an absolute URL.
*/
export function resolveLinkTarget(linkDir: URL, target: string): URL {
if (target.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(target)) {
return pathToFileURL(target);
}
return new URL(target, linkDir);
}
Loading