diff --git a/packages/zen-bash/__tests__/combined.test.ts b/packages/zen-bash/__tests__/combined.test.ts index 0070a91..f463b07 100644 --- a/packages/zen-bash/__tests__/combined.test.ts +++ b/packages/zen-bash/__tests__/combined.test.ts @@ -1,119 +1,113 @@ import { Bash } from 'just-bash'; import { fs as zenfs, InMemory, configure } from '@zenfs/core'; +import { ZenFsAdapter } from '../src/ZenFsAdapter'; -describe('zen-bash: combining just-bash with zen-fs', () => { - describe('using just-bash VirtualFs (default)', () => { - it('executes bash commands with built-in virtual filesystem', async () => { - const bash = new Bash(); - await bash.exec('echo "hello from just-bash" > /tmp/test.txt'); - const result = await bash.exec('cat /tmp/test.txt'); - expect(result.stdout).toBe('hello from just-bash\n'); - }); +describe('zen-bash: just-bash with zen-fs filesystem', () => { + let adapter: ZenFsAdapter; - it('supports complex file operations', async () => { - const bash = new Bash(); - await bash.exec('mkdir -p /tmp/project/src'); - await bash.exec('echo "console.log(42)" > /tmp/project/src/index.js'); - await bash.exec('cp /tmp/project/src/index.js /tmp/project/src/backup.js'); - const result = await bash.exec('cat /tmp/project/src/backup.js'); - expect(result.stdout).toBe('console.log(42)\n'); - }); + beforeEach(async () => { + await configure({ mounts: { '/': InMemory } }); + adapter = new ZenFsAdapter(); + zenfs.mkdirSync('/tmp', { recursive: true }); + zenfs.mkdirSync('/home', { recursive: true }); }); - describe('using zen-fs InMemory backend', () => { - beforeEach(async () => { - await configure({ mounts: { '/': InMemory } }); - }); + describe('just-bash operating on zen-fs filesystem via adapter', () => { + it('writes a file via just-bash, reads it via zen-fs', async () => { + const bash = new Bash({ fs: adapter }); - it('performs file operations with zen-fs', async () => { - zenfs.writeFileSync('/data.txt', 'zen-fs content'); - const content = zenfs.readFileSync('/data.txt', 'utf-8'); - expect(content).toBe('zen-fs content'); - }); + await bash.exec('echo "hello from bash" > /tmp/test.txt'); - it('creates directories and files', async () => { - zenfs.mkdirSync('/project/src', { recursive: true }); - zenfs.writeFileSync('/project/src/main.ts', 'export const x = 1;'); - const files = zenfs.readdirSync('/project/src'); - expect(files).toContain('main.ts'); + const content = zenfs.readFileSync('/tmp/test.txt', 'utf-8'); + expect(content).toBe('hello from bash\n'); }); - }); - describe('interoperability patterns', () => { - beforeEach(async () => { - await configure({ mounts: { '/': InMemory } }); + it('creates directories via just-bash, verifies via zen-fs', async () => { + const bash = new Bash({ fs: adapter }); + + await bash.exec('mkdir -p /home/user/projects/myapp'); + await bash.exec('echo "# My App" > /home/user/projects/myapp/README.md'); + + expect(zenfs.existsSync('/home/user/projects/myapp')).toBe(true); + expect(zenfs.existsSync('/home/user/projects/myapp/README.md')).toBe(true); + expect(zenfs.readFileSync('/home/user/projects/myapp/README.md', 'utf-8')).toBe('# My App\n'); }); - it('prepares files with zen-fs, processes with just-bash', async () => { - zenfs.mkdirSync('/workspace', { recursive: true }); - zenfs.writeFileSync('/workspace/input.txt', 'line1\nline2\nline3\n'); + it('appends to a file via just-bash, verifies via zen-fs', async () => { + const bash = new Bash({ fs: adapter }); - const bash = new Bash({ - files: { - '/workspace/input.txt': zenfs.readFileSync('/workspace/input.txt', 'utf-8') - } - }); + await bash.exec('echo "line1" > /tmp/log.txt'); + await bash.exec('echo "line2" >> /tmp/log.txt'); + await bash.exec('echo "line3" >> /tmp/log.txt'); - const result = await bash.exec('wc -l < /workspace/input.txt'); - expect(result.stdout.trim()).toBe('3'); + const content = zenfs.readFileSync('/tmp/log.txt', 'utf-8'); + expect(content).toBe('line1\nline2\nline3\n'); }); - it('uses just-bash for text processing, zen-fs for storage', async () => { - const bash = new Bash({ - files: { '/data/users.json': '[{"name":"Alice"},{"name":"Bob"}]' } - }); + it('copies files via just-bash, verifies via zen-fs', async () => { + const bash = new Bash({ fs: adapter }); - const result = await bash.exec('cat /data/users.json | grep -o \'"name":"[^"]*"\' | wc -l'); - expect(result.stdout.trim()).toBe('2'); + await bash.exec('echo "original content" > /tmp/original.txt'); + await bash.exec('cp /tmp/original.txt /tmp/copy.txt'); - zenfs.mkdirSync('/processed', { recursive: true }); - zenfs.writeFileSync('/processed/count.txt', result.stdout.trim()); - expect(zenfs.readFileSync('/processed/count.txt', 'utf-8')).toBe('2'); + expect(zenfs.existsSync('/tmp/copy.txt')).toBe(true); + expect(zenfs.readFileSync('/tmp/copy.txt', 'utf-8')).toBe('original content\n'); }); - it('combines bash scripting with zen-fs file management', async () => { - zenfs.mkdirSync('/scripts', { recursive: true }); - zenfs.writeFileSync('/scripts/config.env', 'APP_NAME=myapp\nAPP_VERSION=1.0.0'); + it('moves files via just-bash, verifies via zen-fs', async () => { + const bash = new Bash({ fs: adapter }); - const bash = new Bash({ - files: { - '/scripts/config.env': zenfs.readFileSync('/scripts/config.env', 'utf-8') - } - }); + await bash.exec('echo "movable content" > /tmp/source.txt'); + await bash.exec('mv /tmp/source.txt /tmp/destination.txt'); - const nameResult = await bash.exec('grep APP_NAME /scripts/config.env | cut -d= -f2'); - const versionResult = await bash.exec('grep APP_VERSION /scripts/config.env | cut -d= -f2'); + expect(zenfs.existsSync('/tmp/source.txt')).toBe(false); + expect(zenfs.existsSync('/tmp/destination.txt')).toBe(true); + expect(zenfs.readFileSync('/tmp/destination.txt', 'utf-8')).toBe('movable content\n'); + }); - expect(nameResult.stdout.trim()).toBe('myapp'); - expect(versionResult.stdout.trim()).toBe('1.0.0'); + it('removes files via just-bash, verifies via zen-fs', async () => { + const bash = new Bash({ fs: adapter }); - zenfs.mkdirSync('/output', { recursive: true }); - zenfs.writeFileSync('/output/app-info.json', JSON.stringify({ - name: nameResult.stdout.trim(), - version: versionResult.stdout.trim() - })); + await bash.exec('echo "temporary" > /tmp/temp.txt'); + expect(zenfs.existsSync('/tmp/temp.txt')).toBe(true); - const appInfo = JSON.parse(zenfs.readFileSync('/output/app-info.json', 'utf-8')); - expect(appInfo.name).toBe('myapp'); - expect(appInfo.version).toBe('1.0.0'); + await bash.exec('rm /tmp/temp.txt'); + expect(zenfs.existsSync('/tmp/temp.txt')).toBe(false); }); }); - describe('parallel usage patterns', () => { - it('maintains separate filesystems for isolation', async () => { - await configure({ mounts: { '/': InMemory } }); + describe('bidirectional file operations', () => { + it('zen-fs writes, just-bash reads and processes', async () => { + zenfs.writeFileSync('/tmp/data.csv', 'name,age\nAlice,30\nBob,25\nCharlie,35'); + + const bash = new Bash({ fs: adapter }); + const result = await bash.exec('cat /tmp/data.csv | grep -c ","'); + + expect(result.stdout.trim()).toBe('4'); + }); + + it('just-bash processes, zen-fs stores result', async () => { + zenfs.writeFileSync('/tmp/numbers.txt', '10\n20\n30\n40\n50\n'); + + const bash = new Bash({ fs: adapter }); + await bash.exec('cat /tmp/numbers.txt | wc -l > /tmp/count.txt'); + + const count = zenfs.readFileSync('/tmp/count.txt', 'utf-8'); + expect(count.trim()).toBe('5'); + }); - const bash1 = new Bash({ files: { '/config.txt': 'env=dev' } }); - const bash2 = new Bash({ files: { '/config.txt': 'env=prod' } }); + it('complex workflow: zen-fs setup, bash transform, zen-fs verify', async () => { + zenfs.mkdirSync('/workspace/input', { recursive: true }); + zenfs.mkdirSync('/workspace/output', { recursive: true }); + zenfs.writeFileSync('/workspace/input/config.env', 'DB_HOST=localhost\nDB_PORT=5432\nDB_NAME=mydb'); - const result1 = await bash1.exec('cat /config.txt'); - const result2 = await bash2.exec('cat /config.txt'); + const bash = new Bash({ fs: adapter }); - expect(result1.stdout).toBe('env=dev'); - expect(result2.stdout).toBe('env=prod'); + await bash.exec('grep DB_HOST /workspace/input/config.env | cut -d= -f2 > /workspace/output/host.txt'); + await bash.exec('grep DB_PORT /workspace/input/config.env | cut -d= -f2 > /workspace/output/port.txt'); - zenfs.writeFileSync('/shared.txt', 'shared data'); - expect(zenfs.readFileSync('/shared.txt', 'utf-8')).toBe('shared data'); + expect(zenfs.readFileSync('/workspace/output/host.txt', 'utf-8').trim()).toBe('localhost'); + expect(zenfs.readFileSync('/workspace/output/port.txt', 'utf-8').trim()).toBe('5432'); }); }); }); diff --git a/packages/zen-bash/src/ZenFsAdapter.ts b/packages/zen-bash/src/ZenFsAdapter.ts new file mode 100644 index 0000000..c6ad560 --- /dev/null +++ b/packages/zen-bash/src/ZenFsAdapter.ts @@ -0,0 +1,185 @@ +import { fs as zenfs } from '@zenfs/core'; +import * as path from 'path'; + +export interface FsStat { + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; + mode: number; + size: number; + mtime: Date; +} + +export interface MkdirOptions { + recursive?: boolean; +} + +export interface RmOptions { + recursive?: boolean; + force?: boolean; +} + +export interface CpOptions { + recursive?: boolean; +} + +export interface ReadFileOptions { + encoding?: string | null; +} + +export interface WriteFileOptions { + encoding?: string; +} + +export type FileContent = string | Uint8Array; + +export class ZenFsAdapter { + async readFile(filePath: string, options?: ReadFileOptions | string): Promise { + const encoding = typeof options === 'string' ? options : options?.encoding ?? 'utf-8'; + return zenfs.readFileSync(filePath, encoding as BufferEncoding); + } + + async readFileBuffer(filePath: string): Promise { + const buffer = zenfs.readFileSync(filePath); + if (typeof buffer === 'string') { + return new TextEncoder().encode(buffer); + } + return new Uint8Array(buffer); + } + + async writeFile(filePath: string, content: FileContent, options?: WriteFileOptions | string): Promise { + this.ensureParentDirs(filePath); + zenfs.writeFileSync(filePath, content); + } + + async appendFile(filePath: string, content: FileContent, options?: WriteFileOptions | string): Promise { + this.ensureParentDirs(filePath); + zenfs.appendFileSync(filePath, content); + } + + async exists(filePath: string): Promise { + return zenfs.existsSync(filePath); + } + + async stat(filePath: string): Promise { + const stats = zenfs.statSync(filePath); + return { + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + isSymbolicLink: stats.isSymbolicLink(), + mode: stats.mode, + size: stats.size, + mtime: stats.mtime, + }; + } + + async lstat(filePath: string): Promise { + const stats = zenfs.lstatSync(filePath); + return { + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + isSymbolicLink: stats.isSymbolicLink(), + mode: stats.mode, + size: stats.size, + mtime: stats.mtime, + }; + } + + async mkdir(dirPath: string, options?: MkdirOptions): Promise { + zenfs.mkdirSync(dirPath, { recursive: options?.recursive }); + } + + async readdir(dirPath: string): Promise { + return zenfs.readdirSync(dirPath) as string[]; + } + + async rm(filePath: string, options?: RmOptions): Promise { + try { + zenfs.rmSync(filePath, { recursive: options?.recursive, force: options?.force }); + } catch (e) { + if (!options?.force) throw e; + } + } + + async cp(src: string, dest: string, options?: CpOptions): Promise { + const srcStat = zenfs.statSync(src); + if (srcStat.isDirectory()) { + if (!options?.recursive) { + throw new Error(`EISDIR: is a directory, cp '${src}'`); + } + zenfs.mkdirSync(dest, { recursive: true }); + const entries = zenfs.readdirSync(src) as string[]; + for (const entry of entries) { + await this.cp(path.posix.join(src, entry), path.posix.join(dest, entry), options); + } + } else { + this.ensureParentDirs(dest); + zenfs.copyFileSync(src, dest); + } + } + + async mv(src: string, dest: string): Promise { + zenfs.renameSync(src, dest); + } + + resolvePath(base: string, filePath: string): string { + if (filePath.startsWith('/')) { + return path.posix.normalize(filePath); + } + return path.posix.normalize(path.posix.join(base, filePath)); + } + + getAllPaths(): string[] { + const paths: string[] = []; + this.walkDir('/', paths); + return paths; + } + + private walkDir(dir: string, paths: string[]): void { + paths.push(dir); + try { + const entries = zenfs.readdirSync(dir) as string[]; + for (const entry of entries) { + const fullPath = dir === '/' ? `/${entry}` : `${dir}/${entry}`; + try { + const stats = zenfs.lstatSync(fullPath); + if (stats.isDirectory()) { + this.walkDir(fullPath, paths); + } else { + paths.push(fullPath); + } + } catch { + paths.push(fullPath); + } + } + } catch { + // Directory not readable, skip + } + } + + async chmod(filePath: string, mode: number): Promise { + zenfs.chmodSync(filePath, mode); + } + + async symlink(target: string, linkPath: string): Promise { + this.ensureParentDirs(linkPath); + zenfs.symlinkSync(target, linkPath); + } + + async link(existingPath: string, newPath: string): Promise { + this.ensureParentDirs(newPath); + zenfs.linkSync(existingPath, newPath); + } + + async readlink(linkPath: string): Promise { + const result = zenfs.readlinkSync(linkPath); + return typeof result === 'string' ? result : result.toString('utf-8'); + } + + private ensureParentDirs(filePath: string): void { + const dir = path.posix.dirname(filePath); + if (dir && dir !== '/' && !zenfs.existsSync(dir)) { + zenfs.mkdirSync(dir, { recursive: true }); + } + } +} diff --git a/packages/zen-bash/src/index.ts b/packages/zen-bash/src/index.ts new file mode 100644 index 0000000..b1f71e5 --- /dev/null +++ b/packages/zen-bash/src/index.ts @@ -0,0 +1 @@ +export { ZenFsAdapter } from './ZenFsAdapter';