@garyr/pt-cli 0.26.0 → 0.28.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 +11 -2
- package/dist/commands/initCommand.js +47 -26
- package/dist/commands/learnCommand.js +94 -35
- package/dist/config.js +22 -0
- package/dist/index.js +1 -0
- package/dist/remote.js +29 -0
- package/doc/configuration.md +2 -0
- package/doc/usage.md +99 -0
- package/package.json +9 -6
- package/skills/agency-pt-operator/SKILL.md +51 -1
- package/src/commands/initCommand.ts +50 -28
- package/src/commands/learnCommand.ts +85 -34
- package/src/config.ts +22 -0
- package/src/index.ts +1 -0
- package/src/remote.ts +33 -0
- package/tests/config.test.ts +98 -0
- package/tests/init.test.ts +77 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ graph LR
|
|
|
18
18
|
|
|
19
19
|
%% Flow logic
|
|
20
20
|
Existing -- Learn --> Engine
|
|
21
|
-
Config
|
|
21
|
+
Config <-- Read/Write --> Engine
|
|
22
22
|
Engine -- Initialize --> RSA
|
|
23
23
|
Engine -- Initialize --> RSB
|
|
24
24
|
|
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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."));
|
|
@@ -3,15 +3,44 @@ import path from 'path';
|
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
4
|
import { loadConfig, saveConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, getDefaultPostConfig } from '../config.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
+
import { downloadAndExtract } from '../remote.js';
|
|
6
7
|
export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
7
|
-
|
|
8
|
+
let resolvedPath;
|
|
9
|
+
// Phase 1: Remote Check
|
|
10
|
+
if (sourcePath.startsWith('http')) {
|
|
11
|
+
console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
|
|
12
|
+
resolvedPath = await downloadAndExtract(sourcePath);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
resolvedPath = path.resolve(sourcePath);
|
|
16
|
+
}
|
|
8
17
|
if (!fs.existsSync(resolvedPath)) {
|
|
9
|
-
console.error(chalk.red(`Error: Path "${
|
|
18
|
+
console.error(chalk.red(`Error: Path "${resolvedPath}" does not exist.`));
|
|
10
19
|
process.exit(1);
|
|
11
20
|
}
|
|
12
21
|
const isUpdate = !!updateTemplate;
|
|
13
22
|
const config = loadConfig();
|
|
14
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
|
+
}
|
|
15
44
|
// Check for .info.md
|
|
16
45
|
let infoName = '';
|
|
17
46
|
let infoDesc = '';
|
|
@@ -39,6 +68,11 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
39
68
|
if (options.name) {
|
|
40
69
|
targetName = options.name;
|
|
41
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
|
+
}
|
|
42
76
|
else if (infoName) {
|
|
43
77
|
targetName = infoName;
|
|
44
78
|
if (!options.json)
|
|
@@ -90,7 +124,12 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
90
124
|
}
|
|
91
125
|
}
|
|
92
126
|
else {
|
|
93
|
-
if (
|
|
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) {
|
|
94
133
|
description = infoDesc;
|
|
95
134
|
if (!options.json)
|
|
96
135
|
console.log(chalk.cyan(`Auto-detected template description from .info.md: ${description}`));
|
|
@@ -120,6 +159,9 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
120
159
|
if (isUpdate && config.templates[updateTemplate].variables) {
|
|
121
160
|
variables = [...config.templates[updateTemplate].variables];
|
|
122
161
|
}
|
|
162
|
+
else if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
|
|
163
|
+
variables = [...fileTemplateConfig.variables];
|
|
164
|
+
}
|
|
123
165
|
// Add detected variables if not already present
|
|
124
166
|
for (const varName of detectedVars) {
|
|
125
167
|
if (!variables.some(v => v.name === varName)) {
|
|
@@ -171,7 +213,9 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
171
213
|
}
|
|
172
214
|
}
|
|
173
215
|
// 1. Structure (skeleton)
|
|
174
|
-
const folders =
|
|
216
|
+
const folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
217
|
+
? fileTemplateConfig.folders
|
|
218
|
+
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
175
219
|
// 2. Content Selection (Root only)
|
|
176
220
|
const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
|
177
221
|
.filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
|
|
@@ -241,52 +285,62 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
241
285
|
selectedFolders = copyFoldersResponse.selectedFolders;
|
|
242
286
|
}
|
|
243
287
|
const copy_files = [];
|
|
244
|
-
|
|
245
|
-
copy_files.push(
|
|
288
|
+
if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
|
|
289
|
+
copy_files.push(...fileTemplateConfig.copy_files);
|
|
246
290
|
}
|
|
247
|
-
|
|
248
|
-
|
|
291
|
+
else {
|
|
292
|
+
for (const f of selectedFiles) {
|
|
293
|
+
copy_files.push({ src: f, dest: f, substitute_variables: true });
|
|
294
|
+
}
|
|
295
|
+
for (const d of selectedFolders) {
|
|
296
|
+
copy_files.push({ src: d, dest: d, substitute_variables: true });
|
|
297
|
+
}
|
|
249
298
|
}
|
|
250
299
|
const templateConfig = {
|
|
251
300
|
description: description,
|
|
252
301
|
templateRoot: resolvedPath,
|
|
253
|
-
folders: folders.filter(f => selectedStructure.includes(f.name)),
|
|
302
|
+
folders: fileTemplateConfig.folders ? folders : folders.filter(f => selectedStructure.includes(f.name)),
|
|
254
303
|
copy_files: copy_files,
|
|
255
304
|
variables: variables.length > 0 ? variables : undefined
|
|
256
305
|
};
|
|
257
306
|
// Check for post_config scripts
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (fs.existsSync(shPath)) {
|
|
262
|
-
const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
|
|
263
|
-
let currentDesc = '';
|
|
264
|
-
for (const line of lines) {
|
|
265
|
-
if (line.startsWith('echo "Running: ')) {
|
|
266
|
-
currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
|
|
267
|
-
}
|
|
268
|
-
else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
|
|
269
|
-
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
270
|
-
currentDesc = '';
|
|
271
|
-
}
|
|
272
|
-
}
|
|
307
|
+
let postConfigTasks = [];
|
|
308
|
+
if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
|
|
309
|
+
postConfigTasks = [...fileTemplateConfig.post_config];
|
|
273
310
|
}
|
|
274
|
-
else
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
311
|
+
else {
|
|
312
|
+
const shPath = path.join(resolvedPath, 'post_config.sh');
|
|
313
|
+
const batPath = path.join(resolvedPath, 'post_config.bat');
|
|
314
|
+
if (fs.existsSync(shPath)) {
|
|
315
|
+
const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
|
|
316
|
+
let currentDesc = '';
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
if (line.startsWith('echo "Running: ')) {
|
|
319
|
+
currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
|
|
320
|
+
}
|
|
321
|
+
else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
|
|
322
|
+
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
323
|
+
currentDesc = '';
|
|
324
|
+
}
|
|
280
325
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
326
|
+
}
|
|
327
|
+
else if (fs.existsSync(batPath)) {
|
|
328
|
+
const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
|
|
329
|
+
let currentDesc = '';
|
|
330
|
+
for (const line of lines) {
|
|
331
|
+
if (line.startsWith('echo Running: ')) {
|
|
332
|
+
currentDesc = line.substring(14).trim();
|
|
333
|
+
}
|
|
334
|
+
else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
|
|
335
|
+
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
336
|
+
currentDesc = '';
|
|
337
|
+
}
|
|
284
338
|
}
|
|
285
339
|
}
|
|
286
340
|
}
|
|
287
341
|
if (postConfigTasks.length > 0) {
|
|
288
342
|
templateConfig.post_config = postConfigTasks;
|
|
289
|
-
if (!options.json)
|
|
343
|
+
if (!options.json && !fileTemplateConfig.post_config)
|
|
290
344
|
console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
|
|
291
345
|
}
|
|
292
346
|
// Handle default_post_config tasks
|
|
@@ -344,7 +398,12 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
344
398
|
detectedExecutables.push(file);
|
|
345
399
|
}
|
|
346
400
|
}
|
|
347
|
-
if (
|
|
401
|
+
if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
|
|
402
|
+
templateConfig.post_copy = fileTemplateConfig.post_copy;
|
|
403
|
+
const postCopySrcs = fileTemplateConfig.post_copy.map(f => f.src);
|
|
404
|
+
templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !postCopySrcs.includes(cf.src));
|
|
405
|
+
}
|
|
406
|
+
else if (detectedExecutables.length > 0) {
|
|
348
407
|
if (!options.json) {
|
|
349
408
|
console.log(chalk.cyan("\nAuto-detected " + detectedExecutables.length + " executable file(s) at root:"));
|
|
350
409
|
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
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/remote.ts (New File)
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { Readable } from 'stream';
|
|
6
|
+
import { finished } from 'stream/promises';
|
|
7
|
+
import { extract } from 'tar'; // You'll need: npm install tar
|
|
8
|
+
export async function downloadAndExtract(url) {
|
|
9
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
|
|
10
|
+
let downloadUrl = url;
|
|
11
|
+
// Convert GitHub/Gitea URLs to Zip/Tarball endpoints
|
|
12
|
+
if (url.includes('github.com')) {
|
|
13
|
+
downloadUrl = url.replace(/\/$/, '') + '/archive/refs/heads/main.tar.gz';
|
|
14
|
+
}
|
|
15
|
+
else if (url.includes('gitea')) {
|
|
16
|
+
downloadUrl = url.replace(/\/$/, '') + '/archive/main.tar.gz';
|
|
17
|
+
}
|
|
18
|
+
const response = await fetch(downloadUrl);
|
|
19
|
+
if (!response.ok)
|
|
20
|
+
throw new Error(`Failed to fetch ${downloadUrl}: ${response.statusText}`);
|
|
21
|
+
const dest = path.join(tempDir, 'template.tar.gz');
|
|
22
|
+
const fileStream = fs.createWriteStream(dest);
|
|
23
|
+
await finished(Readable.fromWeb(response.body).pipe(fileStream));
|
|
24
|
+
// Extract tarball
|
|
25
|
+
await extract({ file: dest, cwd: tempDir });
|
|
26
|
+
// Find the actual content folder (archives usually wrap content in a folder)
|
|
27
|
+
const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
|
|
28
|
+
return path.join(tempDir, dirs[0]);
|
|
29
|
+
}
|
package/doc/configuration.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -27,16 +28,18 @@
|
|
|
27
28
|
"url": "https://github.com/garyritchie/pt-cli"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
31
|
+
"chalk": "^5.3.0",
|
|
30
32
|
"commander": "^12.1.0",
|
|
31
33
|
"inquirer": "^12.3.0",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
+
"tar": "^7.5.15",
|
|
35
|
+
"yaml": "^2.6.1"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
|
-
"typescript": "^5.6.0",
|
|
37
|
-
"@types/node": "^22.0.0",
|
|
38
38
|
"@types/inquirer": "^9.0.7",
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"@types/tar": "^6.1.13",
|
|
39
41
|
"ts-node": "^10.9.2",
|
|
40
|
-
"tsx": "^4.21.0"
|
|
42
|
+
"tsx": "^4.21.0",
|
|
43
|
+
"typescript": "^5.6.0"
|
|
41
44
|
}
|
|
42
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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."));
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
4
|
import { loadConfig, saveConfig, FolderNode, TemplateConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, PostCopyFile, TemplateVariable, CopyFileEntry, PostConfigTask, getDefaultPostConfig } from '../config.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
+
import { downloadAndExtract } from '../remote.js';
|
|
6
7
|
|
|
7
8
|
export interface LearnOptions {
|
|
8
9
|
ignore?: string;
|
|
@@ -12,11 +13,20 @@ export interface LearnOptions {
|
|
|
12
13
|
json?: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
|
|
15
17
|
export async function learn(sourcePath: string, updateTemplate: string | null = null, options: LearnOptions = {}): Promise<void> {
|
|
16
|
-
|
|
18
|
+
let resolvedPath: string;
|
|
19
|
+
|
|
20
|
+
// Phase 1: Remote Check
|
|
21
|
+
if (sourcePath.startsWith('http')) {
|
|
22
|
+
console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
|
|
23
|
+
resolvedPath = await downloadAndExtract(sourcePath);
|
|
24
|
+
} else {
|
|
25
|
+
resolvedPath = path.resolve(sourcePath);
|
|
26
|
+
}
|
|
17
27
|
|
|
18
28
|
if (!fs.existsSync(resolvedPath)) {
|
|
19
|
-
console.error(chalk.red(`Error: Path "${
|
|
29
|
+
console.error(chalk.red(`Error: Path "${resolvedPath}" does not exist.`));
|
|
20
30
|
process.exit(1);
|
|
21
31
|
}
|
|
22
32
|
|
|
@@ -24,6 +34,25 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
24
34
|
const config = loadConfig();
|
|
25
35
|
const existingNames = getTemplateNames(config);
|
|
26
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
|
+
|
|
27
56
|
// Check for .info.md
|
|
28
57
|
let infoName = '';
|
|
29
58
|
let infoDesc = '';
|
|
@@ -50,6 +79,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
50
79
|
} else {
|
|
51
80
|
if (options.name) {
|
|
52
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}`));
|
|
53
85
|
} else if (infoName) {
|
|
54
86
|
targetName = infoName;
|
|
55
87
|
if (!options.json) console.log(chalk.cyan(`Auto-detected template name from .info.md: ${targetName}`));
|
|
@@ -96,7 +128,10 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
96
128
|
}
|
|
97
129
|
}
|
|
98
130
|
} else {
|
|
99
|
-
if (
|
|
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) {
|
|
100
135
|
description = infoDesc;
|
|
101
136
|
if (!options.json) console.log(chalk.cyan(`Auto-detected template description from .info.md: ${description}`));
|
|
102
137
|
} else if (options.yes || options.json) {
|
|
@@ -126,6 +161,8 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
126
161
|
// Merge with existing variables if update
|
|
127
162
|
if (isUpdate && config.templates[updateTemplate].variables) {
|
|
128
163
|
variables = [...config.templates[updateTemplate].variables];
|
|
164
|
+
} else if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
|
|
165
|
+
variables = [...fileTemplateConfig.variables];
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
// Add detected variables if not already present
|
|
@@ -184,7 +221,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
184
221
|
}
|
|
185
222
|
|
|
186
223
|
// 1. Structure (skeleton)
|
|
187
|
-
const folders =
|
|
224
|
+
const folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
225
|
+
? fileTemplateConfig.folders
|
|
226
|
+
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
188
227
|
|
|
189
228
|
// 2. Content Selection (Root only)
|
|
190
229
|
const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
|
@@ -260,51 +299,59 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
260
299
|
}
|
|
261
300
|
|
|
262
301
|
const copy_files: CopyFileEntry[] = [];
|
|
263
|
-
|
|
264
|
-
copy_files.push(
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
302
|
+
if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
|
|
303
|
+
copy_files.push(...fileTemplateConfig.copy_files);
|
|
304
|
+
} else {
|
|
305
|
+
for (const f of selectedFiles) {
|
|
306
|
+
copy_files.push({ src: f, dest: f, substitute_variables: true });
|
|
307
|
+
}
|
|
308
|
+
for (const d of selectedFolders) {
|
|
309
|
+
copy_files.push({ src: d, dest: d, substitute_variables: true });
|
|
310
|
+
}
|
|
268
311
|
}
|
|
269
312
|
|
|
270
313
|
const templateConfig: TemplateConfig = {
|
|
271
314
|
description: description,
|
|
272
315
|
templateRoot: resolvedPath,
|
|
273
|
-
folders: folders.filter(f => selectedStructure.includes(f.name)),
|
|
316
|
+
folders: fileTemplateConfig.folders ? folders : folders.filter(f => selectedStructure.includes(f.name)),
|
|
274
317
|
copy_files: copy_files,
|
|
275
318
|
variables: variables.length > 0 ? variables : undefined
|
|
276
319
|
};
|
|
277
320
|
|
|
278
321
|
// Check for post_config scripts
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
322
|
+
let postConfigTasks: PostConfigTask[] = [];
|
|
323
|
+
if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
|
|
324
|
+
postConfigTasks = [...fileTemplateConfig.post_config];
|
|
325
|
+
} else {
|
|
326
|
+
const shPath = path.join(resolvedPath, 'post_config.sh');
|
|
327
|
+
const batPath = path.join(resolvedPath, 'post_config.bat');
|
|
328
|
+
if (fs.existsSync(shPath)) {
|
|
329
|
+
const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
|
|
330
|
+
let currentDesc = '';
|
|
331
|
+
for (const line of lines) {
|
|
332
|
+
if (line.startsWith('echo "Running: ')) {
|
|
333
|
+
currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
|
|
334
|
+
} else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
|
|
335
|
+
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
336
|
+
currentDesc = '';
|
|
337
|
+
}
|
|
291
338
|
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
339
|
+
} else if (fs.existsSync(batPath)) {
|
|
340
|
+
const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
|
|
341
|
+
let currentDesc = '';
|
|
342
|
+
for (const line of lines) {
|
|
343
|
+
if (line.startsWith('echo Running: ')) {
|
|
344
|
+
currentDesc = line.substring(14).trim();
|
|
345
|
+
} else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
|
|
346
|
+
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
347
|
+
currentDesc = '';
|
|
348
|
+
}
|
|
302
349
|
}
|
|
303
350
|
}
|
|
304
351
|
}
|
|
305
352
|
if (postConfigTasks.length > 0) {
|
|
306
353
|
templateConfig.post_config = postConfigTasks;
|
|
307
|
-
if (!options.json) console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
|
|
354
|
+
if (!options.json && !fileTemplateConfig.post_config) console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
|
|
308
355
|
}
|
|
309
356
|
|
|
310
357
|
// Handle default_post_config tasks
|
|
@@ -364,7 +411,11 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
364
411
|
}
|
|
365
412
|
}
|
|
366
413
|
|
|
367
|
-
if (
|
|
414
|
+
if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
|
|
415
|
+
templateConfig.post_copy = fileTemplateConfig.post_copy;
|
|
416
|
+
const postCopySrcs = fileTemplateConfig.post_copy.map(f => f.src);
|
|
417
|
+
templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !postCopySrcs.includes(cf.src));
|
|
418
|
+
} else if (detectedExecutables.length > 0) {
|
|
368
419
|
if (!options.json) {
|
|
369
420
|
console.log(chalk.cyan("\nAuto-detected " + detectedExecutables.length + " executable file(s) at root:"));
|
|
370
421
|
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
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/remote.ts (New File)
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { Readable } from 'stream';
|
|
6
|
+
import { finished } from 'stream/promises';
|
|
7
|
+
import { extract } from 'tar'; // You'll need: npm install tar
|
|
8
|
+
|
|
9
|
+
export async function downloadAndExtract(url: string): Promise<string> {
|
|
10
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
|
|
11
|
+
let downloadUrl = url;
|
|
12
|
+
|
|
13
|
+
// Convert GitHub/Gitea URLs to Zip/Tarball endpoints
|
|
14
|
+
if (url.includes('github.com')) {
|
|
15
|
+
downloadUrl = url.replace(/\/$/, '') + '/archive/refs/heads/main.tar.gz';
|
|
16
|
+
} else if (url.includes('gitea')) {
|
|
17
|
+
downloadUrl = url.replace(/\/$/, '') + '/archive/main.tar.gz';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const response = await fetch(downloadUrl);
|
|
21
|
+
if (!response.ok) throw new Error(`Failed to fetch ${downloadUrl}: ${response.statusText}`);
|
|
22
|
+
|
|
23
|
+
const dest = path.join(tempDir, 'template.tar.gz');
|
|
24
|
+
const fileStream = fs.createWriteStream(dest);
|
|
25
|
+
await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
|
|
26
|
+
|
|
27
|
+
// Extract tarball
|
|
28
|
+
await extract({ file: dest, cwd: tempDir });
|
|
29
|
+
|
|
30
|
+
// Find the actual content folder (archives usually wrap content in a folder)
|
|
31
|
+
const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
|
|
32
|
+
return path.join(tempDir, dirs[0]);
|
|
33
|
+
}
|
|
@@ -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
|
+
});
|