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.
@@ -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 &mdash; 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
+ }