mick-templates 1.0.0 → 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 +28 -0
- package/package.json +25 -4
- package/src/app.tsx +88 -0
- package/src/components/LanguageSelect.tsx +23 -0
- package/src/components/OverwritePrompt.tsx +22 -0
- package/src/components/Status.tsx +21 -0
- package/src/index.tsx +57 -0
- package/src/lib/convert.ts +117 -0
- package/src/lib/pm.ts +95 -0
- package/src/lib/template.ts +158 -0
- package/ts-to-js-codemod.js +19 -0
- package/biome.json +0 -58
- package/cli.tsx +0 -204
- package/tsconfig.json +0 -13
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,17 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mick-templates",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "CLI tool for creating projects from templates",
|
|
4
5
|
"bin": {
|
|
5
|
-
"mick-templates": "cli.
|
|
6
|
+
"mick-templates": "bin/cli.js"
|
|
6
7
|
},
|
|
8
|
+
"files": [
|
|
9
|
+
"templates",
|
|
10
|
+
"src",
|
|
11
|
+
"bin",
|
|
12
|
+
"ts-to-js-codemod.js"
|
|
13
|
+
],
|
|
7
14
|
"type": "module",
|
|
8
15
|
"scripts": {
|
|
9
|
-
"start": "bun
|
|
16
|
+
"start": "bun src/index.tsx"
|
|
10
17
|
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"templates",
|
|
21
|
+
"project-generator",
|
|
22
|
+
"vite",
|
|
23
|
+
"react"
|
|
24
|
+
],
|
|
25
|
+
"author": "Mick",
|
|
26
|
+
"license": "MIT",
|
|
11
27
|
"dependencies": {
|
|
28
|
+
"execa": "^9.5.2",
|
|
29
|
+
"glob": "^10.3.10",
|
|
12
30
|
"ink": "^4.4.1",
|
|
13
31
|
"ink-select-input": "^5.0.0",
|
|
14
|
-
"
|
|
32
|
+
"jscodeshift": "^17.3.0",
|
|
33
|
+
"picocolors": "^1.1.1",
|
|
34
|
+
"react": "^18.2.0",
|
|
35
|
+
"tsx": "^4.21.0"
|
|
15
36
|
},
|
|
16
37
|
"devDependencies": {
|
|
17
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/biome.json
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
|
3
|
-
"vcs": {
|
|
4
|
-
"enabled": true,
|
|
5
|
-
"clientKind": "git",
|
|
6
|
-
"useIgnoreFile": false
|
|
7
|
-
},
|
|
8
|
-
"files": {
|
|
9
|
-
"ignoreUnknown": false,
|
|
10
|
-
"includes": ["**", "!**/node_modules", "!**/dist", "!**/templates"]
|
|
11
|
-
},
|
|
12
|
-
"formatter": {
|
|
13
|
-
"enabled": true,
|
|
14
|
-
"indentStyle": "space",
|
|
15
|
-
"indentWidth": 2,
|
|
16
|
-
"lineWidth": 80
|
|
17
|
-
},
|
|
18
|
-
"linter": {
|
|
19
|
-
"enabled": true,
|
|
20
|
-
"rules": {
|
|
21
|
-
"recommended": true,
|
|
22
|
-
"correctness": {
|
|
23
|
-
"noUnusedVariables": "error",
|
|
24
|
-
"useExhaustiveDependencies": "error"
|
|
25
|
-
},
|
|
26
|
-
"style": {
|
|
27
|
-
"noNonNullAssertion": "warn",
|
|
28
|
-
"useTemplate": "error"
|
|
29
|
-
},
|
|
30
|
-
"suspicious": {
|
|
31
|
-
"noExplicitAny": "warn"
|
|
32
|
-
},
|
|
33
|
-
"nursery": {
|
|
34
|
-
"recommended": true
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
"javascript": {
|
|
39
|
-
"formatter": {
|
|
40
|
-
"quoteStyle": "double",
|
|
41
|
-
"trailingCommas": "es5",
|
|
42
|
-
"semicolons": "always"
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
"assist": {
|
|
46
|
-
"enabled": true,
|
|
47
|
-
"actions": {
|
|
48
|
-
"source": {
|
|
49
|
-
"organizeImports": "on"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
"css": {
|
|
54
|
-
"parser": {
|
|
55
|
-
"tailwindDirectives": true
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
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 <template> [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
|
-
);
|
package/tsconfig.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"jsx": "react-jsx",
|
|
7
|
-
"strict": true,
|
|
8
|
-
"skipLibCheck": true,
|
|
9
|
-
"allowSyntheticDefaultImports": true,
|
|
10
|
-
"types": ["bun"]
|
|
11
|
-
},
|
|
12
|
-
"exclude": ["templates"]
|
|
13
|
-
}
|