@garyr/pt-cli 0.27.0 → 0.30.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 CHANGED
@@ -41,6 +41,7 @@ graph LR
41
41
  ## Features at a Glance
42
42
 
43
43
  - Learn any directory structure and save it as a reusable template
44
+ - **Remote Templates:** Learn templates directly from a remote repository or archive URL (GitHub, Gitea, etc.)
44
45
  - Initialize new projects from learned templates
45
46
  - Define template variables for dynamic file customization
46
47
  - **Automatic Variable Detection:** Scans text files for `{{ var }}` syntax during `learn`/`update`
@@ -48,6 +49,8 @@ graph LR
48
49
  - Configure global post-config tasks in `~/.pt/config.yaml` (apply to all projects)
49
50
  - Baked-in defaults for common project types (javascript, python, godot, etc.)
50
51
  - Share templates or use as an API with JSON export/import
52
+ - **Direct JSON scaffolding:** Initialize projects from a JSON file without registering in `config.yaml`
53
+ - **Portable template configs:** `.pt-template.json` files make shared directories fully self-describing
51
54
  - Fully supports non-interactive mode (`--yes`, `--vars`) for AI agent automation
52
55
 
53
56
  ## Quick Start
@@ -67,9 +70,12 @@ npm link
67
70
  ### Basic Commands
68
71
 
69
72
  ```bash
70
- # Learn an existing project structure
73
+ # Learn an existing local project structure
71
74
  pt learn /path/to/PROJECT
72
75
 
76
+ # Learn a template from a remote repository (GitHub, Gitea, or tarball URL)
77
+ pt learn https://github.com/garyritchie/pt_godot
78
+
73
79
  # Scaffold a new project from a template
74
80
  pt init <template_name> /path/to/NEW_PROJECT
75
81
 
@@ -81,6 +87,9 @@ pt config my-template --json > my-template.json
81
87
 
82
88
  # Import a template from JSON
83
89
  pt add my-new-template --file my-new-template.json
90
+
91
+ # Scaffold directly from a JSON file (no config registration)
92
+ pt init ./new-project --file my-template.json --yes
84
93
  ```
85
94
 
86
95
  ## Documentation
@@ -8,37 +8,58 @@ import { execSync } from 'child_process';
8
8
  export async function init(targetName, destPath, options = {}) {
9
9
  const config = loadConfig();
10
10
  let typeName = targetName;
11
- // If no name provided, list templates
12
- if (!typeName) {
13
- const names = Object.keys(config.templates);
14
- if (names.length === 0) {
15
- console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
16
- return;
11
+ let dest = destPath;
12
+ let template;
13
+ if (options.file) {
14
+ // If direct template file is specified, targetName could be the destPath if destPath is omitted
15
+ if (typeName && !dest) {
16
+ dest = typeName;
17
+ typeName = undefined;
17
18
  }
18
- if (options.yes) {
19
- console.error(chalk.red("No project type specified and running in non-interactive mode."));
19
+ try {
20
+ const fileContent = fs.readFileSync(options.file, 'utf-8');
21
+ template = JSON.parse(fileContent);
22
+ }
23
+ catch (e) {
24
+ console.error(chalk.red(`Error: Failed to read/parse template file "${options.file}": ${e.message}`));
20
25
  process.exit(1);
21
26
  }
22
- const { selected } = await inquirer.prompt({
23
- type: 'list',
24
- name: 'selected',
25
- message: 'Select Project Type:',
26
- loop: false,
27
- theme: {
28
- icon: {
29
- cursor: chalk.green('[x] ')
30
- }
31
- },
32
- choices: names.map(n => ({ name: n, value: n }))
33
- });
34
- typeName = selected;
27
+ if (!typeName) {
28
+ typeName = template.name || 'custom-template';
29
+ }
35
30
  }
36
- const template = config.templates[typeName];
37
- if (!template) {
38
- console.error(chalk.red(`Template "${typeName}" not found.`));
39
- process.exit(1);
31
+ else {
32
+ // If no name provided, list templates
33
+ if (!typeName) {
34
+ const names = Object.keys(config.templates);
35
+ if (names.length === 0) {
36
+ console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
37
+ return;
38
+ }
39
+ if (options.yes) {
40
+ console.error(chalk.red("No project type specified and running in non-interactive mode."));
41
+ process.exit(1);
42
+ }
43
+ const { selected } = await inquirer.prompt({
44
+ type: 'list',
45
+ name: 'selected',
46
+ message: 'Select Project Type:',
47
+ loop: false,
48
+ theme: {
49
+ icon: {
50
+ cursor: chalk.green('[x] ')
51
+ }
52
+ },
53
+ choices: names.map(n => ({ name: n, value: n }))
54
+ });
55
+ typeName = selected;
56
+ }
57
+ template = config.templates[typeName];
58
+ if (!template) {
59
+ console.error(chalk.red(`Template "${typeName}" not found.`));
60
+ process.exit(1);
61
+ }
40
62
  }
41
- let dest = destPath;
42
63
  if (!dest) {
43
64
  if (options.yes) {
44
65
  console.error(chalk.red("No destination path specified and running in non-interactive mode."));
@@ -211,7 +232,8 @@ export async function init(targetName, destPath, options = {}) {
211
232
  loop: false,
212
233
  theme: {
213
234
  icon: {
214
- cursor: chalk.green('[x] ')
235
+ checked: chalk.green('[x] '),
236
+ unchecked: '[ ] ',
215
237
  }
216
238
  },
217
239
  choices
@@ -21,6 +21,26 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
21
21
  const isUpdate = !!updateTemplate;
22
22
  const config = loadConfig();
23
23
  const existingNames = getTemplateNames(config);
24
+ // Check for template configuration JSON file (.pt-template.json or template.json)
25
+ let fileTemplateConfig = {};
26
+ const jsonConfigPaths = [
27
+ path.join(resolvedPath, '.pt-template.json'),
28
+ path.join(resolvedPath, 'template.json')
29
+ ];
30
+ for (const jPath of jsonConfigPaths) {
31
+ if (fs.existsSync(jPath)) {
32
+ try {
33
+ const fileContent = fs.readFileSync(jPath, 'utf-8');
34
+ fileTemplateConfig = JSON.parse(fileContent);
35
+ if (!options.json)
36
+ console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
37
+ break;
38
+ }
39
+ catch (e) {
40
+ console.warn(chalk.yellow(`Warning: Failed to parse ${path.basename(jPath)}: ${e.message}`));
41
+ }
42
+ }
43
+ }
24
44
  // Check for .info.md
25
45
  let infoName = '';
26
46
  let infoDesc = '';
@@ -48,6 +68,11 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
48
68
  if (options.name) {
49
69
  targetName = options.name;
50
70
  }
71
+ else if (fileTemplateConfig.name) {
72
+ targetName = fileTemplateConfig.name;
73
+ if (!options.json)
74
+ console.log(chalk.cyan(`Auto-detected template name from JSON: ${targetName}`));
75
+ }
51
76
  else if (infoName) {
52
77
  targetName = infoName;
53
78
  if (!options.json)
@@ -99,7 +124,12 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
99
124
  }
100
125
  }
101
126
  else {
102
- if (infoDesc) {
127
+ if (fileTemplateConfig.description) {
128
+ description = fileTemplateConfig.description;
129
+ if (!options.json)
130
+ console.log(chalk.cyan(`Auto-detected template description from JSON: ${description}`));
131
+ }
132
+ else if (infoDesc) {
103
133
  description = infoDesc;
104
134
  if (!options.json)
105
135
  console.log(chalk.cyan(`Auto-detected template description from .info.md: ${description}`));
@@ -125,9 +155,32 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
125
155
  console.log(chalk.cyan(`Auto-detected ${detectedVars.length} variable(s): ${detectedVars.join(', ')}`));
126
156
  }
127
157
  let variables = [];
128
- // Merge with existing variables if update
129
- if (isUpdate && config.templates[updateTemplate].variables) {
130
- variables = [...config.templates[updateTemplate].variables];
158
+ // During updates, merge existing template variables with JSON file variables
159
+ if (isUpdate) {
160
+ // Start with existing template variables
161
+ if (config.templates[updateTemplate].variables) {
162
+ variables = [...config.templates[updateTemplate].variables];
163
+ }
164
+ // Then add JSON variables (overwrite/update existing ones with same name)
165
+ if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
166
+ for (const v of fileTemplateConfig.variables) {
167
+ const existingIndex = variables.findIndex(existing => existing.name === v.name);
168
+ if (existingIndex !== -1) {
169
+ // Update existing variable with JSON values (but preserve other fields)
170
+ variables[existingIndex] = { ...variables[existingIndex], ...v };
171
+ }
172
+ else {
173
+ // Add new variable
174
+ variables.push({ ...v });
175
+ }
176
+ }
177
+ }
178
+ }
179
+ else {
180
+ // For new templates, use JSON variables if available
181
+ if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
182
+ variables = [...fileTemplateConfig.variables];
183
+ }
131
184
  }
132
185
  // Add detected variables if not already present
133
186
  for (const varName of detectedVars) {
@@ -180,7 +233,9 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
180
233
  }
181
234
  }
182
235
  // 1. Structure (skeleton)
183
- const folders = extractStructure(resolvedPath, resolvedPath, ignorePatterns);
236
+ const folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
237
+ ? fileTemplateConfig.folders
238
+ : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
184
239
  // 2. Content Selection (Root only)
185
240
  const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
186
241
  .filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
@@ -250,52 +305,62 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
250
305
  selectedFolders = copyFoldersResponse.selectedFolders;
251
306
  }
252
307
  const copy_files = [];
253
- for (const f of selectedFiles) {
254
- copy_files.push({ src: f, dest: f, substitute_variables: true });
308
+ if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
309
+ copy_files.push(...fileTemplateConfig.copy_files);
255
310
  }
256
- for (const d of selectedFolders) {
257
- copy_files.push({ src: d, dest: d, substitute_variables: true });
311
+ else {
312
+ for (const f of selectedFiles) {
313
+ copy_files.push({ src: f, dest: f, substitute_variables: true });
314
+ }
315
+ for (const d of selectedFolders) {
316
+ copy_files.push({ src: d, dest: d, substitute_variables: true });
317
+ }
258
318
  }
259
319
  const templateConfig = {
260
320
  description: description,
261
321
  templateRoot: resolvedPath,
262
- folders: folders.filter(f => selectedStructure.includes(f.name)),
322
+ folders: fileTemplateConfig.folders ? folders : folders.filter(f => selectedStructure.includes(f.name)),
263
323
  copy_files: copy_files,
264
324
  variables: variables.length > 0 ? variables : undefined
265
325
  };
266
326
  // Check for post_config scripts
267
- const postConfigTasks = [];
268
- const shPath = path.join(resolvedPath, 'post_config.sh');
269
- const batPath = path.join(resolvedPath, 'post_config.bat');
270
- if (fs.existsSync(shPath)) {
271
- const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
272
- let currentDesc = '';
273
- for (const line of lines) {
274
- if (line.startsWith('echo "Running: ')) {
275
- currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
276
- }
277
- else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
278
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
279
- currentDesc = '';
280
- }
281
- }
327
+ let postConfigTasks = [];
328
+ if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
329
+ postConfigTasks = [...fileTemplateConfig.post_config];
282
330
  }
283
- else if (fs.existsSync(batPath)) {
284
- const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
285
- let currentDesc = '';
286
- for (const line of lines) {
287
- if (line.startsWith('echo Running: ')) {
288
- currentDesc = line.substring(14).trim();
331
+ else {
332
+ const shPath = path.join(resolvedPath, 'post_config.sh');
333
+ const batPath = path.join(resolvedPath, 'post_config.bat');
334
+ if (fs.existsSync(shPath)) {
335
+ const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
336
+ let currentDesc = '';
337
+ for (const line of lines) {
338
+ if (line.startsWith('echo "Running: ')) {
339
+ currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
340
+ }
341
+ else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
342
+ postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
343
+ currentDesc = '';
344
+ }
289
345
  }
290
- else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
291
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
292
- currentDesc = '';
346
+ }
347
+ else if (fs.existsSync(batPath)) {
348
+ const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
349
+ let currentDesc = '';
350
+ for (const line of lines) {
351
+ if (line.startsWith('echo Running: ')) {
352
+ currentDesc = line.substring(14).trim();
353
+ }
354
+ else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
355
+ postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
356
+ currentDesc = '';
357
+ }
293
358
  }
294
359
  }
295
360
  }
296
361
  if (postConfigTasks.length > 0) {
297
362
  templateConfig.post_config = postConfigTasks;
298
- if (!options.json)
363
+ if (!options.json && !fileTemplateConfig.post_config)
299
364
  console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
300
365
  }
301
366
  // Handle default_post_config tasks
@@ -324,7 +389,8 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
324
389
  loop: false,
325
390
  theme: {
326
391
  icon: {
327
- cursor: chalk.green('[x] ')
392
+ checked: chalk.green('[x] '),
393
+ unchecked: '[ ] ',
328
394
  }
329
395
  },
330
396
  choices
@@ -353,7 +419,12 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
353
419
  detectedExecutables.push(file);
354
420
  }
355
421
  }
356
- if (detectedExecutables.length > 0) {
422
+ if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
423
+ templateConfig.post_copy = fileTemplateConfig.post_copy;
424
+ const postCopySrcs = fileTemplateConfig.post_copy.map(f => f.src);
425
+ templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !postCopySrcs.includes(cf.src));
426
+ }
427
+ else if (detectedExecutables.length > 0) {
357
428
  if (!options.json) {
358
429
  console.log(chalk.cyan("\nAuto-detected " + detectedExecutables.length + " executable file(s) at root:"));
359
430
  for (const file of detectedExecutables) {
package/dist/config.js CHANGED
@@ -88,8 +88,30 @@ export function loadConfig() {
88
88
  process.exit(1);
89
89
  }
90
90
  }
91
+ export function normalizeVariable(v) {
92
+ const result = { name: v.name };
93
+ if (v.prompt !== undefined)
94
+ result.prompt = v.prompt;
95
+ if (v.default !== undefined)
96
+ result.default = v.default;
97
+ if (v.required !== undefined)
98
+ result.required = v.required;
99
+ return result;
100
+ }
91
101
  export function saveConfig(config) {
92
102
  ensureConfigDir();
103
+ // Normalize variable key ordering (forces 'name' to be first in serialized YAML)
104
+ if (config.variables && Array.isArray(config.variables)) {
105
+ config.variables = config.variables.map(normalizeVariable);
106
+ }
107
+ if (config.templates) {
108
+ for (const key of Object.keys(config.templates)) {
109
+ const template = config.templates[key];
110
+ if (template.variables && Array.isArray(template.variables)) {
111
+ template.variables = template.variables.map(normalizeVariable);
112
+ }
113
+ }
114
+ }
93
115
  const content = YAML.stringify(config);
94
116
  const tempPath = CONFIG_PATH + '.tmp';
95
117
  const backupPath = CONFIG_PATH + '.bak';
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ program
38
38
  program
39
39
  .command('init [templateName] [destPath]')
40
40
  .description('Initialize a new project from a learned template')
41
+ .option('-f, --file <jsonPath>', 'Initialize directly from a JSON template file without adding it to local config')
41
42
  .option('--skip-post-config', 'Skip running post-config tasks')
42
43
  .option('--dry-run', 'Show what would be created without making changes')
43
44
  .option('-y, --yes', 'Automatically answer yes to prompts')
package/dist/remote.js CHANGED
@@ -8,12 +8,14 @@ import { extract } from 'tar'; // You'll need: npm install tar
8
8
  export async function downloadAndExtract(url) {
9
9
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
10
10
  let downloadUrl = url;
11
+ // Strip trailing slash and .git suffix before converting to archive URL
12
+ let cleanUrl = url.replace(/\/$/, '').replace(/\.git$/, '');
11
13
  // Convert GitHub/Gitea URLs to Zip/Tarball endpoints
12
14
  if (url.includes('github.com')) {
13
- downloadUrl = url.replace(/\/$/, '') + '/archive/refs/heads/main.tar.gz';
15
+ downloadUrl = cleanUrl + '/archive/refs/heads/main.tar.gz';
14
16
  }
15
17
  else if (url.includes('gitea')) {
16
- downloadUrl = url.replace(/\/$/, '') + '/archive/main.tar.gz';
18
+ downloadUrl = cleanUrl + '/archive/main.tar.gz';
17
19
  }
18
20
  const response = await fetch(downloadUrl);
19
21
  if (!response.ok)
@@ -42,6 +42,8 @@ When learning a template, `pt` scans the source directory for common patterns an
42
42
 
43
43
  Additionally, if `pt learn` finds a `post_config.sh` or `post_config.bat` file at the root of the directory, it will parse the scripts and automatically load the tasks into the `post_config` array.
44
44
 
45
+ If the directory contains a `.pt-template.json` or `template.json` file with a `post_config` array, those tasks take precedence over shell script parsing. See [Usage Guide — JSON Template Config](usage.md#json-template-config-file-pt-templatejson) for the full JSON config format.
46
+
45
47
  ### The 80% Philosophy
46
48
 
47
49
  `pt` is designed to get you **80% of the way there** automatically. For complex templates, you are encouraged to:
package/doc/usage.md CHANGED
@@ -20,6 +20,25 @@ pt learn /path/to/PROJECT --ignore=**/.godot/
20
20
  pt learn /path/to/PROJECT --name my_template --desc "My new template" --yes
21
21
  ```
22
22
 
23
+ ### Remote Template Learning
24
+
25
+ `pt learn` supports learning templates directly from a remote Git repository or tarball archive by passing an `http://` or `https://` URL:
26
+
27
+ ```bash
28
+ # Learn a template directly from a GitHub repository
29
+ pt learn https://github.com/username/my-template
30
+
31
+ # Learn a template from a Gitea repository
32
+ pt learn https://gitea.example.com/username/my-template
33
+ ```
34
+
35
+ #### How it works:
36
+ 1. **URL Translation:** If a GitHub or Gitea URL is provided, `pt` automatically translates the repository URL to its corresponding tarball download endpoint (e.g., `/archive/refs/heads/main.tar.gz`).
37
+ 2. **Download & Extraction:** The tool downloads the archive into a secure temporary folder and extracts it.
38
+ 3. **Template Discovery:** The extracted directory is scanned for metadata (`.pt-template.json`, `template.json`, `.info.md`, `post_config.sh`, `post_config.bat`) and variable placeholders (`{{ var }}`), matching local learn functionality exactly.
39
+ 4. **Save Config:** The template config (skeleton structure, files, variables) is saved to the local configuration, pointing to the temporary folder as the `templateRoot`.
40
+
41
+
23
42
  ### Automatic Variable Detection
24
43
 
25
44
  During `pt learn` or `pt update`, the tool automatically scans text files at the root and in the first-level subdirectories for variable placeholders using the `{{ variable_name }}` syntax.
@@ -43,6 +62,25 @@ pt init <template_name> /path/to/new/PROJECT --dry-run
43
62
 
44
63
  # Non-interactive mode with variables (useful for an API or AI agents)
45
64
  pt init <template_name> /path/to/new/PROJECT --yes --vars project_name=foo,author=bar
65
+
66
+ # Initialize directly from a JSON template file (no config.yaml registration)
67
+ pt init /path/to/new/PROJECT --file my-template.json --yes
68
+ ```
69
+
70
+ ### Direct JSON Scaffolding (`--file`)
71
+
72
+ 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:
73
+
74
+ - One-off project creation from a shared template
75
+ - CI/CD pipelines where you don't want to modify the user's config
76
+ - Receiving a template JSON from a colleague and using it immediately
77
+
78
+ ```bash
79
+ # The template name is read from the JSON's "name" field
80
+ pt init ./new-project --file template.json --yes
81
+
82
+ # With variable overrides
83
+ pt init ./new-project --file template.json --yes --vars client=Acme,author=Jane
46
84
  ```
47
85
 
48
86
  ## Remove a template
@@ -86,11 +124,66 @@ For more details on how these are used, see the [Configuration Guide](configurat
86
124
 
87
125
  You can share your templates with others simply by sharing a directory (or a ZIP of it). When someone else runs `pt learn` on it, `pt` will automatically detect the following files at the root:
88
126
 
127
+ - `.pt-template.json` or `template.json`: **Full template configuration** — name, description, variables (with prompts, defaults, required flags), folders, copy_files, post_config, and post_copy. This is the most complete and portable way to share templates.
89
128
  - `.info.md`: Used to automatically set the template's name (from the first `# Heading`) and description.
90
129
  - `post_config.sh` or `post_config.bat`: Parsed to automatically populate the `post_config` actions in the user's `config.yaml`.
91
130
 
131
+ **Priority order:** `.pt-template.json` > `template.json` > `.info.md` > `post_config.sh`/`.bat`. JSON config files take precedence over `.info.md` for name/description and over shell scripts for post_config tasks.
132
+
92
133
  These files are also automatically generated at the root of a new project whenever you run `pt init`, making it trivial to initialize a project, zip it up, and share it with teammates as a fully-featured template!
93
134
 
135
+ ### JSON Template Config File (`.pt-template.json`)
136
+
137
+ The JSON template config file is the recommended way to make a template directory fully self-describing and portable. Place it at the root of your template directory:
138
+
139
+ ```json
140
+ {
141
+ "name": "my-web-app",
142
+ "description": "A Node.js web application with Express",
143
+ "variables": [
144
+ { "name": "project_name", "prompt": "Project name:", "default": "my-app", "required": true },
145
+ { "name": "author", "prompt": "Author name:", "default": "" }
146
+ ],
147
+ "folders": [
148
+ { "name": "src", "children": [] },
149
+ { "name": "tests", "children": [] }
150
+ ],
151
+ "copy_files": [
152
+ { "src": "package.json", "dest": "package.json", "substitute_variables": true },
153
+ { "src": "README.md", "dest": "README.md", "substitute_variables": true }
154
+ ],
155
+ "post_config": [
156
+ { "command": "git init", "description": "Initialize git repository" },
157
+ { "command": "npm install", "description": "Install dependencies" }
158
+ ],
159
+ "post_copy": [
160
+ { "src": "bin/start.sh", "dest": "bin/start.sh" }
161
+ ]
162
+ }
163
+ ```
164
+
165
+ When `pt learn` encounters this file, **all fields are used as pre-configured defaults**, skipping the corresponding interactive prompts. Any fields not specified in the JSON file will fall back to normal auto-detection (file scanning, executable detection, etc.).
166
+
167
+ ### Portable Template Round-Trip Workflow
168
+
169
+ The complete workflow for sharing a fully portable template:
170
+
171
+ ```bash
172
+ # 1. Export an existing template to JSON
173
+ pt config my-template --json > .pt-template.json
174
+
175
+ # 2. Place the JSON file at the root of your template directory
176
+ cp .pt-template.json /path/to/template-dir/
177
+
178
+ # 3. Share the directory (zip, git, etc.)
179
+
180
+ # 4. Recipient learns the template — all metadata auto-detected
181
+ pt learn /path/to/template-dir --yes
182
+
183
+ # 5. Or scaffold directly without registering in config
184
+ pt init ./new-project --file .pt-template.json --yes
185
+ ```
186
+
94
187
  ### JSON Export & Import
95
188
 
96
189
  For a more portable, text-based approach, you can export and import templates as JSON strings or files.
@@ -112,6 +205,12 @@ Or from a JSON string:
112
205
  pt add <template_name> '{"description":"My Template","files":{...}}'
113
206
  ```
114
207
 
208
+ #### Direct JSON Scaffolding (no config registration)
209
+ To scaffold a project directly from a JSON file without adding the template to your config:
210
+ ```bash
211
+ pt init ./destination --file my_template.json --yes
212
+ ```
213
+
115
214
  #### Exporting Full Config
116
215
  To see your entire configuration (including all templates) in JSON format:
117
216
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "0.27.0",
3
+ "version": "0.30.0",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "build": "tsc",
12
12
  "start": "tsx src/index.ts",
13
13
  "dev": "tsx src/index.ts",
14
+ "test": "node --import tsx --test tests/**/*.test.ts",
14
15
  "prepublishOnly": "npm run build",
15
16
  "build:linux": "bun build ./src/index.ts --compile --target=bun-linux-x64 --outfile ../BUILD/pt-cli/pt-linux",
16
17
  "build:macos": "bun build ./src/index.ts --compile --target=bun-darwin-x64 --outfile ../BUILD/pt-cli/pt-macos",
@@ -41,4 +42,4 @@
41
42
  "tsx": "^4.21.0",
42
43
  "typescript": "^5.6.0"
43
44
  }
44
- }
45
+ }
@@ -17,20 +17,70 @@ As an agent equipped with this skill, you have the ability to rapidly scaffold,
17
17
  When a matching template exists, initialize it using the non-interactive flags.
18
18
  - **Command:** `pt init <template_name> <destination_path> --yes`
19
19
  - If the template requires variables, pass them: `pt init <template_name> <destination_path> --yes --vars key1=value1,key2=value2`
20
+ - **Direct JSON scaffolding:** To scaffold from a JSON template file without registering it in `config.yaml`:
21
+ `pt init <destination_path> --file <json_path> --yes`
20
22
  - *Never* run `pt init` without `--yes`, as interactive prompts will block you.
21
23
  - Note any errors from auto-executed post-config tasks (like `npm install` failing) and correct them if necessary.
22
- - Note any errors from auto-executed post-config tasks (like `npm install` failing) and correct them if necessary.
23
24
 
24
25
  3. **Capturing Knowledge (`pt learn`):**
25
26
  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!
26
27
  - **Command:** `pt learn <source_path> --name <template_name> --desc "<Description>" --yes`
28
+ - **Remote Templates:** You can also learn from a remote Git repository or archive URL directly! Pass the HTTP/HTTPS URL as the `<source_path>`:
29
+ `pt learn https://github.com/username/my-template --name my_template --desc "Description" --yes`
27
30
  - Explain to the user that you've captured this template for future use.
28
31
  - **Automatic Variable Detection:** `pt learn` and `pt update` automatically scan text files (at root and one level deep) for `{{ variable_name }}` placeholders. You can add these to files (e.g., `README.md`, `.makerc`) and run `pt update <template> . --yes` to have them registered as template variables without manual configuration.
32
+ - **JSON Template Config:** If the source directory contains a `.pt-template.json` or `template.json` file, `pt learn` will auto-detect name, description, variables, folders, copy_files, post_config, and post_copy from it — skipping the corresponding interactive prompts.
29
33
 
30
34
  4. **Template Maintenance (`pt rm`):**
31
35
  If a template is obsolete or requested for deletion, use `pt rm`.
32
36
  - **Command:** `pt rm <template_name> --yes`
33
37
 
38
+ ## Template Sharing & Portability
39
+
40
+ Templates are designed to be fully portable. There are two approaches to sharing:
41
+
42
+ ### 1. Directory-based Sharing (recommended for complete templates)
43
+
44
+ Place a `.pt-template.json` at the root of your template directory. This file can include all template metadata:
45
+
46
+ ```json
47
+ {
48
+ "name": "my-template",
49
+ "description": "Description of the template",
50
+ "variables": [
51
+ { "name": "project_name", "prompt": "Project name:", "default": "my-app", "required": true }
52
+ ],
53
+ "post_config": [
54
+ { "command": "git init", "description": "Initialize git" }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ When someone runs `pt learn /path/to/shared-dir --yes`, all metadata is auto-detected from this file.
60
+
61
+ **Priority order:** `.pt-template.json` > `template.json` > `.info.md` > `post_config.sh`/`.bat`
62
+
63
+ ### 2. JSON Export/Import (for config-only sharing)
64
+
65
+ ```bash
66
+ # Export a template to JSON
67
+ pt config my-template --json > my-template.json
68
+
69
+ # Import into another user's config
70
+ pt add my-template --file my-template.json
71
+
72
+ # Or scaffold directly without importing
73
+ pt init ./new-project --file my-template.json --yes
74
+ ```
75
+
76
+ ### Round-trip Workflow
77
+
78
+ The complete portable template workflow:
79
+ 1. `pt config my-template --json > .pt-template.json` — export config
80
+ 2. Copy `.pt-template.json` into the template source directory
81
+ 3. Share the directory (zip, git repo, etc.)
82
+ 4. Recipient: `pt learn /path/to/shared-dir --yes` — auto-detects everything
83
+
34
84
  ## Default Post-Config
35
85
 
36
86
  Default post-config tasks are stored in `~/.pt/config.yaml` under `default_post_config`. They are used as suggestions during `pt learn` to apply to the newly created template. Each task supports:
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import inquirer from 'inquirer';
4
- import { loadConfig, FolderNode, sanitizePath } from '../config.js';
4
+ import { loadConfig, FolderNode, sanitizePath, TemplateConfig } from '../config.js';
5
5
  import chalk from 'chalk';
6
6
  import { processCopyFiles } from '../substitute.js';
7
7
  import { execSync } from 'child_process';
@@ -11,47 +11,69 @@ export interface InitOptions {
11
11
  dryRun?: boolean;
12
12
  yes?: boolean;
13
13
  vars?: string;
14
+ file?: string;
14
15
  }
15
16
 
16
17
  export async function init(targetName: string | undefined, destPath: string | undefined, options: InitOptions = {}) {
17
18
  const config = loadConfig();
18
19
 
19
20
  let typeName: string | undefined = targetName;
21
+ let dest: string | undefined = destPath;
22
+ let template: TemplateConfig;
20
23
 
21
- // If no name provided, list templates
22
- if (!typeName) {
23
- const names = Object.keys(config.templates);
24
- if (names.length === 0) {
25
- console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
26
- return;
24
+ if (options.file) {
25
+ // If direct template file is specified, targetName could be the destPath if destPath is omitted
26
+ if (typeName && !dest) {
27
+ dest = typeName;
28
+ typeName = undefined;
27
29
  }
28
30
 
29
- if (options.yes) {
30
- console.error(chalk.red("No project type specified and running in non-interactive mode."));
31
+ try {
32
+ const fileContent = fs.readFileSync(options.file, 'utf-8');
33
+ template = JSON.parse(fileContent);
34
+ } catch (e) {
35
+ console.error(chalk.red(`Error: Failed to read/parse template file "${options.file}": ${(e as Error).message}`));
31
36
  process.exit(1);
32
37
  }
33
- const { selected } = await inquirer.prompt({
34
- type: 'list',
35
- name: 'selected',
36
- message: 'Select Project Type:',
37
- loop: false,
38
- theme: {
39
- icon: {
40
- cursor: chalk.green('[x] ')
41
- }
42
- },
43
- choices: names.map(n => ({ name: n, value: n }))
44
- });
45
- typeName = selected;
46
- }
47
38
 
48
- const template = config.templates[typeName!];
49
- if (!template) {
50
- console.error(chalk.red(`Template "${typeName}" not found.`));
51
- process.exit(1);
39
+ if (!typeName) {
40
+ typeName = (template as any).name || 'custom-template';
41
+ }
42
+ } else {
43
+ // If no name provided, list templates
44
+ if (!typeName) {
45
+ const names = Object.keys(config.templates);
46
+ if (names.length === 0) {
47
+ console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
48
+ return;
49
+ }
50
+
51
+ if (options.yes) {
52
+ console.error(chalk.red("No project type specified and running in non-interactive mode."));
53
+ process.exit(1);
54
+ }
55
+ const { selected } = await inquirer.prompt({
56
+ type: 'list',
57
+ name: 'selected',
58
+ message: 'Select Project Type:',
59
+ loop: false,
60
+ theme: {
61
+ icon: {
62
+ cursor: chalk.green('[x] ')
63
+ }
64
+ },
65
+ choices: names.map(n => ({ name: n, value: n }))
66
+ });
67
+ typeName = selected;
68
+ }
69
+
70
+ template = config.templates[typeName!];
71
+ if (!template) {
72
+ console.error(chalk.red(`Template "${typeName}" not found.`));
73
+ process.exit(1);
74
+ }
52
75
  }
53
76
 
54
- let dest: string | undefined = destPath;
55
77
  if (!dest) {
56
78
  if (options.yes) {
57
79
  console.error(chalk.red("No destination path specified and running in non-interactive mode."));
@@ -229,7 +251,8 @@ export async function init(targetName: string | undefined, destPath: string | un
229
251
  loop: false,
230
252
  theme: {
231
253
  icon: {
232
- cursor: chalk.green('[x] ')
254
+ checked: chalk.green('[x] '),
255
+ unchecked: '[ ] ',
233
256
  }
234
257
  },
235
258
  choices
@@ -34,6 +34,25 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
34
34
  const config = loadConfig();
35
35
  const existingNames = getTemplateNames(config);
36
36
 
37
+ // Check for template configuration JSON file (.pt-template.json or template.json)
38
+ let fileTemplateConfig: Partial<TemplateConfig> & { name?: string } = {};
39
+ const jsonConfigPaths = [
40
+ path.join(resolvedPath, '.pt-template.json'),
41
+ path.join(resolvedPath, 'template.json')
42
+ ];
43
+ for (const jPath of jsonConfigPaths) {
44
+ if (fs.existsSync(jPath)) {
45
+ try {
46
+ const fileContent = fs.readFileSync(jPath, 'utf-8');
47
+ fileTemplateConfig = JSON.parse(fileContent);
48
+ if (!options.json) console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
49
+ break;
50
+ } catch (e) {
51
+ console.warn(chalk.yellow(`Warning: Failed to parse ${path.basename(jPath)}: ${(e as Error).message}`));
52
+ }
53
+ }
54
+ }
55
+
37
56
  // Check for .info.md
38
57
  let infoName = '';
39
58
  let infoDesc = '';
@@ -60,6 +79,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
60
79
  } else {
61
80
  if (options.name) {
62
81
  targetName = options.name;
82
+ } else if (fileTemplateConfig.name) {
83
+ targetName = fileTemplateConfig.name;
84
+ if (!options.json) console.log(chalk.cyan(`Auto-detected template name from JSON: ${targetName}`));
63
85
  } else if (infoName) {
64
86
  targetName = infoName;
65
87
  if (!options.json) console.log(chalk.cyan(`Auto-detected template name from .info.md: ${targetName}`));
@@ -106,7 +128,10 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
106
128
  }
107
129
  }
108
130
  } else {
109
- if (infoDesc) {
131
+ if (fileTemplateConfig.description) {
132
+ description = fileTemplateConfig.description;
133
+ if (!options.json) console.log(chalk.cyan(`Auto-detected template description from JSON: ${description}`));
134
+ } else if (infoDesc) {
110
135
  description = infoDesc;
111
136
  if (!options.json) console.log(chalk.cyan(`Auto-detected template description from .info.md: ${description}`));
112
137
  } else if (options.yes || options.json) {
@@ -133,9 +158,30 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
133
158
 
134
159
  let variables: TemplateVariable[] = [];
135
160
 
136
- // Merge with existing variables if update
137
- if (isUpdate && config.templates[updateTemplate].variables) {
138
- variables = [...config.templates[updateTemplate].variables];
161
+ // During updates, merge existing template variables with JSON file variables
162
+ if (isUpdate) {
163
+ // Start with existing template variables
164
+ if (config.templates[updateTemplate].variables) {
165
+ variables = [...config.templates[updateTemplate].variables];
166
+ }
167
+ // Then add JSON variables (overwrite/update existing ones with same name)
168
+ if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
169
+ for (const v of fileTemplateConfig.variables) {
170
+ const existingIndex = variables.findIndex(existing => existing.name === v.name);
171
+ if (existingIndex !== -1) {
172
+ // Update existing variable with JSON values (but preserve other fields)
173
+ variables[existingIndex] = { ...variables[existingIndex], ...v };
174
+ } else {
175
+ // Add new variable
176
+ variables.push({ ...v });
177
+ }
178
+ }
179
+ }
180
+ } else {
181
+ // For new templates, use JSON variables if available
182
+ if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
183
+ variables = [...fileTemplateConfig.variables];
184
+ }
139
185
  }
140
186
 
141
187
  // Add detected variables if not already present
@@ -194,7 +240,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
194
240
  }
195
241
 
196
242
  // 1. Structure (skeleton)
197
- const folders = extractStructure(resolvedPath, resolvedPath, ignorePatterns);
243
+ const folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
244
+ ? fileTemplateConfig.folders
245
+ : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
198
246
 
199
247
  // 2. Content Selection (Root only)
200
248
  const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
@@ -270,51 +318,59 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
270
318
  }
271
319
 
272
320
  const copy_files: CopyFileEntry[] = [];
273
- for (const f of selectedFiles) {
274
- copy_files.push({ src: f, dest: f, substitute_variables: true });
275
- }
276
- for (const d of selectedFolders) {
277
- copy_files.push({ src: d, dest: d, substitute_variables: true });
321
+ if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
322
+ copy_files.push(...fileTemplateConfig.copy_files);
323
+ } else {
324
+ for (const f of selectedFiles) {
325
+ copy_files.push({ src: f, dest: f, substitute_variables: true });
326
+ }
327
+ for (const d of selectedFolders) {
328
+ copy_files.push({ src: d, dest: d, substitute_variables: true });
329
+ }
278
330
  }
279
331
 
280
332
  const templateConfig: TemplateConfig = {
281
333
  description: description,
282
334
  templateRoot: resolvedPath,
283
- folders: folders.filter(f => selectedStructure.includes(f.name)),
335
+ folders: fileTemplateConfig.folders ? folders : folders.filter(f => selectedStructure.includes(f.name)),
284
336
  copy_files: copy_files,
285
337
  variables: variables.length > 0 ? variables : undefined
286
338
  };
287
339
 
288
340
  // Check for post_config scripts
289
- const postConfigTasks: PostConfigTask[] = [];
290
- const shPath = path.join(resolvedPath, 'post_config.sh');
291
- const batPath = path.join(resolvedPath, 'post_config.bat');
292
- if (fs.existsSync(shPath)) {
293
- const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
294
- let currentDesc = '';
295
- for (const line of lines) {
296
- if (line.startsWith('echo "Running: ')) {
297
- currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
298
- } else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
299
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
300
- currentDesc = '';
341
+ let postConfigTasks: PostConfigTask[] = [];
342
+ if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
343
+ postConfigTasks = [...fileTemplateConfig.post_config];
344
+ } else {
345
+ const shPath = path.join(resolvedPath, 'post_config.sh');
346
+ const batPath = path.join(resolvedPath, 'post_config.bat');
347
+ if (fs.existsSync(shPath)) {
348
+ const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
349
+ let currentDesc = '';
350
+ for (const line of lines) {
351
+ if (line.startsWith('echo "Running: ')) {
352
+ currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
353
+ } else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
354
+ postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
355
+ currentDesc = '';
356
+ }
301
357
  }
302
- }
303
- } else if (fs.existsSync(batPath)) {
304
- const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
305
- let currentDesc = '';
306
- for (const line of lines) {
307
- if (line.startsWith('echo Running: ')) {
308
- currentDesc = line.substring(14).trim();
309
- } else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
310
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
311
- currentDesc = '';
358
+ } else if (fs.existsSync(batPath)) {
359
+ const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
360
+ let currentDesc = '';
361
+ for (const line of lines) {
362
+ if (line.startsWith('echo Running: ')) {
363
+ currentDesc = line.substring(14).trim();
364
+ } else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
365
+ postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
366
+ currentDesc = '';
367
+ }
312
368
  }
313
369
  }
314
370
  }
315
371
  if (postConfigTasks.length > 0) {
316
372
  templateConfig.post_config = postConfigTasks;
317
- if (!options.json) console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
373
+ if (!options.json && !fileTemplateConfig.post_config) console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
318
374
  }
319
375
 
320
376
  // Handle default_post_config tasks
@@ -343,7 +399,8 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
343
399
  loop: false,
344
400
  theme: {
345
401
  icon: {
346
- cursor: chalk.green('[x] ')
402
+ checked: chalk.green('[x] '),
403
+ unchecked: '[ ] ',
347
404
  }
348
405
  },
349
406
  choices
@@ -374,7 +431,11 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
374
431
  }
375
432
  }
376
433
 
377
- if (detectedExecutables.length > 0) {
434
+ if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
435
+ templateConfig.post_copy = fileTemplateConfig.post_copy;
436
+ const postCopySrcs = fileTemplateConfig.post_copy.map(f => f.src);
437
+ templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !postCopySrcs.includes(cf.src));
438
+ } else if (detectedExecutables.length > 0) {
378
439
  if (!options.json) {
379
440
  console.log(chalk.cyan("\nAuto-detected " + detectedExecutables.length + " executable file(s) at root:"));
380
441
  for (const file of detectedExecutables) {
package/src/config.ts CHANGED
@@ -150,8 +150,30 @@ export function loadConfig(): PtConfig {
150
150
  }
151
151
  }
152
152
 
153
+ export function normalizeVariable(v: TemplateVariable): TemplateVariable {
154
+ const result: any = { name: v.name };
155
+ if (v.prompt !== undefined) result.prompt = v.prompt;
156
+ if (v.default !== undefined) result.default = v.default;
157
+ if (v.required !== undefined) result.required = v.required;
158
+ return result;
159
+ }
160
+
153
161
  export function saveConfig(config: PtConfig) {
154
162
  ensureConfigDir();
163
+
164
+ // Normalize variable key ordering (forces 'name' to be first in serialized YAML)
165
+ if (config.variables && Array.isArray(config.variables)) {
166
+ config.variables = config.variables.map(normalizeVariable);
167
+ }
168
+ if (config.templates) {
169
+ for (const key of Object.keys(config.templates)) {
170
+ const template = config.templates[key];
171
+ if (template.variables && Array.isArray(template.variables)) {
172
+ template.variables = template.variables.map(normalizeVariable);
173
+ }
174
+ }
175
+ }
176
+
155
177
  const content = YAML.stringify(config);
156
178
  const tempPath = CONFIG_PATH + '.tmp';
157
179
  const backupPath = CONFIG_PATH + '.bak';
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ program
49
49
  program
50
50
  .command('init [templateName] [destPath]')
51
51
  .description('Initialize a new project from a learned template')
52
+ .option('-f, --file <jsonPath>', 'Initialize directly from a JSON template file without adding it to local config')
52
53
  .option('--skip-post-config', 'Skip running post-config tasks')
53
54
  .option('--dry-run', 'Show what would be created without making changes')
54
55
  .option('-y, --yes', 'Automatically answer yes to prompts')
package/src/remote.ts CHANGED
@@ -10,11 +10,14 @@ export async function downloadAndExtract(url: string): Promise<string> {
10
10
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
11
11
  let downloadUrl = url;
12
12
 
13
+ // Strip trailing slash and .git suffix before converting to archive URL
14
+ let cleanUrl = url.replace(/\/$/, '').replace(/\.git$/, '');
15
+
13
16
  // Convert GitHub/Gitea URLs to Zip/Tarball endpoints
14
17
  if (url.includes('github.com')) {
15
- downloadUrl = url.replace(/\/$/, '') + '/archive/refs/heads/main.tar.gz';
18
+ downloadUrl = cleanUrl + '/archive/refs/heads/main.tar.gz';
16
19
  } else if (url.includes('gitea')) {
17
- downloadUrl = url.replace(/\/$/, '') + '/archive/main.tar.gz';
20
+ downloadUrl = cleanUrl + '/archive/main.tar.gz';
18
21
  }
19
22
 
20
23
  const response = await fetch(downloadUrl);
@@ -0,0 +1,98 @@
1
+ import { test, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ // Force a temporary home directory for testing before importing anything from the CLI
7
+ const testHome = path.join(process.cwd(), '.test-home-config');
8
+ process.env.HOME = testHome;
9
+
10
+ import { normalizeVariable, saveConfig, loadConfig, PtConfig, CONFIG_PATH } from '../src/config.js';
11
+
12
+ test('normalizeVariable key ordering', () => {
13
+ const variableInput = {
14
+ required: true,
15
+ default: 'my-default',
16
+ prompt: 'Enter variable:',
17
+ name: 'varName'
18
+ };
19
+
20
+ const normalized = normalizeVariable(variableInput);
21
+
22
+ // Verify that name is the first key in the returned object
23
+ const keys = Object.keys(normalized);
24
+ assert.strictEqual(keys[0], 'name', 'The "name" key must be first');
25
+ assert.deepStrictEqual(keys, ['name', 'prompt', 'default', 'required']);
26
+ });
27
+
28
+ test('saveConfig and loadConfig roundtrip', () => {
29
+ // Clean up any existing test files
30
+ if (fs.existsSync(CONFIG_PATH)) {
31
+ fs.unlinkSync(CONFIG_PATH);
32
+ }
33
+ const backupPath = CONFIG_PATH + '.bak';
34
+ if (fs.existsSync(backupPath)) {
35
+ fs.unlinkSync(backupPath);
36
+ }
37
+
38
+ const testConfig: PtConfig = {
39
+ version: '1.0.0',
40
+ templates: {
41
+ 'test-tpl': {
42
+ description: 'Test Template Description',
43
+ folders: [
44
+ { name: 'src', info: 'source folder' }
45
+ ],
46
+ variables: [
47
+ {
48
+ required: true,
49
+ default: 'hello',
50
+ prompt: 'Prompt:',
51
+ name: 'myVar'
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ };
57
+
58
+ // Save the config
59
+ saveConfig(testConfig);
60
+
61
+ // Assert CONFIG_PATH exists and has been created
62
+ assert.ok(fs.existsSync(CONFIG_PATH), 'Config file should be created');
63
+
64
+ // Verify key order in YAML string
65
+ const yamlContent = fs.readFileSync(CONFIG_PATH, 'utf-8');
66
+ assert.ok(yamlContent.includes('name: myVar'), 'Should contain myVar variable');
67
+
68
+ // Load the config back
69
+ const loaded = loadConfig();
70
+ assert.strictEqual(loaded.version, '1.0.0');
71
+ assert.ok(loaded.templates['test-tpl']);
72
+ assert.strictEqual(loaded.templates['test-tpl'].description, 'Test Template Description');
73
+
74
+ const loadedVar = loaded.templates['test-tpl'].variables?.[0];
75
+ assert.ok(loadedVar);
76
+ assert.strictEqual(loadedVar.name, 'myVar');
77
+
78
+ // Test atomic backup behavior: saving again should create a backup
79
+ saveConfig({
80
+ ...testConfig,
81
+ version: '1.0.1'
82
+ });
83
+
84
+ assert.ok(fs.existsSync(backupPath), 'Backup file .bak should exist after a second save');
85
+ const backupContent = fs.readFileSync(backupPath, 'utf-8');
86
+ assert.ok(backupContent.includes("version: 1.0.0") || backupContent.includes("version: '1.0.0'"), 'Backup should contain previous version');
87
+
88
+ // Clean up
89
+ if (fs.existsSync(CONFIG_PATH)) {
90
+ fs.unlinkSync(CONFIG_PATH);
91
+ }
92
+ if (fs.existsSync(backupPath)) {
93
+ fs.unlinkSync(backupPath);
94
+ }
95
+ if (fs.existsSync(testHome)) {
96
+ fs.rmdirSync(testHome);
97
+ }
98
+ });
@@ -0,0 +1,77 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ // Force a temporary home directory for testing before importing anything from the CLI
7
+ const testHome = path.join(process.cwd(), '.test-home-init');
8
+ process.env.HOME = testHome;
9
+
10
+ import { init } from '../src/commands/initCommand.js';
11
+
12
+ test('direct JSON template initialization via --file', async () => {
13
+ const jsonFilePath = path.join(process.cwd(), 'test-direct-template.json');
14
+ const projectDest = path.join(process.cwd(), 'test-scaffolded-project');
15
+
16
+ // Ensure clean state
17
+ if (fs.existsSync(jsonFilePath)) {
18
+ fs.unlinkSync(jsonFilePath);
19
+ }
20
+ if (fs.existsSync(projectDest)) {
21
+ fs.rmSync(projectDest, { recursive: true, force: true });
22
+ }
23
+
24
+ // Create a mock template JSON configuration
25
+ const mockTemplate = {
26
+ name: 'direct-json-test',
27
+ description: 'A mock template for direct JSON scaffolding test',
28
+ folders: [
29
+ {
30
+ name: 'src',
31
+ info: 'contains sources',
32
+ children: [
33
+ { name: 'components', info: 'reusable components' },
34
+ { name: 'utils', info: 'utility functions' }
35
+ ]
36
+ },
37
+ {
38
+ name: 'docs',
39
+ info: 'documentation folder'
40
+ }
41
+ ]
42
+ };
43
+
44
+ fs.writeFileSync(jsonFilePath, JSON.stringify(mockTemplate, null, 2));
45
+
46
+ // Run the init command with the --file option
47
+ // targetName (1st arg) is omitted/undefined, destPath (2nd arg) is our projectDest, file option is provided
48
+ await init(undefined, projectDest, {
49
+ file: jsonFilePath,
50
+ yes: true,
51
+ skipPostConfig: true
52
+ });
53
+
54
+ // Verify structure was created successfully
55
+ assert.ok(fs.existsSync(projectDest), 'Project destination folder should exist');
56
+ assert.ok(fs.existsSync(path.join(projectDest, 'src')), 'src directory should exist');
57
+ assert.ok(fs.existsSync(path.join(projectDest, 'src/components')), 'src/components directory should exist');
58
+ assert.ok(fs.existsSync(path.join(projectDest, 'src/utils')), 'src/utils directory should exist');
59
+ assert.ok(fs.existsSync(path.join(projectDest, 'docs')), 'docs directory should exist');
60
+
61
+ // Verify metadata file .info.md was created
62
+ assert.ok(fs.existsSync(path.join(projectDest, '.info.md')), '.info.md file should exist');
63
+ const infoContent = fs.readFileSync(path.join(projectDest, '.info.md'), 'utf-8');
64
+ assert.ok(infoContent.includes('direct-json-test'), 'Should contain template name');
65
+ assert.ok(infoContent.includes('mock template'), 'Should contain description');
66
+
67
+ // Clean up
68
+ if (fs.existsSync(jsonFilePath)) {
69
+ fs.unlinkSync(jsonFilePath);
70
+ }
71
+ if (fs.existsSync(projectDest)) {
72
+ fs.rmSync(projectDest, { recursive: true, force: true });
73
+ }
74
+ if (fs.existsSync(testHome)) {
75
+ fs.rmSync(testHome, { recursive: true, force: true });
76
+ }
77
+ });