@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.
- package/README.md +219 -0
- package/dist/_shared/bytes.d.ts +18 -0
- package/dist/_shared/bytes.d.ts.map +1 -0
- package/dist/_shared/bytes.js +17 -0
- package/dist/_shared/cache-stats.d.ts +146 -0
- package/dist/_shared/cache-stats.d.ts.map +1 -0
- package/dist/_shared/cache-stats.js +162 -0
- package/dist/_shared/exports-resolver.d.ts +76 -0
- package/dist/_shared/exports-resolver.d.ts.map +1 -0
- package/dist/_shared/exports-resolver.js +284 -0
- package/dist/_shared/install-phase.d.ts +47 -0
- package/dist/_shared/install-phase.d.ts.map +1 -0
- package/dist/_shared/install-phase.js +1 -0
- package/dist/_shared/real-node-imports.d.ts +38 -0
- package/dist/_shared/real-node-imports.d.ts.map +1 -0
- package/dist/_shared/real-node-imports.js +46 -0
- package/dist/_shared/retry.d.ts +93 -0
- package/dist/_shared/retry.d.ts.map +1 -0
- package/dist/_shared/retry.js +161 -0
- package/dist/_shared/session-id.d.ts +39 -0
- package/dist/_shared/session-id.d.ts.map +1 -0
- package/dist/_shared/session-id.js +127 -0
- package/dist/_shared/session-router.d.ts +104 -0
- package/dist/_shared/session-router.d.ts.map +1 -0
- package/dist/_shared/session-router.js +165 -0
- package/dist/_shared/w7-frame.d.ts +93 -0
- package/dist/_shared/w7-frame.d.ts.map +1 -0
- package/dist/_shared/w7-frame.js +402 -0
- package/dist/auth/index.d.ts +16 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +15 -0
- package/dist/auth/middleware.d.ts +85 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +150 -0
- package/dist/auth/token.d.ts +96 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/auth/token.js +226 -0
- package/dist/auth/types.d.ts +141 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +114 -0
- package/dist/bindings/d1.d.ts +102 -0
- package/dist/bindings/d1.d.ts.map +1 -0
- package/dist/bindings/d1.js +549 -0
- package/dist/bindings/kv.d.ts +82 -0
- package/dist/bindings/kv.d.ts.map +1 -0
- package/dist/bindings/kv.js +290 -0
- package/dist/bindings/r2.d.ts +137 -0
- package/dist/bindings/r2.d.ts.map +1 -0
- package/dist/bindings/r2.js +379 -0
- package/dist/cirrus-npm-cjs.generated.d.ts +17 -0
- package/dist/cirrus-npm-cjs.generated.d.ts.map +1 -0
- package/dist/cirrus-npm-cjs.generated.js +41 -0
- package/dist/cirrus-plugin-react.generated.d.ts +34 -0
- package/dist/cirrus-plugin-react.generated.d.ts.map +1 -0
- package/dist/cirrus-plugin-react.generated.js +33 -0
- package/dist/constants.d.ts +34 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +116 -0
- package/dist/esbuild-wasm-bundle.generated.d.ts +41 -0
- package/dist/esbuild-wasm-bundle.generated.d.ts.map +1 -0
- package/dist/esbuild-wasm-bundle.generated.js +40 -0
- package/dist/facets/cirrus-real.d.ts +147 -0
- package/dist/facets/cirrus-real.d.ts.map +1 -0
- package/dist/facets/cirrus-real.js +981 -0
- package/dist/facets/inner-do-registry.d.ts +41 -0
- package/dist/facets/inner-do-registry.d.ts.map +1 -0
- package/dist/facets/inner-do-registry.js +51 -0
- package/dist/facets/manager.d.ts +314 -0
- package/dist/facets/manager.d.ts.map +1 -0
- package/dist/facets/manager.js +2681 -0
- package/dist/facets/process.d.ts +318 -0
- package/dist/facets/process.d.ts.map +1 -0
- package/dist/facets/process.js +567 -0
- package/dist/facets/real-vite-fs-shim.d.ts +151 -0
- package/dist/facets/real-vite-fs-shim.d.ts.map +1 -0
- package/dist/facets/real-vite-fs-shim.js +1279 -0
- package/dist/facets/real-vite-hmr.d.ts +142 -0
- package/dist/facets/real-vite-hmr.d.ts.map +1 -0
- package/dist/facets/real-vite-hmr.js +476 -0
- package/dist/facets/vite-dev-server.d.ts +293 -0
- package/dist/facets/vite-dev-server.d.ts.map +1 -0
- package/dist/facets/vite-dev-server.js +2580 -0
- package/dist/facets/wasm-swap-registry.d.ts +261 -0
- package/dist/facets/wasm-swap-registry.d.ts.map +1 -0
- package/dist/facets/wasm-swap-registry.js +551 -0
- package/dist/facets/ws-terminal.d.ts +107 -0
- package/dist/facets/ws-terminal.d.ts.map +1 -0
- package/dist/facets/ws-terminal.js +207 -0
- package/dist/git/commands.d.ts +13 -0
- package/dist/git/commands.d.ts.map +1 -0
- package/dist/git/commands.js +633 -0
- package/dist/git/network-facet.d.ts +61 -0
- package/dist/git/network-facet.d.ts.map +1 -0
- package/dist/git/network-facet.js +700 -0
- package/dist/git-bundle.generated.d.ts +16 -0
- package/dist/git-bundle.generated.d.ts.map +1 -0
- package/dist/git-bundle.generated.js +15 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/loaders/child-process/spawn-facet.d.ts +90 -0
- package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -0
- package/dist/loaders/child-process/spawn-facet.js +103 -0
- package/dist/loaders/child-process/spawn-pool.d.ts +110 -0
- package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -0
- package/dist/loaders/child-process/spawn-pool.js +161 -0
- package/dist/loaders/fanout-pool.d.ts +195 -0
- package/dist/loaders/fanout-pool.d.ts.map +1 -0
- package/dist/loaders/fanout-pool.js +326 -0
- package/dist/loaders/generated-workers.d.ts +26 -0
- package/dist/loaders/generated-workers.d.ts.map +1 -0
- package/dist/loaders/generated-workers.js +25 -0
- package/dist/loaders/index.d.ts +15 -0
- package/dist/loaders/index.d.ts.map +1 -0
- package/dist/loaders/index.js +13 -0
- package/dist/loaders/loader-pool.d.ts +273 -0
- package/dist/loaders/loader-pool.d.ts.map +1 -0
- package/dist/loaders/loader-pool.js +624 -0
- package/dist/loaders/npm-resolve-preamble.d.ts +34 -0
- package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -0
- package/dist/loaders/npm-resolve-preamble.js +237 -0
- package/dist/loaders/pre-bundle-preamble.d.ts +41 -0
- package/dist/loaders/pre-bundle-preamble.d.ts.map +1 -0
- package/dist/loaders/pre-bundle-preamble.js +114 -0
- package/dist/loaders/vendor/codegen.d.ts +24 -0
- package/dist/loaders/vendor/codegen.d.ts.map +1 -0
- package/dist/loaders/vendor/codegen.js +44 -0
- package/dist/loaders/vendor/errors.d.ts +24 -0
- package/dist/loaders/vendor/errors.d.ts.map +1 -0
- package/dist/loaders/vendor/errors.js +46 -0
- package/dist/loaders/vendor/index.d.ts +16 -0
- package/dist/loaders/vendor/index.d.ts.map +1 -0
- package/dist/loaders/vendor/index.js +11 -0
- package/dist/loaders/vendor/pool.d.ts +88 -0
- package/dist/loaders/vendor/pool.d.ts.map +1 -0
- package/dist/loaders/vendor/pool.js +353 -0
- package/dist/loaders/vendor/primitives.d.ts +11 -0
- package/dist/loaders/vendor/primitives.d.ts.map +1 -0
- package/dist/loaders/vendor/primitives.js +33 -0
- package/dist/loaders/vendor/serialize.d.ts +3 -0
- package/dist/loaders/vendor/serialize.d.ts.map +1 -0
- package/dist/loaders/vendor/serialize.js +25 -0
- package/dist/loaders/vendor/types.d.ts +60 -0
- package/dist/loaders/vendor/types.d.ts.map +1 -0
- package/dist/loaders/vendor/types.js +4 -0
- package/dist/npm/cache.d.ts +136 -0
- package/dist/npm/cache.d.ts.map +1 -0
- package/dist/npm/cache.js +429 -0
- package/dist/npm/install-batch-facet.d.ts +129 -0
- package/dist/npm/install-batch-facet.d.ts.map +1 -0
- package/dist/npm/install-batch-facet.js +598 -0
- package/dist/npm/install-facet.d.ts +29 -0
- package/dist/npm/install-facet.d.ts.map +1 -0
- package/dist/npm/install-facet.js +9 -0
- package/dist/npm/installer.d.ts +239 -0
- package/dist/npm/installer.d.ts.map +1 -0
- package/dist/npm/installer.js +2022 -0
- package/dist/npm/npx-install.d.ts +74 -0
- package/dist/npm/npx-install.d.ts.map +1 -0
- package/dist/npm/npx-install.js +302 -0
- package/dist/npm/pre-bundle-facet.d.ts +168 -0
- package/dist/npm/pre-bundle-facet.d.ts.map +1 -0
- package/dist/npm/pre-bundle-facet.js +653 -0
- package/dist/npm/r2-cache.d.ts +286 -0
- package/dist/npm/r2-cache.d.ts.map +1 -0
- package/dist/npm/r2-cache.js +506 -0
- package/dist/npm/resolve-facet.d.ts +202 -0
- package/dist/npm/resolve-facet.d.ts.map +1 -0
- package/dist/npm/resolve-facet.js +833 -0
- package/dist/npm/resolve-one-facet.d.ts +204 -0
- package/dist/npm/resolve-one-facet.d.ts.map +1 -0
- package/dist/npm/resolve-one-facet.js +437 -0
- package/dist/npm/resolver.d.ts +109 -0
- package/dist/npm/resolver.d.ts.map +1 -0
- package/dist/npm/resolver.js +899 -0
- package/dist/npm/tarball-stream.d.ts +85 -0
- package/dist/npm/tarball-stream.d.ts.map +1 -0
- package/dist/npm/tarball-stream.js +205 -0
- package/dist/npm/tarball.d.ts +51 -0
- package/dist/npm/tarball.d.ts.map +1 -0
- package/dist/npm/tarball.js +185 -0
- package/dist/observability/diag-counters.d.ts +292 -0
- package/dist/observability/diag-counters.d.ts.map +1 -0
- package/dist/observability/diag-counters.js +302 -0
- package/dist/observability/heap-estimate.d.ts +166 -0
- package/dist/observability/heap-estimate.d.ts.map +1 -0
- package/dist/observability/heap-estimate.js +192 -0
- package/dist/observability/heavy-alloc-coord.d.ts +95 -0
- package/dist/observability/heavy-alloc-coord.d.ts.map +1 -0
- package/dist/observability/heavy-alloc-coord.js +149 -0
- package/dist/observability/oom-classify.d.ts +56 -0
- package/dist/observability/oom-classify.d.ts.map +1 -0
- package/dist/observability/oom-classify.js +118 -0
- package/dist/observability/oom-discriminator.d.ts +192 -0
- package/dist/observability/oom-discriminator.d.ts.map +1 -0
- package/dist/observability/oom-discriminator.js +233 -0
- package/dist/real-vite-bundle.generated.d.ts +46 -0
- package/dist/real-vite-bundle.generated.d.ts.map +1 -0
- package/dist/real-vite-bundle.generated.js +53 -0
- package/dist/replica/routing.d.ts +171 -0
- package/dist/replica/routing.d.ts.map +1 -0
- package/dist/replica/routing.js +288 -0
- package/dist/replica/suspension.d.ts +36 -0
- package/dist/replica/suspension.d.ts.map +1 -0
- package/dist/replica/suspension.js +49 -0
- package/dist/router/index.d.ts +156 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +220 -0
- package/dist/runtime/assets-loader.d.ts +71 -0
- package/dist/runtime/assets-loader.d.ts.map +1 -0
- package/dist/runtime/assets-loader.js +128 -0
- package/dist/runtime/barrel-detect.d.ts +46 -0
- package/dist/runtime/barrel-detect.d.ts.map +1 -0
- package/dist/runtime/barrel-detect.js +73 -0
- package/dist/runtime/barrel-synthesizer.d.ts +181 -0
- package/dist/runtime/barrel-synthesizer.d.ts.map +1 -0
- package/dist/runtime/barrel-synthesizer.js +541 -0
- package/dist/runtime/bun-repl.d.ts +46 -0
- package/dist/runtime/bun-repl.d.ts.map +1 -0
- package/dist/runtime/bun-repl.js +326 -0
- package/dist/runtime/bun-runner.d.ts +66 -0
- package/dist/runtime/bun-runner.d.ts.map +1 -0
- package/dist/runtime/bun-runner.js +219 -0
- package/dist/runtime/clang-runner.d.ts +38 -0
- package/dist/runtime/clang-runner.d.ts.map +1 -0
- package/dist/runtime/clang-runner.js +1033 -0
- package/dist/runtime/comment-strip.d.ts +37 -0
- package/dist/runtime/comment-strip.d.ts.map +1 -0
- package/dist/runtime/comment-strip.js +74 -0
- package/dist/runtime/esbuild-service.d.ts +201 -0
- package/dist/runtime/esbuild-service.d.ts.map +1 -0
- package/dist/runtime/esbuild-service.js +1444 -0
- package/dist/runtime/esbuild-wasm-bytes.d.ts +87 -0
- package/dist/runtime/esbuild-wasm-bytes.d.ts.map +1 -0
- package/dist/runtime/esbuild-wasm-bytes.js +131 -0
- package/dist/runtime/framework-detect.d.ts +71 -0
- package/dist/runtime/framework-detect.d.ts.map +1 -0
- package/dist/runtime/framework-detect.js +178 -0
- package/dist/runtime/long-running-handle.d.ts +133 -0
- package/dist/runtime/long-running-handle.d.ts.map +1 -0
- package/dist/runtime/long-running-handle.js +192 -0
- package/dist/runtime/node-repl.d.ts +36 -0
- package/dist/runtime/node-repl.d.ts.map +1 -0
- package/dist/runtime/node-repl.js +293 -0
- package/dist/runtime/node-runner.d.ts +111 -0
- package/dist/runtime/node-runner.d.ts.map +1 -0
- package/dist/runtime/node-runner.js +194 -0
- package/dist/runtime/node-shims.d.ts +24 -0
- package/dist/runtime/node-shims.d.ts.map +1 -0
- package/dist/runtime/node-shims.js +3019 -0
- package/dist/runtime/package-manager.d.ts +118 -0
- package/dist/runtime/package-manager.d.ts.map +1 -0
- package/dist/runtime/package-manager.js +420 -0
- package/dist/runtime/port-registry.d.ts +80 -0
- package/dist/runtime/port-registry.d.ts.map +1 -0
- package/dist/runtime/port-registry.js +166 -0
- package/dist/runtime/process-logs-api.d.ts +102 -0
- package/dist/runtime/process-logs-api.d.ts.map +1 -0
- package/dist/runtime/process-logs-api.js +253 -0
- package/dist/runtime/process-logs.d.ts +311 -0
- package/dist/runtime/process-logs.d.ts.map +1 -0
- package/dist/runtime/process-logs.js +656 -0
- package/dist/runtime/process-table.d.ts +65 -0
- package/dist/runtime/process-table.d.ts.map +1 -0
- package/dist/runtime/process-table.js +107 -0
- package/dist/runtime/project-detect.d.ts +21 -0
- package/dist/runtime/project-detect.d.ts.map +1 -0
- package/dist/runtime/project-detect.js +46 -0
- package/dist/runtime/python-repl.d.ts +66 -0
- package/dist/runtime/python-repl.d.ts.map +1 -0
- package/dist/runtime/python-repl.js +563 -0
- package/dist/runtime/python-runner.d.ts +50 -0
- package/dist/runtime/python-runner.d.ts.map +1 -0
- package/dist/runtime/python-runner.js +870 -0
- package/dist/runtime/repl-session.d.ts +160 -0
- package/dist/runtime/repl-session.d.ts.map +1 -0
- package/dist/runtime/repl-session.js +393 -0
- package/dist/runtime/require-resolver.d.ts +52 -0
- package/dist/runtime/require-resolver.d.ts.map +1 -0
- package/dist/runtime/require-resolver.js +690 -0
- package/dist/runtime/router-basename.d.ts +36 -0
- package/dist/runtime/router-basename.d.ts.map +1 -0
- package/dist/runtime/router-basename.js +310 -0
- package/dist/runtime/ruby-repl.d.ts +43 -0
- package/dist/runtime/ruby-repl.d.ts.map +1 -0
- package/dist/runtime/ruby-repl.js +229 -0
- package/dist/runtime/ruby-runner.d.ts +62 -0
- package/dist/runtime/ruby-runner.d.ts.map +1 -0
- package/dist/runtime/ruby-runner.js +737 -0
- package/dist/runtime/runtime-catalog.d.ts +99 -0
- package/dist/runtime/runtime-catalog.d.ts.map +1 -0
- package/dist/runtime/runtime-catalog.js +183 -0
- package/dist/runtime/runtime-registry.d.ts +136 -0
- package/dist/runtime/runtime-registry.d.ts.map +1 -0
- package/dist/runtime/runtime-registry.js +318 -0
- package/dist/runtime/streams.d.ts +13 -0
- package/dist/runtime/streams.d.ts.map +1 -0
- package/dist/runtime/streams.js +402 -0
- package/dist/runtime/wasi-instance.d.ts +190 -0
- package/dist/runtime/wasi-instance.d.ts.map +1 -0
- package/dist/runtime/wasi-instance.js +2044 -0
- package/dist/runtime/wasm-runner.d.ts +117 -0
- package/dist/runtime/wasm-runner.d.ts.map +1 -0
- package/dist/runtime/wasm-runner.js +705 -0
- package/dist/session/bindings.d.ts +168 -0
- package/dist/session/bindings.d.ts.map +1 -0
- package/dist/session/bindings.js +524 -0
- package/dist/session/ctx-exports.d.ts +16 -0
- package/dist/session/ctx-exports.d.ts.map +1 -0
- package/dist/session/ctx-exports.js +22 -0
- package/dist/session/diag.d.ts +62 -0
- package/dist/session/diag.d.ts.map +1 -0
- package/dist/session/diag.js +118 -0
- package/dist/session/fs-watch.d.ts +132 -0
- package/dist/session/fs-watch.d.ts.map +1 -0
- package/dist/session/fs-watch.js +255 -0
- package/dist/session/helpers.d.ts +150 -0
- package/dist/session/helpers.d.ts.map +1 -0
- package/dist/session/helpers.js +472 -0
- package/dist/session/hibernation.d.ts +145 -0
- package/dist/session/hibernation.d.ts.map +1 -0
- package/dist/session/hibernation.js +385 -0
- package/dist/session/init-phases.d.ts +87 -0
- package/dist/session/init-phases.d.ts.map +1 -0
- package/dist/session/init-phases.js +137 -0
- package/dist/session/init.d.ts +46 -0
- package/dist/session/init.d.ts.map +1 -0
- package/dist/session/init.js +3579 -0
- package/dist/session/keys.d.ts +56 -0
- package/dist/session/keys.d.ts.map +1 -0
- package/dist/session/keys.js +55 -0
- package/dist/session/nimbus-session.d.ts +434 -0
- package/dist/session/nimbus-session.d.ts.map +1 -0
- package/dist/session/nimbus-session.js +1084 -0
- package/dist/session/programmatic.d.ts +105 -0
- package/dist/session/programmatic.d.ts.map +1 -0
- package/dist/session/programmatic.js +326 -0
- package/dist/session/replica-routes.d.ts +68 -0
- package/dist/session/replica-routes.d.ts.map +1 -0
- package/dist/session/replica-routes.js +77 -0
- package/dist/session/routes.d.ts +29 -0
- package/dist/session/routes.d.ts.map +1 -0
- package/dist/session/routes.js +1331 -0
- package/dist/session/rpc.d.ts +323 -0
- package/dist/session/rpc.d.ts.map +1 -0
- package/dist/session/rpc.js +808 -0
- package/dist/session/state-store.d.ts +210 -0
- package/dist/session/state-store.d.ts.map +1 -0
- package/dist/session/state-store.js +459 -0
- package/dist/session/supervisor-rpc.d.ts +269 -0
- package/dist/session/supervisor-rpc.d.ts.map +1 -0
- package/dist/session/supervisor-rpc.js +437 -0
- package/dist/session/ws-hibernation-config.d.ts +64 -0
- package/dist/session/ws-hibernation-config.d.ts.map +1 -0
- package/dist/session/ws-hibernation-config.js +89 -0
- package/dist/session/ws.d.ts +95 -0
- package/dist/session/ws.d.ts.map +1 -0
- package/dist/session/ws.js +395 -0
- package/dist/shell/features.d.ts +410 -0
- package/dist/shell/features.d.ts.map +1 -0
- package/dist/shell/features.js +1885 -0
- package/dist/shell/unix-commands.d.ts +15 -0
- package/dist/shell/unix-commands.d.ts.map +1 -0
- package/dist/shell/unix-commands.js +3703 -0
- package/dist/stubs/lifo-ui.d.ts +10 -0
- package/dist/stubs/lifo-ui.d.ts.map +1 -0
- package/dist/stubs/lifo-ui.js +1 -0
- package/dist/tailwind-play.generated.d.ts +19 -0
- package/dist/tailwind-play.generated.d.ts.map +1 -0
- package/dist/tailwind-play.generated.js +18 -0
- package/dist/vfs/dev-provider.d.ts +64 -0
- package/dist/vfs/dev-provider.d.ts.map +1 -0
- package/dist/vfs/dev-provider.js +191 -0
- package/dist/vfs/events.d.ts +51 -0
- package/dist/vfs/events.d.ts.map +1 -0
- package/dist/vfs/events.js +127 -0
- package/dist/vfs/path.d.ts +30 -0
- package/dist/vfs/path.d.ts.map +1 -0
- package/dist/vfs/path.js +41 -0
- package/dist/vfs/seed-project.d.ts +62 -0
- package/dist/vfs/seed-project.d.ts.map +1 -0
- package/dist/vfs/seed-project.js +1045 -0
- package/dist/vfs/sqlite-vfs.d.ts +435 -0
- package/dist/vfs/sqlite-vfs.d.ts.map +1 -0
- package/dist/vfs/sqlite-vfs.js +1670 -0
- package/dist/vfs/symlink-registry.d.ts +64 -0
- package/dist/vfs/symlink-registry.d.ts.map +1 -0
- package/dist/vfs/symlink-registry.js +137 -0
- package/dist/wrangler/nimbus-wrangler.d.ts +157 -0
- package/dist/wrangler/nimbus-wrangler.d.ts.map +1 -0
- package/dist/wrangler/nimbus-wrangler.js +931 -0
- package/package.json +77 -0
- package/public/_assets/cirrus-plugin-react.bundle.js +70740 -0
- package/public/_assets/esbuild-0.24.2.wasm +0 -0
- package/public/_assets/real-vite-bundle.js +101478 -0
- package/public/_assets/rollup.wasm +0 -0
- package/public/favicon.svg +8 -0
- package/public/index.html +565 -0
- package/public/s/index.html +2379 -0
- package/scripts/bundle-esbuild-wasm.mjs +244 -0
- package/scripts/bundle-facet-workers.mjs +137 -0
- package/scripts/bundle-git.mjs +118 -0
- package/scripts/bundle-npm-cjs.mjs +363 -0
- package/scripts/bundle-plugin-react.mjs +480 -0
- package/scripts/bundle-real-vite.mjs +959 -0
- package/scripts/bundle-runtime.mjs +600 -0
- package/scripts/bundle-tailwind-play.mjs +130 -0
- 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
|
+
}
|