1use std::fs;
2use std::path::{Path, PathBuf};
3
4use include_dir::{include_dir, Dir};
5
6use crate::prompts::InstanceConfig;
7use crate::template;
8
9static REFERENCE_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/reference-app");
12
13const SKIP_NAMES: &[&str] = &[
15 "node_modules",
16 ".nuxt",
17 ".output",
18 ".stryker-tmp",
19 "uploads",
20 ".env",
21 "vitest.config.ts",
22 "__tests__",
23 "e2e",
24 "scripts",
25];
26
27fn should_skip(name: &str) -> bool {
28 SKIP_NAMES.contains(&name)
29}
30
31pub fn create_instance(name: &str, config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
32 let path = PathBuf::from(name);
33 if path.exists() {
34 return Err(format!("Directory '{}' already exists", name).into());
35 }
36 fs::create_dir_all(&path)?;
37 write_reference_app(&path, config)?;
38 Ok(fs::canonicalize(&path)?)
39}
40
41pub fn create_instance_at(base: &Path, name: &str, config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
42 let path = base.join(name);
43 if path.exists() {
44 return Err(format!("Directory '{}' already exists", path.display()).into());
45 }
46 fs::create_dir_all(&path)?;
47 write_reference_app(&path, config)?;
48 Ok(fs::canonicalize(&path)?)
49}
50
51pub fn init_instance(config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
52 let path = PathBuf::from(".");
53 write_reference_app(&path, config)?;
54 Ok(fs::canonicalize(&path)?)
55}
56
57pub fn init_instance_at(dir: &Path, config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
58 write_reference_app(dir, config)?;
59 Ok(fs::canonicalize(dir)?)
60}
61
62fn write_reference_app(dir: &Path, config: &InstanceConfig) -> Result<(), Box<dyn std::error::Error>> {
63 copy_dir_recursive(&REFERENCE_APP, dir)?;
65
66 patch_package_json(dir, config)?;
68
69 patch_nuxt_config(dir, config)?;
71
72 let env_content = template::render_env(config);
74 fs::write(dir.join(".env"), env_content)?;
75
76 let config_content = template::render_config(config);
78 fs::write(dir.join("commonpub.config.ts"), config_content)?;
79
80 if config.use_docker {
82 let compose = template::render_docker_compose(config);
83 fs::write(dir.join("docker-compose.yml"), compose)?;
84 }
85
86 let drizzle_config = template::render_drizzle_config(config);
88 fs::write(dir.join("drizzle.config.ts"), drizzle_config)?;
89
90 fs::create_dir_all(dir.join("uploads"))?;
92 fs::write(dir.join("uploads/.gitkeep"), "")?;
93
94 fs::write(dir.join(".gitignore"), template::render_gitignore())?;
96
97 let readme = format!(
99 "# {}\n\n{}\n\nBuilt with [CommonPub](https://commonpub.dev).\n",
100 config.name, config.description
101 );
102 fs::write(dir.join("README.md"), readme)?;
103
104 Ok(())
105}
106
107fn copy_dir_recursive(embedded: &Dir, target: &Path) -> Result<(), Box<dyn std::error::Error>> {
109 for file in embedded.files() {
110 let name = file.path().file_name().and_then(|n| n.to_str()).unwrap_or("");
111 if should_skip(name) {
112 continue;
113 }
114
115 let dest = target.join(file.path());
116 if let Some(parent) = dest.parent() {
117 fs::create_dir_all(parent)?;
118 }
119 fs::write(&dest, file.contents())?;
120 }
121
122 for sub_dir in embedded.dirs() {
123 let name = sub_dir.path().file_name().and_then(|n| n.to_str()).unwrap_or("");
124 if should_skip(name) {
125 continue;
126 }
127 copy_dir_recursive(sub_dir, target)?;
128 }
129
130 Ok(())
131}
132
133fn patch_package_json(dir: &Path, config: &InstanceConfig) -> Result<(), Box<dyn std::error::Error>> {
135 let pkg_path = dir.join("package.json");
136 let content = fs::read_to_string(&pkg_path)?;
137
138 let patched = content
139 .replace("\"workspace:*\"", "\"^0.4.0\"")
141 .replace("\"@commonpub/reference\"", &format!("\"{}\"", config.name))
143 .replace("\"generate\": \"nuxt generate\",\n ", "")
145 .replace("\"test\": \"vitest run\",\n ", "")
146 .replace("\"lint\": \"eslint .\",\n ", "")
147 .replace("\"typecheck\": \"nuxt typecheck\",\n ", "")
148 .replace("\"clean\": \"rm -rf .nuxt .output\",\n ", "")
149 .replace("\"seed\": \"tsx scripts/seed.ts\"\n", "\"db:push\": \"drizzle-kit push\",\n \"db:studio\": \"drizzle-kit studio\"\n");
150
151 let patched = if !patched.contains("@types/node") {
153 patched.replace(
154 "\"devDependencies\": {",
155 "\"devDependencies\": {\n \"@types/node\": \"^22.0.0\",\n \"drizzle-kit\": \"^0.31.0\","
156 )
157 } else {
158 patched
159 };
160
161 fs::write(&pkg_path, patched)?;
162 Ok(())
163}
164
165fn patch_nuxt_config(dir: &Path, config: &InstanceConfig) -> Result<(), Box<dyn std::error::Error>> {
167 let config_path = dir.join("nuxt.config.ts");
168 let content = fs::read_to_string(&config_path)?;
169
170 let patched = content
171 .replace("import { fileURLToPath } from 'node:url';\n", "")
173 .replace("import { resolve, dirname } from 'node:path';\n", "")
174 .replace("\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n", "")
175 .replace("const uiTheme = (file: string) => resolve(__dirname, '../../packages/ui/theme', file);\n", "")
176 .replace("uiTheme('base.css')", "'@commonpub/ui/theme/base.css'")
178 .replace("uiTheme('dark.css')", "'@commonpub/ui/theme/dark.css'")
179 .replace("uiTheme('components.css')", "'@commonpub/ui/theme/components.css'")
180 .replace("uiTheme('prose.css')", "'@commonpub/ui/theme/prose.css'")
181 .replace("uiTheme('layouts.css')", "'@commonpub/ui/theme/layouts.css'")
182 .replace("uiTheme('forms.css')", "'@commonpub/ui/theme/forms.css'")
183 .replace("uiTheme('editor-panels.css')", "'@commonpub/ui/theme/editor-panels.css'")
184 .replace("allow: ['../..']", "allow: ['..']")
186 .replace("siteUrl: 'http://localhost:3000'", &format!("siteUrl: 'http://{}'", config.domain))
188 .replace("domain: 'localhost:3000'", &format!("domain: '{}'", config.domain))
189 .replace("siteName: 'CommonPub'", &format!("siteName: '{}'", config.name))
190 .replace("siteDescription: 'A CommonPub instance'", &format!("siteDescription: '{}'", config.description))
191 .replace("content: true,\n social: true,\n hubs: true,\n docs: true,\n video: true,\n contests: false,\n learning: true,\n explainers: true,\n federation: false,\n admin: false,",
193 &format!("content: {},\n social: {},\n hubs: {},\n docs: {},\n video: {},\n contests: {},\n learning: {},\n explainers: {},\n federation: {},\n admin: {},",
194 config.feature_content, config.feature_social, config.feature_hubs,
195 config.feature_docs, config.feature_video, config.feature_contests,
196 config.feature_learning, config.feature_explainers, config.feature_federation,
197 config.feature_admin))
198 .replace("contentTypes: 'project,article,blog,explainer'", &format!("contentTypes: '{}'", config.content_types.join(",")))
200 .replace("contestCreation: 'admin'", &format!("contestCreation: '{}'", config.contest_creation));
202
203 fs::write(&config_path, patched)?;
204 Ok(())
205}