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,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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a path for stable prefix comparisons on Windows and POSIX.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} value
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizePathForCompare(value) {
|
|
11
|
+
const resolved = path.resolve(value);
|
|
12
|
+
// path.resolve already drops trailing separators except root; lower-case on win32.
|
|
13
|
+
const normalized =
|
|
14
|
+
process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
15
|
+
// Ensure directory prefix checks use a trailing separator boundary.
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True when `candidate` is the same path as `root` or a path inside `root`.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} candidate
|
|
23
|
+
* @param {string} root
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isSameOrInside(candidate, root) {
|
|
27
|
+
const child = normalizePathForCompare(candidate);
|
|
28
|
+
const parent = normalizePathForCompare(root);
|
|
29
|
+
|
|
30
|
+
if (child === parent) return true;
|
|
31
|
+
|
|
32
|
+
const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep;
|
|
33
|
+
return child.startsWith(prefix);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve and validate the scaffold target directory.
|
|
38
|
+
* Refuses existing non-empty directories.
|
|
39
|
+
* When `templateRoot` is provided, refuses targets equal to or inside the template.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} rawTarget
|
|
42
|
+
* @param {{ templateRoot?: string }} [opts]
|
|
43
|
+
* @returns {string} absolute target path
|
|
44
|
+
*/
|
|
45
|
+
export function validateTarget(rawTarget, opts = {}) {
|
|
46
|
+
if (!rawTarget || !String(rawTarget).trim()) {
|
|
47
|
+
throw new TargetValidationError(
|
|
48
|
+
'Missing target directory.\n\nUsage: create-contractor-site <target-dir>',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const targetDir = path.resolve(process.cwd(), String(rawTarget).trim());
|
|
53
|
+
const { templateRoot } = opts;
|
|
54
|
+
|
|
55
|
+
if (templateRoot) {
|
|
56
|
+
const root = path.resolve(templateRoot);
|
|
57
|
+
if (isSameOrInside(targetDir, root)) {
|
|
58
|
+
throw new TargetValidationError(
|
|
59
|
+
`Target directory must not be the template root or inside it.\n` +
|
|
60
|
+
` Template: ${root}\n` +
|
|
61
|
+
` Target: ${targetDir}\n` +
|
|
62
|
+
`Choose a directory outside the template repository.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(targetDir)) {
|
|
68
|
+
return targetDir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stat = fs.statSync(targetDir);
|
|
72
|
+
if (!stat.isDirectory()) {
|
|
73
|
+
throw new TargetValidationError(
|
|
74
|
+
`Target path exists and is not a directory: ${targetDir}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entries = fs.readdirSync(targetDir);
|
|
79
|
+
if (entries.length > 0) {
|
|
80
|
+
throw new TargetValidationError(
|
|
81
|
+
`Target directory is not empty: ${targetDir}\nChoose a different directory or remove existing files first.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return targetDir;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class TargetValidationError extends Error {
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} message
|
|
91
|
+
*/
|
|
92
|
+
constructor(message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = 'TargetValidationError';
|
|
95
|
+
}
|
|
96
|
+
}
|