create-contractor-site 2.1.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/bin/create-contractor-site.mjs +293 -0
- package/package.json +42 -0
- package/scripts/smoke-test.mjs +502 -0
- package/src/copy-template.mjs +286 -0
- package/src/prompts.mjs +121 -0
- package/src/replace-data.mjs +465 -0
- package/src/run-command.mjs +399 -0
- package/src/validate-target.mjs +96 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runCommand } from './run-command.mjs';
|
|
6
|
+
|
|
7
|
+
/** Directory / file basenames that must never be copied into a client scaffold. */
|
|
8
|
+
const DENY_DIR_NAMES = new Set([
|
|
9
|
+
'node_modules',
|
|
10
|
+
'dist',
|
|
11
|
+
'.astro',
|
|
12
|
+
'.git',
|
|
13
|
+
'.codegraph',
|
|
14
|
+
'docs_trash',
|
|
15
|
+
'openspec',
|
|
16
|
+
'logs',
|
|
17
|
+
// CLI package + internal agent tooling — not part of a client site
|
|
18
|
+
'packages',
|
|
19
|
+
'.atl',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const DENY_FILE_NAMES = new Set(['package-lock.json']);
|
|
23
|
+
|
|
24
|
+
/** Default remote template when the CLI is published outside the monorepo. */
|
|
25
|
+
export const DEFAULT_TEMPLATE_REPO =
|
|
26
|
+
process.env.CREATE_CONTRACTOR_TEMPLATE_REPO ||
|
|
27
|
+
'https://github.com/glacayo/website-multipages.git';
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_TEMPLATE_REF =
|
|
30
|
+
process.env.CREATE_CONTRACTOR_TEMPLATE_REF || 'v2.1.1';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} name basename
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function isDeniedName(name) {
|
|
37
|
+
if (DENY_DIR_NAMES.has(name)) return true;
|
|
38
|
+
if (DENY_FILE_NAMES.has(name)) return true;
|
|
39
|
+
if (name.endsWith('.log')) return true;
|
|
40
|
+
if (name.startsWith('.env')) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} dir
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function looksLikeTemplateRoot(dir) {
|
|
49
|
+
return (
|
|
50
|
+
fs.existsSync(path.join(dir, 'AGENTS.md')) &&
|
|
51
|
+
fs.existsSync(path.join(dir, 'package.json')) &&
|
|
52
|
+
fs.existsSync(path.join(dir, 'src', 'data', 'business.json')) &&
|
|
53
|
+
fs.existsSync(path.join(dir, 'scripts', 'enforce-package-manager.cjs'))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} dir
|
|
59
|
+
*/
|
|
60
|
+
function assertTemplateRoot(dir) {
|
|
61
|
+
if (!looksLikeTemplateRoot(dir)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Template path does not look like the contractor template: ${dir}\n` +
|
|
64
|
+
'Expected AGENTS.md, package.json, src/data/business.json, and scripts/enforce-package-manager.cjs.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk parents looking for the monorepo template root.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} [startDir]
|
|
73
|
+
* @returns {string | null}
|
|
74
|
+
*/
|
|
75
|
+
export function findLocalTemplateRoot(
|
|
76
|
+
startDir = path.dirname(fileURLToPath(import.meta.url)),
|
|
77
|
+
) {
|
|
78
|
+
let dir = path.resolve(startDir);
|
|
79
|
+
for (let i = 0; i < 8; i += 1) {
|
|
80
|
+
if (looksLikeTemplateRoot(dir)) {
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
const parent = path.dirname(dir);
|
|
84
|
+
if (parent === dir) break;
|
|
85
|
+
dir = parent;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clone the template repository into a temp directory (argv-based git only).
|
|
92
|
+
*
|
|
93
|
+
* @param {{ repo?: string, ref?: string }} [opts]
|
|
94
|
+
* @returns {{ root: string, cleanup: () => void }}
|
|
95
|
+
*/
|
|
96
|
+
export function cloneTemplateToTemp(opts = {}) {
|
|
97
|
+
const repo = opts.repo || DEFAULT_TEMPLATE_REPO;
|
|
98
|
+
const ref = opts.ref || DEFAULT_TEMPLATE_REF;
|
|
99
|
+
const tempBase = fs.mkdtempSync(
|
|
100
|
+
path.join(os.tmpdir(), 'create-contractor-site-'),
|
|
101
|
+
);
|
|
102
|
+
const root = path.join(tempBase, 'template');
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
console.log(`→ Cloning template ${repo} @ ${ref} …`);
|
|
106
|
+
runCommand(
|
|
107
|
+
'git',
|
|
108
|
+
[
|
|
109
|
+
'clone',
|
|
110
|
+
'--depth',
|
|
111
|
+
'1',
|
|
112
|
+
'--branch',
|
|
113
|
+
ref,
|
|
114
|
+
'--single-branch',
|
|
115
|
+
repo,
|
|
116
|
+
root,
|
|
117
|
+
],
|
|
118
|
+
{ stdio: 'pipe' },
|
|
119
|
+
);
|
|
120
|
+
assertTemplateRoot(root);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Retry without --branch if the ref is a commit or default branch differs.
|
|
123
|
+
try {
|
|
124
|
+
if (fs.existsSync(root)) {
|
|
125
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
runCommand('git', ['clone', '--depth', '1', repo, root], {
|
|
128
|
+
stdio: 'pipe',
|
|
129
|
+
});
|
|
130
|
+
if (ref && ref !== 'HEAD') {
|
|
131
|
+
runCommand('git', ['checkout', ref], { cwd: root, stdio: 'pipe' });
|
|
132
|
+
}
|
|
133
|
+
assertTemplateRoot(root);
|
|
134
|
+
} catch (inner) {
|
|
135
|
+
try {
|
|
136
|
+
fs.rmSync(tempBase, { recursive: true, force: true });
|
|
137
|
+
} catch {
|
|
138
|
+
// ignore cleanup errors
|
|
139
|
+
}
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Failed to clone template repository.\n` +
|
|
142
|
+
` Repo: ${repo}\n` +
|
|
143
|
+
` Ref: ${ref}\n` +
|
|
144
|
+
`${inner instanceof Error ? inner.message : String(inner)}\n\n` +
|
|
145
|
+
`Recovery:\n` +
|
|
146
|
+
` - Set CREATE_CONTRACTOR_TEMPLATE_ROOT to a local checkout of the template, or\n` +
|
|
147
|
+
` - Set CREATE_CONTRACTOR_TEMPLATE_REPO / CREATE_CONTRACTOR_TEMPLATE_REF, or\n` +
|
|
148
|
+
` - Run the CLI from inside the monorepo workspace.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
root,
|
|
155
|
+
cleanup: () => {
|
|
156
|
+
try {
|
|
157
|
+
fs.rmSync(tempBase, { recursive: true, force: true });
|
|
158
|
+
} catch {
|
|
159
|
+
// Best-effort cleanup; leave a note if it fails later in the CLI.
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the contractor template root for copy.
|
|
167
|
+
*
|
|
168
|
+
* Priority:
|
|
169
|
+
* 1. CREATE_CONTRACTOR_TEMPLATE_ROOT (local/dev override)
|
|
170
|
+
* 2. Walk parents from this package (monorepo / linked workspace)
|
|
171
|
+
* 3. Clone DEFAULT_TEMPLATE_REPO @ DEFAULT_TEMPLATE_REF into a temp dir
|
|
172
|
+
*
|
|
173
|
+
* @param {string} [startDir]
|
|
174
|
+
* @returns {{ root: string, cleanup: (() => void) | null, source: 'env' | 'local' | 'clone' }}
|
|
175
|
+
*/
|
|
176
|
+
export function resolveTemplateRoot(
|
|
177
|
+
startDir = path.dirname(fileURLToPath(import.meta.url)),
|
|
178
|
+
) {
|
|
179
|
+
if (process.env.CREATE_CONTRACTOR_TEMPLATE_ROOT) {
|
|
180
|
+
const fromEnv = path.resolve(process.env.CREATE_CONTRACTOR_TEMPLATE_ROOT);
|
|
181
|
+
assertTemplateRoot(fromEnv);
|
|
182
|
+
return { root: fromEnv, cleanup: null, source: 'env' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const local = findLocalTemplateRoot(startDir);
|
|
186
|
+
if (local) {
|
|
187
|
+
return { root: local, cleanup: null, source: 'local' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cloned = cloneTemplateToTemp();
|
|
191
|
+
return { root: cloned.root, cleanup: cloned.cleanup, source: 'clone' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Recursively copy template files into target using the denylist.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} templateRoot
|
|
198
|
+
* @param {string} targetDir
|
|
199
|
+
* @returns {{ copiedFiles: number, skipped: string[] }}
|
|
200
|
+
*/
|
|
201
|
+
export function copyTemplate(templateRoot, targetDir) {
|
|
202
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
203
|
+
|
|
204
|
+
/** @type {string[]} */
|
|
205
|
+
const skipped = [];
|
|
206
|
+
let copiedFiles = 0;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {string} srcDir
|
|
210
|
+
* @param {string} destDir
|
|
211
|
+
* @param {string} relBase
|
|
212
|
+
*/
|
|
213
|
+
function walk(srcDir, destDir, relBase) {
|
|
214
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
215
|
+
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const name = entry.name;
|
|
218
|
+
const relPath = relBase ? path.join(relBase, name) : name;
|
|
219
|
+
|
|
220
|
+
if (isDeniedName(name)) {
|
|
221
|
+
skipped.push(relPath.replaceAll('\\', '/'));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const srcPath = path.join(srcDir, name);
|
|
226
|
+
const destPath = path.join(destDir, name);
|
|
227
|
+
|
|
228
|
+
if (entry.isDirectory()) {
|
|
229
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
230
|
+
walk(srcPath, destPath, relPath);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (entry.isSymbolicLink()) {
|
|
235
|
+
skipped.push(relPath.replaceAll('\\', '/'));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (entry.isFile()) {
|
|
240
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
241
|
+
fs.copyFileSync(srcPath, destPath);
|
|
242
|
+
copiedFiles += 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
walk(templateRoot, targetDir, '');
|
|
248
|
+
|
|
249
|
+
return { copiedFiles, skipped };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Required paths that must exist after copy (relative, posix-style). */
|
|
253
|
+
export const REQUIRED_AFTER_COPY = [
|
|
254
|
+
'src',
|
|
255
|
+
'public',
|
|
256
|
+
'scripts',
|
|
257
|
+
'package.json',
|
|
258
|
+
'pnpm-lock.yaml',
|
|
259
|
+
'.npmrc',
|
|
260
|
+
'pnpm-workspace.yaml',
|
|
261
|
+
'astro.config.mjs',
|
|
262
|
+
'tsconfig.json',
|
|
263
|
+
'netlify.toml',
|
|
264
|
+
'README.md',
|
|
265
|
+
'AGENTS.md',
|
|
266
|
+
'SKILL.md',
|
|
267
|
+
'scripts/enforce-package-manager.cjs',
|
|
268
|
+
'src/assets/images',
|
|
269
|
+
'src/data/business.json',
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} targetDir
|
|
274
|
+
* @returns {string[]} missing relative paths
|
|
275
|
+
*/
|
|
276
|
+
export function assertRequiredCopied(targetDir) {
|
|
277
|
+
/** @type {string[]} */
|
|
278
|
+
const missing = [];
|
|
279
|
+
for (const rel of REQUIRED_AFTER_COPY) {
|
|
280
|
+
const full = path.join(targetDir, ...rel.split('/'));
|
|
281
|
+
if (!fs.existsSync(full)) {
|
|
282
|
+
missing.push(rel);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return missing;
|
|
286
|
+
}
|
package/src/prompts.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as readline from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} ScaffoldAnswers
|
|
6
|
+
* @property {string} businessName
|
|
7
|
+
* @property {string} legalName
|
|
8
|
+
* @property {string} tagline
|
|
9
|
+
* @property {string} phone
|
|
10
|
+
* @property {string} email
|
|
11
|
+
* @property {string} street
|
|
12
|
+
* @property {string} city
|
|
13
|
+
* @property {string} state
|
|
14
|
+
* @property {string} zip
|
|
15
|
+
* @property {string} serviceArea
|
|
16
|
+
* @property {string} foundedYear
|
|
17
|
+
* @property {string[]} primaryServices
|
|
18
|
+
* @property {string} [siteUrl]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} label
|
|
23
|
+
* @param {{ required?: boolean, defaultValue?: string }} [opts]
|
|
24
|
+
* @param {import('node:readline/promises').Interface} rl
|
|
25
|
+
* @returns {Promise<string>}
|
|
26
|
+
*/
|
|
27
|
+
async function ask(rl, label, opts = {}) {
|
|
28
|
+
const { required = true, defaultValue = '' } = opts;
|
|
29
|
+
while (true) {
|
|
30
|
+
const hint = defaultValue ? ` [${defaultValue}]` : '';
|
|
31
|
+
const raw = await rl.question(`${label}${hint}: `);
|
|
32
|
+
const answer = raw.trim();
|
|
33
|
+
if (answer) return answer;
|
|
34
|
+
if (!required) return defaultValue;
|
|
35
|
+
console.log(' This field is required. Please enter a value (or Ctrl+C to abort).');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect required client fields via readline (no extra deps).
|
|
41
|
+
*
|
|
42
|
+
* @returns {Promise<ScaffoldAnswers>}
|
|
43
|
+
*/
|
|
44
|
+
export async function collectClientAnswers() {
|
|
45
|
+
const rl = readline.createInterface({ input, output });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
console.log('\nEnter client business details (required fields re-prompt if empty).\n');
|
|
49
|
+
|
|
50
|
+
const businessName = await ask(rl, 'Business name');
|
|
51
|
+
const legalName = await ask(rl, 'Legal business name', {
|
|
52
|
+
defaultValue: `${businessName} LLC`,
|
|
53
|
+
});
|
|
54
|
+
const tagline = await ask(rl, 'Tagline');
|
|
55
|
+
const phone = await ask(rl, 'Primary phone');
|
|
56
|
+
const email = await ask(rl, 'Primary email');
|
|
57
|
+
const street = await ask(rl, 'Street address');
|
|
58
|
+
const city = await ask(rl, 'City');
|
|
59
|
+
const state = await ask(rl, 'State');
|
|
60
|
+
const zip = await ask(rl, 'ZIP');
|
|
61
|
+
const serviceArea = await ask(rl, 'Service area description');
|
|
62
|
+
const foundedYear = await ask(rl, 'Founded year');
|
|
63
|
+
const servicesRaw = await ask(rl, 'Primary services (comma-separated)');
|
|
64
|
+
const siteUrl = await ask(rl, 'Site URL (optional)', {
|
|
65
|
+
required: false,
|
|
66
|
+
defaultValue: '',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const primaryServices = servicesRaw
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
|
|
74
|
+
if (primaryServices.length === 0) {
|
|
75
|
+
throw new Error('At least one primary service is required.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
businessName,
|
|
80
|
+
legalName,
|
|
81
|
+
tagline,
|
|
82
|
+
phone,
|
|
83
|
+
email,
|
|
84
|
+
street,
|
|
85
|
+
city,
|
|
86
|
+
state,
|
|
87
|
+
zip,
|
|
88
|
+
serviceArea,
|
|
89
|
+
foundedYear,
|
|
90
|
+
primaryServices,
|
|
91
|
+
siteUrl: siteUrl || undefined,
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
rl.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Non-interactive answers helper for scripted verification.
|
|
100
|
+
*
|
|
101
|
+
* @param {Partial<ScaffoldAnswers> & { businessName: string, primaryServices: string[] }} overrides
|
|
102
|
+
* @returns {ScaffoldAnswers}
|
|
103
|
+
*/
|
|
104
|
+
export function buildAnswers(overrides) {
|
|
105
|
+
const businessName = overrides.businessName;
|
|
106
|
+
return {
|
|
107
|
+
businessName,
|
|
108
|
+
legalName: overrides.legalName ?? `${businessName} LLC`,
|
|
109
|
+
tagline: overrides.tagline ?? `Quality work from ${businessName}.`,
|
|
110
|
+
phone: overrides.phone ?? '(555) 555-0100',
|
|
111
|
+
email: overrides.email ?? 'hello@example.com',
|
|
112
|
+
street: overrides.street ?? '123 Main St',
|
|
113
|
+
city: overrides.city ?? 'Example City',
|
|
114
|
+
state: overrides.state ?? 'VA',
|
|
115
|
+
zip: overrides.zip ?? '00000',
|
|
116
|
+
serviceArea: overrides.serviceArea ?? `${overrides.city ?? 'Example City'} and surrounding areas`,
|
|
117
|
+
foundedYear: overrides.foundedYear ?? '2020',
|
|
118
|
+
primaryServices: overrides.primaryServices,
|
|
119
|
+
siteUrl: overrides.siteUrl,
|
|
120
|
+
};
|
|
121
|
+
}
|