@tiphareth/dsh-hardssh 0.1.2

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 +17780 -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 +134 -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 +96 -0
  126. package/src/client/workspace-gate.ts +225 -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,201 @@
1
+ /**
2
+ * Remote-workspace runner: lets other plugins (dsh-workbench-tiphareth) run
3
+ * git/files/terminals against an SSH-bound workspace. The workbench host asks
4
+ * `resolveRemote(root)` for a local anchor path; a hit returns the remote
5
+ * context + callable channels (git exec via the engine, SFTP ops, PTY).
6
+ * Kept duck-typed so the workbench never needs a compile-time dependency.
7
+ */
8
+
9
+ import type { SshEngine } from './ssh/engine.ts'
10
+ import type { SshWorkspaceLedger } from './ledger.ts'
11
+
12
+ /** The remote context one SSH-bound workspace resolves to. */
13
+ export interface RemoteWorkspaceContext {
14
+ alias: string
15
+ remoteRoot: string
16
+ /** Run `git [args...]` inside remoteRoot. Mirrors runGit's result shape. */
17
+ git(args: readonly string[], opts?: { timeoutMs?: number; input?: string; allowNonZero?: boolean }): Promise<{ stdout: string; stderr: string; exitCode: number }>
18
+ /** Run one arbitrary command (used by tools that shell out). */
19
+ run(command: string, opts?: { timeoutMs?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>
20
+ /** SFTP operations. */
21
+ list(path: string): Promise<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>>
22
+ stat(path: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }>
23
+ readFile(path: string): Promise<{ content: Buffer; mtime: number; size: number }>
24
+ writeFile(path: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }>
25
+ mkdir(path: string): Promise<void>
26
+ rm(path: string, recursive: boolean): Promise<void>
27
+ rename(from: string, to: string): Promise<void>
28
+ /** Open an interactive SSH PTY in remoteRoot (columns/rows for the channel). */
29
+ openTerminal(cols: number, rows: number): Promise<RemotePtyHandle>
30
+ }
31
+
32
+ /** Minimal PTY handle (duck-typed for consumers like dsh-workbench-tiphareth). */
33
+ export interface RemotePtyHandle {
34
+ shell: string
35
+ write(data: string): void
36
+ resize(cols: number, rows: number): void
37
+ kill(): void
38
+ onData(handler: (data: string) => void): { dispose(): void }
39
+ onExit(handler: (event: { exitCode: number }) => void): { dispose(): void }
40
+ }
41
+
42
+ /** Last-resort bound on opening a remote PTY (fresh SSH connect + shell). */
43
+ const OPEN_SHELL_TIMEOUT_MS = 30_000
44
+
45
+ /** Reject `promise` after `ms` with a clear message (unref'd timer). */
46
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
47
+ return new Promise<T>((resolve, reject) => {
48
+ const timer = setTimeout(() => reject(new Error(message)), ms)
49
+ timer.unref?.()
50
+ promise.then(
51
+ (value) => { clearTimeout(timer); resolve(value) },
52
+ (error) => { clearTimeout(timer); reject(error) },
53
+ )
54
+ })
55
+ }
56
+
57
+ /** Builds remote-workspace contexts off the shared ledger + engine. */
58
+ export class RemoteWorkspaceRunner {
59
+ constructor(
60
+ private readonly engine: SshEngine,
61
+ private readonly ledger: SshWorkspaceLedger,
62
+ ) {}
63
+
64
+ /** Resolve a LOCAL anchor path to a remote context, or null if not SSH-bound. */
65
+ resolveRemote(root: string): RemoteWorkspaceContext | null {
66
+ const record = this.ledger.findByAnchorSync(root)
67
+ if (record === undefined) return null
68
+ return this.build(record.alias, record.remoteRoot)
69
+ }
70
+
71
+ private build(alias: string, remoteRoot: string): RemoteWorkspaceContext {
72
+ const engine = this.engine
73
+ const git = async (
74
+ args: readonly string[],
75
+ opts: { timeoutMs?: number; input?: string; allowNonZero?: boolean } = {},
76
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
77
+ const quoted = args.map(arg => quote(arg)).join(' ')
78
+ // Restore real stderr ordering by capturing both streams; the engine
79
+ // exec returns them merged per frame — keep them merged for git too.
80
+ const command = `git ${quoted}`
81
+ const result = await engine.exec(alias, `cd ${quote(remoteRoot)} && ${command}`, opts.timeoutMs)
82
+ const stdout = result.stdout
83
+ const stderr = result.stderr
84
+ const exitCode = result.exitCode ?? 1
85
+ if (exitCode !== 0 && opts.allowNonZero !== true) {
86
+ const err = new Error(stderr.trim() || `git exited ${exitCode}`)
87
+ ;(err as { shortMessage?: string }).shortMessage = stderr.trim()
88
+ throw err
89
+ }
90
+ return { stdout, stderr, exitCode }
91
+ }
92
+ const run = async (
93
+ command: string,
94
+ opts: { timeoutMs?: number } = {},
95
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
96
+ const result = await engine.exec(alias, `cd ${quote(remoteRoot)} && ${command}`, opts.timeoutMs)
97
+ return {
98
+ stdout: result.stdout,
99
+ stderr: result.stderr,
100
+ exitCode: result.exitCode ?? 1,
101
+ }
102
+ }
103
+ const list = async (path: string): Promise<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>> => {
104
+ return engine.ls(alias, path)
105
+ }
106
+ const stat = async (path: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> => {
107
+ return engine.stat(alias, path)
108
+ }
109
+ const readFile = async (path: string): Promise<{ content: Buffer; mtime: number; size: number }> => {
110
+ return engine.readFile(alias, path)
111
+ }
112
+ const writeFile = async (path: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }> => {
113
+ return engine.writeFile(alias, path, content, expectedMtime)
114
+ }
115
+ const mkdir = async (path: string): Promise<void> => { await engine.mkdir(alias, path) }
116
+ const rm = async (path: string, recursive: boolean): Promise<void> => { await engine.rm(alias, path, recursive) }
117
+ const rename = async (from: string, to: string): Promise<void> => { await engine.rename(alias, from, to) }
118
+ const openTerminal = async (cols: number, rows: number): Promise<RemotePtyHandle> => {
119
+ // Bound the shell open: the engine's own connect/channel timeouts cover
120
+ // the normal failure modes, this is a last-resort guard so a terminal
121
+ // request can never hang the workbench's SSE stream indefinitely.
122
+ const session = await withTimeout(
123
+ engine.openShell(alias, { cols, rows }),
124
+ OPEN_SHELL_TIMEOUT_MS,
125
+ `SSH 终端打开超时(${OPEN_SHELL_TIMEOUT_MS / 1000} 秒):${alias}`,
126
+ )
127
+ let exited = false
128
+ const exitHandlers = new Set<(event: { exitCode: number }) => void>()
129
+ const onDataHandlers = new Set<(data: string) => void>()
130
+ const enc = new TextDecoder()
131
+
132
+ // The PTY already runs the user's interactive login shell (client.shell
133
+ // allocates one); just cd into the remote root. Do NOT exec a new shell
134
+ // here — that restarts the shell, re-runs profile/.bashrc, and replays
135
+ // the login banner + prompt (the "command ran twice" symptom).
136
+ //
137
+ // Send `cd` only AFTER the first shell prompt has been emitted — a
138
+ // prompt (line ending in $ / # / > / %) means the login banner finished
139
+ // and the shell is interactive. Sending earlier makes the PTY echo the
140
+ // command raw AND the shell re-echo it after the prompt (double cd).
141
+ let cwdSent = false
142
+ let shellBuf = ''
143
+ const trySendCwd = (): void => {
144
+ if (cwdSent || exited) return
145
+ // Prompt heuristic: the last non-empty line ends with a prompt char
146
+ // (`$ # > %`) OR looks like a user@host prompt (e.g. `@login01 ~`,
147
+ // `majie@host:~/dir$`). Either means the shell is interactive now.
148
+ const lines = shellBuf.split('\n').map(line => line.replace(/\r$/, '')).filter(line => line.trim() !== '')
149
+ const last = lines[lines.length - 1]
150
+ if (last !== undefined
151
+ && ( /[#$>%]\s*$/.test(last) || /^\S+@\S+/.test(last) )
152
+ && !last.trim().endsWith('cd')) {
153
+ cwdSent = true
154
+ session.send(`cd ${quote(remoteRoot)}\r`)
155
+ }
156
+ }
157
+ session.onData = (data: Buffer) => {
158
+ const text = enc.decode(data)
159
+ shellBuf = (shellBuf + text).slice(-4096)
160
+ trySendCwd()
161
+ for (const handler of onDataHandlers) handler(text)
162
+ }
163
+ session.onExit = (code: number | null) => {
164
+ exited = true
165
+ for (const handler of exitHandlers) handler({ exitCode: code ?? -1 })
166
+ }
167
+ // Fallback: if no prompt heuristic matched (odd PS1), send cd after 4s.
168
+ setTimeout(() => {
169
+ if (!cwdSent && !exited) {
170
+ cwdSent = true
171
+ session.send(`cd ${quote(remoteRoot)}\r`)
172
+ }
173
+ }, 4000)
174
+ return {
175
+ shell: 'ssh',
176
+ write(data: string) { if (!exited) session.send(data) },
177
+ resize(c: number, r: number) { session.resize(c, r) },
178
+ kill() {
179
+ try { session.signal('KILL') } catch { /* closed */ }
180
+ try { session.close() } catch { /* closed */ }
181
+ },
182
+ onData(handler: (data: string) => void) {
183
+ onDataHandlers.add(handler)
184
+ return { dispose: () => { onDataHandlers.delete(handler) } }
185
+ },
186
+ onExit(handler: (event: { exitCode: number }) => void) {
187
+ exitHandlers.add(handler)
188
+ return { dispose: () => { exitHandlers.delete(handler) } }
189
+ },
190
+ }
191
+ }
192
+ return { alias, remoteRoot, git, run, list, stat, readFile, writeFile, mkdir, rm, rename, openTerminal }
193
+ }
194
+ }
195
+
196
+ /** POSIX single-quote a shell argument. */
197
+ function quote(arg: string): string {
198
+ if (arg === '') return "''"
199
+ if (/^[a-zA-Z0-9_\-./:=@]+$/.test(arg)) return arg
200
+ return `'${arg.replace(/'/g, `'\\''`)}'`
201
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Remote search semantics shared by the UI file-name search (backend) and the
3
+ * remote_search agent tool (glob / grep modes). Centralizes command building,
4
+ * literal escaping, NUL-delimited parsing, and truncation rules (P1-11) so
5
+ * the three former hand-rolled implementations cannot drift again.
6
+ *
7
+ * Command output is never treated as authorization evidence: this service
8
+ * returns raw absolute paths; callers (backend) re-authorize each hit against
9
+ * the workspace root before exposing it.
10
+ */
11
+
12
+ import type { SshEngine } from './ssh/engine.ts'
13
+ import { shellQuote } from './shell.ts'
14
+
15
+ /** One SSH workspace to search on. */
16
+ export interface SearchTarget {
17
+ alias: string
18
+ /** Remote root; every path is reported absolute under it. */
19
+ root: string
20
+ }
21
+
22
+ /** One raw file-name hit (absolute path, type). */
23
+ export interface RemoteNameHit {
24
+ path: string
25
+ isDir: boolean
26
+ }
27
+
28
+ export interface RemoteNameSearch {
29
+ query: string
30
+ hits: RemoteNameHit[]
31
+ truncated: boolean
32
+ }
33
+
34
+ export interface RemoteGlobResult {
35
+ hits: string[]
36
+ truncated: boolean
37
+ }
38
+
39
+ export interface RemoteGrepResult {
40
+ lines: string[]
41
+ truncated: boolean
42
+ }
43
+
44
+ /** File-name search depth (matches the pre-existing backend budget). */
45
+ const SEARCH_MAX_DEPTH = 4
46
+ /** Glob search depth (matches the pre-existing glob-search budget). */
47
+ const GLOB_MAX_DEPTH = 6
48
+ /** Max hits returned by any search. */
49
+ const SEARCH_HIT_CAP = 200
50
+ /** Per-search engine timeout. */
51
+ const SEARCH_TIMEOUT_MS = 20_000
52
+ /** Raw-output cap for name/glob searches (defensive; parsing decides truncation). */
53
+ const OUTPUT_CAP_BYTES = 256 * 1024
54
+ /** Raw-output cap for grep (the tool contract's 200KB budget). */
55
+ const GREP_CAP_BYTES = 200_000
56
+
57
+ /** Strip control characters and cap the query length (wire hygiene). */
58
+ function sanitizeSearchQuery(query: string): string {
59
+ return query.replace(/\0/g, '').replace(/[\r\n]/g, ' ').slice(0, 128)
60
+ }
61
+
62
+ /** Escape find `-iname` metacharacters so user input matches literally. */
63
+ function escapeFindLiteral(value: string): string {
64
+ return value.replace(/[*?[\]\\]/g, '\\$&')
65
+ }
66
+
67
+ /** Parse NUL-delimited `type\0path\0...` records from `find -printf`. */
68
+ function parseNameRecords(stdout: string): RemoteNameHit[] {
69
+ const parts = stdout.split('\0')
70
+ const hits: RemoteNameHit[] = []
71
+ for (let i = 0; i + 1 < parts.length; i += 2) {
72
+ const type = parts[i]
73
+ const abs = parts[i + 1]
74
+ if (type === undefined || abs === undefined || abs === '') continue
75
+ hits.push({ path: abs, isDir: type === 'd' })
76
+ }
77
+ return hits
78
+ }
79
+
80
+ /**
81
+ * One search implementation shared by the UI and the agent tools.
82
+ * Stateless apart from the engine; safe to construct per call or share.
83
+ */
84
+ export class RemoteSearchService {
85
+ constructor(private readonly engine: SshEngine) {}
86
+
87
+ /** Literal file-name search under `root` (skips node_modules/.git). */
88
+ async searchNames(target: SearchTarget, query: string): Promise<RemoteNameSearch> {
89
+ const literal = sanitizeSearchQuery(query)
90
+ if (literal === '') return { query, hits: [], truncated: false }
91
+ const pattern = escapeFindLiteral(literal)
92
+ const command =
93
+ `find ${shellQuote(target.root)}`
94
+ + ` -maxdepth ${SEARCH_MAX_DEPTH}`
95
+ + ` -not -path ${shellQuote('*/node_modules/*')}`
96
+ + ` -not -path ${shellQuote('*/.git/*')}`
97
+ + ` -iname ${shellQuote(`*${pattern}*`)}`
98
+ + ` -printf '%y\\0%p\\0'`
99
+ + ` 2>/dev/null`
100
+ + ` | head -c ${OUTPUT_CAP_BYTES}`
101
+ const result = await this.engine.exec(target.alias, command, SEARCH_TIMEOUT_MS)
102
+ if (!result.success && result.stdout === '' && result.stderr !== '') {
103
+ throw new Error(result.stderr.trim())
104
+ }
105
+ const allHits = parseNameRecords(result.stdout)
106
+ const capped = Buffer.byteLength(result.stdout, 'utf8') >= OUTPUT_CAP_BYTES
107
+ return {
108
+ query,
109
+ hits: allHits.slice(0, SEARCH_HIT_CAP),
110
+ truncated: allHits.length > SEARCH_HIT_CAP || capped,
111
+ }
112
+ }
113
+
114
+ /** Glob search (pattern keeps glob semantics; `**` crosses directories). */
115
+ async glob(target: SearchTarget, pattern: string): Promise<RemoteGlobResult> {
116
+ const relative = pattern.replace(/^\/+/, '')
117
+ const command =
118
+ `find ${shellQuote(target.root)}`
119
+ + ` -maxdepth ${GLOB_MAX_DEPTH}`
120
+ + ` -path ${shellQuote(`${target.root}/${relative}`)}`
121
+ + ` -printf '%p\\0'`
122
+ + ` 2>/dev/null`
123
+ + ` | head -c ${OUTPUT_CAP_BYTES}`
124
+ const result = await this.engine.exec(target.alias, command, SEARCH_TIMEOUT_MS)
125
+ const hits = result.stdout.split('\0').filter((path) => path !== '')
126
+ const capped = Buffer.byteLength(result.stdout, 'utf8') >= OUTPUT_CAP_BYTES
127
+ // Truncation means "there ARE more than the cap", never "== cap".
128
+ return { hits: hits.slice(0, SEARCH_HIT_CAP), truncated: hits.length > SEARCH_HIT_CAP || capped }
129
+ }
130
+
131
+ /** Fixed-string grep (literal pattern, `-F`; NUL file boundary `-Z`). */
132
+ async grepFixed(target: SearchTarget, pattern: string): Promise<RemoteGrepResult> {
133
+ if (pattern.includes('\0')) return { lines: [], truncated: false }
134
+ const command =
135
+ `grep -rInFZ --exclude-dir=.git --exclude-dir=node_modules -m 200`
136
+ + ` -- ${shellQuote(pattern)} ${shellQuote(target.root)}`
137
+ + ` 2>/dev/null`
138
+ + ` | head -c ${GREP_CAP_BYTES}`
139
+ const result = await this.engine.exec(target.alias, command, SEARCH_TIMEOUT_MS)
140
+ // grep exit 1 = no matches: an empty result, not an error.
141
+ if (result.exitCode === 1) return { lines: [], truncated: false }
142
+ if (!result.success && result.stdout === '' && result.stderr !== '') {
143
+ throw new Error(result.stderr.trim())
144
+ }
145
+ // -Z emits `file\0:line` then bare `:line` continuations for the same file.
146
+ const lines: string[] = []
147
+ let lastFile: string | undefined
148
+ for (const line of result.stdout.split('\n')) {
149
+ if (line === '') continue
150
+ const nul = line.indexOf('\0')
151
+ if (nul >= 0) {
152
+ lastFile = line.slice(0, nul)
153
+ lines.push(lastFile + line.slice(nul + 1))
154
+ } else if (lastFile !== undefined && line.startsWith(':')) {
155
+ lines.push(lastFile + line)
156
+ } else {
157
+ lines.push(line)
158
+ }
159
+ }
160
+ const capped = Buffer.byteLength(result.stdout, 'utf8') >= GREP_CAP_BYTES
161
+ return { lines, truncated: capped }
162
+ }
163
+ }