@pugpigjs/create-wp-project 0.0.2-beta.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,359 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import ora from 'ora'
5
+ import chalk from 'chalk'
6
+ import simpleGit from 'simple-git'
7
+ import { TEMPLATES, EXCLUDED_ITEMS, EXCLUDE_PATTERNS } from './constants.js'
8
+
9
+ /**
10
+ * Validate target directory is safe and meets naming requirements
11
+ * @param {string} directory - The directory path to validate
12
+ * @throws {Error} Exits process if validation fails
13
+ */
14
+ export async function validateTargetDirectory(directory) {
15
+ const targetDir = path.resolve(directory)
16
+
17
+ // Check if directory exists
18
+ if (!await fse.pathExists(targetDir)) {
19
+ console.error(chalk.red(`\n❌ Target directory does not exist: ${targetDir}`))
20
+ console.error(chalk.yellow('Please create the directory first or use an existing one.'))
21
+ process.exit(1)
22
+ }
23
+
24
+ // Safety check: block system directories
25
+ const sensitiveDirs = [os.homedir(), '/', '/usr', '/etc', '/var', '/bin', '/sbin']
26
+ if (sensitiveDirs.includes(targetDir)) {
27
+ console.error(chalk.red(`\n❌ Cannot operate in system directory: ${targetDir}`))
28
+ console.error(chalk.yellow('Please use a dedicated project directory.'))
29
+ process.exit(1)
30
+ }
31
+
32
+ // Safety check: directory name must end with -server
33
+ const dirName = path.basename(targetDir)
34
+ if (!dirName?.toLowerCase()?.endsWith('-server')) {
35
+ console.error(chalk.red(`\n❌ Target directory must end with '-server': ${dirName}`))
36
+ console.error(chalk.yellow('This is a safety measure to prevent accidental operations in wrong directories.'))
37
+ console.error(chalk.gray(`Example: my-project-server, wordpress-server, etc.`))
38
+ process.exit(1)
39
+ }
40
+
41
+ return targetDir
42
+ }
43
+
44
+ /**
45
+ * Shared validation functions for prompts
46
+ */
47
+ export const validators = {
48
+ namespace: (input) => {
49
+ if (!input.trim()) return 'Namespace is required'
50
+ if (!/^[A-Z][a-zA-Z0-9]*$/.test(input)) return 'Namespace must start with uppercase letter and contain only letters and numbers'
51
+ return true
52
+ },
53
+ projectName: (input) => {
54
+ if (!input.trim()) return 'Project name is required'
55
+ if (!/^[a-z0-9_]+$/.test(input)) return 'Project name must be in snake_case (lowercase letters, numbers, and underscores only)'
56
+ if (input.startsWith('_') || input.endsWith('_')) return 'Project name cannot start or end with an underscore'
57
+ if (input.includes('__')) return 'Project name cannot contain consecutive underscores'
58
+ return true
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Shared prompt configurations
64
+ */
65
+ export const prompts = {
66
+ template: {
67
+ type: 'list',
68
+ name: 'template',
69
+ message: 'Select a template:',
70
+ choices: Object.entries(TEMPLATES).map(([key, config]) => ({
71
+ name: `${config.name} - ${config.description}`,
72
+ value: key
73
+ }))
74
+ },
75
+ namespace: {
76
+ type: 'input',
77
+ name: 'namespace',
78
+ message: 'Enter namespace (e.g., "Pugpig", "Acme"):',
79
+ default: 'Pugpig',
80
+ validate: validators.namespace
81
+ },
82
+ getProjectNamePrompt: (previousAnswers) => ({
83
+ type: 'input',
84
+ name: 'projectName',
85
+ message: 'Enter project name (snake_case, e.g., "my_custom_plugin"):',
86
+ validate: (input) => {
87
+ // First run the basic validation
88
+ const basicValidation = validators.projectName(input)
89
+ if (basicValidation !== true) return basicValidation
90
+
91
+ // Check if project name starts with namespace prefix
92
+ if (previousAnswers && previousAnswers.namespace) {
93
+ const namespacePrefix = `${previousAnswers.namespace.toLowerCase()}_`
94
+ if (input.startsWith(namespacePrefix)) {
95
+ return `Project name should not start with "${namespacePrefix}" as the namespace will be automatically prepended to the directory name`
96
+ }
97
+ }
98
+
99
+ return true
100
+ }
101
+ }),
102
+ description: {
103
+ type: 'input',
104
+ name: 'description',
105
+ message: 'Enter project description:',
106
+ default: (answers) => {
107
+ return answers.projectName
108
+ .split('_')
109
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
110
+ .join(' ')
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Clone the git repository to a temporary directory, or copy from local path
117
+ * @param {string} gitUrlOrPath - Git URL or local filesystem path
118
+ * @param {string} tempDir - Temporary directory to clone/copy into
119
+ */
120
+ export async function cloneRepo(gitUrlOrPath, tempDir) {
121
+ // Check if it's a local path
122
+ const isLocalPath = !gitUrlOrPath.startsWith('http://') &&
123
+ !gitUrlOrPath.startsWith('https://') &&
124
+ !gitUrlOrPath.startsWith('git@')
125
+
126
+ console.log(chalk.gray(` Using temporary directory: ${tempDir}`))
127
+
128
+ if (isLocalPath) {
129
+ const spinner = ora('Copying from local template...').start()
130
+ try {
131
+ // Pass tempDir as excludePath to prevent copying the temp directory into itself
132
+ await copyDirectory(gitUrlOrPath, tempDir, tempDir)
133
+ spinner.succeed('Local template copied')
134
+ } catch (error) {
135
+ spinner.fail('Failed to copy local template')
136
+ throw error
137
+ }
138
+ } else {
139
+ const spinner = ora('Cloning template repository...').start()
140
+ try {
141
+ const git = simpleGit()
142
+ await git.clone(gitUrlOrPath, tempDir, ['--depth', '1'])
143
+ spinner.succeed('Template repository cloned')
144
+ } catch (error) {
145
+ spinner.fail('Failed to clone repository')
146
+ throw error
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Check if a path should be excluded from copying
153
+ */
154
+ function shouldExclude(itemName) {
155
+ // Exact match exclusions
156
+ if (EXCLUDED_ITEMS.includes(itemName)) {
157
+ return true
158
+ }
159
+ // Pattern exclusions: .temp-* directories
160
+ if (itemName.startsWith('.temp-')) {
161
+ return true
162
+ }
163
+ return false
164
+ }
165
+
166
+ /**
167
+ * Copy template directory recursively using fs-extra
168
+ * Handles the case where target is inside source by copying item-by-item
169
+ * @param {string} source - Source directory path
170
+ * @param {string} target - Target directory path
171
+ * @param {string} [excludePath] - Additional path to exclude (used to prevent copying target into itself)
172
+ */
173
+ export async function copyDirectory(source, target, excludePath = null) {
174
+ const resolvedSource = path.resolve(source)
175
+ const resolvedTarget = path.resolve(target)
176
+ const resolvedExcludePath = excludePath ? path.resolve(excludePath) : null
177
+
178
+ // Check if target is inside source - if so, we need to copy item-by-item
179
+ const targetIsInsideSource = resolvedTarget.startsWith(resolvedSource + path.sep)
180
+
181
+ if (targetIsInsideSource) {
182
+ // Copy each item individually, skipping the target directory
183
+ await fse.ensureDir(target)
184
+ const entries = await fse.readdir(source, { withFileTypes: true })
185
+
186
+ for (const entry of entries) {
187
+ const sourcePath = path.join(source, entry.name)
188
+ const targetPath = path.join(target, entry.name)
189
+ const resolvedSourcePath = path.resolve(sourcePath)
190
+
191
+ // Skip if this would create a loop
192
+ if (resolvedSourcePath === resolvedTarget || resolvedSourcePath.startsWith(resolvedTarget + path.sep)) {
193
+ continue
194
+ }
195
+
196
+ // Skip excluded items
197
+ if (shouldExclude(entry.name)) {
198
+ continue
199
+ }
200
+
201
+ // Skip exclude path
202
+ if (resolvedExcludePath && resolvedSourcePath === resolvedExcludePath) {
203
+ continue
204
+ }
205
+
206
+ if (entry.isDirectory()) {
207
+ await copyDirectory(sourcePath, targetPath, excludePath)
208
+ } else {
209
+ await fse.copy(sourcePath, targetPath)
210
+ }
211
+ }
212
+ } else {
213
+ // Normal copy with filter
214
+ await fse.copy(source, target, {
215
+ filter: (src) => {
216
+ const resolvedSrc = path.resolve(src)
217
+ const basename = path.basename(src)
218
+
219
+ // Skip excluded items (both exact matches and patterns)
220
+ if (shouldExclude(basename)) {
221
+ return false
222
+ }
223
+
224
+ // Skip if this is the exclude path
225
+ if (resolvedExcludePath && resolvedSrc === resolvedExcludePath) {
226
+ return false
227
+ }
228
+
229
+ return true
230
+ }
231
+ })
232
+ }
233
+ }
234
+
235
+
236
+ /**
237
+ * Recursively get all files in a directory, excluding certain patterns
238
+ * @param {string} dir - Directory to scan
239
+ * @param {string[]} excludePatterns - Patterns to exclude (e.g., 'node_modules', '.git')
240
+ * @returns {Promise<string[]>} - Array of file paths
241
+ */
242
+ async function getAllFiles(dir, excludePatterns = []) {
243
+ const entries = await fse.readdir(dir, { withFileTypes: true })
244
+ const files = []
245
+
246
+ for (const entry of entries) {
247
+ const fullPath = path.join(dir, entry.name)
248
+
249
+ // Skip excluded patterns
250
+ if (excludePatterns.some(pattern => entry.name === pattern || entry.name.startsWith(pattern))) {
251
+ continue
252
+ }
253
+
254
+ if (entry.isDirectory()) {
255
+ // Recursively get files from subdirectories
256
+ const subFiles = await getAllFiles(fullPath, excludePatterns)
257
+ files.push(...subFiles)
258
+ } else {
259
+ files.push(fullPath)
260
+ }
261
+ }
262
+
263
+ return files
264
+ }
265
+
266
+ /**
267
+ * Replace template placeholders in all files (NEW placeholder-based approach)
268
+ * This is the simplified replacement that works with placeholder-based templates
269
+ *
270
+ * @param {string} targetDir - The target directory
271
+ * @param {string} namespace - The namespace (e.g., "Acme")
272
+ * @param {string} projectName - The project name (snake_case)
273
+ * @param {string} description - The project description
274
+ * @param {string[]} additionalFiles - Optional additional files to process
275
+ */
276
+ export async function replacePlaceholdersInFiles(targetDir, namespace, projectName, description, additionalFiles = []) {
277
+ const { replaceInFile: replaceInFilePlaceholder } = await import('./templating.js')
278
+ const spinner = ora('Replacing template placeholders...').start()
279
+
280
+ const directoryName = `${namespace.toLowerCase()}_${projectName}`
281
+ const values = {
282
+ namespace,
283
+ projectName,
284
+ directoryName,
285
+ description
286
+ }
287
+
288
+ // Get all files in the target directory, excluding certain patterns
289
+ const allFiles = await getAllFiles(targetDir, EXCLUDE_PATTERNS)
290
+
291
+ // Add any additional files specified (e.g., root files for init)
292
+ const additionalFilePaths = additionalFiles.map(file => path.join(targetDir, file))
293
+ const filesToProcess = [...allFiles, ...additionalFilePaths]
294
+
295
+ // Apply placeholder replacements to all files
296
+ let replacedCount = 0
297
+ for (const filePath of filesToProcess) {
298
+ const wasReplaced = await replaceInFilePlaceholder(filePath, values)
299
+ if (wasReplaced) {
300
+ replacedCount++
301
+ }
302
+ }
303
+
304
+ spinner.succeed(`Template placeholders replaced (${replacedCount} files updated)`)
305
+ }
306
+
307
+ /**
308
+ * Cleanup temporary directory
309
+ * Now uses directories within project scope for maximum safety
310
+ */
311
+ export async function cleanup(tempDir) {
312
+ const basename = path.basename(tempDir)
313
+
314
+ // Safety check: only remove directories that look like our temp directories
315
+ if (!basename.startsWith('.temp-')) {
316
+ throw new Error(`Refusing to remove directory that doesn't look like a temp directory: ${tempDir}`)
317
+ }
318
+
319
+ // Additional safety: basename must be exactly .temp-{8 hex chars}
320
+ if (!/^\.temp-[a-f0-9]{8}$/.test(basename)) {
321
+ throw new Error(`Invalid temp directory format: ${tempDir}`)
322
+ }
323
+
324
+ const spinner = ora('Cleaning up...').start()
325
+ console.log(chalk.gray(` Removing temporary directory: ${basename}`))
326
+ await fse.remove(tempDir)
327
+ spinner.succeed('Cleanup complete')
328
+ }
329
+
330
+ /**
331
+ * Display next steps after project initialization or template addition
332
+ * @param {string} targetDir - The target directory where files were created
333
+ * @param {string} projectName - The project name
334
+ * @param {string} templateType - The template type key
335
+ * @param {boolean} isInit - Whether this is for init (true) or add (false) command
336
+ */
337
+ export function displayNextSteps(targetDir, projectName, templateType, isInit = false) {
338
+ // Calculate relative paths from current working directory
339
+ const relativePath = path.relative(process.cwd(), targetDir)
340
+ const cdCommand = relativePath || '.'
341
+
342
+ const projectPath = path.join(targetDir, projectName)
343
+ const relativeProjectPath = path.relative(process.cwd(), projectPath)
344
+
345
+ const commitMessage = isInit
346
+ ? `"Initial commit: Add ${projectName} project"`
347
+ : `"Add ${projectName} template"`
348
+
349
+ console.log(chalk.cyan('Add files to git:'))
350
+ console.log(chalk.white(` 1. cd ${cdCommand}`))
351
+ console.log(chalk.white(` 2. git add .`))
352
+ console.log(chalk.white(` 3. git commit -m ${commitMessage}`))
353
+ console.log()
354
+ console.log(chalk.cyan('Run the project:'))
355
+ console.log(chalk.white(` 1. cd ${relativeProjectPath}`))
356
+ console.log(chalk.white(` 2. npm ci`))
357
+ console.log(chalk.white(` 3. npm run ${TEMPLATES[templateType].command}`))
358
+ console.log()
359
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Template placeholder replacement utilities
3
+ *
4
+ * This module handles replacing placeholders in template files with actual values.
5
+ * Uses a simple placeholder approach (__PLACEHOLDER__) instead of complex pattern matching.
6
+ */
7
+
8
+ import fse from 'fs-extra'
9
+ import path from 'path'
10
+ import { SKIP_PLACEHOLDER_REPLACEMENT } from './constants.js'
11
+
12
+ /**
13
+ * Convert snake_case to PascalCase
14
+ * @param {string} str - Snake case string
15
+ * @returns {string} PascalCase string
16
+ * @example toPascalCase('my_custom_plugin') // 'MyCustomPlugin'
17
+ */
18
+ function toPascalCase(str) {
19
+ return str
20
+ .replace(/[-_](.)/g, (_, char) => char.toUpperCase())
21
+ .replace(/^(.)/, (_, char) => char.toUpperCase())
22
+ }
23
+
24
+ /**
25
+ * Available placeholder types and their transformations
26
+ *
27
+ * Placeholders in template files (case-insensitive):
28
+ * - TPL_NAMESPACE / tpl_namespace - Original namespace (e.g., "Acme")
29
+ * - TPL_NAMESPACE_LOWER / tpl_namespace_lower - Lowercase namespace (e.g., "acme")
30
+ * - TPL_PROJECT_NAME / tpl_project_name - Project name in snake_case (e.g., "my_custom_plugin")
31
+ * - TPL_PROJECT_NAME_PASCAL / tpl_project_name_pascal - Project name in PascalCase (e.g., "MyPlugin")
32
+ * - TPL_PROJECT_NAME_SLUG / tpl_project_name_slug - Project name in kebab-case (e.g., "my-plugin")
33
+ * - TPL_PROJECT_NAME_FLAT / tpl_project_name_flat - Project name with no separators (e.g., "myplugin")
34
+ * - TPL_FULL_PROJECT_NAME / tpl_full_project_name - Full project name (e.g., "acme_my_custom_plugin")
35
+ * - TPL_FULL_PROJECT_NAME_SLUG / tpl_full_project_name_slug - Full name kebab-case (e.g., "acme-my-custom-plugin")
36
+ * - TPL_FULL_PROJECT_NAME_TITLE / tpl_full_project_name_title - Title Case (e.g., "Acme My Custom Plugin")
37
+ * - TPL_FULL_PROJECT_NAME_UPPER / tpl_full_project_name_upper - UPPER CASE (e.g., "ACME MY CUSTOM PLUGIN")
38
+ * - TPL_DESCRIPTION / tpl_description - Human-readable description (e.g., "My Custom Plugin")
39
+ * - pkg_* (wildcard) - Any pkg_* gets replaced with full project name slug
40
+ */
41
+
42
+ /**
43
+ * Replace all placeholders in content with actual values
44
+ *
45
+ * @param {string} content - Template content containing placeholders
46
+ * @param {object} values - Values to replace placeholders with
47
+ * @param {string} values.namespace - Namespace (e.g., "Acme")
48
+ * @param {string} values.projectName - Project name in snake_case (e.g., "my_custom_plugin")
49
+ * @param {string} values.directoryName - Full directory name (e.g., "acme_my_custom_plugin")
50
+ * @param {string} values.description - Human-readable description
51
+ * @returns {string} Content with all placeholders replaced
52
+ *
53
+ * @example
54
+ * const content = 'namespace TPL_NAMESPACE\\TPL_PROJECT_NAME_PASCAL;'
55
+ * const result = replaceTemplatePlaceholders(content, {
56
+ * namespace: 'Acme',
57
+ * projectName: 'my_plugin',
58
+ * directoryName: 'acme_my_plugin',
59
+ * description: 'My Plugin'
60
+ * })
61
+ * // Returns: 'namespace Acme\\MyPlugin;'
62
+ */
63
+ export function replaceTemplatePlaceholders(content, values) {
64
+ const { namespace, projectName, description } = values
65
+
66
+ // Get all placeholder values using the centralised function
67
+ const placeholders = getPlaceholderValues(namespace, projectName, description)
68
+
69
+ // Perform all replacements
70
+ // Sort keys by length (longest first) to avoid partial replacements
71
+ // e.g., replace TPL_NAMESPACE_LOWER before TPL_NAMESPACE
72
+ let result = content
73
+ const sortedKeys = Object.keys(placeholders).sort((a, b) => b.length - a.length)
74
+
75
+ // Replace each placeholder (both uppercase and lowercase variants)
76
+ for (const key of sortedKeys) {
77
+ const value = placeholders[key]
78
+ const placeholderUpper = `TPL_${key}`
79
+ const placeholderLower = placeholderUpper.toLowerCase()
80
+
81
+ // Escape regex special chars and replace both variants
82
+ result = result.replace(new RegExp(placeholderUpper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
83
+ result = result.replace(new RegExp(placeholderLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
84
+ }
85
+
86
+ // Replace pkg_* wildcard with full project name slug
87
+ result = result.replace(/pkg_[a-z_]*/g, placeholders.FULL_PROJECT_NAME_SLUG)
88
+
89
+ return result
90
+ }
91
+
92
+ /**
93
+ * Get all available placeholder values for a given project
94
+ * Useful for debugging or displaying what values will be used
95
+ *
96
+ * @param {string} namespace - Namespace (e.g., "Acme")
97
+ * @param {string} projectName - Project name in snake_case
98
+ * @param {string} description - Human-readable description
99
+ * @returns {object} Object with all placeholder values
100
+ *
101
+ * @example
102
+ * const values = getPlaceholderValues('Acme', 'my_plugin', 'My Plugin')
103
+ * console.log(values)
104
+ * // {
105
+ * // NAMESPACE: 'Acme',
106
+ * // NAMESPACE_LOWER: 'acme',
107
+ * // PROJECT_NAME: 'my_plugin',
108
+ * // PROJECT_NAME_PASCAL: 'MyPlugin',
109
+ * // ...
110
+ * // }
111
+ */
112
+ export function getPlaceholderValues(namespace, projectName, description) {
113
+ const fullProjectName = `${namespace.toLowerCase()}_${projectName}`
114
+
115
+ return {
116
+ NAMESPACE: namespace,
117
+ NAMESPACE_LOWER: namespace.toLowerCase(),
118
+ PROJECT_NAME: projectName,
119
+ PROJECT_NAME_PASCAL: toPascalCase(projectName),
120
+ PROJECT_NAME_SLUG: projectName.replace(/_/g, '-'),
121
+ PROJECT_NAME_FLAT: projectName.replace(/_/g, ''),
122
+ FULL_PROJECT_NAME: fullProjectName,
123
+ FULL_PROJECT_NAME_SLUG: fullProjectName.replace(/_/g, '-'),
124
+ FULL_PROJECT_NAME_TITLE: fullProjectName
125
+ .split('_')
126
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
127
+ .join(' '),
128
+ FULL_PROJECT_NAME_UPPER: fullProjectName
129
+ .split('_')
130
+ .map(word => word.toUpperCase())
131
+ .join(' '),
132
+ DESCRIPTION: description
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Replace placeholders in a single file
138
+ *
139
+ * @param {string} filePath - Path to the file
140
+ * @param {object} values - Placeholder values
141
+ * @returns {Promise<boolean>} True if file was modified, false otherwise
142
+ */
143
+ export async function replaceInFile(filePath, values) {
144
+ try {
145
+ // Check if this file should be skipped
146
+ const fileName = path.basename(filePath)
147
+ if (SKIP_PLACEHOLDER_REPLACEMENT.includes(fileName)) {
148
+ return false
149
+ }
150
+
151
+ const content = await fse.readFile(filePath, 'utf-8')
152
+ const updated = replaceTemplatePlaceholders(content, values)
153
+
154
+ if (content !== updated) {
155
+ await fse.writeFile(filePath, updated, 'utf-8')
156
+ return true
157
+ }
158
+
159
+ return false
160
+ } catch (error) {
161
+ // Ignore binary files or read errors
162
+ if (error.code !== 'EISDIR' && error.code !== 'ENOENT') {
163
+ console.warn(`Warning: Could not process ${filePath}: ${error.message}`)
164
+ }
165
+ return false
166
+ }
167
+ }