@outfitter/file-ops 0.1.0-rc.1

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.
package/README.md ADDED
@@ -0,0 +1,365 @@
1
+ # @outfitter/file-ops
2
+
3
+ Workspace detection, secure path handling, glob patterns, file locking, and atomic write utilities for Outfitter projects.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @outfitter/file-ops
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import {
15
+ findWorkspaceRoot,
16
+ securePath,
17
+ glob,
18
+ withLock,
19
+ atomicWrite
20
+ } from "@outfitter/file-ops";
21
+
22
+ // Find workspace root by marker files (.git, package.json)
23
+ const rootResult = await findWorkspaceRoot(process.cwd());
24
+ if (rootResult.isOk()) {
25
+ const root = rootResult.value;
26
+
27
+ // Secure path resolution (prevents traversal attacks)
28
+ const pathResult = securePath("src/config.json", root);
29
+ if (pathResult.isOk()) {
30
+ console.log("Safe path:", pathResult.value);
31
+ }
32
+ }
33
+
34
+ // Find files with glob patterns
35
+ const files = await glob("**/*.ts", {
36
+ cwd: "/project",
37
+ ignore: ["node_modules/**", "**/*.test.ts"]
38
+ });
39
+
40
+ // Atomic write with file locking
41
+ await withLock("/path/to/file.json", async () => {
42
+ await atomicWrite("/path/to/file.json", JSON.stringify(data));
43
+ });
44
+ ```
45
+
46
+ ## API Reference
47
+
48
+ ### Workspace Detection
49
+
50
+ #### `findWorkspaceRoot(startPath, options?)`
51
+
52
+ Finds the workspace root by searching for marker files/directories.
53
+
54
+ ```typescript
55
+ const result = await findWorkspaceRoot("/project/src/lib");
56
+ if (result.isOk()) {
57
+ console.log("Workspace:", result.value); // "/project"
58
+ }
59
+ ```
60
+
61
+ **Options:**
62
+
63
+ | Option | Type | Default | Description |
64
+ |--------|------|---------|-------------|
65
+ | `markers` | `string[]` | `[".git", "package.json"]` | Marker files/directories to search for |
66
+ | `stopAt` | `string` | filesystem root | Stop searching at this directory |
67
+
68
+ ```typescript
69
+ // Custom markers for Rust or Python projects
70
+ const result = await findWorkspaceRoot(startPath, {
71
+ markers: ["Cargo.toml", "pyproject.toml"],
72
+ stopAt: "/home/user"
73
+ });
74
+ ```
75
+
76
+ #### `getRelativePath(absolutePath)`
77
+
78
+ Returns the path relative to the workspace root.
79
+
80
+ ```typescript
81
+ const result = await getRelativePath("/project/src/lib/utils.ts");
82
+ if (result.isOk()) {
83
+ console.log(result.value); // "src/lib/utils.ts"
84
+ }
85
+ ```
86
+
87
+ #### `isInsideWorkspace(path, workspaceRoot)`
88
+
89
+ Checks if a path is inside a workspace directory.
90
+
91
+ ```typescript
92
+ const inside = await isInsideWorkspace("/project/src/file.ts", "/project");
93
+ console.log(inside); // true
94
+
95
+ const outside = await isInsideWorkspace("/etc/passwd", "/project");
96
+ console.log(outside); // false
97
+ ```
98
+
99
+ ### Path Security
100
+
101
+ **IMPORTANT**: These functions protect against path traversal attacks. Always use them when handling user-provided paths.
102
+
103
+ #### Security Model
104
+
105
+ | Attack Vector | Protection |
106
+ |--------------|------------|
107
+ | Path traversal (`../`) | Blocked by all security functions |
108
+ | Null bytes (`\x00`) | Rejected immediately |
109
+ | Absolute paths | Blocked when relative expected |
110
+ | Escape from base directory | Defense-in-depth verification |
111
+
112
+ #### `securePath(path, basePath)`
113
+
114
+ Validates and secures a user-provided path, preventing path traversal attacks.
115
+
116
+ ```typescript
117
+ // SAFE: Validates path stays within basePath
118
+ const result = securePath("data/file.json", "/app/workspace");
119
+ if (result.isOk()) {
120
+ // Safe to use: /app/workspace/data/file.json
121
+ console.log(result.value);
122
+ }
123
+
124
+ // These all return ValidationError:
125
+ securePath("../etc/passwd", base); // Traversal sequence
126
+ securePath("/etc/passwd", base); // Absolute path
127
+ securePath("file\x00.txt", base); // Null byte
128
+ ```
129
+
130
+ **UNSAFE pattern - never do this:**
131
+
132
+ ```typescript
133
+ // DON'T: User input directly in path.join
134
+ const bad = path.join("/base", userInput); // VULNERABLE!
135
+
136
+ // DO: Always validate with securePath first
137
+ const result = securePath(userInput, "/base");
138
+ if (result.isOk()) {
139
+ // Now safe to use
140
+ }
141
+ ```
142
+
143
+ #### `isPathSafe(path, basePath)`
144
+
145
+ Quick boolean check for path safety.
146
+
147
+ ```typescript
148
+ if (isPathSafe(userInput, basePath)) {
149
+ // Safe to proceed
150
+ }
151
+ ```
152
+
153
+ #### `resolveSafePath(basePath, ...segments)`
154
+
155
+ Safely joins multiple path segments.
156
+
157
+ ```typescript
158
+ const result = resolveSafePath("/app", "data", "users", "profile.json");
159
+ if (result.isOk()) {
160
+ console.log(result.value); // "/app/data/users/profile.json"
161
+ }
162
+
163
+ // Rejects dangerous segments
164
+ resolveSafePath("/app", "..", "etc"); // Error: traversal
165
+ resolveSafePath("/app", "/etc/passwd"); // Error: absolute segment
166
+ ```
167
+
168
+ ### Glob Patterns
169
+
170
+ #### `glob(pattern, options?)`
171
+
172
+ Finds files matching a glob pattern. Uses `Bun.Glob` internally.
173
+
174
+ ```typescript
175
+ // Find all TypeScript files
176
+ const result = await glob("**/*.ts", { cwd: "/project" });
177
+
178
+ // Exclude test files and node_modules
179
+ const result = await glob("**/*.ts", {
180
+ cwd: "/project",
181
+ ignore: ["**/*.test.ts", "**/node_modules/**"]
182
+ });
183
+
184
+ // Include dot files
185
+ const result = await glob("**/.*", { cwd: "/project", dot: true });
186
+ ```
187
+
188
+ **Options:**
189
+
190
+ | Option | Type | Default | Description |
191
+ |--------|------|---------|-------------|
192
+ | `cwd` | `string` | `process.cwd()` | Base directory for matching |
193
+ | `ignore` | `string[]` | `[]` | Patterns to exclude |
194
+ | `followSymlinks` | `boolean` | `false` | Follow symbolic links |
195
+ | `dot` | `boolean` | `false` | Include dot files |
196
+
197
+ **Pattern Syntax:**
198
+
199
+ | Pattern | Matches |
200
+ |---------|---------|
201
+ | `*` | Any characters except `/` |
202
+ | `**` | Any characters including `/` (recursive) |
203
+ | `{a,b}` | Alternation (matches `a` or `b`) |
204
+ | `[abc]` | Character class (matches `a`, `b`, or `c`) |
205
+ | `!pattern` | Negation (in ignore array) |
206
+
207
+ ```typescript
208
+ // Negation patterns in ignore array
209
+ const result = await glob("src/**/*.ts", {
210
+ cwd: "/project",
211
+ ignore: ["**/*.ts", "!**/index.ts"] // Ignore all except index.ts
212
+ });
213
+ ```
214
+
215
+ #### `globSync(pattern, options?)`
216
+
217
+ Synchronous version of `glob`.
218
+
219
+ ```typescript
220
+ const result = globSync("src/*.ts", { cwd: "/project" });
221
+ ```
222
+
223
+ ### File Locking
224
+
225
+ Advisory file locking for cross-process coordination. Uses `.lock` files to indicate locks.
226
+
227
+ **Note**: This is advisory locking. All processes must cooperate by using these APIs.
228
+
229
+ #### `withLock(path, callback)`
230
+
231
+ Recommended approach. Executes a callback while holding an exclusive lock, with automatic release.
232
+
233
+ ```typescript
234
+ const result = await withLock("/data/config.json", async () => {
235
+ const config = JSON.parse(await Bun.file("/data/config.json").text());
236
+ config.counter++;
237
+ await atomicWrite("/data/config.json", JSON.stringify(config));
238
+ return config.counter;
239
+ });
240
+
241
+ if (result.isOk()) {
242
+ console.log("New counter:", result.value);
243
+ } else if (result.error._tag === "ConflictError") {
244
+ console.log("File is locked by another process");
245
+ }
246
+ ```
247
+
248
+ #### `acquireLock(path)` / `releaseLock(lock)`
249
+
250
+ Manual lock management. Use `withLock` when possible.
251
+
252
+ ```typescript
253
+ const lockResult = await acquireLock("/data/file.db");
254
+ if (lockResult.isOk()) {
255
+ const lock = lockResult.value;
256
+ try {
257
+ // ... do work ...
258
+ } finally {
259
+ await releaseLock(lock);
260
+ }
261
+ }
262
+ ```
263
+
264
+ #### `isLocked(path)`
265
+
266
+ Checks if a file is currently locked.
267
+
268
+ ```typescript
269
+ if (await isLocked("/data/file.db")) {
270
+ console.log("File is in use");
271
+ }
272
+ ```
273
+
274
+ #### FileLock Interface
275
+
276
+ ```typescript
277
+ interface FileLock {
278
+ path: string; // Path to the locked file
279
+ lockPath: string; // Path to the .lock file
280
+ pid: number; // Process ID holding the lock
281
+ timestamp: number; // When lock was acquired
282
+ }
283
+ ```
284
+
285
+ ### Atomic Writes
286
+
287
+ Write files atomically using temp-file-then-rename strategy. This prevents partial writes and corruption.
288
+
289
+ #### `atomicWrite(path, content, options?)`
290
+
291
+ Writes content to a file atomically.
292
+
293
+ ```typescript
294
+ const result = await atomicWrite("/data/config.json", JSON.stringify(data));
295
+ if (result.isErr()) {
296
+ console.error("Write failed:", result.error.message);
297
+ }
298
+ ```
299
+
300
+ **Options:**
301
+
302
+ | Option | Type | Default | Description |
303
+ |--------|------|---------|-------------|
304
+ | `createParentDirs` | `boolean` | `true` | Create parent directories if needed |
305
+ | `preservePermissions` | `boolean` | `false` | Keep permissions from existing file |
306
+ | `mode` | `number` | `0o644` | File mode for new files |
307
+
308
+ ```typescript
309
+ // Preserve executable permissions
310
+ await atomicWrite("/scripts/run.sh", newContent, {
311
+ preservePermissions: true
312
+ });
313
+
314
+ // Create nested directories automatically
315
+ await atomicWrite("/data/deep/nested/file.json", content, {
316
+ createParentDirs: true
317
+ });
318
+ ```
319
+
320
+ #### `atomicWriteJson(path, data, options?)`
321
+
322
+ Serializes and writes JSON data atomically.
323
+
324
+ ```typescript
325
+ const result = await atomicWriteJson("/data/config.json", {
326
+ name: "app",
327
+ version: "1.0.0",
328
+ settings: { debug: false }
329
+ });
330
+ ```
331
+
332
+ ## Error Handling
333
+
334
+ All functions return `Result` types from `@outfitter/contracts`. Use `.isOk()` and `.isErr()` to handle outcomes.
335
+
336
+ ```typescript
337
+ import type { Result } from "@outfitter/contracts";
338
+
339
+ const result = await findWorkspaceRoot("/path");
340
+
341
+ if (result.isOk()) {
342
+ const workspace = result.value;
343
+ } else {
344
+ // result.error has _tag, message, and error-specific fields
345
+ console.error(result.error._tag, result.error.message);
346
+ }
347
+ ```
348
+
349
+ **Error Types:**
350
+
351
+ | Error | Functions | When |
352
+ |-------|-----------|------|
353
+ | `NotFoundError` | `findWorkspaceRoot`, `getRelativePath` | No workspace marker found |
354
+ | `ValidationError` | `securePath`, `isPathSafe`, `resolveSafePath`, `atomicWriteJson` | Invalid path or data |
355
+ | `ConflictError` | `acquireLock`, `withLock` | File already locked |
356
+ | `InternalError` | `glob`, `releaseLock`, `withLock`, `atomicWrite` | Filesystem or system error |
357
+
358
+ ## Dependencies
359
+
360
+ - `@outfitter/contracts` - Result types and error classes
361
+ - `@outfitter/types` - Type utilities
362
+
363
+ ## License
364
+
365
+ MIT
@@ -0,0 +1,276 @@
1
+ import { ConflictError, InternalError, NotFoundError, Result, ValidationError } from "@outfitter/contracts";
2
+ /**
3
+ * Options for workspace root detection.
4
+ *
5
+ * Configure which marker files trigger workspace detection and where to stop searching.
6
+ */
7
+ interface FindWorkspaceRootOptions {
8
+ /**
9
+ * Marker files/directories to search for.
10
+ * Search stops when any marker is found at a directory level.
11
+ * @defaultValue [".git", "package.json"]
12
+ */
13
+ markers?: string[];
14
+ /**
15
+ * Stop searching at this directory boundary.
16
+ * The search will not continue above this path.
17
+ * @defaultValue filesystem root
18
+ */
19
+ stopAt?: string;
20
+ }
21
+ /**
22
+ * Options for glob operations.
23
+ *
24
+ * Configure base directory, exclusion patterns, and file matching behavior.
25
+ */
26
+ interface GlobOptions {
27
+ /**
28
+ * Base directory for glob matching.
29
+ * All returned paths will be absolute paths within this directory.
30
+ * @defaultValue process.cwd()
31
+ */
32
+ cwd?: string;
33
+ /**
34
+ * Patterns to exclude from results.
35
+ * Supports negation with "!" prefix to re-include previously excluded files.
36
+ */
37
+ ignore?: string[];
38
+ /**
39
+ * Follow symbolic links when scanning directories.
40
+ * @defaultValue false
41
+ */
42
+ followSymlinks?: boolean;
43
+ /**
44
+ * Include files and directories starting with a dot in results.
45
+ * @defaultValue false
46
+ */
47
+ dot?: boolean;
48
+ }
49
+ /**
50
+ * Options for atomic write operations.
51
+ *
52
+ * Configure directory creation, permission handling, and file modes.
53
+ */
54
+ interface AtomicWriteOptions {
55
+ /**
56
+ * Create parent directories if they do not exist.
57
+ * Uses recursive mkdir when enabled.
58
+ * @defaultValue true
59
+ */
60
+ createParentDirs?: boolean;
61
+ /**
62
+ * Preserve file permissions from existing file.
63
+ * If the target file does not exist, falls back to the mode option.
64
+ * @defaultValue false
65
+ */
66
+ preservePermissions?: boolean;
67
+ /**
68
+ * Unix file mode for newly created files.
69
+ * @defaultValue 0o644
70
+ */
71
+ mode?: number;
72
+ }
73
+ /**
74
+ * Represents an acquired file lock.
75
+ *
76
+ * Contains metadata about the lock including the owning process ID and
77
+ * acquisition timestamp. Used with acquireLock and releaseLock functions.
78
+ */
79
+ interface FileLock {
80
+ /** Absolute path to the locked file */
81
+ path: string;
82
+ /** Path to the .lock file that indicates the lock */
83
+ lockPath: string;
84
+ /** Process ID of the lock holder */
85
+ pid: number;
86
+ /** Unix timestamp (milliseconds) when the lock was acquired */
87
+ timestamp: number;
88
+ }
89
+ /**
90
+ * Finds the workspace root by searching upward for marker files/directories.
91
+ *
92
+ * Searches from startPath up to the filesystem root (or stopAt if specified),
93
+ * returning the first directory containing any of the marker files.
94
+ * Default markers are ".git" and "package.json".
95
+ *
96
+ * @param startPath - Path to start searching from (can be file or directory)
97
+ * @param options - Search options including custom markers and stop boundary
98
+ * @returns Result containing absolute workspace root path, or NotFoundError if no markers found
99
+ */
100
+ declare function findWorkspaceRoot(startPath: string, options?: FindWorkspaceRootOptions): Promise<Result<string, InstanceType<typeof NotFoundError>>>;
101
+ /**
102
+ * Gets the path relative to the workspace root.
103
+ *
104
+ * Finds the workspace root from the file's directory and returns the
105
+ * path relative to that root. Uses forward slashes for cross-platform consistency.
106
+ *
107
+ * @param absolutePath - Absolute path to convert to workspace-relative
108
+ * @returns Result containing relative path with forward slashes, or NotFoundError if no workspace found
109
+ */
110
+ declare function getRelativePath(absolutePath: string): Promise<Result<string, InstanceType<typeof NotFoundError>>>;
111
+ /**
112
+ * Checks if a path is inside a workspace directory.
113
+ *
114
+ * Resolves both paths to absolute form and checks if path is equal to
115
+ * or a descendant of workspaceRoot. Does not follow symlinks.
116
+ *
117
+ * @param path - Path to check (can be relative or absolute)
118
+ * @param workspaceRoot - Workspace root directory to check against
119
+ * @returns True if path is inside or equal to workspace root, false otherwise
120
+ */
121
+ declare function isInsideWorkspace(path: string, workspaceRoot: string): boolean;
122
+ /**
123
+ * Validates and secures a user-provided path, preventing path traversal attacks.
124
+ *
125
+ * Security checks performed:
126
+ * - Null bytes are rejected immediately
127
+ * - Path traversal sequences (..) are rejected
128
+ * - Absolute paths are rejected
129
+ * - Final resolved path is verified to remain within basePath (defense in depth)
130
+ *
131
+ * Always use this function when handling user-provided paths instead of
132
+ * directly using path.join with untrusted input.
133
+ *
134
+ * @param path - User-provided path to validate (must be relative)
135
+ * @param basePath - Base directory to resolve against
136
+ * @returns Result containing resolved absolute safe path, or ValidationError if path is unsafe
137
+ */
138
+ declare function securePath(path: string, basePath: string): Result<string, InstanceType<typeof ValidationError>>;
139
+ /**
140
+ * Checks if a path is safe (no traversal, valid characters).
141
+ *
142
+ * Convenience wrapper around securePath that returns a boolean.
143
+ * Use this for quick validation; use securePath when you need the resolved path.
144
+ *
145
+ * @param path - Path to check (should be relative)
146
+ * @param basePath - Base directory to resolve against
147
+ * @returns True if path passes all security checks, false otherwise
148
+ */
149
+ declare function isPathSafe(path: string, basePath: string): boolean;
150
+ /**
151
+ * Safely resolves path segments into an absolute path.
152
+ *
153
+ * Validates each segment for security issues before joining. Use this
154
+ * instead of path.join when any segment may come from user input.
155
+ *
156
+ * Security checks per segment:
157
+ * - Null bytes are rejected
158
+ * - Path traversal (..) is rejected
159
+ * - Absolute path segments are rejected
160
+ *
161
+ * @param basePath - Base directory (must be absolute)
162
+ * @param segments - Path segments to join (each validated individually)
163
+ * @returns Result containing resolved absolute path, or ValidationError if any segment is unsafe
164
+ */
165
+ declare function resolveSafePath(basePath: string, ...segments: string[]): Result<string, InstanceType<typeof ValidationError>>;
166
+ /**
167
+ * Finds files matching a glob pattern.
168
+ *
169
+ * Uses Bun.Glob internally for fast pattern matching. Returns absolute paths.
170
+ * Supports standard glob syntax including recursive matching, alternation, and character classes.
171
+ *
172
+ * Pattern syntax:
173
+ * - Single asterisk matches any characters except path separator
174
+ * - Double asterisk matches any characters including path separator (recursive)
175
+ * - Curly braces for alternation
176
+ * - Square brackets for character classes
177
+ *
178
+ * @param pattern - Glob pattern to match
179
+ * @param options - Glob options including cwd, ignore patterns, and file type filters
180
+ * @returns Result containing array of absolute file paths, or InternalError on failure
181
+ */
182
+ declare function glob(pattern: string, options?: GlobOptions): Promise<Result<string[], InstanceType<typeof InternalError>>>;
183
+ /**
184
+ * Synchronous version of glob.
185
+ *
186
+ * Use the async glob function when possible. This synchronous version
187
+ * blocks the event loop and should only be used in initialization code
188
+ * or synchronous contexts.
189
+ *
190
+ * @param pattern - Glob pattern to match
191
+ * @param options - Glob options including cwd, ignore patterns, and file type filters
192
+ * @returns Result containing array of absolute file paths, or InternalError on failure
193
+ */
194
+ declare function globSync(pattern: string, options?: GlobOptions): Result<string[], InstanceType<typeof InternalError>>;
195
+ /**
196
+ * Acquires an advisory lock on a file.
197
+ *
198
+ * Creates a .lock file next to the target file with lock metadata (PID, timestamp).
199
+ * Uses atomic file creation (wx flag) to prevent race conditions.
200
+ *
201
+ * Important: This is advisory locking. All processes must cooperate by using
202
+ * these locking APIs. The filesystem does not enforce the lock.
203
+ *
204
+ * Prefer using withLock for automatic lock release.
205
+ *
206
+ * @param path - Absolute path to the file to lock
207
+ * @returns Result containing FileLock on success, or ConflictError if already locked
208
+ */
209
+ declare function acquireLock(path: string): Promise<Result<FileLock, InstanceType<typeof ConflictError>>>;
210
+ /**
211
+ * Releases a file lock by removing the .lock file.
212
+ *
213
+ * Should only be called with a lock obtained from acquireLock.
214
+ * Prefer using withLock for automatic lock management.
215
+ *
216
+ * @param lock - Lock object returned from acquireLock
217
+ * @returns Result indicating success, or InternalError if lock file cannot be removed
218
+ */
219
+ declare function releaseLock(lock: FileLock): Promise<Result<void, InstanceType<typeof InternalError>>>;
220
+ /**
221
+ * Executes a callback while holding an exclusive lock on a file.
222
+ *
223
+ * Lock is automatically released after callback completes, whether it
224
+ * succeeds or throws an error. This is the recommended way to use file locking.
225
+ *
226
+ * Uses advisory file locking via .lock files. The lock is NOT enforced
227
+ * by the filesystem - all processes must cooperate by using this API.
228
+ *
229
+ * @typeParam T - Return type of the callback
230
+ * @param path - Absolute path to the file to lock
231
+ * @param callback - Async callback to execute while holding lock
232
+ * @returns Result containing callback return value, ConflictError if locked, or InternalError on failure
233
+ */
234
+ declare function withLock<T>(path: string, callback: () => Promise<T>): Promise<Result<T, InstanceType<typeof ConflictError> | InstanceType<typeof InternalError>>>;
235
+ /**
236
+ * Checks if a file is currently locked.
237
+ *
238
+ * Checks for the existence of a .lock file. Does not verify if the
239
+ * process holding the lock is still running (no stale lock detection).
240
+ *
241
+ * @param path - Absolute path to check
242
+ * @returns True if a lock file exists, false otherwise
243
+ */
244
+ declare function isLocked(path: string): Promise<boolean>;
245
+ /**
246
+ * Writes content to a file atomically using temp-file-then-rename strategy.
247
+ *
248
+ * How it works:
249
+ * 1. Creates a unique temp file in the same directory
250
+ * 2. Writes content to temp file
251
+ * 3. Renames temp file to target (atomic on most filesystems)
252
+ * 4. Cleans up temp file on failure
253
+ *
254
+ * This prevents partial writes and file corruption. The file either
255
+ * contains the old content or the new content, never a partial state.
256
+ *
257
+ * @param path - Absolute path to target file
258
+ * @param content - String content to write
259
+ * @param options - Write options including directory creation and permission handling
260
+ * @returns Result indicating success, or InternalError on failure
261
+ */
262
+ declare function atomicWrite(path: string, content: string, options?: AtomicWriteOptions): Promise<Result<void, InstanceType<typeof InternalError>>>;
263
+ /**
264
+ * Writes JSON data to a file atomically.
265
+ *
266
+ * Serializes data to JSON and writes using atomicWrite.
267
+ * Returns ValidationError if serialization fails.
268
+ *
269
+ * @typeParam T - Type of data to serialize
270
+ * @param path - Absolute path to target file
271
+ * @param data - Data to serialize and write (must be JSON-serializable)
272
+ * @param options - Write options including directory creation and permission handling
273
+ * @returns Result indicating success, ValidationError if serialization fails, or InternalError on write failure
274
+ */
275
+ declare function atomicWriteJson<T>(path: string, data: T, options?: AtomicWriteOptions): Promise<Result<void, InstanceType<typeof InternalError> | InstanceType<typeof ValidationError>>>;
276
+ export { withLock, securePath, resolveSafePath, releaseLock, isPathSafe, isLocked, isInsideWorkspace, globSync, glob, getRelativePath, findWorkspaceRoot, atomicWriteJson, atomicWrite, acquireLock, GlobOptions, FindWorkspaceRootOptions, FileLock, AtomicWriteOptions };
package/dist/index.js ADDED
@@ -0,0 +1,349 @@
1
+ // src/index.ts
2
+ import {
3
+ writeFile as fsWriteFile,
4
+ mkdir,
5
+ rename,
6
+ stat,
7
+ unlink
8
+ } from "node:fs/promises";
9
+ import {
10
+ dirname,
11
+ isAbsolute,
12
+ join,
13
+ normalize,
14
+ relative,
15
+ resolve,
16
+ sep
17
+ } from "node:path";
18
+ import {
19
+ ConflictError,
20
+ InternalError,
21
+ NotFoundError,
22
+ Result,
23
+ ValidationError
24
+ } from "@outfitter/contracts";
25
+ async function markerExistsAt(dir, marker) {
26
+ try {
27
+ const markerPath = join(dir, marker);
28
+ await stat(markerPath);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function findWorkspaceRoot(startPath, options) {
35
+ const markers = options?.markers ?? [".git", "package.json"];
36
+ const stopAt = options?.stopAt;
37
+ let currentDir = resolve(startPath);
38
+ const root = resolve("/");
39
+ while (true) {
40
+ for (const marker of markers) {
41
+ if (await markerExistsAt(currentDir, marker)) {
42
+ return Result.ok(currentDir);
43
+ }
44
+ }
45
+ if (stopAt && currentDir === resolve(stopAt)) {
46
+ break;
47
+ }
48
+ if (currentDir === root) {
49
+ break;
50
+ }
51
+ const parentDir = dirname(currentDir);
52
+ if (parentDir === currentDir) {
53
+ break;
54
+ }
55
+ currentDir = parentDir;
56
+ }
57
+ return Result.err(new NotFoundError({
58
+ message: "No workspace root found",
59
+ resourceType: "workspace",
60
+ resourceId: startPath
61
+ }));
62
+ }
63
+ async function getRelativePath(absolutePath) {
64
+ const workspaceResult = await findWorkspaceRoot(dirname(absolutePath));
65
+ if (workspaceResult.isErr()) {
66
+ return workspaceResult;
67
+ }
68
+ const relativePath = relative(workspaceResult.value, absolutePath);
69
+ return Result.ok(relativePath.split(sep).join("/"));
70
+ }
71
+ function isInsideWorkspace(path, workspaceRoot) {
72
+ const resolvedPath = resolve(path);
73
+ const resolvedRoot = resolve(workspaceRoot);
74
+ return resolvedPath.startsWith(resolvedRoot + sep) || resolvedPath === resolvedRoot;
75
+ }
76
+ function securePath(path, basePath) {
77
+ if (path.includes("\x00")) {
78
+ return Result.err(new ValidationError({
79
+ message: "Path contains null bytes",
80
+ field: "path"
81
+ }));
82
+ }
83
+ const normalizedPath = path.replace(/\\/g, "/");
84
+ if (normalizedPath.includes("..")) {
85
+ return Result.err(new ValidationError({
86
+ message: "Path contains traversal sequence",
87
+ field: "path"
88
+ }));
89
+ }
90
+ if (normalizedPath.startsWith("/")) {
91
+ return Result.err(new ValidationError({
92
+ message: "Absolute paths are not allowed",
93
+ field: "path"
94
+ }));
95
+ }
96
+ const cleanPath = normalizedPath.replace(/^\.\//, "");
97
+ const resolvedPath = join(basePath, cleanPath);
98
+ const normalizedResolved = normalize(resolvedPath);
99
+ const normalizedBase = normalize(basePath);
100
+ if (!normalizedResolved.startsWith(normalizedBase)) {
101
+ return Result.err(new ValidationError({
102
+ message: "Path escapes base directory",
103
+ field: "path"
104
+ }));
105
+ }
106
+ return Result.ok(resolvedPath);
107
+ }
108
+ function isPathSafe(path, basePath) {
109
+ return Result.isOk(securePath(path, basePath));
110
+ }
111
+ function resolveSafePath(basePath, ...segments) {
112
+ for (const segment of segments) {
113
+ if (segment.includes("\x00")) {
114
+ return Result.err(new ValidationError({
115
+ message: "Path segment contains null bytes",
116
+ field: "path"
117
+ }));
118
+ }
119
+ if (segment.includes("..")) {
120
+ return Result.err(new ValidationError({
121
+ message: "Path segment contains traversal sequence",
122
+ field: "path"
123
+ }));
124
+ }
125
+ if (isAbsolute(segment)) {
126
+ return Result.err(new ValidationError({
127
+ message: "Absolute path segments are not allowed",
128
+ field: "path"
129
+ }));
130
+ }
131
+ }
132
+ const resolvedPath = join(basePath, ...segments);
133
+ const normalizedResolved = normalize(resolvedPath);
134
+ const normalizedBase = normalize(basePath);
135
+ if (normalizedResolved !== normalizedBase && !normalizedResolved.startsWith(normalizedBase + sep)) {
136
+ return Result.err(new ValidationError({
137
+ message: "Path escapes base directory",
138
+ field: "path"
139
+ }));
140
+ }
141
+ return Result.ok(resolvedPath);
142
+ }
143
+ function createIgnoreFilter(ignore, cwd) {
144
+ if (!ignore || ignore.length === 0) {
145
+ return () => true;
146
+ }
147
+ const ignorePatterns = [];
148
+ const negationPatterns = [];
149
+ for (const pattern of ignore) {
150
+ if (pattern.startsWith("!")) {
151
+ negationPatterns.push(pattern.slice(1));
152
+ } else {
153
+ ignorePatterns.push(pattern);
154
+ }
155
+ }
156
+ return (filePath) => {
157
+ const relativePath = relative(cwd, filePath);
158
+ let isIgnored = false;
159
+ for (const pattern of ignorePatterns) {
160
+ const glob = new Bun.Glob(pattern);
161
+ if (glob.match(relativePath)) {
162
+ isIgnored = true;
163
+ break;
164
+ }
165
+ }
166
+ if (isIgnored) {
167
+ for (const pattern of negationPatterns) {
168
+ const glob = new Bun.Glob(pattern);
169
+ if (glob.match(relativePath)) {
170
+ isIgnored = false;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+ return !isIgnored;
176
+ };
177
+ }
178
+ async function glob(pattern, options) {
179
+ try {
180
+ const cwd = options?.cwd ?? process.cwd();
181
+ const bunGlob = new Bun.Glob(pattern);
182
+ const files = [];
183
+ const ignoreFilter = createIgnoreFilter(options?.ignore, cwd);
184
+ const scanOptions = {
185
+ cwd,
186
+ ...options?.dot !== undefined && { dot: options.dot },
187
+ ...options?.followSymlinks !== undefined && {
188
+ followSymlinks: options.followSymlinks
189
+ }
190
+ };
191
+ for await (const file of bunGlob.scan(scanOptions)) {
192
+ const absolutePath = join(cwd, file);
193
+ if (ignoreFilter(absolutePath)) {
194
+ files.push(absolutePath);
195
+ }
196
+ }
197
+ return Result.ok(files);
198
+ } catch (error) {
199
+ return Result.err(new InternalError({
200
+ message: error instanceof Error ? error.message : "Glob operation failed"
201
+ }));
202
+ }
203
+ }
204
+ function globSync(pattern, options) {
205
+ try {
206
+ const cwd = options?.cwd ?? process.cwd();
207
+ const bunGlob = new Bun.Glob(pattern);
208
+ const files = [];
209
+ const ignoreFilter = createIgnoreFilter(options?.ignore, cwd);
210
+ const scanOptions = {
211
+ cwd,
212
+ ...options?.dot !== undefined && { dot: options.dot },
213
+ ...options?.followSymlinks !== undefined && {
214
+ followSymlinks: options.followSymlinks
215
+ }
216
+ };
217
+ for (const file of bunGlob.scanSync(scanOptions)) {
218
+ const absolutePath = join(cwd, file);
219
+ if (ignoreFilter(absolutePath)) {
220
+ files.push(absolutePath);
221
+ }
222
+ }
223
+ return Result.ok(files);
224
+ } catch (error) {
225
+ return Result.err(new InternalError({
226
+ message: error instanceof Error ? error.message : "Glob operation failed"
227
+ }));
228
+ }
229
+ }
230
+ async function acquireLock(path) {
231
+ const lockPath = `${path}.lock`;
232
+ const lockFile = Bun.file(lockPath);
233
+ if (await lockFile.exists()) {
234
+ return Result.err(new ConflictError({
235
+ message: `File is already locked: ${path}`
236
+ }));
237
+ }
238
+ const lock = {
239
+ path,
240
+ lockPath,
241
+ pid: process.pid,
242
+ timestamp: Date.now()
243
+ };
244
+ try {
245
+ await fsWriteFile(lockPath, JSON.stringify({ pid: lock.pid, timestamp: lock.timestamp }), { flag: "wx" });
246
+ } catch (error) {
247
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
248
+ return Result.err(new ConflictError({
249
+ message: `File is already locked: ${path}`
250
+ }));
251
+ }
252
+ throw error;
253
+ }
254
+ return Result.ok(lock);
255
+ }
256
+ async function releaseLock(lock) {
257
+ try {
258
+ await unlink(lock.lockPath);
259
+ return Result.ok(undefined);
260
+ } catch (error) {
261
+ return Result.err(new InternalError({
262
+ message: error instanceof Error ? error.message : "Failed to release lock"
263
+ }));
264
+ }
265
+ }
266
+ async function withLock(path, callback) {
267
+ const lockResult = await acquireLock(path);
268
+ if (lockResult.isErr()) {
269
+ return lockResult;
270
+ }
271
+ const lock = lockResult.value;
272
+ try {
273
+ const result = await callback();
274
+ const releaseResult = await releaseLock(lock);
275
+ if (releaseResult.isErr()) {
276
+ return releaseResult;
277
+ }
278
+ return Result.ok(result);
279
+ } catch (error) {
280
+ const releaseResult = await releaseLock(lock);
281
+ if (releaseResult.isErr()) {
282
+ return Result.err(new InternalError({
283
+ message: `Callback failed: ${error instanceof Error ? error.message : "Unknown error"}; lock release also failed: ${releaseResult.error.message}`
284
+ }));
285
+ }
286
+ return Result.err(new InternalError({
287
+ message: error instanceof Error ? error.message : "Callback failed"
288
+ }));
289
+ }
290
+ }
291
+ function isLocked(path) {
292
+ const lockPath = `${path}.lock`;
293
+ return Bun.file(lockPath).exists();
294
+ }
295
+ async function atomicWrite(path, content, options) {
296
+ const createParentDirs = options?.createParentDirs ?? true;
297
+ const parentDir = dirname(path);
298
+ const randomSuffix = Math.random().toString(36).slice(2, 10);
299
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomSuffix}.tmp`;
300
+ try {
301
+ if (createParentDirs) {
302
+ await mkdir(parentDir, { recursive: true });
303
+ }
304
+ let mode = options?.mode ?? 420;
305
+ if (options?.preservePermissions) {
306
+ try {
307
+ const stats = await stat(path);
308
+ mode = stats.mode;
309
+ } catch {}
310
+ }
311
+ await fsWriteFile(tempPath, content, { mode });
312
+ await rename(tempPath, path);
313
+ return Result.ok(undefined);
314
+ } catch (error) {
315
+ try {
316
+ await unlink(tempPath);
317
+ } catch {}
318
+ return Result.err(new InternalError({
319
+ message: error instanceof Error ? error.message : "Atomic write failed"
320
+ }));
321
+ }
322
+ }
323
+ async function atomicWriteJson(path, data, options) {
324
+ try {
325
+ const content = JSON.stringify(data);
326
+ return await atomicWrite(path, content, options);
327
+ } catch (error) {
328
+ return Result.err(new ValidationError({
329
+ message: error instanceof Error ? error.message : "Failed to serialize JSON",
330
+ field: "data"
331
+ }));
332
+ }
333
+ }
334
+ export {
335
+ withLock,
336
+ securePath,
337
+ resolveSafePath,
338
+ releaseLock,
339
+ isPathSafe,
340
+ isLocked,
341
+ isInsideWorkspace,
342
+ globSync,
343
+ glob,
344
+ getRelativePath,
345
+ findWorkspaceRoot,
346
+ atomicWriteJson,
347
+ atomicWrite,
348
+ acquireLock
349
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@outfitter/file-ops",
3
+ "description": "Workspace detection, secure path handling, and file locking for Outfitter",
4
+ "version": "0.1.0-rc.1",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "bunup --filter @outfitter/file-ops",
23
+ "lint": "biome lint ./src",
24
+ "lint:fix": "biome lint --write ./src",
25
+ "test": "bun test",
26
+ "typecheck": "tsc --noEmit",
27
+ "clean": "rm -rf dist"
28
+ },
29
+ "dependencies": {
30
+ "@outfitter/contracts": "workspace:*",
31
+ "@outfitter/types": "workspace:*"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "typescript": "^5.8.0"
36
+ },
37
+ "keywords": [
38
+ "outfitter",
39
+ "file-ops",
40
+ "workspace",
41
+ "security",
42
+ "typescript"
43
+ ],
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/outfitter-dev/outfitter.git",
48
+ "directory": "packages/file-ops"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }