Skip to main content

tideway_cli/commands/
setup.rs

1//! Setup command - installs frontend dependencies (Tailwind, shadcn, etc.)
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use std::path::Path;
6use std::process::Command;
7
8use crate::cli::{Framework, SetupArgs, Style};
9use crate::{
10    is_json_output, is_plan_mode, print_error, print_info, print_success, print_warning,
11    remove_dir, remove_file, write_file,
12};
13
14/// Components required for tideway frontend
15const SHADCN_VUE_COMPONENTS: &[&str] = &[
16    "alert",
17    "avatar",
18    "badge",
19    "button",
20    "card",
21    "checkbox",
22    "dialog",
23    "dropdown-menu",
24    "form",
25    "input",
26    "label",
27    "select",
28    "separator",
29    "skeleton",
30    "sonner",
31    "switch",
32    "table",
33    "tabs",
34];
35
36/// Run the setup command
37pub fn run(args: SetupArgs) -> Result<()> {
38    if !is_json_output() {
39        println!(
40            "\n{} Setting up frontend dependencies...\n",
41            "tideway".cyan().bold()
42        );
43    }
44
45    // Check for package.json
46    if !std::path::Path::new("package.json").exists() {
47        print_error("No package.json found. Please run this from a frontend project directory.");
48        if !is_json_output() {
49            println!("\nTo create a new Vue project:");
50            println!("  npm create vue@latest my-app");
51            println!("  cd my-app");
52            println!("  tideway setup");
53        }
54        return Ok(());
55    }
56
57    match args.framework {
58        Framework::Vue => setup_vue(&args)?,
59    }
60
61    if !is_json_output() {
62        println!("\n{} Frontend setup complete!\n", "✓".green().bold());
63
64        println!("{}", "Next steps:".yellow().bold());
65        println!("  1. Generate components:");
66        println!("     tideway generate all --with-views");
67        println!();
68        println!("  2. Set up your router to use the generated views");
69        println!();
70    }
71
72    Ok(())
73}
74
75fn setup_vue(args: &SetupArgs) -> Result<()> {
76    // Step 1: Clean up default Vue starter files
77    cleanup_vue_starter()?;
78
79    // Step 2: Install Tailwind if needed
80    if !args.no_tailwind && args.style != Style::Unstyled {
81        setup_tailwind()?;
82    }
83
84    // Step 3: Install shadcn-vue if using shadcn style
85    if args.style == Style::Shadcn && !args.no_components {
86        setup_shadcn_vue()?;
87    }
88
89    Ok(())
90}
91
92fn cleanup_vue_starter() -> Result<()> {
93    print_info("Cleaning up default Vue starter files...");
94
95    // Remove default components
96    let default_files = [
97        "src/components/HelloWorld.vue",
98        "src/components/TheWelcome.vue",
99        "src/components/WelcomeItem.vue",
100        "src/components/icons/IconCommunity.vue",
101        "src/components/icons/IconDocumentation.vue",
102        "src/components/icons/IconEcosystem.vue",
103        "src/components/icons/IconSupport.vue",
104        "src/components/icons/IconTooling.vue",
105    ];
106
107    for file in default_files {
108        if Path::new(file).exists() {
109            let _ = remove_file(Path::new(file));
110        }
111    }
112
113    // Remove icons directory if empty
114    if Path::new("src/components/icons").exists() {
115        let _ = remove_dir(Path::new("src/components/icons"));
116    }
117
118    // Replace HomeView with a simple redirect
119    if Path::new("src/views/HomeView.vue").exists() {
120        write_file(
121            Path::new("src/views/HomeView.vue"),
122            r#"<script setup lang="ts">
123import { onMounted } from 'vue'
124import { useRouter } from 'vue-router'
125
126const router = useRouter()
127
128onMounted(() => {
129  router.push('/login')
130})
131</script>
132
133<template>
134  <div class="min-h-screen flex items-center justify-center">
135    <p class="text-muted-foreground">Redirecting...</p>
136  </div>
137</template>
138"#,
139        )?;
140        print_success("Replaced HomeView.vue with login redirect");
141    }
142
143    // Remove AboutView if it exists
144    if Path::new("src/views/AboutView.vue").exists() {
145        let _ = remove_file(Path::new("src/views/AboutView.vue"));
146    }
147
148    // Clean up App.vue
149    if Path::new("src/App.vue").exists() {
150        write_file(
151            Path::new("src/App.vue"),
152            r#"<script setup lang="ts">
153import { RouterView } from 'vue-router'
154</script>
155
156<template>
157  <RouterView />
158</template>
159"#,
160        )?;
161        print_success("Cleaned up App.vue");
162    }
163
164    // Clean up main.css - remove default styles and base.css import
165    if Path::new("src/assets/main.css").exists() {
166        let content = std::fs::read_to_string("src/assets/main.css")?;
167        // Keep only @import lines that are NOT base.css
168        let imports: Vec<&str> = content
169            .lines()
170            .filter(|line| {
171                let trimmed = line.trim();
172                trimmed.starts_with("@import") && !trimmed.contains("base.css")
173            })
174            .collect();
175
176        if !imports.is_empty() {
177            write_file(
178                Path::new("src/assets/main.css"),
179                &(imports.join("\n") + "\n"),
180            )?;
181            print_success("Cleaned up main.css");
182        }
183    }
184
185    // Remove base.css if it exists (default Vue styles)
186    if Path::new("src/assets/base.css").exists() {
187        let _ = remove_file(Path::new("src/assets/base.css"));
188        print_success("Removed base.css");
189    }
190
191    // Clean up router - remove default Home and About routes
192    cleanup_router()?;
193
194    Ok(())
195}
196
197fn cleanup_router() -> Result<()> {
198    let router_path = Path::new("src/router/index.ts");
199    if !router_path.exists() {
200        return Ok(());
201    }
202
203    // Replace with a clean router template - tideway generate will add routes
204    write_file(
205        router_path,
206        r#"import { createRouter, createWebHistory } from 'vue-router'
207
208const router = createRouter({
209  history: createWebHistory(import.meta.env.BASE_URL),
210  routes: [],
211})
212
213export default router
214"#,
215    )?;
216    print_success("Cleaned up router (removed default routes)");
217
218    Ok(())
219}
220
221fn setup_tailwind() -> Result<()> {
222    print_info("Setting up Tailwind CSS v4...");
223
224    // Check if vite.config exists
225    let vite_config_path = if Path::new("vite.config.ts").exists() {
226        "vite.config.ts"
227    } else if Path::new("vite.config.js").exists() {
228        "vite.config.js"
229    } else {
230        print_warning("No vite.config found. Tailwind v4 setup requires Vite.");
231        return Ok(());
232    };
233
234    // Install Tailwind v4 with Vite plugin
235    print_info("Installing @tailwindcss/vite...");
236    if !run_external_command(
237        "npm",
238        &["install", "-D", "tailwindcss", "@tailwindcss/vite"],
239        "install tailwind dependencies",
240    )? {
241        print_warning("Failed to install Tailwind CSS");
242        return Ok(());
243    }
244
245    print_success("Tailwind CSS v4 installed");
246
247    // Update vite.config to add tailwindcss plugin
248    print_info("Configuring vite.config...");
249    let vite_config = std::fs::read_to_string(vite_config_path)?;
250
251    if !vite_config.contains("@tailwindcss/vite") {
252        let updated = vite_config
253            .replace(
254                "import vue from '@vitejs/plugin-vue'",
255                "import vue from '@vitejs/plugin-vue'\nimport tailwindcss from '@tailwindcss/vite'",
256            )
257            .replace("plugins: [vue()]", "plugins: [vue(), tailwindcss()]")
258            .replace(
259                "plugins: [\n    vue()",
260                "plugins: [\n    vue(),\n    tailwindcss()",
261            );
262
263        write_file(Path::new(vite_config_path), &updated)?;
264        print_success("Updated vite.config with tailwindcss plugin");
265    } else {
266        print_info("Tailwind already in vite.config");
267    }
268
269    // Update main CSS file
270    let css_paths = ["src/assets/main.css", "src/style.css", "src/index.css"];
271    for css_path in css_paths {
272        if Path::new(css_path).exists() {
273            let css_content = std::fs::read_to_string(css_path)?;
274            if !css_content.contains("@import \"tailwindcss\"")
275                && !css_content.contains("@import 'tailwindcss'")
276            {
277                let updated = format!("@import \"tailwindcss\";\n\n{}", css_content);
278                write_file(Path::new(css_path), &updated)?;
279                print_success(&format!("Added Tailwind import to {}", css_path));
280            }
281            break;
282        }
283    }
284
285    Ok(())
286}
287
288fn setup_shadcn_vue() -> Result<()> {
289    print_info("Setting up shadcn-vue...");
290
291    // First, ensure tsconfig has the @ alias
292    setup_tsconfig_alias()?;
293
294    // Check if shadcn is already initialized (components.json exists)
295    let has_shadcn = Path::new("components.json").exists();
296
297    if !has_shadcn {
298        print_info("Initializing shadcn-vue...");
299
300        if !run_external_command(
301            "npx",
302            &["shadcn-vue@latest", "init", "-y", "-d"],
303            "initialize shadcn-vue",
304        )? {
305            print_error("Failed to initialize shadcn-vue");
306            if !is_json_output() {
307                println!("You can try running manually: npx shadcn-vue@latest init");
308            }
309            return Ok(());
310        }
311
312        print_success("shadcn-vue initialized");
313    } else {
314        print_info("shadcn-vue already initialized");
315    }
316
317    // Install required components
318    print_info(&format!(
319        "Installing {} shadcn components...",
320        SHADCN_VUE_COMPONENTS.len()
321    ));
322
323    let components = SHADCN_VUE_COMPONENTS.join(" ");
324
325    let mut add_args = vec!["shadcn-vue@latest", "add", "-y"];
326    add_args.extend(SHADCN_VUE_COMPONENTS);
327
328    if !run_external_command("npx", &add_args, "install shadcn components")? {
329        print_warning("Some components may have failed to install");
330        if !is_json_output() {
331            println!(
332                "You can try running manually: npx shadcn-vue@latest add {}",
333                components
334            );
335        }
336        return Ok(());
337    }
338
339    print_success("shadcn components installed");
340
341    // Install tw-animate-css (required by shadcn-vue animations)
342    print_info("Installing tw-animate-css...");
343    if run_external_command(
344        "npm",
345        &["install", "tw-animate-css"],
346        "install tw-animate-css",
347    )? {
348        print_success("tw-animate-css installed");
349    }
350
351    Ok(())
352}
353
354fn setup_tsconfig_alias() -> Result<()> {
355    // Need to update BOTH tsconfig.json and tsconfig.app.json for shadcn-vue
356    let configs = ["tsconfig.json", "tsconfig.app.json"];
357
358    for tsconfig_path in configs {
359        if !Path::new(tsconfig_path).exists() {
360            continue;
361        }
362
363        print_info(&format!("Checking {} for import alias...", tsconfig_path));
364
365        let content = std::fs::read_to_string(tsconfig_path)?;
366
367        // Check if paths already configured
368        if content.contains("\"@/*\"") || content.contains("'@/*'") {
369            print_info(&format!("{} already has import alias", tsconfig_path));
370            continue;
371        }
372
373        // Add paths to compilerOptions - handle both regular and references-style tsconfig
374        let updated = if content.contains("\"compilerOptions\": {") {
375            // Regular tsconfig with existing compilerOptions
376            content.replace(
377                "\"compilerOptions\": {",
378                "\"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    },"
379            )
380        } else if content.contains("\"files\":") || content.contains("\"references\":") {
381            // References-style tsconfig without compilerOptions - add it
382            content.replace(
383                "{",
384                "{\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },"
385            )
386        } else {
387            content
388        };
389
390        write_file(Path::new(tsconfig_path), &updated)?;
391        print_success(&format!("Added @ import alias to {}", tsconfig_path));
392    }
393
394    // Also update vite.config for path resolution
395    setup_vite_path_resolution()?;
396
397    // Create tailwind.config.ts stub for shadcn-vue compatibility
398    setup_tailwind_config_stub()?;
399
400    Ok(())
401}
402
403fn setup_tailwind_config_stub() -> Result<()> {
404    // shadcn-vue requires a tailwind.config file even though Tailwind v4 doesn't need one
405    let config_exists =
406        Path::new("tailwind.config.ts").exists() || Path::new("tailwind.config.js").exists();
407
408    if config_exists {
409        return Ok(());
410    }
411
412    print_info("Creating tailwind.config.ts for shadcn-vue compatibility...");
413    write_file(
414        Path::new("tailwind.config.ts"),
415        "// Tailwind v4 uses CSS-based configuration, but shadcn-vue needs this file\nexport default {}\n",
416    )?;
417    print_success("Created tailwind.config.ts");
418
419    Ok(())
420}
421
422fn setup_vite_path_resolution() -> Result<()> {
423    // Install @types/node if needed
424    print_info("Installing @types/node for path resolution...");
425    let _ = run_external_command(
426        "npm",
427        &["install", "-D", "@types/node"],
428        "install @types/node",
429    );
430
431    // Update vite.config to add resolve.alias
432    let vite_config_path = if Path::new("vite.config.ts").exists() {
433        "vite.config.ts"
434    } else if Path::new("vite.config.js").exists() {
435        "vite.config.js"
436    } else {
437        return Ok(());
438    };
439
440    let content = std::fs::read_to_string(vite_config_path)?;
441
442    // Check if already has path import and resolve.alias
443    if content.contains("fileURLToPath") && content.contains("resolve:") {
444        print_info("Vite path resolution already configured");
445        return Ok(());
446    }
447
448    // Add the import and resolve config
449    let mut updated = content;
450
451    // Add import if not present
452    if !updated.contains("fileURLToPath") {
453        updated = format!(
454            "import {{ fileURLToPath, URL }} from 'node:url'\n{}",
455            updated
456        );
457    }
458
459    // Add resolve.alias if not present
460    if !updated.contains("resolve:") {
461        updated = updated.replace(
462            "plugins: [",
463            "resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url))\n    }\n  },\n  plugins: ["
464        );
465    }
466
467    write_file(Path::new(vite_config_path), &updated)?;
468    print_success("Added path resolution to vite.config");
469
470    Ok(())
471}
472
473fn run_external_command(program: &str, args: &[&str], context: &str) -> Result<bool> {
474    if is_plan_mode() {
475        print_info(&format!(
476            "Plan: run command `{}`",
477            format_command(program, args)
478        ));
479        return Ok(true);
480    }
481
482    let status = Command::new(program)
483        .args(args)
484        .status()
485        .with_context(|| format!("Failed to {}", context))?;
486
487    Ok(status.success())
488}
489
490fn format_command(program: &str, args: &[&str]) -> String {
491    if args.is_empty() {
492        program.to_string()
493    } else {
494        format!("{} {}", program, args.join(" "))
495    }
496}