mick-templates 1.0.1 → 1.1.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.
package/bin/cli.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "child_process";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ // Get the directory where this script is located
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const binDir = dirname(__filename);
9
+ const projectRoot = join(binDir, "..");
10
+ const cliPath = join(projectRoot, "src", "index.tsx");
11
+
12
+ // Find tsx in node_modules/.bin
13
+ const tsxPath = join(projectRoot, "node_modules", ".bin", "tsx");
14
+
15
+ // Execute tsx with the TypeScript file
16
+ const child = spawn(tsxPath, [cliPath, ...process.argv.slice(2)], {
17
+ stdio: "inherit",
18
+ cwd: process.cwd(),
19
+ });
20
+
21
+ child.on("exit", (code) => {
22
+ process.exit(code);
23
+ });
24
+
25
+ child.on("error", (err) => {
26
+ console.error("Failed to start CLI:", err.message);
27
+ process.exit(1);
28
+ });
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "mick-templates",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool for creating projects from templates",
5
5
  "bin": {
6
- "mick-templates": "cli.tsx"
6
+ "mick-templates": "bin/cli.js"
7
7
  },
8
8
  "files": [
9
9
  "templates",
10
- "cli.tsx"
10
+ "src",
11
+ "bin",
12
+ "ts-to-js-codemod.js"
11
13
  ],
12
14
  "type": "module",
13
- "engines": {
14
- "bun": ">=1.0.0"
15
- },
16
15
  "scripts": {
17
- "start": "bun cli.tsx"
16
+ "start": "bun src/index.tsx"
18
17
  },
19
18
  "keywords": [
20
19
  "cli",
@@ -26,9 +25,14 @@
26
25
  "author": "Mick",
27
26
  "license": "MIT",
28
27
  "dependencies": {
28
+ "execa": "^9.5.2",
29
+ "glob": "^10.3.10",
29
30
  "ink": "^4.4.1",
30
31
  "ink-select-input": "^5.0.0",
31
- "react": "^18.2.0"
32
+ "jscodeshift": "^17.3.0",
33
+ "picocolors": "^1.1.1",
34
+ "react": "^18.2.0",
35
+ "tsx": "^4.21.0"
32
36
  },
33
37
  "devDependencies": {
34
38
  "@types/bun": "latest",
package/src/app.tsx ADDED
@@ -0,0 +1,88 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { readdirSync, existsSync } from "fs";
3
+ import { LanguageSelect } from "./components/LanguageSelect.js";
4
+ import { OverwritePrompt } from "./components/OverwritePrompt.js";
5
+ import { Status } from "./components/Status.js";
6
+ import { createProject, type Language } from "./lib/template.js";
7
+
8
+ type Step = "language" | "overwrite" | "processing" | "done" | "error";
9
+
10
+ interface Props {
11
+ template: string;
12
+ projectName?: string;
13
+ language?: Language;
14
+ skipInstall?: boolean;
15
+ }
16
+
17
+ export function App({ template, projectName, language: initialLang, skipInstall = false }: Props) {
18
+ const [step, setStep] = useState<Step>("processing");
19
+ const [language, setLanguage] = useState<Language | undefined>(initialLang);
20
+ const [targetDir, setTargetDir] = useState("");
21
+ const [error, setError] = useState("");
22
+
23
+ const needsOverwrite = !projectName && existsSync(process.cwd()) && readdirSync(process.cwd()).length > 0;
24
+
25
+ useEffect(() => {
26
+ // Determine initial step
27
+ if (!language) {
28
+ setStep("language");
29
+ return;
30
+ }
31
+
32
+ if (needsOverwrite && step === "processing") {
33
+ setStep("overwrite");
34
+ return;
35
+ }
36
+
37
+ // Don't run if we're waiting for user input
38
+ if (step === "language" || step === "overwrite") return;
39
+
40
+ // Create project
41
+ runCreate(language, false);
42
+ }, [language]);
43
+
44
+ const runCreate = async (lang: Language, overwrite: boolean) => {
45
+ setStep("processing");
46
+ try {
47
+ const dir = await createProject({
48
+ template,
49
+ projectName,
50
+ language: lang,
51
+ overwrite,
52
+ skipInstall,
53
+ });
54
+ setTargetDir(dir);
55
+ setStep("done");
56
+ } catch (e) {
57
+ setError(e instanceof Error ? e.message : String(e));
58
+ setStep("error");
59
+ }
60
+ };
61
+
62
+ const handleLanguageSelect = (lang: Language) => {
63
+ setLanguage(lang);
64
+ if (needsOverwrite) {
65
+ setStep("overwrite");
66
+ } else {
67
+ runCreate(lang, false);
68
+ }
69
+ };
70
+
71
+ const handleOverwrite = (overwrite: boolean) => {
72
+ if (!overwrite) {
73
+ process.exit(0);
74
+ }
75
+ runCreate(language!, true);
76
+ };
77
+
78
+ if (step === "language") {
79
+ return <LanguageSelect onSelect={handleLanguageSelect} />;
80
+ }
81
+
82
+ if (step === "overwrite") {
83
+ return <OverwritePrompt onSelect={handleOverwrite} />;
84
+ }
85
+
86
+ return <Status status={step} targetDir={targetDir} error={error} />;
87
+ }
88
+
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import SelectInput from "ink-select-input";
4
+ import type { Language } from "../lib/template.js";
5
+
6
+ interface Props {
7
+ onSelect: (lang: Language) => void;
8
+ }
9
+
10
+ const items = [
11
+ { label: "TypeScript (recommended)", value: "ts" as Language },
12
+ { label: "JavaScript", value: "js" as Language },
13
+ ];
14
+
15
+ export function LanguageSelect({ onSelect }: Props) {
16
+ return (
17
+ <Box flexDirection="column">
18
+ <Text>Choose language:</Text>
19
+ <SelectInput items={items} onSelect={(item) => onSelect(item.value)} />
20
+ </Box>
21
+ );
22
+ }
23
+
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import SelectInput from "ink-select-input";
4
+
5
+ interface Props {
6
+ onSelect: (overwrite: boolean) => void;
7
+ }
8
+
9
+ const items = [
10
+ { label: "Overwrite", value: true },
11
+ { label: "Cancel", value: false },
12
+ ];
13
+
14
+ export function OverwritePrompt({ onSelect }: Props) {
15
+ return (
16
+ <Box flexDirection="column">
17
+ <Text>Directory not empty. What do you want to do?</Text>
18
+ <SelectInput items={items} onSelect={(item) => onSelect(item.value)} />
19
+ </Box>
20
+ );
21
+ }
22
+
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { Text } from "ink";
3
+
4
+ interface Props {
5
+ status: "processing" | "done" | "error";
6
+ targetDir?: string;
7
+ error?: string;
8
+ }
9
+
10
+ export function Status({ status, targetDir, error }: Props) {
11
+ if (status === "error") {
12
+ return <Text color="red">Error: {error}</Text>;
13
+ }
14
+
15
+ if (status === "done") {
16
+ return <Text color="green">✓ Project created in {targetDir}</Text>;
17
+ }
18
+
19
+ return <Text color="yellow">Creating project...</Text>;
20
+ }
21
+
package/src/index.tsx ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import React from "react";
3
+ import { render, Box, Text } from "ink";
4
+ import { App } from "./app.js";
5
+ import { listTemplates, type Language } from "./lib/template.js";
6
+
7
+ const args = process.argv.slice(2);
8
+
9
+ // Parse arguments
10
+ const positional = args.filter((arg) => !arg.startsWith("--"));
11
+ const [template, projectName] = positional;
12
+
13
+ const skipInstall = args.includes("--skip-install");
14
+ const langFlag = args.find((arg) => arg.startsWith("--lang="))?.split("=")[1];
15
+
16
+ // Validate language flag
17
+ const validLangs = ["js", "ts"];
18
+ if (langFlag && !validLangs.includes(langFlag)) {
19
+ console.error(`Error: Invalid language "${langFlag}". Use "js" or "ts".`);
20
+ process.exit(1);
21
+ }
22
+
23
+ // Show usage if no template
24
+ if (!template) {
25
+ const templates = await listTemplates();
26
+
27
+ render(
28
+ <Box flexDirection="column" padding={1}>
29
+ <Text color="red" bold>
30
+ Usage: mick-templates {"<template>"} [name] [--lang=js|ts] [--skip-install]
31
+ </Text>
32
+ <Box marginTop={1} flexDirection="column">
33
+ <Text bold>Available templates:</Text>
34
+ <Box borderStyle="single" borderColor="gray" marginTop={1} paddingX={1} flexDirection="column">
35
+ {templates.map((t) => (
36
+ <Box key={t.name} flexDirection="row">
37
+ <Text color="cyan" bold>{t.name}</Text>
38
+ {t.description && <Text color="gray"> - {t.description}</Text>}
39
+ </Box>
40
+ ))}
41
+ </Box>
42
+ </Box>
43
+ </Box>
44
+ );
45
+ process.exit(1);
46
+ }
47
+
48
+ // Render app
49
+ render(
50
+ <App
51
+ template={template}
52
+ projectName={projectName}
53
+ language={langFlag as Language | undefined}
54
+ skipInstall={skipInstall}
55
+ />
56
+ );
57
+
@@ -0,0 +1,117 @@
1
+ import { readFile, writeFile, rename, rm } from "fs/promises";
2
+ import { glob } from "glob";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { execa } from "execa";
6
+ import { getPMX, detectPM } from "./pm.js";
7
+
8
+ const TS_DEPS = [
9
+ "typescript",
10
+ "@types/react",
11
+ "@types/react-dom",
12
+ "@types/bun",
13
+ "@types/node",
14
+ "typescript-eslint",
15
+ ];
16
+
17
+ /**
18
+ * Convert a TypeScript project to JavaScript.
19
+ */
20
+ export async function convertToJS(targetDir: string): Promise<void> {
21
+ // Remove tsconfig.json
22
+ await rm(join(targetDir, "tsconfig.json"), { force: true });
23
+
24
+ // Rename vite.config.ts -> vite.config.js
25
+ await renameIfExists(
26
+ join(targetDir, "vite.config.ts"),
27
+ join(targetDir, "vite.config.js")
28
+ );
29
+
30
+ // Rename all .tsx -> .jsx
31
+ const tsxFiles = await glob("**/*.tsx", { cwd: targetDir });
32
+ for (const file of tsxFiles) {
33
+ const oldPath = join(targetDir, file);
34
+ const newPath = oldPath.replace(/\.tsx$/, ".jsx");
35
+ await rename(oldPath, newPath);
36
+ }
37
+
38
+ // Rename all .ts -> .js
39
+ const tsFiles = await glob("**/*.ts", { cwd: targetDir });
40
+ for (const file of tsFiles) {
41
+ const oldPath = join(targetDir, file);
42
+ const newPath = oldPath.replace(/\.ts$/, ".js");
43
+ await rename(oldPath, newPath);
44
+ }
45
+
46
+ // Update HTML files to reference .jsx instead of .tsx
47
+ await updateHTMLScripts(targetDir);
48
+
49
+ // Run jscodeshift on all JS/JSX files to strip types
50
+ await runCodemod(targetDir);
51
+
52
+ // Clean up package.json
53
+ await cleanPackageJSON(targetDir);
54
+ }
55
+
56
+ async function renameIfExists(from: string, to: string): Promise<void> {
57
+ try {
58
+ await rename(from, to);
59
+ } catch {
60
+ // File doesn't exist, ignore
61
+ }
62
+ }
63
+
64
+ async function updateHTMLScripts(targetDir: string): Promise<void> {
65
+ const htmlFiles = await glob("**/*.html", { cwd: targetDir });
66
+ for (const file of htmlFiles) {
67
+ const filePath = join(targetDir, file);
68
+ let content = await readFile(filePath, "utf-8");
69
+ content = content.replace(/src="([^"]+)\.tsx"/g, 'src="$1.jsx"');
70
+ content = content.replace(/src="([^"]+)\.ts"/g, 'src="$1.js"');
71
+ await writeFile(filePath, content);
72
+ }
73
+ }
74
+
75
+ async function runCodemod(targetDir: string): Promise<void> {
76
+ const jsFiles = await glob("**/*.{js,jsx}", { cwd: targetDir });
77
+ if (jsFiles.length === 0) return;
78
+
79
+ const __dirname = dirname(fileURLToPath(import.meta.url));
80
+ const codemodPath = join(__dirname, "..", "..", "ts-to-js-codemod.js");
81
+ const pmx = getPMX(detectPM());
82
+ const [cmd, ...baseArgs] = pmx.split(" ");
83
+
84
+ for (const file of jsFiles) {
85
+ const filePath = join(targetDir, file);
86
+ await execa(
87
+ cmd,
88
+ [...baseArgs, "jscodeshift", "-t", codemodPath, "--parser=tsx", filePath],
89
+ {
90
+ cwd: targetDir,
91
+ stdio: "pipe",
92
+ }
93
+ );
94
+ }
95
+ }
96
+
97
+ async function cleanPackageJSON(targetDir: string): Promise<void> {
98
+ const pkgPath = join(targetDir, "package.json");
99
+ try {
100
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
101
+
102
+ // Remove TS dependencies
103
+ for (const dep of TS_DEPS) {
104
+ delete pkg.dependencies?.[dep];
105
+ delete pkg.devDependencies?.[dep];
106
+ }
107
+
108
+ // Update build script to remove tsc
109
+ if (pkg.scripts?.build) {
110
+ pkg.scripts.build = pkg.scripts.build.replace("tsc -b && ", "");
111
+ }
112
+
113
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2));
114
+ } catch {
115
+ // No package.json, ignore
116
+ }
117
+ }
package/src/lib/pm.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { execa } from "execa";
2
+ import { dirname, join } from "path";
3
+
4
+ export type PackageManager = "npm" | "yarn" | "pnpm" | "bun" | "deno";
5
+
6
+ /**
7
+ * Detect the package manager based on how the CLI was invoked.
8
+ * Checks npm_config_user_agent first (set by npx/bunx/etc),
9
+ * then falls back to checking the runtime executable.
10
+ */
11
+ export function detectPM(): PackageManager {
12
+ const ua = process.env.npm_config_user_agent || "";
13
+
14
+ if (ua.startsWith("bun/")) return "bun";
15
+ if (ua.startsWith("yarn/")) return "yarn";
16
+ if (ua.startsWith("pnpm/")) return "pnpm";
17
+ if (ua.startsWith("deno/")) return "deno";
18
+ if (ua.startsWith("npm/")) return "npm";
19
+
20
+ // Fallback: check runtime executable
21
+ const runtime = process.argv0.toLowerCase();
22
+ if (runtime.includes("bun")) return "bun";
23
+ if (runtime.includes("deno")) return "deno";
24
+
25
+ return "npm";
26
+ }
27
+
28
+ /**
29
+ * Get the executable path for the package manager.
30
+ * Uses the runtime path if available for better reliability.
31
+ */
32
+ function getPMExecutable(pm: PackageManager): string {
33
+ const runtime = process.argv0;
34
+
35
+ // If the runtime matches the PM, use it directly (handles custom paths like ~/.bun/bin/bun)
36
+ if (pm === "bun" && runtime.toLowerCase().includes("bun")) return runtime;
37
+ if (pm === "deno" && runtime.toLowerCase().includes("deno")) return runtime;
38
+
39
+ return pm;
40
+ }
41
+
42
+ /**
43
+ * Get the install command for a package manager.
44
+ */
45
+ export function getInstallCmd(pm: PackageManager): string[] {
46
+ const exe = getPMExecutable(pm);
47
+
48
+ switch (pm) {
49
+ case "bun":
50
+ return [exe, "install"];
51
+ case "yarn":
52
+ return [exe, "install"];
53
+ case "pnpm":
54
+ return [exe, "install"];
55
+ case "deno":
56
+ return [exe, "install"];
57
+ default:
58
+ return ["npm", "install"];
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get the package runner (npx, bunx, etc.) for a package manager.
64
+ */
65
+ export function getPMX(pm: PackageManager): string {
66
+ const runtime = process.argv0;
67
+
68
+ // For bun, use bunx from the same directory as the runtime
69
+ if (pm === "bun" && runtime.toLowerCase().includes("bun")) {
70
+ const bunDir = dirname(runtime);
71
+ return join(bunDir, "bunx");
72
+ }
73
+
74
+ switch (pm) {
75
+ case "bun":
76
+ return "bunx";
77
+ case "yarn":
78
+ return "yarn dlx";
79
+ case "pnpm":
80
+ return "pnpm dlx";
81
+ case "deno":
82
+ return "deno run -A npm:";
83
+ default:
84
+ return "npx";
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Run the install command in a directory.
90
+ */
91
+ export async function install(cwd: string): Promise<void> {
92
+ const pm = detectPM();
93
+ const [cmd, ...args] = getInstallCmd(pm);
94
+ await execa(cmd, args, { cwd, stdio: "inherit" });
95
+ }
@@ -0,0 +1,158 @@
1
+ import { cp, readdir, rm, readFile, writeFile, mkdir, stat } from "fs/promises";
2
+ import { join, resolve, basename, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { convertToJS } from "./convert.js";
5
+ import { install } from "./pm.js";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const TEMPLATES_DIR = join(__dirname, "..", "..", "templates");
9
+
10
+ export type Language = "ts" | "js";
11
+
12
+ export interface CreateOptions {
13
+ template: string;
14
+ projectName?: string;
15
+ language: Language;
16
+ overwrite?: boolean;
17
+ skipInstall?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Check if a path exists.
22
+ */
23
+ async function pathExists(path: string): Promise<boolean> {
24
+ try {
25
+ await stat(path);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * List available templates.
34
+ */
35
+ export async function listTemplates(): Promise<
36
+ { name: string; description: string }[]
37
+ > {
38
+ if (!(await pathExists(TEMPLATES_DIR))) return [];
39
+
40
+ const dirs = await readdir(TEMPLATES_DIR, { withFileTypes: true });
41
+ const templates: { name: string; description: string }[] = [];
42
+
43
+ for (const dir of dirs) {
44
+ if (!dir.isDirectory()) continue;
45
+
46
+ let description = "";
47
+ try {
48
+ const meta = JSON.parse(
49
+ await readFile(join(TEMPLATES_DIR, dir.name, "template.json"), "utf-8")
50
+ );
51
+ description = meta.description || "";
52
+ } catch {
53
+ // No template.json
54
+ }
55
+
56
+ templates.push({ name: dir.name, description });
57
+ }
58
+
59
+ return templates;
60
+ }
61
+
62
+ /**
63
+ * Create a project from a template.
64
+ */
65
+ export async function createProject(opts: CreateOptions): Promise<string> {
66
+ const {
67
+ template,
68
+ projectName,
69
+ language,
70
+ overwrite = false,
71
+ skipInstall = false,
72
+ } = opts;
73
+
74
+ const templatePath = join(TEMPLATES_DIR, template);
75
+ if (!(await pathExists(templatePath))) {
76
+ throw new Error(`Template "${template}" not found`);
77
+ }
78
+
79
+ const targetDir = projectName
80
+ ? resolve(process.cwd(), projectName)
81
+ : process.cwd();
82
+
83
+ // Create or clean directory
84
+ if (projectName) {
85
+ // For new projects, ensure directory is completely clean
86
+ try {
87
+ // First check if directory exists
88
+ const exists = await pathExists(targetDir);
89
+ if (exists) {
90
+ await rm(targetDir, { recursive: true, force: true });
91
+ // Wait a bit to ensure deletion is complete
92
+ await new Promise((resolve) => setTimeout(resolve, 10));
93
+ }
94
+ } catch (error) {
95
+ // If rm fails, try to clean contents instead
96
+ try {
97
+ const files = await readdir(targetDir);
98
+ for (const f of files) {
99
+ await rm(join(targetDir, f), { recursive: true, force: true });
100
+ }
101
+ } catch {
102
+ // If that also fails, throw the original error
103
+ throw error;
104
+ }
105
+ }
106
+ await mkdir(targetDir, { recursive: true });
107
+ } else if (overwrite) {
108
+ const files = await readdir(targetDir);
109
+ for (const f of files) {
110
+ if (f === ".git" || f === "node_modules") continue;
111
+ await rm(join(targetDir, f), { recursive: true, force: true });
112
+ }
113
+ }
114
+
115
+ // Copy template
116
+ await cp(templatePath, targetDir, { recursive: true });
117
+
118
+ // Convert to JS if needed
119
+ if (language === "js") {
120
+ await convertToJS(targetDir);
121
+ }
122
+
123
+ // Replace project name in files
124
+ if (projectName) {
125
+ await replaceProjectName(targetDir, basename(projectName));
126
+ }
127
+
128
+ // Install dependencies
129
+ if (!skipInstall && (await pathExists(join(targetDir, "package.json")))) {
130
+ await install(targetDir);
131
+ }
132
+
133
+ return targetDir;
134
+ }
135
+
136
+ async function replaceProjectName(
137
+ targetDir: string,
138
+ name: string
139
+ ): Promise<void> {
140
+ const files = ["index.html", "package.json"];
141
+
142
+ for (const file of files) {
143
+ const filePath = join(targetDir, file);
144
+ if (!(await pathExists(filePath))) continue;
145
+
146
+ let content = await readFile(filePath, "utf-8");
147
+
148
+ if (file === "index.html") {
149
+ content = content.replace(/<title>.*<\/title>/, `<title>${name}</title>`);
150
+ } else if (file === "package.json") {
151
+ const pkg = JSON.parse(content);
152
+ pkg.name = name;
153
+ content = JSON.stringify(pkg, null, 2);
154
+ }
155
+
156
+ await writeFile(filePath, content);
157
+ }
158
+ }
@@ -0,0 +1,19 @@
1
+ // Transform TypeScript files to JavaScript
2
+ module.exports = function (fileInfo, api) {
3
+ const j = api.jscodeshift;
4
+ const root = j(fileInfo.source);
5
+
6
+ // Remove type assertions (as Type)
7
+ root.find(j.TSAsExpression).forEach((path) => {
8
+ path.replace(path.node.expression);
9
+ });
10
+
11
+ // Update import declarations from .tsx to .jsx
12
+ root.find(j.ImportDeclaration).forEach((path) => {
13
+ if (path.node.source.value && path.node.source.value.endsWith(".tsx")) {
14
+ path.node.source.value = path.node.source.value.replace(/\.tsx$/, ".jsx");
15
+ }
16
+ });
17
+
18
+ return root.toSource();
19
+ };
package/cli.tsx DELETED
@@ -1,204 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { useState, useEffect } from "react";
3
- import { render, Text, Box } from "ink";
4
- import SelectInput from "ink-select-input";
5
- import { cp, readdir, rm, mkdir, readFile, writeFile, stat } from "fs/promises";
6
- import { existsSync, readdirSync } from "fs";
7
- import { join, resolve } from "path";
8
- import { spawn } from "child_process";
9
-
10
- const TEMPLATES_DIR = join(import.meta.dir, "templates");
11
-
12
- const exec = (cmd: string, args: string[], cwd: string) =>
13
- new Promise<void>((res, rej) => {
14
- spawn(cmd, args, { cwd, stdio: "inherit" })
15
- .on("close", (code) => (code === 0 ? res() : rej()))
16
- .on("error", rej);
17
- });
18
-
19
- const replaceProjectName = async (targetDir: string, projectName: string) => {
20
- const files = ["index.html", "package.json"];
21
-
22
- for (const file of files) {
23
- const filePath = join(targetDir, file);
24
- if (existsSync(filePath)) {
25
- const content = await readFile(filePath, "utf-8");
26
- const updated = content
27
- .replace(/<title>.*<\/title>/, `<title>${projectName}</title>`)
28
- .replace(/("name":\s*")[^"]*"/, `$1${projectName}"`)
29
- .replace(/vite/g, projectName);
30
- await writeFile(filePath, updated);
31
- }
32
- }
33
- };
34
-
35
- const createProject = async (
36
- template: string,
37
- projectName?: string,
38
- overwrite = false,
39
- skipInstall = false
40
- ) => {
41
- const templatePath = join(TEMPLATES_DIR, template);
42
- const targetDir = projectName
43
- ? resolve(process.cwd(), projectName)
44
- : process.cwd();
45
-
46
- if (!existsSync(templatePath))
47
- throw new Error(`Template "${template}" not found`);
48
-
49
- if (projectName) {
50
- await mkdir(targetDir, { recursive: true });
51
- } else if (overwrite) {
52
- const files = await readdir(targetDir);
53
- await Promise.all(
54
- files
55
- .filter((f) => ![".git", "node_modules"].includes(f))
56
- .map((f) => rm(join(targetDir, f), { recursive: true, force: true }))
57
- );
58
- }
59
-
60
- await cp(templatePath, targetDir, { recursive: true });
61
-
62
- // Replace template placeholders with project name
63
- if (projectName) {
64
- await replaceProjectName(targetDir, projectName);
65
- }
66
-
67
- if (!skipInstall && existsSync(join(targetDir, "package.json"))) {
68
- const pm = process.argv[0].includes("bun") ? "bun" : "npm";
69
- await exec(pm, ["install"], targetDir);
70
- }
71
-
72
- return targetDir;
73
- };
74
-
75
- const App = ({
76
- template,
77
- projectName,
78
- skipInstall = false,
79
- }: {
80
- template: string;
81
- projectName?: string;
82
- skipInstall?: boolean;
83
- }) => {
84
- const [status, setStatus] = useState<
85
- "prompting" | "processing" | "done" | "error"
86
- >("processing");
87
- const [error, setError] = useState("");
88
- const [targetDir, setTargetDir] = useState("");
89
-
90
- useEffect(() => {
91
- const needsPrompt =
92
- !projectName &&
93
- existsSync(process.cwd()) &&
94
- readdirSync(process.cwd()).length > 0;
95
-
96
- if (needsPrompt) {
97
- setStatus("prompting");
98
- return;
99
- }
100
-
101
- createProject(template, projectName, false, skipInstall)
102
- .then((dir) => {
103
- setTargetDir(dir);
104
- setStatus("done");
105
- })
106
- .catch((e) => {
107
- setError(e.message);
108
- setStatus("error");
109
- });
110
- }, [template, projectName, skipInstall]);
111
-
112
- const handleOverwrite = (overwrite: boolean) => {
113
- setStatus("processing");
114
- createProject(template, projectName, overwrite, skipInstall)
115
- .then((dir) => {
116
- setTargetDir(dir);
117
- setStatus("done");
118
- })
119
- .catch((e) => {
120
- setError(e.message);
121
- setStatus("error");
122
- });
123
- };
124
-
125
- if (status === "error") {
126
- return <Text color="red">Error: {error}</Text>;
127
- }
128
-
129
- if (status === "done") {
130
- return <Text color="green">✓ Project created in {targetDir}</Text>;
131
- }
132
-
133
- if (status === "prompting") {
134
- return (
135
- <Box flexDirection="column">
136
- <Text>Directory not empty. What do you want to do?</Text>
137
- <SelectInput
138
- items={[
139
- { label: "Overwrite", value: "overwrite" },
140
- { label: "Cancel", value: "cancel" },
141
- ]}
142
- onSelect={({ value }) => {
143
- if (value === "overwrite") handleOverwrite(true);
144
- else process.exit(0);
145
- }}
146
- />
147
- </Box>
148
- );
149
- }
150
-
151
- return <Text color="yellow">Processing...</Text>;
152
- };
153
-
154
- const args = process.argv.slice(2);
155
- const [template, projectName] = args.filter((arg) => !arg.startsWith("--"));
156
- const skipInstall = args.includes("--skip-install");
157
-
158
- if (!template) {
159
- const templates = existsSync(TEMPLATES_DIR)
160
- ? readdirSync(TEMPLATES_DIR).map((name) => {
161
- let description = "";
162
- try {
163
- const meta = JSON.parse(
164
- require("fs").readFileSync(
165
- join(TEMPLATES_DIR, name, "template.json"),
166
- "utf-8"
167
- )
168
- );
169
- description = meta.description || "";
170
- } catch {}
171
- return { name, description };
172
- })
173
- : [];
174
-
175
- render(
176
- <Box flexDirection="column" padding={1}>
177
- <Text color="red" bold>
178
- Usage: mick-templates &lt;template&gt; [name] [--skip-install]
179
- </Text>
180
- <Box marginTop={1} flexDirection="column">
181
- <Text bold>Available templates:</Text>
182
- <Box borderStyle="single" borderColor="gray" marginTop={1} paddingX={1}>
183
- {templates.map((t, index) => (
184
- <Box key={t.name} flexDirection="row">
185
- <Text color="cyan" bold>
186
- {t.name}
187
- </Text>
188
- {t.description && <Text color="gray"> - {t.description}</Text>}
189
- </Box>
190
- ))}
191
- </Box>
192
- </Box>
193
- </Box>
194
- );
195
- process.exit(1);
196
- }
197
-
198
- render(
199
- <App
200
- template={template}
201
- projectName={projectName}
202
- skipInstall={skipInstall}
203
- />
204
- );