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.
@@ -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
+ }
@@ -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
+ }