ecopages 0.2.0-rc.0 → 0.2.0-rc.2
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 +29 -12
- package/bin/brand.js +42 -0
- package/bin/cli.js +8 -92
- package/bin/git-template-source.js +88 -0
- package/bin/init.js +341 -0
- package/package.json +3 -2
- package/templates.json +95 -0
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
|
|
9
|
+
Initialize a new project from the interactive template picker:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
bunx ecopages init
|
|
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
|
|
21
|
-
|
|
|
22
|
-
| `ecopages init
|
|
23
|
-
| `ecopages dev
|
|
24
|
-
| `ecopages dev:watch
|
|
25
|
-
| `ecopages dev:hot
|
|
26
|
-
| `ecopages build
|
|
27
|
-
| `ecopages start
|
|
28
|
-
| `ecopages 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 | `bun run [entry] --dev` |
|
|
30
|
+
| `ecopages dev:watch` | Dev server + hard restarts on file changes | `bun --watch run [entry] --dev` |
|
|
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
|
-
>
|
|
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
|
|
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,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 { withBrandBanner } from './brand.js';
|
|
10
9
|
|
|
11
10
|
const logger = new Logger('[ecopages:cli]', { debug: process.env.ECOPAGES_LOGGER_DEBUG === 'true' });
|
|
12
11
|
|
|
@@ -46,27 +45,12 @@ const sharedServerOptionDefinitions = {
|
|
|
46
45
|
},
|
|
47
46
|
};
|
|
48
47
|
|
|
49
|
-
const initOptionDefinitions = {
|
|
50
|
-
template: {
|
|
51
|
-
type: 'string',
|
|
52
|
-
},
|
|
53
|
-
repo: {
|
|
54
|
-
type: 'string',
|
|
55
|
-
},
|
|
56
|
-
help: {
|
|
57
|
-
type: 'boolean',
|
|
58
|
-
short: 'h',
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
|
|
62
48
|
function getMainHelpText() {
|
|
63
49
|
return [
|
|
64
|
-
`ecopages ${pkg.version}`,
|
|
65
|
-
'',
|
|
66
50
|
'Usage: ecopages <command> [options]',
|
|
67
51
|
'',
|
|
68
52
|
'Commands:',
|
|
69
|
-
' init
|
|
53
|
+
' init [dir] Initialize a new project from a template',
|
|
70
54
|
' dev Start the development server',
|
|
71
55
|
' dev:watch Start the development server with watch mode',
|
|
72
56
|
' dev:hot Start the development server with hot reload',
|
|
@@ -117,19 +101,6 @@ function getBuildCommandHelpText() {
|
|
|
117
101
|
].join('\n');
|
|
118
102
|
}
|
|
119
103
|
|
|
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',
|
|
129
|
-
' -h, --help Show help',
|
|
130
|
-
].join('\n');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
104
|
function parseCommandArguments(rawArgs, options) {
|
|
134
105
|
return parseArgs({
|
|
135
106
|
args: rawArgs,
|
|
@@ -168,25 +139,6 @@ function parseServerCommandArgs(rawArgs, commandName, description, mode = 'serve
|
|
|
168
139
|
};
|
|
169
140
|
}
|
|
170
141
|
|
|
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
142
|
function runLaunchPlan(launchPlan) {
|
|
191
143
|
if (Object.keys(launchPlan.envOverrides).length > 0) {
|
|
192
144
|
logger.debug(`Environment overrides: ${JSON.stringify(launchPlan.envOverrides)}`);
|
|
@@ -246,44 +198,6 @@ async function runEntryCommand(args, options = {}, entryFile = 'app.ts', launchM
|
|
|
246
198
|
runLaunchPlan(launchPlan);
|
|
247
199
|
}
|
|
248
200
|
|
|
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
|
-
}
|
|
262
|
-
|
|
263
|
-
logger.info(`Creating target directory '${dir}'...`);
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
await downloadTemplate(`github:${repo}/examples/${template}`, {
|
|
267
|
-
dir,
|
|
268
|
-
force: true,
|
|
269
|
-
});
|
|
270
|
-
|
|
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
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
201
|
async function runServerCommand(rawArgs, definition) {
|
|
288
202
|
const parsed = parseServerCommandArgs(rawArgs, definition.name, definition.description, definition.mode);
|
|
289
203
|
|
|
@@ -303,7 +217,7 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
|
|
|
303
217
|
const [commandName, ...commandArgs] = rawArgs;
|
|
304
218
|
|
|
305
219
|
if (!commandName || commandName === '--help' || commandName === '-h') {
|
|
306
|
-
console.log(getMainHelpText());
|
|
220
|
+
console.log(withBrandBanner(pkg.version, getMainHelpText()));
|
|
307
221
|
return;
|
|
308
222
|
}
|
|
309
223
|
|
|
@@ -314,9 +228,11 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
|
|
|
314
228
|
|
|
315
229
|
try {
|
|
316
230
|
switch (commandName) {
|
|
317
|
-
case 'init':
|
|
318
|
-
await
|
|
231
|
+
case 'init': {
|
|
232
|
+
const { runInitCommand } = await import('./init.js');
|
|
233
|
+
await runInitCommand(commandArgs, logger);
|
|
319
234
|
return;
|
|
235
|
+
}
|
|
320
236
|
case 'dev':
|
|
321
237
|
await runServerCommand(commandArgs, {
|
|
322
238
|
name: 'dev',
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const GIT_PROVIDER_PREFIX = /^(github|gitlab|bitbucket|sourcehut):/u;
|
|
2
|
+
|
|
3
|
+
const HOST_PROVIDER = {
|
|
4
|
+
'github.com': 'github',
|
|
5
|
+
'gitlab.com': 'gitlab',
|
|
6
|
+
'bitbucket.org': 'bitbucket',
|
|
7
|
+
'git.sr.ht': 'sourcehut',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function repositoryName(value) {
|
|
11
|
+
return value.replace(/\.git$/u, '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withSubpath(repository, subpath) {
|
|
15
|
+
return subpath.length > 0 ? `${repository}/${subpath.join('/')}` : repository;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalizes a Git provider URL or giget source into a giget template source.
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeGitSource(source) {
|
|
22
|
+
if (GIT_PROVIDER_PREFIX.test(source)) {
|
|
23
|
+
return source;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(source);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`Unsupported template source '${source}'. Use a Git provider URL or giget source.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hostProvider = HOST_PROVIDER[url.hostname];
|
|
34
|
+
if (!hostProvider) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Unsupported template host '${url.hostname}'. Supported hosts are GitHub, GitLab, Bitbucket, and SourceHut.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parts = url.pathname
|
|
41
|
+
.replace(/^\/+|\/+$/gu, '')
|
|
42
|
+
.split('/')
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
|
|
45
|
+
if (hostProvider === 'github' && parts.length >= 2) {
|
|
46
|
+
const [owner, repositoryPart, marker, ref, ...subpath] = parts;
|
|
47
|
+
const repository = `${owner}/${repositoryName(repositoryPart)}`;
|
|
48
|
+
if (marker === 'tree' || marker === 'blob') {
|
|
49
|
+
if (!ref) throw new Error(`Template URL '${source}' is missing a Git ref.`);
|
|
50
|
+
return `github:${withSubpath(repository, subpath)}#${ref}`;
|
|
51
|
+
}
|
|
52
|
+
return `github:${repository}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (hostProvider === 'gitlab') {
|
|
56
|
+
const markerIndex = parts.indexOf('-');
|
|
57
|
+
const treeIndex = parts.findIndex((part) => part === 'tree' || part === 'blob');
|
|
58
|
+
if (markerIndex > 0 && treeIndex > markerIndex && parts[treeIndex + 1]) {
|
|
59
|
+
const repository = parts.slice(0, markerIndex).map(repositoryName).join('/');
|
|
60
|
+
const ref = parts[treeIndex + 1];
|
|
61
|
+
const subpath = parts.slice(treeIndex + 2);
|
|
62
|
+
return `gitlab:${withSubpath(repository, subpath)}#${ref}`;
|
|
63
|
+
}
|
|
64
|
+
if (parts.length >= 2) return `gitlab:${parts.join('/')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (hostProvider === 'bitbucket' && parts.length >= 2) {
|
|
68
|
+
const [workspace, repositoryPart, marker, ref, ...subpath] = parts;
|
|
69
|
+
const repository = `${workspace}/${repositoryName(repositoryPart)}`;
|
|
70
|
+
if (marker === 'src' && ref) {
|
|
71
|
+
return `bitbucket:${withSubpath(repository, subpath)}#${ref}`;
|
|
72
|
+
}
|
|
73
|
+
return `bitbucket:${repository}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (hostProvider === 'sourcehut' && parts.length >= 2) {
|
|
77
|
+
const treeIndex = parts.findIndex((part) => part === 'tree' || part === 'blob');
|
|
78
|
+
if (treeIndex > 1 && parts[treeIndex + 1]) {
|
|
79
|
+
const repository = parts.slice(0, treeIndex).map(repositoryName).join('/');
|
|
80
|
+
const ref = parts[treeIndex + 1];
|
|
81
|
+
const subpath = parts.slice(treeIndex + 2);
|
|
82
|
+
return `sourcehut:${withSubpath(repository, subpath)}#${ref}`;
|
|
83
|
+
}
|
|
84
|
+
return `sourcehut:${parts.join('/')}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error(`Unsupported template URL '${source}'. Include a repository and optional Git ref.`);
|
|
88
|
+
}
|
package/bin/init.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
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 collectInitAnswers(parsed) {
|
|
224
|
+
const packageManager = detectPackageManager();
|
|
225
|
+
let dir = parsed.dir;
|
|
226
|
+
let templateId = parsed.template;
|
|
227
|
+
let source = parsed.from;
|
|
228
|
+
|
|
229
|
+
if (parsed.interactive) {
|
|
230
|
+
if (!dir) {
|
|
231
|
+
const answer = await prompts.text({
|
|
232
|
+
message: 'Project directory:',
|
|
233
|
+
placeholder: 'my-app',
|
|
234
|
+
validate: (value) => (value.trim().length === 0 ? 'Enter a project directory.' : undefined),
|
|
235
|
+
});
|
|
236
|
+
if (isCancelled(answer)) return null;
|
|
237
|
+
dir = answer.trim();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!templateId && !source) {
|
|
241
|
+
const answer = await prompts.select({
|
|
242
|
+
message: 'Select a template:',
|
|
243
|
+
options: [
|
|
244
|
+
...officialTemplates.map((template) => ({
|
|
245
|
+
label: template.displayName,
|
|
246
|
+
value: template.id,
|
|
247
|
+
hint: template.description,
|
|
248
|
+
})),
|
|
249
|
+
{
|
|
250
|
+
label: 'Community template',
|
|
251
|
+
value: '__community__',
|
|
252
|
+
hint: 'Use a Git repository or provider source.',
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
if (isCancelled(answer)) return null;
|
|
257
|
+
if (answer === '__community__') {
|
|
258
|
+
const sourceAnswer = await prompts.text({
|
|
259
|
+
message: 'Template source:',
|
|
260
|
+
placeholder: 'github:owner/repository#v1.0.0',
|
|
261
|
+
validate: (value) => (value.trim().length === 0 ? 'Enter a Git template source.' : undefined),
|
|
262
|
+
});
|
|
263
|
+
if (isCancelled(sourceAnswer)) return null;
|
|
264
|
+
source = sourceAnswer.trim();
|
|
265
|
+
} else {
|
|
266
|
+
templateId = answer;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!templateId && !source) templateId = defaultOfficialTemplate.id;
|
|
272
|
+
if (parsed.interactive) {
|
|
273
|
+
prompts.log.step(`Resolved template source: ${getTemplateSource(templateId, source)}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let install = parsed.start ? true : parsed.install;
|
|
277
|
+
if (install === undefined && parsed.interactive) {
|
|
278
|
+
const answer = await prompts.confirm({
|
|
279
|
+
message: `Install dependencies with ${packageManager}?`,
|
|
280
|
+
initialValue: true,
|
|
281
|
+
});
|
|
282
|
+
if (isCancelled(answer)) return null;
|
|
283
|
+
install = answer;
|
|
284
|
+
}
|
|
285
|
+
install ??= false;
|
|
286
|
+
|
|
287
|
+
let start = parsed.start;
|
|
288
|
+
if (start === undefined && parsed.interactive && install) {
|
|
289
|
+
const answer = await prompts.confirm({ message: 'Start the development server now?', initialValue: false });
|
|
290
|
+
if (isCancelled(answer)) return null;
|
|
291
|
+
start = answer;
|
|
292
|
+
}
|
|
293
|
+
start ??= false;
|
|
294
|
+
|
|
295
|
+
return { dir, templateId, source, packageManager, install, start };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function runInitCommand(rawArgs, logger) {
|
|
299
|
+
const parsed = parseInitCommandArgs(rawArgs);
|
|
300
|
+
if (parsed.help) return;
|
|
301
|
+
|
|
302
|
+
printBrandBanner(pkg.version);
|
|
303
|
+
const answers = await collectInitAnswers(parsed);
|
|
304
|
+
if (!answers) return;
|
|
305
|
+
|
|
306
|
+
const { dir, templateId, source, packageManager, install, start } = answers;
|
|
307
|
+
if (existsSync(dir)) throw new Error(`Target directory already exists: ${dir}`);
|
|
308
|
+
|
|
309
|
+
const officialTemplate = source ? null : findOfficialTemplate(templateId);
|
|
310
|
+
const templateSource = getTemplateSource(templateId, source);
|
|
311
|
+
const packageDirectory = officialTemplate?.packageDirectory ?? '.';
|
|
312
|
+
let targetCreated = false;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
if (!parsed.interactive) prompts.log.step(`Resolved template source: ${templateSource}`);
|
|
316
|
+
prompts.log.step(`Downloading ${officialTemplate?.displayName ?? 'community template'}...`);
|
|
317
|
+
targetCreated = true;
|
|
318
|
+
await downloadTemplate(templateSource, { dir, force: true });
|
|
319
|
+
prompts.log.step('Preparing project manifest...');
|
|
320
|
+
updateProjectManifest(dir, packageDirectory, Boolean(officialTemplate));
|
|
321
|
+
prompts.log.step(`Project created in ${dir}.`);
|
|
322
|
+
|
|
323
|
+
const packageCwd = join(dir, packageDirectory);
|
|
324
|
+
if (install) {
|
|
325
|
+
prompts.log.step(`Installing dependencies with ${packageManager}...`);
|
|
326
|
+
await runChildCommand(packageManager, ['install'], packageCwd);
|
|
327
|
+
}
|
|
328
|
+
if (start) {
|
|
329
|
+
prompts.log.step('Starting the development server...');
|
|
330
|
+
await runChildCommand(packageManager, ['run', 'dev'], packageCwd);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const relativeCwd = packageDirectory === '.' ? dir : join(dir, packageDirectory);
|
|
334
|
+
logger.info(
|
|
335
|
+
`Project initialized. Run 'cd "${relativeCwd}" && ${packageManager} install && ${packageManager} run dev'.`,
|
|
336
|
+
);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (targetCreated) rmSync(dir, { recursive: true, force: true });
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecopages",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.2",
|
|
4
4
|
"description": "CLI utilities for Ecopages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"ecopages": "bin/cli.js"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@
|
|
35
|
+
"@clack/prompts": "^1.7.0",
|
|
36
|
+
"@ecopages/core": "0.2.0-rc.2",
|
|
36
37
|
"@ecopages/logger": "^0.2.3",
|
|
37
38
|
"giget": "^2.0.0",
|
|
38
39
|
"tsx": "^4.22.3"
|
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
|
+
}
|