@gjsify/fs 0.1.15 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/lib/esm/callback.js +22 -13
  2. package/lib/esm/cp.js +253 -0
  3. package/lib/esm/dir.js +160 -0
  4. package/lib/esm/fd-ops.js +189 -0
  5. package/lib/esm/file-handle.js +263 -84
  6. package/lib/esm/fs-watcher.js +88 -4
  7. package/lib/esm/glob.js +164 -0
  8. package/lib/esm/index.js +128 -2
  9. package/lib/esm/promises.js +90 -27
  10. package/lib/esm/read-stream.js +53 -43
  11. package/lib/esm/stat-watcher.js +121 -0
  12. package/lib/esm/statfs.js +57 -0
  13. package/lib/esm/sync.js +70 -52
  14. package/lib/esm/utils.js +7 -0
  15. package/lib/esm/utimes.js +62 -0
  16. package/lib/esm/write-stream.js +2 -5
  17. package/lib/types/cp.d.ts +18 -0
  18. package/lib/types/cp.spec.d.ts +2 -0
  19. package/lib/types/dir.d.ts +29 -0
  20. package/lib/types/dir.spec.d.ts +2 -0
  21. package/lib/types/fd-ops.d.ts +57 -0
  22. package/lib/types/fd-ops.spec.d.ts +2 -0
  23. package/lib/types/file-handle.d.ts +34 -4
  24. package/lib/types/fs-watcher.d.ts +9 -2
  25. package/lib/types/glob.d.ts +8 -0
  26. package/lib/types/glob.spec.d.ts +2 -0
  27. package/lib/types/index.d.ts +51 -1
  28. package/lib/types/promises.d.ts +31 -4
  29. package/lib/types/read-stream.d.ts +3 -1
  30. package/lib/types/stat-watcher.d.ts +21 -0
  31. package/lib/types/statfs.d.ts +35 -0
  32. package/lib/types/statfs.spec.d.ts +2 -0
  33. package/lib/types/sync.d.ts +4 -7
  34. package/lib/types/utils.d.ts +2 -0
  35. package/lib/types/utimes.d.ts +13 -0
  36. package/lib/types/utimes.spec.d.ts +2 -0
  37. package/lib/types/watch.spec.d.ts +2 -0
  38. package/lib/types/watchfile.spec.d.ts +2 -0
  39. package/lib/types/write-stream.d.ts +1 -2
  40. package/package.json +12 -12
  41. package/src/callback.ts +22 -13
  42. package/src/cp.spec.ts +181 -0
  43. package/src/cp.ts +328 -0
  44. package/src/dir.spec.ts +204 -0
  45. package/src/dir.ts +199 -0
  46. package/src/fd-ops.spec.ts +234 -0
  47. package/src/fd-ops.ts +251 -0
  48. package/src/file-handle.ts +264 -94
  49. package/src/fs-watcher.ts +101 -6
  50. package/src/glob.spec.ts +201 -0
  51. package/src/glob.ts +205 -0
  52. package/src/index.ts +74 -0
  53. package/src/promises.ts +94 -29
  54. package/src/read-stream.ts +49 -43
  55. package/src/stat-watcher.ts +116 -0
  56. package/src/statfs.spec.ts +67 -0
  57. package/src/statfs.ts +92 -0
  58. package/src/streams.spec.ts +58 -0
  59. package/src/sync.ts +75 -57
  60. package/src/test.mts +13 -2
  61. package/src/utils.ts +10 -0
  62. package/src/utimes.spec.ts +113 -0
  63. package/src/utimes.ts +97 -0
  64. package/src/watch.spec.ts +171 -0
  65. package/src/watchfile.spec.ts +185 -0
  66. package/src/write-stream.ts +5 -8
  67. package/tsconfig.tsbuildinfo +1 -1
package/src/statfs.ts ADDED
@@ -0,0 +1,92 @@
1
+ // Reference: Node.js lib/fs.js (fs.statfs / fs.promises.statfs)
2
+ // Reimplemented for GJS using Gio.File.query_filesystem_info
3
+
4
+ import Gio from '@girs/gio-2.0';
5
+ import GLib from '@girs/glib-2.0';
6
+ import { normalizePath } from './utils.js';
7
+
8
+ import type { PathLike } from 'node:fs';
9
+
10
+ const FS_INFO_ATTRS = [
11
+ 'filesystem::size',
12
+ 'filesystem::free',
13
+ ].join(',');
14
+
15
+ // Block size used to derive block counts from byte counts.
16
+ // Gio does not expose the real fs block size; 4096 is a safe default.
17
+ const BSIZE = 4096;
18
+
19
+ export interface StatFsResult {
20
+ type: number;
21
+ bsize: number;
22
+ blocks: number;
23
+ bfree: number;
24
+ bavail: number;
25
+ files: number;
26
+ ffree: number;
27
+ }
28
+
29
+ export interface BigIntStatFsResult {
30
+ type: bigint;
31
+ bsize: bigint;
32
+ blocks: bigint;
33
+ bfree: bigint;
34
+ bavail: bigint;
35
+ files: bigint;
36
+ ffree: bigint;
37
+ }
38
+
39
+ function buildStatFs(info: Gio.FileInfo): StatFsResult {
40
+ const totalBytes = Number(info.get_attribute_uint64('filesystem::size') ?? 0);
41
+ const freeBytes = Number(info.get_attribute_uint64('filesystem::free') ?? 0);
42
+ const blocks = Math.floor(totalBytes / BSIZE);
43
+ const bfree = Math.floor(freeBytes / BSIZE);
44
+ return { type: 0, bsize: BSIZE, blocks, bfree, bavail: bfree, files: 0, ffree: 0 };
45
+ }
46
+
47
+ function buildBigIntStatFs(info: Gio.FileInfo): BigIntStatFsResult {
48
+ const totalBytes = BigInt(info.get_attribute_uint64('filesystem::size') ?? 0);
49
+ const freeBytes = BigInt(info.get_attribute_uint64('filesystem::free') ?? 0);
50
+ const bsize = BigInt(BSIZE);
51
+ const blocks = totalBytes / bsize;
52
+ const bfree = freeBytes / bsize;
53
+ return { type: 0n, bsize, blocks, bfree, bavail: bfree, files: 0n, ffree: 0n };
54
+ }
55
+
56
+ export function statfsSync(path: PathLike, options?: { bigint?: false }): StatFsResult;
57
+ export function statfsSync(path: PathLike, options: { bigint: true }): BigIntStatFsResult;
58
+ export function statfsSync(path: PathLike, options?: { bigint?: boolean }): StatFsResult | BigIntStatFsResult {
59
+ const file = Gio.File.new_for_path(normalizePath(path));
60
+ const info = file.query_filesystem_info(FS_INFO_ATTRS, null);
61
+ return options?.bigint === true ? buildBigIntStatFs(info) : buildStatFs(info);
62
+ }
63
+
64
+ function queryFsInfoAsync(path: PathLike, useBigInt: boolean): Promise<StatFsResult | BigIntStatFsResult> {
65
+ return new Promise((resolve, reject) => {
66
+ const file = Gio.File.new_for_path(normalizePath(path));
67
+ file.query_filesystem_info_async(FS_INFO_ATTRS, GLib.PRIORITY_DEFAULT, null, (_s: unknown, res: Gio.AsyncResult) => {
68
+ try {
69
+ const info = file.query_filesystem_info_finish(res);
70
+ resolve(useBigInt ? buildBigIntStatFs(info) : buildStatFs(info));
71
+ } catch (err) {
72
+ reject(err);
73
+ }
74
+ });
75
+ });
76
+ }
77
+
78
+ export function statfs(path: PathLike, callback: (err: NodeJS.ErrnoException | null, stats: StatFsResult) => void): void;
79
+ export function statfs(path: PathLike, options: { bigint?: false }, callback: (err: NodeJS.ErrnoException | null, stats: StatFsResult) => void): void;
80
+ export function statfs(path: PathLike, options: { bigint: true }, callback: (err: NodeJS.ErrnoException | null, stats: BigIntStatFsResult) => void): void;
81
+ export function statfs(path: PathLike, optionsOrCb: any, callback?: any): void {
82
+ if (typeof optionsOrCb === 'function') {
83
+ callback = optionsOrCb;
84
+ optionsOrCb = {};
85
+ }
86
+ const useBigInt = optionsOrCb?.bigint === true;
87
+ queryFsInfoAsync(path, useBigInt).then(result => callback(null, result), err => callback(err, null));
88
+ }
89
+
90
+ export async function statfsAsync(path: PathLike, options?: { bigint?: boolean }): Promise<StatFsResult | BigIntStatFsResult> {
91
+ return queryFsInfoAsync(path, options?.bigint === true);
92
+ }
@@ -159,6 +159,64 @@ export default async () => {
159
159
  cleanup();
160
160
  }
161
161
  });
162
+
163
+ // Regression: ReadStream uses _construct() for async file opening.
164
+ // Verifies that 'open' and 'ready' fire and that pending starts as true.
165
+ await it('should emit open and ready events before data, pending starts true', async () => {
166
+ setup();
167
+ try {
168
+ const filePath = join(tmpDir, 'open-ready.txt');
169
+ writeFileSync(filePath, 'hello');
170
+ const stream = createReadStream(filePath);
171
+ expect(stream.pending).toBeTruthy();
172
+ const events: string[] = [];
173
+ let openFd: number | undefined;
174
+ await new Promise<void>((resolve, reject) => {
175
+ stream.on('open', (fd: number) => { openFd = fd; events.push('open'); });
176
+ stream.on('ready', () => events.push('ready'));
177
+ stream.on('data', () => { if (!events.includes('data')) events.push('data'); });
178
+ stream.on('end', () => resolve());
179
+ stream.on('error', reject);
180
+ stream.resume();
181
+ });
182
+ expect(openFd).toBeDefined();
183
+ // 'open' and 'ready' must have fired (file was opened)
184
+ expect(events).toContain('open');
185
+ expect(events).toContain('ready');
186
+ // 'open' must fire before any data event
187
+ const openIdx = events.indexOf('open');
188
+ const dataIdx = events.indexOf('data');
189
+ if (dataIdx !== -1) expect(openIdx).toBeLessThan(dataIdx);
190
+ } finally {
191
+ cleanup();
192
+ }
193
+ });
194
+
195
+ // Regression: piping a ReadStream before the file is open (while pending=true)
196
+ // must still deliver all data. Previously _pendingReadSize approach hung under
197
+ // backpressure on GJS 1.88; _construct() fixes this properly.
198
+ await it('should pipe correctly when pipe() is called before file opens', async () => {
199
+ setup();
200
+ try {
201
+ const srcPath = join(tmpDir, 'pre-pipe-src.txt');
202
+ const dstPath = join(tmpDir, 'pre-pipe-dst.txt');
203
+ const size = 3 * 64 * 1024; // 3 × 64KB — forces multiple read chunks + backpressure
204
+ writeFileSync(srcPath, 'X'.repeat(size));
205
+ await new Promise<void>((resolve, reject) => {
206
+ const src = createReadStream(srcPath);
207
+ const dst = createWriteStream(dstPath);
208
+ // pipe() is called immediately — src is still pending (file not open yet)
209
+ expect(src.pending).toBeTruthy();
210
+ src.on('error', reject);
211
+ dst.on('error', reject);
212
+ dst.on('finish', resolve);
213
+ src.pipe(dst);
214
+ });
215
+ expect(readFileSync(dstPath, 'utf8').length).toBe(size);
216
+ } finally {
217
+ cleanup();
218
+ }
219
+ });
162
220
  });
163
221
 
164
222
  // ---- createWriteStream ----
package/src/sync.ts CHANGED
@@ -13,7 +13,7 @@ import { FileHandle } from './file-handle.js';
13
13
  import { Dirent } from './dirent.js';
14
14
  import { Stats, BigIntStats, STAT_ATTRIBUTES } from './stats.js';
15
15
  import { createNodeError, isNotFoundError } from './errors.js';
16
- import { tempDirPath } from './utils.js';
16
+ import { tempDirPath, normalizePath } from './utils.js';
17
17
 
18
18
  import type { OpenFlags, EncodingOption } from './types/index.js';
19
19
  import type {
@@ -31,24 +31,26 @@ export { existsSync }
31
31
  // --- stat / lstat ---
32
32
 
33
33
  export function statSync(path: PathLike, options?: StatSyncOptions): Stats | BigIntStats | undefined {
34
+ const pathStr = normalizePath(path);
34
35
  try {
35
- const file = Gio.File.new_for_path(path.toString());
36
+ const file = Gio.File.new_for_path(pathStr);
36
37
  const info = file.query_info(STAT_ATTRIBUTES, Gio.FileQueryInfoFlags.NONE, null);
37
- return options?.bigint ? new BigIntStats(info, path) : new Stats(info, path);
38
+ return options?.bigint ? new BigIntStats(info, pathStr) : new Stats(info, pathStr);
38
39
  } catch (err: unknown) {
39
40
  if (options?.throwIfNoEntry === false && isNotFoundError(err)) return undefined;
40
- throw createNodeError(err, 'stat', path);
41
+ throw createNodeError(err, 'stat', pathStr);
41
42
  }
42
43
  }
43
44
 
44
45
  export function lstatSync(path: PathLike, options?: StatSyncOptions): Stats | BigIntStats | undefined {
46
+ const pathStr = normalizePath(path);
45
47
  try {
46
- const file = Gio.File.new_for_path(path.toString());
48
+ const file = Gio.File.new_for_path(pathStr);
47
49
  const info = file.query_info(STAT_ATTRIBUTES, Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
48
- return options?.bigint ? new BigIntStats(info, path) : new Stats(info, path);
50
+ return options?.bigint ? new BigIntStats(info, pathStr) : new Stats(info, pathStr);
49
51
  } catch (err: unknown) {
50
52
  if (options?.throwIfNoEntry === false && isNotFoundError(err)) return undefined;
51
- throw createNodeError(err, 'lstat', path);
53
+ throw createNodeError(err, 'lstat', pathStr);
52
54
  }
53
55
  }
54
56
 
@@ -58,7 +60,7 @@ export function readdirSync(
58
60
  path: PathLike,
59
61
  options?: { withFileTypes?: boolean; encoding?: string; recursive?: boolean }
60
62
  ): string[] | Dirent[] {
61
- const pathStr = path.toString();
63
+ const pathStr = normalizePath(path);
62
64
  const file = Gio.File.new_for_path(pathStr);
63
65
  const enumerator = file.enumerate_children(
64
66
  'standard::name,standard::type',
@@ -101,7 +103,8 @@ export function readdirSync(
101
103
  const MAX_SYMLINK_DEPTH = 40; // matches Linux MAXSYMLINKS
102
104
 
103
105
  export function realpathSync(path: PathLike): string {
104
- let current = Gio.File.new_for_path(path.toString());
106
+ const pathStr = normalizePath(path);
107
+ let current = Gio.File.new_for_path(pathStr);
105
108
  let depth = 0;
106
109
 
107
110
  while (true) {
@@ -120,7 +123,7 @@ export function realpathSync(path: PathLike): string {
120
123
  current = parent ? parent.resolve_relative_path(target) : Gio.File.new_for_path(target);
121
124
 
122
125
  if (++depth > MAX_SYMLINK_DEPTH) {
123
- throw new Error(`ELOOP: too many levels of symbolic links, realpath '${path}'`);
126
+ throw new Error(`ELOOP: too many levels of symbolic links, realpath '${pathStr}'`);
124
127
  }
125
128
  }
126
129
  }
@@ -129,24 +132,27 @@ export function realpathSync(path: PathLike): string {
129
132
  // --- symlink ---
130
133
 
131
134
  export function symlinkSync(target: PathLike, path: PathLike, _type?: 'file' | 'dir' | 'junction'): void {
132
- const file = Gio.File.new_for_path(path.toString());
133
- file.make_symbolic_link(target.toString(), null);
135
+ const pathStr = normalizePath(path);
136
+ const targetStr = normalizePath(target);
137
+ const file = Gio.File.new_for_path(pathStr);
138
+ file.make_symbolic_link(targetStr, null);
134
139
  }
135
140
 
136
- export function readFileSync(path: string, options = { encoding: null, flag: 'r' }) {
137
- const file = Gio.File.new_for_path(path);
141
+ export function readFileSync(path: PathLike, options: any = { encoding: null, flag: 'r' }) {
142
+ const pathStr = normalizePath(path);
143
+ const file = Gio.File.new_for_path(pathStr);
138
144
 
139
145
  try {
140
146
  const [ok, data] = file.load_contents(null);
141
147
 
142
148
  if (!ok) {
143
- throw createNodeError(new Error('failed to read file'), 'read', path);
149
+ throw createNodeError(new Error('failed to read file'), 'read', pathStr);
144
150
  }
145
151
 
146
152
  return encodeUint8Array(getEncodingFromOptions(options, "buffer"), data);
147
153
  } catch (err: unknown) {
148
154
  if ((err as { code?: unknown }).code && typeof (err as { code?: unknown }).code === 'string') throw err; // Already a Node error
149
- throw createNodeError(err, 'read', path);
155
+ throw createNodeError(err, 'read', pathStr);
150
156
  }
151
157
  }
152
158
 
@@ -203,9 +209,7 @@ export function mkdirSync(path: PathLike, options?: Mode | MakeDirectoryOptions
203
209
  mode = options || 0o777;
204
210
  }
205
211
 
206
- if (typeof path !== 'string') {
207
- path = path.toString();
208
- }
212
+ path = normalizePath(path);
209
213
 
210
214
  if(typeof mode === 'string') {
211
215
  throw new TypeError("mode as string is currently not supported!");
@@ -275,57 +279,63 @@ function mkdirSyncRecursive(pathStr: string, mode: number): string | undefined {
275
279
  * @since v0.1.21
276
280
  */
277
281
  export function rmdirSync(path: PathLike, _options?: RmDirOptions): void {
278
- const file = Gio.File.new_for_path(path.toString());
282
+ const pathStr = normalizePath(path);
283
+ const file = Gio.File.new_for_path(pathStr);
279
284
  try {
280
285
  // Check if it's a directory
281
286
  const info = file.query_info('standard::type', Gio.FileQueryInfoFlags.NONE, null);
282
287
  if (info.get_file_type() !== Gio.FileType.DIRECTORY) {
283
288
  const err = Object.assign(new Error(), { code: 4 }); // Gio.IOErrorEnum.NOT_DIRECTORY
284
- throw createNodeError(err, 'rmdir', path);
289
+ throw createNodeError(err, 'rmdir', pathStr);
285
290
  }
286
291
  // Check if empty — rmdir only removes empty directories (use rmSync for recursive)
287
292
  const enumerator = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null);
288
293
  if (enumerator.next_file(null) !== null) {
289
294
  const err = Object.assign(new Error(), { code: 5 }); // Gio.IOErrorEnum.NOT_EMPTY
290
- throw createNodeError(err, 'rmdir', path);
295
+ throw createNodeError(err, 'rmdir', pathStr);
291
296
  }
292
297
  file.delete(null);
293
298
  } catch (err: unknown) {
294
299
  if ((err as { code?: unknown }).code && typeof (err as { code?: unknown }).code === 'string') throw err; // Already a Node error
295
- throw createNodeError(err, 'rmdir', path);
300
+ throw createNodeError(err, 'rmdir', pathStr);
296
301
  }
297
302
  }
298
303
 
299
304
  export function unlinkSync(path: PathLike): void {
300
- const file = Gio.File.new_for_path(path.toString());
305
+ const pathStr = normalizePath(path);
306
+ const file = Gio.File.new_for_path(pathStr);
301
307
  try {
302
308
  file.delete(null);
303
309
  } catch (err: unknown) {
304
- throw createNodeError(err, 'unlink', path);
310
+ throw createNodeError(err, 'unlink', pathStr);
305
311
  }
306
312
  }
307
313
 
308
- export function writeFileSync(path: string, data: string | Uint8Array) {
309
- GLib.file_set_contents(path, data);
314
+ export function writeFileSync(path: PathLike, data: string | Uint8Array) {
315
+ GLib.file_set_contents(normalizePath(path), data);
310
316
  }
311
317
 
312
318
  // --- rename ---
313
319
 
314
320
  export function renameSync(oldPath: PathLike, newPath: PathLike): void {
315
- const src = Gio.File.new_for_path(oldPath.toString());
316
- const dest = Gio.File.new_for_path(newPath.toString());
321
+ const oldStr = normalizePath(oldPath);
322
+ const newStr = normalizePath(newPath);
323
+ const src = Gio.File.new_for_path(oldStr);
324
+ const dest = Gio.File.new_for_path(newStr);
317
325
  try {
318
326
  src.move(dest, Gio.FileCopyFlags.OVERWRITE, null, null);
319
327
  } catch (err: unknown) {
320
- throw createNodeError(err, 'rename', oldPath, newPath);
328
+ throw createNodeError(err, 'rename', oldStr, newStr);
321
329
  }
322
330
  }
323
331
 
324
332
  // --- copyFile ---
325
333
 
326
334
  export function copyFileSync(src: PathLike, dest: PathLike, mode?: number): void {
327
- const srcFile = Gio.File.new_for_path(src.toString());
328
- const destFile = Gio.File.new_for_path(dest.toString());
335
+ const srcStr = normalizePath(src);
336
+ const destStr = normalizePath(dest);
337
+ const srcFile = Gio.File.new_for_path(srcStr);
338
+ const destFile = Gio.File.new_for_path(destStr);
329
339
  let flags = Gio.FileCopyFlags.NONE;
330
340
  // mode 0 = default (overwrite), COPYFILE_EXCL (1) = no overwrite
331
341
  if (mode && (mode & 1) === 0) {
@@ -336,40 +346,42 @@ export function copyFileSync(src: PathLike, dest: PathLike, mode?: number): void
336
346
  try {
337
347
  srcFile.copy(destFile, flags, null, null);
338
348
  } catch (err: unknown) {
339
- throw createNodeError(err, 'copyfile', src, dest);
349
+ throw createNodeError(err, 'copyfile', srcStr, destStr);
340
350
  }
341
351
  }
342
352
 
343
353
  // --- access ---
344
354
 
345
355
  export function accessSync(path: PathLike, mode?: number): void {
346
- const file = Gio.File.new_for_path(path.toString());
356
+ const pathStr = normalizePath(path);
357
+ const file = Gio.File.new_for_path(pathStr);
347
358
  try {
348
359
  const info = file.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
349
360
  // mode: F_OK=0, R_OK=4, W_OK=2, X_OK=1
350
361
  if (mode !== undefined && mode !== 0) {
351
362
  // Gio.IOErrorEnum.PERMISSION_DENIED = 14 → maps to EACCES via createNodeError
352
- const permErr = { code: 14, message: `permission denied, access '${path}'` };
363
+ const permErr = { code: 14, message: `permission denied, access '${pathStr}'` };
353
364
  if ((mode & 4) && !info.get_attribute_boolean('access::can-read')) {
354
- throw createNodeError(permErr, 'access', path);
365
+ throw createNodeError(permErr, 'access', pathStr);
355
366
  }
356
367
  if ((mode & 2) && !info.get_attribute_boolean('access::can-write')) {
357
- throw createNodeError(permErr, 'access', path);
368
+ throw createNodeError(permErr, 'access', pathStr);
358
369
  }
359
370
  if ((mode & 1) && !info.get_attribute_boolean('access::can-execute')) {
360
- throw createNodeError(permErr, 'access', path);
371
+ throw createNodeError(permErr, 'access', pathStr);
361
372
  }
362
373
  }
363
374
  } catch (err: unknown) {
364
375
  if ((err as { code?: unknown }).code && typeof (err as { code?: unknown }).code === 'string') throw err; // Already a Node-style error
365
- throw createNodeError(err, 'access', path);
376
+ throw createNodeError(err, 'access', pathStr);
366
377
  }
367
378
  }
368
379
 
369
380
  // --- appendFile ---
370
381
 
371
382
  export function appendFileSync(path: PathLike, data: string | Uint8Array, options?: { encoding?: string; mode?: number; flag?: string } | string): void {
372
- const file = Gio.File.new_for_path(path.toString());
383
+ const pathStr = normalizePath(path);
384
+ const file = Gio.File.new_for_path(pathStr);
373
385
  let bytes: Uint8Array;
374
386
  if (typeof data === 'string') {
375
387
  bytes = new TextEncoder().encode(data);
@@ -384,19 +396,20 @@ export function appendFileSync(path: PathLike, data: string | Uint8Array, option
384
396
  }
385
397
  stream.close(null);
386
398
  } catch (err: unknown) {
387
- throw createNodeError(err, 'appendfile', path);
399
+ throw createNodeError(err, 'appendfile', pathStr);
388
400
  }
389
401
  }
390
402
 
391
403
  // --- readlink ---
392
404
 
393
405
  export function readlinkSync(path: PathLike, options?: { encoding?: string } | string): string | Buffer {
394
- const file = Gio.File.new_for_path(path.toString());
406
+ const pathStr = normalizePath(path);
407
+ const file = Gio.File.new_for_path(pathStr);
395
408
  try {
396
409
  const info = file.query_info('standard::symlink-target', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
397
410
  const target = info.get_symlink_target();
398
411
  if (!target) {
399
- throw Object.assign(new Error(`EINVAL: invalid argument, readlink '${path}'`), { code: 'EINVAL', errno: -22, syscall: 'readlink', path: path.toString() });
412
+ throw Object.assign(new Error(`EINVAL: invalid argument, readlink '${pathStr}'`), { code: 'EINVAL', errno: -22, syscall: 'readlink', path: pathStr });
400
413
  }
401
414
  const encoding = typeof options === 'string' ? options : options?.encoding;
402
415
  if (encoding === 'buffer') {
@@ -405,24 +418,27 @@ export function readlinkSync(path: PathLike, options?: { encoding?: string } | s
405
418
  return target;
406
419
  } catch (err: unknown) {
407
420
  if (typeof (err as { code?: unknown }).code === 'string') throw err;
408
- throw createNodeError(err, 'readlink', path);
421
+ throw createNodeError(err, 'readlink', pathStr);
409
422
  }
410
423
  }
411
424
 
412
425
  // --- link ---
413
426
 
414
427
  export function linkSync(existingPath: PathLike, newPath: PathLike): void {
428
+ const existingStr = normalizePath(existingPath);
429
+ const newStr = normalizePath(newPath);
415
430
  // Gio doesn't have a direct hard link API, use GLib
416
- const result = GLib.spawn_command_line_sync(`ln ${existingPath.toString()} ${newPath.toString()}`);
431
+ const result = GLib.spawn_command_line_sync(`ln ${existingStr} ${newStr}`);
417
432
  if (!result[0]) {
418
- throw Object.assign(new Error(`EPERM: operation not permitted, link '${existingPath}' -> '${newPath}'`), { code: 'EPERM', errno: -1, syscall: 'link', path: existingPath.toString(), dest: newPath.toString() });
433
+ throw Object.assign(new Error(`EPERM: operation not permitted, link '${existingStr}' -> '${newStr}'`), { code: 'EPERM', errno: -1, syscall: 'link', path: existingStr, dest: newStr });
419
434
  }
420
435
  }
421
436
 
422
437
  // --- truncate ---
423
438
 
424
439
  export function truncateSync(path: PathLike, len?: number): void {
425
- const file = Gio.File.new_for_path(path.toString());
440
+ const pathStr = normalizePath(path);
441
+ const file = Gio.File.new_for_path(pathStr);
426
442
  try {
427
443
  const stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null);
428
444
  if (len && len > 0) {
@@ -435,35 +451,37 @@ export function truncateSync(path: PathLike, len?: number): void {
435
451
  }
436
452
  stream.close(null);
437
453
  } catch (err: unknown) {
438
- throw createNodeError(err, 'truncate', path);
454
+ throw createNodeError(err, 'truncate', pathStr);
439
455
  }
440
456
  }
441
457
 
442
458
  // --- chmodSync ---
443
459
 
444
460
  export function chmodSync(path: PathLike, mode: Mode): void {
461
+ const pathStr = normalizePath(path);
445
462
  const modeNum = typeof mode === 'string' ? parseInt(mode, 8) : mode;
446
- const result = GLib.spawn_command_line_sync(`chmod ${modeNum.toString(8)} ${path.toString()}`);
463
+ const result = GLib.spawn_command_line_sync(`chmod ${modeNum.toString(8)} ${pathStr}`);
447
464
  if (!result[0]) {
448
- throw Object.assign(new Error(`EPERM: operation not permitted, chmod '${path}'`), { code: 'EPERM', errno: -1, syscall: 'chmod', path: path.toString() });
465
+ throw Object.assign(new Error(`EPERM: operation not permitted, chmod '${pathStr}'`), { code: 'EPERM', errno: -1, syscall: 'chmod', path: pathStr });
449
466
  }
450
467
  }
451
468
 
452
469
  // --- chownSync ---
453
470
 
454
471
  export function chownSync(path: PathLike, uid: number, gid: number): void {
455
- const result = GLib.spawn_command_line_sync(`chown ${uid}:${gid} ${path.toString()}`);
472
+ const pathStr = normalizePath(path);
473
+ const result = GLib.spawn_command_line_sync(`chown ${uid}:${gid} ${pathStr}`);
456
474
  if (!result[0]) {
457
- throw Object.assign(new Error(`EPERM: operation not permitted, chown '${path}'`), { code: 'EPERM', errno: -1, syscall: 'chown', path: path.toString() });
475
+ throw Object.assign(new Error(`EPERM: operation not permitted, chown '${pathStr}'`), { code: 'EPERM', errno: -1, syscall: 'chown', path: pathStr });
458
476
  }
459
477
  }
460
478
 
461
- export function watch(filename: string, options: { persistent?: boolean; recursive?: boolean; encoding?: string } | undefined, listener: ((eventType: string, filename: string | null) => void) | undefined) {
462
- return new FSWatcher(filename, options, listener);
479
+ export function watch(filename: PathLike, options: { persistent?: boolean; recursive?: boolean; encoding?: string } | undefined, listener: ((eventType: string, filename: string | null) => void) | undefined) {
480
+ return new FSWatcher(normalizePath(filename), options, listener);
463
481
  }
464
482
 
465
- export function openSync(path: PathLike, flags?: OpenFlags, mode?: Mode): FileHandle {
466
- return new FileHandle({ path, flags, mode });
483
+ export function openSync(path: PathLike, flags?: OpenFlags | number, mode?: Mode): FileHandle {
484
+ return new FileHandle({ path, flags: flags as OpenFlags | undefined, mode });
467
485
  }
468
486
 
469
487
  /**
@@ -507,7 +525,7 @@ export function mkdtempSync(prefix: string, options?: EncodingOption | BufferEnc
507
525
  * @since v14.14.0
508
526
  */
509
527
  export function rmSync(path: PathLike, options?: RmOptions): void {
510
- const pathStr = path.toString();
528
+ const pathStr = normalizePath(path);
511
529
  const file = Gio.File.new_for_path(pathStr);
512
530
  const recursive = options?.recursive || false;
513
531
  const force = options?.force || false;
package/src/test.mts CHANGED
@@ -1,4 +1,7 @@
1
- import '@gjsify/node-globals/register';
1
+ import '@gjsify/node-globals/register/process';
2
+ import '@gjsify/node-globals/register/buffer';
3
+ import '@gjsify/node-globals/register/timers';
4
+ import '@gjsify/node-globals/register/url';
2
5
  import { run } from '@gjsify/unit';
3
6
 
4
7
  import testSuiteCallback from './callback.spec.js';
@@ -12,5 +15,13 @@ import testSuiteExtended from './extended.spec.js';
12
15
 
13
16
  import testSuiteErrors from './errors.spec.js';
14
17
  import testSuiteStreams from './streams.spec.js';
18
+ import testSuiteCp from './cp.spec.js';
19
+ import testSuiteDir from './dir.spec.js';
20
+ import testSuiteGlob from './glob.spec.js';
21
+ import testSuiteWatch from './watch.spec.js';
22
+ import testSuiteWatchFile from './watchfile.spec.js';
23
+ import testSuiteStatFs from './statfs.spec.js';
24
+ import testSuiteUtimes from './utimes.spec.js';
25
+ import testSuiteFdOps from './fd-ops.spec.js';
15
26
 
16
- run({testSuiteCallback, testSuiteFileHandle, testSuitePromise, testSuiteSync, testSuiteSymlink, testSuiteStat, testSuiteNewApis, testSuiteExtended, testSuiteErrors, testSuiteStreams});
27
+ run({testSuiteCallback, testSuiteFileHandle, testSuitePromise, testSuiteSync, testSuiteSymlink, testSuiteStat, testSuiteNewApis, testSuiteExtended, testSuiteErrors, testSuiteStreams, testSuiteCp, testSuiteDir, testSuiteGlob, testSuiteWatch, testSuiteWatchFile, testSuiteStatFs, testSuiteUtimes, testSuiteFdOps});
package/src/utils.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  // Shared filesystem utilities for GJS — original implementation using Gio
2
2
 
3
3
  import { existsSync } from './sync.js';
4
+ import { fileURLToPath, URL as NodeURL } from 'node:url';
5
+
6
+ import type { PathLike } from 'node:fs';
7
+
8
+ // Gio.File.new_for_path only accepts strings; convert URL/Buffer accordingly.
9
+ export function normalizePath(path: PathLike): string {
10
+ if (path instanceof URL || path instanceof NodeURL) return fileURLToPath(path as URL);
11
+ if (typeof path === 'string') return path;
12
+ return (path as Buffer).toString();
13
+ }
4
14
 
5
15
  const CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
6
16
 
@@ -0,0 +1,113 @@
1
+ // Ported from refs/node-test/parallel/test-fs-utimes.js (behavior)
2
+ // Original: MIT, Node.js contributors.
3
+ // Rewritten for @gjsify/unit — behavior preserved, assertion dialect adapted.
4
+
5
+ import { describe, it, expect } from '@gjsify/unit';
6
+ import { utimesSync, utimes, lutimesSync, lchownSync, promises } from 'node:fs';
7
+ import { statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ const TMP = tmpdir();
12
+
13
+ function tmpFile(name: string): string {
14
+ const p = join(TMP, `gjsify-utimes-${name}-${process.pid}`);
15
+ writeFileSync(p, 'test');
16
+ return p;
17
+ }
18
+
19
+ export default async () => {
20
+ await describe('fs.utimes / fs.lutimes / fs.lchown / fs.lchmod', async () => {
21
+ await it('utimesSync sets mtime', async () => {
22
+ const f = tmpFile('mtime');
23
+ const mtime = new Date('2020-01-01T00:00:00Z');
24
+ utimesSync(f, mtime, mtime);
25
+ const stat = statSync(f);
26
+ expect(stat.mtime.getFullYear()).toBe(2020);
27
+ unlinkSync(f);
28
+ });
29
+
30
+ await it('utimesSync sets atime', async () => {
31
+ const f = tmpFile('atime');
32
+ const atime = new Date('2021-06-15T12:00:00Z');
33
+ const mtime = new Date('2020-01-01T00:00:00Z');
34
+ utimesSync(f, atime, mtime);
35
+ const stat = statSync(f);
36
+ expect(stat.mtime.getFullYear()).toBe(2020);
37
+ unlinkSync(f);
38
+ });
39
+
40
+ await it('utimes callback sets timestamps', async () => {
41
+ const f = tmpFile('cb');
42
+ const mtime = new Date('2019-03-10T00:00:00Z');
43
+ await new Promise<void>((resolve, reject) => {
44
+ utimes(f, mtime, mtime, (err) => {
45
+ if (err) reject(err);
46
+ else resolve();
47
+ });
48
+ });
49
+ const stat = statSync(f);
50
+ expect(stat.mtime.getFullYear()).toBe(2019);
51
+ unlinkSync(f);
52
+ });
53
+
54
+ await it('promises.utimes sets timestamps', async () => {
55
+ const f = tmpFile('promise');
56
+ const mtime = new Date('2018-08-01T00:00:00Z');
57
+ await promises.utimes(f, mtime, mtime);
58
+ const stat = statSync(f);
59
+ expect(stat.mtime.getFullYear()).toBe(2018);
60
+ unlinkSync(f);
61
+ });
62
+
63
+ await it('lutimesSync does not throw on a symlink', async () => {
64
+ const target = tmpFile('lutime-target');
65
+ const link = join(TMP, `gjsify-lutime-link-${process.pid}`);
66
+ try { unlinkSync(link); } catch {}
67
+ symlinkSync(target, link);
68
+ const mtime = new Date('2017-05-20T00:00:00Z');
69
+ // Just verify the call completes without throwing
70
+ expect(() => lutimesSync(link, mtime, mtime)).not.toThrow();
71
+ unlinkSync(link);
72
+ unlinkSync(target);
73
+ });
74
+
75
+ await it('lutimes callback completes without error', async () => {
76
+ const f = tmpFile('lutimes-cb');
77
+ const mtime = new Date('2016-01-01T00:00:00Z');
78
+ const { lutimes } = await import('node:fs') as any;
79
+ await new Promise<void>((resolve, reject) => {
80
+ lutimes(f, mtime, mtime, (err: any) => {
81
+ if (err) reject(err);
82
+ else resolve();
83
+ });
84
+ });
85
+ unlinkSync(f);
86
+ });
87
+
88
+ await it('lchownSync does not throw (may need root to actually change)', async () => {
89
+ const f = tmpFile('lchown');
90
+ const link = join(TMP, `gjsify-lchown-link-${process.pid}`);
91
+ try { unlinkSync(link); } catch {}
92
+ symlinkSync(f, link);
93
+ // This will only actually change owner if running as root; just verify no throw
94
+ try {
95
+ lchownSync(link, process.getuid ? process.getuid() : 0, process.getgid ? process.getgid() : 0);
96
+ } catch {
97
+ // acceptable if kernel denies (EPERM) — just must not crash the process
98
+ }
99
+ unlinkSync(link);
100
+ unlinkSync(f);
101
+ });
102
+
103
+ await it('lchmod is a no-op (does not throw)', async () => {
104
+ const f = tmpFile('lchmod');
105
+ // Node.js removed lchmod on Linux; on GJS we export it as a no-op
106
+ const fsModule = await import('node:fs') as any;
107
+ if (typeof fsModule.lchmod === 'function') {
108
+ expect(() => fsModule.lchmod(f, 0o644, () => {})).not.toThrow();
109
+ }
110
+ unlinkSync(f);
111
+ });
112
+ });
113
+ };