@garyr/pt-cli 0.25.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/dist/config.js ADDED
@@ -0,0 +1,283 @@
1
+ import YAML from 'yaml';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import chalk from 'chalk';
6
+ export const HOME_DIR = path.join(os.homedir(), '.pt');
7
+ export const CONFIG_PATH = path.join(HOME_DIR, 'config.yaml');
8
+ export function ensureConfigDir() {
9
+ if (!fs.existsSync(HOME_DIR)) {
10
+ fs.mkdirSync(HOME_DIR, { recursive: true });
11
+ }
12
+ }
13
+ export function loadConfig() {
14
+ ensureConfigDir();
15
+ if (!fs.existsSync(CONFIG_PATH)) {
16
+ const defaultConfig = {
17
+ version: '3.0',
18
+ templates: {},
19
+ default_post_config: [],
20
+ variables: []
21
+ };
22
+ // Don't automatically save a default config on load.
23
+ // This prevents accidental creation of mostly-empty configs
24
+ // if the home directory is temporarily mapped incorrectly.
25
+ return defaultConfig;
26
+ }
27
+ try {
28
+ const content = fs.readFileSync(CONFIG_PATH, 'utf-8');
29
+ if (!content.trim()) {
30
+ throw new Error("Config file is empty");
31
+ }
32
+ const config = YAML.parse(content);
33
+ // Initialize ignore for legacy configs that don't have it
34
+ if (config.ignore === undefined) {
35
+ config.ignore = [];
36
+ }
37
+ // Initialize default_post_config for configs that don't have it
38
+ if (config.default_post_config === undefined) {
39
+ // Migrate from global_post_config if it exists
40
+ if (config.global_post_config !== undefined) {
41
+ config.default_post_config = config.global_post_config;
42
+ delete config.global_post_config;
43
+ saveConfig(config);
44
+ }
45
+ else {
46
+ config.default_post_config = [];
47
+ }
48
+ }
49
+ // Initialize variables for configs that don't have it
50
+ if (config.variables === undefined) {
51
+ config.variables = [];
52
+ }
53
+ else if (!Array.isArray(config.variables)) {
54
+ // Migrate from Record<string, string> to TemplateVariable[]
55
+ const oldVars = config.variables;
56
+ config.variables = Object.entries(oldVars).map(([name, value]) => ({
57
+ name,
58
+ prompt: `Enter ${name}:`,
59
+ default: value
60
+ }));
61
+ }
62
+ // Migration from 2.0 to 3.0
63
+ if (config.version === '2.0') {
64
+ for (const key in config.templates) {
65
+ const t = config.templates[key];
66
+ if (t.description === undefined && t.name) {
67
+ t.description = t.name;
68
+ delete t.name;
69
+ }
70
+ if (t.type) {
71
+ delete t.type;
72
+ }
73
+ }
74
+ config.version = '3.0';
75
+ saveConfig(config);
76
+ console.log(`\nConfig migrated to version 3.0 (renamed 'name' to 'description', removed 'type')`);
77
+ }
78
+ return config;
79
+ }
80
+ catch (err) {
81
+ const error = err;
82
+ console.error(chalk.red(`\nError loading config: ${error.message}`));
83
+ // If we have a backup, maybe suggest using it
84
+ const backupPath = CONFIG_PATH + '.bak';
85
+ if (fs.existsSync(backupPath)) {
86
+ console.error(chalk.yellow(`A backup exists at ${backupPath}. You may want to restore it.`));
87
+ }
88
+ process.exit(1);
89
+ }
90
+ }
91
+ export function saveConfig(config) {
92
+ ensureConfigDir();
93
+ const content = YAML.stringify(config);
94
+ const tempPath = CONFIG_PATH + '.tmp';
95
+ const backupPath = CONFIG_PATH + '.bak';
96
+ try {
97
+ // 1. Create a backup of the current valid config if it exists
98
+ if (fs.existsSync(CONFIG_PATH)) {
99
+ fs.copyFileSync(CONFIG_PATH, backupPath);
100
+ }
101
+ // 2. Write to a temporary file first (atomic save)
102
+ fs.writeFileSync(tempPath, content);
103
+ // 3. Rename temp file to actual config path
104
+ fs.renameSync(tempPath, CONFIG_PATH);
105
+ }
106
+ catch (err) {
107
+ const error = err;
108
+ console.error(chalk.red(`\nFailed to save config: ${error.message}`));
109
+ if (fs.existsSync(tempPath)) {
110
+ try {
111
+ fs.unlinkSync(tempPath);
112
+ }
113
+ catch (e) { }
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+ export function getTemplateNames(config) {
119
+ return Object.keys(config.templates || {});
120
+ }
121
+ /**
122
+ * Get default post-config tasks with defaults applied for unchecked entries.
123
+ * Default tasks that have checked=true (or undefined, which defaults to true)
124
+ * will be auto-checked. Tasks with checked=false stay unchecked by default.
125
+ */
126
+ export function getDefaultPostConfig(config) {
127
+ const defaults = config.default_post_config || [];
128
+ return defaults.map(t => ({
129
+ ...t,
130
+ checked: t.checked !== false // default to true if not explicitly false
131
+ }));
132
+ }
133
+ // Default exclusions for template scanning
134
+ export const DEFAULT_EXCLUDES = [
135
+ '.git',
136
+ '.gitea',
137
+ '.vscode',
138
+ 'node_modules',
139
+ 'dist',
140
+ 'build',
141
+ 'bin',
142
+ '.DS_Store',
143
+ 'Thumbs.db',
144
+ '.stignore',
145
+ '.stfolder',
146
+ '.stversions',
147
+ ];
148
+ // Check if a path should be excluded
149
+ export function shouldExclude(dirPath, fullPath, excludes) {
150
+ const name = path.basename(fullPath); // Check the entry name, not the parent dir
151
+ const allExcludes = [...DEFAULT_EXCLUDES, ...(excludes || [])];
152
+ // Check if any entry is a git submodule
153
+ if (name === '.git' && fs.existsSync(path.join(fullPath, 'modules'))) {
154
+ return true;
155
+ }
156
+ // Check for submodules in the parent
157
+ const gitmodulesPath = path.join(fullPath, '..', '.gitmodules');
158
+ if (fs.existsSync(gitmodulesPath)) {
159
+ try {
160
+ const gitmodules = fs.readFileSync(gitmodulesPath, 'utf-8');
161
+ const regex = new RegExp(`path = ${name}\\s*$`, 'm');
162
+ if (regex.test(gitmodules)) {
163
+ return true;
164
+ }
165
+ }
166
+ catch (e) {
167
+ // Ignore errors reading gitmodules
168
+ }
169
+ }
170
+ return allExcludes.includes(name);
171
+ }
172
+ // Check if a folder should be ignored based on ignore patterns.
173
+ // Patterns support glob-style wildcards:
174
+ // DAILIES/* - ignore everything inside DAILIES (DAILIES itself is kept)
175
+ // DAILIES/** - same as DAILIES/* (deep match from root)
176
+ // **/FOLDER/ - ignore any folder named FOLDER at any depth
177
+ // FOLDER - ignore this specific folder (at root or as name)
178
+ export function shouldIgnore(folderName, relativePath, ignorePatterns) {
179
+ if (!ignorePatterns || ignorePatterns.length === 0)
180
+ return false;
181
+ // Normalize relativePath to use forward slashes for consistent pattern matching
182
+ const normalizedPath = relativePath.split(path.sep).join('/');
183
+ const parts = normalizedPath.split('/');
184
+ for (let pattern of ignorePatterns) {
185
+ // Normalize pattern slashes
186
+ pattern = pattern.split(/[/\\]/).join('/');
187
+ // 1. Deep match: "**/NAME" or "**/NAME/"
188
+ if (pattern.startsWith('**/')) {
189
+ let target = pattern.substring(3);
190
+ if (target.endsWith('/'))
191
+ target = target.slice(0, -1);
192
+ // Match if any segment of the path matches the target
193
+ if (parts.includes(target))
194
+ return true;
195
+ continue;
196
+ }
197
+ // 2. Wildcard children at root: "FOLDER/*" or "FOLDER/**"
198
+ // Matches children of the named folder, NOT the folder itself
199
+ if (pattern.endsWith('/*') || pattern.endsWith('/**')) {
200
+ const suffix = pattern.endsWith('/**') ? 3 : 2;
201
+ const parentName = pattern.slice(0, -suffix);
202
+ if (parts[0] === parentName && parts.length > 1) {
203
+ return true;
204
+ }
205
+ continue;
206
+ }
207
+ // 3. Exact match (name or path)
208
+ let cleanPattern = pattern;
209
+ if (cleanPattern.endsWith('/'))
210
+ cleanPattern = cleanPattern.slice(0, -1);
211
+ if (folderName === cleanPattern || normalizedPath === cleanPattern) {
212
+ return true;
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+ // Check if a file should be excluded (e.g., .gitignore patterns)
218
+ export function shouldExcludeFile(fileName) {
219
+ const excludePatterns = [
220
+ '*.pyc',
221
+ '*.pyo',
222
+ '*.pyd',
223
+ '.Python',
224
+ '*.egg-info',
225
+ '*.egg',
226
+ '*.whl',
227
+ '*.so',
228
+ '*.dll',
229
+ '*.dylib',
230
+ '*.exe',
231
+ '*.o',
232
+ '*.a',
233
+ '*.lib',
234
+ '*.class',
235
+ '*.jar',
236
+ '*.war',
237
+ '*.ear',
238
+ '*.log',
239
+ '*.tmp',
240
+ '*.swp',
241
+ '*.swo',
242
+ '*~',
243
+ '.bak',
244
+ '*.md',
245
+ '*.txt',
246
+ '*.json',
247
+ '*.yaml',
248
+ '*.yml',
249
+ '*.ini',
250
+ '*.conf',
251
+ '*.config',
252
+ '.gitconfig',
253
+ '.makerc',
254
+ 'Gemfile.lock',
255
+ 'package.json',
256
+ 'package-lock.json',
257
+ 'yarn.lock',
258
+ 'pnpm-lock.yaml',
259
+ 'composer.json',
260
+ 'composer.lock',
261
+ ];
262
+ for (const pattern of excludePatterns) {
263
+ if (pattern.startsWith('*')) {
264
+ const ext = pattern.substring(1);
265
+ if (fileName.endsWith(ext)) {
266
+ return true;
267
+ }
268
+ }
269
+ else if (fileName === pattern) {
270
+ return true;
271
+ }
272
+ }
273
+ return false;
274
+ }
275
+ // Sanitize path to prevent traversal
276
+ export function sanitizePath(p) {
277
+ // Remove any path segments that attempt to go up, and trim whitespace
278
+ const segments = p.split(/[/\\]/);
279
+ const safeSegments = segments
280
+ .map(s => s.trim())
281
+ .filter(s => s !== '..' && s !== '.' && s !== '');
282
+ return safeSegments.join(path.sep);
283
+ }
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ // Command imports
4
+ import { learn } from './commands/learnCommand.js';
5
+ import { init } from './commands/initCommand.js';
6
+ import { configCommand } from './commands/configCommand.js';
7
+ import { ignoreCommand } from './commands/ignoreCommand.js';
8
+ import { variablesCommand } from './commands/variablesCommand.js';
9
+ import { addCommand } from './commands/addCommand.js';
10
+ import { removeCommand } from './commands/removeCommand.js';
11
+ import pkg from '../package.json' with { type: 'json' };
12
+ const program = new Command();
13
+ program
14
+ .name('pt')
15
+ .description('Project Template CLI - Learn project structures and initialize new ones')
16
+ .version(pkg.version, '-v', 'output the version number');
17
+ program
18
+ .command('learn [path]')
19
+ .description('Learn a project structure from an existing directory')
20
+ .option('--ignore <patterns>', 'Folder patterns to ignore (comma-separated)')
21
+ .option('-y, --yes', 'Automatically confirm prompts')
22
+ .option('--name <name>', 'Template name (skip prompt)')
23
+ .option('--desc <description>', 'Template description (skip prompt)')
24
+ .option('--json', 'Output template structure as JSON for sharing instead of saving')
25
+ .action(async (pathArg, options) => {
26
+ await learn(pathArg || '.', null, options);
27
+ });
28
+ program
29
+ .command('update <templateName> [sourcePath]')
30
+ .description('Update an existing template with new structure/files')
31
+ .option('--ignore <patterns>', 'Folder patterns to ignore (comma-separated)')
32
+ .option('-y, --yes', 'Automatically confirm prompts')
33
+ .option('--desc <description>', 'Template description (skip prompt)')
34
+ .action(async (templateName, sourcePath, options) => {
35
+ await learn(sourcePath || '.', templateName, options);
36
+ });
37
+ program
38
+ .command('init [templateName] [destPath]')
39
+ .description('Initialize a new project from a learned template')
40
+ .option('--skip-post-config', 'Skip running post-config tasks')
41
+ .option('--dry-run', 'Show what would be created without making changes')
42
+ .option('-y, --yes', 'Automatically answer yes to prompts')
43
+ .option('--vars <variables>', 'Comma-separated key=value variables (e.g. key1=val1,key2=val2)')
44
+ .action(async (typeName, destPath, options) => {
45
+ await init(typeName, destPath, options);
46
+ });
47
+ program
48
+ .command('config [templateName]')
49
+ .description('Show current config location and list templates, or export a specific template')
50
+ .option('--json', 'Output config or specific template as JSON')
51
+ .action(configCommand);
52
+ program
53
+ .command('ignore [patterns]')
54
+ .description('View or set global ignore patterns (comma-separated)')
55
+ .option('--set', 'Set the ignore patterns to the provided value')
56
+ .action(ignoreCommand);
57
+ program
58
+ .command('variables [pairs]')
59
+ .description('View or set global variables (comma-separated key=value)')
60
+ .option('--set', 'Set the variables to the provided pairs')
61
+ .option('--json <data>', 'Set variables via JSON string or file')
62
+ .option('--delete <key>', 'Delete a specific global variable')
63
+ .action(variablesCommand);
64
+ program
65
+ .command('add <name> [json]')
66
+ .description('Import/add a template from a JSON string or file')
67
+ .option('-f, --file <path>', 'Path to JSON file containing template data')
68
+ .action(addCommand);
69
+ program
70
+ .command('remove <template>')
71
+ .alias('rm')
72
+ .description('Remove a learned template from the config')
73
+ .option('-y, --yes', 'Automatically confirm removal')
74
+ .action(removeCommand);
75
+ program.parse(process.argv);
@@ -0,0 +1,73 @@
1
+ import { execSync } from 'child_process';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ /**
5
+ * Runs post-configuration tasks for a project.
6
+ */
7
+ export async function runPostConfig(destPath, tasks, projectType, options = {}) {
8
+ if (options.skipPostConfig)
9
+ return;
10
+ // 1. Filter tasks by type
11
+ const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
12
+ if (applicableTasks.length === 0) {
13
+ return;
14
+ }
15
+ // 2. Ask user (skip in dryRun or yes mode)
16
+ let run = false;
17
+ if (options.dryRun) {
18
+ console.log(chalk.yellow(`\n[DRY RUN] Applicable post-config tasks:`));
19
+ run = true;
20
+ }
21
+ else if (options.yes) {
22
+ run = true;
23
+ }
24
+ else {
25
+ const response = await inquirer.prompt({
26
+ type: 'confirm',
27
+ name: 'run',
28
+ message: 'Run post-config tasks?',
29
+ default: false
30
+ });
31
+ run = response.run;
32
+ }
33
+ if (!run)
34
+ return;
35
+ // 3. Show and run each task
36
+ for (let i = 0; i < applicableTasks.length; i++) {
37
+ const task = applicableTasks[i];
38
+ const progress = `[${i + 1}/${applicableTasks.length}]`;
39
+ if (task.command) {
40
+ if (options.dryRun) {
41
+ console.log(chalk.gray(` [DRY RUN] Would run: ${task.command}`));
42
+ }
43
+ else {
44
+ try {
45
+ console.log(chalk.yellow(`\n${progress} Running: ${task.command}`));
46
+ execSync(task.command, {
47
+ cwd: destPath,
48
+ stdio: 'inherit'
49
+ });
50
+ console.log(chalk.green(' ✓ Command completed successfully'));
51
+ }
52
+ catch (err) {
53
+ console.log(chalk.red(' ✗ Command failed'));
54
+ }
55
+ }
56
+ }
57
+ if (task.script) {
58
+ if (options.dryRun) {
59
+ console.log(chalk.gray(` [DRY RUN] Would run script: ${task.script}`));
60
+ }
61
+ else {
62
+ try {
63
+ process.stdout.write(`${progress} ${task.script} `);
64
+ // Logic to run script would go here
65
+ console.log(chalk.yellow('(not yet implemented)'));
66
+ }
67
+ catch (err) {
68
+ console.log(chalk.red('✗'));
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,98 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { sanitizePath } from './config.js';
5
+ /**
6
+ * Replaces all {{var}} patterns in the content with values from the variables object.
7
+ */
8
+ export function substituteVariables(content, variables) {
9
+ return content.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, varName) => {
10
+ return variables[varName] ?? `{{${varName}}}`;
11
+ });
12
+ }
13
+ /**
14
+ * Processes copy_files tasks from a template.
15
+ */
16
+ export async function processCopyFiles(templateRoot, resolvedDest, template, variables, dryRun = false) {
17
+ if (!template.copy_files)
18
+ return;
19
+ for (const copyFile of template.copy_files) {
20
+ const srcPath = path.join(templateRoot, copyFile.src);
21
+ const destPath = path.join(resolvedDest, sanitizePath(copyFile.dest));
22
+ if (!fs.existsSync(srcPath)) {
23
+ console.warn(chalk.yellow(`Warning: ${copyFile.src} not found in template`));
24
+ continue;
25
+ }
26
+ const stat = fs.statSync(srcPath);
27
+ if (stat.isDirectory()) {
28
+ // Recursive directory copy
29
+ if (dryRun) {
30
+ console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
31
+ }
32
+ else {
33
+ copyDirRecursive(srcPath, destPath, variables, copyFile.substitute_variables || false, copyFile.chmod);
34
+ }
35
+ console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
36
+ }
37
+ else {
38
+ // Single file copy
39
+ if (dryRun) {
40
+ console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
41
+ if (copyFile.substitute_variables) {
42
+ console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
43
+ }
44
+ if (copyFile.chmod) {
45
+ console.log(chalk.gray(` [DRY RUN] Would chmod ${copyFile.chmod} ${copyFile.dest}`));
46
+ }
47
+ continue;
48
+ }
49
+ // Ensure destination directory exists
50
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
51
+ let content = fs.readFileSync(srcPath, 'utf-8');
52
+ if (copyFile.substitute_variables) {
53
+ content = substituteVariables(content, variables);
54
+ }
55
+ fs.writeFileSync(destPath, content);
56
+ if (copyFile.chmod) {
57
+ try {
58
+ fs.chmodSync(destPath, parseInt(copyFile.chmod, 8));
59
+ }
60
+ catch (e) {
61
+ if (process.platform !== 'win32') {
62
+ console.error(chalk.red(`Failed to set chmod ${copyFile.chmod} on ${copyFile.dest}`));
63
+ }
64
+ }
65
+ }
66
+ console.log(chalk.green(` ✓ ${copyFile.dest}`));
67
+ }
68
+ }
69
+ }
70
+ function copyDirRecursive(src, dest, variables, substitute, chmod) {
71
+ fs.mkdirSync(dest, { recursive: true });
72
+ const entries = fs.readdirSync(src, { withFileTypes: true });
73
+ for (const entry of entries) {
74
+ const srcPath = path.join(src, entry.name);
75
+ const destPath = path.join(dest, entry.name);
76
+ if (entry.isDirectory()) {
77
+ copyDirRecursive(srcPath, destPath, variables, substitute, chmod);
78
+ }
79
+ else {
80
+ let content = fs.readFileSync(srcPath, 'utf-8');
81
+ if (substitute) {
82
+ content = substituteVariables(content, variables);
83
+ }
84
+ fs.writeFileSync(destPath, content);
85
+ // Preserve execute permission if it exists in source
86
+ try {
87
+ const srcStat = fs.statSync(srcPath);
88
+ if (srcStat.mode & 0o111) {
89
+ fs.chmodSync(destPath, 0o755);
90
+ }
91
+ else if (chmod) {
92
+ fs.chmodSync(destPath, parseInt(chmod, 8));
93
+ }
94
+ }
95
+ catch (e) { }
96
+ }
97
+ }
98
+ }