@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 +365 -0
- package/dist/index.d.ts +276 -0
- package/dist/index.js +349 -0
- package/package.json +53 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|