@synstack/fs
Version:
File system operations made easy
652 lines (613 loc) • 18.1 kB
text/typescript
import { git } from "@synstack/git";
import { glob } from "@synstack/glob";
import { type AnyPath, path } from "@synstack/path";
import { Pipeable } from "@synstack/pipe";
import type { TemplateExpression } from "execa";
import * as fsSync from "fs";
import * as fs from "fs/promises";
import { tmpdir } from "os";
import { fsDirs } from "./dirs-array.lib.ts";
import { FsFile } from "./file.lib.ts";
import { fsFiles, fsFilesFromDir } from "./files-array.lib.ts";
export class FsDir extends Pipeable<FsDir> {
private readonly _path: AnyPath;
protected constructor(pathArg: AnyPath) {
super();
// Always normalize the path to ensure consistent drive letter casing on Windows
this._path = path.resolve(pathArg);
}
/**
* Get the string representation of the directory path.
*
* @returns The absolute path of the directory as a string
*
* ```typescript
* const srcDir = fsDir("./src");
* console.log(srcDir.toString()); // "/absolute/path/to/src"
* ```
*/
public toString(): AnyPath {
return this._path;
}
/**
* Get the primitive value of the directory path.
*
* @returns The absolute path of the directory
*/
public valueOf(): AnyPath {
return this._path;
}
/**
* Get the current instance of the directory.
* Used for type compatibility with Pipeable.
*
* @returns The current FsDir instance
*/
public instanceOf(): FsDir {
return this;
}
/**
* Get the absolute path of the directory.
*
* @returns The absolute path as a string
*
* ```typescript
* const srcDir = fsDir("./src");
* console.log(srcDir.path); // "/absolute/path/to/src"
* ```
*/
public get path(): AnyPath {
return this._path;
}
/**
* Get the relative path from this directory to another file/directory
*
* @param dirOrfile - The other directory
* @returns The relative path as a string
*/
public relativePathTo(dirOrfile: FsDir | FsFile) {
return path.relative(this.path, dirOrfile.path);
}
/**
* Get the relative path from another file/directory to this directory
*
* @param dirOrFile - The other directory
* @returns The relative path as a string
*/
public relativePathFrom(dirOrFile: FsDir | FsFile): string {
if (dirOrFile instanceof FsFile)
return this.relativePathFrom(dirOrFile.dir());
return path.relative(dirOrFile.path, this.path);
}
/**
* Create a new directory instance with the provided path(s).
* Resolves relative paths to absolute paths.
*
* @param arg - A path or an existing FsDir instance
* @returns A new FsDir instance with the resolved path
*
* ```typescript
* // Create from absolute path
* const rootDir = fsDir("/path/to/project");
*
* // Create from relative path
* const srcDir = fsDir("./src");
*
* // Create from existing directory
* const existingDir = fsDir(fsDir("/path/to/existing"));
* ```
*/
public static cwd(this: void, arg: FsDir | AnyPath): FsDir;
public static cwd(this: void, arg: FsDir | AnyPath) {
if (arg instanceof FsDir) return arg;
return new FsDir(arg);
}
/**
* Create a new directory instance with the temporary directory.
* @returns A new FsDir instance with the temporary directory
*/
public static tmpDir(this: void) {
return new FsDir(tmpdir());
}
/**
* Create a new directory instance with a path relative to this directory.
*
* @alias {@link to}
* @param relativePath - The relative path to append to the current directory
* @returns A new FsDir instance representing the combined path
*
* ```typescript
* const projectDir = fsDir("/path/to/project");
*
* // Navigate to subdirectories
* const srcDir = projectDir.toDir("src");
* const testDir = projectDir.toDir("tests");
*
* // Navigate up and down
* const siblingDir = srcDir.toDir("../other");
* ```
*/
public toDir(relativePath: string) {
return new FsDir(path.join(this._path, relativePath));
}
/**
* Create a new directory instance with a path relative to this directory.
*
* @alias {@link toDir}
* @param relativePath - The relative path to append to the current directory
* @returns A new FsDir instance representing the combined path
*
* ```typescript
* const projectDir = fsDir("/path/to/project");
*
* // Navigate to subdirectories
* const srcDir = projectDir.to("src");
* const testDir = projectDir.to("tests");
*
* // Navigate up and down
* const siblingDir = srcDir.to("../other");
* ```
*/
public to(relativePath: string) {
return this.toDir(relativePath);
}
/**
* Create a new file instance with a path relative to this directory.
*
* @alias {@link file}
* @param relativePath - The relative path to the file from this directory
* @returns A new FsFile instance for the specified path
* @throws If an absolute path is provided
*
* ```typescript
* const srcDir = fsDir("./src");
*
* // Access files in the directory
* const configFile = srcDir.toFile("config.json");
* const deepFile = srcDir.toFile("components/Button.tsx");
*
* // Error: Cannot use absolute paths
* srcDir.toFile("/absolute/path"); // throws Error
* ```
*/
public toFile(relativePath: string) {
if (path.isAbsolute(relativePath))
throw new Error(`
Trying to access a dir file from an absolute paths:
- Folder path: ${this._path}
- File path: ${relativePath}
`);
return FsFile.from(path.join(this._path, relativePath));
}
/**
* Create a new file instance with a path relative to this directory.
*
* @alias {@link toFile}
* @param relativePath - The relative path to the file from this directory
* @returns A new FsFile instance for the specified path
* @throws If an absolute path is provided
*
* ```typescript
* const srcDir = fsDir("./src");
*
* // Access files in the directory
* const configFile = srcDir.file("config.json");
* const deepFile = srcDir.file("components/Button.tsx");
*
* // Error: Cannot use absolute paths
* srcDir.file("/absolute/path"); // throws Error
* ```
*/
public file(relativePath: string) {
return this.toFile(relativePath);
}
/**
* Get the name of the directory (last segment of the path).
*
* @returns The directory name without the full path
*
* ```typescript
* const srcDir = fsDir("/path/to/project/src");
* console.log(srcDir.name()); // "src"
* ```
*/
public name(): string {
return path.filename(this._path);
}
/**
* Check if the directory exists in the file system.
*
* @returns A promise that resolves to true if the directory exists, false otherwise
*
* ```typescript
* const configDir = fsDir("./config");
*
* if (await configDir.exists()) {
* // Directory exists, safe to use
* const files = await configDir.glob("*.json");
* } else {
* // Create the directory first
* await configDir.make();
* }
* ```
*/
public async exists(): Promise<boolean> {
return fs
.access(this._path, fsSync.constants.F_OK)
.then(() => true)
.catch((error: any) => {
if (error.code === "ENOENT") return false;
throw new Error(
`Failed to check existence of directory ${this._path}`,
{
cause: error,
},
);
});
}
/**
* Check if the directory exists in the file system synchronously.
*
* @synchronous
* @returns True if the directory exists, false otherwise
*
* ```typescript
* const configDir = fsDir("./config");
*
* if (configDir.existsSync()) {
* // Directory exists, safe to use
* const files = configDir.globSync("*.json");
* } else {
* // Create the directory first
* configDir.makeSync();
* }
* ```
*/
public existsSync(): boolean {
try {
fsSync.accessSync(this._path, fsSync.constants.F_OK);
return true;
} catch (error: any) {
if (error.code === "ENOENT") return false;
throw new Error(`Failed to check existence of directory ${this._path}`, {
cause: error,
});
}
}
/**
* Create the directory and any necessary parent directories.
*
* @returns A promise that resolves when the directory is created
*
* ```typescript
* const assetsDir = fsDir("./dist/assets/images");
*
* // Creates all necessary parent directories
* await assetsDir.make();
* ```
*/
public async make(): Promise<void> {
try {
await fs.mkdir(this._path, { recursive: true });
} catch (error) {
throw new Error(`Failed to create directory ${this._path}`, {
cause: error,
});
}
}
/**
* Create the directory and any necessary parent directories synchronously.
*
* @synchronous
*
* ```typescript
* const assetsDir = fsDir("./dist/assets/images");
*
* // Creates all necessary parent directories immediately
* assetsDir.makeSync();
* ```
*/
public makeSync(): void {
try {
fsSync.mkdirSync(this._path, { recursive: true });
} catch (error) {
throw new Error(`Failed to create directory ${this._path}`, {
cause: error,
});
}
}
/**
* Copy the directory and all its contents to a new location.
*
* @param toDir - The destination path for the directory or an existing FsDir instance
* @returns A promise that resolves to the new copied directory
* @throws If the copy operation fails
*
* ```typescript
* import { fsDir } from "@synstack/fs";
*
* const sourceDir = fsDir("./source");
* const copiedDir = await sourceDir.copyTo("./backup/source");
* // Both directories now exist with the same content
* ```
*/
public async copyTo(toDir: FsDir | string): Promise<FsDir> {
const destDir = FsDir.cwd(toDir);
const parentDir = path.dirname(destDir.path);
try {
// Ensure parent directory exists
await fs.mkdir(parentDir, { recursive: true });
// Use native fs.cp with recursive option
await fs.cp(this._path, destDir.path, { recursive: true });
return destDir;
} catch (error) {
throw new Error(
`Failed to copy directory from ${this._path} to ${destDir.path}`,
{
cause: error,
},
);
}
}
/**
* Copy the directory and all its contents to a new location synchronously.
*
* @param toDir - The destination path for the directory or an existing FsDir instance
* @returns The new copied directory
* @throws If the copy operation fails
*
* ```typescript
* import { fsDir } from "@synstack/fs";
*
* const sourceDir = fsDir("./source");
* const copiedDir = sourceDir.copyToSync("./backup/source");
* // Both directories now exist with the same content
* ```
*/
public copyToSync(toDir: FsDir | string): FsDir {
const destDir = FsDir.cwd(toDir);
const parentDir = path.dirname(destDir.path);
try {
// Ensure parent directory exists
fsSync.mkdirSync(parentDir, { recursive: true });
// Use native fsSync.cpSync with recursive option
fsSync.cpSync(this._path, destDir.path, { recursive: true });
return destDir;
} catch (error) {
throw new Error(
`Failed to copy directory from ${this._path} to ${destDir.path}`,
{
cause: error,
},
);
}
}
/**
* Move the directory to a new location.
*
* @param newPath - The new path for the directory
* @returns A promise that resolves the new directory
*/
public async move(newPath: FsDir | AnyPath): Promise<FsDir> {
const newDir = FsDir.cwd(newPath);
try {
await fs.rename(this._path, newDir.path);
return newDir;
} catch (error) {
throw new Error(
`Failed to move directory from ${this._path} to ${newDir.path}`,
{
cause: error,
},
);
}
}
/**
* Move the directory to a new location synchronously.
*
* @param newPath - The new path for the directory
* @returns The new directory
*/
public moveSync(newPath: FsDir | AnyPath): FsDir {
const newDir = FsDir.cwd(newPath);
try {
fsSync.renameSync(this._path, newDir.path);
return newDir;
} catch (error) {
throw new Error(
`Failed to move directory from ${this._path} to ${newDir.path}`,
{
cause: error,
},
);
}
}
/**
* Remove the directory and all its contents recursively.
* If the directory doesn't exist, the operation is silently ignored.
*
* @returns A promise that resolves when the directory is removed
*
* ```typescript
* const tempDir = fsDir("./temp");
*
* // Remove directory and all contents
* await tempDir.rm();
* ```
*/
public async rm(): Promise<void> {
try {
await fs.rm(this._path, { recursive: true });
} catch (error: any) {
if (error.code === "ENOENT") return;
throw new Error(`Failed to remove directory ${this._path}`, {
cause: error,
});
}
}
/**
* Remove the directory and all its contents recursively synchronously.
* If the directory doesn't exist, the operation is silently ignored.
*
* @synchronous
*
* ```typescript
* const tempDir = fsDir("./temp");
*
* // Remove directory and all contents immediately
* tempDir.rmSync();
* ```
*/
public rmSync(): void {
try {
fsSync.rmSync(this._path, { recursive: true });
} catch (error: any) {
if (error.code === "ENOENT") return;
throw new Error(`Failed to remove directory ${this._path}`, {
cause: error,
});
}
}
/**
* Find files in the directory that match the specified glob patterns.
*
* @param patterns - One or more glob patterns to match against
* @returns A promise that resolves to an FsFileArray containing the matching files
*
* ```typescript
* const srcDir = fsDir("./src");
*
* // Find all TypeScript files
* const tsFiles = await srcDir.glob("**\/*.ts");
*
* // Find multiple file types
* const assets = await srcDir.glob("**\/*.{png,jpg,svg}");
*
* // Use array of patterns
* const configs = await srcDir.glob(["*.json", "*.yaml"]);
* ```
*/
public glob(...patterns: Array<string> | [Array<string>]) {
return glob
.cwd(this._path)
.options({ absolute: true })
.find(...patterns)
.then(fsFiles);
}
/**
* Find files in the directory that match the specified glob patterns synchronously.
*
* @param patterns - One or more glob patterns to match against
* @returns An FsFileArray containing the matching files
* @synchronous
*
* ```typescript
* const srcDir = fsDir("./src");
*
* // Find all TypeScript files synchronously
* const tsFiles = srcDir.globSync("**\/*.ts");
*
* // Find multiple file types
* const assets = srcDir.globSync("**\/*.{png,jpg,svg}");
* ```
*/
public globSync(...patterns: Array<string> | [Array<string>]) {
return fsFiles(
glob
.cwd(this._path)
.options({ absolute: true })
.findSync(...patterns),
);
}
/**
* Find folders in the directory that match the specified glob patterns.
*
* @param patterns - One or more glob patterns to match against
* @returns A promise that resolves to an FsDirArray containing the matching folders
*/
public globDirs(...patterns: Array<string> | [Array<string>]) {
const patternsWithTrailingSlash = patterns
.flat()
.map((p) => glob.ensureDirTrailingSlash(p));
return glob
.cwd(this._path)
.options({ nodir: false, absolute: true })
.find(patternsWithTrailingSlash)
.then(fsDirs);
}
/**
* Find folders in the directory that match the specified glob patterns synchronously.
*
* @param patterns - One or more glob patterns to match against
* @returns An FsDirArray containing the matching folders
* @synchronous
*/
public globDirsSync(...patterns: Array<string> | [Array<string>]) {
const patternsWithTrailingSlash = patterns
.flat()
.map((p) => glob.ensureDirTrailingSlash(p));
return fsDirs(
glob
.cwd(this._path)
.options({ nodir: false, absolute: true })
.findSync(patternsWithTrailingSlash),
);
}
/**
* Find files tracked by git in the directory.
*
* @returns A promise that resolves to an FsFileArray containing the git-tracked files
*
* ```typescript
* const projectDir = fsDir("./project");
*
* // Get all git-tracked files in the directory
* const trackedFiles = await projectDir.gitLs();
* ```
*/
public async gitLs() {
return git.ls(this._path).then(fsFilesFromDir(this));
}
/**
* Execute a command in the directory.
*/
public async exec(
template: TemplateStringsArray,
...args: Array<TemplateExpression>
) {
const { execa } = await import("execa").catch(() => {
throw new Error(
"The `execa` package is not installed. Please install it first.",
);
});
return execa({ cwd: this.path })(template, ...args);
}
}
/**
* Create a new directory instance with the provided path.
* Resolves relative paths to absolute paths.
*
* @param arg - A path or an existing FsDir instance
* @returns A new FsDir instance with the resolved path
*
* ```typescript
* // Create from absolute path
* const rootDir = fsDir("/path/to/project");
*
* // Create from relative path
* const srcDir = fsDir("./src");
*
* // Create from existing directory
* const existingDir = fsDir(fsDir("/path/to/existing"));
* ```
*/
export const fsDir = FsDir.cwd;
/**
* Create a new directory instance with the temporary directory.
* @returns A new FsDir instance with the temporary directory
*/
export const fsTmpDir = FsDir.tmpDir;
/**
* @deprecated Changed to avoid namespacing conflicts. Use {@link fsDir} instead
*/
export const dir = FsDir.cwd;