@citruslime/create-boilerplate 3.0.0-beta.2 → 3.0.0-beta.3

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.
Files changed (76) hide show
  1. package/README.md +7 -7
  2. package/main.js +353 -314
  3. package/package.json +3 -4
  4. package/template/README.md +4 -4
  5. package/template/_editorconfig +5 -0
  6. package/template/_gitattributes +5 -2
  7. package/template/_gitignore +6 -2
  8. package/template/_husky/post-checkout +4 -0
  9. package/template/_husky/post-merge +4 -0
  10. package/template/_husky/pre-commit +4 -0
  11. package/template/apps/app/_lintstagedrc.js +5 -0
  12. package/template/apps/app/_stylelint.config.js +4 -0
  13. package/template/apps/app/eslint.config.js +39 -0
  14. package/template/apps/app/index.html +17 -0
  15. package/template/apps/app/package.json +23 -0
  16. package/template/{src → apps/app/src}/app.vue +1 -7
  17. package/template/apps/app/src/components/.gitkeep +0 -0
  18. package/template/apps/app/src/main.css +6 -0
  19. package/template/apps/app/src/main.ts +17 -0
  20. package/template/{src/pages/dashboard/index.vue → apps/app/src/pages/dashboard.vue} +4 -6
  21. package/template/{src/router/index.ts → apps/app/src/router.ts} +12 -7
  22. package/template/apps/app/src/state/auth.ts +11 -0
  23. package/template/apps/app/src/utils/.gitkeep +0 -0
  24. package/template/{tsconfig.app.json → apps/app/tsconfig.app.json} +7 -9
  25. package/template/{tsconfig.json → apps/app/tsconfig.json} +1 -4
  26. package/template/apps/app/tsconfig.node.json +13 -0
  27. package/template/{vite.config.ts → apps/app/vite.config.ts} +57 -27
  28. package/template/package.json +11 -10
  29. package/template/packages/config-eslint/_lintstagedrc.js +4 -0
  30. package/template/packages/config-eslint/eslint.config.js +1 -0
  31. package/template/packages/config-eslint/package.json +10 -0
  32. package/template/packages/config-stylelint/_lintstagedrc.js +4 -0
  33. package/template/packages/config-stylelint/eslint.config.js +1 -0
  34. package/template/packages/config-stylelint/package.json +13 -0
  35. package/template/packages/config-typescript/node.json +17 -0
  36. package/template/packages/config-typescript/package.json +9 -0
  37. package/template/packages/config-typescript/vue.json +18 -0
  38. package/template/packages/utils/_lintstagedrc.js +4 -0
  39. package/template/packages/utils/eslint.config.js +1 -0
  40. package/template/packages/utils/exports-list.json +1 -0
  41. package/template/packages/utils/exports-list.ts +95 -0
  42. package/template/packages/utils/package.json +43 -0
  43. package/template/packages/utils/plugin.ts +55 -0
  44. package/template/packages/utils/resolver.ts +38 -0
  45. package/template/packages/utils/src/api/api.ts +6 -0
  46. package/template/packages/utils/src/api/endpoints.ts +18 -0
  47. package/template/packages/utils/src/api/errors.ts +19 -0
  48. package/template/packages/utils/src/api/models/app-info.ts +5 -0
  49. package/template/packages/utils/src/index.ts +2 -0
  50. package/template/packages/utils/tsconfig.build.json +19 -0
  51. package/template/packages/utils/tsconfig.json +11 -0
  52. package/template/packages/utils/tsconfig.lib.json +15 -0
  53. package/template/packages/utils/tsconfig.node.json +21 -0
  54. package/template/packages/utils/vite.config.ts +31 -0
  55. package/template/pnpm-workspace.yaml +11 -0
  56. package/template/{.vscode/template.code-workspace → template.code-workspace} +26 -7
  57. package/template/turbo.json +24 -0
  58. package/hooks/post-checkout +0 -8
  59. package/hooks/post-merge +0 -8
  60. package/hooks/pre-commit +0 -8
  61. package/template/.vscode/extensions.json +0 -14
  62. package/template/.vscode/settings.json +0 -40
  63. package/template/_lintstagedrc.js +0 -5
  64. package/template/_npmrc +0 -3
  65. package/template/eslint.config.js +0 -11
  66. package/template/index.html +0 -13
  67. package/template/postcss.config.js +0 -6
  68. package/template/src/main.ts +0 -15
  69. package/template/src/state/auth.ts +0 -13
  70. package/template/tailwind.config.ts +0 -9
  71. package/template/tsconfig.node.json +0 -17
  72. package/template/tsconfig.vitest.json +0 -13
  73. /package/template/{_stylelintignore → apps/app/_stylelintignore} +0 -0
  74. /package/template/{env.d.ts → apps/app/env.d.ts} +0 -0
  75. /package/template/{public → apps/app/public}/favicon.ico +0 -0
  76. /package/template/{_stylelint.config.js → packages/config-stylelint/index.js} +0 -0
package/main.js CHANGED
@@ -1,25 +1,39 @@
1
1
  #!/usr/bin/env node
2
- import { execSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
- import { join, relative } from 'node:path';
2
+ import { exec, execSync, spawn } from 'node:child_process';
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
4
+ import { userInfo } from 'node:os';
5
+ import { extname, join, relative, resolve } from 'node:path';
5
6
  import { fileURLToPath, URL } from 'node:url';
6
-
7
- import { green, lightBlue, red } from 'kolorist';
7
+ import { promisify } from 'node:util';
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ import {
12
+ bold,
13
+ cyan,
14
+ dim,
15
+ green,
16
+ lightBlue,
17
+ magenta,
18
+ red
19
+ } from 'kolorist';
8
20
  import parseArgs from 'minimist';
9
21
  import { default as prompts } from 'prompts';
10
22
 
11
- const argv = parseArgs(process.argv.slice(2));
23
+ const argv = parseArgs(process.argv.slice(2), {
24
+ alias: { v: 'verbose' },
25
+ boolean: ['verbose']
26
+ });
12
27
  const cwd = process.cwd();
13
28
  const codeDir = fileURLToPath(new URL('./', import.meta.url));
29
+ const verbose = Boolean(argv.verbose);
14
30
 
15
31
  const placeholdersToReplace = {
16
32
  /* eslint-disable @typescript-eslint/naming-convention */
17
33
  '[[PACKAGE_NAME]]': '',
18
- '[[PACKAGE_DIR]]': '',
19
- '[[ROOT_DIR]]': '',
20
- '[[HUSKY_DIR]]': '',
21
- '\'[[PROXY]]\'': '{}',
22
- '\'[[FRONTEND_PORT]]\'': 0
34
+ '[[GIT_ROOT]]': '',
35
+ '[[DEFAULT_USERNAME]]': '',
36
+ '\'[[PROXY]]\'': ''
23
37
  /* eslint-enable @typescript-eslint/naming-convention */
24
38
  };
25
39
 
@@ -30,303 +44,248 @@ const filesToRename = {
30
44
  '_lintstagedrc.js': '.lintstagedrc.js',
31
45
  _stylelintignore: '.stylelintignore',
32
46
  '_stylelint.config.js': 'stylelint.config.js',
33
- 'template.code-workspace': 'citrus-lime.code-workspace',
34
- _npmrc: '.npmrc'
47
+ _husky: '.husky'
35
48
  };
36
49
 
37
- const dependenciesToInstall = [
38
- {
39
- name: '@citruslime/ui'
40
- },
41
- {
42
- name: '@citruslime/utils'
43
- },
44
- {
45
- name: '@vueuse/core'
46
- },
47
- {
48
- name: 'pinia'
49
- },
50
- {
51
- name: 'pinia-plugin-persistedstate'
52
- },
53
- {
54
- name: 'vue'
55
- },
56
- {
57
- name: 'vue-i18n'
58
- },
59
- {
60
- name: 'vue-router'
61
- },
62
- {
63
- name: '@citruslime/config',
64
- dev: true
65
- },
66
- {
67
- name: '@citruslime/theme',
68
- dev: true
69
- },
70
- {
71
- name: '@tsconfig/node20',
72
- dev: true
73
- },
74
- {
75
- name: '@types/jsdom',
76
- dev: true
77
- },
78
- {
79
- name: '@types/luxon',
80
- dev: true
81
- },
82
- {
83
- name: '@types/node',
84
- dev: true
85
- },
86
- {
87
- name: '@vitejs/plugin-vue',
88
- dev: true
89
- },
90
- {
91
- name: '@vue/tsconfig',
92
- dev: true
93
- },
94
- {
95
- name: 'husky',
96
- dev: true
97
- },
98
- {
99
- name: 'lint-staged',
100
- dev: true
101
- },
102
- {
103
- name: 'postcss',
104
- dev: true
105
- },
106
- {
107
- name: 'npm-run-all',
108
- dev: true
109
- },
110
- {
111
- name: 'typescript',
112
- dev: true
113
- },
114
- {
115
- name: 'unplugin-auto-import',
116
- dev: true
117
- },
118
- {
119
- name: 'unplugin-vue-components',
120
- dev: true
121
- },
122
- {
123
- name: 'unplugin-vue-router',
124
- dev: true
125
- },
126
- {
127
- name: 'vite',
128
- dev: true
129
- },
130
- {
131
- name: 'vite-plugin-mkcert',
132
- dev: true
133
- },
134
- {
135
- name: 'vue-tsc',
136
- dev: true
137
- }
50
+ const spinnerFrames = [
51
+ '⠋',
52
+ '',
53
+ '⠹',
54
+ '⠸',
55
+ '',
56
+ '⠴',
57
+ '⠦',
58
+ '',
59
+ '⠇',
60
+ '⠏'
138
61
  ];
139
62
 
140
63
  /**
141
- * Initialise the new package.
64
+ * Short banner (similar in spirit to create-vite / npm init style CLIs).
65
+ */
66
+ function printBanner () {
67
+ console.log();
68
+ console.log(` ${bold(cyan('Citrus-Lime Vue Template'))}`);
69
+
70
+ if (!verbose) {
71
+ console.log();
72
+ console.log(dim('Tip: pass --verbose for further details.'));
73
+ }
74
+
75
+ console.log();
76
+ }
77
+
78
+ /**
79
+ * Runs task with a spinner (interactive TTY) or plain lines
80
+ * (CI, piped stdout, or --verbose so child process output does not garble the spinner).
81
+ *
82
+ * @param {string} label Step description.
83
+ * @param {() => void | Promise<void>} callback Work to run (sync or async).
84
+ * @throws {Error} An error if the work fails.
85
+ */
86
+ async function runStep (label, callback) {
87
+ const plain = !process.stdout.isTTY || verbose;
88
+
89
+ if (plain) {
90
+ console.log(`${lightBlue('›')} ${label}`);
91
+
92
+ try {
93
+ await Promise.resolve(callback());
94
+ }
95
+ catch (e) {
96
+ console.log(`${red('✖')} ${label}`);
97
+ throw e;
98
+ }
99
+
100
+ console.log(`${green('✓')} ${label}`);
101
+
102
+ return;
103
+ }
104
+
105
+ let frame = 0;
106
+ const interval = setInterval(() => {
107
+ const f = spinnerFrames[frame++ % spinnerFrames.length];
108
+
109
+ process.stdout.write(`\r${dim('◆')} ${magenta(f)} ${label}\x1b[K`);
110
+ }, 50);
111
+
112
+ try {
113
+ await Promise.resolve(callback());
114
+ }
115
+ catch (e) {
116
+ process.stdout.write('\r\x1b[K');
117
+ throw e;
118
+ }
119
+ finally {
120
+ clearInterval(interval);
121
+ }
122
+
123
+ process.stdout.write(`\r${dim('◆')} ${green('✓')} ${label}\x1b[K\n`);
124
+ }
125
+
126
+ /**
127
+ * Initialise the new project.
128
+ *
129
+ * @throws {Error} Operation cancelled error.
142
130
  */
143
131
  async function init () {
144
- let packageDir = argv._[0];
145
- const rootDir = execSync('git rev-parse --show-toplevel')
146
- .toString()
147
- .trim();
132
+ printBanner();
133
+
134
+ const packageDir = argv._[0];
148
135
 
149
136
  const {
150
137
  packageName,
151
- empty,
152
- backendPort,
153
- packageManager
138
+ backendPort
154
139
  } = await prompts([
155
140
  {
156
141
  type: 'text',
157
142
  name: 'packageName',
158
143
  message: 'Enter a name for the project:',
159
- validate: value => /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(value) || 'Invalid package name'
160
- },
161
- {
162
- type: packageDir ? null : 'text',
163
- name: 'packageDir',
164
- message: 'Enter a directory for the project:',
165
- initial: '.',
166
- onState: state => packageDir = state.value
167
- },
168
- {
169
- type: () => directoryValid(packageDir) ? null : 'confirm',
170
- name: 'empty',
171
- message: () => `${packageDir === '.' ? 'Current directory' : `Target directory "${packageDir}"`} is not empty. Remove existing files and continue?`
144
+ validate: value => /^[a-z0-9-~][a-z0-9-._~]*$/.test(value) || 'Invalid project name (lowercase, no scope)'
172
145
  },
173
146
  {
174
- type (_, { empty }) {
175
- if (empty === false) {
176
- cancel();
177
- }
178
-
179
- return null;
180
- },
181
- name: 'emptyConfirmation'
182
- },
183
- {
184
- type: 'confirm',
185
- name: 'hasApi',
186
- message: 'Setup backend proxy?',
187
- initial: true
188
- },
189
- {
190
- type: prev => prev ? 'text' : null,
147
+ type: 'number',
191
148
  name: 'backendPort',
192
149
  message: 'Enter the port that the backend server runs on:',
193
- initial: 0
194
- },
195
- {
196
- type: 'select',
197
- name: 'packageManager',
198
- message: 'Select a package manager:',
199
- choices: [
200
- {
201
- title: 'pnpm',
202
- value: 'pnpm'
203
- },
204
- {
205
- title: 'yarn',
206
- value: 'yarn'
207
- },
208
- {
209
- title: 'npm',
210
- value: 'npm'
211
- }
212
- ],
213
- initial: 0
150
+ validate: value => (Number.isInteger(value) && value >= 1024 && value <= 65535) || 'Enter a valid port (1024-65535)'
214
151
  }
215
152
  ], {
216
- onCancel: () => cancel()
153
+ onCancel () {
154
+ throw new Error('Operation was cancelled.');
155
+ }
217
156
  });
218
157
 
219
- print(lightBlue('Initialising package...'), true);
158
+ console.log();
159
+ console.log(dim('—'.repeat(42)));
160
+ console.log();
220
161
 
221
- packageDir = packageDir.toString();
162
+ const targetDir = (packageDir ?? packageName).toString();
222
163
 
223
- setReplacements(packageName, packageDir, rootDir, backendPort ?? 0, packageManager);
224
- prepareDir(join(cwd, packageDir), empty);
225
- copyTemplate(packageDir);
164
+ const projectDir = resolve(cwd, targetDir);
226
165
 
227
- process.chdir(packageDir);
228
- installDependencies(packageManager);
166
+ await runStep('Preparing project directory', () => {
167
+ if (!existsSync(projectDir)) {
168
+ mkdirSync(projectDir, { recursive: true });
169
+ }
170
+ });
229
171
 
230
- print(lightBlue('Creating hooks...'), true);
231
- copyHooks(rootDir);
172
+ const gitRoot = getGitRoot(projectDir);
173
+ const gitRootRelative = (relative(projectDir, gitRoot ?? projectDir) || '.').replaceAll('\\', '/');
232
174
 
233
- print(green(`Package ${packageName} has been successfully initialised in ${packageDir}.`), true);
234
- }
175
+ setReplacements(packageName, backendPort, gitRootRelative);
235
176
 
236
- /**
237
- * Checks if a directory is valid for project creation.
238
- *
239
- * @param {string} packageDir The directory of the package being created.
240
- * @returns True, if the directory is valid; false, otherwise.
241
- */
242
- function directoryValid (packageDir) {
243
- const directoryExists = existsSync(packageDir);
244
- const directorySize = directoryExists ? readdirSync(packageDir).length : 0;
245
- const directoryOnlyContainsGit = directorySize === 1 && existsSync(`${packageDir}/.git`);
246
-
247
- return !directoryExists ||
248
- directorySize === 0 ||
249
- directoryOnlyContainsGit;
177
+ await runStep('Scaffolding template files', () => {
178
+ copyTemplate(projectDir, gitRoot ?? projectDir);
179
+ });
180
+
181
+ if (!gitRoot) {
182
+ await runStep('Initialising Git repository', () => execAsync('git init', {
183
+ cwd: projectDir,
184
+ stdio: 'ignore'
185
+ }));
186
+ }
187
+
188
+ await installDependencies(projectDir, packageName);
189
+
190
+ console.log();
191
+ console.log(bold(green('All set.')));
192
+ console.log();
193
+ console.log(dim('Your new project is ready. Next:'));
194
+ console.log();
195
+ console.log(` ${cyan('$')} ${dim('cd')} ${targetDir}`);
196
+ console.log(` ${cyan('$')} ${dim('pnpm dev')}`);
197
+ console.log();
250
198
  }
251
199
 
252
200
  /**
253
- * Set the dynamic values for the file renaming and placeholder replacements.
201
+ * Set the dynamic values for the placeholder replacements.
254
202
  *
255
- * @param {string} packageName The name of the package being created.
256
- * @param {string} packageDir The directory of the package being created.
257
- * @param {string} rootDir The relative path of the root of the repository.
203
+ * @param {string} packageName The name of the project.
258
204
  * @param {number} backendPort The port that the backend server runs on.
259
- * @param packageManager The selected package manager to use.
205
+ * @param {string} gitRoot The relative path from the project directory to the Git root.
260
206
  */
261
- function setReplacements (packageName, packageDir, rootDir, backendPort, packageManager) {
262
- const husky = relative(join(cwd, packageDir, '.vscode'), join(rootDir, '.husky'));
263
- const repoRoot = relative(join(cwd, packageDir), rootDir);
264
- const projDir = relative(rootDir, join(cwd, packageDir));
265
-
207
+ function setReplacements (packageName, backendPort, gitRoot) {
266
208
  filesToRename['template.code-workspace'] = `${packageName}.code-workspace`;
267
209
  placeholdersToReplace['[[PACKAGE_NAME]]'] = packageName;
268
- placeholdersToReplace['[[PACKAGE_DIR]]'] = projDir;
269
- placeholdersToReplace['[[ROOT_DIR]]'] = repoRoot.replaceAll('\\', '/');
270
- placeholdersToReplace['[[HUSKY_DIR]]'] = husky.replaceAll('\\', '/');
271
- placeholdersToReplace['\'[[FRONTEND_PORT]]\''] = Math.floor(Math.random() * 1001) + 4000;
272
- placeholdersToReplace['[[PACKAGE_MANAGER]]'] = packageManager;
273
-
274
- if (backendPort !== 0) {
275
- placeholdersToReplace['\'[[PROXY]]\''] = `{
210
+ placeholdersToReplace['[[GIT_ROOT]]'] = gitRoot;
211
+ placeholdersToReplace['[[DEFAULT_USERNAME]]'] = getDefaultUsername();
212
+ placeholdersToReplace['\'[[PROXY]]\''] = `{
213
+ // eslint-disable-next-line @typescript-eslint/naming-convention
276
214
  '/api': {
277
215
  target: 'https://localhost:${backendPort}',
278
216
  secure: false,
279
- rewrite: (path) => path.replace('/api', '')
217
+ rewrite: (path: string): string => path.replace('/api', '')
280
218
  }
281
219
  }`;
282
- }
283
220
  }
284
221
 
285
222
  /**
286
- * Prepares the directory for the new package to be created in.
223
+ * Gets username to use as default for the project.
287
224
  *
288
- * @param {string} path The path of the directory to be prepared.
289
- * @param {boolean} empty If the current contents needs to be emptied.
225
+ * @returns {string} Git `user.name`, if set; otherwise, the OS username.
290
226
  */
291
- function prepareDir (path, empty) {
292
- if (empty) {
293
- const options = {
294
- recursive: true,
295
- force: true
296
- };
297
-
298
- print(lightBlue('Emptying directory...'), true);
227
+ function getDefaultUsername () {
228
+ try {
229
+ const username = execSync('git config user.name', {
230
+ cwd: process.cwd(),
231
+ encoding: 'utf-8',
232
+ stdio: [
233
+ 'ignore',
234
+ 'pipe',
235
+ 'ignore'
236
+ ]
237
+ }).trim();
238
+
239
+ if (username) {
240
+ return username;
241
+ }
299
242
 
300
- forEachInDir(path, item => rmSync(join(path, item), options));
243
+ return userInfo().username;
301
244
  }
302
- else if (!existsSync(path)) {
303
- print(lightBlue('Creating directory...'), true);
304
-
305
- mkdirSync(path);
245
+ catch {
246
+ // Git is missing, not on PATH, or config is unreadable.
306
247
  }
248
+
249
+ return 'Unknown';
307
250
  }
308
251
 
309
252
  /**
310
- * Copies the template directory into the new package directory.
253
+ * Returns the root directory of the Git repository containing the given
254
+ * directory, or null if the directory is not inside a Git repository.
311
255
  *
312
- * @param {string} packageDir The directory of the new package.
256
+ * @param {string} dir The directory to check.
257
+ * @returns {string | null} The Git root path, or null.
313
258
  */
314
- function copyTemplate (packageDir) {
315
- const templateDir = join(codeDir, 'template');
316
-
317
- forEachInDir(templateDir, item => copy(templateDir, packageDir, item));
259
+ function getGitRoot (dir) {
260
+ try {
261
+ return execSync('git rev-parse --show-toplevel', {
262
+ cwd: dir,
263
+ stdio: [
264
+ 'ignore',
265
+ 'pipe',
266
+ 'ignore'
267
+ ],
268
+ encoding: 'utf-8'
269
+ }).trim();
270
+ }
271
+ catch {
272
+ return null;
273
+ }
318
274
  }
319
275
 
320
276
  /**
321
- * Copies the hooks directory into the husky directory for the repository.
277
+ * Copies the template directory into the project directory.
278
+ * The `_husky` directory is copied to the Git root so that hooks are installed into the correct location.
322
279
  *
323
- * @param {string} rootDir The relative path from the package directory to the root of the repository.
280
+ * @param {string} projectDir The directory of the new project.
281
+ * @param {string} gitRoot The root of the Git repository.
324
282
  */
325
- function copyHooks (rootDir) {
326
- const hooksDir = join(codeDir, 'hooks');
327
- const huskyDir = join(rootDir, '.husky');
283
+ function copyTemplate (projectDir, gitRoot) {
284
+ const templateDir = join(codeDir, 'template');
328
285
 
329
- forEachInDir(hooksDir, item => copy(hooksDir, huskyDir, item));
286
+ for (const item of readdirSync(templateDir)) {
287
+ copy(templateDir, item === '_husky' ? gitRoot : projectDir, item);
288
+ }
330
289
  }
331
290
 
332
291
  /**
@@ -337,118 +296,198 @@ function copyHooks (rootDir) {
337
296
  * @param {string} item The item to copy.
338
297
  */
339
298
  function copy (source, destination, item) {
299
+ if (item === '.gitkeep') {
300
+ return;
301
+ }
302
+
340
303
  const sourceItem = join(source, item);
341
304
  const info = statSync(sourceItem);
342
305
 
343
306
  if (info.isDirectory()) {
344
- const destinationDir = join(destination, item);
307
+ const renamedItem = filesToRename[item] ?? item;
308
+ const destinationDir = join(destination, renamedItem);
345
309
 
346
310
  mkdirSync(destinationDir, { recursive: true });
347
311
 
348
- forEachInDir(sourceItem, item => copy(sourceItem, destinationDir, item));
312
+ for (const child of readdirSync(sourceItem)) {
313
+ copy(sourceItem, destinationDir, child);
314
+ }
349
315
  }
350
316
  else {
351
- const destinationFile = filesToRename[item] ? join(destination, filesToRename[item]) : join(destination, item);
317
+ const destinationFile = join(destination, filesToRename[item] ?? item);
352
318
 
353
319
  if (existsSync(destinationFile)) {
354
- print(lightBlue(`Skipping ${item}...`), true);
320
+ if (verbose) {
321
+ console.log(`${dim('skip')} ${item}`);
322
+ }
355
323
  }
356
- else {
357
- const options = {
358
- encoding: 'utf-8'
359
- };
324
+ else if (extname(item).toLowerCase() === '.ico') {
325
+ if (verbose) {
326
+ console.log(`${dim('copy')} ${item}`);
327
+ }
360
328
 
361
- print(lightBlue(`Copying ${item}...`), true);
329
+ copyFileSync(sourceItem, destinationFile);
330
+ }
331
+ else {
332
+ if (verbose) {
333
+ console.log(`${dim('copy')} ${item}`);
334
+ }
362
335
 
363
- let contents = readFileSync(sourceItem, options);
336
+ let contents = readFileSync(sourceItem, { encoding: 'utf-8' });
364
337
 
365
338
  for (const [
366
339
  key,
367
340
  value
368
341
  ] of Object.entries(placeholdersToReplace)) {
369
- contents = contents.replace(key, value);
342
+ contents = contents.replaceAll(key, value);
370
343
  }
371
344
 
372
- writeFileSync(destinationFile, contents, options);
345
+ writeFileSync(destinationFile, contents, { encoding: 'utf-8' });
373
346
  }
374
347
  }
375
348
  }
376
349
 
377
350
  /**
378
- * Installs the required dependencies for the template.
379
- *
380
- * @param {string} packageManager The package manager to use.
351
+ * Dependency definitions per workspace.
352
+ * The major version constraint ensures pnpm resolves to the latest minor/patch
353
+ * and writes the full ^x.y.z version into package.json.
381
354
  */
382
- function installDependencies (packageManager) {
383
- const prefix = `${packageManager} ${packageManager !== 'npm' ? 'add' : 'install'}`;
384
-
385
- print(lightBlue('Installing dev dependencies...'), true);
386
-
387
- let devDependencies = '';
388
-
389
- for (const dependency of dependenciesToInstall.filter(d => d.dev)) {
390
- devDependencies += `${dependency.name}${dependency.next ? '@next' : '@latest'} `;
391
- }
392
-
393
- execSync(`${prefix} -D ${devDependencies}`);
394
-
395
- print(lightBlue('Installing dependencies...'), true);
396
-
397
- let dependencies = '';
398
-
399
- for (const dependency of dependenciesToInstall.filter(d => !d.dev)) {
400
- dependencies += `${dependency.name}${dependency.next ? '@next' : '@latest'} `;
355
+ const dependencies = {
356
+ root: {
357
+ dev: [
358
+ 'eslint@9',
359
+ 'husky@9',
360
+ 'lint-staged@16',
361
+ 'stylelint@17',
362
+ 'turbo@2'
363
+ ]
364
+ },
365
+ app: {
366
+ prod: [
367
+ '@citruslime/ui@4',
368
+ '@citruslime/vue-utils@2',
369
+ '@vueuse/core@14',
370
+ 'luxon@3',
371
+ 'pinia@3',
372
+ 'pinia-plugin-persistedstate@4',
373
+ 'vue@3',
374
+ 'vue-i18n@11',
375
+ 'vue-router@5'
376
+ ],
377
+ dev: [
378
+ '@citruslime/theme@2',
379
+ '@intlify/unplugin-vue-i18n@11',
380
+ '@tailwindcss/vite@4',
381
+ '@types/jsdom@27',
382
+ '@types/luxon@3',
383
+ '@types/node@25',
384
+ '@vitejs/plugin-vue@6',
385
+ 'jsdom@28',
386
+ 'npm-run-all@4',
387
+ 'typescript@5',
388
+ 'unplugin-auto-import@21',
389
+ 'unplugin-vue-components@32',
390
+ 'vite@8',
391
+ 'vite-plugin-mkcert@1',
392
+ 'vite-plugin-vue-devtools@8',
393
+ 'vue-tsc@3'
394
+ ]
395
+ },
396
+ 'config-eslint': {
397
+ prod: ['@citruslime/config@2']
398
+ },
399
+ 'config-stylelint': {
400
+ prod: ['@citruslime/config@2']
401
+ },
402
+ 'config-typescript': {
403
+ prod: [
404
+ '@tsconfig/node24@24',
405
+ '@vue/tsconfig@0'
406
+ ]
407
+ },
408
+ utils: {
409
+ prod: [
410
+ '@citruslime/vue-utils@2',
411
+ 'unimport@6'
412
+ ],
413
+ dev: [
414
+ '@types/node@25',
415
+ 'npm-run-all@4',
416
+ 'typescript@5',
417
+ 'unplugin-auto-import@21',
418
+ 'vite@8',
419
+ 'vue-tsc@3'
420
+ ]
401
421
  }
402
-
403
- execSync(`${prefix} ${dependencies}`);
404
-
405
- print(lightBlue('Running final install...'), true);
406
-
407
- execSync(`${packageManager} install`);
408
- }
422
+ };
409
423
 
410
424
  /**
411
- * Calls the callback for each item in a directory.
425
+ * Runs a shell command. Use this instead of promisified `exec` when `stdio` must be
426
+ * `'inherit'` — `exec` always buffers child output and does not honour `stdio` for streaming.
412
427
  *
413
- * @param {string} dir The directory to loop-through the contents of.
414
- * @param {function(string): void} callback The callback to provide with each item.
428
+ * @param {string} command Full command line (shell parsing).
429
+ * @param {string} cwd Working directory.
430
+ * @returns {Promise<void>}
415
431
  */
416
- function forEachInDir (dir, callback) {
417
- const items = readdirSync(dir);
418
-
419
- for (const item of items) {
420
- callback(item);
421
- }
432
+ function runShellCommand (command, cwd) {
433
+ return new Promise((resolve, reject) => {
434
+ const child = spawn(command, {
435
+ cwd,
436
+ shell: true,
437
+ stdio: verbose ? 'inherit' : 'ignore',
438
+ windowsHide: true
439
+ });
440
+
441
+ child.on('error', reject);
442
+ child.on('close', (code, signal) => {
443
+ if (code === 0) {
444
+ resolve();
445
+ }
446
+ else {
447
+ reject(new Error(`Command failed with exit code ${code}${signal ? ` (${signal})` : ''}: ${command}`));
448
+ }
449
+ });
450
+ });
422
451
  }
423
452
 
424
453
  /**
425
- * Throws an error to indicate that the current operation has been cancelled.
454
+ * Installs all project dependencies using pnpm add with major version constraints.
455
+ * Pnpm resolves each to the latest matching version and writes ^x.y.z into package.json.
426
456
  *
427
- * @throws {Error} Operation cancelled error.
457
+ * @param {string} projectDir The project directory.
458
+ * @param {string} packageName The name of the project used as the npm scope.
459
+ * @returns {Promise<void>}
428
460
  */
429
- function cancel () {
430
- throw new Error('Operation was cancelled.');
431
- }
461
+ async function installDependencies (projectDir, packageName) {
462
+ const runPnpm = cmd => runShellCommand(cmd, projectDir);
432
463
 
433
- /**
434
- * Prints a message to the console.
435
- *
436
- * @param {string} message The message to display.
437
- * @param {boolean} clearPrevious Whether to clear any existing message.
438
- */
439
- function print (message, clearPrevious = false) {
440
- if (clearPrevious) {
441
- process.stdout.clearLine(0);
442
- process.stdout.cursorTo(0);
464
+ await runStep('pnpm install (workspace)', () => runPnpm('pnpm install --ignore-scripts'));
465
+
466
+ await runStep('Adding root DevDependencies', () => runPnpm(`pnpm add -D -w ${dependencies.root.dev.join(' ')}`));
467
+
468
+ for (const [
469
+ workspace,
470
+ deps
471
+ ] of Object.entries(dependencies).filter(([k]) => k !== 'root')) {
472
+ const filter = `--filter @${packageName}/${workspace}`;
473
+
474
+ if (deps.prod?.length) {
475
+ await runStep(`Dependencies · @${packageName}/${workspace}`, () => runPnpm(`pnpm ${filter} add ${deps.prod.join(' ')}`));
476
+ }
477
+
478
+ if (deps.dev?.length) {
479
+ await runStep(`DevDependencies · @${packageName}/${workspace}`, () => runPnpm(`pnpm ${filter} add -D ${deps.dev.join(' ')}`));
480
+ }
443
481
  }
444
482
 
445
- process.stdout.write(message);
483
+ await runStep('pnpm install (final lockfile)', () => runPnpm('pnpm install'));
446
484
  }
447
485
 
448
486
  try {
449
487
  await init();
450
488
  }
451
489
  catch (e) {
452
- print(`\n${red(e.message)}`);
490
+ console.error();
491
+ console.error(`${red('✖')} ${e.message}`);
453
492
  process.exit(1);
454
493
  }