@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,306 @@
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 SFTPConfig {
7
+ host: string
8
+ port?: number
9
+ username?: string
10
+ password?: string
11
+ privateKey?: string | Buffer
12
+ passphrase?: string
13
+ root?: string
14
+ url?: string
15
+ visibility?: 'public' | 'private'
16
+ }
17
+
18
+ /**
19
+ * SFTP (SSH File Transfer Protocol) filesystem driver.
20
+ *
21
+ * Requires: bun add ssh2-sftp-client
22
+ */
23
+ export class SFTPDriver implements FilesystemDriver {
24
+ private _client: any = null
25
+ private _connected = false
26
+ private readonly root: string
27
+ private readonly urlBase: string | undefined
28
+ private readonly _config: SFTPConfig
29
+
30
+ constructor(config: SFTPConfig) {
31
+ this._config = config
32
+ this.root = (config.root ?? '/').replace(/\/+$/, '')
33
+ this.urlBase = config.url
34
+ }
35
+
36
+ private async client(): Promise<any> {
37
+ if (!this._client) {
38
+ let SftpClient: any
39
+ try {
40
+ const mod = await import('ssh2-sftp-client')
41
+ SftpClient = mod.default ?? mod
42
+ } catch {
43
+ throw new FilesystemError(
44
+ 'The ssh2-sftp-client package is required for the SFTP driver. Install it with: bun add ssh2-sftp-client',
45
+ )
46
+ }
47
+ this._client = new SftpClient()
48
+ this._connected = false
49
+ }
50
+ if (!this._connected) {
51
+ const opts: any = {
52
+ host: this._config.host,
53
+ port: this._config.port ?? 22,
54
+ username: this._config.username,
55
+ }
56
+ if (this._config.password) opts.password = this._config.password
57
+ if (this._config.privateKey) opts.privateKey = this._config.privateKey
58
+ if (this._config.passphrase) opts.passphrase = this._config.passphrase
59
+ await this._client.connect(opts)
60
+ this._connected = true
61
+ }
62
+ return this._client
63
+ }
64
+
65
+ private fullPath(path: string): string {
66
+ return `${this.root}/${path.replace(/^\/+/, '')}`
67
+ }
68
+
69
+ /** Disconnect from the SFTP server. */
70
+ async disconnect(): Promise<void> {
71
+ if (this._client && this._connected) {
72
+ await this._client.end()
73
+ this._connected = false
74
+ }
75
+ }
76
+
77
+ // ── Reads ─────────────────────────────────────────────────────────────────
78
+
79
+ async exists(path: string): Promise<boolean> {
80
+ const sftp = await this.client()
81
+ const result = await sftp.exists(this.fullPath(path))
82
+ return result !== false
83
+ }
84
+
85
+ async get(path: string): Promise<string | null> {
86
+ const sftp = await this.client()
87
+ try {
88
+ const buffer = await sftp.get(this.fullPath(path))
89
+ if (Buffer.isBuffer(buffer)) return buffer.toString('utf-8')
90
+ if (typeof buffer === 'string') return buffer
91
+ return null
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ async getBytes(path: string): Promise<Uint8Array | null> {
98
+ const sftp = await this.client()
99
+ try {
100
+ const buffer = await sftp.get(this.fullPath(path))
101
+ if (Buffer.isBuffer(buffer)) return new Uint8Array(buffer)
102
+ if (typeof buffer === 'string') return new TextEncoder().encode(buffer)
103
+ return null
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 sftp = await this.client()
125
+ const fp = this.fullPath(path)
126
+ // Ensure parent directory exists
127
+ const dir = fp.split('/').slice(0, -1).join('/')
128
+ if (dir) await sftp.mkdir(dir, true)
129
+ const buffer = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
130
+ await sftp.put(buffer, fp)
131
+
132
+ const visibility = options?.visibility ?? this._config.visibility
133
+ if (visibility) {
134
+ const mode = visibility === 'public' ? 0o644 : 0o600
135
+ await sftp.chmod(fp, mode)
136
+ }
137
+ }
138
+
139
+ async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
140
+ const body = new Uint8Array(await new Response(stream).arrayBuffer())
141
+ await this.put(path, body, options)
142
+ }
143
+
144
+ async append(path: string, contents: string): Promise<void> {
145
+ const sftp = await this.client()
146
+ const fp = this.fullPath(path)
147
+ await sftp.append(Buffer.from(contents, 'utf-8'), fp)
148
+ }
149
+
150
+ async prepend(path: string, contents: string): Promise<void> {
151
+ const existing = (await this.get(path)) ?? ''
152
+ await this.put(path, contents + existing)
153
+ }
154
+
155
+ // ── Operations ────────────────────────────────────────────────────────────
156
+
157
+ async delete(path: string | string[]): Promise<boolean> {
158
+ const paths = Array.isArray(path) ? path : [path]
159
+ const sftp = await this.client()
160
+ try {
161
+ for (const p of paths) {
162
+ await sftp.delete(this.fullPath(p))
163
+ }
164
+ return true
165
+ } catch {
166
+ return false
167
+ }
168
+ }
169
+
170
+ async copy(from: string, to: string): Promise<void> {
171
+ // SFTP doesn't support server-side copy — download and re-upload
172
+ const bytes = await this.getBytes(from)
173
+ if (!bytes) throw new FileNotFoundError(from)
174
+ await this.put(to, bytes)
175
+ }
176
+
177
+ async move(from: string, to: string): Promise<void> {
178
+ const sftp = await this.client()
179
+ await sftp.rename(this.fullPath(from), this.fullPath(to))
180
+ }
181
+
182
+ // ── Metadata ──────────────────────────────────────────────────────────────
183
+
184
+ async size(path: string): Promise<number> {
185
+ const sftp = await this.client()
186
+ try {
187
+ const stats = await sftp.stat(this.fullPath(path))
188
+ return stats.size
189
+ } catch {
190
+ throw new FileNotFoundError(path)
191
+ }
192
+ }
193
+
194
+ async lastModified(path: string): Promise<number> {
195
+ const sftp = await this.client()
196
+ try {
197
+ const stats = await sftp.stat(this.fullPath(path))
198
+ return stats.modifyTime
199
+ } catch {
200
+ throw new FileNotFoundError(path)
201
+ }
202
+ }
203
+
204
+ async mimeType(path: string): Promise<string | null> {
205
+ return guessMimeType(path) ?? null
206
+ }
207
+
208
+ path(filePath: string): string {
209
+ return this.fullPath(filePath)
210
+ }
211
+
212
+ // ── URLs ──────────────────────────────────────────────────────────────────
213
+
214
+ url(path: string): string {
215
+ if (!this.urlBase) {
216
+ throw new FilesystemError('URL generation is not supported — no url configured for this SFTP disk.', { path })
217
+ }
218
+ return `${this.urlBase.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
219
+ }
220
+
221
+ async temporaryUrl(_path: string, _expiration: number, _options?: Record<string, any>): Promise<string> {
222
+ throw new FilesystemError('Temporary URLs are not supported by the SFTP driver.')
223
+ }
224
+
225
+ // ── Directories ───────────────────────────────────────────────────────────
226
+
227
+ async files(directory: string = ''): Promise<string[]> {
228
+ const sftp = await this.client()
229
+ try {
230
+ const entries = await sftp.list(this.fullPath(directory))
231
+ return entries
232
+ .filter((e: any) => e.type === '-') // regular file
233
+ .map((e: any) => directory ? `${directory}/${e.name}` : e.name)
234
+ } catch {
235
+ return []
236
+ }
237
+ }
238
+
239
+ async allFiles(directory: string = ''): Promise<string[]> {
240
+ const results: string[] = []
241
+ const entries = await this.files(directory)
242
+ results.push(...entries)
243
+
244
+ const dirs = await this.directories(directory)
245
+ for (const dir of dirs) {
246
+ results.push(...(await this.allFiles(dir)))
247
+ }
248
+ return results.sort()
249
+ }
250
+
251
+ async directories(directory: string = ''): Promise<string[]> {
252
+ const sftp = await this.client()
253
+ try {
254
+ const entries = await sftp.list(this.fullPath(directory))
255
+ return entries
256
+ .filter((e: any) => e.type === 'd') // directory
257
+ .map((e: any) => directory ? `${directory}/${e.name}` : e.name)
258
+ } catch {
259
+ return []
260
+ }
261
+ }
262
+
263
+ async allDirectories(directory: string = ''): Promise<string[]> {
264
+ const results: string[] = []
265
+ const dirs = await this.directories(directory)
266
+ for (const dir of dirs) {
267
+ results.push(dir)
268
+ results.push(...(await this.allDirectories(dir)))
269
+ }
270
+ return results.sort()
271
+ }
272
+
273
+ async makeDirectory(path: string): Promise<void> {
274
+ const sftp = await this.client()
275
+ await sftp.mkdir(this.fullPath(path), true)
276
+ }
277
+
278
+ async deleteDirectory(directory: string): Promise<boolean> {
279
+ const sftp = await this.client()
280
+ try {
281
+ await sftp.rmdir(this.fullPath(directory), true)
282
+ return true
283
+ } catch {
284
+ return false
285
+ }
286
+ }
287
+
288
+ // ── Visibility ────────────────────────────────────────────────────────────
289
+
290
+ async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
291
+ const sftp = await this.client()
292
+ const mode = visibility === 'public' ? 0o644 : 0o600
293
+ await sftp.chmod(this.fullPath(path), mode)
294
+ }
295
+
296
+ async getVisibility(path: string): Promise<string> {
297
+ const sftp = await this.client()
298
+ try {
299
+ const stats = await sftp.stat(this.fullPath(path))
300
+ // Check if others have read permission
301
+ return (stats.permissions & 0o004) ? 'public' : 'private'
302
+ } catch {
303
+ return this._config.visibility ?? 'private'
304
+ }
305
+ }
306
+ }
@@ -0,0 +1,7 @@
1
+ import { FilesystemError } from './FilesystemError.ts'
2
+
3
+ export class FileExistsError extends FilesystemError {
4
+ constructor(path: string) {
5
+ super(`File already exists: ${path}`, { path })
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import { FilesystemError } from './FilesystemError.ts'
2
+
3
+ export class FileNotFoundError extends FilesystemError {
4
+ constructor(path: string) {
5
+ super(`File not found: ${path}`, { path })
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import { MantiqError } from '@mantiq/core'
2
+
3
+ export class FilesystemError extends MantiqError {
4
+ constructor(message: string, context?: Record<string, any>) {
5
+ super(message, context)
6
+ }
7
+ }
@@ -0,0 +1,61 @@
1
+ const EXT_MAP: Record<string, string> = {
2
+ '.html': 'text/html',
3
+ '.htm': 'text/html',
4
+ '.css': 'text/css',
5
+ '.js': 'application/javascript',
6
+ '.mjs': 'application/javascript',
7
+ '.json': 'application/json',
8
+ '.xml': 'application/xml',
9
+ '.csv': 'text/csv',
10
+ '.txt': 'text/plain',
11
+ '.md': 'text/markdown',
12
+ '.yaml': 'application/x-yaml',
13
+ '.yml': 'application/x-yaml',
14
+ '.toml': 'application/toml',
15
+ '.png': 'image/png',
16
+ '.jpg': 'image/jpeg',
17
+ '.jpeg': 'image/jpeg',
18
+ '.gif': 'image/gif',
19
+ '.webp': 'image/webp',
20
+ '.avif': 'image/avif',
21
+ '.svg': 'image/svg+xml',
22
+ '.ico': 'image/x-icon',
23
+ '.bmp': 'image/bmp',
24
+ '.tiff': 'image/tiff',
25
+ '.tif': 'image/tiff',
26
+ '.pdf': 'application/pdf',
27
+ '.zip': 'application/zip',
28
+ '.gz': 'application/gzip',
29
+ '.tar': 'application/x-tar',
30
+ '.br': 'application/x-brotli',
31
+ '.7z': 'application/x-7z-compressed',
32
+ '.rar': 'application/vnd.rar',
33
+ '.mp3': 'audio/mpeg',
34
+ '.ogg': 'audio/ogg',
35
+ '.wav': 'audio/wav',
36
+ '.flac': 'audio/flac',
37
+ '.aac': 'audio/aac',
38
+ '.mp4': 'video/mp4',
39
+ '.webm': 'video/webm',
40
+ '.mov': 'video/quicktime',
41
+ '.avi': 'video/x-msvideo',
42
+ '.mkv': 'video/x-matroska',
43
+ '.woff': 'font/woff',
44
+ '.woff2': 'font/woff2',
45
+ '.ttf': 'font/ttf',
46
+ '.otf': 'font/otf',
47
+ '.eot': 'application/vnd.ms-fontobject',
48
+ '.wasm': 'application/wasm',
49
+ '.doc': 'application/msword',
50
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
51
+ '.xls': 'application/vnd.ms-excel',
52
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
53
+ '.ppt': 'application/vnd.ms-powerpoint',
54
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
55
+ }
56
+
57
+ export function guessMimeType(path: string): string | undefined {
58
+ const dot = path.lastIndexOf('.')
59
+ if (dot === -1) return undefined
60
+ return EXT_MAP[path.slice(dot).toLowerCase()]
61
+ }
@@ -0,0 +1,13 @@
1
+ import { Application } from '@mantiq/core'
2
+ import type { FilesystemManager } from '../FilesystemManager.ts'
3
+ import type { FilesystemDriver } from '../contracts/FilesystemDriver.ts'
4
+
5
+ export const FILESYSTEM_MANAGER = Symbol('FilesystemManager')
6
+
7
+ export function storage(): FilesystemManager
8
+ export function storage(disk: string): FilesystemDriver
9
+ export function storage(disk?: string): FilesystemManager | FilesystemDriver {
10
+ const manager = Application.getInstance().make<FilesystemManager>(FILESYSTEM_MANAGER)
11
+ if (disk === undefined) return manager
12
+ return manager.disk(disk)
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // ── Contracts ─────────────────────────────────────────────────────────────────
2
+ export type { FilesystemDriver, PutOptions } from './contracts/FilesystemDriver.ts'
3
+ export type {
4
+ FilesystemConfig,
5
+ DiskConfig,
6
+ S3DiskConfig,
7
+ GCSDiskConfig,
8
+ AzureDiskConfig,
9
+ FTPDiskConfig,
10
+ SFTPDiskConfig,
11
+ } from './contracts/FilesystemConfig.ts'
12
+
13
+ // ── Core ──────────────────────────────────────────────────────────────────────
14
+ export { FilesystemManager } from './FilesystemManager.ts'
15
+ export { FilesystemServiceProvider } from './FilesystemServiceProvider.ts'
16
+
17
+ // ── Drivers ───────────────────────────────────────────────────────────────────
18
+ export { LocalDriver } from './drivers/LocalDriver.ts'
19
+ export { NullDriver } from './drivers/NullDriver.ts'
20
+ export { S3Driver } from './drivers/S3Driver.ts'
21
+ export type { S3Config } from './drivers/S3Driver.ts'
22
+ export { GCSDriver } from './drivers/GCSDriver.ts'
23
+ export type { GCSConfig } from './drivers/GCSDriver.ts'
24
+ export { AzureBlobDriver } from './drivers/AzureBlobDriver.ts'
25
+ export type { AzureConfig } from './drivers/AzureBlobDriver.ts'
26
+ export { FTPDriver } from './drivers/FTPDriver.ts'
27
+ export type { FTPConfig } from './drivers/FTPDriver.ts'
28
+ export { SFTPDriver } from './drivers/SFTPDriver.ts'
29
+ export type { SFTPConfig } from './drivers/SFTPDriver.ts'
30
+
31
+ // ── Errors ────────────────────────────────────────────────────────────────────
32
+ export { FilesystemError } from './errors/FilesystemError.ts'
33
+ export { FileNotFoundError } from './errors/FileNotFoundError.ts'
34
+ export { FileExistsError } from './errors/FileExistsError.ts'
35
+
36
+ // ── Helpers ───────────────────────────────────────────────────────────────────
37
+ export { storage, FILESYSTEM_MANAGER } from './helpers/storage.ts'
38
+ export { guessMimeType } from './helpers/mime.ts'