create-stackit 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 +194 -0
- package/bin/index.js +64 -0
- package/package.json +43 -0
- package/src/choices.js +63 -0
- package/src/generator.js +133 -0
- package/src/injectors/addons.js +311 -0
- package/src/injectors/structure.js +51 -0
- package/src/pm.js +94 -0
- package/src/prompts.js +179 -0
- package/src/utils.js +18 -0
- package/templates/next-js-redux-mui/README.md +15 -0
- package/templates/next-js-redux-mui/app/layout.jsx +12 -0
- package/templates/next-js-redux-mui/app/page.jsx +15 -0
- package/templates/next-js-redux-mui/app/providers.jsx +16 -0
- package/templates/next-js-redux-mui/package.json +22 -0
- package/templates/next-js-redux-mui/src/store/counterSlice.js +13 -0
- package/templates/next-js-redux-mui/src/store/store.js +8 -0
- package/templates/next-js-redux-mui/src/theme.js +9 -0
- package/templates/next-js-redux-shadcn/README.md +15 -0
- package/templates/next-js-redux-shadcn/app/globals.css +3 -0
- package/templates/next-js-redux-shadcn/app/layout.jsx +13 -0
- package/templates/next-js-redux-shadcn/app/page.jsx +15 -0
- package/templates/next-js-redux-shadcn/app/providers.jsx +11 -0
- package/templates/next-js-redux-shadcn/package.json +23 -0
- package/templates/next-js-redux-shadcn/src/store/counterSlice.js +13 -0
- package/templates/next-js-redux-shadcn/src/store/store.js +8 -0
- package/templates/next-js-zustand-mui/README.md +15 -0
- package/templates/next-js-zustand-mui/app/layout.jsx +16 -0
- package/templates/next-js-zustand-mui/app/page.jsx +18 -0
- package/templates/next-js-zustand-mui/package.json +21 -0
- package/templates/next-js-zustand-mui/src/theme.js +9 -0
- package/templates/next-js-zustand-shadcn/README.md +15 -0
- package/templates/next-js-zustand-shadcn/app/globals.css +3 -0
- package/templates/next-js-zustand-shadcn/app/layout.jsx +10 -0
- package/templates/next-js-zustand-shadcn/app/page.jsx +18 -0
- package/templates/next-js-zustand-shadcn/package.json +22 -0
- package/templates/next-ts-redux-mui/README.md +15 -0
- package/templates/next-ts-redux-mui/app/layout.tsx +14 -0
- package/templates/next-ts-redux-mui/app/page.tsx +15 -0
- package/templates/next-ts-redux-mui/app/providers.tsx +16 -0
- package/templates/next-ts-redux-mui/package.json +27 -0
- package/templates/next-ts-redux-mui/src/store/counterSlice.ts +16 -0
- package/templates/next-ts-redux-mui/src/store/store.ts +11 -0
- package/templates/next-ts-redux-mui/src/theme.ts +9 -0
- package/templates/next-ts-redux-shadcn/README.md +15 -0
- package/templates/next-ts-redux-shadcn/app/globals.css +3 -0
- package/templates/next-ts-redux-shadcn/app/layout.tsx +15 -0
- package/templates/next-ts-redux-shadcn/app/page.tsx +15 -0
- package/templates/next-ts-redux-shadcn/app/providers.tsx +11 -0
- package/templates/next-ts-redux-shadcn/package.json +27 -0
- package/templates/next-ts-redux-shadcn/src/store/counterSlice.ts +16 -0
- package/templates/next-ts-redux-shadcn/src/store/store.ts +11 -0
- package/templates/next-ts-zustand-mui/README.md +15 -0
- package/templates/next-ts-zustand-mui/app/layout.tsx +18 -0
- package/templates/next-ts-zustand-mui/app/page.tsx +18 -0
- package/templates/next-ts-zustand-mui/package.json +26 -0
- package/templates/next-ts-zustand-mui/src/theme.ts +9 -0
- package/templates/next-ts-zustand-shadcn/README.md +15 -0
- package/templates/next-ts-zustand-shadcn/app/globals.css +3 -0
- package/templates/next-ts-zustand-shadcn/app/layout.tsx +12 -0
- package/templates/next-ts-zustand-shadcn/app/page.tsx +18 -0
- package/templates/next-ts-zustand-shadcn/package.json +26 -0
- package/templates/vite-js-redux-mui/README.md +15 -0
- package/templates/vite-js-redux-mui/package.json +24 -0
- package/templates/vite-js-redux-mui/src/App.jsx +14 -0
- package/templates/vite-js-redux-mui/src/main.jsx +18 -0
- package/templates/vite-js-redux-mui/src/store/counterSlice.js +13 -0
- package/templates/vite-js-redux-mui/src/store/store.js +8 -0
- package/templates/vite-js-redux-mui/src/theme.js +9 -0
- package/templates/vite-js-redux-shadcn/README.md +15 -0
- package/templates/vite-js-redux-shadcn/package.json +24 -0
- package/templates/vite-js-redux-shadcn/src/App.jsx +14 -0
- package/templates/vite-js-redux-shadcn/src/index.css +3 -0
- package/templates/vite-js-redux-shadcn/src/main.jsx +14 -0
- package/templates/vite-js-redux-shadcn/src/store/counterSlice.js +13 -0
- package/templates/vite-js-redux-shadcn/src/store/store.js +8 -0
- package/templates/vite-js-zustand-mui/README.md +15 -0
- package/templates/vite-js-zustand-mui/package.json +23 -0
- package/templates/vite-js-zustand-mui/src/App.jsx +17 -0
- package/templates/vite-js-zustand-mui/src/main.jsx +14 -0
- package/templates/vite-js-zustand-mui/src/theme.js +9 -0
- package/templates/vite-js-zustand-shadcn/README.md +15 -0
- package/templates/vite-js-zustand-shadcn/package.json +23 -0
- package/templates/vite-js-zustand-shadcn/src/App.jsx +17 -0
- package/templates/vite-js-zustand-shadcn/src/index.css +3 -0
- package/templates/vite-js-zustand-shadcn/src/main.jsx +10 -0
- package/templates/vite-ts-redux-mui/README.md +15 -0
- package/templates/vite-ts-redux-mui/package.json +27 -0
- package/templates/vite-ts-redux-mui/src/App.tsx +14 -0
- package/templates/vite-ts-redux-mui/src/main.tsx +18 -0
- package/templates/vite-ts-redux-mui/src/store/counterSlice.ts +16 -0
- package/templates/vite-ts-redux-mui/src/store/store.ts +11 -0
- package/templates/vite-ts-redux-mui/src/theme.ts +9 -0
- package/templates/vite-ts-redux-shadcn/README.md +15 -0
- package/templates/vite-ts-redux-shadcn/package.json +27 -0
- package/templates/vite-ts-redux-shadcn/src/App.tsx +14 -0
- package/templates/vite-ts-redux-shadcn/src/index.css +3 -0
- package/templates/vite-ts-redux-shadcn/src/main.tsx +14 -0
- package/templates/vite-ts-redux-shadcn/src/store/counterSlice.ts +16 -0
- package/templates/vite-ts-redux-shadcn/src/store/store.ts +11 -0
- package/templates/vite-ts-zustand-mui/README.md +15 -0
- package/templates/vite-ts-zustand-mui/package.json +26 -0
- package/templates/vite-ts-zustand-mui/src/App.tsx +17 -0
- package/templates/vite-ts-zustand-mui/src/main.tsx +14 -0
- package/templates/vite-ts-zustand-mui/src/theme.ts +9 -0
- package/templates/vite-ts-zustand-shadcn/README.md +15 -0
- package/templates/vite-ts-zustand-shadcn/package.json +23 -0
- package/templates/vite-ts-zustand-shadcn/src/main.tsx +2 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { logger } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Each addon is a self-contained injector. Register it in ADDON_REGISTRY.
|
|
7
|
+
* An injector receives { targetDir, framework, state, ui, pkg } and mutates pkg
|
|
8
|
+
* (returns updated pkg) and writes any files it needs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ADDON_REGISTRY = {
|
|
12
|
+
'tanstack-query': injectTanstackQuery,
|
|
13
|
+
axios: injectAxios,
|
|
14
|
+
forms: injectForms,
|
|
15
|
+
toast: injectToast,
|
|
16
|
+
testing: injectTesting,
|
|
17
|
+
lint: injectLint,
|
|
18
|
+
hooks: injectHooks,
|
|
19
|
+
auth: injectAuth,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function applyAddons({ targetDir, framework, state, ui, addons }) {
|
|
23
|
+
if (!addons || addons.length === 0) return;
|
|
24
|
+
|
|
25
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
26
|
+
let pkg = await fs.readJson(pkgPath);
|
|
27
|
+
|
|
28
|
+
for (const addon of addons) {
|
|
29
|
+
const fn = ADDON_REGISTRY[addon];
|
|
30
|
+
if (!fn) {
|
|
31
|
+
logger.warn(` ↳ Unknown addon: ${addon}, skipping`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
pkg = (await fn({ targetDir, framework, state, ui, pkg })) || pkg;
|
|
35
|
+
logger.dim(` ✓ injected: ${addon}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ───────────────────────────────────────────────
|
|
42
|
+
// Injectors
|
|
43
|
+
// ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async function injectTanstackQuery({ targetDir, pkg }) {
|
|
46
|
+
pkg.dependencies = {
|
|
47
|
+
...pkg.dependencies,
|
|
48
|
+
'@tanstack/react-query': '^5.59.0',
|
|
49
|
+
'@tanstack/react-query-devtools': '^5.59.0',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const file = path.join(targetDir, 'src/lib/react-query.ts');
|
|
53
|
+
await fs.outputFile(
|
|
54
|
+
file,
|
|
55
|
+
`import { QueryClient } from '@tanstack/react-query';
|
|
56
|
+
|
|
57
|
+
export const queryClient = new QueryClient({
|
|
58
|
+
defaultOptions: {
|
|
59
|
+
queries: {
|
|
60
|
+
retry: 1,
|
|
61
|
+
refetchOnWindowFocus: false,
|
|
62
|
+
staleTime: 60_000,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
`
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await fs.outputFile(
|
|
70
|
+
path.join(targetDir, 'src/hooks/api/useApiQuery.ts'),
|
|
71
|
+
`import { useQuery, type UseQueryOptions, type QueryKey } from '@tanstack/react-query';
|
|
72
|
+
|
|
73
|
+
export function useApiQuery<TData = unknown, TError = Error>(
|
|
74
|
+
key: QueryKey,
|
|
75
|
+
fn: () => Promise<TData>,
|
|
76
|
+
options?: Omit<UseQueryOptions<TData, TError>, 'queryKey' | 'queryFn'>
|
|
77
|
+
) {
|
|
78
|
+
return useQuery<TData, TError>({
|
|
79
|
+
queryKey: key,
|
|
80
|
+
queryFn: fn,
|
|
81
|
+
...options,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
`
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await fs.outputFile(
|
|
88
|
+
path.join(targetDir, 'src/hooks/api/useApiMutation.ts'),
|
|
89
|
+
`import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
|
|
90
|
+
|
|
91
|
+
export function useApiMutation<TData = unknown, TVariables = void, TError = Error>(
|
|
92
|
+
fn: (vars: TVariables) => Promise<TData>,
|
|
93
|
+
options?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>
|
|
94
|
+
) {
|
|
95
|
+
return useMutation<TData, TError, TVariables>({
|
|
96
|
+
mutationFn: fn,
|
|
97
|
+
...options,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return pkg;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function injectAxios({ targetDir, pkg }) {
|
|
107
|
+
pkg.dependencies = { ...pkg.dependencies, axios: '^1.7.7' };
|
|
108
|
+
|
|
109
|
+
await fs.outputFile(
|
|
110
|
+
path.join(targetDir, 'src/lib/api-client.ts'),
|
|
111
|
+
`import axios, { AxiosError } from 'axios';
|
|
112
|
+
|
|
113
|
+
const baseURL =
|
|
114
|
+
(typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_API_URL) ||
|
|
115
|
+
process.env.NEXT_PUBLIC_API_URL ||
|
|
116
|
+
'/api';
|
|
117
|
+
|
|
118
|
+
export const api = axios.create({
|
|
119
|
+
baseURL,
|
|
120
|
+
withCredentials: true,
|
|
121
|
+
timeout: 30_000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
api.interceptors.request.use((config) => {
|
|
125
|
+
const token =
|
|
126
|
+
typeof window !== 'undefined' ? window.localStorage.getItem('token') : null;
|
|
127
|
+
if (token) config.headers.Authorization = \`Bearer \${token}\`;
|
|
128
|
+
return config;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
api.interceptors.response.use(
|
|
132
|
+
(res) => res,
|
|
133
|
+
(error: AxiosError<{ message?: string }>) => {
|
|
134
|
+
const message =
|
|
135
|
+
error.response?.data?.message ||
|
|
136
|
+
error.message ||
|
|
137
|
+
'Something went wrong';
|
|
138
|
+
|
|
139
|
+
if (typeof window !== 'undefined') {
|
|
140
|
+
window.dispatchEvent(new CustomEvent('api-error', { detail: message }));
|
|
141
|
+
}
|
|
142
|
+
return Promise.reject(error);
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
`
|
|
146
|
+
);
|
|
147
|
+
return pkg;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function injectForms({ pkg }) {
|
|
151
|
+
pkg.dependencies = {
|
|
152
|
+
...pkg.dependencies,
|
|
153
|
+
'react-hook-form': '^7.53.0',
|
|
154
|
+
zod: '^3.23.8',
|
|
155
|
+
'@hookform/resolvers': '^3.9.0',
|
|
156
|
+
};
|
|
157
|
+
return pkg;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function injectToast({ targetDir, pkg }) {
|
|
161
|
+
pkg.dependencies = { ...pkg.dependencies, 'react-hot-toast': '^2.4.1' };
|
|
162
|
+
|
|
163
|
+
await fs.outputFile(
|
|
164
|
+
path.join(targetDir, 'src/components/ui/AppToaster.tsx'),
|
|
165
|
+
`import { useEffect } from 'react';
|
|
166
|
+
import { Toaster, toast } from 'react-hot-toast';
|
|
167
|
+
|
|
168
|
+
export function AppToaster() {
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const onApiError = (e: Event) => {
|
|
171
|
+
const detail = (e as CustomEvent<string>).detail;
|
|
172
|
+
toast.error(detail || 'Something went wrong');
|
|
173
|
+
};
|
|
174
|
+
window.addEventListener('api-error', onApiError);
|
|
175
|
+
return () => window.removeEventListener('api-error', onApiError);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
return <Toaster position="top-right" />;
|
|
179
|
+
}
|
|
180
|
+
`
|
|
181
|
+
);
|
|
182
|
+
return pkg;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function injectTesting({ pkg }) {
|
|
186
|
+
pkg.devDependencies = {
|
|
187
|
+
...pkg.devDependencies,
|
|
188
|
+
vitest: '^2.1.1',
|
|
189
|
+
'@testing-library/react': '^16.0.1',
|
|
190
|
+
'@testing-library/jest-dom': '^6.5.0',
|
|
191
|
+
'@testing-library/user-event': '^14.5.2',
|
|
192
|
+
jsdom: '^25.0.1',
|
|
193
|
+
};
|
|
194
|
+
pkg.scripts = {
|
|
195
|
+
...pkg.scripts,
|
|
196
|
+
test: 'vitest',
|
|
197
|
+
'test:ui': 'vitest --ui',
|
|
198
|
+
};
|
|
199
|
+
return pkg;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function injectLint({ pkg }) {
|
|
203
|
+
pkg.devDependencies = {
|
|
204
|
+
...pkg.devDependencies,
|
|
205
|
+
eslint: '^9.12.0',
|
|
206
|
+
prettier: '^3.3.3',
|
|
207
|
+
'eslint-config-prettier': '^9.1.0',
|
|
208
|
+
};
|
|
209
|
+
pkg.scripts = {
|
|
210
|
+
...pkg.scripts,
|
|
211
|
+
lint: 'eslint .',
|
|
212
|
+
format: 'prettier --write .',
|
|
213
|
+
};
|
|
214
|
+
return pkg;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function injectHooks({ targetDir }) {
|
|
218
|
+
// Just write the hook files; no deps needed.
|
|
219
|
+
const hooksDir = path.join(targetDir, 'src/hooks/core');
|
|
220
|
+
await fs.outputFile(
|
|
221
|
+
path.join(hooksDir, 'useDebounce.ts'),
|
|
222
|
+
`import { useEffect, useState } from 'react';
|
|
223
|
+
|
|
224
|
+
export function useDebounce<T>(value: T, delay = 500): T {
|
|
225
|
+
const [debounced, setDebounced] = useState(value);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const t = setTimeout(() => setDebounced(value), delay);
|
|
228
|
+
return () => clearTimeout(t);
|
|
229
|
+
}, [value, delay]);
|
|
230
|
+
return debounced;
|
|
231
|
+
}
|
|
232
|
+
`
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
await fs.outputFile(
|
|
236
|
+
path.join(hooksDir, 'useThrottle.ts'),
|
|
237
|
+
`import { useCallback, useRef } from 'react';
|
|
238
|
+
|
|
239
|
+
export function useThrottle<TArgs extends unknown[]>(
|
|
240
|
+
fn: (...args: TArgs) => void,
|
|
241
|
+
delay = 300
|
|
242
|
+
) {
|
|
243
|
+
const last = useRef(0);
|
|
244
|
+
return useCallback(
|
|
245
|
+
(...args: TArgs) => {
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
if (now - last.current >= delay) {
|
|
248
|
+
last.current = now;
|
|
249
|
+
fn(...args);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
[fn, delay]
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
`
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await fs.outputFile(
|
|
259
|
+
path.join(hooksDir, 'useToggle.ts'),
|
|
260
|
+
`import { useCallback, useState } from 'react';
|
|
261
|
+
|
|
262
|
+
export function useToggle(initial = false) {
|
|
263
|
+
const [value, setValue] = useState(initial);
|
|
264
|
+
const toggle = useCallback(() => setValue((v) => !v), []);
|
|
265
|
+
return [value, toggle, setValue] as const;
|
|
266
|
+
}
|
|
267
|
+
`
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await fs.outputFile(
|
|
271
|
+
path.join(hooksDir, 'usePrevious.ts'),
|
|
272
|
+
`import { useEffect, useRef } from 'react';
|
|
273
|
+
|
|
274
|
+
export function usePrevious<T>(value: T): T | undefined {
|
|
275
|
+
const ref = useRef<T | undefined>(undefined);
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
ref.current = value;
|
|
278
|
+
}, [value]);
|
|
279
|
+
return ref.current;
|
|
280
|
+
}
|
|
281
|
+
`
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await fs.outputFile(
|
|
285
|
+
path.join(targetDir, 'src/hooks/data/normalize.ts'),
|
|
286
|
+
`export function normalizeArray<T extends Record<string, any>>(
|
|
287
|
+
arr: T[],
|
|
288
|
+
key: keyof T = 'id' as keyof T
|
|
289
|
+
): Record<string, T> {
|
|
290
|
+
return arr.reduce<Record<string, T>>((acc, item) => {
|
|
291
|
+
acc[String(item[key])] = item;
|
|
292
|
+
return acc;
|
|
293
|
+
}, {});
|
|
294
|
+
}
|
|
295
|
+
`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function injectAuth({ targetDir, pkg }) {
|
|
300
|
+
// Stub — real implementation depends on chosen state lib.
|
|
301
|
+
// Leaving a placeholder file so the user has a starting point.
|
|
302
|
+
await fs.outputFile(
|
|
303
|
+
path.join(targetDir, 'src/features/auth/README.md'),
|
|
304
|
+
`# Auth feature
|
|
305
|
+
|
|
306
|
+
This is a scaffolded auth module. Wire up your endpoints in \`api/\`,
|
|
307
|
+
state in \`store/\`, and protected routes per your framework.
|
|
308
|
+
`
|
|
309
|
+
);
|
|
310
|
+
return pkg;
|
|
311
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Templates ship with `modular` structure by default.
|
|
6
|
+
* If user picks `atomic`, we restructure components/ into atoms/molecules/organisms.
|
|
7
|
+
* Otherwise we just ensure the modular skeleton exists.
|
|
8
|
+
*/
|
|
9
|
+
export async function applyStructure({ targetDir, framework, structure }) {
|
|
10
|
+
const srcDir = path.join(targetDir, 'src');
|
|
11
|
+
|
|
12
|
+
if (structure === 'atomic') {
|
|
13
|
+
const atomicBase = path.join(srcDir, 'components');
|
|
14
|
+
await fs.ensureDir(path.join(atomicBase, 'atoms'));
|
|
15
|
+
await fs.ensureDir(path.join(atomicBase, 'molecules'));
|
|
16
|
+
await fs.ensureDir(path.join(atomicBase, 'organisms'));
|
|
17
|
+
await fs.ensureDir(path.join(atomicBase, 'templates'));
|
|
18
|
+
await fs.outputFile(
|
|
19
|
+
path.join(atomicBase, 'README.md'),
|
|
20
|
+
`# Atomic structure
|
|
21
|
+
|
|
22
|
+
- \`atoms/\` — smallest building blocks (Button, Input, Label)
|
|
23
|
+
- \`molecules/\` — small groupings (SearchBar = Input + Button)
|
|
24
|
+
- \`organisms/\` — complex sections (Header, ProductList)
|
|
25
|
+
- \`templates/\` — page-level layouts (no real data)
|
|
26
|
+
`
|
|
27
|
+
);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// modular (default)
|
|
32
|
+
await fs.ensureDir(path.join(srcDir, 'features'));
|
|
33
|
+
await fs.ensureDir(path.join(srcDir, 'components/ui'));
|
|
34
|
+
await fs.ensureDir(path.join(srcDir, 'components/shared'));
|
|
35
|
+
await fs.outputFile(
|
|
36
|
+
path.join(srcDir, 'features/README.md'),
|
|
37
|
+
`# Feature modules
|
|
38
|
+
|
|
39
|
+
Each feature is self-contained:
|
|
40
|
+
|
|
41
|
+
\`\`\`
|
|
42
|
+
features/auth/
|
|
43
|
+
api/ — service calls
|
|
44
|
+
components/ — feature-only components
|
|
45
|
+
hooks/ — feature-only hooks
|
|
46
|
+
store/ — feature state slice
|
|
47
|
+
types.ts
|
|
48
|
+
\`\`\`
|
|
49
|
+
`
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/pm.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package manager detection + helpers.
|
|
3
|
+
*
|
|
4
|
+
* Detection logic mirrors what create-vite / create-next-app do:
|
|
5
|
+
* read process.env.npm_config_user_agent which looks like:
|
|
6
|
+
* "npm/10.5.0 node/v20.11.0 darwin x64"
|
|
7
|
+
* "yarn/1.22.22 npm/? node/v20.11.0 darwin x64"
|
|
8
|
+
* "pnpm/9.12.0 npm/? node/v20.11.0 darwin x64"
|
|
9
|
+
*
|
|
10
|
+
* This is set by whichever PM invoked the CLI (e.g. `pnpm create stackit`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const SUPPORTED_PMS = ['npm', 'yarn', 'pnpm'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect the PM that invoked this CLI. Returns null if not detected
|
|
17
|
+
* or if the detected PM isn't in our supported list.
|
|
18
|
+
*/
|
|
19
|
+
export function detectPackageManager() {
|
|
20
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
21
|
+
if (!userAgent) return null;
|
|
22
|
+
|
|
23
|
+
// Take just the first token: "pnpm/9.12.0" → "pnpm"
|
|
24
|
+
const name = userAgent.split(' ')[0]?.split('/')[0];
|
|
25
|
+
if (!name) return null;
|
|
26
|
+
|
|
27
|
+
return SUPPORTED_PMS.includes(name) ? name : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get install args for a given PM. All three accept bare invocation
|
|
32
|
+
* but we pass the explicit subcommand for clarity in logs.
|
|
33
|
+
*/
|
|
34
|
+
export function getInstallCommand(pm) {
|
|
35
|
+
switch (pm) {
|
|
36
|
+
case 'yarn':
|
|
37
|
+
return { cmd: 'yarn', args: [] }; // yarn alone = install
|
|
38
|
+
case 'pnpm':
|
|
39
|
+
return { cmd: 'pnpm', args: ['install'] };
|
|
40
|
+
case 'npm':
|
|
41
|
+
default:
|
|
42
|
+
return { cmd: 'npm', args: ['install'] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the user-facing run command for a script.
|
|
48
|
+
* e.g. runScript('pnpm', 'dev') → 'pnpm dev'
|
|
49
|
+
* runScript('npm', 'dev') → 'npm run dev'
|
|
50
|
+
*/
|
|
51
|
+
export function runScript(pm, script) {
|
|
52
|
+
switch (pm) {
|
|
53
|
+
case 'yarn':
|
|
54
|
+
return `yarn ${script}`;
|
|
55
|
+
case 'pnpm':
|
|
56
|
+
return `pnpm ${script}`;
|
|
57
|
+
case 'npm':
|
|
58
|
+
default:
|
|
59
|
+
return `npm run ${script}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the install command as a single user-facing string,
|
|
65
|
+
* for README and "next steps" messaging.
|
|
66
|
+
*/
|
|
67
|
+
export function installCommandString(pm) {
|
|
68
|
+
switch (pm) {
|
|
69
|
+
case 'yarn':
|
|
70
|
+
return 'yarn';
|
|
71
|
+
case 'pnpm':
|
|
72
|
+
return 'pnpm install';
|
|
73
|
+
case 'npm':
|
|
74
|
+
default:
|
|
75
|
+
return 'npm install';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The packageManager field value for package.json.
|
|
81
|
+
* Used by Corepack to enforce PM consistency across a team.
|
|
82
|
+
* Versions are minimums; users can bump as needed.
|
|
83
|
+
*/
|
|
84
|
+
export function getPackageManagerField(pm) {
|
|
85
|
+
switch (pm) {
|
|
86
|
+
case 'yarn':
|
|
87
|
+
return 'yarn@1.22.22';
|
|
88
|
+
case 'pnpm':
|
|
89
|
+
return 'pnpm@9.12.0';
|
|
90
|
+
case 'npm':
|
|
91
|
+
default:
|
|
92
|
+
return 'npm@10.8.0';
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import validateProjectName from 'validate-npm-package-name';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { CHOICES } from './choices.js';
|
|
7
|
+
import {SUPPORTED_PMS, detectPackageManager} from './pm.js'
|
|
8
|
+
import {logger} from './utils.js'
|
|
9
|
+
|
|
10
|
+
const onCancel = () => {
|
|
11
|
+
const err = new Error('User cancelled');
|
|
12
|
+
err.isCancelled = true;
|
|
13
|
+
throw err;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function validateName(name) {
|
|
17
|
+
if (!name || !name.trim()) return 'Project name is required';
|
|
18
|
+
const result = validateProjectName(name);
|
|
19
|
+
if (!result.validForNewPackages) {
|
|
20
|
+
const issues = [...(result.errors || []), ...(result.warnings || [])];
|
|
21
|
+
return issues.join(', ') || 'Invalid project name';
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function confirmOverwrite(targetDir) {
|
|
27
|
+
if (!fs.existsSync(targetDir)) return true;
|
|
28
|
+
const files = fs.readdirSync(targetDir);
|
|
29
|
+
if (files.length === 0) return true;
|
|
30
|
+
|
|
31
|
+
const { overwrite } = await prompts(
|
|
32
|
+
{
|
|
33
|
+
type: 'confirm',
|
|
34
|
+
name: 'overwrite',
|
|
35
|
+
message: chalk.yellow(
|
|
36
|
+
`Directory "${path.basename(targetDir)}" is not empty. Overwrite?`
|
|
37
|
+
),
|
|
38
|
+
initial: false,
|
|
39
|
+
},
|
|
40
|
+
{ onCancel }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return overwrite;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runPrompts(initial) {
|
|
47
|
+
const questions = [];
|
|
48
|
+
|
|
49
|
+
if (!initial.projectName) {
|
|
50
|
+
questions.push({
|
|
51
|
+
type: 'text',
|
|
52
|
+
name: 'projectName',
|
|
53
|
+
message: 'Project name:',
|
|
54
|
+
initial: 'my-stackit-app',
|
|
55
|
+
validate: validateName,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!initial.framework) {
|
|
60
|
+
questions.push({
|
|
61
|
+
type: 'select',
|
|
62
|
+
name: 'framework',
|
|
63
|
+
message: 'Framework?',
|
|
64
|
+
choices: CHOICES.framework,
|
|
65
|
+
initial: 0,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!initial.language) {
|
|
70
|
+
questions.push({
|
|
71
|
+
type: 'select',
|
|
72
|
+
name: 'language',
|
|
73
|
+
message: 'Language?',
|
|
74
|
+
choices: CHOICES.language,
|
|
75
|
+
initial: 0,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!initial.state) {
|
|
80
|
+
questions.push({
|
|
81
|
+
type: 'select',
|
|
82
|
+
name: 'state',
|
|
83
|
+
message: 'State management?',
|
|
84
|
+
choices: CHOICES.state,
|
|
85
|
+
initial: 0,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!initial.ui) {
|
|
90
|
+
questions.push({
|
|
91
|
+
type: 'select',
|
|
92
|
+
name: 'ui',
|
|
93
|
+
message: 'UI library?',
|
|
94
|
+
choices: CHOICES.ui,
|
|
95
|
+
initial: 0,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!initial.structure) {
|
|
100
|
+
questions.push({
|
|
101
|
+
type: 'select',
|
|
102
|
+
name: 'structure',
|
|
103
|
+
message: 'Folder structure?',
|
|
104
|
+
choices: CHOICES.structure,
|
|
105
|
+
initial: 1, // modular default — better for real apps
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
let resolvedPm = null;
|
|
111
|
+
let pmSource = null;
|
|
112
|
+
|
|
113
|
+
if(initial.packageManager){
|
|
114
|
+
if(!SUPPORTED_PMS.includes(initial.packageManager)){
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Unsupported package manager: "${initial.packageManager}". ` +
|
|
117
|
+
`Supported: ${SUPPORTED_PMS.join(', ')}`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
resolvedPm = initial.packageManager;
|
|
121
|
+
pmSource = 'flag'
|
|
122
|
+
}else{
|
|
123
|
+
const detected = detectPackageManager();
|
|
124
|
+
if(detected){
|
|
125
|
+
resolvedPm= detected;
|
|
126
|
+
pmSource='detected';
|
|
127
|
+
logger.dim(` ↳ detected package manager: ${chalk.cyan(detected)}`);
|
|
128
|
+
}else{
|
|
129
|
+
questions.push({
|
|
130
|
+
type: 'select',
|
|
131
|
+
name: 'packageManager',
|
|
132
|
+
message: 'Package Manager',
|
|
133
|
+
choices: CHOICES.packageManager,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Always-asked toggles
|
|
139
|
+
questions.push(
|
|
140
|
+
{
|
|
141
|
+
type: 'multiselect',
|
|
142
|
+
name: 'addons',
|
|
143
|
+
message: 'Pick add-ons (space to toggle, enter to confirm):',
|
|
144
|
+
choices: CHOICES.addons,
|
|
145
|
+
hint: '- Space to select, Enter to submit',
|
|
146
|
+
instructions: false,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
type: 'confirm',
|
|
150
|
+
name: 'initGit',
|
|
151
|
+
message: 'Initialize a git repository?',
|
|
152
|
+
initial: true,
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const answered = await prompts(questions, { onCancel });
|
|
157
|
+
const finalAnswers = { ...initial, ...answered };
|
|
158
|
+
|
|
159
|
+
// Validate even if name came from CLI
|
|
160
|
+
const nameCheck = validateName(finalAnswers.projectName);
|
|
161
|
+
if (nameCheck !== true) {
|
|
162
|
+
throw new Error(`Invalid project name: ${nameCheck}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Compute target dir + overwrite confirmation
|
|
166
|
+
const targetDir = path.resolve(process.cwd(), finalAnswers.projectName);
|
|
167
|
+
const ok = await confirmOverwrite(targetDir);
|
|
168
|
+
if (!ok) onCancel();
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...finalAnswers,
|
|
172
|
+
targetDir,
|
|
173
|
+
templateKey: buildTemplateKey(finalAnswers),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildTemplateKey({ framework, language, state, ui }) {
|
|
178
|
+
return `${framework}-${language ?? 'ts'}-${state}-${ui}`;
|
|
179
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export const logger = {
|
|
4
|
+
info: (msg) => console.log(chalk.cyan(msg)),
|
|
5
|
+
success: (msg) => console.log(chalk.green(msg)),
|
|
6
|
+
warn: (msg) => console.log(chalk.yellow(msg)),
|
|
7
|
+
error: (msg) => console.log(chalk.red(msg)),
|
|
8
|
+
dim: (msg) => console.log(chalk.dim(msg)),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function printBanner() {
|
|
12
|
+
console.log();
|
|
13
|
+
console.log(chalk.bold.magenta(' ╭───────────────────────────────╮'));
|
|
14
|
+
console.log(chalk.bold.magenta(' │ ') + chalk.bold.white('create-stackit') + chalk.bold.magenta(' v0.1.0 │'));
|
|
15
|
+
console.log(chalk.bold.magenta(' │ ') + chalk.dim('pragmatic react starters ') + chalk.bold.magenta('│'));
|
|
16
|
+
console.log(chalk.bold.magenta(' ╰───────────────────────────────╯'));
|
|
17
|
+
console.log();
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
Generated with [create-stackit](https://github.com/your-handle/create-stackit).
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
- Next.js + JavaScript
|
|
7
|
+
- Redux Toolkit
|
|
8
|
+
- Material UI
|
|
9
|
+
|
|
10
|
+
## Scripts
|
|
11
|
+
```bash
|
|
12
|
+
npm run dev # start dev server
|
|
13
|
+
npm run build # production build
|
|
14
|
+
npm run start # start production server
|
|
15
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
3
|
+
import { increment } from '../src/store/counterSlice'
|
|
4
|
+
|
|
5
|
+
export default function Home() {
|
|
6
|
+
const count = useSelector((state) => state.counter.value)
|
|
7
|
+
const dispatch = useDispatch()
|
|
8
|
+
return (
|
|
9
|
+
<main>
|
|
10
|
+
<h1>create-stackit</h1>
|
|
11
|
+
<p>Count: {count}</p>
|
|
12
|
+
<button onClick={() => dispatch(increment())}>Increment</button>
|
|
13
|
+
</main>
|
|
14
|
+
)
|
|
15
|
+
}
|