create-gtkx 0.21.0

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.
Files changed (55) hide show
  1. package/LICENSE +373 -0
  2. package/bin/create-gtkx.js +5 -0
  3. package/dist/cli.d.ts +24 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +19 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/command.d.ts +65 -0
  8. package/dist/command.d.ts.map +1 -0
  9. package/dist/command.js +69 -0
  10. package/dist/command.js.map +1 -0
  11. package/dist/create.d.ts +3 -0
  12. package/dist/create.d.ts.map +1 -0
  13. package/dist/create.js +7 -0
  14. package/dist/create.js.map +1 -0
  15. package/dist/deps.d.ts +3 -0
  16. package/dist/deps.d.ts.map +1 -0
  17. package/dist/deps.js +51 -0
  18. package/dist/deps.js.map +1 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/options.d.ts +25 -0
  24. package/dist/options.d.ts.map +1 -0
  25. package/dist/options.js +13 -0
  26. package/dist/options.js.map +1 -0
  27. package/dist/scaffolder.d.ts +51 -0
  28. package/dist/scaffolder.d.ts.map +1 -0
  29. package/dist/scaffolder.js +191 -0
  30. package/dist/scaffolder.js.map +1 -0
  31. package/dist/templates.d.ts +11 -0
  32. package/dist/templates.d.ts.map +1 -0
  33. package/dist/templates.js +25 -0
  34. package/dist/templates.js.map +1 -0
  35. package/package.json +65 -0
  36. package/src/cli.ts +21 -0
  37. package/src/command.ts +87 -0
  38. package/src/create.ts +7 -0
  39. package/src/deps.ts +51 -0
  40. package/src/index.ts +4 -0
  41. package/src/options.ts +24 -0
  42. package/src/scaffolder.ts +306 -0
  43. package/src/templates.ts +42 -0
  44. package/templates/claude/EXAMPLES.md.ejs +790 -0
  45. package/templates/claude/SKILL.md.ejs +126 -0
  46. package/templates/claude/WIDGETS.md.ejs +934 -0
  47. package/templates/config/vitest.config.ts.ejs +11 -0
  48. package/templates/gitignore.ejs +4 -0
  49. package/templates/gtkx.config.ts.ejs +6 -0
  50. package/templates/package.json.ejs +16 -0
  51. package/templates/src/app.tsx.ejs +48 -0
  52. package/templates/src/gtkx-env.d.ts.ejs +2 -0
  53. package/templates/src/index.tsx.ejs +4 -0
  54. package/templates/tests/app.test.tsx.ejs +16 -0
  55. package/templates/tsconfig.json.ejs +14 -0
@@ -0,0 +1,306 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { errorMessage, isValidApplicationId, renderEmptyGtkxEnvModule, toUpperFirst } from "@gtkx/utils";
3
+ import { isValidProjectName, PACKAGE_MANAGERS, type PackageManager, type TestingOption } from "./options.js";
4
+ import { TEMPLATE_SUFFIX, type TemplateContext } from "./templates.js";
5
+
6
+ export type CreateOptions = {
7
+ name?: string | undefined;
8
+ applicationId?: string | undefined;
9
+ packageManager?: PackageManager | undefined;
10
+ testing?: TestingOption | undefined;
11
+ claudeSkills?: boolean | undefined;
12
+ };
13
+
14
+ type ResolvedOptions = {
15
+ name: string;
16
+ applicationId: string;
17
+ packageManager: PackageManager;
18
+ testing: TestingOption;
19
+ claudeSkills: boolean;
20
+ };
21
+
22
+ type ScaffolderPrompts = Pick<
23
+ typeof import("@clack/prompts"),
24
+ "intro" | "note" | "cancel" | "text" | "select" | "confirm" | "isCancel"
25
+ > & {
26
+ spinner(): { start(message: string): void; stop(message: string): void };
27
+ log: { info(message: string): void; error(message: string): void };
28
+ };
29
+
30
+ type ScaffolderFs = {
31
+ existsSync(path: string): boolean;
32
+ mkdirSync(path: string, opts: { recursive: boolean }): void;
33
+ writeFileSync(path: string, content: string): void;
34
+ };
35
+
36
+ type InstallDependenciesFn = (opts: {
37
+ cwd: string;
38
+ packageManager: PackageManager;
39
+ dependencies: string[];
40
+ dev: boolean;
41
+ }) => Promise<void>;
42
+
43
+ type GitInitFn = (cwd: string) => Promise<void>;
44
+
45
+ export type ScaffolderDeps = {
46
+ cwd(): string;
47
+ gtkxVersion: string;
48
+ fs: ScaffolderFs;
49
+ prompts: ScaffolderPrompts;
50
+ listTemplates(): string[];
51
+ render(template: string, context: TemplateContext): string;
52
+ install: InstallDependenciesFn;
53
+ gitInit: GitInitFn;
54
+ detectPackageManager(cwd: string): Promise<PackageManager | undefined>;
55
+ exit(code: number): never;
56
+ };
57
+
58
+ export type Scaffolder = {
59
+ run(options?: CreateOptions): Promise<void>;
60
+ };
61
+
62
+ const DEPENDENCIES = ["@gtkx/css", "@gtkx/ffi", "@gtkx/react", "react"];
63
+
64
+ const DEV_DEPENDENCIES = ["@gtkx/cli", "@gtkx/config", "@types/react", "typescript", "vite"];
65
+
66
+ const pinGtkxDependency = (name: string, version: string): string =>
67
+ name.startsWith("@gtkx/") ? `${name}@^${version}` : name;
68
+
69
+ const TESTING_DEV_DEPENDENCIES = ["@gtkx/testing", "vitest"];
70
+
71
+ const RUN_DEV_COMMAND: Record<PackageManager, string> = Object.fromEntries(
72
+ PACKAGE_MANAGERS.map((manager) => [manager.value, manager.runDev]),
73
+ ) as Record<PackageManager, string>;
74
+
75
+ const getRunCommand = (pm: PackageManager): string => RUN_DEV_COMMAND[pm];
76
+
77
+ const titleFromName = (name: string): string => name.split("-").map(toUpperFirst).join(" ");
78
+
79
+ const suggestApplicationId = (name: string): string => `com.${name.replaceAll("-", "")}.app`;
80
+
81
+ const getDevDependencies = (testing: TestingOption): string[] => {
82
+ const devDeps = [...DEV_DEPENDENCIES];
83
+ if (testing === "vitest") {
84
+ devDeps.push(...TESTING_DEV_DEPENDENCIES);
85
+ }
86
+ return devDeps;
87
+ };
88
+
89
+ const guardCancellation = <T>(deps: ScaffolderDeps, value: T | symbol): T => {
90
+ if (deps.prompts.isCancel(value)) {
91
+ deps.prompts.cancel("Operation canceled");
92
+ deps.exit(0);
93
+ }
94
+ return value as T;
95
+ };
96
+
97
+ const validateProjectName = (deps: ScaffolderDeps, value: string | undefined): string | undefined => {
98
+ if (!value) return "Project name is required";
99
+ if (!isValidProjectName(value)) {
100
+ return "Project name must be lowercase letters, numbers, and hyphens only";
101
+ }
102
+ if (deps.fs.existsSync(resolve(deps.cwd(), value))) {
103
+ return `Directory "${value}" already exists`;
104
+ }
105
+ return undefined;
106
+ };
107
+
108
+ const validateApplicationIdInput = (value: string | undefined): string | undefined => {
109
+ if (!value) return "Application ID is required";
110
+ if (!isValidApplicationId(value)) {
111
+ return "Application ID must be reverse domain notation (e.g., com.example.myapp)";
112
+ }
113
+ return undefined;
114
+ };
115
+
116
+ const promptName = async (deps: ScaffolderDeps): Promise<string> =>
117
+ guardCancellation(
118
+ deps,
119
+ await deps.prompts.text({
120
+ message: "Project name",
121
+ placeholder: "my-app",
122
+ validate: (value) => validateProjectName(deps, value),
123
+ }),
124
+ );
125
+
126
+ const promptApplicationId = async (deps: ScaffolderDeps, name: string): Promise<string> => {
127
+ const defaultApplicationId = suggestApplicationId(name);
128
+ return guardCancellation(
129
+ deps,
130
+ await deps.prompts.text({
131
+ message: "Application ID",
132
+ placeholder: defaultApplicationId,
133
+ initialValue: defaultApplicationId,
134
+ validate: validateApplicationIdInput,
135
+ }),
136
+ );
137
+ };
138
+
139
+ const promptPackageManager = async (deps: ScaffolderDeps): Promise<PackageManager> => {
140
+ const detected = await deps.detectPackageManager(deps.cwd()).catch(() => undefined);
141
+ const initial: PackageManager = detected ?? "pnpm";
142
+ return guardCancellation(
143
+ deps,
144
+ await deps.prompts.select<PackageManager>({
145
+ message: "Package manager",
146
+ options: PACKAGE_MANAGERS.map((manager) => {
147
+ const hint = detected === manager.value ? "detected" : manager.recommended ? "recommended" : undefined;
148
+ return { value: manager.value, label: manager.label, ...(hint === undefined ? {} : { hint }) };
149
+ }),
150
+ initialValue: initial,
151
+ }),
152
+ );
153
+ };
154
+
155
+ const promptTesting = async (deps: ScaffolderDeps): Promise<TestingOption> => {
156
+ const enable = guardCancellation(
157
+ deps,
158
+ await deps.prompts.confirm({
159
+ message: "Include testing setup (Vitest)?",
160
+ initialValue: true,
161
+ }),
162
+ );
163
+ return enable ? "vitest" : "none";
164
+ };
165
+
166
+ const promptClaudeSkills = async (deps: ScaffolderDeps): Promise<boolean> =>
167
+ guardCancellation(
168
+ deps,
169
+ await deps.prompts.confirm({
170
+ message: "Include Claude Code skills?",
171
+ initialValue: true,
172
+ }),
173
+ );
174
+
175
+ const promptForOptions = async (deps: ScaffolderDeps, options: CreateOptions): Promise<ResolvedOptions> => {
176
+ const name = options.name ?? (await promptName(deps));
177
+ const applicationId = options.applicationId ?? (await promptApplicationId(deps, name));
178
+ const packageManager = options.packageManager ?? (await promptPackageManager(deps));
179
+ const testing = options.testing ?? (await promptTesting(deps));
180
+ const claudeSkills = options.claudeSkills ?? (await promptClaudeSkills(deps));
181
+ return { name, applicationId, packageManager, testing, claudeSkills };
182
+ };
183
+
184
+ const TESTING_TEMPLATE_PREFIXES = ["config/", "tests/"] as const;
185
+ const CLAUDE_TEMPLATE_PREFIX = "claude/";
186
+ const CLAUDE_SKILLS_DIR = ".claude/skills/developing-gtkx-apps";
187
+
188
+ const TEMPLATE_DESTINATIONS: Record<string, string> = {
189
+ gitignore: ".gitignore",
190
+ "config/vitest.config.ts": "vitest.config.ts",
191
+ };
192
+
193
+ const destinationFor = (templateRelativePath: string): string => {
194
+ if (templateRelativePath.startsWith(CLAUDE_TEMPLATE_PREFIX)) {
195
+ return `${CLAUDE_SKILLS_DIR}/${templateRelativePath.slice(CLAUDE_TEMPLATE_PREFIX.length)}`;
196
+ }
197
+ return TEMPLATE_DESTINATIONS[templateRelativePath] ?? templateRelativePath;
198
+ };
199
+
200
+ const isTemplateIncluded = (templateRelativePath: string, resolved: ResolvedOptions): boolean => {
201
+ if (templateRelativePath.startsWith(CLAUDE_TEMPLATE_PREFIX)) {
202
+ return resolved.claudeSkills;
203
+ }
204
+ if (TESTING_TEMPLATE_PREFIXES.some((prefix) => templateRelativePath.startsWith(prefix))) {
205
+ return resolved.testing === "vitest";
206
+ }
207
+ return true;
208
+ };
209
+
210
+ const scaffoldProject = (deps: ScaffolderDeps, projectPath: string, resolved: ResolvedOptions): void => {
211
+ const { name, applicationId, testing } = resolved;
212
+ const context: TemplateContext = { name, applicationId, title: titleFromName(name), testing };
213
+
214
+ deps.fs.mkdirSync(projectPath, { recursive: true });
215
+
216
+ for (const template of deps.listTemplates()) {
217
+ const relativeTemplate = template.slice(0, -TEMPLATE_SUFFIX.length);
218
+ if (!isTemplateIncluded(relativeTemplate, resolved)) continue;
219
+
220
+ const destination = join(projectPath, destinationFor(relativeTemplate));
221
+ deps.fs.mkdirSync(dirname(destination), { recursive: true });
222
+ deps.fs.writeFileSync(destination, deps.render(template, context));
223
+ }
224
+ };
225
+
226
+ type InstallAllOptions = {
227
+ projectPath: string;
228
+ name: string;
229
+ packageManager: PackageManager;
230
+ devDependencies: string[];
231
+ };
232
+
233
+ const installAllDependencies = async (deps: ScaffolderDeps, options: InstallAllOptions): Promise<void> => {
234
+ const { projectPath, name, packageManager, devDependencies } = options;
235
+ const spinner = deps.prompts.spinner();
236
+ spinner.start("Installing dependencies...");
237
+
238
+ const pin = (names: string[]): string[] => names.map((name) => pinGtkxDependency(name, deps.gtkxVersion));
239
+
240
+ try {
241
+ await deps.install({ cwd: projectPath, packageManager, dependencies: pin(DEPENDENCIES), dev: false });
242
+ await deps.install({ cwd: projectPath, packageManager, dependencies: pin(devDependencies), dev: true });
243
+ spinner.stop("Dependencies installed!");
244
+ } catch (error) {
245
+ spinner.stop("Failed to install dependencies");
246
+ deps.prompts.log.error(`Error: ${errorMessage(error)}`);
247
+ deps.prompts.log.info("You can install dependencies manually by running:");
248
+ deps.prompts.log.info(` cd ${name}`);
249
+ }
250
+ };
251
+
252
+ const writeInitialSchemaEnv = (deps: ScaffolderDeps, projectPath: string): void => {
253
+ const storeDir = join(projectPath, "node_modules", ".gtkx");
254
+ deps.fs.mkdirSync(storeDir, { recursive: true });
255
+ deps.fs.writeFileSync(join(storeDir, "env.d.ts"), renderEmptyGtkxEnvModule());
256
+ };
257
+
258
+ const initializeGitRepo = async (deps: ScaffolderDeps, projectPath: string): Promise<void> => {
259
+ const spinner = deps.prompts.spinner();
260
+ spinner.start("Initializing git repository...");
261
+ try {
262
+ await deps.gitInit(projectPath);
263
+ spinner.stop("Git repository initialized!");
264
+ } catch {
265
+ spinner.stop("Failed to initialize git repository");
266
+ }
267
+ };
268
+
269
+ const HEADLESS_COMPOSITOR_NOTE = `
270
+
271
+ To run tests, you need a headless Wayland compositor installed:
272
+ Fedora: sudo dnf install weston
273
+ Ubuntu: sudo apt install weston`;
274
+
275
+ const printNextSteps = (deps: ScaffolderDeps, resolved: ResolvedOptions): void => {
276
+ const runCmd = getRunCommand(resolved.packageManager);
277
+ const nextSteps = `cd ${resolved.name}\n${runCmd}`;
278
+ const testingNote = resolved.testing === "none" ? "" : HEADLESS_COMPOSITOR_NOTE;
279
+ deps.prompts.note(`${nextSteps}${testingNote}`, "Next steps");
280
+ };
281
+
282
+ export const createScaffolder = (deps: ScaffolderDeps): Scaffolder => ({
283
+ async run(options: CreateOptions = {}): Promise<void> {
284
+ deps.prompts.intro("Create GTKX App");
285
+
286
+ const resolved = await promptForOptions(deps, options);
287
+ const projectPath = resolve(deps.cwd(), resolved.name);
288
+ const devDeps = getDevDependencies(resolved.testing);
289
+
290
+ const projectSpinner = deps.prompts.spinner();
291
+ projectSpinner.start("Creating project structure...");
292
+ scaffoldProject(deps, projectPath, resolved);
293
+ projectSpinner.stop("Project structure created!");
294
+
295
+ await installAllDependencies(deps, {
296
+ projectPath,
297
+ name: resolved.name,
298
+ packageManager: resolved.packageManager,
299
+ devDependencies: devDeps,
300
+ });
301
+ writeInitialSchemaEnv(deps, projectPath);
302
+ await initializeGitRepo(deps, projectPath);
303
+
304
+ printNextSteps(deps, resolved);
305
+ },
306
+ });
@@ -0,0 +1,42 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sortedAlpha } from "@gtkx/utils";
4
+ import ejs from "ejs";
5
+ import type { TestingOption } from "./options.js";
6
+
7
+ export type TemplateContext = {
8
+ name: string;
9
+ applicationId: string;
10
+ title: string;
11
+ testing: TestingOption;
12
+ };
13
+
14
+ export const TEMPLATE_SUFFIX = ".ejs";
15
+
16
+ const getTemplatesDir = (): string => {
17
+ return join(import.meta.dirname, "..", "templates");
18
+ };
19
+
20
+ const renderTemplate = (templatePath: string, context: TemplateContext): string => {
21
+ const templateContent = readFileSync(templatePath, "utf-8");
22
+ return ejs.render(templateContent, context);
23
+ };
24
+
25
+ export const listTemplates = (): string[] =>
26
+ sortedAlpha(
27
+ readdirSync(getTemplatesDir(), { recursive: true, withFileTypes: true })
28
+ .filter((entry) => entry.isFile() && entry.name.endsWith(TEMPLATE_SUFFIX))
29
+ .map((entry) => join(entry.parentPath, entry.name))
30
+ .map((absolute) =>
31
+ absolute
32
+ .slice(getTemplatesDir().length + 1)
33
+ .split(/[/\\]/)
34
+ .join("/"),
35
+ ),
36
+ );
37
+
38
+ export const renderFile = (templateName: string, context: TemplateContext): string => {
39
+ const templatesDir = getTemplatesDir();
40
+ const templatePath = join(templatesDir, templateName);
41
+ return renderTemplate(templatePath, context);
42
+ };