dev-manager-tauri/src-tauri/src/commands/templates.rs
lanrtop 4dd19574f0 feat: 新增 pnpm Monorepo+Expo 模板及蓝图模块扩展
- 新增 pnpm Monorepo + Expo Router 项目模板(含 Metro monorepo 路径解析及 Windows 兼容修复)
- 扩展蓝图系统:新增 api-keys、server-registry、ssh-config-sync、vscode-remote 等 15+ 模块定义
- 新增 .blueprint/rules.md 项目私有规则支持,并在 CLAUDE.md 中集成
- 蓝图版本升级至 1.6.0,优化版本比较逻辑(语义化版本元组对比)
2026-04-29 22:11:28 +09:00

1625 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::db;
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::Stdio;
use uuid::Uuid;
// ── 数据结构 ──────────────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Clone)]
pub struct TemplateFile {
pub id: String,
pub template_id: String,
pub path: String,
pub content: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ProjectTemplate {
pub id: String,
pub name: String,
pub description: Option<String>,
pub platform: String, // "win" | "wsl" | "both"
pub tech_stack: Option<String>,
pub init_commands: Option<String>,
pub is_builtin: bool,
pub created_at: Option<String>,
#[serde(default)]
pub files: Vec<TemplateFile>,
/// JSON 数组,格式:[{label, description, initCommands}]
/// 存在时前端显示下拉让用户选择初始化方式
pub variants: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateFileInput {
pub path: String,
pub content: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveTemplateInput {
pub id: Option<String>,
pub name: String,
pub description: Option<String>,
pub platform: String,
pub tech_stack: Option<String>,
pub init_commands: Option<String>,
pub files: Vec<TemplateFileInput>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProjectResult {
pub project_id: String,
pub logs: Vec<String>,
}
// ── Tauri 命令 ────────────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_templates() -> Result<Vec<ProjectTemplate>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at, variants
FROM project_templates ORDER BY is_builtin DESC, name",
)
.map_err(|e| e.to_string())?;
let templates = stmt
.query_map([], |row| {
Ok(ProjectTemplate {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
platform: row.get(3)?,
tech_stack: row.get(4)?,
init_commands: row.get(5)?,
is_builtin: row.get::<_, i64>(6)? == 1,
created_at: row.get(7)?,
files: vec![],
variants: row.get(8)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(templates)
}
#[tauri::command]
pub fn get_template(id: String) -> Result<ProjectTemplate, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut tmpl = conn
.query_row(
"SELECT id, name, description, platform, tech_stack, init_commands, is_builtin, created_at, variants
FROM project_templates WHERE id = ?1",
params![id],
|row| {
Ok(ProjectTemplate {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
platform: row.get(3)?,
tech_stack: row.get(4)?,
init_commands: row.get(5)?,
is_builtin: row.get::<_, i64>(6)? == 1,
created_at: row.get(7)?,
files: vec![],
variants: row.get(8)?,
})
},
)
.map_err(|e| format!("模板不存在: {e}"))?;
let mut file_stmt = conn
.prepare(
"SELECT id, template_id, path, content
FROM project_template_files WHERE template_id = ?1 ORDER BY path",
)
.map_err(|e| e.to_string())?;
tmpl.files = file_stmt
.query_map(params![id], |row| {
Ok(TemplateFile {
id: row.get(0)?,
template_id: row.get(1)?,
path: row.get(2)?,
content: row.get(3)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(tmpl)
}
#[tauri::command]
pub fn save_template(input: SaveTemplateInput) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string());
conn.execute(
"INSERT INTO project_templates (id, name, description, platform, tech_stack, init_commands, is_builtin)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
platform = excluded.platform,
tech_stack = excluded.tech_stack,
init_commands = excluded.init_commands",
params![id, input.name, input.description, input.platform, input.tech_stack, input.init_commands],
)
.map_err(|e| e.to_string())?;
// 全量替换文件列表
conn.execute(
"DELETE FROM project_template_files WHERE template_id = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
for file in &input.files {
let file_id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO project_template_files (id, template_id, path, content) VALUES (?1, ?2, ?3, ?4)",
params![file_id, id, file.path, file.content],
)
.map_err(|e| e.to_string())?;
}
Ok(id)
}
#[tauri::command]
pub fn delete_template(id: String) -> Result<(), String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM project_templates WHERE id = ?1 AND is_builtin = 0",
params![id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn create_project_from_template(
app: tauri::AppHandle,
template_id: String,
parent_path: String,
project_name: String,
platform: String,
space_id: Option<String>,
variant_init_commands: Option<String>,
) -> Result<CreateProjectResult, String> {
use tauri::Emitter;
let mut tmpl = get_template(template_id)?;
// 如果用户选择了 variant用其 initCommands 覆盖模板默认值
if let Some(v) = variant_init_commands {
tmpl.init_commands = Some(v);
}
let mut logs: Vec<String> = Vec::new();
// 辅助:同时 emit 事件 + 记录到 logs
macro_rules! log_push {
($msg:expr) => {{
let m: String = $msg;
let _ = app.emit("template:progress", &m);
logs.push(m);
}};
}
// ── 1. 计算项目路径 ───────────────────────────────────────────────────────
let win_parent = crate::commands::publish::wsl_to_win(&parent_path);
let project_win_path = format!(
"{}\\{}",
win_parent.trim_end_matches('\\'),
project_name
);
let project_root = Path::new(&project_win_path);
if project_root.exists() {
return Err(format!("目录已存在: {}", project_win_path));
}
// ── 2. 创建根目录 ─────────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:1");
std::fs::create_dir_all(project_root)
.map_err(|e| format!("创建目录失败: {e}"))?;
log_push!(format!("📁 已创建目录: {}", project_win_path));
// ── 3. 写入模板文件 ───────────────────────────────────────────────────────
for file in &tmpl.files {
if file.path.trim().is_empty() {
continue;
}
let content = apply_vars(&file.content, &project_name);
let file_path = project_root.join(&file.path);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("创建子目录失败 {}: {e}", file.path))?;
}
std::fs::write(&file_path, content)
.map_err(|e| format!("写入 {} 失败: {e}", file.path))?;
log_push!(format!("📄 {}", file.path));
}
// ── 4. 执行初始化命令 ─────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:2");
if let Some(cmds) = &tmpl.init_commands {
let linux_project_path = if platform == "wsl" {
crate::commands::publish::extract_linux_path(&parent_path, &win_parent)
.map(|p| format!("{}/{}", p.trim_end_matches('/'), project_name))
} else {
None
};
for line in cmds.lines() {
let cmd = line.trim();
if cmd.is_empty() || cmd.starts_with('#') {
continue;
}
let _ = app.emit("template:progress", &format!("__cmd:{}", cmd));
log_push!(format!("⚙ 执行: {}", cmd));
// 用 spawn 流式读 stdout实时推送每行输出
let spawn_result = if platform == "wsl" {
let lp = linux_project_path.as_deref().unwrap_or(&project_win_path);
crate::silent_cmd("wsl")
.args(["bash", "-c", &format!("cd '{}' && {}", lp, cmd)])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
} else {
crate::silent_cmd("cmd")
.args(["/c", &format!("chcp 65001 > nul && {}", cmd)])
.current_dir(&project_win_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
};
match spawn_result {
Ok(mut child) => {
// 流式读 stdout每行立即 emit
if let Some(stdout) = child.stdout.take() {
for line in BufReader::new(stdout).lines() {
if let Ok(line) = line {
let line = line.trim().to_string();
if !line.is_empty() {
log_push!(line);
}
}
}
}
// 等进程结束,收集 stderr
let output = child.wait_with_output().map_err(|e| e.to_string())?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
log_push!(format!("{}", stderr));
}
if output.status.success() {
log_push!("".to_string());
} else {
log_push!(format!(" ✗ 退出码: {}", output.status));
}
}
Err(e) => {
log_push!(format!(" ✗ 启动失败: {e}"));
}
}
}
}
// ── 5. 注册到数据库 ───────────────────────────────────────────────────────
let _ = app.emit("template:progress", "__step:3");
let conn = db::pool().get().map_err(|e| e.to_string())?;
let profile_id = Uuid::new_v4().to_string();
let workspace_id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO project_profiles (id, name, description, type, status, priority, tech_stack)
VALUES (?1, ?2, ?3, 'app', 'active', 'mid', ?4)",
params![profile_id, project_name, tmpl.description, tmpl.tech_stack],
)
.map_err(|e| e.to_string())?;
let is_wsl = platform == "wsl";
let (wsl_path, win_path_col): (Option<&str>, Option<&str>) = if is_wsl {
(Some(&project_win_path), None)
} else {
(None, Some(&project_win_path))
};
let stored_platform = if is_wsl { "wsl" } else { "windows" };
conn.execute(
"INSERT INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![workspace_id, profile_id, space_id, wsl_path, win_path_col, stored_platform],
)
.map_err(|e| e.to_string())?;
log_push!(format!("✅ 项目已注册ID: {}", workspace_id));
Ok(CreateProjectResult {
project_id: workspace_id,
logs,
})
}
// ── 变量替换 ──────────────────────────────────────────────────────────────────
fn apply_vars(content: &str, project_name: &str) -> String {
let kebab = project_name
.to_lowercase()
.replace(' ', "-")
.replace('_', "-");
let snake = project_name
.to_lowercase()
.replace(' ', "_")
.replace('-', "_");
content
.replace("{{project_name}}", project_name)
.replace("{{project_name_kebab}}", &kebab)
.replace("{{project_name_snake}}", &snake)
}
// ── 内置模板种子数据 ──────────────────────────────────────────────────────────
pub fn seed_builtin_templates() {
let conn = match db::pool().get() {
Ok(c) => c,
Err(_) => return,
};
// 不做全局跳过,直接用 INSERT OR IGNORE 保证幂等
// 这样新增内置模板时,已有用户也能自动获得
struct Tpl {
id: &'static str,
name: &'static str,
description: &'static str,
platform: &'static str,
tech_stack: &'static str,
init_commands: &'static str,
files: Vec<(&'static str, &'static str)>,
/// JSON 字符串,存在时前端显示下拉选择初始化方式
variants: Option<&'static str>,
}
let templates = vec![
Tpl {
id: "builtin-pnpm-monorepo",
name: "pnpm Monorepo",
description: "多包 monorepo含 apps/ 与 packages/ 目录",
platform: "both",
tech_stack: "Node,pnpm",
init_commands: "pnpm install",
variants: None,
files: vec![
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"scripts": {
"dev": "pnpm -r --filter './apps/**' dev",
"build": "pnpm -r --filter './apps/**' build",
"lint": "pnpm -r lint"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
}
"#),
("pnpm-workspace.yaml", r#"packages:
- 'apps/*'
- 'packages/*'
"#),
(".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"),
("apps/.gitkeep", ""),
("packages/.gitkeep", ""),
(".gitignore", "node_modules/\ndist/\n.next/\n*.log\n.env\n.env.*\n!.env.example\n"),
],
},
Tpl {
id: "builtin-pnpm-app",
name: "pnpm App",
description: "单包 Node.js 应用",
platform: "both",
tech_stack: "Node,pnpm",
init_commands: "pnpm install",
variants: None,
files: vec![
("package.json", r#"{
"name": "{{project_name_kebab}}",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
}
}
"#),
("src/index.js", "console.log('Hello from {{project_name}}!')\n"),
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n"),
],
},
Tpl {
id: "builtin-rust",
name: "Rust",
description: "Rust 二进制项目",
platform: "both",
tech_stack: "Rust",
init_commands: "cargo build",
variants: None,
files: vec![
("Cargo.toml", r#"[package]
name = "{{project_name_kebab}}"
version = "0.1.0"
edition = "2021"
[dependencies]
"#),
("src/main.rs", "fn main() {\n println!(\"Hello from {{project_name}}!\");\n}\n"),
(".gitignore", "target/\n"),
],
},
Tpl {
id: "builtin-empty",
name: "空文件夹",
description: "只创建目录,不添加任何文件",
platform: "both",
tech_stack: "",
init_commands: "",
variants: None,
files: vec![],
},
Tpl {
id: "builtin-tauri-react",
name: "Tauri + React",
description: "桌面应用Tauri v2 + React 19 + TypeScript + Vite",
platform: "both",
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri",
init_commands: "pnpm install",
variants: None,
files: vec![
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));
"#),
("tsconfig.json", r#"{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
"#),
("index.html", r#"<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{project_name}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
"#),
("src/main.tsx", r#"import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
"#),
("src/App.tsx", r#"import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>Tauri v2 + React + TypeScript</p>
<button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
</div>
);
}
export default App;
"#),
("src-tauri/tauri.conf.json", r#"{
"$schema": "https://schema.tauri.app/config/2",
"productName": "{{project_name_kebab}}",
"version": "0.1.0",
"identifier": "com.example.{{project_name_snake}}",
"build": {
"beforeDevCommand": "pnpm vite",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm vite build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "{{project_name}}",
"width": 1200,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": []
}
}
"#),
("src-tauri/Cargo.toml", r#"[package]
name = "{{project_name_snake}}"
version = "0.1.0"
description = "A Tauri App"
authors = []
edition = "2021"
[lib]
name = "{{project_name_snake}}_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
"#),
("src-tauri/build.rs", "fn main() {\n tauri_build::build()\n}\n"),
("src-tauri/src/main.rs", r#"// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
{{project_name_snake}}_lib::run()
}
"#),
("src-tauri/src/lib.rs", r#"#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
"#),
("src-tauri/capabilities/default.json", r#"{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
"#),
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
Tpl {
id: "builtin-expo",
name: "Expo",
description: "React Native 移动端 AppAndroid / iOS。TypeScript + Expo SDK适合游戏、工具、内容类手机应用。选择下方模板变体来确定初始化方式。",
platform: "both",
tech_stack: "React Native,TypeScript,Expo",
init_commands: "pnpm create expo-app@latest . --template blank-typescript",
variants: Some(r#"[
{"label":"blank-typescript推荐","description":"最小化 TypeScript 起步,适合游戏类、工具类、独立 App","initCommands":"pnpm create expo-app@latest . --template blank-typescript"},
{"label":"tabs底部标签导航","description":"内置底部 Tab 栏 + Expo Router适合多页面内容型 App","initCommands":"pnpm create expo-app@latest . --template tabs"},
{"label":"bare裸原生配置","description":"完整原生 Expo 配置,可深度定制原生模块,适合进阶项目","initCommands":"pnpm create expo-app@latest . --template bare-minimum"},
{"label":"默认(向导模式)","description":"运行官方交互式向导,在终端中选择模板类型","initCommands":"pnpm create expo-app@latest ."}
]"#),
files: vec![],
},
Tpl {
id: "builtin-pnpm-expo-monorepo",
name: "pnpm Monorepo + Expo",
description: "pnpm 多包 Monorepoapps/ 内含 Expo Router (React Native) 应用packages/ 内含共享 @repo/types 包。Metro 已配置 monorepo 路径解析(含 Windows 兼容修复),适合多 App 扩展场景。",
platform: "both",
tech_stack: "React Native,TypeScript,Expo,pnpm",
init_commands: "pnpm install",
variants: None,
files: vec![
// ── monorepo 根 ──────────────────────────────────────────────
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"scripts": {
"start": "pnpm --filter app start",
"android": "pnpm --filter app android",
"ios": "pnpm --filter app ios",
"lint": "pnpm -r lint"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
}
"#),
("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n - 'packages/*'\n"),
("tsconfig.base.json", r#"{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", "build"]
}
"#),
(".gitignore", "node_modules/\ndist/\n.expo/\nandroid/\nios/\n*.log\n.env\n.env.*\n!.env.example\n"),
(".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"),
// ── packages/types ───────────────────────────────────────────
("packages/types/package.json", r#"{
"name": "@repo/types",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"files": ["src"],
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}
"#),
("packages/types/src/index.ts", "// 在此导出跨 App 共享的类型\nexport type {}\n"),
// ── apps/app ─────────────────────────────────────────────────
("apps/app/package.json", r#"{
"name": "app",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"@repo/types": "workspace:*",
"expo": "~52.0.0",
"expo-router": "~4.0.0",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-native": "0.76.3",
"react-native-gesture-handler": "~2.20.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~18.3.12",
"typescript": "^5.4.5"
}
}
"#),
("apps/app/app.json", r##"{
"expo": {
"name": "{{project_name}}",
"slug": "{{project_name_kebab}}",
"version": "1.0.0",
"orientation": "default",
"userInterfaceStyle": "light",
"scheme": "{{project_name_kebab}}",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.example.{{project_name_snake}}"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff"
},
"package": "com.example.{{project_name_snake}}"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
"##),
("apps/app/tsconfig.json", r#"{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@repo/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
"#),
("apps/app/babel.config.js", r#"module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}
"#),
// Metro 配置monorepo 路径解析 + Windows release 构建兼容修复
("apps/app/metro.config.js", r#"const { getDefaultConfig } = require('expo/metro-config')
const path = require('path')
const projectRoot = __dirname
const monorepoRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
// Windows release 构建兼容:强制 unstable_serverRoot 为 projectRoot
// 避免 Metro 与 RN Gradle 插件的路径基准不一致。
config.server = {
...config.server,
unstable_serverRoot: projectRoot,
}
// 让 Metro 监听 monorepo 根目录,确保根 node_modules 可被解析
config.watchFolders = [monorepoRoot]
// 模块解析:优先查 app 自己的 node_modules再查 monorepo 根
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
]
module.exports = config
"#),
("apps/app/expo-env.d.ts", "/// <reference types=\"expo/types\" />\n\n// NOTE: This file should not be edited and should be in your git ignore\n"),
("apps/app/app/_layout.tsx", r#"import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack>
<Stack.Screen name="index" options={{ title: '{{project_name}}' }} />
</Stack>
</>
)
}
"#),
("apps/app/app/index.tsx", r#"import { View, Text, StyleSheet } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function HomeScreen() {
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>{{project_name}}</Text>
<Text style={styles.subtitle}>Expo Router + pnpm Monorepo</Text>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
title: { fontSize: 24, fontWeight: 'bold' },
subtitle: { fontSize: 14, color: '#666' },
})
"#),
],
},
// ── P1: React + Vite SPA ─────────────────────────────────────────────
Tpl {
id: "builtin-vite-react",
name: "React + Vite SPA",
description: "纯前端 Web 应用React 19 + TypeScript + TailwindCSS v4 + React Router v7",
platform: "both",
tech_stack: "React,TypeScript,Vite,TailwindCSS",
init_commands: "pnpm install",
variants: None,
files: vec![
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19",
"react-dom": "^19",
"react-router": "^7"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
"#),
("tsconfig.json", r#"{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
"#),
("index.html", r#"<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{project_name}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
"#),
("src/main.tsx", r#"import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
"#),
("src/App.tsx", r#"import { Routes, Route } from "react-router";
import Home from "./pages/Home";
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
</Routes>
);
}
"#),
("src/index.css", "@import \"tailwindcss\";\n"),
("src/pages/Home.tsx", r#"export default function Home() {
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">{{project_name}}</h1>
<p className="mt-2 text-gray-500">React + Vite + TailwindCSS</p>
</div>
</main>
);
}
"#),
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\n"),
],
},
// ── P1: Tauri + React + TailwindCSS ──────────────────────────────────
Tpl {
id: "builtin-tauri-react-tailwind",
name: "Tauri + React + Tailwind",
description: "桌面应用Tauri v2 + React 19 + TypeScript + TailwindCSS v4",
platform: "both",
tech_stack: "Rust,Node,pnpm,React,TypeScript,Tauri,TailwindCSS",
init_commands: "pnpm install",
variants: None,
files: vec![
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));
"#),
("tsconfig.json", r#"{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
"#),
("index.html", r#"<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{project_name}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
"#),
("src/main.tsx", r#"import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
"#),
("src/index.css", "@import \"tailwindcss\";\n"),
("src/App.tsx", r#"import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900">{{project_name}}</h1>
<p className="text-gray-500">Tauri v2 + React + TailwindCSS</p>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => setCount((c) => c + 1)}
>
count: {count}
</button>
</div>
</main>
);
}
export default App;
"#),
("src-tauri/tauri.conf.json", r#"{
"$schema": "https://schema.tauri.app/config/2",
"productName": "{{project_name_kebab}}",
"version": "0.1.0",
"identifier": "com.example.{{project_name_snake}}",
"build": {
"beforeDevCommand": "pnpm vite",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm vite build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "{{project_name}}",
"width": 1200,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": []
}
}
"#),
("src-tauri/Cargo.toml", r#"[package]
name = "{{project_name_snake}}"
version = "0.1.0"
description = "A Tauri App"
authors = []
edition = "2021"
[lib]
name = "{{project_name_snake}}_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
"#),
("src-tauri/build.rs", "fn main() {\n tauri_build::build()\n}\n"),
("src-tauri/src/main.rs", r#"// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
{{project_name_snake}}_lib::run()
}
"#),
("src-tauri/src/lib.rs", r#"#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
"#),
("src-tauri/capabilities/default.json", r#"{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
"#),
(".gitignore", "node_modules/\ndist/\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
// ── P3: Web + Mobile 共享 Monorepo ────────────────────────────────────
Tpl {
id: "builtin-web-mobile-monorepo",
name: "Web + Mobile Monorepo",
description: "跨平台代码复用packages/core 共享业务逻辑apps/webVite + Tailwind+ apps/mobileExpo + NativeWind",
platform: "both",
tech_stack: "React,React Native,TypeScript,Expo,Vite,TailwindCSS,NativeWind,pnpm",
init_commands: "pnpm install",
variants: None,
files: vec![
// ── 根目录 ──────────────────────────────────────────────────────
("package.json", r#"{
"name": "{{project_name_kebab}}",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
"build:web": "pnpm --filter web build",
"start:mobile": "pnpm --filter mobile start",
"android": "pnpm --filter mobile android",
"ios": "pnpm --filter mobile ios",
"typecheck": "pnpm -r typecheck"
},
"engines": {
"node": ">=18",
"pnpm": ">=8"
}
}
"#),
("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n - 'packages/*'\n"),
("tsconfig.base.json", r#"{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", "build"]
}
"#),
(".gitignore", "node_modules/\ndist/\nbuild/\n.expo/\nandroid/\nios/\n*.log\n.env\n.env.*\n!.env.example\n"),
(".npmrc", "strict-peer-dependencies=false\nauto-install-peers=true\n"),
// ── packages/types ──────────────────────────────────────────────
("packages/types/package.json", r#"{
"name": "@repo/types",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5"
}
}
"#),
("packages/types/tsconfig.json", r#"{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
"#),
("packages/types/src/index.ts", r#"// 在此定义跨平台共享的类型
export interface User {
id: string
name: string
email: string
}
export interface ApiResponse<T> {
data: T
error?: string
}
"#),
// ── packages/core ───────────────────────────────────────────────
("packages/core/package.json", r#"{
"name": "@repo/core",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/types": "workspace:*"
},
"devDependencies": {
"typescript": "^5"
}
}
"#),
("packages/core/tsconfig.json", r#"{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
"#),
("packages/core/src/index.ts", r#"// 在此导出跨平台共享的业务逻辑(纯 TS不依赖任何平台 API
export * from "./api"
"#),
("packages/core/src/api.ts", r#"import type { ApiResponse, User } from "@repo/types"
const BASE_URL = ""
export async function getUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`${BASE_URL}/api/users/${id}`)
if (!res.ok) return { data: null as unknown as User, error: res.statusText }
const data = await res.json()
return { data }
}
"#),
// ── apps/web ────────────────────────────────────────────────────
("apps/web/package.json", r#"{
"name": "web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core": "workspace:*",
"@repo/types": "workspace:*",
"react": "^19",
"react-dom": "^19",
"react-router": "^7"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6"
}
}
"#),
("apps/web/tsconfig.json", r#"{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@repo/core": ["../../packages/core/src/index.ts"],
"@repo/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["src"]
}
"#),
("apps/web/vite.config.ts", r#"import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
"#),
("apps/web/index.html", r#"<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{project_name}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
"#),
("apps/web/src/main.tsx", r#"import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
"#),
("apps/web/src/index.css", "@import \"tailwindcss\";\n"),
("apps/web/src/App.tsx", r#"import { Routes, Route } from "react-router";
import Home from "./pages/Home";
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
</Routes>
);
}
"#),
("apps/web/src/pages/Home.tsx", r#"export default function Home() {
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">{{project_name}}</h1>
<p className="mt-2 text-gray-500">Web + Mobile Monorepo</p>
</div>
</main>
);
}
"#),
// ── apps/mobile ─────────────────────────────────────────────────
("apps/mobile/package.json", r#"{
"name": "mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core": "workspace:*",
"@repo/types": "workspace:*",
"expo": "~52.0.0",
"expo-router": "~4.0.0",
"expo-status-bar": "~2.0.0",
"nativewind": "^4.0.1",
"react": "18.3.1",
"react-native": "0.76.3",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~18.3.12",
"typescript": "^5.4.5"
}
}
"#),
("apps/mobile/app.json", r##"{
"expo": {
"name": "{{project_name}}",
"slug": "{{project_name_kebab}}",
"version": "1.0.0",
"scheme": "{{project_name_kebab}}",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.example.{{project_name_snake}}"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff"
},
"package": "com.example.{{project_name_snake}}"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
"##),
("apps/mobile/tsconfig.json", r#"{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@repo/core": ["../../packages/core/src/index.ts"],
"@repo/types": ["../../packages/types/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
"#),
("apps/mobile/tailwind.config.js", r#"/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
}
"#),
("apps/mobile/global.css", "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"),
("apps/mobile/babel.config.js", r#"module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: ['nativewind/babel'],
}
}
"#),
("apps/mobile/metro.config.js", r#"const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')
const path = require('path')
const projectRoot = __dirname
const monorepoRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
config.watchFolders = [monorepoRoot]
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
]
module.exports = withNativeWind(config, { input: './global.css' })
"#),
("apps/mobile/expo-env.d.ts", "/// <reference types=\"expo/types\" />\n"),
("apps/mobile/app/_layout.tsx", r#"import '../global.css'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack>
<Stack.Screen name="index" options={{ title: '{{project_name}}' }} />
</Stack>
</>
)
}
"#),
("apps/mobile/app/index.tsx", r#"import { View, Text } from 'react-native'
export default function HomeScreen() {
return (
<View className="flex-1 items-center justify-center bg-gray-50">
<Text className="text-3xl font-bold text-gray-900">{{project_name}}</Text>
<Text className="mt-2 text-gray-500">Web + Mobile Monorepo</Text>
</View>
)
}
"#),
],
},
];
for t in &templates {
let _ = conn.execute(
"INSERT OR IGNORE INTO project_templates
(id, name, description, platform, tech_stack, init_commands, is_builtin, variants)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)",
params![
t.id,
t.name,
if t.description.is_empty() { None } else { Some(t.description) },
t.platform,
if t.tech_stack.is_empty() { None } else { Some(t.tech_stack) },
if t.init_commands.is_empty() { None } else { Some(t.init_commands) },
t.variants,
],
);
// 已存在的模板也补充 variantsINSERT OR IGNORE 不更新已有行)
let _ = conn.execute(
"UPDATE project_templates SET variants = ?1 WHERE id = ?2 AND is_builtin = 1",
params![t.variants, t.id],
);
for (path, content) in &t.files {
let file_id = format!("{}-{}", t.id, path.replace('/', "-"));
let _ = conn.execute(
"INSERT OR IGNORE INTO project_template_files (id, template_id, path, content)
VALUES (?1, ?2, ?3, ?4)",
params![file_id, t.id, path, content],
);
}
}
}