ecopages 0.2.0-alpha.9 → 0.2.1
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 +19 -23
- package/bin/cli.js +196 -105
- package/bin/cli.test.ts +136 -0
- package/bin/launch-plan.js +23 -53
- package/bin/launch-plan.test.ts +28 -184
- package/package.json +5 -5
- package/bin/node-thin-host.js +0 -138
- package/bin/node-thin-host.test.ts +0 -167
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The official CLI for the Ecopages framework.
|
|
4
4
|
|
|
5
|
-
It provides scaffolding and development commands to streamline your workflow. It
|
|
5
|
+
It provides scaffolding and development commands to streamline your workflow. It prefers Bun when available, falls back to Node otherwise, and automatically detects your `eco.config.ts`.
|
|
6
6
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
@@ -17,15 +17,15 @@ bun dev
|
|
|
17
17
|
|
|
18
18
|
## Commands
|
|
19
19
|
|
|
20
|
-
| Command | Description | Equivalent (Bun) |
|
|
21
|
-
| :--------------------------- | :----------------------------------------- | :------------------------------ |
|
|
22
|
-
| `ecopages init <dir>` | Scaffolds a new project | N/A |
|
|
23
|
-
| `ecopages dev [entry]` | Starts the dev server | `bun run [entry] --dev` |
|
|
24
|
-
| `ecopages dev:watch [entry]` | Dev server + hard restarts on file changes | `bun --watch run [entry] --dev` |
|
|
25
|
-
| `ecopages dev:hot [entry]` | Dev server + HMR (no hard restarts) | `bun --hot run [entry] --dev` |
|
|
26
|
-
| `ecopages build [entry]` | Creates a production build | `bun run [entry] --build` |
|
|
27
|
-
| `ecopages start [entry]` | Starts the production server | `bun run [entry]` |
|
|
28
|
-
| `ecopages preview [entry]` | Previews the production build locally | `bun run [entry] --preview` |
|
|
20
|
+
| Command | Description | Equivalent (Bun) |
|
|
21
|
+
| :--------------------------- | :----------------------------------------- | :------------------------------ |
|
|
22
|
+
| `ecopages init <dir>` | Scaffolds a new project | N/A |
|
|
23
|
+
| `ecopages dev [entry]` | Starts the dev server | `bun run [entry] --dev` |
|
|
24
|
+
| `ecopages dev:watch [entry]` | Dev server + hard restarts on file changes | `bun --watch run [entry] --dev` |
|
|
25
|
+
| `ecopages dev:hot [entry]` | Dev server + HMR (no hard restarts) | `bun --hot run [entry] --dev` |
|
|
26
|
+
| `ecopages build [entry]` | Creates a production build | `bun run [entry] --build` |
|
|
27
|
+
| `ecopages start [entry]` | Starts the production server | `bun run [entry]` |
|
|
28
|
+
| `ecopages preview [entry]` | Previews the production build locally | `bun run [entry] --preview` |
|
|
29
29
|
|
|
30
30
|
> [!NOTE]
|
|
31
31
|
> `[entry]` defaults to `app.ts` if not provided.
|
|
@@ -34,27 +34,23 @@ bun dev
|
|
|
34
34
|
|
|
35
35
|
Server and build commands accept the following options. They automatically map to the equivalent environment variables for the underlying process:
|
|
36
36
|
|
|
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
|
|
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` or `node` |
|
|
45
45
|
|
|
46
46
|
### Runtime Detection
|
|
47
47
|
|
|
48
|
-
The CLI
|
|
49
|
-
|
|
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.
|
|
48
|
+
The CLI prefers Bun when the package manager already indicates Bun, when the `Bun` global is available, or when you force it with `--runtime bun`. Otherwise it falls back to Node.
|
|
51
49
|
|
|
52
50
|
You can explicitly force the engine using the `--runtime` flag:
|
|
53
51
|
|
|
54
52
|
```bash
|
|
55
|
-
ecopages dev --runtime node
|
|
56
53
|
ecopages build --runtime bun
|
|
57
|
-
ecopages dev --runtime node-experimental
|
|
58
54
|
```
|
|
59
55
|
|
|
60
56
|
### Example Usage
|
package/bin/cli.js
CHANGED
|
@@ -1,69 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { defineCommand, runMain } from 'citty';
|
|
4
|
+
import { downloadTemplate } from 'giget';
|
|
4
5
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
6
|
import { spawn } from 'node:child_process';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
|
-
import tiged from 'tiged';
|
|
8
8
|
import { Logger } from '@ecopages/logger';
|
|
9
9
|
import { createLaunchPlan, launchPlanRequiresExistingEntryFile } from './launch-plan.js';
|
|
10
10
|
|
|
11
|
-
const logger = new Logger('[ecopages:cli]');
|
|
11
|
+
const logger = new Logger('[ecopages:cli]', { debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true' });
|
|
12
12
|
|
|
13
|
-
const program = new Command();
|
|
14
13
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
15
14
|
|
|
16
|
-
program.name('ecopages').description('Ecopages CLI utilities').version(pkg.version);
|
|
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
|
-
|
|
53
|
-
program
|
|
54
|
-
.command('init <dir>')
|
|
55
|
-
.description('Initialize a new project from a template')
|
|
56
|
-
.option('--template <name>', 'Template name from ecopages/examples/', 'starter-jsx')
|
|
57
|
-
.option('--repo <repo>', 'GitHub repo (user/repo)', 'ecopages/ecopages')
|
|
58
|
-
.action(handleInit);
|
|
59
|
-
|
|
60
15
|
function runLaunchPlan(launchPlan) {
|
|
61
16
|
if (Object.keys(launchPlan.envOverrides).length > 0) {
|
|
62
|
-
logger.
|
|
17
|
+
logger.debug(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
|
|
63
18
|
}
|
|
64
19
|
|
|
65
|
-
logger.
|
|
66
|
-
logger.
|
|
20
|
+
logger.debug(`Runtime: ${launchPlan.runtime}`);
|
|
21
|
+
logger.debug(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(' ')}`);
|
|
67
22
|
|
|
68
23
|
const child = spawn(launchPlan.command, launchPlan.commandArgs, {
|
|
69
24
|
stdio: 'inherit',
|
|
@@ -73,9 +28,9 @@ function runLaunchPlan(launchPlan) {
|
|
|
73
28
|
child.on('error', (error) => {
|
|
74
29
|
if (error && error.code === 'ENOENT') {
|
|
75
30
|
const hint =
|
|
76
|
-
launchPlan.
|
|
31
|
+
launchPlan.runtime === 'bun'
|
|
77
32
|
? 'Install Bun from https://bun.sh to continue.'
|
|
78
|
-
: '
|
|
33
|
+
: 'Reinstall ecopages and its dependencies so the packaged tsx runtime is available for Node.js launches.';
|
|
79
34
|
logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
|
|
80
35
|
process.exit(1);
|
|
81
36
|
}
|
|
@@ -90,14 +45,22 @@ function runLaunchPlan(launchPlan) {
|
|
|
90
45
|
}
|
|
91
46
|
|
|
92
47
|
/**
|
|
93
|
-
*
|
|
48
|
+
* Launch the entry file via the detected or forced runtime (bun or node).
|
|
94
49
|
* Automatically detects eco.config.ts and applies preloads.
|
|
95
50
|
* @param {string[]} args - Arguments to pass to the entry file
|
|
96
51
|
* @param {object} options - CLI options (watch, hot, port, hostname, etc.)
|
|
97
52
|
* @param {string} entryFile - Entry file to run
|
|
98
53
|
*/
|
|
99
|
-
async function
|
|
100
|
-
|
|
54
|
+
async function runEntryCommand(args, options = {}, entryFile = 'app.ts') {
|
|
55
|
+
let launchPlan;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
launchPlan = await createLaunchPlan(args, options, entryFile);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
61
|
+
logger.error(message);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
101
64
|
|
|
102
65
|
if (launchPlanRequiresExistingEntryFile(launchPlan) && !existsSync(entryFile)) {
|
|
103
66
|
logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
|
|
@@ -107,63 +70,191 @@ async function runBunCommand(args, options = {}, entryFile = 'app.ts') {
|
|
|
107
70
|
runLaunchPlan(launchPlan);
|
|
108
71
|
}
|
|
109
72
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
73
|
+
/** Shared server argument definitions for citty commands. */
|
|
74
|
+
const serverArgs = {
|
|
75
|
+
entry: {
|
|
76
|
+
type: 'positional',
|
|
77
|
+
description: 'Entry file',
|
|
78
|
+
default: 'app.ts',
|
|
79
|
+
},
|
|
80
|
+
port: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
alias: ['p'],
|
|
83
|
+
description: 'Override ECOPAGES_PORT',
|
|
84
|
+
},
|
|
85
|
+
hostname: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
alias: ['n'],
|
|
88
|
+
description: 'Override ECOPAGES_HOSTNAME',
|
|
89
|
+
},
|
|
90
|
+
'base-url': {
|
|
91
|
+
type: 'string',
|
|
92
|
+
alias: ['b'],
|
|
93
|
+
description: 'Override ECOPAGES_BASE_URL',
|
|
94
|
+
},
|
|
95
|
+
debug: {
|
|
96
|
+
type: 'boolean',
|
|
97
|
+
alias: ['d'],
|
|
98
|
+
description: 'Enable debug logging (ECOPAGES_LOGGER_DEBUG=true)',
|
|
99
|
+
},
|
|
100
|
+
'react-fast-refresh': {
|
|
101
|
+
type: 'boolean',
|
|
102
|
+
alias: ['r'],
|
|
103
|
+
description: 'Enable React Fast Refresh for HMR',
|
|
104
|
+
},
|
|
105
|
+
runtime: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
description: 'Force a specific runtime (bun or node)',
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const initCommand = defineCommand({
|
|
112
|
+
meta: {
|
|
113
|
+
name: 'init',
|
|
114
|
+
description: 'Initialize a new project from a template',
|
|
115
|
+
},
|
|
116
|
+
args: {
|
|
117
|
+
dir: {
|
|
118
|
+
type: 'positional',
|
|
119
|
+
description: 'Target directory name',
|
|
120
|
+
required: true,
|
|
121
|
+
},
|
|
122
|
+
template: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Template name from ecopages/examples/',
|
|
125
|
+
default: 'starter-jsx',
|
|
126
|
+
},
|
|
127
|
+
repo: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
description: 'GitHub repo (user/repo)',
|
|
130
|
+
default: 'ecopages/ecopages',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
async run({ args }) {
|
|
134
|
+
const { dir, template, repo } = args;
|
|
135
|
+
|
|
136
|
+
if (existsSync(dir)) {
|
|
137
|
+
logger.error(`Target directory already exists: ${dir}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
logger.info(`Creating target directory '${dir}'...`);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await downloadTemplate(`github:${repo}/examples/${template}`, {
|
|
145
|
+
dir,
|
|
146
|
+
force: true,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const pkgPath = join(dir, 'package.json');
|
|
150
|
+
if (existsSync(pkgPath)) {
|
|
151
|
+
const projectPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
152
|
+
projectPkg.name = dir;
|
|
153
|
+
writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
|
|
154
|
+
logger.info(`Renamed project to '${dir}'`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
logger.info('Project initialized! Run `bun install && bun dev` to start.');
|
|
158
|
+
} catch (err) {
|
|
159
|
+
logger.error(`Failed to fetch template: ${err.message}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
128
163
|
});
|
|
129
164
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
165
|
+
const devCommand = defineCommand({
|
|
166
|
+
meta: {
|
|
167
|
+
name: 'dev',
|
|
168
|
+
description: 'Start the development server',
|
|
169
|
+
},
|
|
170
|
+
args: serverArgs,
|
|
171
|
+
async run({ args }) {
|
|
172
|
+
await runEntryCommand(['--dev'], { ...args, nodeEnv: 'development' }, args.entry);
|
|
173
|
+
},
|
|
137
174
|
});
|
|
138
175
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
176
|
+
const devWatchCommand = defineCommand({
|
|
177
|
+
meta: {
|
|
178
|
+
name: 'dev:watch',
|
|
179
|
+
description: 'Start the development server with watch mode (restarts on file changes)',
|
|
180
|
+
},
|
|
181
|
+
args: serverArgs,
|
|
182
|
+
async run({ args }) {
|
|
183
|
+
await runEntryCommand(['--dev'], { ...args, watch: true, nodeEnv: 'development' }, args.entry);
|
|
184
|
+
},
|
|
146
185
|
});
|
|
147
186
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
const devHotCommand = defineCommand({
|
|
188
|
+
meta: {
|
|
189
|
+
name: 'dev:hot',
|
|
190
|
+
description: 'Start the development server with hot reload (HMR without restart)',
|
|
191
|
+
},
|
|
192
|
+
args: serverArgs,
|
|
193
|
+
async run({ args }) {
|
|
194
|
+
await runEntryCommand(['--dev'], { ...args, hot: true, nodeEnv: 'development' }, args.entry);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
156
197
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
198
|
+
const buildCommand = defineCommand({
|
|
199
|
+
meta: {
|
|
200
|
+
name: 'build',
|
|
201
|
+
description: 'Build the project for production',
|
|
202
|
+
},
|
|
203
|
+
args: {
|
|
204
|
+
entry: {
|
|
205
|
+
type: 'positional',
|
|
206
|
+
description: 'Entry file',
|
|
207
|
+
default: 'app.ts',
|
|
208
|
+
},
|
|
209
|
+
runtime: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Force a specific runtime (bun or node)',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
async run({ args }) {
|
|
215
|
+
await runEntryCommand(['--build'], { nodeEnv: 'production', ...args }, args.entry);
|
|
216
|
+
},
|
|
161
217
|
});
|
|
162
218
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
219
|
+
const startCommand = defineCommand({
|
|
220
|
+
meta: {
|
|
221
|
+
name: 'start',
|
|
222
|
+
description: 'Start the production server',
|
|
223
|
+
},
|
|
224
|
+
args: serverArgs,
|
|
225
|
+
async run({ args }) {
|
|
226
|
+
await runEntryCommand([], { ...args, nodeEnv: 'production' }, args.entry);
|
|
227
|
+
},
|
|
167
228
|
});
|
|
168
229
|
|
|
169
|
-
|
|
230
|
+
const previewCommand = defineCommand({
|
|
231
|
+
meta: {
|
|
232
|
+
name: 'preview',
|
|
233
|
+
description: 'Preview the production build',
|
|
234
|
+
},
|
|
235
|
+
args: serverArgs,
|
|
236
|
+
async run({ args }) {
|
|
237
|
+
await runEntryCommand(['--preview'], { ...args, nodeEnv: 'production' }, args.entry);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export const mainCommand = defineCommand({
|
|
242
|
+
meta: {
|
|
243
|
+
name: 'ecopages',
|
|
244
|
+
version: pkg.version,
|
|
245
|
+
description: 'Ecopages CLI utilities',
|
|
246
|
+
},
|
|
247
|
+
subCommands: {
|
|
248
|
+
init: initCommand,
|
|
249
|
+
dev: devCommand,
|
|
250
|
+
'dev:watch': devWatchCommand,
|
|
251
|
+
'dev:hot': devHotCommand,
|
|
252
|
+
build: buildCommand,
|
|
253
|
+
start: startCommand,
|
|
254
|
+
preview: previewCommand,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!process.env.VITEST) {
|
|
259
|
+
runMain(mainCommand);
|
|
260
|
+
}
|
package/bin/cli.test.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { runCommand } from 'citty';
|
|
3
|
+
import { mainCommand } from './cli.js';
|
|
4
|
+
import * as giget from 'giget';
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as launchPlan from './launch-plan.js';
|
|
7
|
+
|
|
8
|
+
vi.mock('giget', () => ({
|
|
9
|
+
downloadTemplate: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('node:fs', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import('node:fs')>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
existsSync: vi.fn((path) => actual.existsSync(path)),
|
|
17
|
+
writeFileSync: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock('./launch-plan.js', () => ({
|
|
22
|
+
createLaunchPlan: vi.fn(),
|
|
23
|
+
launchPlanRequiresExistingEntryFile: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('node:child_process', () => ({
|
|
27
|
+
spawn: vi.fn().mockImplementation(() => ({
|
|
28
|
+
on: vi.fn(),
|
|
29
|
+
})),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('@ecopages/logger', () => ({
|
|
33
|
+
Logger: class {
|
|
34
|
+
info = vi.fn();
|
|
35
|
+
warn = vi.fn();
|
|
36
|
+
error = vi.fn();
|
|
37
|
+
debug = vi.fn();
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
describe('CLI Commands', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
|
|
45
|
+
// Default mocks
|
|
46
|
+
vi.mocked(fs.existsSync).mockReturnValue(false); // pretend no existing dir
|
|
47
|
+
vi.mocked(launchPlan.createLaunchPlan).mockResolvedValue({
|
|
48
|
+
runtime: 'node',
|
|
49
|
+
command: 'node',
|
|
50
|
+
commandArgs: [],
|
|
51
|
+
envOverrides: {},
|
|
52
|
+
env: {},
|
|
53
|
+
} as any);
|
|
54
|
+
vi.mocked(launchPlan.launchPlanRequiresExistingEntryFile).mockReturnValue(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('runs init command with default template and repo', async () => {
|
|
58
|
+
await runCommand(mainCommand, { rawArgs: ['init', 'my-new-project'] });
|
|
59
|
+
expect(giget.downloadTemplate).toHaveBeenCalledWith('github:ecopages/ecopages/examples/starter-jsx', {
|
|
60
|
+
dir: 'my-new-project',
|
|
61
|
+
force: true,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('runs init command with custom template and repo', async () => {
|
|
66
|
+
await runCommand(mainCommand, {
|
|
67
|
+
rawArgs: ['init', 'my-dir', '--template', 'starter-lit', '--repo', 'custom/repo'],
|
|
68
|
+
});
|
|
69
|
+
expect(giget.downloadTemplate).toHaveBeenCalledWith('github:custom/repo/examples/starter-lit', {
|
|
70
|
+
dir: 'my-dir',
|
|
71
|
+
force: true,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('runs dev command and passes defaults to launch plan', async () => {
|
|
76
|
+
await runCommand(mainCommand, { rawArgs: ['dev'] });
|
|
77
|
+
expect(launchPlan.createLaunchPlan).toHaveBeenCalledWith(
|
|
78
|
+
['--dev'],
|
|
79
|
+
expect.objectContaining({ nodeEnv: 'development' }),
|
|
80
|
+
'app.ts',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('runs dev:hot command', async () => {
|
|
85
|
+
await runCommand(mainCommand, { rawArgs: ['dev:hot'] });
|
|
86
|
+
expect(launchPlan.createLaunchPlan).toHaveBeenCalledWith(
|
|
87
|
+
['--dev'],
|
|
88
|
+
expect.objectContaining({ hot: true, nodeEnv: 'development' }),
|
|
89
|
+
'app.ts',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('runs dev:watch command', async () => {
|
|
94
|
+
await runCommand(mainCommand, { rawArgs: ['dev:watch'] });
|
|
95
|
+
expect(launchPlan.createLaunchPlan).toHaveBeenCalledWith(
|
|
96
|
+
['--dev'],
|
|
97
|
+
expect.objectContaining({ watch: true, nodeEnv: 'development' }),
|
|
98
|
+
'app.ts',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('runs build command with custom entry file', async () => {
|
|
103
|
+
await runCommand(mainCommand, { rawArgs: ['build', 'server.ts'] });
|
|
104
|
+
expect(launchPlan.createLaunchPlan).toHaveBeenCalledWith(
|
|
105
|
+
['--build'],
|
|
106
|
+
expect.objectContaining({ nodeEnv: 'production' }),
|
|
107
|
+
'server.ts',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('passes shared server options like port and hostname correctly', async () => {
|
|
112
|
+
await runCommand(mainCommand, { rawArgs: ['start', '-p', '4000', '--hostname', '0.0.0.0'] });
|
|
113
|
+
expect(launchPlan.createLaunchPlan).toHaveBeenCalledWith(
|
|
114
|
+
[],
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
nodeEnv: 'production',
|
|
117
|
+
port: '4000',
|
|
118
|
+
hostname: '0.0.0.0',
|
|
119
|
+
}),
|
|
120
|
+
'app.ts',
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('allows overriding base url and debug options', async () => {
|
|
125
|
+
await runCommand(mainCommand, { rawArgs: ['preview', '--base-url', '/my-app/', '-d'] });
|
|
126
|
+
expect(launchPlan.createLaunchPlan).toHaveBeenCalledWith(
|
|
127
|
+
['--preview'],
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
nodeEnv: 'production',
|
|
130
|
+
'base-url': '/my-app/',
|
|
131
|
+
debug: true,
|
|
132
|
+
}),
|
|
133
|
+
'app.ts',
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
package/bin/launch-plan.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
4
3
|
|
|
5
|
-
const
|
|
6
|
-
const DEFAULT_INTERNAL_WORK_DIR = '.eco';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
7
5
|
|
|
8
6
|
export function buildEnvOverrides(options) {
|
|
9
7
|
const env = {};
|
|
@@ -16,7 +14,7 @@ export function buildEnvOverrides(options) {
|
|
|
16
14
|
}
|
|
17
15
|
|
|
18
16
|
export function detectRuntime(options = {}) {
|
|
19
|
-
if (options.runtime === 'bun' || options.runtime === 'node'
|
|
17
|
+
if (options.runtime === 'bun' || options.runtime === 'node') {
|
|
20
18
|
return options.runtime;
|
|
21
19
|
}
|
|
22
20
|
|
|
@@ -42,7 +40,7 @@ export function buildBunArgs(args, options, entryFile, hasConfig) {
|
|
|
42
40
|
bunArgs.push('run');
|
|
43
41
|
|
|
44
42
|
if (hasConfig) {
|
|
45
|
-
bunArgs.push('--preload', 'eco.config.ts');
|
|
43
|
+
bunArgs.push('--preload', './eco.config.ts');
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
bunArgs.push(entryFile, ...args);
|
|
@@ -54,12 +52,14 @@ export function buildBunArgs(args, options, entryFile, hasConfig) {
|
|
|
54
52
|
return bunArgs;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
export function buildNodeArgs(args, options, entryFile) {
|
|
55
|
+
export function buildNodeArgs(args, options, entryFile, hasConfig) {
|
|
58
56
|
const nodeArgs = [];
|
|
59
57
|
|
|
60
|
-
if (
|
|
58
|
+
if (hasConfig) {
|
|
59
|
+
nodeArgs.push('--import', './eco.config.ts');
|
|
60
|
+
}
|
|
61
61
|
|
|
62
|
-
nodeArgs.push(
|
|
62
|
+
nodeArgs.push(entryFile, ...args);
|
|
63
63
|
|
|
64
64
|
if (options.reactFastRefresh) {
|
|
65
65
|
nodeArgs.push('--react-fast-refresh');
|
|
@@ -68,41 +68,14 @@ export function buildNodeArgs(args, options, entryFile) {
|
|
|
68
68
|
return nodeArgs;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.');
|
|
71
|
+
export function resolveTsxCliPath() {
|
|
72
|
+
try {
|
|
73
|
+
return require.resolve('tsx/cli');
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Unable to resolve the packaged tsx runtime required for Node.js launches. Reinstall ecopages and its dependencies, or use the Bun runtime instead.',
|
|
77
|
+
);
|
|
88
78
|
}
|
|
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
79
|
}
|
|
107
80
|
|
|
108
81
|
export async function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
|
|
@@ -111,19 +84,16 @@ export async function createLaunchPlan(args, options = {}, entryFile = 'app.ts')
|
|
|
111
84
|
const runtime = detectRuntime(options);
|
|
112
85
|
const env = { ...process.env, ...envOverrides };
|
|
113
86
|
|
|
114
|
-
if (runtime === 'node'
|
|
115
|
-
const
|
|
87
|
+
if (runtime === 'node') {
|
|
88
|
+
const tsxCliPath = resolveTsxCliPath();
|
|
116
89
|
|
|
117
90
|
return {
|
|
118
91
|
runtime,
|
|
119
|
-
executionStrategy: '
|
|
120
|
-
command:
|
|
121
|
-
commandArgs: buildNodeArgs(args, options, entryFile),
|
|
92
|
+
executionStrategy: 'direct-runtime',
|
|
93
|
+
command: process.execPath,
|
|
94
|
+
commandArgs: [tsxCliPath, ...buildNodeArgs(args, options, entryFile, hasConfig)],
|
|
122
95
|
envOverrides,
|
|
123
|
-
env
|
|
124
|
-
...env,
|
|
125
|
-
ECOPAGES_NODE_RUNTIME_MANIFEST_PATH: manifestFilePath,
|
|
126
|
-
},
|
|
96
|
+
env,
|
|
127
97
|
};
|
|
128
98
|
}
|
|
129
99
|
|
package/bin/launch-plan.test.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
6
|
import {
|
|
6
7
|
buildEnvOverrides,
|
|
7
|
-
buildNodeArgs,
|
|
8
8
|
buildBunArgs,
|
|
9
|
-
|
|
9
|
+
buildNodeArgs,
|
|
10
10
|
createLaunchPlan,
|
|
11
11
|
detectRuntime,
|
|
12
12
|
launchPlanRequiresExistingEntryFile,
|
|
13
|
-
|
|
13
|
+
resolveTsxCliPath,
|
|
14
14
|
} from './launch-plan.js';
|
|
15
15
|
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
16
18
|
const originalUserAgent = process.env.npm_config_user_agent;
|
|
17
19
|
|
|
18
20
|
afterEach(() => {
|
|
@@ -46,27 +48,6 @@ describe('launch-plan', () => {
|
|
|
46
48
|
);
|
|
47
49
|
}
|
|
48
50
|
|
|
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
51
|
it('buildEnvOverrides maps CLI options onto environment variables', () => {
|
|
71
52
|
expect(
|
|
72
53
|
buildEnvOverrides({
|
|
@@ -85,31 +66,11 @@ describe('launch-plan', () => {
|
|
|
85
66
|
});
|
|
86
67
|
});
|
|
87
68
|
|
|
88
|
-
it('detectRuntime
|
|
69
|
+
it('detectRuntime returns node when Bun is not available', () => {
|
|
89
70
|
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
90
71
|
expect(detectRuntime()).toBe('node');
|
|
91
72
|
expect(detectRuntime({ runtime: 'bun' })).toBe('bun');
|
|
92
73
|
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
74
|
});
|
|
114
75
|
|
|
115
76
|
it('buildBunArgs preloads eco.config.ts when present', () => {
|
|
@@ -117,66 +78,45 @@ describe('launch-plan', () => {
|
|
|
117
78
|
'--hot',
|
|
118
79
|
'run',
|
|
119
80
|
'--preload',
|
|
120
|
-
'eco.config.ts',
|
|
81
|
+
'./eco.config.ts',
|
|
121
82
|
'app.ts',
|
|
122
83
|
'--dev',
|
|
123
84
|
]);
|
|
124
85
|
});
|
|
125
86
|
|
|
126
|
-
it('
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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');
|
|
87
|
+
it('buildNodeArgs imports eco.config.ts when present', () => {
|
|
88
|
+
expect(buildNodeArgs(['--dev'], {}, 'app.ts', true)).toEqual([
|
|
89
|
+
'--import',
|
|
90
|
+
'./eco.config.ts',
|
|
91
|
+
'app.ts',
|
|
92
|
+
'--dev',
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
143
95
|
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
}
|
|
96
|
+
it('resolveTsxCliPath resolves the packaged tsx cli entry', () => {
|
|
97
|
+
expect(resolveTsxCliPath()).toBe(require.resolve('tsx/cli'));
|
|
159
98
|
});
|
|
160
99
|
|
|
161
|
-
it('createLaunchPlan
|
|
100
|
+
it('createLaunchPlan uses the packaged tsx cli for node runtime', async () => {
|
|
162
101
|
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
163
102
|
try {
|
|
164
103
|
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
165
104
|
process.chdir(tempDir);
|
|
166
|
-
fs.writeFileSync(path.join(tempDir, '
|
|
105
|
+
fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
|
|
167
106
|
writeExperimentalRuntimeConfig(tempDir);
|
|
168
107
|
|
|
169
|
-
const plan = await createLaunchPlan(['--dev'], {
|
|
108
|
+
const plan = await createLaunchPlan(['--dev'], { runtime: 'node', nodeEnv: 'development' }, 'app.ts');
|
|
170
109
|
|
|
171
110
|
expect(plan).toMatchObject({
|
|
172
111
|
runtime: 'node',
|
|
173
|
-
executionStrategy: '
|
|
174
|
-
command:
|
|
112
|
+
executionStrategy: 'direct-runtime',
|
|
113
|
+
command: process.execPath,
|
|
175
114
|
});
|
|
176
115
|
expect(plan.commandArgs).toEqual([
|
|
177
|
-
'
|
|
178
|
-
|
|
179
|
-
'
|
|
116
|
+
require.resolve('tsx/cli'),
|
|
117
|
+
'--import',
|
|
118
|
+
'./eco.config.ts',
|
|
119
|
+
'app.ts',
|
|
180
120
|
'--dev',
|
|
181
121
|
]);
|
|
182
122
|
} finally {
|
|
@@ -198,90 +138,7 @@ describe('launch-plan', () => {
|
|
|
198
138
|
executionStrategy: 'direct-runtime',
|
|
199
139
|
command: 'bun',
|
|
200
140
|
});
|
|
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
|
-
});
|
|
141
|
+
expect(plan.commandArgs).toEqual(['run', '--preload', './eco.config.ts', 'app.ts', '--preview']);
|
|
285
142
|
} finally {
|
|
286
143
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
287
144
|
}
|
|
@@ -290,25 +147,12 @@ describe('launch-plan', () => {
|
|
|
290
147
|
it('launchPlanRequiresExistingEntryFile requires a concrete entry on every runtime path', async () => {
|
|
291
148
|
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
|
|
292
149
|
try {
|
|
293
|
-
process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
|
|
294
150
|
process.chdir(tempDir);
|
|
295
151
|
fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
|
|
296
152
|
writeExperimentalRuntimeConfig(tempDir);
|
|
297
153
|
|
|
298
|
-
const
|
|
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');
|
|
154
|
+
const bunPlan = await createLaunchPlan(['--dev'], { nodeEnv: 'development' }, 'app.ts');
|
|
308
155
|
expect(launchPlanRequiresExistingEntryFile(bunPlan)).toBe(true);
|
|
309
|
-
|
|
310
|
-
const experimentalNodePlan = await createLaunchPlan(['--dev'], { runtime: 'node-experimental' }, 'app.ts');
|
|
311
|
-
expect(launchPlanRequiresExistingEntryFile(experimentalNodePlan)).toBe(true);
|
|
312
156
|
} finally {
|
|
313
157
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
314
158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecopages",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "CLI utilities for Ecopages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ecopages/core": "workspace:*",
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
38
|
+
"@ecopages/logger": "^0.2.2",
|
|
39
|
+
"citty": "^0.1.6",
|
|
40
|
+
"giget": "^2.0.0",
|
|
41
|
+
"tsx": "^4.21.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"bun-types": "*",
|
package/bin/node-thin-host.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
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
|
-
});
|