@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,3579 @@
1
+ /**
2
+ * session/init.ts — initSession boot + shell-command registrations.
3
+ *
4
+ * Why this is one big function and not a class:
5
+ * initSession runs once per /ws upgrade and walks the session
6
+ * through Phase R (rehydrate from SQL), Phase B (build kernel +
7
+ * shell + register commands), Phase W (attach terminal), and
8
+ * (cold-only) Phase O (MOTD + framework hint). The phases share
9
+ * lots of locals (vfs, kernel, registry, shell) and ordering
10
+ * matters strictly — there's no interesting reuse boundary that a
11
+ * class decomposition would expose.
12
+ *
13
+ * The function is intentionally written so that a reader sees:
14
+ * 1. setPhase('rehydrate') ...
15
+ * 2. setPhase('build') ... (~95% of the LOC)
16
+ * 3. setPhase('online') if (cold)
17
+ * 4. self._b4Phase = 'hydrated'
18
+ *
19
+ * Extracted from src/nimbus-session.ts during the SESSION-REFACTOR
20
+ * (audit/sections/SESSION-REFACTOR-PLAN.md §B.3.8 + S6) so the DO
21
+ * class itself stays small. Imports `self: InitHost` (a narrow view
22
+ * of SessionInternal + readonly ctx/env) which gives the compiler
23
+ * enough type info without the surface area of the full DO class.
24
+ *
25
+ * Imports and class delegators on NimbusSession preserve back-compat:
26
+ * - acceptShellWebSocket → self.initSession(ws) (S7 will extract).
27
+ * - The class still has `initSession(ws)` as a delegator method.
28
+ */
29
+ import { Kernel, Shell, createDefaultRegistry, ProcessRegistry, MemoryPersistenceBackend, createCurlCommand, createNpmCommand, createNpxCommand, createTopCommand, createWatchCommand, createHelpCommand, rehydrateGlobalPackages, } from '@lifo-sh/core';
30
+ import { SqliteVFSProvider } from '../vfs/sqlite-vfs.js';
31
+ import { DevProvider } from '../vfs/dev-provider.js';
32
+ import { WebSocketTerminal } from '../facets/ws-terminal.js';
33
+ import { EsbuildService } from '../runtime/esbuild-service.js';
34
+ import { runNodeScript } from '../runtime/node-runner.js';
35
+ import { runBunScript, BUN_VERSION } from '../runtime/bun-runner.js';
36
+ import { buildRuntimeHandler } from '../runtime/runtime-registry.js';
37
+ import { makeWasmRunner, WASM_RUNNER_VERSION, WASM_RUNNER_HELP, } from '../runtime/wasm-runner.js';
38
+ import { ViteDevServer } from '../facets/vite-dev-server.js';
39
+ import { CirrusReal, shouldUseRealVite } from '../facets/cirrus-real.js';
40
+ import { makeLongRunningPortStub, resolveLongRunningPort, expandArgvShellDefaults, } from '../runtime/long-running-handle.js';
41
+ import { acquireHeavyAlloc } from '../observability/heavy-alloc-coord.js';
42
+ import { NimbusWrangler } from '../wrangler/nimbus-wrangler.js';
43
+ import { filterWranglerFlags, detectBundlerBin, checkNodeModulesGuard, detectUnsupportedWranglerConfig, NIMBUS_UNSUPPORTED_BINS, } from './helpers.js';
44
+ import { HeredocHandler, LineEditorExtender, FdRedirectNormalizer, SubshellNormalizer, BraceExpander, DollarVarShim, BacktickNormalizer } from '../shell/features.js';
45
+ import { registerUnixCommands } from '../shell/unix-commands.js';
46
+ import { registerGitCommands } from '../git/commands.js';
47
+ import { makeNimbusVerbHandler, rehydrateInstalledRuntimes, registerRunnerFactory, } from '../runtime/package-manager.js';
48
+ import { makeClangRunnerFactory } from '../runtime/clang-runner.js';
49
+ import { makePythonRunnerFactory } from '../runtime/python-runner.js';
50
+ import { makeRubyRunnerFactory } from '../runtime/ruby-runner.js';
51
+ import { hasSeededProject, SEED_PROJECT_DIR } from '../vfs/seed-project.js';
52
+ import { notifyTerminalEvent } from '../runtime/process-logs-api.js';
53
+ import { stripAnsi } from '../runtime/process-logs.js';
54
+ import { NIMBUS_VERSION, DEFAULT_HOSTNAME, DEFAULT_MOUNT_POINTS, NODE_VERSION, } from '../constants.js';
55
+ import { enc } from '../_shared/bytes.js';
56
+ import { ensureSessionStateSchema, loadShellState, stampHydratedAt, countSessionStateKeys, loadKernelMounts, persistKernelMounts, appendScrollback, loadScrollback, } from './state-store.js';
57
+ import { recordRecoveryEvent } from '../observability/oom-discriminator.js';
58
+ import { setPhase } from './init-phases.js';
59
+ export function initSession(self, ws) {
60
+ self.ensureSqliteFs();
61
+ self.ensureFacetManager();
62
+ self.seedFilesystem();
63
+ // ── Phase R: rehydrate session state from DO SQLite [B'.1] ──────────
64
+ //
65
+ // Track B' invariant: every observable session field has a SQL-backed
66
+ // source of truth. The fresh Shell/Kernel/Terminal we build below are
67
+ // CACHES of those rows — initialised from the snapshot if a row
68
+ // exists (silent re-init), defaults otherwise (true cold start).
69
+ //
70
+ // hasPersistedState is the cold-vs-rehydrate discriminator. The
71
+ // `hydratedAt` field lets the /api/_diag/session debug endpoint
72
+ // surface "this DO instance found a row at <ts>" for forensic
73
+ // tooling.
74
+ // [B'.4] Phase R — Rehydrate. Read persisted state values from DO
75
+ // SQLite. Pure SQL reads; the actual application of these values
76
+ // (Shell ctor params, mount list, scrollback bytes) happens in
77
+ // later phases.
78
+ setPhase(self, 'rehydrate', 'init-session');
79
+ ensureSessionStateSchema(self.ctx);
80
+ const persisted = loadShellState(self.ctx);
81
+ // [B'.4] Phase W (early-wire) — construct WebSocketTerminal with
82
+ // the B'.3 scrollback tee. Marked as 'wire' here even though
83
+ // 'build' hasn't run yet because the terminal is the WS-facing
84
+ // facet and the scrollback replay below is wire-phase work.
85
+ // Phase B will tag in once we start building the kernel.
86
+ setPhase(self, 'wire', 'init-session');
87
+ self.terminal = new WebSocketTerminal(ws, (frame) => {
88
+ try {
89
+ appendScrollback(self.ctx, frame, Date.now());
90
+ }
91
+ catch (e) {
92
+ try {
93
+ console.warn('[B\'.3] appendScrollback failed:', e?.message || e);
94
+ }
95
+ catch { }
96
+ }
97
+ });
98
+ // [B'.3] Replay persisted scrollback BEFORE the cold-start UI gate.
99
+ // On rehydrate (hasPersistedState=true) we emit the prior
100
+ // session's terminal contents as a single batched write — the
101
+ // user reconnects to "where they left off" + a fresh prompt.
102
+ // On cold start (no row) loadScrollback returns '' so this is a
103
+ // no-op and the MOTD/Phase O block below runs normally.
104
+ //
105
+ // The replay itself goes through terminal.write → flush → tee,
106
+ // so the replayed bytes also re-append to scrollback. That's the
107
+ // correct semantics: a user who reconnects twice should see the
108
+ // same scrollback both times. The cap eviction keeps total bytes
109
+ // bounded.
110
+ if (persisted.hasPersistedState) {
111
+ try {
112
+ const replay = loadScrollback(self.ctx);
113
+ if (replay.length > 0)
114
+ self.terminal.write(replay);
115
+ }
116
+ catch (e) {
117
+ try {
118
+ console.warn('[B\'.3] scrollback replay failed:', e?.message || e);
119
+ }
120
+ catch { }
121
+ }
122
+ }
123
+ // [B'.4] Phase B — Build. Construct Kernel + Shell + registry +
124
+ // install all commands. CPU-intensive phase. Spans from here
125
+ // through ~line 1925 (just before Phase O).
126
+ setPhase(self, 'build', 'init-session');
127
+ // ── Boot kernel with in-memory VFS (mounts delegate to SqliteFS) ──
128
+ self.kernel = new Kernel(new MemoryPersistenceBackend());
129
+ self.kernel.initFilesystem();
130
+ // ── Mount SqliteFSProvider at all top-level directories [B'.2] ──
131
+ //
132
+ // Mount list = DEFAULT_MOUNT_POINTS ∪ persisted-mounts. The
133
+ // defaults are always present (they're platform invariants);
134
+ // any extras a future custom-mount feature might add survive
135
+ // reconnect via the nimbus_kernel_mounts table. The persist
136
+ // step at the end writes the merged list back so the table
137
+ // tracks the live mount tree.
138
+ const persistedMounts = loadKernelMounts(self.ctx);
139
+ const mountPoints = Array.from(new Set([
140
+ ...DEFAULT_MOUNT_POINTS,
141
+ ...persistedMounts,
142
+ ]));
143
+ for (const mp of mountPoints) {
144
+ const provider = new SqliteVFSProvider(self.sqliteFs, mp);
145
+ self.kernel.vfs.mount('/' + mp, provider);
146
+ }
147
+ // Persist the mount-tree. Today this writes the same
148
+ // DEFAULT_MOUNT_POINTS list every initSession (idempotent — the
149
+ // table just keeps the same 7 rows). Future custom mounts will
150
+ // flow through the same code path.
151
+ try {
152
+ persistKernelMounts(self.ctx, mountPoints);
153
+ }
154
+ catch { /* fail-soft */ }
155
+ // BUG-SWEEP-R3-1 (2026-05-11): mount a virtual /dev provider.
156
+ // Pre-fix `cmd > /dev/null` and `cmd 2>/dev/null` errored with
157
+ // `ENOENT: '/dev': no such file or directory`. This provider
158
+ // synthesizes /dev/null, /dev/zero, /dev/random, /dev/urandom,
159
+ // /dev/full, /dev/{stdin,stdout,stderr,tty}. Read/write surface
160
+ // matches MountProvider so lifo-sh's redirect machinery sees
161
+ // valid targets. Not persisted — recreated fresh each init.
162
+ try {
163
+ self.kernel.vfs.mount('/dev', new DevProvider());
164
+ }
165
+ catch (e) {
166
+ console.error('[init] /dev mount failed:', e?.message || e);
167
+ }
168
+ // ── Monkey-patch appendFile to go through mount provider ──
169
+ const vfs = self.kernel.vfs;
170
+ const originalAppendFile = vfs.appendFile.bind(vfs);
171
+ vfs.appendFile = (path, content) => {
172
+ const prov = vfs.getProvider?.(path);
173
+ if (prov) {
174
+ try {
175
+ const existing = prov.provider.readFile(prov.subpath);
176
+ const nc = typeof content === 'string' ? enc.encode(content) : content;
177
+ const combined = new Uint8Array(existing.length + nc.length);
178
+ combined.set(existing, 0);
179
+ combined.set(nc, existing.length);
180
+ prov.provider.writeFile(prov.subpath, combined);
181
+ }
182
+ catch {
183
+ prov.provider.writeFile(prov.subpath, content);
184
+ }
185
+ }
186
+ else {
187
+ originalAppendFile(path, content);
188
+ }
189
+ };
190
+ // ── Create command registry ──
191
+ const registry = createDefaultRegistry();
192
+ const kernel = self.kernel;
193
+ const sqliteFs = self.sqliteFs;
194
+ const facetMgr = self.facetManager;
195
+ // ── monaco-wave-a (2026-05-13): editor-pane fs bridge ──
196
+ //
197
+ // The terminal hosts the WS that the editor pane reuses for
198
+ // fs-read / fs-write / fs-list messages. We install the handler
199
+ // here because this is the first point in init where sqliteFs is
200
+ // in scope; the terminal stays a stable instance across warm
201
+ // rejoins (attach() swaps ws ref), so installing once is enough.
202
+ //
203
+ // Protocol (all replies are paired `<type>-result` frames):
204
+ // IN { type:'fs-read', path }
205
+ // OUT { type:'fs-read-result', path, content?, error?, binary? }
206
+ // IN { type:'fs-write', path, content }
207
+ // OUT { type:'fs-write-result', path, ok, error? }
208
+ // IN { type:'fs-list', dir, recursive? }
209
+ // OUT { type:'fs-list-result', dir, entries:[{path,type}], error? }
210
+ //
211
+ // Binary refuse: fs-read uses readFile(bytes) + fatal:true UTF-8
212
+ // decode. Throws on invalid bytes → reply { binary:true } with no
213
+ // content. The editor pane shows a friendly placeholder; this is
214
+ // the same heuristic hardening-r5 already uses for VFS<->facet
215
+ // serialization (see manager.ts _readBundleCell).
216
+ //
217
+ // Path normalization: strip leading '/'. SqliteVFS is rooted at
218
+ // the bare filename space ('home/user/...' etc.). The editor pane
219
+ // sends absolute paths (POSIX-shaped) so we normalize here.
220
+ self.terminal.onFs((msg, reply) => {
221
+ const stripSlash = (p) => (p || '').replace(/^\/+/, '');
222
+ try {
223
+ if (msg.type === 'fs-read') {
224
+ const p = stripSlash(String(msg.path || ''));
225
+ if (!sqliteFs.exists(p)) {
226
+ reply({ type: 'fs-read-result', path: msg.path, error: 'ENOENT: no such file or directory' });
227
+ return;
228
+ }
229
+ if (sqliteFs.isDirectory(p)) {
230
+ reply({ type: 'fs-read-result', path: msg.path, error: 'EISDIR: is a directory' });
231
+ return;
232
+ }
233
+ // Read bytes; attempt strict UTF-8 decode. Non-UTF-8 → mark
234
+ // binary so the editor shows a friendly placeholder rather
235
+ // than mojibake.
236
+ const bytes = sqliteFs.readFile(p);
237
+ try {
238
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
239
+ reply({ type: 'fs-read-result', path: msg.path, content });
240
+ }
241
+ catch {
242
+ reply({
243
+ type: 'fs-read-result',
244
+ path: msg.path,
245
+ binary: true,
246
+ error: 'binary file (non-UTF-8) — editor cannot display',
247
+ });
248
+ }
249
+ return;
250
+ }
251
+ if (msg.type === 'fs-write') {
252
+ const p = stripSlash(String(msg.path || ''));
253
+ if (!p) {
254
+ reply({ type: 'fs-write-result', path: msg.path, ok: false, error: 'empty path' });
255
+ return;
256
+ }
257
+ // Ensure parent exists (mkdir -p the directory chain).
258
+ const lastSlash = p.lastIndexOf('/');
259
+ if (lastSlash > 0) {
260
+ try {
261
+ sqliteFs.mkdir(p.substring(0, lastSlash), { recursive: true });
262
+ }
263
+ catch { }
264
+ }
265
+ const content = typeof msg.content === 'string' ? msg.content : String(msg.content ?? '');
266
+ sqliteFs.writeFile(p, content);
267
+ reply({ type: 'fs-write-result', path: msg.path, ok: true });
268
+ return;
269
+ }
270
+ if (msg.type === 'fs-list') {
271
+ const dir = stripSlash(String(msg.dir || ''));
272
+ const recursive = msg.recursive === true;
273
+ if (dir && !sqliteFs.exists(dir)) {
274
+ reply({ type: 'fs-list-result', dir: msg.dir, entries: [], error: 'ENOENT' });
275
+ return;
276
+ }
277
+ if (dir && !sqliteFs.isDirectory(dir)) {
278
+ reply({ type: 'fs-list-result', dir: msg.dir, entries: [], error: 'ENOTDIR' });
279
+ return;
280
+ }
281
+ // BFS walk with per-call cap so a 10k-file project doesn't
282
+ // ship a megabyte JSON frame. 2000 entries is well above
283
+ // typical project sizes (vite scaffold = ~30 files; even
284
+ // node_modules tree of a 50-dep project under 2000).
285
+ const MAX_ENTRIES = 2000;
286
+ const out = [];
287
+ const queue = [dir];
288
+ while (queue.length > 0 && out.length < MAX_ENTRIES) {
289
+ const cur = queue.shift();
290
+ let entries;
291
+ try {
292
+ entries = sqliteFs.readdir(cur);
293
+ }
294
+ catch {
295
+ continue;
296
+ }
297
+ for (const e of entries) {
298
+ if (out.length >= MAX_ENTRIES)
299
+ break;
300
+ if (e.name === 'node_modules' || e.name === '.git')
301
+ continue; // skip noisy
302
+ const child = cur ? cur + '/' + e.name : e.name;
303
+ out.push({ path: '/' + child, type: e.type });
304
+ if (recursive && e.type === 'directory')
305
+ queue.push(child);
306
+ }
307
+ }
308
+ reply({
309
+ type: 'fs-list-result',
310
+ dir: msg.dir,
311
+ entries: out,
312
+ truncated: out.length >= MAX_ENTRIES,
313
+ });
314
+ return;
315
+ }
316
+ reply({ type: msg.type + '-result', ok: false, error: 'unknown fs message type' });
317
+ }
318
+ catch (e) {
319
+ reply({
320
+ type: msg.type + '-result',
321
+ path: msg.path,
322
+ dir: msg.dir,
323
+ ok: false,
324
+ error: (e?.message || String(e)),
325
+ });
326
+ }
327
+ });
328
+ // ── True-OS Wave-3 v1.1: ./<wasm-binary> shell-side dispatch ──
329
+ // When the user invokes `./hello` (or any `./X` / `/abs/X`)
330
+ // shell-form path and the file:
331
+ // (a) exists in SqliteFS at that relative-to-cwd path, AND
332
+ // (b) starts with the wasm magic bytes `\0asm\1\0\0\0`
333
+ // we route the invocation to the wasm-runner command with the
334
+ // user's args. This is how `clang t.c -o hello && ./hello`
335
+ // closes the True-OS demo: the linker emits a wasm executable,
336
+ // and the shell knows to run it via the WASI shim.
337
+ //
338
+ // Implementation: monkey-patch registry.resolve. If the standard
339
+ // name lookup misses AND the name has a `/` (path-shaped), we
340
+ // attempt the wasm-magic check; on hit, return a synthetic
341
+ // Command that invokes wasm-runner.
342
+ const __origResolve = registry.resolve.bind(registry);
343
+ registry.resolve = async (name) => {
344
+ const found = await __origResolve(name);
345
+ if (found)
346
+ return found;
347
+ // Only `./X`, `/abs/X`, `../X` are path-shaped invocations.
348
+ // Bare-word "X" must come from the registered set.
349
+ if (!name || (!name.startsWith('./') && !name.startsWith('/') && !name.startsWith('../'))) {
350
+ return undefined;
351
+ }
352
+ // Resolve the path-shape against cwd. cwd is read at every
353
+ // resolve call (not cached) so `cd` between invocations is
354
+ // honoured. The wasm-bytes existence check is also live —
355
+ // recompile produces fresh bytes; we always re-check the
356
+ // magic on the latest VFS state.
357
+ const cwdN = ((self.shell && self.shell.getCwd?.()) || '/home/user')
358
+ .replace(/^\/+/, '').replace(/\/+$/, '');
359
+ let resolved;
360
+ if (name.startsWith('/')) {
361
+ resolved = name.replace(/^\/+/, '');
362
+ }
363
+ else {
364
+ const parts = (`${cwdN}/${name}`).split('/');
365
+ const out = [];
366
+ for (const p of parts) {
367
+ if (!p || p === '.')
368
+ continue;
369
+ if (p === '..') {
370
+ if (out.length)
371
+ out.pop();
372
+ continue;
373
+ }
374
+ out.push(p);
375
+ }
376
+ resolved = out.join('/');
377
+ }
378
+ if (!sqliteFs.exists(resolved) || sqliteFs.isDirectory(resolved)) {
379
+ return undefined;
380
+ }
381
+ // Read first 4 bytes; check for the wasm magic `\0asm`.
382
+ let bytes;
383
+ try {
384
+ bytes = sqliteFs.readFile(resolved);
385
+ }
386
+ catch {
387
+ return undefined;
388
+ }
389
+ const isWasm = bytes.length >= 4
390
+ && bytes[0] === 0x00 && bytes[1] === 0x61
391
+ && bytes[2] === 0x73 && bytes[3] === 0x6d;
392
+ if (!isWasm)
393
+ return undefined;
394
+ // Build a synthetic command that delegates to wasm-runner with
395
+ // the user's args. wasm-runner's contract: args[0] is the .wasm
396
+ // path (resolved against ctx.cwd by the runtime-handler),
397
+ // args[1..] are forwarded as WASI argv.
398
+ //
399
+ // We pass the BASENAME (not "./hello") because:
400
+ // 1. The runtime-handler joins ctx.cwd + args[0] without
401
+ // normalising "./" segments, producing "/home/user/./hello"
402
+ // which the supervisor's SqliteFS doesn't recognise.
403
+ // 2. The basename keeps the resolved path canonical
404
+ // (cwd + "hello" → "home/user/hello").
405
+ const wasmRunnerCmd = await __origResolve('wasm-runner');
406
+ if (!wasmRunnerCmd)
407
+ return undefined;
408
+ // Compute basename from the original name. For "./hello"
409
+ // → "hello"; for "/abs/path/X" → "X"; for "../foo/X" → "X".
410
+ const nameBasename = name.split('/').pop() || name;
411
+ return async (ctx) => {
412
+ const userArgs = ctx.args || [];
413
+ // The wasm-runner resolves against ctx.cwd. To preserve the
414
+ // original invocation directory (which may differ from cwd
415
+ // at resolve-time if cd happened during dispatch), pass an
416
+ // absolute path computed at resolve-time.
417
+ const absPath = '/' + resolved;
418
+ const newCtx = { ...ctx, args: [absPath, ...userArgs] };
419
+ // Discard nameBasename — kept here only because tooling may
420
+ // log argv[0]; the wasm-runner shell-handler decides the
421
+ // WASI argv[0] based on the resolved filename. See
422
+ // src/runtime/wasm-runner.ts:363 (progName).
423
+ void nameBasename;
424
+ return await wasmRunnerCmd(newCtx);
425
+ };
426
+ };
427
+ // W8: hand the registry to the cp broker so child_process.spawn from
428
+ // a parent facet can resolve and dispatch commands the same way the
429
+ // shell does. Done AFTER all registrations are complete (below).
430
+ self._setCpRegistry(registry);
431
+ // ── Unix commands (30+ real implementations) ──
432
+ registerUnixCommands(registry, sqliteFs);
433
+ // ── Git integration (isomorphic-git) ──
434
+ // ctx + env are passed for clone/fetch/pull which run in a facet to avoid
435
+ // exhausting the supervisor DO's CPU budget on large repos.
436
+ registerGitCommands(registry, sqliteFs, self.ctx, self.env);
437
+ // ── True-OS Wave-3: `nimbus install` package manager + runner registry.
438
+ //
439
+ // 1. Register the clang-runner factory FIRST so the rehydration step
440
+ // below can re-bind `clang` / `wasm-ld` from an already-installed
441
+ // manifest.
442
+ // 2. Register the `nimbus` shell verb (install/uninstall/list/available).
443
+ // 3. Rehydrate any previously-installed runtimes from VFS so their
444
+ // bins reappear in the registry after DO eviction or WS reconnect.
445
+ registerRunnerFactory('clang-runner', makeClangRunnerFactory({
446
+ facetMgr,
447
+ vfs: sqliteFs,
448
+ }));
449
+ // Pyodide v1 — Python 3.13 via the same R2-package-manager
450
+ // substrate that ships clang. Manifest entrypoints `python` and
451
+ // `python3` both bind to this factory; the runner ferries
452
+ // pyodide.asm.wasm via LOADER modules-map and pyodide.asm.js +
453
+ // python_stdlib.zip via the loader-pool context channel.
454
+ //
455
+ // REPL-W1: wrap the one-shot factory so `python` with NO args (and
456
+ // no flags that would consume args) drops into an interactive
457
+ // REPL. The wrap is purely additive — args-bearing invocations
458
+ // pass through to the existing handler unchanged.
459
+ {
460
+ const onePython = makePythonRunnerFactory({ facetMgr, vfs: sqliteFs });
461
+ const wrappedPython = (manifest, installRoot, binName, binKind) => {
462
+ const oneShotHandler = onePython(manifest, installRoot, binName, binKind);
463
+ return async function pythonReplOrOneShot(ctx) {
464
+ const argv = ctx.args || [];
465
+ // No args at all → REPL session. Hand off to runPythonRepl
466
+ // which builds its own NimbusLoaderPool (separate from the
467
+ // one-shot dispatch's pool) and drives a ReplSession.
468
+ if (argv.length === 0 && self.terminal) {
469
+ const { runPythonRepl } = await import('../runtime/python-repl.js');
470
+ return await runPythonRepl({
471
+ facetMgr,
472
+ vfs: sqliteFs,
473
+ terminal: self.terminal,
474
+ installRoot,
475
+ // REPL-R7-1: thread the shell so ReplSession can drain
476
+ // shell.pasteQueue on attach (multi-line WS frames like
477
+ // `python\nexit(7)` would otherwise drop the tail input).
478
+ shell: self.shell,
479
+ });
480
+ }
481
+ // Args present (one-shot mode: -c, script, -m, -). Fall
482
+ // through to the canonical handler.
483
+ return await oneShotHandler(ctx);
484
+ };
485
+ };
486
+ registerRunnerFactory('python-runner', wrappedPython);
487
+ }
488
+ // Ruby v1 — Ruby 3.3.4 via ruby.wasm 2.9.3-2.9.4. Same architecture
489
+ // as python-runner: ruby+stdlib.wasm rides via LOADER modules-map,
490
+ // bootstrap runs at child-facet module-init time, per-call
491
+ // __rubyRun drives rb-eval-string-protect with a wrapper that
492
+ // catches SystemExit. See src/runtime/ruby-runner.ts +
493
+ // /workspace/.seal-internal/2026-05-11-ruby-v1/audit.md.
494
+ //
495
+ // REPL Stream A: wrap the one-shot factory so `ruby` with NO args
496
+ // drops into an interactive REPL. The wrap is purely additive —
497
+ // args-bearing invocations pass through to the existing handler.
498
+ try {
499
+ const oneRuby = makeRubyRunnerFactory({ facetMgr, vfs: sqliteFs });
500
+ const wrappedRuby = (manifest, installRoot, binName, binKind) => {
501
+ const oneShotHandler = oneRuby(manifest, installRoot, binName, binKind);
502
+ return async function rubyReplOrOneShot(ctx) {
503
+ const argv = ctx.args || [];
504
+ if (argv.length === 0 && self.terminal) {
505
+ const { runRubyRepl } = await import('../runtime/ruby-repl.js');
506
+ return await runRubyRepl({
507
+ facetMgr,
508
+ vfs: sqliteFs,
509
+ terminal: self.terminal,
510
+ installRoot,
511
+ });
512
+ }
513
+ return await oneShotHandler(ctx);
514
+ };
515
+ };
516
+ registerRunnerFactory('ruby-runner', wrappedRuby);
517
+ }
518
+ catch (e) {
519
+ console.error('[init] ruby-runner registration FAILED:', e?.message || e, e?.stack || '');
520
+ }
521
+ {
522
+ // Cast registry to the minimal package-manager shape. lifo-sh's
523
+ // CommandRegistry has register(name, handler) which matches.
524
+ const pkgRegistry = registry;
525
+ const nimbusGetHome = () => {
526
+ // Shell env carries HOME; fall back to the lifo-sh convention.
527
+ const envHome = (self.shell && self.shell.env && self.shell.env.HOME)
528
+ ? self.shell.env.HOME
529
+ : '/home/user';
530
+ return envHome;
531
+ };
532
+ registry.register('nimbus', makeNimbusVerbHandler({
533
+ env: self.env,
534
+ vfs: sqliteFs,
535
+ registry: pkgRegistry,
536
+ getHome: nimbusGetHome,
537
+ }));
538
+ // Rehydration runs here even though the shell hasn't been built
539
+ // yet — that's fine: it accesses VFS + registry which are both
540
+ // already initialised. Runs O(installed-runtimes); typically 0
541
+ // on a fresh session.
542
+ try {
543
+ const rehydration = rehydrateInstalledRuntimes(sqliteFs, pkgRegistry, nimbusGetHome());
544
+ if (rehydration.count > 0) {
545
+ // Surface to terminal via the standard MOTD-style line so
546
+ // users see what's been auto-rebound. Not an error path.
547
+ self.terminal?.write?.(`\x1b[2m[nimbus] rehydrated ${rehydration.count} runtime bin(s): ${rehydration.bins.join(', ')}\x1b[0m\r\n`);
548
+ }
549
+ }
550
+ catch { /* fail-soft: rehydration must not block session boot */ }
551
+ }
552
+ // ── node command: facet-based execution ─────────────────────────────
553
+ // Parses args, reads script from VFS, delegates to FacetManager.
554
+ // The facet creates a dynamic worker where new Function() is allowed
555
+ // during module startup.
556
+ // ── node command (multi-runtime wave: refactored to use runtime-registry) ──
557
+ //
558
+ // Behaviour preserved exactly:
559
+ // - primitive #1 nodeFlagSpan (--version/-v/-h scan only flags
560
+ // before script path)
561
+ // - primitive #1 shebang strip
562
+ // - .ts/.tsx/.jsx esbuild auto-transform
563
+ // - G4 binSpawn ctx propagation (when the .bin handler set
564
+ // ctx.__nimbusBinSpawn, runFresh reuses the caller's PID
565
+ // instead of double-spawning)
566
+ // - --watch/--inspect/--inspect-brk routing via runNodeScript →
567
+ // isLongRunningInvocation
568
+ //
569
+ // The registry encodes the shared shape; per-runtime overrides
570
+ // live in the spec object.
571
+ const nodeSpec = {
572
+ name: 'node',
573
+ // CLN-1b: was stale literal 'v20.0.0'; pull from src/constants.ts
574
+ // canonical (src/runtime/node-shims.ts already does the same).
575
+ version: NODE_VERSION,
576
+ helpText: 'Usage: node [options] [script.js] [arguments]\n' +
577
+ ' node -e "code"\n\n' +
578
+ 'Options:\n' +
579
+ ' -e, --eval <code> Evaluate code\n' +
580
+ ' -v, --version Print version\n' +
581
+ ' -h, --help Print help\n' +
582
+ '\nExecution via DO Facets (isolated V8 isolate)',
583
+ run: async (fm, code, opts) => runNodeScript(fm, code, opts),
584
+ supportsBinSpawn: true,
585
+ };
586
+ {
587
+ const oneShotNode = buildRuntimeHandler(nodeSpec, {
588
+ vfs: sqliteFs,
589
+ facetMgr,
590
+ getEsbuild: () => {
591
+ if (!self.esbuildService) {
592
+ self.ensureSqliteFs();
593
+ self.esbuildService = new EsbuildService(self.sqliteFs);
594
+ }
595
+ return self.esbuildService;
596
+ },
597
+ registry,
598
+ });
599
+ // REPL Stream A: no-args invocation → drop into REPL session.
600
+ registry.register('node', async function nodeReplOrOneShot(ctx) {
601
+ const argv = ctx.args || [];
602
+ if (argv.length === 0 && self.terminal) {
603
+ const { runNodeRepl } = await import('../runtime/node-repl.js');
604
+ return await runNodeRepl({ facetMgr, terminal: self.terminal });
605
+ }
606
+ return await oneShotNode(ctx);
607
+ });
608
+ }
609
+ // ── bun command (multi-runtime wave: refactored to use runtime-registry) ──
610
+ //
611
+ // Behaviour preserved exactly:
612
+ // - --version / --help
613
+ // - install / i / add → delegate to npm
614
+ // - run <script> → look up package.json#scripts and shell.execute
615
+ // - -e / --eval flow
616
+ // - script-path flow with .ts/.tsx/.jsx auto-transform
617
+ // - BUN_SHIM_PREAMBLE prepend (handled inside runBunScript itself)
618
+ //
619
+ // Bun does NOT use binSpawn ctx propagation today (its runFresh
620
+ // chain doesn't share PID state with the .bin handler — the .bin
621
+ // handler always dispatches through `node`, not `bun`). So
622
+ // supportsBinSpawn=false (default).
623
+ const bunSpec = {
624
+ name: 'bun',
625
+ version: BUN_VERSION,
626
+ helpText: 'Usage: bun [options] [script.[js|ts|tsx]] [args...]\n' +
627
+ ' bun -e "code"\n' +
628
+ ' bun install [pkg ...]\n' +
629
+ ' bun run <script>\n\n' +
630
+ 'Bun-runtime shim provides Bun.serve/Bun.file/Bun.write/\n' +
631
+ 'Bun.spawn/Bun.password/Bun.gunzip backed by Workers-native\n' +
632
+ 'primitives. Bun.sql / Bun.S3 throw (use D1/Hyperdrive/R2).\n' +
633
+ 'Execution via DO Facets (isolated V8 isolate per call).',
634
+ run: async (fm, code, opts) => runBunScript(fm, code, opts),
635
+ subcommands: {
636
+ // bun install / i / add → npm install (same VFS, same R2 caches).
637
+ install: async (ctx, reg) => {
638
+ const npmCmd = await reg.resolve('npm');
639
+ if (npmCmd) {
640
+ return await npmCmd({ ...ctx, args: ['install', ...(ctx.args || []).slice(1)] });
641
+ }
642
+ ctx.stderr.write('bun install: npm handler unavailable\n');
643
+ return 1;
644
+ },
645
+ i: async (ctx, reg) => {
646
+ const npmCmd = await reg.resolve('npm');
647
+ if (npmCmd) {
648
+ return await npmCmd({ ...ctx, args: ['install', ...(ctx.args || []).slice(1)] });
649
+ }
650
+ ctx.stderr.write('bun i: npm handler unavailable\n');
651
+ return 1;
652
+ },
653
+ add: async (ctx, reg) => {
654
+ const npmCmd = await reg.resolve('npm');
655
+ if (npmCmd) {
656
+ return await npmCmd({ ...ctx, args: ['install', ...(ctx.args || []).slice(1)] });
657
+ }
658
+ ctx.stderr.write('bun add: npm handler unavailable\n');
659
+ return 1;
660
+ },
661
+ // bun run <script> — read package.json scripts.<name>, execute via shell.
662
+ run: async (ctx) => {
663
+ const args = ctx.args || [];
664
+ const scriptName = args[1];
665
+ if (!scriptName) {
666
+ ctx.stderr.write('bun run: missing script name\n');
667
+ return 1;
668
+ }
669
+ const pkgPath = (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/package.json';
670
+ let pkgScript;
671
+ try {
672
+ const pkg = JSON.parse(sqliteFs.readFileString(pkgPath));
673
+ pkgScript = pkg.scripts?.[scriptName];
674
+ }
675
+ catch {
676
+ ctx.stderr.write(`bun run: cannot read package.json at ${pkgPath}\n`);
677
+ return 1;
678
+ }
679
+ if (!pkgScript) {
680
+ ctx.stderr.write(`bun run: script "${scriptName}" not found in package.json\n`);
681
+ return 1;
682
+ }
683
+ try {
684
+ const shellResult = await shell.execute(pkgScript, {
685
+ cwd: ctx.cwd,
686
+ env: ctx.env,
687
+ onStdout: (d) => ctx.stdout.write(d),
688
+ onStderr: (d) => ctx.stderr.write(d),
689
+ });
690
+ return shellResult.exitCode;
691
+ }
692
+ catch (e) {
693
+ ctx.stderr.write(`bun run: ${e?.message ?? String(e)}\n`);
694
+ return 1;
695
+ }
696
+ },
697
+ },
698
+ };
699
+ {
700
+ const oneShotBun = buildRuntimeHandler(bunSpec, {
701
+ vfs: sqliteFs,
702
+ facetMgr,
703
+ getEsbuild: () => {
704
+ if (!self.esbuildService) {
705
+ self.ensureSqliteFs();
706
+ self.esbuildService = new EsbuildService(self.sqliteFs);
707
+ }
708
+ return self.esbuildService;
709
+ },
710
+ registry,
711
+ });
712
+ // REPL Stream A: no-args invocation → drop into REPL session.
713
+ registry.register('bun', async function bunReplOrOneShot(ctx) {
714
+ const argv = ctx.args || [];
715
+ if (argv.length === 0 && self.terminal) {
716
+ const { runBunRepl } = await import('../runtime/bun-repl.js');
717
+ return await runBunRepl({ facetMgr, terminal: self.terminal });
718
+ }
719
+ return await oneShotBun(ctx);
720
+ });
721
+ }
722
+ // ── wasm-runner: native WebAssembly runtime via LOADER-modules transport ──
723
+ //
724
+ // Re-introduced after the multi-runtime wave's revert. Bytes
725
+ // ride INSIDE the worker code blob (LOADER's modules map, the
726
+ // one phase where wasm code generation IS allowed); request-time
727
+ // WebAssembly.instantiate(bytes) is CSP-blocked and avoided.
728
+ //
729
+ // Validated transport: /workspace/.seal-internal/2026-05-10-
730
+ // wasm-csp/findings.md — add(3,4)===7 in 11ms warm against the
731
+ // deployed Cloudflare fleet.
732
+ //
733
+ // bypassesScriptRead: the registry skips the read-source/
734
+ // shebang-strip/esbuild-transform flow. wasm-runner reads bytes
735
+ // itself in spec.run() and ships via NimbusLoaderPool.
736
+ const wasmSpec = {
737
+ name: 'wasm-runner',
738
+ version: WASM_RUNNER_VERSION,
739
+ helpText: WASM_RUNNER_HELP,
740
+ run: makeWasmRunner({
741
+ // Wave-2: extended VFS surface for WASI file-IO. The wasm-runner
742
+ // snapshots a session subtree into the facet, flushes the diff
743
+ // back via this surface after _start returns.
744
+ vfs: {
745
+ exists: (p) => sqliteFs.exists(p),
746
+ isDirectory: (p) => sqliteFs.isDirectory(p),
747
+ readFile: (p) => sqliteFs.readFile(p),
748
+ writeFile: (p, c) => sqliteFs.writeFile(p, c),
749
+ readdir: (p) => sqliteFs.readdir(p),
750
+ mkdir: (p, o) => sqliteFs.mkdir(p, o),
751
+ unlink: (p) => sqliteFs.unlink(p),
752
+ rmdir: (p) => sqliteFs.rmdir(p),
753
+ },
754
+ env: self.env,
755
+ ctx: self.ctx,
756
+ processTable: self.processTable,
757
+ processLogs: self.processLogs,
758
+ }),
759
+ bypassesScriptRead: true,
760
+ };
761
+ registry.register('wasm-runner', buildRuntimeHandler(wasmSpec, {
762
+ vfs: sqliteFs,
763
+ facetMgr,
764
+ getEsbuild: () => {
765
+ if (!self.esbuildService) {
766
+ self.ensureSqliteFs();
767
+ self.esbuildService = new EsbuildService(self.sqliteFs);
768
+ }
769
+ return self.esbuildService;
770
+ },
771
+ registry,
772
+ }));
773
+ try {
774
+ registry.register('curl', createCurlCommand(kernel));
775
+ }
776
+ catch { }
777
+ // ── df with SQLite stats + cache + process metrics ──────────────────
778
+ registry.register('df', async (ctx) => {
779
+ const stats = sqliteFs.getStats();
780
+ const pstats = facetMgr.stats;
781
+ const used = stats.usedBytes;
782
+ const cap = stats.capacityBytes;
783
+ const avail = cap - used;
784
+ const pct = ((used / cap) * 100).toFixed(0);
785
+ const fmt = (b) => {
786
+ if (b >= 1e9)
787
+ return (b / 1e9).toFixed(1) + 'G';
788
+ if (b >= 1e6)
789
+ return (b / 1e6).toFixed(1) + 'M';
790
+ if (b >= 1e3)
791
+ return (b / 1e3).toFixed(1) + 'K';
792
+ return b + 'B';
793
+ };
794
+ ctx.stdout.write('Filesystem Size Used Avail Use% Mounted on\n');
795
+ ctx.stdout.write('sqlite ' + fmt(cap).padStart(5) + ' ' + fmt(used).padStart(5) +
796
+ ' ' + fmt(avail).padStart(5) + ' ' + pct.padStart(3) + '% /\n');
797
+ ctx.stdout.write('\nCache: ' + stats.cache.entries + '/' + stats.cache.maxEntries +
798
+ ' slots | hit rate: ' + stats.cache.hitRate +
799
+ '% | evictions: ' + stats.cache.evictions + '\n');
800
+ ctx.stdout.write('Procs: ' + pstats.running + ' running, ' +
801
+ pstats.exited + ' exited, ' +
802
+ pstats.total + ' total (next PID: ' + pstats.nextPid + ')\n');
803
+ return 0;
804
+ });
805
+ // ── esbuild command: transform/bundle via esbuild facet ───────────────
806
+ // Lazy-creates the EsbuildService on first use (esbuild-wasm is ~10MB).
807
+ registry.register('esbuild', async (ctx) => {
808
+ const args = ctx.args || [];
809
+ if (args.includes('--version')) {
810
+ ctx.stdout.write('0.24.2 (esbuild-wasm, bundled)\n');
811
+ return 0;
812
+ }
813
+ if (args.includes('--help') || args.length === 0) {
814
+ ctx.stdout.write('Usage: esbuild [options] [entry points]\n\n');
815
+ ctx.stdout.write('Options:\n');
816
+ ctx.stdout.write(' --bundle Bundle all dependencies into output\n');
817
+ ctx.stdout.write(' --outfile=<path> Write output to a file\n');
818
+ ctx.stdout.write(' --outdir=<path> Write output to a directory\n');
819
+ ctx.stdout.write(' --format=esm|cjs Output format (default: esm)\n');
820
+ ctx.stdout.write(' --platform=browser|node Target platform\n');
821
+ ctx.stdout.write(' --minify Minify output\n');
822
+ ctx.stdout.write(' --sourcemap Generate source maps\n');
823
+ ctx.stdout.write(' --target=<target> JS target (default: esnext)\n');
824
+ ctx.stdout.write(' --loader=<loader> Force file loader (ts, tsx, jsx, css)\n');
825
+ ctx.stdout.write(' --version Show version\n');
826
+ ctx.stdout.write('\nPowered by esbuild-wasm (bundled in supervisor).\n');
827
+ return 0;
828
+ }
829
+ // Lazy-init esbuild service
830
+ if (!self.esbuildService) {
831
+ self.ensureSqliteFs();
832
+ self.esbuildService = new EsbuildService(self.sqliteFs);
833
+ }
834
+ // Parse flags
835
+ const flags = {};
836
+ const entryPoints = [];
837
+ for (const arg of args) {
838
+ if (arg.startsWith('--')) {
839
+ const eqIdx = arg.indexOf('=');
840
+ if (eqIdx > 0) {
841
+ flags[arg.substring(2, eqIdx)] = arg.substring(eqIdx + 1);
842
+ }
843
+ else {
844
+ flags[arg.substring(2)] = 'true';
845
+ }
846
+ }
847
+ else {
848
+ entryPoints.push(arg);
849
+ }
850
+ }
851
+ // Transform-only mode (single file, no --bundle)
852
+ if (entryPoints.length === 1 && !flags['bundle']) {
853
+ // Read the file and transform it
854
+ let filePath = entryPoints[0];
855
+ if (!filePath.startsWith('/')) {
856
+ filePath = (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/' + filePath;
857
+ }
858
+ else {
859
+ filePath = filePath.replace(/^\/+/, '');
860
+ }
861
+ let code;
862
+ try {
863
+ code = sqliteFs.readFileString(filePath);
864
+ }
865
+ catch {
866
+ ctx.stderr.write(`esbuild: could not read file: ${entryPoints[0]}\n`);
867
+ return 1;
868
+ }
869
+ try {
870
+ ctx.stderr.write('Transforming...\n');
871
+ const result = await self.esbuildService.transform(code, {
872
+ loader: flags['loader'] || (() => {
873
+ const ext = filePath.split('.').pop()?.toLowerCase();
874
+ return { ts: 'ts', tsx: 'tsx', jsx: 'jsx', js: 'js', mts: 'ts', mjs: 'js', css: 'css', json: 'json' }[ext || ''];
875
+ })(),
876
+ format: flags['format'] || 'esm',
877
+ target: flags['target'] || 'esnext',
878
+ sourcemap: flags['sourcemap'] === 'true',
879
+ minify: flags['minify'] === 'true',
880
+ });
881
+ if (flags['outfile']) {
882
+ const outPath = flags['outfile'].replace(/^\/+/, '');
883
+ // Ensure parent dirs exist
884
+ const parts = outPath.split('/');
885
+ for (let i = 1; i < parts.length; i++) {
886
+ const dir = parts.slice(0, i).join('/');
887
+ if (dir && !sqliteFs.exists(dir))
888
+ sqliteFs.mkdir(dir, { recursive: true });
889
+ }
890
+ sqliteFs.writeFile(outPath, result.code);
891
+ ctx.stdout.write(` ${outPath} ${result.code.length} bytes\n`);
892
+ }
893
+ else {
894
+ ctx.stdout.write(result.code);
895
+ }
896
+ for (const w of result.warnings || []) {
897
+ ctx.stderr.write(`warning: ${w.text}\n`);
898
+ }
899
+ return 0;
900
+ }
901
+ catch (e) {
902
+ ctx.stderr.write(`esbuild error: ${e?.message || e}\n`);
903
+ return 1;
904
+ }
905
+ }
906
+ // Bundle mode
907
+ if (entryPoints.length === 0) {
908
+ ctx.stderr.write('esbuild: no entry points specified\n');
909
+ return 1;
910
+ }
911
+ // Resolve entry points relative to cwd
912
+ const resolvedEntryPoints = entryPoints.map(ep => {
913
+ if (ep.startsWith('/'))
914
+ return ep.replace(/^\/+/, '');
915
+ return (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/' + ep;
916
+ });
917
+ try {
918
+ ctx.stderr.write('Bundling...\n');
919
+ const result = await self.esbuildService.build(resolvedEntryPoints, {
920
+ bundle: flags['bundle'] === 'true',
921
+ format: flags['format'] || 'esm',
922
+ target: flags['target'] || 'esnext',
923
+ platform: flags['platform'] || 'browser',
924
+ outdir: flags['outfile'] ? undefined : (flags['outdir'] || '/dist'),
925
+ outfile: flags['outfile'],
926
+ sourcemap: flags['sourcemap'] === 'true',
927
+ minify: flags['minify'] === 'true',
928
+ external: flags['external']?.split(','),
929
+ });
930
+ for (const e of result.errors || []) {
931
+ ctx.stderr.write(`error: ${e.text}\n`);
932
+ }
933
+ for (const w of result.warnings || []) {
934
+ ctx.stderr.write(`warning: ${w.text}\n`);
935
+ }
936
+ if (result.errors?.length)
937
+ return 1;
938
+ // Write output files to VFS
939
+ for (const f of result.outputFiles || []) {
940
+ const outPath = f.path.replace(/^\/+/, '');
941
+ const parts = outPath.split('/');
942
+ for (let i = 1; i < parts.length; i++) {
943
+ const dir = parts.slice(0, i).join('/');
944
+ if (dir && !sqliteFs.exists(dir))
945
+ sqliteFs.mkdir(dir, { recursive: true });
946
+ }
947
+ sqliteFs.writeFile(outPath, f.contents);
948
+ ctx.stdout.write(` ${outPath} ${f.contents.length} bytes\n`);
949
+ }
950
+ ctx.stderr.write(`Done (${result.outputFiles?.length || 0} output files)\n`);
951
+ return 0;
952
+ }
953
+ catch (e) {
954
+ ctx.stderr.write(`esbuild error: ${e?.message || e}\n`);
955
+ return 1;
956
+ }
957
+ });
958
+ // ── vite command: start/stop the dev server ──────────────────────────
959
+ registry.register('vite', async (ctx) => {
960
+ const args = ctx.args || [];
961
+ const cwd = (ctx.cwd || '/home/user').replace(/^\/+/, '');
962
+ if (args.includes('--help') || args.includes('-h')) {
963
+ ctx.stdout.write('Usage: vite [command] [options]\n\n');
964
+ ctx.stdout.write('Commands:\n');
965
+ ctx.stdout.write(' (default) Start dev server\n');
966
+ ctx.stdout.write(' build Build for production\n');
967
+ ctx.stdout.write(' preview Serve the built dist/\n');
968
+ ctx.stdout.write(' stop Stop dev server\n\n');
969
+ ctx.stdout.write('Options:\n');
970
+ ctx.stdout.write(' --root <dir> Project root\n');
971
+ ctx.stdout.write(' --port <n> Server port\n');
972
+ return 0;
973
+ }
974
+ self.ensureSqliteFs();
975
+ // ── Parse vite.config.ts if it exists ──
976
+ const viteConfig = {};
977
+ for (const cfgName of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
978
+ const cfgPath = cwd + '/' + cfgName;
979
+ if (self.sqliteFs.exists(cfgPath)) {
980
+ try {
981
+ let cfgCode = self.sqliteFs.readFileString(cfgPath);
982
+ // Transform TS to JS
983
+ if (cfgName.endsWith('.ts')) {
984
+ if (!self.esbuildService)
985
+ self.esbuildService = new EsbuildService(self.sqliteFs);
986
+ const t = await self.esbuildService.transform(cfgCode, { loader: 'ts', format: 'esm' });
987
+ cfgCode = t.code;
988
+ }
989
+ // Extract config values via regex (safer than eval in Workers)
990
+ const rootMatch = cfgCode.match(/root\s*:\s*['"]([^'"]+)['"]/);
991
+ if (rootMatch)
992
+ viteConfig.root = rootMatch[1];
993
+ const portMatch = cfgCode.match(/port\s*:\s*(\d+)/);
994
+ if (portMatch)
995
+ viteConfig.port = parseInt(portMatch[1]);
996
+ const outDirMatch = cfgCode.match(/outDir\s*:\s*['"]([^'"]+)['"]/);
997
+ if (outDirMatch)
998
+ viteConfig.outDir = outDirMatch[1];
999
+ const baseMatch = cfgCode.match(/base\s*:\s*['"]([^'"]+)['"]/);
1000
+ if (baseMatch)
1001
+ viteConfig.base = baseMatch[1];
1002
+ // Nimbus-specific: opt out of the React Router basename injection.
1003
+ // Users who want to handle /preview/ routing themselves can set
1004
+ // `nimbusInjectBasename: false` in vite.config.ts.
1005
+ const injectMatch = cfgCode.match(/nimbusInjectBasename\s*:\s*(true|false)/);
1006
+ if (injectMatch)
1007
+ viteConfig.injectBasename = injectMatch[1] === 'true';
1008
+ // resolve.alias: "@": path.resolve(__dirname, "./src") or "@": "./src"
1009
+ // After esbuild transform, values can be string literals OR path.resolve() calls.
1010
+ // Supports any alias key (not just @-prefixed): "@", "~", "#", "components", etc.
1011
+ if (!viteConfig.alias)
1012
+ viteConfig.alias = {};
1013
+ // Match string literal values: "key": "./path"
1014
+ const aliasLiterals = cfgCode.matchAll(/["']([^"']+)["']\s*:\s*["'](\.[^"']+)["']/g);
1015
+ for (const am of aliasLiterals) {
1016
+ viteConfig.alias[am[1]] = am[2];
1017
+ }
1018
+ // Match path.resolve() values: "key": path.resolve(__dirname, "./path")
1019
+ const aliasResolves = cfgCode.matchAll(/["']([^"']+)["']\s*:\s*(?:path\.resolve|resolve)\s*\([^,]*,\s*["']([^"']+)["']\s*\)/g);
1020
+ for (const am of aliasResolves) {
1021
+ viteConfig.alias[am[1]] = am[2];
1022
+ }
1023
+ }
1024
+ catch (e) {
1025
+ ctx.stderr.write(`Warning: could not parse ${cfgName}: ${e?.message}\n`);
1026
+ }
1027
+ break;
1028
+ }
1029
+ }
1030
+ // ── vite build ──
1031
+ if (args[0] === 'build') {
1032
+ if (!self.esbuildService)
1033
+ self.esbuildService = new EsbuildService(self.sqliteFs);
1034
+ const htmlPath = cwd + '/index.html';
1035
+ let entryPoint = cwd + '/src/main.tsx';
1036
+ let origHtml = '';
1037
+ try {
1038
+ origHtml = self.sqliteFs.readFileString(htmlPath);
1039
+ const m = origHtml.match(/src=["']([^"']+\.(?:tsx?|jsx?|mjs))["']/);
1040
+ if (m)
1041
+ entryPoint = cwd + '/' + m[1].replace(/^\//, '');
1042
+ }
1043
+ catch {
1044
+ ctx.stderr.write('Warning: no index.html\n');
1045
+ }
1046
+ if (!self.sqliteFs.exists(entryPoint)) {
1047
+ const alts = [cwd + '/src/main.tsx', cwd + '/src/main.ts', cwd + '/src/index.tsx', cwd + '/src/index.ts'];
1048
+ entryPoint = alts.find(p => self.sqliteFs.exists(p)) || entryPoint;
1049
+ }
1050
+ ctx.stdout.write('Building for production...\n');
1051
+ ctx.stdout.write(' Entry: ' + entryPoint + '\n');
1052
+ const t0 = Date.now();
1053
+ try {
1054
+ const outDir = viteConfig.outDir || 'dist';
1055
+ const distDir = cwd + '/' + outDir;
1056
+ // Detect which packages are installed vs need CDN
1057
+ const nmDir = cwd + '/node_modules';
1058
+ const externals = [];
1059
+ const cdnPackages = [];
1060
+ for (const pkg of ['react', 'react-dom', 'react/jsx-runtime', 'react-dom/client']) {
1061
+ const pkgBase = pkg.split('/')[0];
1062
+ if (!self.sqliteFs.exists(nmDir + '/' + pkgBase)) {
1063
+ externals.push(pkg);
1064
+ if (!cdnPackages.includes(pkgBase))
1065
+ cdnPackages.push(pkgBase);
1066
+ }
1067
+ }
1068
+ if (viteConfig.alias)
1069
+ externals.push(...Object.keys(viteConfig.alias));
1070
+ // Bundle JS
1071
+ const result = await self.esbuildService.build([entryPoint], {
1072
+ bundle: true, format: 'esm', target: 'es2020', platform: 'browser',
1073
+ minify: true, outdir: '/' + distDir + '/assets',
1074
+ external: externals.length > 0 ? externals : undefined,
1075
+ });
1076
+ if (result.errors?.length) {
1077
+ for (const e of result.errors)
1078
+ ctx.stderr.write(' error: ' + e.text + '\n');
1079
+ return 1;
1080
+ }
1081
+ // Generate content hash for filenames
1082
+ let jsContent = '';
1083
+ for (const f of result.outputFiles || []) {
1084
+ jsContent = f.contents;
1085
+ }
1086
+ const hashNum = jsContent.split('').reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0);
1087
+ const hash = (hashNum >>> 0).toString(36).padStart(6, '0');
1088
+ // Write JS with hashed filename
1089
+ const jsFilename = 'index-' + hash + '.js';
1090
+ const jsPath = distDir + '/assets/' + jsFilename;
1091
+ self.sqliteFs.mkdir(distDir + '/assets', { recursive: true });
1092
+ self.sqliteFs.writeFile(jsPath, jsContent);
1093
+ ctx.stdout.write(' \x1b[2m' + outDir + '/assets/' + jsFilename + '\x1b[0m ' + (jsContent.length / 1024).toFixed(2) + ' kB\n');
1094
+ // Collect all CSS files from src/
1095
+ let allCss = '';
1096
+ const collectCss = (dir) => {
1097
+ try {
1098
+ for (const e of self.sqliteFs.readdir(dir)) {
1099
+ const fp = dir + '/' + e.name;
1100
+ if (e.type === 'directory')
1101
+ collectCss(fp);
1102
+ else if (e.name.endsWith('.css')) {
1103
+ try {
1104
+ allCss += self.sqliteFs.readFileString(fp) + '\n';
1105
+ }
1106
+ catch { }
1107
+ }
1108
+ }
1109
+ }
1110
+ catch { }
1111
+ };
1112
+ collectCss(cwd + '/src');
1113
+ const cssFilename = 'index-' + hash + '.css';
1114
+ if (allCss.trim()) {
1115
+ self.sqliteFs.writeFile(distDir + '/assets/' + cssFilename, allCss);
1116
+ ctx.stdout.write(' \x1b[2m' + outDir + '/assets/' + cssFilename + '\x1b[0m ' + (allCss.length / 1024).toFixed(2) + ' kB\n');
1117
+ }
1118
+ // Generate dist/index.html
1119
+ if (origHtml) {
1120
+ let distHtml = origHtml;
1121
+ // Only remove importmap if ALL packages are bundled (no CDN needed)
1122
+ if (cdnPackages.length === 0) {
1123
+ distHtml = distHtml.replace(/<script\s+type=["']importmap["']>[\s\S]*?<\/script>\s*/i, '');
1124
+ }
1125
+ distHtml = distHtml
1126
+ .replace(/(<script[^>]*)\ssrc=["'][^"']+\.(?:tsx?|jsx?|mjs)["']/, '$1 src="/assets/' + jsFilename + '"')
1127
+ .replace(/<link[^>]*href=["'][^"']*\.css["'][^>]*\/?>/, '<link rel="stylesheet" crossorigin href="/assets/' + cssFilename + '">');
1128
+ self.sqliteFs.writeFile(distDir + '/index.html', distHtml);
1129
+ ctx.stdout.write(' \x1b[2m' + outDir + '/index.html\x1b[0m ' + (distHtml.length / 1024).toFixed(2) + ' kB\n');
1130
+ if (cdnPackages.length > 0) {
1131
+ ctx.stdout.write(' \x1b[33mNote: ' + cdnPackages.join(', ') + ' loaded from CDN (not bundled)\x1b[0m\n');
1132
+ }
1133
+ }
1134
+ ctx.stdout.write('\n\x1b[32m\u2713 built in ' + ((Date.now() - t0) / 1000).toFixed(2) + 's\x1b[0m\n');
1135
+ return 0;
1136
+ }
1137
+ catch (e) {
1138
+ ctx.stderr.write('Build error: ' + (e?.message || e) + '\n');
1139
+ return 1;
1140
+ }
1141
+ }
1142
+ // ── vite preview ──
1143
+ if (args[0] === 'preview') {
1144
+ ctx.stdout.write('Serving dist/ — open ' + self.viteBasePath + '/\n');
1145
+ const distRoot = cwd + '/' + (viteConfig.outDir || 'dist');
1146
+ if (!self.sqliteFs.exists(distRoot)) {
1147
+ ctx.stderr.write('dist/ not found. Run vite build first.\n');
1148
+ return 1;
1149
+ }
1150
+ // Start vite on the dist directory
1151
+ if (!self.esbuildService)
1152
+ self.esbuildService = new EsbuildService(self.sqliteFs);
1153
+ if (self.viteDevServer?.isRunning)
1154
+ self.viteDevServer.stop();
1155
+ const previewBasePath = self.viteBasePath;
1156
+ // Primitives wave (P5/P8): same long-running treatment as the
1157
+ // dev path, just on the dist/ directory.
1158
+ const previewPort = viteConfig.port || 4173; // vite preview default
1159
+ const previewProcEntry = self.processTable.spawn('vite preview (' + distRoot + ')', [], distRoot);
1160
+ self.processTable.setLongRunning(previewProcEntry.pid);
1161
+ self.viteDevServer = new ViteDevServer({
1162
+ vfs: self.sqliteFs, esbuild: self.esbuildService, root: distRoot,
1163
+ onHmrMessage: () => { },
1164
+ sql: self.ctx.storage.sql,
1165
+ basePath: previewBasePath,
1166
+ env: self.env,
1167
+ ctx: self.ctx,
1168
+ port: previewPort,
1169
+ pid: previewProcEntry.pid,
1170
+ processLogs: self.processLogs,
1171
+ });
1172
+ self.viteDevServer.start();
1173
+ try {
1174
+ const previewStub = makeLongRunningPortStub(self.viteDevServer);
1175
+ self.portRegistry.register(previewPort, previewProcEntry.pid, previewStub);
1176
+ self._viteShimPid = previewProcEntry.pid;
1177
+ self._viteShimPort = previewPort;
1178
+ }
1179
+ catch { }
1180
+ try {
1181
+ await self.ctx.storage.put('vite-config', { root: distRoot, basePath: previewBasePath, port: previewPort });
1182
+ }
1183
+ catch { }
1184
+ ctx.stdout.write('Serving at ' + previewBasePath + '/ \x1b[2m(pid=' + previewProcEntry.pid + ', port=' + previewPort + ')\x1b[0m\n');
1185
+ return 0;
1186
+ }
1187
+ // ── vite stop ──
1188
+ if (args[0] === 'stop') {
1189
+ let stopped = false;
1190
+ if (self.cirrusReal?.isRunning) {
1191
+ self.cirrusReal.stop(self.ctx);
1192
+ self.cirrusReal = null;
1193
+ stopped = true;
1194
+ }
1195
+ if (self.viteDevServer?.isRunning) {
1196
+ self.viteDevServer.stop();
1197
+ self.viteDevServer = null;
1198
+ try {
1199
+ await self.ctx.storage.delete('vite-config');
1200
+ }
1201
+ catch { }
1202
+ stopped = true;
1203
+ }
1204
+ // Primitive #3 teardown — symmetric with the start path. Always
1205
+ // safe to call: unregisterByPid is idempotent, exit() guards
1206
+ // against re-marking already-terminal entries.
1207
+ if (self._viteShimPid != null) {
1208
+ try {
1209
+ self.portRegistry.unregisterByPid(self._viteShimPid);
1210
+ }
1211
+ catch { }
1212
+ try {
1213
+ self.processTable.exit(self._viteShimPid, 0);
1214
+ }
1215
+ catch { }
1216
+ notifyTerminalEvent(self.terminal, {
1217
+ type: 'exit', pid: self._viteShimPid, code: 0, command: 'vite',
1218
+ });
1219
+ self._viteShimPid = null;
1220
+ self._viteShimPort = null;
1221
+ }
1222
+ if (stopped) {
1223
+ ctx.stdout.write('\x1b[33mDev server stopped.\x1b[0m\n');
1224
+ }
1225
+ else {
1226
+ ctx.stdout.write('No dev server running.\n');
1227
+ }
1228
+ return 0;
1229
+ }
1230
+ // ── vite (default: dev server) ──
1231
+ let vfsRoot = cwd;
1232
+ for (let i = 0; i < args.length; i++) {
1233
+ if (args[i] === '--root' && args[i + 1])
1234
+ vfsRoot = args[i + 1].replace(/^\/+/, '');
1235
+ }
1236
+ if (viteConfig.root && viteConfig.root !== '.') {
1237
+ // Resolve relative root against cwd
1238
+ const configRoot = viteConfig.root.replace(/^\.\//, '');
1239
+ vfsRoot = configRoot.startsWith('/') ? configRoot : cwd + '/' + configRoot;
1240
+ }
1241
+ // Normalize: remove /., //, leading/trailing slashes
1242
+ vfsRoot = vfsRoot
1243
+ .replace(/\/\.\//g, '/') // /./ → /
1244
+ .replace(/\/\.$/, '') // trailing /.
1245
+ .replace(/\/+/g, '/') // collapse //
1246
+ .replace(/^\/+/, '') // leading /
1247
+ .replace(/\/+$/, ''); // trailing /
1248
+ // ── Preflight: node_modules guard ────────────────────────────────────
1249
+ // Direct `vite` invocation requires installed deps. Bail loudly BEFORE
1250
+ // spawning a dev server that would just serve broken modules and
1251
+ // confuse the user. --force / --no-install-check bypasses the check.
1252
+ const bypassInstallCheck = args.includes('--force') || args.includes('--no-install-check');
1253
+ if (!bypassInstallCheck) {
1254
+ const guard = checkNodeModulesGuard(self.sqliteFs, vfsRoot);
1255
+ if (guard.missing) {
1256
+ ctx.stderr.write('\x1b[31m\u2718\x1b[0m \x1b[1mnode_modules/ not found\x1b[0m' +
1257
+ (guard.depCount > 0 ? ` (${guard.depCount} dependencies declared)` : '') + '\n' +
1258
+ ' Run \x1b[36mnpm install\x1b[0m in ' + vfsRoot + ' first,\n' +
1259
+ ' or re-run with \x1b[36m--force\x1b[0m to skip this check.\n');
1260
+ return 1;
1261
+ }
1262
+ }
1263
+ if (self.viteDevServer?.isRunning)
1264
+ self.viteDevServer.stop();
1265
+ // ── Real-vite mode (Phase 0 spike, opt-in) ─────────────────────────
1266
+ // NIMBUS_REAL_VITE=1 or `nimbusDevServer: 'real'` in vite.config.ts
1267
+ // routes the session through a dynamic-worker facet running the
1268
+ // real `vite` npm package. The in-process Cirrus shim is bypassed.
1269
+ //
1270
+ // This is EXPERIMENTAL and gated behind an explicit opt-in. Any
1271
+ // error here falls back to Cirrus by the user re-running without
1272
+ // the env flag — we do not silently fall back (fidelity over
1273
+ // magic).
1274
+ let realViteCfgSource;
1275
+ try {
1276
+ const p = [cwd + '/vite.config.ts', cwd + '/vite.config.js', cwd + '/vite.config.mjs']
1277
+ .find(p => self.sqliteFs.exists(p));
1278
+ if (p)
1279
+ realViteCfgSource = self.sqliteFs.readFileString(p);
1280
+ }
1281
+ catch { }
1282
+ const sessionEnv = (ctx && ctx.env) || {};
1283
+ const useReal = shouldUseRealVite({ env: sessionEnv, viteConfigSource: realViteCfgSource });
1284
+ if (useReal) {
1285
+ if (self.cirrusReal?.isRunning)
1286
+ self.cirrusReal.stop(self.ctx);
1287
+ // 5173 is Vite's default; under workerd it's a routing key, not
1288
+ // a real socket, so we reuse the same number per session.
1289
+ const vitePort = viteConfig.port || 5173;
1290
+ const previewBasePath = self.viteBasePath;
1291
+ // Acquire the heavy-alloc gate so the fire-and-forget pre-bundle
1292
+ // phase (still in flight on a fresh `npm install && npm run dev`)
1293
+ // pauses new dispatches while we allocate the cirrus-real boot
1294
+ // payload (user-vite-config esbuild bundle ~few MiB, plugin-react
1295
+ // bundle, syntheticCode string with snapshotFiles inlined ~few
1296
+ // MiB, LOADER.load worker bundle). With concurrent allocations
1297
+ // and a shared isolate (Mini-PRD: DO shared isolate issues), peak
1298
+ // pressure is what kills us — not steady-state. Released right
1299
+ // after cirrusReal.start() in a finally so a throw in the boot
1300
+ // path doesn't permanently pin the gate.
1301
+ const heavyAllocRelease = acquireHeavyAlloc();
1302
+ // Safety net: release the gate after a generous ceiling even
1303
+ // if the release path is bypassed by an unexpected control
1304
+ // flow (defensive — boot always reaches start() in well-tested
1305
+ // code paths today). Without this, a future regression that
1306
+ // exits the cirrus-real boot block without hitting our finally
1307
+ // would leave pre-bundle blocked for 30 s on every later
1308
+ // dispatch attempt — annoying but not fatal (waitForLowAllocPressure
1309
+ // has its own 30 s ceiling).
1310
+ const heavyAllocCeiling = setTimeout(() => heavyAllocRelease(), 60_000);
1311
+ // Pre-bundle the user's vite.config.ts if present. Must handle
1312
+ // plugin imports — @vitejs/plugin-react, vite-plugin-svgr, etc.
1313
+ // — which live in the project's node_modules. esbuild resolves
1314
+ // those against the VFS via our existing EsbuildService, then
1315
+ // emits an ESM string the facet imports as user-vite-config.js.
1316
+ let userConfigBundle = null;
1317
+ // Extra synthetic files to seed into the facet's fs snapshot.
1318
+ // Populated below when pre-bundling plugin-react — it does
1319
+ // fs.readFileSync(_require.resolve('./refreshUtils.js')) at
1320
+ // transform time and expects to find that file on disk.
1321
+ const extraSyntheticFiles = {};
1322
+ const cfgPath = [cwd + '/vite.config.ts', cwd + '/vite.config.js', cwd + '/vite.config.mjs']
1323
+ .find(p => self.sqliteFs.exists(p));
1324
+ if (cfgPath) {
1325
+ try {
1326
+ if (!self.esbuildService)
1327
+ self.esbuildService = new EsbuildService(self.sqliteFs);
1328
+ const bundleResult = await self.esbuildService.build([cfgPath], {
1329
+ bundle: true,
1330
+ format: 'esm',
1331
+ target: 'es2022',
1332
+ platform: 'neutral',
1333
+ // Path C externals:
1334
+ // - vite: the facet provides vite-config-helper.js
1335
+ // re-exporting the prebundled vite.bundle.js.
1336
+ // - @vitejs/plugin-react: the facet provides a
1337
+ // prebundled cirrus-plugin-react.js (built by
1338
+ // scripts/bundle-plugin-react.mjs at build time;
1339
+ // includes babel, react-refresh, inlined assets).
1340
+ // - @vitejs/plugin-react/jsx-runtime: same bundle.
1341
+ // Any OTHER plugin the user imports (plugin-vue,
1342
+ // plugin-svgr, etc.) falls through to esbuild bundling,
1343
+ // which may or may not work depending on whether its
1344
+ // assets can be fully inlined.
1345
+ external: [
1346
+ 'node:*', 'fs', 'path', 'url', 'util', 'os', 'crypto',
1347
+ 'events', 'stream', 'buffer', 'module', 'perf_hooks',
1348
+ 'esbuild', 'esbuild-wasm',
1349
+ 'vite', 'vite/*',
1350
+ '@vitejs/plugin-react', '@vitejs/plugin-react/*',
1351
+ ],
1352
+ // Same synthetic import.meta.url hack as vite.bundle.js so
1353
+ // plugins that use `fileURLToPath(import.meta.url)` to find
1354
+ // their own install dir don't crash.
1355
+ define: {
1356
+ 'import.meta.url': JSON.stringify('file:///user-vite-config.js'),
1357
+ },
1358
+ keepNames: true,
1359
+ });
1360
+ const out = bundleResult.outputFiles?.[0];
1361
+ if (out) {
1362
+ userConfigBundle = String(out.contents);
1363
+ // LOADER.load requires .js-suffixed specifiers. Externals
1364
+ // survive bundling as bare specifiers in the output; we
1365
+ // rewrite them to .js-suffixed paths pointing at the
1366
+ // facets helper modules.
1367
+ userConfigBundle = userConfigBundle.replace(/from\s*["']vite["']/g, 'from "./vite-config-helper.js"');
1368
+ userConfigBundle = userConfigBundle.replace(/from\s*["']vite\/(.+?)["']/g, 'from "./vite-config-helper.js"');
1369
+ userConfigBundle = userConfigBundle.replace(/from\s*["']@vitejs\/plugin-react["']/g, 'from "./cirrus-plugin-react.js"');
1370
+ userConfigBundle = userConfigBundle.replace(/from\s*["']@vitejs\/plugin-react\/(.+?)["']/g, 'from "./cirrus-plugin-react.js"');
1371
+ // Path C eliminates the need for userspaceRequire /
1372
+ // createRequire / node:fs rewrites in the user-config
1373
+ // bundle — the heavy lifting moved into the
1374
+ // prebundled @vitejs/plugin-react. Left as-is in case
1375
+ // other plugins the user adds still need them.
1376
+ userConfigBundle = userConfigBundle.replace(/\bimport\(\s*(["'][^"']+["'])\s*\)/g, (_, spec) => `Promise.resolve().then(() => {` +
1377
+ ` const m = globalThis.__cirrusRealUserspaceRequire?.(${spec});` +
1378
+ ` if (!m) throw new Error('[cirrus-real] dynamic import failed for ' + ${spec});` +
1379
+ ` return { default: m.default ?? m, ...(typeof m === 'object' ? m : {}) };` +
1380
+ ` })`);
1381
+ userConfigBundle = userConfigBundle.replace(/\bcreateRequire\(/g, '(globalThis.__cirrusNodeCreateRequire || createRequire)(');
1382
+ userConfigBundle = userConfigBundle.replace(/from\s*["']node:fs["']/g, 'from "./cirrus-fs.js"');
1383
+ userConfigBundle = userConfigBundle.replace(/from\s*["']node:fs\/promises["']/g, 'from "./cirrus-fs-promises.js"');
1384
+ if (bundleResult.errors?.length) {
1385
+ console.warn('[vite-cmd] esbuild bundle errors:', bundleResult.errors);
1386
+ }
1387
+ }
1388
+ else {
1389
+ console.warn('[vite-cmd] esbuild.build produced no output');
1390
+ }
1391
+ }
1392
+ catch (e) {
1393
+ ctx.stderr.write('\x1b[33m!\x1b[0m vite.config bundling failed: ' + (e?.message || e) + '\n');
1394
+ ctx.stderr.write(' Real-vite will run with default config.\n');
1395
+ }
1396
+ }
1397
+ self.cirrusReal = new CirrusReal({
1398
+ env: self.env,
1399
+ port: vitePort,
1400
+ root: vfsRoot,
1401
+ basePath: previewBasePath,
1402
+ vfs: self.sqliteFs,
1403
+ vfsEvents: self.sqliteFs.events,
1404
+ userConfigBundle,
1405
+ extraSyntheticFiles,
1406
+ });
1407
+ // Reserve a PID so `ps`/logs show it like any other facet.
1408
+ const entry = self.processTable.spawn('vite (real, ' + vfsRoot + ')', [], vfsRoot);
1409
+ self.processTable.setLongRunning(entry.pid);
1410
+ try {
1411
+ // [sdk-phase-1] start() is now async because it ASSETS-fetches
1412
+ // the large Vite/plugin-react bundles on first invocation
1413
+ // (cached per-isolate after). The enclosing function is async.
1414
+ await self.cirrusReal.start(self.ctx, entry.pid);
1415
+ }
1416
+ finally {
1417
+ // Cirrus-real boot allocation done (or threw). Pre-bundle is
1418
+ // free to resume. If start() threw, the gate must still
1419
+ // release so a future retry doesn't deadlock pre-bundle.
1420
+ clearTimeout(heavyAllocCeiling);
1421
+ heavyAllocRelease();
1422
+ }
1423
+ // Primitive #3 — register the cirrus-real port the same way
1424
+ // the default-Cirrus shim does. Same single hook; the only
1425
+ // difference is which handler.handleRequest the stub forwards
1426
+ // into.
1427
+ const cirrusStub = makeLongRunningPortStub(self.cirrusReal);
1428
+ self.portRegistry.register(vitePort, entry.pid, cirrusStub);
1429
+ self._viteShimPid = entry.pid;
1430
+ self._viteShimPort = vitePort;
1431
+ // ── Boot banner (§4.3 of PHASE2-REAL-VITE-PLAN.md) ──────
1432
+ const snap = self.cirrusReal.stats.snapshot;
1433
+ ctx.stdout.write('\n\x1b[1;36m Nimbus: real-vite mode\x1b[0m \x1b[2m(experimental, Phase 1-4)\x1b[0m\n\n');
1434
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Preview: \x1b[36m' + previewBasePath + '/\x1b[0m\n');
1435
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Vite: ' + self.cirrusReal.stats.viteVersion + ' (bundled)\n');
1436
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Root: ' + vfsRoot + '\n');
1437
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Port: ' + vitePort + ' \x1b[2m(virtual routing key)\x1b[0m\n');
1438
+ if (snap) {
1439
+ const kb = (snap.totalBytes / 1024).toFixed(1);
1440
+ const pkgJson = snap.packageJsonCount;
1441
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Snapshot: ' + snap.fileCount + ' files / ' +
1442
+ kb + ' KB ' +
1443
+ (pkgJson ? '\x1b[2m(incl. ' + pkgJson + ' package.json, rest lazy)\x1b[0m' : '') + '\n');
1444
+ }
1445
+ if (userConfigBundle) {
1446
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Config: ' + cfgPath + ' \x1b[2m(' +
1447
+ (userConfigBundle.length / 1024).toFixed(0) + ' KB bundled)\x1b[0m\n');
1448
+ }
1449
+ ctx.stdout.write('\n \x1b[2mWorks:\x1b[0m @vitejs/plugin-react, JSX/TSX transforms, SPA fallback, HMR.\n');
1450
+ ctx.stdout.write(' \x1b[2mPartial:\x1b[0m other plugins (Babel-family generally OK; SWC/Rolldown blocked).\n');
1451
+ ctx.stdout.write(' \x1b[2mBlocked:\x1b[0m vite build (rolldown needs node:wasi). Use cirrus for build.\n');
1452
+ ctx.stdout.write('\n \x1b[2mRun \x1b[0mvite stop\x1b[2m, or \x1b[0mNIMBUS_REAL_VITE=0 vite\x1b[2m for Cirrus.\x1b[0m\n\n');
1453
+ return 0;
1454
+ }
1455
+ // Parse define config from vite.config.ts (e.g. define: { global: "globalThis" })
1456
+ let viteDefine;
1457
+ try {
1458
+ const cfgPath = [cwd + '/vite.config.ts', cwd + '/vite.config.js', cwd + '/vite.config.mjs']
1459
+ .find(p => self.sqliteFs.exists(p));
1460
+ if (cfgPath) {
1461
+ let cfgCode = self.sqliteFs.readFileString(cfgPath);
1462
+ if (cfgPath.endsWith('.ts')) {
1463
+ const t = await self.esbuildService.transform(cfgCode, { loader: 'ts', format: 'esm' });
1464
+ cfgCode = t.code;
1465
+ }
1466
+ const defineMatch = cfgCode.match(/define\s*:\s*\{([^}]+)\}/);
1467
+ if (defineMatch) {
1468
+ viteDefine = {};
1469
+ const entries = defineMatch[1].matchAll(/["']?([^"',:\s]+)["']?\s*:\s*["']([^"']+)["']/g);
1470
+ for (const e of entries)
1471
+ viteDefine[e[1]] = e[2];
1472
+ }
1473
+ }
1474
+ }
1475
+ catch { }
1476
+ if (!self.esbuildService)
1477
+ self.esbuildService = new EsbuildService(self.sqliteFs);
1478
+ const previewBasePath = self.viteBasePath;
1479
+ // Primitive #3 — long-running PORT-bound process registration.
1480
+ //
1481
+ // BEFORE this wave: vite was a fire-and-forget shell builtin. No
1482
+ // PID, no port, no log buffer, --port silently ignored. Markflow's
1483
+ // `vite --host 0.0.0.0 --port ${PORT:-3000}` printed a banner and
1484
+ // returned the prompt, leaving the user with a blank preview and
1485
+ // no process tab.
1486
+ //
1487
+ // AFTER: vite allocates a real PID via processTable, registers the
1488
+ // resolved port with the supervisor's port-registry, exposes a
1489
+ // handler stub via the generic long-running adapter, and is
1490
+ // visible to `ps` / the Process tab. `vite stop` (or `kill <pid>`)
1491
+ // tears it down via the same primitives that handle every other
1492
+ // long-running facet.
1493
+ //
1494
+ // Argv expansion: package.json scripts commonly write
1495
+ // `--port ${PORT:-3000}`. Nimbus's shell doesn't expand
1496
+ // parameter substitution, so we do it here against ctx.env right
1497
+ // before argv parsing — see runtime/long-running-handle.ts.
1498
+ const expandedArgs = expandArgvShellDefaults(args, ctx.env || {});
1499
+ const vitePortDefault = 5173;
1500
+ const resolvedPort = resolveLongRunningPort({
1501
+ argv: expandedArgs,
1502
+ env: ctx.env,
1503
+ configPort: viteConfig.port,
1504
+ fallback: vitePortDefault,
1505
+ });
1506
+ // Allocate PID FIRST so we can plumb it into ViteDevServer's
1507
+ // process-log wiring at construction time. The PID stays valid
1508
+ // for the life of this dev-server instance; subsequent log lines
1509
+ // emitted by ViteDevServer flow into processLogs[pid].stderr,
1510
+ // visible in the Process tab.
1511
+ const viteProcEntry = self.processTable.spawn('vite (' + vfsRoot + ')', expandedArgs, vfsRoot);
1512
+ self.processTable.setLongRunning(viteProcEntry.pid);
1513
+ self.viteDevServer = new ViteDevServer({
1514
+ vfs: self.sqliteFs,
1515
+ esbuild: self.esbuildService,
1516
+ root: vfsRoot,
1517
+ port: resolvedPort,
1518
+ aliases: viteConfig.alias,
1519
+ define: viteDefine,
1520
+ onHmrMessage: (msg) => {
1521
+ if (self.terminal)
1522
+ try {
1523
+ self.terminal.ws.send(JSON.stringify({ type: 'hmr', data: msg }));
1524
+ }
1525
+ catch { }
1526
+ },
1527
+ sql: self.ctx.storage.sql,
1528
+ injectBasename: viteConfig.injectBasename,
1529
+ basePath: previewBasePath,
1530
+ env: self.env,
1531
+ ctx: self.ctx,
1532
+ // Primitives wave (P8): wire dev-server diagnostics into the
1533
+ // supervisor's per-PID log store so the Process tab is no
1534
+ // longer silent after the banner.
1535
+ pid: viteProcEntry.pid,
1536
+ processLogs: self.processLogs,
1537
+ });
1538
+ self.viteDevServer.start();
1539
+ try {
1540
+ await self.ctx.storage.put('vite-config', {
1541
+ root: vfsRoot, aliases: viteConfig.alias, define: viteDefine,
1542
+ injectBasename: viteConfig.injectBasename, basePath: previewBasePath,
1543
+ port: resolvedPort,
1544
+ });
1545
+ }
1546
+ catch { }
1547
+ // Register the port and build the long-running stub. The stub
1548
+ // forwards into the in-process viteDevServer through the generic
1549
+ // long-running adapter — same hook every future long-running
1550
+ // facet uses (Express, Bun.serve, http.createServer().listen()).
1551
+ const viteStub = makeLongRunningPortStub(self.viteDevServer);
1552
+ self.portRegistry.register(resolvedPort, viteProcEntry.pid, viteStub);
1553
+ // Track the wiring so `vite stop` and crash-handlers can tear it
1554
+ // down without searching the registry.
1555
+ self._viteShimPid = viteProcEntry.pid;
1556
+ self._viteShimPort = resolvedPort;
1557
+ // Spawn / long-running event for the Process tab UI. Mirrors the
1558
+ // shellExecuteTracked banner so the user sees the same shape no
1559
+ // matter how vite was invoked.
1560
+ if (self.terminal) {
1561
+ self.terminal.write(`\x1b[2m[shell started (long-running): pid=${viteProcEntry.pid} cmd="vite ${expandedArgs.join(' ')}"]\x1b[0m\r\n`);
1562
+ }
1563
+ notifyTerminalEvent(self.terminal, {
1564
+ type: 'spawn',
1565
+ pid: viteProcEntry.pid,
1566
+ command: 'vite ' + expandedArgs.join(' '),
1567
+ longRunning: true,
1568
+ });
1569
+ // Banner — reports the resolved port and PID so the user can
1570
+ // verify the multi-target routing.
1571
+ ctx.stdout.write('\n\x1b[1;36m Nimbus Vite Dev Server\x1b[0m\n\n');
1572
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Preview: \x1b[36m' + previewBasePath + '/\x1b[0m');
1573
+ if (resolvedPort !== vitePortDefault) {
1574
+ ctx.stdout.write(' \x1b[2m(also: ' + previewBasePath + '/?port=' + resolvedPort + ')\x1b[0m');
1575
+ }
1576
+ ctx.stdout.write('\n');
1577
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Root: ' + vfsRoot + '\n');
1578
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Port: ' + resolvedPort + ' \x1b[2m(pid=' + viteProcEntry.pid + ')\x1b[0m\n');
1579
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Transforms: .ts .tsx .jsx (React JSX automatic)\n');
1580
+ if (viteConfig.alias)
1581
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Aliases: ' + Object.keys(viteConfig.alias).join(', ') + '\n');
1582
+ if (viteDefine)
1583
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Define: ' + Object.keys(viteDefine).join(', ') + '\n');
1584
+ const twCfg = [vfsRoot + '/tailwind.config.js', vfsRoot + '/tailwind.config.ts'].find(p => self.sqliteFs.exists(p));
1585
+ if (twCfg)
1586
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Tailwind: edge-vendored Play CDN \x1b[2m(detected)\x1b[0m\n');
1587
+ ctx.stdout.write('\n \x1b[2mRun \x1b[0mvite stop\x1b[2m, or \x1b[0mkill ' + viteProcEntry.pid + '\x1b[2m, to stop.\x1b[0m\n\n');
1588
+ return 0;
1589
+ });
1590
+ // ── nimbus-wrangler / wrangler command: Worker dev server ─────────────
1591
+ //
1592
+ // `wrangler` is registered as a transparent alias for `nimbus-wrangler`
1593
+ // so projects with `"dev": "wrangler dev"` in package.json Just Work.
1594
+ // The shared handler below takes an extra `invokedAs` flag so we can
1595
+ // - print a one-shot "DO-in-DO mode" banner on the first wrangler
1596
+ // invocation per session (so users know they're getting a compat
1597
+ // layer, not real wrangler)
1598
+ // - silently strip wrangler-specific flags (--ip, --port, etc.) that
1599
+ // have no meaning inside a DO.
1600
+ const wranglerHandler = (invokedAs) => async (ctx) => {
1601
+ const rawArgs = ctx.args || [];
1602
+ // Filter wrangler-only flags early (works for both invocation paths;
1603
+ // a no-op for nimbus-wrangler since it doesn't accept them anyway).
1604
+ const { args, ignored } = filterWranglerFlags(rawArgs);
1605
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
1606
+ ctx.stdout.write(`Usage: ${invokedAs} dev [options]\n\n`);
1607
+ ctx.stdout.write('Run your Cloudflare Worker locally on the actual CF runtime\n');
1608
+ ctx.stdout.write('(DO-in-DO via env.LOADER — workerd in a workerd).\n\n');
1609
+ ctx.stdout.write('Commands:\n');
1610
+ ctx.stdout.write(' dev Start the dev server\n');
1611
+ ctx.stdout.write(' stop Stop the dev server\n\n');
1612
+ ctx.stdout.write('Options:\n');
1613
+ ctx.stdout.write(' --root <dir> Project root (default: cwd)\n\n');
1614
+ if (invokedAs === 'wrangler') {
1615
+ ctx.stdout.write('Note: \x1b[2minside Nimbus, `wrangler` is an alias for\x1b[0m \x1b[36mnimbus-wrangler\x1b[0m.\n');
1616
+ ctx.stdout.write('Most real-wrangler flags (--ip, --port, --local, --log-level, ...)\n');
1617
+ ctx.stdout.write('are silently ignored because the DO provides its own routing.\n');
1618
+ }
1619
+ return 0;
1620
+ }
1621
+ if (args[0] === 'stop') {
1622
+ if (self.nimbusWrangler?.isRunning) {
1623
+ self.nimbusWrangler.stop();
1624
+ ctx.stdout.write('\x1b[33mWorker dev server stopped.\x1b[0m\n');
1625
+ }
1626
+ else {
1627
+ ctx.stdout.write('No Worker dev server running.\n');
1628
+ }
1629
+ return 0;
1630
+ }
1631
+ if (args[0] !== 'dev') {
1632
+ ctx.stderr.write(`Unknown command: ${args[0]}. Use "${invokedAs} dev" or "${invokedAs} --help".\n`);
1633
+ return 1;
1634
+ }
1635
+ // First-run banner — only when invoked as `wrangler`, and only once
1636
+ // per session. Makes it OBVIOUS to the user that they're not running
1637
+ // real wrangler, and that Nimbus is doing something different.
1638
+ if (invokedAs === 'wrangler' && !self.wranglerAliasBannerShown) {
1639
+ ctx.stdout.write('\x1b[2m\u2388 wrangler (Nimbus DO-in-DO mode) — bundling via esbuild-wasm, running via env.LOADER\x1b[0m\n');
1640
+ self.wranglerAliasBannerShown = true;
1641
+ }
1642
+ // Report ignored flags (also one-shot — if user sees it once per
1643
+ // session that's enough to spot a typo; no need to spam on rebuilds).
1644
+ if (ignored.length > 0 && invokedAs === 'wrangler') {
1645
+ ctx.stdout.write('\x1b[2m ignored wrangler flags: ' + ignored.join(' ') + '\x1b[0m\n');
1646
+ }
1647
+ // Lazy-init esbuild
1648
+ if (!self.esbuildService) {
1649
+ self.ensureSqliteFs();
1650
+ self.esbuildService = new EsbuildService(self.sqliteFs);
1651
+ }
1652
+ // Parse --root flag; default to the shell cwd so `npm run dev` from
1653
+ // a project directory picks up that project's wrangler.jsonc.
1654
+ let root = ctx.cwd || '/home/user';
1655
+ for (let i = 1; i < args.length; i++) {
1656
+ if (args[i] === '--root' && args[i + 1])
1657
+ root = args[i + 1];
1658
+ }
1659
+ // Stop existing
1660
+ if (self.nimbusWrangler?.isRunning)
1661
+ self.nimbusWrangler.stop();
1662
+ const vfsRoot = root.replace(/^\/+/, '');
1663
+ // Pre-flight: read the wrangler config ourselves and call out any
1664
+ // binding fields nimbus-wrangler can't provide. NimbusWrangler will
1665
+ // still try to bundle + load, but user sees up-front why their
1666
+ // Worker may fail when it tries to access a missing binding.
1667
+ const unsupportedFields = detectUnsupportedWranglerConfig(self.sqliteFs, vfsRoot);
1668
+ ctx.stdout.write('\n');
1669
+ ctx.stdout.write('\x1b[1;35m ' + (invokedAs === 'wrangler' ? 'Wrangler' : 'Nimbus Wrangler') + ' Dev\x1b[0m\n\n');
1670
+ if (unsupportedFields.length > 0) {
1671
+ ctx.stderr.write('\x1b[33m\u26A0\x1b[0m \x1b[1mNimbus-incompatible wrangler.jsonc fields detected:\x1b[0m\n');
1672
+ for (const f of unsupportedFields) {
1673
+ ctx.stderr.write(' - \x1b[33m' + f + '\x1b[0m\n');
1674
+ }
1675
+ ctx.stderr.write(' These bindings are NOT provisioned inside nimbus-wrangler. Your Worker\n' +
1676
+ ' will get \x1b[2mundefined\x1b[0m when it tries to access them, which typically\n' +
1677
+ ' causes a runtime TypeError. The bundle will still build and load.\n' +
1678
+ ' \x1b[2mDeploy with real wrangler to get the real bindings.\x1b[0m\n\n');
1679
+ }
1680
+ self.nimbusWrangler = new NimbusWrangler({
1681
+ vfs: self.sqliteFs,
1682
+ esbuild: self.esbuildService,
1683
+ env: self.env,
1684
+ // Supervisor DO ctx — required for ctx.facets.get() when
1685
+ // synthesizing durable_objects bindings on the inner Worker.
1686
+ ctx: self.ctx,
1687
+ root: vfsRoot,
1688
+ onLog: (msg) => {
1689
+ if (self.terminal) {
1690
+ try {
1691
+ self.terminal.write(msg);
1692
+ }
1693
+ catch { }
1694
+ }
1695
+ },
1696
+ onHmrMessage: (msg) => {
1697
+ if (self.terminal) {
1698
+ try {
1699
+ self.terminal.ws.send(JSON.stringify({ type: 'hmr', data: msg }));
1700
+ }
1701
+ catch { }
1702
+ }
1703
+ },
1704
+ });
1705
+ const ok = await self.nimbusWrangler.start();
1706
+ if (!ok) {
1707
+ ctx.stderr.write(' \x1b[31mFailed to start Worker dev server.\x1b[0m\n');
1708
+ return 1;
1709
+ }
1710
+ const cfg = self.nimbusWrangler.stats;
1711
+ // Primitives wave (P5): banner advertises the canonical
1712
+ // `/__nimbus/worker/` route. The legacy `/worker/` URL is still
1713
+ // accepted for one release (Sunset 2027-01-01) but new sessions
1714
+ // are pointed at the namespaced form.
1715
+ const workerBase = (self.sessionBasePath || '') + '/__nimbus/worker';
1716
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Worker: \x1b[36m' + workerBase + '/\x1b[0m\n');
1717
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Name: ' + cfg.name + '\n');
1718
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Entry: ' + cfg.main + '\n');
1719
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Root: ' + cfg.root + '\n');
1720
+ ctx.stdout.write(' \x1b[32m\u279C\x1b[0m Reload: on file change\n\n');
1721
+ ctx.stdout.write(' \x1b[2mRun \x1b[0m' + invokedAs + ' stop\x1b[2m to stop.\x1b[0m\n\n');
1722
+ return 0;
1723
+ };
1724
+ registry.register('nimbus-wrangler', wranglerHandler('nimbus-wrangler'));
1725
+ registry.register('wrangler', wranglerHandler('wrangler'));
1726
+ // ── npm-fast command: parallel npm install (v2 — batched writes) ────
1727
+ registry.register('npm-fast', async (ctx) => {
1728
+ const args = ctx.args || [];
1729
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
1730
+ ctx.stdout.write('Usage: npm-fast install <packages...>\n\n');
1731
+ ctx.stdout.write('Nimbus npm v2 — batched VFS writes, content-addressed cache.\n');
1732
+ ctx.stdout.write('Handles 100+ dependency projects without crashing.\n');
1733
+ return 0;
1734
+ }
1735
+ if (args[0] !== 'install' && args[0] !== 'i') {
1736
+ ctx.stderr.write('Only "npm-fast install" is supported. Use "npm" for other commands.\n');
1737
+ return 1;
1738
+ }
1739
+ const packages = args.slice(1).filter((a) => !a.startsWith('-'));
1740
+ if (packages.length === 0) {
1741
+ ctx.stderr.write('Specify packages to install: npm-fast install react react-dom\n');
1742
+ return 1;
1743
+ }
1744
+ self.ensureSqliteFs();
1745
+ const cwd = (ctx.cwd || '/home/user').replace(/^\/+/, '');
1746
+ // Ensure package.json exists
1747
+ const pkgJsonPath = cwd + '/package.json';
1748
+ if (!self.sqliteFs.exists(pkgJsonPath)) {
1749
+ self.sqliteFs.writeFile(pkgJsonPath, '{"name":"project","version":"1.0.0","dependencies":{}}\n');
1750
+ }
1751
+ ctx.stdout.write('\x1b[36mNimbus npm v2 (batched writes)\x1b[0m\n');
1752
+ self.ensureNpmInstaller((msg) => {
1753
+ ctx.stdout.write('[npm] ' + msg + '\n');
1754
+ });
1755
+ const result = await self.npmInstaller.install(cwd, { packages });
1756
+ if (result.failed.length > 0) {
1757
+ ctx.stderr.write('\x1b[31mFailed: ' + result.failed.join(', ') + '\x1b[0m\n');
1758
+ }
1759
+ // [HONEST INSTALL MESSAGE P0a] Yellow + "(N failed, see above)"
1760
+ // when partial. Green only when failed.length === 0. Pre-fix the
1761
+ // green line printed unconditionally — see user transcript line
1762
+ // 831 ("added 264 packages" with 353 silent failures above).
1763
+ const partial = result.failed.length > 0;
1764
+ const color = partial ? '\x1b[33m' : '\x1b[32m';
1765
+ const suffix = partial ? ` (${result.failed.length} failed, see above)` : '';
1766
+ ctx.stdout.write(`\n${color}added ${result.installed.length} packages (${result.totalFiles} files) in ${(result.elapsed / 1000).toFixed(1)}s${suffix}\x1b[0m\n`);
1767
+ if (result.cachedHits > 0) {
1768
+ ctx.stdout.write(`\x1b[2m (${result.cachedHits} from cache)\x1b[0m\n`);
1769
+ }
1770
+ return result.failed.length > 0 ? 1 : 0;
1771
+ });
1772
+ // ── Set up environment [B'.1: rehydrate from SQL] ──
1773
+ //
1774
+ // Cold start: env is the platform default below.
1775
+ // Silent re-init (persisted env present): the Shell's constructor
1776
+ // does `this.env = { ...n }`, so we layer the persisted env over
1777
+ // the defaults — defaults provide PATH/PS1/etc. (which the user
1778
+ // never sets explicitly), persisted overlays whatever the user
1779
+ // did set (NIMBUS_TEST=cool, etc.).
1780
+ //
1781
+ // Primitive #7 (primitives-extension wave): PORT/HOST and
1782
+ // NIMBUS_SESSION_ID are part of the standard contract.
1783
+ //
1784
+ // PORT=3000 — the same default Markflow's `${PORT:-3000}` shell
1785
+ // expansion targets, and what every Express/Hono/
1786
+ // fastify/Bun.serve script reads when the user
1787
+ // doesn't set it explicitly. Long-running spawns
1788
+ // still pull from `--port` argv first (see
1789
+ // runtime/long-running-handle.ts:resolveLongRunningPort);
1790
+ // this default is the SOURCE for that fall-through.
1791
+ //
1792
+ // HOST=0.0.0.0 — Cloudflare Workers / DO have no localhost vs.
1793
+ // external distinction (the supervisor never opens
1794
+ // a real socket); 0.0.0.0 is what every tutorial
1795
+ // tells users to bind to and matches CF docs.
1796
+ //
1797
+ // NIMBUS_SESSION_ID — derived from sessionBasePath = "/s/<id>".
1798
+ // Set lazily here as a placeholder ("") and patched
1799
+ // below right after Shell construction so the user's
1800
+ // first command sees the real id.
1801
+ //
1802
+ // Why these aren't optional: package.json scripts that hardcode
1803
+ // process.env.PORT (Express's default app, every "create-vite"
1804
+ // template) get `undefined` without this. Sentry / Datadog / any
1805
+ // ops integration that wants a session-stable token uses
1806
+ // NIMBUS_SESSION_ID.
1807
+ const env = {
1808
+ HOME: '/home/user',
1809
+ USER: 'user',
1810
+ SHELL: '/bin/sh',
1811
+ HOSTNAME: DEFAULT_HOSTNAME,
1812
+ TERM: 'xterm-256color',
1813
+ PWD: '/home/user',
1814
+ PATH: '/usr/local/bin:/usr/bin:/bin:/home/user/.local/bin',
1815
+ PS1: `\x1b[1;32muser@${DEFAULT_HOSTNAME}\x1b[0m:\x1b[1;34m\\w\x1b[0m$ `,
1816
+ NODE_ENV: 'development',
1817
+ LANG: 'en_US.UTF-8',
1818
+ EDITOR: 'nano',
1819
+ NIMBUS_VERSION: NIMBUS_VERSION,
1820
+ TMPDIR: '/tmp',
1821
+ XDG_CONFIG_HOME: '/home/user/.config',
1822
+ XDG_DATA_HOME: '/home/user/.local/share',
1823
+ npm_config_prefix: '/usr/local',
1824
+ // Primitive #7 contract additions.
1825
+ PORT: '3000',
1826
+ HOST: '0.0.0.0',
1827
+ NIMBUS_SESSION_ID: '', // patched after Shell ctor — see below.
1828
+ // Persisted env keys win over defaults — the user's `export FOO=bar`
1829
+ // survives reconnect.
1830
+ ...(persisted.env || {}),
1831
+ };
1832
+ // ── Create shell ──
1833
+ const processRegistry = new ProcessRegistry();
1834
+ self.shell = new Shell(self.terminal, self.kernel.vfs, registry, env, processRegistry);
1835
+ // Primitive #7: patch NIMBUS_SESSION_ID into the live shell env.
1836
+ // sessionBasePath is "/s/<sid>" set by the X-Nimbus-Base header on
1837
+ // the first /ws upgrade — by the time initSession runs (after the
1838
+ // ws handshake), it's populated. Older /ws-pre-base callers see
1839
+ // an empty string, which is the safe placeholder (no false id).
1840
+ //
1841
+ // We patch the live env (not the local `env` map above) so persisted
1842
+ // shell state on warm-rejoin still picks up the SAME session id —
1843
+ // the DO's name is stable across hibernation cycles. Any user
1844
+ // `export NIMBUS_SESSION_ID=...` would have been persisted to
1845
+ // persisted.env and the spread above would have overridden the
1846
+ // empty placeholder; we only set when the live env is empty
1847
+ // (don't clobber a user-set value).
1848
+ const sessionIdFromBase = (self.sessionBasePath || '').replace(/^\/s\//, '');
1849
+ if (sessionIdFromBase) {
1850
+ // Shell.env is declared private but mutable at runtime — there's
1851
+ // no public setter. We `any`-cast deliberately; the alternative
1852
+ // (replacing the whole Shell after ctor) would lose the kernel +
1853
+ // registry wiring. Anti-req note: this is NOT a defensive cast,
1854
+ // it's a deliberate single-write operation to plug the contract
1855
+ // gap that env-construction couldn't fill (sessionBasePath
1856
+ // wasn't yet hydrated at ctor time).
1857
+ const shellAny = self.shell;
1858
+ if (!shellAny.env.NIMBUS_SESSION_ID) {
1859
+ shellAny.env.NIMBUS_SESSION_ID = sessionIdFromBase;
1860
+ }
1861
+ }
1862
+ // Rehydrate cwd if persisted. The Shell ctor defaults this.cwd to
1863
+ // env.HOME (which we did NOT override in the persisted overlay
1864
+ // above — HOME stays the platform default). setCwd is exposed in
1865
+ // the Shell's public API; cd-builtin assigns this.cwd directly
1866
+ // bypassing setCwd (verified in node_modules/@lifo-sh/core), but
1867
+ // restoring AFTER construction is fine because nothing has called
1868
+ // cd yet. The next user `cd` will of course work as expected.
1869
+ if (persisted.cwd) {
1870
+ try {
1871
+ self.shell.setCwd(persisted.cwd);
1872
+ }
1873
+ catch { /* fail-soft */ }
1874
+ }
1875
+ // ── SHELL-R6-1 + SHELL-R6-2 + SHELL-R6-3 ─────────────────────────
1876
+ //
1877
+ // Three interpreter-level patches over @lifo-sh/core's Interpreter
1878
+ // (instance: self.shell.interpreter). All three are upstream bugs;
1879
+ // patching at the interpreter instance avoids touching the
1880
+ // node_modules tree (postinstall) while still giving us full
1881
+ // control over the affected semantics.
1882
+ //
1883
+ // The patches are idempotent: each one keeps a reference to the
1884
+ // original and replaces the bound method on the instance.
1885
+ //
1886
+ // SHELL-R6-1 (executeListEntries — `&&`/`||` chain):
1887
+ // Pre-fix: `false && A || B` produces neither A nor B because
1888
+ // the original loop does `break` when a connector "short-
1889
+ // circuits", abandoning the rest of the chain. POSIX semantic is
1890
+ // "skip the IMMEDIATELY NEXT entry, keep evaluating the
1891
+ // remaining connectors against the unchanged exit code."
1892
+ //
1893
+ // SHELL-R6-2 (executePipelineCommands — broken-pipe abort):
1894
+ // Pre-fix: `yes | head -n 5` hangs forever because `head`
1895
+ // reads all of stdin via readAll() and `yes` is never aborted
1896
+ // when `head` exits. Patched version creates a per-pipeline
1897
+ // AbortController, swaps it into config.getAbortSignal for the
1898
+ // duration, and aborts it the moment the LAST command in the
1899
+ // pipeline (the consumer) resolves — propagating SIGPIPE-like
1900
+ // semantics to producer commands that watch their `e.signal`.
1901
+ //
1902
+ // SHELL-R6-3 (wait builtin — registered below in step 3):
1903
+ // Pre-fix: `wait` is "command not found". Implementing it lets
1904
+ // users reliably collect background-job output before continuing.
1905
+ //
1906
+ // Layer notes:
1907
+ // - These are not in src/shell/ because the bug is in the
1908
+ // interpreter's control-flow, not in a builtin or a
1909
+ // line-preprocessor wrapper. Shimming at executeLine wouldn't
1910
+ // help — the parsed AST already encodes the connectors.
1911
+ // - We bind methods to the interpreter instance so the
1912
+ // prototype stays untouched (other sessions sharing the same
1913
+ // module won't see our patches — important because the Shell
1914
+ // class itself is per-session-instance but the prototype
1915
+ // methods would be shared).
1916
+ {
1917
+ const interp = self.shell.interpreter;
1918
+ if (interp) {
1919
+ // ── SHELL-R6-1: corrected executeListEntries ──
1920
+ const originalExecutePipeline = interp.executePipeline.bind(interp);
1921
+ interp.executeListEntries = async function patchedExecuteListEntries(entries, t) {
1922
+ let s = 0;
1923
+ let skipNext = false;
1924
+ for (let n = 0; n < entries.length; n++) {
1925
+ const entry = entries[n];
1926
+ if (!skipNext) {
1927
+ s = await originalExecutePipeline(entry.pipeline, t);
1928
+ }
1929
+ skipNext = false;
1930
+ // Decide whether to skip the NEXT entry based on this
1931
+ // entry's connector + the *current* exit status (whether
1932
+ // we just executed or carried forward from a skip).
1933
+ if (entry.connector === '&&' && s !== 0)
1934
+ skipNext = true;
1935
+ else if (entry.connector === '||' && s === 0)
1936
+ skipNext = true;
1937
+ }
1938
+ this.lastExitCode = s;
1939
+ return s;
1940
+ };
1941
+ // ── SHELL-R6-2: corrected executePipelineCommands ──
1942
+ //
1943
+ // Per-pipeline AbortController + config.getAbortSignal swap.
1944
+ // When the LAST command in the pipeline resolves (the
1945
+ // consumer side), abort the per-pipeline controller, which
1946
+ // cascades to all sibling commands' per-command signals via
1947
+ // the upstream-link they set up at executeSimpleCommand
1948
+ // creation (node_modules/@lifo-sh/core/dist/index-Djm2onjx.js:5194).
1949
+ const originalExecuteCommand = interp.executeCommand.bind(interp);
1950
+ // The pipe class used by lifo-sh between pipeline commands.
1951
+ // We need a reference to call .close() at the right moment.
1952
+ // It's not exported, but we can instantiate one via a probe
1953
+ // and capture the constructor. Easier: just rely on the
1954
+ // close-on-completion semantics already in place AND add the
1955
+ // abort propagation as a separate concern.
1956
+ interp.executePipelineCommands = async function patchedExecutePipelineCommands(commands, stdin) {
1957
+ const pipes = [];
1958
+ const promises = [];
1959
+ // Per-pipeline abort: linked to upstream (if any) so cancel
1960
+ // still flows through, AND we trigger it ourselves when the
1961
+ // consumer finishes.
1962
+ const perPipelineCtrl = new AbortController();
1963
+ const origGetAbortSignal = this.config.getAbortSignal;
1964
+ this.config.getAbortSignal = () => {
1965
+ // Re-derive the linked signal each call. Per-command code
1966
+ // in lifo-sh calls this once at command-spawn time, but
1967
+ // we keep the chaining honest in case multiple calls
1968
+ // happen during pipeline setup.
1969
+ const upstream = origGetAbortSignal ? origGetAbortSignal.call(this.config) : null;
1970
+ if (upstream && !upstream.aborted) {
1971
+ upstream.addEventListener('abort', () => perPipelineCtrl.abort(), { once: true });
1972
+ }
1973
+ else if (upstream && upstream.aborted) {
1974
+ perPipelineCtrl.abort();
1975
+ }
1976
+ return perPipelineCtrl.signal;
1977
+ };
1978
+ // Need a way to construct the same pipe class lifo-sh uses
1979
+ // internally. Easiest: probe the prototype of an existing
1980
+ // execution by calling original executePipelineCommands with
1981
+ // 2 trivial commands… too circular. Better: lazy-discover
1982
+ // by running a tiny one-shot probe at startup, or import
1983
+ // the class.
1984
+ //
1985
+ // Pragmatic: lifo-sh's pipe class is not exported, but its
1986
+ // shape is `{ reader: { read, readAll }, writer: { write }, close() }`.
1987
+ // We reimplement it inline — small footprint, exact shape.
1988
+ class R6Pipe {
1989
+ buffer = [];
1990
+ closed = false;
1991
+ waiting = null;
1992
+ writer = {
1993
+ write: (e) => {
1994
+ if (this.closed)
1995
+ return; // SIGPIPE — drop write
1996
+ if (this.waiting) {
1997
+ const t = this.waiting;
1998
+ this.waiting = null;
1999
+ t(e);
2000
+ }
2001
+ else {
2002
+ this.buffer.push(e);
2003
+ }
2004
+ },
2005
+ };
2006
+ reader = {
2007
+ read: () => this.read(),
2008
+ readAll: () => this.readAll(),
2009
+ };
2010
+ read() {
2011
+ if (this.buffer.length > 0)
2012
+ return Promise.resolve(this.buffer.shift());
2013
+ if (this.closed)
2014
+ return Promise.resolve(null);
2015
+ return new Promise((resolve) => { this.waiting = resolve; });
2016
+ }
2017
+ async readAll() {
2018
+ const out = [];
2019
+ while (true) {
2020
+ const t = await this.read();
2021
+ if (t === null)
2022
+ break;
2023
+ out.push(t);
2024
+ }
2025
+ return out.join('');
2026
+ }
2027
+ close() {
2028
+ this.closed = true;
2029
+ if (this.waiting) {
2030
+ const w = this.waiting;
2031
+ this.waiting = null;
2032
+ w(null);
2033
+ }
2034
+ }
2035
+ }
2036
+ try {
2037
+ for (let o = 0; o < commands.length; o++) {
2038
+ const a = o > 0 ? pipes[o - 1].reader : undefined;
2039
+ let c;
2040
+ if (o < commands.length - 1) {
2041
+ const f = new R6Pipe();
2042
+ pipes.push(f);
2043
+ c = f.writer;
2044
+ }
2045
+ const isLast = o === commands.length - 1;
2046
+ const l = commands[o];
2047
+ const u = o === 0 ? stdin : undefined;
2048
+ const h = originalExecuteCommand(l, a, c, u).then((f) => {
2049
+ if (o < commands.length - 1)
2050
+ pipes[o].close();
2051
+ if (isLast) {
2052
+ // Consumer done — abort all upstream producers.
2053
+ // They watch e.signal via the per-command AbortController
2054
+ // that linked to our perPipelineCtrl.signal.
2055
+ perPipelineCtrl.abort();
2056
+ // Close any still-open upstream pipes so producers'
2057
+ // writes become no-ops (defense-in-depth).
2058
+ for (const p of pipes)
2059
+ p.close();
2060
+ }
2061
+ return f;
2062
+ });
2063
+ promises.push(h);
2064
+ }
2065
+ const i = await Promise.all(promises);
2066
+ return i[i.length - 1];
2067
+ }
2068
+ finally {
2069
+ this.config.getAbortSignal = origGetAbortSignal;
2070
+ }
2071
+ };
2072
+ }
2073
+ }
2074
+ // ── SHELL-R6-3: `wait` builtin ───────────────────────────────────
2075
+ //
2076
+ // POSIX: `wait` (no args) blocks until all currently-running
2077
+ // background jobs finish; `wait %N` or `wait pid` waits for a
2078
+ // specific one. lifo-sh's jobTable already tracks job promises
2079
+ // (node_modules/@lifo-sh/core/dist/index-Djm2onjx.js:5386 — `zi`).
2080
+ //
2081
+ // We register at the Shell.builtins map (same pattern as the
2082
+ // BUG-SWEEP-4 echo override below). This must run AFTER Shell
2083
+ // construction (jobTable doesn't exist before).
2084
+ //
2085
+ // Note: this addresses the `wait: command not found` half of
2086
+ // SHELL-R6-B3. The naked `cmd &` racing-past-prompt issue is a
2087
+ // deeper architectural limit of lifo-sh's `executeList`
2088
+ // background path (it prints `[N] PID (background)` then
2089
+ // immediately returns 0, with the printPrompt() of the line
2090
+ // editor happening before the background job's stdout drains).
2091
+ // A correct fix would need to defer the prompt until job output
2092
+ // settles, which spans the Shell + line-editor + executor —
2093
+ // beyond R6's scope. With `wait` available, users have a
2094
+ // reliable path: `cmd & wait` collects output deterministically.
2095
+ // ── shell-polish (2026-05-12): real `read` as a lifo-sh builtin ──
2096
+ //
2097
+ // Pre-fix `read VAR < file` set VAR to "" because the registered
2098
+ // command in src/shell/unix-commands.ts mutates ctx.env which is a
2099
+ // shallow COPY of the shell env (see node_modules/@lifo-sh/core/dist
2100
+ // /index-Djm2onjx.js:5197 — `env: { ...this.config.env }`). Builtins
2101
+ // run with direct `this.env` access (no copy), so a builtin CAN
2102
+ // mutate the shell env. The same workaround pattern as the `wait`
2103
+ // builtin below.
2104
+ //
2105
+ // Builtin signature per node_modules/@lifo-sh/core/dist
2106
+ // /index-Djm2onjx.js:5182-5184 is `(args, stdout, stderr, stdin)`.
2107
+ // The stdin arg is either:
2108
+ // - the redirect-file reader for `read x < file` (has .read())
2109
+ // - the pipe reader for `echo X | read x` (also has .read())
2110
+ // - undefined when no stdin
2111
+ //
2112
+ // We drain one line from stdin (real bash: `read` consumes until \n
2113
+ // or EOF). The line goes into shellAny.env[VAR].
2114
+ {
2115
+ const shellAny = self.shell;
2116
+ if (shellAny.builtins && typeof shellAny.builtins.set === 'function') {
2117
+ shellAny.builtins.set('read', async function nimbusRead(args, _stdout, _stderr, stdin) {
2118
+ // Drop flag-args (we tolerate -r, -p, -t, -n but don't
2119
+ // implement non-defaults). Treat all reads as raw.
2120
+ const positional = args.filter((a) => !a.startsWith('-'));
2121
+ const varName = positional[0] || 'REPLY';
2122
+ // Drain stdin if present.
2123
+ let content = '';
2124
+ if (stdin && typeof stdin.readAll === 'function') {
2125
+ try {
2126
+ content = await stdin.readAll();
2127
+ }
2128
+ catch {
2129
+ content = '';
2130
+ }
2131
+ }
2132
+ else if (stdin && typeof stdin.read === 'function') {
2133
+ // Some stream shapes only expose .read() yielding chunks.
2134
+ const parts = [];
2135
+ try {
2136
+ let chunk;
2137
+ while ((chunk = await stdin.read()) !== null && chunk !== undefined) {
2138
+ parts.push(String(chunk));
2139
+ }
2140
+ }
2141
+ catch { /* fail-soft */ }
2142
+ content = parts.join('');
2143
+ }
2144
+ // Real `read` consumes ONE line — first newline or all of stdin.
2145
+ const firstNewline = content.indexOf('\n');
2146
+ const line = firstNewline >= 0 ? content.substring(0, firstNewline) : content;
2147
+ // Mutate the shell env in place — this is the whole point of
2148
+ // the builtin shim.
2149
+ shellAny.env[varName] = line;
2150
+ // POSIX: exit 1 on EOF (no input).
2151
+ return content.length === 0 ? 1 : 0;
2152
+ });
2153
+ }
2154
+ }
2155
+ {
2156
+ const shellAny = self.shell;
2157
+ if (shellAny.builtins && typeof shellAny.builtins.set === 'function') {
2158
+ shellAny.builtins.set('wait', async function nimbusWait(args, t) {
2159
+ const jobTable = shellAny.jobTable;
2160
+ if (!jobTable || typeof jobTable.list !== 'function') {
2161
+ // jobTable unavailable — nothing to wait on, succeed silently.
2162
+ return 0;
2163
+ }
2164
+ const targetIds = [];
2165
+ if (args.length > 0) {
2166
+ for (const a of args) {
2167
+ const m = /^%(\d+)$/.exec(a) || /^(\d+)$/.exec(a);
2168
+ if (!m) {
2169
+ try {
2170
+ t.write(`wait: ${a}: not a valid job id\n`);
2171
+ }
2172
+ catch { }
2173
+ return 127;
2174
+ }
2175
+ targetIds.push(parseInt(m[1], 10));
2176
+ }
2177
+ }
2178
+ const jobs = jobTable.list().filter((j) => {
2179
+ if (targetIds.length === 0)
2180
+ return j.status === 'running';
2181
+ return targetIds.includes(j.id);
2182
+ });
2183
+ if (jobs.length === 0)
2184
+ return 0;
2185
+ // Await each job's promise; ignore individual failures so we
2186
+ // mirror POSIX `wait` (returns exit code of the LAST one).
2187
+ let last = 0;
2188
+ for (const j of jobs) {
2189
+ try {
2190
+ const r = await j.promise;
2191
+ last = (typeof r === 'number') ? r : 0;
2192
+ }
2193
+ catch {
2194
+ last = 1;
2195
+ }
2196
+ }
2197
+ return last;
2198
+ });
2199
+ }
2200
+ }
2201
+ // ── Heredoc support (<<) — all logic lives in shell-features.ts ──
2202
+ HeredocHandler.install(self.shell, self.terminal, self.sqliteFs);
2203
+ // ── fd-redirect normalizer (BUG-SWEEP-2) — strip `2>&1` / `>&2` /
2204
+ // `<&0` etc before lifo-sh's parser chokes on them. Stdout and
2205
+ // stderr share the terminal sink so these are no-ops anyway. ──
2206
+ FdRedirectNormalizer.install(self.shell);
2207
+ // ── Brace expansion (BUG-SWEEP-R3-3) — `ls *.{js,ts}` etc.
2208
+ // Runs BEFORE subshell / dollar / fd-redirect so token expansion
2209
+ // happens before downstream parsing. lifo-sh's executeLine chain
2210
+ // invokes wrappers most-recently-installed-first; since we install
2211
+ // BraceExpander AFTER FdRedirectNormalizer, it wraps FdRedirect
2212
+ // and runs first. Same pattern for the others below.
2213
+ BraceExpander.install(self.shell);
2214
+ // ── $$ / $0 expansion (BUG-SWEEP-R3-4) — lifo-sh doesn't expand
2215
+ // these special vars. We rewrite before lifo-sh's parser sees
2216
+ // the line.
2217
+ DollarVarShim.install(self.shell);
2218
+ // ── Subshell `(...)` (BUG-SWEEP-R3-2) — lifo-sh's parser rejects.
2219
+ // Strip outer parens for bare subshells and run inner commands
2220
+ // with cwd/env save-restore. Chained / piped cases fall through
2221
+ // to orig (still errors but with clearer surface).
2222
+ SubshellNormalizer.install(self.shell);
2223
+ // ── Backtick `\`cmd\`` (BUG-SWEEP-R4-4) — rewrite to $(cmd) so
2224
+ // lifo-sh's existing $(...) machinery handles it. Quote-aware.
2225
+ BacktickNormalizer.install(self.shell);
2226
+ // ── BUG-SWEEP-4: echo -n / -e flag override ─────────────────────
2227
+ //
2228
+ // @lifo-sh/core builtinEcho ignores flags: `echo -n hi` outputs
2229
+ // `-n hi` literally; `echo -e "a\tb"` outputs `-e "a\\tb"`.
2230
+ // Real users hit this immediately. The Shell exposes `builtins`
2231
+ // as a Map; replacing the entry post-construction is supported.
2232
+ //
2233
+ // POSIX echo handles -n (suppress trailing newline), -e (interpret
2234
+ // backslash escapes: \\t \\n \\r \\\\ \\0 \\\\xNN). -E disables -e
2235
+ // (default). Multiple flags can stack as -ne. We replicate that
2236
+ // behaviour against the same `t.write(...)` sink lifo-sh passes.
2237
+ {
2238
+ const shellAny = self.shell;
2239
+ if (shellAny.builtins && typeof shellAny.builtins.set === 'function') {
2240
+ shellAny.builtins.set('echo', async function nimbusEcho(args, t) {
2241
+ let interpretEscapes = false;
2242
+ let suppressNewline = false;
2243
+ let i = 0;
2244
+ // Parse leading flags. Stop at first non-flag arg (POSIX).
2245
+ while (i < args.length) {
2246
+ const a = args[i];
2247
+ if (a === '--') {
2248
+ i++;
2249
+ break;
2250
+ }
2251
+ if (a === '-n') {
2252
+ suppressNewline = true;
2253
+ i++;
2254
+ continue;
2255
+ }
2256
+ if (a === '-e') {
2257
+ interpretEscapes = true;
2258
+ i++;
2259
+ continue;
2260
+ }
2261
+ if (a === '-E') {
2262
+ interpretEscapes = false;
2263
+ i++;
2264
+ continue;
2265
+ }
2266
+ // Combined flags like -ne, -en, -nE
2267
+ if (/^-[neE]+$/.test(a)) {
2268
+ for (const ch of a.slice(1)) {
2269
+ if (ch === 'n')
2270
+ suppressNewline = true;
2271
+ else if (ch === 'e')
2272
+ interpretEscapes = true;
2273
+ else if (ch === 'E')
2274
+ interpretEscapes = false;
2275
+ }
2276
+ i++;
2277
+ continue;
2278
+ }
2279
+ break;
2280
+ }
2281
+ let out = args.slice(i).join(' ');
2282
+ if (interpretEscapes) {
2283
+ out = out
2284
+ .replace(/\\\\/g, '\u0000') // protect literal \\\\ → NUL placeholder
2285
+ .replace(/\\n/g, '\n')
2286
+ .replace(/\\t/g, '\t')
2287
+ .replace(/\\r/g, '\r')
2288
+ .replace(/\\b/g, '\b')
2289
+ .replace(/\\f/g, '\f')
2290
+ .replace(/\\v/g, '\v')
2291
+ .replace(/\\a/g, '\x07')
2292
+ .replace(/\\0([0-7]{1,3})?/g, (_m, oct) => String.fromCharCode(oct ? parseInt(oct, 8) : 0))
2293
+ .replace(/\\x([0-9a-fA-F]{1,2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
2294
+ .replace(/\u0000/g, '\\');
2295
+ }
2296
+ t.write(suppressNewline ? out : out + '\n');
2297
+ return 0;
2298
+ });
2299
+ // BUG-SWEEP-R4-1 (2026-05-11): `unset VAR` doesn't actually
2300
+ // unset on prod. lifo-sh's executeSimpleCommand passes a
2301
+ // COPY of shell.env (env: {...this.config.env}); our
2302
+ // registered mkUnset only mutates the copy. Override the
2303
+ // builtin so mutation hits shell.env directly.
2304
+ //
2305
+ // bash semantics: `unset VAR [VAR2 ...]` deletes each from
2306
+ // the shell's environment. Returns 0 even if var didn't
2307
+ // exist (unset is idempotent).
2308
+ shellAny.builtins.set('unset', async function nimbusUnset(args) {
2309
+ for (const name of args) {
2310
+ if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
2311
+ delete shellAny.env[name];
2312
+ }
2313
+ }
2314
+ return 0;
2315
+ });
2316
+ // BUG-SWEEP-R4-1b: `export VAR=val` similarly needs to mutate
2317
+ // shell.env (not just ctx.env). lifo-sh has its own builtinExport
2318
+ // (node_modules/@lifo-sh/core/dist/index-Djm2onjx.js:builtinExport)
2319
+ // that DOES mutate this.env correctly. Verify by re-checking
2320
+ // ts output after deploy. Keep our override OFF here unless
2321
+ // empirical evidence shows the lifo-sh impl is wrong.
2322
+ // (Skipping override; relying on lifo-sh's builtinExport.)
2323
+ }
2324
+ }
2325
+ // ── Readline-parity keybindings (Ctrl+K, Ctrl+W, Alt+B, Alt+F, Alt+D,
2326
+ // Ctrl+Y, Ctrl+T, Ctrl+L, Ctrl+R, Alt+. , Ctrl+←/→, Alt+←/→, Linux
2327
+ // Home/End variants, Ctrl+B/F/N/P, …). Installed AFTER Heredoc so
2328
+ // its handleInput wrapper takes precedence when heredoc mode is
2329
+ // active. ──
2330
+ LineEditorExtender.install(self.shell, self.terminal);
2331
+ // ── Wire npm/npx with shellExecute ──
2332
+ const shell = self.shell;
2333
+ const shellExecute = async (cmd, cmdCtx) => {
2334
+ const result = await shell.execute(cmd, {
2335
+ cwd: cmdCtx.cwd,
2336
+ env: cmdCtx.env,
2337
+ onStdout: (d) => cmdCtx.stdout.write(d),
2338
+ onStderr: (d) => cmdCtx.stderr.write(d),
2339
+ });
2340
+ return result.exitCode;
2341
+ };
2342
+ // ── Fix 3: tracked shell.execute — wires output into processTable +
2343
+ // ProcessLogStore so scripts that bypass the facet pipeline (like
2344
+ // npm-run fallthrough) still show up in `ps`, `logs`, and exit
2345
+ // dumps. Mirrors the instrumentation `_rpcStdout` / `_rpcStderr`
2346
+ // already provide for facet processes.
2347
+ //
2348
+ // Also honours the `longRunning` flag: pass `longRunning=true` for
2349
+ // npm run dev / start so the `[started (long-running): pid=N ...]`
2350
+ // banner matches the existing facet UX.
2351
+ // Note: `self` is declared earlier in this method (line ~1359).
2352
+ const shellExecuteTracked = async (cmd, cmdCtx, opts = {}) => {
2353
+ const argv = cmd.split(/\s+/).filter(Boolean);
2354
+ const entry = self.processTable.spawn(cmd, argv, cmdCtx.cwd || '/home/user');
2355
+ const pid = entry.pid;
2356
+ const startedAt = Date.now();
2357
+ // Spawn banner — matches facet-manager.ts onSpawn format.
2358
+ if (self.terminal) {
2359
+ const label = opts.longRunning ? 'started (long-running)' : 'started';
2360
+ self.terminal.write(`\x1b[2m[shell ${label}: pid=${pid} cmd="${cmd}"]\x1b[0m\r\n`);
2361
+ }
2362
+ // Structured spawn event for the tabs UI (mirrors the facet-manager
2363
+ // onSpawn hook). Long-running shell commands like `vite` and
2364
+ // `wrangler dev` trigger auto-open of a log tab.
2365
+ notifyTerminalEvent(self.terminal, {
2366
+ type: 'spawn', pid, command: cmd, longRunning: !!opts.longRunning,
2367
+ });
2368
+ // Wrap the caller-supplied streams so every chunk is both displayed
2369
+ // AND captured in the ring buffer keyed by this PID.
2370
+ const tee = (stream, target) => (d) => {
2371
+ try {
2372
+ self.processLogs.append(pid, stream, String(d));
2373
+ }
2374
+ catch { }
2375
+ try {
2376
+ target.write(d);
2377
+ }
2378
+ catch { }
2379
+ };
2380
+ let exitCode = 1;
2381
+ try {
2382
+ const result = await shell.execute(cmd, {
2383
+ cwd: cmdCtx.cwd,
2384
+ env: cmdCtx.env,
2385
+ onStdout: tee('stdout', cmdCtx.stdout),
2386
+ onStderr: tee('stderr', cmdCtx.stderr),
2387
+ });
2388
+ exitCode = result.exitCode;
2389
+ }
2390
+ catch (e) {
2391
+ // Surface the error in the terminal AND the ring buffer — the
2392
+ // whole reason this path exists is to stop silent failures.
2393
+ const msg = (e && (e.stack || e.message)) || String(e);
2394
+ tee('stderr', cmdCtx.stderr)('shellExecuteTracked error: ' + msg + '\n');
2395
+ exitCode = 1;
2396
+ }
2397
+ finally {
2398
+ try {
2399
+ self.processTable.exit(pid, exitCode);
2400
+ }
2401
+ catch { }
2402
+ try {
2403
+ if (!self.processLogs.getExit(pid)) {
2404
+ self.processLogs.markExit(pid, exitCode);
2405
+ }
2406
+ }
2407
+ catch { }
2408
+ // Structured exit for the tabs UI. Always fires (the UI doesn't
2409
+ // know which tabs are open, and client-side dedupe is trivial).
2410
+ // Include the command so the UI can backfill a tab for pids it
2411
+ // never saw a spawn event for (e.g. evals routed past onSpawn).
2412
+ notifyTerminalEvent(self.terminal, { type: 'exit', pid, code: exitCode, command: cmd });
2413
+ // Fix 5 trace + Fix 4 dump both read this state; invoke the
2414
+ // session helper so semantics stay in one place.
2415
+ try {
2416
+ self._emitShellExecDone(pid, cmd, exitCode, Date.now() - startedAt);
2417
+ }
2418
+ catch { }
2419
+ }
2420
+ return exitCode;
2421
+ };
2422
+ // ── Primitive #2 (primitives-extension wave): generic .bin handler ──
2423
+ //
2424
+ // Direct terminal invocation of `<bincmd> [args]` (e.g. `tsc --version`,
2425
+ // `prettier --check .`, `eslint .`) used to fall through to "command
2426
+ // not found" because `registry.resolve(name)` only knows about
2427
+ // pre-registered commands. The user had to type `npm run <script>`
2428
+ // — but that requires a script entry in package.json.
2429
+ //
2430
+ // Fix: wrap registry.resolve. When the upstream resolver returns
2431
+ // undefined AND the cwd has `node_modules/.bin/<name>`, synthesise
2432
+ // a handler that:
2433
+ // - reads the shim's "node <relative-script>" pointer (the
2434
+ // node_modules/.bin/<name> file is a tiny POSIX wrapper or a
2435
+ // direct JS file in well-formed packages);
2436
+ // - feeds the resulting `node <script> <args...>` line through
2437
+ // shellExecuteTracked so the bin gets a PID, log buffer,
2438
+ // processTable entry, and Process tab presence — same long-
2439
+ // running treatment as a `npm run dev` script.
2440
+ //
2441
+ // Long-running detection: if the bin name is dev/start/serve/watch,
2442
+ // OR the argv contains explicit watch/dev/serve flags, the
2443
+ // shellExecuteTracked call gets longRunning=true so the Process
2444
+ // tab opens automatically and ^C/restart wires through.
2445
+ //
2446
+ // This is generic — it does NOT enumerate per-bin behaviours.
2447
+ // Every project's bin shims are picked up the same way.
2448
+ const LONG_RUNNING_BIN_NAMES = new Set([
2449
+ 'vite', 'next', 'astro', 'nuxt', 'remix', 'serve', 'http-server',
2450
+ 'wrangler', 'nodemon', 'tsx', 'ts-node-dev', 'webpack-dev-server',
2451
+ 'parcel', 'rollup', 'esbuild', 'turbo',
2452
+ ]);
2453
+ function looksLongRunning(binName, argv) {
2454
+ // Bin name says "long-running"
2455
+ if (LONG_RUNNING_BIN_NAMES.has(binName)) {
2456
+ // ...unless the user passed a one-shot subcommand or version flag.
2457
+ for (const a of argv) {
2458
+ if (a === '--version' || a === '-v' || a === '--help' || a === '-h')
2459
+ return false;
2460
+ if (a === 'build' || a === 'preview')
2461
+ return false;
2462
+ }
2463
+ return true;
2464
+ }
2465
+ // Argv flag says "long-running"
2466
+ for (const a of argv) {
2467
+ if (a === '--watch' || a === '-w' || a === '--serve' || a === '--dev')
2468
+ return true;
2469
+ }
2470
+ return false;
2471
+ }
2472
+ const upstreamResolve = registry.resolve.bind(registry);
2473
+ registry.resolve = async function nimbusBinFallbackResolve(name) {
2474
+ const upstream = await upstreamResolve(name);
2475
+ if (upstream)
2476
+ return upstream;
2477
+ // CRITICAL: probe for the bin shim BEFORE returning a handler.
2478
+ // If we always returned a handler, callers like Nimbus's npx
2479
+ // wrapper that check `if (resolved) { use resolved } else
2480
+ // { fall through to core's auto-install }` would never reach
2481
+ // the auto-install path — every unknown name would short-circuit
2482
+ // through us and fail with "command not found", regressing
2483
+ // `npx <pkg>` for unstalled packages.
2484
+ //
2485
+ // The cwd lookup uses self.shell.cwd (the live shell state)
2486
+ // when no specific cwd is supplied. Best-effort: if shell isn't
2487
+ // built yet (very early init), fall back to /home/user.
2488
+ const cwd = (self.shell?.cwd || '/home/user').replace(/^\/+/, '');
2489
+ const binShimPath = cwd + '/node_modules/.bin/' + name;
2490
+ if (!sqliteFs.exists(binShimPath))
2491
+ return undefined;
2492
+ // Synthesised handler. Each resolve call recomputes (cwd can
2493
+ // change between invocations); we never cache under `name`.
2494
+ return async (ctx) => {
2495
+ // Re-check at INVOCATION TIME against ctx.cwd — the cwd at
2496
+ // resolve() time was the shell's current cwd, but the user
2497
+ // could have piped through a subshell since. Same path
2498
+ // computation as above; identical when cwd hasn't changed.
2499
+ const ctxCwd = (ctx.cwd || '/home/user').replace(/^\/+/, '');
2500
+ const ctxBinShimPath = ctxCwd + '/node_modules/.bin/' + name;
2501
+ if (!sqliteFs.exists(ctxBinShimPath)) {
2502
+ ctx.stderr.write(`${name}: command not found\n`);
2503
+ return 127;
2504
+ }
2505
+ const binShimPath = ctxBinShimPath;
2506
+ // Bin shims are typically a 2-liner:
2507
+ // #!/usr/bin/env node
2508
+ // require('../lib/<entry>.js')
2509
+ //
2510
+ // We can't just feed the shim to Nimbus's node command:
2511
+ // 1. The shebang line isn't stripped (real node strips it
2512
+ // pre-parse; Nimbus's node-runner does not).
2513
+ // 2. The require's relative path is anchored to the SHIM's
2514
+ // directory (.../node_modules/.bin/), so running the
2515
+ // shim from a different cwd resolves wrong.
2516
+ //
2517
+ // Approach: parse the shim to find the actual entry script,
2518
+ // resolve it against the shim's directory, and pass THAT
2519
+ // directly to node. The entry script handles its own require
2520
+ // graph from its real location.
2521
+ let entryAbsPath;
2522
+ try {
2523
+ const shimCode = sqliteFs.readFileString(binShimPath);
2524
+ const stripped = shimCode.replace(/^#![^\n]*\n/, '');
2525
+ const reqMatch = stripped.match(/require\s*\(\s*["']([^"']+)["']\s*\)/);
2526
+ if (!reqMatch) {
2527
+ // Some shims are direct JS (no wrapper require). Fall back
2528
+ // to running the stripped-shebang version verbatim.
2529
+ const tmpPath = 'tmp/.bin-' + name + '.js';
2530
+ try {
2531
+ sqliteFs.mkdir('tmp', { recursive: true });
2532
+ }
2533
+ catch { }
2534
+ sqliteFs.writeFile(tmpPath, stripped);
2535
+ entryAbsPath = tmpPath;
2536
+ }
2537
+ else {
2538
+ const reqArg = reqMatch[1];
2539
+ // Three possible shim shapes we observe in the wild:
2540
+ // 1. `require('../lib/X.js')` — relative; resolve against
2541
+ // shim's directory. Real-Node convention.
2542
+ // 2. `require('home/user/.../X')` — VFS-absolute (no
2543
+ // leading slash, no leading dot). This is what
2544
+ // Nimbus's installer at npm/installer.ts:1453
2545
+ // generates. Use the path AS-IS.
2546
+ // 3. `require('/abs/path')` — POSIX absolute. Strip the
2547
+ // leading slash and use directly.
2548
+ //
2549
+ // Discriminator: a leading '.' marks (1); otherwise treat
2550
+ // as already-resolved (2 or 3).
2551
+ if (reqArg.startsWith('./') || reqArg.startsWith('../')) {
2552
+ const shimDir = binShimPath.substring(0, binShimPath.lastIndexOf('/'));
2553
+ const parts = (shimDir + '/' + reqArg).split('/');
2554
+ const stack = [];
2555
+ for (const p of parts) {
2556
+ if (p === '' || p === '.')
2557
+ continue;
2558
+ if (p === '..') {
2559
+ stack.pop();
2560
+ continue;
2561
+ }
2562
+ stack.push(p);
2563
+ }
2564
+ entryAbsPath = stack.join('/');
2565
+ }
2566
+ else {
2567
+ entryAbsPath = reqArg.replace(/^\/+/, '');
2568
+ }
2569
+ // Try common JS extensions if the file doesn't exist
2570
+ // verbatim (some shims drop them).
2571
+ if (!sqliteFs.exists(entryAbsPath)) {
2572
+ for (const ext of ['.js', '.cjs', '.mjs']) {
2573
+ if (sqliteFs.exists(entryAbsPath + ext)) {
2574
+ entryAbsPath = entryAbsPath + ext;
2575
+ break;
2576
+ }
2577
+ }
2578
+ }
2579
+ if (!sqliteFs.exists(entryAbsPath)) {
2580
+ ctx.stderr.write(`${name}: bin entry not found: ${entryAbsPath}\n`);
2581
+ return 1;
2582
+ }
2583
+ }
2584
+ }
2585
+ catch (e) {
2586
+ ctx.stderr.write(`${name}: failed to parse bin shim: ${e?.message || e}\n`);
2587
+ return 1;
2588
+ }
2589
+ const argv = ctx.args || [];
2590
+ const longRunning = looksLongRunning(name, argv);
2591
+ // CRITICAL: do NOT route through shell.execute → registry.resolve
2592
+ // → this same wrapper (would recurse). Instead, invoke the
2593
+ // 'node' command directly with the bin shim as argv[0]. The
2594
+ // node command is registered upstream-side, so upstreamResolve
2595
+ // returns the real handler; no recursion.
2596
+ const nodeCmd = await upstreamResolve('node');
2597
+ if (!nodeCmd) {
2598
+ ctx.stderr.write(`${name}: node command unavailable\n`);
2599
+ return 1;
2600
+ }
2601
+ // Build shell-line for the spawn-tracked banner: the user-facing
2602
+ // command label that `ps` and the Process tab show.
2603
+ const shellLine = `${name} ${argv.join(' ')}`.trim();
2604
+ // PID + log + (if long-running) port-registry: shellExecuteTracked
2605
+ // does this. But we've established it can recurse via
2606
+ // shell.execute. To get the bookkeeping WITHOUT the recursion,
2607
+ // we replicate the spawn-banner + processTable.spawn dance
2608
+ // here, then dispatch via nodeCmd directly.
2609
+ const entry = self.processTable.spawn(shellLine, [name, ...argv], cwd);
2610
+ const pid = entry.pid;
2611
+ const startedAt = Date.now();
2612
+ if (longRunning) {
2613
+ self.processTable.setLongRunning(pid);
2614
+ }
2615
+ if (self.terminal) {
2616
+ const label = longRunning ? 'started (long-running)' : 'started';
2617
+ self.terminal.write(`\x1b[2m[bin ${label}: pid=${pid} cmd="${shellLine}"]\x1b[0m\r\n`);
2618
+ }
2619
+ notifyTerminalEvent(self.terminal, {
2620
+ type: 'spawn', pid, command: shellLine, longRunning,
2621
+ });
2622
+ // Tee output through processLogs the same way shellExecuteTracked
2623
+ // does, so logs <pid> + the Process tab WS log stream see chunks.
2624
+ const tee = (stream, target) => (d) => {
2625
+ try {
2626
+ self.processLogs.append(pid, stream, String(d));
2627
+ }
2628
+ catch { }
2629
+ try {
2630
+ target.write(d);
2631
+ }
2632
+ catch { }
2633
+ };
2634
+ let exitCode = 1;
2635
+ try {
2636
+ // Prepend '/' so nodeCmd's path-resolver treats this as an
2637
+ // absolute VFS path (line ~295) rather than cwd-relative.
2638
+ // entryAbsPath is already VFS-absolute (no leading slash, no
2639
+ // ./../); we just adapt to nodeCmd's input contract.
2640
+ const entryForNode = '/' + entryAbsPath;
2641
+ // G4 (runtime-pkg wave): we ALREADY allocated `pid` above;
2642
+ // signal nodeCmd to NOT spawn a duplicate one. Pre-fix,
2643
+ // facetMgr.exec did its own processTable.spawn → ps showed
2644
+ // TWO rows per bin invocation (the wrapper's banner + the
2645
+ // facet's `node /tmp/...`). Now both layers share one PID
2646
+ // and one user-visible "command" string (the typed line).
2647
+ //
2648
+ // Stash the spawn directives on ctx so they propagate
2649
+ // through nodeCmd → runFresh → facetMgr.exec. We cannot
2650
+ // pass them via args; they aren't argv. ctx is the only
2651
+ // arbitrarily-extensible shape.
2652
+ const ctxWithSpawn = {
2653
+ ...ctx,
2654
+ args: [entryForNode, ...argv],
2655
+ stdout: { write: tee('stdout', ctx.stdout) },
2656
+ stderr: { write: tee('stderr', ctx.stderr) },
2657
+ __nimbusBinSpawn: { skipSpawn: true, callerPid: pid, command: shellLine },
2658
+ };
2659
+ exitCode = await nodeCmd(ctxWithSpawn);
2660
+ }
2661
+ catch (e) {
2662
+ const msg = (e && (e.stack || e.message)) || String(e);
2663
+ tee('stderr', ctx.stderr)(`bin error: ${msg}\n`);
2664
+ exitCode = 1;
2665
+ }
2666
+ finally {
2667
+ try {
2668
+ self.processTable.exit(pid, exitCode);
2669
+ }
2670
+ catch { }
2671
+ try {
2672
+ if (!self.processLogs.getExit(pid)) {
2673
+ self.processLogs.markExit(pid, exitCode);
2674
+ }
2675
+ }
2676
+ catch { }
2677
+ notifyTerminalEvent(self.terminal, { type: 'exit', pid, code: exitCode, command: shellLine });
2678
+ try {
2679
+ self._emitShellExecDone(pid, shellLine, exitCode, Date.now() - startedAt);
2680
+ }
2681
+ catch { }
2682
+ }
2683
+ return exitCode;
2684
+ };
2685
+ };
2686
+ // Register core npm with enhanced `npm run <script>` support
2687
+ const coreNpmCmd = createNpmCommand(registry, shellExecute, kernel);
2688
+ registry.register('npm', async (ctx) => {
2689
+ const args = ctx.args || [];
2690
+ const sub = args[0];
2691
+ // npm run <script> / npm test / npm start — parse package.json and execute
2692
+ if (sub === 'run' || sub === 'run-script' || sub === 'test' || sub === 'start') {
2693
+ const scriptName = sub === 'test' ? 'test' : sub === 'start' ? 'start' : args[1];
2694
+ if (!scriptName) {
2695
+ // npm run (no script) — list available scripts
2696
+ const pkgPath = (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/package.json';
2697
+ try {
2698
+ const pkg = JSON.parse(sqliteFs.readFileString(pkgPath));
2699
+ if (pkg.scripts && Object.keys(pkg.scripts).length > 0) {
2700
+ ctx.stdout.write('Lifecycle scripts:\n');
2701
+ for (const [name, cmd] of Object.entries(pkg.scripts)) {
2702
+ ctx.stdout.write(` ${name}\n ${cmd}\n`);
2703
+ }
2704
+ }
2705
+ else {
2706
+ ctx.stdout.write('No scripts found in package.json\n');
2707
+ }
2708
+ }
2709
+ catch {
2710
+ ctx.stderr.write('npm ERR! no package.json found\n');
2711
+ return 1;
2712
+ }
2713
+ return 0;
2714
+ }
2715
+ const pkgPath = (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/package.json';
2716
+ try {
2717
+ const pkg = JSON.parse(sqliteFs.readFileString(pkgPath));
2718
+ const script = pkg.scripts?.[scriptName];
2719
+ if (!script) {
2720
+ ctx.stderr.write(`npm ERR! Missing script: "${scriptName}"\n`);
2721
+ if (pkg.scripts) {
2722
+ ctx.stderr.write('npm ERR! Available scripts:\n');
2723
+ for (const name of Object.keys(pkg.scripts))
2724
+ ctx.stderr.write(` - ${name}\n`);
2725
+ }
2726
+ return 1;
2727
+ }
2728
+ // ── node_modules preflight ────────────────────────────────────
2729
+ // If the script invokes a known bundler/framework CLI (vite, next,
2730
+ // webpack, tsc, ...) and node_modules is missing, HARD-FAIL before
2731
+ // running it — the tool would crash with a cryptic "command not
2732
+ // found" / "cannot find package" error that's less helpful.
2733
+ // For custom/unknown scripts (e.g. `echo hi`), emit a warning but
2734
+ // continue — the user's intent might not need deps at all.
2735
+ // Bypass with --force / --no-install-check in the script args, or
2736
+ // by setting NIMBUS_SKIP_INSTALL_CHECK=1 in env.
2737
+ const scriptArgs = args.slice(sub === 'run' || sub === 'run-script' ? 2 : 1);
2738
+ const bypassRunCheck = scriptArgs.includes('--force') ||
2739
+ scriptArgs.includes('--no-install-check') ||
2740
+ ctx.env?.NIMBUS_SKIP_INSTALL_CHECK === '1';
2741
+ if (!bypassRunCheck) {
2742
+ const projDir = (ctx.cwd || '/home/user').replace(/^\/+/, '');
2743
+ const guard = checkNodeModulesGuard(sqliteFs, projDir);
2744
+ if (guard.missing) {
2745
+ const bundler = detectBundlerBin(script);
2746
+ if (bundler) {
2747
+ // Hard fail: script needs a bundler binary that lives in node_modules/.bin.
2748
+ ctx.stderr.write('\x1b[31m\u2718\x1b[0m \x1b[1mnode_modules/ not found\x1b[0m — ' +
2749
+ `script "${scriptName}" runs \x1b[36m${bundler}\x1b[0m which needs installed dependencies ` +
2750
+ `(${guard.depCount} declared).\n` +
2751
+ ' Run \x1b[36mnpm install\x1b[0m first,\n' +
2752
+ ' or re-run with \x1b[36mnpm run ' + scriptName + ' -- --force\x1b[0m to skip this check.\n');
2753
+ return 1;
2754
+ }
2755
+ // Soft warning: script might not need deps; let it try.
2756
+ ctx.stderr.write('\x1b[33m\u26A0\x1b[0m node_modules/ not found (' + guard.depCount + ' deps declared) — ' +
2757
+ 'proceeding anyway. Run \x1b[36mnpm install\x1b[0m if the script fails.\n\n');
2758
+ }
2759
+ }
2760
+ ctx.stdout.write(`\n> ${pkg.name || 'project'}@${pkg.version || '1.0.0'} ${scriptName}\n`);
2761
+ ctx.stdout.write(`> ${script}\n\n`);
2762
+ // ── Next.js loud-block (was W11 per-framework substrate) ──────
2763
+ //
2764
+ // Primitives wave (P12): the previous wave shipped this as a
2765
+ // 45-line standalone module at src/frameworks/next.ts that
2766
+ // exported nothing but the strings used here. Per the
2767
+ // "no per-framework substrate" rule, the message is inlined
2768
+ // (the only thing the module did was hold these strings) and
2769
+ // the file is deleted.
2770
+ //
2771
+ // Next.js dev/start still needs a custom http.Server +
2772
+ // child_process.fork with v8-IPC + webpack/Turbopack, none of
2773
+ // which Nimbus ships. We surface a deterministic message
2774
+ // rather than letting the script hang or emit a confusing
2775
+ // crash. The block remains a one-off symbol-detection
2776
+ // guard, NOT a generic per-framework code path. Any future
2777
+ // similar guard belongs alongside this one — not in its own
2778
+ // src/frameworks/<name>.ts file.
2779
+ if ((scriptName === 'dev' || scriptName === 'start') &&
2780
+ (pkg.dependencies?.next || pkg.devDependencies?.next) &&
2781
+ !(scriptArgs.includes('--force') || scriptArgs.includes('--allow-next'))) {
2782
+ const NEXT_BLOCK_MESSAGE = '\x1b[31m\u2718\x1b[0m \x1b[1mNext.js dev server is not supported in Nimbus.\x1b[0m\n' +
2783
+ ' Specific blockers:\n' +
2784
+ " 1. \x1b[2mchild_process.fork\x1b[0m IPC uses v8-serializer (Nimbus ships JSON projection).\n" +
2785
+ ' 2. webpack / Turbopack bundlers are not integrated with the pre-bundle pipeline.\n' +
2786
+ ' 3. Custom \x1b[2mhttp.Server\x1b[0m semantics (keep-alive, raw sockets) are facet-incompatible.\n' +
2787
+ '\n' +
2788
+ ' Workaround: deploy with \x1b[36mnext build\x1b[0m + a hosted runtime,\n' +
2789
+ ' or pass \x1b[36m--allow-next\x1b[0m to bypass at your own risk.\n';
2790
+ ctx.stderr.write(NEXT_BLOCK_MESSAGE);
2791
+ return 127;
2792
+ }
2793
+ // ── Shell-composite detection ──────────────────────────────────
2794
+ // Scripts like `cd packages/cf-backend && vite dev` or
2795
+ // `NODE_ENV=prod node build.js | tee log` need the full shell
2796
+ // parser (operators, builtins like cd/export, pipes, redirects,
2797
+ // env-var prefixes, globs, heredocs). The naive whitespace split
2798
+ // below can only handle a single bare command — for anything
2799
+ // else it mis-identifies the first token (e.g. "cd") as the
2800
+ // command name, fails to resolve it in the registry, and emits
2801
+ // a misleading "cd: command not found".
2802
+ //
2803
+ // `shellExecuteTracked` routes through `shell.execute` which
2804
+ // IS the full shell (same path as interactive terminal input),
2805
+ // so composite scripts behave identically to typing them at
2806
+ // the prompt.
2807
+ //
2808
+ // Metacharacters checked:
2809
+ // && || | ; operator chains + pipes
2810
+ // > < redirects (covers >> and <<)
2811
+ // ` $( command substitution
2812
+ // ^NAME= leading env-var prefix (VAR=x cmd)
2813
+ // Single-command scripts (no metacharacters) still take the
2814
+ // fast registry path below for better stdout wiring + clearer
2815
+ // "unsupported" / "command not found" messages.
2816
+ const scriptTrim = script.trim();
2817
+ const hasShellMeta = /(\&\&|\|\||[|;<>`]|\$\()/.test(scriptTrim) ||
2818
+ /^[A-Za-z_][A-Za-z0-9_]*=/.test(scriptTrim);
2819
+ if (hasShellMeta) {
2820
+ const longRunningComposite = scriptName === 'dev' || scriptName === 'start' ||
2821
+ scriptName === 'serve' || scriptName === 'watch';
2822
+ return await shellExecuteTracked(scriptTrim, {
2823
+ ...ctx,
2824
+ env: {
2825
+ ...ctx.env,
2826
+ npm_lifecycle_event: scriptName,
2827
+ npm_package_name: pkg.name || '',
2828
+ },
2829
+ }, { longRunning: longRunningComposite });
2830
+ }
2831
+ // Parse script into command + args (single-command fast path).
2832
+ const scriptParts = scriptTrim.split(/\s+/);
2833
+ const cmdName = scriptParts[0];
2834
+ const cmdArgs = scriptParts.slice(1);
2835
+ // Try to resolve via registry — same path as direct terminal input
2836
+ const resolved = await registry.resolve(cmdName);
2837
+ if (resolved) {
2838
+ // Call with the SAME ctx (stdout wired to terminal)
2839
+ return await resolved({
2840
+ ...ctx,
2841
+ args: cmdArgs,
2842
+ env: { ...ctx.env, npm_lifecycle_event: scriptName, npm_package_name: pkg.name || '' },
2843
+ });
2844
+ }
2845
+ // ── Fix 1: deterministic "unsupported command" hint ────────────
2846
+ // A command not registered in the shell (and therefore about to
2847
+ // fall through to `shell.execute`, whose "command not found"
2848
+ // message silently vanishes into a buffered string) typically
2849
+ // means one of:
2850
+ // a) The project expects a tool like `wrangler` that Nimbus
2851
+ // skips during `npm install` (see SKIP_PACKAGES in
2852
+ // src/npm-resolver.ts). There may be a `.bin` shim if the
2853
+ // user installed it manually, but it tries to spawn workerd
2854
+ // via `child_process.spawn` which isn't available in a DO
2855
+ // isolate. Running it just hangs or crashes silently.
2856
+ // b) The project uses a genuinely unknown command. Surface
2857
+ // that too so the user sees SOMETHING rather than a silent
2858
+ // prompt.
2859
+ const projDirForBin = (ctx.cwd || '/home/user').replace(/^\/+/, '');
2860
+ const binShimPath = projDirForBin + '/node_modules/.bin/' + cmdName;
2861
+ const hasBinShim = sqliteFs.exists(binShimPath);
2862
+ const unsupported = NIMBUS_UNSUPPORTED_BINS[cmdName];
2863
+ if (unsupported) {
2864
+ ctx.stderr.write('\x1b[31m\u2718\x1b[0m \x1b[1m' + cmdName + '\x1b[0m is not supported inside Nimbus.\n' +
2865
+ ' ' + unsupported.reason + '\n' +
2866
+ (unsupported.alternative
2867
+ ? ' \x1b[2mTry:\x1b[0m \x1b[36m' + unsupported.alternative + '\x1b[0m\n'
2868
+ : '') +
2869
+ (hasBinShim
2870
+ ? ' \x1b[2m(Found node_modules/.bin/' + cmdName + ' — it installed, but it cannot run here.)\x1b[0m\n'
2871
+ : ''));
2872
+ return 127;
2873
+ }
2874
+ // Known POSIX shell builtins are handled by shell.execute, not
2875
+ // by Nimbus's command registry or by node_modules/.bin shims.
2876
+ // A single-command script like `cd target-dir` (degenerate but
2877
+ // occasionally seen) or `true` / `:` (exit-0 no-op placeholders)
2878
+ // would otherwise trip the "command not found" branch below
2879
+ // with a misleading "not a built-in Nimbus command" message.
2880
+ // Route them through shellExecuteTracked so the shell's own
2881
+ // builtin handler runs.
2882
+ const SHELL_BUILTINS = new Set([
2883
+ 'cd', 'export', 'unset', 'set', 'source', '.', 'alias',
2884
+ 'unalias', 'eval', 'exec', 'exit', 'return', 'shift',
2885
+ 'pwd', 'read', 'true', 'false', ':', 'test', '[',
2886
+ ]);
2887
+ if (SHELL_BUILTINS.has(cmdName)) {
2888
+ const longRunningBuiltin = scriptName === 'dev' || scriptName === 'start' ||
2889
+ scriptName === 'serve' || scriptName === 'watch';
2890
+ return await shellExecuteTracked(scriptTrim, {
2891
+ ...ctx,
2892
+ env: {
2893
+ ...ctx.env,
2894
+ npm_lifecycle_event: scriptName,
2895
+ npm_package_name: pkg.name || '',
2896
+ },
2897
+ }, { longRunning: longRunningBuiltin });
2898
+ }
2899
+ if (!hasBinShim) {
2900
+ // Command not registered, no bin shim, not a shell builtin.
2901
+ // Tell the user explicitly.
2902
+ ctx.stderr.write('\x1b[31m\u2718\x1b[0m \x1b[1m' + cmdName + ': command not found\x1b[0m\n' +
2903
+ ' Script "' + scriptName + '" wants to run: \x1b[36m' + script + '\x1b[0m\n' +
2904
+ ' "' + cmdName + '" is not a built-in Nimbus command and no\n' +
2905
+ ' \x1b[2mnode_modules/.bin/' + cmdName + '\x1b[0m shim was found.\n' +
2906
+ ' Check your package.json scripts or install the missing package.\n');
2907
+ return 127;
2908
+ }
2909
+ // Has a .bin shim (shell.execute would try to exec it via the
2910
+ // PATH-lookup in @lifo-sh/core). Route through shellExecuteTracked
2911
+ // so stdout/stderr land in the terminal AND the ring buffer, AND
2912
+ // the process shows up in `ps`/`logs` for post-mortem. Long-
2913
+ // running flag is set for dev/start scripts so the banner reads
2914
+ // "started (long-running)" and exit dumps always fire (Fix 4).
2915
+ const longRunning = scriptName === 'dev' || scriptName === 'start' ||
2916
+ scriptName === 'serve' || scriptName === 'watch';
2917
+ return await shellExecuteTracked(script, {
2918
+ ...ctx,
2919
+ env: { ...ctx.env, npm_lifecycle_event: scriptName, npm_package_name: pkg.name || '' },
2920
+ }, { longRunning });
2921
+ }
2922
+ catch (e) {
2923
+ ctx.stderr.write(`npm ERR! ${e?.message || e}\n`);
2924
+ return 1;
2925
+ }
2926
+ }
2927
+ // npm ls — list installed packages
2928
+ if (sub === 'ls' || sub === 'list') {
2929
+ const pkgPath = (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/package.json';
2930
+ const nmDir = (ctx.cwd || '/home/user').replace(/^\/+/, '') + '/node_modules';
2931
+ try {
2932
+ const pkg = JSON.parse(sqliteFs.readFileString(pkgPath));
2933
+ ctx.stdout.write(`${pkg.name || 'project'}@${pkg.version || '1.0.0'} ${ctx.cwd}\n`);
2934
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
2935
+ const names = Object.keys(deps);
2936
+ for (let i = 0; i < names.length; i++) {
2937
+ const isLast = i === names.length - 1;
2938
+ const prefix = isLast ? '└── ' : '├── ';
2939
+ const name = names[i];
2940
+ let version = deps[name];
2941
+ // Try to read actual installed version
2942
+ try {
2943
+ const installed = JSON.parse(sqliteFs.readFileString(nmDir + '/' + name + '/package.json'));
2944
+ version = installed.version;
2945
+ }
2946
+ catch { }
2947
+ ctx.stdout.write(`${prefix}${name}@${version}\n`);
2948
+ }
2949
+ }
2950
+ catch {
2951
+ ctx.stderr.write('npm ERR! no package.json found\n');
2952
+ return 1;
2953
+ }
2954
+ return 0;
2955
+ }
2956
+ // npm init / npm init -y
2957
+ if (sub === 'init') {
2958
+ const cwd = (ctx.cwd || '/home/user').replace(/^\/+/, '');
2959
+ const pkgPath = cwd + '/package.json';
2960
+ if (sqliteFs.exists(pkgPath) && !args.includes('-y') && !args.includes('--yes')) {
2961
+ ctx.stderr.write('package.json already exists. Use -y to overwrite.\n');
2962
+ return 1;
2963
+ }
2964
+ const name = cwd.split('/').pop() || 'project';
2965
+ const pkg = {
2966
+ name, version: '1.0.0', description: '', main: 'index.js',
2967
+ type: 'module',
2968
+ scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview', test: 'echo "no test"' },
2969
+ keywords: [], author: '', license: 'MIT', dependencies: {}, devDependencies: {},
2970
+ };
2971
+ sqliteFs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
2972
+ ctx.stdout.write('Wrote to ' + pkgPath + '\n');
2973
+ return 0;
2974
+ }
2975
+ // npm uninstall <pkg>
2976
+ if (sub === 'uninstall' || sub === 'un' || sub === 'remove' || sub === 'rm') {
2977
+ const packages = args.slice(1).filter(a => !a.startsWith('-'));
2978
+ if (packages.length === 0) {
2979
+ ctx.stderr.write('Usage: npm uninstall <pkg>\n');
2980
+ return 1;
2981
+ }
2982
+ const cwd = (ctx.cwd || '/home/user').replace(/^\/+/, '');
2983
+ const nmDir = cwd + '/node_modules';
2984
+ for (const pkg of packages) {
2985
+ const pkgDir = nmDir + '/' + pkg;
2986
+ // Recursively delete package directory
2987
+ const deleteRecursive = (dir) => {
2988
+ try {
2989
+ for (const e of sqliteFs.readdir(dir)) {
2990
+ const fp = dir + '/' + e.name;
2991
+ if (e.type === 'directory')
2992
+ deleteRecursive(fp);
2993
+ else
2994
+ try {
2995
+ sqliteFs.unlink(fp);
2996
+ }
2997
+ catch { }
2998
+ }
2999
+ try {
3000
+ sqliteFs.rmdir(dir);
3001
+ }
3002
+ catch { }
3003
+ }
3004
+ catch { }
3005
+ };
3006
+ deleteRecursive(pkgDir);
3007
+ ctx.stdout.write('removed ' + pkg + '\n');
3008
+ }
3009
+ // Update package.json
3010
+ const pkgPath = cwd + '/package.json';
3011
+ try {
3012
+ const pkgJson = JSON.parse(sqliteFs.readFileString(pkgPath));
3013
+ for (const pkg of packages) {
3014
+ delete pkgJson.dependencies?.[pkg];
3015
+ delete pkgJson.devDependencies?.[pkg];
3016
+ }
3017
+ sqliteFs.writeFile(pkgPath, JSON.stringify(pkgJson, null, 2) + '\n');
3018
+ }
3019
+ catch { }
3020
+ return 0;
3021
+ }
3022
+ // npm install (no args or with packages) — use NpmInstaller v2 (batched writes)
3023
+ if (sub === 'install' || sub === 'i' || sub === 'add') {
3024
+ const explicitPkgs = args.slice(1).filter((a) => !a.startsWith('-'));
3025
+ self.ensureSqliteFs();
3026
+ const installCwd = (ctx.cwd || '/home/user').replace(/^\/+/, '');
3027
+ // Ensure package.json exists for bare `npm install`
3028
+ if (explicitPkgs.length === 0) {
3029
+ const pkgJsonPath = installCwd + '/package.json';
3030
+ if (!sqliteFs.exists(pkgJsonPath)) {
3031
+ ctx.stderr.write('npm ERR! no package.json found\n');
3032
+ return 1;
3033
+ }
3034
+ }
3035
+ const pkgLabel = explicitPkgs.length > 0
3036
+ ? `${explicitPkgs.length} packages`
3037
+ : 'dependencies from package.json';
3038
+ ctx.stdout.write(`\x1b[36mInstalling ${pkgLabel} (npm v2 — batched writes)...\x1b[0m\n`);
3039
+ self.ensureNpmInstaller((msg) => {
3040
+ ctx.stdout.write('[npm] ' + msg + '\n');
3041
+ });
3042
+ try {
3043
+ const result = await self.npmInstaller.install(installCwd, {
3044
+ packages: explicitPkgs.length > 0 ? explicitPkgs : undefined,
3045
+ });
3046
+ if (result.failed?.length > 0) {
3047
+ ctx.stderr.write('\x1b[31mFailed: ' + result.failed.join(', ') + '\x1b[0m\n');
3048
+ }
3049
+ // [HONEST INSTALL MESSAGE P0a] Yellow + "(N failed, see above)"
3050
+ // when partial. Green only when failed.length === 0.
3051
+ const partial = (result.failed?.length || 0) > 0;
3052
+ const color = partial ? '\x1b[33m' : '\x1b[32m';
3053
+ const suffix = partial ? ` (${result.failed.length} failed, see above)` : '';
3054
+ ctx.stdout.write(`\n${color}added ${result.installed?.length || 0} packages (${result.totalFiles || 0} files) in ${((result.elapsed || 0) / 1000).toFixed(1)}s${suffix}\x1b[0m\n`);
3055
+ if (result.cachedHits > 0) {
3056
+ ctx.stdout.write(`\x1b[2m (${result.cachedHits} from cache)\x1b[0m\n`);
3057
+ }
3058
+ return result.failed?.length > 0 ? 1 : 0;
3059
+ }
3060
+ catch (e) {
3061
+ ctx.stderr.write(`\x1b[31mnpm install failed: ${e?.message}\x1b[0m\n`);
3062
+ return 1;
3063
+ }
3064
+ }
3065
+ // ── npm create <pkg> / npm init <pkg> → npx create-<pkg> ─────────
3066
+ //
3067
+ // Per the npm spec, `npm create X args...` and `npm init X args...`
3068
+ // (when X is supplied) are sugar for invoking the `create-X`
3069
+ // initializer package via npx. Specifically:
3070
+ //
3071
+ // npm create foo args... → npx create-foo args...
3072
+ // npm create foo@1.2 args... → npx create-foo@1.2 args...
3073
+ // npm create @scope/foo args... → npx @scope/create-foo args...
3074
+ // npm create @scope args... → npx @scope/create args...
3075
+ //
3076
+ // `npm init` (no args) is a different beast — it scaffolds a
3077
+ // package.json interactively. The `sub === 'init'` branch above
3078
+ // handles the no-arg case; here we only intercept the
3079
+ // initializer-package case (1+ args after `init`).
3080
+ //
3081
+ // Without this routing, `npm create vite@latest mvp -- --template
3082
+ // react-ts` hits lifo-sh's core npm dispatch which only knows
3083
+ // {init, install/i/add, uninstall/remove/rm/un, list/ls,
3084
+ // run/run-script, start, test, info/view/show, search, version}
3085
+ // — and emits "npm: unknown command 'create'". Every modern
3086
+ // framework's `create-*` flow (create-vite, create-next-app
3087
+ // routed via "npm create", create-cloudflare, create-astro, etc.)
3088
+ // depends on this.
3089
+ //
3090
+ // This is a primitive: one fix, every framework wins.
3091
+ if (sub === 'create' || (sub === 'init' && args.length >= 2 && !args[1].startsWith('-'))) {
3092
+ const arg1 = args[1];
3093
+ if (!arg1) {
3094
+ ctx.stderr.write('npm create: missing package name\n');
3095
+ ctx.stderr.write('Usage: npm create <pkg> [args...]\n');
3096
+ return 1;
3097
+ }
3098
+ // Parse pkg + version. Scope-aware:
3099
+ // @scope → @scope/create
3100
+ // @scope/foo → @scope/create-foo
3101
+ // foo → create-foo
3102
+ // foo@1.2.3 → create-foo@1.2.3
3103
+ // foo@latest → create-foo@latest
3104
+ function rewriteToCreatePkg(spec) {
3105
+ // Strip an optional version range and re-append after the rewrite.
3106
+ const atIdx = spec.lastIndexOf('@');
3107
+ const hasVersion = atIdx > 0; // a leading @ is the scope; not a version
3108
+ const bare = hasVersion ? spec.slice(0, atIdx) : spec;
3109
+ const version = hasVersion ? spec.slice(atIdx) : '';
3110
+ let pkg;
3111
+ if (bare.startsWith('@')) {
3112
+ const slash = bare.indexOf('/');
3113
+ if (slash < 0) {
3114
+ // @scope → @scope/create
3115
+ pkg = bare + '/create';
3116
+ }
3117
+ else {
3118
+ // @scope/foo → @scope/create-foo
3119
+ const scope = bare.slice(0, slash);
3120
+ const name = bare.slice(slash + 1);
3121
+ pkg = scope + '/create-' + name;
3122
+ }
3123
+ }
3124
+ else {
3125
+ pkg = 'create-' + bare;
3126
+ }
3127
+ return pkg + version;
3128
+ }
3129
+ const createPkg = rewriteToCreatePkg(arg1);
3130
+ const passThrough = args.slice(2);
3131
+ // `npm create` accepts an optional `--` separator to push the
3132
+ // remaining args to the create script; npx doesn't need a
3133
+ // separator (positional args after the package name go to the
3134
+ // package). We strip a single literal `--` token if present so
3135
+ // `npm create vite@latest mvp -- --template react-ts` becomes
3136
+ // `npx --yes create-vite@latest mvp --template react-ts`.
3137
+ const stripped = passThrough.filter((a, i, arr) => !(a === '--' && i < arr.length - 1) && !(a === '--' && arr.indexOf('--') === i));
3138
+ // Inform the user what we're routing to — matches npm's own
3139
+ // visible "npx" line so the create flow is honest.
3140
+ ctx.stdout.write(`> npx --yes ${createPkg}${stripped.length ? ' ' + stripped.join(' ') : ''}\n`);
3141
+ // Dispatch through the npx registry entry (which itself falls
3142
+ // back to lifo-sh core npx for install + exec). `--yes` skips
3143
+ // the "Ok to proceed? (y)" prompt.
3144
+ const npxHandler = await registry.resolve('npx');
3145
+ if (!npxHandler) {
3146
+ ctx.stderr.write('npm create: npx command unavailable\n');
3147
+ return 1;
3148
+ }
3149
+ return await npxHandler({
3150
+ ...ctx,
3151
+ args: ['--yes', createPkg, ...stripped],
3152
+ });
3153
+ }
3154
+ // Fall through to core npm for other subcommands
3155
+ return coreNpmCmd(ctx);
3156
+ });
3157
+ // npx: check node_modules/.bin first, then built-in commands, then
3158
+ // Nimbus-native install + run via NpmInstaller (handles major-only
3159
+ // semver ranges correctly — see remix-wrappy-fix wave). The lifo-sh
3160
+ // core createNpxCommand is retained as a last-resort fallback for
3161
+ // any edge case the Nimbus path doesn't handle (e.g. `--help`
3162
+ // banners).
3163
+ const coreNpxCmd = createNpxCommand(registry, shellExecute);
3164
+ registry.register('npx', async (ctx) => {
3165
+ const npxArgs = ctx.args || [];
3166
+ const cmd = npxArgs[0];
3167
+ if (!cmd) {
3168
+ ctx.stderr.write('Usage: npx <command> [args...]\n');
3169
+ return 1;
3170
+ }
3171
+ // Check if it's a built-in command (vite, esbuild, etc.)
3172
+ const resolved = await registry.resolve(cmd);
3173
+ if (resolved) {
3174
+ return await resolved({ ...ctx, args: npxArgs.slice(1) });
3175
+ }
3176
+ // Nimbus-native npx install + run path. Routes the package
3177
+ // install through NpmInstaller (full-packument-resolve) instead
3178
+ // of @lifo-sh/core's per-version-endpoint shortcut which drops
3179
+ // major-only-range transitive deps (wrappy:'1', inherits:'2',
3180
+ // etc.). See src/npm/npx-install.ts for the audit.
3181
+ const { resolveNpxBinary } = await import('../npm/npx-install.js');
3182
+ self.ensureNpmInstaller((msg) => ctx.stdout.write('[npm] ' + msg + '\n'));
3183
+ self.ensureSqliteFs();
3184
+ const installer = self.npmInstaller;
3185
+ const resolveResult = await resolveNpxBinary(installer, self.sqliteFs, ctx.cwd || '/home/user', npxArgs, (msg) => ctx.stdout.write(msg + '\n'));
3186
+ if (resolveResult.ok && resolveResult.binPath) {
3187
+ const nodeCmd = await registry.resolve('node');
3188
+ if (nodeCmd) {
3189
+ return await nodeCmd({
3190
+ ...ctx,
3191
+ args: [resolveResult.binPath, ...(resolveResult.binArgs || [])],
3192
+ });
3193
+ }
3194
+ // Node not registered — should never happen but fall through
3195
+ // to core for safety.
3196
+ }
3197
+ else if (resolveResult.error && !/^(--version|-v|--help|-h|missing-cmd)$/.test(resolveResult.error)) {
3198
+ // Real install/resolve failure — surface and bail.
3199
+ ctx.stderr.write(resolveResult.error + '\n');
3200
+ return 1;
3201
+ }
3202
+ // --version / --help / missing-cmd / node-unregistered → fall
3203
+ // through to core npx for its banner / help output.
3204
+ return coreNpxCmd(ctx);
3205
+ });
3206
+ // ── Register process commands (enhanced with facet process tracking) ──
3207
+ registry.register('ps', async (ctx) => {
3208
+ ctx.stdout.write(' PID STATUS COMMAND\n');
3209
+ for (const proc of self.processTable.getAll()) {
3210
+ // Prefer log-store exit info over ProcessTable's: the store has
3211
+ // the authoritative code and survives reap. For `running`, rely
3212
+ // on ProcessTable (store has no "running" concept).
3213
+ let status;
3214
+ if (proc.state === 'running') {
3215
+ status = '\x1b[32mrunning\x1b[0m';
3216
+ }
3217
+ else if (proc.state === 'killed') {
3218
+ status = `\x1b[33mkilled(${proc.exitCode ?? 137})\x1b[0m`;
3219
+ }
3220
+ else {
3221
+ // 'exited' — distinguish clean vs crashed.
3222
+ const code = proc.exitCode ?? 0;
3223
+ status = code === 0
3224
+ ? `\x1b[2mexited(0)\x1b[0m`
3225
+ : `\x1b[31mcrashed(${code})\x1b[0m`;
3226
+ }
3227
+ ctx.stdout.write(` ${String(proc.pid).padStart(3)} ${status.padEnd(26)} ${proc.command}\n`);
3228
+ }
3229
+ // Show vite dev server
3230
+ if (self.viteDevServer?.isRunning) {
3231
+ ctx.stdout.write(' \x1b[33m---\x1b[0m \x1b[32mrunning\x1b[0m vite dev server (' + self.viteBasePath + '/)\n');
3232
+ }
3233
+ if (self.processTable.getAll().length === 0 && !self.viteDevServer?.isRunning) {
3234
+ ctx.stdout.write(' (no processes)\n');
3235
+ }
3236
+ return 0;
3237
+ });
3238
+ // ── `logs <pid>` — tail per-process ring buffer ──
3239
+ // Flags:
3240
+ // -f / --follow stream new chunks until the process exits
3241
+ // -n / --lines N number of lines from the tail (default 200)
3242
+ // --bytes N max bytes from the tail (overrides --lines)
3243
+ // --plain strip ANSI escapes on output (keeps buffer raw)
3244
+ registry.register('logs', async (ctx) => {
3245
+ const args = ctx.args || [];
3246
+ const follow = args.includes('-f') || args.includes('--follow');
3247
+ const plain = args.includes('--plain');
3248
+ let lines = 200;
3249
+ let bytes;
3250
+ for (let i = 0; i < args.length; i++) {
3251
+ const a = args[i];
3252
+ if ((a === '-n' || a === '--lines') && args[i + 1]) {
3253
+ const n = parseInt(args[i + 1], 10);
3254
+ if (!isNaN(n) && n > 0)
3255
+ lines = n;
3256
+ i++;
3257
+ }
3258
+ else if (a === '--bytes' && args[i + 1]) {
3259
+ const n = parseInt(args[i + 1], 10);
3260
+ if (!isNaN(n) && n > 0)
3261
+ bytes = n;
3262
+ i++;
3263
+ }
3264
+ }
3265
+ const pidArg = args.find(a => /^\d+$/.test(a));
3266
+ if (!pidArg) {
3267
+ ctx.stderr.write('usage: logs [-f] [-n LINES | --bytes N] [--plain] <pid>\n');
3268
+ return 1;
3269
+ }
3270
+ const pid = parseInt(pidArg, 10);
3271
+ if (!self.processLogs.has(pid)) {
3272
+ ctx.stderr.write(`no logs for pid ${pid}\n`);
3273
+ return 1;
3274
+ }
3275
+ // Paint a single chunk for live-stream (follow-mode) rendering.
3276
+ // `--plain` strips ANSI per chunk — safe for live output because
3277
+ // individual streamed chunks from the RPC layer never split an
3278
+ // escape sequence (the RPC boundary always delivers a complete
3279
+ // write call). Backfill is different (see below).
3280
+ const renderChunk = (c) => {
3281
+ let data = c.data;
3282
+ if (plain)
3283
+ data = stripAnsi(data);
3284
+ if (c.stream === 'stderr' && !plain) {
3285
+ return `\x1b[31m${data}\x1b[0m`;
3286
+ }
3287
+ return data;
3288
+ };
3289
+ // Backfill. Concatenate same-stream consecutive chunks BEFORE
3290
+ // stripping so that any ANSI escape split across chunk boundaries
3291
+ // (by the 4 KB splitter inside ProcessLogStore) gets rejoined and
3292
+ // stripped cleanly instead of leaking `1m` / `[31m` fragments.
3293
+ const tailOpts = bytes !== undefined ? { bytes } : { lines };
3294
+ const chunks = self.processLogs.tail(pid, tailOpts);
3295
+ let group = [];
3296
+ const flushGroup = () => {
3297
+ if (group.length === 0)
3298
+ return;
3299
+ const stream = group[0].stream;
3300
+ let data = group.map(c => c.data).join('');
3301
+ if (plain)
3302
+ data = stripAnsi(data);
3303
+ if (stream === 'stderr' && !plain) {
3304
+ ctx.stdout.write(`\x1b[31m${data}\x1b[0m`);
3305
+ }
3306
+ else {
3307
+ ctx.stdout.write(data);
3308
+ }
3309
+ group = [];
3310
+ };
3311
+ for (const c of chunks) {
3312
+ if (group.length > 0 && group[group.length - 1].stream !== c.stream) {
3313
+ flushGroup();
3314
+ }
3315
+ group.push(c);
3316
+ }
3317
+ flushGroup();
3318
+ if (!follow) {
3319
+ // Footer only when process has exited already.
3320
+ const exit = self.processLogs.getExit(pid);
3321
+ if (exit) {
3322
+ ctx.stdout.write(`\r\n\x1b[2m[process exited with code ${exit.code}${exit.reason ? ` (${exit.reason})` : ''}]\x1b[0m\r\n`);
3323
+ }
3324
+ return 0;
3325
+ }
3326
+ // Follow mode: subscribe to live appends, poll for exit.
3327
+ const entry = self.processTable.get(pid);
3328
+ const alreadyExited = !entry || entry.state !== 'running' || self.processLogs.getExit(pid);
3329
+ if (alreadyExited) {
3330
+ const exit = self.processLogs.getExit(pid);
3331
+ if (exit) {
3332
+ ctx.stdout.write(`\r\n\x1b[2m[process exited with code ${exit.code}${exit.reason ? ` (${exit.reason})` : ''}]\x1b[0m\r\n`);
3333
+ }
3334
+ return 0;
3335
+ }
3336
+ return await new Promise((resolve) => {
3337
+ let done = false;
3338
+ const finish = (code) => {
3339
+ if (done)
3340
+ return;
3341
+ done = true;
3342
+ unsub();
3343
+ unsubExit();
3344
+ resolve(code);
3345
+ };
3346
+ const unsub = self.processLogs.subscribe(pid, (c) => {
3347
+ ctx.stdout.write(renderChunk(c));
3348
+ });
3349
+ const unsubExit = self.processLogs.subscribeExit(pid, (exit) => {
3350
+ ctx.stdout.write(`\r\n\x1b[2m[process exited with code ${exit.code}${exit.reason ? ` (${exit.reason})` : ''}]\x1b[0m\r\n`);
3351
+ finish(0);
3352
+ });
3353
+ // TOCTOU: the process may have exited between our `alreadyExited`
3354
+ // check above and these subscribe calls. Re-check now that the
3355
+ // exit subscriber is wired — if exit already set, the subscribe
3356
+ // callback never fires, so synthesize the footer ourselves.
3357
+ const exitNow = self.processLogs.getExit(pid);
3358
+ if (exitNow) {
3359
+ ctx.stdout.write(`\r\n\x1b[2m[process exited with code ${exitNow.code}${exitNow.reason ? ` (${exitNow.reason})` : ''}]\x1b[0m\r\n`);
3360
+ finish(0);
3361
+ return;
3362
+ }
3363
+ // If ctx exposes an AbortSignal (Ctrl+C wired by the shell),
3364
+ // honor it. Otherwise, follow-mode ends only on process exit.
3365
+ if (ctx.signal && typeof ctx.signal.addEventListener === 'function') {
3366
+ ctx.signal.addEventListener('abort', () => finish(130));
3367
+ }
3368
+ });
3369
+ });
3370
+ registry.register('jobs', async (ctx) => {
3371
+ const running = self.processTable.getRunning();
3372
+ if (running.length === 0 && !self.viteDevServer?.isRunning) {
3373
+ ctx.stdout.write('No background jobs.\n');
3374
+ return 0;
3375
+ }
3376
+ for (let i = 0; i < running.length; i++) {
3377
+ ctx.stdout.write(`[${i + 1}] Running ${running[i].command} (pid ${running[i].pid})\n`);
3378
+ }
3379
+ if (self.viteDevServer?.isRunning) {
3380
+ ctx.stdout.write(`[${running.length + 1}] Running vite dev server\n`);
3381
+ }
3382
+ return 0;
3383
+ });
3384
+ registry.register('kill', async (ctx) => {
3385
+ const pidArg = ctx.args[0];
3386
+ if (!pidArg) {
3387
+ ctx.stderr.write('Usage: kill <pid>\n');
3388
+ return 1;
3389
+ }
3390
+ const pid = parseInt(pidArg);
3391
+ if (isNaN(pid)) {
3392
+ ctx.stderr.write('kill: invalid pid\n');
3393
+ return 1;
3394
+ }
3395
+ // Primitives wave (P11): if the target is the vite shim PID
3396
+ // (registered by P5's long-running spawn), tear down the
3397
+ // in-process viteDevServer / cirrusReal too — facetManager.kill
3398
+ // only handles real Worker-Loader facets, and would leave the
3399
+ // shim running with its port registered against a dead PID.
3400
+ if (self._viteShimPid === pid) {
3401
+ try {
3402
+ if (self.cirrusReal?.isRunning) {
3403
+ self.cirrusReal.stop(self.ctx);
3404
+ self.cirrusReal = null;
3405
+ }
3406
+ if (self.viteDevServer?.isRunning) {
3407
+ self.viteDevServer.stop();
3408
+ self.viteDevServer = null;
3409
+ try {
3410
+ await self.ctx.storage.delete('vite-config');
3411
+ }
3412
+ catch { }
3413
+ }
3414
+ }
3415
+ catch (e) {
3416
+ ctx.stderr.write('kill: while stopping vite shim: ' + (e?.message || e) + '\n');
3417
+ }
3418
+ try {
3419
+ self.portRegistry.unregisterByPid(pid);
3420
+ }
3421
+ catch { }
3422
+ try {
3423
+ self.processTable.kill(pid);
3424
+ }
3425
+ catch { }
3426
+ notifyTerminalEvent(self.terminal, {
3427
+ type: 'exit', pid, code: 137, command: 'vite',
3428
+ });
3429
+ self._viteShimPid = null;
3430
+ self._viteShimPort = null;
3431
+ ctx.stdout.write(`Process ${pid} killed.\n`);
3432
+ return 0;
3433
+ }
3434
+ if (self.facetManager?.kill(pid)) {
3435
+ ctx.stdout.write(`Process ${pid} killed.\n`);
3436
+ return 0;
3437
+ }
3438
+ ctx.stderr.write(`kill: no such process: ${pid}\n`);
3439
+ return 1;
3440
+ });
3441
+ registry.register('top', createTopCommand(processRegistry));
3442
+ registry.register('watch', createWatchCommand(registry));
3443
+ registry.register('help', createHelpCommand(registry));
3444
+ // ── Rehydrate globally-installed npm packages ──
3445
+ try {
3446
+ rehydrateGlobalPackages(self.kernel.vfs, registry);
3447
+ }
3448
+ catch { }
3449
+ // ── Phase O: one-shot online output [B'.1] ─────────────────────────
3450
+ //
3451
+ // Only emit cold-start UI (MOTD, starter-app hint, framework-detect)
3452
+ // when this initSession is actually a cold start. A silent re-init —
3453
+ // the same DO instance reaccepting a /ws upgrade after wsClose —
3454
+ // skips this block entirely. The user sees their persisted shell
3455
+ // (cwd preserved, env preserved) without a banner reprint that would
3456
+ // make the recovery look like a reset.
3457
+ //
3458
+ // The cold-vs-rehydrate discriminator is `persisted.hasPersistedState`
3459
+ // — true iff at least one nimbus_session_kv row was found at Phase R.
3460
+ // A truly cold DO (or one whose session-state was explicitly cleared
3461
+ // via /api/_test/session/reset) reads zero rows and falls through to
3462
+ // the cold-start path below.
3463
+ // [B'.4] Phase boundary: Build complete, transition to either
3464
+ // Online (cold start) or hydrated (warm re-init). Phase O runs
3465
+ // only on cold start; warm sessions skip the MOTD block and go
3466
+ // directly to hydrated.
3467
+ if (!persisted.hasPersistedState) {
3468
+ setPhase(self, 'online', 'init-session');
3469
+ // ── Show MOTD ──
3470
+ try {
3471
+ const motd = self.sqliteFs.readFileString('etc/motd');
3472
+ self.terminal.write(motd + '\r\n');
3473
+ }
3474
+ catch { }
3475
+ // ── Starter-app hint (only if seed sentinel still exists) ──
3476
+ // We check the live VFS, not a static file, so that if the user
3477
+ // deletes ~/.nimbus-seeded (or ~/app) the hint stops appearing on
3478
+ // next login.
3479
+ try {
3480
+ if (hasSeededProject(self.sqliteFs) && self.sqliteFs.exists(SEED_PROJECT_DIR)) {
3481
+ self.terminal.write('\x1b[2mStarter app ready at \x1b[36m~/app\x1b[0m\x1b[2m — try:\x1b[0m\r\n' +
3482
+ ' \x1b[36mcd app && npm install && npm run dev\x1b[0m\r\n\r\n');
3483
+ }
3484
+ }
3485
+ catch { }
3486
+ // ── W11: framework detection MOTD line ──
3487
+ // If ~/app has a recognizable framework, print one informational line.
3488
+ // Purely advisory — does not change boot behaviour. Fire-and-forget
3489
+ // because initSession is sync; any failure is silently swallowed.
3490
+ void (async () => {
3491
+ try {
3492
+ const projDir = SEED_PROJECT_DIR;
3493
+ const pkgPath = projDir + '/package.json';
3494
+ if (!self.sqliteFs.exists(pkgPath))
3495
+ return;
3496
+ const pkg = JSON.parse(self.sqliteFs.readFileString(pkgPath));
3497
+ const files = new Set();
3498
+ try {
3499
+ for (const e of self.sqliteFs.readdir(projDir))
3500
+ files.add(e.name);
3501
+ }
3502
+ catch { }
3503
+ const fileContents = {};
3504
+ for (const c of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
3505
+ if (files.has(c)) {
3506
+ try {
3507
+ fileContents[c] = self.sqliteFs.readFileString(projDir + '/' + c);
3508
+ }
3509
+ catch { }
3510
+ }
3511
+ }
3512
+ const { detectFramework, describeDetect } = await import('../runtime/framework-detect.js');
3513
+ const result = detectFramework({
3514
+ pkg: { dependencies: pkg.dependencies, devDependencies: pkg.devDependencies, scripts: pkg.scripts },
3515
+ files,
3516
+ fileContents,
3517
+ });
3518
+ if (result.framework !== 'unknown' && result.framework !== 'vite' && self.terminal) {
3519
+ self.terminal.write('\x1b[2m[nimbus]\x1b[0m \x1b[36m' + describeDetect(result) + '\x1b[0m\r\n\r\n');
3520
+ }
3521
+ }
3522
+ catch { /* MOTD line is non-critical */ }
3523
+ })();
3524
+ }
3525
+ // ── Phase O cont.: record the lifecycle transition [B'.1] ──────────
3526
+ //
3527
+ // C'.2 recovery_event ring entry — every initSession call records
3528
+ // either a cold→hydrated (first connect) or drained→hydrated
3529
+ // (silent re-init) transition. The probe at audit/probes/
3530
+ // interactive-liveness/error-recovery/ asserts both states show
3531
+ // dataLoss=false. Track B' guarantees this for in-isolate transitions;
3532
+ // a true cold-isolate boot reads no SQL row and shows
3533
+ // snapshotKeysRehydrated=0 (still dataLoss=false because there was
3534
+ // no state to lose).
3535
+ //
3536
+ // [B'.4] We also set the live phase indicator to 'hydrated' here.
3537
+ // For cold starts, the prior phase was 'online' (Phase O ran);
3538
+ // for warm re-inits, the prior phase was 'build' (Phase O
3539
+ // skipped). Setting to 'hydrated' is the terminal init phase
3540
+ // both paths end on.
3541
+ {
3542
+ const fromState = persisted.hasPersistedState ? 'drained' : 'cold';
3543
+ const snapshotKeys = countSessionStateKeys(self.ctx);
3544
+ try {
3545
+ recordRecoveryEvent({
3546
+ at: Date.now(),
3547
+ fromState: fromState,
3548
+ toState: 'hydrated',
3549
+ trigger: 'init-session',
3550
+ isolateGen: self._w9IsolateGen,
3551
+ dataLoss: false,
3552
+ snapshotKeysRehydrated: snapshotKeys,
3553
+ });
3554
+ }
3555
+ catch { /* observability is non-critical */ }
3556
+ // [B'.4] Update live phase. setPhase records its own transition
3557
+ // recovery_event; this one is the legacy/coarse marker that
3558
+ // C'.3 + B'.1 probes look for.
3559
+ self._b4Phase = 'hydrated';
3560
+ // Stamp hydrated_at for the /api/_diag/session debug endpoint.
3561
+ try {
3562
+ stampHydratedAt(self.ctx, Date.now());
3563
+ }
3564
+ catch { /* non-critical */ }
3565
+ }
3566
+ // ── Start shell ──
3567
+ self.shell.start();
3568
+ (async () => {
3569
+ try {
3570
+ await self.shell.sourceFile('/etc/profile');
3571
+ }
3572
+ catch { }
3573
+ try {
3574
+ await self.shell.sourceFile('/home/user/.nimbusrc');
3575
+ }
3576
+ catch { }
3577
+ })();
3578
+ ws.send(JSON.stringify({ type: 'ready' }));
3579
+ }