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.
package/src/scaffolder.ts CHANGED
@@ -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, type Stats, 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" };
@@ -23,11 +23,13 @@ import { listTemplates, renderFile, type TemplateContext } from "./templates.js"
23
23
  type CreateOptions = {
24
24
  name?: string | undefined;
25
25
  applicationId?: string | undefined;
26
+ displayName?: string | undefined;
26
27
  packageManager?: string | undefined;
27
28
  isTypescript?: boolean | undefined;
28
29
  shouldIncludeTesting?: boolean | undefined;
29
30
  isInteractive?: boolean | undefined;
30
31
  shouldOverwrite?: boolean | undefined;
32
+ shouldInstallDependencies?: boolean | undefined;
31
33
  };
32
34
 
33
35
  type ResolvedOptions = {
@@ -35,11 +37,28 @@ type ResolvedOptions = {
35
37
  root: string;
36
38
  isCurrentDirectory: boolean;
37
39
  name: string;
40
+ displayName: string;
38
41
  applicationId: string;
39
42
  packageManager: PackageManager;
40
43
  isTypescript: boolean;
41
44
  shouldIncludeTesting: boolean;
42
- shouldEmptyTarget: boolean;
45
+ shouldInitializeGit: boolean;
46
+ shouldInstallDependencies: boolean;
47
+ };
48
+
49
+ type ScaffoldFile = { destination: string; contents: string };
50
+
51
+ type ScaffoldChanges = string[];
52
+
53
+ type ScaffoldPlan = {
54
+ files: ScaffoldFile[];
55
+ replaceDestinations: string[];
56
+ changes: ScaffoldChanges;
57
+ };
58
+
59
+ type ScaffoldChange = {
60
+ destination: string;
61
+ relativePath: string;
43
62
  };
44
63
 
45
64
  type InstallDependenciesOptions = {
@@ -64,12 +83,13 @@ type SpinnerStep = {
64
83
  pending: string;
65
84
  done: string;
66
85
  failed: string;
67
- run: () => Promise<void>;
86
+ run: () => Promise<void> | void;
68
87
  explain?: ((error: unknown) => void) | undefined;
69
88
  };
70
89
 
71
90
  const selfVersion = packageManifest.version;
72
91
  const ICON_TEMPLATE_NAME = "icon.svg";
92
+ const TYPESCRIPT_VERSION = "^6.0.3";
73
93
 
74
94
  const GTKX_ENV_MODULE_HEADER = `/**
75
95
  * Generated by \`gtkx codegen\`, \`gtkx dev\`, and \`gtkx build\`; do not edit.
@@ -88,6 +108,10 @@ const ADD_COMMAND: Record<PackageManager, string> = Object.fromEntries(
88
108
  PACKAGE_MANAGERS.map((manager) => [manager.value, manager.addCommand]),
89
109
  ) as Record<PackageManager, string>;
90
110
 
111
+ const INSTALL_COMMAND: Record<PackageManager, string> = Object.fromEntries(
112
+ PACKAGE_MANAGERS.map((manager) => [manager.value, manager.installCommand]),
113
+ ) as Record<PackageManager, string>;
114
+
91
115
  const INHERITED_SCRIPT_POLICY_VARS = ["npm_config_allow_scripts", "npm_config_allow-scripts"];
92
116
  const TESTING_TEMPLATES = new Set(["vitest.config.ts", "tests/app.test.tsx"]);
93
117
  const TYPESCRIPT_TEMPLATES = new Set(["tsconfig.json", "src/gtkx-env.d.ts"]);
@@ -106,12 +130,8 @@ const APPLICATION_ID_SUFFIX = ".app";
106
130
  const APPLICATION_ID_SEGMENT_LIMIT = APPLICATION_ID_MAX_LENGTH - APPLICATION_ID_PREFIX.length -
107
131
  APPLICATION_ID_SUFFIX.length;
108
132
 
109
- const pinGtkxDependency = (name: string, version: string): string =>
110
- name.startsWith("@gtkx/") ? `${name}@^${version}` : name;
111
-
112
- const getDevCommand = (packageManager: PackageManager): string => DEV_COMMAND[packageManager];
113
- const getAddCommand = (packageManager: PackageManager): string => ADD_COMMAND[packageManager];
114
- const titleFromName = (name: string): string => name.split("-").map((part) => upperFirst(part)).join(" ");
133
+ const displayNameFromProjectName = (name: string): string =>
134
+ name.split("-").map((part) => upperFirst(part)).join(" ");
115
135
 
116
136
  const gitConfigValue = (key: string): string | null => {
117
137
  const git = tryResolveExecutable("git");
@@ -147,7 +167,9 @@ const stripTrailingSlashes = (value: string): string => {
147
167
  return value.slice(0, end);
148
168
  };
149
169
 
150
- const formatTargetDir = (target: string): string => stripTrailingSlashes(target.trim().replaceAll(/[<>:"\\|?*]/g, ""));
170
+ const formatTargetDir = (target: string): string =>
171
+ stripTrailingSlashes(target.trim().replaceAll(/[<>:"\\|?*]/g, ""));
172
+
151
173
  const deriveProjectName = (target: string): string => basename(resolve(process.cwd(), target));
152
174
 
153
175
  const isDirEmpty = (dir: string): boolean => {
@@ -156,6 +178,12 @@ const isDirEmpty = (dir: string): boolean => {
156
178
  return entries.length === 0 || (entries.length === 1 && entries[0] === ".git");
157
179
  };
158
180
 
181
+ const shouldInitializeGit = (root: string): boolean => {
182
+ const entry = lstatSync(root, { throwIfNoEntry: false });
183
+
184
+ return entry === undefined || (entry.isDirectory() && readdirSync(root).length === 0);
185
+ };
186
+
159
187
  const getDevDependencies = ({
160
188
  isTypescript,
161
189
  shouldIncludeTesting,
@@ -173,8 +201,10 @@ const getDevDependencies = ({
173
201
  return devDeps;
174
202
  };
175
203
 
204
+ const isCancellation = (value: unknown): value is symbol => p.isCancel(value);
205
+
176
206
  const guardCancellation = <T>(value: T | symbol): T => {
177
- if (p.isCancel(value)) {
207
+ if (isCancellation(value)) {
178
208
  p.cancel("Operation canceled");
179
209
  throw new OperationCanceledError();
180
210
  }
@@ -203,6 +233,17 @@ const validateApplicationIdInput = (value: string | undefined): string | undefin
203
233
  const validateApplicationIdAnswer = (value: string | undefined): string | undefined =>
204
234
  value ? validateApplicationIdFormat(value) : undefined;
205
235
 
236
+ const validateDisplayName = (value: string | undefined): string | undefined => {
237
+ if (value === undefined || value.trim().length === 0) {
238
+ return "Display name is required";
239
+ }
240
+
241
+ return value.includes("\n") || value.includes("\r") ? "Display name must be a single line" : undefined;
242
+ };
243
+
244
+ const validateDisplayNameAnswer = (value: string | undefined): string | undefined =>
245
+ value ? validateDisplayName(value) : undefined;
246
+
206
247
  const fail = (message: string): never => {
207
248
  p.log.error(message);
208
249
  throw new ScaffoldAbortedError(message);
@@ -220,14 +261,6 @@ const requestedPackageManager = (value: string | undefined): PackageManager | un
220
261
  return value;
221
262
  };
222
263
 
223
- const targetStats = (root: string): Stats | undefined => {
224
- try {
225
- return statSync(root, { throwIfNoEntry: false });
226
- } catch {
227
- return undefined;
228
- }
229
- };
230
-
231
264
  const validateTargetDir = (target: string): string | undefined => {
232
265
  if (!target) {
233
266
  return "Project directory is required";
@@ -263,40 +296,36 @@ const promptApplicationId = async (name: string): Promise<string> => {
263
296
  );
264
297
  };
265
298
 
266
- const detectPackageManager = async (cwd: string): Promise<PackageManager | undefined> => {
267
- const detected = await nypmDetectPackageManager(cwd, { includeParentDirs: true });
268
-
269
- if (!detected) {
270
- return undefined;
271
- }
299
+ const promptDisplayName = async (name: string): Promise<string> => {
300
+ const defaultDisplayName = displayNameFromProjectName(name);
272
301
 
273
- return isKnownPackageManager(detected.name) ? detected.name : undefined;
302
+ return guardCancellation(
303
+ await p.text({
304
+ message: "Display name",
305
+ placeholder: defaultDisplayName,
306
+ defaultValue: defaultDisplayName,
307
+ validate: validateDisplayNameAnswer,
308
+ }),
309
+ );
274
310
  };
275
311
 
276
- const packageManagerHint = (
277
- manager: (typeof PACKAGE_MANAGERS)[number],
278
- detected: PackageManager | undefined,
279
- ): string | undefined => {
280
- if (detected === manager.value) {
281
- return "detected";
282
- }
312
+ const packageManagerOption = (manager: (typeof PACKAGE_MANAGERS)[number], detected: PackageManager | undefined) => {
313
+ let hint: string | undefined;
283
314
 
284
- if (manager.isRecommended) {
285
- return "recommended";
315
+ if (detected === manager.value) {
316
+ hint = "detected";
317
+ } else if (manager.isRecommended) {
318
+ hint = "recommended";
286
319
  }
287
320
 
288
- return undefined;
289
- };
290
-
291
- const packageManagerOption = (manager: (typeof PACKAGE_MANAGERS)[number], detected: PackageManager | undefined) => {
292
- const hint = packageManagerHint(manager, detected);
293
-
294
321
  return { value: manager.value, label: manager.label, ...(hint !== undefined && { hint }) };
295
322
  };
296
323
 
297
324
  const detectedPackageManager = async (): Promise<PackageManager | undefined> => {
298
325
  try {
299
- return await detectPackageManager(process.cwd());
326
+ const detected = await nypmDetectPackageManager(process.cwd(), { includeParentDirs: true });
327
+
328
+ return detected && isKnownPackageManager(detected.name) ? detected.name : undefined;
300
329
  } catch {
301
330
  return undefined;
302
331
  }
@@ -315,73 +344,101 @@ const promptPackageManager = async (): Promise<PackageManager> => {
315
344
  );
316
345
  };
317
346
 
318
- const shouldUseTypeScript = async (): Promise<boolean> =>
319
- guardCancellation(
320
- await p.confirm({
321
- message: "Use TypeScript?",
322
- initialValue: true,
323
- }),
324
- );
347
+ const isOptionEnabled = async (
348
+ value: boolean | undefined,
349
+ isInteractive: boolean | undefined,
350
+ message: string,
351
+ ): Promise<boolean> =>
352
+ value ??
353
+ (isInteractive
354
+ ? guardCancellation(await p.confirm({ message, initialValue: true }))
355
+ : true);
325
356
 
326
- const shouldSetUpTesting = async (): Promise<boolean> =>
327
- guardCancellation(
328
- await p.confirm({
329
- message: "Include testing setup (Vitest)?",
330
- initialValue: true,
331
- }),
332
- );
357
+ const formatFileList = (heading: string, files: string[]): string => {
358
+ const indentedFiles = files.map((file) => ` ${file}`).join("\n");
333
359
 
334
- const emptyDir = (root: string): void => {
335
- for (const entry of readdirSync(root)) {
336
- if (entry === ".git") {
337
- continue;
338
- }
360
+ return `${heading}:\n${indentedFiles}`;
361
+ };
339
362
 
340
- rmSync(join(root, entry), { recursive: true, force: true });
363
+ const reportPlannedChanges = (changes: ScaffoldChanges): void => {
364
+ if (changes.length > 0) {
365
+ p.log.info(formatFileList("Scaffold files to replace", changes));
341
366
  }
342
367
  };
343
368
 
344
- const prepareTargetDirectory = (root: string, shouldEmptyTarget: boolean): void => {
345
- mkdirSync(root, { recursive: true });
346
-
347
- if (shouldEmptyTarget) {
348
- emptyDir(root);
369
+ const reportCompletedChanges = (changes: ScaffoldChanges): void => {
370
+ if (changes.length > 0) {
371
+ p.log.success(formatFileList("Replaced scaffold files", changes));
349
372
  }
350
373
  };
351
374
 
352
- const shouldOverwriteDirectory = async (target: string, options: CreateOptions): Promise<boolean> => {
353
- if (!options.isInteractive) {
354
- return options.shouldOverwrite === true;
355
- }
375
+ const confirmOverwrite = async (target: string, changes: ScaffoldChanges): Promise<void> => {
376
+ reportPlannedChanges(changes);
356
377
 
357
- return guardCancellation(
378
+ const shouldReplace = guardCancellation(
358
379
  await p.confirm({
359
- message: `Directory "${target}" is not empty. Overwrite its contents?`,
380
+ message: `Directory "${target}" is not empty. Replace scaffold files?`,
360
381
  initialValue: false,
361
382
  }),
362
383
  );
384
+
385
+ if (!shouldReplace) {
386
+ fail(`Directory "${target}" is not empty`);
387
+ }
363
388
  };
364
389
 
365
- const shouldEmptyTargetDirectory = async (root: string, target: string, options: CreateOptions): Promise<boolean> => {
366
- const stats = targetStats(root);
390
+ const refuseNonEmptyDirectory = (target: string, changes: ScaffoldChanges): never => {
391
+ const heading = `Directory "${target}" is not empty. Pass --overwrite to`;
367
392
 
368
- if (stats === undefined) {
369
- return false;
393
+ return fail(
394
+ changes.length === 0
395
+ ? `${heading} scaffold into it`
396
+ : formatFileList(`${heading} replace these scaffold files`, changes),
397
+ );
398
+ };
399
+
400
+ const acceptNonEmptyDirectory = async (
401
+ target: string,
402
+ options: CreateOptions,
403
+ changes: ScaffoldChanges,
404
+ ): Promise<void> => {
405
+ if (options.isInteractive) {
406
+ await confirmOverwrite(target, changes);
407
+
408
+ return;
370
409
  }
371
410
 
372
- if (!stats.isDirectory()) {
373
- return fail(`Target "${target}" is not a directory`);
411
+ if (options.shouldOverwrite === true) {
412
+ reportPlannedChanges(changes);
413
+
414
+ return;
374
415
  }
375
416
 
376
- if (isDirEmpty(root)) {
377
- return false;
417
+ refuseNonEmptyDirectory(target, changes);
418
+ };
419
+
420
+ const assertTargetIsDirectory = (root: string, target: string): void => {
421
+ const stats = statSync(root, { throwIfNoEntry: false });
422
+
423
+ if (stats !== undefined && !stats.isDirectory()) {
424
+ fail(`Target "${target}" is not a directory`);
378
425
  }
426
+ };
379
427
 
380
- if (await shouldOverwriteDirectory(target, options)) {
381
- return true;
428
+ const validateTargetAvailability = async (
429
+ root: string,
430
+ target: string,
431
+ options: CreateOptions,
432
+ changes: ScaffoldChanges,
433
+ ): Promise<void> => {
434
+ const stats = statSync(root, { throwIfNoEntry: false });
435
+ assertTargetIsDirectory(root, target);
436
+
437
+ if (stats === undefined || isDirEmpty(root)) {
438
+ return;
382
439
  }
383
440
 
384
- return fail(`Directory "${target}" is not empty`);
441
+ await acceptNonEmptyDirectory(target, options, changes);
385
442
  };
386
443
 
387
444
  const resolveTarget = async (options: CreateOptions): Promise<string> => {
@@ -404,6 +461,14 @@ const resolveApplicationId = async (options: CreateOptions, name: string): Promi
404
461
  return error === undefined ? applicationId : fail(error);
405
462
  };
406
463
 
464
+ const resolveDisplayName = async (options: CreateOptions, name: string): Promise<string> => {
465
+ const value = options.displayName ??
466
+ (options.isInteractive ? await promptDisplayName(name) : displayNameFromProjectName(name));
467
+ const error = validateDisplayName(value);
468
+
469
+ return error === undefined ? value.trim() : fail(error);
470
+ };
471
+
407
472
  const resolvePackageManager = async (
408
473
  requested: PackageManager | undefined,
409
474
  options: CreateOptions,
@@ -419,44 +484,36 @@ const resolvePackageManager = async (
419
484
  return (await detectedPackageManager()) ?? "pnpm";
420
485
  };
421
486
 
422
- const isTypescriptSelected = async (options: CreateOptions): Promise<boolean> => {
423
- if (options.isTypescript !== undefined) {
424
- return options.isTypescript;
425
- }
426
-
427
- return options.isInteractive ? shouldUseTypeScript() : true;
428
- };
429
-
430
- const isTestingIncluded = async (options: CreateOptions): Promise<boolean> => {
431
- if (options.shouldIncludeTesting !== undefined) {
432
- return options.shouldIncludeTesting;
433
- }
434
-
435
- return options.isInteractive ? shouldSetUpTesting() : true;
436
- };
437
-
438
487
  const resolveOptions = async (options: CreateOptions): Promise<ResolvedOptions> => {
439
488
  const requested = requestedPackageManager(options.packageManager);
440
489
  const target = await resolveTarget(options);
441
490
  const name = deriveProjectName(target);
491
+ const displayName = await resolveDisplayName(options, name);
442
492
  const applicationId = await resolveApplicationId(options, name);
443
493
  const root = resolve(process.cwd(), target);
444
494
  const isCurrentDirectory = root === process.cwd();
445
- const shouldEmptyTarget = await shouldEmptyTargetDirectory(root, target, options);
495
+ const shouldInitialize = shouldInitializeGit(root);
446
496
  const packageManager = await resolvePackageManager(requested, options);
447
- const isTypescript = await isTypescriptSelected(options);
448
- const shouldIncludeTesting = await isTestingIncluded(options);
497
+ const isTypescript = await isOptionEnabled(options.isTypescript, options.isInteractive, "Use TypeScript?");
498
+
499
+ const shouldIncludeTesting = await isOptionEnabled(
500
+ options.shouldIncludeTesting,
501
+ options.isInteractive,
502
+ "Include testing setup (Vitest)?",
503
+ );
449
504
 
450
505
  return {
451
506
  target,
452
507
  root,
453
508
  isCurrentDirectory,
454
509
  name,
510
+ displayName,
455
511
  applicationId,
456
512
  packageManager,
457
513
  isTypescript,
458
514
  shouldIncludeTesting,
459
- shouldEmptyTarget,
515
+ shouldInitializeGit: shouldInitialize,
516
+ shouldInstallDependencies: options.shouldInstallDependencies !== false,
460
517
  };
461
518
  };
462
519
 
@@ -472,48 +529,185 @@ const isTemplateIncluded = (templateRelativePath: string, resolved: ResolvedOpti
472
529
  return true;
473
530
  };
474
531
 
475
- const toScriptExtension = (templateRelativePath: string, isTypescript: boolean): string =>
476
- isTypescript ? templateRelativePath : templateRelativePath.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js");
477
-
478
- const toIconPath = (path: string, applicationId: string): string =>
479
- path.endsWith(ICON_TEMPLATE_NAME) ? `${path.slice(0, -ICON_TEMPLATE_NAME.length)}${applicationId}.svg` : path;
532
+ const destinationPath = (template: string, resolved: ResolvedOptions): string => {
533
+ const scriptPath = resolved.isTypescript ? template : template.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js");
480
534
 
481
- const toDestinationPath = (templateRelativePath: string, isTypescript: boolean, applicationId: string): string =>
482
- toIconPath(toScriptExtension(templateRelativePath, isTypescript), applicationId);
483
-
484
- const addTestScript = (root: string): void => {
485
- updateManifest(root, (manifest) => {
486
- (manifest.scripts as Record<string, string>).test = "vitest run";
487
- });
535
+ return scriptPath.endsWith(ICON_TEMPLATE_NAME)
536
+ ? `${scriptPath.slice(0, -ICON_TEMPLATE_NAME.length)}${resolved.applicationId}.svg`
537
+ : scriptPath;
488
538
  };
489
539
 
490
540
  const templateContext = (resolved: ResolvedOptions): TemplateContext => ({
491
541
  name: resolved.name,
492
542
  applicationId: resolved.applicationId,
493
- title: titleFromName(resolved.name),
543
+ displayName: resolved.displayName,
494
544
  shouldIncludeTesting: resolved.shouldIncludeTesting,
495
545
  isTypescript: resolved.isTypescript,
496
546
  importExtension: resolved.isTypescript ? ".js" : ".jsx",
497
- developerName: gitConfigValue("user.name") ?? titleFromName(resolved.name),
547
+ developerName: gitConfigValue("user.name") ?? resolved.displayName,
498
548
  developerEmail: gitConfigValue("user.email"),
499
549
  });
500
550
 
501
- const scaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<void> => {
502
- const { applicationId, isTypescript, shouldIncludeTesting } = resolved;
551
+ const replaceTemplateDestination = (destination: string): void => {
552
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
553
+
554
+ if (entry === undefined) {
555
+ return;
556
+ }
557
+
558
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
559
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
560
+ }
561
+
562
+ rmSync(destination, { force: true });
563
+ };
564
+
565
+ const projectRelativeDestination = (root: string, destination: string): string => {
566
+ const relativePath = relative(root, destination);
567
+ const isOutsideProject = relativePath.length === 0 || relativePath.startsWith(`..${sep}`) ||
568
+ isAbsolute(relativePath);
569
+
570
+ if (isOutsideProject) {
571
+ fail(`Scaffold destination is outside the project: ${destination}`);
572
+ }
573
+
574
+ return relativePath;
575
+ };
576
+
577
+ const assertRootIsNotSymbolicLink = (root: string): void => {
578
+ const rootEntry = lstatSync(root, { throwIfNoEntry: false });
579
+
580
+ if (rootEntry?.isSymbolicLink()) {
581
+ fail(`Cannot scaffold through a symbolic link: ${root}`);
582
+ }
583
+ };
584
+
585
+ const assertSafeAncestor = (ancestor: string): void => {
586
+ const entry = lstatSync(ancestor, { throwIfNoEntry: false });
587
+
588
+ if (entry?.isSymbolicLink()) {
589
+ fail(`Cannot scaffold through a symbolic link: ${ancestor}`);
590
+ }
591
+
592
+ if (entry !== undefined && !entry.isDirectory()) {
593
+ fail(`Cannot scaffold through a non-directory path: ${ancestor}`);
594
+ }
595
+ };
596
+
597
+ const assertSafeAncestors = (root: string, relativePath: string): void => {
598
+ let ancestor = root;
599
+ const segments = relativePath.split(sep);
600
+ const ancestorSegments = segments.slice(0, -1);
601
+
602
+ for (const segment of ancestorSegments) {
603
+ ancestor = join(ancestor, segment);
604
+ assertSafeAncestor(ancestor);
605
+ }
606
+ };
607
+
608
+ const assertReplaceableDestination = (destination: string): void => {
609
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
610
+ const isReplaceable = entry === undefined || entry.isFile() || entry.isSymbolicLink();
611
+
612
+ if (!isReplaceable) {
613
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
614
+ }
615
+ };
616
+
617
+ const assertSafeDestination = (root: string, destination: string): void => {
618
+ const relativePath = projectRelativeDestination(root, destination);
619
+ assertRootIsNotSymbolicLink(root);
620
+ assertSafeAncestors(root, relativePath);
621
+ assertReplaceableDestination(destination);
622
+ };
623
+
624
+ const plannedScaffoldFiles = async (root: string, resolved: ResolvedOptions): Promise<ScaffoldFile[]> => {
503
625
  const context = templateContext(resolved);
626
+ const files: ScaffoldFile[] = [];
504
627
 
505
628
  for (const template of listTemplates()) {
506
- if (!isTemplateIncluded(template, resolved)) {
507
- continue;
629
+ if (isTemplateIncluded(template, resolved)) {
630
+ files.push({
631
+ destination: join(root, destinationPath(template, resolved)),
632
+ contents: await renderFile(template, context),
633
+ });
508
634
  }
635
+ }
636
+
637
+ return files;
638
+ };
639
+
640
+ const auxiliaryDestinations = (root: string, resolved: ResolvedOptions): string[] =>
641
+ resolved.packageManager === "pnpm" ? [join(root, "pnpm-workspace.yaml")] : [];
509
642
 
510
- const destination = join(root, toDestinationPath(template, isTypescript, applicationId));
511
- mkdirSync(dirname(destination), { recursive: true });
512
- writeFileSync(destination, await renderFile(template, context));
643
+ const envModulePath = (root: string): string => join(root, "node_modules", ".gtkx", "env.d.ts");
644
+
645
+ const shouldWriteEnvModule = (resolved: ResolvedOptions): boolean =>
646
+ resolved.isTypescript && resolved.shouldInstallDependencies;
647
+
648
+ const generatedDestinations = (root: string, resolved: ResolvedOptions): string[] =>
649
+ shouldWriteEnvModule(resolved) ? [envModulePath(root)] : [];
650
+
651
+ const isPresent = (path: string): boolean => lstatSync(path, { throwIfNoEntry: false }) !== undefined;
652
+
653
+ const classifyScaffoldChange = (
654
+ root: string,
655
+ destination: string,
656
+ ): ScaffoldChange | undefined => {
657
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
658
+
659
+ if (entry === undefined) {
660
+ return undefined;
513
661
  }
514
662
 
515
- if (shouldIncludeTesting) {
516
- addTestScript(root);
663
+ return { destination, relativePath: projectRelativeDestination(root, destination) };
664
+ };
665
+
666
+ const addScaffoldChange = (plan: ScaffoldPlan, change: ScaffoldChange | undefined): void => {
667
+ if (change === undefined) {
668
+ return;
669
+ }
670
+
671
+ plan.replaceDestinations.push(change.destination);
672
+ plan.changes.push(change.relativePath);
673
+ };
674
+
675
+ const planScaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<ScaffoldPlan> => {
676
+ const files = await plannedScaffoldFiles(root, resolved);
677
+ const reported = [...files.map((file) => file.destination), ...auxiliaryDestinations(root, resolved)];
678
+ const generated = generatedDestinations(root, resolved);
679
+
680
+ for (const destination of [...reported, ...generated]) {
681
+ assertSafeDestination(root, destination);
682
+ }
683
+
684
+ const plan: ScaffoldPlan = {
685
+ files,
686
+ replaceDestinations: generated.filter((destination) => isPresent(destination)),
687
+ changes: [],
688
+ };
689
+
690
+ for (const destination of reported) {
691
+ addScaffoldChange(plan, classifyScaffoldChange(root, destination));
692
+ }
693
+
694
+ return plan;
695
+ };
696
+
697
+ const scaffoldProject = (root: string, resolved: ResolvedOptions, plan: ScaffoldPlan): void => {
698
+ for (const destination of plan.replaceDestinations) {
699
+ replaceTemplateDestination(destination);
700
+ }
701
+
702
+ for (const file of plan.files) {
703
+ mkdirSync(dirname(file.destination), { recursive: true });
704
+ writeFileSync(file.destination, file.contents);
705
+ }
706
+
707
+ if (resolved.shouldIncludeTesting) {
708
+ updateManifest(root, (manifest) => {
709
+ (manifest.scripts as Record<string, string>).test = "vitest run";
710
+ });
517
711
  }
518
712
  };
519
713
 
@@ -543,10 +737,34 @@ const installDependencies = async (options: InstallDependenciesOptions): Promise
543
737
  });
544
738
  };
545
739
 
546
- const pin = (names: string[]): string[] => names.map((dependency) => pinGtkxDependency(dependency, selfVersion));
740
+ const pin = (names: string[]): string[] =>
741
+ names.map((dependency) => {
742
+ if (dependency.startsWith("@gtkx/")) {
743
+ return `${dependency}@^${selfVersion}`;
744
+ }
745
+
746
+ return dependency === "typescript" ? `${dependency}@${TYPESCRIPT_VERSION}` : dependency;
747
+ });
748
+
749
+ const dependencyVersions = (names: string[]): Record<string, string> => Object.fromEntries(
750
+ names.map((dependency) => {
751
+ if (dependency.startsWith("@gtkx/")) {
752
+ return [dependency, `^${selfVersion}`];
753
+ }
754
+
755
+ return [dependency, dependency === "typescript" ? TYPESCRIPT_VERSION : "latest"];
756
+ }),
757
+ );
758
+
759
+ const writeDependencies = (root: string, devDependencies: string[]): void => {
760
+ updateManifest(root, (manifest) => {
761
+ manifest.dependencies = dependencyVersions(DEPENDENCIES);
762
+ manifest.devDependencies = dependencyVersions(devDependencies);
763
+ });
764
+ };
547
765
 
548
766
  const formatRecovery = ({ resolved, devDependencies }: InstallFailureOptions): string => {
549
- const add = getAddCommand(resolved.packageManager);
767
+ const add = ADD_COMMAND[resolved.packageManager];
550
768
 
551
769
  const steps = [
552
770
  ...(resolved.isCurrentDirectory ? [] : [`cd ${resolved.target}`]),
@@ -603,12 +821,32 @@ const installAllDependencies = async (options: InstallAllOptions): Promise<void>
603
821
  };
604
822
 
605
823
  const writeInitialEnvModule = (root: string): void => {
606
- const storeDir = join(root, "node_modules", ".gtkx");
607
- mkdirSync(storeDir, { recursive: true });
608
- writeFileSync(join(storeDir, "env.d.ts"), `${GTKX_ENV_MODULE_HEADER}\n`);
824
+ const path = envModulePath(root);
825
+ mkdirSync(dirname(path), { recursive: true });
826
+ writeFileSync(path, `${GTKX_ENV_MODULE_HEADER}\n`);
827
+ };
828
+
829
+ const isInsideGitRepository = (root: string): boolean => {
830
+ const git = tryResolveExecutable("git");
831
+
832
+ if (git === undefined) {
833
+ return false;
834
+ }
835
+
836
+ try {
837
+ execFileSync(git, ["-C", root, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
838
+
839
+ return true;
840
+ } catch {
841
+ return false;
842
+ }
609
843
  };
610
844
 
611
- const initializeGitRepo = async (root: string): Promise<void> => {
845
+ const initializeGitRepo = async (root: string, shouldInitialize: boolean): Promise<void> => {
846
+ if (!shouldInitialize || isInsideGitRepository(root)) {
847
+ return;
848
+ }
849
+
612
850
  const spinner = p.spinner();
613
851
  spinner.start("Initializing git repository...");
614
852
 
@@ -624,21 +862,30 @@ const initializeGitRepo = async (root: string): Promise<void> => {
624
862
  };
625
863
 
626
864
  const printNextSteps = (resolved: ResolvedOptions): void => {
627
- const devCmd = getDevCommand(resolved.packageManager);
865
+ const devCmd = DEV_COMMAND[resolved.packageManager];
628
866
  const cdStep = resolved.isCurrentDirectory ? "" : `cd ${resolved.target}\n`;
867
+ const installStep = resolved.shouldInstallDependencies ? "" : `${INSTALL_COMMAND[resolved.packageManager]}\n`;
629
868
  const testingNote = resolved.shouldIncludeTesting ? HEADLESS_COMPOSITOR_NOTE : "";
630
- p.note(`${cdStep}${devCmd}${testingNote}`, "Next steps");
869
+ p.note(`${cdStep}${installStep}${devCmd}${testingNote}`, "Next steps");
631
870
  };
632
871
 
633
- const createProjectStructure = async (root: string, resolved: ResolvedOptions): Promise<void> => {
872
+ const createProjectStructure = async (
873
+ root: string,
874
+ resolved: ResolvedOptions,
875
+ plan: ScaffoldPlan,
876
+ ): Promise<void> => {
634
877
  await runWithSpinner({
635
878
  pending: "Creating project structure...",
636
879
  done: "Project structure created",
637
880
  failed: "Failed to create the project structure",
638
- run: async () => {
639
- prepareTargetDirectory(root, resolved.shouldEmptyTarget);
640
- await scaffoldProject(root, resolved);
881
+ run: () => {
882
+ mkdirSync(root, { recursive: true });
883
+ scaffoldProject(root, resolved, plan);
641
884
  writeBuildAllowance(root, resolved.packageManager);
885
+
886
+ if (shouldWriteEnvModule(resolved)) {
887
+ writeInitialEnvModule(root);
888
+ }
642
889
  },
643
890
  });
644
891
  };
@@ -648,14 +895,19 @@ const scaffold = async (options: CreateOptions = {}): Promise<void> => {
648
895
  const resolved = await resolveOptions(options);
649
896
  const { root } = resolved;
650
897
  const devDeps = getDevDependencies(resolved);
651
- await createProjectStructure(root, resolved);
652
- await installAllDependencies({ resolved, devDependencies: devDeps });
898
+ assertTargetIsDirectory(root, resolved.target);
899
+ const plan = await planScaffoldProject(root, resolved);
900
+ await validateTargetAvailability(root, resolved.target, options, plan.changes);
901
+ await createProjectStructure(root, resolved, plan);
902
+ reportCompletedChanges(plan.changes);
653
903
 
654
- if (resolved.isTypescript) {
655
- writeInitialEnvModule(root);
904
+ if (resolved.shouldInstallDependencies) {
905
+ await installAllDependencies({ resolved, devDependencies: devDeps });
906
+ } else {
907
+ writeDependencies(root, devDeps);
656
908
  }
657
909
 
658
- await initializeGitRepo(root);
910
+ await initializeGitRepo(root, resolved.shouldInitializeGit);
659
911
  printNextSteps(resolved);
660
912
  };
661
913