@nimbus-sh/worker 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.
Files changed (408) hide show
  1. package/README.md +219 -0
  2. package/dist/_shared/bytes.d.ts +18 -0
  3. package/dist/_shared/bytes.d.ts.map +1 -0
  4. package/dist/_shared/bytes.js +17 -0
  5. package/dist/_shared/cache-stats.d.ts +146 -0
  6. package/dist/_shared/cache-stats.d.ts.map +1 -0
  7. package/dist/_shared/cache-stats.js +162 -0
  8. package/dist/_shared/exports-resolver.d.ts +76 -0
  9. package/dist/_shared/exports-resolver.d.ts.map +1 -0
  10. package/dist/_shared/exports-resolver.js +284 -0
  11. package/dist/_shared/install-phase.d.ts +47 -0
  12. package/dist/_shared/install-phase.d.ts.map +1 -0
  13. package/dist/_shared/install-phase.js +1 -0
  14. package/dist/_shared/real-node-imports.d.ts +38 -0
  15. package/dist/_shared/real-node-imports.d.ts.map +1 -0
  16. package/dist/_shared/real-node-imports.js +46 -0
  17. package/dist/_shared/retry.d.ts +93 -0
  18. package/dist/_shared/retry.d.ts.map +1 -0
  19. package/dist/_shared/retry.js +161 -0
  20. package/dist/_shared/session-id.d.ts +39 -0
  21. package/dist/_shared/session-id.d.ts.map +1 -0
  22. package/dist/_shared/session-id.js +127 -0
  23. package/dist/_shared/session-router.d.ts +104 -0
  24. package/dist/_shared/session-router.d.ts.map +1 -0
  25. package/dist/_shared/session-router.js +165 -0
  26. package/dist/_shared/w7-frame.d.ts +93 -0
  27. package/dist/_shared/w7-frame.d.ts.map +1 -0
  28. package/dist/_shared/w7-frame.js +402 -0
  29. package/dist/auth/index.d.ts +16 -0
  30. package/dist/auth/index.d.ts.map +1 -0
  31. package/dist/auth/index.js +15 -0
  32. package/dist/auth/middleware.d.ts +85 -0
  33. package/dist/auth/middleware.d.ts.map +1 -0
  34. package/dist/auth/middleware.js +150 -0
  35. package/dist/auth/token.d.ts +96 -0
  36. package/dist/auth/token.d.ts.map +1 -0
  37. package/dist/auth/token.js +226 -0
  38. package/dist/auth/types.d.ts +141 -0
  39. package/dist/auth/types.d.ts.map +1 -0
  40. package/dist/auth/types.js +114 -0
  41. package/dist/bindings/d1.d.ts +102 -0
  42. package/dist/bindings/d1.d.ts.map +1 -0
  43. package/dist/bindings/d1.js +549 -0
  44. package/dist/bindings/kv.d.ts +82 -0
  45. package/dist/bindings/kv.d.ts.map +1 -0
  46. package/dist/bindings/kv.js +290 -0
  47. package/dist/bindings/r2.d.ts +137 -0
  48. package/dist/bindings/r2.d.ts.map +1 -0
  49. package/dist/bindings/r2.js +379 -0
  50. package/dist/cirrus-npm-cjs.generated.d.ts +17 -0
  51. package/dist/cirrus-npm-cjs.generated.d.ts.map +1 -0
  52. package/dist/cirrus-npm-cjs.generated.js +41 -0
  53. package/dist/cirrus-plugin-react.generated.d.ts +34 -0
  54. package/dist/cirrus-plugin-react.generated.d.ts.map +1 -0
  55. package/dist/cirrus-plugin-react.generated.js +33 -0
  56. package/dist/constants.d.ts +34 -0
  57. package/dist/constants.d.ts.map +1 -0
  58. package/dist/constants.js +116 -0
  59. package/dist/esbuild-wasm-bundle.generated.d.ts +41 -0
  60. package/dist/esbuild-wasm-bundle.generated.d.ts.map +1 -0
  61. package/dist/esbuild-wasm-bundle.generated.js +40 -0
  62. package/dist/facets/cirrus-real.d.ts +147 -0
  63. package/dist/facets/cirrus-real.d.ts.map +1 -0
  64. package/dist/facets/cirrus-real.js +981 -0
  65. package/dist/facets/inner-do-registry.d.ts +41 -0
  66. package/dist/facets/inner-do-registry.d.ts.map +1 -0
  67. package/dist/facets/inner-do-registry.js +51 -0
  68. package/dist/facets/manager.d.ts +314 -0
  69. package/dist/facets/manager.d.ts.map +1 -0
  70. package/dist/facets/manager.js +2681 -0
  71. package/dist/facets/process.d.ts +318 -0
  72. package/dist/facets/process.d.ts.map +1 -0
  73. package/dist/facets/process.js +567 -0
  74. package/dist/facets/real-vite-fs-shim.d.ts +151 -0
  75. package/dist/facets/real-vite-fs-shim.d.ts.map +1 -0
  76. package/dist/facets/real-vite-fs-shim.js +1279 -0
  77. package/dist/facets/real-vite-hmr.d.ts +142 -0
  78. package/dist/facets/real-vite-hmr.d.ts.map +1 -0
  79. package/dist/facets/real-vite-hmr.js +476 -0
  80. package/dist/facets/vite-dev-server.d.ts +293 -0
  81. package/dist/facets/vite-dev-server.d.ts.map +1 -0
  82. package/dist/facets/vite-dev-server.js +2580 -0
  83. package/dist/facets/wasm-swap-registry.d.ts +261 -0
  84. package/dist/facets/wasm-swap-registry.d.ts.map +1 -0
  85. package/dist/facets/wasm-swap-registry.js +551 -0
  86. package/dist/facets/ws-terminal.d.ts +107 -0
  87. package/dist/facets/ws-terminal.d.ts.map +1 -0
  88. package/dist/facets/ws-terminal.js +207 -0
  89. package/dist/git/commands.d.ts +13 -0
  90. package/dist/git/commands.d.ts.map +1 -0
  91. package/dist/git/commands.js +633 -0
  92. package/dist/git/network-facet.d.ts +61 -0
  93. package/dist/git/network-facet.d.ts.map +1 -0
  94. package/dist/git/network-facet.js +700 -0
  95. package/dist/git-bundle.generated.d.ts +16 -0
  96. package/dist/git-bundle.generated.d.ts.map +1 -0
  97. package/dist/git-bundle.generated.js +15 -0
  98. package/dist/index.d.ts +41 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +73 -0
  101. package/dist/loaders/child-process/spawn-facet.d.ts +90 -0
  102. package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -0
  103. package/dist/loaders/child-process/spawn-facet.js +103 -0
  104. package/dist/loaders/child-process/spawn-pool.d.ts +110 -0
  105. package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -0
  106. package/dist/loaders/child-process/spawn-pool.js +161 -0
  107. package/dist/loaders/fanout-pool.d.ts +195 -0
  108. package/dist/loaders/fanout-pool.d.ts.map +1 -0
  109. package/dist/loaders/fanout-pool.js +326 -0
  110. package/dist/loaders/generated-workers.d.ts +26 -0
  111. package/dist/loaders/generated-workers.d.ts.map +1 -0
  112. package/dist/loaders/generated-workers.js +25 -0
  113. package/dist/loaders/index.d.ts +15 -0
  114. package/dist/loaders/index.d.ts.map +1 -0
  115. package/dist/loaders/index.js +13 -0
  116. package/dist/loaders/loader-pool.d.ts +273 -0
  117. package/dist/loaders/loader-pool.d.ts.map +1 -0
  118. package/dist/loaders/loader-pool.js +624 -0
  119. package/dist/loaders/npm-resolve-preamble.d.ts +34 -0
  120. package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -0
  121. package/dist/loaders/npm-resolve-preamble.js +237 -0
  122. package/dist/loaders/pre-bundle-preamble.d.ts +41 -0
  123. package/dist/loaders/pre-bundle-preamble.d.ts.map +1 -0
  124. package/dist/loaders/pre-bundle-preamble.js +114 -0
  125. package/dist/loaders/vendor/codegen.d.ts +24 -0
  126. package/dist/loaders/vendor/codegen.d.ts.map +1 -0
  127. package/dist/loaders/vendor/codegen.js +44 -0
  128. package/dist/loaders/vendor/errors.d.ts +24 -0
  129. package/dist/loaders/vendor/errors.d.ts.map +1 -0
  130. package/dist/loaders/vendor/errors.js +46 -0
  131. package/dist/loaders/vendor/index.d.ts +16 -0
  132. package/dist/loaders/vendor/index.d.ts.map +1 -0
  133. package/dist/loaders/vendor/index.js +11 -0
  134. package/dist/loaders/vendor/pool.d.ts +88 -0
  135. package/dist/loaders/vendor/pool.d.ts.map +1 -0
  136. package/dist/loaders/vendor/pool.js +353 -0
  137. package/dist/loaders/vendor/primitives.d.ts +11 -0
  138. package/dist/loaders/vendor/primitives.d.ts.map +1 -0
  139. package/dist/loaders/vendor/primitives.js +33 -0
  140. package/dist/loaders/vendor/serialize.d.ts +3 -0
  141. package/dist/loaders/vendor/serialize.d.ts.map +1 -0
  142. package/dist/loaders/vendor/serialize.js +25 -0
  143. package/dist/loaders/vendor/types.d.ts +60 -0
  144. package/dist/loaders/vendor/types.d.ts.map +1 -0
  145. package/dist/loaders/vendor/types.js +4 -0
  146. package/dist/npm/cache.d.ts +136 -0
  147. package/dist/npm/cache.d.ts.map +1 -0
  148. package/dist/npm/cache.js +429 -0
  149. package/dist/npm/install-batch-facet.d.ts +129 -0
  150. package/dist/npm/install-batch-facet.d.ts.map +1 -0
  151. package/dist/npm/install-batch-facet.js +598 -0
  152. package/dist/npm/install-facet.d.ts +29 -0
  153. package/dist/npm/install-facet.d.ts.map +1 -0
  154. package/dist/npm/install-facet.js +9 -0
  155. package/dist/npm/installer.d.ts +239 -0
  156. package/dist/npm/installer.d.ts.map +1 -0
  157. package/dist/npm/installer.js +2022 -0
  158. package/dist/npm/npx-install.d.ts +74 -0
  159. package/dist/npm/npx-install.d.ts.map +1 -0
  160. package/dist/npm/npx-install.js +302 -0
  161. package/dist/npm/pre-bundle-facet.d.ts +168 -0
  162. package/dist/npm/pre-bundle-facet.d.ts.map +1 -0
  163. package/dist/npm/pre-bundle-facet.js +653 -0
  164. package/dist/npm/r2-cache.d.ts +286 -0
  165. package/dist/npm/r2-cache.d.ts.map +1 -0
  166. package/dist/npm/r2-cache.js +506 -0
  167. package/dist/npm/resolve-facet.d.ts +202 -0
  168. package/dist/npm/resolve-facet.d.ts.map +1 -0
  169. package/dist/npm/resolve-facet.js +833 -0
  170. package/dist/npm/resolve-one-facet.d.ts +204 -0
  171. package/dist/npm/resolve-one-facet.d.ts.map +1 -0
  172. package/dist/npm/resolve-one-facet.js +437 -0
  173. package/dist/npm/resolver.d.ts +109 -0
  174. package/dist/npm/resolver.d.ts.map +1 -0
  175. package/dist/npm/resolver.js +899 -0
  176. package/dist/npm/tarball-stream.d.ts +85 -0
  177. package/dist/npm/tarball-stream.d.ts.map +1 -0
  178. package/dist/npm/tarball-stream.js +205 -0
  179. package/dist/npm/tarball.d.ts +51 -0
  180. package/dist/npm/tarball.d.ts.map +1 -0
  181. package/dist/npm/tarball.js +185 -0
  182. package/dist/observability/diag-counters.d.ts +292 -0
  183. package/dist/observability/diag-counters.d.ts.map +1 -0
  184. package/dist/observability/diag-counters.js +302 -0
  185. package/dist/observability/heap-estimate.d.ts +166 -0
  186. package/dist/observability/heap-estimate.d.ts.map +1 -0
  187. package/dist/observability/heap-estimate.js +192 -0
  188. package/dist/observability/heavy-alloc-coord.d.ts +95 -0
  189. package/dist/observability/heavy-alloc-coord.d.ts.map +1 -0
  190. package/dist/observability/heavy-alloc-coord.js +149 -0
  191. package/dist/observability/oom-classify.d.ts +56 -0
  192. package/dist/observability/oom-classify.d.ts.map +1 -0
  193. package/dist/observability/oom-classify.js +118 -0
  194. package/dist/observability/oom-discriminator.d.ts +192 -0
  195. package/dist/observability/oom-discriminator.d.ts.map +1 -0
  196. package/dist/observability/oom-discriminator.js +233 -0
  197. package/dist/real-vite-bundle.generated.d.ts +46 -0
  198. package/dist/real-vite-bundle.generated.d.ts.map +1 -0
  199. package/dist/real-vite-bundle.generated.js +53 -0
  200. package/dist/replica/routing.d.ts +171 -0
  201. package/dist/replica/routing.d.ts.map +1 -0
  202. package/dist/replica/routing.js +288 -0
  203. package/dist/replica/suspension.d.ts +36 -0
  204. package/dist/replica/suspension.d.ts.map +1 -0
  205. package/dist/replica/suspension.js +49 -0
  206. package/dist/router/index.d.ts +156 -0
  207. package/dist/router/index.d.ts.map +1 -0
  208. package/dist/router/index.js +220 -0
  209. package/dist/runtime/assets-loader.d.ts +71 -0
  210. package/dist/runtime/assets-loader.d.ts.map +1 -0
  211. package/dist/runtime/assets-loader.js +128 -0
  212. package/dist/runtime/barrel-detect.d.ts +46 -0
  213. package/dist/runtime/barrel-detect.d.ts.map +1 -0
  214. package/dist/runtime/barrel-detect.js +73 -0
  215. package/dist/runtime/barrel-synthesizer.d.ts +181 -0
  216. package/dist/runtime/barrel-synthesizer.d.ts.map +1 -0
  217. package/dist/runtime/barrel-synthesizer.js +541 -0
  218. package/dist/runtime/bun-repl.d.ts +46 -0
  219. package/dist/runtime/bun-repl.d.ts.map +1 -0
  220. package/dist/runtime/bun-repl.js +326 -0
  221. package/dist/runtime/bun-runner.d.ts +66 -0
  222. package/dist/runtime/bun-runner.d.ts.map +1 -0
  223. package/dist/runtime/bun-runner.js +219 -0
  224. package/dist/runtime/clang-runner.d.ts +38 -0
  225. package/dist/runtime/clang-runner.d.ts.map +1 -0
  226. package/dist/runtime/clang-runner.js +1033 -0
  227. package/dist/runtime/comment-strip.d.ts +37 -0
  228. package/dist/runtime/comment-strip.d.ts.map +1 -0
  229. package/dist/runtime/comment-strip.js +74 -0
  230. package/dist/runtime/esbuild-service.d.ts +201 -0
  231. package/dist/runtime/esbuild-service.d.ts.map +1 -0
  232. package/dist/runtime/esbuild-service.js +1444 -0
  233. package/dist/runtime/esbuild-wasm-bytes.d.ts +87 -0
  234. package/dist/runtime/esbuild-wasm-bytes.d.ts.map +1 -0
  235. package/dist/runtime/esbuild-wasm-bytes.js +131 -0
  236. package/dist/runtime/framework-detect.d.ts +71 -0
  237. package/dist/runtime/framework-detect.d.ts.map +1 -0
  238. package/dist/runtime/framework-detect.js +178 -0
  239. package/dist/runtime/long-running-handle.d.ts +133 -0
  240. package/dist/runtime/long-running-handle.d.ts.map +1 -0
  241. package/dist/runtime/long-running-handle.js +192 -0
  242. package/dist/runtime/node-repl.d.ts +36 -0
  243. package/dist/runtime/node-repl.d.ts.map +1 -0
  244. package/dist/runtime/node-repl.js +293 -0
  245. package/dist/runtime/node-runner.d.ts +111 -0
  246. package/dist/runtime/node-runner.d.ts.map +1 -0
  247. package/dist/runtime/node-runner.js +194 -0
  248. package/dist/runtime/node-shims.d.ts +24 -0
  249. package/dist/runtime/node-shims.d.ts.map +1 -0
  250. package/dist/runtime/node-shims.js +3019 -0
  251. package/dist/runtime/package-manager.d.ts +118 -0
  252. package/dist/runtime/package-manager.d.ts.map +1 -0
  253. package/dist/runtime/package-manager.js +420 -0
  254. package/dist/runtime/port-registry.d.ts +80 -0
  255. package/dist/runtime/port-registry.d.ts.map +1 -0
  256. package/dist/runtime/port-registry.js +166 -0
  257. package/dist/runtime/process-logs-api.d.ts +102 -0
  258. package/dist/runtime/process-logs-api.d.ts.map +1 -0
  259. package/dist/runtime/process-logs-api.js +253 -0
  260. package/dist/runtime/process-logs.d.ts +311 -0
  261. package/dist/runtime/process-logs.d.ts.map +1 -0
  262. package/dist/runtime/process-logs.js +656 -0
  263. package/dist/runtime/process-table.d.ts +65 -0
  264. package/dist/runtime/process-table.d.ts.map +1 -0
  265. package/dist/runtime/process-table.js +107 -0
  266. package/dist/runtime/project-detect.d.ts +21 -0
  267. package/dist/runtime/project-detect.d.ts.map +1 -0
  268. package/dist/runtime/project-detect.js +46 -0
  269. package/dist/runtime/python-repl.d.ts +66 -0
  270. package/dist/runtime/python-repl.d.ts.map +1 -0
  271. package/dist/runtime/python-repl.js +563 -0
  272. package/dist/runtime/python-runner.d.ts +50 -0
  273. package/dist/runtime/python-runner.d.ts.map +1 -0
  274. package/dist/runtime/python-runner.js +870 -0
  275. package/dist/runtime/repl-session.d.ts +160 -0
  276. package/dist/runtime/repl-session.d.ts.map +1 -0
  277. package/dist/runtime/repl-session.js +393 -0
  278. package/dist/runtime/require-resolver.d.ts +52 -0
  279. package/dist/runtime/require-resolver.d.ts.map +1 -0
  280. package/dist/runtime/require-resolver.js +690 -0
  281. package/dist/runtime/router-basename.d.ts +36 -0
  282. package/dist/runtime/router-basename.d.ts.map +1 -0
  283. package/dist/runtime/router-basename.js +310 -0
  284. package/dist/runtime/ruby-repl.d.ts +43 -0
  285. package/dist/runtime/ruby-repl.d.ts.map +1 -0
  286. package/dist/runtime/ruby-repl.js +229 -0
  287. package/dist/runtime/ruby-runner.d.ts +62 -0
  288. package/dist/runtime/ruby-runner.d.ts.map +1 -0
  289. package/dist/runtime/ruby-runner.js +737 -0
  290. package/dist/runtime/runtime-catalog.d.ts +99 -0
  291. package/dist/runtime/runtime-catalog.d.ts.map +1 -0
  292. package/dist/runtime/runtime-catalog.js +183 -0
  293. package/dist/runtime/runtime-registry.d.ts +136 -0
  294. package/dist/runtime/runtime-registry.d.ts.map +1 -0
  295. package/dist/runtime/runtime-registry.js +318 -0
  296. package/dist/runtime/streams.d.ts +13 -0
  297. package/dist/runtime/streams.d.ts.map +1 -0
  298. package/dist/runtime/streams.js +402 -0
  299. package/dist/runtime/wasi-instance.d.ts +190 -0
  300. package/dist/runtime/wasi-instance.d.ts.map +1 -0
  301. package/dist/runtime/wasi-instance.js +2044 -0
  302. package/dist/runtime/wasm-runner.d.ts +117 -0
  303. package/dist/runtime/wasm-runner.d.ts.map +1 -0
  304. package/dist/runtime/wasm-runner.js +705 -0
  305. package/dist/session/bindings.d.ts +168 -0
  306. package/dist/session/bindings.d.ts.map +1 -0
  307. package/dist/session/bindings.js +524 -0
  308. package/dist/session/ctx-exports.d.ts +16 -0
  309. package/dist/session/ctx-exports.d.ts.map +1 -0
  310. package/dist/session/ctx-exports.js +22 -0
  311. package/dist/session/diag.d.ts +62 -0
  312. package/dist/session/diag.d.ts.map +1 -0
  313. package/dist/session/diag.js +118 -0
  314. package/dist/session/fs-watch.d.ts +132 -0
  315. package/dist/session/fs-watch.d.ts.map +1 -0
  316. package/dist/session/fs-watch.js +255 -0
  317. package/dist/session/helpers.d.ts +150 -0
  318. package/dist/session/helpers.d.ts.map +1 -0
  319. package/dist/session/helpers.js +472 -0
  320. package/dist/session/hibernation.d.ts +145 -0
  321. package/dist/session/hibernation.d.ts.map +1 -0
  322. package/dist/session/hibernation.js +385 -0
  323. package/dist/session/init-phases.d.ts +87 -0
  324. package/dist/session/init-phases.d.ts.map +1 -0
  325. package/dist/session/init-phases.js +137 -0
  326. package/dist/session/init.d.ts +46 -0
  327. package/dist/session/init.d.ts.map +1 -0
  328. package/dist/session/init.js +3579 -0
  329. package/dist/session/keys.d.ts +56 -0
  330. package/dist/session/keys.d.ts.map +1 -0
  331. package/dist/session/keys.js +55 -0
  332. package/dist/session/nimbus-session.d.ts +434 -0
  333. package/dist/session/nimbus-session.d.ts.map +1 -0
  334. package/dist/session/nimbus-session.js +1084 -0
  335. package/dist/session/programmatic.d.ts +105 -0
  336. package/dist/session/programmatic.d.ts.map +1 -0
  337. package/dist/session/programmatic.js +326 -0
  338. package/dist/session/replica-routes.d.ts +68 -0
  339. package/dist/session/replica-routes.d.ts.map +1 -0
  340. package/dist/session/replica-routes.js +77 -0
  341. package/dist/session/routes.d.ts +29 -0
  342. package/dist/session/routes.d.ts.map +1 -0
  343. package/dist/session/routes.js +1331 -0
  344. package/dist/session/rpc.d.ts +323 -0
  345. package/dist/session/rpc.d.ts.map +1 -0
  346. package/dist/session/rpc.js +808 -0
  347. package/dist/session/state-store.d.ts +210 -0
  348. package/dist/session/state-store.d.ts.map +1 -0
  349. package/dist/session/state-store.js +459 -0
  350. package/dist/session/supervisor-rpc.d.ts +269 -0
  351. package/dist/session/supervisor-rpc.d.ts.map +1 -0
  352. package/dist/session/supervisor-rpc.js +437 -0
  353. package/dist/session/ws-hibernation-config.d.ts +64 -0
  354. package/dist/session/ws-hibernation-config.d.ts.map +1 -0
  355. package/dist/session/ws-hibernation-config.js +89 -0
  356. package/dist/session/ws.d.ts +95 -0
  357. package/dist/session/ws.d.ts.map +1 -0
  358. package/dist/session/ws.js +395 -0
  359. package/dist/shell/features.d.ts +410 -0
  360. package/dist/shell/features.d.ts.map +1 -0
  361. package/dist/shell/features.js +1885 -0
  362. package/dist/shell/unix-commands.d.ts +15 -0
  363. package/dist/shell/unix-commands.d.ts.map +1 -0
  364. package/dist/shell/unix-commands.js +3703 -0
  365. package/dist/stubs/lifo-ui.d.ts +10 -0
  366. package/dist/stubs/lifo-ui.d.ts.map +1 -0
  367. package/dist/stubs/lifo-ui.js +1 -0
  368. package/dist/tailwind-play.generated.d.ts +19 -0
  369. package/dist/tailwind-play.generated.d.ts.map +1 -0
  370. package/dist/tailwind-play.generated.js +18 -0
  371. package/dist/vfs/dev-provider.d.ts +64 -0
  372. package/dist/vfs/dev-provider.d.ts.map +1 -0
  373. package/dist/vfs/dev-provider.js +191 -0
  374. package/dist/vfs/events.d.ts +51 -0
  375. package/dist/vfs/events.d.ts.map +1 -0
  376. package/dist/vfs/events.js +127 -0
  377. package/dist/vfs/path.d.ts +30 -0
  378. package/dist/vfs/path.d.ts.map +1 -0
  379. package/dist/vfs/path.js +41 -0
  380. package/dist/vfs/seed-project.d.ts +62 -0
  381. package/dist/vfs/seed-project.d.ts.map +1 -0
  382. package/dist/vfs/seed-project.js +1045 -0
  383. package/dist/vfs/sqlite-vfs.d.ts +435 -0
  384. package/dist/vfs/sqlite-vfs.d.ts.map +1 -0
  385. package/dist/vfs/sqlite-vfs.js +1670 -0
  386. package/dist/vfs/symlink-registry.d.ts +64 -0
  387. package/dist/vfs/symlink-registry.d.ts.map +1 -0
  388. package/dist/vfs/symlink-registry.js +137 -0
  389. package/dist/wrangler/nimbus-wrangler.d.ts +157 -0
  390. package/dist/wrangler/nimbus-wrangler.d.ts.map +1 -0
  391. package/dist/wrangler/nimbus-wrangler.js +931 -0
  392. package/package.json +77 -0
  393. package/public/_assets/cirrus-plugin-react.bundle.js +70740 -0
  394. package/public/_assets/esbuild-0.24.2.wasm +0 -0
  395. package/public/_assets/real-vite-bundle.js +101478 -0
  396. package/public/_assets/rollup.wasm +0 -0
  397. package/public/favicon.svg +8 -0
  398. package/public/index.html +565 -0
  399. package/public/s/index.html +2379 -0
  400. package/scripts/bundle-esbuild-wasm.mjs +244 -0
  401. package/scripts/bundle-facet-workers.mjs +137 -0
  402. package/scripts/bundle-git.mjs +118 -0
  403. package/scripts/bundle-npm-cjs.mjs +363 -0
  404. package/scripts/bundle-plugin-react.mjs +480 -0
  405. package/scripts/bundle-real-vite.mjs +959 -0
  406. package/scripts/bundle-runtime.mjs +600 -0
  407. package/scripts/bundle-tailwind-play.mjs +130 -0
  408. package/scripts/patch-lifo-ui.mjs +131 -0
@@ -0,0 +1,1885 @@
1
+ /**
2
+ * shell-features.ts — Shell preprocessor for pipes, redirects, operators,
3
+ * env var expansion, glob expansion, and quoted strings.
4
+ *
5
+ * Wraps the LIFO shell's command execution with Unix-like features.
6
+ * Called from nimbus-session.ts before dispatching to the registry.
7
+ */
8
+ // ── Quoted string parsing ───────────────────────────────────────────────
9
+ /**
10
+ * Parse a command line into tokens, respecting quotes.
11
+ * "hello world" → one token. 'don\'t' → one token.
12
+ */
13
+ export function tokenize(input) {
14
+ const tokens = [];
15
+ let current = '';
16
+ let inSingle = false;
17
+ let inDouble = false;
18
+ let escaped = false;
19
+ for (let i = 0; i < input.length; i++) {
20
+ const ch = input[i];
21
+ if (escaped) {
22
+ current += ch;
23
+ escaped = false;
24
+ continue;
25
+ }
26
+ if (ch === '\\' && !inSingle) {
27
+ escaped = true;
28
+ continue;
29
+ }
30
+ if (ch === "'" && !inDouble) {
31
+ inSingle = !inSingle;
32
+ continue;
33
+ }
34
+ if (ch === '"' && !inSingle) {
35
+ inDouble = !inDouble;
36
+ continue;
37
+ }
38
+ if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) {
39
+ if (current) {
40
+ tokens.push(current);
41
+ current = '';
42
+ }
43
+ continue;
44
+ }
45
+ current += ch;
46
+ }
47
+ if (current)
48
+ tokens.push(current);
49
+ return tokens;
50
+ }
51
+ // ── Environment variable expansion ──────────────────────────────────────
52
+ /**
53
+ * Expand $VAR and ${VAR} in a string using the env map.
54
+ */
55
+ export function expandVars(input, env) {
56
+ return input
57
+ .replace(/\$\{([^}]+)\}/g, (_, name) => env[name] ?? '')
58
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => env[name] ?? '');
59
+ }
60
+ // ── Glob expansion ──────────────────────────────────────────────────────
61
+ /**
62
+ * Expand glob patterns (* and ?) against VFS directory entries.
63
+ */
64
+ export function expandGlobs(tokens, cwd, vfs) {
65
+ const result = [];
66
+ for (const token of tokens) {
67
+ if (!token.includes('*') && !token.includes('?')) {
68
+ result.push(token);
69
+ continue;
70
+ }
71
+ // Split into dir + pattern
72
+ const lastSlash = token.lastIndexOf('/');
73
+ const dir = lastSlash >= 0 ? token.substring(0, lastSlash) : '.';
74
+ const pattern = lastSlash >= 0 ? token.substring(lastSlash + 1) : token;
75
+ const vfsDir = dir === '.' ? cwd.replace(/^\/+/, '') :
76
+ (dir.startsWith('/') ? dir.replace(/^\/+/, '') : cwd.replace(/^\/+/, '') + '/' + dir);
77
+ try {
78
+ const entries = vfs.readdir(vfsDir);
79
+ const re = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
80
+ const matches = entries.filter(e => re.test(e.name)).map(e => dir === '.' ? e.name : dir + '/' + e.name);
81
+ if (matches.length > 0) {
82
+ result.push(...matches.sort());
83
+ }
84
+ else {
85
+ result.push(token); // No matches — keep literal
86
+ }
87
+ }
88
+ catch {
89
+ result.push(token); // Dir doesn't exist — keep literal
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ /**
95
+ * Split a command line by operators (|, &&, ||, ;) respecting quotes.
96
+ * Returns segments with their operator type.
97
+ */
98
+ export function splitOperators(input) {
99
+ const segments = [];
100
+ const operators = [];
101
+ let current = '';
102
+ let inSingle = false;
103
+ let inDouble = false;
104
+ for (let i = 0; i < input.length; i++) {
105
+ const ch = input[i];
106
+ if (ch === "'" && !inDouble) {
107
+ inSingle = !inSingle;
108
+ current += ch;
109
+ continue;
110
+ }
111
+ if (ch === '"' && !inSingle) {
112
+ inDouble = !inDouble;
113
+ current += ch;
114
+ continue;
115
+ }
116
+ if (inSingle || inDouble) {
117
+ current += ch;
118
+ continue;
119
+ }
120
+ if (ch === '|' && input[i + 1] === '|') {
121
+ segments.push(current.trim());
122
+ operators.push('||');
123
+ current = '';
124
+ i++;
125
+ continue;
126
+ }
127
+ if (ch === '&' && input[i + 1] === '&') {
128
+ segments.push(current.trim());
129
+ operators.push('&&');
130
+ current = '';
131
+ i++;
132
+ continue;
133
+ }
134
+ if (ch === '|') {
135
+ segments.push(current.trim());
136
+ operators.push('|');
137
+ current = '';
138
+ continue;
139
+ }
140
+ if (ch === ';') {
141
+ segments.push(current.trim());
142
+ operators.push(';');
143
+ current = '';
144
+ continue;
145
+ }
146
+ current += ch;
147
+ }
148
+ if (current.trim())
149
+ segments.push(current.trim());
150
+ return { segments, operators };
151
+ }
152
+ /**
153
+ * Parse redirects from a command string.
154
+ * Returns the clean command and any redirect targets.
155
+ */
156
+ export function parseRedirects(cmd) {
157
+ let redirectOut;
158
+ let redirectAppend;
159
+ let redirectErr;
160
+ // 2>/dev/null or 2> file
161
+ cmd = cmd.replace(/2>\s*(\S+)/g, (_, file) => { redirectErr = file; return ''; });
162
+ // >> file (append)
163
+ cmd = cmd.replace(/>>\s*(\S+)/g, (_, file) => { redirectAppend = file; return ''; });
164
+ // > file (overwrite)
165
+ cmd = cmd.replace(/>\s*(\S+)/g, (_, file) => { redirectOut = file; return ''; });
166
+ return { command: cmd.trim(), redirectOut, redirectAppend, redirectErr };
167
+ }
168
+ /**
169
+ * Detect and parse a heredoc operator in a command line.
170
+ * Supports: << DELIM, << 'DELIM', << "DELIM", <<- DELIM, <<- 'DELIM'
171
+ *
172
+ * Returns null if no heredoc is found.
173
+ */
174
+ export function parseHeredoc(input) {
175
+ // Match <<- or << followed by optional whitespace and a delimiter.
176
+ // The delimiter may be quoted with single or double quotes.
177
+ // Must not match <<< (herestring) — require that third char is NOT <.
178
+ // Scan character-by-character to respect quoting context.
179
+ let inSingle = false;
180
+ let inDouble = false;
181
+ let heredocPos = -1;
182
+ for (let i = 0; i < input.length; i++) {
183
+ const ch = input[i];
184
+ if (ch === "'" && !inDouble) {
185
+ inSingle = !inSingle;
186
+ continue;
187
+ }
188
+ if (ch === '"' && !inSingle) {
189
+ inDouble = !inDouble;
190
+ continue;
191
+ }
192
+ if (inSingle || inDouble)
193
+ continue;
194
+ if (ch === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
195
+ heredocPos = i;
196
+ break;
197
+ }
198
+ }
199
+ if (heredocPos === -1)
200
+ return null;
201
+ const beforeHeredoc = input.substring(0, heredocPos).trimEnd();
202
+ let rest = input.substring(heredocPos + 2); // skip the <<
203
+ // Check for - (strip tabs)
204
+ let stripTabs = false;
205
+ if (rest.startsWith('-')) {
206
+ stripTabs = true;
207
+ rest = rest.substring(1);
208
+ }
209
+ // Skip whitespace
210
+ rest = rest.trimStart();
211
+ // Parse delimiter — may be quoted
212
+ let delimiter;
213
+ let quoted = false;
214
+ if (rest.startsWith("'")) {
215
+ // Single-quoted delimiter: no expansion
216
+ const endQuote = rest.indexOf("'", 1);
217
+ if (endQuote === -1)
218
+ return null; // malformed
219
+ delimiter = rest.substring(1, endQuote);
220
+ quoted = true;
221
+ }
222
+ else if (rest.startsWith('"')) {
223
+ // Double-quoted delimiter: treated as quoted (no expansion for v1)
224
+ const endQuote = rest.indexOf('"', 1);
225
+ if (endQuote === -1)
226
+ return null; // malformed
227
+ delimiter = rest.substring(1, endQuote);
228
+ quoted = true;
229
+ }
230
+ else {
231
+ // Unquoted delimiter: first non-whitespace word (allows hyphens, dots, etc.)
232
+ const match = rest.match(/^(\S+)/);
233
+ if (!match)
234
+ return null; // no valid delimiter
235
+ delimiter = match[1];
236
+ quoted = false;
237
+ }
238
+ if (!delimiter)
239
+ return null;
240
+ // Parse the command part (before <<) for redirects.
241
+ // Common patterns:
242
+ // cat > file << DELIM → redirect to file, command is "cat"
243
+ // cat >> file << DELIM → append to file, command is "cat"
244
+ // command << DELIM → pipe stdin to command
245
+ // cat << DELIM > file → redirect after heredoc (uncommon but valid)
246
+ let command = beforeHeredoc;
247
+ let redirectFile;
248
+ let redirectAppend = false;
249
+ // Detect >> file or > file in the command part
250
+ const appendMatch = command.match(/>>\s*(\S+)/);
251
+ if (appendMatch) {
252
+ redirectFile = appendMatch[1];
253
+ redirectAppend = true;
254
+ command = command.replace(/>>\s*\S+/, '').trim();
255
+ }
256
+ else {
257
+ const outMatch = command.match(/>\s*(\S+)/);
258
+ if (outMatch) {
259
+ redirectFile = outMatch[1];
260
+ redirectAppend = false;
261
+ command = command.replace(/>\s*\S+/, '').trim();
262
+ }
263
+ }
264
+ return {
265
+ command: command.trim(),
266
+ delimiter,
267
+ quoted,
268
+ stripTabs,
269
+ redirectFile,
270
+ redirectAppend,
271
+ };
272
+ }
273
+ /**
274
+ * HeredocHandler — monkey-patches a Shell instance to support heredoc syntax.
275
+ *
276
+ * Usage: call `HeredocHandler.install(shell, terminal, vfs)` after creating the shell.
277
+ * All heredoc logic lives here in shell-features.ts; nimbus-session.ts only calls install().
278
+ *
279
+ * When the user types a command containing `<<`, the handler:
280
+ * 1. Intercepts executeLine, parses the heredoc
281
+ * 2. Enters accumulation mode — shows `> ` continuation prompt
282
+ * 3. Collects input lines until the delimiter is found
283
+ * 4. Executes the command with the accumulated content as stdin
284
+ * 5. For `cat > file << DELIM`, writes the content directly to the file
285
+ *
286
+ * Design notes:
287
+ * - The Shell from @lifo-sh/core calls `executeLine(line)` when the user
288
+ * presses Enter with non-empty input. For empty input, it calls
289
+ * `printPrompt()` directly. We patch both methods so that empty lines
290
+ * inside a heredoc body are properly captured.
291
+ * - During accumulation mode, the shell's normal line-editing (arrow keys,
292
+ * backspace, history, tab completion) still works for each content line.
293
+ * - The `> ` continuation prompt visually signals heredoc mode.
294
+ */
295
+ export class HeredocHandler {
296
+ shell; // Shell instance (from @lifo-sh/core)
297
+ terminal; // ITerminal-compatible (has write())
298
+ vfs; // SqliteVFS
299
+ originalExecuteLine = null;
300
+ originalPrintPrompt = null;
301
+ originalHandleInput = null;
302
+ // ── Accumulation state ──
303
+ active = false;
304
+ heredocInfo = null;
305
+ lines = [];
306
+ historyLengthAtStart = 0;
307
+ /** Safety limit to prevent unbounded memory growth */
308
+ static MAX_HEREDOC_LINES = 50000;
309
+ constructor(shell, terminal, vfs) {
310
+ this.shell = shell;
311
+ this.terminal = terminal;
312
+ this.vfs = vfs;
313
+ }
314
+ /**
315
+ * Install the heredoc handler on a Shell instance.
316
+ * Call once after the Shell is created.
317
+ */
318
+ static install(shell, terminal, vfs) {
319
+ const handler = new HeredocHandler(shell, terminal, vfs);
320
+ handler._patch();
321
+ return handler;
322
+ }
323
+ /** Cancel heredoc accumulation and return to normal shell prompt. */
324
+ _cancel() {
325
+ this.active = false;
326
+ this.heredocInfo = null;
327
+ this.lines = [];
328
+ // Restore history to before the heredoc started (removes content lines)
329
+ if (this.shell.history) {
330
+ this.shell.history.length = this.historyLengthAtStart;
331
+ }
332
+ }
333
+ _patch() {
334
+ // Save originals (bound to shell)
335
+ this.originalExecuteLine = this.shell.executeLine.bind(this.shell);
336
+ this.originalPrintPrompt = this.shell.printPrompt.bind(this.shell);
337
+ this.originalHandleInput = this.shell.handleInput.bind(this.shell);
338
+ // Patch handleInput to intercept Ctrl+C during heredoc mode.
339
+ // Without this, Ctrl+C during heredoc calls printPrompt (patched below),
340
+ // which would silently insert an empty line instead of cancelling.
341
+ // Also patches multi-line paste to preserve empty lines for heredoc mode.
342
+ this.shell.handleInput = (data) => {
343
+ // Ctrl+C (\x03) while in heredoc mode → cancel accumulation
344
+ if (this.active && data === '\x03') {
345
+ this._cancel();
346
+ this.terminal.write('^C\r\n');
347
+ this.originalPrintPrompt();
348
+ return;
349
+ }
350
+ // During heredoc accumulation, we must intercept ALL input that contains
351
+ // Enter (\r or \n) to prevent the Shell's .trim() from stripping leading
352
+ // whitespace. The Shell does `lineBuffer.trim()` before calling executeLine,
353
+ // which destroys indentation that is critical for heredoc content.
354
+ //
355
+ // For multi-line paste, we also preserve empty lines that the Shell's
356
+ // original handleInput would drop from the pasteQueue.
357
+ if (this.active && !this.shell.running && data.length > 1 && /[\r\n]/.test(data)) {
358
+ const parts = data.split(/\r\n|\r|\n/);
359
+ // First part extends the current line buffer
360
+ if (parts[0]) {
361
+ this.shell.lineBuffer = this.shell.lineBuffer.slice(0, this.shell.cursorPos)
362
+ + parts[0] + this.shell.lineBuffer.slice(this.shell.cursorPos);
363
+ this.shell.cursorPos += parts[0].length;
364
+ }
365
+ if (parts.length > 1) {
366
+ // "Enter" the current line — use raw lineBuffer (NOT trimmed)
367
+ this.shell.redrawLine();
368
+ this.terminal.write('\r\n');
369
+ const currentLine = this.shell.lineBuffer; // preserve leading whitespace
370
+ this.shell.lineBuffer = '';
371
+ this.shell.cursorPos = 0;
372
+ this.shell.screenCursorRow = 0;
373
+ this.shell.historyIndex = -1;
374
+ // Push subsequent lines to paste queue (including empty strings),
375
+ // but skip the trailing empty string (artifact of splitting "...\r")
376
+ for (let i = 1; i < parts.length; i++) {
377
+ if (i === parts.length - 1 && parts[i] === '')
378
+ continue;
379
+ this.shell.pasteQueue.push(parts[i]);
380
+ }
381
+ // Add to history (matches Shell behavior)
382
+ if (currentLine.trim()) {
383
+ this.shell.history.push(currentLine.trim());
384
+ }
385
+ // Process the raw line (with leading whitespace preserved)
386
+ const isDelim = this._processLine(currentLine);
387
+ if (isDelim && this.heredocInfo !== null) {
388
+ this._finishHeredoc();
389
+ }
390
+ else if (!isDelim) {
391
+ this.terminal.write('> ');
392
+ this._drainPasteQueue();
393
+ }
394
+ }
395
+ return;
396
+ }
397
+ // Single Enter (\r alone) while in heredoc mode — intercept to
398
+ // preserve the raw lineBuffer content (Shell would .trim() it).
399
+ if (this.active && !this.shell.running && data === '\r') {
400
+ this.terminal.write('\r\n');
401
+ const currentLine = this.shell.lineBuffer; // raw, untrimmed
402
+ this.shell.lineBuffer = '';
403
+ this.shell.cursorPos = 0;
404
+ this.shell.screenCursorRow = 0;
405
+ this.shell.historyIndex = -1;
406
+ // Add trimmed version to history (matches Shell behavior)
407
+ if (currentLine.trim()) {
408
+ this.shell.history.push(currentLine.trim());
409
+ }
410
+ // Process the raw line (preserving leading whitespace)
411
+ const isDelim = this._processLine(currentLine);
412
+ if (isDelim && this.heredocInfo !== null) {
413
+ this._finishHeredoc();
414
+ }
415
+ else if (!isDelim) {
416
+ this.terminal.write('> ');
417
+ }
418
+ return;
419
+ }
420
+ // Default: pass through to original
421
+ return this.originalHandleInput(data);
422
+ };
423
+ // Replace executeLine with our interceptor
424
+ this.shell.executeLine = async (line) => {
425
+ if (this.active) {
426
+ // We're accumulating heredoc lines.
427
+ // The shell may have trimmed the line (losing leading tabs).
428
+ // For <<- mode, use the raw lineBuffer if it's non-empty —
429
+ // it preserves indentation that the shell's .trim() removes.
430
+ const rawLine = (this.heredocInfo?.stripTabs && this.shell.lineBuffer)
431
+ ? this.shell.lineBuffer
432
+ : line;
433
+ await this._accumulateLine(rawLine);
434
+ return;
435
+ }
436
+ // Check if this line starts a heredoc
437
+ const info = parseHeredoc(line);
438
+ if (info) {
439
+ await this._startAccumulation(info);
440
+ return;
441
+ }
442
+ // No heredoc — pass through to original
443
+ return this.originalExecuteLine(line);
444
+ };
445
+ // Patch printPrompt to handle empty lines during heredoc accumulation.
446
+ // The Shell calls printPrompt() directly when the user presses Enter
447
+ // on an empty line (without calling executeLine). During heredoc mode,
448
+ // an empty line should be added to the content, not ignored.
449
+ this.shell.printPrompt = () => {
450
+ if (this.active) {
451
+ // Empty line in heredoc — accumulate it
452
+ this._accumulateLine('');
453
+ return;
454
+ }
455
+ // Normal prompt
456
+ return this.originalPrintPrompt();
457
+ };
458
+ }
459
+ async _startAccumulation(info) {
460
+ this.active = true;
461
+ this.heredocInfo = info;
462
+ this.lines = [];
463
+ // Record history length so we can truncate content lines from history later
464
+ this.historyLengthAtStart = this.shell.history?.length ?? 0;
465
+ // Show continuation prompt, then drain any paste queue lines
466
+ // (handles pasted multi-line heredocs where all lines arrive at once)
467
+ this.terminal.write('> ');
468
+ await this._drainPasteQueue();
469
+ }
470
+ /**
471
+ * Process a single heredoc content line (non-recursive).
472
+ * Returns true if this was the delimiter (heredoc is complete).
473
+ */
474
+ _processLine(line) {
475
+ const info = this.heredocInfo;
476
+ // Strip trailing \r and \n from the line before any processing.
477
+ // Lines may arrive with trailing newlines from terminal input processing
478
+ // or paste splitting edge cases — we join with \n later, so storing them
479
+ // with trailing newlines would produce double newlines in the output.
480
+ const cleaned = line.replace(/[\r\n]+$/, '');
481
+ // Delimiter matching: exact match after optional tab stripping.
482
+ // For <<- (stripTabs): strip leading tabs from BOTH content and delimiter lines.
483
+ // For << (no stripTabs): exact match — line must equal delimiter exactly.
484
+ // This matches bash behavior where the delimiter must be alone on its
485
+ // line with no surrounding whitespace (except leading tabs for <<-).
486
+ const checkLine = info.stripTabs ? cleaned.replace(/^\t+/, '') : cleaned;
487
+ if (checkLine === info.delimiter) {
488
+ return true; // signal: delimiter found
489
+ }
490
+ // Size guard
491
+ if (this.lines.length >= HeredocHandler.MAX_HEREDOC_LINES) {
492
+ this.terminal.write(`\x1b[31mheredoc: exceeded ${HeredocHandler.MAX_HEREDOC_LINES} line limit\x1b[0m\r\n`);
493
+ this._cancel();
494
+ this.originalPrintPrompt();
495
+ return true; // stop accumulation
496
+ }
497
+ // Accumulate the line (after stripping leading tabs for <<-)
498
+ const processedLine = info.stripTabs ? cleaned.replace(/^\t+/, '') : cleaned;
499
+ this.lines.push(processedLine);
500
+ return false;
501
+ }
502
+ /**
503
+ * Called when executeLine fires during accumulation (user typed a line + Enter).
504
+ * Also called during paste processing.
505
+ */
506
+ async _accumulateLine(line) {
507
+ if (this._processLine(line)) {
508
+ // Delimiter found (or limit exceeded) — execute the heredoc
509
+ if (this.heredocInfo === null)
510
+ return; // limit exceeded, already cancelled
511
+ await this._finishHeredoc();
512
+ return;
513
+ }
514
+ // Show next continuation prompt
515
+ this.terminal.write('> ');
516
+ }
517
+ /**
518
+ * Drain the Shell's pasteQueue during heredoc accumulation.
519
+ * When a multi-line heredoc is pasted, all lines land in the paste queue.
520
+ * Uses an iterative loop (not recursive) to avoid stack overflow.
521
+ */
522
+ async _drainPasteQueue() {
523
+ const queue = this.shell.pasteQueue;
524
+ if (!queue || queue.length === 0)
525
+ return;
526
+ // Process all queued lines iteratively while still in accumulation mode
527
+ while (this.active && queue.length > 0) {
528
+ const nextLine = queue.shift();
529
+ // Echo the line + newline (matches Shell.drainPasteQueue behavior)
530
+ this.terminal.write(nextLine + '\r\n');
531
+ if (this._processLine(nextLine)) {
532
+ // Delimiter found — finish heredoc and let remaining queue items
533
+ // be handled by the shell's normal drainPasteQueue after we're done
534
+ if (this.heredocInfo === null)
535
+ return; // limit exceeded
536
+ await this._finishHeredoc();
537
+ return;
538
+ }
539
+ // Show continuation prompt for next line
540
+ this.terminal.write('> ');
541
+ }
542
+ }
543
+ async _finishHeredoc() {
544
+ const info = this.heredocInfo;
545
+ let content = this.lines.join('\n') + '\n';
546
+ // BUG-SWEEP-R3-5 (2026-05-11): expand $NAME / ${NAME} in heredoc
547
+ // content when delimiter is NOT quoted (bash semantics: <<EOF
548
+ // expands, <<'EOF' doesn't). Without this, `cat <<EOF` with $X
549
+ // inside outputs literal `$X` — surprising for users coming from
550
+ // bash/zsh.
551
+ if (!info.quoted) {
552
+ const env = (this.shell.env || {});
553
+ content = expandHeredocVars(content, env);
554
+ }
555
+ // Reset state before executing (in case execution triggers more input)
556
+ this.active = false;
557
+ this.heredocInfo = null;
558
+ this.lines = [];
559
+ // Remove heredoc content lines from shell history
560
+ if (this.shell.history) {
561
+ this.shell.history.length = this.historyLengthAtStart;
562
+ }
563
+ try {
564
+ if (info.redirectFile) {
565
+ // Direct file write: `cat > file << DELIM` or `cat >> file << DELIM`
566
+ // This is the most common heredoc pattern — write content to file.
567
+ // We handle it directly via VFS for reliability, regardless of command.
568
+ const cwd = this.shell.getCwd().replace(/^\/+/, '');
569
+ const filePath = info.redirectFile.startsWith('/')
570
+ ? info.redirectFile.replace(/^\/+/, '')
571
+ : cwd + '/' + info.redirectFile;
572
+ // Ensure parent directories exist
573
+ const parts = filePath.split('/');
574
+ for (let i = 1; i < parts.length; i++) {
575
+ const dir = parts.slice(0, i).join('/');
576
+ if (dir && !this.vfs.exists(dir)) {
577
+ this.vfs.mkdir(dir, { recursive: true });
578
+ }
579
+ }
580
+ if (info.redirectAppend) {
581
+ const existing = this.vfs.exists(filePath) ? this.vfs.readFileString(filePath) : '';
582
+ this.vfs.writeFile(filePath, existing + content);
583
+ }
584
+ else {
585
+ this.vfs.writeFile(filePath, content);
586
+ }
587
+ }
588
+ else if (info.command) {
589
+ // Pipe heredoc content as stdin to command: `command << DELIM`
590
+ // Use shell.execute() which supports stdin option.
591
+ //
592
+ // shell-polish (2026-05-12): MUST pass onStdout / onStderr so
593
+ // the command's output reaches the terminal. Pre-fix:
594
+ //
595
+ // $ cat << 'EOF'
596
+ // > hi
597
+ // > 2
598
+ // > EOF
599
+ // user@nimbus:~$ <-- expected "hi\n2" before this prompt
600
+ //
601
+ // Without onStdout, shell.execute() ran cat with stdin=content
602
+ // but cat's stdout was buffered to nowhere — the terminal saw
603
+ // only the prompt return. The redirect path (cat > file <<...)
604
+ // worked because we wrote directly to VFS at line 624-628.
605
+ // Fix: stream stdout + stderr to the terminal via the same
606
+ // onStdout/onStderr signature used by shell.execute callers
607
+ // in src/session/init.ts:2217-2218.
608
+ await this.shell.execute(info.command, {
609
+ stdin: content,
610
+ onStdout: (d) => this.terminal.write(d),
611
+ onStderr: (d) => this.terminal.write(d),
612
+ });
613
+ }
614
+ }
615
+ catch (e) {
616
+ this.terminal.write(`\x1b[31mheredoc error: ${e?.message || e}\x1b[0m\r\n`);
617
+ }
618
+ // Re-show the normal prompt and drain paste queue (matches Shell.executeLine behavior)
619
+ this.originalPrintPrompt();
620
+ if (typeof this.shell.drainPasteQueue === 'function') {
621
+ this.shell.drainPasteQueue();
622
+ }
623
+ }
624
+ }
625
+ // ── Line preprocessor (fd-redirect normalisation) ──────────────────────
626
+ /**
627
+ * BUG-SWEEP-2 (2026-05-11): @lifo-sh/core ≥0.5.5's shell parser does
628
+ * not support fd-to-fd redirects (`2>&1`, `>&2`, `<&0`). Encountering
629
+ * one raises `Expected Word but got Amp ('&')` from the parser and
630
+ * the whole pipeline fails.
631
+ *
632
+ * Real-world impact:
633
+ * - `pip install foo 2>&1 | tail -5` → parse error
634
+ * - `cmd 2>&1` (standard "merge stderr into stdout") → parse error
635
+ * - `echo error >&2` ("write to stderr") → parse error
636
+ *
637
+ * In Nimbus's execution model, stdout AND stderr both stream to the
638
+ * same terminal frame buffer (see WebSocketTerminal). `2>&1` is
639
+ * therefore a no-op (stderr is already on the same sink as stdout)
640
+ * and `>&2` is functionally the same as no-redirect. We rewrite both
641
+ * to empty so the parser sees a clean command line.
642
+ *
643
+ * What this DOES NOT solve:
644
+ * - `cmd 2>/path/to/file` — file-redirect of stderr. NOT supported
645
+ * downstream either (lifo-sh's parser accepts `2>file` but the
646
+ * interpreter at executeSimpleCommand only honours stdout
647
+ * redirection via vfs.writeFile). Out of scope; flag as docs gap.
648
+ * - `cmd > /dev/null` — /dev/null missing on prod. Separate fix.
649
+ *
650
+ * Install AFTER HeredocHandler so heredoc accumulation isn't disturbed
651
+ * (we only run when executeLine fires on a complete line).
652
+ */
653
+ export class FdRedirectNormalizer {
654
+ shell;
655
+ constructor(shell) {
656
+ this.shell = shell;
657
+ }
658
+ /** Install on a Shell instance. Idempotent. */
659
+ static install(shell) {
660
+ const norm = new FdRedirectNormalizer(shell);
661
+ norm._patch();
662
+ return norm;
663
+ }
664
+ _patch() {
665
+ const orig = this.shell.executeLine.bind(this.shell);
666
+ this.shell.executeLine = async (line) => {
667
+ // Skip rewriting inside single-quoted strings (the user explicitly
668
+ // wants the literal `2>&1` text — rare but possible). Outside
669
+ // quotes, replace `2>&1`, `1>&2`, `>&2`, `&>file` (the merge-all
670
+ // operator is already supported by lifo-sh; only fd-to-fd needs
671
+ // stripping).
672
+ const rewritten = FdRedirectNormalizer.normalize(line);
673
+ return orig(rewritten);
674
+ };
675
+ }
676
+ /**
677
+ * Strip fd-to-fd redirects from a shell line. Preserves single-
678
+ * quoted substrings verbatim (user-intended literals are safe).
679
+ *
680
+ * Returns the rewritten line.
681
+ */
682
+ static normalize(line) {
683
+ // Single-quote-aware scan. Build output by copying runs of
684
+ // non-single-quoted text after running the rewrite regex; quoted
685
+ // runs are copied verbatim.
686
+ let out = '';
687
+ let i = 0;
688
+ while (i < line.length) {
689
+ const ch = line[i];
690
+ if (ch === "'") {
691
+ // Find matching close quote (bash: no escapes inside single quotes).
692
+ const j = line.indexOf("'", i + 1);
693
+ if (j < 0) {
694
+ out += line.slice(i);
695
+ break;
696
+ }
697
+ out += line.slice(i, j + 1);
698
+ i = j + 1;
699
+ continue;
700
+ }
701
+ // Find next single-quote (run end).
702
+ const next = line.indexOf("'", i);
703
+ const chunkEnd = next < 0 ? line.length : next;
704
+ const chunk = line.slice(i, chunkEnd);
705
+ // Rewrite operators in the chunk:
706
+ // 2>&1, 1>&2, >&2, <&0, &>&1 etc — drop entirely.
707
+ // `2>&-` (close fd) — drop.
708
+ // The pattern matches optional leading-digit + `>&` + digit-or-dash.
709
+ const stripped = chunk.replace(/\s*\d?[<>]&[\d-]\s*/g, ' ');
710
+ out += stripped;
711
+ i = chunkEnd;
712
+ }
713
+ return out;
714
+ }
715
+ }
716
+ // ── Subshell / grouping normalizer ─────────────────────────────────────
717
+ /**
718
+ * BUG-SWEEP-R3-2 (2026-05-11): @lifo-sh/core's parser doesn't support
719
+ * subshell grouping `(cmd1; cmd2)`. Pre-fix `(echo a; echo b)` raised
720
+ * `unexpected token '('`. Common shell idioms (`(cd /x && cmd)` for
721
+ * cd-scoped execution, pipeline grouping `(a; b) | c`) all failed.
722
+ *
723
+ * In bash, `(cmd1; cmd2)` runs in a subshell — its `cd`/var/env
724
+ * changes do NOT affect the parent. We approximate by:
725
+ * 1. Strip the outer `(...)` parens.
726
+ * 2. Save cwd + env before running.
727
+ * 3. Run the inner sequence.
728
+ * 4. Restore cwd + env.
729
+ *
730
+ * Approximation gap: pipe-to-group `(a; b) | c` we currently
731
+ * concatenate the group into a `{ a; b ; }` brace-group inline and
732
+ * let lifo-sh handle the pipe — but lifo-sh also doesn't support
733
+ * brace-groups. So we rewrite `(a; b) | c` to `a; b | c` which is
734
+ * NOT the same semantically (only b's output pipes). Pipe-after-
735
+ * group is documented as a remaining gap.
736
+ *
737
+ * Bare subshells `(a; b)` are fully supported via the cwd/env
738
+ * save-restore pattern.
739
+ */
740
+ export class SubshellNormalizer {
741
+ shell;
742
+ constructor(shell) {
743
+ this.shell = shell;
744
+ }
745
+ static install(shell) {
746
+ const norm = new SubshellNormalizer(shell);
747
+ norm._patch();
748
+ return norm;
749
+ }
750
+ _patch() {
751
+ const orig = this.shell.executeLine.bind(this.shell);
752
+ const shell = this.shell;
753
+ this.shell.executeLine = async (line) => {
754
+ // Quick reject: no parens, no scan.
755
+ if (line.indexOf('(') < 0)
756
+ return orig(line);
757
+ // Find a top-level `(...)` group. Quote-aware: skip parens inside
758
+ // strings.
759
+ const groups = SubshellNormalizer.findTopLevelGroups(line);
760
+ if (groups.length === 0)
761
+ return orig(line);
762
+ // Conservative: only handle the BARE-group case (line is exactly
763
+ // `(...)` possibly with leading/trailing whitespace). Mixed
764
+ // `(...) | cmd`, `cmd && (...)` etc fall through to orig
765
+ // (which will still throw, but we don't make it worse).
766
+ const trimmed = line.trim();
767
+ if (groups.length === 1 && groups[0].start === line.indexOf('(') &&
768
+ groups[0].end === line.lastIndexOf(')') &&
769
+ trimmed.startsWith('(') && trimmed.endsWith(')')) {
770
+ const inner = line.slice(groups[0].start + 1, groups[0].end);
771
+ // Save shell state.
772
+ const savedCwd = typeof shell.getCwd === 'function' ? shell.getCwd() : shell.cwd;
773
+ const savedEnv = { ...(shell.env || {}) };
774
+ try {
775
+ await orig(inner);
776
+ }
777
+ finally {
778
+ if (typeof shell.setCwd === 'function')
779
+ shell.setCwd(savedCwd);
780
+ else if ('cwd' in shell)
781
+ shell.cwd = savedCwd;
782
+ // Restore env: replace contents in-place so any held refs see new state.
783
+ if (shell.env) {
784
+ for (const k of Object.keys(shell.env))
785
+ delete shell.env[k];
786
+ Object.assign(shell.env, savedEnv);
787
+ }
788
+ }
789
+ return;
790
+ }
791
+ // Has parens but in chain/pipe context — pass through unchanged.
792
+ // lifo-sh's parser will error, surfacing a clear message; we
793
+ // didn't make it worse.
794
+ return orig(line);
795
+ };
796
+ }
797
+ /**
798
+ * Find top-level `(...)` groups. Quote-aware: skip parens inside
799
+ * 'single' or "double" or `backtick` strings. Returns array of
800
+ * `{start, end}` indices (start = opening paren, end = closing).
801
+ */
802
+ static findTopLevelGroups(line) {
803
+ const groups = [];
804
+ let depth = 0;
805
+ let groupStart = -1;
806
+ let i = 0;
807
+ while (i < line.length) {
808
+ const ch = line[i];
809
+ if (ch === "'" || ch === '"' || ch === '`') {
810
+ const quote = ch;
811
+ i++;
812
+ while (i < line.length && line[i] !== quote) {
813
+ if (line[i] === '\\')
814
+ i++;
815
+ i++;
816
+ }
817
+ i++;
818
+ continue;
819
+ }
820
+ if (ch === '\\' && i + 1 < line.length) {
821
+ i += 2;
822
+ continue;
823
+ }
824
+ if (ch === '(') {
825
+ if (depth === 0)
826
+ groupStart = i;
827
+ depth++;
828
+ }
829
+ else if (ch === ')') {
830
+ depth--;
831
+ if (depth === 0 && groupStart >= 0) {
832
+ groups.push({ start: groupStart, end: i });
833
+ groupStart = -1;
834
+ }
835
+ }
836
+ i++;
837
+ }
838
+ return groups;
839
+ }
840
+ }
841
+ // ── Brace expansion ─────────────────────────────────────────────────────
842
+ /**
843
+ * BUG-SWEEP-R3-3 (2026-05-11): @lifo-sh/core doesn't expand
844
+ * `{a,b,c}` brace expressions. Pre-fix `ls /x/*.{js,ts}` returned
845
+ * nothing because the literal `*.{js,ts}` doesn't match a glob shape.
846
+ *
847
+ * Bash semantics: brace expansion runs BEFORE filename expansion.
848
+ * echo a{1,2,3}b → a1b a2b a3b
849
+ * ls *.{js,ts} → expanded to two glob patterns, both fed to ls
850
+ *
851
+ * We implement the comma-list form (bash {1..5} sequence form is a
852
+ * separate feature; we skip it here as lower-impact).
853
+ */
854
+ export class BraceExpander {
855
+ shell;
856
+ constructor(shell) {
857
+ this.shell = shell;
858
+ }
859
+ static install(shell) {
860
+ const norm = new BraceExpander(shell);
861
+ norm._patch();
862
+ return norm;
863
+ }
864
+ _patch() {
865
+ const orig = this.shell.executeLine.bind(this.shell);
866
+ this.shell.executeLine = async (line) => {
867
+ if (line.indexOf('{') < 0)
868
+ return orig(line);
869
+ // Only expand if `{...,...}` shape present (avoid messing with
870
+ // `${VAR}` parameter expansion — different syntax).
871
+ const expanded = BraceExpander.expandBraces(line);
872
+ return orig(expanded);
873
+ };
874
+ }
875
+ /**
876
+ * Expand bash-style brace lists in a shell line. Quote-aware:
877
+ * literals inside single quotes are preserved verbatim. `${var}`
878
+ * parameter expansions are skipped (they don't contain commas
879
+ * at the top level of the braces).
880
+ *
881
+ * Strategy: tokenize on whitespace (preserving quoted tokens),
882
+ * expand each token, re-join with spaces.
883
+ */
884
+ static expandBraces(line) {
885
+ const tokens = tokenizeRespectingQuotes(line);
886
+ const out = [];
887
+ for (const tok of tokens) {
888
+ const expanded = expandTokenBraces(tok);
889
+ out.push(...expanded);
890
+ }
891
+ return out.join(' ');
892
+ }
893
+ }
894
+ /** Split a line into tokens, preserving quoted runs as single tokens. */
895
+ function tokenizeRespectingQuotes(line) {
896
+ const tokens = [];
897
+ let cur = '';
898
+ let i = 0;
899
+ while (i < line.length) {
900
+ const ch = line[i];
901
+ if (/\s/.test(ch)) {
902
+ if (cur) {
903
+ tokens.push(cur);
904
+ cur = '';
905
+ }
906
+ // Preserve the whitespace as a separator-token so we can re-join
907
+ // with the original spacing.
908
+ let ws = '';
909
+ while (i < line.length && /\s/.test(line[i])) {
910
+ ws += line[i];
911
+ i++;
912
+ }
913
+ tokens.push(ws);
914
+ continue;
915
+ }
916
+ if (ch === "'" || ch === '"' || ch === '`') {
917
+ const quote = ch;
918
+ cur += ch;
919
+ i++;
920
+ while (i < line.length && line[i] !== quote) {
921
+ if (line[i] === '\\') {
922
+ cur += line[i];
923
+ i++;
924
+ if (i < line.length) {
925
+ cur += line[i];
926
+ i++;
927
+ }
928
+ continue;
929
+ }
930
+ cur += line[i];
931
+ i++;
932
+ }
933
+ if (i < line.length) {
934
+ cur += line[i];
935
+ i++;
936
+ }
937
+ continue;
938
+ }
939
+ cur += ch;
940
+ i++;
941
+ }
942
+ if (cur)
943
+ tokens.push(cur);
944
+ return tokens;
945
+ }
946
+ /**
947
+ * Expand a single token's braces into N tokens.
948
+ * "a{1,2}b" → ["a1b", "a2b"]
949
+ * "a{1,2}b{x,y}" → ["a1bx", "a1by", "a2bx", "a2by"]
950
+ *
951
+ * `${...}` (parameter expansion) is preserved as-is.
952
+ */
953
+ function expandTokenBraces(tok) {
954
+ // Whitespace-only token: keep verbatim.
955
+ if (/^\s+$/.test(tok))
956
+ return [tok];
957
+ // Find the first unescaped `{` that has a matching `}` and at least
958
+ // one comma at the same nesting level. Skip `${...}`.
959
+ let i = 0;
960
+ while (i < tok.length) {
961
+ if (tok[i] === '\\' && i + 1 < tok.length) {
962
+ i += 2;
963
+ continue;
964
+ }
965
+ if (tok[i] === "'" || tok[i] === '"' || tok[i] === '`') {
966
+ const q = tok[i];
967
+ i++;
968
+ while (i < tok.length && tok[i] !== q) {
969
+ if (tok[i] === '\\')
970
+ i++;
971
+ i++;
972
+ }
973
+ i++;
974
+ continue;
975
+ }
976
+ if (tok[i] === '$' && tok[i + 1] === '{') {
977
+ // Parameter expansion ${...}: skip past matching }.
978
+ let depth = 0;
979
+ while (i < tok.length) {
980
+ if (tok[i] === '{')
981
+ depth++;
982
+ else if (tok[i] === '}') {
983
+ depth--;
984
+ if (depth === 0) {
985
+ i++;
986
+ break;
987
+ }
988
+ }
989
+ i++;
990
+ }
991
+ continue;
992
+ }
993
+ if (tok[i] === '{') {
994
+ // Find matching close + check for comma at top level.
995
+ const start = i;
996
+ let depth = 0;
997
+ let hasTopLevelComma = false;
998
+ let close = -1;
999
+ for (let j = i; j < tok.length; j++) {
1000
+ const c = tok[j];
1001
+ if (c === '{')
1002
+ depth++;
1003
+ else if (c === '}') {
1004
+ depth--;
1005
+ if (depth === 0) {
1006
+ close = j;
1007
+ break;
1008
+ }
1009
+ }
1010
+ else if (c === ',' && depth === 1)
1011
+ hasTopLevelComma = true;
1012
+ }
1013
+ if (close > 0 && hasTopLevelComma) {
1014
+ const prefix = tok.slice(0, start);
1015
+ const inner = tok.slice(start + 1, close);
1016
+ const suffix = tok.slice(close + 1);
1017
+ // Split inner on top-level commas.
1018
+ const parts = [];
1019
+ let depth2 = 0;
1020
+ let cur = '';
1021
+ for (let j = 0; j < inner.length; j++) {
1022
+ const c = inner[j];
1023
+ if (c === '{')
1024
+ depth2++;
1025
+ else if (c === '}')
1026
+ depth2--;
1027
+ else if (c === ',' && depth2 === 0) {
1028
+ parts.push(cur);
1029
+ cur = '';
1030
+ continue;
1031
+ }
1032
+ cur += c;
1033
+ }
1034
+ parts.push(cur);
1035
+ // Recursively expand each combination.
1036
+ const result = [];
1037
+ for (const p of parts) {
1038
+ for (const sub of expandTokenBraces(prefix + p + suffix)) {
1039
+ result.push(sub);
1040
+ }
1041
+ }
1042
+ return result;
1043
+ }
1044
+ }
1045
+ i++;
1046
+ }
1047
+ return [tok];
1048
+ }
1049
+ // ── Variable shim: $$, $0 ──────────────────────────────────────────────
1050
+ /**
1051
+ * BUG-SWEEP-R3-4 (2026-05-11): @lifo-sh/core doesn't expand `$$`
1052
+ * (PID) or `$0` (shell name). Pre-fix `echo $$` printed literal `$$`
1053
+ * and `echo $0` printed empty. Common in shell scripts (PID for
1054
+ * lockfiles, $0 for script-name-aware behaviour).
1055
+ *
1056
+ * Fix: rewrite `$$` → a stable per-session PID (we use the supervisor
1057
+ * DO's spawn-counter so it's deterministic per session) and `$0` →
1058
+ * the shell name (always `nimbus-sh`).
1059
+ *
1060
+ * NOT addressed: `$1`..`$9` positional params (only meaningful inside
1061
+ * scripts/functions which lifo-sh handles already), `$#`, `$?` (real
1062
+ * `$?` works through lifo-sh's lastExitCode env), `$@`, `$*`.
1063
+ */
1064
+ export class DollarVarShim {
1065
+ shell;
1066
+ pid;
1067
+ constructor(shell) {
1068
+ this.shell = shell;
1069
+ // Stable per-session PID derived from session-id hash + monotonic
1070
+ // counter. We use the WS Terminal's session if available, else
1071
+ // fall back to a random integer in [1, 99999].
1072
+ this.pid = Math.floor(Math.random() * 99000) + 1000;
1073
+ }
1074
+ static install(shell) {
1075
+ const norm = new DollarVarShim(shell);
1076
+ norm._patch();
1077
+ return norm;
1078
+ }
1079
+ _patch() {
1080
+ const orig = this.shell.executeLine.bind(this.shell);
1081
+ const self = this;
1082
+ this.shell.executeLine = async (line) => {
1083
+ if (line.indexOf('$') < 0)
1084
+ return orig(line);
1085
+ const rewritten = DollarVarShim.expandDollarVars(line, self.pid);
1086
+ return orig(rewritten);
1087
+ };
1088
+ }
1089
+ /**
1090
+ * Expand `$$` and `$0` in a shell line. Quote-aware: literals
1091
+ * inside single quotes preserved. Double-quoted strings: variables
1092
+ * DO expand (bash semantics).
1093
+ */
1094
+ static expandDollarVars(line, pid) {
1095
+ let out = '';
1096
+ let i = 0;
1097
+ while (i < line.length) {
1098
+ const ch = line[i];
1099
+ if (ch === "'") {
1100
+ // Single-quoted: preserve verbatim.
1101
+ const j = line.indexOf("'", i + 1);
1102
+ if (j < 0) {
1103
+ out += line.slice(i);
1104
+ break;
1105
+ }
1106
+ out += line.slice(i, j + 1);
1107
+ i = j + 1;
1108
+ continue;
1109
+ }
1110
+ if (ch === '\\' && i + 1 < line.length) {
1111
+ out += line.slice(i, i + 2);
1112
+ i += 2;
1113
+ continue;
1114
+ }
1115
+ if (ch === '$') {
1116
+ const next = line[i + 1];
1117
+ if (next === '$') {
1118
+ out += String(pid);
1119
+ i += 2;
1120
+ continue;
1121
+ }
1122
+ if (next === '0') {
1123
+ out += 'nimbus-sh';
1124
+ i += 2;
1125
+ continue;
1126
+ }
1127
+ }
1128
+ out += ch;
1129
+ i++;
1130
+ }
1131
+ return out;
1132
+ }
1133
+ }
1134
+ // ── Heredoc variable expansion ─────────────────────────────────────────
1135
+ /**
1136
+ * BUG-SWEEP-R3-5 (2026-05-11): lifo-sh's HeredocHandler accumulates
1137
+ * lines verbatim — `$X` references inside an unquoted-delimiter
1138
+ * heredoc don't expand. Bash semantics:
1139
+ * cat <<EOF → variables expand
1140
+ * cat <<'EOF' → no expansion (single-quoted delim)
1141
+ *
1142
+ * Our HeredocHandler installation already lives in features.ts but
1143
+ * doesn't expand vars. We patch the accumulated content at submit
1144
+ * time: substitute `$NAME` / `${NAME}` with env values when the
1145
+ * delimiter is NOT single-quoted.
1146
+ *
1147
+ * The patch wraps shell.execute()'s string-content stage. We can't
1148
+ * easily intercept the heredoc submit from outside the handler, so
1149
+ * we attach a post-build hook that consults shell.env.
1150
+ *
1151
+ * Decision: rather than patching HeredocHandler (a class we own —
1152
+ * src/shell/features.ts:HeredocHandler), expose a public
1153
+ * `expandHeredocVars(content, env)` and call it from inside
1154
+ * HeredocHandler._accumulateLine / submit logic.
1155
+ */
1156
+ export function expandHeredocVars(content, env) {
1157
+ let out = '';
1158
+ let i = 0;
1159
+ while (i < content.length) {
1160
+ const ch = content[i];
1161
+ if (ch === '\\' && i + 1 < content.length && content[i + 1] === '$') {
1162
+ out += '$';
1163
+ i += 2;
1164
+ continue;
1165
+ }
1166
+ if (ch === '$') {
1167
+ // ${NAME} form
1168
+ if (content[i + 1] === '{') {
1169
+ const close = content.indexOf('}', i + 2);
1170
+ if (close > 0) {
1171
+ const name = content.slice(i + 2, close);
1172
+ out += env[name] !== undefined ? env[name] : '';
1173
+ i = close + 1;
1174
+ continue;
1175
+ }
1176
+ }
1177
+ // $NAME form (alnum/_, no leading digit)
1178
+ const m = content.slice(i + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
1179
+ if (m) {
1180
+ out += env[m[0]] !== undefined ? env[m[0]] : '';
1181
+ i += 1 + m[0].length;
1182
+ continue;
1183
+ }
1184
+ }
1185
+ out += ch;
1186
+ i++;
1187
+ }
1188
+ return out;
1189
+ }
1190
+ // ── Backtick command substitution ─────────────────────────────────────
1191
+ /**
1192
+ * BUG-SWEEP-R4-4 (2026-05-11): backtick `` `cmd` `` command
1193
+ * substitution. Pre-fix `echo \`date +%Y\`` printed literal
1194
+ * '`date +%Y`'. `$(cmd)` form worked; backtick form didn't.
1195
+ *
1196
+ * bash supports both. Many real-world scripts use backticks.
1197
+ *
1198
+ * Approach: rewrite top-level backtick spans into `$(...)` form
1199
+ * before lifo-sh's parser sees the line. Quote-aware:
1200
+ * - inside single quotes, backticks are literal (preserve)
1201
+ * - inside double quotes, backticks DO expand (rewrite)
1202
+ * - inside backticks, escapes \\\` and \\\$ should be honored (we
1203
+ * handle the simple case; nested backticks aren't supported)
1204
+ */
1205
+ export class BacktickNormalizer {
1206
+ shell;
1207
+ constructor(shell) {
1208
+ this.shell = shell;
1209
+ }
1210
+ static install(shell) {
1211
+ const norm = new BacktickNormalizer(shell);
1212
+ norm._patch();
1213
+ return norm;
1214
+ }
1215
+ _patch() {
1216
+ const orig = this.shell.executeLine.bind(this.shell);
1217
+ this.shell.executeLine = async (line) => {
1218
+ if (line.indexOf('`') < 0)
1219
+ return orig(line);
1220
+ const rewritten = BacktickNormalizer.rewrite(line);
1221
+ return orig(rewritten);
1222
+ };
1223
+ }
1224
+ /**
1225
+ * Rewrite `cmd` → $(cmd). Quote-aware: single-quotes preserve
1226
+ * literal backticks; double-quotes pass through and rewrite inside.
1227
+ */
1228
+ static rewrite(line) {
1229
+ let out = '';
1230
+ let i = 0;
1231
+ while (i < line.length) {
1232
+ const ch = line[i];
1233
+ if (ch === "'") {
1234
+ // Single-quoted: preserve verbatim (no backtick expansion).
1235
+ const j = line.indexOf("'", i + 1);
1236
+ if (j < 0) {
1237
+ out += line.slice(i);
1238
+ break;
1239
+ }
1240
+ out += line.slice(i, j + 1);
1241
+ i = j + 1;
1242
+ continue;
1243
+ }
1244
+ if (ch === '\\' && i + 1 < line.length) {
1245
+ out += line.slice(i, i + 2);
1246
+ i += 2;
1247
+ continue;
1248
+ }
1249
+ if (ch === '`') {
1250
+ // Find matching close backtick. Honor backslash escapes.
1251
+ let j = i + 1;
1252
+ let inner = '';
1253
+ while (j < line.length && line[j] !== '`') {
1254
+ if (line[j] === '\\' && j + 1 < line.length) {
1255
+ // Inside backticks: \\\` and \\\$ unescape; other escapes pass through.
1256
+ const next = line[j + 1];
1257
+ if (next === '`' || next === '$' || next === '\\') {
1258
+ inner += next;
1259
+ j += 2;
1260
+ continue;
1261
+ }
1262
+ }
1263
+ inner += line[j];
1264
+ j++;
1265
+ }
1266
+ if (j >= line.length) {
1267
+ // Unmatched backtick — leave as literal.
1268
+ out += line.slice(i);
1269
+ break;
1270
+ }
1271
+ out += '$(' + inner + ')';
1272
+ i = j + 1;
1273
+ continue;
1274
+ }
1275
+ out += ch;
1276
+ i++;
1277
+ }
1278
+ return out;
1279
+ }
1280
+ }
1281
+ // ── Line-editor extension (readline parity) ─────────────────────────────
1282
+ /**
1283
+ * LineEditorExtender — adds bash-readline-parity keybindings to the
1284
+ * @lifo-sh/core Shell line editor.
1285
+ *
1286
+ * The vanilla Shell handles only a minimal set: ←/→/↑/↓, Home, End,
1287
+ * Ctrl+A, Ctrl+E, Ctrl+C, Ctrl+D (EOF), Ctrl+U (whole-line wipe), Tab,
1288
+ * Enter, Backspace, Delete. Every other readline binding (Ctrl+W,
1289
+ * Ctrl+K, Ctrl+Y, Alt+B, Alt+F, Alt+D, Ctrl+←, Alt+. , Ctrl+R, etc.)
1290
+ * gets dropped on the floor.
1291
+ *
1292
+ * This handler wraps `shell.handleInput` to recognise the missing
1293
+ * sequences and apply their readline-spec semantics directly to
1294
+ * `shell.lineBuffer` / `shell.cursorPos` / `shell.history`, then
1295
+ * triggers `shell.redrawLine()` to re-render.
1296
+ *
1297
+ * Install AFTER HeredocHandler so the heredoc wrapper sees input
1298
+ * first (its mode is exclusive); when heredoc isn't active our
1299
+ * handler runs.
1300
+ *
1301
+ * Word boundary conventions (matches bash 5.x readline):
1302
+ * - Ctrl+W (unix-word-rubout): whitespace boundary — bash override
1303
+ * - Alt+Backspace (backward-kill-word): readline word `[A-Za-z0-9_]+`
1304
+ * - Alt+B / Alt+F (backward/forward-word): readline word
1305
+ * - Ctrl+← / Ctrl+→ (alias for word nav): readline word
1306
+ * - Alt+D (kill-word): readline word
1307
+ *
1308
+ * Kill-ring: simple single-slot (the most-recent cut). Ctrl+Y yanks it.
1309
+ *
1310
+ * Ctrl+R sub-mode: enters reverse-i-search; capturing chars narrows
1311
+ * the match against history; Enter runs the matched line; Esc/Ctrl+G
1312
+ * aborts; another Ctrl+R steps to the next-older match.
1313
+ */
1314
+ export class LineEditorExtender {
1315
+ shell;
1316
+ terminal;
1317
+ originalHandleInput = null;
1318
+ /** Single-slot kill-ring. Set by Ctrl+W / Ctrl+K / Ctrl+U / Alt+Backspace / Alt+D. */
1319
+ killRing = '';
1320
+ /** Yank-last-arg state — index into `history` (from the end), most
1321
+ * recent = 0. Reset when the user types anything other than Alt+. */
1322
+ yankLastArgIndex = -1;
1323
+ /** Length of the inserted last-arg blob from the previous Alt+. press
1324
+ * so a follow-up Alt+. can remove it and insert the older one. */
1325
+ yankLastArgInsertedLen = 0;
1326
+ /** Reverse-i-search sub-mode state. */
1327
+ rsearchActive = false;
1328
+ rsearchQuery = '';
1329
+ /** History index (from end) of the currently-displayed match;
1330
+ * -1 = no match, otherwise points at `history[history.length-1-idx]`. */
1331
+ rsearchMatchIndex = -1;
1332
+ /** Match readline's word definition: ASCII alphanumerics + underscore. */
1333
+ static isWordChar(ch) {
1334
+ if (!ch)
1335
+ return false;
1336
+ const c = ch.charCodeAt(0);
1337
+ return ((c >= 0x30 && c <= 0x39) || // 0-9
1338
+ (c >= 0x41 && c <= 0x5a) || // A-Z
1339
+ (c >= 0x61 && c <= 0x7a) || // a-z
1340
+ c === 0x5f // _
1341
+ );
1342
+ }
1343
+ constructor(shell, terminal) {
1344
+ this.shell = shell;
1345
+ this.terminal = terminal;
1346
+ }
1347
+ static install(shell, terminal) {
1348
+ const handler = new LineEditorExtender(shell, terminal);
1349
+ handler._patch();
1350
+ return handler;
1351
+ }
1352
+ _patch() {
1353
+ this.originalHandleInput = this.shell.handleInput.bind(this.shell);
1354
+ this.shell.handleInput = (data) => {
1355
+ // Skip when a process is running — the line editor only governs
1356
+ // shell-prompt input. Process-stdin input has its own model
1357
+ // (terminalStdin / stdinLineBuffer) handled in @lifo-sh/core.
1358
+ if (this.shell.running) {
1359
+ return this.originalHandleInput(data);
1360
+ }
1361
+ // Reverse-i-search sub-mode owns ALL input until exited.
1362
+ if (this.rsearchActive) {
1363
+ this._rsearchHandle(data);
1364
+ return;
1365
+ }
1366
+ // Yank-last-arg state is reset on any non-Alt+. input.
1367
+ if (data !== '\x1b.') {
1368
+ this.yankLastArgIndex = -1;
1369
+ this.yankLastArgInsertedLen = 0;
1370
+ }
1371
+ // Try each binding. First-match wins; on no match fall through.
1372
+ if (this._handleBinding(data))
1373
+ return;
1374
+ return this.originalHandleInput(data);
1375
+ };
1376
+ }
1377
+ /**
1378
+ * Returns true if the input was a recognised readline binding and we
1379
+ * handled it; false to fall through to the original handler.
1380
+ */
1381
+ _handleBinding(data) {
1382
+ const s = this.shell;
1383
+ switch (data) {
1384
+ // ── char-by-char navigation (readline Ctrl aliases) ──
1385
+ case '\x02': // Ctrl+B → left
1386
+ if (s.cursorPos > 0) {
1387
+ s.cursorPos--;
1388
+ s.redrawLine();
1389
+ }
1390
+ return true;
1391
+ case '\x06': // Ctrl+F → right
1392
+ if (s.cursorPos < s.lineBuffer.length) {
1393
+ s.cursorPos++;
1394
+ s.redrawLine();
1395
+ }
1396
+ return true;
1397
+ // ── Home/End (Linux variants the upstream Shell misses) ──
1398
+ case '\x1b[1~': // Home (vt220 / Linux)
1399
+ case '\x1b[7~': // Home (rxvt)
1400
+ s.cursorPos = 0;
1401
+ s.redrawLine();
1402
+ return true;
1403
+ case '\x1b[4~': // End (vt220 / Linux)
1404
+ case '\x1b[8~': // End (rxvt)
1405
+ s.cursorPos = s.lineBuffer.length;
1406
+ s.redrawLine();
1407
+ return true;
1408
+ // ── word-by-word navigation ──
1409
+ case '\x1b[1;5D': // Ctrl+← (xterm modifier-5)
1410
+ case '\x1b[1;3D': // Alt+← (xterm modifier-3, Mac Option+←)
1411
+ case '\x1bb': // Alt+B / Esc-b
1412
+ case '\x1bB':
1413
+ s.cursorPos = this._prevWordStart(s.lineBuffer, s.cursorPos);
1414
+ s.redrawLine();
1415
+ return true;
1416
+ case '\x1b[1;5C': // Ctrl+→
1417
+ case '\x1b[1;3C': // Alt+→
1418
+ case '\x1bf': // Alt+F / Esc-f
1419
+ case '\x1bF':
1420
+ s.cursorPos = this._nextWordEnd(s.lineBuffer, s.cursorPos);
1421
+ s.redrawLine();
1422
+ return true;
1423
+ // ── history aliases ──
1424
+ case '\x10': // Ctrl+P → ↑
1425
+ // Delegate to the existing handler for ↑ which already manages
1426
+ // historyIndex/savedLine bookkeeping.
1427
+ return this.originalHandleInput('\x1b[A'), true;
1428
+ case '\x0e': // Ctrl+N → ↓
1429
+ return this.originalHandleInput('\x1b[B'), true;
1430
+ // ── kill-to-end (Ctrl+K) ──
1431
+ case '\x0b':
1432
+ if (s.cursorPos < s.lineBuffer.length) {
1433
+ this.killRing = s.lineBuffer.slice(s.cursorPos);
1434
+ s.lineBuffer = s.lineBuffer.slice(0, s.cursorPos);
1435
+ s.redrawLine();
1436
+ }
1437
+ return true;
1438
+ // ── kill-to-start (Ctrl+U) — override the upstream whole-line wipe ──
1439
+ case '\x15':
1440
+ if (s.cursorPos > 0) {
1441
+ this.killRing = s.lineBuffer.slice(0, s.cursorPos);
1442
+ s.lineBuffer = s.lineBuffer.slice(s.cursorPos);
1443
+ s.cursorPos = 0;
1444
+ s.redrawLine();
1445
+ }
1446
+ // cursorPos === 0 + non-empty line: bash's Ctrl+U here also kills
1447
+ // the whole line (cursor was already at start, "to start" = 0).
1448
+ // Falling through to the upstream whole-line wipe is wrong for
1449
+ // mid-line case but correct for the start case. Since we
1450
+ // returned true above only when something was cut, the
1451
+ // start-of-empty case falls through to upstream which is fine
1452
+ // (whole line is already empty). For non-empty-at-start, we
1453
+ // also return true to avoid double-redraw.
1454
+ return true;
1455
+ // ── kill-word-back (Ctrl+W) — bash uses WHITESPACE boundary ──
1456
+ case '\x17': {
1457
+ if (s.cursorPos === 0)
1458
+ return true;
1459
+ const buf = s.lineBuffer;
1460
+ let i = s.cursorPos;
1461
+ // Skip trailing whitespace BEFORE the cursor.
1462
+ while (i > 0 && /\s/.test(buf[i - 1]))
1463
+ i--;
1464
+ // Then skip non-whitespace (the "word").
1465
+ while (i > 0 && !/\s/.test(buf[i - 1]))
1466
+ i--;
1467
+ this.killRing = buf.slice(i, s.cursorPos);
1468
+ s.lineBuffer = buf.slice(0, i) + buf.slice(s.cursorPos);
1469
+ s.cursorPos = i;
1470
+ s.redrawLine();
1471
+ return true;
1472
+ }
1473
+ // ── kill-word-back (Alt+Backspace) — readline word boundary ──
1474
+ case '\x1b\x7f':
1475
+ case '\x1b\x08': {
1476
+ if (s.cursorPos === 0)
1477
+ return true;
1478
+ const buf = s.lineBuffer;
1479
+ const newPos = this._prevWordStart(buf, s.cursorPos);
1480
+ this.killRing = buf.slice(newPos, s.cursorPos);
1481
+ s.lineBuffer = buf.slice(0, newPos) + buf.slice(s.cursorPos);
1482
+ s.cursorPos = newPos;
1483
+ s.redrawLine();
1484
+ return true;
1485
+ }
1486
+ // ── kill-word-forward (Alt+D) — readline word boundary ──
1487
+ case '\x1bd':
1488
+ case '\x1bD': {
1489
+ const buf = s.lineBuffer;
1490
+ if (s.cursorPos >= buf.length)
1491
+ return true;
1492
+ const newPos = this._nextWordEnd(buf, s.cursorPos);
1493
+ this.killRing = buf.slice(s.cursorPos, newPos);
1494
+ s.lineBuffer = buf.slice(0, s.cursorPos) + buf.slice(newPos);
1495
+ // cursor stays at s.cursorPos (chars removed AFTER cursor)
1496
+ s.redrawLine();
1497
+ return true;
1498
+ }
1499
+ // ── yank (Ctrl+Y) ──
1500
+ case '\x19': {
1501
+ if (!this.killRing)
1502
+ return true;
1503
+ const buf = s.lineBuffer;
1504
+ s.lineBuffer = buf.slice(0, s.cursorPos) + this.killRing + buf.slice(s.cursorPos);
1505
+ s.cursorPos += this.killRing.length;
1506
+ s.redrawLine();
1507
+ return true;
1508
+ }
1509
+ // ── transpose (Ctrl+T) ──
1510
+ case '\x14': {
1511
+ const buf = s.lineBuffer;
1512
+ if (buf.length < 2)
1513
+ return true;
1514
+ let pos = s.cursorPos;
1515
+ // bash behaviour: at end of line, swap last two chars without
1516
+ // advancing the cursor. Mid-line: swap char-before with
1517
+ // char-at, advance cursor by one.
1518
+ if (pos >= buf.length) {
1519
+ const a = buf[buf.length - 2];
1520
+ const b = buf[buf.length - 1];
1521
+ s.lineBuffer = buf.slice(0, -2) + b + a;
1522
+ }
1523
+ else if (pos === 0) {
1524
+ // bash: bell, no-op.
1525
+ return true;
1526
+ }
1527
+ else {
1528
+ const before = buf[pos - 1];
1529
+ const at = buf[pos];
1530
+ s.lineBuffer = buf.slice(0, pos - 1) + at + before + buf.slice(pos + 1);
1531
+ s.cursorPos = pos + 1;
1532
+ }
1533
+ s.redrawLine();
1534
+ return true;
1535
+ }
1536
+ // ── Ctrl+L: clear screen, redraw current line ──
1537
+ case '\x0c':
1538
+ // \x1b[H = cursor to top-left, \x1b[2J = erase screen
1539
+ this.terminal.write('\x1b[H\x1b[2J');
1540
+ // Reset the screen-row tracker so redrawLine doesn't try to
1541
+ // ANSI-up past the (now-cleared) top of screen.
1542
+ s.screenCursorRow = 0;
1543
+ s.redrawLine();
1544
+ return true;
1545
+ // ── Ctrl+\: bash sends SIGQUIT; with no fg job, readline noops.
1546
+ // We mirror: silently absorb, leave the line intact.
1547
+ case '\x1c':
1548
+ return true;
1549
+ // ── Ctrl+D mid-line: delete char at cursor (bash readline) ──
1550
+ case '\x04': {
1551
+ const buf = s.lineBuffer;
1552
+ // If line is non-empty AND cursor not at end, delete char at cursor.
1553
+ if (s.cursorPos < buf.length) {
1554
+ s.lineBuffer = buf.slice(0, s.cursorPos) + buf.slice(s.cursorPos + 1);
1555
+ s.redrawLine();
1556
+ return true;
1557
+ }
1558
+ // Otherwise fall through to the upstream handler (which treats
1559
+ // Ctrl+D on an empty line as EOF / no-op).
1560
+ return false;
1561
+ }
1562
+ // ── Alt+. — yank-last-arg ──
1563
+ case '\x1b.': {
1564
+ return this._yankLastArg();
1565
+ }
1566
+ // ── Alt+U / Alt+L / Alt+C — uppercase / lowercase / capitalize word ──
1567
+ case '\x1bu':
1568
+ case '\x1bU':
1569
+ return this._caseTransformWord('upper');
1570
+ case '\x1bl':
1571
+ case '\x1bL':
1572
+ return this._caseTransformWord('lower');
1573
+ case '\x1bc':
1574
+ case '\x1bC':
1575
+ return this._caseTransformWord('capitalize');
1576
+ // ── Ctrl+R — enter reverse-i-search ──
1577
+ case '\x12':
1578
+ this._rsearchEnter();
1579
+ return true;
1580
+ }
1581
+ return false;
1582
+ }
1583
+ // ── Word-boundary helpers ──
1584
+ /** Move from `pos` to the start of the previous readline word. */
1585
+ _prevWordStart(buf, pos) {
1586
+ let i = pos;
1587
+ // Skip non-word chars (whitespace, punctuation) backward.
1588
+ while (i > 0 && !LineEditorExtender.isWordChar(buf[i - 1]))
1589
+ i--;
1590
+ // Then skip the word backward.
1591
+ while (i > 0 && LineEditorExtender.isWordChar(buf[i - 1]))
1592
+ i--;
1593
+ return i;
1594
+ }
1595
+ /** Move from `pos` to the end of the next readline word. */
1596
+ _nextWordEnd(buf, pos) {
1597
+ let i = pos;
1598
+ // Skip non-word chars forward.
1599
+ while (i < buf.length && !LineEditorExtender.isWordChar(buf[i]))
1600
+ i++;
1601
+ // Then skip the word forward.
1602
+ while (i < buf.length && LineEditorExtender.isWordChar(buf[i]))
1603
+ i++;
1604
+ return i;
1605
+ }
1606
+ // ── Case transforms ──
1607
+ _caseTransformWord(mode) {
1608
+ const s = this.shell;
1609
+ const buf = s.lineBuffer;
1610
+ let i = s.cursorPos;
1611
+ // Skip non-word forward.
1612
+ while (i < buf.length && !LineEditorExtender.isWordChar(buf[i]))
1613
+ i++;
1614
+ const wordStart = i;
1615
+ while (i < buf.length && LineEditorExtender.isWordChar(buf[i]))
1616
+ i++;
1617
+ const wordEnd = i;
1618
+ if (wordEnd === wordStart)
1619
+ return true; // no word to transform
1620
+ const word = buf.slice(wordStart, wordEnd);
1621
+ let transformed;
1622
+ if (mode === 'upper')
1623
+ transformed = word.toUpperCase();
1624
+ else if (mode === 'lower')
1625
+ transformed = word.toLowerCase();
1626
+ else
1627
+ transformed = word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
1628
+ s.lineBuffer = buf.slice(0, wordStart) + transformed + buf.slice(wordEnd);
1629
+ s.cursorPos = wordEnd;
1630
+ s.redrawLine();
1631
+ return true;
1632
+ }
1633
+ // ── Yank-last-arg (Alt+.) ──
1634
+ _yankLastArg() {
1635
+ const s = this.shell;
1636
+ const history = s.history ?? [];
1637
+ if (history.length === 0)
1638
+ return true;
1639
+ // Advance the history index. First press → 0 (most-recent).
1640
+ // Repeat press → 1 (previous), 2, ... wrap at end.
1641
+ this.yankLastArgIndex = (this.yankLastArgIndex < 0)
1642
+ ? 0
1643
+ : Math.min(this.yankLastArgIndex + 1, history.length - 1);
1644
+ const histLine = history[history.length - 1 - this.yankLastArgIndex] ?? '';
1645
+ // bash: "last arg" = the LAST whitespace-delimited word of the
1646
+ // previous command (after expansion). We don't expand here; the
1647
+ // raw last word is what users expect.
1648
+ const m = histLine.match(/(\S+)\s*$/);
1649
+ const lastArg = m ? m[1] : '';
1650
+ const buf = s.lineBuffer;
1651
+ // Remove the previous Alt+. insertion (if any) before inserting
1652
+ // the next.
1653
+ const removedFrom = s.cursorPos - this.yankLastArgInsertedLen;
1654
+ const removedTo = s.cursorPos;
1655
+ const newBuf = buf.slice(0, removedFrom) + lastArg + buf.slice(removedTo);
1656
+ s.lineBuffer = newBuf;
1657
+ s.cursorPos = removedFrom + lastArg.length;
1658
+ this.yankLastArgInsertedLen = lastArg.length;
1659
+ s.redrawLine();
1660
+ return true;
1661
+ }
1662
+ // ── Reverse-i-search (Ctrl+R sub-mode) ──
1663
+ _rsearchEnter() {
1664
+ this.rsearchActive = true;
1665
+ this.rsearchQuery = '';
1666
+ this.rsearchMatchIndex = -1;
1667
+ this._rsearchRender();
1668
+ }
1669
+ _rsearchHandle(data) {
1670
+ const s = this.shell;
1671
+ // Esc / Ctrl+G → abort, keep the partial line buffer as it was.
1672
+ if (data === '\x1b' || data === '\x07') {
1673
+ this._rsearchExit(/*restoreLine=*/ true);
1674
+ return;
1675
+ }
1676
+ // Enter → accept the current match and execute it.
1677
+ if (data === '\r' || data === '\n') {
1678
+ const accepted = this._rsearchCurrentMatch();
1679
+ this._rsearchExit(/*restoreLine=*/ false);
1680
+ if (accepted) {
1681
+ s.lineBuffer = accepted;
1682
+ s.cursorPos = accepted.length;
1683
+ }
1684
+ s.redrawLine();
1685
+ // Defer to the original handler to execute the (now-restored)
1686
+ // line. Sending '\r' triggers the same path Enter would.
1687
+ this.originalHandleInput('\r');
1688
+ return;
1689
+ }
1690
+ // Another Ctrl+R → step to the next-older match.
1691
+ if (data === '\x12') {
1692
+ this._rsearchAdvance();
1693
+ this._rsearchRender();
1694
+ return;
1695
+ }
1696
+ // Backspace / Ctrl+H → remove last query char and re-search.
1697
+ if (data === '\x7f' || data === '\x08') {
1698
+ this.rsearchQuery = this.rsearchQuery.slice(0, -1);
1699
+ this.rsearchMatchIndex = -1; // restart search from most-recent
1700
+ this._rsearchSearch();
1701
+ this._rsearchRender();
1702
+ return;
1703
+ }
1704
+ // Any other escape sequence aborts (cursor arrows etc).
1705
+ if (data.startsWith('\x1b')) {
1706
+ // Bash: arrow keys exit search and keep the current match in
1707
+ // the buffer for further editing.
1708
+ const accepted = this._rsearchCurrentMatch();
1709
+ this._rsearchExit(/*restoreLine=*/ false);
1710
+ if (accepted) {
1711
+ s.lineBuffer = accepted;
1712
+ s.cursorPos = accepted.length;
1713
+ }
1714
+ s.redrawLine();
1715
+ return;
1716
+ }
1717
+ // Ctrl+C → cancel out of search and clear the line.
1718
+ if (data === '\x03') {
1719
+ this._rsearchExit(/*restoreLine=*/ true);
1720
+ s.lineBuffer = '';
1721
+ s.cursorPos = 0;
1722
+ this.terminal.write('^C\r\n');
1723
+ // Use the shell's prompt path.
1724
+ if (typeof s.printPrompt === 'function')
1725
+ s.printPrompt();
1726
+ return;
1727
+ }
1728
+ // Otherwise: append printable to the query and search.
1729
+ if (data >= ' ' && !data.startsWith('\x1b')) {
1730
+ this.rsearchQuery += data;
1731
+ this.rsearchMatchIndex = -1;
1732
+ this._rsearchSearch();
1733
+ this._rsearchRender();
1734
+ }
1735
+ }
1736
+ /** Step the match index forward (older) and re-search from there. */
1737
+ _rsearchAdvance() {
1738
+ const history = this.shell.history ?? [];
1739
+ let idx = this.rsearchMatchIndex < 0 ? 0 : this.rsearchMatchIndex + 1;
1740
+ for (; idx < history.length; idx++) {
1741
+ const line = history[history.length - 1 - idx];
1742
+ if (line && line.includes(this.rsearchQuery)) {
1743
+ this.rsearchMatchIndex = idx;
1744
+ return;
1745
+ }
1746
+ }
1747
+ // No further match — keep the current one.
1748
+ }
1749
+ /** Find the most-recent history line containing the query. */
1750
+ _rsearchSearch() {
1751
+ if (!this.rsearchQuery) {
1752
+ this.rsearchMatchIndex = -1;
1753
+ return;
1754
+ }
1755
+ const history = this.shell.history ?? [];
1756
+ for (let idx = 0; idx < history.length; idx++) {
1757
+ const line = history[history.length - 1 - idx];
1758
+ if (line && line.includes(this.rsearchQuery)) {
1759
+ this.rsearchMatchIndex = idx;
1760
+ return;
1761
+ }
1762
+ }
1763
+ this.rsearchMatchIndex = -1;
1764
+ }
1765
+ _rsearchCurrentMatch() {
1766
+ if (this.rsearchMatchIndex < 0)
1767
+ return null;
1768
+ const history = this.shell.history ?? [];
1769
+ return history[history.length - 1 - this.rsearchMatchIndex] ?? null;
1770
+ }
1771
+ /** Render the search-prompt line. */
1772
+ _rsearchRender() {
1773
+ const match = this._rsearchCurrentMatch();
1774
+ // Clear the current screen row + redraw the search prompt.
1775
+ this.terminal.write('\r\x1b[K');
1776
+ if (match) {
1777
+ this.terminal.write(`(reverse-i-search)\`${this.rsearchQuery}': ${match}`);
1778
+ }
1779
+ else {
1780
+ this.terminal.write(`(failed reverse-i-search)\`${this.rsearchQuery}': `);
1781
+ }
1782
+ }
1783
+ /**
1784
+ * Exit the search sub-mode. If `restoreLine` is true, the line
1785
+ * buffer is left as it was when search began (no insertion). The
1786
+ * caller is expected to call `redrawLine()` afterward if needed.
1787
+ */
1788
+ _rsearchExit(restoreLine) {
1789
+ this.rsearchActive = false;
1790
+ this.rsearchQuery = '';
1791
+ this.rsearchMatchIndex = -1;
1792
+ // Clear the search prompt line.
1793
+ this.terminal.write('\r\x1b[K');
1794
+ if (restoreLine) {
1795
+ // Caller wants the original buffer kept; nothing to do here —
1796
+ // the caller will redraw.
1797
+ }
1798
+ }
1799
+ }
1800
+ /**
1801
+ * Execute a full command line with pipes, redirects, operators, var expansion, and globs.
1802
+ * Returns the exit code of the last command.
1803
+ */
1804
+ export async function executeCommandLine(input, ctx) {
1805
+ // 1. Expand environment variables
1806
+ let expanded = expandVars(input, ctx.env);
1807
+ // 2. Split by operators (&&, ||, ;, |)
1808
+ const { segments, operators } = splitOperators(expanded);
1809
+ let lastExitCode = 0;
1810
+ let pipeStdin;
1811
+ let inPipeChain = false;
1812
+ for (let i = 0; i < segments.length; i++) {
1813
+ const segment = segments[i];
1814
+ const prevOp = i > 0 ? operators[i - 1] : '';
1815
+ const nextOp = i < operators.length ? operators[i] : '';
1816
+ // Handle && and || operators
1817
+ if (prevOp === '&&' && lastExitCode !== 0)
1818
+ continue; // skip on failure
1819
+ if (prevOp === '||' && lastExitCode === 0)
1820
+ continue; // skip on success
1821
+ // Check if we're starting/continuing a pipe chain
1822
+ if (prevOp === '|') {
1823
+ inPipeChain = true;
1824
+ // pipeStdin already set from previous command's stdout
1825
+ }
1826
+ else {
1827
+ inPipeChain = false;
1828
+ pipeStdin = undefined;
1829
+ }
1830
+ if (!segment)
1831
+ continue;
1832
+ // 3. Parse redirects
1833
+ const parsed = parseRedirects(segment);
1834
+ // 4. Tokenize the command (handles quotes)
1835
+ let tokens = tokenize(parsed.command);
1836
+ if (tokens.length === 0)
1837
+ continue;
1838
+ // 5. Expand globs
1839
+ tokens = expandGlobs(tokens, ctx.cwd, ctx.vfs);
1840
+ const cmdName = tokens[0];
1841
+ const cmdArgs = tokens.slice(1);
1842
+ // 6. Execute the command
1843
+ const result = await ctx.runCommand(cmdName, cmdArgs, pipeStdin);
1844
+ lastExitCode = result.exitCode;
1845
+ // 7. Handle redirects
1846
+ if (parsed.redirectOut) {
1847
+ const path = parsed.redirectOut === '/dev/null' ? null :
1848
+ (parsed.redirectOut.startsWith('/') ? parsed.redirectOut.replace(/^\/+/, '') :
1849
+ ctx.cwd.replace(/^\/+/, '') + '/' + parsed.redirectOut);
1850
+ if (path) {
1851
+ try {
1852
+ const parts = path.split('/');
1853
+ for (let j = 1; j < parts.length; j++) {
1854
+ const dir = parts.slice(0, j).join('/');
1855
+ if (dir && !ctx.vfs.exists(dir))
1856
+ ctx.vfs.mkdir(dir, { recursive: true });
1857
+ }
1858
+ ctx.vfs.writeFile(path, result.stdout);
1859
+ }
1860
+ catch { }
1861
+ }
1862
+ }
1863
+ else if (parsed.redirectAppend) {
1864
+ const path = parsed.redirectAppend.startsWith('/') ? parsed.redirectAppend.replace(/^\/+/, '') :
1865
+ ctx.cwd.replace(/^\/+/, '') + '/' + parsed.redirectAppend;
1866
+ try {
1867
+ const existing = ctx.vfs.exists(path) ? ctx.vfs.readFileString(path) : '';
1868
+ ctx.vfs.writeFile(path, existing + result.stdout);
1869
+ }
1870
+ catch { }
1871
+ }
1872
+ else if (nextOp === '|') {
1873
+ // Pipe: capture stdout for next command's stdin
1874
+ pipeStdin = result.stdout;
1875
+ }
1876
+ else {
1877
+ // No redirect, no pipe: write to terminal
1878
+ if (result.stdout)
1879
+ ctx.writeStdout(result.stdout);
1880
+ }
1881
+ }
1882
+ // Update $? in env
1883
+ ctx.env['?'] = String(lastExitCode);
1884
+ return lastExitCode;
1885
+ }