create-gtkx 1.6.0 → 2.0.0-beta.2

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,6 +23,7 @@ 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;
@@ -35,13 +36,16 @@ type ResolvedOptions = {
35
36
  root: string;
36
37
  isCurrentDirectory: boolean;
37
38
  name: string;
39
+ displayName: string;
38
40
  applicationId: string;
39
41
  packageManager: PackageManager;
40
42
  isTypescript: boolean;
41
43
  shouldIncludeTesting: boolean;
42
- shouldEmptyTarget: boolean;
44
+ shouldInitializeGit: boolean;
43
45
  };
44
46
 
47
+ type ScaffoldFile = { destination: string; contents: string };
48
+
45
49
  type InstallDependenciesOptions = {
46
50
  cwd: string;
47
51
  packageManager: PackageManager;
@@ -106,12 +110,8 @@ const APPLICATION_ID_SUFFIX = ".app";
106
110
  const APPLICATION_ID_SEGMENT_LIMIT = APPLICATION_ID_MAX_LENGTH - APPLICATION_ID_PREFIX.length -
107
111
  APPLICATION_ID_SUFFIX.length;
108
112
 
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(" ");
113
+ const displayNameFromProjectName = (name: string): string =>
114
+ name.split("-").map((part) => upperFirst(part)).join(" ");
115
115
 
116
116
  const gitConfigValue = (key: string): string | null => {
117
117
  const git = tryResolveExecutable("git");
@@ -147,7 +147,9 @@ const stripTrailingSlashes = (value: string): string => {
147
147
  return value.slice(0, end);
148
148
  };
149
149
 
150
- const formatTargetDir = (target: string): string => stripTrailingSlashes(target.trim().replaceAll(/[<>:"\\|?*]/g, ""));
150
+ const formatTargetDir = (target: string): string =>
151
+ stripTrailingSlashes(target.trim().replaceAll(/[<>:"\\|?*]/g, ""));
152
+
151
153
  const deriveProjectName = (target: string): string => basename(resolve(process.cwd(), target));
152
154
 
153
155
  const isDirEmpty = (dir: string): boolean => {
@@ -156,6 +158,12 @@ const isDirEmpty = (dir: string): boolean => {
156
158
  return entries.length === 0 || (entries.length === 1 && entries[0] === ".git");
157
159
  };
158
160
 
161
+ const shouldInitializeGit = (root: string): boolean => {
162
+ const entry = lstatSync(root, { throwIfNoEntry: false });
163
+
164
+ return entry === undefined || (entry.isDirectory() && readdirSync(root).length === 0);
165
+ };
166
+
159
167
  const getDevDependencies = ({
160
168
  isTypescript,
161
169
  shouldIncludeTesting,
@@ -203,6 +211,17 @@ const validateApplicationIdInput = (value: string | undefined): string | undefin
203
211
  const validateApplicationIdAnswer = (value: string | undefined): string | undefined =>
204
212
  value ? validateApplicationIdFormat(value) : undefined;
205
213
 
214
+ const validateDisplayName = (value: string | undefined): string | undefined => {
215
+ if (value === undefined || value.trim().length === 0) {
216
+ return "Display name is required";
217
+ }
218
+
219
+ return value.includes("\n") || value.includes("\r") ? "Display name must be a single line" : undefined;
220
+ };
221
+
222
+ const validateDisplayNameAnswer = (value: string | undefined): string | undefined =>
223
+ value ? validateDisplayName(value) : undefined;
224
+
206
225
  const fail = (message: string): never => {
207
226
  p.log.error(message);
208
227
  throw new ScaffoldAbortedError(message);
@@ -220,14 +239,6 @@ const requestedPackageManager = (value: string | undefined): PackageManager | un
220
239
  return value;
221
240
  };
222
241
 
223
- const targetStats = (root: string): Stats | undefined => {
224
- try {
225
- return statSync(root, { throwIfNoEntry: false });
226
- } catch {
227
- return undefined;
228
- }
229
- };
230
-
231
242
  const validateTargetDir = (target: string): string | undefined => {
232
243
  if (!target) {
233
244
  return "Project directory is required";
@@ -263,40 +274,36 @@ const promptApplicationId = async (name: string): Promise<string> => {
263
274
  );
264
275
  };
265
276
 
266
- const detectPackageManager = async (cwd: string): Promise<PackageManager | undefined> => {
267
- const detected = await nypmDetectPackageManager(cwd, { includeParentDirs: true });
277
+ const promptDisplayName = async (name: string): Promise<string> => {
278
+ const defaultDisplayName = displayNameFromProjectName(name);
268
279
 
269
- if (!detected) {
270
- return undefined;
271
- }
272
-
273
- return isKnownPackageManager(detected.name) ? detected.name : undefined;
280
+ return guardCancellation(
281
+ await p.text({
282
+ message: "Display name",
283
+ placeholder: defaultDisplayName,
284
+ defaultValue: defaultDisplayName,
285
+ validate: validateDisplayNameAnswer,
286
+ }),
287
+ );
274
288
  };
275
289
 
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
- }
290
+ const packageManagerOption = (manager: (typeof PACKAGE_MANAGERS)[number], detected: PackageManager | undefined) => {
291
+ let hint: string | undefined;
283
292
 
284
- if (manager.isRecommended) {
285
- return "recommended";
293
+ if (detected === manager.value) {
294
+ hint = "detected";
295
+ } else if (manager.isRecommended) {
296
+ hint = "recommended";
286
297
  }
287
298
 
288
- return undefined;
289
- };
290
-
291
- const packageManagerOption = (manager: (typeof PACKAGE_MANAGERS)[number], detected: PackageManager | undefined) => {
292
- const hint = packageManagerHint(manager, detected);
293
-
294
299
  return { value: manager.value, label: manager.label, ...(hint !== undefined && { hint }) };
295
300
  };
296
301
 
297
302
  const detectedPackageManager = async (): Promise<PackageManager | undefined> => {
298
303
  try {
299
- return await detectPackageManager(process.cwd());
304
+ const detected = await nypmDetectPackageManager(process.cwd(), { includeParentDirs: true });
305
+
306
+ return detected && isKnownPackageManager(detected.name) ? detected.name : undefined;
300
307
  } catch {
301
308
  return undefined;
302
309
  }
@@ -315,39 +322,15 @@ const promptPackageManager = async (): Promise<PackageManager> => {
315
322
  );
316
323
  };
317
324
 
318
- const shouldUseTypeScript = async (): Promise<boolean> =>
319
- guardCancellation(
320
- await p.confirm({
321
- message: "Use TypeScript?",
322
- initialValue: true,
323
- }),
324
- );
325
-
326
- const shouldSetUpTesting = async (): Promise<boolean> =>
327
- guardCancellation(
328
- await p.confirm({
329
- message: "Include testing setup (Vitest)?",
330
- initialValue: true,
331
- }),
332
- );
333
-
334
- const emptyDir = (root: string): void => {
335
- for (const entry of readdirSync(root)) {
336
- if (entry === ".git") {
337
- continue;
338
- }
339
-
340
- rmSync(join(root, entry), { recursive: true, force: true });
341
- }
342
- };
343
-
344
- const prepareTargetDirectory = (root: string, shouldEmptyTarget: boolean): void => {
345
- mkdirSync(root, { recursive: true });
346
-
347
- if (shouldEmptyTarget) {
348
- emptyDir(root);
349
- }
350
- };
325
+ const isOptionEnabled = async (
326
+ value: boolean | undefined,
327
+ isInteractive: boolean | undefined,
328
+ message: string,
329
+ ): Promise<boolean> =>
330
+ value ??
331
+ (isInteractive
332
+ ? guardCancellation(await p.confirm({ message, initialValue: true }))
333
+ : true);
351
334
 
352
335
  const shouldOverwriteDirectory = async (target: string, options: CreateOptions): Promise<boolean> => {
353
336
  if (!options.isInteractive) {
@@ -356,17 +339,17 @@ const shouldOverwriteDirectory = async (target: string, options: CreateOptions):
356
339
 
357
340
  return guardCancellation(
358
341
  await p.confirm({
359
- message: `Directory "${target}" is not empty. Overwrite its contents?`,
342
+ message: `Directory "${target}" is not empty. Replace scaffold files?`,
360
343
  initialValue: false,
361
344
  }),
362
345
  );
363
346
  };
364
347
 
365
- const shouldEmptyTargetDirectory = async (root: string, target: string, options: CreateOptions): Promise<boolean> => {
366
- const stats = targetStats(root);
348
+ const validateTargetAvailability = async (root: string, target: string, options: CreateOptions): Promise<void> => {
349
+ const stats = statSync(root, { throwIfNoEntry: false });
367
350
 
368
351
  if (stats === undefined) {
369
- return false;
352
+ return;
370
353
  }
371
354
 
372
355
  if (!stats.isDirectory()) {
@@ -374,14 +357,14 @@ const shouldEmptyTargetDirectory = async (root: string, target: string, options:
374
357
  }
375
358
 
376
359
  if (isDirEmpty(root)) {
377
- return false;
360
+ return;
378
361
  }
379
362
 
380
363
  if (await shouldOverwriteDirectory(target, options)) {
381
- return true;
364
+ return;
382
365
  }
383
366
 
384
- return fail(`Directory "${target}" is not empty`);
367
+ fail(`Directory "${target}" is not empty`);
385
368
  };
386
369
 
387
370
  const resolveTarget = async (options: CreateOptions): Promise<string> => {
@@ -404,6 +387,14 @@ const resolveApplicationId = async (options: CreateOptions, name: string): Promi
404
387
  return error === undefined ? applicationId : fail(error);
405
388
  };
406
389
 
390
+ const resolveDisplayName = async (options: CreateOptions, name: string): Promise<string> => {
391
+ const value = options.displayName ??
392
+ (options.isInteractive ? await promptDisplayName(name) : displayNameFromProjectName(name));
393
+ const error = validateDisplayName(value);
394
+
395
+ return error === undefined ? value.trim() : fail(error);
396
+ };
397
+
407
398
  const resolvePackageManager = async (
408
399
  requested: PackageManager | undefined,
409
400
  options: CreateOptions,
@@ -419,44 +410,36 @@ const resolvePackageManager = async (
419
410
  return (await detectedPackageManager()) ?? "pnpm";
420
411
  };
421
412
 
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
413
  const resolveOptions = async (options: CreateOptions): Promise<ResolvedOptions> => {
439
414
  const requested = requestedPackageManager(options.packageManager);
440
415
  const target = await resolveTarget(options);
441
416
  const name = deriveProjectName(target);
417
+ const displayName = await resolveDisplayName(options, name);
442
418
  const applicationId = await resolveApplicationId(options, name);
443
419
  const root = resolve(process.cwd(), target);
444
420
  const isCurrentDirectory = root === process.cwd();
445
- const shouldEmptyTarget = await shouldEmptyTargetDirectory(root, target, options);
421
+ const shouldInitialize = shouldInitializeGit(root);
422
+ await validateTargetAvailability(root, target, options);
446
423
  const packageManager = await resolvePackageManager(requested, options);
447
- const isTypescript = await isTypescriptSelected(options);
448
- const shouldIncludeTesting = await isTestingIncluded(options);
424
+ const isTypescript = await isOptionEnabled(options.isTypescript, options.isInteractive, "Use TypeScript?");
425
+
426
+ const shouldIncludeTesting = await isOptionEnabled(
427
+ options.shouldIncludeTesting,
428
+ options.isInteractive,
429
+ "Include testing setup (Vitest)?",
430
+ );
449
431
 
450
432
  return {
451
433
  target,
452
434
  root,
453
435
  isCurrentDirectory,
454
436
  name,
437
+ displayName,
455
438
  applicationId,
456
439
  packageManager,
457
440
  isTypescript,
458
441
  shouldIncludeTesting,
459
- shouldEmptyTarget,
442
+ shouldInitializeGit: shouldInitialize,
460
443
  };
461
444
  };
462
445
 
@@ -472,48 +455,140 @@ const isTemplateIncluded = (templateRelativePath: string, resolved: ResolvedOpti
472
455
  return true;
473
456
  };
474
457
 
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;
458
+ const destinationPath = (template: string, resolved: ResolvedOptions): string => {
459
+ const scriptPath = resolved.isTypescript ? template : template.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js");
480
460
 
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
- });
461
+ return scriptPath.endsWith(ICON_TEMPLATE_NAME)
462
+ ? `${scriptPath.slice(0, -ICON_TEMPLATE_NAME.length)}${resolved.applicationId}.svg`
463
+ : scriptPath;
488
464
  };
489
465
 
490
466
  const templateContext = (resolved: ResolvedOptions): TemplateContext => ({
491
467
  name: resolved.name,
492
468
  applicationId: resolved.applicationId,
493
- title: titleFromName(resolved.name),
469
+ displayName: resolved.displayName,
494
470
  shouldIncludeTesting: resolved.shouldIncludeTesting,
495
471
  isTypescript: resolved.isTypescript,
496
472
  importExtension: resolved.isTypescript ? ".js" : ".jsx",
497
- developerName: gitConfigValue("user.name") ?? titleFromName(resolved.name),
473
+ developerName: gitConfigValue("user.name") ?? resolved.displayName,
498
474
  developerEmail: gitConfigValue("user.email"),
499
475
  });
500
476
 
501
- const scaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<void> => {
502
- const { applicationId, isTypescript, shouldIncludeTesting } = resolved;
477
+ const replaceTemplateDestination = (destination: string): void => {
478
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
479
+
480
+ if (entry === undefined) {
481
+ return;
482
+ }
483
+
484
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
485
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
486
+ }
487
+
488
+ rmSync(destination, { force: true });
489
+ };
490
+
491
+ const projectRelativeDestination = (root: string, destination: string): string => {
492
+ const relativePath = relative(root, destination);
493
+ const isOutsideProject = relativePath.length === 0 || relativePath.startsWith(`..${sep}`) ||
494
+ isAbsolute(relativePath);
495
+
496
+ if (isOutsideProject) {
497
+ fail(`Scaffold destination is outside the project: ${destination}`);
498
+ }
499
+
500
+ return relativePath;
501
+ };
502
+
503
+ const assertRootIsNotSymbolicLink = (root: string): void => {
504
+ const rootEntry = lstatSync(root, { throwIfNoEntry: false });
505
+
506
+ if (rootEntry?.isSymbolicLink()) {
507
+ fail(`Cannot scaffold through a symbolic link: ${root}`);
508
+ }
509
+ };
510
+
511
+ const assertSafeAncestor = (ancestor: string): void => {
512
+ const entry = lstatSync(ancestor, { throwIfNoEntry: false });
513
+
514
+ if (entry?.isSymbolicLink()) {
515
+ fail(`Cannot scaffold through a symbolic link: ${ancestor}`);
516
+ }
517
+
518
+ if (entry !== undefined && !entry.isDirectory()) {
519
+ fail(`Cannot scaffold through a non-directory path: ${ancestor}`);
520
+ }
521
+ };
522
+
523
+ const assertSafeAncestors = (root: string, relativePath: string): void => {
524
+ let ancestor = root;
525
+ const segments = relativePath.split(sep);
526
+ const ancestorSegments = segments.slice(0, -1);
527
+
528
+ for (const segment of ancestorSegments) {
529
+ ancestor = join(ancestor, segment);
530
+ assertSafeAncestor(ancestor);
531
+ }
532
+ };
533
+
534
+ const assertReplaceableDestination = (destination: string): void => {
535
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
536
+ const isReplaceable = entry === undefined || entry.isFile() || entry.isSymbolicLink();
537
+
538
+ if (!isReplaceable) {
539
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
540
+ }
541
+ };
542
+
543
+ const assertSafeDestination = (root: string, destination: string): void => {
544
+ const relativePath = projectRelativeDestination(root, destination);
545
+ assertRootIsNotSymbolicLink(root);
546
+ assertSafeAncestors(root, relativePath);
547
+ assertReplaceableDestination(destination);
548
+ };
549
+
550
+ const plannedScaffoldFiles = async (root: string, resolved: ResolvedOptions): Promise<ScaffoldFile[]> => {
503
551
  const context = templateContext(resolved);
552
+ const files: ScaffoldFile[] = [];
504
553
 
505
554
  for (const template of listTemplates()) {
506
- if (!isTemplateIncluded(template, resolved)) {
507
- continue;
555
+ if (isTemplateIncluded(template, resolved)) {
556
+ files.push({
557
+ destination: join(root, destinationPath(template, resolved)),
558
+ contents: await renderFile(template, context),
559
+ });
508
560
  }
561
+ }
562
+
563
+ return files;
564
+ };
509
565
 
510
- const destination = join(root, toDestinationPath(template, isTypescript, applicationId));
511
- mkdirSync(dirname(destination), { recursive: true });
512
- writeFileSync(destination, await renderFile(template, context));
566
+ const auxiliaryDestinations = (root: string, resolved: ResolvedOptions): string[] => [
567
+ ...(resolved.packageManager === "pnpm" ? [join(root, "pnpm-workspace.yaml")] : []),
568
+ ...(resolved.isTypescript ? [join(root, "node_modules", ".gtkx", "env.d.ts")] : []),
569
+ ];
570
+
571
+ const scaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<void> => {
572
+ const files = await plannedScaffoldFiles(root, resolved);
573
+ const destinations = [...files.map((file) => file.destination), ...auxiliaryDestinations(root, resolved)];
574
+
575
+ for (const destination of destinations) {
576
+ assertSafeDestination(root, destination);
513
577
  }
514
578
 
515
- if (shouldIncludeTesting) {
516
- addTestScript(root);
579
+ for (const destination of destinations) {
580
+ replaceTemplateDestination(destination);
581
+ }
582
+
583
+ for (const file of files) {
584
+ mkdirSync(dirname(file.destination), { recursive: true });
585
+ writeFileSync(file.destination, file.contents);
586
+ }
587
+
588
+ if (resolved.shouldIncludeTesting) {
589
+ updateManifest(root, (manifest) => {
590
+ (manifest.scripts as Record<string, string>).test = "vitest run";
591
+ });
517
592
  }
518
593
  };
519
594
 
@@ -543,10 +618,11 @@ const installDependencies = async (options: InstallDependenciesOptions): Promise
543
618
  });
544
619
  };
545
620
 
546
- const pin = (names: string[]): string[] => names.map((dependency) => pinGtkxDependency(dependency, selfVersion));
621
+ const pin = (names: string[]): string[] =>
622
+ names.map((dependency) => dependency.startsWith("@gtkx/") ? `${dependency}@^${selfVersion}` : dependency);
547
623
 
548
624
  const formatRecovery = ({ resolved, devDependencies }: InstallFailureOptions): string => {
549
- const add = getAddCommand(resolved.packageManager);
625
+ const add = ADD_COMMAND[resolved.packageManager];
550
626
 
551
627
  const steps = [
552
628
  ...(resolved.isCurrentDirectory ? [] : [`cd ${resolved.target}`]),
@@ -608,7 +684,27 @@ const writeInitialEnvModule = (root: string): void => {
608
684
  writeFileSync(join(storeDir, "env.d.ts"), `${GTKX_ENV_MODULE_HEADER}\n`);
609
685
  };
610
686
 
611
- const initializeGitRepo = async (root: string): Promise<void> => {
687
+ const isInsideGitRepository = (root: string): boolean => {
688
+ const git = tryResolveExecutable("git");
689
+
690
+ if (git === undefined) {
691
+ return false;
692
+ }
693
+
694
+ try {
695
+ execFileSync(git, ["-C", root, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
696
+
697
+ return true;
698
+ } catch {
699
+ return false;
700
+ }
701
+ };
702
+
703
+ const initializeGitRepo = async (root: string, shouldInitialize: boolean): Promise<void> => {
704
+ if (!shouldInitialize || isInsideGitRepository(root)) {
705
+ return;
706
+ }
707
+
612
708
  const spinner = p.spinner();
613
709
  spinner.start("Initializing git repository...");
614
710
 
@@ -624,7 +720,7 @@ const initializeGitRepo = async (root: string): Promise<void> => {
624
720
  };
625
721
 
626
722
  const printNextSteps = (resolved: ResolvedOptions): void => {
627
- const devCmd = getDevCommand(resolved.packageManager);
723
+ const devCmd = DEV_COMMAND[resolved.packageManager];
628
724
  const cdStep = resolved.isCurrentDirectory ? "" : `cd ${resolved.target}\n`;
629
725
  const testingNote = resolved.shouldIncludeTesting ? HEADLESS_COMPOSITOR_NOTE : "";
630
726
  p.note(`${cdStep}${devCmd}${testingNote}`, "Next steps");
@@ -636,9 +732,13 @@ const createProjectStructure = async (root: string, resolved: ResolvedOptions):
636
732
  done: "Project structure created",
637
733
  failed: "Failed to create the project structure",
638
734
  run: async () => {
639
- prepareTargetDirectory(root, resolved.shouldEmptyTarget);
735
+ mkdirSync(root, { recursive: true });
640
736
  await scaffoldProject(root, resolved);
641
737
  writeBuildAllowance(root, resolved.packageManager);
738
+
739
+ if (resolved.isTypescript) {
740
+ writeInitialEnvModule(root);
741
+ }
642
742
  },
643
743
  });
644
744
  };
@@ -651,11 +751,7 @@ const scaffold = async (options: CreateOptions = {}): Promise<void> => {
651
751
  await createProjectStructure(root, resolved);
652
752
  await installAllDependencies({ resolved, devDependencies: devDeps });
653
753
 
654
- if (resolved.isTypescript) {
655
- writeInitialEnvModule(root);
656
- }
657
-
658
- await initializeGitRepo(root);
754
+ await initializeGitRepo(root, resolved.shouldInitializeGit);
659
755
  printNextSteps(resolved);
660
756
  };
661
757
 
@@ -3,21 +3,11 @@ import { defineConfig } from "@gtkx/config";
3
3
  export default defineConfig({
4
4
  applicationId: "<%= applicationId %>",
5
5
  applicationIcon: "data/icons",
6
- future: {
7
- v2ByteArrays: true,
8
- v2ValueReturns: true,
9
- v2FinishResults: true,
10
- v2InoutReturns: true,
11
- v2ResourceImports: true,
12
- v2DefaultLibraries: true,
13
- v2TreeShaking: true,
14
- },
15
6
  deploy: {
16
- name: "<%= title %>",
7
+ name: <%- sourceStringLiteral(displayName) %>,
17
8
  summary: "A GTK4 application built with GTKX",
18
9
  description: [
19
- "<%= title %> is a GTK4 and Adwaita application built with GTKX, which renders native GObject "
20
- + "widgets from React. Replace this paragraph with a description of what your application does.",
10
+ <%- sourceStringLiteral(`${displayName} is a GTK4 and Adwaita application built with GTKX, which renders native GObject widgets from React. Replace this paragraph with a description of what your application does.`) %>,
21
11
  ],
22
12
  categories: ["Utility"],
23
13
  },
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ node = "26"
@@ -10,6 +10,9 @@
10
10
  "author": <%- sourceStringLiteral(developerName) %>,
11
11
  <% } -%>
12
12
  "type": "module",
13
+ "engines": {
14
+ "node": ">=26.7.0"
15
+ },
13
16
  "scripts": {
14
17
  "dev": "gtkx dev",
15
18
  "build": "gtkx build",
@@ -8,7 +8,7 @@ const MainWindow = () => {
8
8
 
9
9
  return (
10
10
  <GtkApplicationWindow
11
- title="<%= title %>"
11
+ title={<%- sourceStringLiteral(displayName) %>}
12
12
  defaultWidth={400}
13
13
  defaultHeight={300}
14
14
  onCloseRequest={quit}
package/src/templates.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { sortStrings, sourceStringLiteral } from "@gtkx/utils";
1
+ import { sortStrings, sourceStringLiteral, toPosixPath } from "@gtkx/utils";
2
2
  import ejs from "ejs";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  type TemplateContext = {
7
7
  name: string;
8
8
  applicationId: string;
9
- title: string;
9
+ displayName: string;
10
10
  shouldIncludeTesting: boolean;
11
11
  isTypescript: boolean;
12
12
  importExtension: string;
@@ -14,20 +14,18 @@ type TemplateContext = {
14
14
  developerEmail: string | null;
15
15
  };
16
16
 
17
- const getTemplatesDir = (): string => {
18
- return join(import.meta.dirname, "templates");
19
- };
17
+ const TEMPLATES_DIR = join(import.meta.dirname, "templates");
20
18
 
21
19
  const listTemplates = (): string[] =>
22
20
  sortStrings(
23
- readdirSync(getTemplatesDir(), { recursive: true, withFileTypes: true })
21
+ readdirSync(TEMPLATES_DIR, { recursive: true, withFileTypes: true })
24
22
  .filter((entry) => entry.isFile())
25
23
  .map((entry) => join(entry.parentPath, entry.name))
26
- .map((absolute) => absolute.slice(getTemplatesDir().length + 1).replaceAll(/[/\\]/g, "/"))
24
+ .map((absolute) => toPosixPath(absolute.slice(TEMPLATES_DIR.length + 1)))
27
25
  .map((relative) => relative.replace(/\.ejs$/, "")),
28
26
  );
29
27
 
30
28
  const renderFile = async (templateName: string, context: TemplateContext): Promise<string> =>
31
- ejs.renderFile(join(getTemplatesDir(), `${templateName}.ejs`), { ...context, sourceStringLiteral });
29
+ ejs.renderFile(join(TEMPLATES_DIR, `${templateName}.ejs`), { ...context, sourceStringLiteral });
32
30
 
33
31
  export { listTemplates, renderFile, type TemplateContext };