@garyr/pt-cli 0.30.1 → 0.31.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.
@@ -0,0 +1,479 @@
1
+ import { test, 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 from the CLI
7
+ const testHome = path.join(process.cwd(), '.test-home-substitute');
8
+ process.env.HOME = testHome;
9
+
10
+ import { substituteVariables, processCopyFiles } from '../src/substitute.js';
11
+ import { TemplateConfig } from '../src/config.js';
12
+
13
+ // Helper: create a temp directory and return its path
14
+ function makeTempDir(name: string): string {
15
+ const dir = path.join(process.cwd(), `.test-tmp-substitute-${name}-${Date.now()}`);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ return dir;
18
+ }
19
+
20
+ // Helper: recursively remove a directory
21
+ function rmDir(dir: string) {
22
+ if (fs.existsSync(dir)) {
23
+ fs.rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ }
26
+
27
+ // Clean up the test home after all tests
28
+ after(() => {
29
+ rmDir(testHome);
30
+ });
31
+
32
+ // ============================================================
33
+ // substituteVariables tests
34
+ // ============================================================
35
+
36
+ test('substituteVariables: basic single variable substitution', () => {
37
+ const result = substituteVariables('Hello {{ name }}!', { name: 'World' });
38
+ assert.strictEqual(result, 'Hello World!');
39
+ });
40
+
41
+ test('substituteVariables: multiple variables in one string', () => {
42
+ const result = substituteVariables(
43
+ '{{ greeting }}, {{ name }}! Welcome to {{ place }}.',
44
+ { greeting: 'Hello', name: 'Alice', place: 'Wonderland' }
45
+ );
46
+ assert.strictEqual(result, 'Hello, Alice! Welcome to Wonderland.');
47
+ });
48
+
49
+ test('substituteVariables: variable with extra whitespace in braces', () => {
50
+ const result = substituteVariables('Value is {{ name }}.', { name: 'OK' });
51
+ assert.strictEqual(result, 'Value is OK.');
52
+ });
53
+
54
+ test('substituteVariables: variable with no whitespace in braces', () => {
55
+ const result = substituteVariables('Value is {{name}}.', { name: 'OK' });
56
+ assert.strictEqual(result, 'Value is OK.');
57
+ });
58
+
59
+ test('substituteVariables: missing variable remains as normalized placeholder', () => {
60
+ const result = substituteVariables('Hello {{ unknown }}!', {});
61
+ // The regex replaces {{ unknown }} with {{unknown}} (no spaces) when not found
62
+ assert.strictEqual(result, 'Hello {{unknown}}!');
63
+ });
64
+
65
+ test('substituteVariables: empty variables object leaves all placeholders', () => {
66
+ const content = '{{ foo }} and {{ bar }}';
67
+ const result = substituteVariables(content, {});
68
+ assert.strictEqual(result, '{{foo}} and {{bar}}');
69
+ });
70
+
71
+ test('substituteVariables: no variables in content returns content unchanged', () => {
72
+ const content = 'Just a plain string with no mustaches.';
73
+ const result = substituteVariables(content, { name: 'ignored' });
74
+ assert.strictEqual(result, content);
75
+ });
76
+
77
+ test('substituteVariables: mixed - some found, some not', () => {
78
+ const result = substituteVariables(
79
+ '{{ found }} and {{ missing }}',
80
+ { found: 'YES' }
81
+ );
82
+ assert.strictEqual(result, 'YES and {{missing}}');
83
+ });
84
+
85
+ test('substituteVariables: repeated variable is substituted in all occurrences', () => {
86
+ const result = substituteVariables(
87
+ '{{ x }} + {{ x }} = {{ result }}',
88
+ { x: '2', result: '4' }
89
+ );
90
+ assert.strictEqual(result, '2 + 2 = 4');
91
+ });
92
+
93
+ test('substituteVariables: empty string content', () => {
94
+ const result = substituteVariables('', { name: 'test' });
95
+ assert.strictEqual(result, '');
96
+ });
97
+
98
+ test('substituteVariables: value containing braces is not re-processed', () => {
99
+ // Substituted values should be inserted literally, no recursive substitution
100
+ const result = substituteVariables('{{ name }}', { name: '{{ other }}' });
101
+ assert.strictEqual(result, '{{ other }}');
102
+ });
103
+
104
+ // ============================================================
105
+ // processCopyFiles tests
106
+ // ============================================================
107
+
108
+ test('processCopyFiles: template with no copy_files returns immediately', async () => {
109
+ const template: TemplateConfig = {
110
+ description: 'No copy files',
111
+ folders: [],
112
+ // no copy_files key at all
113
+ };
114
+
115
+ // Should complete without error
116
+ await processCopyFiles('/nonexistent', '/nonexistent', template, {}, false);
117
+ });
118
+
119
+ test('processCopyFiles: copy single file without substitution', async () => {
120
+ const templateRoot = makeTempDir('copy-single-src');
121
+ const destDir = makeTempDir('copy-single-dest');
122
+
123
+ try {
124
+ // Create source file
125
+ fs.writeFileSync(path.join(templateRoot, 'readme.txt'), 'Hello {{ name }}!');
126
+
127
+ const template: TemplateConfig = {
128
+ description: 'Single copy',
129
+ folders: [],
130
+ copy_files: [
131
+ { src: 'readme.txt', dest: 'readme.txt' }
132
+ ],
133
+ };
134
+
135
+ await processCopyFiles(templateRoot, destDir, template, { name: 'World' }, false);
136
+
137
+ const destFile = path.join(destDir, 'readme.txt');
138
+ assert.ok(fs.existsSync(destFile), 'Destination file should exist');
139
+ // Without substitute_variables, content should be unchanged
140
+ const content = fs.readFileSync(destFile, 'utf-8');
141
+ assert.strictEqual(content, 'Hello {{ name }}!');
142
+ } finally {
143
+ rmDir(templateRoot);
144
+ rmDir(destDir);
145
+ }
146
+ });
147
+
148
+ test('processCopyFiles: copy single file with substitution enabled', async () => {
149
+ const templateRoot = makeTempDir('copy-sub-src');
150
+ const destDir = makeTempDir('copy-sub-dest');
151
+
152
+ try {
153
+ // Create source file with variable placeholders
154
+ fs.writeFileSync(
155
+ path.join(templateRoot, 'config.txt'),
156
+ 'project={{ project_name }}\nauthor={{ author }}'
157
+ );
158
+
159
+ const template: TemplateConfig = {
160
+ description: 'Copy with substitution',
161
+ folders: [],
162
+ copy_files: [
163
+ { src: 'config.txt', dest: 'config.txt', substitute_variables: true }
164
+ ],
165
+ };
166
+
167
+ await processCopyFiles(
168
+ templateRoot,
169
+ destDir,
170
+ template,
171
+ { project_name: 'MyApp', author: 'Alice' },
172
+ false
173
+ );
174
+
175
+ const destFile = path.join(destDir, 'config.txt');
176
+ assert.ok(fs.existsSync(destFile), 'Destination file should exist');
177
+ const content = fs.readFileSync(destFile, 'utf-8');
178
+ assert.strictEqual(content, 'project=MyApp\nauthor=Alice');
179
+ } finally {
180
+ rmDir(templateRoot);
181
+ rmDir(destDir);
182
+ }
183
+ });
184
+
185
+ test('processCopyFiles: copy to nested destination path', async () => {
186
+ const templateRoot = makeTempDir('copy-nested-src');
187
+ const destDir = makeTempDir('copy-nested-dest');
188
+
189
+ try {
190
+ fs.writeFileSync(path.join(templateRoot, 'file.txt'), 'content');
191
+
192
+ const template: TemplateConfig = {
193
+ description: 'Nested dest',
194
+ folders: [],
195
+ copy_files: [
196
+ { src: 'file.txt', dest: 'sub/dir/file.txt' }
197
+ ],
198
+ };
199
+
200
+ await processCopyFiles(templateRoot, destDir, template, {}, false);
201
+
202
+ const destFile = path.join(destDir, 'sub', 'dir', 'file.txt');
203
+ assert.ok(fs.existsSync(destFile), 'File should be created in nested directory');
204
+ assert.strictEqual(fs.readFileSync(destFile, 'utf-8'), 'content');
205
+ } finally {
206
+ rmDir(templateRoot);
207
+ rmDir(destDir);
208
+ }
209
+ });
210
+
211
+ test('processCopyFiles: copy a directory recursively', async () => {
212
+ const templateRoot = makeTempDir('copy-dir-src');
213
+ const destDir = makeTempDir('copy-dir-dest');
214
+
215
+ try {
216
+ // Create a source directory structure
217
+ const srcDir = path.join(templateRoot, 'scripts');
218
+ fs.mkdirSync(srcDir, { recursive: true });
219
+ fs.writeFileSync(path.join(srcDir, 'run.sh'), '#!/bin/bash\necho "hello"');
220
+ fs.mkdirSync(path.join(srcDir, 'utils'), { recursive: true });
221
+ fs.writeFileSync(path.join(srcDir, 'utils', 'helper.sh'), '#!/bin/bash\necho "helper"');
222
+
223
+ const template: TemplateConfig = {
224
+ description: 'Directory copy',
225
+ folders: [],
226
+ copy_files: [
227
+ { src: 'scripts', dest: 'scripts' }
228
+ ],
229
+ };
230
+
231
+ await processCopyFiles(templateRoot, destDir, template, {}, false);
232
+
233
+ // Verify the directory structure was copied
234
+ assert.ok(fs.existsSync(path.join(destDir, 'scripts', 'run.sh')), 'run.sh should exist');
235
+ assert.ok(fs.existsSync(path.join(destDir, 'scripts', 'utils', 'helper.sh')), 'helper.sh should exist');
236
+ assert.strictEqual(
237
+ fs.readFileSync(path.join(destDir, 'scripts', 'run.sh'), 'utf-8'),
238
+ '#!/bin/bash\necho "hello"'
239
+ );
240
+ assert.strictEqual(
241
+ fs.readFileSync(path.join(destDir, 'scripts', 'utils', 'helper.sh'), 'utf-8'),
242
+ '#!/bin/bash\necho "helper"'
243
+ );
244
+ } finally {
245
+ rmDir(templateRoot);
246
+ rmDir(destDir);
247
+ }
248
+ });
249
+
250
+ test('processCopyFiles: recursive directory copy with substitution', async () => {
251
+ const templateRoot = makeTempDir('copy-dir-sub-src');
252
+ const destDir = makeTempDir('copy-dir-sub-dest');
253
+
254
+ try {
255
+ const srcDir = path.join(templateRoot, 'templates');
256
+ fs.mkdirSync(srcDir, { recursive: true });
257
+ fs.writeFileSync(path.join(srcDir, 'index.html'), '<title>{{ title }}</title>');
258
+ fs.mkdirSync(path.join(srcDir, 'css'), { recursive: true });
259
+ fs.writeFileSync(path.join(srcDir, 'css', 'theme.css'), '/* Theme: {{ theme }} */');
260
+
261
+ const template: TemplateConfig = {
262
+ description: 'Dir copy with substitution',
263
+ folders: [],
264
+ copy_files: [
265
+ { src: 'templates', dest: 'output', substitute_variables: true }
266
+ ],
267
+ };
268
+
269
+ await processCopyFiles(
270
+ templateRoot,
271
+ destDir,
272
+ template,
273
+ { title: 'My Page', theme: 'dark' },
274
+ false
275
+ );
276
+
277
+ assert.strictEqual(
278
+ fs.readFileSync(path.join(destDir, 'output', 'index.html'), 'utf-8'),
279
+ '<title>My Page</title>'
280
+ );
281
+ assert.strictEqual(
282
+ fs.readFileSync(path.join(destDir, 'output', 'css', 'theme.css'), 'utf-8'),
283
+ '/* Theme: dark */'
284
+ );
285
+ } finally {
286
+ rmDir(templateRoot);
287
+ rmDir(destDir);
288
+ }
289
+ });
290
+
291
+ test('processCopyFiles: dry run mode does not copy files', async () => {
292
+ const templateRoot = makeTempDir('dryrun-src');
293
+ const destDir = makeTempDir('dryrun-dest');
294
+
295
+ try {
296
+ fs.writeFileSync(path.join(templateRoot, 'data.txt'), 'should not be copied');
297
+
298
+ const template: TemplateConfig = {
299
+ description: 'Dry run test',
300
+ folders: [],
301
+ copy_files: [
302
+ { src: 'data.txt', dest: 'data.txt' }
303
+ ],
304
+ };
305
+
306
+ await processCopyFiles(templateRoot, destDir, template, {}, true);
307
+
308
+ // In dry run, the file should NOT be copied
309
+ assert.ok(
310
+ !fs.existsSync(path.join(destDir, 'data.txt')),
311
+ 'File should NOT exist in dry run mode'
312
+ );
313
+ } finally {
314
+ rmDir(templateRoot);
315
+ rmDir(destDir);
316
+ }
317
+ });
318
+
319
+ test('processCopyFiles: dry run mode with substitution and chmod logs but does not act', async () => {
320
+ const templateRoot = makeTempDir('dryrun-full-src');
321
+ const destDir = makeTempDir('dryrun-full-dest');
322
+
323
+ try {
324
+ fs.writeFileSync(path.join(templateRoot, 'script.sh'), '#!/bin/bash\necho {{ msg }}');
325
+
326
+ const template: TemplateConfig = {
327
+ description: 'Dry run full',
328
+ folders: [],
329
+ copy_files: [
330
+ { src: 'script.sh', dest: 'script.sh', substitute_variables: true, chmod: '0755' }
331
+ ],
332
+ };
333
+
334
+ await processCopyFiles(templateRoot, destDir, template, { msg: 'hello' }, true);
335
+
336
+ assert.ok(
337
+ !fs.existsSync(path.join(destDir, 'script.sh')),
338
+ 'File should NOT exist in dry run mode'
339
+ );
340
+ } finally {
341
+ rmDir(templateRoot);
342
+ rmDir(destDir);
343
+ }
344
+ });
345
+
346
+ test('processCopyFiles: missing source file warns but does not crash', async () => {
347
+ const templateRoot = makeTempDir('missing-src');
348
+ const destDir = makeTempDir('missing-dest');
349
+
350
+ try {
351
+ const template: TemplateConfig = {
352
+ description: 'Missing source',
353
+ folders: [],
354
+ copy_files: [
355
+ { src: 'nonexistent.txt', dest: 'nonexistent.txt' }
356
+ ],
357
+ };
358
+
359
+ // Should complete without throwing
360
+ await processCopyFiles(templateRoot, destDir, template, {}, false);
361
+
362
+ // Destination should not exist
363
+ assert.ok(
364
+ !fs.existsSync(path.join(destDir, 'nonexistent.txt')),
365
+ 'Destination file should not exist for missing source'
366
+ );
367
+ } finally {
368
+ rmDir(templateRoot);
369
+ rmDir(destDir);
370
+ }
371
+ });
372
+
373
+ test('processCopyFiles: chmod option sets file permissions', async () => {
374
+ // chmod only works properly on non-Windows
375
+ if (process.platform === 'win32') {
376
+ return;
377
+ }
378
+
379
+ const templateRoot = makeTempDir('chmod-src');
380
+ const destDir = makeTempDir('chmod-dest');
381
+
382
+ try {
383
+ fs.writeFileSync(path.join(templateRoot, 'run.sh'), '#!/bin/bash\necho hi');
384
+
385
+ const template: TemplateConfig = {
386
+ description: 'Chmod test',
387
+ folders: [],
388
+ copy_files: [
389
+ { src: 'run.sh', dest: 'run.sh', chmod: '0755' }
390
+ ],
391
+ };
392
+
393
+ await processCopyFiles(templateRoot, destDir, template, {}, false);
394
+
395
+ const destFile = path.join(destDir, 'run.sh');
396
+ assert.ok(fs.existsSync(destFile), 'File should exist');
397
+
398
+ const stat = fs.statSync(destFile);
399
+ // Check that the execute bit is set (0o755 = 493 decimal)
400
+ const mode = stat.mode & 0o777;
401
+ assert.ok(
402
+ (mode & 0o111) !== 0,
403
+ `File should have execute permission, got mode ${mode.toString(8)}`
404
+ );
405
+ } finally {
406
+ rmDir(templateRoot);
407
+ rmDir(destDir);
408
+ }
409
+ });
410
+
411
+ test('processCopyFiles: multiple copy_files entries', async () => {
412
+ const templateRoot = makeTempDir('multi-src');
413
+ const destDir = makeTempDir('multi-dest');
414
+
415
+ try {
416
+ fs.writeFileSync(path.join(templateRoot, 'a.txt'), 'file a');
417
+ fs.writeFileSync(path.join(templateRoot, 'b.txt'), 'Hello {{ who }}');
418
+
419
+ const template: TemplateConfig = {
420
+ description: 'Multiple copies',
421
+ folders: [],
422
+ copy_files: [
423
+ { src: 'a.txt', dest: 'a.txt' },
424
+ { src: 'b.txt', dest: 'b.txt', substitute_variables: true },
425
+ ],
426
+ };
427
+
428
+ await processCopyFiles(templateRoot, destDir, template, { who: 'World' }, false);
429
+
430
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'a.txt'), 'utf-8'), 'file a');
431
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'b.txt'), 'utf-8'), 'Hello World');
432
+ } finally {
433
+ rmDir(templateRoot);
434
+ rmDir(destDir);
435
+ }
436
+ });
437
+
438
+ test('processCopyFiles: copy_files as empty array does nothing', async () => {
439
+ const template: TemplateConfig = {
440
+ description: 'Empty copy_files',
441
+ folders: [],
442
+ copy_files: [],
443
+ };
444
+
445
+ // Should complete without error
446
+ await processCopyFiles('/nonexistent', '/nonexistent', template, {}, false);
447
+ });
448
+
449
+ test('processCopyFiles: dry run on directory copy does not create files', async () => {
450
+ const templateRoot = makeTempDir('dryrun-dir-src');
451
+ const destDir = makeTempDir('dryrun-dir-dest');
452
+
453
+ try {
454
+ const srcDir = path.join(templateRoot, 'mydir');
455
+ fs.mkdirSync(srcDir, { recursive: true });
456
+ fs.writeFileSync(path.join(srcDir, 'file.txt'), 'content');
457
+
458
+ const template: TemplateConfig = {
459
+ description: 'Dry run dir',
460
+ folders: [],
461
+ copy_files: [
462
+ { src: 'mydir', dest: 'mydir' }
463
+ ],
464
+ };
465
+
466
+ await processCopyFiles(templateRoot, destDir, template, {}, true);
467
+
468
+ // In dry run, directory copy should not create files
469
+ // Note: the source code logs the green checkmark even in dry run for directories,
470
+ // but the actual copyDirRecursive is skipped, so the dest files won't exist.
471
+ assert.ok(
472
+ !fs.existsSync(path.join(destDir, 'mydir', 'file.txt')),
473
+ 'File should NOT exist inside directory in dry run mode'
474
+ );
475
+ } finally {
476
+ rmDir(templateRoot);
477
+ rmDir(destDir);
478
+ }
479
+ });