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

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, 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,13 +37,17 @@ 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;
43
47
  };
44
48
 
49
+ type ScaffoldFile = { destination: string; contents: string };
50
+
45
51
  type InstallDependenciesOptions = {
46
52
  cwd: string;
47
53
  packageManager: PackageManager;
@@ -88,6 +94,10 @@ const ADD_COMMAND: Record<PackageManager, string> = Object.fromEntries(
88
94
  PACKAGE_MANAGERS.map((manager) => [manager.value, manager.addCommand]),
89
95
  ) as Record<PackageManager, string>;
90
96
 
97
+ const INSTALL_COMMAND: Record<PackageManager, string> = Object.fromEntries(
98
+ PACKAGE_MANAGERS.map((manager) => [manager.value, manager.installCommand]),
99
+ ) as Record<PackageManager, string>;
100
+
91
101
  const INHERITED_SCRIPT_POLICY_VARS = ["npm_config_allow_scripts", "npm_config_allow-scripts"];
92
102
  const TESTING_TEMPLATES = new Set(["vitest.config.ts", "tests/app.test.tsx"]);
93
103
  const TYPESCRIPT_TEMPLATES = new Set(["tsconfig.json", "src/gtkx-env.d.ts"]);
@@ -106,7 +116,8 @@ const APPLICATION_ID_SUFFIX = ".app";
106
116
  const APPLICATION_ID_SEGMENT_LIMIT = APPLICATION_ID_MAX_LENGTH - APPLICATION_ID_PREFIX.length -
107
117
  APPLICATION_ID_SUFFIX.length;
108
118
 
109
- const titleFromName = (name: string): string => name.split("-").map((part) => upperFirst(part)).join(" ");
119
+ const displayNameFromProjectName = (name: string): string =>
120
+ name.split("-").map((part) => upperFirst(part)).join(" ");
110
121
 
111
122
  const gitConfigValue = (key: string): string | null => {
112
123
  const git = tryResolveExecutable("git");
@@ -153,6 +164,12 @@ const isDirEmpty = (dir: string): boolean => {
153
164
  return entries.length === 0 || (entries.length === 1 && entries[0] === ".git");
154
165
  };
155
166
 
167
+ const shouldInitializeGit = (root: string): boolean => {
168
+ const entry = lstatSync(root, { throwIfNoEntry: false });
169
+
170
+ return entry === undefined || (entry.isDirectory() && readdirSync(root).length === 0);
171
+ };
172
+
156
173
  const getDevDependencies = ({
157
174
  isTypescript,
158
175
  shouldIncludeTesting,
@@ -200,6 +217,17 @@ const validateApplicationIdInput = (value: string | undefined): string | undefin
200
217
  const validateApplicationIdAnswer = (value: string | undefined): string | undefined =>
201
218
  value ? validateApplicationIdFormat(value) : undefined;
202
219
 
220
+ const validateDisplayName = (value: string | undefined): string | undefined => {
221
+ if (value === undefined || value.trim().length === 0) {
222
+ return "Display name is required";
223
+ }
224
+
225
+ return value.includes("\n") || value.includes("\r") ? "Display name must be a single line" : undefined;
226
+ };
227
+
228
+ const validateDisplayNameAnswer = (value: string | undefined): string | undefined =>
229
+ value ? validateDisplayName(value) : undefined;
230
+
203
231
  const fail = (message: string): never => {
204
232
  p.log.error(message);
205
233
  throw new ScaffoldAbortedError(message);
@@ -252,6 +280,19 @@ const promptApplicationId = async (name: string): Promise<string> => {
252
280
  );
253
281
  };
254
282
 
283
+ const promptDisplayName = async (name: string): Promise<string> => {
284
+ const defaultDisplayName = displayNameFromProjectName(name);
285
+
286
+ return guardCancellation(
287
+ await p.text({
288
+ message: "Display name",
289
+ placeholder: defaultDisplayName,
290
+ defaultValue: defaultDisplayName,
291
+ validate: validateDisplayNameAnswer,
292
+ }),
293
+ );
294
+ };
295
+
255
296
  const packageManagerOption = (manager: (typeof PACKAGE_MANAGERS)[number], detected: PackageManager | undefined) => {
256
297
  let hint: string | undefined;
257
298
 
@@ -297,16 +338,6 @@ const isOptionEnabled = async (
297
338
  ? guardCancellation(await p.confirm({ message, initialValue: true }))
298
339
  : true);
299
340
 
300
- const emptyDir = (root: string): void => {
301
- for (const entry of readdirSync(root)) {
302
- if (entry === ".git") {
303
- continue;
304
- }
305
-
306
- rmSync(join(root, entry), { recursive: true, force: true });
307
- }
308
- };
309
-
310
341
  const shouldOverwriteDirectory = async (target: string, options: CreateOptions): Promise<boolean> => {
311
342
  if (!options.isInteractive) {
312
343
  return options.shouldOverwrite === true;
@@ -314,17 +345,17 @@ const shouldOverwriteDirectory = async (target: string, options: CreateOptions):
314
345
 
315
346
  return guardCancellation(
316
347
  await p.confirm({
317
- message: `Directory "${target}" is not empty. Overwrite its contents?`,
348
+ message: `Directory "${target}" is not empty. Replace scaffold files?`,
318
349
  initialValue: false,
319
350
  }),
320
351
  );
321
352
  };
322
353
 
323
- const shouldEmptyTargetDirectory = async (root: string, target: string, options: CreateOptions): Promise<boolean> => {
354
+ const validateTargetAvailability = async (root: string, target: string, options: CreateOptions): Promise<void> => {
324
355
  const stats = statSync(root, { throwIfNoEntry: false });
325
356
 
326
357
  if (stats === undefined) {
327
- return false;
358
+ return;
328
359
  }
329
360
 
330
361
  if (!stats.isDirectory()) {
@@ -332,14 +363,14 @@ const shouldEmptyTargetDirectory = async (root: string, target: string, options:
332
363
  }
333
364
 
334
365
  if (isDirEmpty(root)) {
335
- return false;
366
+ return;
336
367
  }
337
368
 
338
369
  if (await shouldOverwriteDirectory(target, options)) {
339
- return true;
370
+ return;
340
371
  }
341
372
 
342
- return fail(`Directory "${target}" is not empty`);
373
+ fail(`Directory "${target}" is not empty`);
343
374
  };
344
375
 
345
376
  const resolveTarget = async (options: CreateOptions): Promise<string> => {
@@ -362,6 +393,14 @@ const resolveApplicationId = async (options: CreateOptions, name: string): Promi
362
393
  return error === undefined ? applicationId : fail(error);
363
394
  };
364
395
 
396
+ const resolveDisplayName = async (options: CreateOptions, name: string): Promise<string> => {
397
+ const value = options.displayName ??
398
+ (options.isInteractive ? await promptDisplayName(name) : displayNameFromProjectName(name));
399
+ const error = validateDisplayName(value);
400
+
401
+ return error === undefined ? value.trim() : fail(error);
402
+ };
403
+
365
404
  const resolvePackageManager = async (
366
405
  requested: PackageManager | undefined,
367
406
  options: CreateOptions,
@@ -381,10 +420,12 @@ const resolveOptions = async (options: CreateOptions): Promise<ResolvedOptions>
381
420
  const requested = requestedPackageManager(options.packageManager);
382
421
  const target = await resolveTarget(options);
383
422
  const name = deriveProjectName(target);
423
+ const displayName = await resolveDisplayName(options, name);
384
424
  const applicationId = await resolveApplicationId(options, name);
385
425
  const root = resolve(process.cwd(), target);
386
426
  const isCurrentDirectory = root === process.cwd();
387
- const shouldEmptyTarget = await shouldEmptyTargetDirectory(root, target, options);
427
+ const shouldInitialize = shouldInitializeGit(root);
428
+ await validateTargetAvailability(root, target, options);
388
429
  const packageManager = await resolvePackageManager(requested, options);
389
430
  const isTypescript = await isOptionEnabled(options.isTypescript, options.isInteractive, "Use TypeScript?");
390
431
 
@@ -399,11 +440,13 @@ const resolveOptions = async (options: CreateOptions): Promise<ResolvedOptions>
399
440
  root,
400
441
  isCurrentDirectory,
401
442
  name,
443
+ displayName,
402
444
  applicationId,
403
445
  packageManager,
404
446
  isTypescript,
405
447
  shouldIncludeTesting,
406
- shouldEmptyTarget,
448
+ shouldInitializeGit: shouldInitialize,
449
+ shouldInstallDependencies: options.shouldInstallDependencies !== false,
407
450
  };
408
451
  };
409
452
 
@@ -430,25 +473,123 @@ const destinationPath = (template: string, resolved: ResolvedOptions): string =>
430
473
  const templateContext = (resolved: ResolvedOptions): TemplateContext => ({
431
474
  name: resolved.name,
432
475
  applicationId: resolved.applicationId,
433
- title: titleFromName(resolved.name),
476
+ displayName: resolved.displayName,
434
477
  shouldIncludeTesting: resolved.shouldIncludeTesting,
435
478
  isTypescript: resolved.isTypescript,
436
479
  importExtension: resolved.isTypescript ? ".js" : ".jsx",
437
- developerName: gitConfigValue("user.name") ?? titleFromName(resolved.name),
480
+ developerName: gitConfigValue("user.name") ?? resolved.displayName,
438
481
  developerEmail: gitConfigValue("user.email"),
439
482
  });
440
483
 
441
- const scaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<void> => {
484
+ const replaceTemplateDestination = (destination: string): void => {
485
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
486
+
487
+ if (entry === undefined) {
488
+ return;
489
+ }
490
+
491
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
492
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
493
+ }
494
+
495
+ rmSync(destination, { force: true });
496
+ };
497
+
498
+ const projectRelativeDestination = (root: string, destination: string): string => {
499
+ const relativePath = relative(root, destination);
500
+ const isOutsideProject = relativePath.length === 0 || relativePath.startsWith(`..${sep}`) ||
501
+ isAbsolute(relativePath);
502
+
503
+ if (isOutsideProject) {
504
+ fail(`Scaffold destination is outside the project: ${destination}`);
505
+ }
506
+
507
+ return relativePath;
508
+ };
509
+
510
+ const assertRootIsNotSymbolicLink = (root: string): void => {
511
+ const rootEntry = lstatSync(root, { throwIfNoEntry: false });
512
+
513
+ if (rootEntry?.isSymbolicLink()) {
514
+ fail(`Cannot scaffold through a symbolic link: ${root}`);
515
+ }
516
+ };
517
+
518
+ const assertSafeAncestor = (ancestor: string): void => {
519
+ const entry = lstatSync(ancestor, { throwIfNoEntry: false });
520
+
521
+ if (entry?.isSymbolicLink()) {
522
+ fail(`Cannot scaffold through a symbolic link: ${ancestor}`);
523
+ }
524
+
525
+ if (entry !== undefined && !entry.isDirectory()) {
526
+ fail(`Cannot scaffold through a non-directory path: ${ancestor}`);
527
+ }
528
+ };
529
+
530
+ const assertSafeAncestors = (root: string, relativePath: string): void => {
531
+ let ancestor = root;
532
+ const segments = relativePath.split(sep);
533
+ const ancestorSegments = segments.slice(0, -1);
534
+
535
+ for (const segment of ancestorSegments) {
536
+ ancestor = join(ancestor, segment);
537
+ assertSafeAncestor(ancestor);
538
+ }
539
+ };
540
+
541
+ const assertReplaceableDestination = (destination: string): void => {
542
+ const entry = lstatSync(destination, { throwIfNoEntry: false });
543
+ const isReplaceable = entry === undefined || entry.isFile() || entry.isSymbolicLink();
544
+
545
+ if (!isReplaceable) {
546
+ fail(`Cannot replace scaffold file because its destination is not a file: ${destination}`);
547
+ }
548
+ };
549
+
550
+ const assertSafeDestination = (root: string, destination: string): void => {
551
+ const relativePath = projectRelativeDestination(root, destination);
552
+ assertRootIsNotSymbolicLink(root);
553
+ assertSafeAncestors(root, relativePath);
554
+ assertReplaceableDestination(destination);
555
+ };
556
+
557
+ const plannedScaffoldFiles = async (root: string, resolved: ResolvedOptions): Promise<ScaffoldFile[]> => {
442
558
  const context = templateContext(resolved);
559
+ const files: ScaffoldFile[] = [];
443
560
 
444
561
  for (const template of listTemplates()) {
445
- if (!isTemplateIncluded(template, resolved)) {
446
- continue;
562
+ if (isTemplateIncluded(template, resolved)) {
563
+ files.push({
564
+ destination: join(root, destinationPath(template, resolved)),
565
+ contents: await renderFile(template, context),
566
+ });
447
567
  }
568
+ }
569
+
570
+ return files;
571
+ };
572
+
573
+ const auxiliaryDestinations = (root: string, resolved: ResolvedOptions): string[] => [
574
+ ...(resolved.packageManager === "pnpm" ? [join(root, "pnpm-workspace.yaml")] : []),
575
+ ...(resolved.isTypescript ? [join(root, "node_modules", ".gtkx", "env.d.ts")] : []),
576
+ ];
577
+
578
+ const scaffoldProject = async (root: string, resolved: ResolvedOptions): Promise<void> => {
579
+ const files = await plannedScaffoldFiles(root, resolved);
580
+ const destinations = [...files.map((file) => file.destination), ...auxiliaryDestinations(root, resolved)];
581
+
582
+ for (const destination of destinations) {
583
+ assertSafeDestination(root, destination);
584
+ }
448
585
 
449
- const destination = join(root, destinationPath(template, resolved));
450
- mkdirSync(dirname(destination), { recursive: true });
451
- writeFileSync(destination, await renderFile(template, context));
586
+ for (const destination of destinations) {
587
+ replaceTemplateDestination(destination);
588
+ }
589
+
590
+ for (const file of files) {
591
+ mkdirSync(dirname(file.destination), { recursive: true });
592
+ writeFileSync(file.destination, file.contents);
452
593
  }
453
594
 
454
595
  if (resolved.shouldIncludeTesting) {
@@ -487,6 +628,17 @@ const installDependencies = async (options: InstallDependenciesOptions): Promise
487
628
  const pin = (names: string[]): string[] =>
488
629
  names.map((dependency) => dependency.startsWith("@gtkx/") ? `${dependency}@^${selfVersion}` : dependency);
489
630
 
631
+ const dependencyVersions = (names: string[]): Record<string, string> => Object.fromEntries(
632
+ names.map((dependency) => [dependency, dependency.startsWith("@gtkx/") ? `^${selfVersion}` : "latest"]),
633
+ );
634
+
635
+ const writeDependencies = (root: string, devDependencies: string[]): void => {
636
+ updateManifest(root, (manifest) => {
637
+ manifest.dependencies = dependencyVersions(DEPENDENCIES);
638
+ manifest.devDependencies = dependencyVersions(devDependencies);
639
+ });
640
+ };
641
+
490
642
  const formatRecovery = ({ resolved, devDependencies }: InstallFailureOptions): string => {
491
643
  const add = ADD_COMMAND[resolved.packageManager];
492
644
 
@@ -550,7 +702,27 @@ const writeInitialEnvModule = (root: string): void => {
550
702
  writeFileSync(join(storeDir, "env.d.ts"), `${GTKX_ENV_MODULE_HEADER}\n`);
551
703
  };
552
704
 
553
- const initializeGitRepo = async (root: string): Promise<void> => {
705
+ const isInsideGitRepository = (root: string): boolean => {
706
+ const git = tryResolveExecutable("git");
707
+
708
+ if (git === undefined) {
709
+ return false;
710
+ }
711
+
712
+ try {
713
+ execFileSync(git, ["-C", root, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
714
+
715
+ return true;
716
+ } catch {
717
+ return false;
718
+ }
719
+ };
720
+
721
+ const initializeGitRepo = async (root: string, shouldInitialize: boolean): Promise<void> => {
722
+ if (!shouldInitialize || isInsideGitRepository(root)) {
723
+ return;
724
+ }
725
+
554
726
  const spinner = p.spinner();
555
727
  spinner.start("Initializing git repository...");
556
728
 
@@ -568,8 +740,9 @@ const initializeGitRepo = async (root: string): Promise<void> => {
568
740
  const printNextSteps = (resolved: ResolvedOptions): void => {
569
741
  const devCmd = DEV_COMMAND[resolved.packageManager];
570
742
  const cdStep = resolved.isCurrentDirectory ? "" : `cd ${resolved.target}\n`;
743
+ const installStep = resolved.shouldInstallDependencies ? "" : `${INSTALL_COMMAND[resolved.packageManager]}\n`;
571
744
  const testingNote = resolved.shouldIncludeTesting ? HEADLESS_COMPOSITOR_NOTE : "";
572
- p.note(`${cdStep}${devCmd}${testingNote}`, "Next steps");
745
+ p.note(`${cdStep}${installStep}${devCmd}${testingNote}`, "Next steps");
573
746
  };
574
747
 
575
748
  const createProjectStructure = async (root: string, resolved: ResolvedOptions): Promise<void> => {
@@ -579,13 +752,12 @@ const createProjectStructure = async (root: string, resolved: ResolvedOptions):
579
752
  failed: "Failed to create the project structure",
580
753
  run: async () => {
581
754
  mkdirSync(root, { recursive: true });
582
-
583
- if (resolved.shouldEmptyTarget) {
584
- emptyDir(root);
585
- }
586
-
587
755
  await scaffoldProject(root, resolved);
588
756
  writeBuildAllowance(root, resolved.packageManager);
757
+
758
+ if (resolved.isTypescript) {
759
+ writeInitialEnvModule(root);
760
+ }
589
761
  },
590
762
  });
591
763
  };
@@ -596,13 +768,14 @@ const scaffold = async (options: CreateOptions = {}): Promise<void> => {
596
768
  const { root } = resolved;
597
769
  const devDeps = getDevDependencies(resolved);
598
770
  await createProjectStructure(root, resolved);
599
- await installAllDependencies({ resolved, devDependencies: devDeps });
600
771
 
601
- if (resolved.isTypescript) {
602
- writeInitialEnvModule(root);
772
+ if (resolved.shouldInstallDependencies) {
773
+ await installAllDependencies({ resolved, devDependencies: devDeps });
774
+ } else {
775
+ writeDependencies(root, devDeps);
603
776
  }
604
777
 
605
- await initializeGitRepo(root);
778
+ await initializeGitRepo(root, resolved.shouldInitializeGit);
606
779
  printNextSteps(resolved);
607
780
  };
608
781
 
@@ -4,11 +4,10 @@ export default defineConfig({
4
4
  applicationId: "<%= applicationId %>",
5
5
  applicationIcon: "data/icons",
6
6
  deploy: {
7
- name: "<%= title %>",
7
+ name: <%- sourceStringLiteral(displayName) %>,
8
8
  summary: "A GTK4 application built with GTKX",
9
9
  description: [
10
- "<%= title %> is a GTK4 and Adwaita application built with GTKX, which renders native GObject "
11
- + "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.`) %>,
12
11
  ],
13
12
  categories: ["Utility"],
14
13
  },
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ node = "26.7.0"
@@ -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
@@ -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;