ecopages 0.2.0-alpha.2 → 0.2.0-alpha.21

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 prefers Bun when available, falls back to Node otherwise, and automatically detects your `eco.config.ts`.
10
6
 
11
7
  ## Quick Start
12
8
 
13
- Initialize a new project:
9
+ Initialize a new project from the default template:
14
10
 
15
11
  ```bash
16
12
  bunx ecopages init my-app
@@ -19,91 +15,76 @@ bun install
19
15
  bun dev
20
16
  ```
21
17
 
22
- ## CLI Utilities
18
+ ## Commands
23
19
 
24
- ### Commands
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` |
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` or `node` |
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 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
- ```bash
53
- # Start dev server on port 8080 with debug logging
54
- ecopages dev --port 8080 --debug
55
-
56
- # Start dev server with React Fast Refresh
57
- ecopages dev -r
50
+ You can explicitly force the engine using the `--runtime` flag:
58
51
 
59
- # Start production server with custom hostname
60
- ecopages start --hostname 0.0.0.0 --port 3001
52
+ ```bash
53
+ ecopages build --runtime bun
61
54
  ```
62
55
 
63
- ## Ecopages Packages
56
+ ### Example Usage
64
57
 
65
- The Ecopages ecosystem consists of individual framework packages published to [JSR](https://jsr.io/@ecopages). Import them directly in your project:
58
+ ```bash
59
+ # Debug dev server on custom port
60
+ ecopages dev --port 8080 --debug
66
61
 
67
- ```typescript
68
- import { eco } from '@ecopages/core';
69
- import { kitajsPlugin } from '@ecopages/kitajs';
62
+ # Dev server with React Fast Refresh enabled
63
+ ecopages dev -r
70
64
  ```
71
65
 
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) |
66
+ ## Ecosystem & Plugins
77
67
 
78
- | `@ecopages/core` | Foundational layer of the Ecopages ecosystem. | [JSR](https://jsr.io/@ecopages/core) |
79
- | `@ecopages/file-system` | Runtime-agnostic file system utilities (Bun/Node.js). | [JSR](https://jsr.io/@ecopages/file-system) |
80
- | `@ecopages/image-processor` | Image processing library for optimized responsive images. | [JSR](https://jsr.io/@ecopages/image-processor) |
81
- | `@ecopages/kitajs` | KitaJS plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/kitajs) |
82
- | `@ecopages/lit` | Lit plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/lit) |
83
- | `@ecopages/mdx` | MDX plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/mdx) |
84
- | `@ecopages/postcss-processor` | Utility functions for processing CSS with PostCSS. | [JSR](https://jsr.io/@ecopages/postcss-processor) |
85
- | `@ecopages/react` | React plugin for Ecopages integration. | [JSR](https://jsr.io/@ecopages/react) |
86
- | `@ecopages/react-router` | Client-side SPA router for Ecopages React apps. | [JSR](https://jsr.io/@ecopages/react-router) |
68
+ Ecopages relies on a modular architecture. Core logic and framework integrations are published as `@ecopages/*` packages on [npm](https://www.npmjs.com/org/ecopages).
87
69
 
88
- Explore all packages at [jsr.io/@ecopages](https://jsr.io/@ecopages).
70
+ ### Official Packages
89
71
 
90
- ## Installation
72
+ | Package | Description |
73
+ | :---------------------------- | :----------------------------------------- |
74
+ | `@ecopages/browser-router` | Client-side navigation & view transitions. |
75
+ | `@ecopages/codemod` | AST migrations for codebase upgrades. |
76
+ | `@ecopages/core` | The foundational SSG engine. |
77
+ | `@ecopages/ecopages-jsx` | Ecopages-owned JSX routes and hydration. |
78
+ | `@ecopages/file-system` | Runtime-agnostic file system utilities. |
79
+ | `@ecopages/image-processor` | Asset pipeline for responsive images. |
80
+ | `@ecopages/kitajs` | Integration for KitaJS. |
81
+ | `@ecopages/lit` | Integration for Lit SSR/Islands. |
82
+ | `@ecopages/mdx` | Integration for standalone MDX routes. |
83
+ | `@ecopages/postcss-processor` | CSS processing pipeline using PostCSS. |
84
+ | `@ecopages/react` | Integration for React 19 SSR/Islands. |
85
+ | `@ecopages/react-router` | SPA routing for React. |
91
86
 
92
- ```bash
93
- bun add ecopages
94
- ```
95
-
96
- To use Ecopages packages in your project, create a `.npmrc` file in the root of your project to configure JSR registry resolution:
97
-
98
- ```ini
99
- @jsr:registry=https://npm.jsr.io
100
- ```
101
-
102
- Then add the packages you need:
103
-
104
- ```bash
105
- bun jsr add @ecopages/core @ecopages/kitajs
106
- ```
87
+ Explore all packages at [npmjs.com/org/ecopages](https://www.npmjs.com/org/ecopages).
107
88
 
108
89
  ## License
109
90
 
package/bin/cli.js CHANGED
@@ -1,169 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { Command } from 'commander';
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
+ import { createLaunchPlan, launchPlanRequiresExistingEntryFile } from './launch-plan.js';
9
10
 
10
- const logger = new Logger('[ecopages:cli]');
11
+ const logger = new Logger('[ecopages:cli]', { debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true' });
11
12
 
12
- const program = new Command();
13
13
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
14
14
 
15
- program.name('ecopages').description('Ecopages CLI utilities').version(pkg.version);
16
-
17
- async function handleInit(dir, opts) {
18
- const { template, repo } = opts;
19
- const targetDir = dir;
20
-
21
- if (existsSync(targetDir)) {
22
- logger.error(`Target directory already exists: ${targetDir}`);
23
- process.exit(1);
24
- }
25
-
26
- logger.info(`Creating target directory '${targetDir}'...`);
27
-
28
- try {
29
- const emitter = tiged(`${repo}/examples/${template}`, {
30
- disableCache: true,
31
- force: true,
32
- verbose: false,
33
- });
34
-
35
- await emitter.clone(targetDir);
36
-
37
- const pkgPath = join(targetDir, 'package.json');
38
- if (existsSync(pkgPath)) {
39
- const projectPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
40
- projectPkg.name = dir;
41
- writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
42
- logger.info(`Renamed project to '${dir}'`);
43
- }
44
-
45
- logger.info('Project initialized! Run `bun install && bun dev` to start.');
46
- } catch (err) {
47
- logger.error(`Failed to fetch template: ${err.message}`);
48
- process.exit(1);
49
- }
50
- }
51
-
52
- program
53
- .command('init <dir>')
54
- .description('Initialize a new project from a template')
55
- .option('--template <name>', 'Template name from ecopages/examples/', 'starter-jsx')
56
- .option('--repo <repo>', 'GitHub repo (user/repo)', 'ecopages/ecopages')
57
- .action(handleInit);
58
-
59
- /**
60
- * Build environment variables from CLI options
61
- */
62
- function buildEnvOverrides(options) {
63
- const env = {};
64
- if (options.port) env.ECOPAGES_PORT = String(options.port);
65
- if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
66
- if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
67
- if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
68
- if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
69
- return env;
70
- }
71
-
72
- function detectRuntime(options = {}) {
73
- if (options.runtime === 'bun' || options.runtime === 'node') {
74
- return options.runtime;
75
- }
76
-
77
- const userAgent = process.env.npm_config_user_agent || '';
78
-
79
- /**
80
- * If explicitly launched via bun (e.g., `bun run ...`)
81
- */
82
- if (userAgent.startsWith('bun/')) {
83
- return 'bun';
84
- }
85
-
86
- /**
87
- * Catch when CLI is run directly by Bun without a package manager command
88
- */
89
- if (typeof Bun !== 'undefined') {
90
- return 'bun';
91
- }
92
-
93
- /**
94
- * Default to node for npm, pnpm, yarn, or direct execution
95
- */
96
- return 'node';
97
- }
98
-
99
- function buildBunArgs(args, options, entryFile, hasConfig) {
100
- const bunArgs = [];
101
-
102
- if (options.watch) bunArgs.push('--watch');
103
- if (options.hot) bunArgs.push('--hot');
104
-
105
- bunArgs.push('run');
106
-
107
- if (hasConfig) {
108
- bunArgs.push('--preload', 'eco.config.ts');
109
- }
110
-
111
- bunArgs.push(entryFile, ...args);
112
-
113
- if (options.reactFastRefresh) {
114
- bunArgs.push('--react-fast-refresh');
115
- }
116
-
117
- return bunArgs;
118
- }
119
-
120
- function buildNodeArgs(args, options, entryFile) {
121
- const tsxArgs = [];
122
-
123
- if (options.watch) tsxArgs.push('watch');
124
-
125
- tsxArgs.push(entryFile, ...args);
126
-
127
- if (options.reactFastRefresh) {
128
- tsxArgs.push('--react-fast-refresh');
129
- }
130
-
131
- return tsxArgs;
132
- }
133
-
134
- function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
135
- const hasConfig = existsSync('eco.config.ts');
136
- const envOverrides = buildEnvOverrides(options);
137
- const runtime = detectRuntime(options);
138
-
139
- if (runtime === 'node') {
140
- return {
141
- runtime,
142
- executionStrategy: 'tsx',
143
- command: 'tsx',
144
- commandArgs: buildNodeArgs(args, options, entryFile),
145
- envOverrides,
146
- env: { ...process.env, ...envOverrides },
147
- };
148
- }
149
-
150
- return {
151
- runtime,
152
- executionStrategy: 'direct-runtime',
153
- command: 'bun',
154
- commandArgs: buildBunArgs(args, options, entryFile, hasConfig),
155
- envOverrides,
156
- env: { ...process.env, ...envOverrides },
157
- };
158
- }
159
-
160
15
  function runLaunchPlan(launchPlan) {
161
16
  if (Object.keys(launchPlan.envOverrides).length > 0) {
162
- logger.info(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
17
+ logger.debug(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
163
18
  }
164
19
 
165
- logger.info(`Runtime: ${launchPlan.runtime}`);
166
- logger.info(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(' ')}`);
20
+ logger.debug(`Runtime: ${launchPlan.runtime}`);
21
+ logger.debug(`Running: ${launchPlan.command} ${launchPlan.commandArgs.join(' ')}`);
167
22
 
168
23
  const child = spawn(launchPlan.command, launchPlan.commandArgs, {
169
24
  stdio: 'inherit',
@@ -173,9 +28,9 @@ function runLaunchPlan(launchPlan) {
173
28
  child.on('error', (error) => {
174
29
  if (error && error.code === 'ENOENT') {
175
30
  const hint =
176
- launchPlan.command === 'bun'
31
+ launchPlan.runtime === 'bun'
177
32
  ? 'Install Bun from https://bun.sh to continue.'
178
- : 'Install tsx (`npm i -g tsx` or add it as a devDependency) to continue.';
33
+ : 'Reinstall ecopages and its dependencies so the packaged tsx runtime is available for Node.js launches.';
179
34
  logger.error(`Command not found: ${launchPlan.command}. ${hint}`);
180
35
  process.exit(1);
181
36
  }
@@ -190,79 +45,216 @@ function runLaunchPlan(launchPlan) {
190
45
  }
191
46
 
192
47
  /**
193
- * Execute a bun command with the given arguments and options.
48
+ * Launch the entry file via the detected or forced runtime (bun or node).
194
49
  * Automatically detects eco.config.ts and applies preloads.
195
50
  * @param {string[]} args - Arguments to pass to the entry file
196
51
  * @param {object} options - CLI options (watch, hot, port, hostname, etc.)
197
52
  * @param {string} entryFile - Entry file to run
198
53
  */
199
- function runBunCommand(args, options = {}, entryFile = 'app.ts') {
200
- if (!existsSync(entryFile)) {
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
+ }
64
+
65
+ if (launchPlanRequiresExistingEntryFile(launchPlan) && !existsSync(entryFile)) {
201
66
  logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
202
67
  process.exit(1);
203
68
  }
204
69
 
205
- const launchPlan = createLaunchPlan(args, options, entryFile);
206
70
  runLaunchPlan(launchPlan);
207
71
  }
208
72
 
209
- /**
210
- * Add shared server options to a command.
211
- * @param {import('commander').Command} cmd - The command to add options to
212
- * @returns {import('commander').Command} The command with options added
213
- */
214
- const serverOptions = (cmd) =>
215
- cmd
216
- .option('-p, --port <port>', 'Override ECOPAGES_PORT')
217
- .option('-n, --hostname <hostname>', 'Override ECOPAGES_HOSTNAME')
218
- .option('-b, --base-url <url>', 'Override ECOPAGES_BASE_URL')
219
- .option('-d, --debug', 'Enable debug logging (ECOPAGES_LOGGER_DEBUG=true)')
220
- .option('-r, --react-fast-refresh', 'Enable React Fast Refresh for HMR')
221
- .option('--runtime <runtime>', 'Force a specific runtime (bun or node)');
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
+ }
222
140
 
223
- serverOptions(
224
- program.command('dev').description('Start the development server').argument('[entry]', 'Entry file', 'app.ts'),
225
- ).action((entry, opts) => {
226
- runBunCommand(['--dev'], { ...opts, nodeEnv: 'development' }, entry);
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
+ },
227
163
  });
228
164
 
229
- serverOptions(
230
- program
231
- .command('dev:watch')
232
- .description('Start the development server with watch mode (restarts on file changes)')
233
- .argument('[entry]', 'Entry file', 'app.ts'),
234
- ).action((entry, opts) => {
235
- runBunCommand(['--dev'], { ...opts, watch: true, nodeEnv: 'development' }, entry);
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
+ },
236
174
  });
237
175
 
238
- serverOptions(
239
- program
240
- .command('dev:hot')
241
- .description('Start the development server with hot reload (HMR without restart)')
242
- .argument('[entry]', 'Entry file', 'app.ts'),
243
- ).action((entry, opts) => {
244
- runBunCommand(['--dev'], { ...opts, hot: true, nodeEnv: 'development' }, entry);
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
+ },
245
185
  });
246
186
 
247
- program
248
- .command('build')
249
- .description('Build the project for production')
250
- .argument('[entry]', 'Entry file', 'app.ts')
251
- .option('--runtime <runtime>', 'Force a specific runtime (bun or node)')
252
- .action((entry, opts) => {
253
- runBunCommand(['--build'], { nodeEnv: 'production', ...opts }, entry);
254
- });
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
+ });
197
+
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
+ },
217
+ });
218
+
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
+ },
228
+ });
255
229
 
256
- serverOptions(
257
- program.command('start').description('Start the production server').argument('[entry]', 'Entry file', 'app.ts'),
258
- ).action((entry, opts) => {
259
- runBunCommand([], { ...opts, nodeEnv: 'production' }, entry);
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
+ },
260
239
  });
261
240
 
262
- serverOptions(
263
- program.command('preview').description('Preview the production build').argument('[entry]', 'Entry file', 'app.ts'),
264
- ).action((entry, opts) => {
265
- runBunCommand(['--preview'], { ...opts, nodeEnv: 'production' }, entry);
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
+ },
266
256
  });
267
257
 
268
- program.parse();
258
+ if (!process.env.VITEST) {
259
+ runMain(mainCommand);
260
+ }
@@ -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
+ });
@@ -0,0 +1,112 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+
6
+ export function buildEnvOverrides(options) {
7
+ const env = {};
8
+ if (options.port) env.ECOPAGES_PORT = String(options.port);
9
+ if (options.hostname) env.ECOPAGES_HOSTNAME = options.hostname;
10
+ if (options.baseUrl) env.ECOPAGES_BASE_URL = options.baseUrl;
11
+ if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
12
+ if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
13
+ return env;
14
+ }
15
+
16
+ export function detectRuntime(options = {}) {
17
+ if (options.runtime === 'bun' || options.runtime === 'node') {
18
+ return options.runtime;
19
+ }
20
+
21
+ const userAgent = process.env.npm_config_user_agent || '';
22
+
23
+ if (userAgent.startsWith('bun/')) {
24
+ return 'bun';
25
+ }
26
+
27
+ if (typeof Bun !== 'undefined') {
28
+ return 'bun';
29
+ }
30
+
31
+ return 'node';
32
+ }
33
+
34
+ export function buildBunArgs(args, options, entryFile, hasConfig) {
35
+ const bunArgs = [];
36
+
37
+ if (options.watch) bunArgs.push('--watch');
38
+ if (options.hot) bunArgs.push('--hot');
39
+
40
+ bunArgs.push('run');
41
+
42
+ if (hasConfig) {
43
+ bunArgs.push('--preload', './eco.config.ts');
44
+ }
45
+
46
+ bunArgs.push(entryFile, ...args);
47
+
48
+ if (options.reactFastRefresh) {
49
+ bunArgs.push('--react-fast-refresh');
50
+ }
51
+
52
+ return bunArgs;
53
+ }
54
+
55
+ export function buildNodeArgs(args, options, entryFile, hasConfig) {
56
+ const nodeArgs = [];
57
+
58
+ if (hasConfig) {
59
+ nodeArgs.push('--import', './eco.config.ts');
60
+ }
61
+
62
+ nodeArgs.push(entryFile, ...args);
63
+
64
+ if (options.reactFastRefresh) {
65
+ nodeArgs.push('--react-fast-refresh');
66
+ }
67
+
68
+ return nodeArgs;
69
+ }
70
+
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
+ );
78
+ }
79
+ }
80
+
81
+ export async function createLaunchPlan(args, options = {}, entryFile = 'app.ts') {
82
+ const hasConfig = existsSync('eco.config.ts');
83
+ const envOverrides = buildEnvOverrides(options);
84
+ const runtime = detectRuntime(options);
85
+ const env = { ...process.env, ...envOverrides };
86
+
87
+ if (runtime === 'node') {
88
+ const tsxCliPath = resolveTsxCliPath();
89
+
90
+ return {
91
+ runtime,
92
+ executionStrategy: 'direct-runtime',
93
+ command: process.execPath,
94
+ commandArgs: [tsxCliPath, ...buildNodeArgs(args, options, entryFile, hasConfig)],
95
+ envOverrides,
96
+ env,
97
+ };
98
+ }
99
+
100
+ return {
101
+ runtime,
102
+ executionStrategy: 'direct-runtime',
103
+ command: 'bun',
104
+ commandArgs: buildBunArgs(args, options, entryFile, hasConfig),
105
+ envOverrides,
106
+ env,
107
+ };
108
+ }
109
+
110
+ export function launchPlanRequiresExistingEntryFile(launchPlan) {
111
+ return launchPlan.executionStrategy !== 'config-only-bootstrap';
112
+ }
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import {
7
+ buildEnvOverrides,
8
+ buildBunArgs,
9
+ buildNodeArgs,
10
+ createLaunchPlan,
11
+ detectRuntime,
12
+ launchPlanRequiresExistingEntryFile,
13
+ resolveTsxCliPath,
14
+ } from './launch-plan.js';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ const originalUserAgent = process.env.npm_config_user_agent;
19
+
20
+ afterEach(() => {
21
+ if (originalUserAgent === undefined) {
22
+ delete process.env.npm_config_user_agent;
23
+ } else {
24
+ process.env.npm_config_user_agent = originalUserAgent;
25
+ }
26
+ process.chdir('/Users/andeeplus/github/ecopages');
27
+ });
28
+
29
+ describe('launch-plan', () => {
30
+ function writeExperimentalRuntimeConfig(tempDir) {
31
+ fs.writeFileSync(
32
+ path.join(tempDir, 'eco.config.ts'),
33
+ [
34
+ 'const rootDir = process.cwd();',
35
+ 'export default {',
36
+ '\trootDir,',
37
+ '\tloaders: new Map(),',
38
+ '\tabsolutePaths: {',
39
+ '\t\tconfig: `${rootDir}/eco.config.ts`,',
40
+ '\t\tsrcDir: `${rootDir}/src`,',
41
+ '\t\tdistDir: `${rootDir}/dist`,',
42
+ '\t\tworkDir: `${rootDir}/.eco`,',
43
+ '\t},',
44
+ '\truntime: {},',
45
+ '};',
46
+ ].join('\n'),
47
+ 'utf8',
48
+ );
49
+ }
50
+
51
+ it('buildEnvOverrides maps CLI options onto environment variables', () => {
52
+ expect(
53
+ buildEnvOverrides({
54
+ port: 4173,
55
+ hostname: '127.0.0.1',
56
+ baseUrl: 'https://example.test',
57
+ debug: true,
58
+ nodeEnv: 'production',
59
+ }),
60
+ ).toEqual({
61
+ ECOPAGES_PORT: '4173',
62
+ ECOPAGES_HOSTNAME: '127.0.0.1',
63
+ ECOPAGES_BASE_URL: 'https://example.test',
64
+ ECOPAGES_LOGGER_DEBUG: 'true',
65
+ NODE_ENV: 'production',
66
+ });
67
+ });
68
+
69
+ it('detectRuntime returns node when Bun is not available', () => {
70
+ process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
71
+ expect(detectRuntime()).toBe('node');
72
+ expect(detectRuntime({ runtime: 'bun' })).toBe('bun');
73
+ expect(detectRuntime({ runtime: 'node' })).toBe('node');
74
+ });
75
+
76
+ it('buildBunArgs preloads eco.config.ts when present', () => {
77
+ expect(buildBunArgs(['--dev'], { hot: true }, 'app.ts', true)).toEqual([
78
+ '--hot',
79
+ 'run',
80
+ '--preload',
81
+ './eco.config.ts',
82
+ 'app.ts',
83
+ '--dev',
84
+ ]);
85
+ });
86
+
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
+ });
95
+
96
+ it('resolveTsxCliPath resolves the packaged tsx cli entry', () => {
97
+ expect(resolveTsxCliPath()).toBe(require.resolve('tsx/cli'));
98
+ });
99
+
100
+ it('createLaunchPlan uses the packaged tsx cli for node runtime', async () => {
101
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
102
+ try {
103
+ process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v24.0.0 darwin arm64';
104
+ process.chdir(tempDir);
105
+ fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
106
+ writeExperimentalRuntimeConfig(tempDir);
107
+
108
+ const plan = await createLaunchPlan(['--dev'], { runtime: 'node', nodeEnv: 'development' }, 'app.ts');
109
+
110
+ expect(plan).toMatchObject({
111
+ runtime: 'node',
112
+ executionStrategy: 'direct-runtime',
113
+ command: process.execPath,
114
+ });
115
+ expect(plan.commandArgs).toEqual([
116
+ require.resolve('tsx/cli'),
117
+ '--import',
118
+ './eco.config.ts',
119
+ 'app.ts',
120
+ '--dev',
121
+ ]);
122
+ } finally {
123
+ fs.rmSync(tempDir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ it('createLaunchPlan uses bun direct runtime and preloads eco.config.ts', async () => {
128
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
129
+ try {
130
+ process.chdir(tempDir);
131
+ fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
132
+ writeExperimentalRuntimeConfig(tempDir);
133
+
134
+ const plan = await createLaunchPlan(['--preview'], { runtime: 'bun' }, 'app.ts');
135
+
136
+ expect(plan).toMatchObject({
137
+ runtime: 'bun',
138
+ executionStrategy: 'direct-runtime',
139
+ command: 'bun',
140
+ });
141
+ expect(plan.commandArgs).toEqual(['run', '--preload', './eco.config.ts', 'app.ts', '--preview']);
142
+ } finally {
143
+ fs.rmSync(tempDir, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ it('launchPlanRequiresExistingEntryFile requires a concrete entry on every runtime path', async () => {
148
+ const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'eco-cli-launch-plan-'));
149
+ try {
150
+ process.chdir(tempDir);
151
+ fs.writeFileSync(path.join(tempDir, 'app.ts'), 'await Promise.resolve();', 'utf8');
152
+ writeExperimentalRuntimeConfig(tempDir);
153
+
154
+ const bunPlan = await createLaunchPlan(['--dev'], { nodeEnv: 'development' }, 'app.ts');
155
+ expect(launchPlanRequiresExistingEntryFile(bunPlan)).toBe(true);
156
+ } finally {
157
+ fs.rmSync(tempDir, { recursive: true, force: true });
158
+ }
159
+ });
160
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecopages",
3
- "version": "0.2.0-alpha.2",
3
+ "version": "0.2.0-alpha.21",
4
4
  "description": "CLI utilities for Ecopages",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,9 +34,11 @@
34
34
  "README.md"
35
35
  ],
36
36
  "dependencies": {
37
- "commander": "^12.1.0",
38
- "tiged": "^2.12.7",
39
- "@ecopages/logger": "^0.2.2"
37
+ "@ecopages/core": "workspace:*",
38
+ "@ecopages/logger": "^0.2.2",
39
+ "citty": "^0.1.6",
40
+ "giget": "^2.0.0",
41
+ "tsx": "^4.21.0"
40
42
  },
41
43
  "peerDependencies": {
42
44
  "bun-types": "*",