# pwd-fs

[![License](https://img.shields.io/npm/l/pwd-fs)](https://github.com/woodger/pwd-fs/blob/master/LICENSE)
[![CI](https://github.com/woodger/pwd-fs/actions/workflows/ci.yml/badge.svg)](https://github.com/woodger/pwd-fs/actions/workflows/ci.yml)

`pwd-fs` is a path-aware wrapper around Node.js file system APIs.

It provides:

- a dedicated working directory per instance
- Promise-based async methods
- matching synchronous variants via `{ sync: true }`
- recursive helpers for copy, remove, chmod, chown, and mkdir

Relative paths are resolved against `pfs.pwd`. Absolute paths are used as-is, so `pfs.pwd` is a convenience base path, not a sandbox.

## Why Use It

Use `pwd-fs` when you want file system operations to be rooted at a specific working directory without manually calling `path.resolve()` before every operation.

It is especially useful for:

- CLI tools that operate inside a project root
- build or code generation scripts
- isolated test fixtures
- small automation tasks that need Promise-based file system helpers

## Installation

```bash
npm install pwd-fs
```

## Table of Contents

- [Quick Start](#quick-start)
- [Common Recipes](#common-recipes)
- [Compatibility](#compatibility)
- [Exports](#exports)
- [API](#api)
- [`new PoweredFileSystem(pwd?)`](#new-poweredfilesystempwd)
- [`pfs.pwd`](#pfspwd)
- [`pfs.resolve(src)`](#pfsresolvesrc)
- [`pfs.constants`](#pfsconstants)
- [`PoweredFileSystem.bitmask(mode)`](#poweredfilesystembitmaskmode)
- [`pfs.test(src, options?)`](#pfstestsrc-options)
- [`pfs.stat(src, options?)`](#pfsstatsrc-options)
- [`pfs.chmod(src, mode, options?)`](#pfschmodsrc-mode-options)
- [`pfs.chown(src, options?)`](#pfschownsrc-options)
- [`pfs.symlink(src, dest, options?)`](#pfssymlinksrc-dest-options)
- [`pfs.copy(src, dest, options?)`](#pfscopysrc-dest-options)
- [`pfs.rename(src, dest, options?)`](#pfsrenamesrc-dest-options)
- [`pfs.remove(src, options?)`](#pfsremovesrc-options)
- [`pfs.emptyDir(src, options?)`](#pfsemptydirsrc-options)
- [`pfs.read(src, options?)`](#pfsreadsrc-options)
- [`pfs.write(src, data, options?)`](#pfswritesrc-data-options)
- [`pfs.append(src, data, options?)`](#pfsappendsrc-data-options)
- [`pfs.readdir(dir, options?)`](#pfsreaddirdir-options)
- [`pfs.readlink(src, options?)`](#pfsreadlinksrc-options)
- [`pfs.realpath(src, options?)`](#pfsrealpathsrc-options)
- [`pfs.mkdir(dir, options?)`](#pfsmkdirdir-options)
- [Sync Mode](#sync-mode)
- [Error Behavior](#error-behavior)
- [Umask Behavior](#umask-behavior)
- [Notes](#notes)
- [Platform Caveats](#platform-caveats)
- [When To Use Native `fs`](#when-to-use-native-fs)
- [Development](#development)
- [License](#license)

## Quick Start

```ts
import { pfs } from 'pwd-fs';

await pfs.mkdir('./own/project'); // recursively create the directory
```

## Common Recipes

### Work inside a project directory

```ts
import { PoweredFileSystem } from 'pwd-fs';

const projectFs = new PoweredFileSystem('/workspace/my-project');

await projectFs.write('./.cache/build.txt', 'ok');
const exists = await projectFs.test('./.cache/build.txt');
```

### Copy assets into a build directory

```ts
await pfs.mkdir('./dist');
await pfs.copy('./assets', './dist');
```

Result:

- source: `./assets`
- destination directory: `./dist`
- created output: `./dist/assets`

### Empty a directory but keep it

```ts
await pfs.emptyDir('./cache');
```

### Resolve a symlink target

```ts
const target = await pfs.readlink('./current');
const resolved = await pfs.realpath('./current');
```

### Append to a file

```ts
await pfs.write('./app.log', 'first line\n');
await pfs.write('./app.log', 'second line\n', { flag: 'a' });
```

### Read a file as a buffer

```ts
const raw = await pfs.read('./archive.bin', { encoding: null });
```

### Remove a temporary directory recursively

```ts
if (await pfs.test('./tmp')) {
  await pfs.remove('./tmp');
}
```

## Compatibility

- package `engines`: Node.js `>=18`
- module format: CommonJS package output with TypeScript declarations
- platform notes:
  - `chown()` is effectively a no-op on Windows apart from path validation
  - `chmod()` behavior on Windows is limited by the platform
  - `x` access checks in `test()` do not have the same meaning on Windows as on Unix-like systems

## Exports

```ts
import PoweredFileSystem, { pfs, bitmask } from 'pwd-fs';
```

- `default`: `PoweredFileSystem`
- `pfs`: default instance rooted at `process.cwd()`
- `bitmask(mode)`: helper that extracts standard permission bits from `fs.Stats.mode`

## API

### `new PoweredFileSystem(pwd?)`

Creates a new instance with `pwd` as the base directory for relative paths.

- `pwd?: string`
- default: `process.cwd()`

```ts
import { PoweredFileSystem } from 'pwd-fs';

const pfs = new PoweredFileSystem('./workspace');
```

### `pfs.pwd`

Absolute base directory used to resolve relative paths.

### `pfs.resolve(src)`

Resolves `src` against `pfs.pwd`.

```ts
const pfs = new PoweredFileSystem('/workspace/project');
const file = pfs.resolve('./src/index.ts');
```

Absolute paths are preserved:

```ts
pfs.resolve('/tmp/outside.txt'); // '/tmp/outside.txt'
```

### `pfs.constants`

Access mode aliases used by `pfs.test()`:

- `e`: existence
- `r`: readable
- `w`: writable
- `x`: executable

### `PoweredFileSystem.bitmask(mode)`

Static alias for `bitmask(mode)`.

```ts
const { mode } = await pfs.stat('./file.txt');
const permissions = PoweredFileSystem.bitmask(mode);
```

### `pfs.test(src, options?)`

Checks whether a path is accessible.

```ts
test<T extends boolean = false>(
  src: string,
  options?: { sync?: T; flag?: 'e' | 'r' | 'w' | 'x' }
): T extends true ? boolean : Promise<boolean>
```

- `src`: absolute or instance-relative path
- `flag`: access check to perform
- default flag: `'e'`

```ts
const exists = await pfs.test('./notes.txt');
const writable = pfs.test('./notes.txt', { sync: true, flag: 'w' });
```

### `pfs.stat(src, options?)`

Returns `fs.lstat()` information for a path.

```ts
stat<T extends boolean = false>(
  src: string,
  options?: { sync?: T }
): T extends true ? Stats : Promise<Stats>
```

This method uses `lstat`, so symbolic links are reported as links instead of followed targets.

### `pfs.chmod(src, mode, options?)`

Recursively applies permissions to a file or directory tree.

```ts
chmod<T extends boolean = false>(
  src: string,
  mode: number,
  options?: { sync?: T }
): T extends true ? void : Promise<void>
```

```ts
await pfs.chmod('./build', 0o755);
```

Note: on Windows, permission handling is limited by platform behavior.

### `pfs.chown(src, options?)`

Recursively applies ownership to a file or directory tree.

```ts
chown<T extends boolean = false>(
  src: string,
  options?: { sync?: T; uid?: number; gid?: number }
): T extends true ? void : Promise<void>
```

- `uid` and `gid` default to `0`
- when `uid` or `gid` is `0`, the current value from the source entry is preserved
- on Windows, ownership changes are not performed, but path validation still happens

### `pfs.symlink(src, dest, options?)`

Creates a symbolic link from `dest` to `src`.

```ts
symlink<T extends boolean = false>(
  src: string,
  dest: string,
  options?: { sync?: T }
): T extends true ? void : Promise<void>
```

```ts
await pfs.symlink('./target.txt', './target-link.txt');
```

### `pfs.copy(src, dest, options?)`

Copies `src` into the destination directory.

```ts
copy<T extends boolean = false>(
  src: string,
  dest: string,
  options?: {
    sync?: T;
    umask?: number;
    overwrite?: boolean;
    filter?: (src: string, dest: string) => boolean;
  }
): T extends true ? void : Promise<void>
```

Behavior:

- copying a file creates `dest/<basename(src)>`
- copying a directory creates `dest/<basename(src)>` recursively
- the target must not already exist
- `overwrite: true` replaces an existing target entry with the same basename
- `filter()` can skip specific source entries during the copy

```ts
await pfs.copy('./assets', './dist');
```

This creates `./dist/assets`, not a direct rename to `./dist`.

```ts
await pfs.copy('./assets', './dist', {
  overwrite: true,
  filter: (src) => !src.endsWith('.map')
});
```

### `pfs.rename(src, dest, options?)`

Renames or moves a file system entry.

```ts
rename<T extends boolean = false>(
  src: string,
  dest: string,
  options?: { sync?: T }
): T extends true ? void : Promise<void>
```

### `pfs.remove(src, options?)`

Removes a file, directory, or symbolic link.

```ts
remove<T extends boolean = false>(
  src: string,
  options?: { sync?: T }
): T extends true ? void : Promise<void>
```

Behavior:

- directories are removed recursively
- symbolic links are unlinked without deleting the target

### `pfs.emptyDir(src, options?)`

Removes all entries inside a directory while preserving the directory itself.

```ts
emptyDir<T extends boolean = false>(
  src: string,
  options?: { sync?: T }
): T extends true ? void : Promise<void>
```

```ts
await pfs.emptyDir('./tmp');
```

### `pfs.read(src, options?)`

Reads a file.

```ts
read<T extends boolean = false>(
  src: string,
  options?: {
    sync?: T;
    encoding?: BufferEncoding | null;
    flag?: string;
  }
): T extends true ? string | Buffer : Promise<string | Buffer>
```

- default `encoding`: `'utf8'`
- use `encoding: null` to get a `Buffer`
- default `flag`: `'r'`

```ts
const text = await pfs.read('./file.txt');
const buffer = pfs.read('./file.txt', { sync: true, encoding: null });
```

### `pfs.write(src, data, options?)`

Writes a file and explicitly reapplies the computed mode.

```ts
write<T extends boolean = false>(
  src: string,
  data: Buffer | string,
  options?: {
    sync?: T;
    encoding?: BufferEncoding | null;
    umask?: number;
    flag?: string;
  }
): T extends true ? void : Promise<void>
```

- default `encoding`: `'utf8'`
- default `umask`: `0o000`
- default `flag`: `'w'`
- use `flag: 'a'` to append
- any valid Node.js string file flag is accepted, such as `'r'`, `'w'`, `'a'`, `'wx'`, or `'a+'`

```ts
await pfs.write('./report.txt', 'generated output');
await pfs.write('./report.txt', '\nnext line', { flag: 'a' });
```

### `pfs.append(src, data, options?)`

Deprecated wrapper around `write(..., { flag: 'a' })`.

```ts
append<T extends boolean = false>(
  src: string,
  data: Buffer | string,
  options?: {
    sync?: T;
    encoding?: BufferEncoding | null;
    umask?: number;
  }
): T extends true ? void : Promise<void>
```

Prefer:

```ts
await pfs.write('./file.txt', 'content', { flag: 'a' });
```

### `pfs.readdir(dir, options?)`

Reads a directory and returns entry names.

```ts
readdir<T extends boolean = false>(
  dir: string,
  options?: { sync?: T; encoding?: BufferEncoding | null }
): T extends true ? string[] : Promise<string[]>
```

- default `encoding`: `'utf8'`

### `pfs.readlink(src, options?)`

Reads the stored target path from a symbolic link.

```ts
readlink<T extends boolean = false>(
  src: string,
  options?: { sync?: T; encoding?: BufferEncoding }
): T extends true ? string : Promise<string>
```

### `pfs.realpath(src, options?)`

Resolves a path to its canonical absolute location.

```ts
realpath<T extends boolean = false>(
  src: string,
  options?: { sync?: T; encoding?: BufferEncoding }
): T extends true ? string : Promise<string>
```

### `pfs.mkdir(dir, options?)`

Creates a directory tree recursively.

```ts
mkdir<T extends boolean = false>(
  dir: string,
  options?: { sync?: T; umask?: number }
): T extends true ? void : Promise<void>
```

- existing directories are accepted
- default `umask`: `0o000`

```ts
await pfs.mkdir('./public/assets/icons');
```

## Sync Mode

Every API method supports a synchronous form through `{ sync: true }`.

```ts
pfs.mkdir('./cache', { sync: true });
pfs.write('./cache/data.json', '{}', { sync: true });
const content = pfs.read('./cache/data.json', { sync: true });
```

## Error Behavior

Most async methods reject with the underlying Node.js error. Their sync variants throw the same class of error synchronously.

Typical cases:

- `test()` is the exception:
  it returns `false` for inaccessible or missing paths instead of rejecting or throwing
- `read()`, `stat()`, `readdir()`, `chmod()`, `chown()`, `rename()`, and `remove()` fail for missing paths
- `readlink()` and `realpath()` fail for missing paths
- `read()` fails when the target is a directory
- `readdir()` fails when the target is not a directory
- `emptyDir()` fails when the target is not a directory
- `write()` fails when the target path points to a directory
- `copy()` fails when the source does not exist
- `copy()` fails when the destination already contains an entry with the same basename as the source, unless `overwrite: true` is used
- `copy()` fails when a directory is copied into itself
- `symlink()` fails when the destination already exists
- `mkdir()` accepts an existing directory, but fails when a path segment is a file

Practical pattern:

```ts
if (await pfs.test('./dist')) {
  await pfs.remove('./dist');
}

await pfs.mkdir('./dist');
```

## Umask Behavior

`copy()`, `write()`, and `mkdir()` support `umask`.

Effective permissions:

| Umask | File mode | Directory mode |
| --- | --- | --- |
| `0o000` | `0o666` | `0o777` |
| `0o022` | `0o644` | `0o755` |
| `0o027` | `0o640` | `0o750` |
| `0o077` | `0o600` | `0o700` |

## Notes

- Relative paths are resolved against `pfs.pwd`
- Absolute paths are not constrained by `pfs.pwd`
- `stat()` returns `lstat()` data
- `remove()` does not follow symbolic links
- `append()` is kept for backward compatibility and is deprecated

## Platform Caveats

| Area | Unix-like systems | Windows |
| --- | --- | --- |
| `chmod()` | Recursive permission changes work as expected | Permission handling is limited by platform behavior |
| `chown()` | Recursive ownership changes are applied | Ownership is not changed; only path validation is performed |
| `symlink()` | Link type is inferred by the platform | The implementation resolves the source first and chooses `file` or `junction` explicitly |
| `test(..., { flag: 'x' })` | Uses executable access checks | Does not have the same semantics as Unix execute checks |
| `remove()` on symlinks | Removes the link, not the target | Removes the link, not the target |

## When To Use Native `fs`

Prefer native `node:fs` APIs directly when you need:

- streams such as `createReadStream()` or `createWriteStream()`
- advanced flags and options not exposed by this wrapper
- very low-level control over file descriptors
- exact parity with Node's callback-based APIs

## Development

```bash
yarn install --frozen-lockfile
yarn lint
yarn build
yarn test
```

## License

MIT
