@dbx-tools/fs 0.6.10
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 +60 -0
- package/index.ts +12 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +10 -0
- package/lib/src/local-fs.d.ts +154 -0
- package/lib/src/local-fs.js +330 -0
- package/lib/src/local-path.d.ts +23 -0
- package/lib/src/local-path.js +30 -0
- package/lib/src/os-path.d.ts +50 -0
- package/lib/src/os-path.js +143 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +55 -0
- package/src/local-fs.ts +461 -0
- package/src/local-path.ts +42 -0
- package/src/os-path.ts +167 -0
package/src/local-fs.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-local {@link FileSystem} backed by Node's `node:fs/promises`.
|
|
3
|
+
*
|
|
4
|
+
* Extends {@link BaseFileSystem} so this module only owns Node I/O, host path
|
|
5
|
+
* conversion, and symlink containment. Portable behavior (encoding, recursion,
|
|
6
|
+
* parent creation, fallbacks, POSIX namespace paths) lives in `@dbx-tools/shared-fs`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
appendFile,
|
|
13
|
+
cp,
|
|
14
|
+
lstat,
|
|
15
|
+
mkdir,
|
|
16
|
+
readdir,
|
|
17
|
+
readFile,
|
|
18
|
+
realpath,
|
|
19
|
+
rename,
|
|
20
|
+
rm,
|
|
21
|
+
rmdir,
|
|
22
|
+
writeFile,
|
|
23
|
+
} from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { hash } from "@dbx-tools/shared-core";
|
|
26
|
+
import {
|
|
27
|
+
BaseFileSystem,
|
|
28
|
+
baseFS,
|
|
29
|
+
FileSystemError,
|
|
30
|
+
posixPath,
|
|
31
|
+
type CopyOptions,
|
|
32
|
+
type FileEntry,
|
|
33
|
+
type FileEntryType,
|
|
34
|
+
type FileStat,
|
|
35
|
+
type FileSystemErrorCode,
|
|
36
|
+
type WriteFileOptions,
|
|
37
|
+
} from "@dbx-tools/shared-fs";
|
|
38
|
+
import { resolveLocalRoot } from "./local-path.ts";
|
|
39
|
+
import { resolveLocalHome, resolveLocalTemp, type ResolveOsPathsOptions } from "./os-path.ts";
|
|
40
|
+
|
|
41
|
+
/** Options for {@link LocalFileSystem}. */
|
|
42
|
+
export interface LocalFileSystemOptions {
|
|
43
|
+
/** Unique identifier. Defaults to a stable hash of the absolute root. */
|
|
44
|
+
id?: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Root directory on disk. Relative paths resolve against `process.cwd()`.
|
|
48
|
+
* `~` / `~/...` expand via `os.homedir()` (before `HOME`), App `/home/app`,
|
|
49
|
+
* or a created `./.home` (create failures skip).
|
|
50
|
+
* Stored on {@link LocalFileSystem.root} in POSIX form.
|
|
51
|
+
*/
|
|
52
|
+
root: string;
|
|
53
|
+
|
|
54
|
+
/** Block all write operations. Defaults to false. */
|
|
55
|
+
readOnly?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keep every resolved path under {@link root}. Defaults to true.
|
|
59
|
+
* When false, absolute input paths are accepted as-is (no sandbox).
|
|
60
|
+
*/
|
|
61
|
+
contained?: boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create {@link root} (and parents) during init. Defaults to true.
|
|
65
|
+
*/
|
|
66
|
+
createRoot?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Options for the factories that take their root FROM the OS
|
|
71
|
+
* ({@link homeFS} / {@link tmpFS} / {@link scratchFS}) rather than from the
|
|
72
|
+
* caller: {@link LocalFileSystemOptions} without `root`, plus the
|
|
73
|
+
* {@link resolveOsPaths} injectables.
|
|
74
|
+
*/
|
|
75
|
+
export type OsFileSystemOptions = Omit<LocalFileSystemOptions, "root"> & {
|
|
76
|
+
/** Forwarded to {@link resolveLocalHome} / {@link resolveLocalTemp}. */
|
|
77
|
+
os?: ResolveOsPathsOptions;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* {@link FileSystem} implementation that stores files in a folder on the local
|
|
82
|
+
* machine.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* const fs = new LocalFileSystem({ root: "./data" });
|
|
87
|
+
* await fs.init();
|
|
88
|
+
* await fs.writeFile("hello.txt", "hi");
|
|
89
|
+
*
|
|
90
|
+
* const home = new LocalFileSystem({ root: "~/projects/data" });
|
|
91
|
+
* const scratch = tmpFS("my-job");
|
|
92
|
+
* const cache = homeFS(".cache/app");
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export class LocalFileSystem extends BaseFileSystem<"local"> {
|
|
96
|
+
private readonly contained: boolean;
|
|
97
|
+
/** Symlink-resolved host root, refreshed in {@link onInit}. */
|
|
98
|
+
private realRoot: string;
|
|
99
|
+
|
|
100
|
+
constructor(options: LocalFileSystemOptions) {
|
|
101
|
+
const hostRoot = resolveLocalRoot(options.root);
|
|
102
|
+
const root = posixPath.normalizeRoot(posixPath.toPosix(hostRoot));
|
|
103
|
+
super({
|
|
104
|
+
id: options.id ?? `local-${hash.fnvHash(root)}`,
|
|
105
|
+
backend: "local",
|
|
106
|
+
root,
|
|
107
|
+
readOnly: options.readOnly,
|
|
108
|
+
createRoot: options.createRoot ?? true,
|
|
109
|
+
});
|
|
110
|
+
this.realRoot = this.toBackendPath(root);
|
|
111
|
+
this.contained = options.contained ?? true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected override toBackendPath(posixBackendPath: string): string {
|
|
115
|
+
return posixPath.toHost(posixBackendPath, path.sep);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected override async createRootDirectory(): Promise<void> {
|
|
119
|
+
await mkdir(this.toBackendPath(this.root), { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected override async onInit(): Promise<void> {
|
|
123
|
+
const hostRoot = this.toBackendPath(this.root);
|
|
124
|
+
if (!this.createRoot) {
|
|
125
|
+
const info = await lstat(hostRoot);
|
|
126
|
+
if (!info.isDirectory()) {
|
|
127
|
+
throw new FileSystemError(
|
|
128
|
+
"NOT_DIRECTORY",
|
|
129
|
+
`Local filesystem root is not a directory: ${this.root}`,
|
|
130
|
+
this.root,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Canonicalize so containment checks survive OS root symlinks (e.g. /var -> /private/var).
|
|
135
|
+
this.realRoot = await realpath(hostRoot);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
override resolvePath(inputPath: string): string {
|
|
139
|
+
if (!this.contained) {
|
|
140
|
+
const resolved = path.isAbsolute(inputPath)
|
|
141
|
+
? path.resolve(inputPath)
|
|
142
|
+
: path.resolve(this.toBackendPath(this.root), inputPath);
|
|
143
|
+
return this.toBackendPath(posixPath.toPosix(resolved));
|
|
144
|
+
}
|
|
145
|
+
return super.resolvePath(inputPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* When contained, resolve symlinks and ensure the real path still sits under
|
|
150
|
+
* {@link root}. Missing leaf paths are allowed when `allowMissing` is set so
|
|
151
|
+
* writes can create new files.
|
|
152
|
+
*/
|
|
153
|
+
protected override async preparePath(
|
|
154
|
+
resolvedPath: string,
|
|
155
|
+
options?: { allowMissing?: boolean },
|
|
156
|
+
): Promise<string> {
|
|
157
|
+
if (!this.contained) return resolvedPath;
|
|
158
|
+
try {
|
|
159
|
+
const real = await realpath(resolvedPath);
|
|
160
|
+
if (!isWithinHostRoot(this.realRoot, real)) {
|
|
161
|
+
throw new FileSystemError(
|
|
162
|
+
"PERMISSION_DENIED",
|
|
163
|
+
"Path escapes the filesystem root",
|
|
164
|
+
resolvedPath,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return resolvedPath;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof FileSystemError) throw err;
|
|
170
|
+
if (options?.allowMissing && isErrno(err, "ENOENT")) {
|
|
171
|
+
let cursor = path.dirname(resolvedPath);
|
|
172
|
+
while (cursor !== path.dirname(cursor)) {
|
|
173
|
+
try {
|
|
174
|
+
const real = await realpath(cursor);
|
|
175
|
+
if (!isWithinHostRoot(this.realRoot, real)) {
|
|
176
|
+
throw new FileSystemError(
|
|
177
|
+
"PERMISSION_DENIED",
|
|
178
|
+
"Path escapes the filesystem root",
|
|
179
|
+
resolvedPath,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return resolvedPath;
|
|
183
|
+
} catch (inner) {
|
|
184
|
+
if (inner instanceof FileSystemError) throw inner;
|
|
185
|
+
if (!isErrno(inner, "ENOENT")) throw this.mapError(inner, resolvedPath);
|
|
186
|
+
cursor = path.dirname(cursor);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return resolvedPath;
|
|
190
|
+
}
|
|
191
|
+
throw this.mapError(err, resolvedPath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Classify Node errno codes; {@link BaseFileSystem} wraps every primitive. */
|
|
196
|
+
protected override mapError(err: unknown, filePath: string): FileSystemError {
|
|
197
|
+
return baseFS.mapFileSystemError(err, filePath, nodeErrorCode);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
protected override async readBytesAt(resolvedPath: string): Promise<Uint8Array> {
|
|
201
|
+
const buffer = await readFile(resolvedPath);
|
|
202
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
protected override async writeBytesAt(
|
|
206
|
+
resolvedPath: string,
|
|
207
|
+
content: Uint8Array,
|
|
208
|
+
options: Required<WriteFileOptions>,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
await writeFile(resolvedPath, content, options.overwrite ? undefined : { flag: "wx" });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
protected override async deleteFileAt(resolvedPath: string): Promise<void> {
|
|
214
|
+
await rm(resolvedPath, { force: false });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
protected override async createDirectoryAt(resolvedPath: string): Promise<void> {
|
|
218
|
+
await mkdir(resolvedPath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
protected override async removeDirectoryAt(resolvedPath: string): Promise<void> {
|
|
222
|
+
await rmdir(resolvedPath);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
protected override async listDirectoryAt(resolvedPath: string): Promise<FileEntry[]> {
|
|
226
|
+
const dirents = await readdir(resolvedPath, { withFileTypes: true });
|
|
227
|
+
const entries: FileEntry[] = [];
|
|
228
|
+
for (const dirent of dirents) {
|
|
229
|
+
const type = entryType(dirent);
|
|
230
|
+
let size: number | undefined;
|
|
231
|
+
if (type === "file" || type === "symbolic-link") {
|
|
232
|
+
try {
|
|
233
|
+
size = (await lstat(path.join(resolvedPath, dirent.name))).size;
|
|
234
|
+
} catch {
|
|
235
|
+
// Race with concurrent deletes; omit size.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
entries.push(
|
|
239
|
+
size === undefined ? { name: dirent.name, type } : { name: dirent.name, type, size },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return entries;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
protected override async statAt(resolvedPath: string): Promise<Omit<FileStat, "path">> {
|
|
246
|
+
const info = await lstat(resolvedPath);
|
|
247
|
+
return {
|
|
248
|
+
name: path.basename(resolvedPath) || path.basename(this.toBackendPath(this.root)),
|
|
249
|
+
type: entryType(info),
|
|
250
|
+
size: info.size,
|
|
251
|
+
createdAt: info.birthtime,
|
|
252
|
+
modifiedAt: info.mtime,
|
|
253
|
+
accessedAt: info.atime,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
protected override isNotFoundError(error: unknown): boolean {
|
|
258
|
+
return (
|
|
259
|
+
(error instanceof FileSystemError && error.code === "NOT_FOUND") || isErrno(error, "ENOENT")
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
protected override async tryAppendFileAt(
|
|
264
|
+
resolvedPath: string,
|
|
265
|
+
content: Uint8Array,
|
|
266
|
+
): Promise<boolean> {
|
|
267
|
+
await appendFile(resolvedPath, content);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
protected override async tryCopyFileAt(
|
|
272
|
+
sourcePath: string,
|
|
273
|
+
destinationPath: string,
|
|
274
|
+
options: Required<CopyOptions>,
|
|
275
|
+
): Promise<boolean> {
|
|
276
|
+
await cp(sourcePath, destinationPath, {
|
|
277
|
+
recursive: true,
|
|
278
|
+
force: options.overwrite,
|
|
279
|
+
errorOnExist: !options.overwrite,
|
|
280
|
+
});
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
protected override async tryMoveFileAt(
|
|
285
|
+
sourcePath: string,
|
|
286
|
+
destinationPath: string,
|
|
287
|
+
options: Required<CopyOptions>,
|
|
288
|
+
): Promise<boolean> {
|
|
289
|
+
try {
|
|
290
|
+
await rename(sourcePath, destinationPath);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// Across devices `rename` cannot work; fall back to copy + remove. Any
|
|
293
|
+
// other failure propagates for the base class to map.
|
|
294
|
+
if (!isErrno(err, "EXDEV")) throw err;
|
|
295
|
+
await cp(sourcePath, destinationPath, { recursive: true, force: options.overwrite });
|
|
296
|
+
await rm(sourcePath, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
303
|
+
|
|
304
|
+
function isWithinHostRoot(root: string, candidate: string): boolean {
|
|
305
|
+
const relative = path.relative(root, candidate);
|
|
306
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isErrno(err: unknown, code: string): boolean {
|
|
310
|
+
return (
|
|
311
|
+
typeof err === "object" &&
|
|
312
|
+
err !== null &&
|
|
313
|
+
"code" in err &&
|
|
314
|
+
(err as NodeJS.ErrnoException).code === code
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function entryType(entry: {
|
|
319
|
+
isFile(): boolean;
|
|
320
|
+
isDirectory(): boolean;
|
|
321
|
+
isSymbolicLink(): boolean;
|
|
322
|
+
}): FileEntryType {
|
|
323
|
+
if (entry.isSymbolicLink()) return "symbolic-link";
|
|
324
|
+
if (entry.isDirectory()) return "directory";
|
|
325
|
+
if (entry.isFile()) return "file";
|
|
326
|
+
return "other";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Node errno -> portable {@link FileSystemErrorCode}. */
|
|
330
|
+
const NODE_ERROR_CODES: Readonly<Record<string, FileSystemErrorCode>> = {
|
|
331
|
+
ENOENT: "NOT_FOUND",
|
|
332
|
+
EEXIST: "ALREADY_EXISTS",
|
|
333
|
+
ENOTDIR: "NOT_DIRECTORY",
|
|
334
|
+
EISDIR: "IS_DIRECTORY",
|
|
335
|
+
ENOTEMPTY: "DIRECTORY_NOT_EMPTY",
|
|
336
|
+
EACCES: "PERMISSION_DENIED",
|
|
337
|
+
EPERM: "PERMISSION_DENIED",
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
function nodeErrorCode(err: unknown): FileSystemErrorCode | undefined {
|
|
341
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
342
|
+
return code ? NODE_ERROR_CODES[code] : undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* {@link LocalFileSystem} rooted under {@link resolveLocalHome}, with
|
|
347
|
+
* {@link relativeRoot} joined as a relative path (leading `/` / `~` stripped
|
|
348
|
+
* so the result stays under home).
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* ```ts
|
|
352
|
+
* const cache = homeFS(".cache/my-app");
|
|
353
|
+
* await cache.writeFile("state.json", "{}");
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
export function homeFS(
|
|
357
|
+
relativeRoot: string = ".",
|
|
358
|
+
options: OsFileSystemOptions = {},
|
|
359
|
+
): LocalFileSystem {
|
|
360
|
+
const { os, ...fsOptions } = options;
|
|
361
|
+
return new LocalFileSystem({
|
|
362
|
+
...fsOptions,
|
|
363
|
+
root: joinUnderBase(resolveLocalHome(os), relativeRoot),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* {@link LocalFileSystem} rooted under {@link resolveLocalTemp}, with
|
|
369
|
+
* {@link relativeRoot} joined as a relative path (leading `/` / `~` stripped
|
|
370
|
+
* so the result stays under temp).
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* ```ts
|
|
374
|
+
* const scratch = tmpFS("job-42");
|
|
375
|
+
* await scratch.writeFile("out.bin", bytes);
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
export function tmpFS(
|
|
379
|
+
relativeRoot: string = ".",
|
|
380
|
+
options: OsFileSystemOptions = {},
|
|
381
|
+
): LocalFileSystem {
|
|
382
|
+
const { os, ...fsOptions } = options;
|
|
383
|
+
return new LocalFileSystem({
|
|
384
|
+
...fsOptions,
|
|
385
|
+
root: joinUnderBase(resolveLocalTemp(os), relativeRoot),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* {@link tmpFS} under a root that is unique per call (`<prefix>-<id>`), for a
|
|
391
|
+
* scratch area no other process or run can collide with.
|
|
392
|
+
*
|
|
393
|
+
* Prefer this over minting the id at the call site - it is the one place that
|
|
394
|
+
* decides what a throwaway working directory looks like.
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* ```ts
|
|
398
|
+
* const scratch = scratchFS("remote-skills");
|
|
399
|
+
* await scratch.writeFile("SKILL.md", body);
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
export function scratchFS(prefix: string, options: OsFileSystemOptions = {}): LocalFileSystem {
|
|
403
|
+
return tmpFS(`${prefix}-${hash.id()}`, options);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Rebuild the STABLE temp tree named by {@link key}, every call.
|
|
408
|
+
*
|
|
409
|
+
* {@link materialize} writes into a throwaway {@link scratchFS} root; only on
|
|
410
|
+
* success does that root REPLACE the stable one. Two properties fall out of
|
|
411
|
+
* that ordering, and both are the reason to prefer this over writing into the
|
|
412
|
+
* stable path directly:
|
|
413
|
+
*
|
|
414
|
+
* - repeated runs reuse ONE directory instead of leaving a new scratch behind
|
|
415
|
+
* on every boot, and
|
|
416
|
+
* - a reader never observes a half-written tree, and a failed rebuild leaves
|
|
417
|
+
* the previous one intact.
|
|
418
|
+
*
|
|
419
|
+
* The swap is a `rename` within the same temp root, so it is atomic.
|
|
420
|
+
*
|
|
421
|
+
* @param key - Stable temp-relative path. May be nested (`"skills/abc123"`)
|
|
422
|
+
* to give each input its own directory.
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* const tools = await rebuildFS("databricks-aitools", (scratch) =>
|
|
427
|
+
* installInto(scratch.root),
|
|
428
|
+
* );
|
|
429
|
+
* ```
|
|
430
|
+
*/
|
|
431
|
+
export async function rebuildFS(
|
|
432
|
+
key: string,
|
|
433
|
+
materialize: (scratch: LocalFileSystem) => Promise<void>,
|
|
434
|
+
options: OsFileSystemOptions = {},
|
|
435
|
+
): Promise<LocalFileSystem> {
|
|
436
|
+
const scratch = scratchFS(key, options);
|
|
437
|
+
await scratch.init();
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await materialize(scratch);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
await rm(scratch.root, { recursive: true, force: true }).catch(() => undefined);
|
|
443
|
+
throw err;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const stable = tmpFS(key, options);
|
|
447
|
+
const stableHost = path.resolve(stable.root);
|
|
448
|
+
await rm(stableHost, { recursive: true, force: true });
|
|
449
|
+
await mkdir(path.dirname(stableHost), { recursive: true });
|
|
450
|
+
await rename(path.resolve(scratch.root), stableHost);
|
|
451
|
+
return stable;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Join {@link relativeRoot} under {@link base}. Empty / `.` keeps {@link base};
|
|
456
|
+
* leading `~` or `/` is stripped so an absolute-looking input cannot escape.
|
|
457
|
+
*/
|
|
458
|
+
function joinUnderBase(base: string, relativeRoot: string): string {
|
|
459
|
+
const rest = posixPath.toRelativeSegment(relativeRoot);
|
|
460
|
+
return rest ? path.resolve(base, rest) : base;
|
|
461
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local host path helpers for {@link LocalFileSystem}.
|
|
3
|
+
*
|
|
4
|
+
* Home / temp directory resolution lives in {@link ./os-path.ts}; this module
|
|
5
|
+
* owns `~` expansion and root resolution against that home.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { posixPath } from "@dbx-tools/shared-fs";
|
|
12
|
+
import { resolveLocalHome, type ResolveOsPathsOptions } from "./os-path.ts";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
APP_HOME,
|
|
16
|
+
clearOsPathsCache,
|
|
17
|
+
resolveLocalHome,
|
|
18
|
+
resolveLocalTemp,
|
|
19
|
+
resolveOsPaths,
|
|
20
|
+
type OsPaths,
|
|
21
|
+
type ResolveOsPathsOptions,
|
|
22
|
+
} from "./os-path.ts";
|
|
23
|
+
|
|
24
|
+
/** True when {@link input} is `~` or a path under `~/`. See {@link posixPath.isHomeRelativePath}. */
|
|
25
|
+
export const isHomeRelativePath = posixPath.isHomeRelativePath;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Expand `~` / `~/...` against {@link home}, joining with the HOST separator.
|
|
29
|
+
* Non-home inputs are returned trimmed and unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export function expandLocalHomePath(input: string, home: string = resolveLocalHome()): string {
|
|
32
|
+
return posixPath.expandHome(input, home, path.join);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a local filesystem root: expand `~` when needed, then `path.resolve`
|
|
37
|
+
* (so relative roots still land under `process.cwd()`).
|
|
38
|
+
*/
|
|
39
|
+
export function resolveLocalRoot(root: string, options: ResolveOsPathsOptions = {}): string {
|
|
40
|
+
const cwd = options.cwd ?? process.cwd();
|
|
41
|
+
return path.resolve(cwd, expandLocalHomePath(root, resolveLocalHome(options)));
|
|
42
|
+
}
|
package/src/os-path.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve durable local home and temp directories for Node hosts.
|
|
3
|
+
*
|
|
4
|
+
* Home order (mkdir + write/delete probe; failure skips):
|
|
5
|
+
* 1. `os.homedir()`
|
|
6
|
+
* 2. `HOME` / `USERPROFILE`
|
|
7
|
+
* 3. `/home/app` when {@link databricks.isAppEnv}
|
|
8
|
+
* 4. `./.home` under cwd
|
|
9
|
+
*
|
|
10
|
+
* Temp order (same ensure/probe/skip):
|
|
11
|
+
* 1. `os.tmpdir()`
|
|
12
|
+
* 2. `TMPDIR` / `TMP` / `TEMP`
|
|
13
|
+
* 3. `.tmp` under the resolved home
|
|
14
|
+
*
|
|
15
|
+
* Both paths are memoized per resolved cwd (same idea as
|
|
16
|
+
* `@dbx-tools/core` `project` command caching): cwd is part of the key because
|
|
17
|
+
* the `./.home` / `<home>/.tmp` fallbacks depend on it.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir, tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { databricks } from "@dbx-tools/appkit";
|
|
26
|
+
|
|
27
|
+
/** Databricks Apps container home when {@link databricks.isAppEnv}. */
|
|
28
|
+
export const APP_HOME = "/home/app";
|
|
29
|
+
|
|
30
|
+
/** Resolved home + temp for one cwd. */
|
|
31
|
+
export interface OsPaths {
|
|
32
|
+
readonly home: string;
|
|
33
|
+
readonly tmp: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Injectable knobs for {@link resolveOsPaths} (mostly tests). */
|
|
37
|
+
export interface ResolveOsPathsOptions {
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
/** Defaults to `os.homedir`. */
|
|
41
|
+
homeDir?: () => string;
|
|
42
|
+
/** Defaults to `os.tmpdir`. */
|
|
43
|
+
tmpDir?: () => string;
|
|
44
|
+
/** App-env candidate; defaults to {@link APP_HOME}. */
|
|
45
|
+
appHome?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Memoized {@link OsPaths} keyed by resolved cwd. */
|
|
49
|
+
const osPathsCache = new Map<string, OsPaths>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve home + temp for {@link cwd}, memoized when {@link cwd} is the process
|
|
53
|
+
* cwd (an explicit other cwd is computed fresh, matching core `project`
|
|
54
|
+
* caching).
|
|
55
|
+
*/
|
|
56
|
+
export function resolveOsPaths(options: ResolveOsPathsOptions = {}): OsPaths {
|
|
57
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
58
|
+
const cacheEnabled = cwd === path.resolve(process.cwd());
|
|
59
|
+
if (cacheEnabled) {
|
|
60
|
+
const hit = osPathsCache.get(cwd);
|
|
61
|
+
if (hit) return hit;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const env = options.env ?? process.env;
|
|
65
|
+
const home = resolveHome(env, cwd, options);
|
|
66
|
+
const tmp = resolveTemp(env, home, options);
|
|
67
|
+
const resolved: OsPaths = { home, tmp };
|
|
68
|
+
|
|
69
|
+
if (cacheEnabled) osPathsCache.set(cwd, resolved);
|
|
70
|
+
return resolved;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** {@link resolveOsPaths}.home */
|
|
74
|
+
export function resolveLocalHome(options: ResolveOsPathsOptions = {}): string {
|
|
75
|
+
return resolveOsPaths(options).home;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** {@link resolveOsPaths}.tmp */
|
|
79
|
+
export function resolveLocalTemp(options: ResolveOsPathsOptions = {}): string {
|
|
80
|
+
return resolveOsPaths(options).tmp;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop memoized entries (tests). */
|
|
84
|
+
export function clearOsPathsCache(): void {
|
|
85
|
+
osPathsCache.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveHome(env: NodeJS.ProcessEnv, cwd: string, options: ResolveOsPathsOptions): string {
|
|
89
|
+
const tried = new Set<string>();
|
|
90
|
+
const candidates: Array<string | undefined> = [
|
|
91
|
+
tryCall(options.homeDir ?? homedir),
|
|
92
|
+
env.HOME?.trim(),
|
|
93
|
+
env.USERPROFILE?.trim(),
|
|
94
|
+
databricks.isAppEnv(env) ? (options.appHome ?? APP_HOME) : undefined,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
for (const candidate of candidates) {
|
|
98
|
+
if (!candidate || tried.has(candidate)) continue;
|
|
99
|
+
tried.add(candidate);
|
|
100
|
+
if (isUsableDir(candidate)) return candidate;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fallback = path.resolve(cwd, ".home");
|
|
104
|
+
if (!isUsableDir(fallback)) {
|
|
105
|
+
throw new Error(`Unable to create a writable home directory at ${fallback}`);
|
|
106
|
+
}
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveTemp(env: NodeJS.ProcessEnv, home: string, options: ResolveOsPathsOptions): string {
|
|
111
|
+
const tried = new Set<string>();
|
|
112
|
+
// os.tmpdir first, then the standard override env vars (Node checks
|
|
113
|
+
// TMPDIR/TMP/TEMP on POSIX and TEMP/TMP on Windows inside tmpdir itself;
|
|
114
|
+
// listing them again lets a failed create/write fall through to the next).
|
|
115
|
+
const candidates: Array<string | undefined> = [
|
|
116
|
+
tryCall(options.tmpDir ?? tmpdir),
|
|
117
|
+
env.TMPDIR?.trim(),
|
|
118
|
+
env.TMP?.trim(),
|
|
119
|
+
env.TEMP?.trim(),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
if (!candidate || tried.has(candidate)) continue;
|
|
124
|
+
tried.add(candidate);
|
|
125
|
+
if (isUsableDir(candidate)) return candidate;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fallback = path.join(home, ".tmp");
|
|
129
|
+
if (!isUsableDir(fallback)) {
|
|
130
|
+
throw new Error(`Unable to create a writable temp directory at ${fallback}`);
|
|
131
|
+
}
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tryCall(fn: () => string): string | undefined {
|
|
136
|
+
try {
|
|
137
|
+
return fn().trim() || undefined;
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* True when {@link dir} can be created (if missing) and a probe file can be
|
|
145
|
+
* written then deleted. Read-only or otherwise unusable dirs return false.
|
|
146
|
+
*/
|
|
147
|
+
function isUsableDir(dir: string): boolean {
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(dir, { recursive: true });
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const probe = path.join(dir, `.dbx-os-path-probe-${process.pid}-${process.hrtime.bigint()}`);
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(probe, "");
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
} finally {
|
|
161
|
+
try {
|
|
162
|
+
unlinkSync(probe);
|
|
163
|
+
} catch {
|
|
164
|
+
// Probe cleanup is best-effort; usability already decided by the write.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|