create-gtkx 2.0.0-beta.1 → 2.0.0-beta.11

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.
Files changed (43) hide show
  1. package/README.md +8 -163
  2. package/dist/build-allowance.d.ts.map +1 -1
  3. package/dist/build-allowance.js +6 -5
  4. package/dist/build-allowance.js.map +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/command.d.ts +12 -3
  8. package/dist/command.d.ts.map +1 -1
  9. package/dist/command.js +111 -45
  10. package/dist/command.js.map +1 -1
  11. package/dist/manifest.d.ts.map +1 -1
  12. package/dist/manifest.js +2 -4
  13. package/dist/manifest.js.map +1 -1
  14. package/dist/package-managers.d.ts +3 -3
  15. package/dist/package-managers.d.ts.map +1 -1
  16. package/dist/package-managers.js +21 -3
  17. package/dist/package-managers.js.map +1 -1
  18. package/dist/scaffolder.d.ts +2 -0
  19. package/dist/scaffolder.d.ts.map +1 -1
  20. package/dist/scaffolder.js +200 -112
  21. package/dist/scaffolder.js.map +1 -1
  22. package/dist/templates/gtkx.config.ts.ejs +3 -4
  23. package/dist/templates/package.json.ejs +4 -1
  24. package/dist/templates/src/app.tsx.ejs +27 -24
  25. package/dist/templates.d.ts +1 -1
  26. package/dist/templates.d.ts.map +1 -1
  27. package/dist/templates.js.map +1 -1
  28. package/package.json +26 -12
  29. package/src/build-allowance.ts +6 -7
  30. package/src/cli.ts +1 -1
  31. package/src/command.ts +140 -57
  32. package/src/manifest.ts +2 -4
  33. package/src/package-managers.ts +21 -3
  34. package/src/scaffolder.ts +282 -153
  35. package/src/templates/gtkx.config.ts.ejs +3 -4
  36. package/src/templates/package.json.ejs +4 -1
  37. package/src/templates/src/app.tsx.ejs +27 -24
  38. package/src/templates.ts +1 -1
  39. package/dist/install-hints.d.ts +0 -3
  40. package/dist/install-hints.d.ts.map +0 -1
  41. package/dist/install-hints.js +0 -35
  42. package/dist/install-hints.js.map +0 -1
  43. package/src/install-hints.ts +0 -54
package/src/scaffolder.ts CHANGED
@@ -2,14 +2,13 @@ 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, 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" };
10
10
  import { writeBuildAllowance } from "./build-allowance.js";
11
11
  import { OperationCanceledError, ScaffoldAbortedError } from "./errors.js";
12
- import { getInstallHint } from "./install-hints.js";
13
12
  import { updateManifest } from "./manifest.js";
14
13
  import {
15
14
  isKnownPackageManager,
@@ -23,57 +22,49 @@ import { listTemplates, renderFile, type TemplateContext } from "./templates.js"
23
22
  type CreateOptions = {
24
23
  name?: string | undefined;
25
24
  applicationId?: string | undefined;
25
+ displayName?: string | undefined;
26
26
  packageManager?: string | undefined;
27
27
  isTypescript?: boolean | undefined;
28
28
  shouldIncludeTesting?: boolean | undefined;
29
29
  isInteractive?: boolean | undefined;
30
30
  shouldOverwrite?: boolean | undefined;
31
+ shouldInstallDependencies?: boolean | undefined;
31
32
  };
32
33
 
33
34
  type ResolvedOptions = {
34
35
  target: string;
35
36
  root: string;
36
- isCurrentDirectory: boolean;
37
37
  name: string;
38
+ displayName: string;
38
39
  applicationId: string;
39
40
  packageManager: PackageManager;
40
41
  isTypescript: boolean;
41
42
  shouldIncludeTesting: boolean;
42
- shouldEmptyTarget: boolean;
43
+ shouldInitializeGit: boolean;
44
+ shouldInstallDependencies: boolean;
43
45
  };
44
46
 
45
- type InstallDependenciesOptions = {
46
- cwd: string;
47
- packageManager: PackageManager;
48
- dependencies: string[];
49
- isDev: boolean;
50
- };
47
+ type ScaffoldFile = { destination: string; contents: string };
51
48
 
52
- type InstallAllOptions = {
53
- resolved: ResolvedOptions;
54
- devDependencies: string[];
55
- };
49
+ type ScaffoldChanges = string[];
56
50
 
57
- type InstallFailureOptions = {
58
- error: unknown;
59
- resolved: ResolvedOptions;
60
- devDependencies: string[];
51
+ type ScaffoldPlan = {
52
+ files: ScaffoldFile[];
53
+ replaceDestinations: string[];
54
+ changes: ScaffoldChanges;
61
55
  };
62
56
 
63
57
  type SpinnerStep = {
64
58
  pending: string;
65
59
  done: string;
66
60
  failed: string;
67
- run: () => Promise<void>;
68
- explain?: ((error: unknown) => void) | undefined;
61
+ run: () => Promise<void> | void;
62
+ explain?: (() => void) | undefined;
69
63
  };
70
64
 
71
65
  const selfVersion = packageManifest.version;
72
66
  const ICON_TEMPLATE_NAME = "icon.svg";
73
-
74
- const GTKX_ENV_MODULE_HEADER = `/**
75
- * Generated by \`gtkx codegen\`, \`gtkx dev\`, and \`gtkx build\`; do not edit.
76
- */`;
67
+ const TYPESCRIPT_VERSION = "^6.0.3";
77
68
 
78
69
  const DEPENDENCIES = ["@gtkx/cairo", "@gtkx/css", "@gtkx/runtime", "@gtkx/react", "react"];
79
70
  const DEV_DEPENDENCIES = ["@gtkx/cli", "@gtkx/config", "@gtkx/mcp", "vite"];
@@ -84,8 +75,8 @@ const DEV_COMMAND: Record<PackageManager, string> = Object.fromEntries(
84
75
  PACKAGE_MANAGERS.map((manager) => [manager.value, manager.devCommand]),
85
76
  ) as Record<PackageManager, string>;
86
77
 
87
- const ADD_COMMAND: Record<PackageManager, string> = Object.fromEntries(
88
- PACKAGE_MANAGERS.map((manager) => [manager.value, manager.addCommand]),
78
+ const INSTALL_COMMAND: Record<PackageManager, string> = Object.fromEntries(
79
+ PACKAGE_MANAGERS.map((manager) => [manager.value, manager.installCommand]),
89
80
  ) as Record<PackageManager, string>;
90
81
 
91
82
  const INHERITED_SCRIPT_POLICY_VARS = ["npm_config_allow_scripts", "npm_config_allow-scripts"];
@@ -98,7 +89,6 @@ To run tests, you need a headless Wayland compositor installed:
98
89
  Fedora: sudo dnf install sway
99
90
  Ubuntu: sudo apt install sway`;
100
91
 
101
- const RECOVERY_HEADING = "Finish the setup by adding the dependencies again, which is safe to repeat:";
102
92
  const APPLICATION_ID_FORMAT_ERROR = "Application ID must be reverse domain notation (e.g., com.example.myapp)";
103
93
  const APPLICATION_ID_PREFIX = "com.";
104
94
  const APPLICATION_ID_SUFFIX = ".app";
@@ -106,7 +96,8 @@ const APPLICATION_ID_SUFFIX = ".app";
106
96
  const APPLICATION_ID_SEGMENT_LIMIT = APPLICATION_ID_MAX_LENGTH - APPLICATION_ID_PREFIX.length -
107
97
  APPLICATION_ID_SUFFIX.length;
108
98
 
109
- const titleFromName = (name: string): string => name.split("-").map((part) => upperFirst(part)).join(" ");
99
+ const displayNameFromProjectName = (name: string): string =>
100
+ name.split("-").map((part) => upperFirst(part)).join(" ");
110
101
 
111
102
  const gitConfigValue = (key: string): string | null => {
112
103
  const git = tryResolveExecutable("git");
@@ -132,19 +123,6 @@ const applicationIdSegment = (name: string): string => {
132
123
  const suggestApplicationId = (name: string): string =>
133
124
  `${APPLICATION_ID_PREFIX}${applicationIdSegment(name)}${APPLICATION_ID_SUFFIX}`;
134
125
 
135
- const stripTrailingSlashes = (value: string): string => {
136
- let end = value.length;
137
-
138
- while (end > 0 && value[end - 1] === "/") {
139
- end--;
140
- }
141
-
142
- return value.slice(0, end);
143
- };
144
-
145
- const formatTargetDir = (target: string): string =>
146
- stripTrailingSlashes(target.trim().replaceAll(/[<>:"\\|?*]/g, ""));
147
-
148
126
  const deriveProjectName = (target: string): string => basename(resolve(process.cwd(), target));
149
127
 
150
128
  const isDirEmpty = (dir: string): boolean => {
@@ -153,6 +131,12 @@ const isDirEmpty = (dir: string): boolean => {
153
131
  return entries.length === 0 || (entries.length === 1 && entries[0] === ".git");
154
132
  };
155
133
 
134
+ const shouldInitializeGit = (root: string): boolean => {
135
+ const entry = lstatSync(root, { throwIfNoEntry: false });
136
+
137
+ return entry === undefined || (entry.isDirectory() && readdirSync(root).length === 0);
138
+ };
139
+
156
140
  const getDevDependencies = ({
157
141
  isTypescript,
158
142
  shouldIncludeTesting,
@@ -170,8 +154,10 @@ const getDevDependencies = ({
170
154
  return devDeps;
171
155
  };
172
156
 
173
- const guardCancellation = <T>(value: T | symbol): T => {
174
- if (p.isCancel(value)) {
157
+ const isCancellation = (value: unknown): value is typeof p.CANCEL_SYMBOL => p.isCancel(value);
158
+
159
+ const guardCancellation = <T>(value: T | typeof p.CANCEL_SYMBOL): T => {
160
+ if (isCancellation(value)) {
175
161
  p.cancel("Operation canceled");
176
162
  throw new OperationCanceledError();
177
163
  }
@@ -200,6 +186,17 @@ const validateApplicationIdInput = (value: string | undefined): string | undefin
200
186
  const validateApplicationIdAnswer = (value: string | undefined): string | undefined =>
201
187
  value ? validateApplicationIdFormat(value) : undefined;
202
188
 
189
+ const validateDisplayName = (value: string | undefined): string | undefined => {
190
+ if (value === undefined || value.trim().length === 0) {
191
+ return "Display name is required";
192
+ }
193
+
194
+ return value.includes("\n") || value.includes("\r") ? "Display name must be a single line" : undefined;
195
+ };
196
+
197
+ const validateDisplayNameAnswer = (value: string | undefined): string | undefined =>
198
+ value ? validateDisplayName(value) : undefined;
199
+
203
200
  const fail = (message: string): never => {
204
201
  p.log.error(message);
205
202
  throw new ScaffoldAbortedError(message);
@@ -226,17 +223,15 @@ const validateTargetDir = (target: string): string | undefined => {
226
223
  };
227
224
 
228
225
  const validateTargetAnswer = (value: string | undefined): string | undefined =>
229
- validateTargetDir(formatTargetDir(value ?? ""));
226
+ validateTargetDir(value ?? "");
230
227
 
231
228
  const promptTarget = async (): Promise<string> =>
232
- formatTargetDir(
233
- guardCancellation(
234
- await p.text({
235
- message: "Project directory",
236
- placeholder: "my-app",
237
- validate: validateTargetAnswer,
238
- }),
239
- ),
229
+ guardCancellation(
230
+ await p.text({
231
+ message: "Project directory",
232
+ placeholder: "my-app",
233
+ validate: validateTargetAnswer,
234
+ }),
240
235
  );
241
236
 
242
237
  const promptApplicationId = async (name: string): Promise<string> => {
@@ -252,6 +247,19 @@ const promptApplicationId = async (name: string): Promise<string> => {
252
247
  );
253
248
  };
254
249
 
250
+ const promptDisplayName = async (name: string): Promise<string> => {
251
+ const defaultDisplayName = displayNameFromProjectName(name);
252
+
253
+ return guardCancellation(
254
+ await p.text({
255
+ message: "Display name",
256
+ placeholder: defaultDisplayName,
257
+ defaultValue: defaultDisplayName,
258
+ validate: validateDisplayNameAnswer,
259
+ }),
260
+ );
261
+ };
262
+
255
263
  const packageManagerOption = (manager: (typeof PACKAGE_MANAGERS)[number], detected: PackageManager | undefined) => {
256
264
  let hint: string | undefined;
257
265
 
@@ -292,54 +300,96 @@ const isOptionEnabled = async (
292
300
  isInteractive: boolean | undefined,
293
301
  message: string,
294
302
  ): Promise<boolean> =>
295
- value ??
296
- (isInteractive
297
- ? guardCancellation(await p.confirm({ message, initialValue: true }))
298
- : true);
299
-
300
- const emptyDir = (root: string): void => {
301
- for (const entry of readdirSync(root)) {
302
- if (entry === ".git") {
303
- continue;
304
- }
303
+ value ?? (!isInteractive || guardCancellation(await p.confirm({ message, initialValue: true })));
305
304
 
306
- rmSync(join(root, entry), { recursive: true, force: true });
305
+ const formatFileList = (heading: string, files: string[]): string => {
306
+ const indentedFiles = files.map((file) => ` ${file}`).join("\n");
307
+
308
+ return `${heading}:\n${indentedFiles}`;
309
+ };
310
+
311
+ const reportPlannedChanges = (changes: ScaffoldChanges): void => {
312
+ if (changes.length > 0) {
313
+ p.log.info(formatFileList("Scaffold files to replace", changes));
307
314
  }
308
315
  };
309
316
 
310
- const shouldOverwriteDirectory = async (target: string, options: CreateOptions): Promise<boolean> => {
311
- if (!options.isInteractive) {
312
- return options.shouldOverwrite === true;
317
+ const reportCompletedChanges = (changes: ScaffoldChanges): void => {
318
+ if (changes.length > 0) {
319
+ p.log.success(formatFileList("Replaced scaffold files", changes));
313
320
  }
321
+ };
314
322
 
315
- return guardCancellation(
323
+ const confirmOverwrite = async (target: string, changes: ScaffoldChanges): Promise<void> => {
324
+ reportPlannedChanges(changes);
325
+
326
+ const shouldReplace = guardCancellation(
316
327
  await p.confirm({
317
- message: `Directory "${target}" is not empty. Overwrite its contents?`,
328
+ message: `Directory "${target}" is not empty. Replace scaffold files?`,
318
329
  initialValue: false,
319
330
  }),
320
331
  );
332
+
333
+ if (!shouldReplace) {
334
+ fail(`Directory "${target}" is not empty`);
335
+ }
321
336
  };
322
337
 
323
- const shouldEmptyTargetDirectory = async (root: string, target: string, options: CreateOptions): Promise<boolean> => {
324
- const stats = statSync(root, { throwIfNoEntry: false });
338
+ const refuseNonEmptyDirectory = (target: string, changes: ScaffoldChanges): never => {
339
+ const heading = `Directory "${target}" is not empty. Pass --overwrite to`;
325
340
 
326
- if (stats === undefined) {
327
- return false;
341
+ return fail(
342
+ changes.length === 0
343
+ ? `${heading} scaffold into it`
344
+ : formatFileList(`${heading} replace these scaffold files`, changes),
345
+ );
346
+ };
347
+
348
+ const acceptNonEmptyDirectory = async (
349
+ target: string,
350
+ options: CreateOptions,
351
+ changes: ScaffoldChanges,
352
+ ): Promise<void> => {
353
+ if (options.isInteractive) {
354
+ await confirmOverwrite(target, changes);
355
+
356
+ return;
328
357
  }
329
358
 
330
- if (!stats.isDirectory()) {
331
- return fail(`Target "${target}" is not a directory`);
359
+ if (options.shouldOverwrite === true) {
360
+ reportPlannedChanges(changes);
361
+
362
+ return;
332
363
  }
333
364
 
334
- if (isDirEmpty(root)) {
335
- return false;
365
+ refuseNonEmptyDirectory(target, changes);
366
+ };
367
+
368
+ const assertTargetIsDirectory = (root: string, target: string): void => {
369
+ const stats = lstatSync(root, { throwIfNoEntry: false });
370
+
371
+ if (stats?.isSymbolicLink()) {
372
+ fail(`Cannot scaffold through a symbolic link: ${root}`);
336
373
  }
337
374
 
338
- if (await shouldOverwriteDirectory(target, options)) {
339
- return true;
375
+ if (stats !== undefined && !stats.isDirectory()) {
376
+ fail(`Target "${target}" is not a directory`);
340
377
  }
378
+ };
379
+
380
+ const validateTargetAvailability = async (
381
+ root: string,
382
+ target: string,
383
+ options: CreateOptions,
384
+ changes: ScaffoldChanges,
385
+ ): Promise<void> => {
386
+ const stats = statSync(root, { throwIfNoEntry: false });
341
387
 
342
- return fail(`Directory "${target}" is not empty`);
388
+ if (stats === undefined || isDirEmpty(root)) {
389
+ return;
390
+ }
391
+
392
+ await acceptNonEmptyDirectory(target, options, changes);
343
393
  };
344
394
 
345
395
  const resolveTarget = async (options: CreateOptions): Promise<string> => {
@@ -347,7 +397,7 @@ const resolveTarget = async (options: CreateOptions): Promise<string> => {
347
397
  return promptTarget();
348
398
  }
349
399
 
350
- const target = formatTargetDir(options.name ?? "");
400
+ const target = options.name ?? "";
351
401
  const error = validateTargetDir(target);
352
402
 
353
403
  return error === undefined ? target : fail(error);
@@ -362,6 +412,14 @@ const resolveApplicationId = async (options: CreateOptions, name: string): Promi
362
412
  return error === undefined ? applicationId : fail(error);
363
413
  };
364
414
 
415
+ const resolveDisplayName = async (options: CreateOptions, name: string): Promise<string> => {
416
+ const value = options.displayName ??
417
+ (options.isInteractive ? await promptDisplayName(name) : displayNameFromProjectName(name));
418
+ const error = validateDisplayName(value);
419
+
420
+ return error === undefined ? value.trim() : fail(error);
421
+ };
422
+
365
423
  const resolvePackageManager = async (
366
424
  requested: PackageManager | undefined,
367
425
  options: CreateOptions,
@@ -381,10 +439,10 @@ const resolveOptions = async (options: CreateOptions): Promise<ResolvedOptions>
381
439
  const requested = requestedPackageManager(options.packageManager);
382
440
  const target = await resolveTarget(options);
383
441
  const name = deriveProjectName(target);
442
+ const displayName = await resolveDisplayName(options, name);
384
443
  const applicationId = await resolveApplicationId(options, name);
385
444
  const root = resolve(process.cwd(), target);
386
- const isCurrentDirectory = root === process.cwd();
387
- const shouldEmptyTarget = await shouldEmptyTargetDirectory(root, target, options);
445
+ const shouldInitialize = shouldInitializeGit(root);
388
446
  const packageManager = await resolvePackageManager(requested, options);
389
447
  const isTypescript = await isOptionEnabled(options.isTypescript, options.isInteractive, "Use TypeScript?");
390
448
 
@@ -397,13 +455,14 @@ const resolveOptions = async (options: CreateOptions): Promise<ResolvedOptions>
397
455
  return {
398
456
  target,
399
457
  root,
400
- isCurrentDirectory,
401
458
  name,
459
+ displayName,
402
460
  applicationId,
403
461
  packageManager,
404
462
  isTypescript,
405
463
  shouldIncludeTesting,
406
- shouldEmptyTarget,
464
+ shouldInitializeGit: shouldInitialize,
465
+ shouldInstallDependencies: options.shouldInstallDependencies !== false,
407
466
  };
408
467
  };
409
468
 
@@ -430,31 +489,93 @@ const destinationPath = (template: string, resolved: ResolvedOptions): string =>
430
489
  const templateContext = (resolved: ResolvedOptions): TemplateContext => ({
431
490
  name: resolved.name,
432
491
  applicationId: resolved.applicationId,
433
- title: titleFromName(resolved.name),
492
+ displayName: resolved.displayName,
434
493
  shouldIncludeTesting: resolved.shouldIncludeTesting,
435
494
  isTypescript: resolved.isTypescript,
436
495
  importExtension: resolved.isTypescript ? ".js" : ".jsx",
437
- developerName: gitConfigValue("user.name") ?? titleFromName(resolved.name),
496
+ developerName: gitConfigValue("user.name") ?? resolved.displayName,
438
497
  developerEmail: gitConfigValue("user.email"),
439
498
  });
440
499
 
441
- const scaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<void> => {
500
+ const assertSafeAncestor = (ancestor: string): void => {
501
+ const entry = lstatSync(ancestor, { throwIfNoEntry: false });
502
+
503
+ if (entry?.isSymbolicLink()) {
504
+ fail(`Cannot scaffold through a symbolic link: ${ancestor}`);
505
+ }
506
+
507
+ if (entry !== undefined && !entry.isDirectory()) {
508
+ fail(`Cannot scaffold through a non-directory path: ${ancestor}`);
509
+ }
510
+ };
511
+
512
+ const assertSafeAncestors = (root: string, relativePath: string): void => {
513
+ let ancestor = root;
514
+ const segments = relativePath.split(sep);
515
+ const ancestorSegments = segments.slice(0, -1);
516
+
517
+ for (const segment of ancestorSegments) {
518
+ ancestor = join(ancestor, segment);
519
+ assertSafeAncestor(ancestor);
520
+ }
521
+ };
522
+
523
+ const hasReplaceableDestination = (destination: string): boolean => {
524
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
525
+ const isReplaceable = entry === undefined || entry.isFile() || entry.isSymbolicLink();
526
+
527
+ if (!isReplaceable) {
528
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
529
+ }
530
+
531
+ return entry !== undefined;
532
+ };
533
+
534
+ const hasExistingDestination = (root: string, destination: string): boolean => {
535
+ assertSafeAncestors(root, relative(root, destination));
536
+
537
+ return hasReplaceableDestination(destination);
538
+ };
539
+
540
+ const plannedScaffoldFiles = async (root: string, resolved: ResolvedOptions): Promise<ScaffoldFile[]> => {
442
541
  const context = templateContext(resolved);
542
+ const files: ScaffoldFile[] = [];
443
543
 
444
544
  for (const template of listTemplates()) {
445
- if (!isTemplateIncluded(template, resolved)) {
446
- continue;
545
+ if (isTemplateIncluded(template, resolved)) {
546
+ files.push({
547
+ destination: join(root, destinationPath(template, resolved)),
548
+ contents: await renderFile(template, context),
549
+ });
447
550
  }
551
+ }
552
+
553
+ return files;
554
+ };
555
+
556
+ const auxiliaryDestinations = (root: string, resolved: ResolvedOptions): string[] =>
557
+ resolved.packageManager === "pnpm" ? [join(root, "pnpm-workspace.yaml")] : [];
558
+
559
+ const planScaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<ScaffoldPlan> => {
560
+ const files = await plannedScaffoldFiles(root, resolved);
561
+ const destinations = [...files.map((file) => file.destination), ...auxiliaryDestinations(root, resolved)];
562
+ const replaceDestinations = destinations.filter((destination) => hasExistingDestination(root, destination));
563
+
564
+ return {
565
+ files,
566
+ replaceDestinations,
567
+ changes: replaceDestinations.map((destination) => relative(root, destination)),
568
+ };
569
+ };
448
570
 
449
- const destination = join(root, destinationPath(template, resolved));
450
- mkdirSync(dirname(destination), { recursive: true });
451
- writeFileSync(destination, await renderFile(template, context));
571
+ const scaffoldProject = (plan: ScaffoldPlan): void => {
572
+ for (const destination of plan.replaceDestinations) {
573
+ rmSync(destination);
452
574
  }
453
575
 
454
- if (resolved.shouldIncludeTesting) {
455
- updateManifest(root, (manifest) => {
456
- (manifest.scripts as Record<string, string>).test = "vitest run";
457
- });
576
+ for (const file of plan.files) {
577
+ mkdirSync(dirname(file.destination), { recursive: true });
578
+ writeFileSync(file.destination, file.contents);
458
579
  }
459
580
  };
460
581
 
@@ -476,40 +597,29 @@ const withoutInheritedScriptPolicy = async (run: () => Promise<void>): Promise<v
476
597
  }
477
598
  };
478
599
 
479
- const installDependencies = async (options: InstallDependenciesOptions): Promise<void> => {
480
- const { cwd, packageManager, dependencies, isDev } = options;
481
-
482
- await withoutInheritedScriptPolicy(async () => {
483
- await addDependency(dependencies, { cwd, packageManager, dev: isDev, silent: true });
484
- });
485
- };
486
-
487
- const pin = (names: string[]): string[] =>
488
- names.map((dependency) => dependency.startsWith("@gtkx/") ? `${dependency}@^${selfVersion}` : dependency);
489
-
490
- const formatRecovery = ({ resolved, devDependencies }: InstallFailureOptions): string => {
491
- const add = ADD_COMMAND[resolved.packageManager];
600
+ const dependencyVersions = (names: string[]): Record<string, string> => Object.fromEntries(
601
+ names.map((dependency) => {
602
+ if (dependency.startsWith("@gtkx/")) {
603
+ return [dependency, `^${selfVersion}`];
604
+ }
492
605
 
493
- const steps = [
494
- ...(resolved.isCurrentDirectory ? [] : [`cd ${resolved.target}`]),
495
- `${add} ${pin(DEPENDENCIES).join(" ")}`,
496
- `${add} -D ${pin(devDependencies).join(" ")}`,
497
- ];
606
+ return [dependency, dependency === "typescript" ? TYPESCRIPT_VERSION : "latest"];
607
+ }),
608
+ );
498
609
 
499
- const indented = steps.map((step) => ` ${step}`).join("\n");
610
+ const dependencyArguments = (names: string[]): string[] =>
611
+ Object.entries(dependencyVersions(names)).map(([name, version]) =>
612
+ version === "latest" ? name : `${name}@${version}`);
500
613
 
501
- return `${RECOVERY_HEADING}\n${indented}`;
614
+ const writeDependencies = (root: string, devDependencies: string[]): void => {
615
+ updateManifest(root, (manifest) => {
616
+ manifest.dependencies = dependencyVersions(DEPENDENCIES);
617
+ manifest.devDependencies = dependencyVersions(devDependencies);
618
+ });
502
619
  };
503
620
 
504
- const explainInstallFailure = (options: InstallFailureOptions): void => {
505
- const hint = getInstallHint(errorMessage(options.error));
506
-
507
- if (hint !== undefined) {
508
- p.log.warn(hint);
509
- }
510
-
511
- p.log.info(formatRecovery(options));
512
- };
621
+ const formatRecovery = (resolved: ResolvedOptions): string =>
622
+ `To finish setup, run ${INSTALL_COMMAND[resolved.packageManager]} in ${resolved.root}.`;
513
623
 
514
624
  const runWithSpinner = async (step: SpinnerStep): Promise<void> => {
515
625
  const spinner = p.spinner();
@@ -521,13 +631,12 @@ const runWithSpinner = async (step: SpinnerStep): Promise<void> => {
521
631
  } catch (error) {
522
632
  spinner.error(step.failed);
523
633
  p.log.error(errorMessage(error));
524
- step.explain?.(error);
634
+ step.explain?.();
525
635
  throw new ScaffoldAbortedError(step.failed);
526
636
  }
527
637
  };
528
638
 
529
- const installAllDependencies = async (options: InstallAllOptions): Promise<void> => {
530
- const { resolved, devDependencies } = options;
639
+ const installAllDependencies = async (resolved: ResolvedOptions, devDependencies: string[]): Promise<void> => {
531
640
  const { root, packageManager } = resolved;
532
641
 
533
642
  await runWithSpinner({
@@ -535,22 +644,39 @@ const installAllDependencies = async (options: InstallAllOptions): Promise<void>
535
644
  done: "Dependencies installed",
536
645
  failed: "Failed to install dependencies",
537
646
  run: async () => {
538
- await installDependencies({ cwd: root, packageManager, dependencies: pin(DEPENDENCIES), isDev: false });
539
- await installDependencies({ cwd: root, packageManager, dependencies: pin(devDependencies), isDev: true });
647
+ await withoutInheritedScriptPolicy(async () => {
648
+ const options = { cwd: root, packageManager, silent: true };
649
+ await addDependency(dependencyArguments(DEPENDENCIES), options);
650
+ await addDependency(dependencyArguments(devDependencies), { ...options, dev: true });
651
+ });
540
652
  },
541
- explain: (error) => {
542
- explainInstallFailure({ error, resolved, devDependencies });
653
+ explain: () => {
654
+ p.log.info(formatRecovery(resolved));
543
655
  },
544
656
  });
545
657
  };
546
658
 
547
- const writeInitialEnvModule = (root: string): void => {
548
- const storeDir = join(root, "node_modules", ".gtkx");
549
- mkdirSync(storeDir, { recursive: true });
550
- writeFileSync(join(storeDir, "env.d.ts"), `${GTKX_ENV_MODULE_HEADER}\n`);
659
+ const isInsideGitRepository = (root: string): boolean => {
660
+ const git = tryResolveExecutable("git");
661
+
662
+ if (git === undefined) {
663
+ return false;
664
+ }
665
+
666
+ try {
667
+ execFileSync(git, ["-C", root, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
668
+
669
+ return true;
670
+ } catch {
671
+ return false;
672
+ }
551
673
  };
552
674
 
553
- const initializeGitRepo = async (root: string): Promise<void> => {
675
+ const initializeGitRepo = async (root: string, shouldInitialize: boolean): Promise<void> => {
676
+ if (!shouldInitialize || isInsideGitRepository(root)) {
677
+ return;
678
+ }
679
+
554
680
  const spinner = p.spinner();
555
681
  spinner.start("Initializing git repository...");
556
682
 
@@ -567,24 +693,23 @@ const initializeGitRepo = async (root: string): Promise<void> => {
567
693
 
568
694
  const printNextSteps = (resolved: ResolvedOptions): void => {
569
695
  const devCmd = DEV_COMMAND[resolved.packageManager];
570
- const cdStep = resolved.isCurrentDirectory ? "" : `cd ${resolved.target}\n`;
696
+ const installStep = resolved.shouldInstallDependencies ? "" : `${INSTALL_COMMAND[resolved.packageManager]}\n`;
571
697
  const testingNote = resolved.shouldIncludeTesting ? HEADLESS_COMPOSITOR_NOTE : "";
572
- p.note(`${cdStep}${devCmd}${testingNote}`, "Next steps");
698
+ p.note(`${installStep}${devCmd}${testingNote}`, `Run in ${resolved.root}`);
573
699
  };
574
700
 
575
- const createProjectStructure = async (root: string, resolved: ResolvedOptions): Promise<void> => {
701
+ const createProjectStructure = async (
702
+ root: string,
703
+ resolved: ResolvedOptions,
704
+ plan: ScaffoldPlan,
705
+ ): Promise<void> => {
576
706
  await runWithSpinner({
577
707
  pending: "Creating project structure...",
578
708
  done: "Project structure created",
579
709
  failed: "Failed to create the project structure",
580
- run: async () => {
710
+ run: () => {
581
711
  mkdirSync(root, { recursive: true });
582
-
583
- if (resolved.shouldEmptyTarget) {
584
- emptyDir(root);
585
- }
586
-
587
- await scaffoldProject(root, resolved);
712
+ scaffoldProject(plan);
588
713
  writeBuildAllowance(root, resolved.packageManager);
589
714
  },
590
715
  });
@@ -595,14 +720,18 @@ const scaffold = async (options: CreateOptions = {}): Promise<void> => {
595
720
  const resolved = await resolveOptions(options);
596
721
  const { root } = resolved;
597
722
  const devDeps = getDevDependencies(resolved);
598
- await createProjectStructure(root, resolved);
599
- await installAllDependencies({ resolved, devDependencies: devDeps });
723
+ assertTargetIsDirectory(root, resolved.target);
724
+ const plan = await planScaffoldProject(root, resolved);
725
+ await validateTargetAvailability(root, resolved.target, options, plan.changes);
726
+ await createProjectStructure(root, resolved, plan);
727
+ writeDependencies(root, devDeps);
728
+ reportCompletedChanges(plan.changes);
600
729
 
601
- if (resolved.isTypescript) {
602
- writeInitialEnvModule(root);
730
+ if (resolved.shouldInstallDependencies) {
731
+ await installAllDependencies(resolved, devDeps);
603
732
  }
604
733
 
605
- await initializeGitRepo(root);
734
+ await initializeGitRepo(root, resolved.shouldInitializeGit);
606
735
  printNextSteps(resolved);
607
736
  };
608
737