ecopages 0.1.105 → 0.2.0-alpha.10

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,93 +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) |
77
- | `@ecopages/bun-inline-css-plugin` | Bun plugin to process CSS files using CSS Processors. | [JSR](https://jsr.io/@ecopages/bun-inline-css-plugin) |
78
- | `@ecopages/bun-mdx-kitajs-loader` | Bun loader to load MDX files with KitaJS. | [JSR](https://jsr.io/@ecopages/bun-mdx-kitajs-loader) |
79
- | `@ecopages/bun-postcss-loader` | Bun loader to load PostCSS files. | [JSR](https://jsr.io/@ecopages/bun-postcss-loader) |
80
- | `@ecopages/core` | Foundational layer of the Ecopages ecosystem. | [JSR](https://jsr.io/@ecopages/core) |
81
- | `@ecopages/file-system` | Runtime-agnostic file system utilities (Bun/Node.js). | [JSR](https://jsr.io/@ecopages/file-system) |
82
- | `@ecopages/image-processor` | Image processing library for optimized responsive images. | [JSR](https://jsr.io/@ecopages/image-processor) |
83
- | `@ecopages/kitajs` | KitaJS plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/kitajs) |
84
- | `@ecopages/lit` | Lit plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/lit) |
85
- | `@ecopages/mdx` | MDX plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/mdx) |
86
- | `@ecopages/postcss-processor` | Utility functions for processing CSS with PostCSS. | [JSR](https://jsr.io/@ecopages/postcss-processor) |
87
- | `@ecopages/react` | React plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/react) |
88
- | `@ecopages/react-router` | Client-side SPA router for Ecopages React apps. | [JSR](https://jsr.io/@ecopages/react-router) |
70
+ ## Ecosystem & Plugins
89
71
 
90
- Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
91
-
92
- ## Installation
93
-
94
- ```bash
95
- bun add ecopages
96
- ```
72
+ Ecopages relies on a modular architecture. Core logic and framework integrations are published separately to [JSR](https://jsr.io/@ecopages).
97
73
 
98
- 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:
99
75
 
100
76
  ```ini
101
77
  @jsr:registry=https://npm.jsr.io
102
78
  ```
103
79
 
104
- 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. |
105
95
 
106
- ```bash
107
- bun jsr add @ecopages/core @ecopages/kitajs
108
- ```
96
+ Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
109
97
 
110
98
  ## License
111
99
 
package/bin/cli.js CHANGED
@@ -1,10 +1,12 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { spawn } from 'node:child_process';
6
+ import { join } from 'node:path';
6
7
  import tiged from 'tiged';
7
8
  import { Logger } from '@ecopages/logger';
9
+ import { createLaunchPlan, launchPlanRequiresExistingEntryFile } from './launch-plan.js';
8
10
 
9
11
  const logger = new Logger('[ecopages:cli]');
10
12
 
@@ -13,48 +15,78 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url),
13
15
 
14
16
  program.name('ecopages').description('Ecopages CLI utilities').version(pkg.version);
15
17
 
18
+ async function handleInit(dir, opts) {
19
+ const { template, repo } = opts;
20
+ const targetDir = dir;
21
+
22
+ if (existsSync(targetDir)) {
23
+ logger.error(`Target directory already exists: ${targetDir}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ logger.info(`Creating target directory '${targetDir}'...`);
28
+
29
+ try {
30
+ const emitter = tiged(`${repo}/examples/${template}`, {
31
+ disableCache: true,
32
+ force: true,
33
+ verbose: false,
34
+ });
35
+
36
+ await emitter.clone(targetDir);
37
+
38
+ const pkgPath = join(targetDir, 'package.json');
39
+ if (existsSync(pkgPath)) {
40
+ const projectPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
41
+ projectPkg.name = dir;
42
+ writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
43
+ logger.info(`Renamed project to '${dir}'`);
44
+ }
45
+
46
+ logger.info('Project initialized! Run `bun install && bun dev` to start.');
47
+ } catch (err) {
48
+ logger.error(`Failed to fetch template: ${err.message}`);
49
+ process.exit(1);
50
+ }
51
+ }
52
+
16
53
  program
17
54
  .command('init <dir>')
18
55
  .description('Initialize a new project from a template')
19
56
  .option('--template <name>', 'Template name from ecopages/examples/', 'starter-jsx')
20
57
  .option('--repo <repo>', 'GitHub repo (user/repo)', 'ecopages/ecopages')
21
- .action(async (dir, opts) => {
22
- const { template, repo } = opts;
23
- const targetDir = dir;
58
+ .action(handleInit);
24
59
 
25
- if (existsSync(targetDir)) {
26
- logger.error(`Target directory already exists: ${targetDir}`);
27
- process.exit(1);
28
- }
60
+ function runLaunchPlan(launchPlan) {
61
+ if (Object.keys(launchPlan.envOverrides).length > 0) {
62
+ logger.info(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
63
+ }
29
64
 
30
- logger.info(`Creating target directory '${targetDir}'...`);
65
+ logger.info(`Runtime: ${launchPlan.runtime}`);
66
+ logger.info(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(' ')}`);
31
67
 
32
- try {
33
- const emitter = tiged(`${repo}/examples/${template}`, {
34
- disableCache: true,
35
- force: true,
36
- verbose: false,
37
- });
68
+ const child = spawn(launchPlan.command, launchPlan.commandArgs, {
69
+ stdio: 'inherit',
70
+ env: launchPlan.env,
71
+ });
38
72
 
39
- await emitter.clone(targetDir);
40
- logger.info('Project initialized! Run `bun install && bun dev` to start.');
41
- } catch (err) {
42
- logger.error(`Failed to fetch template: ${err.message}`);
73
+ child.on('error', (error) => {
74
+ if (error && error.code === 'ENOENT') {
75
+ const hint =
76
+ launchPlan.command === 'bun'
77
+ ? 'Install Bun from https://bun.sh to continue.'
78
+ : 'Install Node.js and ensure the `node` command is available to continue.';
79
+ logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
43
80
  process.exit(1);
44
81
  }
82
+
83
+ logger.error(`Failed to run command: ${error.message}`);
84
+ process.exit(1);
45
85
  });
46
86
 
47
- /**
48
- * Build environment variables from CLI options
49
- */
50
- function buildEnvOverrides(options) {
51
- const env = {};
52
- if (options.port) env.ECOPAGES_PORT = String(options.port);
53
- if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
54
- if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
55
- if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
56
- if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
57
- return env;
87
+ child.on('exit', (code) => {
88
+ process.exit(code || 0);
89
+ });
58
90
  }
59
91
 
60
92
  /**
@@ -64,41 +96,15 @@ function buildEnvOverrides(options) {
64
96
  * @param {object} options - CLI options (watch, hot, port, hostname, etc.)
65
97
  * @param {string} entryFile - Entry file to run
66
98
  */
67
- function runBunCommand(args, options = {}, entryFile = 'app.ts') {
68
- const hasConfig = existsSync('eco.config.ts');
69
- 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)) {
70
103
  logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
71
104
  process.exit(1);
72
105
  }
73
106
 
74
- const bunArgs = [];
75
- if (options.watch) bunArgs.push('--watch');
76
- if (options.hot) bunArgs.push('--hot');
77
-
78
- bunArgs.push('run');
79
-
80
- if (hasConfig) {
81
- bunArgs.push('--preload', 'eco.config.ts');
82
- }
83
- bunArgs.push(entryFile, ...args);
84
-
85
- if (options.reactFastRefresh) {
86
- bunArgs.push('--react-fast-refresh');
87
- }
88
-
89
- /** Merge CLI overrides with current environment */
90
- const envOverrides = buildEnvOverrides(options);
91
- const env = { ...process.env, ...envOverrides };
92
-
93
- if (Object.keys(envOverrides).length > 0) {
94
- logger.info(`Environment overrides: ${JSON.stringify(envOverrides)}`);
95
- }
96
- logger.info(`Running: bun ${bunArgs.join(' ')}`);
97
-
98
- const child = spawn('bun', bunArgs, { stdio: 'inherit', env });
99
- child.on('exit', (code) => {
100
- process.exit(code || 0);
101
- });
107
+ runLaunchPlan(launchPlan);
102
108
  }
103
109
 
104
110
  /**
@@ -112,12 +118,13 @@ const serverOptions = (cmd) =>
112
118
  .option('-n, --hostname <hostname>', 'Override ECOPAGES_HOSTNAME')
113
119
  .option('-b, --base-url <url>', 'Override ECOPAGES_BASE_URL')
114
120
  .option('-d, --debug', 'Enable debug logging (ECOPAGES_LOGGER_DEBUG=true)')
115
- .option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR');
121
+ .option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR')
122
+ .option('--runtime <runtime>', 'Force a specific runtime (bun, node, or node-experimental)');
116
123
 
117
124
  serverOptions(
118
125
  program.command('dev').description('Start the development server').argument('[entry]', 'Entry file', 'app.ts'),
119
- ).action((entry, opts) => {
120
- runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
126
+ ).action(async (entry, opts) => {
127
+ await runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
121
128
  });
122
129
 
123
130
  serverOptions(
@@ -125,8 +132,8 @@ serverOptions(
125
132
  .command('dev:watch')
126
133
  .description('Start the development server with watch mode (restarts on file changes)')
127
134
  .argument('[entry]', 'Entry file', 'app.ts'),
128
- ).action((entry, opts) => {
129
- runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
135
+ ).action(async (entry, opts) => {
136
+ await runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
130
137
  });
131
138
 
132
139
  serverOptions(
@@ -134,28 +141,29 @@ serverOptions(
134
141
  .command('dev:hot')
135
142
  .description('Start the development server with hot reload (HMR without restart)')
136
143
  .argument('[entry]', 'Entry file', 'app.ts'),
137
- ).action((entry, opts) => {
138
- runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
144
+ ).action(async (entry, opts) => {
145
+ await runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
139
146
  });
140
147
 
141
148
  program
142
149
  .command('build')
143
150
  .description('Build the project for production')
144
151
  .argument('[entry]', 'Entry file', 'app.ts')
145
- .action((entry) => {
146
- runBunCommand(['--build'], { nodeEnv: 'production' }, 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);
147
155
  });
148
156
 
149
157
  serverOptions(
150
158
  program.command('start').description('Start the production server').argument('[entry]', 'Entry file', 'app.ts'),
151
- ).action((entry, opts) => {
152
- runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
159
+ ).action(async (entry, opts) => {
160
+ await runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
153
161
  });
154
162
 
155
163
  serverOptions(
156
164
  program.command('preview').description('Preview the production build').argument('[entry]', 'Entry file', 'app.ts'),
157
- ).action((entry, opts) => {
158
- runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
165
+ ).action(async (entry, opts) => {
166
+ await runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
159
167
  });
160
168
 
161
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.1.105",
3
+ "version": "0.2.0-alpha.10",
4
4
  "description": "CLI utilities for Ecopages",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/ecopages/ecopages.git",
11
- "directory": "npm/ecopages"
11
+ "directory": "packages/ecopages"
12
12
  },
13
13
  "homepage": "https://github.com/ecopages/ecopages#readme",
14
14
  "bugs": {
@@ -34,12 +34,14 @@
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"
40
42
  },
41
43
  "peerDependencies": {
42
- "bun-types": "latest",
44
+ "bun-types": "*",
43
45
  "typescript": "^5"
44
46
  }
45
47
  }