@enfyra/create 0.1.0
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/LICENSE +21 -0
- package/README.md +49 -0
- package/components/connection-validator.js +124 -0
- package/components/env-builder.js +58 -0
- package/components/package-managers.js +51 -0
- package/components/port-guard.js +89 -0
- package/components/project-setup.js +387 -0
- package/components/prompts.js +136 -0
- package/components/validators.js +76 -0
- package/index.js +145 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Enfyra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @enfyra/create
|
|
2
|
+
|
|
3
|
+
Create a complete Enfyra workspace with one command:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @enfyra/create my-enfyra
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The generated project contains:
|
|
10
|
+
|
|
11
|
+
- `app/` - Enfyra Nuxt admin app
|
|
12
|
+
- `server/` - Enfyra server
|
|
13
|
+
- Root workspace scripts for running both together
|
|
14
|
+
- `app/.env` connected to `http://localhost:1105`
|
|
15
|
+
- `server/.env` with `PORT=1105`
|
|
16
|
+
|
|
17
|
+
The CLI prompts for:
|
|
18
|
+
|
|
19
|
+
- Package manager: npm, yarn, or pnpm
|
|
20
|
+
- Database type and URI
|
|
21
|
+
- Redis URI
|
|
22
|
+
- Default admin account
|
|
23
|
+
|
|
24
|
+
It validates the database and Redis connections before scaffolding. If validation fails, you can re-enter the connection details, continue anyway, or exit setup.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd my-enfyra
|
|
30
|
+
<package-manager> run dev
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run one side only:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
<package-manager> run dev:server
|
|
37
|
+
<package-manager> run dev:app
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The server port is fixed to `1105`. During setup, the CLI checks the port first and asks before killing any process that is already using it.
|
|
41
|
+
|
|
42
|
+
The Nuxt app is configured with `PORT=3000`; Nuxt may pick another port at dev time if `3000` is already busy.
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Node.js 24.x
|
|
47
|
+
- npm, yarn, or pnpm
|
|
48
|
+
- A running MySQL, PostgreSQL, or MongoDB instance
|
|
49
|
+
- A running Redis instance
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const mysql = require('mysql2/promise');
|
|
2
|
+
const { Client } = require('pg');
|
|
3
|
+
const { MongoClient } = require('mongodb');
|
|
4
|
+
const Redis = require('ioredis');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
|
|
7
|
+
function parseDatabaseUri(uri) {
|
|
8
|
+
const url = new URL(uri);
|
|
9
|
+
const protocol = url.protocol.replace(':', '');
|
|
10
|
+
const host = url.hostname;
|
|
11
|
+
const port = url.port ? parseInt(url.port, 10) : (protocol === 'mysql' ? 3306 : 5432);
|
|
12
|
+
const user = url.username || '';
|
|
13
|
+
const password = url.password || '';
|
|
14
|
+
const database = url.pathname ? url.pathname.replace(/^\//, '') : '';
|
|
15
|
+
|
|
16
|
+
return { host, port, user, password, database };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function validateDatabaseConnection(config) {
|
|
20
|
+
const { dbType, dbUri, mongoUri, setupReplica, dbReplicaUri } = config;
|
|
21
|
+
|
|
22
|
+
console.log(chalk.gray(`\nTesting ${dbType} connection...`));
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (dbType === 'mysql') {
|
|
26
|
+
await pingMysql(dbUri);
|
|
27
|
+
if (setupReplica && dbReplicaUri) await pingMysql(dbReplicaUri);
|
|
28
|
+
} else if (dbType === 'postgres') {
|
|
29
|
+
await pingPostgres(dbUri);
|
|
30
|
+
if (setupReplica && dbReplicaUri) await pingPostgres(dbReplicaUri);
|
|
31
|
+
} else {
|
|
32
|
+
await pingMongo(mongoUri);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(chalk.green('Database connection successful'));
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.log(chalk.red(`Database connection failed: ${error.message}`));
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function validateRedisConnection(redisUri) {
|
|
44
|
+
console.log(chalk.gray('\nTesting Redis connection...'));
|
|
45
|
+
|
|
46
|
+
const redis = new Redis(redisUri, {
|
|
47
|
+
connectTimeout: 5000,
|
|
48
|
+
lazyConnect: true,
|
|
49
|
+
maxRetriesPerRequest: 1,
|
|
50
|
+
});
|
|
51
|
+
redis.on('error', () => {});
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await redis.connect();
|
|
55
|
+
await redis.ping();
|
|
56
|
+
console.log(chalk.green('Redis connection successful'));
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.log(chalk.red(`Redis connection failed: ${error.message}`));
|
|
60
|
+
return false;
|
|
61
|
+
} finally {
|
|
62
|
+
redis.disconnect();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function validateAllConnections(config) {
|
|
67
|
+
console.log(chalk.cyan('\nValidating connections...'));
|
|
68
|
+
|
|
69
|
+
const dbValid = await validateDatabaseConnection(config);
|
|
70
|
+
const redisValid = await validateRedisConnection(config.redisUri);
|
|
71
|
+
|
|
72
|
+
if (dbValid && redisValid) {
|
|
73
|
+
console.log(chalk.green('\nAll connections validated successfully.\n'));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(chalk.red('\nConnection validation failed.\n'));
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function pingMysql(uri) {
|
|
82
|
+
const parsed = parseDatabaseUri(uri);
|
|
83
|
+
const connection = await mysql.createConnection({
|
|
84
|
+
host: parsed.host,
|
|
85
|
+
port: parsed.port,
|
|
86
|
+
user: parsed.user,
|
|
87
|
+
password: parsed.password,
|
|
88
|
+
connectTimeout: 5000,
|
|
89
|
+
});
|
|
90
|
+
await connection.ping();
|
|
91
|
+
await connection.end();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function pingPostgres(uri) {
|
|
95
|
+
const parsed = parseDatabaseUri(uri);
|
|
96
|
+
const client = new Client({
|
|
97
|
+
host: parsed.host,
|
|
98
|
+
port: parsed.port,
|
|
99
|
+
user: parsed.user,
|
|
100
|
+
password: parsed.password,
|
|
101
|
+
database: parsed.database || 'postgres',
|
|
102
|
+
connectionTimeoutMillis: 5000,
|
|
103
|
+
});
|
|
104
|
+
await client.connect();
|
|
105
|
+
await client.query('SELECT 1');
|
|
106
|
+
await client.end();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function pingMongo(uri) {
|
|
110
|
+
const client = new MongoClient(uri, {
|
|
111
|
+
serverSelectionTimeoutMS: 5000,
|
|
112
|
+
connectTimeoutMS: 5000,
|
|
113
|
+
});
|
|
114
|
+
await client.connect();
|
|
115
|
+
const dbName = uri.match(/\/([^/?]+)(\?|$)/)?.[1] || 'enfyra';
|
|
116
|
+
await client.db(dbName).admin().ping();
|
|
117
|
+
await client.close();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
validateDatabaseConnection,
|
|
122
|
+
validateRedisConnection,
|
|
123
|
+
validateAllConnections,
|
|
124
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
async function generateEnvFiles(projectPath, config) {
|
|
6
|
+
await fs.writeFile(path.join(projectPath, 'app', '.env'), buildAppEnv(config));
|
|
7
|
+
await fs.writeFile(path.join(projectPath, 'server', '.env'), buildServerEnv(config));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function buildAppEnv(config) {
|
|
11
|
+
return [
|
|
12
|
+
`API_URL=${config.apiUrl}`,
|
|
13
|
+
`PORT=${config.appPort}`,
|
|
14
|
+
].join('\n') + '\n';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildServerEnv(config) {
|
|
18
|
+
const dbLines = [];
|
|
19
|
+
|
|
20
|
+
if (config.dbType === 'mongodb') {
|
|
21
|
+
dbLines.push(`DB_URI=${config.mongoUri}`);
|
|
22
|
+
} else {
|
|
23
|
+
dbLines.push(`DB_URI=${config.dbUri}`);
|
|
24
|
+
if (config.setupReplica && config.dbReplicaUri) {
|
|
25
|
+
dbLines.push(`DB_REPLICA_URIS=${config.dbReplicaUri}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return [
|
|
30
|
+
...dbLines,
|
|
31
|
+
`REDIS_URI=${config.redisUri}`,
|
|
32
|
+
'DEFAULT_TTL=5',
|
|
33
|
+
'REDIS_RUNTIME_CACHE=false',
|
|
34
|
+
'REDIS_USER_CACHE_LIMIT_MB=30',
|
|
35
|
+
'REDIS_USER_CACHE_MAX_VALUE_BYTES=0',
|
|
36
|
+
`NODE_NAME=${toNodeName(config.projectName)}`,
|
|
37
|
+
`PORT=${config.serverPort}`,
|
|
38
|
+
`SECRET_KEY=${crypto.randomBytes(32).toString('hex')}`,
|
|
39
|
+
'SALT_ROUNDS=10',
|
|
40
|
+
'ACCESS_TOKEN_EXP=15m',
|
|
41
|
+
'REFRESH_TOKEN_NO_REMEMBER_EXP=1d',
|
|
42
|
+
'REFRESH_TOKEN_REMEMBER_EXP=7d',
|
|
43
|
+
`ADMIN_EMAIL=${config.adminEmail}`,
|
|
44
|
+
`ADMIN_PASSWORD=${config.adminPassword}`,
|
|
45
|
+
'NODE_ENV=development',
|
|
46
|
+
'BOOTSTRAP_VERBOSE=0',
|
|
47
|
+
].join('\n') + '\n';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toNodeName(projectName) {
|
|
51
|
+
return projectName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'enfyra';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
generateEnvFiles,
|
|
56
|
+
buildAppEnv,
|
|
57
|
+
buildServerEnv,
|
|
58
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
|
|
3
|
+
function detectPackageManagers() {
|
|
4
|
+
const managers = [];
|
|
5
|
+
|
|
6
|
+
for (const name of ['npm', 'yarn', 'pnpm']) {
|
|
7
|
+
try {
|
|
8
|
+
const version = execSync(`${name} --version`, {
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
stdio: 'pipe',
|
|
11
|
+
shell: true,
|
|
12
|
+
}).trim();
|
|
13
|
+
managers.push({ name, value: name, version });
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return managers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getWorkspaceRunCommand(packageManager, workspace, script) {
|
|
21
|
+
if (packageManager === 'yarn') return `yarn --cwd ${workspace} ${script}`;
|
|
22
|
+
if (packageManager === 'pnpm') return `pnpm --dir ${workspace} run ${script}`;
|
|
23
|
+
return `npm run ${script} -w ${workspace}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getPackageRunCommand(packageManager, script) {
|
|
27
|
+
if (packageManager === 'yarn') return `yarn ${script}`;
|
|
28
|
+
if (packageManager === 'pnpm') return `pnpm run ${script}`;
|
|
29
|
+
return `npm run ${script}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getPackageExecCommand(packageManager, binary, args = []) {
|
|
33
|
+
const suffix = [binary, ...args].join(' ');
|
|
34
|
+
if (packageManager === 'yarn') return `yarn exec ${suffix}`;
|
|
35
|
+
if (packageManager === 'pnpm') return `pnpm exec ${suffix}`;
|
|
36
|
+
return `npx ${suffix}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getInstallCommand(packageManager) {
|
|
40
|
+
if (packageManager === 'yarn') return { command: 'yarn', args: ['install'] };
|
|
41
|
+
if (packageManager === 'pnpm') return { command: 'pnpm', args: ['install'] };
|
|
42
|
+
return { command: 'npm', args: ['install', '--legacy-peer-deps'] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
detectPackageManagers,
|
|
47
|
+
getWorkspaceRunCommand,
|
|
48
|
+
getPackageRunCommand,
|
|
49
|
+
getPackageExecCommand,
|
|
50
|
+
getInstallCommand,
|
|
51
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
|
+
const inquirer = require('inquirer');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
|
|
6
|
+
async function ensureServerPortAvailable(port) {
|
|
7
|
+
if (await isPortAvailable(port)) return;
|
|
8
|
+
|
|
9
|
+
const pids = getPortPids(port);
|
|
10
|
+
const pidText = pids.length ? ` PID(s): ${pids.join(', ')}` : '';
|
|
11
|
+
console.log(chalk.yellow(`Port ${port} is already in use.${pidText}`));
|
|
12
|
+
|
|
13
|
+
const { killPort } = await inquirer.prompt([
|
|
14
|
+
{
|
|
15
|
+
type: 'confirm',
|
|
16
|
+
name: 'killPort',
|
|
17
|
+
message: `Kill the process using port ${port} and continue?`,
|
|
18
|
+
default: false,
|
|
19
|
+
},
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
if (!killPort) {
|
|
23
|
+
console.log(chalk.yellow(`Setup cancelled. Free port ${port} and run the command again.`));
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!pids.length) {
|
|
28
|
+
console.log(chalk.red(`Could not identify the process using port ${port}.`));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const pid of pids) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(Number(pid), 'SIGTERM');
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await wait(1200);
|
|
39
|
+
|
|
40
|
+
if (!(await isPortAvailable(port))) {
|
|
41
|
+
for (const pid of pids) {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(Number(pid), 'SIGKILL');
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
await wait(800);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!(await isPortAvailable(port))) {
|
|
50
|
+
console.log(chalk.red(`Port ${port} is still busy. Please free it and try again.`));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(chalk.green(`Port ${port} is available.`));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPortAvailable(port) {
|
|
58
|
+
const server = net.createServer();
|
|
59
|
+
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
server.once('error', () => resolve(false));
|
|
62
|
+
server.once('listening', () => {
|
|
63
|
+
server.close(() => resolve(true));
|
|
64
|
+
});
|
|
65
|
+
server.listen(port, '0.0.0.0');
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getPortPids(port) {
|
|
70
|
+
try {
|
|
71
|
+
const output = execFileSync('lsof', ['-ti', `tcp:${port}`], {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
74
|
+
});
|
|
75
|
+
return output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function wait(ms) {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
ensureServerPortAvailable,
|
|
87
|
+
isPortAvailable,
|
|
88
|
+
getPortPids,
|
|
89
|
+
};
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const { downloadTemplate } = require('giget');
|
|
7
|
+
const { generateEnvFiles } = require('./env-builder');
|
|
8
|
+
const {
|
|
9
|
+
getInstallCommand,
|
|
10
|
+
getPackageExecCommand,
|
|
11
|
+
getWorkspaceRunCommand,
|
|
12
|
+
} = require('./package-managers');
|
|
13
|
+
|
|
14
|
+
async function createProject(config, projectPath) {
|
|
15
|
+
const spinner = ora();
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
spinner.start(chalk.blue('Creating workspace...'));
|
|
19
|
+
await fs.ensureDir(projectPath);
|
|
20
|
+
spinner.succeed(chalk.green('Workspace created'));
|
|
21
|
+
|
|
22
|
+
spinner.start(chalk.blue('Downloading Enfyra app...'));
|
|
23
|
+
await downloadTemplate('github:enfyra/app', {
|
|
24
|
+
dir: path.join(projectPath, 'app'),
|
|
25
|
+
force: true,
|
|
26
|
+
provider: 'github',
|
|
27
|
+
});
|
|
28
|
+
spinner.succeed(chalk.green('App downloaded'));
|
|
29
|
+
|
|
30
|
+
spinner.start(chalk.blue('Downloading Enfyra server...'));
|
|
31
|
+
await downloadTemplate('github:enfyra/server', {
|
|
32
|
+
dir: path.join(projectPath, 'server'),
|
|
33
|
+
force: true,
|
|
34
|
+
provider: 'github',
|
|
35
|
+
});
|
|
36
|
+
spinner.succeed(chalk.green('Server downloaded'));
|
|
37
|
+
|
|
38
|
+
spinner.start(chalk.blue(`Preparing ${config.packageManager} workspaces...`));
|
|
39
|
+
await removeTemplateFiles(projectPath);
|
|
40
|
+
await cleanPackageManagerRestrictions(projectPath);
|
|
41
|
+
await updateWorkspacePackageJson(projectPath, config);
|
|
42
|
+
await writePackageManagerConfig(projectPath, config);
|
|
43
|
+
await updateAppPackageJson(path.join(projectPath, 'app'), config);
|
|
44
|
+
await updateServerPackageJson(path.join(projectPath, 'server'), config);
|
|
45
|
+
spinner.succeed(chalk.green(`${config.packageManager} workspaces prepared`));
|
|
46
|
+
|
|
47
|
+
spinner.start(chalk.blue('Generating environment files...'));
|
|
48
|
+
await generateEnvFiles(projectPath, config);
|
|
49
|
+
spinner.succeed(chalk.green('Environment files created'));
|
|
50
|
+
|
|
51
|
+
spinner.start(chalk.blue(`Installing dependencies with ${config.packageManager}...`));
|
|
52
|
+
await installDependencies(projectPath, config.packageManager);
|
|
53
|
+
spinner.succeed(chalk.green('Dependencies installed'));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
spinner.fail(chalk.red('Setup failed'));
|
|
56
|
+
|
|
57
|
+
if (fs.existsSync(projectPath)) {
|
|
58
|
+
await fs.remove(projectPath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function updateWorkspacePackageJson(projectPath, config) {
|
|
66
|
+
const packageName = toPackageName(config.projectName);
|
|
67
|
+
const packageManager = config.packageManager;
|
|
68
|
+
const resolutions = await collectTemplateResolutions(projectPath);
|
|
69
|
+
const packageJson = {
|
|
70
|
+
name: packageName,
|
|
71
|
+
version: '0.1.0',
|
|
72
|
+
private: true,
|
|
73
|
+
workspaces: [
|
|
74
|
+
'app',
|
|
75
|
+
'server',
|
|
76
|
+
],
|
|
77
|
+
scripts: {
|
|
78
|
+
dev: `concurrently -k -n server,app -c cyan,green "${getWorkspaceRunCommand(packageManager, 'server', 'dev')}" "${getPackageExecCommand(packageManager, 'wait-on', [`http://localhost:${config.serverPort}`])} && ${getWorkspaceRunCommand(packageManager, 'app', 'dev')}"`,
|
|
79
|
+
'dev:app': getWorkspaceRunCommand(packageManager, 'app', 'dev'),
|
|
80
|
+
'dev:server': getWorkspaceRunCommand(packageManager, 'server', 'dev'),
|
|
81
|
+
build: `${getWorkspaceRunCommand(packageManager, 'server', 'build')} && ${getWorkspaceRunCommand(packageManager, 'app', 'build')}`,
|
|
82
|
+
'build:app': getWorkspaceRunCommand(packageManager, 'app', 'build'),
|
|
83
|
+
'build:server': getWorkspaceRunCommand(packageManager, 'server', 'build'),
|
|
84
|
+
start: getWorkspaceRunCommand(packageManager, 'server', 'start'),
|
|
85
|
+
},
|
|
86
|
+
devDependencies: {
|
|
87
|
+
concurrently: '^9.2.1',
|
|
88
|
+
'wait-on': '^8.0.3',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (config.packageManagerVersion) {
|
|
93
|
+
packageJson.packageManager = `${packageManager}@${config.packageManagerVersion}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (packageManager === 'yarn' && Object.keys(resolutions).length > 0) {
|
|
97
|
+
packageJson.resolutions = resolutions;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await fs.writeJson(path.join(projectPath, 'package.json'), packageJson, { spaces: 2 });
|
|
101
|
+
await fs.writeFile(path.join(projectPath, '.gitignore'), buildGitignore());
|
|
102
|
+
await ensureWorkspaceGitignores(projectPath);
|
|
103
|
+
if (packageManager === 'pnpm') {
|
|
104
|
+
await fs.writeFile(path.join(projectPath, 'pnpm-workspace.yaml'), buildPnpmWorkspaceYaml(resolutions, config.packageManagerVersion));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function writePackageManagerConfig(projectPath, config) {
|
|
109
|
+
if (config.packageManager !== 'yarn') return;
|
|
110
|
+
|
|
111
|
+
await fs.writeFile(
|
|
112
|
+
path.join(projectPath, '.yarnrc.yml'),
|
|
113
|
+
[
|
|
114
|
+
'approvedGitRepositories:',
|
|
115
|
+
' - "**"',
|
|
116
|
+
'',
|
|
117
|
+
'enableScripts: true',
|
|
118
|
+
'',
|
|
119
|
+
'nodeLinker: node-modules',
|
|
120
|
+
'',
|
|
121
|
+
'npmMinimalAgeGate: 0',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n'),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function updateAppPackageJson(appPath, config) {
|
|
128
|
+
const packageJsonPath = path.join(appPath, 'package.json');
|
|
129
|
+
const packageJson = await fs.readJson(packageJsonPath);
|
|
130
|
+
|
|
131
|
+
packageJson.name = `${toPackageName(config.projectName)}-app`;
|
|
132
|
+
packageJson.version = '0.1.0';
|
|
133
|
+
delete packageJson.packageManager;
|
|
134
|
+
delete packageJson.repository;
|
|
135
|
+
delete packageJson.bugs;
|
|
136
|
+
delete packageJson.homepage;
|
|
137
|
+
if (config.packageManager === 'pnpm') delete packageJson.resolutions;
|
|
138
|
+
|
|
139
|
+
if (packageJson.engines) {
|
|
140
|
+
delete packageJson.engines.npm;
|
|
141
|
+
delete packageJson.engines.yarn;
|
|
142
|
+
delete packageJson.engines.pnpm;
|
|
143
|
+
if (Object.keys(packageJson.engines).length === 0) delete packageJson.engines;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
ensureAppDirectDependencies(packageJson);
|
|
147
|
+
|
|
148
|
+
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function updateServerPackageJson(serverPath, config) {
|
|
152
|
+
const packageJsonPath = path.join(serverPath, 'package.json');
|
|
153
|
+
const packageJson = await fs.readJson(packageJsonPath);
|
|
154
|
+
|
|
155
|
+
packageJson.name = `${toPackageName(config.projectName)}-server`;
|
|
156
|
+
packageJson.version = '0.1.0';
|
|
157
|
+
delete packageJson.packageManager;
|
|
158
|
+
delete packageJson.repository;
|
|
159
|
+
delete packageJson.bugs;
|
|
160
|
+
delete packageJson.homepage;
|
|
161
|
+
if (config.packageManager === 'pnpm') delete packageJson.resolutions;
|
|
162
|
+
|
|
163
|
+
if (packageJson.engines) {
|
|
164
|
+
delete packageJson.engines.npm;
|
|
165
|
+
delete packageJson.engines.yarn;
|
|
166
|
+
delete packageJson.engines.pnpm;
|
|
167
|
+
if (Object.keys(packageJson.engines).length === 0) delete packageJson.engines;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
rewritePackageManagerScripts(packageJson, config.packageManager);
|
|
171
|
+
|
|
172
|
+
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function removeTemplateFiles(projectPath) {
|
|
176
|
+
const paths = [
|
|
177
|
+
'.github',
|
|
178
|
+
path.join('app', '.github'),
|
|
179
|
+
path.join('server', '.github'),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
for (const relativePath of paths) {
|
|
183
|
+
const fullPath = path.join(projectPath, relativePath);
|
|
184
|
+
if (fs.existsSync(fullPath)) await fs.remove(fullPath);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function cleanPackageManagerRestrictions(projectPath) {
|
|
189
|
+
const files = [
|
|
190
|
+
'.npmrc',
|
|
191
|
+
'.yarnrc',
|
|
192
|
+
'.yarnrc.yml',
|
|
193
|
+
'pnpm-workspace.yaml',
|
|
194
|
+
'package-lock.json',
|
|
195
|
+
'yarn.lock',
|
|
196
|
+
'pnpm-lock.yaml',
|
|
197
|
+
'.pnp.cjs',
|
|
198
|
+
'.pnp.loader.mjs',
|
|
199
|
+
];
|
|
200
|
+
const directories = [
|
|
201
|
+
'.yarn',
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
for (const workspace of ['', 'app', 'server']) {
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
const filePath = path.join(projectPath, workspace, file);
|
|
207
|
+
if (fs.existsSync(filePath)) await fs.remove(filePath);
|
|
208
|
+
}
|
|
209
|
+
for (const directory of directories) {
|
|
210
|
+
const directoryPath = path.join(projectPath, workspace, directory);
|
|
211
|
+
if (fs.existsSync(directoryPath)) await fs.remove(directoryPath);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function rewritePackageManagerScripts(packageJson, packageManager = 'npm') {
|
|
217
|
+
if (!packageJson.scripts) return;
|
|
218
|
+
|
|
219
|
+
const runCommand = packageManager === 'yarn' ? 'yarn' : `${packageManager} run`;
|
|
220
|
+
for (const [name, script] of Object.entries(packageJson.scripts)) {
|
|
221
|
+
if (typeof script !== 'string') continue;
|
|
222
|
+
packageJson.scripts[name] = script.replace(/\byarn\s+([A-Za-z0-9:_-]+)/g, `${runCommand} $1`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function ensureAppDirectDependencies(packageJson) {
|
|
227
|
+
packageJson.dependencies ||= {};
|
|
228
|
+
const tiptapVersion = packageJson.dependencies['@tiptap/core'] || packageJson.dependencies['@tiptap/starter-kit'] || '3.15.3';
|
|
229
|
+
|
|
230
|
+
for (const dependency of [
|
|
231
|
+
'@tiptap/extension-link',
|
|
232
|
+
'@tiptap/extension-placeholder',
|
|
233
|
+
'@tiptap/extension-underline',
|
|
234
|
+
'@tiptap/extensions',
|
|
235
|
+
]) {
|
|
236
|
+
packageJson.dependencies[dependency] ||= tiptapVersion;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function installDependencies(projectPath, packageManager) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const { command, args } = getInstallCommand(packageManager);
|
|
243
|
+
const install = spawn(command, args, {
|
|
244
|
+
cwd: projectPath,
|
|
245
|
+
stdio: 'pipe',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
let stdout = '';
|
|
249
|
+
let stderr = '';
|
|
250
|
+
|
|
251
|
+
install.stdout.on('data', (data) => {
|
|
252
|
+
stdout += data.toString();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
install.stderr.on('data', (data) => {
|
|
256
|
+
stderr += data.toString();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
install.on('close', (code) => {
|
|
260
|
+
if (code === 0) {
|
|
261
|
+
resolve();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
reject(new Error(stderr || stdout || `npm install failed with code ${code}`));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
install.on('error', (error) => {
|
|
269
|
+
reject(new Error(`${command} spawn error: ${error.message}`));
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildGitignore() {
|
|
275
|
+
return [
|
|
276
|
+
'node_modules',
|
|
277
|
+
'.env',
|
|
278
|
+
'.env.*',
|
|
279
|
+
'!.env.example',
|
|
280
|
+
'.nuxt',
|
|
281
|
+
'.output',
|
|
282
|
+
'dist',
|
|
283
|
+
'logs',
|
|
284
|
+
'*.log',
|
|
285
|
+
'.DS_Store',
|
|
286
|
+
].join('\n') + '\n';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function ensureWorkspaceGitignores(projectPath) {
|
|
290
|
+
for (const workspace of ['app', 'server']) {
|
|
291
|
+
const gitignorePath = path.join(projectPath, workspace, '.gitignore');
|
|
292
|
+
const existing = fs.existsSync(gitignorePath) ? await fs.readFile(gitignorePath, 'utf8') : '';
|
|
293
|
+
const next = mergeGitignoreEntries(existing, ['.env', '.env.*', '!.env.example']);
|
|
294
|
+
await fs.writeFile(gitignorePath, next);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function mergeGitignoreEntries(content, entries) {
|
|
299
|
+
const lines = content.split(/\r?\n/);
|
|
300
|
+
const existing = new Set(lines.map((line) => line.trim()));
|
|
301
|
+
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
if (!existing.has(entry)) lines.push(entry);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function collectTemplateResolutions(projectPath) {
|
|
310
|
+
const resolutions = {};
|
|
311
|
+
|
|
312
|
+
for (const workspace of ['app', 'server']) {
|
|
313
|
+
const packageJsonPath = path.join(projectPath, workspace, 'package.json');
|
|
314
|
+
if (!fs.existsSync(packageJsonPath)) continue;
|
|
315
|
+
|
|
316
|
+
const packageJson = await fs.readJson(packageJsonPath);
|
|
317
|
+
Object.assign(resolutions, packageJson.resolutions || {});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return resolutions;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildPnpmWorkspaceYaml(resolutions, pnpmVersion) {
|
|
324
|
+
const allowedBuildDependencies = [
|
|
325
|
+
'@parcel/watcher',
|
|
326
|
+
'esbuild',
|
|
327
|
+
'isolated-vm',
|
|
328
|
+
'msgpackr-extract',
|
|
329
|
+
'sharp',
|
|
330
|
+
'vue-demi',
|
|
331
|
+
];
|
|
332
|
+
const lines = [
|
|
333
|
+
'packages:',
|
|
334
|
+
' - app',
|
|
335
|
+
' - server',
|
|
336
|
+
'',
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
if (getMajorVersion(pnpmVersion) >= 11) {
|
|
340
|
+
lines.push('allowBuilds:');
|
|
341
|
+
for (const dependency of allowedBuildDependencies) {
|
|
342
|
+
lines.push(` ${yamlQuote(dependency)}: true`);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
lines.push('onlyBuiltDependencies:');
|
|
346
|
+
for (const dependency of allowedBuildDependencies) {
|
|
347
|
+
lines.push(` - ${yamlQuote(dependency)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const overrideEntries = Object.entries(resolutions);
|
|
352
|
+
if (overrideEntries.length > 0) {
|
|
353
|
+
lines.push('', 'overrides:');
|
|
354
|
+
for (const [name, version] of overrideEntries) {
|
|
355
|
+
lines.push(` ${yamlQuote(name)}: ${yamlQuote(version)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return `${lines.join('\n')}\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function yamlQuote(value) {
|
|
363
|
+
return JSON.stringify(String(value));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getMajorVersion(version) {
|
|
367
|
+
const major = Number(String(version || '').split('.')[0]);
|
|
368
|
+
return Number.isFinite(major) && major > 0 ? major : 11;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function toPackageName(projectName) {
|
|
372
|
+
return projectName
|
|
373
|
+
.toLowerCase()
|
|
374
|
+
.replace(/_/g, '-')
|
|
375
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
376
|
+
.replace(/^-+|-+$/g, '') || 'enfyra';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = {
|
|
380
|
+
createProject,
|
|
381
|
+
cleanPackageManagerRestrictions,
|
|
382
|
+
rewritePackageManagerScripts,
|
|
383
|
+
toPackageName,
|
|
384
|
+
buildPnpmWorkspaceYaml,
|
|
385
|
+
ensureAppDirectDependencies,
|
|
386
|
+
mergeGitignoreEntries,
|
|
387
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const validators = require('./validators');
|
|
2
|
+
|
|
3
|
+
function getPrompts(projectNameArg, options = {}) {
|
|
4
|
+
const prompts = [];
|
|
5
|
+
|
|
6
|
+
if (!projectNameArg && !options.connectionOnly) {
|
|
7
|
+
prompts.push({
|
|
8
|
+
type: 'input',
|
|
9
|
+
name: 'projectName',
|
|
10
|
+
message: 'Project name:',
|
|
11
|
+
default: 'my-enfyra',
|
|
12
|
+
validate: validators.validateProjectName,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!options.connectionOnly) {
|
|
17
|
+
prompts.push({
|
|
18
|
+
type: 'list',
|
|
19
|
+
name: 'packageManager',
|
|
20
|
+
message: 'Package manager:',
|
|
21
|
+
choices: options.packageManagers.map((pm) => ({
|
|
22
|
+
name: `${getPackageManagerIcon(pm.name)} ${pm.name} (v${pm.version})`,
|
|
23
|
+
value: pm.value,
|
|
24
|
+
short: pm.name,
|
|
25
|
+
})),
|
|
26
|
+
default: options.packageManagers.find((pm) => pm.value === 'npm')?.value || options.packageManagers[0]?.value,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
prompts.push(
|
|
31
|
+
{
|
|
32
|
+
type: 'list',
|
|
33
|
+
name: 'dbType',
|
|
34
|
+
message: 'Database type:',
|
|
35
|
+
choices: [
|
|
36
|
+
{ name: 'MySQL', value: 'mysql' },
|
|
37
|
+
{ name: 'PostgreSQL', value: 'postgres' },
|
|
38
|
+
{ name: 'MongoDB', value: 'mongodb' },
|
|
39
|
+
],
|
|
40
|
+
default: 'mysql',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'input',
|
|
44
|
+
name: 'dbUri',
|
|
45
|
+
message: (answers) => {
|
|
46
|
+
if (answers.dbType === 'postgres') return 'Database URI (postgresql://postgres:password@host:port/database):';
|
|
47
|
+
return 'Database URI (mysql://user:password@host:port/database):';
|
|
48
|
+
},
|
|
49
|
+
default: (answers) => {
|
|
50
|
+
if (answers.dbType === 'postgres') return 'postgresql://postgres:postgres@localhost:5432/enfyra';
|
|
51
|
+
return 'mysql://root:1234@localhost:3306/enfyra';
|
|
52
|
+
},
|
|
53
|
+
when: (answers) => answers.dbType !== 'mongodb',
|
|
54
|
+
validate: validators.dbUri,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'input',
|
|
58
|
+
name: 'mongoUri',
|
|
59
|
+
message: 'MongoDB URI:',
|
|
60
|
+
default: 'mongodb://enfyra_admin:enfyra_password_123@localhost:27017/enfyra?authSource=admin',
|
|
61
|
+
when: (answers) => answers.dbType === 'mongodb',
|
|
62
|
+
validate: validators.mongoUri,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
type: 'confirm',
|
|
66
|
+
name: 'setupReplica',
|
|
67
|
+
message: 'Setup read replica?',
|
|
68
|
+
default: false,
|
|
69
|
+
when: (answers) => answers.dbType !== 'mongodb',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: 'input',
|
|
73
|
+
name: 'dbReplicaUri',
|
|
74
|
+
message: (answers) => {
|
|
75
|
+
if (answers.dbType === 'postgres') return 'Replica URI (postgresql://postgres:password@host:port/database):';
|
|
76
|
+
return 'Replica URI (mysql://user:password@host:port/database):';
|
|
77
|
+
},
|
|
78
|
+
default: (answers) => {
|
|
79
|
+
if (answers.dbType === 'postgres') return 'postgresql://postgres:postgres@localhost:5433/enfyra';
|
|
80
|
+
return 'mysql://root:1234@localhost:3307/enfyra';
|
|
81
|
+
},
|
|
82
|
+
when: (answers) => answers.setupReplica === true && answers.dbType !== 'mongodb',
|
|
83
|
+
validate: validators.dbUri,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: 'input',
|
|
87
|
+
name: 'redisUri',
|
|
88
|
+
message: 'Redis URI:',
|
|
89
|
+
default: 'redis://localhost:6379',
|
|
90
|
+
validate: validators.redisUri,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!options.connectionOnly) {
|
|
95
|
+
prompts.push(
|
|
96
|
+
{
|
|
97
|
+
type: 'input',
|
|
98
|
+
name: 'adminEmail',
|
|
99
|
+
message: 'Admin email:',
|
|
100
|
+
validate: validators.email,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'password',
|
|
104
|
+
name: 'adminPassword',
|
|
105
|
+
message: 'Admin password:',
|
|
106
|
+
mask: '*',
|
|
107
|
+
validate: validators.adminPassword,
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return prompts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getConfirmationPrompt() {
|
|
116
|
+
return {
|
|
117
|
+
type: 'confirm',
|
|
118
|
+
name: 'confirm',
|
|
119
|
+
message: 'Create Enfyra workspace with app/ and server/?',
|
|
120
|
+
default: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getPackageManagerIcon(name) {
|
|
125
|
+
const icons = {
|
|
126
|
+
npm: '📦',
|
|
127
|
+
yarn: '🧶',
|
|
128
|
+
pnpm: '⚡',
|
|
129
|
+
};
|
|
130
|
+
return icons[name] || '📦';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
getPrompts,
|
|
135
|
+
getConfirmationPrompt,
|
|
136
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
function validateProjectName(input) {
|
|
6
|
+
if (!input || !input.trim()) return 'Project name is required';
|
|
7
|
+
if (!/^[a-zA-Z0-9-_]+$/.test(input)) return 'Only letters, numbers, - and _ allowed';
|
|
8
|
+
if (fs.existsSync(path.join(process.cwd(), input))) return `Directory ${input} already exists`;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function checkNodeVersion() {
|
|
13
|
+
const nodeVersion = process.versions.node;
|
|
14
|
+
const major = parseInt(nodeVersion.split('.')[0], 10);
|
|
15
|
+
if (major !== 24) {
|
|
16
|
+
console.log(chalk.red(`Node.js version ${nodeVersion} is not supported.`));
|
|
17
|
+
console.log(chalk.yellow('Enfyra Server requires Node.js 24.x.'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dbUri(input) {
|
|
23
|
+
if (!input.trim()) return 'Database URI is required';
|
|
24
|
+
if (!input.startsWith('mysql://') && !input.startsWith('postgresql://') && !input.startsWith('postgres://')) {
|
|
25
|
+
return 'Must start with mysql://, postgresql://, or postgres://';
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(input);
|
|
29
|
+
if (!url.hostname) return 'Invalid URI: hostname is required';
|
|
30
|
+
if (!url.pathname || url.pathname === '/') return 'Invalid URI: database name is required';
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return 'Invalid URI format';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mongoUri(input) {
|
|
38
|
+
if (!input.trim()) return 'MongoDB URI is required';
|
|
39
|
+
if (!input.startsWith('mongodb://') && !input.startsWith('mongodb+srv://')) {
|
|
40
|
+
return 'Must start with mongodb:// or mongodb+srv://';
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(input);
|
|
44
|
+
if (!url.hostname) return 'Invalid URI: hostname is required';
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return 'Invalid URI format';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function redisUri(input) {
|
|
52
|
+
if (!input.trim()) return 'Redis is required for Enfyra';
|
|
53
|
+
if (!input.startsWith('redis://')) return 'Must start with redis://';
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function email(input) {
|
|
58
|
+
if (!input.trim()) return 'Email is required';
|
|
59
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) return 'Invalid email format';
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function adminPassword(input) {
|
|
64
|
+
if (!input || input.length < 4) return 'Password must be at least 4 characters';
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
validateProjectName,
|
|
70
|
+
checkNodeVersion,
|
|
71
|
+
dbUri,
|
|
72
|
+
mongoUri,
|
|
73
|
+
redisUri,
|
|
74
|
+
email,
|
|
75
|
+
adminPassword,
|
|
76
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const fs = require('fs-extra');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const inquirer = require('inquirer');
|
|
8
|
+
const packageJson = require('./package.json');
|
|
9
|
+
const { validateProjectName, checkNodeVersion } = require('./components/validators');
|
|
10
|
+
const { getPrompts, getConfirmationPrompt } = require('./components/prompts');
|
|
11
|
+
const { ensureServerPortAvailable } = require('./components/port-guard');
|
|
12
|
+
const { validateAllConnections } = require('./components/connection-validator');
|
|
13
|
+
const { createProject } = require('./components/project-setup');
|
|
14
|
+
const { detectPackageManagers, getWorkspaceRunCommand, getPackageRunCommand } = require('./components/package-managers');
|
|
15
|
+
|
|
16
|
+
const SERVER_PORT = 1105;
|
|
17
|
+
|
|
18
|
+
const banner = `
|
|
19
|
+
${chalk.cyan.bold('╔═══════════════════════════════════════╗')}
|
|
20
|
+
${chalk.cyan.bold('║')} ${chalk.white.bold('Create Enfyra')} ${chalk.cyan.bold('║')}
|
|
21
|
+
${chalk.cyan.bold('║')} ${chalk.gray('App + Server in one workspace')} ${chalk.cyan.bold('║')}
|
|
22
|
+
${chalk.cyan.bold('╚═══════════════════════════════════════╝')}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const program = new Command();
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.name('create')
|
|
30
|
+
.description('Create a complete Enfyra app and server workspace')
|
|
31
|
+
.version(packageJson.version)
|
|
32
|
+
.argument('[project-name]', 'Name of the project to create')
|
|
33
|
+
.option('--skip-connection-check', 'Skip database and Redis connection checks')
|
|
34
|
+
.parse();
|
|
35
|
+
|
|
36
|
+
console.log(banner);
|
|
37
|
+
checkNodeVersion();
|
|
38
|
+
const packageManagers = detectPackageManagers();
|
|
39
|
+
if (packageManagers.length === 0) {
|
|
40
|
+
console.log(chalk.red('No compatible package manager found.'));
|
|
41
|
+
console.log(chalk.yellow('Please install npm, yarn, or pnpm.'));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
console.log(chalk.gray('Detected package managers:'));
|
|
45
|
+
for (const manager of packageManagers) {
|
|
46
|
+
console.log(chalk.gray(` • ${manager.name} v${manager.version}`));
|
|
47
|
+
}
|
|
48
|
+
console.log('');
|
|
49
|
+
|
|
50
|
+
let projectName = program.args[0];
|
|
51
|
+
if (projectName) {
|
|
52
|
+
const validation = validateProjectName(projectName);
|
|
53
|
+
if (validation !== true) {
|
|
54
|
+
console.log(chalk.red(`Invalid project name: ${validation}`));
|
|
55
|
+
projectName = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const answers = await inquirer.prompt(getPrompts(projectName, { packageManagers }));
|
|
60
|
+
const selectedPackageManager = packageManagers.find((pm) => pm.value === answers.packageManager);
|
|
61
|
+
const config = {
|
|
62
|
+
...answers,
|
|
63
|
+
projectName: projectName || answers.projectName,
|
|
64
|
+
packageManager: answers.packageManager,
|
|
65
|
+
packageManagerVersion: selectedPackageManager?.version,
|
|
66
|
+
appPort: 3000,
|
|
67
|
+
serverPort: SERVER_PORT,
|
|
68
|
+
apiUrl: `http://localhost:${SERVER_PORT}`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const projectPath = path.resolve(config.projectName);
|
|
72
|
+
if (fs.existsSync(projectPath)) {
|
|
73
|
+
console.log(chalk.red(`Directory ${chalk.bold(config.projectName)} already exists.`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await ensureServerPortAvailable(SERVER_PORT);
|
|
78
|
+
|
|
79
|
+
if (!program.opts().skipConnectionCheck) {
|
|
80
|
+
let connectionsValid = false;
|
|
81
|
+
while (!connectionsValid) {
|
|
82
|
+
connectionsValid = await validateAllConnections(config);
|
|
83
|
+
|
|
84
|
+
if (!connectionsValid) {
|
|
85
|
+
const { retry } = await inquirer.prompt([
|
|
86
|
+
{
|
|
87
|
+
type: 'list',
|
|
88
|
+
name: 'retry',
|
|
89
|
+
message: 'What would you like to do?',
|
|
90
|
+
choices: [
|
|
91
|
+
{ name: 'Re-enter connection details', value: 'retry' },
|
|
92
|
+
{ name: 'Continue anyway and create the project', value: 'continue' },
|
|
93
|
+
{ name: 'Exit setup', value: 'exit' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
if (retry === 'continue') {
|
|
99
|
+
console.log(chalk.yellow('\nContinuing without verified database/Redis connections.'));
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (retry === 'exit') {
|
|
104
|
+
console.log(chalk.yellow('\nSetup cancelled. Fix your connections and try again later.'));
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const connectionConfig = await inquirer.prompt(
|
|
109
|
+
getPrompts(config.projectName, { connectionOnly: true }),
|
|
110
|
+
);
|
|
111
|
+
Object.assign(config, connectionConfig);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { confirm } = await inquirer.prompt([getConfirmationPrompt()]);
|
|
117
|
+
if (!confirm) {
|
|
118
|
+
console.log(chalk.yellow('\nInstallation cancelled.'));
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await createProject(config, projectPath);
|
|
124
|
+
|
|
125
|
+
console.log(chalk.green.bold('\nProject created successfully.'));
|
|
126
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
127
|
+
console.log(chalk.gray(` cd ${config.projectName}`));
|
|
128
|
+
console.log(chalk.gray(` ${getPackageRunCommand(config.packageManager, 'dev')}`));
|
|
129
|
+
console.log(chalk.gray('\nIndividual workspaces:'));
|
|
130
|
+
console.log(chalk.gray(` ${getWorkspaceRunCommand(config.packageManager, 'server', 'dev')}`));
|
|
131
|
+
console.log(chalk.gray(` ${getWorkspaceRunCommand(config.packageManager, 'app', 'dev')}`));
|
|
132
|
+
console.log(chalk.yellow('\nKeep server/.env SECRET_KEY stable and backed up.'));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(chalk.red.bold('\nSetup failed:'));
|
|
135
|
+
console.error(chalk.red(error.message));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
process.on('unhandledRejection', (error) => {
|
|
141
|
+
console.error(chalk.red(`\nUnexpected error: ${error.message}`));
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@enfyra/create",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create a complete Enfyra app with frontend and server workspaces",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-enfyra": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"preferGlobal": true,
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24.0.0 <25"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --check index.js && node --check components/*.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"enfyra",
|
|
18
|
+
"create",
|
|
19
|
+
"nuxt",
|
|
20
|
+
"server",
|
|
21
|
+
"baas",
|
|
22
|
+
"cli",
|
|
23
|
+
"scaffold"
|
|
24
|
+
],
|
|
25
|
+
"author": "Enfyra Team",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"chalk": "^4.1.2",
|
|
29
|
+
"commander": "^11.1.0",
|
|
30
|
+
"fs-extra": "^11.1.1",
|
|
31
|
+
"giget": "^2.0.0",
|
|
32
|
+
"inquirer": "^8.2.5",
|
|
33
|
+
"ioredis": "^5.3.2",
|
|
34
|
+
"mongodb": "^6.3.0",
|
|
35
|
+
"mysql2": "^3.6.0",
|
|
36
|
+
"ora": "^5.4.1",
|
|
37
|
+
"pg": "^8.11.0",
|
|
38
|
+
"wait-on": "^8.0.3"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/enfyra/create.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/enfyra/create/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/enfyra/create#readme",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"index.js",
|
|
53
|
+
"components",
|
|
54
|
+
"README.md",
|
|
55
|
+
"LICENSE"
|
|
56
|
+
]
|
|
57
|
+
}
|