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