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

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