@tiphareth/dsh-hardssh 0.1.2-alpha

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.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Remote filesystem provider for the `ctx.fs` capability seam: paths,
3
+ * contents, and atomic staging files live on the remote host, reached through
4
+ * the dsh-ssh engine's SFTP/exec primitives. Ported and adapted from
5
+ * UynajGI/dsh-ssh (MIT, https://github.com/UynajGI/dsh-ssh) — the seam
6
+ * contract (targets, versions, atomic writes, CRLF handling, canonical path
7
+ * transport) is preserved; the connection owner is replaced by the shared
8
+ * SshEngine and the working directory follows the mode store's remote root.
9
+ *
10
+ * @module dsh-hardssh/remote-fs
11
+ */
12
+
13
+ import { createHash, randomUUID } from 'node:crypto'
14
+ import type { Readable } from 'node:stream'
15
+ import { posix } from 'node:path'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type { SshEngine } from '../ssh/engine.ts'
18
+ import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
19
+ import type {
20
+ FsDirEntry,
21
+ FsEditOutcome,
22
+ FsEditRequest,
23
+ FsInfo,
24
+ FsPathInfo,
25
+ FsTarget,
26
+ FsWriteIntent,
27
+ FsWriteOutcome,
28
+ } from '@deepseek-ai/dsh-fs'
29
+ import type { WorkspaceState } from '../protocol.ts'
30
+ import { quoteShellArg } from './environment.ts'
31
+
32
+ const BINARY_SAMPLE_BYTES = 8192
33
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
34
+
35
+ /** Shape of one remote stat the provider works with (engine-normalized). */
36
+ interface RemoteStats {
37
+ isFile(): boolean
38
+ isDirectory(): boolean
39
+ isSymbolicLink(): boolean
40
+ size: number
41
+ mtime: number
42
+ mode: number
43
+ }
44
+
45
+ function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
46
+ if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
47
+ }
48
+
49
+ function normalizeLineEndings(value: string): string {
50
+ return value.replaceAll('\r\n', '\n')
51
+ }
52
+
53
+ function detectsCrlf(value: string): boolean {
54
+ const sample = value.slice(0, 4096)
55
+ const crlf = sample.split('\r\n').length - 1
56
+ const lf = sample.split('\n').length - 1 - crlf
57
+ return crlf > lf
58
+ }
59
+
60
+ function restoreLineEndings(value: string, crlf: boolean): string {
61
+ return crlf ? normalizeLineEndings(value).replaceAll('\n', '\r\n') : value
62
+ }
63
+
64
+ function decodeText(bytes: Uint8Array, displayPath: string): string {
65
+ if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
66
+ throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
67
+ }
68
+ try {
69
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
70
+ } catch (error: unknown) {
71
+ throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
72
+ }
73
+ }
74
+
75
+ /** Decode a base64-wrapped NUL-terminated canonical path from `realpath -mz`. */
76
+ function decodeCanonicalPath(encoded: string): string {
77
+ if (encoded.length === 0 || !BASE64.test(encoded)) {
78
+ throw new Error('fs-ssh: canonical path transport returned invalid base64')
79
+ }
80
+ const framed = Buffer.from(encoded, 'base64')
81
+ if (framed.toString('base64') !== encoded || framed.length < 2 || framed.at(-1) !== 0 || framed.subarray(0, -1).includes(0)) {
82
+ throw new Error('fs-ssh: canonical path transport returned invalid NUL framing')
83
+ }
84
+ let path: string
85
+ try {
86
+ path = new TextDecoder('utf-8', { fatal: true }).decode(framed.subarray(0, -1))
87
+ } catch (error: unknown) {
88
+ throw new Error('fs-ssh: canonical path is not valid UTF-8', { cause: error })
89
+ }
90
+ if (!posix.isAbsolute(path)) throw new Error('fs-ssh: canonical path is not absolute')
91
+ return path
92
+ }
93
+
94
+ function entryType(stats: RemoteStats): FsInfo['type'] {
95
+ if (stats.isFile()) return 'file'
96
+ if (stats.isDirectory()) return 'directory'
97
+ return 'other'
98
+ }
99
+
100
+ function entryVersion(stats: RemoteStats, path: string): ReturnType<typeof FsVersion> {
101
+ return FsVersion(`ssh:${createHash('sha256').update(JSON.stringify([path, stats.size, stats.mtime, stats.mode])).digest('hex')}`)
102
+ }
103
+
104
+ function mapError(error: unknown, operation: string, displayPath: string, signal?: AbortSignal): FsError {
105
+ if (error instanceof FsError) return error
106
+ if (signal?.aborted === true) return new FsError(`${operation} aborted`, 'FS_ABORTED', { cause: error })
107
+ const code = String((error as { code?: unknown }).code ?? '')
108
+ const message = String(error)
109
+ if (/NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`)) {
110
+ return new FsError(`cannot ${operation} "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
111
+ }
112
+ if (/PERMISSION_DENIED|EACCES|permission denied/i.test(`${code} ${message}`)) {
113
+ return new FsError(`cannot ${operation} "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
114
+ }
115
+ return new FsError(`cannot ${operation} "${displayPath}": ${message}`, 'FS_IO_ERROR', { cause: error })
116
+ }
117
+
118
+ function literalEdit(content: string, request: FsEditRequest, displayPath: string): string {
119
+ const oldString = normalizeLineEndings(request.oldString)
120
+ const newString = normalizeLineEndings(request.newString)
121
+ if (oldString.length === 0) {
122
+ throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, 'FS_EDIT_NOT_FOUND')
123
+ }
124
+ let matches = 0
125
+ let offset = 0
126
+ while (true) {
127
+ const found = content.indexOf(oldString, offset)
128
+ if (found < 0) break
129
+ matches += 1
130
+ offset = found + oldString.length
131
+ }
132
+ if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, 'FS_EDIT_NOT_FOUND')
133
+ if (!request.replaceAll && matches !== 1) {
134
+ throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, 'FS_AMBIGUOUS_EDIT')
135
+ }
136
+ return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString)
137
+ }
138
+
139
+ /** Whether one SFTP/exec error means "path absent". */
140
+ function isNotFound(error: unknown): boolean {
141
+ const code = String((error as { code?: unknown }).code ?? '')
142
+ const message = String(error)
143
+ return /NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`)
144
+ }
145
+
146
+ /**
147
+ * Remote filesystem backend over the dsh-ssh engine. The working directory
148
+ * follows the mode store: relative paths resolve against the resolved remote
149
+ * root; a POSIX-absolute cwd override is honored; local (Windows) cwds are
150
+ * ignored so the model's relative-path habit keeps working remotely.
151
+ */
152
+ export class SshFileSystem extends FileSystem {
153
+ private readonly locks = new Map<string, Promise<unknown>>()
154
+
155
+ constructor(
156
+ ctx: Context,
157
+ private readonly engine: SshEngine,
158
+ private readonly getState: () => WorkspaceState,
159
+ ) {
160
+ super(ctx)
161
+ }
162
+
163
+ /** The active remote execution world (throws when not in remote mode). */
164
+ private current(): { alias: string; remoteRoot: string } {
165
+ const state = this.getState()
166
+ if (state.mode !== 'remote' || state.alias === undefined) {
167
+ throw new FsError('not in remote mode — switch the GUI to SSH mode first', 'FS_IO_ERROR')
168
+ }
169
+ if (state.remoteRoot === undefined) {
170
+ throw new FsError('remote workspace root is not set', 'FS_IO_ERROR')
171
+ }
172
+ return { alias: state.alias, remoteRoot: state.remoteRoot }
173
+ }
174
+
175
+ /**
176
+ * Resolve the working directory for a path: a POSIX-absolute cwd wins;
177
+ * anything else (relative or a local Windows path) falls back to the
178
+ * remote root.
179
+ */
180
+ resolveRemoteCwd(cwd: string | undefined): string {
181
+ if (cwd !== undefined && posix.isAbsolute(cwd)) return cwd
182
+ return this.current().remoteRoot
183
+ }
184
+
185
+ override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
186
+ assertNotAborted(opts?.signal, 'resolve')
187
+ if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
188
+ const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path)
189
+ try {
190
+ const targetKey = await this.canonicalPath(displayPath, opts?.signal)
191
+ assertNotAborted(opts?.signal, 'resolve')
192
+ return { targetKey: FsTargetKey(targetKey), displayPath }
193
+ } catch (error: unknown) {
194
+ throw mapError(error, 'resolve', displayPath, opts?.signal)
195
+ }
196
+ }
197
+
198
+ override processPath(target: FsTarget): string {
199
+ return String(target.targetKey)
200
+ }
201
+
202
+ override fileUrl(target: FsTarget): string {
203
+ const path = this.processPath(target)
204
+ if (!posix.isAbsolute(path)) throw new Error(`fs-ssh: expected an absolute process path: ${JSON.stringify(path)}`)
205
+ return `file://${path.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
206
+ }
207
+
208
+ override contains(parent: FsTarget, child: FsTarget): boolean {
209
+ const relative = posix.relative(this.processPath(parent), this.processPath(child))
210
+ return relative === '' || (relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative))
211
+ }
212
+
213
+ override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
214
+ assertNotAborted(signal, 'stat')
215
+ const stats = await this.probe(String(target.targetKey), target.displayPath, signal)
216
+ if (stats === undefined) return undefined
217
+ return {
218
+ version: entryVersion(stats, String(target.targetKey)),
219
+ type: entryType(stats),
220
+ ...(stats.isFile() ? { size: stats.size } : {}),
221
+ }
222
+ }
223
+
224
+ override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
225
+ assertNotAborted(signal, 'lstat')
226
+ if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
227
+ const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path)
228
+ const { alias } = this.current()
229
+ try {
230
+ const info = await this.engine.lstat(alias, displayPath)
231
+ assertNotAborted(signal, 'lstat')
232
+ if (info === undefined) return undefined
233
+ const type = info.type === 'symlink' ? 'symlink' as const : info.type === 'directory' ? 'directory' as const : info.type === 'file' ? 'file' as const : 'other' as const
234
+ return {
235
+ version: entryVersion(this.asStats({ type: info.type, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode }), displayPath),
236
+ type,
237
+ ...(type === 'file' ? { size: info.size } : {}),
238
+ }
239
+ } catch (error: unknown) {
240
+ if (isNotFound(error)) return undefined
241
+ throw mapError(error, 'lstat', displayPath, signal)
242
+ }
243
+ }
244
+
245
+ override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
246
+ await this.requireRegular(target, signal)
247
+ const bytes = await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY)
248
+ assertNotAborted(signal, 'read')
249
+ return decodeText(bytes, target.displayPath)
250
+ }
251
+
252
+ override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
253
+ const info = await this.requireRegular(target, signal)
254
+ if (info.size !== undefined && info.size > maxBytes) {
255
+ throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
256
+ }
257
+ const bytes = await this.readBytesRaw(target, signal, maxBytes)
258
+ assertNotAborted(signal, 'read')
259
+ return bytes
260
+ }
261
+
262
+ override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
263
+ await this.requireRegular(target, signal)
264
+ const { alias } = this.current()
265
+ const displayPath = target.displayPath
266
+ const stream = await this.engine.readStream(alias, String(target.targetKey))
267
+ return {
268
+ async *[Symbol.asyncIterator](): AsyncGenerator<string> {
269
+ const decoder = new TextDecoder('utf-8', { fatal: true })
270
+ let sampledBytes = 0
271
+ try {
272
+ for await (const chunk of stream) {
273
+ assertNotAborted(signal, 'read')
274
+ const bytes = Buffer.from(chunk as Uint8Array)
275
+ if (sampledBytes < BINARY_SAMPLE_BYTES) {
276
+ const sample = bytes.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes)
277
+ if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
278
+ sampledBytes += sample.length
279
+ }
280
+ try {
281
+ const text = decoder.decode(bytes, { stream: true })
282
+ if (text.length > 0) yield text
283
+ } catch (error: unknown) {
284
+ throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
285
+ }
286
+ }
287
+ try {
288
+ decoder.decode()
289
+ } catch (error: unknown) {
290
+ throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
291
+ }
292
+ } catch (error: unknown) {
293
+ throw mapError(error, 'read', displayPath, signal)
294
+ }
295
+ },
296
+ }
297
+ }
298
+
299
+ override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
300
+ const info = await this.stat(target, signal)
301
+ if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
302
+ if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
303
+ const { alias } = this.current()
304
+ try {
305
+ const listed = await this.engine.ls(alias, String(target.targetKey))
306
+ assertNotAborted(signal, 'list')
307
+ const displayPaths = listed.map(entry => posix.join(target.displayPath, entry.name))
308
+ // One batch SFTP pass instead of one `realpath` exec per entry (P1-26);
309
+ // symlink canonicalization is kept — target keys stay jail-safe.
310
+ const canonicalPaths = await this.engine.realpaths(alias, displayPaths)
311
+ const entries: FsDirEntry[] = []
312
+ for (let i = 0; i < listed.length; i += 1) {
313
+ const entry = listed[i]!
314
+ const displayPath = displayPaths[i]!
315
+ const canonical = canonicalPaths[i]!
316
+ const stats = this.asStats({
317
+ type: entry.type === 'dir' ? 'directory' : entry.type === 'file' ? 'file' : 'other',
318
+ size: entry.size,
319
+ mtimeMs: entry.mtimeMs,
320
+ mode: entry.mode ?? 0o600,
321
+ })
322
+ entries.push({
323
+ name: entry.name,
324
+ type: entryType(stats),
325
+ target: { targetKey: FsTargetKey(canonical), displayPath },
326
+ version: entryVersion(stats, canonical),
327
+ ...(entry.type === 'file' ? { size: entry.size } : {}),
328
+ })
329
+ }
330
+ return entries.sort((left, right) => left.name.localeCompare(right.name))
331
+ } catch (error: unknown) {
332
+ throw mapError(error, 'list', target.displayPath, signal)
333
+ }
334
+ }
335
+
336
+ override async writeText(
337
+ target: FsTarget,
338
+ content: string,
339
+ expected?: FsWriteIntent,
340
+ signal?: AbortSignal,
341
+ ): Promise<FsWriteOutcome> {
342
+ return this.withLock(String(target.targetKey), async () => {
343
+ const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
344
+ if (existing !== undefined && !existing.isFile()) {
345
+ throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
346
+ }
347
+ this.checkWriteIntent(existing, expected, target)
348
+ const before = existing === undefined ? null : await this.readForDiff(target, signal)
349
+ const version = await this.writeAtomic(target, content, existing, expected?.kind === 'createIfAbsent', signal)
350
+ return {
351
+ operation: existing === undefined ? 'create' : 'update',
352
+ version,
353
+ before,
354
+ after: normalizeLineEndings(content),
355
+ }
356
+ })
357
+ }
358
+
359
+ override async editText(
360
+ target: FsTarget,
361
+ edit: FsEditRequest,
362
+ expected?: { version: ReturnType<typeof FsVersion> },
363
+ signal?: AbortSignal,
364
+ ): Promise<FsEditOutcome> {
365
+ return this.withLock(String(target.targetKey), async () => {
366
+ const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
367
+ if (existing === undefined) {
368
+ throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
369
+ }
370
+ if (!existing.isFile()) {
371
+ throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
372
+ }
373
+ if (expected !== undefined && entryVersion(existing, String(target.targetKey)) !== expected.version) {
374
+ throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
375
+ }
376
+ const raw = await this.readForEdit(target, signal)
377
+ const before = normalizeLineEndings(raw)
378
+ const after = literalEdit(before, edit, target.displayPath)
379
+ const storage = restoreLineEndings(after, detectsCrlf(raw))
380
+ const version = await this.writeAtomic(target, storage, existing, false, signal)
381
+ return { version, before, after }
382
+ })
383
+ }
384
+
385
+ private async withLock<T>(targetKey: string, operation: () => Promise<T>): Promise<T> {
386
+ const prior = this.locks.get(targetKey) ?? Promise.resolve()
387
+ const run = prior.then(operation, operation)
388
+ const tail = run.then(() => undefined, () => undefined)
389
+ this.locks.set(targetKey, tail)
390
+ try {
391
+ return await run
392
+ } finally {
393
+ if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey)
394
+ }
395
+ }
396
+
397
+ private async canonicalPath(path: string, signal?: AbortSignal): Promise<string> {
398
+ const { alias } = this.current()
399
+ const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${quoteShellArg(path)} | base64 -w0`, 10_000)
400
+ signal?.throwIfAborted()
401
+ if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for ${path}`)
402
+ return decodeCanonicalPath(result.stdout.trim())
403
+ }
404
+
405
+ private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<RemoteStats | undefined> {
406
+ assertNotAborted(signal, 'stat')
407
+ const { alias } = this.current()
408
+ try {
409
+ const info = await this.engine.stat(alias, path)
410
+ assertNotAborted(signal, 'stat')
411
+ return this.asStats({ type: info.type, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode })
412
+ } catch (error: unknown) {
413
+ if (isNotFound(error)) return undefined
414
+ throw mapError(error, 'stat', displayPath, signal)
415
+ }
416
+ }
417
+
418
+ private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<FsInfo> {
419
+ const info = await this.stat(target, signal)
420
+ if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
421
+ if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
422
+ return info
423
+ }
424
+
425
+ private async readBytesRaw(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
426
+ const { alias } = this.current()
427
+ try {
428
+ const data = await this.engine.readFile(alias, String(target.targetKey))
429
+ assertNotAborted(signal, 'read')
430
+ if (data.content.length > maxBytes) {
431
+ throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
432
+ }
433
+ return data.content
434
+ } catch (error: unknown) {
435
+ throw mapError(error, 'read', target.displayPath, signal)
436
+ }
437
+ }
438
+
439
+ private checkWriteIntent(existing: RemoteStats | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {
440
+ if (expected?.kind === 'createIfAbsent' && existing !== undefined) {
441
+ throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
442
+ }
443
+ if (expected?.kind === 'replaceIfVersion') {
444
+ if (existing === undefined || entryVersion(existing, String(target.targetKey)) !== expected.version) {
445
+ throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
446
+ }
447
+ }
448
+ }
449
+
450
+ private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
451
+ try {
452
+ return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath))
453
+ } catch (error: unknown) {
454
+ if (error instanceof FsError && error.code === 'FS_NOT_TEXT') return null
455
+ throw mapError(error, 'read', target.displayPath, signal)
456
+ }
457
+ }
458
+
459
+ private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
460
+ return decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath)
461
+ }
462
+
463
+ private async writeAtomic(
464
+ target: FsTarget,
465
+ content: string,
466
+ existing: RemoteStats | undefined,
467
+ createIfAbsent: boolean,
468
+ signal?: AbortSignal,
469
+ ): Promise<ReturnType<typeof FsVersion>> {
470
+ assertNotAborted(signal, 'write')
471
+ const { alias } = this.current()
472
+ const targetPath = String(target.targetKey)
473
+ const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
474
+ const temporary = posix.join(stagingDirectory, 'content')
475
+ let stagingCreated = false
476
+ try {
477
+ await this.engine.mkdir(alias, stagingDirectory)
478
+ stagingCreated = true
479
+ await this.engine.writeFile(alias, temporary, Buffer.from(content, 'utf8'))
480
+ assertNotAborted(signal, 'write')
481
+ const mode = existing === undefined ? 0o600 : existing.mode & 0o777
482
+ await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${quoteShellArg(temporary)}`, 10_000)
483
+ assertNotAborted(signal, 'write')
484
+ if (createIfAbsent) {
485
+ const publication = await this.engine.exec(
486
+ alias,
487
+ `if ln -T -- ${quoteShellArg(temporary)} ${quoteShellArg(targetPath)}; then printf created; elif test -e ${quoteShellArg(targetPath)} || test -L ${quoteShellArg(targetPath)}; then printf exists; else exit 1; fi`,
488
+ 10_000,
489
+ )
490
+ if (publication.stdout.trim() === 'exists') {
491
+ throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
492
+ }
493
+ if (publication.stdout.trim() !== 'created') throw new Error('guarded create returned an invalid publication result')
494
+ } else {
495
+ await this.engine.rename(alias, temporary, targetPath)
496
+ }
497
+ assertNotAborted(signal, 'write')
498
+ await this.removeStaging(stagingDirectory)
499
+ const committed = await this.probe(targetPath, target.displayPath, signal)
500
+ if (committed === undefined) throw new FsError(`cannot write "${target.displayPath}": commit produced no file`, 'FS_IO_ERROR')
501
+ return entryVersion(committed, targetPath)
502
+ } catch (error: unknown) {
503
+ if (stagingCreated) await this.removeStaging(stagingDirectory)
504
+ throw mapError(error, 'write', target.displayPath, signal)
505
+ }
506
+ }
507
+
508
+ private async removeStaging(directory: string): Promise<void> {
509
+ const { alias } = this.current()
510
+ try {
511
+ await this.engine.rm(alias, directory, true)
512
+ } catch {
513
+ // The target is already committed; an empty private directory cannot turn that write into a failure.
514
+ }
515
+ }
516
+
517
+ /** Normalize an engine stat/ls shape into the RemoteStats the helpers expect. */
518
+ private asStats(info: { type: 'dir' | 'file' | 'other' | 'directory' | 'symlink'; size: number; mtimeMs: number; mode: number }): RemoteStats {
519
+ const isDir = info.type === 'dir' || info.type === 'directory'
520
+ const isFile = info.type === 'file'
521
+ return {
522
+ isFile: () => isFile,
523
+ isDirectory: () => isDir,
524
+ isSymbolicLink: () => info.type === 'symlink',
525
+ size: info.size,
526
+ mtime: Math.round(info.mtimeMs / 1000),
527
+ mode: info.mode,
528
+ }
529
+ }
530
+ }
531
+
532
+ export default SshFileSystem