create-contractor-site 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,399 +1,399 @@
1
- import { spawnSync } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
-
5
- const MIN_PNPM_VERSION = '11.1.2';
6
-
7
- /**
8
- * @param {string} filePath
9
- * @returns {boolean}
10
- */
11
- function isExecutableFile(filePath) {
12
- try {
13
- return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
14
- } catch {
15
- return false;
16
- }
17
- }
18
-
19
- /**
20
- * Follow a Windows .cmd shim to the real .exe when the shim points at one.
21
- * Handles pnpm's `@"%~dp0\..\...\pnpm.exe" %*` layout and absolute quoted exes.
22
- *
23
- * @param {string} cmdPath
24
- * @returns {string | null}
25
- */
26
- function resolveExeFromCmdShim(cmdPath) {
27
- if (!/\.(cmd|bat)$/i.test(cmdPath) || !isExecutableFile(cmdPath)) {
28
- return null;
29
- }
30
-
31
- let text = '';
32
- try {
33
- text = fs.readFileSync(cmdPath, 'utf8');
34
- } catch {
35
- return null;
36
- }
37
-
38
- const dir = path.dirname(cmdPath);
39
- const patterns = [
40
- /"(%~dp0[^"\r\n]+?\.(?:exe|cjs|js))"/i,
41
- /(%~dp0\\[^\s"&|<>]+?\.(?:exe|cjs|js))/i,
42
- /"([a-zA-Z]:\\[^"\r\n]+?\.(?:exe|cjs|js))"/i,
43
- ];
44
-
45
- for (const re of patterns) {
46
- const match = text.match(re);
47
- if (!match) continue;
48
- const raw = match[1].replace(/%~dp0/gi, dir + path.sep);
49
- const resolved = path.normalize(raw);
50
- if (isExecutableFile(resolved)) return resolved;
51
- }
52
-
53
- return null;
54
- }
55
-
56
- /**
57
- * Search PATH for a command, preferring real executables over shell shims.
58
- * Never shells out — pure filesystem lookup.
59
- *
60
- * @param {string} command bare name like "pnpm" or "git"
61
- * @returns {string | null} absolute path or null
62
- */
63
- export function resolveCommandPath(command) {
64
- if (!command || typeof command !== 'string') return null;
65
-
66
- if (path.isAbsolute(command) && isExecutableFile(command)) {
67
- const fromShim = resolveExeFromCmdShim(command);
68
- return fromShim || command;
69
- }
70
-
71
- /** @type {string[]} */
72
- const names = [];
73
- if (process.platform === 'win32') {
74
- if (command === 'pnpm') {
75
- // Prefer native exe, then cmd shim (which we may dereference), then bare name.
76
- names.push('pnpm.exe', 'pnpm.CMD', 'pnpm.cmd', 'pnpm.bat', 'pnpm');
77
- } else if (command === 'git') {
78
- names.push('git.exe', 'git.cmd', 'git.bat', 'git');
79
- } else if (/\.(exe|cmd|bat|com)$/i.test(command)) {
80
- names.push(command);
81
- } else {
82
- const pathExt = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
83
- .split(';')
84
- .filter(Boolean);
85
- // Prefer .EXE first regardless of PATHEXT order.
86
- const ordered = [
87
- ...pathExt.filter((e) => e.toUpperCase() === '.EXE'),
88
- ...pathExt.filter((e) => e.toUpperCase() !== '.EXE'),
89
- ];
90
- for (const ext of ordered) {
91
- names.push(`${command}${ext}`);
92
- }
93
- names.push(command);
94
- }
95
- } else {
96
- names.push(command);
97
- }
98
-
99
- const pathEnv = process.env.PATH || '';
100
- for (const dir of pathEnv.split(path.delimiter)) {
101
- if (!dir) continue;
102
- for (const name of names) {
103
- const full = path.join(dir, name);
104
- if (!isExecutableFile(full)) continue;
105
-
106
- const fromShim = resolveExeFromCmdShim(full);
107
- if (fromShim) return fromShim;
108
- // Skip extensionless / .ps1 shims that cannot be spawned with shell:false.
109
- if (process.platform === 'win32' && /\.ps1$/i.test(full)) continue;
110
- if (
111
- process.platform === 'win32' &&
112
- !/\.(exe|cmd|bat|com)$/i.test(full) &&
113
- command === 'pnpm'
114
- ) {
115
- // Bare "pnpm" next to pnpm.CMD is often a POSIX shim; keep looking.
116
- continue;
117
- }
118
- return full;
119
- }
120
- }
121
-
122
- return null;
123
- }
124
-
125
- /**
126
- * Prefer running pnpm via its Node entrypoint or native exe so Windows never
127
- * needs a shell. Falls back to cmd.exe only for unresolved .cmd shims.
128
- *
129
- * @param {string} pnpmPath
130
- * @returns {{ command: string, argsPrefix: string[], viaCmd: boolean }}
131
- */
132
- function pnpmSpawnTarget(pnpmPath) {
133
- if (/\.exe$/i.test(pnpmPath)) {
134
- return { command: pnpmPath, argsPrefix: [], viaCmd: false };
135
- }
136
-
137
- const dir = path.dirname(pnpmPath);
138
- const cjsCandidates = [
139
- path.join(dir, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'),
140
- path.join(dir, '..', 'pnpm', 'bin', 'pnpm.cjs'),
141
- path.join(dir, 'pnpm.cjs'),
142
- // @pnpm/exe layout sometimes ships a JS entry beside the exe package
143
- path.join(dir, 'pnpm.js'),
144
- ];
145
-
146
- for (const candidate of cjsCandidates) {
147
- if (isExecutableFile(candidate)) {
148
- return {
149
- command: process.execPath,
150
- argsPrefix: [candidate],
151
- viaCmd: false,
152
- };
153
- }
154
- }
155
-
156
- if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(pnpmPath)) {
157
- return {
158
- command: process.env.ComSpec || 'cmd.exe',
159
- argsPrefix: [],
160
- viaCmd: true,
161
- };
162
- }
163
-
164
- return { command: pnpmPath, argsPrefix: [], viaCmd: false };
165
- }
166
-
167
- /**
168
- * Quote one argument for cmd.exe when invoking via `cmd /d /s /c "..."`.
169
- *
170
- * @param {string} value
171
- * @returns {string}
172
- */
173
- function quoteForCmd(value) {
174
- if (value === '') return '""';
175
- // Always quote on Windows cmd lines when special chars present.
176
- if (!/[\s"&<>|^%!]/u.test(value)) return value;
177
- return `"${String(value).replace(/"/g, '""')}"`;
178
- }
179
-
180
- /**
181
- * Build a single /c argument for cmd.exe. With /S, cmd strips the outer quotes,
182
- * so we wrap the full command line once.
183
- *
184
- * @param {string} commandPath
185
- * @param {string[]} args
186
- * @returns {string}
187
- */
188
- function buildCmdCArgument(commandPath, args) {
189
- const tokens = [commandPath, ...args].map(quoteForCmd).join(' ');
190
- return `"${tokens}"`;
191
- }
192
-
193
- /**
194
- * Spawn a command with argv arrays on all platforms. Never uses shell:true.
195
- *
196
- * @param {string} command bare tool name ("pnpm", "git") or absolute path
197
- * @param {string[]} args
198
- * @param {{ cwd?: string, stdio?: 'inherit' | 'pipe' }} [opts]
199
- * @returns {{ status: number, stdout: string, stderr: string }}
200
- */
201
- export function runCommand(command, args, opts = {}) {
202
- const { cwd = process.cwd(), stdio = 'inherit' } = opts;
203
-
204
- const resolved =
205
- path.isAbsolute(command) && isExecutableFile(command)
206
- ? resolveExeFromCmdShim(command) || command
207
- : resolveCommandPath(command);
208
-
209
- if (!resolved) {
210
- throw new Error(
211
- `Command not found: ${command}. Install it and ensure it is on PATH.`,
212
- );
213
- }
214
-
215
- /** @type {string} */
216
- let spawnCmd = resolved;
217
- /** @type {string[]} */
218
- let spawnArgs = [...args];
219
- /** @type {boolean} */
220
- let windowsVerbatim = false;
221
-
222
- const isPnpm =
223
- command === 'pnpm' || /[/\\]pnpm(\.cmd|\.exe|\.CMD)?$/i.test(resolved);
224
-
225
- if (isPnpm) {
226
- const target = pnpmSpawnTarget(resolved);
227
- if (target.viaCmd) {
228
- spawnCmd = process.env.ComSpec || 'cmd.exe';
229
- spawnArgs = ['/d', '/s', '/c', buildCmdCArgument(resolved, args)];
230
- windowsVerbatim = true;
231
- } else {
232
- spawnCmd = target.command;
233
- spawnArgs = [...target.argsPrefix, ...args];
234
- }
235
- } else if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved)) {
236
- spawnCmd = process.env.ComSpec || 'cmd.exe';
237
- spawnArgs = ['/d', '/s', '/c', buildCmdCArgument(resolved, args)];
238
- windowsVerbatim = true;
239
- }
240
-
241
- /** @type {import('node:child_process').SpawnSyncReturns<string>} */
242
- const result = spawnSync(spawnCmd, spawnArgs, {
243
- cwd,
244
- stdio,
245
- encoding: 'utf8',
246
- shell: false,
247
- env: process.env,
248
- windowsHide: true,
249
- windowsVerbatimArguments: windowsVerbatim,
250
- });
251
-
252
- if (result.error) {
253
- const err = /** @type {NodeJS.ErrnoException} */ (result.error);
254
- if (err.code === 'ENOENT') {
255
- throw new Error(
256
- `Command not found: ${command}. Install it and ensure it is on PATH.`,
257
- );
258
- }
259
- throw err;
260
- }
261
-
262
- const status = result.status ?? 1;
263
- if (status !== 0) {
264
- const detail =
265
- stdio === 'pipe'
266
- ? `\n${result.stderr || result.stdout || ''}`.trim()
267
- : '';
268
- throw new Error(
269
- `\`${command} ${args.join(' ')}\` failed with exit code ${status}.${detail ? `\n${detail}` : ''}`,
270
- );
271
- }
272
-
273
- return {
274
- status,
275
- stdout: result.stdout ?? '',
276
- stderr: result.stderr ?? '',
277
- };
278
- }
279
-
280
- /**
281
- * @param {string} version
282
- * @returns {[number, number, number]}
283
- */
284
- function parseSemver(version) {
285
- const [major = '0', minor = '0', patch = '0'] = String(version)
286
- .trim()
287
- .split(/[.-]/u);
288
- return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
289
- }
290
-
291
- /**
292
- * @param {string} actual
293
- * @param {string} minimum
294
- * @returns {boolean}
295
- */
296
- export function isVersionAtLeast(actual, minimum) {
297
- const left = parseSemver(actual);
298
- const right = parseSemver(minimum);
299
- for (let i = 0; i < 3; i += 1) {
300
- if (left[i] > right[i]) return true;
301
- if (left[i] < right[i]) return false;
302
- }
303
- return true;
304
- }
305
-
306
- /**
307
- * Ensure pnpm is available. Never falls back to npm/npx.
308
- */
309
- export function assertPnpmAvailable() {
310
- if (!resolveCommandPath('pnpm')) {
311
- throw new Error(
312
- 'pnpm is required but was not found on PATH.\n' +
313
- 'Install pnpm >= 11.1.2 (https://pnpm.io/installation), then re-run this CLI.\n' +
314
- 'This tool never uses npm install, npm ci, or npx.',
315
- );
316
- }
317
-
318
- try {
319
- const result = runCommand('pnpm', ['--version'], { stdio: 'pipe' });
320
- const version = result.stdout.trim();
321
- if (!isVersionAtLeast(version, MIN_PNPM_VERSION)) {
322
- throw new Error(
323
- `pnpm ${MIN_PNPM_VERSION} or newer is required. Found: ${version || 'unknown'}.`,
324
- );
325
- }
326
- } catch (err) {
327
- throw new Error(
328
- 'pnpm was found on PATH but failed to run.\n' +
329
- `${err instanceof Error ? err.message : String(err)}\n` +
330
- 'Install pnpm >= 11.1.2 (https://pnpm.io/installation), then re-run this CLI.\n' +
331
- 'This tool never uses npm install, npm ci, or npx.',
332
- );
333
- }
334
- }
335
-
336
- /**
337
- * Ensure git is available.
338
- */
339
- export function assertGitAvailable() {
340
- if (!resolveCommandPath('git')) {
341
- throw new Error(
342
- 'git is required but was not found on PATH.\n' +
343
- 'Install git, then re-run this CLI to finish initialization.',
344
- );
345
- }
346
-
347
- try {
348
- runCommand('git', ['--version'], { stdio: 'pipe' });
349
- } catch (err) {
350
- throw new Error(
351
- 'git was found on PATH but failed to run.\n' +
352
- `${err instanceof Error ? err.message : String(err)}\n` +
353
- 'Install git, then re-run this CLI to finish initialization.',
354
- );
355
- }
356
- }
357
-
358
- /**
359
- * Run pnpm-only target setup: install → validate:data → build.
360
- *
361
- * @param {string} targetDir
362
- */
363
- export function runPnpmSetup(targetDir) {
364
- console.log('\n→ pnpm install');
365
- runCommand('pnpm', ['install'], { cwd: targetDir });
366
- console.log('\n→ pnpm run validate:data');
367
- runCommand('pnpm', ['run', 'validate:data'], { cwd: targetDir });
368
- console.log('\n→ pnpm run build');
369
- runCommand('pnpm', ['run', 'build'], { cwd: targetDir });
370
- }
371
-
372
- /**
373
- * Initialize git and create the initial scaffold commit.
374
- * Only call after successful install/validate/build.
375
- *
376
- * @param {string} targetDir
377
- */
378
- export function runGitInit(targetDir) {
379
- console.log('\n→ git init');
380
- runCommand('git', ['init'], { cwd: targetDir });
381
- console.log('→ git add -A');
382
- runCommand('git', ['add', '-A'], { cwd: targetDir });
383
- console.log('→ git commit');
384
- // Local identity only for the scaffold commit — does not require global git config.
385
- runCommand(
386
- 'git',
387
- [
388
- '-c',
389
- 'user.name=create-contractor-site',
390
- '-c',
391
- 'user.email=scaffold@localhost',
392
- 'commit',
393
- '-m',
394
- 'chore: initial client scaffold from contractor template',
395
- '--no-gpg-sign',
396
- ],
397
- { cwd: targetDir },
398
- );
399
- }
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const MIN_PNPM_VERSION = '11.1.2';
6
+
7
+ /**
8
+ * @param {string} filePath
9
+ * @returns {boolean}
10
+ */
11
+ function isExecutableFile(filePath) {
12
+ try {
13
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Follow a Windows .cmd shim to the real .exe when the shim points at one.
21
+ * Handles pnpm's `@"%~dp0\..\...\pnpm.exe" %*` layout and absolute quoted exes.
22
+ *
23
+ * @param {string} cmdPath
24
+ * @returns {string | null}
25
+ */
26
+ function resolveExeFromCmdShim(cmdPath) {
27
+ if (!/\.(cmd|bat)$/i.test(cmdPath) || !isExecutableFile(cmdPath)) {
28
+ return null;
29
+ }
30
+
31
+ let text = '';
32
+ try {
33
+ text = fs.readFileSync(cmdPath, 'utf8');
34
+ } catch {
35
+ return null;
36
+ }
37
+
38
+ const dir = path.dirname(cmdPath);
39
+ const patterns = [
40
+ /"(%~dp0[^"\r\n]+?\.(?:exe|cjs|js))"/i,
41
+ /(%~dp0\\[^\s"&|<>]+?\.(?:exe|cjs|js))/i,
42
+ /"([a-zA-Z]:\\[^"\r\n]+?\.(?:exe|cjs|js))"/i,
43
+ ];
44
+
45
+ for (const re of patterns) {
46
+ const match = text.match(re);
47
+ if (!match) continue;
48
+ const raw = match[1].replace(/%~dp0/gi, dir + path.sep);
49
+ const resolved = path.normalize(raw);
50
+ if (isExecutableFile(resolved)) return resolved;
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Search PATH for a command, preferring real executables over shell shims.
58
+ * Never shells out — pure filesystem lookup.
59
+ *
60
+ * @param {string} command bare name like "pnpm" or "git"
61
+ * @returns {string | null} absolute path or null
62
+ */
63
+ export function resolveCommandPath(command) {
64
+ if (!command || typeof command !== 'string') return null;
65
+
66
+ if (path.isAbsolute(command) && isExecutableFile(command)) {
67
+ const fromShim = resolveExeFromCmdShim(command);
68
+ return fromShim || command;
69
+ }
70
+
71
+ /** @type {string[]} */
72
+ const names = [];
73
+ if (process.platform === 'win32') {
74
+ if (command === 'pnpm') {
75
+ // Prefer native exe, then cmd shim (which we may dereference), then bare name.
76
+ names.push('pnpm.exe', 'pnpm.CMD', 'pnpm.cmd', 'pnpm.bat', 'pnpm');
77
+ } else if (command === 'git') {
78
+ names.push('git.exe', 'git.cmd', 'git.bat', 'git');
79
+ } else if (/\.(exe|cmd|bat|com)$/i.test(command)) {
80
+ names.push(command);
81
+ } else {
82
+ const pathExt = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
83
+ .split(';')
84
+ .filter(Boolean);
85
+ // Prefer .EXE first regardless of PATHEXT order.
86
+ const ordered = [
87
+ ...pathExt.filter((e) => e.toUpperCase() === '.EXE'),
88
+ ...pathExt.filter((e) => e.toUpperCase() !== '.EXE'),
89
+ ];
90
+ for (const ext of ordered) {
91
+ names.push(`${command}${ext}`);
92
+ }
93
+ names.push(command);
94
+ }
95
+ } else {
96
+ names.push(command);
97
+ }
98
+
99
+ const pathEnv = process.env.PATH || '';
100
+ for (const dir of pathEnv.split(path.delimiter)) {
101
+ if (!dir) continue;
102
+ for (const name of names) {
103
+ const full = path.join(dir, name);
104
+ if (!isExecutableFile(full)) continue;
105
+
106
+ const fromShim = resolveExeFromCmdShim(full);
107
+ if (fromShim) return fromShim;
108
+ // Skip extensionless / .ps1 shims that cannot be spawned with shell:false.
109
+ if (process.platform === 'win32' && /\.ps1$/i.test(full)) continue;
110
+ if (
111
+ process.platform === 'win32' &&
112
+ !/\.(exe|cmd|bat|com)$/i.test(full) &&
113
+ command === 'pnpm'
114
+ ) {
115
+ // Bare "pnpm" next to pnpm.CMD is often a POSIX shim; keep looking.
116
+ continue;
117
+ }
118
+ return full;
119
+ }
120
+ }
121
+
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Prefer running pnpm via its Node entrypoint or native exe so Windows never
127
+ * needs a shell. Falls back to cmd.exe only for unresolved .cmd shims.
128
+ *
129
+ * @param {string} pnpmPath
130
+ * @returns {{ command: string, argsPrefix: string[], viaCmd: boolean }}
131
+ */
132
+ function pnpmSpawnTarget(pnpmPath) {
133
+ if (/\.exe$/i.test(pnpmPath)) {
134
+ return { command: pnpmPath, argsPrefix: [], viaCmd: false };
135
+ }
136
+
137
+ const dir = path.dirname(pnpmPath);
138
+ const cjsCandidates = [
139
+ path.join(dir, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'),
140
+ path.join(dir, '..', 'pnpm', 'bin', 'pnpm.cjs'),
141
+ path.join(dir, 'pnpm.cjs'),
142
+ // @pnpm/exe layout sometimes ships a JS entry beside the exe package
143
+ path.join(dir, 'pnpm.js'),
144
+ ];
145
+
146
+ for (const candidate of cjsCandidates) {
147
+ if (isExecutableFile(candidate)) {
148
+ return {
149
+ command: process.execPath,
150
+ argsPrefix: [candidate],
151
+ viaCmd: false,
152
+ };
153
+ }
154
+ }
155
+
156
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(pnpmPath)) {
157
+ return {
158
+ command: process.env.ComSpec || 'cmd.exe',
159
+ argsPrefix: [],
160
+ viaCmd: true,
161
+ };
162
+ }
163
+
164
+ return { command: pnpmPath, argsPrefix: [], viaCmd: false };
165
+ }
166
+
167
+ /**
168
+ * Quote one argument for cmd.exe when invoking via `cmd /d /s /c "..."`.
169
+ *
170
+ * @param {string} value
171
+ * @returns {string}
172
+ */
173
+ function quoteForCmd(value) {
174
+ if (value === '') return '""';
175
+ // Always quote on Windows cmd lines when special chars present.
176
+ if (!/[\s"&<>|^%!]/u.test(value)) return value;
177
+ return `"${String(value).replace(/"/g, '""')}"`;
178
+ }
179
+
180
+ /**
181
+ * Build a single /c argument for cmd.exe. With /S, cmd strips the outer quotes,
182
+ * so we wrap the full command line once.
183
+ *
184
+ * @param {string} commandPath
185
+ * @param {string[]} args
186
+ * @returns {string}
187
+ */
188
+ function buildCmdCArgument(commandPath, args) {
189
+ const tokens = [commandPath, ...args].map(quoteForCmd).join(' ');
190
+ return `"${tokens}"`;
191
+ }
192
+
193
+ /**
194
+ * Spawn a command with argv arrays on all platforms. Never uses shell:true.
195
+ *
196
+ * @param {string} command bare tool name ("pnpm", "git") or absolute path
197
+ * @param {string[]} args
198
+ * @param {{ cwd?: string, stdio?: 'inherit' | 'pipe' }} [opts]
199
+ * @returns {{ status: number, stdout: string, stderr: string }}
200
+ */
201
+ export function runCommand(command, args, opts = {}) {
202
+ const { cwd = process.cwd(), stdio = 'inherit' } = opts;
203
+
204
+ const resolved =
205
+ path.isAbsolute(command) && isExecutableFile(command)
206
+ ? resolveExeFromCmdShim(command) || command
207
+ : resolveCommandPath(command);
208
+
209
+ if (!resolved) {
210
+ throw new Error(
211
+ `Command not found: ${command}. Install it and ensure it is on PATH.`,
212
+ );
213
+ }
214
+
215
+ /** @type {string} */
216
+ let spawnCmd = resolved;
217
+ /** @type {string[]} */
218
+ let spawnArgs = [...args];
219
+ /** @type {boolean} */
220
+ let windowsVerbatim = false;
221
+
222
+ const isPnpm =
223
+ command === 'pnpm' || /[/\\]pnpm(\.cmd|\.exe|\.CMD)?$/i.test(resolved);
224
+
225
+ if (isPnpm) {
226
+ const target = pnpmSpawnTarget(resolved);
227
+ if (target.viaCmd) {
228
+ spawnCmd = process.env.ComSpec || 'cmd.exe';
229
+ spawnArgs = ['/d', '/s', '/c', buildCmdCArgument(resolved, args)];
230
+ windowsVerbatim = true;
231
+ } else {
232
+ spawnCmd = target.command;
233
+ spawnArgs = [...target.argsPrefix, ...args];
234
+ }
235
+ } else if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved)) {
236
+ spawnCmd = process.env.ComSpec || 'cmd.exe';
237
+ spawnArgs = ['/d', '/s', '/c', buildCmdCArgument(resolved, args)];
238
+ windowsVerbatim = true;
239
+ }
240
+
241
+ /** @type {import('node:child_process').SpawnSyncReturns<string>} */
242
+ const result = spawnSync(spawnCmd, spawnArgs, {
243
+ cwd,
244
+ stdio,
245
+ encoding: 'utf8',
246
+ shell: false,
247
+ env: process.env,
248
+ windowsHide: true,
249
+ windowsVerbatimArguments: windowsVerbatim,
250
+ });
251
+
252
+ if (result.error) {
253
+ const err = /** @type {NodeJS.ErrnoException} */ (result.error);
254
+ if (err.code === 'ENOENT') {
255
+ throw new Error(
256
+ `Command not found: ${command}. Install it and ensure it is on PATH.`,
257
+ );
258
+ }
259
+ throw err;
260
+ }
261
+
262
+ const status = result.status ?? 1;
263
+ if (status !== 0) {
264
+ const detail =
265
+ stdio === 'pipe'
266
+ ? `\n${result.stderr || result.stdout || ''}`.trim()
267
+ : '';
268
+ throw new Error(
269
+ `\`${command} ${args.join(' ')}\` failed with exit code ${status}.${detail ? `\n${detail}` : ''}`,
270
+ );
271
+ }
272
+
273
+ return {
274
+ status,
275
+ stdout: result.stdout ?? '',
276
+ stderr: result.stderr ?? '',
277
+ };
278
+ }
279
+
280
+ /**
281
+ * @param {string} version
282
+ * @returns {[number, number, number]}
283
+ */
284
+ function parseSemver(version) {
285
+ const [major = '0', minor = '0', patch = '0'] = String(version)
286
+ .trim()
287
+ .split(/[.-]/u);
288
+ return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
289
+ }
290
+
291
+ /**
292
+ * @param {string} actual
293
+ * @param {string} minimum
294
+ * @returns {boolean}
295
+ */
296
+ export function isVersionAtLeast(actual, minimum) {
297
+ const left = parseSemver(actual);
298
+ const right = parseSemver(minimum);
299
+ for (let i = 0; i < 3; i += 1) {
300
+ if (left[i] > right[i]) return true;
301
+ if (left[i] < right[i]) return false;
302
+ }
303
+ return true;
304
+ }
305
+
306
+ /**
307
+ * Ensure pnpm is available. Never falls back to npm/npx.
308
+ */
309
+ export function assertPnpmAvailable() {
310
+ if (!resolveCommandPath('pnpm')) {
311
+ throw new Error(
312
+ 'pnpm is required but was not found on PATH.\n' +
313
+ 'Install pnpm >= 11.1.2 (https://pnpm.io/installation), then re-run this CLI.\n' +
314
+ 'This tool never uses npm install, npm ci, or npx.',
315
+ );
316
+ }
317
+
318
+ try {
319
+ const result = runCommand('pnpm', ['--version'], { stdio: 'pipe' });
320
+ const version = result.stdout.trim();
321
+ if (!isVersionAtLeast(version, MIN_PNPM_VERSION)) {
322
+ throw new Error(
323
+ `pnpm ${MIN_PNPM_VERSION} or newer is required. Found: ${version || 'unknown'}.`,
324
+ );
325
+ }
326
+ } catch (err) {
327
+ throw new Error(
328
+ 'pnpm was found on PATH but failed to run.\n' +
329
+ `${err instanceof Error ? err.message : String(err)}\n` +
330
+ 'Install pnpm >= 11.1.2 (https://pnpm.io/installation), then re-run this CLI.\n' +
331
+ 'This tool never uses npm install, npm ci, or npx.',
332
+ );
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Ensure git is available.
338
+ */
339
+ export function assertGitAvailable() {
340
+ if (!resolveCommandPath('git')) {
341
+ throw new Error(
342
+ 'git is required but was not found on PATH.\n' +
343
+ 'Install git, then re-run this CLI to finish initialization.',
344
+ );
345
+ }
346
+
347
+ try {
348
+ runCommand('git', ['--version'], { stdio: 'pipe' });
349
+ } catch (err) {
350
+ throw new Error(
351
+ 'git was found on PATH but failed to run.\n' +
352
+ `${err instanceof Error ? err.message : String(err)}\n` +
353
+ 'Install git, then re-run this CLI to finish initialization.',
354
+ );
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Run pnpm-only target setup: install → validate:data → build.
360
+ *
361
+ * @param {string} targetDir
362
+ */
363
+ export function runPnpmSetup(targetDir) {
364
+ console.log('\n→ pnpm install');
365
+ runCommand('pnpm', ['install'], { cwd: targetDir });
366
+ console.log('\n→ pnpm run validate:data');
367
+ runCommand('pnpm', ['run', 'validate:data'], { cwd: targetDir });
368
+ console.log('\n→ pnpm run build');
369
+ runCommand('pnpm', ['run', 'build'], { cwd: targetDir });
370
+ }
371
+
372
+ /**
373
+ * Initialize git and create the initial scaffold commit.
374
+ * Only call after successful install/validate/build.
375
+ *
376
+ * @param {string} targetDir
377
+ */
378
+ export function runGitInit(targetDir) {
379
+ console.log('\n→ git init');
380
+ runCommand('git', ['init'], { cwd: targetDir });
381
+ console.log('→ git add -A');
382
+ runCommand('git', ['add', '-A'], { cwd: targetDir });
383
+ console.log('→ git commit');
384
+ // Local identity only for the scaffold commit — does not require global git config.
385
+ runCommand(
386
+ 'git',
387
+ [
388
+ '-c',
389
+ 'user.name=create-contractor-site',
390
+ '-c',
391
+ 'user.email=scaffold@localhost',
392
+ 'commit',
393
+ '-m',
394
+ 'chore: initial client scaffold from contractor template',
395
+ '--no-gpg-sign',
396
+ ],
397
+ { cwd: targetDir },
398
+ );
399
+ }