dev-manager-tauri/src-tauri/src/commands/templates.rs
lanrtop 0851aa9783 feat(projects/parity): register_project 控制面投影,登记本体拒绝同目录重复登记(P8f)
- projects:从 create_project 抽出登记本体 register_project_records(连接注入),
  UI create_project 与 MCP register_project 共用;只写数据库,不建目录、不执行 git init、不接入。
  拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID;
  重复检查与插入放进同一 IMMEDIATE 事务;workspace 插入去掉 OR IGNORE,ID 冲突不再静默成功
- 同目录判定 registration_path_key:去长路径前缀、斜杠统一、合并重复分隔符、去尾部分隔符、
  不区分大小写;win_path 与 wsl_path 两列都比较,WSL Linux 路径按 wsl.localhost 换算;
  错误信息给出已有 project_id(根治 PR #34 审查 L1/L2:同目录两条登记互相抢占 Codex 托管块)
- mcp_tools:新增 register_project(path 必填、name 可选,project_id 由炼境生成 UUID,归入当前空间,
  structuredContent 返回);只开放在全局路由,is_project_scoped_tool 显式排除;描述写明须用户确认目标
- 批量添加对已登记目录计入 skipped,模板新建与挂载仓库复用同一重复目录检查
- project-context.yaml「MCP 工具接口」段同步(T1)

cargo test 335 passed / 0 failed / 5 ignored(新增 10 条),0 warning;pnpm typecheck 通过;
9 处变异全部被对应用例捕获。

Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:29:26 +09:00

2459 lines
76 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));
}
// 模板新建会先建目录,过不了登记本体的「目录须已存在」校验,只复用其重复目录检查(P8f):
// 目录已删但登记仍在时,不能再新建出第二条同目录登记
{
let conn = db::pool().get().map_err(|e| e.to_string())?;
crate::commands::projects::ensure_path_not_registered(&conn, project_root)?;
}
// ── 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: r#"{"客户端":["Web"],"框架/工具":["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: r#"{"客户端":["Web"],"框架/工具":["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: r#"{"客户端":["CLI","Desktop"],"语言":["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: r#"{"客户端":["Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Tauri","Node","pnpm"]}"#,
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 移动端 App(Android / iOS)。TypeScript + Expo SDK,适合游戏、工具、内容类手机应用。选择下方模板变体来确定初始化方式。",
platform: "both",
tech_stack: r#"{"客户端":["Android","iOS"],"语言":["TypeScript"],"框架/工具":["React Native","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 多包 Monorepo:apps/ 内含 Expo Router (React Native) 应用,packages/ 内含共享 @repo/types 包。Metro 已配置 monorepo 路径解析(含 Windows 兼容修复),适合多 App 扩展场景。",
platform: "both",
tech_stack: r#"{"客户端":["Android","iOS","Web"],"语言":["TypeScript"],"框架/工具":["React Native","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: r#"{"客户端":["Web"],"语言":["TypeScript"],"框架/工具":["React","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: r#"{"客户端":["Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Tauri","TailwindCSS","Node","pnpm"]}"#,
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/web(Vite + Tailwind)+ apps/mobile(Expo + NativeWind)",
platform: "both",
tech_stack: r#"{"客户端":["Android","iOS","Web"],"语言":["TypeScript"],"框架/工具":["React","React Native","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>
)
}
"#),
],
},
// ── 三端体系模板(同一架构,按需起步)────────────────────────────────────
Tpl {
id: "builtin-react-web",
name: "React Web",
description: "三端体系 Web 基础。React 18 + TypeScript + Ant Design 5 + Zustand,可随时扩展为 Capacitor Android 或 Tauri Desktop。",
platform: "both",
tech_stack: r#"{"体系":["三端"],"客户端":["Web"],"语言":["TypeScript"],"框架/工具":["React","Ant Design","Zustand","Vite","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": {
"web": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
};
});
"#),
("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>
"#),
(".env", "VITE_PLATFORM=web\n"),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\n"),
],
},
Tpl {
id: "builtin-react-capacitor",
name: "React + Capacitor",
description: "Web + Android。React 18 + Ant Design 5 + Zustand + Capacitor 8,pnpm build:cap 打包为原生 APK。",
platform: "both",
tech_stack: r#"{"体系":["三端"],"客户端":["Web","Android"],"语言":["TypeScript"],"框架/工具":["React","Ant Design","Zustand","Capacitor","Vite","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": {
"web": "vite",
"build": "vite build",
"build:cap": "vite build --mode capacitor && npx cap sync",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@capacitor/core": "^8.0.0",
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
};
});
"#),
("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", "capacitor.config.ts"]
}
"#),
("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>
"#),
(".env", "VITE_PLATFORM=web\n"),
(".env.capacitor", "VITE_PLATFORM=android\n"),
("capacitor.config.ts", r#"import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.{{project_name_snake}}",
appName: "{{project_name}}",
webDir: "dist",
};
export default config;
"#),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand + Capacitor</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
(".gitignore", "node_modules/\ndist/\n*.log\n.env\n.env.*\n!.env.example\nandroid/\n"),
],
},
Tpl {
id: "builtin-react-tauri-antd",
name: "React + Tauri",
description: "Web + Desktop。React 18 + Ant Design 5 + Zustand + Tauri 2,pnpm build:desktop 打包为原生桌面应用。",
platform: "both",
tech_stack: r#"{"体系":["三端"],"客户端":["Web","Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Ant Design","Zustand","Tauri","Vite","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": {
"web": "vite",
"build": "vite build",
"build:desktop": "tauri build",
"dev:desktop": "tauri dev",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2",
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
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>
"#),
(".env", "VITE_PLATFORM=web\n"),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand + Tauri 2</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
("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 web",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm 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*.log\n.env\n.env.*\n!.env.example\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
Tpl {
id: "builtin-react-triplatform",
name: "React Tri-Platform",
description: "三端合一。同一套 React 18 + Ant Design 5 + Zustand 代码,pnpm web / build:cap / build:desktop 分别构建 Web / Android / Desktop。",
platform: "both",
tech_stack: r#"{"体系":["三端"],"客户端":["Web","Android","Desktop"],"语言":["TypeScript","Rust"],"框架/工具":["React","Ant Design","Zustand","Capacitor","Tauri","Vite","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": {
"web": "vite",
"build": "vite build",
"build:cap": "vite build --mode capacitor && npx cap sync",
"build:desktop": "tauri build",
"dev:desktop": "tauri dev",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@capacitor/core": "^8.0.0",
"@tauri-apps/api": "^2",
"antd": "^5.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
"#),
("vite.config.ts", r#"import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
base: mode === "capacitor" ? "./" : "/",
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", "capacitor.config.ts"]
}
"#),
("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>
"#),
(".env", "VITE_PLATFORM=web\n"),
(".env.capacitor", "VITE_PLATFORM=android\n"),
("capacitor.config.ts", r#"import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.{{project_name_snake}}",
appName: "{{project_name}}",
webDir: "dist",
};
export default config;
"#),
("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 { Button } from "antd";
import { useAppStore } from "./store";
export default function App() {
const { count, increment } = useAppStore();
return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<h1>{{project_name}}</h1>
<p>React 18 + Ant Design 5 + Zustand · Web / Android / Desktop</p>
<Button type="primary" onClick={increment}>
count: {count}
</Button>
</div>
);
}
"#),
("src/store/index.ts", r#"import { create } from "zustand";
interface AppStore {
count: number;
increment: () => void;
}
export const useAppStore = create<AppStore>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"#),
("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 web",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm 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*.log\n.env\n.env.*\n!.env.example\nandroid/\nsrc-tauri/target/\nsrc-tauri/gen/\n"),
],
},
// ── 知识库(择升环)────────────────────────────────────────────────────
Tpl {
id: "builtin-knowledge-loop",
name: "知识库(择升环)",
description: "方法论 / 知识沉淀文档库。三层晋升结构:dialogues 对话原料 → concepts 概念卡 → CORE 稳定核心,晋升规则内置于 AGENTS.md,agent 进库即按择升环工作流运转。",
platform: "both",
tech_stack: r#"{"体系":["知识库"],"框架/工具":["Markdown","Git"]}"#,
init_commands: "git init",
variants: None,
files: vec![
("CORE.md", r#"# {{project_name}} · 核心层
> 本文件是知识库的稳定核心——只收录经过实践检验、表述已收敛的内容。
> ⚠️ 过滤铁律:全文上限 300 行。收录新内容超限时,必须合并或挤掉旧内容——
> 只升不择的核心层会失去判断力。
## 定义
<!-- 本知识库研究的核心概念,一句话可复述的稳定定义 -->
## 已验证原理
<!-- 每条原理注明来源,格式:
### 原理名
一段话陈述。
来源:concepts/<概念卡>.md · 验证案例:cases/<案例>.md
-->
## 修订记录
<!-- 日期 · 进了什么 · 挤掉/合并了什么 · 为什么 -->
"#),
("AGENTS.md", r#"# {{project_name}} — AI Agent 工作规程
本仓库是知识库,不是代码库。目录即晋升流水线:
```
dialogues/(对话原料,允许粗糙)
→ concepts/(概念卡,一事一文件)
→ CORE.md(稳定核心,上限 300 行)
```
cases/ 是检验层:概念卡晋升 CORE 的依据来自真实案例。
## 每次会话必须执行
1. 先读 `CORE.md` 与本次话题相关的 `concepts/` 卡——上一轮的结论是这一轮的输入
2. 探讨结束后,把整理稿写入 `dialogues/YYYY-MM-DD-主题.md`
3. 对照晋升规则检查是否触发晋升 / 降级,触发则执行
4. git commit 收尾——本库以 git 历史作为演化台账,scope 标注层级(见下)
## Commit 层级信号
统一格式 `docs(<层级>): <摘要>`,让外部观测者(如炼境飞轮)凭 commit 流
即可读出晋升频率与环的健康度——dialogue 多而 concept 少 = 只积累不过滤;
concept 多而 core 不动 = 过滤了但不收敛。
- `docs(dialogue): YYYY-MM-DD-主题` — 新对话沉淀
- `docs(concept): <概念名> 立卡|修订|废弃` — 概念卡变动
- `docs(core): <结论>入核,挤掉/合并<旧条目>` — 核心层变动
- `docs(case): <事件>复盘` — 案例复盘
## 晋升规则(择)
| 从 | 到 | 条件 |
|----|----|------|
| dialogues/ | concepts/ | 同一想法在 ≥2 次对话中出现,或 ≥1 个真实案例站住 |
| concepts/ | CORE.md | 状态「稳定」+ 经 cases/ 检验 + 表述收敛为一段话 |
降级同样重要:被案例证伪的概念卡标记废弃;CORE.md 超 300 行必须挤旧。
只升不择,核心层会膨胀成噪音。
## 写作纪律
- 单一事实来源:同一结论只在一层的一个文件里详述,其他位置引用
- 概念卡必须写「边界」(什么条件下不成立)——没有边界的结论不可信
- 修改 CORE.md 必须在其修订记录写明:进了什么、挤了什么、为什么
- 案例复盘必做归因二分:执行缺口(概念没用好)修行动;
规格缺口(概念本身有漏洞)回改概念卡——后者是全库最值钱的信号
"#),
("concepts/README.md", r#"# concepts/ — 概念卡层
一个概念一个文件,文件名 kebab-case(如 `pinggu-wai-huan.md`)。
## 概念卡格式
```markdown
# 概念名
- 状态:孵化 | 稳定 | 已入核 | 已废弃
- 出处:dialogues/YYYY-MM-DD-xxx.md
- 最近验证:YYYY-MM-DD(cases/xxx.md)
## 定义
一段话说清它是什么。
## 为什么成立
支撑它的推理或证据。
## 边界
它在什么条件下不成立。
```
## 晋升与降级
- 晋升进 CORE.md 后,状态改「已入核」,详述保留在卡内,CORE 只收一段话结论
- 被案例证伪 → 状态改「已废弃」并写明证伪案例,不直接删(失败也是判断力)
"#),
("dialogues/README.md", r#"# dialogues/ — 对话沉淀层(原料)
每次探讨结束后,把整理稿存入本目录。这一层的目标是「不丢」,不是「精炼」。
- 命名:`YYYY-MM-DD-主题.md`
- 开头写三行摘要:讨论了什么 / 新想法 / 待验证
- 同一想法第二次出现时,检查是否触发晋升为概念卡
"#),
("cases/README.md", r#"# cases/ — 实例复盘层(检验)
记录概念在真实事件中的应用与翻车。案例是概念卡晋升 CORE 的检验依据。
- 命名:`YYYY-MM-DD-事件.md`
- 必写字段:用了哪个概念 / 预期 / 实际结果 / 归因(执行缺口 or 规格缺口)
- 规格缺口 → 回改概念卡;执行缺口 → 概念不动,记录教训
"#),
(".gitignore", ".DS_Store\nThumbs.db\n*.tmp\n"),
],
},
];
for t in &templates {
let _ = conn.execute(
"INSERT INTO project_templates
(id, name, description, platform, tech_stack, init_commands, is_builtin, variants)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
tech_stack = excluded.tech_stack,
init_commands = excluded.init_commands,
variants = excluded.variants",
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,
],
);
for (path, content) in &t.files {
let file_id = format!("{}-{}", t.id, path.replace('/', "-"));
let _ = conn.execute(
"INSERT OR REPLACE INTO project_template_files (id, template_id, path, content)
VALUES (?1, ?2, ?3, ?4)",
params![file_id, t.id, path, content],
);
}
}
}