ecopages 0.2.0-alpha.5 → 0.2.0-alpha.6

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 CHANGED
@@ -1,16 +1,12 @@
1
1
  # ecopages
2
2
 
3
- > **DRAFT / EXPERIMENTAL**
4
- > This package is currently in a draft state and is subject to significant changes.
3
+ The official CLI for the Ecopages framework.
5
4
 
6
- `ecopages` is a CLI tool for the Ecopages framework. It provides:
7
-
8
- - **Project scaffolding**: Quickly initialize new Ecopages projects from templates using `bunx ecopages init`
9
- - **Command utilities**: Namespaced commands that wrap common Bun operations, automatically detecting and applying your `eco.config.ts`
5
+ It provides scaffolding and development commands to streamline your workflow. It natively wraps your execution environment (Bun or Node) and automatically detects your `eco.config.ts`.
10
6
 
11
7
  ## Quick Start
12
8
 
13
- Initialize a new project:
9
+ Initialize a new project from the default template:
14
10
 
15
11
  ```bash
16
12
  bunx ecopages init my-app
@@ -19,91 +15,85 @@ bun install
19
15
  bun dev
20
16
  ```
21
17
 
22
- ## CLI Utilities
18
+ ## Commands
23
19
 
24
- ### Commands
20
+ | Command | Description | Equivalent (Bun) | Equivalent (Node) |
21
+ | :--------------------------- | :----------------------------------------- | :------------------------------ | :-------------------------------------------- |
22
+ | `ecopages init <dir>` | Scaffolds a new project | N/A | N/A |
23
+ | `ecopages dev [entry]` | Starts the dev server | `bun run [entry] --dev` | `node [ecopages thin host] [entry] --dev` |
24
+ | `ecopages dev:watch [entry]` | Dev server + hard restarts on file changes | `bun --watch run [entry] --dev` | `node --watch [ecopages thin host] ...` |
25
+ | `ecopages dev:hot [entry]` | Dev server + HMR (no hard restarts) | `bun --hot run [entry] --dev` | N/A |
26
+ | `ecopages build [entry]` | Creates a production build | `bun run [entry] --build` | `node [ecopages thin host] [entry] --build` |
27
+ | `ecopages start [entry]` | Starts the production server | `bun run [entry]` | `node [ecopages thin host] [entry]` |
28
+ | `ecopages preview [entry]` | Previews the production build locally | `bun run [entry] --preview` | `node [ecopages thin host] [entry] --preview` |
25
29
 
26
- | Command | Description | Bun Equivalent |
27
- | :--------------------------- | :----------------------------------------------- | :------------------------------ |
28
- | `ecopages init <dir>` | Initialize a new Ecopages project | scaffolding tool |
29
- | `ecopages dev [entry]` | Start the development server | `bun run [entry] --dev` |
30
- | `ecopages dev:watch [entry]` | Start with watch mode (restarts on file changes) | `bun --watch run [entry] --dev` |
31
- | `ecopages dev:hot [entry]` | Start with hot reload (HMR without restart) | `bun --hot run [entry] --dev` |
32
- | `ecopages build [entry]` | Build for production | `bun run [entry] --build` |
33
- | `ecopages start [entry]` | Start production server | `bun run [entry]` |
34
- | `ecopages preview [entry]` | Preview production build | `bun run [entry] --preview` |
30
+ > [!NOTE]
31
+ > `[entry]` defaults to `app.ts` if not provided.
35
32
 
36
- > **Note:** `[entry]` defaults to `app.ts` if not provided.
33
+ ## Environment & Runtime Options
37
34
 
38
- ### Environment Overrides
35
+ Server and build commands accept the following options. They automatically map to the equivalent environment variables for the underlying process:
39
36
 
40
- All server commands (`dev`, `dev:watch`, `dev:hot`, `start`, `preview`) support the following options:
37
+ | Option | Env Var | Description |
38
+ | :------------------------- | :---------------------- | :-------------------------------------------------------- |
39
+ | `-p, --port <port>` | `ECOPAGES_PORT` | Server port (default 3000) |
40
+ | `-n, --hostname <host>` | `ECOPAGES_HOSTNAME` | Server hostname |
41
+ | `-b, --base-url <url>` | `ECOPAGES_BASE_URL` | Base URL string |
42
+ | `-d, --debug` | `ECOPAGES_LOGGER_DEBUG` | Enables debug-level logging |
43
+ | `-r, --react-fast-refresh` | | Enables React Fast Refresh |
44
+ | `--runtime <runtime>` | | Force execution via `bun`, `node`, or `node-experimental` |
41
45
 
42
- | Option | Environment Variable | Description |
43
- | :------------------------- | :---------------------- | :---------------------------- |
44
- | `-p, --port <port>` | `ECOPAGES_PORT` | Server port (default 3000) |
45
- | `-n, --hostname <host>` | `ECOPAGES_HOSTNAME` | Server hostname |
46
- | `-b, --base-url <url>` | `ECOPAGES_BASE_URL` | Base URL for the app |
47
- | `-d, --debug` | `ECOPAGES_LOGGER_DEBUG` | Enable debug logging |
48
- | `-r, --react-fast-refresh` | - | Enable React Fast Refresh HMR |
46
+ ### Runtime Detection
49
47
 
50
- **Example:**
48
+ The CLI automatically detects your runtime environments by inspecting the package manager (`npm_config_user_agent`) or whether the `Bun` global exists. If neither is forcing Bun, it executes your app through the Ecopages-owned Node thin host.
51
49
 
52
- ```bash
53
- # Start dev server on port 8080 with debug logging
54
- ecopages dev --port 8080 --debug
50
+ Both `node` and `node-experimental` now launch through the thin-host boundary and hand off the same serialized Node runtime manifest. `node-experimental` remains available as an explicit verification alias while the unified Node host path continues to settle.
55
51
 
56
- # Start dev server with React Fast Refresh
57
- ecopages dev -r
52
+ You can explicitly force the engine using the `--runtime` flag:
58
53
 
59
- # Start production server with custom hostname
60
- ecopages start --hostname 0.0.0.0 --port 3001
54
+ ```bash
55
+ ecopages dev --runtime node
56
+ ecopages build --runtime bun
57
+ ecopages dev --runtime node-experimental
61
58
  ```
62
59
 
63
- ## Ecopages Packages
60
+ ### Example Usage
64
61
 
65
- The Ecopages ecosystem consists of individual framework packages published to [JSR](https://jsr.io/@ecopages). Import them directly in your project:
62
+ ```bash
63
+ # Debug dev server on custom port
64
+ ecopages dev --port 8080 --debug
66
65
 
67
- ```typescript
68
- import { eco } from '@ecopages/core';
69
- import { kitajsPlugin } from '@ecopages/kitajs';
66
+ # Dev server with React Fast Refresh enabled
67
+ ecopages dev -r
70
68
  ```
71
69
 
72
- ### Available Packages
73
-
74
- | Package | Description | JSR Link |
75
- | :------------------------- | :-------------------------------------------------------- | :--------------------------------------------- |
76
- | `@ecopages/browser-router` | Client-side navigation and view transitions for Ecopages. | [JSR](https://jsr.io/@ecopages/browser-router) |
70
+ ## Ecosystem & Plugins
77
71
 
78
- | `@ecopages/core` | Foundational layer of the Ecopages ecosystem. | [JSR](https://jsr.io/@ecopages/core) |
79
- | `@ecopages/file-system` | Runtime-agnostic file system utilities (Bun/Node.js). | [JSR](https://jsr.io/@ecopages/file-system) |
80
- | `@ecopages/image-processor` | Image processing library for optimized responsive images. | [JSR](https://jsr.io/@ecopages/image-processor) |
81
- | `@ecopages/kitajs` | KitaJS plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/kitajs) |
82
- | `@ecopages/lit` | Lit plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/lit) |
83
- | `@ecopages/mdx` | MDX plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/mdx) |
84
- | `@ecopages/postcss-processor` | Utility functions for processing CSS with PostCSS. | [JSR](https://jsr.io/@ecopages/postcss-processor) |
85
- | `@ecopages/react` | React plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/react) |
86
- | `@ecopages/react-router` | Client-side SPA router for Ecopages React apps. | [JSR](https://jsr.io/@ecopages/react-router) |
72
+ Ecopages relies on a modular architecture. Core logic and framework integrations are published separately to [JSR](https://jsr.io/@ecopages).
87
73
 
88
- Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
89
-
90
- ## Installation
91
-
92
- ```bash
93
- bun add ecopages
94
- ```
95
-
96
- To use Ecopages packages in your project, create a `.npmrc` file in the root of your project to configure JSR registry resolution:
74
+ Configure your project to use JSR by adding a `.npmrc` file:
97
75
 
98
76
  ```ini
99
77
  @jsr:registry=https://npm.jsr.io
100
78
  ```
101
79
 
102
- Then add the packages you need:
80
+ ### Official Packages
81
+
82
+ | Package | Description |
83
+ | :---------------------------- | :----------------------------------------- |
84
+ | `@ecopages/browser-router` | Client-side navigation & view transitions. |
85
+ | `@ecopages/codemod` | AST migrations for codebase upgrades. |
86
+ | `@ecopages/core` | The foundational SSG engine. |
87
+ | `@ecopages/file-system` | Runtime-agnostic file system utilities. |
88
+ | `@ecopages/image-processor` | Asset pipeline for responsive images. |
89
+ | `@ecopages/kitajs` | Integration for KitaJS. |
90
+ | `@ecopages/lit` | Integration for Lit SSR/Islands. |
91
+ | `@ecopages/mdx` | Integration for standalone MDX routes. |
92
+ | `@ecopages/postcss-processor` | CSS processing pipeline using PostCSS. |
93
+ | `@ecopages/react` | Integration for React 19 SSR/Islands. |
94
+ | `@ecopages/react-router` | SPA routing for React. |
103
95
 
104
- ```bash
105
- bun jsr add @ecopages/core @ecopages/kitajs
106
- ```
96
+ Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
107
97
 
108
98
  ## License
109
99
 
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
6
6
  import { join } from 'node:path';
7
7
  import tiged from 'tiged';
8
8
  import { Logger } from '@ecopages/logger';
9
+ import { createLaunchPlan, launchPlanRequiresExistingEntryFile } from './launch-plan.js';
9
10
 
10
11
  const logger = new Logger('[ecopages:cli]');
11
12
 
@@ -56,107 +57,6 @@ program
56
57
  .option('--repo <repo>', 'GitHub repo (user/repo)', 'ecopages/ecopages')
57
58
  .action(handleInit);
58
59
 
59
- /**
60
- * Build environment variables from CLI options
61
- */
62
- function buildEnvOverrides(options) {
63
- const env = {};
64
- if (options.port) env.ECOPAGES_PORT = String(options.port);
65
- if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
66
- if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
67
- if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
68
- if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
69
- return env;
70
- }
71
-
72
- function detectRuntime(options = {}) {
73
- if (options.runtime === 'bun' || options.runtime === 'node') {
74
- return options.runtime;
75
- }
76
-
77
- const userAgent = process.env.npm_config_user_agent || '';
78
-
79
- /**
80
- * If explicitly launched via bun (e.g., `bun run ...`)
81
- */
82
- if (userAgent.startsWith('bun/')) {
83
- return 'bun';
84
- }
85
-
86
- /**
87
- * Catch when CLI is run directly by Bun without a package manager command
88
- */
89
- if (typeof Bun !== 'undefined') {
90
- return 'bun';
91
- }
92
-
93
- /**
94
- * Default to node for npm, pnpm, yarn, or direct execution
95
- */
96
- return 'node';
97
- }
98
-
99
- function buildBunArgs(args, options, entryFile, hasConfig) {
100
- const bunArgs = [];
101
-
102
- if (options.watch) bunArgs.push('--watch');
103
- if (options.hot) bunArgs.push('--hot');
104
-
105
- bunArgs.push('run');
106
-
107
- if (hasConfig) {
108
- bunArgs.push('--preload', 'eco.config.ts');
109
- }
110
-
111
- bunArgs.push(entryFile, ...args);
112
-
113
- if (options.reactFastRefresh) {
114
- bunArgs.push('--react-fast-refresh');
115
- }
116
-
117
- return bunArgs;
118
- }
119
-
120
- function buildNodeArgs(args, options, entryFile) {
121
- const tsxArgs = [];
122
-
123
- if (options.watch) tsxArgs.push('watch');
124
-
125
- tsxArgs.push(entryFile, ...args);
126
-
127
- if (options.reactFastRefresh) {
128
- tsxArgs.push('--react-fast-refresh');
129
- }
130
-
131
- return tsxArgs;
132
- }
133
-
134
- function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
135
- const hasConfig = existsSync('eco.config.ts');
136
- const envOverrides = buildEnvOverrides(options);
137
- const runtime = detectRuntime(options);
138
-
139
- if (runtime === 'node') {
140
- return {
141
- runtime,
142
- executionStrategy: 'tsx',
143
- command: 'tsx',
144
- commandArgs: buildNodeArgs(args, options, entryFile),
145
- envOverrides,
146
- env: { ...process.env, ...envOverrides },
147
- };
148
- }
149
-
150
- return {
151
- runtime,
152
- executionStrategy: 'direct-runtime',
153
- command: 'bun',
154
- commandArgs: buildBunArgs(args, options, entryFile, hasConfig),
155
- envOverrides,
156
- env: { ...process.env, ...envOverrides },
157
- };
158
- }
159
-
160
60
  function runLaunchPlan(launchPlan) {
161
61
  if (Object.keys(launchPlan.envOverrides).length > 0) {
162
62
  logger.info(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
@@ -175,7 +75,7 @@ function runLaunchPlan(launchPlan) {
175
75
  const hint =
176
76
  launchPlan.command === 'bun'
177
77
  ? 'Install Bun from https://bun.sh to continue.'
178
- : 'Install tsx (`npm i -g tsx` or add it as a devDependency) to continue.';
78
+ : 'Install Node.js and ensure the `node` command is available to continue.';
179
79
  logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
180
80
  process.exit(1);
181
81
  }
@@ -196,13 +96,14 @@ function runLaunchPlan(launchPlan) {
196
96
  * @param {object} options - CLI options (watch, hot, port, hostname, etc.)
197
97
  * @param {string} entryFile - Entry file to run
198
98
  */
199
- function runBunCommand(args, options = {}, entryFile = 'app.ts') {
200
- if (!existsSync(entryFile)) {
99
+ async function runBunCommand(args, options = {}, entryFile = 'app.ts') {
100
+ const launchPlan = await createLaunchPlan(args, options, entryFile);
101
+
102
+ if (launchPlanRequiresExistingEntryFile(launchPlan) && !existsSync(entryFile)) {
201
103
  logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
202
104
  process.exit(1);
203
105
  }
204
106
 
205
- const launchPlan = createLaunchPlan(args, options, entryFile);
206
107
  runLaunchPlan(launchPlan);
207
108
  }
208
109
 
@@ -218,12 +119,12 @@ const serverOptions = (cmd) =>
218
119
  .option('-b, --base-url <url>', 'Override ECOPAGES_BASE_URL')
219
120
  .option('-d, --debug', 'Enable debug logging (ECOPAGES_LOGGER_DEBUG=true)')
220
121
  .option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR')
221
- .option('--runtime <runtime>', 'Force a specific runtime (bun or node)');
122
+ .option('--runtime <runtime>', 'Force a specific runtime (bun, node, or node-experimental)');
222
123
 
223
124
  serverOptions(
224
125
  program.command('dev').description('Start the development server').argument('[entry]', 'Entry file', 'app.ts'),
225
- ).action((entry, opts) => {
226
- runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
126
+ ).action(async (entry, opts) => {
127
+ await runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
227
128
  });
228
129
 
229
130
  serverOptions(
@@ -231,8 +132,8 @@ serverOptions(
231
132
  .command('dev:watch')
232
133
  .description('Start the development server with watch mode (restarts on file changes)')
233
134
  .argument('[entry]', 'Entry file', 'app.ts'),
234
- ).action((entry, opts) => {
235
- runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
135
+ ).action(async (entry, opts) => {
136
+ await runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
236
137
  });
237
138
 
238
139
  serverOptions(
@@ -240,29 +141,29 @@ serverOptions(
240
141
  .command('dev:hot')
241
142
  .description('Start the development server with hot reload (HMR without restart)')
242
143
  .argument('[entry]', 'Entry file', 'app.ts'),
243
- ).action((entry, opts) => {
244
- runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
144
+ ).action(async (entry, opts) => {
145
+ await runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
245
146
  });
246
147
 
247
148
  program
248
149
  .command('build')
249
150
  .description('Build the project for production')
250
151
  .argument('[entry]', 'Entry file', 'app.ts')
251
- .option('--runtime <runtime>', 'Force a specific runtime (bun or node)')
252
- .action((entry, opts) => {
253
- runBunCommand(['--build'], { nodeEnv: 'production', ...opts }, entry);
152
+ .option('--runtime <runtime>', 'Force a specific runtime (bun, node, or node-experimental)')
153
+ .action(async (entry, opts) => {
154
+ await runBunCommand(['--build'], { nodeEnv: 'production', ...opts }, entry);
254
155
  });
255
156
 
256
157
  serverOptions(
257
158
  program.command('start').description('Start the production server').argument('[entry]', 'Entry file', 'app.ts'),
258
- ).action((entry, opts) => {
259
- runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
159
+ ).action(async (entry, opts) => {
160
+ await runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
260
161
  });
261
162
 
262
163
  serverOptions(
263
164
  program.command('preview').description('Preview the production build').argument('[entry]', 'Entry file', 'app.ts'),
264
- ).action((entry, opts) => {
265
- runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
165
+ ).action(async (entry, opts) => {
166
+ await runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
266
167
  });
267
168
 
268
169
  program.parse();
@@ -0,0 +1,142 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const NODE_THIN_HOST_PATH = fileURLToPath(new URL('./node-thin-host.js', import.meta.url));
6
+ const DEFAULT_INTERNAL_WORK_DIR = '.eco';
7
+
8
+ export function buildEnvOverrides(options) {
9
+ const env = {};
10
+ if (options.port) env.ECOPAGES_PORT = String(options.port);
11
+ if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
12
+ if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
13
+ if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
14
+ if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
15
+ return env;
16
+ }
17
+
18
+ export function detectRuntime(options = {}) {
19
+ if (options.runtime === 'bun' || options.runtime === 'node' || options.runtime === 'node-experimental') {
20
+ return options.runtime;
21
+ }
22
+
23
+ const userAgent = process.env.npm_config_user_agent || '';
24
+
25
+ if (userAgent.startsWith('bun/')) {
26
+ return 'bun';
27
+ }
28
+
29
+ if (typeof Bun !== 'undefined') {
30
+ return 'bun';
31
+ }
32
+
33
+ return 'node';
34
+ }
35
+
36
+ export function buildBunArgs(args, options, entryFile, hasConfig) {
37
+ const bunArgs = [];
38
+
39
+ if (options.watch) bunArgs.push('--watch');
40
+ if (options.hot) bunArgs.push('--hot');
41
+
42
+ bunArgs.push('run');
43
+
44
+ if (hasConfig) {
45
+ bunArgs.push('--preload', 'eco.config.ts');
46
+ }
47
+
48
+ bunArgs.push(entryFile, ...args);
49
+
50
+ if (options.reactFastRefresh) {
51
+ bunArgs.push('--react-fast-refresh');
52
+ }
53
+
54
+ return bunArgs;
55
+ }
56
+
57
+ export function buildNodeArgs(args, options, entryFile) {
58
+ const nodeArgs = [];
59
+
60
+ if (options.watch) nodeArgs.push('--watch');
61
+
62
+ nodeArgs.push(NODE_THIN_HOST_PATH, entryFile, ...args);
63
+
64
+ if (options.reactFastRefresh) {
65
+ nodeArgs.push('--react-fast-refresh');
66
+ }
67
+
68
+ return nodeArgs;
69
+ }
70
+
71
+ export function resolveNodeRuntimeManifestPath(projectDir = process.cwd()) {
72
+ return path.join(path.resolve(projectDir), DEFAULT_INTERNAL_WORK_DIR, 'runtime', 'node-runtime-manifest.json');
73
+ }
74
+
75
+ export async function createNodeRuntimeManifestFile(
76
+ entryFile,
77
+ options = {
78
+ cwd: process.cwd(),
79
+ env: process.env,
80
+ },
81
+ ) {
82
+ const projectDir = path.resolve(options.cwd ?? process.cwd());
83
+ const configPath = path.join(projectDir, 'eco.config.ts');
84
+ const manifestFilePath = options.manifestFilePath ?? resolveNodeRuntimeManifestPath(projectDir);
85
+
86
+ if (!existsSync(configPath)) {
87
+ throw new Error('The Node thin-host runtime requires eco.config.ts in the current project root.');
88
+ }
89
+
90
+ const manifest = {
91
+ runtime: 'node',
92
+ appRootDir: projectDir,
93
+ sourceRootDir: path.join(projectDir, 'src'),
94
+ distDir: path.join(projectDir, 'dist'),
95
+ workDir: path.join(projectDir, DEFAULT_INTERNAL_WORK_DIR),
96
+ modulePaths: {
97
+ config: configPath,
98
+ entry: path.resolve(projectDir, entryFile),
99
+ },
100
+ };
101
+
102
+ mkdirSync(path.dirname(manifestFilePath), { recursive: true });
103
+ writeFileSync(manifestFilePath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
104
+
105
+ return manifestFilePath;
106
+ }
107
+
108
+ export async function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
109
+ const hasConfig = existsSync('eco.config.ts');
110
+ const envOverrides = buildEnvOverrides(options);
111
+ const runtime = detectRuntime(options);
112
+ const env = { ...process.env, ...envOverrides };
113
+
114
+ if (runtime === 'node' || runtime === 'node-experimental') {
115
+ const manifestFilePath = await createNodeRuntimeManifestFile(entryFile, { env });
116
+
117
+ return {
118
+ runtime,
119
+ executionStrategy: 'node-thin-host',
120
+ command: 'node',
121
+ commandArgs: buildNodeArgs(args, options, entryFile),
122
+ envOverrides,
123
+ env: {
124
+ ...env,
125
+ ECOPAGES_NODE_RUNTIME_MANIFEST_PATH: manifestFilePath,
126
+ },
127
+ };
128
+ }
129
+
130
+ return {
131
+ runtime,
132
+ executionStrategy: 'direct-runtime',
133
+ command: 'bun',
134
+ commandArgs: buildBunArgs(args, options, entryFile, hasConfig),
135
+ envOverrides,
136
+ env,
137
+ };
138
+ }
139
+
140
+ export function launchPlanRequiresExistingEntryFile(launchPlan) {
141
+ return launchPlan.executionStrategy !== 'config-only-bootstrap';
142
+ }
@@ -0,0 +1,316 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import {
6
+ buildEnvOverrides,
7
+ buildNodeArgs,
8
+ buildBunArgs,
9
+ createNodeRuntimeManifestFile,
10
+ createLaunchPlan,
11
+ detectRuntime,
12
+ launchPlanRequiresExistingEntryFile,
13
+ resolveNodeRuntimeManifestPath,
14
+ } from './launch-plan.js';
15
+
16
+ const originalUserAgent = process.env.npm_config_user_agent;
17
+
18
+ afterEach(() => {
19
+ if (originalUserAgent === undefined) {
20
+ delete process.env.npm_config_user_agent;
21
+ } else {
22
+ process.env.npm_config_user_agent = originalUserAgent;
23
+ }
24
+ process.chdir('/Users/andeeplus/github/ecopages');
25
+ });
26
+
27
+ describe('launch-plan', () => {
28
+ function writeExperimentalRuntimeConfig(tempDir) {
29
+ fs.writeFileSync(
30
+ path.join(tempDir, 'eco.config.ts'),
31
+ [
32
+ 'const rootDir = process.cwd();',
33
+ 'export default {',
34
+ '\trootDir,',
35
+ '\tloaders: new Map(),',
36
+ '\tabsolutePaths: {',
37
+ '\t\tconfig: `${rootDir}/eco.config.ts`,',
38
+ '\t\tsrcDir: `${rootDir}/src`,',
39
+ '\t\tdistDir: `${rootDir}/dist`,',
40
+ '\t\tworkDir: `${rootDir}/.eco`,',
41
+ '\t},',
42
+ '\truntime: {},',
43
+ '};',
44
+ ].join('\n'),
45
+ 'utf8',
46
+ );
47
+ }
48
+
49
+ function writeImportMetaRuntimeConfig(tempDir) {
50
+ fs.writeFileSync(
51
+ path.join(tempDir, 'eco.config.ts'),
52
+ [
53
+ "import path from 'node:path';",
54
+ 'export default {',
55
+ '\trootDir: import.meta.dirname,',
56
+ '\tloaders: new Map(),',
57
+ '\tabsolutePaths: {',
58
+ "\t\tconfig: path.join(import.meta.dirname, 'eco.config.ts'),",
59
+ "\t\tsrcDir: path.join(import.meta.dirname, 'src'),",
60
+ "\t\tdistDir: path.join(import.meta.dirname, 'dist'),",
61
+ "\t\tworkDir: path.join(import.meta.dirname, '.eco'),",
62
+ '\t},',
63
+ '\truntime: {},',
64
+ '};',
65
+ ].join('\n'),
66
+ 'utf8',
67
+ );
68
+ }
69
+
70
+ it('buildEnvOverrides maps CLI options onto environment variables', () => {
71
+ expect(
72
+ buildEnvOverrides({
73
+ port: 4173,
74
+ hostname: '127.0.0.1',
75
+ baseUrl: 'https://example.test',
76
+ debug: true,
77
+ nodeEnv: 'production',
78
+ }),
79
+ ).toEqual({
80
+ ECOPAGES_PORT: '4173',
81
+ ECOPAGES_HOSTNAME: '127.0.0.1',
82
+ ECOPAGES_BASE_URL: 'https://example.test',
83
+ ECOPAGES_LOGGER_DEBUG: 'true',
84
+ NODE_ENV: 'production',
85
+ });
86
+ });
87
+
88
+ it('detectRuntime defaults to node unless bun is explicit', () => {
89
+ process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
90
+ expect(detectRuntime()).toBe('node');
91
+ expect(detectRuntime({ runtime: 'bun' })).toBe('bun');
92
+ expect(detectRuntime({ runtime: 'node' })).toBe('node');
93
+ expect(detectRuntime({ runtime: 'node-experimental' })).toBe('node-experimental');
94
+ });
95
+
96
+ it('buildNodeArgs preserves watch mode and fast refresh flags', () => {
97
+ expect(buildNodeArgs(['--dev'], { watch: true, reactFastRefresh: true }, 'app.ts')).toEqual([
98
+ '--watch',
99
+ expect.stringMatching(/node-thin-host\.js$/),
100
+ 'app.ts',
101
+ '--dev',
102
+ '--react-fast-refresh',
103
+ ]);
104
+ });
105
+
106
+ it('buildNodeArgs routes stable Node through the thin launcher', () => {
107
+ expect(buildNodeArgs(['--dev'], { watch: true }, 'app.ts')).toEqual([
108
+ '--watch',
109
+ expect.stringMatching(/node-thin-host\.js$/),
110
+ 'app.ts',
111
+ '--dev',
112
+ ]);
113
+ });
114
+
115
+ it('buildBunArgs preloads eco.config.ts when present', () => {
116
+ expect(buildBunArgs(['--dev'], { hot: true }, 'app.ts', true)).toEqual([
117
+ '--hot',
118
+ 'run',
119
+ '--preload',
120
+ 'eco.config.ts',
121
+ 'app.ts',
122
+ '--dev',
123
+ ]);
124
+ });
125
+
126
+ it('createLaunchPlan routes node app launches through the thin host', async () => {
127
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
128
+ try {
129
+ process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
130
+ process.chdir(tempDir);
131
+ writeExperimentalRuntimeConfig(tempDir);
132
+ fs.writeFileSync(
133
+ path.join(tempDir, 'app.ts'),
134
+ [
135
+ "import { EcopagesApp } from '@ecopages/core';",
136
+ "import appConfig from './eco.config';",
137
+ 'const app = new EcopagesApp({ appConfig });',
138
+ 'await app.start();',
139
+ ].join('\n'),
140
+ 'utf8',
141
+ );
142
+ const plan = await createLaunchPlan(['--dev'], { watch: true, nodeEnv: 'development' }, 'app.ts');
143
+
144
+ expect(plan).toMatchObject({
145
+ runtime: 'node',
146
+ executionStrategy: 'node-thin-host',
147
+ command: 'node',
148
+ envOverrides: { NODE_ENV: 'development' },
149
+ });
150
+ expect(plan.commandArgs).toEqual([
151
+ '--watch',
152
+ expect.stringMatching(/node-thin-host\.js$/),
153
+ 'app.ts',
154
+ '--dev',
155
+ ]);
156
+ } finally {
157
+ fs.rmSync(tempDir, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ it('createLaunchPlan preserves direct node entrypoints on the thin-host path', async () => {
162
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
163
+ try {
164
+ process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
165
+ process.chdir(tempDir);
166
+ fs.writeFileSync(path.join(tempDir, 'server.ts'), 'await Promise.resolve();', 'utf8');
167
+ writeExperimentalRuntimeConfig(tempDir);
168
+
169
+ const plan = await createLaunchPlan(['--dev'], { watch: true }, 'server.ts');
170
+
171
+ expect(plan).toMatchObject({
172
+ runtime: 'node',
173
+ executionStrategy: 'node-thin-host',
174
+ command: 'node',
175
+ });
176
+ expect(plan.commandArgs).toEqual([
177
+ '--watch',
178
+ expect.stringMatching(/node-thin-host\.js$/),
179
+ 'server.ts',
180
+ '--dev',
181
+ ]);
182
+ } finally {
183
+ fs.rmSync(tempDir, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ it('createLaunchPlan uses bun direct runtime and preloads eco.config.ts', async () => {
188
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
189
+ try {
190
+ process.chdir(tempDir);
191
+ fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
192
+ writeExperimentalRuntimeConfig(tempDir);
193
+
194
+ const plan = await createLaunchPlan(['--preview'], { runtime: 'bun' }, 'app.ts');
195
+
196
+ expect(plan).toMatchObject({
197
+ runtime: 'bun',
198
+ executionStrategy: 'direct-runtime',
199
+ command: 'bun',
200
+ });
201
+ expect(plan.commandArgs).toEqual(['run', '--preload', 'eco.config.ts', 'app.ts', '--preview']);
202
+ } finally {
203
+ fs.rmSync(tempDir, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ it('createNodeRuntimeManifestFile writes the core-owned manifest under .eco/runtime', async () => {
208
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
209
+ try {
210
+ process.chdir(tempDir);
211
+ writeExperimentalRuntimeConfig(tempDir);
212
+ const manifestFilePath = await createNodeRuntimeManifestFile('app.ts');
213
+ const manifest = JSON.parse(fs.readFileSync(manifestFilePath, 'utf8'));
214
+ const resolvedTempDir = fs.realpathSync(tempDir);
215
+
216
+ expect(manifestFilePath).toBe(resolveNodeRuntimeManifestPath(resolvedTempDir));
217
+ expect(manifest).toMatchObject({
218
+ runtime: 'node',
219
+ appRootDir: resolvedTempDir,
220
+ sourceRootDir: path.join(resolvedTempDir, 'src'),
221
+ distDir: path.join(resolvedTempDir, 'dist'),
222
+ workDir: path.join(resolvedTempDir, '.eco'),
223
+ modulePaths: {
224
+ config: path.join(resolvedTempDir, 'eco.config.ts'),
225
+ entry: path.join(resolvedTempDir, 'app.ts'),
226
+ },
227
+ });
228
+ } finally {
229
+ fs.rmSync(tempDir, { recursive: true, force: true });
230
+ }
231
+ });
232
+
233
+ it('createNodeRuntimeManifestFile does not evaluate eco.config.ts while writing the manifest', async () => {
234
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
235
+ try {
236
+ process.chdir(tempDir);
237
+ writeImportMetaRuntimeConfig(tempDir);
238
+ const manifestFilePath = await createNodeRuntimeManifestFile('app.ts');
239
+ const manifest = JSON.parse(fs.readFileSync(manifestFilePath, 'utf8'));
240
+ const resolvedTempDir = fs.realpathSync(tempDir);
241
+
242
+ expect(manifestFilePath).toBe(resolveNodeRuntimeManifestPath(resolvedTempDir));
243
+ expect(manifest).toMatchObject({
244
+ appRootDir: resolvedTempDir,
245
+ sourceRootDir: path.join(resolvedTempDir, 'src'),
246
+ distDir: path.join(resolvedTempDir, 'dist'),
247
+ workDir: path.join(resolvedTempDir, '.eco'),
248
+ modulePaths: {
249
+ config: path.join(resolvedTempDir, 'eco.config.ts'),
250
+ entry: path.join(resolvedTempDir, 'app.ts'),
251
+ },
252
+ });
253
+ } finally {
254
+ fs.rmSync(tempDir, { recursive: true, force: true });
255
+ }
256
+ });
257
+
258
+ it('createLaunchPlan routes node-experimental through the thin host launcher', async () => {
259
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
260
+ try {
261
+ process.chdir(tempDir);
262
+ writeExperimentalRuntimeConfig(tempDir);
263
+ const resolvedTempDir = fs.realpathSync(tempDir);
264
+ const plan = await createLaunchPlan(
265
+ ['--dev'],
266
+ { runtime: 'node-experimental', nodeEnv: 'development' },
267
+ 'app.ts',
268
+ );
269
+
270
+ expect(plan).toMatchObject({
271
+ runtime: 'node-experimental',
272
+ executionStrategy: 'node-thin-host',
273
+ command: 'node',
274
+ envOverrides: { NODE_ENV: 'development' },
275
+ });
276
+ expect(plan.commandArgs).toEqual([expect.stringMatching(/node-thin-host\.js$/), 'app.ts', '--dev']);
277
+ expect(plan.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH).toBe(resolveNodeRuntimeManifestPath(resolvedTempDir));
278
+ expect(JSON.parse(fs.readFileSync(plan.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH, 'utf8'))).toMatchObject({
279
+ runtime: 'node',
280
+ modulePaths: {
281
+ config: path.join(resolvedTempDir, 'eco.config.ts'),
282
+ entry: path.join(resolvedTempDir, 'app.ts'),
283
+ },
284
+ });
285
+ } finally {
286
+ fs.rmSync(tempDir, { recursive: true, force: true });
287
+ }
288
+ });
289
+
290
+ it('launchPlanRequiresExistingEntryFile requires a concrete entry on every runtime path', async () => {
291
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
292
+ try {
293
+ process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
294
+ process.chdir(tempDir);
295
+ fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
296
+ writeExperimentalRuntimeConfig(tempDir);
297
+
298
+ const nodePlan = await createLaunchPlan(['--dev'], { nodeEnv: 'development' }, 'app.ts');
299
+ expect(nodePlan.executionStrategy).toBe('node-thin-host');
300
+ expect(launchPlanRequiresExistingEntryFile(nodePlan)).toBe(true);
301
+
302
+ fs.writeFileSync(path.join(tempDir, 'server.ts'), 'await Promise.resolve();', 'utf8');
303
+ const directNodePlan = await createLaunchPlan(['--dev'], { nodeEnv: 'development' }, 'server.ts');
304
+ expect(directNodePlan.executionStrategy).toBe('node-thin-host');
305
+ expect(launchPlanRequiresExistingEntryFile(directNodePlan)).toBe(true);
306
+
307
+ const bunPlan = await createLaunchPlan(['--dev'], { runtime: 'bun' }, 'app.ts');
308
+ expect(launchPlanRequiresExistingEntryFile(bunPlan)).toBe(true);
309
+
310
+ const experimentalNodePlan = await createLaunchPlan(['--dev'], { runtime: 'node-experimental' }, 'app.ts');
311
+ expect(launchPlanRequiresExistingEntryFile(experimentalNodePlan)).toBe(true);
312
+ } finally {
313
+ fs.rmSync(tempDir, { recursive: true, force: true });
314
+ }
315
+ });
316
+ });
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { Logger } from '@ecopages/logger';
5
+ import { assertNodeRuntimeManifest, createNodeRuntimeAdapter } from '@ecopages/core/node/runtime-adapter';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const logger = new Logger('[ecopages:node-thin-host]');
9
+
10
+ function formatErrorForLog(error) {
11
+ if (error instanceof Error) {
12
+ return error.stack ?? error.message;
13
+ }
14
+
15
+ return String(error);
16
+ }
17
+
18
+ function attachShutdownHandlers(session) {
19
+ let shutdownPromise;
20
+
21
+ const shutdown = async (signal) => {
22
+ if (!shutdownPromise) {
23
+ shutdownPromise = (async () => {
24
+ try {
25
+ logger.info(`Received ${signal}. Shutting down Node thin-host runtime.`);
26
+ await session.dispose();
27
+ } catch (error) {
28
+ logger.error(formatErrorForLog(error));
29
+ } finally {
30
+ process.exit(0);
31
+ }
32
+ })();
33
+ }
34
+
35
+ await shutdownPromise;
36
+ };
37
+
38
+ process.once('SIGINT', () => {
39
+ void shutdown('SIGINT');
40
+ });
41
+ process.once('SIGTERM', () => {
42
+ void shutdown('SIGTERM');
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Creates the host-to-adapter handoff payload for the Node thin-host runtime.
48
+ */
49
+ export function createRuntimeStartOptions(options = {}) {
50
+ return {
51
+ manifest: options.manifest ?? readRuntimeManifest(),
52
+ workingDirectory: options.workingDirectory ?? process.cwd(),
53
+ cliArgs: options.cliArgs ?? process.argv.slice(2),
54
+ };
55
+ }
56
+
57
+ export function readRuntimeManifest() {
58
+ const manifestFilePath = process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH;
59
+
60
+ if (!manifestFilePath) {
61
+ throw new Error('Missing ECOPAGES_NODE_RUNTIME_MANIFEST_PATH for Node thin-host launch.');
62
+ }
63
+
64
+ let serializedManifest;
65
+
66
+ try {
67
+ serializedManifest = readFileSync(manifestFilePath, 'utf8');
68
+ } catch (error) {
69
+ throw new Error(
70
+ `Failed to read ECOPAGES_NODE_RUNTIME_MANIFEST_PATH at ${manifestFilePath}: ${error instanceof Error ? error.message : String(error)}`,
71
+ );
72
+ }
73
+
74
+ let parsedManifest;
75
+
76
+ try {
77
+ parsedManifest = JSON.parse(serializedManifest);
78
+ } catch (error) {
79
+ throw new Error(
80
+ `Invalid Node runtime manifest JSON: ${error instanceof Error ? error.message : String(error)}`,
81
+ );
82
+ }
83
+
84
+ return assertNodeRuntimeManifest(parsedManifest);
85
+ }
86
+
87
+ /**
88
+ * Starts the Node thin-host runtime by delegating validated input to the
89
+ * adapter boundary.
90
+ *
91
+ * @remarks
92
+ * This function intentionally keeps the thin host transport-oriented. It does
93
+ * not own framework bootstrap policy beyond reading the manifest, creating the
94
+ * adapter, and delegating startup plus shutdown lifecycle.
95
+ */
96
+ export async function startThinHostRuntime(options = {}) {
97
+ const adapter = options.adapter ?? createNodeRuntimeAdapter();
98
+ const startOptions = createRuntimeStartOptions(options);
99
+ const session = await adapter.start(startOptions);
100
+ let loadedAppRuntime;
101
+
102
+ try {
103
+ loadedAppRuntime = await session.loadApp();
104
+ } catch (error) {
105
+ try {
106
+ await session.dispose();
107
+ } catch (disposeError) {
108
+ throw new Error(
109
+ `Failed to dispose Node thin-host runtime session after bootstrap error: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}`,
110
+ { cause: error instanceof Error ? error : undefined },
111
+ );
112
+ }
113
+
114
+ throw error;
115
+ }
116
+
117
+ if (options.attachShutdownHandlers !== false) {
118
+ attachShutdownHandlers(session);
119
+ }
120
+
121
+ return {
122
+ session,
123
+ loadedAppRuntime,
124
+ };
125
+ }
126
+
127
+ async function main() {
128
+ try {
129
+ await startThinHostRuntime();
130
+ } catch (error) {
131
+ logger.error(formatErrorForLog(error));
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
137
+ await main();
138
+ }
@@ -0,0 +1,167 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { createRuntimeStartOptions, readRuntimeManifest, startThinHostRuntime } from './node-thin-host.js';
6
+
7
+ const originalManifestPath = process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH;
8
+
9
+ afterEach(() => {
10
+ if (originalManifestPath === undefined) {
11
+ delete process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH;
12
+ } else {
13
+ process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH = originalManifestPath;
14
+ }
15
+ });
16
+
17
+ describe('node-thin-host', () => {
18
+ it('reads, validates, and returns the runtime manifest from the file handoff', () => {
19
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-node-thin-host-'));
20
+ const manifestFilePath = path.join(tempDir, 'node-runtime-manifest.json');
21
+
22
+ try {
23
+ fs.writeFileSync(
24
+ manifestFilePath,
25
+ JSON.stringify({
26
+ runtime: 'node',
27
+ appRootDir: '/repo',
28
+ sourceRootDir: '/repo/src',
29
+ distDir: '/repo/dist',
30
+ workDir: '/repo/.eco',
31
+ modulePaths: {
32
+ config: '/repo/eco.config.ts',
33
+ entry: '/repo/app.ts',
34
+ },
35
+ }),
36
+ 'utf8',
37
+ );
38
+
39
+ process.env.ECOPAGES_NODE_RUNTIME_MANIFEST_PATH = manifestFilePath;
40
+
41
+ expect(readRuntimeManifest()).toMatchObject({
42
+ modulePaths: {
43
+ entry: '/repo/app.ts',
44
+ },
45
+ });
46
+ } finally {
47
+ fs.rmSync(tempDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ it('creates the adapter handoff from validated manifest plus process launch context', () => {
52
+ const manifest = {
53
+ runtime: 'node',
54
+ appRootDir: '/repo',
55
+ sourceRootDir: '/repo/src',
56
+ distDir: '/repo/dist',
57
+ workDir: '/repo/.eco',
58
+ modulePaths: {
59
+ config: '/repo/eco.config.ts',
60
+ entry: '/repo/app.ts',
61
+ },
62
+ };
63
+
64
+ expect(
65
+ createRuntimeStartOptions({
66
+ manifest,
67
+ workingDirectory: '/repo',
68
+ cliArgs: ['app.ts', '--dev'],
69
+ }),
70
+ ).toEqual({
71
+ manifest,
72
+ workingDirectory: '/repo',
73
+ cliArgs: ['app.ts', '--dev'],
74
+ });
75
+ });
76
+
77
+ it('delegates startup to the adapter boundary and returns the loaded runtime session', async () => {
78
+ const session = {
79
+ loadApp: vi.fn(async () => ({
80
+ manifest: { runtime: 'node' },
81
+ workingDirectory: '/repo',
82
+ appConfig: { rootDir: '/repo' },
83
+ entryModulePath: '/repo/app.ts',
84
+ entryModule: { default: true },
85
+ })),
86
+ dispose: vi.fn(async () => undefined),
87
+ };
88
+ const adapter = {
89
+ start: vi.fn(async () => session),
90
+ };
91
+ const manifest = {
92
+ runtime: 'node',
93
+ appRootDir: '/repo',
94
+ sourceRootDir: '/repo/src',
95
+ distDir: '/repo/dist',
96
+ workDir: '/repo/.eco',
97
+ modulePaths: {
98
+ config: '/repo/eco.config.ts',
99
+ entry: '/repo/app.ts',
100
+ },
101
+ };
102
+
103
+ const runtime = await startThinHostRuntime({
104
+ adapter,
105
+ manifest,
106
+ workingDirectory: '/repo',
107
+ cliArgs: ['app.ts', '--dev'],
108
+ attachShutdownHandlers: false,
109
+ });
110
+
111
+ expect(adapter.start).toHaveBeenCalledWith({
112
+ manifest,
113
+ workingDirectory: '/repo',
114
+ cliArgs: ['app.ts', '--dev'],
115
+ });
116
+ expect(session.loadApp).toHaveBeenCalledTimes(1);
117
+ expect(runtime).toMatchObject({
118
+ loadedAppRuntime: {
119
+ entryModulePath: '/repo/app.ts',
120
+ appConfig: { rootDir: '/repo' },
121
+ },
122
+ session,
123
+ });
124
+ });
125
+
126
+ it('disposes the runtime session if app bootstrap fails after adapter startup', async () => {
127
+ const bootstrapError = new Error('bootstrap failed');
128
+ const session = {
129
+ loadApp: vi.fn(async () => {
130
+ throw bootstrapError;
131
+ }),
132
+ dispose: vi.fn(async () => undefined),
133
+ };
134
+ const adapter = {
135
+ start: vi.fn(async () => session),
136
+ };
137
+ const manifest = {
138
+ runtime: 'node',
139
+ appRootDir: '/repo',
140
+ sourceRootDir: '/repo/src',
141
+ distDir: '/repo/dist',
142
+ workDir: '/repo/.eco',
143
+ modulePaths: {
144
+ config: '/repo/eco.config.ts',
145
+ entry: '/repo/app.ts',
146
+ },
147
+ };
148
+
149
+ await expect(
150
+ startThinHostRuntime({
151
+ adapter,
152
+ manifest,
153
+ workingDirectory: '/repo',
154
+ cliArgs: ['app.ts', '--dev'],
155
+ attachShutdownHandlers: false,
156
+ }),
157
+ ).rejects.toThrow('bootstrap failed');
158
+
159
+ expect(adapter.start).toHaveBeenCalledWith({
160
+ manifest,
161
+ workingDirectory: '/repo',
162
+ cliArgs: ['app.ts', '--dev'],
163
+ });
164
+ expect(session.loadApp).toHaveBeenCalledTimes(1);
165
+ expect(session.dispose).toHaveBeenCalledTimes(1);
166
+ });
167
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecopages",
3
- "version": "0.2.0-alpha.5",
3
+ "version": "0.2.0-alpha.6",
4
4
  "description": "CLI utilities for Ecopages",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,8 @@
34
34
  "README.md"
35
35
  ],
36
36
  "dependencies": {
37
+ "@ecopages/core": "workspace:*",
38
+ "esbuild": "^0.27.3",
37
39
  "commander": "^12.1.0",
38
40
  "tiged": "^2.12.7",
39
41
  "@ecopages/logger": "^0.2.2"