@happyvertical/files 0.74.8
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/AGENT.md +33 -0
- package/LICENSE +7 -0
- package/README.md +136 -0
- package/dist/cli/claude-context.d.ts +3 -0
- package/dist/cli/claude-context.d.ts.map +1 -0
- package/dist/cli/claude-context.js +21 -0
- package/dist/cli/claude-context.js.map +1 -0
- package/dist/factory.d.ts +30 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/fetch.d.ts +142 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/filesystem-local.d.ts +82 -0
- package/dist/filesystem-local.d.ts.map +1 -0
- package/dist/filesystem.d.ts +155 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2719 -0
- package/dist/index.js.map +1 -0
- package/dist/legacy.d.ts +209 -0
- package/dist/legacy.d.ts.map +1 -0
- package/dist/node/local.d.ts +332 -0
- package/dist/node/local.d.ts.map +1 -0
- package/dist/providers/gdrive.d.ts +87 -0
- package/dist/providers/gdrive.d.ts.map +1 -0
- package/dist/providers/s3.d.ts +32 -0
- package/dist/providers/s3.d.ts.map +1 -0
- package/dist/redact.d.ts +2 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/shared/base.d.ts +106 -0
- package/dist/shared/base.d.ts.map +1 -0
- package/dist/shared/factory.d.ts +148 -0
- package/dist/shared/factory.d.ts.map +1 -0
- package/dist/shared/types.d.ts +464 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/metadata.json +35 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2719 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createWriteStream, existsSync, statSync, constants } from "node:fs";
|
|
3
|
+
import { rename, rm, lstat, realpath, writeFile, stat, chmod, chown, mkdir, readFile, readdir, access, rmdir, unlink, copyFile } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { join, dirname, basename, resolve, isAbsolute, relative, sep, extname } from "node:path";
|
|
6
|
+
import { Readable, Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { getTempDirectory } from "@happyvertical/utils";
|
|
9
|
+
import { URL as URL$1 } from "node:url";
|
|
10
|
+
import { S3Client, ListObjectsV2Command, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand } from "@aws-sdk/client-s3";
|
|
11
|
+
function createTempFilePath(filepath) {
|
|
12
|
+
return join(
|
|
13
|
+
dirname(filepath),
|
|
14
|
+
`.${basename(filepath)}.${randomUUID()}.download`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
function isErrnoException(error) {
|
|
18
|
+
return error instanceof Error && "code" in error;
|
|
19
|
+
}
|
|
20
|
+
async function resolveDestinationPath(filepath) {
|
|
21
|
+
try {
|
|
22
|
+
const destinationStats = await lstat(filepath);
|
|
23
|
+
if (!destinationStats.isSymbolicLink()) {
|
|
24
|
+
return filepath;
|
|
25
|
+
}
|
|
26
|
+
return await realpath(filepath);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
29
|
+
return filepath;
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function preserveExistingDestinationMetadata(sourcePath, tempFilepath) {
|
|
35
|
+
try {
|
|
36
|
+
const sourceStats = await stat(sourcePath);
|
|
37
|
+
await chmod(tempFilepath, sourceStats.mode);
|
|
38
|
+
try {
|
|
39
|
+
await chown(tempFilepath, sourceStats.uid, sourceStats.gid);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (!isErrnoException(error) || !["EPERM", "EINVAL", "ENOSYS", "EROFS"].includes(error.code ?? "")) {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(isErrnoException(error) && error.code === "ENOENT")) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class MaxBytesTransform extends Transform {
|
|
52
|
+
constructor(maxBytes) {
|
|
53
|
+
super();
|
|
54
|
+
this.maxBytes = maxBytes;
|
|
55
|
+
}
|
|
56
|
+
totalBytes = 0;
|
|
57
|
+
_transform(chunk, _encoding, callback) {
|
|
58
|
+
this.totalBytes += chunk.byteLength;
|
|
59
|
+
if (this.totalBytes > this.maxBytes) {
|
|
60
|
+
callback(
|
|
61
|
+
new Error(
|
|
62
|
+
`Downloaded content exceeded maxBytes (${this.totalBytes} > ${this.maxBytes})`
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
callback(null, chunk);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
class RateLimiter {
|
|
71
|
+
/**
|
|
72
|
+
* Map of domains to their rate limit configurations
|
|
73
|
+
* Each domain tracks: lastRequest time, request limit, interval, and current queue size
|
|
74
|
+
*/
|
|
75
|
+
domains = /* @__PURE__ */ new Map();
|
|
76
|
+
/**
|
|
77
|
+
* Default maximum number of requests per interval
|
|
78
|
+
* Applied to domains that don't have specific limits configured
|
|
79
|
+
*/
|
|
80
|
+
defaultLimit = 6;
|
|
81
|
+
/**
|
|
82
|
+
* Default interval in milliseconds (500ms)
|
|
83
|
+
* Time window for the request limit enforcement
|
|
84
|
+
*/
|
|
85
|
+
defaultInterval = 500;
|
|
86
|
+
getDefaultDomainConfig() {
|
|
87
|
+
const config = this.domains.get("default");
|
|
88
|
+
if (!config) {
|
|
89
|
+
throw new Error("Default domain rate limit configuration is missing");
|
|
90
|
+
}
|
|
91
|
+
return config;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Creates a new RateLimiter with default settings
|
|
95
|
+
* Initializes with a 'default' domain configuration used as fallback
|
|
96
|
+
*/
|
|
97
|
+
constructor() {
|
|
98
|
+
this.domains.set("default", {
|
|
99
|
+
lastRequest: 0,
|
|
100
|
+
limit: this.defaultLimit,
|
|
101
|
+
interval: this.defaultInterval,
|
|
102
|
+
queue: 0
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Extracts the domain from a URL for rate limiting purposes
|
|
107
|
+
*
|
|
108
|
+
* @param url - URL to extract domain from
|
|
109
|
+
* @returns Domain string (hostname) or 'default' if the URL is invalid
|
|
110
|
+
*
|
|
111
|
+
* @internal
|
|
112
|
+
*/
|
|
113
|
+
getDomain(url) {
|
|
114
|
+
try {
|
|
115
|
+
return new URL(url).hostname;
|
|
116
|
+
} catch {
|
|
117
|
+
return "default";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Waits until the next request can be made according to rate limits
|
|
122
|
+
*
|
|
123
|
+
* This method implements the core rate limiting logic. It checks if the
|
|
124
|
+
* current request would exceed the domain's rate limit and delays if necessary.
|
|
125
|
+
*
|
|
126
|
+
* @param url - URL to check rate limits for (domain extracted automatically)
|
|
127
|
+
* @returns Promise that resolves when the request can proceed safely
|
|
128
|
+
*
|
|
129
|
+
* @internal
|
|
130
|
+
*/
|
|
131
|
+
async waitForNext(url) {
|
|
132
|
+
const domain = this.getDomain(url);
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const domainConfig = this.domains.get(domain) || this.getDefaultDomainConfig();
|
|
135
|
+
if (domainConfig.queue >= domainConfig.limit) {
|
|
136
|
+
const timeToWait = Math.max(
|
|
137
|
+
0,
|
|
138
|
+
domainConfig.lastRequest + domainConfig.interval - now
|
|
139
|
+
);
|
|
140
|
+
if (timeToWait > 0) {
|
|
141
|
+
await new Promise((resolve2) => setTimeout(resolve2, timeToWait));
|
|
142
|
+
}
|
|
143
|
+
domainConfig.queue = 0;
|
|
144
|
+
}
|
|
145
|
+
domainConfig.lastRequest = now;
|
|
146
|
+
domainConfig.queue++;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Sets rate limit for a specific domain
|
|
150
|
+
*
|
|
151
|
+
* @param domain - Domain to set limits for
|
|
152
|
+
* @param limit - Maximum number of requests per interval
|
|
153
|
+
* @param interval - Interval in milliseconds
|
|
154
|
+
*/
|
|
155
|
+
setDomainLimit(domain, limit, interval) {
|
|
156
|
+
this.domains.set(domain, {
|
|
157
|
+
lastRequest: 0,
|
|
158
|
+
limit,
|
|
159
|
+
interval,
|
|
160
|
+
queue: 0
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Gets rate limit configuration for a domain
|
|
165
|
+
*
|
|
166
|
+
* @param domain - Domain to get limits for
|
|
167
|
+
* @returns Rate limit configuration
|
|
168
|
+
*/
|
|
169
|
+
getDomainLimit(domain) {
|
|
170
|
+
return this.domains.get(domain) || this.getDefaultDomainConfig();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const rateLimiter = new RateLimiter();
|
|
174
|
+
async function addRateLimit(domain, limit, interval) {
|
|
175
|
+
rateLimiter.setDomainLimit(domain, limit, interval);
|
|
176
|
+
}
|
|
177
|
+
async function getRateLimit(domain) {
|
|
178
|
+
const config = rateLimiter.getDomainLimit(domain);
|
|
179
|
+
return {
|
|
180
|
+
limit: config.limit,
|
|
181
|
+
interval: config.interval
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function rateLimitedFetch(url, options) {
|
|
185
|
+
await rateLimiter.waitForNext(url);
|
|
186
|
+
return fetch(url, options);
|
|
187
|
+
}
|
|
188
|
+
function buildFetchSignal(timeout, signal) {
|
|
189
|
+
if (timeout == null) {
|
|
190
|
+
return signal ?? void 0;
|
|
191
|
+
}
|
|
192
|
+
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
193
|
+
if (!signal) {
|
|
194
|
+
return timeoutSignal;
|
|
195
|
+
}
|
|
196
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
197
|
+
}
|
|
198
|
+
function assertOkResponse(response, url) {
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Failed to fetch ${url}: ${response.status} ${response.statusText}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function fetchText(url) {
|
|
206
|
+
const response = await rateLimitedFetch(url);
|
|
207
|
+
assertOkResponse(response, url);
|
|
208
|
+
return response.text();
|
|
209
|
+
}
|
|
210
|
+
async function fetchJSON(url) {
|
|
211
|
+
const response = await rateLimitedFetch(url);
|
|
212
|
+
assertOkResponse(response, url);
|
|
213
|
+
return response.json();
|
|
214
|
+
}
|
|
215
|
+
async function fetchBuffer(url) {
|
|
216
|
+
const response = await rateLimitedFetch(url);
|
|
217
|
+
assertOkResponse(response, url);
|
|
218
|
+
return Buffer.from(await response.arrayBuffer());
|
|
219
|
+
}
|
|
220
|
+
async function fetchToFile(url, filepath, options = {}) {
|
|
221
|
+
const { timeout, maxBytes, signal, ...requestInit } = options;
|
|
222
|
+
const destinationPath = await resolveDestinationPath(filepath);
|
|
223
|
+
const response = await rateLimitedFetch(url, {
|
|
224
|
+
...requestInit,
|
|
225
|
+
signal: buildFetchSignal(timeout, signal)
|
|
226
|
+
});
|
|
227
|
+
assertOkResponse(response, url);
|
|
228
|
+
await writeResponseToResolvedPath(response, destinationPath, { maxBytes });
|
|
229
|
+
}
|
|
230
|
+
async function writeResponseBodyToTempFile(response, tempFilepath, options = {}) {
|
|
231
|
+
const { maxBytes } = options;
|
|
232
|
+
if (!response.body) {
|
|
233
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
234
|
+
if (maxBytes != null && buffer.byteLength > maxBytes) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Downloaded content exceeded maxBytes (${buffer.byteLength} > ${maxBytes})`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
await writeFile(tempFilepath, buffer);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const source = Readable.fromWeb(
|
|
243
|
+
response.body
|
|
244
|
+
);
|
|
245
|
+
const destination = createWriteStream(tempFilepath);
|
|
246
|
+
if (maxBytes != null) {
|
|
247
|
+
await pipeline(source, new MaxBytesTransform(maxBytes), destination);
|
|
248
|
+
} else {
|
|
249
|
+
await pipeline(source, destination);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function writeResponseToResolvedPath(response, destinationPath, options = {}) {
|
|
253
|
+
const tempFilepath = createTempFilePath(destinationPath);
|
|
254
|
+
try {
|
|
255
|
+
await writeResponseBodyToTempFile(response, tempFilepath, options);
|
|
256
|
+
await preserveExistingDestinationMetadata(destinationPath, tempFilepath);
|
|
257
|
+
await rename(tempFilepath, destinationPath);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
await rm(tempFilepath, { force: true }).catch(() => {
|
|
260
|
+
});
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function writeResponseToFile(response, filepath, options = {}) {
|
|
265
|
+
const destinationPath = await resolveDestinationPath(filepath);
|
|
266
|
+
await writeResponseToResolvedPath(response, destinationPath, options);
|
|
267
|
+
}
|
|
268
|
+
class FilesystemAdapter {
|
|
269
|
+
/**
|
|
270
|
+
* Configuration options
|
|
271
|
+
*/
|
|
272
|
+
options;
|
|
273
|
+
/**
|
|
274
|
+
* Cache directory path
|
|
275
|
+
*/
|
|
276
|
+
cacheDir;
|
|
277
|
+
/**
|
|
278
|
+
* Creates a new FilesystemAdapter instance
|
|
279
|
+
*
|
|
280
|
+
* @param options - Configuration options
|
|
281
|
+
*/
|
|
282
|
+
constructor(options) {
|
|
283
|
+
this.options = options;
|
|
284
|
+
this.cacheDir = options.cacheDir || getTempDirectory("cache");
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Factory method to create and initialize a FilesystemAdapter
|
|
288
|
+
*
|
|
289
|
+
* @param options - Configuration options
|
|
290
|
+
* @returns Promise resolving to an initialized FilesystemAdapter
|
|
291
|
+
*/
|
|
292
|
+
static async create(options) {
|
|
293
|
+
const fs = new FilesystemAdapter(options);
|
|
294
|
+
await fs.initialize();
|
|
295
|
+
return fs;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Initializes the adapter by creating the cache directory
|
|
299
|
+
*/
|
|
300
|
+
async initialize() {
|
|
301
|
+
await mkdir(this.cacheDir, { recursive: true });
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Downloads a file from a URL
|
|
305
|
+
*
|
|
306
|
+
* @param url - URL to download from
|
|
307
|
+
* @param options - Download options
|
|
308
|
+
* @param options.force - Whether to force download even if cached
|
|
309
|
+
* @returns Promise resolving to the path of the downloaded file
|
|
310
|
+
*/
|
|
311
|
+
async download(_url, _options = {
|
|
312
|
+
force: false
|
|
313
|
+
}) {
|
|
314
|
+
return "";
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Checks if a file or directory exists
|
|
318
|
+
*
|
|
319
|
+
* @param path - Path to check
|
|
320
|
+
* @returns Promise resolving to boolean indicating existence
|
|
321
|
+
*/
|
|
322
|
+
async exists(_path) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Reads a file's contents
|
|
327
|
+
*
|
|
328
|
+
* @param path - Path to the file
|
|
329
|
+
* @returns Promise resolving to the file contents as a string
|
|
330
|
+
*/
|
|
331
|
+
async read(_path) {
|
|
332
|
+
return "";
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Writes content to a file
|
|
336
|
+
*
|
|
337
|
+
* @param path - Path to the file
|
|
338
|
+
* @param content - Content to write
|
|
339
|
+
* @returns Promise that resolves when the write is complete
|
|
340
|
+
*/
|
|
341
|
+
async write(_path, _content) {
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Deletes a file or directory
|
|
345
|
+
*
|
|
346
|
+
* @param path - Path to delete
|
|
347
|
+
* @returns Promise that resolves when the deletion is complete
|
|
348
|
+
*/
|
|
349
|
+
async delete(_path) {
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Lists files in a directory
|
|
353
|
+
*
|
|
354
|
+
* @param path - Directory path to list
|
|
355
|
+
* @returns Promise resolving to an array of file names
|
|
356
|
+
*/
|
|
357
|
+
async list(_path) {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Gets data from cache if available and not expired
|
|
362
|
+
*
|
|
363
|
+
* @param file - Cache file identifier
|
|
364
|
+
* @param expiry - Cache expiry time in milliseconds
|
|
365
|
+
* @returns Promise resolving to the cached data or undefined if not found/expired
|
|
366
|
+
*/
|
|
367
|
+
async getCached(file, expiry = 3e5) {
|
|
368
|
+
return getCached(file, expiry);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Sets data in cache
|
|
372
|
+
*
|
|
373
|
+
* @param file - Cache file identifier
|
|
374
|
+
* @param data - Data to cache
|
|
375
|
+
* @returns Promise that resolves when the data is cached
|
|
376
|
+
*/
|
|
377
|
+
async setCached(file, data) {
|
|
378
|
+
return setCached(file, data);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const TMP_DIR = path.resolve(getTempDirectory("kissd"));
|
|
382
|
+
const isFile = (file) => {
|
|
383
|
+
try {
|
|
384
|
+
const fileStat = statSync(file);
|
|
385
|
+
return fileStat.isDirectory() ? false : fileStat;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const isDirectory = (dir) => {
|
|
391
|
+
try {
|
|
392
|
+
const dirStat = statSync(dir);
|
|
393
|
+
if (dirStat.isDirectory()) return true;
|
|
394
|
+
throw new Error(`${dir} exists but isn't a directory`);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const ensureDirectoryExists = async (dir) => {
|
|
403
|
+
if (!isDirectory(dir)) {
|
|
404
|
+
console.log(`Creating directory: ${dir}`);
|
|
405
|
+
await mkdir(dir, { recursive: true });
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
const upload = async (url, data) => {
|
|
409
|
+
try {
|
|
410
|
+
const response = await fetch(url, {
|
|
411
|
+
method: "PUT",
|
|
412
|
+
body: Buffer.isBuffer(data) ? new Uint8Array(data) : data,
|
|
413
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
414
|
+
});
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
throw new Error(`unexpected response ${response.statusText}`);
|
|
417
|
+
}
|
|
418
|
+
return response;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
const err = error;
|
|
421
|
+
console.error(`Error uploading data to ${url}
|
|
422
|
+
Error: ${err.message}`);
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
async function download(url, filepath) {
|
|
427
|
+
try {
|
|
428
|
+
const response = await fetch(url);
|
|
429
|
+
if (!response.ok) {
|
|
430
|
+
throw new Error(`Unexpected response ${response.statusText}`);
|
|
431
|
+
}
|
|
432
|
+
const fileStream = createWriteStream(filepath);
|
|
433
|
+
return new Promise((resolve2, reject) => {
|
|
434
|
+
fileStream.on("error", reject);
|
|
435
|
+
fileStream.on("finish", resolve2);
|
|
436
|
+
response.body?.pipeTo(
|
|
437
|
+
new WritableStream({
|
|
438
|
+
write(chunk) {
|
|
439
|
+
fileStream.write(Buffer.from(chunk));
|
|
440
|
+
},
|
|
441
|
+
close() {
|
|
442
|
+
fileStream.end();
|
|
443
|
+
},
|
|
444
|
+
abort(reason) {
|
|
445
|
+
fileStream.destroy();
|
|
446
|
+
reject(reason);
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
).catch(reject);
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
const err = error;
|
|
453
|
+
console.error("Error downloading file:", err);
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const downloadFileWithCache = async (url, targetPath = null) => {
|
|
458
|
+
const parsedUrl = new URL$1(url);
|
|
459
|
+
console.log(targetPath);
|
|
460
|
+
const downloadPath = targetPath || `${TMP_DIR}/downloads/${parsedUrl.hostname}${parsedUrl.pathname}`;
|
|
461
|
+
console.log("downloadPath", downloadPath);
|
|
462
|
+
if (!isFile(downloadPath)) {
|
|
463
|
+
await ensureDirectoryExists(dirname(downloadPath));
|
|
464
|
+
await download(url, downloadPath);
|
|
465
|
+
}
|
|
466
|
+
return downloadPath;
|
|
467
|
+
};
|
|
468
|
+
const listFiles = async (dirPath, options = { match: /.*/ }) => {
|
|
469
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
470
|
+
const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
471
|
+
return options.match ? files.filter((item) => options.match?.test(item)) : files;
|
|
472
|
+
};
|
|
473
|
+
async function getCached(file, expiry = 3e5) {
|
|
474
|
+
const cacheFile = path.resolve(TMP_DIR, file);
|
|
475
|
+
const cached = existsSync(cacheFile);
|
|
476
|
+
if (cached) {
|
|
477
|
+
const stats = statSync(cacheFile);
|
|
478
|
+
const modTime = new Date(stats.mtime);
|
|
479
|
+
const now = /* @__PURE__ */ new Date();
|
|
480
|
+
const isExpired = expiry && now.getTime() - modTime.getTime() > expiry;
|
|
481
|
+
if (!isExpired) {
|
|
482
|
+
return await readFile(cacheFile, "utf8");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async function setCached(file, data) {
|
|
487
|
+
const cacheFile = path.resolve(TMP_DIR, file);
|
|
488
|
+
await ensureDirectoryExists(path.dirname(cacheFile));
|
|
489
|
+
await writeFile(cacheFile, data);
|
|
490
|
+
}
|
|
491
|
+
const mimeTypes = {
|
|
492
|
+
".html": "text/html",
|
|
493
|
+
".js": "application/javascript",
|
|
494
|
+
".json": "application/json",
|
|
495
|
+
".css": "text/css",
|
|
496
|
+
".png": "image/png",
|
|
497
|
+
".jpg": "image/jpeg",
|
|
498
|
+
".jpeg": "image/jpeg",
|
|
499
|
+
".gif": "image/gif",
|
|
500
|
+
".txt": "text/plain",
|
|
501
|
+
".doc": "application/msword",
|
|
502
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
503
|
+
".xls": "application/vnd.ms-excel",
|
|
504
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
505
|
+
".pdf": "application/pdf",
|
|
506
|
+
".xml": "application/xml",
|
|
507
|
+
".zip": "application/zip",
|
|
508
|
+
".rar": "application/x-rar-compressed",
|
|
509
|
+
".mp3": "audio/mpeg",
|
|
510
|
+
".mp4": "video/mp4",
|
|
511
|
+
".avi": "video/x-msvideo",
|
|
512
|
+
".mov": "video/quicktime"
|
|
513
|
+
// Add more mappings as needed
|
|
514
|
+
};
|
|
515
|
+
function getMimeType(fileOrUrl) {
|
|
516
|
+
const urlPattern = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
|
|
517
|
+
let extension;
|
|
518
|
+
if (urlPattern.test(fileOrUrl)) {
|
|
519
|
+
const url = new URL$1(fileOrUrl);
|
|
520
|
+
extension = path.extname(url.pathname);
|
|
521
|
+
} else {
|
|
522
|
+
extension = path.extname(fileOrUrl);
|
|
523
|
+
}
|
|
524
|
+
return mimeTypes[extension.toLowerCase()] || "application/octet-stream";
|
|
525
|
+
}
|
|
526
|
+
class FilesystemError extends Error {
|
|
527
|
+
constructor(message, code, path2, provider) {
|
|
528
|
+
super(message);
|
|
529
|
+
this.code = code;
|
|
530
|
+
this.path = path2;
|
|
531
|
+
this.provider = provider;
|
|
532
|
+
this.name = "FilesystemError";
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
class FileNotFoundError extends FilesystemError {
|
|
536
|
+
constructor(path2, provider) {
|
|
537
|
+
super(`File not found: ${path2}`, "ENOENT", path2, provider);
|
|
538
|
+
this.name = "FileNotFoundError";
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
class PermissionError extends FilesystemError {
|
|
542
|
+
constructor(path2, provider) {
|
|
543
|
+
super(`Permission denied: ${path2}`, "EACCES", path2, provider);
|
|
544
|
+
this.name = "PermissionError";
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
class DirectoryNotEmptyError extends FilesystemError {
|
|
548
|
+
constructor(path2, provider) {
|
|
549
|
+
super(`Directory not empty: ${path2}`, "ENOTEMPTY", path2, provider);
|
|
550
|
+
this.name = "DirectoryNotEmptyError";
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
class InvalidPathError extends FilesystemError {
|
|
554
|
+
constructor(path2, provider) {
|
|
555
|
+
super(`Invalid path: ${path2}`, "EINVAL", path2, provider);
|
|
556
|
+
this.name = "InvalidPathError";
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
class BaseFilesystemProvider {
|
|
560
|
+
basePath;
|
|
561
|
+
applyBasePath;
|
|
562
|
+
cacheDir;
|
|
563
|
+
createMissing;
|
|
564
|
+
providerType;
|
|
565
|
+
constructor(options = {}) {
|
|
566
|
+
this.basePath = options.basePath || "";
|
|
567
|
+
this.applyBasePath = options.applyBasePath ?? true;
|
|
568
|
+
this.cacheDir = options.cacheDir || this.getDefaultCacheDir();
|
|
569
|
+
this.createMissing = options.createMissing ?? true;
|
|
570
|
+
this.providerType = this.constructor.name.toLowerCase().replace("filesystemprovider", "");
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Get default cache directory for the current context
|
|
574
|
+
*/
|
|
575
|
+
getDefaultCacheDir() {
|
|
576
|
+
try {
|
|
577
|
+
const { getTempDirectory: getTempDirectory2 } = require("@happyvertical/utils");
|
|
578
|
+
return getTempDirectory2("files-cache");
|
|
579
|
+
} catch {
|
|
580
|
+
if (process?.versions?.node) {
|
|
581
|
+
try {
|
|
582
|
+
const { tmpdir } = require("node:os");
|
|
583
|
+
const { join: join2 } = require("node:path");
|
|
584
|
+
return join2(tmpdir(), "have-sdk", "files-cache");
|
|
585
|
+
} catch {
|
|
586
|
+
return "./tmp/have-sdk/files-cache";
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return "./tmp/have-sdk/files-cache";
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Throw error for unsupported operations
|
|
594
|
+
*/
|
|
595
|
+
throwUnsupported(operation) {
|
|
596
|
+
throw new FilesystemError(
|
|
597
|
+
`Operation '${operation}' not supported by ${this.providerType} provider`,
|
|
598
|
+
"ENOTSUP",
|
|
599
|
+
void 0,
|
|
600
|
+
this.providerType
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Normalize path by removing leading/trailing slashes and resolving relative paths
|
|
605
|
+
*/
|
|
606
|
+
normalizePath(path2) {
|
|
607
|
+
if (!path2) return "";
|
|
608
|
+
let normalized = path2.startsWith("/") ? path2.slice(1) : path2;
|
|
609
|
+
if (this.applyBasePath && this.basePath) {
|
|
610
|
+
normalized = this.joinPaths(this.basePath, normalized);
|
|
611
|
+
}
|
|
612
|
+
return normalized;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Universal path joining function that works in both Node.js and browser
|
|
616
|
+
*/
|
|
617
|
+
joinPaths(...paths) {
|
|
618
|
+
return paths.filter((p) => p && p.length > 0).map((p) => p.replace(/^\/+|\/+$/g, "")).join("/");
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Validate that a path is safe (no directory traversal)
|
|
622
|
+
*/
|
|
623
|
+
validatePath(path2) {
|
|
624
|
+
if (!path2) {
|
|
625
|
+
throw new FilesystemError("Path cannot be empty", "EINVAL", path2);
|
|
626
|
+
}
|
|
627
|
+
if (path2.includes("..") || path2.includes("~")) {
|
|
628
|
+
throw new FilesystemError(
|
|
629
|
+
"Path contains invalid characters (directory traversal)",
|
|
630
|
+
"EINVAL",
|
|
631
|
+
path2
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Get cache key for a given path
|
|
637
|
+
*/
|
|
638
|
+
getCacheKey(path2) {
|
|
639
|
+
return `${this.constructor.name}-${path2}`;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Provider methods with default implementations (may be overridden)
|
|
643
|
+
*/
|
|
644
|
+
async upload(_localPath, _remotePath, _options = {}) {
|
|
645
|
+
this.throwUnsupported("upload");
|
|
646
|
+
}
|
|
647
|
+
async download(_remotePath, _localPath, _options = {}) {
|
|
648
|
+
this.throwUnsupported("download");
|
|
649
|
+
}
|
|
650
|
+
async downloadWithCache(remotePath, options = {}) {
|
|
651
|
+
const cacheKey = this.getCacheKey(remotePath);
|
|
652
|
+
if (!options.force) {
|
|
653
|
+
const cached = await this.cache.get(cacheKey, options.expiry);
|
|
654
|
+
if (cached) {
|
|
655
|
+
return cached;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const localPath = await this.download(remotePath, void 0, options);
|
|
659
|
+
await this.cache.set(cacheKey, localPath);
|
|
660
|
+
return localPath;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Cache implementation - providers can override for their specific storage
|
|
664
|
+
*/
|
|
665
|
+
cache = {
|
|
666
|
+
get: async (_key, _expiry) => {
|
|
667
|
+
this.throwUnsupported("cache.get");
|
|
668
|
+
},
|
|
669
|
+
set: async (_key, _data) => {
|
|
670
|
+
this.throwUnsupported("cache.set");
|
|
671
|
+
},
|
|
672
|
+
clear: async (_key) => {
|
|
673
|
+
this.throwUnsupported("cache.clear");
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
// Legacy method implementations - providers can override or use default ENOTSUP errors
|
|
677
|
+
/**
|
|
678
|
+
* Check if a path is a file (legacy)
|
|
679
|
+
*/
|
|
680
|
+
async isFile(file) {
|
|
681
|
+
try {
|
|
682
|
+
const stats = await this.getStats(file);
|
|
683
|
+
return stats.isFile ? stats : false;
|
|
684
|
+
} catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Check if a path is a directory (legacy)
|
|
690
|
+
*/
|
|
691
|
+
async isDirectory(dir) {
|
|
692
|
+
try {
|
|
693
|
+
const stats = await this.getStats(dir);
|
|
694
|
+
return stats.isDirectory;
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Create a directory if it doesn't exist (legacy)
|
|
701
|
+
*/
|
|
702
|
+
async ensureDirectoryExists(dir) {
|
|
703
|
+
if (!await this.isDirectory(dir)) {
|
|
704
|
+
await this.createDirectory(dir, { recursive: true });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Upload data to a URL using PUT method (legacy)
|
|
709
|
+
*/
|
|
710
|
+
async uploadToUrl(_url, _data) {
|
|
711
|
+
this.throwUnsupported("uploadToUrl");
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Download a file from a URL and save it to a local file (legacy)
|
|
715
|
+
*/
|
|
716
|
+
async downloadFromUrl(_url, _filepath) {
|
|
717
|
+
this.throwUnsupported("downloadFromUrl");
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Download a file with caching support (legacy)
|
|
721
|
+
*/
|
|
722
|
+
async downloadFileWithCache(_url, _targetPath) {
|
|
723
|
+
this.throwUnsupported("downloadFileWithCache");
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* List files in a directory with optional filtering (legacy)
|
|
727
|
+
*/
|
|
728
|
+
async listFiles(dirPath, options = { match: /.*/ }) {
|
|
729
|
+
const files = await this.list(dirPath);
|
|
730
|
+
const fileNames = files.filter((file) => !file.isDirectory).map((file) => file.name);
|
|
731
|
+
return options.match ? fileNames.filter((name) => options.match?.test(name)) : fileNames;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Get data from cache if available and not expired (legacy)
|
|
735
|
+
*/
|
|
736
|
+
async getCached(file, expiry = 3e5) {
|
|
737
|
+
return await this.cache.get(file, expiry);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Set data in cache (legacy)
|
|
741
|
+
*/
|
|
742
|
+
async setCached(file, data) {
|
|
743
|
+
await this.cache.set(file, data);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
class LocalFilesystemProvider extends BaseFilesystemProvider {
|
|
747
|
+
rootPath;
|
|
748
|
+
constructor(options = {}) {
|
|
749
|
+
super({ ...options, applyBasePath: false });
|
|
750
|
+
this.rootPath = options.basePath ? resolve(options.basePath) : process.cwd();
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Resolve a relative path to an absolute path within the root directory
|
|
754
|
+
*
|
|
755
|
+
* This method validates the path, normalizes it, and resolves it relative to
|
|
756
|
+
* the configured root path. It ensures path safety by preventing directory
|
|
757
|
+
* traversal attacks.
|
|
758
|
+
*
|
|
759
|
+
* @param path - Relative path to resolve
|
|
760
|
+
* @returns Absolute path within the root directory
|
|
761
|
+
* @throws {FilesystemError} When path is invalid or contains directory traversal
|
|
762
|
+
*
|
|
763
|
+
* @internal
|
|
764
|
+
*/
|
|
765
|
+
resolvePath(path2) {
|
|
766
|
+
this.validatePath(path2);
|
|
767
|
+
const normalized = this.normalizePath(path2);
|
|
768
|
+
return join(this.rootPath, normalized);
|
|
769
|
+
}
|
|
770
|
+
resolveCachePath(path2) {
|
|
771
|
+
this.validatePath(path2);
|
|
772
|
+
if (isAbsolute(path2)) {
|
|
773
|
+
throw new InvalidPathError(path2, this.providerType);
|
|
774
|
+
}
|
|
775
|
+
const normalized = this.normalizePath(path2);
|
|
776
|
+
const cacheRoot = resolve(this.cacheDir);
|
|
777
|
+
const cachePath = resolve(cacheRoot, normalized);
|
|
778
|
+
const relativePath = relative(cacheRoot, cachePath);
|
|
779
|
+
if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
780
|
+
throw new InvalidPathError(path2, this.providerType);
|
|
781
|
+
}
|
|
782
|
+
return cachePath;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Check if a file or directory exists at the specified path
|
|
786
|
+
*
|
|
787
|
+
* Uses Node.js fs.access() to check for file existence without reading
|
|
788
|
+
* the file contents. This is more efficient than trying to read or stat
|
|
789
|
+
* the file when only existence needs to be verified.
|
|
790
|
+
*
|
|
791
|
+
* @param path - Path to check for existence
|
|
792
|
+
* @returns Promise resolving to true if the path exists, false otherwise
|
|
793
|
+
*
|
|
794
|
+
* @example
|
|
795
|
+
* ```typescript
|
|
796
|
+
* const exists = await fs.exists('config.json');
|
|
797
|
+
* if (exists) {
|
|
798
|
+
* console.log('Config file found');
|
|
799
|
+
* }
|
|
800
|
+
* ```
|
|
801
|
+
*/
|
|
802
|
+
async exists(path2) {
|
|
803
|
+
try {
|
|
804
|
+
const resolvedPath = this.resolvePath(path2);
|
|
805
|
+
await access(resolvedPath, constants.F_OK);
|
|
806
|
+
return true;
|
|
807
|
+
} catch {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Read file contents as string or Buffer
|
|
813
|
+
*
|
|
814
|
+
* Reads the entire contents of a file into memory. For large files, consider
|
|
815
|
+
* using streaming approaches to avoid memory issues.
|
|
816
|
+
*
|
|
817
|
+
* @param path - Path to the file to read
|
|
818
|
+
* @param options - Read options including encoding and raw mode
|
|
819
|
+
* @param options.encoding - Text encoding for string output (default: 'utf8')
|
|
820
|
+
* @param options.raw - If true, returns Buffer instead of string
|
|
821
|
+
* @returns Promise resolving to file contents as string or Buffer
|
|
822
|
+
* @throws {FileNotFoundError} When the file doesn't exist
|
|
823
|
+
* @throws {PermissionError} When read access is denied
|
|
824
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```typescript
|
|
828
|
+
* // Read as UTF-8 string (default)
|
|
829
|
+
* const text = await fs.read('document.txt');
|
|
830
|
+
*
|
|
831
|
+
* // Read as Buffer for binary data
|
|
832
|
+
* const buffer = await fs.read('image.png', { raw: true });
|
|
833
|
+
*
|
|
834
|
+
* // Read with specific encoding
|
|
835
|
+
* const latin1Text = await fs.read('legacy.txt', { encoding: 'latin1' });
|
|
836
|
+
* ```
|
|
837
|
+
*/
|
|
838
|
+
async read(path2, options = {}) {
|
|
839
|
+
try {
|
|
840
|
+
const resolvedPath = this.resolvePath(path2);
|
|
841
|
+
if (options.raw) {
|
|
842
|
+
return await readFile(resolvedPath);
|
|
843
|
+
}
|
|
844
|
+
return await readFile(resolvedPath, options.encoding || "utf8");
|
|
845
|
+
} catch (error) {
|
|
846
|
+
if (error.code === "ENOENT") {
|
|
847
|
+
throw new FileNotFoundError(path2, "local");
|
|
848
|
+
}
|
|
849
|
+
if (error.code === "EACCES") {
|
|
850
|
+
throw new PermissionError(path2, "local");
|
|
851
|
+
}
|
|
852
|
+
throw new FilesystemError(
|
|
853
|
+
`Failed to read file: ${error.message}`,
|
|
854
|
+
error.code || "UNKNOWN",
|
|
855
|
+
path2,
|
|
856
|
+
"local"
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Write content to a file
|
|
862
|
+
*
|
|
863
|
+
* Creates or overwrites a file with the provided content. Can automatically
|
|
864
|
+
* create parent directories if they don't exist. Supports both string and
|
|
865
|
+
* binary data.
|
|
866
|
+
*
|
|
867
|
+
* @param path - Path where the file should be written
|
|
868
|
+
* @param content - Content to write (string or Buffer)
|
|
869
|
+
* @param options - Write options
|
|
870
|
+
* @param options.encoding - Text encoding for string content
|
|
871
|
+
* @param options.mode - File permissions (e.g., 0o644)
|
|
872
|
+
* @param options.createParents - Whether to create parent directories (default: true)
|
|
873
|
+
* @returns Promise that resolves when the file is written
|
|
874
|
+
* @throws {FileNotFoundError} When parent directory doesn't exist and createParents is false
|
|
875
|
+
* @throws {PermissionError} When write access is denied
|
|
876
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
877
|
+
*
|
|
878
|
+
* @example
|
|
879
|
+
* ```typescript
|
|
880
|
+
* // Write text file
|
|
881
|
+
* await fs.write('config.json', JSON.stringify({ key: 'value' }));
|
|
882
|
+
*
|
|
883
|
+
* // Write binary data
|
|
884
|
+
* const imageBuffer = await someImageProcessing();
|
|
885
|
+
* await fs.write('output.png', imageBuffer);
|
|
886
|
+
*
|
|
887
|
+
* // Write with specific permissions
|
|
888
|
+
* await fs.write('secret.txt', 'sensitive data', { mode: 0o600 });
|
|
889
|
+
*
|
|
890
|
+
* // Write without creating parent directories
|
|
891
|
+
* await fs.write('existing/dir/file.txt', 'content', { createParents: false });
|
|
892
|
+
* ```
|
|
893
|
+
*/
|
|
894
|
+
async write(path2, content, options = {}) {
|
|
895
|
+
try {
|
|
896
|
+
const resolvedPath = this.resolvePath(path2);
|
|
897
|
+
if (options.createParents ?? this.createMissing) {
|
|
898
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
899
|
+
}
|
|
900
|
+
await writeFile(resolvedPath, content, {
|
|
901
|
+
encoding: options.encoding,
|
|
902
|
+
mode: options.mode
|
|
903
|
+
});
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (error.code === "ENOENT") {
|
|
906
|
+
throw new FileNotFoundError(dirname(path2), "local");
|
|
907
|
+
}
|
|
908
|
+
if (error.code === "EACCES") {
|
|
909
|
+
throw new PermissionError(path2, "local");
|
|
910
|
+
}
|
|
911
|
+
throw new FilesystemError(
|
|
912
|
+
`Failed to write file: ${error.message}`,
|
|
913
|
+
error.code || "UNKNOWN",
|
|
914
|
+
path2,
|
|
915
|
+
"local"
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Delete a file or empty directory
|
|
921
|
+
*
|
|
922
|
+
* Removes a file or directory from the filesystem. For directories, they must
|
|
923
|
+
* be empty. Use recursive deletion utilities for non-empty directories.
|
|
924
|
+
*
|
|
925
|
+
* @param path - Path to the file or directory to delete
|
|
926
|
+
* @returns Promise that resolves when the item is deleted
|
|
927
|
+
* @throws {FileNotFoundError} When the file or directory doesn't exist
|
|
928
|
+
* @throws {PermissionError} When delete access is denied
|
|
929
|
+
* @throws {DirectoryNotEmptyError} When trying to delete a non-empty directory
|
|
930
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
931
|
+
*
|
|
932
|
+
* @example
|
|
933
|
+
* ```typescript
|
|
934
|
+
* // Delete a file
|
|
935
|
+
* await fs.delete('temp.txt');
|
|
936
|
+
*
|
|
937
|
+
* // Delete an empty directory
|
|
938
|
+
* await fs.delete('empty-dir/');
|
|
939
|
+
* ```
|
|
940
|
+
*/
|
|
941
|
+
async delete(path2) {
|
|
942
|
+
try {
|
|
943
|
+
const resolvedPath = this.resolvePath(path2);
|
|
944
|
+
const stats = await stat(resolvedPath);
|
|
945
|
+
if (stats.isDirectory()) {
|
|
946
|
+
await rmdir(resolvedPath);
|
|
947
|
+
} else {
|
|
948
|
+
await unlink(resolvedPath);
|
|
949
|
+
}
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if (error.code === "ENOENT") {
|
|
952
|
+
throw new FileNotFoundError(path2, "local");
|
|
953
|
+
}
|
|
954
|
+
if (error.code === "EACCES") {
|
|
955
|
+
throw new PermissionError(path2, "local");
|
|
956
|
+
}
|
|
957
|
+
if (error.code === "ENOTEMPTY") {
|
|
958
|
+
throw new DirectoryNotEmptyError(path2, "local");
|
|
959
|
+
}
|
|
960
|
+
throw new FilesystemError(
|
|
961
|
+
`Failed to delete: ${error.message}`,
|
|
962
|
+
error.code || "UNKNOWN",
|
|
963
|
+
path2,
|
|
964
|
+
"local"
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Copy a file from source to destination
|
|
970
|
+
*
|
|
971
|
+
* Creates an exact copy of a file at the destination path. Will create
|
|
972
|
+
* parent directories if they don't exist and createMissing is enabled.
|
|
973
|
+
* The operation preserves file contents but not necessarily all metadata.
|
|
974
|
+
*
|
|
975
|
+
* @param sourcePath - Path to the source file
|
|
976
|
+
* @param destPath - Path where the copy should be created
|
|
977
|
+
* @returns Promise that resolves when the file is copied
|
|
978
|
+
* @throws {FileNotFoundError} When the source file doesn't exist
|
|
979
|
+
* @throws {PermissionError} When read/write access is denied
|
|
980
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
981
|
+
*
|
|
982
|
+
* @example
|
|
983
|
+
* ```typescript
|
|
984
|
+
* // Copy a file
|
|
985
|
+
* await fs.copy('original.txt', 'backup.txt');
|
|
986
|
+
*
|
|
987
|
+
* // Copy to a different directory
|
|
988
|
+
* await fs.copy('data.json', 'backup/data-copy.json');
|
|
989
|
+
* ```
|
|
990
|
+
*/
|
|
991
|
+
async copy(sourcePath, destPath) {
|
|
992
|
+
try {
|
|
993
|
+
const resolvedSource = this.resolvePath(sourcePath);
|
|
994
|
+
const resolvedDest = this.resolvePath(destPath);
|
|
995
|
+
if (this.createMissing) {
|
|
996
|
+
await mkdir(dirname(resolvedDest), { recursive: true });
|
|
997
|
+
}
|
|
998
|
+
await copyFile(resolvedSource, resolvedDest);
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
if (error.code === "ENOENT") {
|
|
1001
|
+
throw new FileNotFoundError(sourcePath, "local");
|
|
1002
|
+
}
|
|
1003
|
+
if (error.code === "EACCES") {
|
|
1004
|
+
throw new PermissionError(sourcePath, "local");
|
|
1005
|
+
}
|
|
1006
|
+
throw new FilesystemError(
|
|
1007
|
+
`Failed to copy: ${error.message}`,
|
|
1008
|
+
error.code || "UNKNOWN",
|
|
1009
|
+
sourcePath,
|
|
1010
|
+
"local"
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Move/rename a file from source to destination
|
|
1016
|
+
*
|
|
1017
|
+
* Moves a file to a new location, effectively combining copy and delete
|
|
1018
|
+
* operations. This is atomic on most filesystems when moving within the
|
|
1019
|
+
* same filesystem. Will create parent directories if they don't exist.
|
|
1020
|
+
*
|
|
1021
|
+
* @param sourcePath - Path to the source file
|
|
1022
|
+
* @param destPath - Path where the file should be moved
|
|
1023
|
+
* @returns Promise that resolves when the file is moved
|
|
1024
|
+
* @throws {FileNotFoundError} When the source file doesn't exist
|
|
1025
|
+
* @throws {PermissionError} When move access is denied
|
|
1026
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
1027
|
+
*
|
|
1028
|
+
* @example
|
|
1029
|
+
* ```typescript
|
|
1030
|
+
* // Rename a file
|
|
1031
|
+
* await fs.move('old-name.txt', 'new-name.txt');
|
|
1032
|
+
*
|
|
1033
|
+
* // Move to a different directory
|
|
1034
|
+
* await fs.move('temp.txt', 'archive/temp.txt');
|
|
1035
|
+
* ```
|
|
1036
|
+
*/
|
|
1037
|
+
async move(sourcePath, destPath) {
|
|
1038
|
+
try {
|
|
1039
|
+
const resolvedSource = this.resolvePath(sourcePath);
|
|
1040
|
+
const resolvedDest = this.resolvePath(destPath);
|
|
1041
|
+
if (this.createMissing) {
|
|
1042
|
+
await mkdir(dirname(resolvedDest), { recursive: true });
|
|
1043
|
+
}
|
|
1044
|
+
await rename(resolvedSource, resolvedDest);
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (error.code === "ENOENT") {
|
|
1047
|
+
throw new FileNotFoundError(sourcePath, "local");
|
|
1048
|
+
}
|
|
1049
|
+
if (error.code === "EACCES") {
|
|
1050
|
+
throw new PermissionError(sourcePath, "local");
|
|
1051
|
+
}
|
|
1052
|
+
throw new FilesystemError(
|
|
1053
|
+
`Failed to move: ${error.message}`,
|
|
1054
|
+
error.code || "UNKNOWN",
|
|
1055
|
+
sourcePath,
|
|
1056
|
+
"local"
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Create a directory at the specified path
|
|
1062
|
+
*
|
|
1063
|
+
* Creates a new directory with optional recursive creation of parent
|
|
1064
|
+
* directories. Supports setting directory permissions.
|
|
1065
|
+
*
|
|
1066
|
+
* @param path - Path where the directory should be created
|
|
1067
|
+
* @param options - Directory creation options
|
|
1068
|
+
* @param options.recursive - Whether to create parent directories (default: true)
|
|
1069
|
+
* @param options.mode - Directory permissions (e.g., 0o755)
|
|
1070
|
+
* @returns Promise that resolves when the directory is created
|
|
1071
|
+
* @throws {PermissionError} When create access is denied
|
|
1072
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
1073
|
+
*
|
|
1074
|
+
* @example
|
|
1075
|
+
* ```typescript
|
|
1076
|
+
* // Create directory with parents
|
|
1077
|
+
* await fs.createDirectory('path/to/new/dir');
|
|
1078
|
+
*
|
|
1079
|
+
* // Create directory with specific permissions
|
|
1080
|
+
* await fs.createDirectory('private-dir', { mode: 0o700 });
|
|
1081
|
+
*
|
|
1082
|
+
* // Create directory without parent creation
|
|
1083
|
+
* await fs.createDirectory('existing-parent/new-dir', { recursive: false });
|
|
1084
|
+
* ```
|
|
1085
|
+
*/
|
|
1086
|
+
async createDirectory(path2, options = {}) {
|
|
1087
|
+
try {
|
|
1088
|
+
const resolvedPath = this.resolvePath(path2);
|
|
1089
|
+
await mkdir(resolvedPath, {
|
|
1090
|
+
recursive: options.recursive ?? true,
|
|
1091
|
+
mode: options.mode
|
|
1092
|
+
});
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (error.code === "EACCES") {
|
|
1095
|
+
throw new PermissionError(path2, "local");
|
|
1096
|
+
}
|
|
1097
|
+
throw new FilesystemError(
|
|
1098
|
+
`Failed to create directory: ${error.message}`,
|
|
1099
|
+
error.code || "UNKNOWN",
|
|
1100
|
+
path2,
|
|
1101
|
+
"local"
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* List the contents of a directory
|
|
1107
|
+
*
|
|
1108
|
+
* Returns an array of FileInfo objects describing each item in the directory.
|
|
1109
|
+
* Supports filtering, recursive listing, and detailed metadata retrieval.
|
|
1110
|
+
*
|
|
1111
|
+
* @param path - Path to the directory to list
|
|
1112
|
+
* @param options - Listing options
|
|
1113
|
+
* @param options.recursive - Whether to include subdirectories recursively
|
|
1114
|
+
* @param options.filter - RegExp or string pattern to filter file names
|
|
1115
|
+
* @param options.detailed - Whether to include MIME types and extended metadata
|
|
1116
|
+
* @returns Promise resolving to array of FileInfo objects
|
|
1117
|
+
* @throws {FileNotFoundError} When the directory doesn't exist
|
|
1118
|
+
* @throws {PermissionError} When read access is denied
|
|
1119
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
1120
|
+
*
|
|
1121
|
+
* @example
|
|
1122
|
+
* ```typescript
|
|
1123
|
+
* // List all files and directories
|
|
1124
|
+
* const items = await fs.list('/path/to/dir');
|
|
1125
|
+
* items.forEach(item => {
|
|
1126
|
+
* console.log(`${item.name} (${item.isDirectory ? 'dir' : 'file'})`);
|
|
1127
|
+
* });
|
|
1128
|
+
*
|
|
1129
|
+
* // List only text files
|
|
1130
|
+
* const textFiles = await fs.list('/documents', { filter: /\.txt$/ });
|
|
1131
|
+
*
|
|
1132
|
+
* // Recursive listing with detailed info
|
|
1133
|
+
* const allFiles = await fs.list('/project', {
|
|
1134
|
+
* recursive: true,
|
|
1135
|
+
* detailed: true
|
|
1136
|
+
* });
|
|
1137
|
+
* ```
|
|
1138
|
+
*/
|
|
1139
|
+
async list(path2, options = {}) {
|
|
1140
|
+
try {
|
|
1141
|
+
const resolvedPath = this.resolvePath(path2);
|
|
1142
|
+
const entries = await readdir(resolvedPath, { withFileTypes: true });
|
|
1143
|
+
const results = [];
|
|
1144
|
+
for (const entry of entries) {
|
|
1145
|
+
const fullPath = join(resolvedPath, entry.name);
|
|
1146
|
+
const relativePath = join(path2, entry.name);
|
|
1147
|
+
if (options.filter) {
|
|
1148
|
+
const filterPattern = typeof options.filter === "string" ? new RegExp(options.filter) : options.filter;
|
|
1149
|
+
if (!filterPattern.test(entry.name)) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const stats = await stat(fullPath);
|
|
1154
|
+
const fileInfo = {
|
|
1155
|
+
name: entry.name,
|
|
1156
|
+
path: relativePath,
|
|
1157
|
+
size: stats.size,
|
|
1158
|
+
isDirectory: entry.isDirectory(),
|
|
1159
|
+
lastModified: stats.mtime,
|
|
1160
|
+
extension: entry.isFile() ? extname(entry.name).slice(1) : void 0
|
|
1161
|
+
};
|
|
1162
|
+
if (options.detailed) {
|
|
1163
|
+
fileInfo.mimeType = await this.getMimeType(relativePath);
|
|
1164
|
+
}
|
|
1165
|
+
results.push(fileInfo);
|
|
1166
|
+
if (options.recursive && entry.isDirectory()) {
|
|
1167
|
+
const subResults = await this.list(relativePath, options);
|
|
1168
|
+
results.push(...subResults);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return results;
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
if (error.code === "ENOENT") {
|
|
1174
|
+
throw new FileNotFoundError(path2, "local");
|
|
1175
|
+
}
|
|
1176
|
+
if (error.code === "EACCES") {
|
|
1177
|
+
throw new PermissionError(path2, "local");
|
|
1178
|
+
}
|
|
1179
|
+
throw new FilesystemError(
|
|
1180
|
+
`Failed to list directory: ${error.message}`,
|
|
1181
|
+
error.code || "UNKNOWN",
|
|
1182
|
+
path2,
|
|
1183
|
+
"local"
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Get detailed file or directory statistics
|
|
1189
|
+
*
|
|
1190
|
+
* Returns comprehensive metadata about a file or directory including size,
|
|
1191
|
+
* timestamps, permissions, and ownership information.
|
|
1192
|
+
*
|
|
1193
|
+
* @param path - Path to the file or directory
|
|
1194
|
+
* @returns Promise resolving to FileStats object with detailed metadata
|
|
1195
|
+
* @throws {FileNotFoundError} When the path doesn't exist
|
|
1196
|
+
* @throws {PermissionError} When stat access is denied
|
|
1197
|
+
* @throws {FilesystemError} For other filesystem errors
|
|
1198
|
+
*
|
|
1199
|
+
* @example
|
|
1200
|
+
* ```typescript
|
|
1201
|
+
* const stats = await fs.getStats('document.pdf');
|
|
1202
|
+
* console.log(`Size: ${stats.size} bytes`);
|
|
1203
|
+
* console.log(`Modified: ${stats.mtime}`);
|
|
1204
|
+
* console.log(`Is file: ${stats.isFile}`);
|
|
1205
|
+
* console.log(`Permissions: ${stats.mode.toString(8)}`);
|
|
1206
|
+
* ```
|
|
1207
|
+
*/
|
|
1208
|
+
async getStats(path2) {
|
|
1209
|
+
try {
|
|
1210
|
+
const resolvedPath = this.resolvePath(path2);
|
|
1211
|
+
const stats = await stat(resolvedPath);
|
|
1212
|
+
return {
|
|
1213
|
+
size: stats.size,
|
|
1214
|
+
isDirectory: stats.isDirectory(),
|
|
1215
|
+
isFile: stats.isFile(),
|
|
1216
|
+
birthtime: stats.birthtime,
|
|
1217
|
+
atime: stats.atime,
|
|
1218
|
+
mtime: stats.mtime,
|
|
1219
|
+
ctime: stats.ctime,
|
|
1220
|
+
mode: stats.mode,
|
|
1221
|
+
uid: stats.uid,
|
|
1222
|
+
gid: stats.gid
|
|
1223
|
+
};
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
if (error.code === "ENOENT") {
|
|
1226
|
+
throw new FileNotFoundError(path2, "local");
|
|
1227
|
+
}
|
|
1228
|
+
if (error.code === "EACCES") {
|
|
1229
|
+
throw new PermissionError(path2, "local");
|
|
1230
|
+
}
|
|
1231
|
+
throw new FilesystemError(
|
|
1232
|
+
`Failed to get stats: ${error.message}`,
|
|
1233
|
+
error.code || "UNKNOWN",
|
|
1234
|
+
path2,
|
|
1235
|
+
"local"
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Get the MIME type for a file based on its extension
|
|
1241
|
+
*
|
|
1242
|
+
* Determines the MIME type by examining the file extension. Returns a
|
|
1243
|
+
* standard MIME type string that can be used for content-type headers
|
|
1244
|
+
* or file type detection.
|
|
1245
|
+
*
|
|
1246
|
+
* @param path - Path to the file
|
|
1247
|
+
* @returns Promise resolving to MIME type string (e.g., 'text/plain', 'image/png')
|
|
1248
|
+
*
|
|
1249
|
+
* @example
|
|
1250
|
+
* ```typescript
|
|
1251
|
+
* const mimeType = await fs.getMimeType('document.pdf');
|
|
1252
|
+
* console.log(mimeType); // 'application/pdf'
|
|
1253
|
+
*
|
|
1254
|
+
* const imageMime = await fs.getMimeType('photo.jpg');
|
|
1255
|
+
* console.log(imageMime); // 'image/jpeg'
|
|
1256
|
+
* ```
|
|
1257
|
+
*/
|
|
1258
|
+
async getMimeType(path2) {
|
|
1259
|
+
const mimeTypes2 = {
|
|
1260
|
+
".html": "text/html",
|
|
1261
|
+
".js": "application/javascript",
|
|
1262
|
+
".json": "application/json",
|
|
1263
|
+
".css": "text/css",
|
|
1264
|
+
".png": "image/png",
|
|
1265
|
+
".jpg": "image/jpeg",
|
|
1266
|
+
".jpeg": "image/jpeg",
|
|
1267
|
+
".gif": "image/gif",
|
|
1268
|
+
".txt": "text/plain",
|
|
1269
|
+
".doc": "application/msword",
|
|
1270
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1271
|
+
".xls": "application/vnd.ms-excel",
|
|
1272
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1273
|
+
".pdf": "application/pdf",
|
|
1274
|
+
".xml": "application/xml",
|
|
1275
|
+
".zip": "application/zip",
|
|
1276
|
+
".rar": "application/x-rar-compressed",
|
|
1277
|
+
".mp3": "audio/mpeg",
|
|
1278
|
+
".mp4": "video/mp4",
|
|
1279
|
+
".avi": "video/x-msvideo",
|
|
1280
|
+
".mov": "video/quicktime"
|
|
1281
|
+
};
|
|
1282
|
+
const extension = extname(path2).toLowerCase();
|
|
1283
|
+
return mimeTypes2[extension] || "application/octet-stream";
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Upload data to a URL using PUT method (legacy)
|
|
1287
|
+
*/
|
|
1288
|
+
async uploadToUrl(url, data) {
|
|
1289
|
+
try {
|
|
1290
|
+
const response = await fetch(url, {
|
|
1291
|
+
method: "PUT",
|
|
1292
|
+
body: Buffer.isBuffer(data) ? new Uint8Array(data) : data,
|
|
1293
|
+
headers: { "Content-Type": "application/octet-stream" }
|
|
1294
|
+
});
|
|
1295
|
+
if (!response.ok) {
|
|
1296
|
+
throw new Error(`unexpected response ${response.statusText}`);
|
|
1297
|
+
}
|
|
1298
|
+
return response;
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
const err = error;
|
|
1301
|
+
console.error(`Error uploading data to ${url}
|
|
1302
|
+
Error: ${err.message}`);
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Download a file from a URL and save it to a local file (legacy)
|
|
1308
|
+
*/
|
|
1309
|
+
async downloadFromUrl(url, filepath) {
|
|
1310
|
+
try {
|
|
1311
|
+
const response = await fetch(url);
|
|
1312
|
+
if (!response.ok) {
|
|
1313
|
+
throw new Error(`Unexpected response ${response.statusText}`);
|
|
1314
|
+
}
|
|
1315
|
+
const fileStream = createWriteStream(this.resolvePath(filepath));
|
|
1316
|
+
return new Promise((resolve2, reject) => {
|
|
1317
|
+
fileStream.on("error", reject);
|
|
1318
|
+
fileStream.on("finish", resolve2);
|
|
1319
|
+
response.body?.pipeTo(
|
|
1320
|
+
new WritableStream({
|
|
1321
|
+
write(chunk) {
|
|
1322
|
+
fileStream.write(Buffer.from(chunk));
|
|
1323
|
+
},
|
|
1324
|
+
close() {
|
|
1325
|
+
fileStream.end();
|
|
1326
|
+
},
|
|
1327
|
+
abort(reason) {
|
|
1328
|
+
fileStream.destroy();
|
|
1329
|
+
reject(reason);
|
|
1330
|
+
}
|
|
1331
|
+
})
|
|
1332
|
+
).catch(reject);
|
|
1333
|
+
});
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
const err = error;
|
|
1336
|
+
console.error("Error downloading file:", err);
|
|
1337
|
+
throw error;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Download a file with caching support (legacy)
|
|
1342
|
+
*/
|
|
1343
|
+
async downloadFileWithCache(url, targetPath = null) {
|
|
1344
|
+
const parsedUrl = new URL$1(url);
|
|
1345
|
+
const downloadPath = targetPath || join(
|
|
1346
|
+
getTempDirectory("downloads"),
|
|
1347
|
+
parsedUrl.hostname + parsedUrl.pathname
|
|
1348
|
+
);
|
|
1349
|
+
if (!existsSync(downloadPath)) {
|
|
1350
|
+
await mkdir(dirname(downloadPath), { recursive: true });
|
|
1351
|
+
await this.downloadFromUrl(url, downloadPath);
|
|
1352
|
+
}
|
|
1353
|
+
return downloadPath;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Get data from cache if available and not expired (legacy)
|
|
1357
|
+
*/
|
|
1358
|
+
async getCached(file, expiry = 3e5) {
|
|
1359
|
+
const cacheFile = this.resolveCachePath(file);
|
|
1360
|
+
const cached = existsSync(cacheFile);
|
|
1361
|
+
if (cached) {
|
|
1362
|
+
const stats = statSync(cacheFile);
|
|
1363
|
+
const modTime = new Date(stats.mtime);
|
|
1364
|
+
const now = /* @__PURE__ */ new Date();
|
|
1365
|
+
const isExpired = expiry && now.getTime() - modTime.getTime() > expiry;
|
|
1366
|
+
if (!isExpired) {
|
|
1367
|
+
return await readFile(cacheFile, "utf8");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return void 0;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Set data in cache (legacy)
|
|
1374
|
+
*/
|
|
1375
|
+
async setCached(file, data) {
|
|
1376
|
+
const cacheFile = this.resolveCachePath(file);
|
|
1377
|
+
await mkdir(dirname(cacheFile), { recursive: true });
|
|
1378
|
+
await writeFile(cacheFile, data);
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Cache implementation using file system
|
|
1382
|
+
*/
|
|
1383
|
+
cache = {
|
|
1384
|
+
get: async (key, expiry) => {
|
|
1385
|
+
return await this.getCached(key, expiry);
|
|
1386
|
+
},
|
|
1387
|
+
set: async (key, data) => {
|
|
1388
|
+
await this.setCached(key, data);
|
|
1389
|
+
},
|
|
1390
|
+
clear: async (key) => {
|
|
1391
|
+
if (key) {
|
|
1392
|
+
const cacheFile = this.resolveCachePath(key);
|
|
1393
|
+
try {
|
|
1394
|
+
await unlink(cacheFile);
|
|
1395
|
+
} catch {
|
|
1396
|
+
}
|
|
1397
|
+
} else {
|
|
1398
|
+
try {
|
|
1399
|
+
await rmdir(this.cacheDir, { recursive: true });
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
/**
|
|
1406
|
+
* Get provider capabilities
|
|
1407
|
+
*/
|
|
1408
|
+
async getCapabilities() {
|
|
1409
|
+
return {
|
|
1410
|
+
streaming: true,
|
|
1411
|
+
atomicOperations: true,
|
|
1412
|
+
versioning: false,
|
|
1413
|
+
sharing: false,
|
|
1414
|
+
realTimeSync: false,
|
|
1415
|
+
offlineCapable: true,
|
|
1416
|
+
supportedOperations: [
|
|
1417
|
+
"exists",
|
|
1418
|
+
"read",
|
|
1419
|
+
"write",
|
|
1420
|
+
"delete",
|
|
1421
|
+
"copy",
|
|
1422
|
+
"move",
|
|
1423
|
+
"createDirectory",
|
|
1424
|
+
"list",
|
|
1425
|
+
"getStats",
|
|
1426
|
+
"getMimeType",
|
|
1427
|
+
"upload",
|
|
1428
|
+
"download",
|
|
1429
|
+
"downloadWithCache",
|
|
1430
|
+
"isFile",
|
|
1431
|
+
"isDirectory",
|
|
1432
|
+
"ensureDirectoryExists",
|
|
1433
|
+
"uploadToUrl",
|
|
1434
|
+
"downloadFromUrl",
|
|
1435
|
+
"downloadFileWithCache",
|
|
1436
|
+
"listFiles",
|
|
1437
|
+
"getCached",
|
|
1438
|
+
"setCached"
|
|
1439
|
+
]
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
const local = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1444
|
+
__proto__: null,
|
|
1445
|
+
LocalFilesystemProvider
|
|
1446
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1447
|
+
const MIME_TYPES$1 = {
|
|
1448
|
+
".html": "text/html",
|
|
1449
|
+
".js": "application/javascript",
|
|
1450
|
+
".json": "application/json",
|
|
1451
|
+
".css": "text/css",
|
|
1452
|
+
".png": "image/png",
|
|
1453
|
+
".jpg": "image/jpeg",
|
|
1454
|
+
".jpeg": "image/jpeg",
|
|
1455
|
+
".gif": "image/gif",
|
|
1456
|
+
".svg": "image/svg+xml",
|
|
1457
|
+
".txt": "text/plain",
|
|
1458
|
+
".md": "text/markdown",
|
|
1459
|
+
".csv": "text/csv",
|
|
1460
|
+
".doc": "application/msword",
|
|
1461
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1462
|
+
".xls": "application/vnd.ms-excel",
|
|
1463
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1464
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
1465
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1466
|
+
".pdf": "application/pdf",
|
|
1467
|
+
".xml": "application/xml",
|
|
1468
|
+
".zip": "application/zip",
|
|
1469
|
+
".mp3": "audio/mpeg",
|
|
1470
|
+
".mp4": "video/mp4"
|
|
1471
|
+
};
|
|
1472
|
+
const GOOGLE_EXPORT_MIMES = {
|
|
1473
|
+
"application/vnd.google-apps.document": "text/plain",
|
|
1474
|
+
"application/vnd.google-apps.spreadsheet": "text/csv",
|
|
1475
|
+
"application/vnd.google-apps.presentation": "application/pdf",
|
|
1476
|
+
"application/vnd.google-apps.drawing": "image/png"
|
|
1477
|
+
};
|
|
1478
|
+
class GoogleDriveProvider extends BaseFilesystemProvider {
|
|
1479
|
+
constructor(options) {
|
|
1480
|
+
super(options);
|
|
1481
|
+
this.options = options;
|
|
1482
|
+
this.rootFolderId = options.folderId || "root";
|
|
1483
|
+
this.pathCacheTTL = options.pathCacheTTL ?? 3e5;
|
|
1484
|
+
this.pageSize = options.pageSize ?? 100;
|
|
1485
|
+
}
|
|
1486
|
+
drive;
|
|
1487
|
+
rootFolderId;
|
|
1488
|
+
pathCache = /* @__PURE__ */ new Map();
|
|
1489
|
+
pathCacheTTL;
|
|
1490
|
+
pageSize;
|
|
1491
|
+
/**
|
|
1492
|
+
* Lazily initialize the Google Drive client on first use
|
|
1493
|
+
*/
|
|
1494
|
+
async getDrive() {
|
|
1495
|
+
if (this.drive) return this.drive;
|
|
1496
|
+
const { google } = await import("googleapis");
|
|
1497
|
+
let auth;
|
|
1498
|
+
if (this.options.serviceAccountKey) {
|
|
1499
|
+
const { GoogleAuth } = await import("google-auth-library");
|
|
1500
|
+
const key = JSON.parse(this.options.serviceAccountKey);
|
|
1501
|
+
auth = new GoogleAuth({
|
|
1502
|
+
credentials: key,
|
|
1503
|
+
scopes: this.options.scopes || [
|
|
1504
|
+
"https://www.googleapis.com/auth/drive"
|
|
1505
|
+
]
|
|
1506
|
+
});
|
|
1507
|
+
} else if (this.options.clientId && this.options.clientSecret && this.options.refreshToken) {
|
|
1508
|
+
const oauth2 = new google.auth.OAuth2(
|
|
1509
|
+
this.options.clientId,
|
|
1510
|
+
this.options.clientSecret
|
|
1511
|
+
);
|
|
1512
|
+
oauth2.setCredentials({
|
|
1513
|
+
refresh_token: this.options.refreshToken,
|
|
1514
|
+
access_token: this.options.accessToken
|
|
1515
|
+
});
|
|
1516
|
+
auth = oauth2;
|
|
1517
|
+
} else if (this.options.accessToken) {
|
|
1518
|
+
const oauth2 = new google.auth.OAuth2();
|
|
1519
|
+
oauth2.setCredentials({ access_token: this.options.accessToken });
|
|
1520
|
+
auth = oauth2;
|
|
1521
|
+
} else {
|
|
1522
|
+
throw new FilesystemError(
|
|
1523
|
+
"Google Drive provider requires OAuth2 credentials, a serviceAccountKey, or an accessToken",
|
|
1524
|
+
"EINVAL",
|
|
1525
|
+
void 0,
|
|
1526
|
+
"gdrive"
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
this.drive = google.drive({ version: "v3", auth });
|
|
1530
|
+
return this.drive;
|
|
1531
|
+
}
|
|
1532
|
+
// ---------------------------------------------------------------------------
|
|
1533
|
+
// Path ↔ File-ID resolution
|
|
1534
|
+
// ---------------------------------------------------------------------------
|
|
1535
|
+
/**
|
|
1536
|
+
* Resolve a hierarchical path like "folder/sub/file.txt" to a Drive file ID
|
|
1537
|
+
* by walking each segment. Results are cached with a configurable TTL.
|
|
1538
|
+
*/
|
|
1539
|
+
async resolvePathToId(path2) {
|
|
1540
|
+
const normalized = this.normalizePath(path2);
|
|
1541
|
+
if (!normalized || normalized === ".") return this.rootFolderId;
|
|
1542
|
+
const cached = this.pathCache.get(normalized);
|
|
1543
|
+
if (cached && Date.now() - cached.ts < this.pathCacheTTL) {
|
|
1544
|
+
return cached.id;
|
|
1545
|
+
}
|
|
1546
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
1547
|
+
let parentId = this.rootFolderId;
|
|
1548
|
+
for (const segment of segments) {
|
|
1549
|
+
const drive = await this.getDrive();
|
|
1550
|
+
const q = `'${parentId}' in parents and name = '${segment.replace(/'/g, "\\'")}' and trashed = false`;
|
|
1551
|
+
const res = await this.wrapApiCall(
|
|
1552
|
+
() => drive.files.list({
|
|
1553
|
+
q,
|
|
1554
|
+
fields: "files(id, name)",
|
|
1555
|
+
pageSize: 1
|
|
1556
|
+
}),
|
|
1557
|
+
path2
|
|
1558
|
+
);
|
|
1559
|
+
if (!res.data.files || res.data.files.length === 0) {
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
parentId = res.data.files[0].id;
|
|
1563
|
+
}
|
|
1564
|
+
this.pathCache.set(normalized, { id: parentId, ts: Date.now() });
|
|
1565
|
+
return parentId;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Resolve a path to an ID, throwing FileNotFoundError if it doesn't exist
|
|
1569
|
+
*/
|
|
1570
|
+
async requireId(path2) {
|
|
1571
|
+
const id = await this.resolvePathToId(path2);
|
|
1572
|
+
if (!id) throw new FileNotFoundError(path2, "gdrive");
|
|
1573
|
+
return id;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Ensure all parent folders exist for a given path, creating them if needed.
|
|
1577
|
+
* Returns the parent folder ID and the final segment name.
|
|
1578
|
+
*/
|
|
1579
|
+
async ensureParents(path2) {
|
|
1580
|
+
const normalized = this.normalizePath(path2);
|
|
1581
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
1582
|
+
const name = segments.pop();
|
|
1583
|
+
let parentId = this.rootFolderId;
|
|
1584
|
+
let currentPath = "";
|
|
1585
|
+
for (const segment of segments) {
|
|
1586
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
1587
|
+
const existingId = await this.resolvePathToId(currentPath);
|
|
1588
|
+
if (existingId) {
|
|
1589
|
+
parentId = existingId;
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
const drive = await this.getDrive();
|
|
1593
|
+
const res = await this.wrapApiCall(
|
|
1594
|
+
() => drive.files.create({
|
|
1595
|
+
requestBody: {
|
|
1596
|
+
name: segment,
|
|
1597
|
+
mimeType: "application/vnd.google-apps.folder",
|
|
1598
|
+
parents: [parentId]
|
|
1599
|
+
},
|
|
1600
|
+
fields: "id"
|
|
1601
|
+
}),
|
|
1602
|
+
path2
|
|
1603
|
+
);
|
|
1604
|
+
parentId = res.data.id;
|
|
1605
|
+
this.pathCache.set(currentPath, { id: parentId, ts: Date.now() });
|
|
1606
|
+
}
|
|
1607
|
+
return { parentId, name };
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Invalidate cache entries that are prefixed by the given path
|
|
1611
|
+
*/
|
|
1612
|
+
invalidateCache(path2) {
|
|
1613
|
+
const normalized = this.normalizePath(path2);
|
|
1614
|
+
for (const key of this.pathCache.keys()) {
|
|
1615
|
+
if (key === normalized || key.startsWith(normalized + "/")) {
|
|
1616
|
+
this.pathCache.delete(key);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
// ---------------------------------------------------------------------------
|
|
1621
|
+
// API error mapping
|
|
1622
|
+
// ---------------------------------------------------------------------------
|
|
1623
|
+
/**
|
|
1624
|
+
* Wrap a Drive API call and map HTTP errors to typed filesystem errors
|
|
1625
|
+
*/
|
|
1626
|
+
async wrapApiCall(fn, path2) {
|
|
1627
|
+
try {
|
|
1628
|
+
return await fn();
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
const status = error?.code ?? error?.response?.status;
|
|
1631
|
+
if (status === 404) {
|
|
1632
|
+
throw new FileNotFoundError(path2, "gdrive");
|
|
1633
|
+
}
|
|
1634
|
+
if (status === 403) {
|
|
1635
|
+
throw new PermissionError(path2, "gdrive");
|
|
1636
|
+
}
|
|
1637
|
+
if (status === 401) {
|
|
1638
|
+
throw new FilesystemError(
|
|
1639
|
+
`Authentication failed: ${error.message}`,
|
|
1640
|
+
"EACCES",
|
|
1641
|
+
path2,
|
|
1642
|
+
"gdrive"
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
if (status === 429) {
|
|
1646
|
+
throw new FilesystemError(
|
|
1647
|
+
`Rate limited: ${error.message}`,
|
|
1648
|
+
"EAGAIN",
|
|
1649
|
+
path2,
|
|
1650
|
+
"gdrive"
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
throw new FilesystemError(
|
|
1654
|
+
`Google Drive API error: ${error.message}`,
|
|
1655
|
+
error.code?.toString() || "UNKNOWN",
|
|
1656
|
+
path2,
|
|
1657
|
+
"gdrive"
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
// ---------------------------------------------------------------------------
|
|
1662
|
+
// FilesystemInterface implementation
|
|
1663
|
+
// ---------------------------------------------------------------------------
|
|
1664
|
+
/** Checks whether a non-trashed file or folder exists at the given path. */
|
|
1665
|
+
async exists(path2) {
|
|
1666
|
+
const id = await this.resolvePathToId(path2);
|
|
1667
|
+
return id !== null;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Read a file from Google Drive. Google Docs native types (Document,
|
|
1671
|
+
* Spreadsheet, Presentation, Drawing) are automatically exported to a
|
|
1672
|
+
* portable format (plain text, CSV, PDF, PNG respectively).
|
|
1673
|
+
*/
|
|
1674
|
+
async read(path2, options = {}) {
|
|
1675
|
+
const fileId = await this.requireId(path2);
|
|
1676
|
+
const drive = await this.getDrive();
|
|
1677
|
+
const meta = await this.wrapApiCall(
|
|
1678
|
+
() => drive.files.get({ fileId, fields: "mimeType" }),
|
|
1679
|
+
path2
|
|
1680
|
+
);
|
|
1681
|
+
const mimeType = meta.data.mimeType;
|
|
1682
|
+
const exportMime = GOOGLE_EXPORT_MIMES[mimeType];
|
|
1683
|
+
let data;
|
|
1684
|
+
if (exportMime) {
|
|
1685
|
+
const res = await this.wrapApiCall(
|
|
1686
|
+
() => drive.files.export(
|
|
1687
|
+
{ fileId, mimeType: exportMime },
|
|
1688
|
+
{ responseType: "arraybuffer" }
|
|
1689
|
+
),
|
|
1690
|
+
path2
|
|
1691
|
+
);
|
|
1692
|
+
data = Buffer.from(res.data);
|
|
1693
|
+
} else {
|
|
1694
|
+
const res = await this.wrapApiCall(
|
|
1695
|
+
() => drive.files.get(
|
|
1696
|
+
{ fileId, alt: "media" },
|
|
1697
|
+
{ responseType: "arraybuffer" }
|
|
1698
|
+
),
|
|
1699
|
+
path2
|
|
1700
|
+
);
|
|
1701
|
+
data = Buffer.from(res.data);
|
|
1702
|
+
}
|
|
1703
|
+
if (options.raw) {
|
|
1704
|
+
return data;
|
|
1705
|
+
}
|
|
1706
|
+
return data.toString(options.encoding || "utf8");
|
|
1707
|
+
}
|
|
1708
|
+
/** Write content to Drive. Updates the file in-place if it already exists, otherwise creates it. */
|
|
1709
|
+
async write(path2, content, options = {}) {
|
|
1710
|
+
const body = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
1711
|
+
const ext = extname(path2).toLowerCase();
|
|
1712
|
+
const contentMime = MIME_TYPES$1[ext] || "application/octet-stream";
|
|
1713
|
+
const existingId = await this.resolvePathToId(path2);
|
|
1714
|
+
const drive = await this.getDrive();
|
|
1715
|
+
if (existingId && existingId !== this.rootFolderId) {
|
|
1716
|
+
await this.wrapApiCall(
|
|
1717
|
+
() => drive.files.update({
|
|
1718
|
+
fileId: existingId,
|
|
1719
|
+
media: { mimeType: contentMime, body }
|
|
1720
|
+
}),
|
|
1721
|
+
path2
|
|
1722
|
+
);
|
|
1723
|
+
} else {
|
|
1724
|
+
if (options.createParents ?? this.createMissing) {
|
|
1725
|
+
const { parentId, name } = await this.ensureParents(path2);
|
|
1726
|
+
await this.wrapApiCall(
|
|
1727
|
+
() => drive.files.create({
|
|
1728
|
+
requestBody: {
|
|
1729
|
+
name,
|
|
1730
|
+
parents: [parentId]
|
|
1731
|
+
},
|
|
1732
|
+
media: { mimeType: contentMime, body },
|
|
1733
|
+
fields: "id"
|
|
1734
|
+
}),
|
|
1735
|
+
path2
|
|
1736
|
+
);
|
|
1737
|
+
} else {
|
|
1738
|
+
const parentPath = dirname(this.normalizePath(path2));
|
|
1739
|
+
const parentId = parentPath === "." || parentPath === "" ? this.rootFolderId : await this.requireId(parentPath);
|
|
1740
|
+
const name = basename(path2);
|
|
1741
|
+
await this.wrapApiCall(
|
|
1742
|
+
() => drive.files.create({
|
|
1743
|
+
requestBody: {
|
|
1744
|
+
name,
|
|
1745
|
+
parents: [parentId]
|
|
1746
|
+
},
|
|
1747
|
+
media: { mimeType: contentMime, body },
|
|
1748
|
+
fields: "id"
|
|
1749
|
+
}),
|
|
1750
|
+
path2
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
this.invalidateCache(path2);
|
|
1755
|
+
}
|
|
1756
|
+
/** Moves the file to the Drive trash rather than permanently deleting it. */
|
|
1757
|
+
async delete(path2) {
|
|
1758
|
+
const fileId = await this.requireId(path2);
|
|
1759
|
+
const drive = await this.getDrive();
|
|
1760
|
+
await this.wrapApiCall(
|
|
1761
|
+
() => drive.files.update({
|
|
1762
|
+
fileId,
|
|
1763
|
+
requestBody: { trashed: true }
|
|
1764
|
+
}),
|
|
1765
|
+
path2
|
|
1766
|
+
);
|
|
1767
|
+
this.invalidateCache(path2);
|
|
1768
|
+
}
|
|
1769
|
+
async copy(sourcePath, destPath) {
|
|
1770
|
+
const sourceId = await this.requireId(sourcePath);
|
|
1771
|
+
const drive = await this.getDrive();
|
|
1772
|
+
const { parentId, name } = await this.ensureParents(destPath);
|
|
1773
|
+
await this.wrapApiCall(
|
|
1774
|
+
() => drive.files.copy({
|
|
1775
|
+
fileId: sourceId,
|
|
1776
|
+
requestBody: {
|
|
1777
|
+
name,
|
|
1778
|
+
parents: [parentId]
|
|
1779
|
+
},
|
|
1780
|
+
fields: "id"
|
|
1781
|
+
}),
|
|
1782
|
+
sourcePath
|
|
1783
|
+
);
|
|
1784
|
+
this.invalidateCache(destPath);
|
|
1785
|
+
}
|
|
1786
|
+
/** Moves a file by updating its parent references (single API call, not copy+delete). */
|
|
1787
|
+
async move(sourcePath, destPath) {
|
|
1788
|
+
const sourceId = await this.requireId(sourcePath);
|
|
1789
|
+
const drive = await this.getDrive();
|
|
1790
|
+
const fileMeta = await this.wrapApiCall(
|
|
1791
|
+
() => drive.files.get({ fileId: sourceId, fields: "parents" }),
|
|
1792
|
+
sourcePath
|
|
1793
|
+
);
|
|
1794
|
+
const previousParents = (fileMeta.data.parents || []).join(",");
|
|
1795
|
+
const { parentId, name } = await this.ensureParents(destPath);
|
|
1796
|
+
await this.wrapApiCall(
|
|
1797
|
+
() => drive.files.update({
|
|
1798
|
+
fileId: sourceId,
|
|
1799
|
+
addParents: parentId,
|
|
1800
|
+
removeParents: previousParents,
|
|
1801
|
+
requestBody: { name },
|
|
1802
|
+
fields: "id, parents"
|
|
1803
|
+
}),
|
|
1804
|
+
sourcePath
|
|
1805
|
+
);
|
|
1806
|
+
this.invalidateCache(sourcePath);
|
|
1807
|
+
this.invalidateCache(destPath);
|
|
1808
|
+
}
|
|
1809
|
+
async createDirectory(path2, options = {}) {
|
|
1810
|
+
const normalized = this.normalizePath(path2);
|
|
1811
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
1812
|
+
if (options.recursive ?? true) {
|
|
1813
|
+
let currentPath = "";
|
|
1814
|
+
for (const segment of segments) {
|
|
1815
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
1816
|
+
const existingId = await this.resolvePathToId(currentPath);
|
|
1817
|
+
if (!existingId) {
|
|
1818
|
+
const parentPath = dirname(currentPath);
|
|
1819
|
+
const parentId = parentPath === "." || parentPath === "" ? this.rootFolderId : await this.requireId(parentPath);
|
|
1820
|
+
const drive = await this.getDrive();
|
|
1821
|
+
const res = await this.wrapApiCall(
|
|
1822
|
+
() => drive.files.create({
|
|
1823
|
+
requestBody: {
|
|
1824
|
+
name: segment,
|
|
1825
|
+
mimeType: "application/vnd.google-apps.folder",
|
|
1826
|
+
parents: [parentId]
|
|
1827
|
+
},
|
|
1828
|
+
fields: "id"
|
|
1829
|
+
}),
|
|
1830
|
+
path2
|
|
1831
|
+
);
|
|
1832
|
+
this.pathCache.set(currentPath, {
|
|
1833
|
+
id: res.data.id,
|
|
1834
|
+
ts: Date.now()
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
} else {
|
|
1839
|
+
const parentPath = dirname(normalized);
|
|
1840
|
+
const parentId = parentPath === "." || parentPath === "" ? this.rootFolderId : await this.requireId(parentPath);
|
|
1841
|
+
const name = segments[segments.length - 1];
|
|
1842
|
+
const drive = await this.getDrive();
|
|
1843
|
+
const res = await this.wrapApiCall(
|
|
1844
|
+
() => drive.files.create({
|
|
1845
|
+
requestBody: {
|
|
1846
|
+
name,
|
|
1847
|
+
mimeType: "application/vnd.google-apps.folder",
|
|
1848
|
+
parents: [parentId]
|
|
1849
|
+
},
|
|
1850
|
+
fields: "id"
|
|
1851
|
+
}),
|
|
1852
|
+
path2
|
|
1853
|
+
);
|
|
1854
|
+
this.pathCache.set(normalized, { id: res.data.id, ts: Date.now() });
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
/** Lists non-trashed children of a folder, with optional pagination via {@link GoogleDriveOptions.pageSize}. */
|
|
1858
|
+
async list(path2, options = {}) {
|
|
1859
|
+
const folderId = !path2 || path2 === "." || path2 === "/" ? this.rootFolderId : await this.requireId(path2);
|
|
1860
|
+
const drive = await this.getDrive();
|
|
1861
|
+
const results = [];
|
|
1862
|
+
let pageToken;
|
|
1863
|
+
do {
|
|
1864
|
+
const res = await this.wrapApiCall(
|
|
1865
|
+
() => drive.files.list({
|
|
1866
|
+
q: `'${folderId}' in parents and trashed = false`,
|
|
1867
|
+
fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime)",
|
|
1868
|
+
pageSize: this.pageSize,
|
|
1869
|
+
pageToken
|
|
1870
|
+
}),
|
|
1871
|
+
path2
|
|
1872
|
+
);
|
|
1873
|
+
const files = res.data.files || [];
|
|
1874
|
+
for (const file of files) {
|
|
1875
|
+
const isDir = file.mimeType === "application/vnd.google-apps.folder";
|
|
1876
|
+
const name = file.name;
|
|
1877
|
+
const filePath = path2 && path2 !== "." && path2 !== "/" ? `${this.normalizePath(path2)}/${name}` : name;
|
|
1878
|
+
if (options.filter) {
|
|
1879
|
+
const filterPattern = typeof options.filter === "string" ? new RegExp(options.filter) : options.filter;
|
|
1880
|
+
if (!filterPattern.test(name)) continue;
|
|
1881
|
+
}
|
|
1882
|
+
const ext = isDir ? void 0 : extname(name).slice(1) || void 0;
|
|
1883
|
+
const fileInfo = {
|
|
1884
|
+
name,
|
|
1885
|
+
path: filePath,
|
|
1886
|
+
size: Number(file.size || 0),
|
|
1887
|
+
isDirectory: isDir,
|
|
1888
|
+
lastModified: new Date(file.modifiedTime),
|
|
1889
|
+
extension: ext
|
|
1890
|
+
};
|
|
1891
|
+
if (options.detailed) {
|
|
1892
|
+
fileInfo.mimeType = file.mimeType;
|
|
1893
|
+
}
|
|
1894
|
+
results.push(fileInfo);
|
|
1895
|
+
this.pathCache.set(filePath, { id: file.id, ts: Date.now() });
|
|
1896
|
+
if (options.recursive && isDir) {
|
|
1897
|
+
const subResults = await this.list(filePath, options);
|
|
1898
|
+
results.push(...subResults);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
pageToken = res.data.nextPageToken ?? void 0;
|
|
1902
|
+
} while (pageToken);
|
|
1903
|
+
return results;
|
|
1904
|
+
}
|
|
1905
|
+
/** Returns file metadata. Mode is synthetic (0o755 for folders, 0o644 for files); uid/gid are always 0. */
|
|
1906
|
+
async getStats(path2) {
|
|
1907
|
+
const fileId = await this.requireId(path2);
|
|
1908
|
+
const drive = await this.getDrive();
|
|
1909
|
+
const res = await this.wrapApiCall(
|
|
1910
|
+
() => drive.files.get({
|
|
1911
|
+
fileId,
|
|
1912
|
+
fields: "id, name, mimeType, size, createdTime, modifiedTime"
|
|
1913
|
+
}),
|
|
1914
|
+
path2
|
|
1915
|
+
);
|
|
1916
|
+
const file = res.data;
|
|
1917
|
+
const isDir = file.mimeType === "application/vnd.google-apps.folder";
|
|
1918
|
+
const modifiedTime = new Date(file.modifiedTime);
|
|
1919
|
+
const createdTime = new Date(file.createdTime);
|
|
1920
|
+
return {
|
|
1921
|
+
size: Number(file.size || 0),
|
|
1922
|
+
isDirectory: isDir,
|
|
1923
|
+
isFile: !isDir,
|
|
1924
|
+
birthtime: createdTime,
|
|
1925
|
+
atime: modifiedTime,
|
|
1926
|
+
mtime: modifiedTime,
|
|
1927
|
+
ctime: modifiedTime,
|
|
1928
|
+
mode: isDir ? 493 : 420,
|
|
1929
|
+
uid: 0,
|
|
1930
|
+
gid: 0
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
async getMimeType(path2) {
|
|
1934
|
+
const ext = extname(path2).toLowerCase();
|
|
1935
|
+
return MIME_TYPES$1[ext] || "application/octet-stream";
|
|
1936
|
+
}
|
|
1937
|
+
async upload(localPath, remotePath, _options = {}) {
|
|
1938
|
+
const content = await readFile(localPath);
|
|
1939
|
+
await this.write(remotePath, content);
|
|
1940
|
+
}
|
|
1941
|
+
/** Downloads a file from Drive to the local filesystem, defaulting to the provider's cache directory. */
|
|
1942
|
+
async download(remotePath, localPath, _options = {}) {
|
|
1943
|
+
const content = await this.read(remotePath, { raw: true });
|
|
1944
|
+
const target = localPath || join(this.cacheDir, this.normalizePath(remotePath));
|
|
1945
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1946
|
+
await writeFile(target, content);
|
|
1947
|
+
return target;
|
|
1948
|
+
}
|
|
1949
|
+
async getCapabilities() {
|
|
1950
|
+
return {
|
|
1951
|
+
streaming: false,
|
|
1952
|
+
atomicOperations: false,
|
|
1953
|
+
versioning: true,
|
|
1954
|
+
sharing: true,
|
|
1955
|
+
realTimeSync: true,
|
|
1956
|
+
offlineCapable: false,
|
|
1957
|
+
maxFileSize: 5 * 1024 * 1024 * 1024 * 1024,
|
|
1958
|
+
// 5 TB
|
|
1959
|
+
supportedOperations: [
|
|
1960
|
+
"exists",
|
|
1961
|
+
"read",
|
|
1962
|
+
"write",
|
|
1963
|
+
"delete",
|
|
1964
|
+
"copy",
|
|
1965
|
+
"move",
|
|
1966
|
+
"createDirectory",
|
|
1967
|
+
"list",
|
|
1968
|
+
"getStats",
|
|
1969
|
+
"getMimeType",
|
|
1970
|
+
"upload",
|
|
1971
|
+
"download"
|
|
1972
|
+
]
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
const gdrive = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1977
|
+
__proto__: null,
|
|
1978
|
+
GoogleDriveProvider
|
|
1979
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1980
|
+
const MIME_TYPES = {
|
|
1981
|
+
".txt": "text/plain",
|
|
1982
|
+
".json": "application/json",
|
|
1983
|
+
".csv": "text/csv",
|
|
1984
|
+
".svg": "image/svg+xml",
|
|
1985
|
+
".png": "image/png",
|
|
1986
|
+
".jpg": "image/jpeg",
|
|
1987
|
+
".jpeg": "image/jpeg",
|
|
1988
|
+
".webp": "image/webp",
|
|
1989
|
+
".pdf": "application/pdf",
|
|
1990
|
+
".mp4": "video/mp4",
|
|
1991
|
+
".webm": "video/webm"
|
|
1992
|
+
};
|
|
1993
|
+
function toDate(value) {
|
|
1994
|
+
return value instanceof Date ? value : /* @__PURE__ */ new Date();
|
|
1995
|
+
}
|
|
1996
|
+
function toCopySource(bucket, key) {
|
|
1997
|
+
return `${bucket}/${key.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
1998
|
+
}
|
|
1999
|
+
class S3FilesystemProvider extends BaseFilesystemProvider {
|
|
2000
|
+
constructor(options) {
|
|
2001
|
+
super(options);
|
|
2002
|
+
this.options = options;
|
|
2003
|
+
this.bucket = options.bucket;
|
|
2004
|
+
this.client = new S3Client({
|
|
2005
|
+
region: options.region,
|
|
2006
|
+
endpoint: options.endpoint,
|
|
2007
|
+
forcePathStyle: options.forcePathStyle,
|
|
2008
|
+
credentials: options.accessKeyId && options.secretAccessKey ? {
|
|
2009
|
+
accessKeyId: options.accessKeyId,
|
|
2010
|
+
secretAccessKey: options.secretAccessKey
|
|
2011
|
+
} : void 0
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
client;
|
|
2015
|
+
bucket;
|
|
2016
|
+
normalizeS3Path(path2, options = {}) {
|
|
2017
|
+
const normalized = path2 === "." || path2 === "/" ? this.basePath.replace(/^\/+|\/+$/g, "") : this.normalizePath(path2).replace(/^\/+|\/+$/g, "");
|
|
2018
|
+
if (!normalized) {
|
|
2019
|
+
return "";
|
|
2020
|
+
}
|
|
2021
|
+
if (options.preserveTrailingSlash && path2 !== "." && path2 !== "/" && /\/+$/.test(path2)) {
|
|
2022
|
+
return `${normalized}/`;
|
|
2023
|
+
}
|
|
2024
|
+
return normalized;
|
|
2025
|
+
}
|
|
2026
|
+
toDirectoryKey(path2) {
|
|
2027
|
+
const directoryPath = path2.endsWith("/") ? path2 : `${path2}/`;
|
|
2028
|
+
return this.normalizeS3Path(directoryPath, {
|
|
2029
|
+
preserveTrailingSlash: true
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
isRootPath(path2) {
|
|
2033
|
+
return path2 === "." || path2 === "/";
|
|
2034
|
+
}
|
|
2035
|
+
toDirectoryStats(lastModified) {
|
|
2036
|
+
const timestamp = toDate(lastModified);
|
|
2037
|
+
return {
|
|
2038
|
+
size: 0,
|
|
2039
|
+
isDirectory: true,
|
|
2040
|
+
isFile: false,
|
|
2041
|
+
birthtime: timestamp,
|
|
2042
|
+
atime: timestamp,
|
|
2043
|
+
mtime: timestamp,
|
|
2044
|
+
ctime: timestamp,
|
|
2045
|
+
mode: 0,
|
|
2046
|
+
uid: 0,
|
|
2047
|
+
gid: 0
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
async headDirectoryMarker(path2) {
|
|
2051
|
+
const directoryKey = this.toDirectoryKey(path2);
|
|
2052
|
+
if (!directoryKey) {
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
try {
|
|
2056
|
+
return await this.head(directoryKey);
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
if (error instanceof FileNotFoundError) {
|
|
2059
|
+
return null;
|
|
2060
|
+
}
|
|
2061
|
+
throw error;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
async directoryHasChildren(directoryKey) {
|
|
2065
|
+
const listing = await this.client.send(
|
|
2066
|
+
new ListObjectsV2Command({
|
|
2067
|
+
Bucket: this.bucket,
|
|
2068
|
+
Prefix: directoryKey,
|
|
2069
|
+
MaxKeys: 2
|
|
2070
|
+
})
|
|
2071
|
+
);
|
|
2072
|
+
return (listing.Contents || []).some(
|
|
2073
|
+
(entry) => entry.Key && entry.Key !== directoryKey
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
getDefaultDownloadTarget(remotePath) {
|
|
2077
|
+
const normalizedRemotePath = this.normalizeS3Path(remotePath, {
|
|
2078
|
+
preserveTrailingSlash: remotePath.endsWith("/")
|
|
2079
|
+
});
|
|
2080
|
+
const segments = normalizedRemotePath.split("/").filter(Boolean);
|
|
2081
|
+
if (!segments.length) {
|
|
2082
|
+
throw new InvalidPathError(remotePath, "s3");
|
|
2083
|
+
}
|
|
2084
|
+
if (segments.some(
|
|
2085
|
+
(segment) => segment === ".." || segment === "~" || segment.startsWith("~") || /^[A-Za-z]:/.test(segment)
|
|
2086
|
+
)) {
|
|
2087
|
+
throw new InvalidPathError(remotePath, "s3");
|
|
2088
|
+
}
|
|
2089
|
+
const baseDir = resolve(this.cacheDir);
|
|
2090
|
+
const target = resolve(baseDir, ...segments);
|
|
2091
|
+
const targetRelativePath = relative(baseDir, target);
|
|
2092
|
+
if (!targetRelativePath || targetRelativePath === ".." || targetRelativePath.startsWith(`..${sep}`)) {
|
|
2093
|
+
throw new InvalidPathError(remotePath, "s3");
|
|
2094
|
+
}
|
|
2095
|
+
return target;
|
|
2096
|
+
}
|
|
2097
|
+
async bodyToBuffer(body) {
|
|
2098
|
+
if (!body) return Buffer.alloc(0);
|
|
2099
|
+
if (Buffer.isBuffer(body)) return body;
|
|
2100
|
+
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
2101
|
+
if (typeof body.transformToByteArray === "function") {
|
|
2102
|
+
const bytes = await body.transformToByteArray();
|
|
2103
|
+
return Buffer.from(bytes);
|
|
2104
|
+
}
|
|
2105
|
+
if (typeof body.getReader === "function") {
|
|
2106
|
+
const reader = body.getReader();
|
|
2107
|
+
const chunks = [];
|
|
2108
|
+
while (true) {
|
|
2109
|
+
const { done, value } = await reader.read();
|
|
2110
|
+
if (done) break;
|
|
2111
|
+
if (value) chunks.push(Buffer.from(value));
|
|
2112
|
+
}
|
|
2113
|
+
return Buffer.concat(chunks);
|
|
2114
|
+
}
|
|
2115
|
+
if (Symbol.asyncIterator in Object(body)) {
|
|
2116
|
+
const chunks = [];
|
|
2117
|
+
for await (const chunk of body) {
|
|
2118
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2119
|
+
}
|
|
2120
|
+
return Buffer.concat(chunks);
|
|
2121
|
+
}
|
|
2122
|
+
return Buffer.from(String(body));
|
|
2123
|
+
}
|
|
2124
|
+
async head(key) {
|
|
2125
|
+
try {
|
|
2126
|
+
return await this.client.send(
|
|
2127
|
+
new HeadObjectCommand({
|
|
2128
|
+
Bucket: this.bucket,
|
|
2129
|
+
Key: key
|
|
2130
|
+
})
|
|
2131
|
+
);
|
|
2132
|
+
} catch (error) {
|
|
2133
|
+
if (error?.$metadata?.httpStatusCode === 404 || error?.name === "NotFound") {
|
|
2134
|
+
throw new FileNotFoundError(key, "s3");
|
|
2135
|
+
}
|
|
2136
|
+
if (error?.$metadata?.httpStatusCode === 403) {
|
|
2137
|
+
throw new PermissionError(key, "s3");
|
|
2138
|
+
}
|
|
2139
|
+
throw new FilesystemError(
|
|
2140
|
+
`Failed to stat S3 object: ${error instanceof Error ? error.message : String(error)}`,
|
|
2141
|
+
error?.name || "UNKNOWN",
|
|
2142
|
+
key,
|
|
2143
|
+
"s3"
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
toFileInfo(key, entry, isDirectory2 = false) {
|
|
2148
|
+
const normalized = key.replace(/\/$/, "");
|
|
2149
|
+
const name = normalized.split("/").pop() || normalized;
|
|
2150
|
+
const extension = isDirectory2 ? void 0 : extname(normalized).replace(/^\./, "");
|
|
2151
|
+
const mimeType = isDirectory2 ? void 0 : MIME_TYPES[extname(normalized).toLowerCase()] || "application/octet-stream";
|
|
2152
|
+
return {
|
|
2153
|
+
name,
|
|
2154
|
+
path: normalized,
|
|
2155
|
+
size: Number(entry.Size || 0),
|
|
2156
|
+
isDirectory: isDirectory2,
|
|
2157
|
+
lastModified: toDate(entry.LastModified),
|
|
2158
|
+
mimeType,
|
|
2159
|
+
extension
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
async exists(path2) {
|
|
2163
|
+
if (this.isRootPath(path2)) {
|
|
2164
|
+
return true;
|
|
2165
|
+
}
|
|
2166
|
+
const key = this.normalizeS3Path(path2, {
|
|
2167
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2168
|
+
});
|
|
2169
|
+
if (!key) {
|
|
2170
|
+
return true;
|
|
2171
|
+
}
|
|
2172
|
+
try {
|
|
2173
|
+
await this.head(key);
|
|
2174
|
+
return true;
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
if (error instanceof FileNotFoundError) {
|
|
2177
|
+
const directoryMarker = await this.headDirectoryMarker(path2);
|
|
2178
|
+
if (directoryMarker) {
|
|
2179
|
+
return true;
|
|
2180
|
+
}
|
|
2181
|
+
const listing = await this.client.send(
|
|
2182
|
+
new ListObjectsV2Command({
|
|
2183
|
+
Bucket: this.bucket,
|
|
2184
|
+
Prefix: `${key}/`,
|
|
2185
|
+
MaxKeys: 1
|
|
2186
|
+
})
|
|
2187
|
+
);
|
|
2188
|
+
return Boolean((listing.Contents || []).length);
|
|
2189
|
+
}
|
|
2190
|
+
throw error;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
async read(path2, options = {}) {
|
|
2194
|
+
const key = this.normalizeS3Path(path2, {
|
|
2195
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2196
|
+
});
|
|
2197
|
+
try {
|
|
2198
|
+
const response = await this.client.send(
|
|
2199
|
+
new GetObjectCommand({
|
|
2200
|
+
Bucket: this.bucket,
|
|
2201
|
+
Key: key
|
|
2202
|
+
})
|
|
2203
|
+
);
|
|
2204
|
+
const buffer = await this.bodyToBuffer(response.Body);
|
|
2205
|
+
if (options.raw) {
|
|
2206
|
+
return buffer;
|
|
2207
|
+
}
|
|
2208
|
+
return buffer.toString(options.encoding || "utf8");
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
if (error?.$metadata?.httpStatusCode === 404 || error?.name === "NoSuchKey") {
|
|
2211
|
+
throw new FileNotFoundError(path2, "s3");
|
|
2212
|
+
}
|
|
2213
|
+
if (error?.$metadata?.httpStatusCode === 403) {
|
|
2214
|
+
throw new PermissionError(path2, "s3");
|
|
2215
|
+
}
|
|
2216
|
+
throw new FilesystemError(
|
|
2217
|
+
`Failed to read S3 object: ${error instanceof Error ? error.message : String(error)}`,
|
|
2218
|
+
error?.name || "UNKNOWN",
|
|
2219
|
+
path2,
|
|
2220
|
+
"s3"
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
async write(path2, content, options = {}) {
|
|
2225
|
+
const key = this.normalizeS3Path(path2, {
|
|
2226
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2227
|
+
});
|
|
2228
|
+
const body = typeof content === "string" ? Buffer.from(content, options.encoding || "utf8") : content;
|
|
2229
|
+
try {
|
|
2230
|
+
await this.client.send(
|
|
2231
|
+
new PutObjectCommand({
|
|
2232
|
+
Bucket: this.bucket,
|
|
2233
|
+
Key: key,
|
|
2234
|
+
Body: body,
|
|
2235
|
+
ContentType: MIME_TYPES[extname(key).toLowerCase()] || "application/octet-stream"
|
|
2236
|
+
})
|
|
2237
|
+
);
|
|
2238
|
+
} catch (error) {
|
|
2239
|
+
if (error?.$metadata?.httpStatusCode === 403) {
|
|
2240
|
+
throw new PermissionError(path2, "s3");
|
|
2241
|
+
}
|
|
2242
|
+
throw new FilesystemError(
|
|
2243
|
+
`Failed to write S3 object: ${error instanceof Error ? error.message : String(error)}`,
|
|
2244
|
+
error?.name || "UNKNOWN",
|
|
2245
|
+
path2,
|
|
2246
|
+
"s3"
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
async delete(path2) {
|
|
2251
|
+
let key = this.normalizeS3Path(path2, {
|
|
2252
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2253
|
+
});
|
|
2254
|
+
try {
|
|
2255
|
+
if (key) {
|
|
2256
|
+
try {
|
|
2257
|
+
const stats = await this.getStats(path2);
|
|
2258
|
+
if (stats.isDirectory) {
|
|
2259
|
+
key = this.toDirectoryKey(path2);
|
|
2260
|
+
if (key && await this.directoryHasChildren(key)) {
|
|
2261
|
+
throw new DirectoryNotEmptyError(path2, "s3");
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
} catch (error) {
|
|
2265
|
+
if (!(error instanceof FileNotFoundError)) {
|
|
2266
|
+
throw error;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
await this.client.send(
|
|
2271
|
+
new DeleteObjectCommand({
|
|
2272
|
+
Bucket: this.bucket,
|
|
2273
|
+
Key: key
|
|
2274
|
+
})
|
|
2275
|
+
);
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
if (error instanceof FilesystemError) {
|
|
2278
|
+
throw error;
|
|
2279
|
+
}
|
|
2280
|
+
throw new FilesystemError(
|
|
2281
|
+
`Failed to delete S3 object: ${error instanceof Error ? error.message : String(error)}`,
|
|
2282
|
+
error?.name || "UNKNOWN",
|
|
2283
|
+
path2,
|
|
2284
|
+
"s3"
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
async copy(sourcePath, destPath) {
|
|
2289
|
+
const sourceKey = this.normalizeS3Path(sourcePath, {
|
|
2290
|
+
preserveTrailingSlash: sourcePath.endsWith("/")
|
|
2291
|
+
});
|
|
2292
|
+
const destKey = this.normalizeS3Path(destPath, {
|
|
2293
|
+
preserveTrailingSlash: destPath.endsWith("/")
|
|
2294
|
+
});
|
|
2295
|
+
try {
|
|
2296
|
+
await this.client.send(
|
|
2297
|
+
new CopyObjectCommand({
|
|
2298
|
+
Bucket: this.bucket,
|
|
2299
|
+
Key: destKey,
|
|
2300
|
+
CopySource: toCopySource(this.bucket, sourceKey)
|
|
2301
|
+
})
|
|
2302
|
+
);
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
throw new FilesystemError(
|
|
2305
|
+
`Failed to copy S3 object: ${error instanceof Error ? error.message : String(error)}`,
|
|
2306
|
+
error?.name || "UNKNOWN",
|
|
2307
|
+
destPath,
|
|
2308
|
+
"s3"
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
async move(sourcePath, destPath) {
|
|
2313
|
+
await this.copy(sourcePath, destPath);
|
|
2314
|
+
await this.delete(sourcePath);
|
|
2315
|
+
}
|
|
2316
|
+
async createDirectory(path2, _options = {}) {
|
|
2317
|
+
if (!this.toDirectoryKey(path2)) {
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
const directoryPath = path2.endsWith("/") ? path2 : `${path2}/`;
|
|
2321
|
+
await this.write(directoryPath, Buffer.alloc(0));
|
|
2322
|
+
}
|
|
2323
|
+
async list(path2, options = {}) {
|
|
2324
|
+
const prefix = this.normalizeS3Path(path2, {
|
|
2325
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2326
|
+
});
|
|
2327
|
+
const prefixWithSlash = prefix ? `${prefix.replace(/\/+$/, "")}/` : "";
|
|
2328
|
+
const items = [];
|
|
2329
|
+
const seenDirectories = /* @__PURE__ */ new Set();
|
|
2330
|
+
const addDirectory = (key, lastModified) => {
|
|
2331
|
+
const directoryKey = key.endsWith("/") ? key : `${key}/`;
|
|
2332
|
+
const normalized = directoryKey.replace(/\/$/, "");
|
|
2333
|
+
if (!normalized || seenDirectories.has(normalized)) {
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
seenDirectories.add(normalized);
|
|
2337
|
+
items.push(
|
|
2338
|
+
this.toFileInfo(directoryKey, { LastModified: lastModified }, true)
|
|
2339
|
+
);
|
|
2340
|
+
};
|
|
2341
|
+
let continuationToken;
|
|
2342
|
+
do {
|
|
2343
|
+
const response = await this.client.send(
|
|
2344
|
+
new ListObjectsV2Command({
|
|
2345
|
+
Bucket: this.bucket,
|
|
2346
|
+
Prefix: prefixWithSlash,
|
|
2347
|
+
Delimiter: options.recursive ? void 0 : "/",
|
|
2348
|
+
ContinuationToken: continuationToken
|
|
2349
|
+
})
|
|
2350
|
+
);
|
|
2351
|
+
for (const prefixEntry of response.CommonPrefixes || []) {
|
|
2352
|
+
if (prefixEntry.Prefix) {
|
|
2353
|
+
addDirectory(prefixEntry.Prefix);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
for (const entry of response.Contents || []) {
|
|
2357
|
+
if (!entry.Key || entry.Key === prefixWithSlash) continue;
|
|
2358
|
+
const relative2 = prefixWithSlash ? entry.Key.slice(prefixWithSlash.length) : entry.Key;
|
|
2359
|
+
if (entry.Key.endsWith("/")) {
|
|
2360
|
+
addDirectory(entry.Key, entry.LastModified);
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
if (!options.recursive && relative2.includes("/")) {
|
|
2364
|
+
const directory = relative2.split("/")[0];
|
|
2365
|
+
addDirectory(`${prefixWithSlash}${directory}`, entry.LastModified);
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
items.push(this.toFileInfo(entry.Key, entry));
|
|
2369
|
+
}
|
|
2370
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
|
|
2371
|
+
} while (continuationToken);
|
|
2372
|
+
return items.filter((item) => {
|
|
2373
|
+
if (!options.filter) return true;
|
|
2374
|
+
const matcher = typeof options.filter === "string" ? new RegExp(options.filter) : options.filter;
|
|
2375
|
+
return matcher.test(item.name);
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
async getStats(path2) {
|
|
2379
|
+
if (this.isRootPath(path2)) {
|
|
2380
|
+
return this.toDirectoryStats();
|
|
2381
|
+
}
|
|
2382
|
+
const key = this.normalizeS3Path(path2, {
|
|
2383
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2384
|
+
});
|
|
2385
|
+
if (!key) {
|
|
2386
|
+
return this.toDirectoryStats();
|
|
2387
|
+
}
|
|
2388
|
+
try {
|
|
2389
|
+
const response = await this.head(key);
|
|
2390
|
+
return {
|
|
2391
|
+
size: Number(response.ContentLength || 0),
|
|
2392
|
+
isDirectory: key.endsWith("/"),
|
|
2393
|
+
isFile: !key.endsWith("/"),
|
|
2394
|
+
birthtime: toDate(response.LastModified),
|
|
2395
|
+
atime: toDate(response.LastModified),
|
|
2396
|
+
mtime: toDate(response.LastModified),
|
|
2397
|
+
ctime: toDate(response.LastModified),
|
|
2398
|
+
mode: 0,
|
|
2399
|
+
uid: 0,
|
|
2400
|
+
gid: 0
|
|
2401
|
+
};
|
|
2402
|
+
} catch (error) {
|
|
2403
|
+
if (error instanceof FileNotFoundError) {
|
|
2404
|
+
const directoryMarker = await this.headDirectoryMarker(path2);
|
|
2405
|
+
if (directoryMarker) {
|
|
2406
|
+
return this.toDirectoryStats(directoryMarker.LastModified);
|
|
2407
|
+
}
|
|
2408
|
+
const listing = await this.client.send(
|
|
2409
|
+
new ListObjectsV2Command({
|
|
2410
|
+
Bucket: this.bucket,
|
|
2411
|
+
Prefix: `${key.replace(/\/+$/, "")}/`,
|
|
2412
|
+
MaxKeys: 1
|
|
2413
|
+
})
|
|
2414
|
+
);
|
|
2415
|
+
if ((listing.Contents || []).length) {
|
|
2416
|
+
const [firstEntry] = listing.Contents || [];
|
|
2417
|
+
return this.toDirectoryStats(firstEntry?.LastModified);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
throw error;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
async getMimeType(path2) {
|
|
2424
|
+
const key = this.normalizeS3Path(path2, {
|
|
2425
|
+
preserveTrailingSlash: path2.endsWith("/")
|
|
2426
|
+
});
|
|
2427
|
+
try {
|
|
2428
|
+
const response = await this.head(key);
|
|
2429
|
+
return response.ContentType || MIME_TYPES[extname(key).toLowerCase()] || "application/octet-stream";
|
|
2430
|
+
} catch (error) {
|
|
2431
|
+
if (error instanceof FileNotFoundError) {
|
|
2432
|
+
return MIME_TYPES[extname(key).toLowerCase()] || "application/octet-stream";
|
|
2433
|
+
}
|
|
2434
|
+
throw error;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
async upload(localPath, remotePath, _options = {}) {
|
|
2438
|
+
const { readFile: readFile2 } = await import("node:fs/promises");
|
|
2439
|
+
await this.write(remotePath, await readFile2(localPath));
|
|
2440
|
+
}
|
|
2441
|
+
async download(remotePath, localPath, _options = {}) {
|
|
2442
|
+
const buffer = await this.read(remotePath, { raw: true });
|
|
2443
|
+
const target = localPath || this.getDefaultDownloadTarget(remotePath);
|
|
2444
|
+
await mkdir(dirname(target), { recursive: true });
|
|
2445
|
+
await writeFile(target, buffer);
|
|
2446
|
+
return target;
|
|
2447
|
+
}
|
|
2448
|
+
async getCapabilities() {
|
|
2449
|
+
return {
|
|
2450
|
+
streaming: false,
|
|
2451
|
+
atomicOperations: false,
|
|
2452
|
+
versioning: false,
|
|
2453
|
+
sharing: false,
|
|
2454
|
+
realTimeSync: false,
|
|
2455
|
+
offlineCapable: false,
|
|
2456
|
+
supportedOperations: [
|
|
2457
|
+
"exists",
|
|
2458
|
+
"read",
|
|
2459
|
+
"write",
|
|
2460
|
+
"delete",
|
|
2461
|
+
"copy",
|
|
2462
|
+
"move",
|
|
2463
|
+
"createDirectory",
|
|
2464
|
+
"list",
|
|
2465
|
+
"getStats",
|
|
2466
|
+
"getMimeType",
|
|
2467
|
+
"upload",
|
|
2468
|
+
"download"
|
|
2469
|
+
]
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
const s3 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
2474
|
+
__proto__: null,
|
|
2475
|
+
S3FilesystemProvider
|
|
2476
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
2477
|
+
const SECRET_KEY_FRAGMENTS = [
|
|
2478
|
+
"apikey",
|
|
2479
|
+
"api_key",
|
|
2480
|
+
"clientsecret",
|
|
2481
|
+
"client_secret",
|
|
2482
|
+
"credential",
|
|
2483
|
+
"privatekey",
|
|
2484
|
+
"private_key",
|
|
2485
|
+
"secret",
|
|
2486
|
+
"password",
|
|
2487
|
+
"token"
|
|
2488
|
+
];
|
|
2489
|
+
const EXACT_SECRET_KEYS = /* @__PURE__ */ new Set(["serviceaccountkey", "accesskeyid"]);
|
|
2490
|
+
function looksLikeSecret(key) {
|
|
2491
|
+
const normalized = key.toLowerCase();
|
|
2492
|
+
if (EXACT_SECRET_KEYS.has(normalized)) return true;
|
|
2493
|
+
return SECRET_KEY_FRAGMENTS.some((fragment) => normalized.includes(fragment));
|
|
2494
|
+
}
|
|
2495
|
+
function redactFilesystemConfig(value) {
|
|
2496
|
+
return redactSecrets(value, /* @__PURE__ */ new WeakSet(), /* @__PURE__ */ new WeakMap());
|
|
2497
|
+
}
|
|
2498
|
+
function redactSecrets(value, inProgress, done) {
|
|
2499
|
+
if (!value || typeof value !== "object") return value;
|
|
2500
|
+
const obj = value;
|
|
2501
|
+
if (done.has(obj)) return done.get(obj);
|
|
2502
|
+
if (inProgress.has(obj)) return "[circular]";
|
|
2503
|
+
inProgress.add(obj);
|
|
2504
|
+
let result;
|
|
2505
|
+
if (Array.isArray(value)) {
|
|
2506
|
+
result = value.map((item) => redactSecrets(item, inProgress, done));
|
|
2507
|
+
} else {
|
|
2508
|
+
result = Object.fromEntries(
|
|
2509
|
+
Object.entries(value).map(([key, item]) => [
|
|
2510
|
+
key,
|
|
2511
|
+
looksLikeSecret(key) ? "[redacted]" : redactSecrets(item, inProgress, done)
|
|
2512
|
+
])
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
inProgress.delete(obj);
|
|
2516
|
+
done.set(obj, result);
|
|
2517
|
+
return result;
|
|
2518
|
+
}
|
|
2519
|
+
const providers = /* @__PURE__ */ new Map();
|
|
2520
|
+
function registerProvider(type, factory2) {
|
|
2521
|
+
providers.set(type, factory2);
|
|
2522
|
+
}
|
|
2523
|
+
function getAvailableProviders() {
|
|
2524
|
+
return Array.from(providers.keys());
|
|
2525
|
+
}
|
|
2526
|
+
function validateOptions(options) {
|
|
2527
|
+
if (!options) {
|
|
2528
|
+
throw new FilesystemError("Provider options are required", "EINVAL");
|
|
2529
|
+
}
|
|
2530
|
+
const type = options.type || "local";
|
|
2531
|
+
switch (type) {
|
|
2532
|
+
case "local":
|
|
2533
|
+
break;
|
|
2534
|
+
case "s3": {
|
|
2535
|
+
const s3Opts = options;
|
|
2536
|
+
if (!s3Opts.region) {
|
|
2537
|
+
throw new FilesystemError("S3 provider requires region", "EINVAL");
|
|
2538
|
+
}
|
|
2539
|
+
if (!s3Opts.bucket) {
|
|
2540
|
+
throw new FilesystemError("S3 provider requires bucket", "EINVAL");
|
|
2541
|
+
}
|
|
2542
|
+
break;
|
|
2543
|
+
}
|
|
2544
|
+
case "gdrive": {
|
|
2545
|
+
const gdriveOpts = options;
|
|
2546
|
+
const hasOAuth2 = gdriveOpts.clientId && gdriveOpts.clientSecret && gdriveOpts.refreshToken;
|
|
2547
|
+
const hasServiceAccount = !!gdriveOpts.serviceAccountKey;
|
|
2548
|
+
const hasAccessToken = !!gdriveOpts.accessToken;
|
|
2549
|
+
if (!hasOAuth2 && !hasServiceAccount && !hasAccessToken) {
|
|
2550
|
+
throw new FilesystemError(
|
|
2551
|
+
"Google Drive provider requires OAuth2 credentials (clientId + clientSecret + refreshToken), a serviceAccountKey, or an accessToken",
|
|
2552
|
+
"EINVAL"
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
break;
|
|
2556
|
+
}
|
|
2557
|
+
case "webdav": {
|
|
2558
|
+
const webdavOpts = options;
|
|
2559
|
+
if (!webdavOpts.baseUrl) {
|
|
2560
|
+
throw new FilesystemError("WebDAV provider requires baseUrl", "EINVAL");
|
|
2561
|
+
}
|
|
2562
|
+
if (!webdavOpts.username) {
|
|
2563
|
+
throw new FilesystemError(
|
|
2564
|
+
"WebDAV provider requires username",
|
|
2565
|
+
"EINVAL"
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
if (!webdavOpts.password) {
|
|
2569
|
+
throw new FilesystemError(
|
|
2570
|
+
"WebDAV provider requires password",
|
|
2571
|
+
"EINVAL"
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
break;
|
|
2575
|
+
}
|
|
2576
|
+
case "browser-storage":
|
|
2577
|
+
break;
|
|
2578
|
+
default:
|
|
2579
|
+
throw new FilesystemError(`Unknown provider type: ${type}`, "EINVAL");
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
function detectProviderType(options) {
|
|
2583
|
+
if (options.type) {
|
|
2584
|
+
return options.type;
|
|
2585
|
+
}
|
|
2586
|
+
if ("region" in options && "bucket" in options) {
|
|
2587
|
+
return "s3";
|
|
2588
|
+
}
|
|
2589
|
+
if ("clientId" in options && "clientSecret" in options || "serviceAccountKey" in options || "accessToken" in options) {
|
|
2590
|
+
return "gdrive";
|
|
2591
|
+
}
|
|
2592
|
+
if ("baseUrl" in options && "username" in options) {
|
|
2593
|
+
return "webdav";
|
|
2594
|
+
}
|
|
2595
|
+
if ("databaseName" in options || "storageQuota" in options) {
|
|
2596
|
+
return "browser-storage";
|
|
2597
|
+
}
|
|
2598
|
+
if (typeof globalThis !== "undefined") {
|
|
2599
|
+
if (typeof globalThis.window !== "undefined" && typeof globalThis.indexedDB !== "undefined") {
|
|
2600
|
+
return "browser-storage";
|
|
2601
|
+
}
|
|
2602
|
+
if (globalThis.process?.versions?.node) {
|
|
2603
|
+
return "local";
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
return "local";
|
|
2607
|
+
}
|
|
2608
|
+
async function getFilesystem(options = {}) {
|
|
2609
|
+
validateOptions(options);
|
|
2610
|
+
const type = detectProviderType(options);
|
|
2611
|
+
const providerFactory = providers.get(type);
|
|
2612
|
+
if (!providerFactory) {
|
|
2613
|
+
throw new FilesystemError(
|
|
2614
|
+
`Provider '${type}' is not registered. Available providers: ${getAvailableProviders().join(", ")}`,
|
|
2615
|
+
"ENOTFOUND"
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
const ProviderClass = await providerFactory();
|
|
2620
|
+
return new ProviderClass(options);
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
throw new FilesystemError(
|
|
2623
|
+
`Failed to create '${type}' provider: ${error instanceof Error ? error.message : String(error)}`,
|
|
2624
|
+
"ENOENT",
|
|
2625
|
+
void 0,
|
|
2626
|
+
type
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
async function initializeProviders() {
|
|
2631
|
+
registerProvider("local", async () => {
|
|
2632
|
+
const { LocalFilesystemProvider: LocalFilesystemProvider2 } = await Promise.resolve().then(() => local);
|
|
2633
|
+
return LocalFilesystemProvider2;
|
|
2634
|
+
});
|
|
2635
|
+
registerProvider("s3", async () => {
|
|
2636
|
+
const { S3FilesystemProvider: S3FilesystemProvider2 } = await Promise.resolve().then(() => s3);
|
|
2637
|
+
return S3FilesystemProvider2;
|
|
2638
|
+
});
|
|
2639
|
+
registerProvider("gdrive", async () => {
|
|
2640
|
+
const { GoogleDriveProvider: GoogleDriveProvider2 } = await Promise.resolve().then(() => gdrive);
|
|
2641
|
+
return GoogleDriveProvider2;
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
function isProviderAvailable(type) {
|
|
2645
|
+
return providers.has(type);
|
|
2646
|
+
}
|
|
2647
|
+
function getProviderInfo(type) {
|
|
2648
|
+
const descriptions = {
|
|
2649
|
+
local: "Local filesystem provider using Node.js fs module",
|
|
2650
|
+
s3: "S3-compatible provider supporting AWS S3, MinIO, and other S3-compatible services",
|
|
2651
|
+
gdrive: "Google Drive provider using Google Drive API v3",
|
|
2652
|
+
webdav: "WebDAV provider supporting Nextcloud, ownCloud, Apache mod_dav, and other WebDAV servers",
|
|
2653
|
+
"browser-storage": "Browser storage provider using IndexedDB for app file management"
|
|
2654
|
+
};
|
|
2655
|
+
const requiredOptions = {
|
|
2656
|
+
local: [],
|
|
2657
|
+
s3: ["region", "bucket"],
|
|
2658
|
+
gdrive: [],
|
|
2659
|
+
webdav: ["baseUrl", "username", "password"],
|
|
2660
|
+
"browser-storage": []
|
|
2661
|
+
};
|
|
2662
|
+
return {
|
|
2663
|
+
available: isProviderAvailable(type),
|
|
2664
|
+
description: descriptions[type] || "Unknown provider",
|
|
2665
|
+
requiredOptions: requiredOptions[type] || []
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
const factory = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
2669
|
+
__proto__: null,
|
|
2670
|
+
getAvailableProviders,
|
|
2671
|
+
getFilesystem,
|
|
2672
|
+
getProviderInfo,
|
|
2673
|
+
initializeProviders,
|
|
2674
|
+
isProviderAvailable,
|
|
2675
|
+
registerProvider
|
|
2676
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
2677
|
+
Promise.resolve().then(() => factory).then(({ initializeProviders: initializeProviders2 }) => {
|
|
2678
|
+
initializeProviders2().catch(() => {
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
const PACKAGE_VERSION_INITIALIZED = true;
|
|
2682
|
+
export {
|
|
2683
|
+
DirectoryNotEmptyError,
|
|
2684
|
+
FileNotFoundError,
|
|
2685
|
+
FilesystemAdapter,
|
|
2686
|
+
FilesystemError,
|
|
2687
|
+
GoogleDriveProvider,
|
|
2688
|
+
InvalidPathError,
|
|
2689
|
+
LocalFilesystemProvider,
|
|
2690
|
+
PACKAGE_VERSION_INITIALIZED,
|
|
2691
|
+
PermissionError,
|
|
2692
|
+
S3FilesystemProvider,
|
|
2693
|
+
addRateLimit,
|
|
2694
|
+
factory as default,
|
|
2695
|
+
download,
|
|
2696
|
+
downloadFileWithCache,
|
|
2697
|
+
ensureDirectoryExists,
|
|
2698
|
+
fetchBuffer,
|
|
2699
|
+
fetchJSON,
|
|
2700
|
+
fetchText,
|
|
2701
|
+
fetchToFile,
|
|
2702
|
+
getAvailableProviders,
|
|
2703
|
+
getCached,
|
|
2704
|
+
getFilesystem,
|
|
2705
|
+
getMimeType,
|
|
2706
|
+
getProviderInfo,
|
|
2707
|
+
getRateLimit,
|
|
2708
|
+
initializeProviders,
|
|
2709
|
+
isDirectory,
|
|
2710
|
+
isFile,
|
|
2711
|
+
isProviderAvailable,
|
|
2712
|
+
listFiles,
|
|
2713
|
+
redactFilesystemConfig,
|
|
2714
|
+
registerProvider,
|
|
2715
|
+
setCached,
|
|
2716
|
+
upload,
|
|
2717
|
+
writeResponseToFile
|
|
2718
|
+
};
|
|
2719
|
+
//# sourceMappingURL=index.js.map
|