ecopages 0.2.0-rc.0 → 0.2.0-rc.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
@@ -6,10 +6,16 @@ It provides scaffolding and development commands to streamline your workflow. It
6
6
 
7
7
  ## Quick Start
8
8
 
9
- Initialize a new project from the default template:
9
+ Initialize a new project from the interactive template picker:
10
10
 
11
11
  ```bash
12
- bunx ecopages init my-app
12
+ bunx ecopages init
13
+ ```
14
+
15
+ For a deterministic invocation, select a template explicitly:
16
+
17
+ ```bash
18
+ bunx ecopages init my-app --template react
13
19
  cd my-app
14
20
  bun install
15
21
  bun dev
@@ -17,18 +23,29 @@ bun dev
17
23
 
18
24
  ## Commands
19
25
 
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` |
26
+ | Command | Description | Equivalent (Bun) |
27
+ | :-------------------- | :------------------------------------------------------ | :----------------------------------------------------------- |
28
+ | `ecopages init [dir]` | Scaffolds a new project (interactive without `dir`) | N/A |
29
+ | `ecopages dev` | Starts the dev server (supervises config/.env restarts) | `bun run [entry] --dev` |
30
+ | `ecopages dev:watch` | Dev server + hard restarts on file changes | Bun: `bun --watch run [entry] --dev`; Node: `node --watch …` |
31
+ | `ecopages dev:hot` | Dev server + HMR (no hard restarts) | `bun --hot run [entry] --dev` |
32
+ | `ecopages build` | Creates a production build | `bun run [entry] --build` |
33
+ | `ecopages start` | Starts the production server | `bun run [entry]` |
34
+ | `ecopages preview` | Previews the production build locally | `bun run [entry] --preview` |
29
35
 
30
36
  > [!NOTE]
31
- > `[entry]` defaults to `app.ts` if not provided.
37
+ > The entry file defaults to `app.ts`. Override it with `--entry-file`.
38
+
39
+ ### Templates
40
+
41
+ Official templates are versioned with the CLI release. Use `--template <id>` for an official template or `--from <source>` for a community Git template. Community sources support giget provider notation and GitHub, GitLab, Bitbucket, and SourceHut repository URLs.
42
+
43
+ ```bash
44
+ ecopages init my-site --template jsx
45
+ ecopages init my-site --from github:acme/my-template#v1.0.0
46
+ ```
47
+
48
+ The official template IDs are `jsx`, `react`, `lit-jsx`, `radiant`, `blog-jsx`, `blog-react`, `docs-starter`, `react-better-auth`, and `llm-wiki`.
32
49
 
33
50
  ## Environment & Runtime Options
34
51
 
@@ -63,6 +80,8 @@ ecopages dev --port 8080 --debug
63
80
  ecopages dev -r
64
81
  ```
65
82
 
83
+ During `ecopages dev`, adding, changing, or removing `eco.config.ts` (or `--config`) and supported project `.env` files restarts the dev server automatically. The CLI supervises that restart and reloads dotenv files. When the preferred port is busy, development startup uses the same fallback flow as preview: TTY sessions show a Clack confirmation, while non-interactive sessions scan automatically only when the port was not pinned with `--port` or `ECOPAGES_PORT`.
84
+
66
85
  ### Debug logging and startup trace
67
86
 
68
87
  Set these in `.env` or on the command line when diagnosing slow dev startup or first page load.
package/bin/brand.js ADDED
@@ -0,0 +1,42 @@
1
+ import { styleText } from 'node:util';
2
+
3
+ /**
4
+ * Terminal mark rasterized from `assets/brand/logo-on-light.svg`.
5
+ * @remarks Half-blocks keep rows flush; a thin stroke leaves the spiral's inner gaps open.
6
+ */
7
+ const BRAND_MARK_LINES = [
8
+ ' ▄▄▄▄',
9
+ ' ▄▄▄▄█▀▀▀▀▄█',
10
+ ' ▄▄▀▀▀ ██',
11
+ ' ▄█▀ ▄▄▄▄ █',
12
+ '▄█ █▀ ██ █',
13
+ '█▄ ▀█▄ ██ ▄█ ██',
14
+ ' █▄ ▀▀██▀▀▀ █',
15
+ ' ▀▀█▄ ▀█ ▄█▀',
16
+ ' ▄█▀ ▀▀▀▀▀',
17
+ ' █▀',
18
+ '▀',
19
+ ];
20
+
21
+ export function formatBrandBanner(version, stream = process.stdout) {
22
+ const markWidth = Math.max(...BRAND_MARK_LINES.map((line) => [...line].length));
23
+ const versionLabel = stream.hasColors?.() ? styleText('dim', version) : version;
24
+ const labels = ['ecopages', versionLabel];
25
+ const labelStart = Math.max(0, Math.floor((BRAND_MARK_LINES.length - labels.length) / 2));
26
+
27
+ return BRAND_MARK_LINES.map((line, index) => {
28
+ const padded = `${line}${' '.repeat(markWidth - [...line].length)}`;
29
+ const label = labels[index - labelStart];
30
+ return label === undefined ? padded : `${padded} ${label}`;
31
+ }).join('\n');
32
+ }
33
+
34
+ export function withBrandBanner(version, text) {
35
+ if (!process.stdout.isTTY) return text;
36
+ return `${formatBrandBanner(version)}\n\n${text}`;
37
+ }
38
+
39
+ export function printBrandBanner(version) {
40
+ if (!process.stderr.isTTY) return;
41
+ process.stderr.write(`${formatBrandBanner(version, process.stderr)}\n\n`);
42
+ }
package/bin/cli.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { downloadTemplate } from 'giget';
4
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
5
4
  import { spawn } from 'node:child_process';
6
- import { join } from 'node:path';
7
5
  import { parseArgs } from 'node:util';
8
6
  import { Logger } from '@ecopages/logger';
9
7
  import { createLaunchPlan } from './launch-plan.js';
8
+ import {
9
+ ECOPAGES_DEV_RESTART_EXIT_CODE,
10
+ ECOPAGES_DEV_RESTART_REASON_ENV,
11
+ } from '@ecopages/core/dev/development-restart';
12
+ import { withBrandBanner } from './brand.js';
10
13
 
11
14
  const logger = new Logger('[ecopages:cli]', { debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true' });
12
15
 
@@ -40,18 +43,9 @@ const sharedServerOptionDefinitions = {
40
43
  type: 'string',
41
44
  short: 'e',
42
45
  },
43
- help: {
44
- type: 'boolean',
45
- short: 'h',
46
- },
47
- };
48
-
49
- const initOptionDefinitions = {
50
- template: {
51
- type: 'string',
52
- },
53
- repo: {
46
+ config: {
54
47
  type: 'string',
48
+ short: 'c',
55
49
  },
56
50
  help: {
57
51
  type: 'boolean',
@@ -61,12 +55,10 @@ const initOptionDefinitions = {
61
55
 
62
56
  function getMainHelpText() {
63
57
  return [
64
- `ecopages ${pkg.version}`,
65
- '',
66
58
  'Usage: ecopages <command> [options]',
67
59
  '',
68
60
  'Commands:',
69
- ' init <dir> Initialize a new project from a template',
61
+ ' init [dir] Initialize a new project from a template',
70
62
  ' dev Start the development server',
71
63
  ' dev:watch Start the development server with watch mode',
72
64
  ' dev:hot Start the development server with hot reload',
@@ -76,6 +68,7 @@ function getMainHelpText() {
76
68
  '',
77
69
  'Global options:',
78
70
  ' -e, --entry-file <file> Entry file (default: app.ts)',
71
+ ' -c, --config <file> Ecopages config file (default: eco.config.ts)',
79
72
  ' -h, --help Show help',
80
73
  ' --version Show version',
81
74
  ].join('\n');
@@ -95,6 +88,7 @@ function getServerCommandHelpText(commandName, description) {
95
88
  ' -r, --react-fast-refresh Enable React Fast Refresh for Bun HMR',
96
89
  ' --runtime <runtime> Force bun or node',
97
90
  ' -e, --entry-file <file> Entry file (default: app.ts)',
91
+ ' -c, --config <file> Ecopages config file (default: eco.config.ts)',
98
92
  ' -h, --help Show help',
99
93
  ].join('\n');
100
94
  }
@@ -113,19 +107,7 @@ function getBuildCommandHelpText() {
113
107
  ' -r, --react-fast-refresh Enable React Fast Refresh for Bun HMR',
114
108
  ' --runtime <runtime> Force bun or node',
115
109
  ' -e, --entry-file <file> Entry file (default: app.ts)',
116
- ' -h, --help Show help',
117
- ].join('\n');
118
- }
119
-
120
- function getInitCommandHelpText() {
121
- return [
122
- 'Usage: ecopages init <dir> [options]',
123
- '',
124
- 'Initialize a new project from a template.',
125
- '',
126
- 'Options:',
127
- ' --template <template> Template name from ecopages/examples/',
128
- ' --repo <repo> GitHub repo in user/repo form',
110
+ ' -c, --config <file> Ecopages config file (default: eco.config.ts)',
129
111
  ' -h, --help Show help',
130
112
  ].join('\n');
131
113
  }
@@ -164,30 +146,12 @@ function parseServerCommandArgs(rawArgs, commandName, description, mode = 'serve
164
146
  debug: values.debug,
165
147
  reactFastRefresh: values['react-fast-refresh'],
166
148
  runtime: values.runtime,
149
+ configFile: values.config,
167
150
  },
168
151
  };
169
152
  }
170
153
 
171
- function parseInitCommandArgs(rawArgs) {
172
- const { values, positionals } = parseCommandArguments(rawArgs, initOptionDefinitions);
173
-
174
- if (values.help) {
175
- console.log(getInitCommandHelpText());
176
- return { help: true };
177
- }
178
-
179
- if (positionals.length !== 1) {
180
- throw new Error('The `init` command requires exactly one target directory argument.');
181
- }
182
-
183
- return {
184
- dir: positionals[0],
185
- template: values.template ?? 'starter-jsx',
186
- repo: values.repo ?? 'ecopages/ecopages',
187
- };
188
- }
189
-
190
- function runLaunchPlan(launchPlan) {
154
+ function runLaunchPlan(launchPlan, options = {}) {
191
155
  if (Object.keys(launchPlan.envOverrides).length > 0) {
192
156
  logger.debug(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
193
157
  }
@@ -215,6 +179,11 @@ function runLaunchPlan(launchPlan) {
215
179
  });
216
180
 
217
181
  child.on('exit', (code) => {
182
+ if (options.superviseDevRestarts && code === ECOPAGES_DEV_RESTART_EXIT_CODE) {
183
+ options.onDevRestart?.();
184
+ return;
185
+ }
186
+
218
187
  process.exit(code || 0);
219
188
  });
220
189
  }
@@ -227,61 +196,38 @@ function runLaunchPlan(launchPlan) {
227
196
  * @param {string} entryFile - Entry file to run
228
197
  */
229
198
  async function runEntryCommand(args, options = {}, entryFile = 'app.ts', launchMode = 'start') {
230
- let launchPlan;
231
199
  const requiresBuiltBundle = launchMode === 'start';
200
+ const superviseDevRestarts = launchMode === 'dev';
232
201
 
233
202
  if (!requiresBuiltBundle && !existsSync(entryFile)) {
234
203
  logger.error(`Error: Entry file "${entryFile}" not found in the current directory.`);
235
204
  process.exit(1);
236
205
  }
237
206
 
238
- try {
239
- launchPlan = await createLaunchPlan(args, options, entryFile, launchMode);
240
- } catch (error) {
241
- const message = error instanceof Error ? error.message : String(error);
242
- logger.error(message);
243
- process.exit(1);
244
- }
245
-
246
- runLaunchPlan(launchPlan);
247
- }
248
-
249
- async function runInitCommand(rawArgs) {
250
- const parsed = parseInitCommandArgs(rawArgs);
251
-
252
- if (parsed.help) {
253
- return;
254
- }
255
-
256
- const { dir, template, repo } = parsed;
257
-
258
- if (existsSync(dir)) {
259
- logger.error(`Target directory already exists: ${dir}`);
260
- process.exit(1);
261
- }
207
+ const launchChild = async (restartReason) => {
208
+ let launchPlan;
209
+ try {
210
+ launchPlan = await createLaunchPlan(args, options, entryFile, launchMode);
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ logger.error(message);
214
+ process.exit(1);
215
+ }
262
216
 
263
- logger.info(`Creating target directory '${dir}'...`);
217
+ if (restartReason) {
218
+ launchPlan.env[ECOPAGES_DEV_RESTART_REASON_ENV] = restartReason;
219
+ launchPlan.envOverrides[ECOPAGES_DEV_RESTART_REASON_ENV] = restartReason;
220
+ }
264
221
 
265
- try {
266
- await downloadTemplate(`github:${repo}/examples/${template}`, {
267
- dir,
268
- force: true,
222
+ runLaunchPlan(launchPlan, {
223
+ superviseDevRestarts,
224
+ onDevRestart: () => {
225
+ void launchChild('configuration or environment change');
226
+ },
269
227
  });
228
+ };
270
229
 
271
- const pkgPath = join(dir, 'package.json');
272
- if (existsSync(pkgPath)) {
273
- const projectPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
274
- projectPkg.name = dir;
275
- writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
276
- logger.info(`Renamed project to '${dir}'`);
277
- }
278
-
279
- logger.info('Project initialized! Run `bun install && bun dev` to start.');
280
- } catch (error) {
281
- const message = error instanceof Error ? error.message : String(error);
282
- logger.error(`Failed to fetch template: ${message}`);
283
- process.exit(1);
284
- }
230
+ await launchChild();
285
231
  }
286
232
 
287
233
  async function runServerCommand(rawArgs, definition) {
@@ -303,7 +249,7 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
303
249
  const [commandName, ...commandArgs] = rawArgs;
304
250
 
305
251
  if (!commandName || commandName === '--help' || commandName === '-h') {
306
- console.log(getMainHelpText());
252
+ console.log(withBrandBanner(pkg.version, getMainHelpText()));
307
253
  return;
308
254
  }
309
255
 
@@ -314,9 +260,11 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
314
260
 
315
261
  try {
316
262
  switch (commandName) {
317
- case 'init':
318
- await runInitCommand(commandArgs);
263
+ case 'init': {
264
+ const { runInitCommand } = await import('./init.js');
265
+ await runInitCommand(commandArgs, logger);
319
266
  return;
267
+ }
320
268
  case 'dev':
321
269
  await runServerCommand(commandArgs, {
322
270
  name: 'dev',
@@ -0,0 +1,56 @@
1
+ import { repositoryName, withSubpath } from './git-template-source-shared.js';
2
+
3
+ export function normalizeGithubParts(parts, source) {
4
+ if (parts.length < 2) {
5
+ return null;
6
+ }
7
+
8
+ const [owner, repositoryPart, marker, ref, ...subpath] = parts;
9
+ const repository = `${owner}/${repositoryName(repositoryPart)}`;
10
+ if (marker === 'tree' || marker === 'blob') {
11
+ if (!ref) throw new Error(`Template URL '${source}' is missing a Git ref.`);
12
+ return `github:${withSubpath(repository, subpath)}#${ref}`;
13
+ }
14
+ return `github:${repository}`;
15
+ }
16
+
17
+ export function normalizeGitlabParts(parts) {
18
+ const markerIndex = parts.indexOf('-');
19
+ const treeIndex = parts.findIndex((part) => part === 'tree' || part === 'blob');
20
+ if (markerIndex > 0 && treeIndex > markerIndex && parts[treeIndex + 1]) {
21
+ const repository = parts.slice(0, markerIndex).map(repositoryName).join('/');
22
+ const ref = parts[treeIndex + 1];
23
+ const subpath = parts.slice(treeIndex + 2);
24
+ return `gitlab:${withSubpath(repository, subpath)}#${ref}`;
25
+ }
26
+ if (parts.length >= 2) return `gitlab:${parts.join('/')}`;
27
+ return null;
28
+ }
29
+
30
+ export function normalizeBitbucketParts(parts) {
31
+ if (parts.length < 2) {
32
+ return null;
33
+ }
34
+
35
+ const [workspace, repositoryPart, marker, ref, ...subpath] = parts;
36
+ const repository = `${workspace}/${repositoryName(repositoryPart)}`;
37
+ if (marker === 'src' && ref) {
38
+ return `bitbucket:${withSubpath(repository, subpath)}#${ref}`;
39
+ }
40
+ return `bitbucket:${repository}`;
41
+ }
42
+
43
+ export function normalizeSourcehutParts(parts) {
44
+ if (parts.length < 2) {
45
+ return null;
46
+ }
47
+
48
+ const treeIndex = parts.findIndex((part) => part === 'tree' || part === 'blob');
49
+ if (treeIndex > 1 && parts[treeIndex + 1]) {
50
+ const repository = parts.slice(0, treeIndex).map(repositoryName).join('/');
51
+ const ref = parts[treeIndex + 1];
52
+ const subpath = parts.slice(treeIndex + 2);
53
+ return `sourcehut:${withSubpath(repository, subpath)}#${ref}`;
54
+ }
55
+ return `sourcehut:${parts.join('/')}`;
56
+ }
@@ -0,0 +1,7 @@
1
+ export function repositoryName(value) {
2
+ return value.replace(/\.git$/u, '');
3
+ }
4
+
5
+ export function withSubpath(repository, subpath) {
6
+ return subpath.length > 0 ? `${repository}/${subpath.join('/')}` : repository;
7
+ }
@@ -0,0 +1,62 @@
1
+ import {
2
+ normalizeBitbucketParts,
3
+ normalizeGithubParts,
4
+ normalizeGitlabParts,
5
+ normalizeSourcehutParts,
6
+ } from './git-template-source-providers.js';
7
+
8
+ const GIT_PROVIDER_PREFIX = /^(github|gitlab|bitbucket|sourcehut):/u;
9
+
10
+ const HOST_PROVIDER = {
11
+ 'github.com': 'github',
12
+ 'gitlab.com': 'gitlab',
13
+ 'bitbucket.org': 'bitbucket',
14
+ 'git.sr.ht': 'sourcehut',
15
+ };
16
+
17
+ const PROVIDER_NORMALIZERS = {
18
+ github: normalizeGithubParts,
19
+ gitlab: normalizeGitlabParts,
20
+ bitbucket: normalizeBitbucketParts,
21
+ sourcehut: normalizeSourcehutParts,
22
+ };
23
+
24
+ function normalizeProviderUrl(hostProvider, parts, source) {
25
+ const normalize = PROVIDER_NORMALIZERS[hostProvider];
26
+ const normalized = normalize?.(parts, source);
27
+ if (normalized) {
28
+ return normalized;
29
+ }
30
+
31
+ throw new Error(`Unsupported template URL '${source}'. Include a repository and optional Git ref.`);
32
+ }
33
+
34
+ /**
35
+ * Normalizes a Git provider URL or giget source into a giget template source.
36
+ */
37
+ export function normalizeGitSource(source) {
38
+ if (GIT_PROVIDER_PREFIX.test(source)) {
39
+ return source;
40
+ }
41
+
42
+ let url;
43
+ try {
44
+ url = new URL(source);
45
+ } catch {
46
+ throw new Error(`Unsupported template source '${source}'. Use a Git provider URL or giget source.`);
47
+ }
48
+
49
+ const hostProvider = HOST_PROVIDER[url.hostname];
50
+ if (!hostProvider) {
51
+ throw new Error(
52
+ `Unsupported template host '${url.hostname}'. Supported hosts are GitHub, GitLab, Bitbucket, and SourceHut.`,
53
+ );
54
+ }
55
+
56
+ const parts = url.pathname
57
+ .replace(/^\/+|\/+$/gu, '')
58
+ .split('/')
59
+ .filter(Boolean);
60
+
61
+ return normalizeProviderUrl(hostProvider, parts, source);
62
+ }
package/bin/init.js ADDED
@@ -0,0 +1,392 @@
1
+ import { downloadTemplate } from 'giget';
2
+ import * as prompts from '@clack/prompts';
3
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { spawn } from 'node:child_process';
5
+ import { basename, join } from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+ import { printBrandBanner, withBrandBanner } from './brand.js';
8
+ import { normalizeGitSource } from './git-template-source.js';
9
+
10
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
11
+ const templateManifest = JSON.parse(readFileSync(new URL('../templates.json', import.meta.url), 'utf-8'));
12
+ const officialTemplates = [...templateManifest.templates].sort((left, right) => left.order - right.order);
13
+ const defaultOfficialTemplate = officialTemplates.find((template) => template.default) ?? officialTemplates[0];
14
+
15
+ const initOptionDefinitions = {
16
+ template: {
17
+ type: 'string',
18
+ },
19
+ repo: {
20
+ type: 'string',
21
+ },
22
+ from: {
23
+ type: 'string',
24
+ },
25
+ interactive: {
26
+ type: 'boolean',
27
+ },
28
+ 'no-interactive': {
29
+ type: 'boolean',
30
+ },
31
+ install: {
32
+ type: 'boolean',
33
+ },
34
+ 'no-install': {
35
+ type: 'boolean',
36
+ },
37
+ start: {
38
+ type: 'boolean',
39
+ },
40
+ 'no-start': {
41
+ type: 'boolean',
42
+ },
43
+ help: {
44
+ type: 'boolean',
45
+ short: 'h',
46
+ },
47
+ };
48
+
49
+ function getInitCommandHelpText() {
50
+ return [
51
+ 'Usage: ecopages init [dir] [options]',
52
+ '',
53
+ 'Initialize a new project from a template.',
54
+ '',
55
+ 'With no directory in a TTY, starts the interactive project creator.',
56
+ '',
57
+ 'Options:',
58
+ ' --template <id> Official template ID',
59
+ ' --from <source> Community Git template source',
60
+ ' --interactive Force interactive mode',
61
+ ' --no-interactive Disable interactive mode',
62
+ ' --install Install dependencies',
63
+ ' --no-install Skip dependency installation',
64
+ ' --start Install and start the dev server',
65
+ ' --no-start Do not start the dev server',
66
+ ' -h, --help Show help',
67
+ ].join('\n');
68
+ }
69
+
70
+ function parseExclusiveBoolean(enable, disable, enableFlag, disableFlag) {
71
+ if (enable && disable) {
72
+ throw new Error(`The \`${enableFlag}\` and \`${disableFlag}\` options cannot be used together.`);
73
+ }
74
+ if (enable) return true;
75
+ if (disable) return false;
76
+ return undefined;
77
+ }
78
+
79
+ function parseInitCommandArgs(rawArgs) {
80
+ const { values, positionals } = parseArgs({
81
+ args: rawArgs,
82
+ options: initOptionDefinitions,
83
+ allowPositionals: true,
84
+ strict: true,
85
+ });
86
+
87
+ if (values.help) {
88
+ console.log(withBrandBanner(pkg.version, getInitCommandHelpText()));
89
+ return { help: true };
90
+ }
91
+
92
+ if (positionals.length > 1) {
93
+ throw new Error('The `init` command accepts at most one target directory argument.');
94
+ }
95
+
96
+ if (values.template && values.from) {
97
+ throw new Error('The `--template` and `--from` options cannot be used together.');
98
+ }
99
+
100
+ if (values.repo) {
101
+ throw new Error('The `--repo` option was removed. Use `--from <source>` instead.');
102
+ }
103
+
104
+ const interactive = parseExclusiveBoolean(
105
+ values.interactive,
106
+ values['no-interactive'],
107
+ '--interactive',
108
+ '--no-interactive',
109
+ );
110
+ const install = parseExclusiveBoolean(values.install, values['no-install'], '--install', '--no-install');
111
+ const start = parseExclusiveBoolean(values.start, values['no-start'], '--start', '--no-start');
112
+
113
+ if (start && install === false) {
114
+ throw new Error('The `--start` option requires dependency installation.');
115
+ }
116
+
117
+ const dir = positionals[0];
118
+ const useInteractive = interactive ?? (!dir && Boolean(process.stdin.isTTY));
119
+
120
+ if (!dir && !useInteractive) {
121
+ throw new Error(
122
+ 'A target directory is required outside a TTY. Run `ecopages init --interactive` to use prompts.',
123
+ );
124
+ }
125
+
126
+ return {
127
+ dir,
128
+ template: values.template,
129
+ from: values.from,
130
+ interactive: useInteractive,
131
+ install,
132
+ start,
133
+ };
134
+ }
135
+
136
+ function findOfficialTemplate(templateId) {
137
+ const template = officialTemplates.find((candidate) => candidate.id === templateId);
138
+ if (!template) {
139
+ const available = officialTemplates.map((candidate) => candidate.id).join(', ');
140
+ throw new Error(`Unknown official template '${templateId}'. Available templates: ${available}`);
141
+ }
142
+
143
+ return template;
144
+ }
145
+
146
+ function detectPackageManager() {
147
+ const userAgent = process.env.npm_config_user_agent || '';
148
+ if (userAgent.startsWith('bun/')) return 'bun';
149
+ if (userAgent.startsWith('pnpm/')) return 'pnpm';
150
+ return 'npm';
151
+ }
152
+
153
+ function getTemplateSource(templateId, source) {
154
+ if (source) return normalizeGitSource(source);
155
+ const officialTemplate = findOfficialTemplate(templateId);
156
+ return `github:ecopages/ecopages/templates/${officialTemplate.source}#v${pkg.version}`;
157
+ }
158
+
159
+ function runChildCommand(command, args, cwd) {
160
+ return new Promise((resolve, reject) => {
161
+ const child = spawn(command, args, { cwd, stdio: 'inherit' });
162
+ child.on('error', reject);
163
+ child.on('exit', (code, signal) => {
164
+ if (signal) {
165
+ reject(new Error(`${command} was terminated by ${signal}.`));
166
+ return;
167
+ }
168
+
169
+ if (code !== 0) {
170
+ reject(new Error(`${command} exited with code ${code ?? 1}.`));
171
+ return;
172
+ }
173
+
174
+ resolve();
175
+ });
176
+ });
177
+ }
178
+
179
+ function rewriteWorkspaceDependencies(projectPkg) {
180
+ for (const blockName of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
181
+ const block = projectPkg[blockName];
182
+ if (!block || typeof block !== 'object') continue;
183
+ for (const [name, version] of Object.entries(block)) {
184
+ if (version === 'workspace:*') block[name] = pkg.version;
185
+ }
186
+ }
187
+ }
188
+
189
+ function updateProjectManifest(targetDir, packageDirectory, isOfficial) {
190
+ const packagePath = join(targetDir, packageDirectory, 'package.json');
191
+ if (!existsSync(packagePath)) {
192
+ throw new Error(`Template package directory '${packageDirectory}' does not contain a package.json.`);
193
+ }
194
+
195
+ const projectPkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
196
+ projectPkg.name = basename(targetDir);
197
+ if (isOfficial) rewriteWorkspaceDependencies(projectPkg);
198
+
199
+ if (isOfficial) {
200
+ for (const blockName of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
201
+ const block = projectPkg[blockName];
202
+ if (!block || typeof block !== 'object') continue;
203
+ if (
204
+ Object.values(block).some((version) => typeof version === 'string' && version.startsWith('workspace:'))
205
+ ) {
206
+ throw new Error(`Template package still contains workspace dependencies in ${packagePath}.`);
207
+ }
208
+ }
209
+ }
210
+
211
+ writeFileSync(packagePath, `${JSON.stringify(projectPkg, null, 2)}\n`);
212
+ return packagePath;
213
+ }
214
+
215
+ function isCancelled(value) {
216
+ if (prompts.isCancel(value)) {
217
+ prompts.cancel('Project creation cancelled.');
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+
223
+ async function promptProjectDirectory() {
224
+ const answer = await prompts.text({
225
+ message: 'Project directory:',
226
+ placeholder: 'my-app',
227
+ validate: (value) => (value.trim().length === 0 ? 'Enter a project directory.' : undefined),
228
+ });
229
+ if (isCancelled(answer)) return null;
230
+ return answer.trim();
231
+ }
232
+
233
+ async function promptTemplateSelection() {
234
+ const answer = await prompts.select({
235
+ message: 'Select a template:',
236
+ options: [
237
+ ...officialTemplates.map((template) => ({
238
+ label: template.displayName,
239
+ value: template.id,
240
+ hint: template.description,
241
+ })),
242
+ {
243
+ label: 'Community template',
244
+ value: '__community__',
245
+ hint: 'Use a Git repository or provider source.',
246
+ },
247
+ ],
248
+ });
249
+ if (isCancelled(answer)) return null;
250
+ return answer;
251
+ }
252
+
253
+ async function promptCommunityTemplateSource() {
254
+ const sourceAnswer = await prompts.text({
255
+ message: 'Template source:',
256
+ placeholder: 'github:owner/repository#v1.0.0',
257
+ validate: (value) => (value.trim().length === 0 ? 'Enter a Git template source.' : undefined),
258
+ });
259
+ if (isCancelled(sourceAnswer)) return null;
260
+ return sourceAnswer.trim();
261
+ }
262
+
263
+ async function resolveInteractiveTemplateChoice(templateId, source) {
264
+ if (templateId || source) {
265
+ return { templateId, source };
266
+ }
267
+
268
+ const answer = await promptTemplateSelection();
269
+ if (answer === null) {
270
+ return null;
271
+ }
272
+ if (answer === '__community__') {
273
+ const communitySource = await promptCommunityTemplateSource();
274
+ if (communitySource === null) {
275
+ return null;
276
+ }
277
+ return { templateId, source: communitySource };
278
+ }
279
+ return { templateId: answer, source };
280
+ }
281
+
282
+ async function promptInstallChoice(packageManager, parsed) {
283
+ if (parsed.start) {
284
+ return true;
285
+ }
286
+ if (parsed.install !== undefined) {
287
+ return parsed.install;
288
+ }
289
+ if (!parsed.interactive) {
290
+ return undefined;
291
+ }
292
+
293
+ const answer = await prompts.confirm({
294
+ message: `Install dependencies with ${packageManager}?`,
295
+ initialValue: true,
296
+ });
297
+ if (isCancelled(answer)) return null;
298
+ return answer;
299
+ }
300
+
301
+ async function promptStartChoice(parsed, install) {
302
+ if (parsed.start !== undefined) {
303
+ return parsed.start;
304
+ }
305
+ if (!parsed.interactive || !install) {
306
+ return undefined;
307
+ }
308
+
309
+ const answer = await prompts.confirm({ message: 'Start the development server now?', initialValue: false });
310
+ if (isCancelled(answer)) return null;
311
+ return answer;
312
+ }
313
+
314
+ async function collectInitAnswers(parsed) {
315
+ const packageManager = detectPackageManager();
316
+ let dir = parsed.dir;
317
+ let templateId = parsed.template;
318
+ let source = parsed.from;
319
+
320
+ if (parsed.interactive && !dir) {
321
+ const promptedDir = await promptProjectDirectory();
322
+ if (promptedDir === null) return null;
323
+ dir = promptedDir;
324
+ }
325
+
326
+ if (parsed.interactive) {
327
+ const templateChoice = await resolveInteractiveTemplateChoice(templateId, source);
328
+ if (templateChoice === null) return null;
329
+ templateId = templateChoice.templateId;
330
+ source = templateChoice.source;
331
+ }
332
+
333
+ if (!templateId && !source) templateId = defaultOfficialTemplate.id;
334
+ if (parsed.interactive) {
335
+ prompts.log.step(`Resolved template source: ${getTemplateSource(templateId, source)}`);
336
+ }
337
+
338
+ const installAnswer = await promptInstallChoice(packageManager, parsed);
339
+ if (installAnswer === null) return null;
340
+ const install = installAnswer ?? false;
341
+
342
+ const startAnswer = await promptStartChoice(parsed, install);
343
+ if (startAnswer === null) return null;
344
+ const start = startAnswer ?? false;
345
+
346
+ return { dir, templateId, source, packageManager, install, start };
347
+ }
348
+
349
+ export async function runInitCommand(rawArgs, logger) {
350
+ const parsed = parseInitCommandArgs(rawArgs);
351
+ if (parsed.help) return;
352
+
353
+ printBrandBanner(pkg.version);
354
+ const answers = await collectInitAnswers(parsed);
355
+ if (!answers) return;
356
+
357
+ const { dir, templateId, source, packageManager, install, start } = answers;
358
+ if (existsSync(dir)) throw new Error(`Target directory already exists: ${dir}`);
359
+
360
+ const officialTemplate = source ? null : findOfficialTemplate(templateId);
361
+ const templateSource = getTemplateSource(templateId, source);
362
+ const packageDirectory = officialTemplate?.packageDirectory ?? '.';
363
+ let targetCreated = false;
364
+
365
+ try {
366
+ if (!parsed.interactive) prompts.log.step(`Resolved template source: ${templateSource}`);
367
+ prompts.log.step(`Downloading ${officialTemplate?.displayName ?? 'community template'}...`);
368
+ targetCreated = true;
369
+ await downloadTemplate(templateSource, { dir, force: true });
370
+ prompts.log.step('Preparing project manifest...');
371
+ updateProjectManifest(dir, packageDirectory, Boolean(officialTemplate));
372
+ prompts.log.step(`Project created in ${dir}.`);
373
+
374
+ const packageCwd = join(dir, packageDirectory);
375
+ if (install) {
376
+ prompts.log.step(`Installing dependencies with ${packageManager}...`);
377
+ await runChildCommand(packageManager, ['install'], packageCwd);
378
+ }
379
+ if (start) {
380
+ prompts.log.step('Starting the development server...');
381
+ await runChildCommand(packageManager, ['run', 'dev'], packageCwd);
382
+ }
383
+
384
+ const relativeCwd = packageDirectory === '.' ? dir : join(dir, packageDirectory);
385
+ logger.info(
386
+ `Project initialized. Run 'cd "${relativeCwd}" && ${packageManager} install && ${packageManager} run dev'.`,
387
+ );
388
+ } catch (error) {
389
+ if (targetCreated) rmSync(dir, { recursive: true, force: true });
390
+ throw error;
391
+ }
392
+ }
@@ -2,6 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
4
  import { SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME } from '@ecopages/core/utils/resolve-entry-file';
5
+ import {
6
+ resolveEcoConfigPath,
7
+ resolveEmittedEcoConfigPath,
8
+ assertProductionConfigIdentity,
9
+ } from '@ecopages/core/config';
10
+ import { ECOPAGES_ENTRY_WATCH_ENV, getDevEnvFileNames } from '@ecopages/core/dev/development-restart';
5
11
 
6
12
  const SERVER_BUNDLE_MANIFEST_FILENAME = 'manifest.json';
7
13
 
@@ -9,13 +15,7 @@ const nodeRequirePreload = import.meta.resolve('./node-require-preload.js');
9
15
  const tsxLoader = import.meta.resolve('tsx/esm');
10
16
 
11
17
  function getEnvFilePaths(nodeEnv) {
12
- const envFiles = ['.env', '.env.local'];
13
-
14
- if (nodeEnv) {
15
- envFiles.push(`.env.${nodeEnv}`, `.env.${nodeEnv}.local`);
16
- }
17
-
18
- return envFiles.filter((envFile) => existsSync(envFile));
18
+ return getDevEnvFileNames(nodeEnv).filter((envFile) => existsSync(envFile));
19
19
  }
20
20
 
21
21
  export function buildEnvOverrides(options) {
@@ -26,9 +26,26 @@ export function buildEnvOverrides(options) {
26
26
  if (options.debug) env.ECOPAGES_LOGGER_DEBUG = 'true';
27
27
  if (options.nodeEnv) env.NODE_ENV = options.nodeEnv;
28
28
  if (options.entryFile) env.ECOPAGES_ENTRY_FILE = options.entryFile;
29
+ if (options.configFile) {
30
+ env.ECOPAGES_CONFIG_FILE = path.isAbsolute(options.configFile)
31
+ ? options.configFile
32
+ : path.resolve(process.cwd(), options.configFile);
33
+ }
34
+ if (options.watch) {
35
+ env[ECOPAGES_ENTRY_WATCH_ENV] = '1';
36
+ }
29
37
  return env;
30
38
  }
31
39
 
40
+ export function resolveEcoConfigFilePath(options = {}, cwd = process.cwd(), resolverOptions = {}) {
41
+ return resolveEcoConfigPath({
42
+ configFile: options.configFile,
43
+ cwd,
44
+ required: false,
45
+ preferEmitted: resolverOptions.preferEmitted,
46
+ });
47
+ }
48
+
32
49
  export function buildLaunchEnv(options) {
33
50
  const envOverrides = buildEnvOverrides(options);
34
51
  const envFileValues = getEnvFilePaths(options.nodeEnv).reduce((env, envFile) => {
@@ -59,7 +76,7 @@ export function detectRuntime(options = {}) {
59
76
  return 'node';
60
77
  }
61
78
 
62
- export function buildBunArgs(args, options, entryFile, hasConfig) {
79
+ export function buildBunArgs(args, options, entryFile, configFilePath) {
63
80
  const bunArgs = [];
64
81
 
65
82
  if (options.watch) bunArgs.push('--watch');
@@ -67,8 +84,8 @@ export function buildBunArgs(args, options, entryFile, hasConfig) {
67
84
 
68
85
  bunArgs.push('run');
69
86
 
70
- if (hasConfig) {
71
- bunArgs.push('--preload', `./eco.config.${'ts'}`);
87
+ if (configFilePath) {
88
+ bunArgs.push('--preload', configFilePath);
72
89
  }
73
90
 
74
91
  bunArgs.push(entryFile, ...args);
@@ -157,6 +174,10 @@ export function createLaunchPlan(args, options, entryFile, launchMode) {
157
174
  const { envOverrides, env } = buildLaunchEnv(resolvedOptions);
158
175
  const runtime = detectRuntime(resolvedOptions);
159
176
  const resolvedLaunchMode = inferLaunchMode(args, launchMode);
177
+ const isExplicitConfig = Boolean(resolvedOptions.configFile || process.env.ECOPAGES_CONFIG_FILE);
178
+ const resolvedConfigFile = resolveEcoConfigFilePath(resolvedOptions, process.cwd(), {
179
+ preferEmitted: usesProductionBundle(resolvedLaunchMode) && !isExplicitConfig,
180
+ });
160
181
 
161
182
  const distServerApp = resolveProductionServerEntry(process.cwd());
162
183
  const shouldUseBundle = usesProductionBundle(resolvedLaunchMode);
@@ -167,6 +188,12 @@ export function createLaunchPlan(args, options, entryFile, launchMode) {
167
188
  }
168
189
 
169
190
  if (useBundle) {
191
+ assertProductionConfigIdentity(process.cwd(), resolvedConfigFile, { isExplicitOverride: isExplicitConfig });
192
+ const runtimeConfigFile = resolveEmittedEcoConfigPath(process.cwd()) ?? resolvedConfigFile;
193
+ if (runtimeConfigFile) {
194
+ envOverrides.ECOPAGES_CONFIG_FILE = runtimeConfigFile;
195
+ env.ECOPAGES_CONFIG_FILE = runtimeConfigFile;
196
+ }
170
197
  if (runtime === 'node') {
171
198
  return {
172
199
  runtime,
@@ -187,10 +214,16 @@ export function createLaunchPlan(args, options, entryFile, launchMode) {
187
214
  }
188
215
 
189
216
  if (runtime === 'node') {
217
+ const commandArgs = ['--import', nodeRequirePreload, '--import', tsxLoader];
218
+ if (resolvedOptions.watch) {
219
+ commandArgs.push('--watch');
220
+ }
221
+ commandArgs.push(resolvedEntryFile, ...args);
222
+
190
223
  return {
191
224
  runtime,
192
225
  command: process.execPath,
193
- commandArgs: ['--import', nodeRequirePreload, '--import', tsxLoader, resolvedEntryFile, ...args],
226
+ commandArgs,
194
227
  envOverrides,
195
228
  env,
196
229
  };
@@ -199,7 +232,7 @@ export function createLaunchPlan(args, options, entryFile, launchMode) {
199
232
  return {
200
233
  runtime,
201
234
  command: 'bun',
202
- commandArgs: buildBunArgs(args, resolvedOptions, resolvedEntryFile, existsSync('eco.config.ts')),
235
+ commandArgs: buildBunArgs(args, resolvedOptions, resolvedEntryFile, resolvedConfigFile),
203
236
  envOverrides,
204
237
  env,
205
238
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecopages",
3
- "version": "0.2.0-rc.0",
3
+ "version": "0.2.0-rc.10",
4
4
  "description": "CLI utilities for Ecopages",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,11 +28,15 @@
28
28
  "lit",
29
29
  "mdx"
30
30
  ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
31
34
  "bin": {
32
35
  "ecopages": "bin/cli.js"
33
36
  },
34
37
  "dependencies": {
35
- "@ecopages/core": "0.2.0-rc.0",
38
+ "@clack/prompts": "^1.7.0",
39
+ "@ecopages/core": "0.2.0-rc.10",
36
40
  "@ecopages/logger": "^0.2.3",
37
41
  "giget": "^2.0.0",
38
42
  "tsx": "^4.22.3"
@@ -41,4 +45,4 @@
41
45
  "bun-types": "*",
42
46
  "typescript": "^5"
43
47
  }
44
- }
48
+ }
package/templates.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "templates": [
3
+ {
4
+ "id": "jsx",
5
+ "displayName": "JSX",
6
+ "description": "A minimal Ecopages JSX application.",
7
+ "category": "integration",
8
+ "order": 10,
9
+ "default": true,
10
+ "source": "jsx",
11
+ "packageDirectory": ".",
12
+ "integrations": ["ecopages-jsx", "mdx"]
13
+ },
14
+ {
15
+ "id": "react",
16
+ "displayName": "React",
17
+ "description": "A minimal Ecopages React application.",
18
+ "category": "integration",
19
+ "order": 20,
20
+ "source": "react",
21
+ "packageDirectory": ".",
22
+ "integrations": ["react"]
23
+ },
24
+ {
25
+ "id": "lit-jsx",
26
+ "displayName": "Lit JSX",
27
+ "description": "A KitaJS and Lit Ecopages application.",
28
+ "category": "integration",
29
+ "order": 30,
30
+ "source": "lit-jsx",
31
+ "packageDirectory": ".",
32
+ "integrations": ["kitajs", "lit"]
33
+ },
34
+ {
35
+ "id": "radiant",
36
+ "displayName": "Radiant UI",
37
+ "description": "An Ecopages JSX application built with Radiant UI components.",
38
+ "category": "integration",
39
+ "order": 35,
40
+ "source": "radiant",
41
+ "packageDirectory": ".",
42
+ "integrations": ["ecopages-jsx", "radiant-ui"]
43
+ },
44
+ {
45
+ "id": "blog-jsx",
46
+ "displayName": "Blog JSX",
47
+ "description": "A content-driven blog rendered with Ecopages JSX.",
48
+ "category": "application",
49
+ "order": 40,
50
+ "source": "blog-jsx",
51
+ "packageDirectory": ".",
52
+ "integrations": ["ecopages-jsx", "mdx"]
53
+ },
54
+ {
55
+ "id": "blog-react",
56
+ "displayName": "Blog React",
57
+ "description": "A content-driven blog rendered with React.",
58
+ "category": "application",
59
+ "order": 50,
60
+ "source": "blog-react",
61
+ "packageDirectory": ".",
62
+ "integrations": ["react", "mdx"]
63
+ },
64
+ {
65
+ "id": "docs-starter",
66
+ "displayName": "Docs Starter",
67
+ "description": "A documentation site powered by the content processor.",
68
+ "category": "application",
69
+ "order": 60,
70
+ "source": "docs-starter",
71
+ "packageDirectory": ".",
72
+ "integrations": ["ecopages-jsx", "mdx"]
73
+ },
74
+ {
75
+ "id": "react-better-auth",
76
+ "displayName": "React Better Auth",
77
+ "description": "A React application with Better Auth and Drizzle.",
78
+ "category": "application",
79
+ "order": 70,
80
+ "source": "react-better-auth",
81
+ "packageDirectory": ".",
82
+ "integrations": ["react"]
83
+ },
84
+ {
85
+ "id": "llm-wiki",
86
+ "displayName": "LLM Wiki",
87
+ "description": "A persistent, LLM-maintained knowledge base and site.",
88
+ "category": "application",
89
+ "order": 80,
90
+ "source": "llm-wiki",
91
+ "packageDirectory": "app",
92
+ "integrations": ["ecopages-jsx", "mdx"]
93
+ }
94
+ ]
95
+ }