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