From c38d148d40492e192b0308ff49c2b61168a552dd Mon Sep 17 00:00:00 2001 From: Eduardo Date: Sun, 20 Sep 2026 14:37:24 -0300 Subject: [PATCH 1/2] fix(core,ci): resolve critical terminal adapter issues and typescript build errors --- .github/workflows/release.yml | 2 +- .gitignore | 1 + .../codelaunch-core/src/ide/terminal/mod.rs | 72 +++++++------------ package.json | 2 +- tsconfig.json | 1 + tsconfig.node.json | 5 +- vite.config.ts | 1 - 7 files changed, 33 insertions(+), 51 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57a6282..b35210b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: run: npm ci - name: Build and Publish Tauri Release - uses: tauri-apps/tauri-action@v1 + uses: tauri-apps/tauri-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # To enable code signing & notarization, configure the secrets in GitHub and uncomment: diff --git a/.gitignore b/.gitignore index 2d0bf71..e89f3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.tsbuildinfo # Editor directories and files .vscode diff --git a/crates/codelaunch-core/src/ide/terminal/mod.rs b/crates/codelaunch-core/src/ide/terminal/mod.rs index 082d673..c46010c 100644 --- a/crates/codelaunch-core/src/ide/terminal/mod.rs +++ b/crates/codelaunch-core/src/ide/terminal/mod.rs @@ -58,8 +58,10 @@ pub fn render_for_os( output_dir: &Path, os: TargetOs, ) -> Result { - let slug = slugify(&project.name); - let project_dir = output_dir.join(format!("{slug}-terminal")); + let project_dir = output_dir.join(project.id.to_string()); + if project_dir.exists() { + let _ = fs::remove_dir_all(&project_dir); + } fs::create_dir_all(&project_dir)?; let mut generated_files = Vec::new(); @@ -347,9 +349,6 @@ fn generate_macos_terminal_script(term: &Terminal, folder: &Folder) -> String { script.push_str("#!/bin/zsh\n"); script.push_str("[ -f \"$HOME/.zprofile\" ] && source \"$HOME/.zprofile\" 2>/dev/null\n"); script.push_str("[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\" 2>/dev/null\n"); - script.push_str("#!/bin/zsh\n"); - script.push_str("[ -f \"$HOME/.zprofile\" ] && source \"$HOME/.zprofile\" 2>/dev/null\n"); - script.push_str("[ -f \"$HOME/.zshrc\" ] && source \"$HOME/.zshrc\" 2>/dev/null\n"); script.push_str(&format!("cd \"{}\" || exit 1\n", folder_path)); script.push_str(&format!("printf '\\033]0;%s\\007' \"{}\"\n", title)); @@ -510,8 +509,6 @@ fn generate_linux_terminal_script(term: &Terminal, folder: &Folder) -> String { script.push_str("#!/usr/bin/env bash\n"); script.push_str("[ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" 2>/dev/null\n"); script.push_str("[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\" 2>/dev/null\n"); - script.push_str("[ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" 2>/dev/null\n"); - script.push_str("[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\" 2>/dev/null\n"); script.push_str(&format!("cd \"{}\" || exit 1\n", folder_path)); script.push_str(&format!("printf '\\033]0;%s\\007' \"{}\"\n", title)); @@ -821,11 +818,6 @@ mod tests { assert!(master.contains("create window with default profile")); assert!(master.contains("create tab with default profile")); // Terminal.app fallback branch - // iTerm2 branch - assert!(master.contains("tell application \"iTerm\"\n activate")); - assert!(master.contains("create window with default profile")); - assert!(master.contains("create tab with default profile")); - // Terminal.app fallback branch assert!(master.contains("tell application \"Terminal\"\n activate")); assert!(master.contains("do script \"exec")); // Second terminal in the same group creates a tab in that window @@ -863,7 +855,6 @@ mod tests { let master = fs::read_to_string(&rendered.entry_path).unwrap(); // Both groups have a second terminal added as a tab via Cmd+T keystroke in Terminal.app - // Both groups have a second terminal added as a tab via Cmd+T keystroke in Terminal.app let tab_adds = master .matches("keystroke \"t\" using {command down}") .count(); @@ -885,18 +876,12 @@ mod tests { assert_eq!(rendered.generated_files.len(), 2); assert!(rendered.entry_path.ends_with("launch.sh")); - assert!(rendered.entry_path.ends_with("launch.sh")); - let master = fs::read_to_string(&rendered.entry_path).unwrap(); assert!(master.contains("TERMINAL_BIN")); // Modern detection: $TERMINAL, xdg-terminal-exec, x-terminal-emulator assert!(master.contains("$TERMINAL")); assert!(master.contains("xdg-terminal-exec")); assert!(master.contains("x-terminal-emulator")); - // Modern detection: $TERMINAL, xdg-terminal-exec, x-terminal-emulator - assert!(master.contains("$TERMINAL")); - assert!(master.contains("xdg-terminal-exec")); - assert!(master.contains("x-terminal-emulator")); // GNOME / Ptyxis / MATE assert!(master.contains("--window --title=\"API Server\"")); assert!(master.contains("--tab --title=\"Database Migrations\"")); @@ -919,18 +904,6 @@ mod tests { let term2_content = fs::read_to_string(&rendered.generated_files[1]).unwrap(); assert!(term2_content.contains("cargo run --bin migrate")); assert!(!term2_content.contains("exec \"${SHELL:-bash}\" -l")); - - // Verify Linux terminal scripts load user environment (~/.profile and ~/.bashrc) - let term1_content = fs::read_to_string(&rendered.generated_files[0]).unwrap(); - assert!(term1_content.contains("#!/usr/bin/env bash")); - assert!(term1_content.contains("[ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\"")); - assert!(term1_content.contains("[ -f \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\"")); - assert!(term1_content.contains("cd \"/path/to/backend\"")); - assert!(term1_content.contains("trap : INT; npm run dev; exec \"${SHELL:-bash}\" -l")); - - let term2_content = fs::read_to_string(&rendered.generated_files[1]).unwrap(); - assert!(term2_content.contains("cargo run --bin migrate")); - assert!(!term2_content.contains("exec \"${SHELL:-bash}\" -l")); } #[test] @@ -942,8 +915,6 @@ mod tests { assert_eq!(rendered.generated_files.len(), 2); assert!(rendered.entry_path.ends_with("launch.bat")); - assert!(rendered.entry_path.ends_with("launch.bat")); - let master = fs::read_to_string(&rendered.entry_path).unwrap(); assert!(master.contains("where wt.exe")); assert!(master.contains("-w new new-tab --title \"API Server\"")); @@ -967,20 +938,6 @@ mod tests { assert!(term2_content.contains("title Database Migrations")); assert!(term2_content.contains("call cargo run --bin migrate")); assert!(term2_content.contains("exit")); - - // Verify Windows individual terminal scripts - let term1_content = fs::read_to_string(&rendered.generated_files[0]).unwrap(); - assert!(term1_content.contains("@echo off")); - assert!(term1_content.contains("title API Server")); - assert!(term1_content.contains("cd /d \"/path/to/backend\"")); - assert!(term1_content.contains("call npm run dev")); - assert!(!term1_content.contains("exit")); - - let term2_content = fs::read_to_string(&rendered.generated_files[1]).unwrap(); - assert!(term2_content.contains("@echo off")); - assert!(term2_content.contains("title Database Migrations")); - assert!(term2_content.contains("call cargo run --bin migrate")); - assert!(term2_content.contains("exit")); } #[test] @@ -995,4 +952,25 @@ mod tests { assert!(preview.contains("launch.sh")); assert!(preview.contains("launch.bat")); } + + #[test] + fn render_isolates_terminal_workspaces_by_project_id_preventing_collisions() { + let tmp = tempfile::tempdir().unwrap(); + let project1 = create_test_project(); + let project2 = create_test_project(); + assert_ne!(project1.id, project2.id); + + let rendered1 = render_for_os(&project1, tmp.path(), TargetOs::MacOs).unwrap(); + let rendered2 = render_for_os(&project2, tmp.path(), TargetOs::MacOs).unwrap(); + + assert_ne!(rendered1.entry_path, rendered2.entry_path); + assert!(rendered1.entry_path.is_file()); + assert!(rendered2.entry_path.is_file()); + assert!(rendered1 + .entry_path + .starts_with(tmp.path().join(project1.id.to_string()))); + assert!(rendered2 + .entry_path + .starts_with(tmp.path().join(project2.id.to_string()))); + } } diff --git a/package.json b/package.json index 14ef813..bd103a9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc -b && vite build", "preview": "vite preview", "tauri": "tauri", "prepare": "husky" diff --git a/tsconfig.json b/tsconfig.json index 3859f88..07ac423 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "lib": ["ES2020", "DOM"], "module": "ESNext", "skipLibCheck": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", /* Bundler mode */ "moduleResolution": "bundler", diff --git a/tsconfig.node.json b/tsconfig.node.json index 42872c5..56a50aa 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,10 +1,13 @@ { "compilerOptions": { "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "emitDeclarationOnly": true, + "outDir": "./node_modules/.tmp" }, "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 35c7e69..a8e1296 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -// @ts-expect-error type error without @types/node package import process from "node:process"; const host = process.env.TAURI_DEV_HOST; From e7bc791cb2e9b817167d76fb4b1fd667f4361cbc Mon Sep 17 00:00:00 2001 From: Eduardo Date: Sun, 20 Sep 2026 14:41:36 -0300 Subject: [PATCH 2/2] feat(storage,model): atomic persistence, resilient listing, and enhanced validations --- .../codelaunch-core/src/model/validation.rs | 42 ++++++- .../src/storage/project_repo.rs | 108 +++++++++++++++++- 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/crates/codelaunch-core/src/model/validation.rs b/crates/codelaunch-core/src/model/validation.rs index 4729e1c..e711d9c 100644 --- a/crates/codelaunch-core/src/model/validation.rs +++ b/crates/codelaunch-core/src/model/validation.rs @@ -6,8 +6,12 @@ use super::project::Project; pub enum ValidationError { #[error("project name must not be empty")] EmptyName, + #[error("folder name must not be empty")] + EmptyFolderName, #[error("duplicate folder name: {0}")] DuplicateFolderName(String), + #[error("terminal label must not be empty")] + EmptyTerminalLabel, #[error("terminal {0} references a folder that does not exist in this project")] DanglingFolderReference(String), #[error("duplicate terminal label: {0} (VS Code task labels must be unique project-wide)")] @@ -26,7 +30,9 @@ pub fn validate(project: &Project) -> Result<(), Vec> { let mut seen_names = std::collections::HashSet::new(); for folder in &project.folders { - if !seen_names.insert(&folder.name) { + if folder.name.trim().is_empty() { + errors.push(ValidationError::EmptyFolderName); + } else if !seen_names.insert(&folder.name) { errors.push(ValidationError::DuplicateFolderName(folder.name.clone())); } } @@ -34,13 +40,16 @@ pub fn validate(project: &Project) -> Result<(), Vec> { let mut seen_labels = std::collections::HashSet::new(); for group in &project.terminal_groups { for terminal in &group.terminals { - if project.find_folder(terminal.folder_id).is_none() { - errors.push(ValidationError::DanglingFolderReference( + if terminal.label.trim().is_empty() { + errors.push(ValidationError::EmptyTerminalLabel); + } else if !seen_labels.insert(&terminal.label) { + errors.push(ValidationError::DuplicateTerminalLabel( terminal.label.clone(), )); } - if !seen_labels.insert(&terminal.label) { - errors.push(ValidationError::DuplicateTerminalLabel( + + if project.find_folder(terminal.folder_id).is_none() { + errors.push(ValidationError::DanglingFolderReference( terminal.label.clone(), )); } @@ -118,4 +127,27 @@ mod tests { assert!(validate(&project).is_ok()); } + + #[test] + fn rejects_empty_folder_name() { + let mut project = Project::new("Test", IdeKind::VsCode); + project.folders.push(Folder::new(" ", "/tmp/backend")); + let errors = validate(&project).unwrap_err(); + assert_eq!(errors, vec![ValidationError::EmptyFolderName]); + } + + #[test] + fn rejects_empty_terminal_label() { + let mut project = Project::new("Test", IdeKind::VsCode); + let folder = Folder::new("BACKEND", "/tmp/backend"); + let folder_id = folder.id; + project.folders.push(folder); + + let mut group = TerminalGroup::new("group1", 0); + group.terminals.push(Terminal::new(" ", folder_id, 0)); + project.terminal_groups.push(group); + + let errors = validate(&project).unwrap_err(); + assert_eq!(errors, vec![ValidationError::EmptyTerminalLabel]); + } } diff --git a/crates/codelaunch-core/src/storage/project_repo.rs b/crates/codelaunch-core/src/storage/project_repo.rs index a3a0bfb..80b6980 100644 --- a/crates/codelaunch-core/src/storage/project_repo.rs +++ b/crates/codelaunch-core/src/storage/project_repo.rs @@ -43,20 +43,66 @@ impl FsProjectRepository { } } +#[derive(serde::Deserialize)] +struct ProjectListEntry { + id: Uuid, + name: String, + #[serde(default)] + group: Option, + ide: crate::model::IdeKind, + #[serde(default)] + folders: Vec, + #[serde(default)] + terminal_groups: Vec, + updated_at: chrono::DateTime, +} + impl ProjectRepository for FsProjectRepository { fn list(&self) -> Result> { let mut summaries = Vec::new(); for entry in fs::read_dir(&self.base_dir)? { - let entry = entry?; + let Ok(entry) = entry else { + continue; + }; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } - let raw = fs::read_to_string(&path)?; - let value: serde_json::Value = serde_json::from_str(&raw)?; + let raw = match fs::read_to_string(&path) { + Ok(content) => content, + Err(err) => { + eprintln!("Failed to read project file {}: {}", path.display(), err); + continue; + } + }; + let value: serde_json::Value = match serde_json::from_str(&raw) { + Ok(val) => val, + Err(err) => { + eprintln!("Failed to parse JSON in {}: {}", path.display(), err); + continue; + } + }; let value = migrate_schema(value); - let project: Project = serde_json::from_value(value)?; - summaries.push(ProjectSummary::from(&project)); + match serde_json::from_value::(value) { + Ok(entry) => { + summaries.push(ProjectSummary { + id: entry.id, + name: entry.name, + group: entry.group, + ide: entry.ide, + folder_count: entry.folders.len(), + terminal_group_count: entry.terminal_groups.len(), + updated_at: entry.updated_at, + }); + } + Err(err) => { + eprintln!( + "Failed to deserialize project summary in {}: {}", + path.display(), + err + ); + } + } } summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at)); Ok(summaries) @@ -75,8 +121,16 @@ impl ProjectRepository for FsProjectRepository { fn save(&self, project: &Project) -> Result<()> { let path = self.path_for(project.id); + let tmp_path = self.base_dir.join(format!("{}.json.tmp", project.id)); let json = serde_json::to_string_pretty(project)?; - fs::write(path, json)?; + if let Err(err) = fs::write(&tmp_path, json) { + let _ = fs::remove_file(&tmp_path); + return Err(err.into()); + } + if let Err(err) = fs::rename(&tmp_path, &path) { + let _ = fs::remove_file(&tmp_path); + return Err(err.into()); + } Ok(()) } @@ -85,6 +139,10 @@ impl ProjectRepository for FsProjectRepository { if path.exists() { fs::remove_file(path)?; } + let tmp_path = self.base_dir.join(format!("{id}.json.tmp")); + if tmp_path.exists() { + let _ = fs::remove_file(tmp_path); + } Ok(()) } } @@ -112,11 +170,49 @@ mod tests { let summaries = repo.list().unwrap(); assert_eq!(summaries.len(), 1); assert_eq!(summaries[0].id, project.id); + assert_eq!(summaries[0].folder_count, 0); + assert_eq!(summaries[0].terminal_group_count, 0); repo.delete(project.id).unwrap(); assert!(repo.load(project.id).is_err()); } + #[test] + fn save_is_atomic_and_cleans_up_tmp_file() { + let tmp = tempfile::tempdir().unwrap(); + let repo = FsProjectRepository::new(tmp.path()).unwrap(); + + let project = Project::new("Atomic Project", IdeKind::VsCode); + repo.save(&project).unwrap(); + + let final_path = tmp.path().join(format!("{}.json", project.id)); + let tmp_path = tmp.path().join(format!("{}.json.tmp", project.id)); + assert!(final_path.is_file()); + assert!(!tmp_path.exists()); + } + + #[test] + fn list_skips_corrupted_json_files_gracefully() { + let tmp = tempfile::tempdir().unwrap(); + let repo = FsProjectRepository::new(tmp.path()).unwrap(); + + let valid_project = Project::new("Healthy Project", IdeKind::VsCode); + repo.save(&valid_project).unwrap(); + + // Write a corrupt JSON file in the same repository directory + let corrupt_path = tmp.path().join("corrupt-uuid.json"); + fs::write(corrupt_path, "{ broken json content ...").unwrap(); + + // Write a non-json file that should also be ignored + let text_path = tmp.path().join("notes.txt"); + fs::write(text_path, "some notes").unwrap(); + + let summaries = repo.list().unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, valid_project.id); + assert_eq!(summaries[0].name, "Healthy Project"); + } + #[test] fn load_missing_project_errors() { let tmp = tempfile::tempdir().unwrap();