Compare commits

...

23 Commits

Author SHA1 Message Date
9b0d89b9ef 11.2.5 2025-05-26 08:24:56 +00:00
b34ae1362d fix(dev): Update dev dependencies and add local permission settings 2025-05-26 08:24:56 +00:00
2fa68c23a9 11.2.4 2025-05-24 00:29:37 +00:00
4083a36d8c fix(config): Add local permissions configuration for pnpm test commands in .claude/settings.local.json 2025-05-24 00:29:37 +00:00
3988ccbcb3 11.2.3 2025-05-21 13:27:10 +00:00
6eadbc654f 11.2.2 2025-05-21 13:26:29 +00:00
fda1543701 fix(tests/settings): Improve test assertions and update local settings permissions 2025-05-21 13:26:29 +00:00
a9660eda9a 11.2.1 2025-05-21 13:24:41 +00:00
dfd1db152b fix(fs): Fix inconsistent glob matching in listFileTree and update test imports and dependency versions for enhanced stability. 2025-05-21 13:24:41 +00:00
7a32835a74 11.2.0 2025-01-29 18:23:54 +01:00
e78682d9b4 feat(fs): Enhanced copy method with optional replaceTargetDir option for directory replacement 2025-01-29 18:23:54 +01:00
8dceea67be 11.1.9 2025-01-29 18:20:15 +01:00
40018532a7 fix(fs): Fix directory handling in copy and copySync functions 2025-01-29 18:20:14 +01:00
f6fb28d32f 11.1.8 2025-01-29 18:14:02 +01:00
2d1ac0bd50 fix(fs): Fixed copy and copySync functions to ensure they always overwrite files. 2025-01-29 18:14:02 +01:00
04a25221a5 11.1.7 2025-01-29 18:10:48 +01:00
13081b7344 fix(fs): Refactor copy and copySync functions to simplify return type 2025-01-29 18:10:48 +01:00
0abbe8bbd7 11.1.6 2025-01-29 12:21:49 +01:00
de2a250a45 fix(fs): Fix issues with fs file copy functions. 2025-01-29 12:21:49 +01:00
1657d0e1c6 11.1.5 2025-01-07 04:58:31 +01:00
e6b8240031 fix(fs): Improve waitForFileToBeReady function to handle directories and file stabilization 2025-01-07 04:58:31 +01:00
be011a4637 11.1.4 2025-01-07 04:41:18 +01:00
dbddf2a8ba fix(fs): Fix file existence check in waitForFileToBeReady method. 2025-01-07 04:41:17 +01:00
10 changed files with 2897 additions and 4867 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmjs.org/

View File

@@ -1,5 +1,78 @@
# Changelog
## 2025-05-26 - 11.2.5 - fix(dev)
Update dev dependencies and add local permission settings
- Bump @git.zone/tsbuild from 2.5.2 to 2.6.4
- Bump @git.zone/tstest from 1.9.0 to 2.2.5
- Add .claude/settings.local.json to configure permissions for Bash(pnpm test:*)
## 2025-05-24 - 11.2.4 - fix(config)
Add local permissions configuration for pnpm test commands in .claude/settings.local.json
- Introduced .claude/settings.local.json to allow Bash(pnpm test:*) permissions
- Ensured local testing commands have proper execution rights
## 2025-05-21 - 11.2.2 - fix(tests/settings)
Improve test assertions and update local settings permissions
- Refactor StreamFile tests to assert content string type using toBeTypeofString
- Update file existence tests to use resolves.toBeTrue and resolves.toBeFalse for cleaner promise handling
- Add .claude/settings.local.json to allow specific Bash permissions for pnpm test commands
## 2025-05-21 - 11.2.1 - fix(fs)
Fix inconsistent glob matching in listFileTree and update test imports and dependency versions for enhanced stability.
- Enhanced listFileTree to support **/ patterns by using dual patterns (root and nested) with deduplication.
- Updated test imports to use '@git.zone/tstest/tapbundle' for consistency across test files.
- Bumped dependency versions (@push.rocks/lik, smartpromise, smartrequest, glob) in package.json.
- Added npm configuration (.npmrc) and local settings for improved test verbosity.
## 2025-01-29 - 11.2.0 - feat(fs)
Enhanced copy method with optional replaceTargetDir option for directory replacement
- Added optional 'replaceTargetDir' option to 'copy' and 'copySync' methods in 'fs.ts'.
- The 'replaceTargetDir' option allows replacing the target directory if both source and target are directories.
## 2025-01-29 - 11.1.9 - fix(fs)
Fix directory handling in copy and copySync functions
- Ensured existing directories at destination are removed before copying over them in async copy.
- Added a similar check and handling for synchronous copySync when destination is a directory.
## 2025-01-29 - 11.1.8 - fix(fs)
Fixed copy and copySync functions to ensure they always overwrite files.
- Fixed bug in copy function where files were not being overwritten when they already existed at the destination.
- Fixed bug in copySync function to ensure files are overwritten to match the async function's behavior.
## 2025-01-29 - 11.1.7 - fix(fs)
Refactor copy and copySync functions to simplify return type
- Changed the return type of fs.copy and fs.copySync from boolean to void.
- Removed unnecessary promise handling in fs.copy.
## 2025-01-29 - 11.1.6 - fix(fs)
Fix issues with fs file copy functions.
- Updated dependencies in package.json.
- Corrected comments for asynchronous and synchronous file copy functions in fs.ts.
## 2025-01-07 - 11.1.5 - fix(fs)
Improve waitForFileToBeReady function to handle directories and file stabilization
- Enhanced the waitForFileToBeReady to handle directory paths by checking for file existence within directories and waiting for stabilization.
- Modified the watcher logic to cater to changes when monitoring directories for file appearance.
- Introduced a helper function to ensure paths exist and another to resolve the first file in directories.
- Corrected logic for polling and stabilizing files within directories.
## 2025-01-07 - 11.1.4 - fix(fs)
Fix file existence check in waitForFileToBeReady method.
- Ensured that the directory and file exist before setting up the watcher in waitForFileToBeReady.
- Changed ensureDirectoryExists to ensureFileExists for correct file path verification.
- Handled ENOENT errors correctly to retry file existence checks until timeout is reached.
## 2025-01-07 - 11.1.3 - fix(fs)
Fix TypeScript type issue in fs module

View File

@@ -1,13 +1,13 @@
{
"name": "@push.rocks/smartfile",
"private": false,
"version": "11.1.3",
"version": "11.2.5",
"description": "Provides comprehensive tools for efficient file management in Node.js using TypeScript, including handling streams, virtual directories, and various file operations.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"scripts": {
"test": "(tstest test/)",
"test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "tsdoc"
},
@@ -42,29 +42,28 @@
},
"homepage": "https://code.foss.global/push.rocks/smartfile",
"dependencies": {
"@push.rocks/lik": "^6.1.0",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartfile-interfaces": "^1.0.7",
"@push.rocks/smarthash": "^3.0.4",
"@push.rocks/smartjson": "^5.0.20",
"@push.rocks/smartmime": "^2.0.4",
"@push.rocks/smartpath": "^5.0.18",
"@push.rocks/smartpromise": "^4.1.0",
"@push.rocks/smartrequest": "^2.0.23",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartstream": "^3.2.5",
"@types/fs-extra": "^11.0.4",
"@types/glob": "^8.1.0",
"@types/js-yaml": "^4.0.9",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"fs-extra": "^11.3.0",
"glob": "^11.0.2",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.2.0",
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^1.0.90",
"@push.rocks/tapbundle": "^5.5.4",
"@types/node": "^22.10.5"
"@git.zone/tstest": "^2.2.5",
"@types/node": "^22.15.21"
},
"files": [
"ts/**/*",
@@ -80,5 +79,6 @@
],
"browserslist": [
"last 1 chrome versions"
]
],
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

7352
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,27 @@
# SmartFile Implementation Hints
## listFileTree Function Enhancement (ts/fs.ts:367-415)
### Issue Fixed
The `listFileTree` function previously had inconsistent behavior with `**/*.extension` patterns across different systems and glob implementations. Some implementations would miss root-level files when using patterns like `**/*.ts`.
### Solution Implemented
Modified the function to explicitly handle `**/` patterns by:
1. Detecting when a pattern starts with `**/`
2. Extracting the file pattern after `**/` (e.g., `*.ts` from `**/*.ts`)
3. Running both the original pattern and the extracted root pattern
4. Using a Set to deduplicate results and ensure consistent ordering
### Key Benefits
- Guarantees consistent behavior across all systems
- Ensures both root-level and nested files are found with `**/*` patterns
- Maintains backward compatibility
- No performance degradation due to efficient deduplication
### Test Coverage
Added comprehensive tests to verify:
- Both root and nested files are found with `**/*.ts`
- No duplicate entries in results
- Edge cases with various file extensions work correctly
This fix ensures tools like `tsbuild check **/*.ts` work reliably across all systems.

View File

@@ -1,5 +1,5 @@
import * as path from 'path';
import { expect, tap } from '@push.rocks/tapbundle';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartfile from '../ts/index.js'; // adjust the import path as needed
// Test assets path
@@ -57,7 +57,7 @@ tap.test('StreamFile should return content as a buffer', async () => {
tap.test('StreamFile should return content as a string', async () => {
const streamFile = await smartfile.StreamFile.fromPath(path.join(testAssetsPath, 'mytest.json'));
const contentString = await streamFile.getContentAsString();
expect(typeof contentString).toBeTypeofString();
expect(contentString).toBeTypeofString();
// Verify the content matches what's expected
// This assumes the file contains a JSON object with a key 'key1' with value 'this works'
expect(JSON.parse(contentString).key1).toEqual('this works');

View File

@@ -1,7 +1,7 @@
import * as smartfile from '../ts/index.js';
import * as path from 'path';
import { expect, tap } from '@push.rocks/tapbundle';
import { expect, tap } from '@git.zone/tstest/tapbundle';
// ---------------------------
// smartfile.fs
@@ -15,13 +15,8 @@ tap.test('.fs.fileExistsSync -> should return an accurate boolean', async () =>
});
tap.test('.fs.fileExists -> should resolve or reject a promise', async () => {
expect(smartfile.fs.fileExists('./test/testassets/mytest.json')).toBeInstanceOf(Promise);
await smartfile.fs.fileExists('./test/testassets/mytest.json');
await smartfile.fs.fileExists('./test/testassets/notthere.json').catch((err) => {
return expect(err.message).toEqual(
"ENOENT: no such file or directory, access './test/testassets/notthere.json'"
);
});
await expect(smartfile.fs.fileExists('./test/testassets/mytest.json')).resolves.toBeTrue();
await expect(smartfile.fs.fileExists('./test/testassets/notthere.json')).resolves.toBeFalse();
});
tap.test('.fs.listFoldersSync() -> should get the file type from a string', async () => {
@@ -59,6 +54,43 @@ tap.test('.fs.listFileTree() -> should get a file tree', async () => {
expect(folderArrayArg).not.toContain('mytest.json');
});
tap.test('.fs.listFileTree() -> should find both root and nested .ts files with **/*.ts pattern', async () => {
const tsFiles = await smartfile.fs.listFileTree(
process.cwd(),
'**/*.ts'
);
// Should find both root-level and nested TypeScript files
expect(tsFiles).toContain('ts/index.ts');
expect(tsFiles).toContain('ts/classes.smartfile.ts');
expect(tsFiles).toContain('test/test.ts');
// Should find files in multiple levels of nesting
expect(tsFiles.filter(f => f.endsWith('.ts')).length).toBeGreaterThan(5);
// Verify it finds files at all levels (root 'ts/' and nested 'test/')
const hasRootLevelTs = tsFiles.some(f => f.startsWith('ts/') && f.endsWith('.ts'));
const hasNestedTs = tsFiles.some(f => f.startsWith('test/') && f.endsWith('.ts'));
expect(hasRootLevelTs).toBeTrue();
expect(hasNestedTs).toBeTrue();
});
tap.test('.fs.listFileTree() -> should handle edge cases with **/ patterns consistently', async () => {
// Test that our fix ensures no duplicate files in results
const jsonFiles = await smartfile.fs.listFileTree(
path.resolve('./test/testassets/'),
'**/*.json'
);
const uniqueFiles = [...new Set(jsonFiles)];
expect(jsonFiles.length).toEqual(uniqueFiles.length);
// Test that it finds root level files with **/ patterns
const txtFiles = await smartfile.fs.listFileTree(
path.resolve('./test/testassets/'),
'**/*.txt'
);
// Should include both direct files and nested files
expect(txtFiles).toContain('mytest.txt');
expect(txtFiles).toContain('testfolder/testfile1.txt');
});
tap.test('.fs.fileTreeToObject -> should read a file tree into an Object', async () => {
const fileArrayArg = await smartfile.fs.fileTreeToObject(
path.resolve('./test/testassets/'),

View File

@@ -1,4 +1,4 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartfile from '../ts/index.js';

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartfile',
version: '11.1.3',
version: '11.2.5',
description: 'Provides comprehensive tools for efficient file management in Node.js using TypeScript, including handling streams, virtual directories, and various file operations.'
}

230
ts/fs.ts
View File

@@ -72,25 +72,23 @@ export const isFile = (pathArg): boolean => {
===============================================================*/
/**
* copies a file from A to B on the local disk
* copies a file or directory from A to B on the local disk
*/
export const copy = async (fromArg: string, toArg: string): Promise<boolean> => {
const done = plugins.smartpromise.defer<boolean>();
plugins.fsExtra.copy(fromArg, toArg, {}, (err) => {
if (err) {
throw new Error(`Could not copy from ${fromArg} to ${toArg}: ${err}`);
}
done.resolve(true);
});
return done.promise;
export const copy = async (fromArg: string, toArg: string, optionsArg?: plugins.fsExtra.CopyOptions & { replaceTargetDir?: boolean }): Promise<void> => {
if (optionsArg?.replaceTargetDir && isDirectory(fromArg) && isDirectory(toArg)) {
await remove(toArg);
}
return await plugins.fsExtra.copy(fromArg, toArg, optionsArg as plugins.fsExtra.CopyOptions);
};
/**
* copies a file SYNCHRONOUSLY from A to B on the local disk
* copies a file or directory SYNCHRONOUSLY from A to B on the local disk
*/
export const copySync = (fromArg: string, toArg: string): boolean => {
plugins.fsExtra.copySync(fromArg, toArg);
return true;
export const copySync = (fromArg: string, toArg: string, optionsArg?: plugins.fsExtra.CopyOptionsSync & { replaceTargetDir?: boolean }): void => {
if (optionsArg?.replaceTargetDir && isDirectory(fromArg) && isDirectory(toArg)) {
removeSync(toArg);
}
return plugins.fsExtra.copySync(fromArg, toArg, optionsArg as plugins.fsExtra.CopyOptionsSync);
};
/**
@@ -385,7 +383,28 @@ export const listFileTree = async (
dot: true,
};
let fileList = await plugins.glob.glob(miniMatchFilter, options);
// Fix inconsistent **/* glob behavior across systems
// Some glob implementations don't include root-level files when using **/*
// To ensure consistent behavior, we expand **/* patterns to include both root and nested files
let patterns: string[];
if (miniMatchFilter.startsWith('**/')) {
// Extract the part after **/ (e.g., "*.ts" from "**/*.ts")
const rootPattern = miniMatchFilter.substring(3);
// Use both the root pattern and the original pattern to ensure we catch everything
patterns = [rootPattern, miniMatchFilter];
} else {
patterns = [miniMatchFilter];
}
// Collect results from all patterns
const allFiles = new Set<string>();
for (const pattern of patterns) {
const files = await plugins.glob.glob(pattern, options);
files.forEach(file => allFiles.add(file));
}
let fileList = Array.from(allFiles).sort();
if (absolutePathsBool) {
fileList = fileList.map((filePath) => {
return plugins.path.resolve(plugins.path.join(dirPath, filePath));
@@ -397,95 +416,174 @@ export const listFileTree = async (
/**
* Watches for file stability before resolving the promise.
* @param filePathArg The path of the file to monitor.
* Ensures that the directory/file exists before setting up the watcher.
*
* **New behavior**: If the given path is a directory, this function will:
* 1. Wait for that directory to exist (creating a timeout if needed).
* 2. Watch the directory until at least one file appears.
* 3. Then wait for the first file in the directory to stabilize before resolving.
*
* @param fileOrDirPathArg The path of the file or directory to monitor.
* @param timeoutMs The maximum time to wait for the file to stabilize (in milliseconds). Default is 60 seconds.
* @returns A promise that resolves when the file is stable or rejects on timeout or error.
* @returns A promise that resolves when the target is stable or rejects on timeout/error.
*/
export const waitForFileToBeReady = (
filePathArg: string,
export const waitForFileToBeReady = async (
fileOrDirPathArg: string,
timeoutMs: number = 60000
): Promise<void> => {
return new Promise(async (resolve, reject) => {
const startTime = Date.now();
/**
* Ensure that a path (file or directory) exists. If it doesn't yet exist,
* wait until it does (or time out).
* @param pathToCheck The file or directory path to check.
*/
const ensurePathExists = async (pathToCheck: string): Promise<void> => {
while (true) {
try {
await plugins.smartpromise.fromCallback((cb) =>
plugins.fs.access(pathToCheck, plugins.fs.constants.F_OK, cb)
);
return;
} catch (err: any) {
if (err.code !== 'ENOENT') {
throw err; // Propagate unexpected errors
}
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for path to exist: ${pathToCheck}`);
}
await plugins.smartdelay.delayFor(500);
}
}
};
/**
* Checks if a file (not directory) is stable by comparing sizes
* across successive checks.
* @param filePathArg The path of the file to check.
* @returns A promise that resolves once the file stops changing.
*/
const waitForSingleFileToBeStable = async (filePathArg: string): Promise<void> => {
let lastFileSize = -1;
let fileIsStable = false;
const startTime = Date.now();
const fileDir = plugins.path.dirname(filePathArg);
const ensureDirectoryExists = async () => {
while (true) {
try {
// Check if the directory exists
await plugins.smartpromise.fromCallback((cb) =>
plugins.fs.access(fileDir, plugins.fs.constants.R_OK, cb)
);
break; // Exit the loop if the directory exists
} catch (err) {
if (Date.now() - startTime > timeoutMs) {
reject(new Error(`Timeout waiting for directory to exist: ${fileDir}`));
return;
}
// Wait and retry
await plugins.smartdelay.delayFor(500);
}
}
};
// We'll create a helper for repeated stats-checking logic
const checkFileStability = async () => {
try {
const stats = await plugins.smartpromise.fromCallback<plugins.fs.Stats>((cb) =>
plugins.fs.stat(filePathArg, cb)
);
if (stats.isDirectory()) {
// If it unexpectedly turns out to be a directory here, throw
throw new Error(`Expected a file but found a directory: ${filePathArg}`);
}
if (stats.size === lastFileSize) {
fileIsStable = true;
} else {
lastFileSize = stats.size;
fileIsStable = false;
}
} catch (err) {
} catch (err: any) {
// Ignore only if file not found
if (err.code !== 'ENOENT') {
throw err; // Only ignore ENOENT (file not found) errors
throw err;
}
}
};
// Ensure the directory exists before setting up the watcher
await ensureDirectoryExists();
// Ensure file exists first
await ensurePathExists(filePathArg);
const watcher = plugins.fs.watch(filePathArg, { persistent: true }, async () => {
// Set up a watcher on the file itself
const fileWatcher = plugins.fs.watch(filePathArg, { persistent: true }, async () => {
if (!fileIsStable) {
await checkFileStability();
}
});
watcher.on('error', (error) => {
watcher.close();
reject(error);
});
try {
// Poll until stable or timeout
while (!fileIsStable) {
// Check for timeout
if (Date.now() - startTime > timeoutMs) {
watcher.close();
reject(new Error(`Timeout waiting for file to be ready: ${filePathArg}`));
return;
throw new Error(`Timeout waiting for file to stabilize: ${filePathArg}`);
}
// Check file stability
await checkFileStability();
if (!fileIsStable) {
await plugins.smartdelay.delayFor(1000); // Polling interval
await plugins.smartdelay.delayFor(1000);
}
}
watcher.close();
resolve();
} catch (err) {
watcher.close();
reject(err);
} finally {
fileWatcher.close();
}
});
};
/**
* Main logic: check if we have a directory or file at fileOrDirPathArg.
* If directory, wait for first file in the directory to appear and stabilize.
* If file, do the old single-file wait logic.
*/
const statsForGivenPath = await (async () => {
try {
await ensurePathExists(fileOrDirPathArg);
return await plugins.smartpromise.fromCallback<plugins.fs.Stats>((cb) =>
plugins.fs.stat(fileOrDirPathArg, cb)
);
} catch (err) {
// If there's an error (including timeout), just rethrow
throw err;
}
})();
if (!statsForGivenPath.isDirectory()) {
// It's a file just do the single-file stability wait
await waitForSingleFileToBeStable(fileOrDirPathArg);
return;
}
// Otherwise, it's a directory. Wait for the first file inside to appear and be stable
const dirPath = fileOrDirPathArg;
// Helper to find the first file in the directory if it exists
const getFirstFileInDirectory = async (): Promise<string | null> => {
const entries = await plugins.smartpromise.fromCallback<string[]>((cb) =>
plugins.fs.readdir(dirPath, cb)
);
// We only want actual files, not subdirectories
for (const entry of entries) {
const entryPath = plugins.path.join(dirPath, entry);
const entryStats = await plugins.smartpromise.fromCallback<plugins.fs.Stats>((cb) =>
plugins.fs.stat(entryPath, cb)
);
if (entryStats.isFile()) {
return entryPath;
}
}
return null;
};
// Wait for a file to appear in this directory
let firstFilePath = await getFirstFileInDirectory();
if (!firstFilePath) {
// Set up a watcher on the directory to see if a file appears
const directoryWatcher = plugins.fs.watch(dirPath, { persistent: true });
try {
// We'll poll for the existence of a file in that directory
while (!firstFilePath) {
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for a file to appear in directory: ${dirPath}`);
}
firstFilePath = await getFirstFileInDirectory();
if (!firstFilePath) {
await plugins.smartdelay.delayFor(1000);
}
}
} finally {
directoryWatcher.close();
}
}
// Now that we have a file path, wait for that file to stabilize
await waitForSingleFileToBeStable(firstFilePath);
};
/**