From 2c5eecc8458d93ec157b19249748cb6ad7670fba Mon Sep 17 00:00:00 2001 From: Gabriel Gordon-Hall Date: Fri, 4 Jul 2025 16:24:19 +0100 Subject: [PATCH] feat: PostHog product analytics (#58) * wip: posthog analytics * wip: remove posthog-rs crate; call endpoint directly * make analytics non-blocking * session start event * configure analytics for release builds * remove dev_server_stopped event * address review comments * simplify analytics enabled logic * analytics on by default; send start_session when user enables analytics; new task_attempt_start event * lower visibility of analytics logs * chore: bump version to 0.0.37-0 * set analytics to true if previously unset --------- Co-authored-by: GitHub Action --- .github/workflows/pre-release.yml | 2 + backend/Cargo.toml | 2 + backend/build.rs | 9 ++ backend/src/app_state.rs | 50 +++++++- backend/src/execution_monitor.rs | 13 ++ backend/src/executor.rs | 13 ++ backend/src/main.rs | 4 +- backend/src/models/config.rs | 12 +- backend/src/models/task_attempt.rs | 19 ++- backend/src/routes/config.rs | 6 + backend/src/routes/projects.rs | 26 +++- backend/src/routes/task_attempts.rs | 53 +++++++- backend/src/routes/tasks.rs | 57 +++++++-- backend/src/services/analytics.rs | 179 ++++++++++++++++++++++++++++ backend/src/services/mod.rs | 2 + frontend/src/pages/Settings.tsx | 30 +++++ npx-cli/package.json | 4 +- package.json | 4 +- shared/types.ts | 2 +- 19 files changed, 452 insertions(+), 35 deletions(-) create mode 100644 backend/src/services/analytics.rs diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index b4a27e56..3eab5d59 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -199,6 +199,8 @@ jobs: cargo build --release --target ${{ matrix.target }} --bin mcp_task_server env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} - name: Setup Sentry CLI uses: matbour/setup-sentry-cli@v2 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e7174f6b..34e01ab7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -45,8 +45,10 @@ octocrab = "0.44" sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debug-images"] } sentry-tower = "0.41.0" sentry-tracing = { version = "0.41.0", features = ["backtrace"] } +reqwest = { version = "0.11", features = ["json"] } [build-dependencies] +dotenv = "0.15" ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } [profile.release] diff --git a/backend/build.rs b/backend/build.rs index c33e92f8..c041a1f2 100644 --- a/backend/build.rs +++ b/backend/build.rs @@ -1,6 +1,15 @@ use std::{fs, path::Path}; fn main() { + dotenv::dotenv().ok(); + + if let Ok(api_key) = std::env::var("POSTHOG_API_KEY") { + println!("cargo:rustc-env=POSTHOG_API_KEY={}", api_key); + } + if let Ok(api_endpoint) = std::env::var("POSTHOG_API_ENDPOINT") { + println!("cargo:rustc-env=POSTHOG_API_ENDPOINT={}", api_endpoint); + } + // Create frontend/dist directory if it doesn't exist let dist_path = Path::new("../frontend/dist"); if !dist_path.exists() { diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index f5067b0e..50ea5145 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -5,9 +5,11 @@ use nix::{ sys::signal::{kill, Signal}, unistd::Pid, }; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock as TokioRwLock}; use uuid::Uuid; +use crate::services::{generate_user_id, AnalyticsConfig, AnalyticsService}; + #[derive(Debug)] pub enum ExecutionType { SetupScript, @@ -27,17 +29,48 @@ pub struct AppState { running_executions: Arc>>, pub db_pool: sqlx::SqlitePool, config: Arc>, + pub analytics: Arc>, + user_id: String, } impl AppState { - pub fn new( + pub async fn new( db_pool: sqlx::SqlitePool, config: Arc>, ) -> Self { + // Initialize analytics with user preferences + let user_enabled = { + let config_guard = config.read().await; + config_guard.analytics_enabled.unwrap_or(true) + }; + + let analytics_config = AnalyticsConfig::new(user_enabled); + let analytics = Arc::new(TokioRwLock::new(AnalyticsService::new(analytics_config))); + Self { running_executions: Arc::new(Mutex::new(HashMap::new())), db_pool, config, + analytics, + user_id: generate_user_id(), + } + } + + pub async fn update_analytics_config(&self, user_enabled: bool) { + // Check if analytics was disabled before this update + let was_analytics_disabled = { + let analytics = self.analytics.read().await; + !analytics.is_enabled() + }; + + let new_config = AnalyticsConfig::new(user_enabled); + let new_service = AnalyticsService::new(new_config); + let mut analytics = self.analytics.write().await; + *analytics = new_service; + + // If analytics was disabled and is now enabled, fire a session_start event + if was_analytics_disabled && analytics.is_enabled() { + analytics.track_event(&self.user_id, "session_start", None); } } @@ -193,4 +226,17 @@ impl AppState { let config = self.config.read().await; config.sound_file.clone() } + + pub async fn track_analytics_event( + &self, + event_name: &str, + properties: Option, + ) { + let analytics = self.analytics.read().await; + if analytics.is_enabled() { + analytics.track_event(&self.user_id, event_name, properties); + } else { + tracing::debug!("Analytics disabled, skipping event: {}", event_name); + } + } } diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs index a4761e5d..1fde6e5d 100644 --- a/backend/src/execution_monitor.rs +++ b/backend/src/execution_monitor.rs @@ -695,6 +695,19 @@ async fn handle_coding_agent_completion( // Get task to access task_id and project_id for status update if let Ok(Some(task)) = Task::find_by_id(&app_state.db_pool, task_attempt.task_id).await { + app_state + .track_analytics_event( + "task_attempt_finished", + Some(serde_json::json!({ + "task_id": task.id.to_string(), + "project_id": task.project_id.to_string(), + "attempt_id": task_attempt_id.to_string(), + "execution_success": success, + "exit_code": exit_code, + })), + ) + .await; + // Update task status to InReview if let Err(e) = Task::update_status( &app_state.db_pool, diff --git a/backend/src/executor.rs b/backend/src/executor.rs index 24be66d1..37592c2f 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -380,6 +380,19 @@ impl ExecutorConfig { } } +impl std::fmt::Display for ExecutorConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ExecutorConfig::Echo => "echo", + ExecutorConfig::Claude => "claude", + ExecutorConfig::Amp => "amp", + ExecutorConfig::Gemini => "gemini", + ExecutorConfig::Opencode => "opencode", + }; + write!(f, "{}", s) + } +} + /// Stream output from a child process to the database pub async fn stream_output_to_db( output: impl tokio::io::AsyncRead + Unpin, diff --git a/backend/src/main.rs b/backend/src/main.rs index 31b65032..dbcbd636 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -164,8 +164,10 @@ fn main() -> anyhow::Result<()> { let config_arc = Arc::new(RwLock::new(config)); // Create app state - let app_state = AppState::new(pool.clone(), config_arc.clone()); + let app_state = AppState::new(pool.clone(), config_arc.clone()).await; + // Track session start event + app_state.track_analytics_event("session_start", None).await; // Start background task to check for init status and spawn processes let state_clone = app_state.clone(); tokio::spawn(async move { diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs index 820eeab9..9650f93c 100644 --- a/backend/src/models/config.rs +++ b/backend/src/models/config.rs @@ -17,6 +17,7 @@ pub struct Config { pub push_notifications: bool, pub editor: EditorConfig, pub github: GitHubConfig, + pub analytics_enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] @@ -159,6 +160,7 @@ impl Default for Config { push_notifications: true, editor: EditorConfig::default(), github: GitHubConfig::default(), + analytics_enabled: Some(true), } } } @@ -259,7 +261,15 @@ impl Config { // Try to deserialize as is first match serde_json::from_str::(&content) { - Ok(config) => Ok(config), + Ok(mut config) => { + if config.analytics_enabled.is_none() { + config.analytics_enabled = Some(true); + } + + // Always save back to ensure new fields are written to disk + config.save(config_path)?; + Ok(config) + } Err(_) => { // If full deserialization fails, merge with defaults let config = Self::load_with_defaults(&content, config_path)?; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index e5df3eb6..bbae88b4 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -659,7 +659,7 @@ impl TaskAttempt { )); } - Self::start_process_execution( + let result = Self::start_process_execution( pool, app_state, attempt_id, @@ -670,7 +670,22 @@ impl TaskAttempt { crate::models::execution_process::ExecutionProcessType::DevServer, &task_attempt.worktree_path, ) - .await + .await; + + if result.is_ok() { + app_state + .track_analytics_event( + "dev_server_started", + Some(serde_json::json!({ + "task_id": task_id.to_string(), + "project_id": project_id.to_string(), + "attempt_id": attempt_id.to_string() + })), + ) + .await; + } + + result } /// Start a follow-up execution using the same executor type as the first process diff --git a/backend/src/routes/config.rs b/backend/src/routes/config.rs index d47557a1..a744cb7e 100644 --- a/backend/src/routes/config.rs +++ b/backend/src/routes/config.rs @@ -42,6 +42,7 @@ async fn get_config( async fn update_config( Extension(config_arc): Extension>>, + Extension(app_state): Extension, Json(new_config): Json, ) -> ResponseJson> { let config_path = utils::config_path(); @@ -50,6 +51,11 @@ async fn update_config( Ok(_) => { let mut config = config_arc.write().await; *config = new_config.clone(); + drop(config); + + app_state + .update_analytics_config(new_config.analytics_enabled.unwrap_or(true)) + .await; ResponseJson(ApiResponse { success: true, diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs index d682f246..7a37ca0c 100644 --- a/backend/src/routes/projects.rs +++ b/backend/src/routes/projects.rs @@ -151,6 +151,7 @@ pub async fn create_project_branch( pub async fn create_project( Extension(pool): Extension, + Extension(app_state): Extension, Json(payload): Json, ) -> Result>, StatusCode> { let id = Uuid::new_v4(); @@ -249,11 +250,26 @@ pub async fn create_project( } match Project::create(&pool, &payload, id).await { - Ok(project) => Ok(ResponseJson(ApiResponse { - success: true, - data: Some(project), - message: Some("Project created successfully".to_string()), - })), + Ok(project) => { + // Track project creation event + app_state + .track_analytics_event( + "project_created", + Some(serde_json::json!({ + "project_id": project.id.to_string(), + "use_existing_repo": payload.use_existing_repo, + "has_setup_script": payload.setup_script.is_some(), + "has_dev_script": payload.dev_script.is_some(), + })), + ) + .await; + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(project), + message: Some("Project created successfully".to_string()), + })) + } Err(e) => { tracing::error!("Failed to create project: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index 42d15c53..84c9e2cc 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -13,6 +13,7 @@ use tokio::sync::RwLock; use uuid::Uuid; use crate::{ + app_state::AppState, executor::{ExecutorConfig, NormalizedConversation}, models::{ config::Config, @@ -117,8 +118,21 @@ pub async fn create_task_attempt( Ok(true) => {} } + let executor_string = payload.executor.as_ref().map(|exec| exec.to_string()); + match TaskAttempt::create(&pool, &payload, task_id).await { Ok(attempt) => { + app_state + .track_analytics_event( + "task_attempt_started", + Some(serde_json::json!({ + "task_id": task_id.to_string(), + "executor_type": executor_string.as_deref().unwrap_or("default"), + "attempt_id": attempt.id.to_string(), + })), + ) + .await; + // Start execution asynchronously (don't block the response) let pool_clone = pool.clone(); let app_state_clone = app_state.clone(); @@ -230,6 +244,7 @@ pub async fn get_task_attempt_diff( pub async fn merge_task_attempt( Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, Extension(pool): Extension, + Extension(app_state): Extension>, ) -> Result>, StatusCode> { // Verify task attempt exists and belongs to the correct task match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { @@ -242,7 +257,7 @@ pub async fn merge_task_attempt( } match TaskAttempt::merge_changes(&pool, attempt_id, task_id, project_id).await { - Ok(_merge_commit_id) => { + Ok(_) => { // Update task status to Done if let Err(e) = Task::update_status( &pool, @@ -256,6 +271,18 @@ pub async fn merge_task_attempt( return Err(StatusCode::INTERNAL_SERVER_ERROR); } + // Track task attempt merged event + app_state + .track_analytics_event( + "task_attempt_merged", + Some(serde_json::json!({ + "task_id": task_id.to_string(), + "project_id": project_id.to_string(), + "attempt_id": attempt_id.to_string(), + })), + ) + .await; + Ok(ResponseJson(ApiResponse { success: true, data: None, @@ -272,6 +299,7 @@ pub async fn merge_task_attempt( pub async fn create_github_pr( Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, Extension(pool): Extension, + Extension(app_state): Extension>, Json(request): Json, ) -> Result>, StatusCode> { // Verify task attempt exists and belongs to the correct task @@ -326,11 +354,24 @@ pub async fn create_github_pr( ) .await { - Ok(pr_url) => Ok(ResponseJson(ApiResponse { - success: true, - data: Some(pr_url), - message: Some("GitHub PR created successfully".to_string()), - })), + Ok(pr_url) => { + app_state + .track_analytics_event( + "github_pr_created", + Some(serde_json::json!({ + "task_id": task_id.to_string(), + "project_id": project_id.to_string(), + "attempt_id": attempt_id.to_string(), + })), + ) + .await; + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(pr_url), + message: Some("GitHub PR created successfully".to_string()), + })) + } Err(e) => { tracing::error!( "Failed to create GitHub PR for attempt {}: {}", diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index 2961ee15..d5e3e872 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -58,6 +58,7 @@ pub async fn get_task( pub async fn create_task( Path(project_id): Path, Extension(pool): Extension, + Extension(app_state): Extension, Json(mut payload): Json, ) -> Result>, StatusCode> { let id = Uuid::new_v4(); @@ -82,11 +83,25 @@ pub async fn create_task( ); match Task::create(&pool, &payload, id).await { - Ok(task) => Ok(ResponseJson(ApiResponse { - success: true, - data: Some(task), - message: Some("Task created successfully".to_string()), - })), + Ok(task) => { + // Track task creation event + app_state + .track_analytics_event( + "task_created", + Some(serde_json::json!({ + "task_id": task.id.to_string(), + "project_id": project_id.to_string(), + "has_description": task.description.is_some(), + })), + ) + .await; + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(task), + message: Some("Task created successfully".to_string()), + })) + } Err(e) => { tracing::error!("Failed to create task: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -136,20 +151,36 @@ pub async fn create_task_and_start( }; // Create task attempt - let executor_string = payload.executor.as_ref().map(|exec| match exec { - crate::executor::ExecutorConfig::Echo => "echo".to_string(), - crate::executor::ExecutorConfig::Claude => "claude".to_string(), - crate::executor::ExecutorConfig::Amp => "amp".to_string(), - crate::executor::ExecutorConfig::Gemini => "gemini".to_string(), - crate::executor::ExecutorConfig::Opencode => "opencode".to_string(), - }); + let executor_string = payload.executor.as_ref().map(|exec| exec.to_string()); let attempt_payload = CreateTaskAttempt { - executor: executor_string, + executor: executor_string.clone(), base_branch: None, // Not supported in task creation endpoint, only in task attempts }; match TaskAttempt::create(&pool, &attempt_payload, task_id).await { Ok(attempt) => { + app_state + .track_analytics_event( + "task_created", + Some(serde_json::json!({ + "task_id": task.id.to_string(), + "project_id": project_id.to_string(), + "has_description": task.description.is_some(), + })), + ) + .await; + + app_state + .track_analytics_event( + "task_attempt_started", + Some(serde_json::json!({ + "task_id": task.id.to_string(), + "executor_type": executor_string.as_deref().unwrap_or("default"), + "attempt_id": attempt.id.to_string(), + })), + ) + .await; + // Start execution asynchronously (don't block the response) let pool_clone = pool.clone(); let app_state_clone = app_state.clone(); diff --git a/backend/src/services/analytics.rs b/backend/src/services/analytics.rs new file mode 100644 index 00000000..b34ae54c --- /dev/null +++ b/backend/src/services/analytics.rs @@ -0,0 +1,179 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + time::Duration, +}; + +use serde_json::{json, Value}; + +#[derive(Debug, Clone)] +pub struct AnalyticsConfig { + pub posthog_api_key: String, + pub posthog_api_endpoint: String, + pub enabled: bool, +} + +impl AnalyticsConfig { + pub fn new(user_enabled: bool) -> Self { + let api_key = option_env!("POSTHOG_API_KEY").unwrap_or_default(); + let api_endpoint = option_env!("POSTHOG_API_ENDPOINT").unwrap_or_default(); + + let enabled = user_enabled && !api_key.is_empty() && !api_endpoint.is_empty(); + + Self { + posthog_api_key: api_key.to_string(), + posthog_api_endpoint: api_endpoint.to_string(), + enabled, + } + } +} + +#[derive(Debug)] +pub struct AnalyticsService { + config: AnalyticsConfig, + client: reqwest::Client, +} + +impl AnalyticsService { + pub fn new(config: AnalyticsConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + + Self { config, client } + } + + pub fn is_enabled(&self) -> bool { + self.config.enabled + && !self.config.posthog_api_key.is_empty() + && !self.config.posthog_api_endpoint.is_empty() + } + + pub fn track_event(&self, user_id: &str, event_name: &str, properties: Option) { + let endpoint = format!( + "{}/capture/", + self.config.posthog_api_endpoint.trim_end_matches('/') + ); + + let mut event_properties = properties.unwrap_or_else(|| json!({})); + if let Some(props) = event_properties.as_object_mut() { + props.insert( + "timestamp".to_string(), + json!(chrono::Utc::now().to_rfc3339()), + ); + } + + let payload = json!({ + "api_key": self.config.posthog_api_key, + "event": event_name, + "distinct_id": user_id, + "properties": event_properties + }); + + let client = self.client.clone(); + let event_name = event_name.to_string(); + + tokio::spawn(async move { + match client + .post(&endpoint) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + tracing::debug!("Event '{}' sent successfully", event_name); + } else { + let status = response.status(); + let response_text = response.text().await.unwrap_or_default(); + tracing::error!( + "Failed to send event. Status: {}. Response: {}", + status, + response_text + ); + } + } + Err(e) => { + tracing::error!("Error sending event '{}': {}", event_name, e); + } + } + }); + } +} + +/// Generates a consistent, anonymous user ID for npm package telemetry. +/// Returns a hex string prefixed with "npm_user_" +pub fn generate_user_id() -> String { + let mut hasher = DefaultHasher::new(); + + #[cfg(target_os = "macos")] + { + // Use ioreg to get hardware UUID + if let Ok(output) = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(line) = stdout.lines().find(|l| l.contains("IOPlatformUUID")) { + line.hash(&mut hasher); + } + } + } + + #[cfg(target_os = "linux")] + { + if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { + machine_id.trim().hash(&mut hasher); + } + } + + #[cfg(target_os = "windows")] + { + // Use PowerShell to get machine GUID from registry + if let Ok(output) = std::process::Command::new("powershell") + .args(&[ + "-NoProfile", + "-Command", + "(Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid", + ]) + .output() + { + if output.status.success() { + output.stdout.hash(&mut hasher); + } + } + } + + // Add username for per-user differentiation + if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) { + user.hash(&mut hasher); + } + + // Add home directory for additional entropy + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + home.hash(&mut hasher); + } + + format!("npm_user_{:016x}", hasher.finish()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_user_id_format() { + let id = generate_user_id(); + assert!(id.starts_with("npm_user_")); + assert_eq!(id.len(), 25); + } + + #[test] + fn test_consistency() { + let id1 = generate_user_id(); + let id2 = generate_user_id(); + assert_eq!(id1, id2, "ID should be consistent across calls"); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index d8810916..02b70e2b 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,3 +1,5 @@ +pub mod analytics; pub mod pr_monitor; +pub use analytics::{generate_user_id, AnalyticsConfig, AnalyticsService}; pub use pr_monitor::PrMonitorService; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c48578b0..7a896168 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -421,6 +421,36 @@ export function Settings() { + + + Privacy + + Help improve Vibe-Kanban by sharing anonymous usage data. + + + +
+ + updateConfig({ analytics_enabled: checked }) + } + /> +
+ +

+ Enables anonymous usage events tracking to help improve the + application. No prompts or project information are + collected. +

+
+
+
+
+ Safety & Disclaimers diff --git a/npx-cli/package.json b/npx-cli/package.json index c1b40988..eb768908 100644 --- a/npx-cli/package.json +++ b/npx-cli/package.json @@ -1,7 +1,7 @@ { "name": "vibe-kanban", "private": false, - "version": "0.0.37-clash.0", + "version": "0.0.37-0", "main": "index.js", "bin": { "vibe-kanban": "bin/cli.js" @@ -14,4 +14,4 @@ "dist", "bin" ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index 8ef0ca9f..222fafa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibe-kanban", - "version": "0.0.37-clash.0", + "version": "0.0.37-0", "private": true, "scripts": { "dev": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && concurrently \"cargo watch -w backend -x 'run --manifest-path backend/Cargo.toml'\" \"npm run frontend:dev\"", @@ -26,4 +26,4 @@ "node": ">=18", "pnpm": ">=8" } -} +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts index 6129331b..83346977 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -4,7 +4,7 @@ export type ApiResponse = { success: boolean, data: T | null, message: string | null, }; -export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, github: GitHubConfig, }; +export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, }; export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red";