scaffly-cli 1.0.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/README.md +150 -0
- package/bin/scaffly.js +162 -0
- package/package.json +34 -0
- package/src/extras/docker.js +185 -0
- package/src/extras/eslint.js +145 -0
- package/src/extras/github-actions.js +71 -0
- package/src/extras/husky.js +50 -0
- package/src/extras/tailwind.js +97 -0
- package/src/generators/express.js +165 -0
- package/src/generators/fastify.js +160 -0
- package/src/generators/nextjs.js +153 -0
- package/src/generators/vite.js +174 -0
- package/src/prompts/index.js +131 -0
- package/src/utils/index.js +83 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffly — React + Vite generator
|
|
3
|
+
* Creates a React SPA using Vite as the build tool and dev server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { writeFile, writeJson, runCommand } from '../utils/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generates a complete React + Vite project at the given path.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} projectName - Used as the package name and HTML title
|
|
13
|
+
* @param {string} projectPath - Absolute path where the project will be created
|
|
14
|
+
*/
|
|
15
|
+
export async function generate(projectName, projectPath) {
|
|
16
|
+
// ── package.json ───────────────────────────────────────────────────────────
|
|
17
|
+
await writeJson(path.join(projectPath, 'package.json'), {
|
|
18
|
+
name: projectName,
|
|
19
|
+
version: '0.0.0',
|
|
20
|
+
type: 'module',
|
|
21
|
+
private: true,
|
|
22
|
+
scripts: {
|
|
23
|
+
dev: 'vite',
|
|
24
|
+
build: 'vite build',
|
|
25
|
+
preview: 'vite preview',
|
|
26
|
+
},
|
|
27
|
+
dependencies: {
|
|
28
|
+
react: '^18.3.1',
|
|
29
|
+
'react-dom': '^18.3.1',
|
|
30
|
+
},
|
|
31
|
+
devDependencies: {
|
|
32
|
+
'@vitejs/plugin-react': '^4.3.1',
|
|
33
|
+
vite: '^5.3.4',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── vite.config.js ────────────────────────────────────────────────────────
|
|
38
|
+
await writeFile(
|
|
39
|
+
path.join(projectPath, 'vite.config.js'),
|
|
40
|
+
`import { defineConfig } from 'vite';
|
|
41
|
+
import react from '@vitejs/plugin-react';
|
|
42
|
+
|
|
43
|
+
// https://vitejs.dev/config/
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
plugins: [react()],
|
|
46
|
+
});
|
|
47
|
+
`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// ── index.html ────────────────────────────────────────────────────────────
|
|
51
|
+
await writeFile(
|
|
52
|
+
path.join(projectPath, 'index.html'),
|
|
53
|
+
`<!doctype html>
|
|
54
|
+
<html lang="en">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="UTF-8" />
|
|
57
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
58
|
+
<title>${projectName}</title>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<div id="root"></div>
|
|
62
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|
|
65
|
+
`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// ── src/main.jsx ──────────────────────────────────────────────────────────
|
|
69
|
+
await writeFile(
|
|
70
|
+
path.join(projectPath, 'src/main.jsx'),
|
|
71
|
+
`import { StrictMode } from 'react';
|
|
72
|
+
import { createRoot } from 'react-dom/client';
|
|
73
|
+
import './index.css';
|
|
74
|
+
import App from './App.jsx';
|
|
75
|
+
|
|
76
|
+
createRoot(document.getElementById('root')).render(
|
|
77
|
+
<StrictMode>
|
|
78
|
+
<App />
|
|
79
|
+
</StrictMode>
|
|
80
|
+
);
|
|
81
|
+
`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// ── src/App.jsx ───────────────────────────────────────────────────────────
|
|
85
|
+
await writeFile(
|
|
86
|
+
path.join(projectPath, 'src/App.jsx'),
|
|
87
|
+
`import './App.css';
|
|
88
|
+
|
|
89
|
+
function App() {
|
|
90
|
+
return (
|
|
91
|
+
<div className="app">
|
|
92
|
+
<h1>Welcome to ${projectName}</h1>
|
|
93
|
+
<p>Built with React + Vite — scaffolded by Scaffly</p>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default App;
|
|
99
|
+
`
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// ── src/App.css ───────────────────────────────────────────────────────────
|
|
103
|
+
await writeFile(
|
|
104
|
+
path.join(projectPath, 'src/App.css'),
|
|
105
|
+
`.app {
|
|
106
|
+
max-width: 960px;
|
|
107
|
+
margin: 4rem auto;
|
|
108
|
+
padding: 0 1.5rem;
|
|
109
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
h1 {
|
|
113
|
+
font-size: 2.5rem;
|
|
114
|
+
margin-bottom: 1rem;
|
|
115
|
+
color: #111;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
p {
|
|
119
|
+
color: #555;
|
|
120
|
+
font-size: 1.125rem;
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// ── src/index.css ─────────────────────────────────────────────────────────
|
|
126
|
+
await writeFile(
|
|
127
|
+
path.join(projectPath, 'src/index.css'),
|
|
128
|
+
`*,
|
|
129
|
+
*::before,
|
|
130
|
+
*::after {
|
|
131
|
+
box-sizing: border-box;
|
|
132
|
+
margin: 0;
|
|
133
|
+
padding: 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
body {
|
|
137
|
+
background: #ffffff;
|
|
138
|
+
color: #111111;
|
|
139
|
+
line-height: 1.6;
|
|
140
|
+
}
|
|
141
|
+
`
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// ── .gitignore ────────────────────────────────────────────────────────────
|
|
145
|
+
await writeFile(
|
|
146
|
+
path.join(projectPath, '.gitignore'),
|
|
147
|
+
`# Dependencies
|
|
148
|
+
node_modules/
|
|
149
|
+
|
|
150
|
+
# Build output
|
|
151
|
+
dist/
|
|
152
|
+
build/
|
|
153
|
+
|
|
154
|
+
# Environment
|
|
155
|
+
.env
|
|
156
|
+
.env.local
|
|
157
|
+
.env.*.local
|
|
158
|
+
|
|
159
|
+
# Debug
|
|
160
|
+
npm-debug.log*
|
|
161
|
+
yarn-debug.log*
|
|
162
|
+
|
|
163
|
+
# Editor
|
|
164
|
+
.vscode/
|
|
165
|
+
.idea/
|
|
166
|
+
|
|
167
|
+
# Misc
|
|
168
|
+
.DS_Store
|
|
169
|
+
`
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// ── Install dependencies ──────────────────────────────────────────────────
|
|
173
|
+
await runCommand('npm', ['install'], projectPath);
|
|
174
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffly — Interactive prompts
|
|
3
|
+
* Uses @clack/prompts to collect all project configuration from the user.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as p from '@clack/prompts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cancels the CLI if the user pressed Ctrl+C on a given prompt value.
|
|
10
|
+
*
|
|
11
|
+
* @param {unknown} value - The raw value returned by a @clack/prompts call
|
|
12
|
+
* @returns {unknown} The value unchanged, if not cancelled
|
|
13
|
+
*/
|
|
14
|
+
function onCancel(value) {
|
|
15
|
+
if (p.isCancel(value)) {
|
|
16
|
+
p.cancel('Scaffolding cancelled. See you next time!');
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates a project name: non-empty, starts with letter/number,
|
|
24
|
+
* and contains only letters, numbers, hyphens, and underscores.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} value
|
|
27
|
+
* @returns {string|undefined} Error message, or undefined if valid
|
|
28
|
+
*/
|
|
29
|
+
function validateProjectName(value) {
|
|
30
|
+
if (!value || value.trim().length === 0) {
|
|
31
|
+
return 'Project name cannot be empty.';
|
|
32
|
+
}
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(value.trim())) {
|
|
34
|
+
return 'Must start with a letter/number and contain only letters, numbers, hyphens, and underscores.';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs the full interactive prompt sequence and returns the user's choices.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<{ projectName: string, stack: string, extras: string[] }>}
|
|
42
|
+
*/
|
|
43
|
+
export async function collectAnswers() {
|
|
44
|
+
// ── 1. Project name ──────────────────────────────────────────────────────
|
|
45
|
+
const projectName = onCancel(
|
|
46
|
+
await p.text({
|
|
47
|
+
message: 'What is your project name?',
|
|
48
|
+
placeholder: 'my-awesome-app',
|
|
49
|
+
validate: validateProjectName,
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// ── 2. Stack selection ────────────────────────────────────────────────────
|
|
54
|
+
const stack = onCancel(
|
|
55
|
+
await p.select({
|
|
56
|
+
message: 'Which stack would you like to use?',
|
|
57
|
+
options: [
|
|
58
|
+
{
|
|
59
|
+
value: 'nextjs',
|
|
60
|
+
label: 'Next.js',
|
|
61
|
+
hint: 'Full-stack React framework by Vercel',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
value: 'vite',
|
|
65
|
+
label: 'React + Vite',
|
|
66
|
+
hint: 'Blazing fast frontend build tool',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
value: 'express',
|
|
70
|
+
label: 'Node.js + Express',
|
|
71
|
+
hint: 'Minimal and flexible web framework',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
value: 'fastify',
|
|
75
|
+
label: 'Fastify',
|
|
76
|
+
hint: 'High-performance Node.js web framework',
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// ── 3. Extras (multi-select) ──────────────────────────────────────────────
|
|
83
|
+
// Tailwind CSS is only available for frontend stacks
|
|
84
|
+
const isFrontend = stack === 'nextjs' || stack === 'vite';
|
|
85
|
+
|
|
86
|
+
const extrasOptions = [
|
|
87
|
+
{
|
|
88
|
+
value: 'eslint',
|
|
89
|
+
label: 'ESLint + Prettier',
|
|
90
|
+
hint: 'Code linting and automatic formatting',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
value: 'husky',
|
|
94
|
+
label: 'Husky + lint-staged',
|
|
95
|
+
hint: 'Pre-commit hooks to enforce code quality',
|
|
96
|
+
},
|
|
97
|
+
...(isFrontend
|
|
98
|
+
? [
|
|
99
|
+
{
|
|
100
|
+
value: 'tailwind',
|
|
101
|
+
label: 'Tailwind CSS',
|
|
102
|
+
hint: 'Utility-first CSS framework',
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
: []),
|
|
106
|
+
{
|
|
107
|
+
value: 'docker',
|
|
108
|
+
label: 'Docker',
|
|
109
|
+
hint: 'Dockerfile + docker-compose.yml',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
value: 'github-actions',
|
|
113
|
+
label: 'GitHub Actions CI/CD',
|
|
114
|
+
hint: 'Automated lint and test workflow',
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const extras = onCancel(
|
|
119
|
+
await p.multiselect({
|
|
120
|
+
message: 'Which extras would you like to include?',
|
|
121
|
+
options: extrasOptions,
|
|
122
|
+
required: false,
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
projectName: projectName.trim(),
|
|
128
|
+
stack,
|
|
129
|
+
extras: Array.isArray(extras) ? extras : [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffly — Shared utilities
|
|
3
|
+
* Common file system helpers and command runner used across generators and extras.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fse from 'fs-extra';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { execa } from 'execa';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Writes content to a file, creating parent directories as needed.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} filePath - Absolute path to the target file
|
|
14
|
+
* @param {string} content - UTF-8 string content to write
|
|
15
|
+
*/
|
|
16
|
+
export async function writeFile(filePath, content) {
|
|
17
|
+
await fse.ensureDir(path.dirname(filePath));
|
|
18
|
+
await fse.writeFile(filePath, content, 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Writes a JavaScript object to a JSON file with 2-space indentation.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} filePath - Absolute path to the target JSON file
|
|
25
|
+
* @param {object} data - Object to serialize
|
|
26
|
+
*/
|
|
27
|
+
export async function writeJson(filePath, data) {
|
|
28
|
+
await fse.ensureDir(path.dirname(filePath));
|
|
29
|
+
await fse.writeJson(filePath, data, { spaces: 2 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep-merges `updates` into the project's package.json.
|
|
34
|
+
* Existing keys are preserved; conflicting nested objects are merged recursively.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} projectPath - Absolute path to the project root
|
|
37
|
+
* @param {object} updates - Partial package.json fields to merge in
|
|
38
|
+
*/
|
|
39
|
+
export async function updatePackageJson(projectPath, updates) {
|
|
40
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
41
|
+
const existing = await fse.readJson(pkgPath);
|
|
42
|
+
const merged = deepMerge(existing, updates);
|
|
43
|
+
await fse.writeJson(pkgPath, merged, { spaces: 2 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Recursively merges source into target, combining plain objects deeply
|
|
48
|
+
* and overwriting all other values.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} target
|
|
51
|
+
* @param {object} source
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
function deepMerge(target, source) {
|
|
55
|
+
const result = { ...target };
|
|
56
|
+
for (const key of Object.keys(source)) {
|
|
57
|
+
if (isPlainObject(source[key]) && isPlainObject(target[key])) {
|
|
58
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
59
|
+
} else {
|
|
60
|
+
result[key] = source[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isPlainObject(value) {
|
|
67
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Runs a shell command inside the given directory.
|
|
72
|
+
* Stdout/stderr are piped (suppressed); throws on non-zero exit code.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} command - Executable name (e.g. 'npm', 'npx')
|
|
75
|
+
* @param {string[]} args - Arguments to pass
|
|
76
|
+
* @param {string} cwd - Working directory
|
|
77
|
+
*/
|
|
78
|
+
export async function runCommand(command, args, cwd) {
|
|
79
|
+
await execa(command, args, {
|
|
80
|
+
cwd,
|
|
81
|
+
stdio: 'pipe',
|
|
82
|
+
});
|
|
83
|
+
}
|