@garyr/pt-cli 0.32.1 → 0.33.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/doc/usage.md CHANGED
@@ -22,26 +22,24 @@ pt learn /path/to/PROJECT --name my_template --desc "My new template" --yes
22
22
 
23
23
  ### Remote Template Learning
24
24
 
25
- `pt learn` supports learning templates directly from a remote Git repository or tarball archive by passing an `http://` or `https://` URL:
25
+ `pt learn` supports learning templates directly from a remote Git repository or tarball archive by passing an `http://` or `https://` URL. **Security is enforced**: only downloads from `trustedSources` (configured in `~/.pt/config.yaml`) are allowed by default. Untrusted sources trigger a warning and require explicit confirmation.
26
26
 
27
27
  ```bash
28
28
  # Learn a template directly from a GitHub repository
29
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
30
  ```
34
31
 
35
32
  #### 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
33
 
34
+ 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`).
35
+ 2. **Security Verification:** Downloads are restricted to trusted sources (configured in `~/.pt/config.yaml`). Untrusted sources trigger a warning and require explicit confirmation.
36
+ 3. **Download & Extraction:** The tool downloads the archive into a secure temporary folder and extracts it. File size is validated (max 50MB).
37
+ 4. **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.
38
+ 5. **Save Config:** The template config (skeleton structure, files, variables) is saved to the local configuration, pointing to the temporary folder as the `templateRoot`.
41
39
 
42
40
  ### Automatic Variable Detection
43
41
 
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.
42
+ 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.
45
43
 
46
44
  - **Detection Range:** Root files and 1st-level subfolder files (e.g., `README.md`, `.makerc`, `DOC/closedown.md`).
47
45
  - **Registration:** Any detected variables are automatically added to the template's configuration with default prompts (e.g., `Enter variable_name:`).
@@ -141,7 +139,12 @@ The JSON template config file is the recommended way to make a template director
141
139
  "name": "my-web-app",
142
140
  "description": "A Node.js web application with Express",
143
141
  "variables": [
144
- { "name": "project_name", "prompt": "Project name:", "default": "my-app", "required": true },
142
+ {
143
+ "name": "project_name",
144
+ "prompt": "Project name:",
145
+ "default": "my-app",
146
+ "required": true
147
+ },
145
148
  { "name": "author", "prompt": "Author name:", "default": "" }
146
149
  ],
147
150
  "folders": [
@@ -149,16 +152,18 @@ The JSON template config file is the recommended way to make a template director
149
152
  { "name": "tests", "children": [] }
150
153
  ],
151
154
  "copy_files": [
152
- { "src": "package.json", "dest": "package.json", "substitute_variables": true },
155
+ {
156
+ "src": "package.json",
157
+ "dest": "package.json",
158
+ "substitute_variables": true
159
+ },
153
160
  { "src": "README.md", "dest": "README.md", "substitute_variables": true }
154
161
  ],
155
162
  "post_config": [
156
163
  { "command": "git init", "description": "Initialize git repository" },
157
164
  { "command": "npm install", "description": "Install dependencies" }
158
165
  ],
159
- "post_copy": [
160
- { "src": "bin/start.sh", "dest": "bin/start.sh" }
161
- ]
166
+ "post_copy": [{ "src": "bin/start.sh", "dest": "bin/start.sh" }]
162
167
  }
163
168
  ```
164
169
 
@@ -189,30 +194,39 @@ pt init ./new-project --file .pt-template.json --yes
189
194
  For a more portable, text-based approach, you can export and import templates as JSON strings or files.
190
195
 
191
196
  #### Exporting a Template to JSON
197
+
192
198
  To export an existing template from your configuration as JSON:
199
+
193
200
  ```bash
194
201
  pt config <template_name> --json > my_template.json
195
202
  ```
196
203
 
197
204
  #### Importing a Template from JSON
205
+
198
206
  To add a template from a JSON file:
207
+
199
208
  ```bash
200
209
  pt add <template_name> --file my_template.json
201
210
  ```
202
211
 
203
212
  Or from a JSON string:
213
+
204
214
  ```bash
205
215
  pt add <template_name> '{"description":"My Template","files":{...}}'
206
216
  ```
207
217
 
208
218
  #### Direct JSON Scaffolding (no config registration)
219
+
209
220
  To scaffold a project directly from a JSON file without adding the template to your config:
221
+
210
222
  ```bash
211
223
  pt init ./destination --file my_template.json --yes
212
224
  ```
213
225
 
214
226
  #### Exporting Full Config
227
+
215
228
  To see your entire configuration (including all templates) in JSON format:
229
+
216
230
  ```bash
217
231
  pt config --json
218
232
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -14,9 +14,9 @@
14
14
  "test": "node --import tsx --test tests/**/*.test.ts",
15
15
  "test:sequential": "node --import tsx --test tests/config.test.ts && node --import tsx --test tests/init.test.ts && node --import tsx --test tests/learn.test.ts && node --import tsx --test tests/substitute.test.ts && node --import tsx --test tests/config-utils.test.ts",
16
16
  "prepublishOnly": "npm run build",
17
- "build:linux": "bun build ./src/index.ts --compile --target=bun-linux-x64 --outfile ../BUILD/pt-cli/pt-linux",
18
- "build:macos": "bun build ./src/index.ts --compile --target=bun-darwin-x64 --outfile ../BUILD/pt-cli/pt-macos",
19
- "build:windows": "bun build ./src/index.ts --compile --target=bun-windows-x64 --outfile ../BUILD/pt-cli/pt-win.exe",
17
+ "build:linux": "bun build ./src/index.ts --compile --minify --target=bun-linux-x64 --outfile ../BUILD/pt-cli/pt-linux",
18
+ "build:macos": "bun build ./src/index.ts --compile --minify --target=bun-darwin-x64 --outfile ../BUILD/pt-cli/pt-macos",
19
+ "build:windows": "bun build ./src/index.ts --compile --minify --target=bun-windows-x64 --outfile ../BUILD/pt-cli/pt-win.exe",
20
20
  "build:all": "npm run build:linux && npm run build:macos && npm run build:windows"
21
21
  },
22
22
  "license": "MIT",
@@ -79,17 +79,25 @@ pt init ./new-project --file my-template.json --yes
79
79
 
80
80
  ### Round-trip Workflow
81
81
 
82
- The complete portable template workflow:
83
- 1. `pt config my-template --json > .pt-template.json` — export config
84
- 2. Copy `.pt-template.json` into the template source directory
85
- 3. Share the directory (zip, git repo, etc.)
86
- 4. Recipient: `pt learn /path/to/shared-dir --yes` auto-detects everything
87
- 5. Or scaffold directly without registering: `pt init ./new-project --file .pt-template.json --yes`
82
+ The complete portable template workflow, perfect for sharing:
83
+
84
+ 1. **Create template:** `pt init <template> <dest> --yes --skip-post-config` creates structure without auto-executing post-config tasks (e.g., `git init`)
85
+ 2. **Export config:** `pt config <template> --json > <dest>/.pt-template.json` — exports portable JSON config, required to include variables
86
+ 3. **Share directory/JSON:** Share the directory or JSON file
87
+ 4. **Recipient:** `pt learn <path> --name <template> --yes` — imports the template
88
+ 5. **Or scaffold directly:** `pt init ./new-project --file .pt-template.json --yes`
88
89
 
89
90
  ### JSON Output for Sharing
90
91
 
91
92
  You can output a template as JSON for sharing without saving it: `pt learn <source_path> --json`
92
93
 
94
+ ### Important Notes
95
+
96
+ - **Never use manual `mkdir`/`cp` steps** — `pt init` already scaffolds directory structures, and manual file copying bypasses template registration
97
+ - **`--skip-post-config` flag** prevents premature task execution during template creation
98
+ - **JSON export captures all template metadata**, variables, and post-config tasks for maximum portability
99
+ - The workflow ensures portability by bundling metadata and files together, allowing recipients to import via `pt learn` without manual directory manipulation
100
+
93
101
  ## Default Post-Config
94
102
 
95
103
  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:
@@ -128,6 +136,56 @@ Global variables are defined in `~/.pt/config.yaml` under `variables`. They act
128
136
  - Atomic saves, backups, and safe initialization logic prevent data loss.
129
137
  - Platform-specific post-config scripts (`.sh`/`.bat`) are executed automatically based on the OS.
130
138
 
139
+ ## Security Configuration
140
+
141
+ ### Security Policy Settings
142
+
143
+ Security settings are configured in `~/.pt/config.yaml` under the `security` key:
144
+
145
+ ```yaml
146
+ security:
147
+ securityLevel: "warn" # "warn" (default) or "strict"
148
+ trustedSources:
149
+ - "github.com/garyritchie"
150
+ - "git.lyonritchie.com"
151
+ - "github.com/lyonritchie"
152
+ maxExecutionTime: 30000 # 30 seconds per command
153
+ maxCommandsPerRun: 50 # rate limit per init session
154
+ enableAuditLogging: true # write events to security-audit.log
155
+ ```
156
+
157
+ ### Security Levels
158
+
159
+ - **`warn`** (default): Warning-based approach with cancellation prompts
160
+ - **`strict`**: More conservative defaults, enabled by default for new installations
161
+
162
+ ### Trusted Sources
163
+
164
+ When downloading templates from remote URLs, `pt-cli` verifies the source against the `trustedSources` list. Untrusted sources trigger a warning and require explicit user confirmation before proceeding.
165
+
166
+ ### Command Security
167
+
168
+ - **Absolute blocks**: Commands like `sudo`, `rm -rf`, `dd` are always blocked
169
+ - **Dangerous commands**: Commands like `curl`, `python`, `chmod` trigger warnings
170
+ - **Rate limiting**: 50 commands per run prevents runaway execution
171
+ - **Execution timeout**: 30 seconds per command prevents hung processes
172
+
173
+ ### Audit Logging
174
+
175
+ All security events are logged to `~/.pt/security-audit.log` for monitoring and troubleshooting.
176
+
177
+ ## Security Testing
178
+
179
+ Security features can be tested by:
180
+
181
+ 1. **Testing command blocks**: Try running templates with dangerous commands like `sudo rm -rf` or `dd`
182
+ 2. **Testing remote downloads**: Use untrusted URLs to verify source verification
183
+ 3. **Testing rate limiting**: Execute more than 50 commands in a single init session
184
+ 4. **Testing timeouts**: Run commands that hang to verify timeout behavior
185
+ 5. **Reviewing audit logs**: Check `~/.pt/security-audit.log` for security events
186
+
187
+ For more details, see the [Security Guide](security.md).
188
+
131
189
  ## CLI Reference
132
190
 
133
191
  | Command | Description |
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import chalk from 'chalk';
6
+ import { SecurityPolicy } from './safety.js';
6
7
 
7
8
  export function getHomeDir(): string {
8
9
  return path.join(os.homedir(), '.pt');
@@ -65,6 +66,7 @@ export interface PtConfig {
65
66
  ignore?: string[]; // top-level folder ignore patterns for pt learn
66
67
  default_post_config?: PostConfigTask[]; // default post-config tasks applied to all projects
67
68
  variables?: TemplateVariable[]; // global variables for substitution
69
+ security?: SecurityPolicy; // security policy configuration
68
70
  }
69
71
 
70
72
  export function ensureConfigDir() {
@@ -221,6 +223,25 @@ export function getDefaultPostConfig(config: PtConfig): PostConfigTask[] {
221
223
  }));
222
224
  }
223
225
 
226
+ /**
227
+ * Get security policy from config or use defaults
228
+ */
229
+ export function getSecurityPolicy(config: PtConfig): SecurityPolicy {
230
+ const defaultPolicy: SecurityPolicy = {
231
+ maxExecutionTime: 30000, // 30 seconds
232
+ enableAuditLogging: true,
233
+ trustedSources: [
234
+ 'github.com/garyritchie',
235
+ 'gitea.lyonritchie.com/garyritchie',
236
+ 'github.com/lyonritchie',
237
+ ],
238
+ maxCommandsPerRun: 50,
239
+ securityLevel: 'warn',
240
+ };
241
+
242
+ return config.security || defaultPolicy;
243
+ }
244
+
224
245
  // Default exclusions for template scanning
225
246
  export const DEFAULT_EXCLUDES = [
226
247
  '.git',
package/src/postconfig.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
  import { execSync } from 'child_process';
4
5
  import chalk from 'chalk';
5
6
  import inquirer from 'inquirer';
6
7
  import { PostConfigTask } from './config.js';
8
+ import {
9
+ isBlockedCommand,
10
+ isDangerousCommand,
11
+ executeWithTimeout,
12
+ logSecurityEvent,
13
+ canExecute,
14
+ showDangerousCommandWarning,
15
+ getSecurityPolicy,
16
+ } from './safety.js';
7
17
 
8
18
  export interface PostConfigOptions {
9
19
  skipPostConfig?: boolean;
@@ -22,6 +32,16 @@ export async function runPostConfig(
22
32
  ): Promise<void> {
23
33
  if (options.skipPostConfig) return;
24
34
 
35
+ // Load security policy
36
+ const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
37
+ const securityPolicy = getSecurityPolicy(configPath);
38
+
39
+ // Check rate limiting
40
+ if (!canExecute('init', securityPolicy.maxCommandsPerRun)) {
41
+ console.log(chalk.yellow('⚠️ Rate limit reached: max commands per run exceeded'));
42
+ return;
43
+ }
44
+
25
45
  // 1. Filter tasks by type
26
46
  const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
27
47
 
@@ -54,18 +74,59 @@ export async function runPostConfig(
54
74
  const progress = `[${i + 1}/${applicableTasks.length}]`;
55
75
 
56
76
  if (task.command) {
77
+ // SECURITY CHECK 1: Blocklist check (NEVER allow these)
78
+ if (isBlockedCommand(task.command)) {
79
+ console.log(chalk.red(`${progress} ⚠️ BLOCKED: ${task.command}`));
80
+ logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
81
+ continue;
82
+ }
83
+
84
+ // SECURITY CHECK 2: Dangerous command warning (but allow execution)
85
+ if (isDangerousCommand(task.command)) {
86
+ console.log(chalk.yellow(`${progress} ⚠️ WARNING: This command may be dangerous: ${task.command}`));
87
+ console.log(chalk.yellow(` Press CTRL+C to cancel, or wait 5s to continue...`));
88
+
89
+ // Wait for user to cancel or timeout
90
+ const allowContinue = await showDangerousCommandWarning(task.command, 5);
91
+ if (!allowContinue) {
92
+ console.log(chalk.yellow(`${progress} ⊘ Command cancelled by user`));
93
+ logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
94
+ continue;
95
+ }
96
+ }
97
+
98
+ // SECURITY CHECK 3: Rate limiting
99
+ if (!canExecute(task.command, securityPolicy.maxCommandsPerRun)) {
100
+ console.log(chalk.red(`${progress} ⚠️ Rate limited: too many commands executed`));
101
+ continue;
102
+ }
103
+
57
104
  if (options.dryRun) {
58
105
  console.log(chalk.gray(` [DRY RUN] Would run: ${task.command}`));
59
106
  } else {
60
107
  try {
61
108
  console.log(chalk.yellow(`\n${progress} Running: ${task.command}`));
62
- execSync(task.command, {
63
- cwd: destPath,
64
- stdio: 'inherit'
65
- });
66
- console.log(chalk.green(' ✓ Command completed successfully'));
109
+
110
+ // SECURITY CHECK 4: Execution timeout
111
+ const result = await executeWithTimeout(
112
+ task.command,
113
+ destPath,
114
+ securityPolicy.maxExecutionTime
115
+ );
116
+
117
+ if (result.timedOut) {
118
+ console.log(chalk.red(` ✗ Command timed out after ${securityPolicy.maxExecutionTime / 1000}s`));
119
+ logSecurityEvent('command_timed_out', task.command, projectType, 'timedout');
120
+ } else if (result.success) {
121
+ console.log(chalk.green(' ✓ Command completed successfully'));
122
+ logSecurityEvent('command_executed', task.command, projectType, 'success');
123
+ } else {
124
+ console.log(chalk.red(` ✗ Command failed: ${result.stderr}`));
125
+ logSecurityEvent('command_executed', task.command, projectType, 'failed');
126
+ }
67
127
  } catch (err) {
68
128
  console.log(chalk.red(' ✗ Command failed'));
129
+ logSecurityEvent('command_executed', task.command, projectType, 'failed');
69
130
  }
70
131
  }
71
132
  }
package/src/remote.ts CHANGED
@@ -5,8 +5,36 @@ import os from 'os';
5
5
  import { Readable } from 'stream';
6
6
  import { finished } from 'stream/promises';
7
7
  import { extract } from 'tar'; // You'll need: npm install tar
8
+ import chalk from 'chalk';
9
+ import { isTrustedSource, logSecurityEvent, getSecurityPolicy } from './safety.js';
10
+ import { loadConfig } from './config.js';
8
11
 
9
12
  export async function downloadAndExtract(url: string): Promise<string> {
13
+ // Load security policy
14
+ const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
15
+ const config = loadConfig();
16
+ const securityPolicy = config.security || {
17
+ trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
18
+ };
19
+
20
+ // SECURITY CHECK: Verify source is trusted
21
+ if (!isTrustedSource(url, securityPolicy.trustedSources)) {
22
+ console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
23
+ console.log(chalk.yellow(' Only use templates from trusted sources'));
24
+
25
+ const inquirer = (await import('inquirer')).default;
26
+ const response = await inquirer.prompt({
27
+ type: 'confirm',
28
+ name: 'proceed',
29
+ message: chalk.red('Continue anyway?'),
30
+ default: false
31
+ });
32
+
33
+ if (!response.proceed) {
34
+ throw new Error('Download cancelled by user due to untrusted source');
35
+ }
36
+ }
37
+
10
38
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
11
39
  let downloadUrl = url;
12
40
 
@@ -27,10 +55,21 @@ export async function downloadAndExtract(url: string): Promise<string> {
27
55
  const fileStream = fs.createWriteStream(dest);
28
56
  await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
29
57
 
58
+ // SECURITY: Validate downloaded file before extraction
59
+ const stats = fs.statSync(dest);
60
+ if (stats.size > 50 * 1024 * 1024) { // 50MB limit
61
+ throw new Error('Downloaded template is too large (>50MB)');
62
+ }
63
+
30
64
  // Extract tarball
31
65
  await extract({ file: dest, cwd: tempDir });
32
66
 
33
67
  // Find the actual content folder (archives usually wrap content in a folder)
34
68
  const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
35
- return path.join(tempDir, dirs[0]);
69
+ const extractedPath = path.join(tempDir, dirs[0]);
70
+
71
+ // SECURITY: Log successful download
72
+ logSecurityEvent('template_loaded', downloadUrl, 'remote', 'success');
73
+
74
+ return extractedPath;
36
75
  }