@mantiq/filesystem 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,377 @@
1
+ import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
2
+ import { FilesystemError } from '../errors/FilesystemError.ts'
3
+ import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
4
+ import { guessMimeType } from '../helpers/mime.ts'
5
+
6
+ export interface AzureConfig {
7
+ container: string
8
+ connectionString?: string
9
+ accountName?: string
10
+ accountKey?: string
11
+ sasToken?: string
12
+ root?: string
13
+ url?: string
14
+ visibility?: 'public' | 'private'
15
+ }
16
+
17
+ /**
18
+ * Azure Blob Storage filesystem driver.
19
+ *
20
+ * Requires: bun add @azure/storage-blob
21
+ */
22
+ export class AzureBlobDriver implements FilesystemDriver {
23
+ private _containerClient: any = null
24
+ private readonly containerName: string
25
+ private readonly prefix: string
26
+ private readonly urlBase: string | undefined
27
+ private readonly defaultVisibility: 'public' | 'private'
28
+ private readonly _config: AzureConfig
29
+
30
+ constructor(config: AzureConfig) {
31
+ this._config = config
32
+ this.containerName = config.container
33
+ this.prefix = config.root ? config.root.replace(/^\/+|\/+$/g, '') + '/' : ''
34
+ this.urlBase = config.url
35
+ this.defaultVisibility = config.visibility ?? 'private'
36
+ }
37
+
38
+ private async container(): Promise<any> {
39
+ if (!this._containerClient) {
40
+ let BlobServiceClient: any, StorageSharedKeyCredential: any
41
+ try {
42
+ ;({ BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob'))
43
+ } catch {
44
+ throw new FilesystemError(
45
+ 'The @azure/storage-blob package is required for the Azure driver. Install it with: bun add @azure/storage-blob',
46
+ )
47
+ }
48
+
49
+ let serviceClient: any
50
+ if (this._config.connectionString) {
51
+ serviceClient = BlobServiceClient.fromConnectionString(this._config.connectionString)
52
+ } else if (this._config.accountName && this._config.accountKey) {
53
+ const cred = new StorageSharedKeyCredential(this._config.accountName, this._config.accountKey)
54
+ serviceClient = new BlobServiceClient(`https://${this._config.accountName}.blob.core.windows.net`, cred)
55
+ } else if (this._config.accountName && this._config.sasToken) {
56
+ serviceClient = new BlobServiceClient(
57
+ `https://${this._config.accountName}.blob.core.windows.net?${this._config.sasToken}`,
58
+ )
59
+ } else {
60
+ throw new FilesystemError(
61
+ 'Azure driver requires either connectionString, accountName+accountKey, or accountName+sasToken.',
62
+ )
63
+ }
64
+
65
+ this._containerClient = serviceClient.getContainerClient(this.containerName)
66
+ }
67
+ return this._containerClient
68
+ }
69
+
70
+ private async blob(path: string): Promise<any> {
71
+ const c = await this.container()
72
+ return c.getBlockBlobClient(this.key(path))
73
+ }
74
+
75
+ private key(path: string): string {
76
+ return this.prefix + path.replace(/^\/+/, '')
77
+ }
78
+
79
+ private stripPrefix(key: string): string {
80
+ return this.prefix && key.startsWith(this.prefix)
81
+ ? key.slice(this.prefix.length)
82
+ : key
83
+ }
84
+
85
+ // ── Reads ─────────────────────────────────────────────────────────────────
86
+
87
+ async exists(path: string): Promise<boolean> {
88
+ const b = await this.blob(path)
89
+ return b.exists()
90
+ }
91
+
92
+ async get(path: string): Promise<string | null> {
93
+ try {
94
+ const b = await this.blob(path)
95
+ const res = await b.download()
96
+ return this.streamToString(res.readableStreamBody)
97
+ } catch (e: any) {
98
+ if (e.statusCode === 404) return null
99
+ throw new FilesystemError(`Azure get failed: ${e.message}`, { path })
100
+ }
101
+ }
102
+
103
+ async getBytes(path: string): Promise<Uint8Array | null> {
104
+ try {
105
+ const b = await this.blob(path)
106
+ const res = await b.download()
107
+ return this.streamToBytes(res.readableStreamBody)
108
+ } catch (e: any) {
109
+ if (e.statusCode === 404) return null
110
+ throw new FilesystemError(`Azure getBytes failed: ${e.message}`, { path })
111
+ }
112
+ }
113
+
114
+ async stream(path: string): Promise<ReadableStream | null> {
115
+ try {
116
+ const b = await this.blob(path)
117
+ if (!(await b.exists())) return null
118
+ const res = await b.download()
119
+ const body = res.readableStreamBody
120
+ if (!body) return null
121
+ if (body instanceof ReadableStream) return body
122
+ return new ReadableStream({
123
+ start(controller) {
124
+ body.on('data', (chunk: any) => controller.enqueue(new Uint8Array(chunk)))
125
+ body.on('end', () => controller.close())
126
+ body.on('error', (err: any) => controller.error(err))
127
+ },
128
+ })
129
+ } catch (e: any) {
130
+ if (e.statusCode === 404) return null
131
+ throw new FilesystemError(`Azure stream failed: ${e.message}`, { path })
132
+ }
133
+ }
134
+
135
+ // ── Writes ────────────────────────────────────────────────────────────────
136
+
137
+ async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
138
+ const b = await this.blob(path)
139
+ const body = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
140
+ const contentType = options?.mimeType ?? guessMimeType(path) ?? 'application/octet-stream'
141
+ await b.upload(body, body.length, {
142
+ blobHTTPHeaders: { blobContentType: contentType },
143
+ })
144
+ }
145
+
146
+ async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
147
+ const body = new Uint8Array(await new Response(stream).arrayBuffer())
148
+ await this.put(path, body, options)
149
+ }
150
+
151
+ async append(path: string, contents: string): Promise<void> {
152
+ const existing = (await this.get(path)) ?? ''
153
+ await this.put(path, existing + contents)
154
+ }
155
+
156
+ async prepend(path: string, contents: string): Promise<void> {
157
+ const existing = (await this.get(path)) ?? ''
158
+ await this.put(path, contents + existing)
159
+ }
160
+
161
+ // ── Operations ────────────────────────────────────────────────────────────
162
+
163
+ async delete(path: string | string[]): Promise<boolean> {
164
+ const paths = Array.isArray(path) ? path : [path]
165
+ try {
166
+ for (const p of paths) {
167
+ const b = await this.blob(p)
168
+ await b.deleteIfExists()
169
+ }
170
+ return true
171
+ } catch {
172
+ return false
173
+ }
174
+ }
175
+
176
+ async copy(from: string, to: string): Promise<void> {
177
+ const src = await this.blob(from)
178
+ const dest = await this.blob(to)
179
+ const poller = await dest.beginCopyFromURL(src.url)
180
+ await poller.pollUntilDone()
181
+ }
182
+
183
+ async move(from: string, to: string): Promise<void> {
184
+ await this.copy(from, to)
185
+ await this.delete(from)
186
+ }
187
+
188
+ // ── Metadata ──────────────────────────────────────────────────────────────
189
+
190
+ async size(path: string): Promise<number> {
191
+ const props = await this.properties(path)
192
+ return props.contentLength ?? 0
193
+ }
194
+
195
+ async lastModified(path: string): Promise<number> {
196
+ const props = await this.properties(path)
197
+ return props.lastModified ? props.lastModified.getTime() : 0
198
+ }
199
+
200
+ async mimeType(path: string): Promise<string | null> {
201
+ const props = await this.properties(path)
202
+ return props.contentType ?? null
203
+ }
204
+
205
+ path(filePath: string): string {
206
+ return this.key(filePath)
207
+ }
208
+
209
+ // ── URLs ──────────────────────────────────────────────────────────────────
210
+
211
+ url(path: string): string {
212
+ if (this.urlBase) {
213
+ return `${this.urlBase.replace(/\/+$/, '')}/${this.key(path)}`
214
+ }
215
+ if (this._config.accountName) {
216
+ return `https://${this._config.accountName}.blob.core.windows.net/${this.containerName}/${this.key(path)}`
217
+ }
218
+ throw new FilesystemError('Cannot generate URL — no accountName or url configured.', { path })
219
+ }
220
+
221
+ async temporaryUrl(path: string, expiration: number, _options?: Record<string, any>): Promise<string> {
222
+ let generateBlobSASQueryParameters: any, BlobSASPermissions: any, SASProtocol: any, StorageSharedKeyCredential: any
223
+ try {
224
+ ;({
225
+ generateBlobSASQueryParameters,
226
+ BlobSASPermissions,
227
+ SASProtocol,
228
+ StorageSharedKeyCredential,
229
+ } = await import('@azure/storage-blob'))
230
+ } catch {
231
+ throw new FilesystemError(
232
+ 'The @azure/storage-blob package is required for temporaryUrl().',
233
+ )
234
+ }
235
+
236
+ if (!this._config.accountName || !this._config.accountKey) {
237
+ throw new FilesystemError(
238
+ 'temporaryUrl() requires accountName and accountKey for SAS token generation.',
239
+ )
240
+ }
241
+
242
+ const cred = new StorageSharedKeyCredential(this._config.accountName, this._config.accountKey)
243
+ const sas = generateBlobSASQueryParameters({
244
+ containerName: this.containerName,
245
+ blobName: this.key(path),
246
+ permissions: BlobSASPermissions.parse('r'),
247
+ expiresOn: new Date(Date.now() + expiration * 1000),
248
+ protocol: SASProtocol.Https,
249
+ }, cred).toString()
250
+
251
+ return `${this.url(path)}?${sas}`
252
+ }
253
+
254
+ // ── Directories ───────────────────────────────────────────────────────────
255
+
256
+ async files(directory: string = ''): Promise<string[]> {
257
+ const prefix = this.directoryPrefix(directory)
258
+ const c = await this.container()
259
+ const results: string[] = []
260
+
261
+ for await (const item of c.listBlobsByHierarchy('/', { prefix })) {
262
+ if (item.kind !== 'prefix') {
263
+ results.push(this.stripPrefix(item.name))
264
+ }
265
+ }
266
+ return results
267
+ }
268
+
269
+ async allFiles(directory: string = ''): Promise<string[]> {
270
+ const prefix = this.directoryPrefix(directory)
271
+ const c = await this.container()
272
+ const results: string[] = []
273
+
274
+ for await (const blob of c.listBlobsFlat({ prefix })) {
275
+ if (!blob.name.endsWith('/')) {
276
+ results.push(this.stripPrefix(blob.name))
277
+ }
278
+ }
279
+ return results.sort()
280
+ }
281
+
282
+ async directories(directory: string = ''): Promise<string[]> {
283
+ const prefix = this.directoryPrefix(directory)
284
+ const c = await this.container()
285
+ const results: string[] = []
286
+
287
+ for await (const item of c.listBlobsByHierarchy('/', { prefix })) {
288
+ if (item.kind === 'prefix') {
289
+ results.push(this.stripPrefix(item.name).replace(/\/$/, ''))
290
+ }
291
+ }
292
+ return results
293
+ }
294
+
295
+ async allDirectories(directory: string = ''): Promise<string[]> {
296
+ const allFiles = await this.allFiles(directory)
297
+ const dirs = new Set<string>()
298
+ for (const file of allFiles) {
299
+ const parts = file.split('/')
300
+ for (let i = 1; i < parts.length; i++) {
301
+ dirs.add(parts.slice(0, i).join('/'))
302
+ }
303
+ }
304
+ return [...dirs].sort()
305
+ }
306
+
307
+ async makeDirectory(path: string): Promise<void> {
308
+ const b = await this.blob(path + '/')
309
+ await b.upload('', 0)
310
+ }
311
+
312
+ async deleteDirectory(directory: string): Promise<boolean> {
313
+ const prefix = this.directoryPrefix(directory)
314
+ const c = await this.container()
315
+ try {
316
+ for await (const blob of c.listBlobsFlat({ prefix })) {
317
+ await c.deleteBlob(blob.name)
318
+ }
319
+ return true
320
+ } catch {
321
+ return false
322
+ }
323
+ }
324
+
325
+ // ── Visibility ────────────────────────────────────────────────────────────
326
+
327
+ async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
328
+ // Azure manages access at the container level, not per-blob.
329
+ // Store visibility as blob metadata for API compatibility.
330
+ const b = await this.blob(path)
331
+ await b.setMetadata({ 'x-mantiq-visibility': visibility })
332
+ }
333
+
334
+ async getVisibility(path: string): Promise<string> {
335
+ const b = await this.blob(path)
336
+ try {
337
+ const props = await b.getProperties()
338
+ return props.metadata?.['x-mantiq-visibility'] ?? this.defaultVisibility
339
+ } catch {
340
+ return this.defaultVisibility
341
+ }
342
+ }
343
+
344
+ // ── Internal ──────────────────────────────────────────────────────────────
345
+
346
+ private async properties(path: string): Promise<any> {
347
+ const b = await this.blob(path)
348
+ try {
349
+ return await b.getProperties()
350
+ } catch (e: any) {
351
+ if (e.statusCode === 404) throw new FileNotFoundError(path)
352
+ throw new FilesystemError(`Azure properties failed: ${e.message}`, { path })
353
+ }
354
+ }
355
+
356
+ private directoryPrefix(directory: string): string {
357
+ return directory ? this.key(directory).replace(/\/$/, '') + '/' : this.prefix
358
+ }
359
+
360
+ private async streamToString(stream: any): Promise<string> {
361
+ if (!stream) return ''
362
+ const chunks: Buffer[] = []
363
+ for await (const chunk of stream) {
364
+ chunks.push(Buffer.from(chunk))
365
+ }
366
+ return Buffer.concat(chunks).toString('utf-8')
367
+ }
368
+
369
+ private async streamToBytes(stream: any): Promise<Uint8Array> {
370
+ if (!stream) return new Uint8Array(0)
371
+ const chunks: Buffer[] = []
372
+ for await (const chunk of stream) {
373
+ chunks.push(Buffer.from(chunk))
374
+ }
375
+ return new Uint8Array(Buffer.concat(chunks))
376
+ }
377
+ }
@@ -0,0 +1,299 @@
1
+ import { Writable, Readable } from 'node:stream'
2
+ import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
3
+ import { FilesystemError } from '../errors/FilesystemError.ts'
4
+ import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
5
+ import { guessMimeType } from '../helpers/mime.ts'
6
+
7
+ export interface FTPConfig {
8
+ host: string
9
+ port?: number
10
+ username?: string
11
+ password?: string
12
+ secure?: boolean | 'implicit'
13
+ root?: string
14
+ url?: string
15
+ visibility?: 'public' | 'private'
16
+ timeout?: number
17
+ }
18
+
19
+ /**
20
+ * FTP filesystem driver using basic-ftp.
21
+ *
22
+ * Requires: bun add basic-ftp
23
+ */
24
+ export class FTPDriver implements FilesystemDriver {
25
+ private _client: any = null
26
+ private _connected = false
27
+ private readonly root: string
28
+ private readonly urlBase: string | undefined
29
+ private readonly _config: FTPConfig
30
+
31
+ constructor(config: FTPConfig) {
32
+ this._config = config
33
+ this.root = (config.root ?? '/').replace(/\/+$/, '')
34
+ this.urlBase = config.url
35
+ }
36
+
37
+ private async client(): Promise<any> {
38
+ if (!this._client || this._client.closed) {
39
+ let Client: any
40
+ try {
41
+ ;({ Client } = await import('basic-ftp'))
42
+ } catch {
43
+ throw new FilesystemError(
44
+ 'The basic-ftp package is required for the FTP driver. Install it with: bun add basic-ftp',
45
+ )
46
+ }
47
+ this._client = new Client(this._config.timeout ?? 30000)
48
+ this._connected = false
49
+ }
50
+ if (!this._connected) {
51
+ await this._client.access({
52
+ host: this._config.host,
53
+ port: this._config.port ?? 21,
54
+ user: this._config.username ?? 'anonymous',
55
+ password: this._config.password ?? '',
56
+ secure: this._config.secure ?? false,
57
+ })
58
+ this._connected = true
59
+ }
60
+ return this._client
61
+ }
62
+
63
+ private fullPath(path: string): string {
64
+ return `${this.root}/${path.replace(/^\/+/, '')}`
65
+ }
66
+
67
+ /** Disconnect from the FTP server. */
68
+ async disconnect(): Promise<void> {
69
+ if (this._client) {
70
+ this._client.close()
71
+ this._connected = false
72
+ }
73
+ }
74
+
75
+ // ── Reads ─────────────────────────────────────────────────────────────────
76
+
77
+ async exists(path: string): Promise<boolean> {
78
+ const ftp = await this.client()
79
+ try {
80
+ await ftp.size(this.fullPath(path))
81
+ return true
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ async get(path: string): Promise<string | null> {
88
+ const bytes = await this.getBytes(path)
89
+ return bytes ? new TextDecoder().decode(bytes) : null
90
+ }
91
+
92
+ async getBytes(path: string): Promise<Uint8Array | null> {
93
+ const ftp = await this.client()
94
+ try {
95
+ const chunks: Buffer[] = []
96
+ const writable = new Writable({
97
+ write(chunk, _encoding, callback) {
98
+ chunks.push(Buffer.from(chunk))
99
+ callback()
100
+ },
101
+ })
102
+ await ftp.downloadTo(writable, this.fullPath(path))
103
+ return new Uint8Array(Buffer.concat(chunks))
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ async stream(path: string): Promise<ReadableStream | null> {
110
+ if (!(await this.exists(path))) return null
111
+ const bytes = await this.getBytes(path)
112
+ if (!bytes) return null
113
+ return new ReadableStream({
114
+ start(controller) {
115
+ controller.enqueue(bytes)
116
+ controller.close()
117
+ },
118
+ })
119
+ }
120
+
121
+ // ── Writes ────────────────────────────────────────────────────────────────
122
+
123
+ async put(path: string, contents: string | Uint8Array, _options?: PutOptions): Promise<void> {
124
+ const ftp = await this.client()
125
+ const buffer = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
126
+ const readable = Readable.from(buffer)
127
+ await ftp.ensureDir(this.fullPath(path).split('/').slice(0, -1).join('/'))
128
+ await ftp.uploadFrom(readable, this.fullPath(path))
129
+ }
130
+
131
+ async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
132
+ const body = new Uint8Array(await new Response(stream).arrayBuffer())
133
+ await this.put(path, body, options)
134
+ }
135
+
136
+ async append(path: string, contents: string): Promise<void> {
137
+ const ftp = await this.client()
138
+ const buffer = Buffer.from(contents, 'utf-8')
139
+ const readable = Readable.from(buffer)
140
+ await ftp.appendFrom(readable, this.fullPath(path))
141
+ }
142
+
143
+ async prepend(path: string, contents: string): Promise<void> {
144
+ const existing = (await this.get(path)) ?? ''
145
+ await this.put(path, contents + existing)
146
+ }
147
+
148
+ // ── Operations ────────────────────────────────────────────────────────────
149
+
150
+ async delete(path: string | string[]): Promise<boolean> {
151
+ const paths = Array.isArray(path) ? path : [path]
152
+ const ftp = await this.client()
153
+ try {
154
+ for (const p of paths) {
155
+ await ftp.remove(this.fullPath(p))
156
+ }
157
+ return true
158
+ } catch {
159
+ return false
160
+ }
161
+ }
162
+
163
+ async copy(from: string, to: string): Promise<void> {
164
+ // FTP doesn't support server-side copy — download and re-upload
165
+ const bytes = await this.getBytes(from)
166
+ if (!bytes) throw new FileNotFoundError(from)
167
+ await this.put(to, bytes)
168
+ }
169
+
170
+ async move(from: string, to: string): Promise<void> {
171
+ const ftp = await this.client()
172
+ await ftp.rename(this.fullPath(from), this.fullPath(to))
173
+ }
174
+
175
+ // ── Metadata ──────────────────────────────────────────────────────────────
176
+
177
+ async size(path: string): Promise<number> {
178
+ const ftp = await this.client()
179
+ try {
180
+ return await ftp.size(this.fullPath(path))
181
+ } catch {
182
+ throw new FileNotFoundError(path)
183
+ }
184
+ }
185
+
186
+ async lastModified(path: string): Promise<number> {
187
+ const ftp = await this.client()
188
+ try {
189
+ const date = await ftp.lastMod(this.fullPath(path))
190
+ return date.getTime()
191
+ } catch {
192
+ throw new FileNotFoundError(path)
193
+ }
194
+ }
195
+
196
+ async mimeType(path: string): Promise<string | null> {
197
+ return guessMimeType(path) ?? null
198
+ }
199
+
200
+ path(filePath: string): string {
201
+ return this.fullPath(filePath)
202
+ }
203
+
204
+ // ── URLs ──────────────────────────────────────────────────────────────────
205
+
206
+ url(path: string): string {
207
+ if (!this.urlBase) {
208
+ throw new FilesystemError('URL generation is not supported — no url configured for this FTP disk.', { path })
209
+ }
210
+ return `${this.urlBase.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
211
+ }
212
+
213
+ async temporaryUrl(_path: string, _expiration: number, _options?: Record<string, any>): Promise<string> {
214
+ throw new FilesystemError('Temporary URLs are not supported by the FTP driver.')
215
+ }
216
+
217
+ // ── Directories ───────────────────────────────────────────────────────────
218
+
219
+ async files(directory: string = ''): Promise<string[]> {
220
+ const ftp = await this.client()
221
+ try {
222
+ const entries = await ftp.list(this.fullPath(directory))
223
+ return entries
224
+ .filter((e: any) => e.type !== 2) // type 2 = directory
225
+ .map((e: any) => directory ? `${directory}/${e.name}` : e.name)
226
+ } catch {
227
+ return []
228
+ }
229
+ }
230
+
231
+ async allFiles(directory: string = ''): Promise<string[]> {
232
+ const results: string[] = []
233
+ const entries = await this.files(directory)
234
+ results.push(...entries)
235
+
236
+ const dirs = await this.directories(directory)
237
+ for (const dir of dirs) {
238
+ results.push(...(await this.allFiles(dir)))
239
+ }
240
+ return results.sort()
241
+ }
242
+
243
+ async directories(directory: string = ''): Promise<string[]> {
244
+ const ftp = await this.client()
245
+ try {
246
+ const entries = await ftp.list(this.fullPath(directory))
247
+ return entries
248
+ .filter((e: any) => e.type === 2) // type 2 = directory
249
+ .map((e: any) => directory ? `${directory}/${e.name}` : e.name)
250
+ } catch {
251
+ return []
252
+ }
253
+ }
254
+
255
+ async allDirectories(directory: string = ''): Promise<string[]> {
256
+ const results: string[] = []
257
+ const dirs = await this.directories(directory)
258
+ for (const dir of dirs) {
259
+ results.push(dir)
260
+ results.push(...(await this.allDirectories(dir)))
261
+ }
262
+ return results.sort()
263
+ }
264
+
265
+ async makeDirectory(path: string): Promise<void> {
266
+ const ftp = await this.client()
267
+ await ftp.ensureDir(this.fullPath(path))
268
+ }
269
+
270
+ async deleteDirectory(directory: string): Promise<boolean> {
271
+ const ftp = await this.client()
272
+ try {
273
+ await ftp.removeDir(this.fullPath(directory))
274
+ return true
275
+ } catch {
276
+ return false
277
+ }
278
+ }
279
+
280
+ // ── Visibility ────────────────────────────────────────────────────────────
281
+
282
+ async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
283
+ const ftp = await this.client()
284
+ const mode = visibility === 'public' ? '644' : '600'
285
+ try {
286
+ await ftp.send(`SITE CHMOD ${mode} ${this.fullPath(path)}`)
287
+ } catch {
288
+ throw new FilesystemError(
289
+ 'FTP server does not support SITE CHMOD. Visibility changes are not available.',
290
+ { path },
291
+ )
292
+ }
293
+ }
294
+
295
+ async getVisibility(_path: string): Promise<string> {
296
+ // FTP does not provide a reliable way to read file permissions
297
+ return this._config.visibility ?? 'private'
298
+ }
299
+ }