diff --git a/.gitignore b/.gitignore index d93ee32b..5c53b9d0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ coverage package-lock.json /lib/ /demo/**/*.js -/src/**/*.js +/src/**/*.js \ No newline at end of file diff --git a/README.md b/README.md index 08cb5d6b..7ee78d73 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,20 @@ ufs ufs.readFileSync(/* ... */); ``` +This module allows you mark volumes as `readable`/`writable` (both defaulting to true) to prevent unwanted mutating of volumes + +```js +import {ufs} from 'unionfs'; +import {fs as fs1} from 'memfs'; +import * as fs2 from 'fs'; + +ufs + .use(fs1, {writable: false}) + .use(fs2, {readable: false}); + +ufs.writeFileSync(/* ... */); // fs2 will "collect" mutations; fs1 will remain unchanged +``` + Use this module with [`memfs`][memfs] and [`linkfs`][linkfs]. `memfs` allows you to create virtual in-memory file system. `linkfs` allows you to redirect `fs` paths. diff --git a/demo/writeonly.ts b/demo/writeonly.ts new file mode 100644 index 00000000..ce5463d1 --- /dev/null +++ b/demo/writeonly.ts @@ -0,0 +1,20 @@ +/** + * This example overlays a writeonly memfs volume which will effectively "collect" write operations + * and leave the underlying volume unchanged. This is useful for test suite scenarios + * + * Please also see [[../src/__tests__/union.test.ts]] for option examples + */ + +import {ufs} from '../src'; +import {Volume} from 'memfs'; + +const vol1 = Volume.fromJSON({ + '/underlying_file': 'hello', +}); +const vol2 = Volume.fromJSON({}); + +ufs.use(vol1 as any).use(vol2 as any, {writable: false}) +ufs.writeFileSync('/foo', 'bar') + +console.log(vol2.readFileSync('/foo', 'utf8')) // bar +console.log(vol2.readFileSync('/underlying_file', 'utf8')) // error diff --git a/package.json b/package.json index be66913b..8bf3e239 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,6 @@ "prettier": "prettier --ignore-path .gitignore --write \"src/**/*.{ts,js}\"", "prettier:diff": "prettier -l \"src/**/*.{ts,js}\"" }, - "dependencies": { - "fs-monkey": "^1.0.0" - }, "devDependencies": { "@semantic-release/changelog": "3.0.6", "@semantic-release/git": "7.0.18", diff --git a/src/__tests__/union.test.ts b/src/__tests__/union.test.ts index e4afa85a..2ef50839 100644 --- a/src/__tests__/union.test.ts +++ b/src/__tests__/union.test.ts @@ -1,6 +1,7 @@ import { Union } from '..'; import { Volume, createFsFromVolume } from 'memfs'; import * as fs from 'fs'; +import { isMainThread } from 'worker_threads'; describe('union', () => { describe('Union', () => { @@ -76,6 +77,68 @@ describe('union', () => { }); }); + describe('when none of the volumes are readable', () => { + it('throws an error when calling a read method', () => { + const vol1 = Volume.fromJSON({ '/foo': 'bar1' }); + const vol2 = Volume.fromJSON({ '/foo': 'bar2' }); + const ufs = new Union(); + ufs.use(vol1 as any, { readable: false }).use(vol2 as any, { readable: false }); + + expect(() => ufs.readFileSync('/foo')).toThrowError(); + }); + }); + + describe('when none of the volumes are writable', () => { + it('throws an error when calling a write method', () => { + const vol1 = Volume.fromJSON({ '/foo': 'bar' }); + const vol2 = Volume.fromJSON({ '/foo': 'bar' }); + const ufs = new Union(); + ufs.use(vol1 as any, { writable: false }).use(vol2 as any, { writable: false }); + + expect(() => ufs.writeFileSync('/foo', 'bar')).toThrowError(); + }); + }); + + describe('readable/writable', () => { + it('writes to the last vol added', () => { + const vol1 = Volume.fromJSON({}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any); + ufs.writeFileSync('/foo', 'bar'); + + expect(vol2.readFileSync('/foo', 'utf8')).toEqual('bar'); + }); + + it('writes to the last vol added', () => { + const vol1 = Volume.fromJSON({}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any, { readable: false }); + ufs.writeFileSync('/foo', 'bar'); + + expect(vol2.readFileSync('/foo', 'utf8')).toEqual('bar'); + }); + + it('reads from the last vol added', () => { + const vol1 = Volume.fromJSON({'/foo': 'bar'}); + const vol2 = Volume.fromJSON({'/foo': 'bar2'}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any); + + expect(ufs.readFileSync('/foo', 'utf8')).toEqual('bar2'); + }); + + it('reads from the latest added readable vol', () => { + const vol1 = Volume.fromJSON({'/foo': 'bar'}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any, { readable: false }); + + expect(ufs.readFileSync('/foo', 'utf8')).toEqual('bar'); + }); + }); + describe('readdirSync', () => { it('reads one memfs correctly', () => { const vol = Volume.fromJSON({ @@ -148,7 +211,7 @@ describe('union', () => { expect(files.map(f => f.name)).toEqual(['bar', 'baz', 'zzz']); }); - it('throws error when all fss fail', () => { + it('throws an error when all fss fail', () => { const vol = Volume.fromJSON({}); const vol2 = Volume.fromJSON({}); @@ -229,6 +292,87 @@ describe('union', () => { }); }); + describe('when none of the volumes are readable', () => { + it('throws an error when calling a read method', done => { + const vol1 = Volume.fromJSON({ '/foo': 'bar1' }); + const vol2 = Volume.fromJSON({ '/foo': 'bar2' }); + const ufs = new Union(); + ufs.use(vol1 as any, { readable: false }).use(vol2 as any, { readable: false }); + + ufs.readFile('/foo', 'utf8', (err, res) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + }); + + describe('when none of the volumes are writable', () => { + it('throws an error when calling a write method', done => { + const vol1 = Volume.fromJSON({ '/foo': 'bar' }); + const vol2 = Volume.fromJSON({ '/foo': 'bar' }); + const ufs = new Union(); + ufs.use(vol1 as any, { writable: false }).use(vol2 as any, { writable: false }); + ufs.writeFile('/foo', 'bar', (err) => { + expect(err).toBeInstanceOf(Error); + done(); + }); + }); + }); + + describe('readable/writable', () => { + it('writes to the last vol added', done => { + const vol1 = Volume.fromJSON({}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any); + ufs.writeFile('/foo', 'bar', (err) => { + vol2.readFile('/foo', 'utf8', (err, res) => { + expect(res).toEqual('bar'); + done(); + }); + }); + }); + + it('writes to the latest added writable vol', done => { + const vol1 = Volume.fromJSON({}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any, {writable: false}); + ufs.writeFile('/foo', 'bar', (err) => { + ufs.readFile('/foo', 'utf8', (err, res) => { + expect(res).toEqual('bar'); + vol1.readFile('/foo', 'utf8', (err, res) => { + expect(res).toEqual('bar'); + done(); + }); + }); + }); + }); + + it('reads from the last vol added', done => { + const vol1 = Volume.fromJSON({'/foo': 'bar'}); + const vol2 = Volume.fromJSON({'/foo': 'bar2'}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any); + ufs.readFile('/foo', 'utf8', (err, res) => { + expect(res).toEqual('bar2'); + done(); + }); + + }) + + it('reads from the latest added readable vol', done => { + const vol1 = Volume.fromJSON({'/foo': 'bar'}); + const vol2 = Volume.fromJSON({'/foo': 'bar2'}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any, {readable: false}); + ufs.readFile('/foo', 'utf8', (err, res) => { + expect(res).toEqual('bar'); + done(); + }); + }) + }); + describe('readdir', () => { it('reads one memfs correctly', () => { const vol = Volume.fromJSON({ @@ -297,7 +441,7 @@ describe('union', () => { }); }); - it('throws error when all fss fail', done => { + it('throws an error when all fss fail', done => { const vol = Volume.fromJSON({}); const vol2 = Volume.fromJSON({}); @@ -344,6 +488,65 @@ describe('union', () => { await expect(ufs.promises.readFile('/foo', 'utf8')).rejects.toThrowError(); }); + describe('when none of the volumes are readable', () => { + it('throws an error when calling a read method', async () => { + const vol1 = Volume.fromJSON({ '/foo': 'bar1' }); + const vol2 = Volume.fromJSON({ '/foo': 'bar2' }); + const ufs = new Union(); + ufs.use(vol1 as any, { readable: false }).use(vol2 as any, { readable: false }); + + await expect(ufs.promises.readFile('/foo')).rejects.toThrowError(); + }); + }); + + describe('when none of the volumes are writable', () => { + it('throws an error when calling a write method', async () => { + const vol1 = Volume.fromJSON({ '/foo': 'bar' }); + const vol2 = Volume.fromJSON({ '/foo': 'bar' }); + const ufs = new Union(); + ufs.use(vol1 as any, { writable: false }).use(vol2 as any, { writable: false }); + + await expect(ufs.promises.writeFile('/foo', 'bar')).rejects.toThrowError(); + }); + }); + + describe('readable/writable', () => { + it('writes to the last vol added', async () => { + const vol1 = Volume.fromJSON({}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any); + ufs.writeFileSync('/foo', 'bar'); + await expect(vol2.promises.readFile('/foo', 'utf8')).resolves.toEqual('bar'); + }); + + it('writes to the latest added writable vol', async () => { + const vol1 = Volume.fromJSON({}); + const vol2 = Volume.fromJSON({}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any, { writable: false }); + ufs.writeFileSync('/foo', 'bar'); + await expect(vol1.promises.readFile('/foo', 'utf8')).resolves.toEqual('bar'); + }); + + it('reads from the last vol added', async () => { + const vol1 = Volume.fromJSON({'/foo': 'bar'}); + const vol2 = Volume.fromJSON({'/foo': 'bar2'}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any); + await expect(ufs.promises.readFile('/foo', 'utf8')).resolves.toEqual('bar2'); + + }) + + it('reads from the latest added readable vol', async () => { + const vol1 = Volume.fromJSON({'/foo': 'bar'}); + const vol2 = Volume.fromJSON({'/foo': 'bar2'}); + const ufs = new Union(); + ufs.use(vol1 as any).use(vol2 as any, {readable: false}); + await expect(ufs.promises.readFile('/foo', 'utf8')).resolves.toEqual('bar'); + }) + }); + describe('readdir', () => { it('reads one memfs correctly', async () => { const vol = Volume.fromJSON({ @@ -416,7 +619,7 @@ describe('union', () => { expect(files.map(f => f.name)).toEqual(['bar', 'baz', 'zzz']); }); - it('throws error when all fss fail', async () => { + it('throws an error when all fss fail', async () => { const vol = Volume.fromJSON({}); const vol2 = Volume.fromJSON({}); diff --git a/src/index.ts b/src/index.ts index 80b05df5..13f0dca7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -import { Union as _Union } from './union'; +import { Union as _Union, VolOptions } from './union'; import { IFS } from './fs'; export interface IUnionFs extends IFS { - use(fs: IFS): this; + use: (fs: IFS, options?: VolOptions) => this; } export const Union = (_Union as any) as new () => IUnionFs; diff --git a/src/lists.ts b/src/lists.ts new file mode 100644 index 00000000..bf9f1a97 --- /dev/null +++ b/src/lists.ts @@ -0,0 +1,115 @@ +export const fsSyncMethodsWrite = [ + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'closeSync', + 'copyFileSync', + 'createWriteStream', + 'fchmodSync', + 'fchownSync', + 'fdatasyncSync', + 'fsyncSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lstatSync', + 'mkdirSync', + 'mkdtempSync', + 'renameSync', + 'rmdirSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', +] as const; + +export const fsSyncMethodsRead = [ + 'accessSync', + 'createReadStream', + 'existsSync', + 'fstatSync', + 'ftruncateSync', + 'openSync', + 'readdirSync', + 'readFileSync', + 'readlinkSync', + 'readSync', + 'realpathSync', + 'statSync', +] as const; +export const fsAsyncMethodsRead = [ + 'access', + 'exists', + 'fstat', + 'open', + 'read', + 'readdir', + 'readFile', + 'readlink', + 'realpath', + 'unwatchFile', + 'watch', + 'watchFile', +] as const; +export const fsAsyncMethodsWrite = [ + 'appendFile', + 'chmod', + 'chown', + 'close', + 'copyFile', + 'fchmod', + 'fchown', + 'fdatasync', + 'fsync', + 'ftruncate', + 'futimes', + 'lchmod', + 'lchown', + 'link', + 'lstat', + 'mkdir', + 'mkdtemp', + 'rename', + 'rmdir', + 'stat', + 'symlink', + 'truncate', + 'unlink', + 'utimes', + 'write', + 'writeFile', +] as const; + +export const fsPromiseMethodsRead = [ + 'access', + 'open', + 'opendir', + 'readdir', + 'readFile', + 'readlink', + 'realpath', +] as const; + +export const fsPromiseMethodsWrite = [ + 'appendFile', + 'chmod', + 'chown', + 'copyFile', + 'lchmod', + 'lchown', + 'link', + 'lstat', + 'mkdir', + 'mkdtemp', + 'rename', + 'rmdir', + 'stat', + 'symlink', + 'truncate', + 'unlink', + 'utimes', + 'writeFile', +] as const; diff --git a/src/union.ts b/src/union.ts index 83561ff9..35826c36 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,7 +1,14 @@ import { FSWatcher, Dirent } from 'fs'; import { IFS } from './fs'; import { Readable, Writable } from 'stream'; -const { fsAsyncMethods, fsSyncMethods } = require('fs-monkey/lib/util/lists'); +import { + fsSyncMethodsWrite, + fsSyncMethodsRead, + fsAsyncMethodsWrite, + fsAsyncMethodsRead, + fsPromiseMethodsWrite, + fsPromiseMethodsRead, +} from './lists'; export interface IUnionFsError extends Error { prev?: IUnionFsError | null; @@ -48,39 +55,17 @@ const createFSProxy = (watchers: FSWatcher[]) => }, ); -const fsPromisesMethods = [ - 'access', - 'copyFile', - 'open', - 'opendir', - 'rename', - 'truncate', - 'rmdir', - 'mkdir', - 'readdir', - 'readlink', - 'symlink', - 'lstat', - 'stat', - 'link', - 'unlink', - 'chmod', - 'lchmod', - 'lchown', - 'chown', - 'utimes', - 'realpath', - 'mkdtemp', - 'writeFile', - 'appendFile', - 'readFile', -] as const; +export type VolOptions = { + readable?: boolean; + writable?: boolean; +}; + /** * Union object represents a stack of filesystems */ export class Union { - private fss: IFS[] = []; + private fss: [IFS, VolOptions][] = []; public ReadStream: typeof Readable | (new (...args: any[]) => Readable) = Readable; public WriteStream: typeof Writable | (new (...args: any[]) => Writable) = Writable; @@ -88,20 +73,20 @@ export class Union { private promises: {} = {}; constructor() { - for (let method of fsSyncMethods) { + for (let method of [...fsSyncMethodsRead, ...fsSyncMethodsWrite]) { if (!SPECIAL_METHODS.has(method)) { // check we don't already have a property for this method this[method] = (...args) => this.syncMethod(method, args); } } - for (let method of fsAsyncMethods) { + for (let method of [...fsAsyncMethodsRead, ...fsAsyncMethodsWrite]) { if (!SPECIAL_METHODS.has(method)) { // check we don't already have a property for this method this[method] = (...args) => this.asyncMethod(method, args); } } - for (let method of fsPromisesMethods) { + for (let method of [...fsPromiseMethodsRead, ...fsPromiseMethodsWrite]) { if (method === 'readdir') { this.promises[method] = this.readdirPromise; @@ -113,7 +98,6 @@ export class Union { for (let method of SPECIAL_METHODS.values()) { // bind special methods to support - // const { method } = ufs; this[method] = this[method].bind(this); } } @@ -124,7 +108,8 @@ export class Union { public watch = (...args) => { const watchers: FSWatcher[] = []; - for (const fs of this.fss) { + for (const [fs, { readable }] of this.fss) { + if (readable === false) continue; try { const watcher = fs.watch.apply(fs, args); watchers.push(watcher); @@ -138,7 +123,8 @@ export class Union { }; public watchFile = (...args) => { - for (const fs of this.fss) { + for (const [fs, { readable }] of this.fss) { + if (readable === false) continue; try { fs.watchFile.apply(fs, args); } catch (e) { @@ -148,7 +134,8 @@ export class Union { }; public existsSync = (path: string) => { - for (const fs of this.fss) { + for (const [fs, { readable }] of this.fss) { + if (readable === false) continue; try { if (fs.existsSync(path)) { return true; @@ -205,10 +192,11 @@ export class Union { }; const j = this.fss.length - i - 1; - const fs = this.fss[j]; + const [fs, { readable }] = this.fss[j]; const func = fs.readdir; if (!func) iterate(i + 1, Error('Method not supported: readdir')); + else if (readable === false) iterate(i + 1, Error(`Readable disabled for vol '${i}': readdir`)); else func.apply(fs, args); }; iterate(); @@ -218,7 +206,8 @@ export class Union { let lastError: IUnionFsError | null = null; let result = new Map(); for (let i = this.fss.length - 1; i >= 0; i--) { - const fs = this.fss[i]; + const [fs, { readable }] = this.fss[i]; + if (readable === false) continue; try { if (!fs.readdirSync) throw Error(`Method not supported: "readdirSync" with args "${args}"`); for (const res of fs.readdirSync.apply(fs, args)) { @@ -243,7 +232,8 @@ export class Union { let lastError: IUnionFsError | null = null; let result = new Map(); for (let i = this.fss.length - 1; i >= 0; i--) { - const fs = this.fss[i]; + const [fs, { readable }] = this.fss[i]; + if (readable === false) continue; try { if (!fs.promises || !fs.promises.readdir) throw Error(`Method not supported: "readdirSync" with args "${args}"`); @@ -283,7 +273,8 @@ export class Union { public createReadStream = (path: string) => { let lastError = null; - for (const fs of this.fss) { + for (const [fs, { readable }] of this.fss) { + if (readable === false) continue; try { if (!fs.createReadStream) throw Error(`Method not supported: "createReadStream"`); @@ -308,7 +299,8 @@ export class Union { public createWriteStream = (path: string) => { let lastError = null; - for (const fs of this.fss) { + for (const [fs, { writable }] of this.fss) { + if (writable === false) continue; try { if (!fs.createWriteStream) throw Error(`Method not supported: "createWriteStream"`); @@ -337,18 +329,84 @@ export class Union { * @param fs the filesystem interface to be added to the queue of FS's * @returns this instance of a unionFS */ - use(fs: IFS): this { - this.fss.push(fs); + use(fs: IFS, options: VolOptions = {}): this { + this.fss.push([this.createFS(fs, options), options]); return this; } + /** + * At the time of the [[use]] call, we create our sync, async and promise methods + * for performance reasons + * + * @param fs + * @param options + */ + private createFS(fs: IFS, { readable = true, writable = true }: VolOptions): IFS { + const createErroringFn = (state: 'readable' | 'writable') => (...args: any[]) => { + throw new Error(`Filesystem is not ${state}`); + }; + const createFunc = (method: string): any => { + if (!fs[method]) + return (...args: any[]) => { + throw new Error(`Method not supported: "${method}" with args "${args}"`); + }; + return (...args: any[]) => fs[method as string](...args); + }; + + return { + ...fs, + ...fsSyncMethodsRead.reduce((acc, method) => { + acc[method] = readable ? createFunc(method) : createErroringFn('readable'); + return acc; + }, {}), + ...fsSyncMethodsWrite.reduce((acc, method) => { + acc[method] = writable ? createFunc(method) : createErroringFn('writable'); + return acc; + }, {}), + ...fsAsyncMethodsRead.reduce((acc, method) => { + acc[method] = readable ? createFunc(method) : createErroringFn('readable'); + return acc; + }, {}), + ...fsAsyncMethodsWrite.reduce((acc, method) => { + acc[method] = writable ? createFunc(method) : createErroringFn('writable'); + return acc; + }, {}), + promises: { + ...fs.promises, + ...fsPromiseMethodsRead.reduce((acc, method) => { + const promises = fs.promises; + if (!promises || !promises[method]) { + acc[method] = (...args: any) => { + throw Error(`Promise of method not supported: "${String(method)}" with args "${args}"`); + }; + return acc; + } + acc[method] = readable ? (...args: any) => promises[method as string].apply(fs, args) : createErroringFn('readable'); + return acc; + }, {}), + ...fsPromiseMethodsWrite.reduce((acc, method) => { + const promises = fs.promises; + if (!promises || !promises[method]) { + acc[method] = (...args: any) => { + throw Error(`Promise of method not supported: "${String(method)}" with args "${args}"`); + }; + return acc; + } + acc[method] = writable ? (...args: any) => promises[method as string].apply(fs, args) : createErroringFn('writable'); + return acc; + }, {}), + }, + }; + } + private syncMethod(method: string, args: any[]) { + if (!this.fss.length) throw new Error('No file systems attached'); let lastError: IUnionFsError | null = null; for (let i = this.fss.length - 1; i >= 0; i--) { - const fs = this.fss[i]; + const [fs] = this.fss[i]; try { if (!fs[method]) throw Error(`Method not supported: "${method}" with args "${args}"`); - return fs[method].apply(fs, args); + return fs[method](...args); } catch (err) { err.prev = lastError; lastError = err; @@ -381,22 +439,28 @@ export class Union { // Already tried all file systems, return the last error. if (i >= this.fss.length) { // last one - if (cb) cb(err || Error('No file systems attached.')); + if (cb) cb(err ?? (!this.fss.length ? new Error('No file systems attached.') : undefined)); return; } // Replace `callback` with our intermediate function. - args[lastarg] = function(err) { + args[lastarg] = function (err) { if (err) return iterate(i + 1, err); if (cb) cb.apply(cb, arguments); }; const j = this.fss.length - i - 1; - const fs = this.fss[j]; + const [fs] = this.fss[j]; const func = fs[method]; if (!func) iterate(i + 1, Error('Method not supported: ' + method)); - else func.apply(fs, args); + else { + try { + func(...args); + } catch (err) { + iterate(i + 1, err); + } + } }; iterate(); } @@ -405,7 +469,7 @@ export class Union { let lastError = null; for (let i = this.fss.length - 1; i >= 0; i--) { - const theFs = this.fss[i]; + const [theFs] = this.fss[i]; const promises = theFs.promises; @@ -414,7 +478,6 @@ export class Union { throw Error(`Promise of method not supported: "${String(method)}" with args "${args}"`); } - // return promises[method](...args); return await promises[method].apply(promises, args); } catch (err) { err.prev = lastError; diff --git a/yarn.lock b/yarn.lock index 50c96a8d..63e94cb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2510,7 +2510,7 @@ fs-minipass@^1.2.5: dependencies: minipass "^2.6.0" -fs-monkey@1.0.0, fs-monkey@^1.0.0: +fs-monkey@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.0.tgz#b1fe36b2d8a78463fd0b8fd1463b355952743bd0" integrity sha512-nxkkzQ5Ga+ETriXxIof4TncyMSzrV9jFIF+kGN16nw5CiAdWAnG/2FgM7CHhRenW1EBiDx+r1tf/P78HGKCgnA==