@garyr/pt-cli 0.38.0 → 0.40.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 CHANGED
@@ -84,12 +84,22 @@ export function loadConfig() {
84
84
  catch (err) {
85
85
  const error = err;
86
86
  console.error(chalk.red(`\nError loading config: ${error.message}`));
87
- // If we have a backup, maybe suggest using it
88
87
  const backupPath = getConfigPath() + '.bak';
89
88
  if (fs.existsSync(backupPath)) {
90
89
  console.error(chalk.yellow(`A backup exists at ${backupPath}. You may want to restore it.`));
91
90
  }
92
- process.exit(1);
91
+ // Allow the event loop to flush console streams out to Godot before dying
92
+ setTimeout(() => {
93
+ process.exit(1);
94
+ }, 5);
95
+ // 👇 Add this return statement to satisfy the TypeScript compiler
96
+ // The application will terminate before this empty config can be used.
97
+ return {
98
+ version: '3.0',
99
+ templates: {},
100
+ default_post_config: [],
101
+ variables: []
102
+ };
93
103
  }
94
104
  }
95
105
  export function normalizeVariable(v) {
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  // Command imports
5
5
  import { learn } from './commands/learnCommand.js';
6
+ import { update } from './commands/updateCommand.js';
6
7
  import { init } from './commands/initCommand.js';
7
8
  import { configCommand } from './commands/configCommand.js';
8
9
  import { ignoreCommand } from './commands/ignoreCommand.js';
@@ -32,15 +33,18 @@ program
32
33
  }
33
34
  catch (err) {
34
35
  if (options.json) {
35
- console.log(JSON.stringify({
36
+ // Fix: Ensure the error JSON payload isn't truncated before exiting
37
+ process.stdout.write(JSON.stringify({
36
38
  type: 'error',
37
39
  message: err.message || String(err)
38
- }));
40
+ }) + '\n', () => {
41
+ process.exit(1);
42
+ });
39
43
  }
40
44
  else {
41
45
  console.error(chalk.red(`Error: ${err.message || err}`));
46
+ process.exit(1);
42
47
  }
43
- process.exit(1);
44
48
  }
45
49
  });
46
50
  program
@@ -49,9 +53,10 @@ program
49
53
  .option('--ignore <patterns>', 'Folder patterns to ignore (comma-separated)')
50
54
  .option('-y, --yes', 'Automatically confirm prompts')
51
55
  .option('--desc <description>', 'Template description (skip prompt)')
56
+ .option('--no-diff', 'Disable additive mode, show full list')
52
57
  .action(async (templateName, sourcePath, options) => {
53
58
  try {
54
- await learn(sourcePath || '.', templateName, options);
59
+ await update(sourcePath || '.', templateName, options);
55
60
  }
56
61
  catch (err) {
57
62
  console.error(chalk.red(`Error: ${err.message || err}`));
@@ -39,17 +39,20 @@ project=MyProject
39
39
  ```
40
40
 
41
41
  During initialization, the system will:
42
+
42
43
  1. Load `prefix='rst_{{ project }}'` from `.env`
43
44
  2. Detect that `prefix` contains a `{{ project }}` placeholder
44
45
  3. Resolve `{{ project }}` to `MyProject`
45
46
  4. Set `prefix` to `rst_MyProject`
46
47
 
47
48
  This is particularly useful for:
49
+
48
50
  - **Project naming conventions**: `prefix='app_{{ env }}'` + `env=prod` → `app_prod`
49
51
  - **Path templates**: `template_path='docs/{{ project }}'` + `project=wiki` → `docs/wiki`
50
52
  - **Multi-level configurations**: Combine multiple `.env` files with nested references
51
53
 
52
54
  **How it works:**
55
+
53
56
  - Variables are expanded iteratively (up to 10 passes) to prevent infinite loops
54
57
  - Circular references are detected and stopped gracefully
55
58
  - Missing nested variables remain as `{{ variable }}` placeholders
@@ -64,6 +67,7 @@ This is particularly useful for:
64
67
  - **Team collaboration**: Share common variable values across team projects
65
68
 
66
69
  **Example:**
70
+
67
71
  ```bash
68
72
  # Project structure:
69
73
  my-project/
@@ -75,6 +79,7 @@ my-project/
75
79
  When you run `pt init my-template sub-project`, the `prefix` variable will be pre-filled with `rst_` from the parent `.env` file.
76
80
 
77
81
  **Behavior:**
82
+
78
83
  - Scans from the current directory up to 3 parent levels
79
84
  - Uses values from `.env` as defaults (still prompts if not in `.env`)
80
85
  - `--vars` CLI option overrides `.env` values
@@ -109,6 +114,7 @@ If the directory contains a `.pt-template.json` or `template.json` file with a `
109
114
  3. Alternatively, initialize a temporary project from your learned template (`pt init`), refine it manually, and then use `pt update` from that directory to "re-learn" the refined state.
110
115
 
111
116
  **Security Note:** All post-config commands are subject to security validation:
117
+
112
118
  - Dangerous commands (e.g., `curl`, `python`, `chmod`) trigger warnings with 5-second cancellation
113
119
  - Absolute blocks (e.g., `sudo`, `rm -rf`, `dd`) are never allowed
114
120
  - Rate limiting prevents runaway execution (50 commands per run)
@@ -177,7 +183,7 @@ Each task supports:
177
183
 
178
184
  ## Default Post-Config
179
185
 
180
- Default post-config tasks are defined at the top level of `~/.pt/config.yaml` under `default_post_config`. They serve as suggestions when creating or updating templates via `pt learn`.
186
+ Default post-config tasks are defined at the top level of `~/.pt/config.yaml` under `default_post_config`. They serve as suggestions when creating or updating templates via `pt learn`.
181
187
 
182
188
  Unlike previous versions, default tasks are **not** automatically applied during `pt init`. Instead, you select which ones to include when learning a template, and those selections are baked into the template's `post_config` list. This eliminates the need to repeat boilerplate setup (e.g. `git init`) across templates while keeping each template fully self-contained.
183
189
 
@@ -189,21 +195,21 @@ default_post_config:
189
195
  description: "Initialize git repository"
190
196
  - command: "git add -A && git commit -m 'Initial commit'"
191
197
  description: "Initial git commit"
192
- checked: false # default on, but user must manually check
198
+ checked: false # default on, but user must manually check
193
199
  - command: "git lfs install"
194
200
  description: "Install git-lfs hooks"
195
- type: "godot" # only applies to godot projects
201
+ type: "godot" # only applies to godot projects
196
202
  ```
197
203
 
198
204
  ### Fields
199
205
 
200
206
  Each default task supports the same fields as template post-config:
201
207
 
202
- | Field | Description |
203
- | ------------- | -------------------------------------------------------------------------------- |
204
- | `command` | Shell command to run |
205
- | `description` | Shown to user during interactive selection |
206
- | `checked` | Default checkbox state (`true` by default); set `false` to require manual opt-in |
208
+ | Field | Description |
209
+ | ------------- | -------------------------------------------------------------------------------------------------- |
210
+ | `command` | Shell command to run |
211
+ | `description` | Shown to user during interactive selection |
212
+ | `checked` | Default checkbox state (`true` by default); set `false` to require manual opt-in |
207
213
  | `type` | Filter by project type (e.g. `"javascript"`); if set, task only applies when template type matches |
208
214
 
209
215
  ### Behavior
@@ -215,6 +221,7 @@ Each default task supports the same fields as template post-config:
215
221
 
216
222
  You can view current default tasks using `pt config` or `pt default-post-config`.
217
223
  To update default tasks programmatically or via CLI, use the `pt default-post-config` command:
224
+
218
225
  - `pt default-post-config`: List current default post-config tasks.
219
226
  - `pt default-post-config --set --json '...'`: Replace the default post-config tasks list via a JSON string or file.
220
227
 
@@ -299,6 +306,7 @@ Each entry supports:
299
306
  A plausible scenario for customizing a new project's `package.json` and `README.md`:
300
307
 
301
308
  **1. Define in `config.yaml`**:
309
+
302
310
  ```yaml
303
311
  templates:
304
312
  node_web_app:
@@ -317,6 +325,7 @@ templates:
317
325
  ```
318
326
 
319
327
  **2. Template source (`templates/package.json.tmpl`)**:
328
+
320
329
  ```json
321
330
  {
322
331
  "name": "{{project_name}}",
@@ -327,6 +336,7 @@ templates:
327
336
 
328
337
  **3. Resulting project file**:
329
338
  If the user enters `my-service` and `Jane Doe`, the file `package.json` will be created with:
339
+
330
340
  ```json
331
341
  {
332
342
  "name": "my-service",
package/doc/usage.md CHANGED
@@ -7,9 +7,13 @@
7
7
  pt learn /path/to/PROJECT
8
8
 
9
9
  # Update an existing template (use current directory if no path given)
10
+ # (Runs in additive difference mode by default: shows only new items, auto-selected, for de-selection)
10
11
  pt update <template_name>
11
12
  pt update <template_name> /path/to/PROJECT
12
13
 
14
+ # Use full mode (original behavior, presents all items)
15
+ pt update <template_name> /path/to/PROJECT --no-diff
16
+
13
17
  # Ignore specific folders during learning
14
18
  pt learn /path/to/PROJECT --ignore=DAILIES/*,PARKING_LOT/*,REFERENCE/*
15
19
 
@@ -60,12 +64,14 @@ pt init my-template my-new-project
60
64
  ```
61
65
 
62
66
  **Key Features:**
67
+
63
68
  - **Automatic Scanning:** Scans up to 3 parent directories for `.env` files
64
69
  - **Variable Pre-filling:** Values from `.env` are used as defaults in prompts
65
70
  - **Nested Variables:** Supports nested placeholders like `prefix='app_{{ env }}'`
66
71
  - **Override Support:** Use `--vars` to override `.env` values: `--vars project=OverriddenProject`
67
72
 
68
73
  **Example with Nested Variables:**
74
+
69
75
  ```bash
70
76
  # .env file
71
77
  prefix='app_{{ env }}'
@@ -79,6 +85,7 @@ project=MyApp
79
85
  ```
80
86
 
81
87
  **Security Note:** `.env` files are not committed to version control. Use `.gitignore` to exclude them:
88
+
82
89
  ```bash
83
90
  # .gitignore
84
91
  .env
@@ -102,11 +109,13 @@ project=MyProject
102
109
  ```
103
110
 
104
111
  **Use Cases:**
112
+
105
113
  - **Project naming conventions:** `prefix='app_{{ env }}'` + `env=prod` → `app_prod`
106
114
  - **Path templates:** `template_path='docs/{{ project }}'` + `project=wiki` → `docs/wiki`
107
115
  - **Multi-level configurations:** Combine multiple `.env` files with nested references
108
116
 
109
117
  **How it works:**
118
+
110
119
  - Variables are expanded iteratively (up to 10 passes) to prevent infinite loops
111
120
  - Circular references are detected and stopped gracefully
112
121
  - Missing nested variables remain as `{{ variable }}` placeholders
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -27,7 +27,11 @@ As an agent equipped with this skill, you have the ability to rapidly scaffold,
27
27
  3. **Capturing Knowledge (`pt learn`):**
28
28
  If you spend time establishing a new, complex directory structure or configuration (e.g., a specific flavor of an Express backend with testing hooks), save it! Remote URLs (GitHub, Gitea, etc.) are automatically translated to tarball downloads.
29
29
  - **Command:** `pt learn <source_path> --name <template_name> --desc "<Description>" --yes`
30
- - **Update existing template:** Update an existing template with new structure/files: `pt update <template_name> <source_path> --yes`
30
+ - **Update existing template:** Update an existing template with new structure/files: `pt update <template_name> <source_path> --yes`.
31
+ - **Additive Difference Mode (Default):** By default, `pt update` operates in an additive mode that only presents *new* folders, files, and variables from the target directory to the user for de-selection. Existing items and their configurations (such as `substitute_variables` and `post_copy` settings) are preserved as-is.
32
+ - **Editing Metadata:** During update, description and templateRoot are presented for editing with the existing template values pre-filled as defaults.
33
+ - **Global/Default Variables:** Default/global variables require the user to explicitly opt-in via checkboxes, rather than being automatically added.
34
+ - **Full/Original Mode:** To review all items manually instead of showing differences only, pass the `--no-diff` flag: `pt update <template_name> <source_path> --no-diff`
31
35
  - **Remote Templates:** Learn from a remote Git repository or archive URL directly! Pass the HTTP/HTTPS URL as the `<source_path>`:
32
36
  `pt learn https://github.com/username/my-template --name my_template --desc "Description" --yes`
33
37
  - Explain to the user that you've captured this template for future use.
@@ -191,7 +195,7 @@ For more details, see the [Security Guide](security.md).
191
195
  | Command | Description |
192
196
  |---------|-------------|
193
197
  | `pt learn <path>` | Learn a project structure from an existing directory |
194
- | `pt update <template> [path]` | Update an existing template with new structure/files |
198
+ | `pt update <template> [path]` | Update an existing template (additive difference mode by default, use --no-diff for full mode) |
195
199
  | `pt init [template] [dest]` | Initialize a new project from a learned template |
196
200
  | `pt config [template]` | Show current config location and list templates, or export a specific template |
197
201
  | `pt variables [pairs]` | View or set global variables (comma-separated key=value) |
@@ -7,23 +7,124 @@ export interface AddOptions {
7
7
  file?: string;
8
8
  }
9
9
 
10
+ /**
11
+ * Validate JSON file exists and contains valid JSON
12
+ */
13
+ function validateJsonFile(filePath: string): { valid: boolean; data?: any; error?: string } {
14
+ try {
15
+ // Check if file exists
16
+ if (!fs.existsSync(filePath)) {
17
+ return {
18
+ valid: false,
19
+ error: `File not found: ${filePath}`
20
+ };
21
+ }
22
+
23
+ // Check file size (reasonable limit to prevent reading huge files)
24
+ const stats = fs.statSync(filePath);
25
+ if (stats.size > 10 * 1024 * 1024) { // 10MB limit
26
+ return {
27
+ valid: false,
28
+ error: `File too large (${stats.size} bytes). Maximum size is 10MB.`
29
+ };
30
+ }
31
+
32
+ // Read and parse JSON
33
+ const content = fs.readFileSync(filePath, 'utf8');
34
+ if (!content.trim()) {
35
+ return {
36
+ valid: false,
37
+ error: 'File is empty'
38
+ };
39
+ }
40
+
41
+ const data = JSON.parse(content);
42
+ return { valid: true, data };
43
+ } catch (e) {
44
+ const error = e as Error;
45
+ return {
46
+ valid: false,
47
+ error: `JSON parse error: ${error.message}`
48
+ };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Validate template structure
54
+ */
55
+ function validateTemplateStructure(data: any): { valid: boolean; error?: string } {
56
+ if (!data || typeof data !== 'object') {
57
+ return {
58
+ valid: false,
59
+ error: 'Template data must be a JSON object'
60
+ };
61
+ }
62
+
63
+ // Basic structure validation
64
+ if (data.description && typeof data.description !== 'string') {
65
+ return {
66
+ valid: false,
67
+ error: 'Template description must be a string'
68
+ };
69
+ }
70
+
71
+ if (data.variables && Array.isArray(data.variables)) {
72
+ for (let i = 0; i < data.variables.length; i++) {
73
+ const v = data.variables[i];
74
+ if (!v.name || typeof v.name !== 'string') {
75
+ return {
76
+ valid: false,
77
+ error: `Variable at index ${i} must have a string 'name' field`
78
+ };
79
+ }
80
+ }
81
+ }
82
+
83
+ return { valid: true };
84
+ }
85
+
10
86
  export function addCommand(name: string, jsonStr: string | undefined, options: AddOptions = {}) {
11
87
  const config = loadConfig();
88
+
89
+ // Determine if we're reading from file or string
90
+ const isFile = !!options.file;
91
+ let data: any;
92
+
12
93
  try {
13
- let data;
14
- if (options.file) {
15
- const filePath = path.resolve(options.file);
16
- data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
94
+ if (isFile) {
95
+ // Validate file first
96
+ const filePath = path.resolve(options.file!);
97
+ const validation = validateJsonFile(filePath);
98
+
99
+ if (!validation.valid) {
100
+ console.error(chalk.red(`Error: ${validation.error}`));
101
+ console.error(chalk.gray(`File: ${filePath}`));
102
+ process.exit(1);
103
+ }
104
+
105
+ data = validation.data;
17
106
  } else if (jsonStr) {
18
- data = JSON.parse(jsonStr);
107
+ // Parse JSON string directly
108
+ try {
109
+ data = JSON.parse(jsonStr);
110
+ } catch (e) {
111
+ const error = e as Error;
112
+ console.error(chalk.red(`Error: Invalid JSON string - ${error.message}`));
113
+ process.exit(1);
114
+ }
19
115
  } else {
20
116
  console.error('Error: Either a JSON string or --file <path> must be provided.');
21
117
  process.exit(1);
22
118
  }
23
119
 
24
- if (!config.templates) config.templates = {};
120
+ // Validate template structure
121
+ const structureValidation = validateTemplateStructure(data);
122
+ if (!structureValidation.valid) {
123
+ console.error(chalk.red(`Error: Invalid template structure - ${structureValidation.error}`));
124
+ process.exit(1);
125
+ }
25
126
 
26
- // Basic validation: ensure we aren't accidentally adding a full config object
127
+ // Check for full config object
27
128
  if (data && data.templates && typeof data.templates === 'object') {
28
129
  console.error(chalk.red('Error: The provided JSON appears to be a full configuration file, not a single template.'));
29
130
  console.error(chalk.gray('If you want to import a specific template from it, extract that template object first.'));
@@ -35,7 +136,7 @@ export function addCommand(name: string, jsonStr: string | undefined, options: A
35
136
  console.log(chalk.green(`✓ Template "${name}" saved successfully.`));
36
137
  } catch (e) {
37
138
  const error = e as Error;
38
- console.error(chalk.red(`Failed to parse template JSON: ${error.message}`));
139
+ console.error(chalk.red(`Failed to process template: ${error.message}`));
39
140
  process.exit(1);
40
141
  }
41
142
  }
@@ -15,13 +15,20 @@ export function configCommand(templateName: string | undefined, options: ConfigO
15
15
  name: templateName,
16
16
  ...config.templates[templateName]
17
17
  };
18
- console.log(JSON.stringify(output, null, 2));
18
+ // Safely drain stdout before allowing the process to close
19
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
20
+ process.exit(0);
21
+ });
19
22
  } else {
20
- console.error(chalk.red(`Error: Template "${templateName}" not found.`));
21
- process.exit(1);
23
+ process.stderr.write(chalk.red(`Error: Template "${templateName}" not found.\n`), () => {
24
+ process.exit(1);
25
+ });
22
26
  }
23
27
  } else {
24
- console.log(JSON.stringify(config, null, 2));
28
+ // Safely drain the entire global config payload
29
+ process.stdout.write(JSON.stringify(config, null, 2) + '\n', () => {
30
+ process.exit(0);
31
+ });
25
32
  }
26
33
  return;
27
34
  }
@@ -542,11 +542,16 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
542
542
  }
543
543
 
544
544
  if (options.json) {
545
- const output = {
546
- name: targetName,
547
- ...templateConfig
548
- };
549
- console.log(JSON.stringify(output, null, 2));
545
+ const output = {
546
+ name: targetName,
547
+ ...templateConfig
548
+ };
549
+
550
+ // Force the application to wait until every single byte of this JSON string
551
+ // safely clears the operating system's pipe buffer before letting the process die.
552
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
553
+ process.exit(0);
554
+ });
550
555
  return;
551
556
  }
552
557
 
@@ -588,8 +593,8 @@ function extractStructure(dirPath: string, rootPath: string, ignorePatterns?: st
588
593
  let info = "";
589
594
  const gitkeepPath = path.join(fullPath, '.gitkeep.md');
590
595
  const infoPath = path.join(fullPath, '.info.md');
591
- if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
592
- else if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
596
+ if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
597
+ else if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
593
598
  nodes.push({ name: entry.name, info: info, children: children });
594
599
  }
595
600
  } catch (e) {}