@garyr/pt-cli 1.1.1 → 1.3.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/index.js CHANGED
@@ -65,15 +65,33 @@ program
65
65
  }
66
66
  });
67
67
  program
68
- .command('init [templateName] [destPath]')
69
- .description('Initialize a new project from a learned template')
68
+ .command('init [args...]')
69
+ .description('Initialize a new project from one or more learned templates')
70
70
  .option('-f, --file <jsonPath>', 'Initialize directly from a JSON template file without adding it to local config')
71
71
  .option('--skip-post-config', 'Skip running post-config tasks')
72
72
  .option('--dry-run', 'Show what would be created without making changes')
73
73
  .option('-y, --yes', 'Automatically answer yes to prompts')
74
74
  .option('--vars <variables>', 'Comma-separated key=value variables (e.g. key1=val1,key2=val2)')
75
- .action(async (typeName, destPath, options) => {
76
- await init(typeName, destPath, options);
75
+ .option('--collision <mode>', 'File collision resolution strategy (overwrite, newest)', 'overwrite')
76
+ .option('--json', 'Output result as JSON')
77
+ .action(async (args, options) => {
78
+ try {
79
+ await init(args, options);
80
+ }
81
+ catch (err) {
82
+ if (options.json) {
83
+ process.stdout.write(JSON.stringify({
84
+ status: 'error',
85
+ message: err.message || String(err)
86
+ }) + '\n', () => {
87
+ process.exit(1);
88
+ });
89
+ }
90
+ else {
91
+ console.error(chalk.red(`Error: ${err.message || err}`));
92
+ process.exit(1);
93
+ }
94
+ }
77
95
  });
78
96
  program
79
97
  .command('config [templateName]')
@@ -34,44 +34,62 @@ export function substituteVariables(content, variables, maxIterations = 10) {
34
34
  /**
35
35
  * Processes copy_files tasks from a template.
36
36
  */
37
- export async function processCopyFiles(templateRoot, resolvedDest, template, variables, dryRun = false) {
37
+ export async function processCopyFiles(templateRoot, resolvedDest, template, variables, dryRun = false, collisionMode = 'overwrite', silent = false) {
38
38
  if (!template.copy_files)
39
39
  return;
40
40
  for (const copyFile of template.copy_files) {
41
41
  const srcPath = path.join(templateRoot, copyFile.src);
42
42
  const destPath = path.join(resolvedDest, sanitizePath(copyFile.dest));
43
43
  if (!fs.existsSync(srcPath)) {
44
- console.warn(chalk.yellow(`Warning: ${copyFile.src} not found in template`));
44
+ if (!silent)
45
+ console.warn(chalk.yellow(`Warning: ${copyFile.src} not found in template`));
45
46
  continue;
46
47
  }
47
48
  const stat = fs.statSync(srcPath);
48
49
  if (stat.isDirectory()) {
49
50
  // Recursive directory copy
50
51
  if (dryRun) {
51
- console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
52
+ if (!silent)
53
+ console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
52
54
  }
53
55
  else {
54
56
  const dirSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
55
57
  template.variables &&
56
58
  template.variables.length > 0 &&
57
59
  Object.keys(variables).length > 0));
58
- copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
60
+ copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod, collisionMode, silent);
59
61
  }
60
- console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
62
+ if (!silent)
63
+ console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
61
64
  }
62
65
  else {
66
+ // Check collision mode
67
+ if (collisionMode === 'newest' && fs.existsSync(destPath)) {
68
+ const destStat = fs.statSync(destPath);
69
+ if (destStat.mtimeMs > stat.mtimeMs) {
70
+ if (dryRun && !silent) {
71
+ console.log(chalk.yellow(` [DRY RUN] [COLLISION] Destination is newer, keeping ${copyFile.dest}`));
72
+ }
73
+ else if (!silent) {
74
+ console.log(chalk.yellow(` [COLLISION] Destination is newer, keeping ${copyFile.dest}`));
75
+ }
76
+ continue;
77
+ }
78
+ }
63
79
  // Single file copy
64
80
  if (dryRun) {
65
- console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
66
- const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
67
- template.variables &&
68
- template.variables.length > 0 &&
69
- Object.keys(variables).length > 0));
70
- if (drySubstitute) {
71
- console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
72
- }
73
- if (copyFile.chmod) {
74
- console.log(chalk.gray(` [DRY RUN] Would chmod ${copyFile.chmod} ${copyFile.dest}`));
81
+ if (!silent) {
82
+ console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} ${copyFile.dest}`));
83
+ const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
84
+ template.variables &&
85
+ template.variables.length > 0 &&
86
+ Object.keys(variables).length > 0));
87
+ if (drySubstitute) {
88
+ console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
89
+ }
90
+ if (copyFile.chmod) {
91
+ console.log(chalk.gray(` [DRY RUN] Would chmod ${copyFile.chmod} ${copyFile.dest}`));
92
+ }
75
93
  }
76
94
  continue;
77
95
  }
@@ -93,25 +111,33 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
93
111
  fs.chmodSync(destPath, parseInt(copyFile.chmod, 8));
94
112
  }
95
113
  catch (e) {
96
- if (process.platform !== 'win32') {
114
+ if (process.platform !== 'win32' && !silent) {
97
115
  console.error(chalk.red(`Failed to set chmod ${copyFile.chmod} on ${copyFile.dest}`));
98
116
  }
99
117
  }
100
118
  }
101
- console.log(chalk.green(` ✓ ${copyFile.dest}`));
119
+ if (!silent)
120
+ console.log(chalk.green(` ✓ ${copyFile.dest}`));
102
121
  }
103
122
  }
104
123
  }
105
- function copyDirRecursive(src, dest, variables, substitute, chmod) {
124
+ function copyDirRecursive(src, dest, variables, substitute, chmod, collisionMode = 'overwrite', silent = false) {
106
125
  fs.mkdirSync(dest, { recursive: true });
107
126
  const entries = fs.readdirSync(src, { withFileTypes: true });
108
127
  for (const entry of entries) {
109
128
  const srcPath = path.join(src, entry.name);
110
129
  const destPath = path.join(dest, entry.name);
111
130
  if (entry.isDirectory()) {
112
- copyDirRecursive(srcPath, destPath, variables, substitute, chmod);
131
+ copyDirRecursive(srcPath, destPath, variables, substitute, chmod, collisionMode, silent);
113
132
  }
114
133
  else {
134
+ if (collisionMode === 'newest' && fs.existsSync(destPath)) {
135
+ const destStat = fs.statSync(destPath);
136
+ const srcStat = fs.statSync(srcPath);
137
+ if (destStat.mtimeMs > srcStat.mtimeMs) {
138
+ continue;
139
+ }
140
+ }
115
141
  let content = fs.readFileSync(srcPath, 'utf-8');
116
142
  if (substitute) {
117
143
  content = substituteVariables(content, variables);
@@ -161,14 +161,16 @@ Each task supports:
161
161
  | `script` | Path to script relative to template root |
162
162
  | `cross_platform` | If `true`, use platform-safe runner |
163
163
 
164
- **Interaction flow** during `pt init`:
164
+ **Interaction flow** during `pt init` (single or multiple templates):
165
165
 
166
166
  1. Folder structure created
167
- 2. If template has `post_config`:
167
+ 2. If template(s) have `post_config`:
168
168
  - Filter tasks by project type
169
- - Show list: `[1/3] git init` ...
170
- - Prompt: `Run post-config tasks? (y/N)`
171
- - If yes: run each task, show ✓/✗ per task
169
+ - **Aggregate security check** all templates' warnings shown in one prompt
170
+ - If warnings exist: prompt `Security warnings found in N template(s). Run post-config tasks anyway? (y/N)`
171
+ - Show unified task list with checkboxes (duplicates merged, template attribution shown)
172
+ - Prompt: `Select post-config tasks to run:`
173
+ - Run selected tasks, show ✓/✗ per task
172
174
  3. If no `post_config`, suggest baked-in defaults:
173
175
  - Prompt: `No post-config defined. Use suggested tasks?`
174
176
  4. If `--skip-post-config` flag: skip entirely
package/doc/usage.md CHANGED
@@ -124,22 +124,39 @@ project=MyProject
124
124
  ## Initialize a project
125
125
 
126
126
  ```bash
127
- # Initialize from a template (auto-suggests post-config tasks)
128
- pt init <template_name> /path/to/new/PROJECT
127
+ # Initialize from one or more templates (auto-suggests post-config tasks)
128
+ pt init <template_name> [template_name2...] /path/to/new/PROJECT
129
129
 
130
130
  # Skip post-config tasks
131
- pt init <template_name> /path/to/new/PROJECT --skip-post-config
131
+ pt init <template_name> [template_name2...] /path/to/new/PROJECT --skip-post-config
132
132
 
133
133
  # Dry run (preview actions without execution)
134
- pt init <template_name> /path/to/new/PROJECT --dry-run
134
+ pt init <template_name> [template_name2...] /path/to/new/PROJECT --dry-run
135
135
 
136
136
  # Non-interactive mode with variables (useful for an API or AI agents)
137
- pt init <template_name> /path/to/new/PROJECT --yes --vars project_name=foo,author=bar
137
+ pt init <template_name> [template_name2...] /path/to/new/PROJECT --yes --vars project_name=foo,author=bar
138
138
 
139
139
  # Initialize directly from a JSON template file (no config.yaml registration)
140
140
  pt init /path/to/new/PROJECT --file my-template.json --yes
141
141
  ```
142
142
 
143
+ ### Multi-Template Initialization
144
+
145
+ When you provide multiple template names, `pt` combines them into a single project:
146
+
147
+ - **Folder structures merged** — duplicate folder names are merged recursively
148
+ - **Variables merged** — duplicate variable names use the last template's defaults
149
+ - **Copy files merged** — files with same destination prompt for collision resolution
150
+ - **Post-config tasks deduplicated** — identical tasks (same command + description) run once, with template attribution shown in selection
151
+
152
+ ```bash
153
+ # Combine base template with addon
154
+ pt init base-template caddy-addon /path/to/new/PROJECT
155
+
156
+ # Interactive: you'll see one security prompt for all templates, then a unified task list
157
+ # --yes: all deduplicated tasks run automatically
158
+ ```
159
+
143
160
  ### Direct JSON Scaffolding (`--file`)
144
161
 
145
162
  The `--file` option allows you to scaffold a project directly from a JSON template file **without** registering it in your local `~/.pt/config.yaml`. This is ideal for:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,13 +15,14 @@ As an agent equipped with this skill, you have the ability to rapidly scaffold,
15
15
 
16
16
  2. **Scaffolding (`pt init`):**
17
17
  When a matching template exists, initialize it using the non-interactive flags. URL targets (GitHub, Gitea, etc.) are automatically translated to tarball downloads.
18
- - **Command:** `pt init <template_name> <destination_path> --yes`
19
- - If the template requires variables, pass them: `pt init <template_name> <destination_path> --yes --vars key1=value1,key2=value2`
18
+ - **Command:** `pt init <template_name> [template_name2...] <destination_path> --yes`
19
+ - Multiple templates can be combined: `pt init base-template caddy-addon /path/to/new/PROJECT --yes`
20
+ - If templates require variables, pass them: `pt init <template_name> [template_name2...] <destination_path> --yes --vars key1=value1,key2=value2`
20
21
  - **Direct JSON scaffolding:** To scaffold from a JSON template file without registering it in `config.yaml`:
21
22
  `pt init <destination_path> --file <json_path> --yes`
22
23
  - *Never* run `pt init` without `--yes`, as interactive prompts will block you.
23
- - **Dry-run:** Preview what would be created without making changes: `pt init <template_name> <destination_path> --yes --dry-run`
24
- - **Skip post-config:** Skip running post-config tasks: `pt init <template_name> <destination_path> --yes --skip-post-config`
24
+ - **Dry-run:** Preview what would be created without making changes: `pt init <template_name> [template_name2...] <destination_path> --yes --dry-run`
25
+ - **Skip post-config:** Skip running post-config tasks: `pt init <template_name> [template_name2...] <destination_path> --yes --skip-post-config`
25
26
  - Note any errors from auto-executed post-config tasks (like `npm install` failing) and correct them if necessary.
26
27
 
27
28
  3. **Capturing Knowledge (`pt learn`):**
@@ -66,8 +66,8 @@ _pt_completions() {
66
66
  ;;
67
67
  init)
68
68
  if [[ "$cur" == -* ]]; then
69
- COMPREPLY=( $(compgen -W "-f --file --skip-post-config --dry-run -y --yes --vars -h --help" -- "$cur") )
70
- elif [[ $cword -eq 2 ]]; then
69
+ COMPREPLY=( $(compgen -W "-f --file --skip-post-config --dry-run -y --yes --vars --collision --json -h --help" -- "$cur") )
70
+ elif [[ $cword -ge 2 ]]; then
71
71
  local templates
72
72
  templates=$(pt completion --templates 2>/dev/null)
73
73
  COMPREPLY=( $(compgen -W "$templates" -- "$cur") )
@@ -200,9 +200,10 @@ _pt() {
200
200
  '--dry-run[Show what would be created without making changes]' \\
201
201
  '(-y --yes)'{-y,--yes}'[Automatically answer yes to prompts]' \\
202
202
  '--vars=[Comma-separated key=value variables]:variables:' \\
203
+ '--collision=[File collision resolution strategy]:mode:(overwrite newest)' \\
204
+ '--json[Output result as JSON]' \\
203
205
  '(-h --help)'{-h,--help}'[display help for command]' \\
204
- '1:template:_pt_templates' \\
205
- '2:destPath:_files -/'
206
+ '*:templates:_pt_templates'
206
207
  ;;
207
208
  config)
208
209
  _arguments \\
@@ -329,6 +330,8 @@ complete -c pt -n '__fish_pt_using_command init' -l skip-post-config -d 'Skip ru
329
330
  complete -c pt -n '__fish_pt_using_command init' -l dry-run -d 'Show what would be created without making changes'
330
331
  complete -c pt -n '__fish_pt_using_command init' -s y -l yes -d 'Automatically answer yes to prompts'
331
332
  complete -c pt -n '__fish_pt_using_command init' -l vars -d 'Comma-separated key=value variables'
333
+ complete -c pt -n '__fish_pt_using_command init' -l collision -a 'overwrite newest' -d 'File collision resolution strategy'
334
+ complete -c pt -n '__fish_pt_using_command init' -l json -d 'Output result as JSON'
332
335
 
333
336
  # config
334
337
  complete -c pt -n '__fish_pt_using_command config' -a '(__fish_pt_templates)' -d 'Template name'