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