diff --git a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs index 7caa7c7f..fd333e39 100644 --- a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs @@ -54,4 +54,71 @@ describe("VFS API", () => { bash.reset(); assert.ok(!bash.exists("/tmp/p.txt")); }); + + it("stat returns file metadata", () => { + const bash = new Bash(); + bash.writeFile("/tmp/stat.txt", "hello"); + const meta = bash.stat("/tmp/stat.txt"); + assert.equal(meta.fileType, "file"); + assert.equal(meta.size, 5); + assert.ok(meta.mode > 0); + }); + + it("stat returns directory metadata", () => { + const bash = new Bash(); + bash.mkdir("/tmp/statdir"); + const meta = bash.stat("/tmp/statdir"); + assert.equal(meta.fileType, "directory"); + }); + + it("readDir returns entries with metadata", () => { + const bash = new Bash(); + bash.mkdir("/tmp/rd"); + bash.writeFile("/tmp/rd/file.txt", "content"); + bash.mkdir("/tmp/rd/sub"); + const entries = bash.readDir("/tmp/rd"); + assert.ok(Array.isArray(entries)); + assert.equal(entries.length, 2); + const file = entries.find((e) => e.name === "file.txt"); + const dir = entries.find((e) => e.name === "sub"); + assert.ok(file); + assert.ok(dir); + assert.equal(file.metadata.fileType, "file"); + assert.equal(dir.metadata.fileType, "directory"); + }); + + it("appendFile appends content", () => { + const bash = new Bash(); + bash.writeFile("/tmp/ap.txt", "first"); + bash.appendFile("/tmp/ap.txt", "-second"); + assert.equal(bash.readFile("/tmp/ap.txt"), "first-second"); + }); + + it("chmod changes file mode", () => { + const bash = new Bash(); + bash.writeFile("/tmp/ch.txt", "data"); + bash.chmod("/tmp/ch.txt", 0o755); + const meta = bash.stat("/tmp/ch.txt"); + assert.equal(meta.mode, 0o755); + }); + + it("symlink and readLink roundtrip", () => { + const bash = new Bash(); + bash.writeFile("/tmp/target.txt", "data"); + bash.symlink("/tmp/target.txt", "/tmp/link.txt"); + assert.equal(bash.readLink("/tmp/link.txt"), "/tmp/target.txt"); + const meta = bash.stat("/tmp/link.txt"); + assert.equal(meta.fileType, "symlink"); + }); + + it("fs() accessor provides same operations", () => { + const bash = new Bash(); + const fs = bash.fs(); + fs.writeFile("/tmp/fsapi.txt", "via-fs"); + assert.equal(fs.readFile("/tmp/fsapi.txt"), "via-fs"); + const meta = fs.stat("/tmp/fsapi.txt"); + assert.equal(meta.fileType, "file"); + const entries = fs.readDir("/tmp"); + assert.ok(entries.some((e) => e.name === "fsapi.txt")); + }); }); diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 9bceb8af..46d17d4b 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -823,9 +823,9 @@ impl Bash { }) } - /// List entries in a directory. Returns entry names. + /// List entries in a directory with metadata (name, file type, size, etc.). #[napi] - pub fn read_dir(&self, path: String) -> napi::Result> { + pub fn read_dir(&self, path: String) -> napi::Result> { block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; let entries = bash @@ -833,7 +833,13 @@ impl Bash { .read_dir(Path::new(&path)) .await .map_err(|e| napi::Error::from_reason(e.to_string()))?; - Ok(entries.into_iter().map(|e| e.name.clone()).collect()) + Ok(entries + .into_iter() + .map(|e| JsDirEntry { + name: e.name.clone(), + metadata: metadata_to_js(&e.metadata), + }) + .collect()) }) } @@ -1190,9 +1196,9 @@ impl BashTool { }) } - /// List entries in a directory. Returns entry names. + /// List entries in a directory with metadata (name, file type, size, etc.). #[napi] - pub fn read_dir(&self, path: String) -> napi::Result> { + pub fn read_dir(&self, path: String) -> napi::Result> { block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; let entries = bash @@ -1200,7 +1206,13 @@ impl BashTool { .read_dir(Path::new(&path)) .await .map_err(|e| napi::Error::from_reason(e.to_string()))?; - Ok(entries.into_iter().map(|e| e.name.clone()).collect()) + Ok(entries + .into_iter() + .map(|e| JsDirEntry { + name: e.name.clone(), + metadata: metadata_to_js(&e.metadata), + }) + .collect()) }) } diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index e33a1900..613b78d9 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -368,13 +368,48 @@ export class Bash { this.native.remove(path, recursive); } + /** Get metadata for a path (fileType, size, mode, timestamps). */ + stat(path: string): { fileType: string; size: number; mode: number; modified: number; created: number } { + return this.native.stat(path); + } + + /** Append content to a file. */ + appendFile(path: string, content: string): void { + this.native.appendFile(path, content); + } + + /** Change file permissions (octal mode, e.g. 0o755). */ + chmod(path: string, mode: number): void { + this.native.chmod(path, mode); + } + + /** Create a symbolic link pointing to target. */ + symlink(target: string, link: string): void { + this.native.symlink(target, link); + } + + /** Read the target of a symbolic link. */ + readLink(path: string): string { + return this.native.readLink(path); + } + + /** List directory entries with metadata. */ + readDir(path: string): Array<{ name: string; metadata: { fileType: string; size: number; mode: number; modified: number; created: number } }> { + return this.native.readDir(path); + } + + /** Get a JsFileSystem handle for direct VFS operations. */ + fs(): any { + return this.native.fs(); + } + /** - * List entries in a directory. Returns empty array if directory does not exist. + * List entry names in a directory. Returns empty array if directory does not exist. */ ls(path?: string): string[] { const target = path ?? "."; try { - return this.native.readDir(target); + return this.native.readDir(target).map((e: { name: string }) => e.name); } catch { return []; } @@ -555,13 +590,48 @@ export class BashTool { this.native.writeFile(path, content); } + /** Get metadata for a path (fileType, size, mode, timestamps). */ + stat(path: string): { fileType: string; size: number; mode: number; modified: number; created: number } { + return this.native.stat(path); + } + + /** Append content to a file. */ + appendFile(path: string, content: string): void { + this.native.appendFile(path, content); + } + + /** Change file permissions (octal mode, e.g. 0o755). */ + chmod(path: string, mode: number): void { + this.native.chmod(path, mode); + } + + /** Create a symbolic link pointing to target. */ + symlink(target: string, link: string): void { + this.native.symlink(target, link); + } + + /** Read the target of a symbolic link. */ + readLink(path: string): string { + return this.native.readLink(path); + } + + /** List directory entries with metadata. */ + readDir(path: string): Array<{ name: string; metadata: { fileType: string; size: number; mode: number; modified: number; created: number } }> { + return this.native.readDir(path); + } + + /** Get a JsFileSystem handle for direct VFS operations. */ + fs(): any { + return this.native.fs(); + } + /** - * List entries in a directory. Returns empty array if directory does not exist. + * List entry names in a directory. Returns empty array if directory does not exist. */ ls(path?: string): string[] { const target = path ?? "."; try { - return this.native.readDir(target); + return this.native.readDir(target).map((e: { name: string }) => e.name); } catch { return []; }