create-contractor-site 2.1.2 → 2.3.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 +51 -3
- package/bin/create-contractor-site.mjs +30 -2
- package/package.json +3 -3
- package/scripts/smoke-test.mjs +1260 -6
- package/src/copy-template.mjs +11 -1
- package/src/prompts.mjs +360 -20
- package/src/replace-data.mjs +43 -4
package/scripts/smoke-test.mjs
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* 1. Missing target argument
|
|
7
7
|
* 2. Existing non-empty target
|
|
8
8
|
* 3. Target equal to / inside template root
|
|
9
|
-
* 4.
|
|
10
|
-
* 5.
|
|
9
|
+
* 4. Published DEFAULT_TEMPLATE_REF / CREATE_CONTRACTOR_TEMPLATE_REF contract (v2.3.0)
|
|
10
|
+
* 5. Duplicate service input normalization + slug uniqueness + business/services alignment
|
|
11
|
+
* 6. Service-area name parse/dedupe (Chesapeake duplicate-slug case) + areas rebuild
|
|
11
12
|
* without leaking stale template county/ZIP metadata into new areas
|
|
12
|
-
*
|
|
13
|
+
* 7. CREATE_CONTRACTOR_SITE_ANSWERS_JSON real CLI spawn (copy+replace, skip setup)
|
|
14
|
+
* 8. Root workspace isolation vs tools/smart-image capsule (scope, sharp/zod, dist/)
|
|
15
|
+
* 9. Smart-image wrapper/lifecycle (argv safety, Windows bin, setup/check, no hooks)
|
|
16
|
+
* 10. Scaffold deny/gitignore/required retention (isDeniedName, check-ignore, copyTemplate)
|
|
17
|
+
* 11. Temp-target --yes scaffold (install/validate/build) unless SKIP_CLI_E2E=1
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { spawnSync } from 'node:child_process';
|
|
@@ -26,10 +31,39 @@ import {
|
|
|
26
31
|
replaceTargetData,
|
|
27
32
|
slugify,
|
|
28
33
|
} from '../src/replace-data.mjs';
|
|
29
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
buildAnswers,
|
|
36
|
+
DEFAULT_FREE_ESTIMATE,
|
|
37
|
+
DEFAULT_HOURS,
|
|
38
|
+
DEFAULT_INSURANCE,
|
|
39
|
+
DEFAULT_LICENSE,
|
|
40
|
+
DEFAULT_PAYMENT_METHODS,
|
|
41
|
+
DEFAULT_SITE_TYPE,
|
|
42
|
+
DEFAULT_YEARS_EXPERIENCE,
|
|
43
|
+
normalizeDirectories,
|
|
44
|
+
normalizeHours,
|
|
45
|
+
normalizePaymentMethods,
|
|
46
|
+
normalizeSiteType,
|
|
47
|
+
normalizeSocial,
|
|
48
|
+
optionalText,
|
|
49
|
+
requiredText,
|
|
50
|
+
SITE_TYPES,
|
|
51
|
+
SOCIAL_NETWORK_KEYS,
|
|
52
|
+
} from '../src/prompts.mjs';
|
|
30
53
|
import { isSameOrInside, validateTarget, TargetValidationError } from '../src/validate-target.mjs';
|
|
31
|
-
import {
|
|
32
|
-
|
|
54
|
+
import {
|
|
55
|
+
DEFAULT_TEMPLATE_REF,
|
|
56
|
+
REQUIRED_AFTER_COPY,
|
|
57
|
+
assertRequiredCopied,
|
|
58
|
+
copyTemplate,
|
|
59
|
+
findLocalTemplateRoot,
|
|
60
|
+
isDeniedName,
|
|
61
|
+
} from '../src/copy-template.mjs';
|
|
62
|
+
import {
|
|
63
|
+
isVersionAtLeast,
|
|
64
|
+
resolveCommandPath,
|
|
65
|
+
runCommand,
|
|
66
|
+
} from '../src/run-command.mjs';
|
|
33
67
|
|
|
34
68
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
69
|
const CLI = path.resolve(__dirname, '../bin/create-contractor-site.mjs');
|
|
@@ -63,6 +97,32 @@ function assert(condition, message) {
|
|
|
63
97
|
if (!condition) throw new Error(message);
|
|
64
98
|
}
|
|
65
99
|
|
|
100
|
+
/** @param {string} file */
|
|
101
|
+
function readJson(file) {
|
|
102
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Seed template data files into a temp target. @param {string} prefix */
|
|
106
|
+
function seedDataDir(prefix) {
|
|
107
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
108
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
109
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
110
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
111
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
112
|
+
for (const f of [
|
|
113
|
+
'business.json',
|
|
114
|
+
'site.json',
|
|
115
|
+
'services.json',
|
|
116
|
+
'areas.json',
|
|
117
|
+
'navigation.json',
|
|
118
|
+
'landings.json',
|
|
119
|
+
'directories.json',
|
|
120
|
+
]) {
|
|
121
|
+
fs.copyFileSync(path.join(dataSrc, f), path.join(dataDst, f));
|
|
122
|
+
}
|
|
123
|
+
return { tmp, dataDst };
|
|
124
|
+
}
|
|
125
|
+
|
|
66
126
|
/**
|
|
67
127
|
* @param {string[]} args
|
|
68
128
|
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [opts]
|
|
@@ -95,6 +155,43 @@ async function main() {
|
|
|
95
155
|
assert(!isVersionAtLeast('10.9.9', '11.1.2'), 'older major should fail');
|
|
96
156
|
});
|
|
97
157
|
|
|
158
|
+
await test(
|
|
159
|
+
'DEFAULT_TEMPLATE_REF / CREATE_CONTRACTOR_TEMPLATE_REF published contract is v2.3.0',
|
|
160
|
+
() => {
|
|
161
|
+
const copySrcPath = path.join(PKG_ROOT, 'src', 'copy-template.mjs');
|
|
162
|
+
const copySrc = fs.readFileSync(copySrcPath, 'utf8');
|
|
163
|
+
// Pin the source fallback so a silent ref bump cannot ship without updating this test.
|
|
164
|
+
assert(
|
|
165
|
+
/CREATE_CONTRACTOR_TEMPLATE_REF\s*\|\|\s*['"]v2\.3\.0['"]/.test(copySrc),
|
|
166
|
+
'copy-template.mjs DEFAULT_TEMPLATE_REF fallback must be v2.3.0',
|
|
167
|
+
);
|
|
168
|
+
const expected =
|
|
169
|
+
process.env.CREATE_CONTRACTOR_TEMPLATE_REF || 'v2.3.0';
|
|
170
|
+
assert(
|
|
171
|
+
DEFAULT_TEMPLATE_REF === expected,
|
|
172
|
+
`DEFAULT_TEMPLATE_REF must equal CREATE_CONTRACTOR_TEMPLATE_REF or v2.3.0 (got ${DEFAULT_TEMPLATE_REF})`,
|
|
173
|
+
);
|
|
174
|
+
if (!process.env.CREATE_CONTRACTOR_TEMPLATE_REF) {
|
|
175
|
+
assert(
|
|
176
|
+
DEFAULT_TEMPLATE_REF === 'v2.3.0',
|
|
177
|
+
`published default template ref must be v2.3.0 (got ${DEFAULT_TEMPLATE_REF})`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const help = runCli(['--help']);
|
|
182
|
+
assert(help.status === 0, `help exit 0, got ${help.status}`);
|
|
183
|
+
const helpOut = `${help.stdout}\n${help.stderr}`;
|
|
184
|
+
assert(
|
|
185
|
+
/CREATE_CONTRACTOR_TEMPLATE_REF/i.test(helpOut),
|
|
186
|
+
'help must document CREATE_CONTRACTOR_TEMPLATE_REF',
|
|
187
|
+
);
|
|
188
|
+
assert(
|
|
189
|
+
/v2\.3\.0/.test(helpOut),
|
|
190
|
+
'help must document default template ref v2.3.0',
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
98
195
|
await test('missing target argument exits 1 with usage', () => {
|
|
99
196
|
const result = runCli([]);
|
|
100
197
|
assert(result.status === 1, `expected exit 1, got ${result.status}`);
|
|
@@ -299,6 +396,7 @@ async function main() {
|
|
|
299
396
|
'areas.json',
|
|
300
397
|
'navigation.json',
|
|
301
398
|
'landings.json',
|
|
399
|
+
'directories.json',
|
|
302
400
|
]) {
|
|
303
401
|
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
304
402
|
}
|
|
@@ -376,6 +474,594 @@ async function main() {
|
|
|
376
474
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
377
475
|
});
|
|
378
476
|
|
|
477
|
+
await test('buildAnswers normalizes blank text trust fields and foundedYear', () => {
|
|
478
|
+
const answers = buildAnswers({
|
|
479
|
+
businessName: 'Trust Co',
|
|
480
|
+
primaryServices: ['Masonry'],
|
|
481
|
+
freeEstimate: ' ',
|
|
482
|
+
yearsExperience: '\t',
|
|
483
|
+
license: '',
|
|
484
|
+
insurance: null,
|
|
485
|
+
foundedYear: ' ',
|
|
486
|
+
});
|
|
487
|
+
assert(answers.freeEstimate === DEFAULT_FREE_ESTIMATE, 'freeEstimate default');
|
|
488
|
+
assert(answers.yearsExperience === DEFAULT_YEARS_EXPERIENCE, 'yearsExperience default');
|
|
489
|
+
assert(answers.license === DEFAULT_LICENSE, 'license default');
|
|
490
|
+
assert(answers.insurance === DEFAULT_INSURANCE, 'insurance default');
|
|
491
|
+
assert(answers.foundedYear === '', 'foundedYear blank → ""');
|
|
492
|
+
assert(requiredText(' x ', 'y') === 'x', 'requiredText trims');
|
|
493
|
+
assert(optionalText(' 2012 ') === '2012', 'optionalText trims');
|
|
494
|
+
assert(optionalText(undefined) === '', 'optionalText missing → ""');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await test('normalizePaymentMethods CSV/array blank → defaults never []', () => {
|
|
498
|
+
assert(
|
|
499
|
+
JSON.stringify(normalizePaymentMethods('Cash, Check , ,Credit Card')) ===
|
|
500
|
+
JSON.stringify(['Cash', 'Check', 'Credit Card']),
|
|
501
|
+
'CSV trims and drops blanks',
|
|
502
|
+
);
|
|
503
|
+
assert(
|
|
504
|
+
JSON.stringify(normalizePaymentMethods([' Visa ', '', 'Cash'])) ===
|
|
505
|
+
JSON.stringify(['Visa', 'Cash']),
|
|
506
|
+
'array trims and drops blanks',
|
|
507
|
+
);
|
|
508
|
+
assert(
|
|
509
|
+
JSON.stringify(normalizePaymentMethods(' ')) ===
|
|
510
|
+
JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
511
|
+
'whitespace CSV → defaults',
|
|
512
|
+
);
|
|
513
|
+
assert(
|
|
514
|
+
JSON.stringify(normalizePaymentMethods([])) ===
|
|
515
|
+
JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
516
|
+
'empty array → defaults',
|
|
517
|
+
);
|
|
518
|
+
assert(
|
|
519
|
+
JSON.stringify(normalizePaymentMethods(null)) ===
|
|
520
|
+
JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
521
|
+
'null → defaults',
|
|
522
|
+
);
|
|
523
|
+
assert(normalizePaymentMethods('').length > 0, 'never empty list');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await test('normalizeHours keeps 3-row {days,time} shape with safe defaults', () => {
|
|
527
|
+
const fromBlank = normalizeHours(undefined);
|
|
528
|
+
assert(fromBlank.length === 3, 'default length 3');
|
|
529
|
+
assert(
|
|
530
|
+
JSON.stringify(fromBlank) === JSON.stringify(DEFAULT_HOURS),
|
|
531
|
+
'missing → DEFAULT_HOURS',
|
|
532
|
+
);
|
|
533
|
+
assert(
|
|
534
|
+
JSON.stringify(normalizeHours([])) === JSON.stringify(DEFAULT_HOURS),
|
|
535
|
+
'empty array → defaults',
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const partial = normalizeHours([
|
|
539
|
+
{ days: ' ', time: '9:00 AM - 5:00 PM' },
|
|
540
|
+
' 10:00 AM - 1:00 PM ',
|
|
541
|
+
{ days: 'Sunday', time: ' ' },
|
|
542
|
+
]);
|
|
543
|
+
assert(partial.length === 3, 'partial still length 3');
|
|
544
|
+
assert(partial[0].days === DEFAULT_HOURS[0].days, 'blank days → default days');
|
|
545
|
+
assert(partial[0].time === '9:00 AM - 5:00 PM', 'weekday time kept');
|
|
546
|
+
assert(partial[1].days === DEFAULT_HOURS[1].days, 'string row uses default days');
|
|
547
|
+
assert(partial[1].time === '10:00 AM - 1:00 PM', 'string row trims time');
|
|
548
|
+
assert(partial[2].days === 'Sunday', 'sunday days kept');
|
|
549
|
+
assert(partial[2].time === DEFAULT_HOURS[2].time, 'blank time → default');
|
|
550
|
+
|
|
551
|
+
const compact = buildAnswers({
|
|
552
|
+
businessName: 'Hours Co',
|
|
553
|
+
primaryServices: ['Masonry'],
|
|
554
|
+
hoursWeekday: '8-5',
|
|
555
|
+
hoursSaturday: ' ',
|
|
556
|
+
hoursSunday: 'Closed',
|
|
557
|
+
});
|
|
558
|
+
assert(compact.hours.length === 3, 'compact → 3 rows');
|
|
559
|
+
assert(compact.hours[0].time === '8-5', 'compact weekday');
|
|
560
|
+
assert(compact.hours[1].time === DEFAULT_HOURS[1].time, 'blank sat → default');
|
|
561
|
+
assert(compact.hours[2].time === 'Closed', 'compact sunday');
|
|
562
|
+
assert(
|
|
563
|
+
JSON.stringify(compact.paymentMethods) === JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
564
|
+
'omitted payments → defaults',
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await test('replaceTargetData writes trust + payment + hours + empty social', () => {
|
|
569
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
570
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-trust-'));
|
|
571
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
572
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
573
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
574
|
+
for (const file of [
|
|
575
|
+
'business.json',
|
|
576
|
+
'site.json',
|
|
577
|
+
'services.json',
|
|
578
|
+
'areas.json',
|
|
579
|
+
'navigation.json',
|
|
580
|
+
'landings.json',
|
|
581
|
+
'directories.json',
|
|
582
|
+
]) {
|
|
583
|
+
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const original = JSON.parse(
|
|
587
|
+
fs.readFileSync(path.join(dataDst, 'business.json'), 'utf8'),
|
|
588
|
+
);
|
|
589
|
+
const originalDirs = JSON.parse(
|
|
590
|
+
fs.readFileSync(path.join(dataDst, 'directories.json'), 'utf8'),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const answers = buildAnswers({
|
|
594
|
+
businessName: 'Trust Builders',
|
|
595
|
+
primaryServices: ['Masonry'],
|
|
596
|
+
freeEstimate: 'Free Quote Today',
|
|
597
|
+
yearsExperience: '20+',
|
|
598
|
+
license: 'VA Class A #12345',
|
|
599
|
+
insurance: 'Bonded and insured.',
|
|
600
|
+
foundedYear: '',
|
|
601
|
+
paymentMethods: 'Cash, Zelle',
|
|
602
|
+
hours: [
|
|
603
|
+
{ days: 'Monday - Friday', time: '8:00 AM - 4:00 PM' },
|
|
604
|
+
{ days: 'Saturday', time: 'Closed' },
|
|
605
|
+
{ days: 'Sunday', time: 'Closed' },
|
|
606
|
+
],
|
|
607
|
+
});
|
|
608
|
+
replaceTargetData(tmp, answers);
|
|
609
|
+
|
|
610
|
+
const business = JSON.parse(
|
|
611
|
+
fs.readFileSync(path.join(dataDst, 'business.json'), 'utf8'),
|
|
612
|
+
);
|
|
613
|
+
const site = JSON.parse(fs.readFileSync(path.join(dataDst, 'site.json'), 'utf8'));
|
|
614
|
+
const directories = JSON.parse(
|
|
615
|
+
fs.readFileSync(path.join(dataDst, 'directories.json'), 'utf8'),
|
|
616
|
+
);
|
|
617
|
+
assert(business._instructions, '_instructions preserved');
|
|
618
|
+
assert(
|
|
619
|
+
JSON.stringify(Object.keys(business._instructions)) ===
|
|
620
|
+
JSON.stringify(Object.keys(original._instructions)),
|
|
621
|
+
'_instructions keys stable',
|
|
622
|
+
);
|
|
623
|
+
assert(business.free_estimate === 'Free Quote Today', 'free_estimate written');
|
|
624
|
+
assert(business.years_experience === '20+', 'years_experience written');
|
|
625
|
+
assert(business.license === 'VA Class A #12345', 'license written');
|
|
626
|
+
assert(business.insurance === 'Bonded and insured.', 'insurance written');
|
|
627
|
+
assert(
|
|
628
|
+
Object.prototype.hasOwnProperty.call(business, 'founded_year'),
|
|
629
|
+
'founded_year key must remain',
|
|
630
|
+
);
|
|
631
|
+
assert(business.founded_year === '', 'founded_year skip → ""');
|
|
632
|
+
assert(
|
|
633
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(['Cash', 'Zelle']),
|
|
634
|
+
'payment_methods written',
|
|
635
|
+
);
|
|
636
|
+
assert(Array.isArray(business.hours) && business.hours.length === 3, 'hours length 3');
|
|
637
|
+
assert(business.hours[0].days === 'Monday - Friday', 'hours days shape');
|
|
638
|
+
assert(business.hours[0].time === '8:00 AM - 4:00 PM', 'hours time written');
|
|
639
|
+
assert(business.hours[1].time === 'Closed', 'saturday closed');
|
|
640
|
+
assert(JSON.stringify(business.social) === '{}', 'omitted social → {}');
|
|
641
|
+
assert(site.features.enable_directories === false, 'no dirs → enable false');
|
|
642
|
+
assert(directories.directories.length >= 1, 'directories min(1) when none');
|
|
643
|
+
assert(directories.directories.length === originalDirs.directories.length);
|
|
644
|
+
assert(directories._instructions && directories.variant === originalDirs.variant);
|
|
645
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
await test('normalizeSocial/directories + replace populate/none parity', () => {
|
|
649
|
+
// Social blank omission + population (object + compact CSV)
|
|
650
|
+
assert(JSON.stringify(normalizeSocial(undefined)) === '{}', 'missing social → {}');
|
|
651
|
+
assert(
|
|
652
|
+
JSON.stringify(normalizeSocial({ facebook: ' ', instagram: null, x: '' })) === '{}',
|
|
653
|
+
'blank social keys omitted',
|
|
654
|
+
);
|
|
655
|
+
const populated = normalizeSocial({
|
|
656
|
+
facebook: ' https://facebook.com/acme ',
|
|
657
|
+
instagram: '',
|
|
658
|
+
youtube: 'https://youtube.com/@acme',
|
|
659
|
+
unknown: 'https://nope.example',
|
|
660
|
+
});
|
|
661
|
+
assert(
|
|
662
|
+
JSON.stringify(populated) ===
|
|
663
|
+
JSON.stringify({
|
|
664
|
+
facebook: 'https://facebook.com/acme',
|
|
665
|
+
youtube: 'https://youtube.com/@acme',
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
668
|
+
const compact = normalizeSocial(
|
|
669
|
+
'facebook=https://facebook.com/a, google-business=https://g.co/a, ,tiktok=',
|
|
670
|
+
);
|
|
671
|
+
assert(compact.facebook === 'https://facebook.com/a' && compact.google_business === 'https://g.co/a');
|
|
672
|
+
assert(!Object.prototype.hasOwnProperty.call(compact, 'tiktok'));
|
|
673
|
+
assert(SOCIAL_NETWORK_KEYS.includes('linkedin'));
|
|
674
|
+
|
|
675
|
+
// Directories provided vs none
|
|
676
|
+
assert(normalizeDirectories(undefined).length === 0);
|
|
677
|
+
assert(normalizeDirectories([]).length === 0);
|
|
678
|
+
const fromCsv = normalizeDirectories(
|
|
679
|
+
'Google Business|https://g.co/acme, BBB|https://bbb.org/acme ,badrow',
|
|
680
|
+
);
|
|
681
|
+
assert(fromCsv.length === 2 && fromCsv[0].url === 'https://g.co/acme' && fromCsv[0].initials);
|
|
682
|
+
const fromRows = normalizeDirectories([
|
|
683
|
+
{ name: 'Angi', url: 'https://angi.com/x', initials: 'A' },
|
|
684
|
+
{ name: ' ', url: 'https://skip.example' },
|
|
685
|
+
{ name: 'Houzz', url: ' ' },
|
|
686
|
+
]);
|
|
687
|
+
assert(fromRows.length === 1 && fromRows[0].initials === 'A');
|
|
688
|
+
|
|
689
|
+
const none = buildAnswers({ businessName: 'No Social', primaryServices: ['Masonry'] });
|
|
690
|
+
assert(JSON.stringify(none.social) === '{}' && none.directories.length === 0);
|
|
691
|
+
assert(none.enableDirectories === false);
|
|
692
|
+
const withDirs = buildAnswers({
|
|
693
|
+
businessName: 'With Dirs',
|
|
694
|
+
primaryServices: ['Masonry'],
|
|
695
|
+
social: { facebook: 'https://facebook.com/x', instagram: ' ' },
|
|
696
|
+
directories: 'Google|https://g.co/x',
|
|
697
|
+
});
|
|
698
|
+
assert(withDirs.social.facebook === 'https://facebook.com/x');
|
|
699
|
+
assert(!Object.prototype.hasOwnProperty.call(withDirs.social, 'instagram'));
|
|
700
|
+
assert(withDirs.directories.length === 1 && withDirs.enableDirectories === true);
|
|
701
|
+
|
|
702
|
+
// directories:[] + enableDirectories:true → feature false
|
|
703
|
+
const forcedEmpty = buildAnswers({
|
|
704
|
+
businessName: 'Forced Empty Dirs',
|
|
705
|
+
primaryServices: ['Masonry'],
|
|
706
|
+
directories: [],
|
|
707
|
+
enableDirectories: true,
|
|
708
|
+
});
|
|
709
|
+
assert(forcedEmpty.directories.length === 0 && forcedEmpty.enableDirectories === false);
|
|
710
|
+
|
|
711
|
+
// replace: populated social + directories
|
|
712
|
+
const seeded = seedDataDir('ccs-social-');
|
|
713
|
+
replaceTargetData(
|
|
714
|
+
seeded.tmp,
|
|
715
|
+
buildAnswers({
|
|
716
|
+
businessName: 'Social Co',
|
|
717
|
+
primaryServices: ['Patios'],
|
|
718
|
+
social: {
|
|
719
|
+
facebook: 'https://facebook.com/socialco',
|
|
720
|
+
instagram: '',
|
|
721
|
+
youtube: 'https://youtube.com/@socialco',
|
|
722
|
+
},
|
|
723
|
+
directories: [
|
|
724
|
+
{ name: 'Google Business', url: 'https://g.co/socialco', initials: 'G' },
|
|
725
|
+
{ name: 'Angi', url: 'https://angi.com/socialco' },
|
|
726
|
+
],
|
|
727
|
+
}),
|
|
728
|
+
);
|
|
729
|
+
const business = readJson(path.join(seeded.dataDst, 'business.json'));
|
|
730
|
+
const site = readJson(path.join(seeded.dataDst, 'site.json'));
|
|
731
|
+
const directories = readJson(path.join(seeded.dataDst, 'directories.json'));
|
|
732
|
+
assert(
|
|
733
|
+
JSON.stringify(business.social) ===
|
|
734
|
+
JSON.stringify({
|
|
735
|
+
facebook: 'https://facebook.com/socialco',
|
|
736
|
+
youtube: 'https://youtube.com/@socialco',
|
|
737
|
+
}),
|
|
738
|
+
);
|
|
739
|
+
assert(site.features.enable_directories === true);
|
|
740
|
+
assert(directories.directories.length === 2 && directories.directories[0].initials === 'G');
|
|
741
|
+
assert(directories.directories[1].initials && directories._instructions);
|
|
742
|
+
|
|
743
|
+
// replace: directories:[] + enable true → flag false + min(1) placeholder rows
|
|
744
|
+
const forced = seedDataDir('ccs-dirs-force-');
|
|
745
|
+
const origLen = readJson(path.join(forced.dataDst, 'directories.json')).directories.length;
|
|
746
|
+
replaceTargetData(
|
|
747
|
+
forced.tmp,
|
|
748
|
+
buildAnswers({
|
|
749
|
+
businessName: 'Force Enable Empty',
|
|
750
|
+
primaryServices: ['Masonry'],
|
|
751
|
+
directories: [],
|
|
752
|
+
enableDirectories: true,
|
|
753
|
+
}),
|
|
754
|
+
);
|
|
755
|
+
const siteForced = readJson(path.join(forced.dataDst, 'site.json'));
|
|
756
|
+
const dirsForced = readJson(path.join(forced.dataDst, 'directories.json'));
|
|
757
|
+
assert(siteForced.features.enable_directories === false, 'empty+force → enable false');
|
|
758
|
+
assert(dirsForced.directories.length >= 1 && dirsForced.directories.length === origLen);
|
|
759
|
+
fs.rmSync(forced.tmp, { recursive: true, force: true });
|
|
760
|
+
fs.rmSync(seeded.tmp, { recursive: true, force: true });
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
await test('normalizeSiteType canonical + human-ish aliases; invalid → multipage', () => {
|
|
764
|
+
assert(DEFAULT_SITE_TYPE === 'multipage', 'default site type is multipage');
|
|
765
|
+
assert(JSON.stringify(SITE_TYPES) === JSON.stringify(['one-page', 'multipage', 'seo']));
|
|
766
|
+
assert(normalizeSiteType(undefined) === 'multipage', 'missing → multipage');
|
|
767
|
+
assert(normalizeSiteType('') === 'multipage', 'blank → multipage');
|
|
768
|
+
assert(normalizeSiteType(' ') === 'multipage', 'whitespace → multipage');
|
|
769
|
+
assert(normalizeSiteType('one-page') === 'one-page');
|
|
770
|
+
assert(normalizeSiteType('multipage') === 'multipage');
|
|
771
|
+
assert(normalizeSiteType('seo') === 'seo');
|
|
772
|
+
assert(normalizeSiteType('one page') === 'one-page', 'one page alias');
|
|
773
|
+
assert(normalizeSiteType('onepage') === 'one-page', 'onepage alias');
|
|
774
|
+
assert(normalizeSiteType('multi page') === 'multipage', 'multi page alias');
|
|
775
|
+
assert(normalizeSiteType('multi-page') === 'multipage', 'multi-page alias');
|
|
776
|
+
assert(normalizeSiteType('SEO') === 'seo', 'case fold');
|
|
777
|
+
assert(normalizeSiteType('not-a-type') === 'multipage', 'invalid → multipage');
|
|
778
|
+
assert(normalizeSiteType('blog') === 'multipage', 'unknown → multipage');
|
|
779
|
+
|
|
780
|
+
const omitted = buildAnswers({ businessName: 'Type Co', primaryServices: ['Masonry'] });
|
|
781
|
+
assert(omitted.siteType === 'multipage', 'buildAnswers omits → multipage');
|
|
782
|
+
assert(buildAnswers({
|
|
783
|
+
businessName: 'One Page Co',
|
|
784
|
+
primaryServices: ['Masonry'],
|
|
785
|
+
siteType: 'one page',
|
|
786
|
+
}).siteType === 'one-page');
|
|
787
|
+
assert(buildAnswers({
|
|
788
|
+
businessName: 'SEO Co',
|
|
789
|
+
primaryServices: ['Masonry'],
|
|
790
|
+
siteType: 'seo',
|
|
791
|
+
}).siteType === 'seo');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
await test('replaceTargetData writes site_type from answers (default multipage)', () => {
|
|
795
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
796
|
+
const seeded = seedDataDir('ccs-sitetype-');
|
|
797
|
+
try {
|
|
798
|
+
const originalSite = readJson(path.join(seeded.dataDst, 'site.json'));
|
|
799
|
+
replaceTargetData(
|
|
800
|
+
seeded.tmp,
|
|
801
|
+
buildAnswers({ businessName: 'Type Default Co', primaryServices: ['Masonry'] }),
|
|
802
|
+
);
|
|
803
|
+
const siteDefault = readJson(path.join(seeded.dataDst, 'site.json'));
|
|
804
|
+
assert(siteDefault.site_type === 'multipage', `default write multipage, got ${siteDefault.site_type}`);
|
|
805
|
+
assert(siteDefault._instructions, '_instructions preserved');
|
|
806
|
+
assert(
|
|
807
|
+
JSON.stringify(Object.keys(siteDefault._instructions)) ===
|
|
808
|
+
JSON.stringify(Object.keys(originalSite._instructions)),
|
|
809
|
+
'_instructions keys stable',
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
// one-page
|
|
813
|
+
const one = seedDataDir('ccs-sitetype-one-');
|
|
814
|
+
replaceTargetData(
|
|
815
|
+
one.tmp,
|
|
816
|
+
buildAnswers({
|
|
817
|
+
businessName: 'One Page Site',
|
|
818
|
+
primaryServices: ['Patios'],
|
|
819
|
+
siteType: 'one-page',
|
|
820
|
+
}),
|
|
821
|
+
);
|
|
822
|
+
assert(readJson(path.join(one.dataDst, 'site.json')).site_type === 'one-page');
|
|
823
|
+
fs.rmSync(one.tmp, { recursive: true, force: true });
|
|
824
|
+
|
|
825
|
+
// seo
|
|
826
|
+
const seo = seedDataDir('ccs-sitetype-seo-');
|
|
827
|
+
replaceTargetData(
|
|
828
|
+
seo.tmp,
|
|
829
|
+
buildAnswers({
|
|
830
|
+
businessName: 'SEO Site',
|
|
831
|
+
primaryServices: ['Patios'],
|
|
832
|
+
siteType: 'seo',
|
|
833
|
+
}),
|
|
834
|
+
);
|
|
835
|
+
assert(readJson(path.join(seo.dataDst, 'site.json')).site_type === 'seo');
|
|
836
|
+
fs.rmSync(seo.tmp, { recursive: true, force: true });
|
|
837
|
+
} finally {
|
|
838
|
+
fs.rmSync(seeded.tmp, { recursive: true, force: true });
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
await test('--help documents trust/payment/hours/social/directories/siteType defaults', () => {
|
|
843
|
+
const help = runCli(['--help']);
|
|
844
|
+
assert(help.status === 0, `help exit 0, got ${help.status}`);
|
|
845
|
+
const helpOut = `${help.stdout}\n${help.stderr}`;
|
|
846
|
+
assert(/freeEstimate/i.test(helpOut), 'help lists freeEstimate');
|
|
847
|
+
assert(/foundedYear/i.test(helpOut), 'help lists foundedYear');
|
|
848
|
+
assert(/empty string/i.test(helpOut), 'help documents foundedYear empty string');
|
|
849
|
+
assert(/paymentMethods/i.test(helpOut), 'help lists paymentMethods');
|
|
850
|
+
assert(/hoursWeekday/i.test(helpOut), 'help lists compact hours keys');
|
|
851
|
+
assert(/social/i.test(helpOut), 'help lists social');
|
|
852
|
+
assert(/directories/i.test(helpOut), 'help lists directories');
|
|
853
|
+
assert(/enable_directories/i.test(helpOut), 'help mentions enable_directories');
|
|
854
|
+
assert(/siteType/i.test(helpOut), 'help lists siteType');
|
|
855
|
+
assert(/multipage/i.test(helpOut), 'help documents multipage default');
|
|
856
|
+
assert(/one-page/i.test(helpOut), 'help lists one-page');
|
|
857
|
+
assert(/CREATE_CONTRACTOR_SITE_ANSWERS_JSON/i.test(helpOut), 'help lists JSON env');
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
await test('CREATE_CONTRACTOR_SITE_ANSWERS_JSON CLI path defaults intake and siteType', () => {
|
|
861
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
862
|
+
// Fresh non-existent target (CLI rejects existing dirs that are non-empty).
|
|
863
|
+
const targetDir = path.join(
|
|
864
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-jsonenv-parent-')),
|
|
865
|
+
'site',
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const payload = {
|
|
869
|
+
businessName: 'JSON Env Masonry',
|
|
870
|
+
primaryServices: ['Patios'],
|
|
871
|
+
// Blank/whitespace trust fields → buildAnswers defaults; foundedYear → ""
|
|
872
|
+
freeEstimate: ' ',
|
|
873
|
+
yearsExperience: '\t',
|
|
874
|
+
license: '',
|
|
875
|
+
insurance: null,
|
|
876
|
+
foundedYear: ' ',
|
|
877
|
+
// Blank payments + empty hours → defaults (never [])
|
|
878
|
+
paymentMethods: ' , ',
|
|
879
|
+
hours: [],
|
|
880
|
+
social: { facebook: ' ', instagram: '' }, // blank → {}
|
|
881
|
+
directories: [], // none → enable false + min(1)
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const result = runCli([targetDir], {
|
|
885
|
+
env: {
|
|
886
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
887
|
+
CREATE_CONTRACTOR_SITE_ANSWERS_JSON: JSON.stringify(payload),
|
|
888
|
+
NODE_ENV: 'test',
|
|
889
|
+
CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1',
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
894
|
+
try {
|
|
895
|
+
assert(result.status === 0, `CLI exit 0, got ${result.status}:\n${out.slice(-2000)}`);
|
|
896
|
+
assert(/Data replace complete/i.test(out), 'expected skip-setup success banner');
|
|
897
|
+
|
|
898
|
+
const businessPath = path.join(targetDir, 'src/data/business.json');
|
|
899
|
+
assert(fs.existsSync(businessPath), 'expected generated business.json');
|
|
900
|
+
const business = JSON.parse(fs.readFileSync(businessPath, 'utf8'));
|
|
901
|
+
const site = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
902
|
+
const directories = readJson(path.join(targetDir, 'src/data/directories.json'));
|
|
903
|
+
|
|
904
|
+
assert(business.name === 'JSON Env Masonry', 'env business name via CLI');
|
|
905
|
+
assert(
|
|
906
|
+
business.free_estimate === DEFAULT_FREE_ESTIMATE,
|
|
907
|
+
`free_estimate default via CLI, got ${business.free_estimate}`,
|
|
908
|
+
);
|
|
909
|
+
assert(
|
|
910
|
+
business.years_experience === DEFAULT_YEARS_EXPERIENCE,
|
|
911
|
+
`years_experience default via CLI, got ${business.years_experience}`,
|
|
912
|
+
);
|
|
913
|
+
assert(
|
|
914
|
+
business.license === DEFAULT_LICENSE,
|
|
915
|
+
`license default via CLI, got ${business.license}`,
|
|
916
|
+
);
|
|
917
|
+
assert(
|
|
918
|
+
business.insurance === DEFAULT_INSURANCE,
|
|
919
|
+
`insurance default via CLI, got ${business.insurance}`,
|
|
920
|
+
);
|
|
921
|
+
assert(
|
|
922
|
+
Object.prototype.hasOwnProperty.call(business, 'founded_year'),
|
|
923
|
+
'founded_year key must remain',
|
|
924
|
+
);
|
|
925
|
+
assert(business.founded_year === '', 'CLI founded_year blank → ""');
|
|
926
|
+
assert(
|
|
927
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
928
|
+
'CLI blank payments → defaults',
|
|
929
|
+
);
|
|
930
|
+
assert(business.payment_methods.length > 0, 'payment_methods never []');
|
|
931
|
+
assert(
|
|
932
|
+
JSON.stringify(business.hours) === JSON.stringify(DEFAULT_HOURS),
|
|
933
|
+
'CLI empty hours → DEFAULT_HOURS',
|
|
934
|
+
);
|
|
935
|
+
assert(business.hours.every((h) => h.days && h.time), 'hours rows shaped');
|
|
936
|
+
assert(JSON.stringify(business.social) === '{}', 'CLI blank social → {}');
|
|
937
|
+
assert(site.features.enable_directories === false, 'CLI no dirs → enable false');
|
|
938
|
+
assert(directories.directories.length >= 1, 'CLI directories min(1)');
|
|
939
|
+
assert(site.site_type === 'multipage', `CLI omitted siteType → multipage, got ${site.site_type}`);
|
|
940
|
+
} finally {
|
|
941
|
+
const parent = path.dirname(targetDir);
|
|
942
|
+
try {
|
|
943
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
944
|
+
} catch {
|
|
945
|
+
console.warn(` (could not fully remove ${parent})`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
await test('CREATE_CONTRACTOR_SITE_ANSWERS_JSON can set siteType one-page and seo', () => {
|
|
951
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
952
|
+
|
|
953
|
+
for (const { label, siteType, expected } of [
|
|
954
|
+
{ label: 'one-page', siteType: 'one page', expected: 'one-page' },
|
|
955
|
+
{ label: 'seo', siteType: 'seo', expected: 'seo' },
|
|
956
|
+
]) {
|
|
957
|
+
const targetDir = path.join(
|
|
958
|
+
fs.mkdtempSync(path.join(os.tmpdir(), `ccs-sitetype-${label}-`)),
|
|
959
|
+
'site',
|
|
960
|
+
);
|
|
961
|
+
const result = runCli([targetDir], {
|
|
962
|
+
env: {
|
|
963
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
964
|
+
CREATE_CONTRACTOR_SITE_ANSWERS_JSON: JSON.stringify({
|
|
965
|
+
businessName: `JSON ${label}`,
|
|
966
|
+
primaryServices: ['Patios'],
|
|
967
|
+
siteType,
|
|
968
|
+
}),
|
|
969
|
+
NODE_ENV: 'test',
|
|
970
|
+
CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1',
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
974
|
+
try {
|
|
975
|
+
assert(result.status === 0, `CLI ${label} exit 0, got ${result.status}:\n${out.slice(-1500)}`);
|
|
976
|
+
const site = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
977
|
+
assert(site.site_type === expected, `CLI ${label} site_type, got ${site.site_type}`);
|
|
978
|
+
} finally {
|
|
979
|
+
try {
|
|
980
|
+
fs.rmSync(path.dirname(targetDir), { recursive: true, force: true });
|
|
981
|
+
} catch {
|
|
982
|
+
console.warn(` (could not fully remove ${path.dirname(targetDir)})`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
await test('CREATE_CONTRACTOR_SITE_ANSWERS_JSON compact payment/hours/social/dirs path', () => {
|
|
989
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
990
|
+
const targetDir = path.join(
|
|
991
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-jsoncompact-parent-')),
|
|
992
|
+
'site',
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
const payload = {
|
|
996
|
+
businessName: 'Compact Hours Co',
|
|
997
|
+
primaryServices: ['Patios'],
|
|
998
|
+
paymentMethods: ['Cash', ' ', 'ACH'],
|
|
999
|
+
hoursWeekday: '9:00 AM - 5:00 PM',
|
|
1000
|
+
hoursSaturday: 'Closed',
|
|
1001
|
+
hoursSunday: 'Closed',
|
|
1002
|
+
social: 'facebook=https://facebook.com/compact,instagram=https://instagram.com/compact',
|
|
1003
|
+
directories: 'Google Business|https://g.co/compact,BBB|https://bbb.org/compact',
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const result = runCli([targetDir], {
|
|
1007
|
+
env: {
|
|
1008
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
1009
|
+
CREATE_CONTRACTOR_SITE_ANSWERS_JSON: JSON.stringify(payload),
|
|
1010
|
+
NODE_ENV: 'test',
|
|
1011
|
+
CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1',
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
1016
|
+
try {
|
|
1017
|
+
assert(result.status === 0, `CLI exit 0, got ${result.status}:\n${out.slice(-2000)}`);
|
|
1018
|
+
const business = JSON.parse(
|
|
1019
|
+
fs.readFileSync(path.join(targetDir, 'src/data/business.json'), 'utf8'),
|
|
1020
|
+
);
|
|
1021
|
+
const site = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
1022
|
+
const directories = readJson(path.join(targetDir, 'src/data/directories.json'));
|
|
1023
|
+
assert(
|
|
1024
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(['Cash', 'ACH']),
|
|
1025
|
+
`compact payments, got ${JSON.stringify(business.payment_methods)}`,
|
|
1026
|
+
);
|
|
1027
|
+
assert(business.hours.length === 3, 'compact hours length');
|
|
1028
|
+
assert(business.hours[0].days === 'Monday - Friday', 'weekday days label');
|
|
1029
|
+
assert(business.hours[0].time === '9:00 AM - 5:00 PM', 'compact weekday time');
|
|
1030
|
+
assert(business.hours[1].time === 'Closed', 'compact saturday');
|
|
1031
|
+
assert(business.hours[2].time === 'Closed', 'compact sunday');
|
|
1032
|
+
// Trust defaults still apply when omitted
|
|
1033
|
+
assert(business.free_estimate === DEFAULT_FREE_ESTIMATE, 'trust default still works');
|
|
1034
|
+
assert(business.founded_year === '', 'founded_year still ""');
|
|
1035
|
+
assert(business.social.facebook === 'https://facebook.com/compact');
|
|
1036
|
+
assert(business.social.instagram === 'https://instagram.com/compact');
|
|
1037
|
+
assert(site.features.enable_directories === true, 'compact dirs → enable true');
|
|
1038
|
+
assert(directories.directories.length === 2);
|
|
1039
|
+
assert(directories.directories[0].url === 'https://g.co/compact');
|
|
1040
|
+
} finally {
|
|
1041
|
+
const parent = path.dirname(targetDir);
|
|
1042
|
+
try {
|
|
1043
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
1044
|
+
} catch {
|
|
1045
|
+
console.warn(` (could not fully remove ${parent})`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
await test('skip setup guard requires NODE_ENV test', () => {
|
|
1051
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-skip-guard-'));
|
|
1052
|
+
const targetDir = path.join(parent, 'site');
|
|
1053
|
+
try {
|
|
1054
|
+
const result = runCli(['--yes', targetDir], {
|
|
1055
|
+
env: { CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT || '', CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1', NODE_ENV: '' },
|
|
1056
|
+
});
|
|
1057
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
1058
|
+
assert(result.status === 1, `expected guard failure, got ${result.status}`);
|
|
1059
|
+
assert(/requires NODE_ENV=test/i.test(out), `expected guard message:\n${out}`);
|
|
1060
|
+
} finally {
|
|
1061
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
|
|
379
1065
|
await test('replaceTargetData Chesapeake case keeps unique area slugs', () => {
|
|
380
1066
|
assert(REPO_ROOT, 'expected local template root');
|
|
381
1067
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-areas-'));
|
|
@@ -389,6 +1075,7 @@ async function main() {
|
|
|
389
1075
|
'areas.json',
|
|
390
1076
|
'navigation.json',
|
|
391
1077
|
'landings.json',
|
|
1078
|
+
'directories.json',
|
|
392
1079
|
]) {
|
|
393
1080
|
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
394
1081
|
}
|
|
@@ -429,10 +1116,521 @@ async function main() {
|
|
|
429
1116
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
430
1117
|
});
|
|
431
1118
|
|
|
1119
|
+
// --- image-tooling capsule isolation (Unit 1) ---
|
|
1120
|
+
await test('root workspace stays two projects and excludes tools/*', () => {
|
|
1121
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1122
|
+
const wsPath = path.join(REPO_ROOT, 'pnpm-workspace.yaml');
|
|
1123
|
+
const wsText = fs.readFileSync(wsPath, 'utf8');
|
|
1124
|
+
assert(/\n\s*-\s*\.\s*$/m.test(wsText) || /packages:\s*\n\s*-\s*\./.test(wsText),
|
|
1125
|
+
'root workspace must include "."');
|
|
1126
|
+
assert(/-\s*packages\/\*/.test(wsText), 'root workspace must include packages/*');
|
|
1127
|
+
assert(!/tools\//.test(wsText), 'root workspace must not absorb tools/*');
|
|
1128
|
+
assert(
|
|
1129
|
+
!/better-sqlite3/.test(wsText),
|
|
1130
|
+
'root pnpm-workspace.yaml must not declare allowBuilds.better-sqlite3',
|
|
1131
|
+
);
|
|
1132
|
+
|
|
1133
|
+
const list = runCommand('pnpm', ['list', '-r', '--depth', '-1'], {
|
|
1134
|
+
cwd: REPO_ROOT,
|
|
1135
|
+
stdio: 'pipe',
|
|
1136
|
+
});
|
|
1137
|
+
// pnpm prints one name@version line per workspace package
|
|
1138
|
+
const names = (list.stdout || '').match(/^[^\s].*@\d/gm) || [];
|
|
1139
|
+
assert(
|
|
1140
|
+
names.length === 2,
|
|
1141
|
+
`expected exactly 2 root workspace projects, got ${names.length}: ${names.join(', ')}`,
|
|
1142
|
+
);
|
|
1143
|
+
assert(
|
|
1144
|
+
!/smart-image/i.test(list.stdout || ''),
|
|
1145
|
+
'root workspace listing must not include smart-image capsule',
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
await test('root sharp/zod ranges stay unchanged with no overrides', () => {
|
|
1150
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1151
|
+
const pkg = readJson(path.join(REPO_ROOT, 'package.json'));
|
|
1152
|
+
const sharp = pkg.devDependencies?.sharp ?? pkg.dependencies?.sharp;
|
|
1153
|
+
const zod = pkg.devDependencies?.zod ?? pkg.dependencies?.zod;
|
|
1154
|
+
assert(sharp === '^0.34.5', `root sharp range must stay ^0.34.5, got ${sharp}`);
|
|
1155
|
+
assert(zod === '^4.1.5', `root zod range must stay ^4.1.5, got ${zod}`);
|
|
1156
|
+
assert(pkg.overrides == null, 'root package.json must not declare overrides');
|
|
1157
|
+
assert(
|
|
1158
|
+
pkg.pnpm?.overrides == null,
|
|
1159
|
+
'root package.json must not declare pnpm.overrides',
|
|
1160
|
+
);
|
|
1161
|
+
assert(
|
|
1162
|
+
!pkg.dependencies?.['smart-image-cli'] && !pkg.devDependencies?.['smart-image-cli'],
|
|
1163
|
+
'root package.json must not depend on smart-image-cli',
|
|
1164
|
+
);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
await test('dist holds zero capsule or smart-image-cli references', () => {
|
|
1168
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1169
|
+
const distDir = path.join(REPO_ROOT, 'dist');
|
|
1170
|
+
if (!fs.existsSync(distDir)) {
|
|
1171
|
+
console.log(' (dist/ absent — skip content scan; build harness covers post-build)');
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
/** @type {string[]} */
|
|
1175
|
+
const hits = [];
|
|
1176
|
+
const needles = ['smart-image-cli', 'better-sqlite3', 'tools/smart-image', 'tools\\smart-image'];
|
|
1177
|
+
/**
|
|
1178
|
+
* @param {string} dir
|
|
1179
|
+
*/
|
|
1180
|
+
function walk(dir) {
|
|
1181
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1182
|
+
const full = path.join(dir, ent.name);
|
|
1183
|
+
if (ent.isDirectory()) {
|
|
1184
|
+
walk(full);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
// Skip large binary assets; scan text-ish build outputs only.
|
|
1188
|
+
if (!/\.(html|js|mjs|cjs|css|json|txt|xml|svg|map)$/i.test(ent.name)) continue;
|
|
1189
|
+
let text;
|
|
1190
|
+
try {
|
|
1191
|
+
text = fs.readFileSync(full, 'utf8');
|
|
1192
|
+
} catch {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
for (const n of needles) {
|
|
1196
|
+
if (text.includes(n)) hits.push(`${path.relative(distDir, full)}:${n}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
walk(distDir);
|
|
1201
|
+
assert(hits.length === 0, `dist/ must not reference capsule/CLI: ${hits.slice(0, 10).join(', ')}`);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// --- image-tooling wrapper + lifecycle (Unit 2) ---
|
|
1205
|
+
const capDir = REPO_ROOT ? path.join(REPO_ROOT, 'tools', 'smart-image') : '';
|
|
1206
|
+
const runJs = capDir ? path.join(capDir, 'run.mjs') : '';
|
|
1207
|
+
const checkJs = capDir ? path.join(capDir, 'check.mjs') : '';
|
|
1208
|
+
|
|
1209
|
+
/** @param {string} v */
|
|
1210
|
+
const qCmd = (v) =>
|
|
1211
|
+
v === '' ? '""' : /[\s"&<>|^%!]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v;
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* @param {string} cmd
|
|
1215
|
+
* @param {string[]} args
|
|
1216
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [o]
|
|
1217
|
+
*/
|
|
1218
|
+
function spawnArgv(cmd, args, o = {}) {
|
|
1219
|
+
let c = cmd;
|
|
1220
|
+
let a = [...args];
|
|
1221
|
+
let verbatim = false;
|
|
1222
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd)) {
|
|
1223
|
+
c = process.env.ComSpec || 'cmd.exe';
|
|
1224
|
+
a = ['/d', '/s', '/c', `"${[cmd, ...args].map(qCmd).join(' ')}"`];
|
|
1225
|
+
verbatim = true;
|
|
1226
|
+
}
|
|
1227
|
+
return spawnSync(c, a, {
|
|
1228
|
+
encoding: 'utf8',
|
|
1229
|
+
cwd: o.cwd || REPO_ROOT || process.cwd(),
|
|
1230
|
+
env: { ...process.env, ...(o.env || {}) },
|
|
1231
|
+
shell: false,
|
|
1232
|
+
windowsHide: true,
|
|
1233
|
+
windowsVerbatimArguments: verbatim,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** @param {string[]} args @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [o] */
|
|
1238
|
+
function runPnpm(args, o = {}) {
|
|
1239
|
+
const bin = resolveCommandPath('pnpm');
|
|
1240
|
+
assert(Boolean(bin), 'pnpm required');
|
|
1241
|
+
return spawnArgv(/** @type {string} */ (bin), args, o);
|
|
1242
|
+
}
|
|
1243
|
+
/** @param {string[]} args @param {{ env?: NodeJS.ProcessEnv }} [o] */
|
|
1244
|
+
const imgRun = (args, o = {}) =>
|
|
1245
|
+
spawnSync(process.execPath, [runJs, ...args], {
|
|
1246
|
+
encoding: 'utf8',
|
|
1247
|
+
cwd: REPO_ROOT,
|
|
1248
|
+
env: { ...process.env, ...(o.env || {}) },
|
|
1249
|
+
shell: false,
|
|
1250
|
+
windowsHide: true,
|
|
1251
|
+
});
|
|
1252
|
+
/** @param {{ env?: NodeJS.ProcessEnv }} [o] */
|
|
1253
|
+
const imgCheck = (o = {}) =>
|
|
1254
|
+
spawnSync(process.execPath, [checkJs], {
|
|
1255
|
+
encoding: 'utf8',
|
|
1256
|
+
cwd: REPO_ROOT,
|
|
1257
|
+
env: { ...process.env, ...(o.env || {}) },
|
|
1258
|
+
shell: false,
|
|
1259
|
+
windowsHide: true,
|
|
1260
|
+
});
|
|
1261
|
+
/** @param {import('node:child_process').SpawnSyncReturns<string>} r */
|
|
1262
|
+
const out = (r) => `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
1263
|
+
|
|
1264
|
+
await test('images:run passes shell metacharacters as literal argv (exit 3, no side effects)', () => {
|
|
1265
|
+
assert(REPO_ROOT && fs.existsSync(runJs), 'run.mjs required');
|
|
1266
|
+
const marker = path.join(os.tmpdir(), `si-side-${process.pid}.txt`);
|
|
1267
|
+
if (fs.existsSync(marker)) fs.rmSync(marker, { force: true });
|
|
1268
|
+
for (const payload of [
|
|
1269
|
+
'--json; rm -rf /',
|
|
1270
|
+
`$(echo pwned > "${marker}")`,
|
|
1271
|
+
`&& echo pwned > "${marker}"`,
|
|
1272
|
+
]) {
|
|
1273
|
+
for (const r of [runPnpm(['run', 'images:run', '--', payload]), imgRun([payload])]) {
|
|
1274
|
+
assert(r.status === 3, `exit 3 expected, got ${r.status}: ${out(r).slice(0, 300)}`);
|
|
1275
|
+
assert(/invalid_input|unknown|error/i.test(out(r)), `invalid_input for ${payload}`);
|
|
1276
|
+
}
|
|
1277
|
+
assert(!fs.existsSync(marker), `no side-effect file for ${payload}`);
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
await test('Windows smart-img bin is silent while wrapper emits parseable doctor JSON', () => {
|
|
1282
|
+
assert(REPO_ROOT && fs.existsSync(runJs), 'run.mjs required');
|
|
1283
|
+
const binCmd = path.join(capDir, 'node_modules', '.bin', 'smart-img.CMD');
|
|
1284
|
+
assert(
|
|
1285
|
+
fs.existsSync(binCmd) || fs.existsSync(path.join(capDir, 'node_modules', '.bin', 'smart-img')),
|
|
1286
|
+
'smart-img bin required',
|
|
1287
|
+
);
|
|
1288
|
+
if (process.platform === 'win32' && fs.existsSync(binCmd)) {
|
|
1289
|
+
const bin = spawnArgv(binCmd, ['doctor', '--json']);
|
|
1290
|
+
assert(
|
|
1291
|
+
`${bin.stdout || ''}${bin.stderr || ''}`.trim().length === 0,
|
|
1292
|
+
`bin must be silent, got ${out(bin).slice(0, 200)}`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
for (const wrap of [runPnpm(['run', 'images:run', '--', 'doctor', '--json']), imgRun(['doctor', '--json'])]) {
|
|
1296
|
+
assert(wrap.status === 0, `wrapper exit 0, got ${wrap.status}: ${wrap.stderr}`);
|
|
1297
|
+
const parsed = JSON.parse((wrap.stdout || '').trim());
|
|
1298
|
+
assert(parsed.command === 'doctor' || parsed.ok === true, 'doctor JSON');
|
|
1299
|
+
assert(Array.isArray(parsed.details?.checks) || parsed.status === 'success', 'doctor checks');
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
await test('images:* never hooked from install/build/validate/CI; scripts exist', () => {
|
|
1304
|
+
assert(REPO_ROOT, 'repo root');
|
|
1305
|
+
const pkg = readJson(path.join(REPO_ROOT, 'package.json'));
|
|
1306
|
+
const s = pkg.scripts || {};
|
|
1307
|
+
assert(/frozen-lockfile/.test(s['images:setup'] || '') && /tools\/smart-image/.test(s['images:setup'] || ''));
|
|
1308
|
+
assert(/tools\/smart-image\/run\.mjs/.test(s['images:run'] || ''));
|
|
1309
|
+
assert(/tools\/smart-image\/check\.mjs/.test(s['images:check'] || ''));
|
|
1310
|
+
for (const k of ['preinstall', 'postinstall', 'prepare', 'prepublishOnly', 'build', 'validate:data', 'ci:install']) {
|
|
1311
|
+
assert(!/images:/.test(s[k] || ''), `${k} must not reference images:`);
|
|
1312
|
+
}
|
|
1313
|
+
const ci = path.join(REPO_ROOT, '.github');
|
|
1314
|
+
if (fs.existsSync(ci)) {
|
|
1315
|
+
/** @param {string} d */
|
|
1316
|
+
const walk = (d) => {
|
|
1317
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
1318
|
+
const f = path.join(d, e.name);
|
|
1319
|
+
if (e.isDirectory()) walk(f);
|
|
1320
|
+
else if (/\.ya?ml$/i.test(e.name)) {
|
|
1321
|
+
assert(!/images:(check|setup|run)/.test(fs.readFileSync(f, 'utf8')), `CI ${f}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
walk(ci);
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
await test('missing capsule / corrupt lock / offline setup remediate; provider is warning', () => {
|
|
1330
|
+
assert(REPO_ROOT && fs.existsSync(checkJs) && fs.existsSync(capDir), 'capsule required');
|
|
1331
|
+
for (const p of [
|
|
1332
|
+
path.join(REPO_ROOT, 'tools', 'smart-image-MISSING', 'check.mjs'),
|
|
1333
|
+
path.join(REPO_ROOT, 'tools', 'smart-image-MISSING', 'run.mjs'),
|
|
1334
|
+
]) {
|
|
1335
|
+
const r = spawnSync(process.execPath, [p], { encoding: 'utf8', shell: false, windowsHide: true });
|
|
1336
|
+
assert(r.error || r.status !== 0, `missing path must fail: ${p}`);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const tmpCap = fs.mkdtempSync(path.join(os.tmpdir(), 'si-cap-'));
|
|
1340
|
+
const emptyStore = fs.mkdtempSync(path.join(os.tmpdir(), 'si-store-'));
|
|
1341
|
+
try {
|
|
1342
|
+
for (const f of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', 'check.mjs', 'run.mjs']) {
|
|
1343
|
+
fs.copyFileSync(path.join(capDir, f), path.join(tmpCap, f));
|
|
1344
|
+
}
|
|
1345
|
+
const noNm = spawnSync(process.execPath, [path.join(tmpCap, 'check.mjs')], {
|
|
1346
|
+
encoding: 'utf8',
|
|
1347
|
+
shell: false,
|
|
1348
|
+
windowsHide: true,
|
|
1349
|
+
});
|
|
1350
|
+
assert(noNm.status === 1 && /images:setup|not ready|not installed/i.test(out(noNm)));
|
|
1351
|
+
const noRun = spawnSync(process.execPath, [path.join(tmpCap, 'run.mjs'), 'doctor', '--json'], {
|
|
1352
|
+
encoding: 'utf8',
|
|
1353
|
+
shell: false,
|
|
1354
|
+
windowsHide: true,
|
|
1355
|
+
});
|
|
1356
|
+
assert(noRun.status === 1 && /images:setup/i.test(out(noRun)));
|
|
1357
|
+
const offline = runPnpm(
|
|
1358
|
+
['install', '--frozen-lockfile', '--dir', tmpCap, '--offline', '--store-dir', emptyStore],
|
|
1359
|
+
);
|
|
1360
|
+
assert(offline.status !== 0 && /ERR_|error|failed|offline|not found|ENOENT/i.test(out(offline)));
|
|
1361
|
+
} finally {
|
|
1362
|
+
fs.rmSync(tmpCap, { recursive: true, force: true });
|
|
1363
|
+
fs.rmSync(emptyStore, { recursive: true, force: true });
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const lockPath = path.join(capDir, 'pnpm-lock.yaml');
|
|
1367
|
+
const lockOrig = fs.readFileSync(lockPath);
|
|
1368
|
+
try {
|
|
1369
|
+
fs.writeFileSync(lockPath, 'this: is: not: valid: lockfile\n');
|
|
1370
|
+
const setup = runPnpm(['run', 'images:setup']);
|
|
1371
|
+
assert(setup.status !== 0 && /ERR_|error|failed|lock/i.test(out(setup)));
|
|
1372
|
+
const bad = imgCheck();
|
|
1373
|
+
assert(bad.status === 1 && /corrupt|lockfile|not ready|Remediation|images:setup/i.test(out(bad)));
|
|
1374
|
+
} finally {
|
|
1375
|
+
fs.writeFileSync(lockPath, lockOrig);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const healthy = imgCheck();
|
|
1379
|
+
assert(healthy.status === 0 && /ready/i.test(healthy.stdout || ''));
|
|
1380
|
+
|
|
1381
|
+
const emptyCfg = fs.mkdtempSync(path.join(os.tmpdir(), 'si-cfg-'));
|
|
1382
|
+
try {
|
|
1383
|
+
const env = {
|
|
1384
|
+
...process.env,
|
|
1385
|
+
APPDATA: emptyCfg,
|
|
1386
|
+
XDG_CONFIG_HOME: emptyCfg,
|
|
1387
|
+
HOME: emptyCfg,
|
|
1388
|
+
USERPROFILE: emptyCfg,
|
|
1389
|
+
};
|
|
1390
|
+
const warn = imgCheck({ env });
|
|
1391
|
+
assert(warn.status === 0 && /ready/i.test(warn.stdout || ''));
|
|
1392
|
+
assert(/warning:.*provider/i.test(warn.stderr || ''), warn.stderr);
|
|
1393
|
+
const pick = imgRun(
|
|
1394
|
+
[
|
|
1395
|
+
'pick',
|
|
1396
|
+
REPO_ROOT,
|
|
1397
|
+
'--source',
|
|
1398
|
+
'pixabay',
|
|
1399
|
+
'--category',
|
|
1400
|
+
'exterior',
|
|
1401
|
+
'--orientation',
|
|
1402
|
+
'landscape',
|
|
1403
|
+
'--width',
|
|
1404
|
+
'800',
|
|
1405
|
+
'--height',
|
|
1406
|
+
'600',
|
|
1407
|
+
'--json',
|
|
1408
|
+
],
|
|
1409
|
+
{ env },
|
|
1410
|
+
);
|
|
1411
|
+
assert(pick.status !== 0, `provider pick must fail, got ${pick.status}`);
|
|
1412
|
+
assert(
|
|
1413
|
+
pick.status === 4 || /provider|api.?key|pixabay|failed|error/i.test(out(pick)),
|
|
1414
|
+
out(pick).slice(0, 300),
|
|
1415
|
+
);
|
|
1416
|
+
} finally {
|
|
1417
|
+
fs.rmSync(emptyCfg, { recursive: true, force: true });
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// --- image-tooling scaffold / git isolation (Unit 3) ---
|
|
1422
|
+
await test('isDeniedName covers every deny entry, rules, and near-miss negatives', () => {
|
|
1423
|
+
const denyDirs = [
|
|
1424
|
+
'node_modules',
|
|
1425
|
+
'dist',
|
|
1426
|
+
'.astro',
|
|
1427
|
+
'.git',
|
|
1428
|
+
'.codegraph',
|
|
1429
|
+
'docs_trash',
|
|
1430
|
+
'openspec',
|
|
1431
|
+
'logs',
|
|
1432
|
+
'packages',
|
|
1433
|
+
'.atl',
|
|
1434
|
+
'CUSTOMER-IMAGES',
|
|
1435
|
+
'.img-ia',
|
|
1436
|
+
];
|
|
1437
|
+
const denyFiles = ['package-lock.json'];
|
|
1438
|
+
for (const name of denyDirs) {
|
|
1439
|
+
assert(isDeniedName(name) === true, `deny dir ${name}`);
|
|
1440
|
+
}
|
|
1441
|
+
for (const name of denyFiles) {
|
|
1442
|
+
assert(isDeniedName(name) === true, `deny file ${name}`);
|
|
1443
|
+
}
|
|
1444
|
+
assert(isDeniedName('debug.log') === true, '*.log rule');
|
|
1445
|
+
assert(isDeniedName('app.log') === true, '*.log rule (app.log)');
|
|
1446
|
+
assert(isDeniedName('.env') === true, '.env* rule');
|
|
1447
|
+
assert(isDeniedName('.env.local') === true, '.env* rule (.env.local)');
|
|
1448
|
+
assert(isDeniedName('.env.production') === true, '.env* rule (.env.production)');
|
|
1449
|
+
// Near-miss / unrelated basenames must stay allowed
|
|
1450
|
+
assert(isDeniedName('CUSTOMER-IMAGES.md') === false, 'CUSTOMER-IMAGES.md near-miss');
|
|
1451
|
+
assert(isDeniedName('my-CUSTOMER-IMAGES') === false, 'my-CUSTOMER-IMAGES near-miss');
|
|
1452
|
+
assert(isDeniedName('tools') === false, 'tools must never be denied');
|
|
1453
|
+
assert(isDeniedName('smart-image') === false, 'smart-image basename allowed');
|
|
1454
|
+
assert(isDeniedName('_out') === false, 'bare _out must never be denied');
|
|
1455
|
+
assert(isDeniedName('README.sh') === false, 'README.sh alone is not denied');
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
await test('git check-ignore is root-relative for state and capsule node_modules only', () => {
|
|
1459
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1460
|
+
/**
|
|
1461
|
+
* @param {string} rel
|
|
1462
|
+
* @returns {boolean} true when git treats path as ignored
|
|
1463
|
+
*/
|
|
1464
|
+
function isIgnored(rel) {
|
|
1465
|
+
const r = spawnSync('git', ['check-ignore', '-q', '--', rel], {
|
|
1466
|
+
cwd: REPO_ROOT,
|
|
1467
|
+
encoding: 'utf8',
|
|
1468
|
+
shell: false,
|
|
1469
|
+
windowsHide: true,
|
|
1470
|
+
});
|
|
1471
|
+
if (r.error) throw r.error;
|
|
1472
|
+
return r.status === 0;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Directory patterns use trailing slash; also prove bare dir names when they exist.
|
|
1476
|
+
assert(isIgnored('CUSTOMER-IMAGES/') === true, 'root CUSTOMER-IMAGES/ ignored');
|
|
1477
|
+
assert(isIgnored('.img-ia/') === true, 'root .img-ia/ ignored');
|
|
1478
|
+
assert(isIgnored('tools/smart-image/node_modules') === true, 'capsule node_modules ignored');
|
|
1479
|
+
assert(isIgnored('tools/smart-image/node_modules/') === true, 'capsule node_modules/ ignored');
|
|
1480
|
+
|
|
1481
|
+
const probeCi = path.join(REPO_ROOT, 'CUSTOMER-IMAGES');
|
|
1482
|
+
const probeImg = path.join(REPO_ROOT, '.img-ia');
|
|
1483
|
+
/** @type {string[]} */
|
|
1484
|
+
const probes = [];
|
|
1485
|
+
try {
|
|
1486
|
+
if (!fs.existsSync(probeCi)) {
|
|
1487
|
+
fs.mkdirSync(probeCi, { recursive: true });
|
|
1488
|
+
probes.push(probeCi);
|
|
1489
|
+
}
|
|
1490
|
+
if (!fs.existsSync(probeImg)) {
|
|
1491
|
+
fs.mkdirSync(probeImg, { recursive: true });
|
|
1492
|
+
probes.push(probeImg);
|
|
1493
|
+
}
|
|
1494
|
+
assert(isIgnored('CUSTOMER-IMAGES') === true, 'root CUSTOMER-IMAGES dir ignored');
|
|
1495
|
+
assert(isIgnored('.img-ia') === true, 'root .img-ia dir ignored');
|
|
1496
|
+
assert(isIgnored('CUSTOMER-IMAGES/secret.jpg') === true, 'root CUSTOMER-IMAGES child ignored');
|
|
1497
|
+
assert(isIgnored('.img-ia/_out/x.png') === true, 'root .img-ia child ignored');
|
|
1498
|
+
} finally {
|
|
1499
|
+
for (const p of probes) {
|
|
1500
|
+
try {
|
|
1501
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
1502
|
+
} catch {
|
|
1503
|
+
// ignore
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// Nested lookalikes remain trackable (root-anchored / patterns only).
|
|
1509
|
+
// Avoid bare trailing-slash probes here: CRLF blank gitignore lines can
|
|
1510
|
+
// confuse check-ignore's directory-only matching on some Windows git builds.
|
|
1511
|
+
assert(isIgnored('src/CUSTOMER-IMAGES') === false, 'nested CUSTOMER-IMAGES trackable');
|
|
1512
|
+
assert(isIgnored('src/CUSTOMER-IMAGES/photo.jpg') === false, 'nested CUSTOMER-IMAGES file trackable');
|
|
1513
|
+
assert(isIgnored('docs/.img-ia') === false, 'nested .img-ia trackable');
|
|
1514
|
+
assert(isIgnored('docs/.img-ia/readme') === false, 'nested .img-ia child trackable');
|
|
1515
|
+
assert(isIgnored('packages/create-contractor-site/.img-ia') === false, 'nested package .img-ia trackable');
|
|
1516
|
+
|
|
1517
|
+
// Capsule source / manifest / lock / wrapper remain trackable
|
|
1518
|
+
for (const rel of [
|
|
1519
|
+
'tools/smart-image/package.json',
|
|
1520
|
+
'tools/smart-image/pnpm-workspace.yaml',
|
|
1521
|
+
'tools/smart-image/pnpm-lock.yaml',
|
|
1522
|
+
'tools/smart-image/run.mjs',
|
|
1523
|
+
'tools/smart-image/check.mjs',
|
|
1524
|
+
]) {
|
|
1525
|
+
assert(isIgnored(rel) === false, `${rel} must remain trackable`);
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
await test('copyTemplate excludes denied parents, keeps unrelated _out, required misses loud', () => {
|
|
1530
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-deny-src-'));
|
|
1531
|
+
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-deny-dst-'));
|
|
1532
|
+
try {
|
|
1533
|
+
// Minimal template-shaped tree
|
|
1534
|
+
fs.writeFileSync(path.join(src, 'keep.txt'), 'safe');
|
|
1535
|
+
fs.mkdirSync(path.join(src, 'CUSTOMER-IMAGES'), { recursive: true });
|
|
1536
|
+
fs.writeFileSync(path.join(src, 'CUSTOMER-IMAGES', 'secret.jpg'), 'NOCOPY');
|
|
1537
|
+
fs.mkdirSync(path.join(src, '.img-ia', '_out'), { recursive: true });
|
|
1538
|
+
fs.writeFileSync(path.join(src, '.img-ia', 'README.sh'), '#!/bin/sh\necho leak\n');
|
|
1539
|
+
fs.writeFileSync(path.join(src, '.img-ia', '_out', 'candidate.png'), 'NOCOPY');
|
|
1540
|
+
fs.mkdirSync(path.join(src, '_out'), { recursive: true });
|
|
1541
|
+
fs.writeFileSync(path.join(src, '_out', 'client-keep.txt'), 'KEEP');
|
|
1542
|
+
fs.mkdirSync(path.join(src, 'tools', 'smart-image'), { recursive: true });
|
|
1543
|
+
fs.writeFileSync(path.join(src, 'tools', 'smart-image', 'run.mjs'), '// wrapper');
|
|
1544
|
+
|
|
1545
|
+
const { skipped } = copyTemplate(src, dest);
|
|
1546
|
+
const skippedNorm = skipped.map((s) => s.replaceAll('\\', '/'));
|
|
1547
|
+
|
|
1548
|
+
assert(fs.existsSync(path.join(dest, 'keep.txt')), 'unrelated file copies');
|
|
1549
|
+
assert(fs.existsSync(path.join(dest, '_out', 'client-keep.txt')), 'unrelated _out copies');
|
|
1550
|
+
assert(fs.existsSync(path.join(dest, 'tools', 'smart-image', 'run.mjs')), 'tools/smart-image retained');
|
|
1551
|
+
assert(!fs.existsSync(path.join(dest, 'CUSTOMER-IMAGES')), 'CUSTOMER-IMAGES excluded');
|
|
1552
|
+
assert(!fs.existsSync(path.join(dest, '.img-ia')), '.img-ia excluded (parent deny)');
|
|
1553
|
+
assert(
|
|
1554
|
+
!fs.existsSync(path.join(dest, '.img-ia', 'README.sh')),
|
|
1555
|
+
'.img-ia/README.sh absent — no recurse into denied parent',
|
|
1556
|
+
);
|
|
1557
|
+
assert(
|
|
1558
|
+
!fs.existsSync(path.join(dest, '.img-ia', '_out', 'candidate.png')),
|
|
1559
|
+
'.img-ia/_out bytes never copied',
|
|
1560
|
+
);
|
|
1561
|
+
assert(skippedNorm.includes('CUSTOMER-IMAGES'), 'skipped lists CUSTOMER-IMAGES');
|
|
1562
|
+
assert(skippedNorm.includes('.img-ia'), 'skipped lists .img-ia');
|
|
1563
|
+
assert(!skippedNorm.some((s) => s === '_out' || s.startsWith('_out/')), '_out not skipped');
|
|
1564
|
+
|
|
1565
|
+
// assertRequiredCopied: every required capsule/wrapper/skill path missing is reported
|
|
1566
|
+
const stubRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-req-'));
|
|
1567
|
+
try {
|
|
1568
|
+
for (const rel of REQUIRED_AFTER_COPY) {
|
|
1569
|
+
const full = path.join(stubRoot, ...rel.split('/'));
|
|
1570
|
+
const looksFile = path.extname(rel) !== '' || rel.endsWith('package.json');
|
|
1571
|
+
if (looksFile) {
|
|
1572
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1573
|
+
fs.writeFileSync(full, 'x');
|
|
1574
|
+
} else {
|
|
1575
|
+
fs.mkdirSync(full, { recursive: true });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
assert(assertRequiredCopied(stubRoot).length === 0, 'full stub must pass required check');
|
|
1579
|
+
|
|
1580
|
+
const requiredMustExist = [
|
|
1581
|
+
'tools/smart-image/package.json',
|
|
1582
|
+
'tools/smart-image/pnpm-workspace.yaml',
|
|
1583
|
+
'tools/smart-image/pnpm-lock.yaml',
|
|
1584
|
+
'tools/smart-image/run.mjs',
|
|
1585
|
+
'tools/smart-image/check.mjs',
|
|
1586
|
+
'.agents/skills/smart-image-cli/SKILL.md',
|
|
1587
|
+
];
|
|
1588
|
+
for (const rel of requiredMustExist) {
|
|
1589
|
+
assert(
|
|
1590
|
+
REQUIRED_AFTER_COPY.includes(rel),
|
|
1591
|
+
`REQUIRED_AFTER_COPY must list ${rel}`,
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
for (const rel of requiredMustExist) {
|
|
1596
|
+
const full = path.join(stubRoot, ...rel.split('/'));
|
|
1597
|
+
fs.rmSync(full, { force: true });
|
|
1598
|
+
const missing = assertRequiredCopied(stubRoot);
|
|
1599
|
+
assert(missing.includes(rel), `missing must report ${rel}, got ${missing.join(',')}`);
|
|
1600
|
+
// restore for next iteration
|
|
1601
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1602
|
+
fs.writeFileSync(full, 'x');
|
|
1603
|
+
}
|
|
1604
|
+
} finally {
|
|
1605
|
+
fs.rmSync(stubRoot, { recursive: true, force: true });
|
|
1606
|
+
}
|
|
1607
|
+
} finally {
|
|
1608
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
1609
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
|
|
432
1613
|
const skipE2E = process.env.SKIP_CLI_E2E === '1';
|
|
433
1614
|
if (skipE2E) {
|
|
434
1615
|
console.log('\n ↷ SKIP_CLI_E2E=1 — skipping temp-target full scaffold');
|
|
435
1616
|
} else {
|
|
1617
|
+
/** @type {string[]} */
|
|
1618
|
+
const e2eSeeds = [];
|
|
1619
|
+
if (REPO_ROOT) {
|
|
1620
|
+
const seedCi = path.join(REPO_ROOT, 'CUSTOMER-IMAGES');
|
|
1621
|
+
const seedImgIaRoot = path.join(REPO_ROOT, '.img-ia');
|
|
1622
|
+
if (!fs.existsSync(seedCi)) {
|
|
1623
|
+
fs.mkdirSync(seedCi, { recursive: true });
|
|
1624
|
+
fs.writeFileSync(path.join(seedCi, 'e2e-secret.jpg'), 'NOCOPY');
|
|
1625
|
+
e2eSeeds.push(seedCi);
|
|
1626
|
+
}
|
|
1627
|
+
if (!fs.existsSync(seedImgIaRoot)) {
|
|
1628
|
+
fs.mkdirSync(path.join(seedImgIaRoot, '_out'), { recursive: true });
|
|
1629
|
+
fs.writeFileSync(path.join(seedImgIaRoot, '_out', 'e2e-candidate.png'), 'NOCOPY');
|
|
1630
|
+
e2eSeeds.push(seedImgIaRoot);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
try {
|
|
436
1634
|
await test('temp-target --yes scaffold install/validate/build/git', () => {
|
|
437
1635
|
assert(REPO_ROOT, 'expected local template root for E2E');
|
|
438
1636
|
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-e2e-'));
|
|
@@ -462,11 +1660,58 @@ async function main() {
|
|
|
462
1660
|
!fs.existsSync(path.join(targetDir, 'packages')),
|
|
463
1661
|
'packages/ must not be copied',
|
|
464
1662
|
);
|
|
1663
|
+
assert(!fs.existsSync(path.join(targetDir, 'CUSTOMER-IMAGES')), 'CUSTOMER-IMAGES excluded');
|
|
1664
|
+
assert(!fs.existsSync(path.join(targetDir, '.img-ia')), '.img-ia excluded');
|
|
1665
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'package.json')), 'capsule manifest');
|
|
1666
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'pnpm-workspace.yaml')), 'capsule workspace');
|
|
1667
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'pnpm-lock.yaml')), 'capsule lock');
|
|
1668
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'run.mjs')), 'wrapper run.mjs');
|
|
1669
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'check.mjs')), 'wrapper check.mjs');
|
|
1670
|
+
assert(
|
|
1671
|
+
fs.existsSync(path.join(targetDir, '.agents', 'skills', 'smart-image-cli', 'SKILL.md')),
|
|
1672
|
+
'skill retained',
|
|
1673
|
+
);
|
|
1674
|
+
const tgtPkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8'));
|
|
1675
|
+
for (const k of ['images:check', 'images:setup', 'images:run']) {
|
|
1676
|
+
assert(typeof tgtPkg.scripts?.[k] === 'string', `scaffold scripts must include ${k}`);
|
|
1677
|
+
}
|
|
465
1678
|
|
|
466
1679
|
const business = JSON.parse(
|
|
467
1680
|
fs.readFileSync(path.join(targetDir, 'src/data/business.json'), 'utf8'),
|
|
468
1681
|
);
|
|
469
1682
|
assert(business.name === 'Acme Masonry', 'expected replaced business name');
|
|
1683
|
+
// --yes omits trust/payment/hours → buildAnswers defaults (not sample-specific facts)
|
|
1684
|
+
assert(business.free_estimate === DEFAULT_FREE_ESTIMATE, 'E2E free_estimate default');
|
|
1685
|
+
assert(
|
|
1686
|
+
business.years_experience === DEFAULT_YEARS_EXPERIENCE,
|
|
1687
|
+
'E2E years_experience default',
|
|
1688
|
+
);
|
|
1689
|
+
assert(business.license === DEFAULT_LICENSE, 'E2E license default');
|
|
1690
|
+
assert(business.insurance === DEFAULT_INSURANCE, 'E2E insurance default');
|
|
1691
|
+
assert(business.founded_year === '', 'E2E founded_year omitted → ""');
|
|
1692
|
+
assert(
|
|
1693
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
1694
|
+
'E2E payment_methods defaults',
|
|
1695
|
+
);
|
|
1696
|
+
assert(business.payment_methods.length > 0, 'E2E payment_methods never []');
|
|
1697
|
+
assert(
|
|
1698
|
+
JSON.stringify(business.hours) === JSON.stringify(DEFAULT_HOURS),
|
|
1699
|
+
'E2E hours defaults',
|
|
1700
|
+
);
|
|
1701
|
+
assert(
|
|
1702
|
+
business.hours.every((h) => typeof h.days === 'string' && typeof h.time === 'string'),
|
|
1703
|
+
'E2E hours shape',
|
|
1704
|
+
);
|
|
1705
|
+
assert(JSON.stringify(business.social) === '{}', 'E2E omitted social → {}');
|
|
1706
|
+
const siteE2E = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
1707
|
+
const dirsE2E = readJson(path.join(targetDir, 'src/data/directories.json'));
|
|
1708
|
+
assert(siteE2E.features.enable_directories === false, 'E2E no dirs → enable false');
|
|
1709
|
+
assert(dirsE2E.directories.length >= 1, 'E2E directories min(1)');
|
|
1710
|
+
assert(
|
|
1711
|
+
siteE2E.site_type === 'multipage',
|
|
1712
|
+
`E2E --yes site_type multipage, got ${siteE2E.site_type}`,
|
|
1713
|
+
);
|
|
1714
|
+
// Full scaffold already ran validate:data + build (includes route gate) successfully.
|
|
470
1715
|
|
|
471
1716
|
const services = JSON.parse(
|
|
472
1717
|
fs.readFileSync(path.join(targetDir, 'src/data/services.json'), 'utf8'),
|
|
@@ -490,6 +1735,15 @@ async function main() {
|
|
|
490
1735
|
console.warn(` (could not fully remove ${targetDir})`);
|
|
491
1736
|
}
|
|
492
1737
|
});
|
|
1738
|
+
} finally {
|
|
1739
|
+
for (const p of e2eSeeds) {
|
|
1740
|
+
try {
|
|
1741
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
1742
|
+
} catch {
|
|
1743
|
+
// ignore seed cleanup failures
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
493
1747
|
}
|
|
494
1748
|
|
|
495
1749
|
console.log(`\n${passed} passed, ${failed} failed`);
|