@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.0

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/host/api.js ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * HTTP API for the dsh-ssh-helper Settings section.
3
+ *
4
+ * One prefix route, `/ssh-helper/api`, answers
5
+ * `POST /ssh-helper/api/<method>` with a JSON body and a JSON envelope:
6
+ * `{ ok: true, data }` on success and `{ ok: false, error: { code, message } }`
7
+ * on failure. Requests are fenced by `ctx.connection.requestRejection`, bodies
8
+ * are bounded to 1 MiB, and no handler ever returns a password, private key or
9
+ * passphrase.
10
+ *
11
+ * @module host/api
12
+ */
13
+
14
+ /** Route prefix owned by this plugin. */
15
+ const API_PREFIX = '/ssh-helper/api'
16
+ /** Maximum accepted JSON body size (1 MiB). */
17
+ const MAX_BODY_BYTES = 1024 * 1024
18
+ /** Message shown for unexpected internal failures. */
19
+ const INTERNAL_MESSAGE = '服务器内部错误'
20
+ /** Recent history entries returned by `resources/get`. */
21
+ const RECENT_HISTORY_LIMIT = 20
22
+
23
+ /**
24
+ * Create one tagged error.
25
+ * @param {string} code - machine-readable code.
26
+ * @param {string} message - friendly Chinese message.
27
+ * @returns {Error & { code: string }} tagged error.
28
+ */
29
+ function apiError(code, message) {
30
+ const error = new Error(message)
31
+ error.code = code
32
+ return error
33
+ }
34
+
35
+ /** @param {unknown} value @returns {boolean} whether the value is a plain object. */
36
+ function isObject(value) {
37
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
38
+ }
39
+
40
+ /**
41
+ * Whether a response is still writable.
42
+ * @param {object} res - ServerResponse-like object.
43
+ * @returns {boolean} writability.
44
+ */
45
+ function canWrite(res) {
46
+ if (!res || typeof res !== 'object') return false
47
+ if (res.headersSent === true || res.writableEnded === true || res.destroyed === true) return false
48
+ return typeof res.writeHead === 'function' && typeof res.end === 'function'
49
+ }
50
+
51
+ /**
52
+ * Write one JSON response, tolerating a client that already went away.
53
+ * @param {object} res - ServerResponse-like object.
54
+ * @param {number} status - HTTP status.
55
+ * @param {object} body - JSON body.
56
+ */
57
+ function writeJson(res, status, body) {
58
+ if (!canWrite(res)) {
59
+ try {
60
+ res.destroy?.()
61
+ } catch {
62
+ /* nothing left to release */
63
+ }
64
+ return
65
+ }
66
+ let payload
67
+ try {
68
+ payload = JSON.stringify(body)
69
+ } catch {
70
+ payload = JSON.stringify({ ok: false, error: { code: 'internal', message: INTERNAL_MESSAGE } })
71
+ status = 500
72
+ }
73
+ try {
74
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
75
+ res.end(payload)
76
+ } catch {
77
+ try {
78
+ res.destroy?.()
79
+ } catch {
80
+ /* response already gone */
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Write the success envelope.
87
+ * @param {object} res - response.
88
+ * @param {object} data - payload.
89
+ */
90
+ function writeOk(res, data) {
91
+ writeJson(res, 200, { ok: true, data })
92
+ }
93
+
94
+ /**
95
+ * Write the failure envelope.
96
+ * @param {object} res - response.
97
+ * @param {number} status - HTTP status.
98
+ * @param {string} code - machine-readable code.
99
+ * @param {string} message - friendly Chinese message.
100
+ */
101
+ function writeFail(res, status, code, message) {
102
+ writeJson(res, status, { ok: false, error: { code, message } })
103
+ }
104
+
105
+ /**
106
+ * Map one thrown value to the API failure envelope.
107
+ * @param {object} ctx - host plugin context (logger optional).
108
+ * @param {object} res - response.
109
+ * @param {unknown} error - thrown value.
110
+ */
111
+ function writeError(ctx, res, error) {
112
+ const code = error && typeof error.code === 'string' ? error.code : ''
113
+ const message = error && typeof error.message === 'string' ? error.message : ''
114
+ if (code === 'invalid-input' || code === 'invalid-path') return writeFail(res, 400, code, message || '请求参数无效')
115
+ if (code === 'bad-request') return writeFail(res, 400, code, message || '请求格式无效')
116
+ if (code === 'unsupported-media-type') return writeFail(res, 415, code, message || '请求 content-type 不受支持')
117
+ if (code === 'conflict') return writeFail(res, 409, code, message || '请求存在冲突')
118
+ if (code === 'busy') return writeFail(res, 409, code, message || '操作正在进行中')
119
+ if (code === 'not-found' || code === 'plan-not-found' || code === 'no-backup') {
120
+ return writeFail(res, 404, code, message || '资源不存在')
121
+ }
122
+ if (code === 'plan-expired') return writeFail(res, 410, code, message || '计划已过期')
123
+ try {
124
+ ctx?.logger?.warn?.(`dsh-ssh-helper: API request failed${message ? `: ${message}` : ''}`)
125
+ } catch {
126
+ /* logging is best effort */
127
+ }
128
+ return writeFail(res, 500, 'internal', INTERNAL_MESSAGE)
129
+ }
130
+
131
+ /**
132
+ * Read and parse the bounded JSON request body.
133
+ *
134
+ * A body larger than `MAX_BODY_BYTES` destroys the request socket instead of
135
+ * only abandoning the remainder, so an attacker cannot keep streaming bytes at
136
+ * a bounded buffer.
137
+ *
138
+ * @param {object} req - IncomingMessage-like async iterable.
139
+ * @returns {Promise<object>} parsed body (`{}` for an empty body).
140
+ */
141
+ async function readJsonBody(req) {
142
+ const chunks = []
143
+ let total = 0
144
+ let oversized = false
145
+ for await (const chunk of req) {
146
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
147
+ total += buffer.length
148
+ if (total > MAX_BODY_BYTES) {
149
+ oversized = true
150
+ break
151
+ }
152
+ chunks.push(buffer)
153
+ }
154
+ if (oversized) {
155
+ // Do not merely abandon the remainder: drop the socket so the sender stops
156
+ // streaming instead of being throttled at the 1 MiB boundary.
157
+ try {
158
+ req.destroy?.()
159
+ } catch {
160
+ /* the socket is already gone */
161
+ }
162
+ throw apiError('bad-request', '请求体超过 1 MiB 限制')
163
+ }
164
+ const text = Buffer.concat(chunks).toString('utf8')
165
+ if (text.trim() === '') return {}
166
+ let parsed
167
+ try {
168
+ parsed = JSON.parse(text)
169
+ } catch {
170
+ throw apiError('bad-request', '请求体不是有效的 JSON')
171
+ }
172
+ if (!isObject(parsed)) throw apiError('bad-request', '请求体必须是 JSON 对象')
173
+ return parsed
174
+ }
175
+
176
+ /**
177
+ * Whether one request declares a JSON body.
178
+ *
179
+ * The check is conservative: an absent or unrelated `content-type` is rejected.
180
+ * A browser cannot send `application/json` cross-origin without a preflight, so
181
+ * this is a second fence behind `requestRejection` and it keeps form-encoded or
182
+ * plain-text traffic away from the JSON parser.
183
+ *
184
+ * @param {object} req - IncomingMessage-like object.
185
+ * @returns {boolean} whether the request declares `application/json`.
186
+ */
187
+ function hasJsonContentType(req) {
188
+ const headers = req && typeof req === 'object' ? req.headers : undefined
189
+ const raw = headers && typeof headers === 'object' ? headers['content-type'] : undefined
190
+ const contentType = typeof raw === 'string' ? raw.trim() : ''
191
+ return /^application\/json\b/i.test(contentType)
192
+ }
193
+
194
+ /**
195
+ * Require one non-empty string id.
196
+ * @param {object} body - request body.
197
+ * @param {string} [label] - error label.
198
+ * @returns {string} trimmed id.
199
+ */
200
+ function requireId(body, label = 'id') {
201
+ const id = body && typeof body === 'object' ? body.id : undefined
202
+ if (typeof id !== 'string' || id.trim() === '') throw apiError('invalid-input', `缺少 ${label}`)
203
+ return id.trim()
204
+ }
205
+
206
+ /**
207
+ * Run a connectivity test without failing the API call for a reachable
208
+ * failure: a failed connection is a successful response with `success: false`.
209
+ * @param {object} ssh - ssh client.
210
+ * @param {object} record - full record including credentials.
211
+ * @returns {Promise<{ success: boolean, latencyMs: number, message: string, output?: string }>} test result.
212
+ */
213
+ async function runConnectionTest(ssh, record) {
214
+ const startedAt = Date.now()
215
+ try {
216
+ const probed = await ssh.probe(record)
217
+ return {
218
+ success: true,
219
+ latencyMs: typeof probed.latencyMs === 'number' ? probed.latencyMs : Date.now() - startedAt,
220
+ message: '连接成功',
221
+ output: typeof probed.output === 'string' ? probed.output : '',
222
+ }
223
+ } catch (error) {
224
+ const message = error && typeof error.message === 'string' && error.message !== '' ? error.message : '连接失败'
225
+ return { success: false, latencyMs: Date.now() - startedAt, message }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Build the method table.
231
+ * @param {{ accounts: object, resources: object, history: object, ssh: object }} services - stores and client.
232
+ * @returns {Record<string, (body: object) => Promise<object>>} method handlers.
233
+ */
234
+ function createMethods({ accounts, resources, history, ssh }) {
235
+ return {
236
+ /**
237
+ * @returns {Promise<{ accounts: object[], legacy: { available: boolean, count: number } }>} accounts and legacy info.
238
+ */
239
+ async 'accounts/list'() {
240
+ const info = accounts.legacyInfo()
241
+ // `legacyInfo().source` is an absolute host path; it must not reach the
242
+ // browser, so only the two countable fields are projected here.
243
+ return {
244
+ accounts: accounts.list(),
245
+ legacy: { available: info.available === true, count: typeof info.count === 'number' ? info.count : 0 },
246
+ }
247
+ },
248
+ /**
249
+ * @param {object} body - flat account payload with optional `id`.
250
+ * @returns {Promise<{ account: object }>} saved account view.
251
+ */
252
+ async 'accounts/save'(body) {
253
+ const id = typeof body.id === 'string' && body.id.trim() !== '' ? body.id.trim() : ''
254
+ const account = id === '' ? await accounts.create(body) : await accounts.update(id, body)
255
+ return { account }
256
+ },
257
+ /**
258
+ * @param {object} body - `{ id }`.
259
+ * @returns {Promise<{ deleted: true }>} deletion marker.
260
+ */
261
+ async 'accounts/delete'(body) {
262
+ const id = requireId(body, '账户 id')
263
+ const deleted = await accounts.remove(id)
264
+ if (!deleted) throw apiError('not-found', '账户不存在')
265
+ return { deleted: true }
266
+ },
267
+ /**
268
+ * @param {object} body - `{ id }`.
269
+ * @returns {Promise<object>} connectivity test result.
270
+ */
271
+ async 'accounts/test'(body) {
272
+ const id = requireId(body, '账户 id')
273
+ const record = accounts.getSecretRecord(id)
274
+ if (!record) throw apiError('not-found', '账户不存在')
275
+ return runConnectionTest(ssh, record)
276
+ },
277
+ /**
278
+ * @param {object} body - account draft.
279
+ * @returns {Promise<object>} connectivity test result.
280
+ */
281
+ async 'accounts/test-draft'(body) {
282
+ const record = await accounts.testDraft(body)
283
+ return runConnectionTest(ssh, record)
284
+ },
285
+ /** @returns {Promise<{ imported: number, skipped: number, total: number }>} import summary. */
286
+ async 'accounts/import'() {
287
+ return accounts.importLegacy()
288
+ },
289
+ /**
290
+ * @param {object} body - optional `{ server }` filter.
291
+ * @returns {Promise<{ resources: object[] }>} resource views.
292
+ */
293
+ async 'resources/list'(body) {
294
+ return { resources: resources.list({ server: typeof body.server === 'string' ? body.server : undefined }) }
295
+ },
296
+ /**
297
+ * @param {object} body - `{ id }` or `{ resource }`.
298
+ * @returns {Promise<{ resource: object, history: object[] }>} resource and recent history.
299
+ */
300
+ async 'resources/get'(body) {
301
+ const query =
302
+ typeof body.id === 'string' && body.id.trim() !== ''
303
+ ? body.id.trim()
304
+ : typeof body.resource === 'string' && body.resource.trim() !== ''
305
+ ? body.resource.trim()
306
+ : ''
307
+ if (query === '') throw apiError('invalid-input', '缺少资源 id')
308
+ const resource = resources.find(query)
309
+ if (!resource) throw apiError('not-found', '资源不存在')
310
+ return { resource, history: history.list({ resourceId: resource.id, limit: RECENT_HISTORY_LIMIT }) }
311
+ },
312
+ /**
313
+ * @param {object} body - flat resource payload with optional `id`.
314
+ * @returns {Promise<{ resource: object }>} saved resource view.
315
+ */
316
+ async 'resources/save'(body) {
317
+ return { resource: await resources.save(body) }
318
+ },
319
+ /**
320
+ * @param {object} body - `{ id }`.
321
+ * @returns {Promise<{ deleted: true }>} deletion marker.
322
+ */
323
+ async 'resources/delete'(body) {
324
+ const id = requireId(body, '资源 id')
325
+ const deleted = await resources.remove(id)
326
+ if (!deleted) throw apiError('not-found', '资源不存在')
327
+ return { deleted: true }
328
+ },
329
+ /**
330
+ * @param {object} body - `{ resource?, limit? }`.
331
+ * @returns {Promise<{ history: object[] }>} history entries.
332
+ */
333
+ async 'history/list'(body) {
334
+ const rawResource = typeof body.resource === 'string' ? body.resource.trim() : ''
335
+ let resourceId
336
+ if (rawResource !== '') {
337
+ const resource = resources.find(rawResource)
338
+ resourceId = resource ? resource.id : rawResource
339
+ }
340
+ const limit =
341
+ typeof body.limit === 'number' && Number.isFinite(body.limit) ? Math.min(200, Math.max(1, Math.round(body.limit))) : undefined
342
+ return { history: history.list({ resourceId, limit }) }
343
+ },
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Register the `/ssh-helper/api` prefix route.
349
+ * @param {object} ctx - host plugin context (`webServer`, `connection`).
350
+ * @param {{ accounts: object, resources: object, history: object, ssh: object }} services - stores and client.
351
+ * @returns {() => void} route disposer.
352
+ */
353
+ export function registerApiRoutes(ctx, { accounts, resources, history, ssh } = {}) {
354
+ if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== 'function') {
355
+ throw new Error('dsh-ssh-helper: webServer service is unavailable')
356
+ }
357
+ if (!accounts || !resources || !history || !ssh) throw new Error('dsh-ssh-helper: API services are unavailable')
358
+ const methods = createMethods({ accounts, resources, history, ssh })
359
+
360
+ /**
361
+ * Request handler for the prefix route.
362
+ * @param {object} req - IncomingMessage.
363
+ * @param {object} res - ServerResponse.
364
+ * @returns {Promise<void>} resolves once the response settled.
365
+ */
366
+ async function handle(req, res) {
367
+ let closed = false
368
+ const markClosed = () => {
369
+ closed = true
370
+ }
371
+ try {
372
+ res?.once?.('close', markClosed)
373
+ req?.once?.('error', markClosed)
374
+ } catch {
375
+ /* event registration is best effort */
376
+ }
377
+ try {
378
+ const connection = ctx.connection
379
+ let rejected = true
380
+ try {
381
+ rejected = !connection || typeof connection.requestRejection !== 'function' ? true : connection.requestRejection(req) !== undefined
382
+ } catch {
383
+ rejected = true
384
+ }
385
+ if (rejected) {
386
+ writeFail(res, 403, 'forbidden', '请求被拒绝')
387
+ return
388
+ }
389
+ if (req.method !== 'POST') {
390
+ writeFail(res, 405, 'method-not-allowed', '仅支持 POST 请求')
391
+ return
392
+ }
393
+ let method = ''
394
+ try {
395
+ const pathname = new URL(typeof req.url === 'string' && req.url !== '' ? req.url : '/', 'http://dsh.internal').pathname
396
+ if (pathname.startsWith(`${API_PREFIX}/`)) method = pathname.slice(API_PREFIX.length + 1)
397
+ } catch {
398
+ method = ''
399
+ }
400
+ if (method === '' || method.startsWith('/') || method.endsWith('/') || method.includes('//')) {
401
+ writeFail(res, 404, 'not-found', '未知的 API 方法')
402
+ return
403
+ }
404
+ const handler = Object.hasOwn(methods, method) ? methods[method] : undefined
405
+ if (typeof handler !== 'function') {
406
+ writeFail(res, 404, 'not-found', `未知的 API 方法 "${method}"`)
407
+ return
408
+ }
409
+ if (!hasJsonContentType(req)) {
410
+ writeFail(res, 415, 'unsupported-media-type', '请求 content-type 必须是 application/json')
411
+ return
412
+ }
413
+ let body
414
+ try {
415
+ body = await readJsonBody(req)
416
+ } catch (error) {
417
+ if (!closed) writeError(ctx, res, error)
418
+ return
419
+ }
420
+ if (closed) return
421
+ try {
422
+ const data = await handler(body)
423
+ if (!closed) writeOk(res, data)
424
+ } catch (error) {
425
+ if (!closed) writeError(ctx, res, error)
426
+ }
427
+ } catch (error) {
428
+ if (!closed) writeError(ctx, res, error)
429
+ } finally {
430
+ try {
431
+ res?.off?.('close', markClosed)
432
+ req?.off?.('error', markClosed)
433
+ } catch {
434
+ /* listener cleanup is best effort */
435
+ }
436
+ }
437
+ }
438
+
439
+ return ctx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler: handle })
440
+ }
package/host/atomic.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Atomic JSON file storage.
3
+ *
4
+ * Every data file of the plugin goes through this module. Writes are serialized
5
+ * through one promise chain, written to `<file>.tmp` with mode `0600`, then
6
+ * renamed over the live file so a reader never observes a half-written JSON
7
+ * document. A file whose content is not valid JSON is moved aside to
8
+ * `<file>.corrupt-<timestamp>` and treated as absent, which lets the plugin
9
+ * start from an empty store instead of crashing.
10
+ *
11
+ * @module host/atomic
12
+ */
13
+
14
+ import { chmodSync, copyFileSync, readFileSync, renameSync } from 'node:fs'
15
+ import { chmod, mkdir, open, rename } from 'node:fs/promises'
16
+ import path from 'node:path'
17
+
18
+ /** POSIX mode for data files and their temporary siblings. */
19
+ const FILE_MODE = 0o600
20
+ /** POSIX mode for the plugin data directory (owner-only). */
21
+ const DIR_MODE = 0o700
22
+
23
+ /**
24
+ * Move an unreadable file aside without throwing.
25
+ *
26
+ * The quarantine copy can hold the credential JSON, so it is tightened to
27
+ * `0600` on both paths: `renameSync` preserves the mode of the source file
28
+ * (which a foreign writer may have created loosely) and `copyFileSync` creates
29
+ * the copy with the process umask.
30
+ *
31
+ * @param {string} filePath - absolute path of the unreadable file.
32
+ * @returns {void}
33
+ */
34
+ function quarantine(filePath) {
35
+ const backup = `${filePath}.corrupt-${Date.now()}`
36
+ try {
37
+ renameSync(filePath, backup)
38
+ chmodSync(backup, FILE_MODE)
39
+ return
40
+ } catch {
41
+ /* fall through: rename can fail across odd filesystems or permissions */
42
+ }
43
+ try {
44
+ copyFileSync(filePath, backup)
45
+ chmodSync(backup, FILE_MODE)
46
+ } catch {
47
+ /* best effort: the caller still starts from an empty in-memory store */
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Create one atomically written JSON file handle.
53
+ *
54
+ * @param {{ dir: string, filename: string }} options - directory and file name.
55
+ * @returns {{
56
+ * path: string,
57
+ * read: () => unknown,
58
+ * write: (value: unknown) => Promise<void>,
59
+ * update: (mutate: (current: unknown) => unknown | Promise<unknown>) => Promise<unknown>,
60
+ * }} file handle.
61
+ */
62
+ export function createJsonFile({ dir, filename } = {}) {
63
+ if (typeof dir !== 'string' || dir.trim() === '') throw new TypeError('createJsonFile: dir is required')
64
+ if (typeof filename !== 'string' || filename.trim() === '') throw new TypeError('createJsonFile: filename is required')
65
+
66
+ const filePath = path.join(path.resolve(dir), filename)
67
+ const tmpPath = `${filePath}.tmp`
68
+ /** @type {Promise<unknown>} tail of the serialized write chain. */
69
+ let chain = Promise.resolve()
70
+
71
+ /**
72
+ * Read and parse the file synchronously. A missing file or invalid JSON
73
+ * yields `undefined`; invalid JSON is quarantined first.
74
+ * @returns {unknown} parsed JSON value or undefined.
75
+ */
76
+ function read() {
77
+ let text
78
+ try {
79
+ text = readFileSync(filePath, 'utf8')
80
+ } catch (error) {
81
+ if (!error || error.code !== 'ENOENT') quarantine(filePath)
82
+ return undefined
83
+ }
84
+ if (text.trim() === '') return undefined
85
+ try {
86
+ return JSON.parse(text)
87
+ } catch {
88
+ quarantine(filePath)
89
+ return undefined
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Write one already-serialized text body atomically.
95
+ *
96
+ * The temporary file is created with the final mode up front, so the
97
+ * credential-bearing body is never readable by other local users, not even
98
+ * for the instant between creation and an after-the-fact `chmod`.
99
+ *
100
+ * @param {string} text - JSON text.
101
+ * @returns {Promise<void>} resolves after the rename.
102
+ */
103
+ async function writeText(text) {
104
+ await mkdir(path.dirname(filePath), { recursive: true, mode: DIR_MODE })
105
+ const handle = await open(tmpPath, 'w', FILE_MODE)
106
+ try {
107
+ await handle.writeFile(text, 'utf8')
108
+ } finally {
109
+ await handle.close()
110
+ }
111
+ await rename(tmpPath, filePath)
112
+ await chmod(filePath, FILE_MODE).catch(() => {})
113
+ }
114
+
115
+ /**
116
+ * Enqueue one task behind every earlier write/update.
117
+ * @template T
118
+ * @param {() => Promise<T>} task - task to run.
119
+ * @returns {Promise<T>} task outcome.
120
+ */
121
+ function enqueue(task) {
122
+ const run = chain.then(task, task)
123
+ chain = run.then(
124
+ () => undefined,
125
+ () => undefined,
126
+ )
127
+ return run
128
+ }
129
+
130
+ /**
131
+ * Write a value atomically.
132
+ * @param {unknown} value - JSON-serializable value.
133
+ * @returns {Promise<void>} resolves after the rename.
134
+ */
135
+ function write(value) {
136
+ const raw = JSON.stringify(value, null, 2)
137
+ if (typeof raw !== 'string') throw new TypeError('createJsonFile.write: value is not JSON-serializable')
138
+ return enqueue(() => writeText(`${raw}\n`))
139
+ }
140
+
141
+ /**
142
+ * Read the current value, await the mutation and write the result. Returning
143
+ * `undefined` from `mutate` skips the write.
144
+ * @param {(current: unknown) => unknown | Promise<unknown>} mutate - transformer.
145
+ * @returns {Promise<unknown>} the mutation result.
146
+ */
147
+ function update(mutate) {
148
+ return enqueue(async () => {
149
+ const current = read()
150
+ const next = await mutate(current)
151
+ if (next !== undefined) {
152
+ const text = `${JSON.stringify(next, null, 2)}\n`
153
+ if (typeof text === 'string') await writeText(text)
154
+ }
155
+ return next
156
+ })
157
+ }
158
+
159
+ return { path: filePath, read, write, update }
160
+ }