create-gtkx 1.6.0 → 2.0.0-beta.10

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.
@@ -2,8 +2,8 @@ import * as p from "@clack/prompts";
2
2
  import { APPLICATION_ID_MAX_LENGTH, isValidApplicationId } from "@gtkx/config/internal";
3
3
  import { errorMessage, tryResolveExecutable, upperFirst } from "@gtkx/utils";
4
4
  import { execFileSync } from "node:child_process";
5
- import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
- import { basename, dirname, join, resolve } from "node:path";
5
+ import { lstatSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { addDependency, detectPackageManager as nypmDetectPackageManager } from "nypm";
8
8
  import { x } from "tinyexec";
9
9
  import packageManifest from "../package.json" with { type: "json" };
@@ -16,6 +16,7 @@ import { isValidProjectName } from "./project-name.js";
16
16
  import { listTemplates, renderFile } from "./templates.js";
17
17
  const selfVersion = packageManifest.version;
18
18
  const ICON_TEMPLATE_NAME = "icon.svg";
19
+ const TYPESCRIPT_VERSION = "^6.0.3";
19
20
  const GTKX_ENV_MODULE_HEADER = `/**
20
21
  * Generated by \`gtkx codegen\`, \`gtkx dev\`, and \`gtkx build\`; do not edit.
21
22
  */`;
@@ -25,6 +26,7 @@ const TYPESCRIPT_DEV_DEPENDENCIES = ["@types/node", "@types/react", "typescript"
25
26
  const TESTING_DEV_DEPENDENCIES = ["@gtkx/testing", "vitest"];
26
27
  const DEV_COMMAND = Object.fromEntries(PACKAGE_MANAGERS.map((manager) => [manager.value, manager.devCommand]));
27
28
  const ADD_COMMAND = Object.fromEntries(PACKAGE_MANAGERS.map((manager) => [manager.value, manager.addCommand]));
29
+ const INSTALL_COMMAND = Object.fromEntries(PACKAGE_MANAGERS.map((manager) => [manager.value, manager.installCommand]));
28
30
  const INHERITED_SCRIPT_POLICY_VARS = ["npm_config_allow_scripts", "npm_config_allow-scripts"];
29
31
  const TESTING_TEMPLATES = new Set(["vitest.config.ts", "tests/app.test.tsx"]);
30
32
  const TYPESCRIPT_TEMPLATES = new Set(["tsconfig.json", "src/gtkx-env.d.ts"]);
@@ -39,10 +41,7 @@ const APPLICATION_ID_PREFIX = "com.";
39
41
  const APPLICATION_ID_SUFFIX = ".app";
40
42
  const APPLICATION_ID_SEGMENT_LIMIT = APPLICATION_ID_MAX_LENGTH - APPLICATION_ID_PREFIX.length -
41
43
  APPLICATION_ID_SUFFIX.length;
42
- const pinGtkxDependency = (name, version) => name.startsWith("@gtkx/") ? `${name}@^${version}` : name;
43
- const getDevCommand = (packageManager) => DEV_COMMAND[packageManager];
44
- const getAddCommand = (packageManager) => ADD_COMMAND[packageManager];
45
- const titleFromName = (name) => name.split("-").map((part) => upperFirst(part)).join(" ");
44
+ const displayNameFromProjectName = (name) => name.split("-").map((part) => upperFirst(part)).join(" ");
46
45
  const gitConfigValue = (key) => {
47
46
  const git = tryResolveExecutable("git");
48
47
  if (git === undefined) {
@@ -74,6 +73,10 @@ const isDirEmpty = (dir) => {
74
73
  const entries = readdirSync(dir);
75
74
  return entries.length === 0 || (entries.length === 1 && entries[0] === ".git");
76
75
  };
76
+ const shouldInitializeGit = (root) => {
77
+ const entry = lstatSync(root, { throwIfNoEntry: false });
78
+ return entry === undefined || (entry.isDirectory() && readdirSync(root).length === 0);
79
+ };
77
80
  const getDevDependencies = ({ isTypescript, shouldIncludeTesting, }) => {
78
81
  const devDeps = [...DEV_DEPENDENCIES];
79
82
  if (isTypescript) {
@@ -84,8 +87,9 @@ const getDevDependencies = ({ isTypescript, shouldIncludeTesting, }) => {
84
87
  }
85
88
  return devDeps;
86
89
  };
90
+ const isCancellation = (value) => p.isCancel(value);
87
91
  const guardCancellation = (value) => {
88
- if (p.isCancel(value)) {
92
+ if (isCancellation(value)) {
89
93
  p.cancel("Operation canceled");
90
94
  throw new OperationCanceledError();
91
95
  }
@@ -103,6 +107,13 @@ const validateProjectName = (value) => {
103
107
  const validateApplicationIdFormat = (value) => isValidApplicationId(value) ? undefined : APPLICATION_ID_FORMAT_ERROR;
104
108
  const validateApplicationIdInput = (value) => value ? validateApplicationIdFormat(value) : "Application ID is required";
105
109
  const validateApplicationIdAnswer = (value) => value ? validateApplicationIdFormat(value) : undefined;
110
+ const validateDisplayName = (value) => {
111
+ if (value === undefined || value.trim().length === 0) {
112
+ return "Display name is required";
113
+ }
114
+ return value.includes("\n") || value.includes("\r") ? "Display name must be a single line" : undefined;
115
+ };
116
+ const validateDisplayNameAnswer = (value) => value ? validateDisplayName(value) : undefined;
106
117
  const fail = (message) => {
107
118
  p.log.error(message);
108
119
  throw new ScaffoldAbortedError(message);
@@ -116,14 +127,6 @@ const requestedPackageManager = (value) => {
116
127
  }
117
128
  return value;
118
129
  };
119
- const targetStats = (root) => {
120
- try {
121
- return statSync(root, { throwIfNoEntry: false });
122
- }
123
- catch {
124
- return undefined;
125
- }
126
- };
127
130
  const validateTargetDir = (target) => {
128
131
  if (!target) {
129
132
  return "Project directory is required";
@@ -145,29 +148,29 @@ const promptApplicationId = async (name) => {
145
148
  validate: validateApplicationIdAnswer,
146
149
  }));
147
150
  };
148
- const detectPackageManager = async (cwd) => {
149
- const detected = await nypmDetectPackageManager(cwd, { includeParentDirs: true });
150
- if (!detected) {
151
- return undefined;
152
- }
153
- return isKnownPackageManager(detected.name) ? detected.name : undefined;
151
+ const promptDisplayName = async (name) => {
152
+ const defaultDisplayName = displayNameFromProjectName(name);
153
+ return guardCancellation(await p.text({
154
+ message: "Display name",
155
+ placeholder: defaultDisplayName,
156
+ defaultValue: defaultDisplayName,
157
+ validate: validateDisplayNameAnswer,
158
+ }));
154
159
  };
155
- const packageManagerHint = (manager, detected) => {
160
+ const packageManagerOption = (manager, detected) => {
161
+ let hint;
156
162
  if (detected === manager.value) {
157
- return "detected";
163
+ hint = "detected";
158
164
  }
159
- if (manager.isRecommended) {
160
- return "recommended";
165
+ else if (manager.isRecommended) {
166
+ hint = "recommended";
161
167
  }
162
- return undefined;
163
- };
164
- const packageManagerOption = (manager, detected) => {
165
- const hint = packageManagerHint(manager, detected);
166
168
  return { value: manager.value, label: manager.label, ...(hint !== undefined && { hint }) };
167
169
  };
168
170
  const detectedPackageManager = async () => {
169
171
  try {
170
- return await detectPackageManager(process.cwd());
172
+ const detected = await nypmDetectPackageManager(process.cwd(), { includeParentDirs: true });
173
+ return detected && isKnownPackageManager(detected.name) ? detected.name : undefined;
171
174
  }
172
175
  catch {
173
176
  return undefined;
@@ -182,52 +185,64 @@ const promptPackageManager = async () => {
182
185
  initialValue: initial,
183
186
  }));
184
187
  };
185
- const shouldUseTypeScript = async () => guardCancellation(await p.confirm({
186
- message: "Use TypeScript?",
187
- initialValue: true,
188
- }));
189
- const shouldSetUpTesting = async () => guardCancellation(await p.confirm({
190
- message: "Include testing setup (Vitest)?",
191
- initialValue: true,
192
- }));
193
- const emptyDir = (root) => {
194
- for (const entry of readdirSync(root)) {
195
- if (entry === ".git") {
196
- continue;
197
- }
198
- rmSync(join(root, entry), { recursive: true, force: true });
199
- }
188
+ const isOptionEnabled = async (value, isInteractive, message) => value ??
189
+ (isInteractive
190
+ ? guardCancellation(await p.confirm({ message, initialValue: true }))
191
+ : true);
192
+ const formatFileList = (heading, files) => {
193
+ const indentedFiles = files.map((file) => ` ${file}`).join("\n");
194
+ return `${heading}:\n${indentedFiles}`;
200
195
  };
201
- const prepareTargetDirectory = (root, shouldEmptyTarget) => {
202
- mkdirSync(root, { recursive: true });
203
- if (shouldEmptyTarget) {
204
- emptyDir(root);
196
+ const reportPlannedChanges = (changes) => {
197
+ if (changes.length > 0) {
198
+ p.log.info(formatFileList("Scaffold files to replace", changes));
205
199
  }
206
200
  };
207
- const shouldOverwriteDirectory = async (target, options) => {
208
- if (!options.isInteractive) {
209
- return options.shouldOverwrite === true;
201
+ const reportCompletedChanges = (changes) => {
202
+ if (changes.length > 0) {
203
+ p.log.success(formatFileList("Replaced scaffold files", changes));
210
204
  }
211
- return guardCancellation(await p.confirm({
212
- message: `Directory "${target}" is not empty. Overwrite its contents?`,
205
+ };
206
+ const confirmOverwrite = async (target, changes) => {
207
+ reportPlannedChanges(changes);
208
+ const shouldReplace = guardCancellation(await p.confirm({
209
+ message: `Directory "${target}" is not empty. Replace scaffold files?`,
213
210
  initialValue: false,
214
211
  }));
212
+ if (!shouldReplace) {
213
+ fail(`Directory "${target}" is not empty`);
214
+ }
215
215
  };
216
- const shouldEmptyTargetDirectory = async (root, target, options) => {
217
- const stats = targetStats(root);
218
- if (stats === undefined) {
219
- return false;
216
+ const refuseNonEmptyDirectory = (target, changes) => {
217
+ const heading = `Directory "${target}" is not empty. Pass --overwrite to`;
218
+ return fail(changes.length === 0
219
+ ? `${heading} scaffold into it`
220
+ : formatFileList(`${heading} replace these scaffold files`, changes));
221
+ };
222
+ const acceptNonEmptyDirectory = async (target, options, changes) => {
223
+ if (options.isInteractive) {
224
+ await confirmOverwrite(target, changes);
225
+ return;
220
226
  }
221
- if (!stats.isDirectory()) {
222
- return fail(`Target "${target}" is not a directory`);
227
+ if (options.shouldOverwrite === true) {
228
+ reportPlannedChanges(changes);
229
+ return;
223
230
  }
224
- if (isDirEmpty(root)) {
225
- return false;
231
+ refuseNonEmptyDirectory(target, changes);
232
+ };
233
+ const assertTargetIsDirectory = (root, target) => {
234
+ const stats = statSync(root, { throwIfNoEntry: false });
235
+ if (stats !== undefined && !stats.isDirectory()) {
236
+ fail(`Target "${target}" is not a directory`);
226
237
  }
227
- if (await shouldOverwriteDirectory(target, options)) {
228
- return true;
238
+ };
239
+ const validateTargetAvailability = async (root, target, options, changes) => {
240
+ const stats = statSync(root, { throwIfNoEntry: false });
241
+ assertTargetIsDirectory(root, target);
242
+ if (stats === undefined || isDirEmpty(root)) {
243
+ return;
229
244
  }
230
- return fail(`Directory "${target}" is not empty`);
245
+ await acceptNonEmptyDirectory(target, options, changes);
231
246
  };
232
247
  const resolveTarget = async (options) => {
233
248
  if (options.name === undefined && options.isInteractive) {
@@ -243,6 +258,12 @@ const resolveApplicationId = async (options, name) => {
243
258
  const error = validateApplicationIdInput(applicationId);
244
259
  return error === undefined ? applicationId : fail(error);
245
260
  };
261
+ const resolveDisplayName = async (options, name) => {
262
+ const value = options.displayName ??
263
+ (options.isInteractive ? await promptDisplayName(name) : displayNameFromProjectName(name));
264
+ const error = validateDisplayName(value);
265
+ return error === undefined ? value.trim() : fail(error);
266
+ };
246
267
  const resolvePackageManager = async (requested, options) => {
247
268
  if (requested !== undefined) {
248
269
  return requested;
@@ -252,39 +273,30 @@ const resolvePackageManager = async (requested, options) => {
252
273
  }
253
274
  return (await detectedPackageManager()) ?? "pnpm";
254
275
  };
255
- const isTypescriptSelected = async (options) => {
256
- if (options.isTypescript !== undefined) {
257
- return options.isTypescript;
258
- }
259
- return options.isInteractive ? shouldUseTypeScript() : true;
260
- };
261
- const isTestingIncluded = async (options) => {
262
- if (options.shouldIncludeTesting !== undefined) {
263
- return options.shouldIncludeTesting;
264
- }
265
- return options.isInteractive ? shouldSetUpTesting() : true;
266
- };
267
276
  const resolveOptions = async (options) => {
268
277
  const requested = requestedPackageManager(options.packageManager);
269
278
  const target = await resolveTarget(options);
270
279
  const name = deriveProjectName(target);
280
+ const displayName = await resolveDisplayName(options, name);
271
281
  const applicationId = await resolveApplicationId(options, name);
272
282
  const root = resolve(process.cwd(), target);
273
283
  const isCurrentDirectory = root === process.cwd();
274
- const shouldEmptyTarget = await shouldEmptyTargetDirectory(root, target, options);
284
+ const shouldInitialize = shouldInitializeGit(root);
275
285
  const packageManager = await resolvePackageManager(requested, options);
276
- const isTypescript = await isTypescriptSelected(options);
277
- const shouldIncludeTesting = await isTestingIncluded(options);
286
+ const isTypescript = await isOptionEnabled(options.isTypescript, options.isInteractive, "Use TypeScript?");
287
+ const shouldIncludeTesting = await isOptionEnabled(options.shouldIncludeTesting, options.isInteractive, "Include testing setup (Vitest)?");
278
288
  return {
279
289
  target,
280
290
  root,
281
291
  isCurrentDirectory,
282
292
  name,
293
+ displayName,
283
294
  applicationId,
284
295
  packageManager,
285
296
  isTypescript,
286
297
  shouldIncludeTesting,
287
- shouldEmptyTarget,
298
+ shouldInitializeGit: shouldInitialize,
299
+ shouldInstallDependencies: options.shouldInstallDependencies !== false,
288
300
  };
289
301
  };
290
302
  const isTemplateIncluded = (templateRelativePath, resolved) => {
@@ -296,37 +308,139 @@ const isTemplateIncluded = (templateRelativePath, resolved) => {
296
308
  }
297
309
  return true;
298
310
  };
299
- const toScriptExtension = (templateRelativePath, isTypescript) => isTypescript ? templateRelativePath : templateRelativePath.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js");
300
- const toIconPath = (path, applicationId) => path.endsWith(ICON_TEMPLATE_NAME) ? `${path.slice(0, -ICON_TEMPLATE_NAME.length)}${applicationId}.svg` : path;
301
- const toDestinationPath = (templateRelativePath, isTypescript, applicationId) => toIconPath(toScriptExtension(templateRelativePath, isTypescript), applicationId);
302
- const addTestScript = (root) => {
303
- updateManifest(root, (manifest) => {
304
- manifest.scripts.test = "vitest run";
305
- });
311
+ const destinationPath = (template, resolved) => {
312
+ const scriptPath = resolved.isTypescript ? template : template.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js");
313
+ return scriptPath.endsWith(ICON_TEMPLATE_NAME)
314
+ ? `${scriptPath.slice(0, -ICON_TEMPLATE_NAME.length)}${resolved.applicationId}.svg`
315
+ : scriptPath;
306
316
  };
307
317
  const templateContext = (resolved) => ({
308
318
  name: resolved.name,
309
319
  applicationId: resolved.applicationId,
310
- title: titleFromName(resolved.name),
320
+ displayName: resolved.displayName,
311
321
  shouldIncludeTesting: resolved.shouldIncludeTesting,
312
322
  isTypescript: resolved.isTypescript,
313
323
  importExtension: resolved.isTypescript ? ".js" : ".jsx",
314
- developerName: gitConfigValue("user.name") ?? titleFromName(resolved.name),
324
+ developerName: gitConfigValue("user.name") ?? resolved.displayName,
315
325
  developerEmail: gitConfigValue("user.email"),
316
326
  });
317
- const scaffoldProject = async (root, resolved) => {
318
- const { applicationId, isTypescript, shouldIncludeTesting } = resolved;
327
+ const replaceTemplateDestination = (destination) => {
328
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
329
+ if (entry === undefined) {
330
+ return;
331
+ }
332
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
333
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
334
+ }
335
+ rmSync(destination, { force: true });
336
+ };
337
+ const projectRelativeDestination = (root, destination) => {
338
+ const relativePath = relative(root, destination);
339
+ const isOutsideProject = relativePath.length === 0 || relativePath.startsWith(`..${sep}`) ||
340
+ isAbsolute(relativePath);
341
+ if (isOutsideProject) {
342
+ fail(`Scaffold destination is outside the project: ${destination}`);
343
+ }
344
+ return relativePath;
345
+ };
346
+ const assertRootIsNotSymbolicLink = (root) => {
347
+ const rootEntry = lstatSync(root, { throwIfNoEntry: false });
348
+ if (rootEntry?.isSymbolicLink()) {
349
+ fail(`Cannot scaffold through a symbolic link: ${root}`);
350
+ }
351
+ };
352
+ const assertSafeAncestor = (ancestor) => {
353
+ const entry = lstatSync(ancestor, { throwIfNoEntry: false });
354
+ if (entry?.isSymbolicLink()) {
355
+ fail(`Cannot scaffold through a symbolic link: ${ancestor}`);
356
+ }
357
+ if (entry !== undefined && !entry.isDirectory()) {
358
+ fail(`Cannot scaffold through a non-directory path: ${ancestor}`);
359
+ }
360
+ };
361
+ const assertSafeAncestors = (root, relativePath) => {
362
+ let ancestor = root;
363
+ const segments = relativePath.split(sep);
364
+ const ancestorSegments = segments.slice(0, -1);
365
+ for (const segment of ancestorSegments) {
366
+ ancestor = join(ancestor, segment);
367
+ assertSafeAncestor(ancestor);
368
+ }
369
+ };
370
+ const assertReplaceableDestination = (destination) => {
371
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
372
+ const isReplaceable = entry === undefined || entry.isFile() || entry.isSymbolicLink();
373
+ if (!isReplaceable) {
374
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
375
+ }
376
+ };
377
+ const assertSafeDestination = (root, destination) => {
378
+ const relativePath = projectRelativeDestination(root, destination);
379
+ assertRootIsNotSymbolicLink(root);
380
+ assertSafeAncestors(root, relativePath);
381
+ assertReplaceableDestination(destination);
382
+ };
383
+ const plannedScaffoldFiles = async (root, resolved) => {
319
384
  const context = templateContext(resolved);
385
+ const files = [];
320
386
  for (const template of listTemplates()) {
321
- if (!isTemplateIncluded(template, resolved)) {
322
- continue;
387
+ if (isTemplateIncluded(template, resolved)) {
388
+ files.push({
389
+ destination: join(root, destinationPath(template, resolved)),
390
+ contents: await renderFile(template, context),
391
+ });
323
392
  }
324
- const destination = join(root, toDestinationPath(template, isTypescript, applicationId));
325
- mkdirSync(dirname(destination), { recursive: true });
326
- writeFileSync(destination, await renderFile(template, context));
327
393
  }
328
- if (shouldIncludeTesting) {
329
- addTestScript(root);
394
+ return files;
395
+ };
396
+ const auxiliaryDestinations = (root, resolved) => resolved.packageManager === "pnpm" ? [join(root, "pnpm-workspace.yaml")] : [];
397
+ const envModulePath = (root) => join(root, "node_modules", ".gtkx", "env.d.ts");
398
+ const shouldWriteEnvModule = (resolved) => resolved.isTypescript && resolved.shouldInstallDependencies;
399
+ const generatedDestinations = (root, resolved) => shouldWriteEnvModule(resolved) ? [envModulePath(root)] : [];
400
+ const isPresent = (path) => lstatSync(path, { throwIfNoEntry: false }) !== undefined;
401
+ const classifyScaffoldChange = (root, destination) => {
402
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
403
+ if (entry === undefined) {
404
+ return undefined;
405
+ }
406
+ return { destination, relativePath: projectRelativeDestination(root, destination) };
407
+ };
408
+ const addScaffoldChange = (plan, change) => {
409
+ if (change === undefined) {
410
+ return;
411
+ }
412
+ plan.replaceDestinations.push(change.destination);
413
+ plan.changes.push(change.relativePath);
414
+ };
415
+ const planScaffoldProject = async (root, resolved) => {
416
+ const files = await plannedScaffoldFiles(root, resolved);
417
+ const reported = [...files.map((file) => file.destination), ...auxiliaryDestinations(root, resolved)];
418
+ const generated = generatedDestinations(root, resolved);
419
+ for (const destination of [...reported, ...generated]) {
420
+ assertSafeDestination(root, destination);
421
+ }
422
+ const plan = {
423
+ files,
424
+ replaceDestinations: generated.filter((destination) => isPresent(destination)),
425
+ changes: [],
426
+ };
427
+ for (const destination of reported) {
428
+ addScaffoldChange(plan, classifyScaffoldChange(root, destination));
429
+ }
430
+ return plan;
431
+ };
432
+ const scaffoldProject = (root, resolved, plan) => {
433
+ for (const destination of plan.replaceDestinations) {
434
+ replaceTemplateDestination(destination);
435
+ }
436
+ for (const file of plan.files) {
437
+ mkdirSync(dirname(file.destination), { recursive: true });
438
+ writeFileSync(file.destination, file.contents);
439
+ }
440
+ if (resolved.shouldIncludeTesting) {
441
+ updateManifest(root, (manifest) => {
442
+ manifest.scripts.test = "vitest run";
443
+ });
330
444
  }
331
445
  };
332
446
  const withoutInheritedScriptPolicy = async (run) => {
@@ -351,9 +465,26 @@ const installDependencies = async (options) => {
351
465
  await addDependency(dependencies, { cwd, packageManager, dev: isDev, silent: true });
352
466
  });
353
467
  };
354
- const pin = (names) => names.map((dependency) => pinGtkxDependency(dependency, selfVersion));
468
+ const pin = (names) => names.map((dependency) => {
469
+ if (dependency.startsWith("@gtkx/")) {
470
+ return `${dependency}@^${selfVersion}`;
471
+ }
472
+ return dependency === "typescript" ? `${dependency}@${TYPESCRIPT_VERSION}` : dependency;
473
+ });
474
+ const dependencyVersions = (names) => Object.fromEntries(names.map((dependency) => {
475
+ if (dependency.startsWith("@gtkx/")) {
476
+ return [dependency, `^${selfVersion}`];
477
+ }
478
+ return [dependency, dependency === "typescript" ? TYPESCRIPT_VERSION : "latest"];
479
+ }));
480
+ const writeDependencies = (root, devDependencies) => {
481
+ updateManifest(root, (manifest) => {
482
+ manifest.dependencies = dependencyVersions(DEPENDENCIES);
483
+ manifest.devDependencies = dependencyVersions(devDependencies);
484
+ });
485
+ };
355
486
  const formatRecovery = ({ resolved, devDependencies }) => {
356
- const add = getAddCommand(resolved.packageManager);
487
+ const add = ADD_COMMAND[resolved.packageManager];
357
488
  const steps = [
358
489
  ...(resolved.isCurrentDirectory ? [] : [`cd ${resolved.target}`]),
359
490
  `${add} ${pin(DEPENDENCIES).join(" ")}`,
@@ -400,11 +531,27 @@ const installAllDependencies = async (options) => {
400
531
  });
401
532
  };
402
533
  const writeInitialEnvModule = (root) => {
403
- const storeDir = join(root, "node_modules", ".gtkx");
404
- mkdirSync(storeDir, { recursive: true });
405
- writeFileSync(join(storeDir, "env.d.ts"), `${GTKX_ENV_MODULE_HEADER}\n`);
534
+ const path = envModulePath(root);
535
+ mkdirSync(dirname(path), { recursive: true });
536
+ writeFileSync(path, `${GTKX_ENV_MODULE_HEADER}\n`);
406
537
  };
407
- const initializeGitRepo = async (root) => {
538
+ const isInsideGitRepository = (root) => {
539
+ const git = tryResolveExecutable("git");
540
+ if (git === undefined) {
541
+ return false;
542
+ }
543
+ try {
544
+ execFileSync(git, ["-C", root, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
545
+ return true;
546
+ }
547
+ catch {
548
+ return false;
549
+ }
550
+ };
551
+ const initializeGitRepo = async (root, shouldInitialize) => {
552
+ if (!shouldInitialize || isInsideGitRepository(root)) {
553
+ return;
554
+ }
408
555
  const spinner = p.spinner();
409
556
  spinner.start("Initializing git repository...");
410
557
  try {
@@ -419,20 +566,24 @@ const initializeGitRepo = async (root) => {
419
566
  }
420
567
  };
421
568
  const printNextSteps = (resolved) => {
422
- const devCmd = getDevCommand(resolved.packageManager);
569
+ const devCmd = DEV_COMMAND[resolved.packageManager];
423
570
  const cdStep = resolved.isCurrentDirectory ? "" : `cd ${resolved.target}\n`;
571
+ const installStep = resolved.shouldInstallDependencies ? "" : `${INSTALL_COMMAND[resolved.packageManager]}\n`;
424
572
  const testingNote = resolved.shouldIncludeTesting ? HEADLESS_COMPOSITOR_NOTE : "";
425
- p.note(`${cdStep}${devCmd}${testingNote}`, "Next steps");
573
+ p.note(`${cdStep}${installStep}${devCmd}${testingNote}`, "Next steps");
426
574
  };
427
- const createProjectStructure = async (root, resolved) => {
575
+ const createProjectStructure = async (root, resolved, plan) => {
428
576
  await runWithSpinner({
429
577
  pending: "Creating project structure...",
430
578
  done: "Project structure created",
431
579
  failed: "Failed to create the project structure",
432
- run: async () => {
433
- prepareTargetDirectory(root, resolved.shouldEmptyTarget);
434
- await scaffoldProject(root, resolved);
580
+ run: () => {
581
+ mkdirSync(root, { recursive: true });
582
+ scaffoldProject(root, resolved, plan);
435
583
  writeBuildAllowance(root, resolved.packageManager);
584
+ if (shouldWriteEnvModule(resolved)) {
585
+ writeInitialEnvModule(root);
586
+ }
436
587
  },
437
588
  });
438
589
  };
@@ -441,12 +592,18 @@ const scaffold = async (options = {}) => {
441
592
  const resolved = await resolveOptions(options);
442
593
  const { root } = resolved;
443
594
  const devDeps = getDevDependencies(resolved);
444
- await createProjectStructure(root, resolved);
445
- await installAllDependencies({ resolved, devDependencies: devDeps });
446
- if (resolved.isTypescript) {
447
- writeInitialEnvModule(root);
448
- }
449
- await initializeGitRepo(root);
595
+ assertTargetIsDirectory(root, resolved.target);
596
+ const plan = await planScaffoldProject(root, resolved);
597
+ await validateTargetAvailability(root, resolved.target, options, plan.changes);
598
+ await createProjectStructure(root, resolved, plan);
599
+ reportCompletedChanges(plan.changes);
600
+ if (resolved.shouldInstallDependencies) {
601
+ await installAllDependencies({ resolved, devDependencies: devDeps });
602
+ }
603
+ else {
604
+ writeDependencies(root, devDeps);
605
+ }
606
+ await initializeGitRepo(root, resolved.shouldInitializeGit);
450
607
  printNextSteps(resolved);
451
608
  };
452
609
  export { scaffold };