posthog_cli/experimental/
schema.rs1use anyhow::{Context, Result};
2use inquire::{Select, Text};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7use tracing::info;
8
9use crate::api::client::PHClient;
10use crate::invocation_context::context;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14enum Language {
15 TypeScript,
16}
17
18impl Language {
19 fn as_str(&self) -> &'static str {
21 match self {
22 Language::TypeScript => "typescript",
23 }
24 }
25
26 fn display_name(&self) -> &'static str {
28 match self {
29 Language::TypeScript => "TypeScript",
30 }
31 }
32
33 fn default_output_path(&self) -> &'static str {
35 match self {
36 Language::TypeScript => "posthog-typed.ts",
37 }
38 }
39
40 fn all() -> Vec<Language> {
42 vec![Language::TypeScript]
43 }
44
45 fn from_str(s: &str) -> Option<Language> {
47 match s {
48 "typescript" => Some(Language::TypeScript),
49 _ => None,
50 }
51 }
52}
53
54#[derive(Debug, Serialize, Deserialize, Default)]
55struct SchemaConfig {
56 languages: HashMap<String, LanguageConfig>,
57}
58
59#[derive(Debug, Serialize, Deserialize, Clone)]
60struct LanguageConfig {
61 output_path: String,
62 schema_hash: String,
63 updated_at: String,
64 event_count: usize,
65}
66
67impl SchemaConfig {
68 fn load() -> Self {
70 let content = fs::read_to_string("posthog.json").ok();
71 content
72 .and_then(|c| serde_json::from_str(&c).ok())
73 .unwrap_or_default()
74 }
75
76 fn save(&self) -> Result<()> {
78 let json =
79 serde_json::to_string_pretty(self).context("Failed to serialize schema config")?;
80 fs::write("posthog.json", json).context("Failed to write posthog.json")?;
81 Ok(())
82 }
83
84 fn get_language(&self, language: Language) -> Option<&LanguageConfig> {
86 self.languages.get(language.as_str())
87 }
88
89 fn get_output_path(&self, language: Language) -> Option<String> {
91 self.languages
92 .get(language.as_str())
93 .map(|l| l.output_path.clone())
94 }
95
96 fn update_language(
98 &mut self,
99 language: Language,
100 output_path: String,
101 schema_hash: String,
102 event_count: usize,
103 ) {
104 use chrono::Utc;
105
106 self.languages.insert(
107 language.as_str().to_string(),
108 LanguageConfig {
109 output_path,
110 schema_hash,
111 updated_at: Utc::now().to_rfc3339(),
112 event_count,
113 },
114 );
115 }
116}
117
118#[derive(Debug, Deserialize)]
119struct DefinitionsResponse {
120 content: String,
121 event_count: usize,
122 schema_hash: String,
123}
124
125pub fn pull(_host: Option<String>, output_override: Option<String>) -> Result<()> {
126 let language = select_language()?;
128
129 info!(
130 "Fetching {} definitions from PostHog...",
131 language.display_name()
132 );
133
134 let client = &context().client;
136
137 let output_path = determine_output_path(language, output_override)?;
139
140 let response = fetch_definitions(client, language)?;
142
143 info!(
144 "ā Fetched {} definitions for {} events",
145 language.display_name(),
146 response.event_count
147 );
148
149 let config = SchemaConfig::load();
151 if let Some(lang_config) = config.get_language(language) {
152 if lang_config.schema_hash == response.schema_hash {
153 info!(
154 "Schema unchanged for {} (hash: {})",
155 language.as_str(),
156 response.schema_hash
157 );
158 println!(
159 "\nā {} schema is already up to date!",
160 language.display_name()
161 );
162 println!(" No changes detected - skipping file write.");
163 return Ok(());
164 }
165 }
166
167 info!("Writing {}...", output_path);
169
170 if let Some(parent) = Path::new(&output_path).parent() {
172 if !parent.as_os_str().is_empty() {
173 fs::create_dir_all(parent)
174 .context(format!("Failed to create directory {}", parent.display()))?;
175 }
176 }
177
178 fs::write(&output_path, &response.content).context(format!("Failed to write {output_path}"))?;
179 info!("ā Generated {}", output_path);
180
181 info!("Updating posthog.json...");
183 let mut config = SchemaConfig::load();
184 config.update_language(
185 language,
186 output_path.clone(),
187 response.schema_hash,
188 response.event_count,
189 );
190 config.save()?;
191 info!("ā Updated posthog.json");
192
193 println!("\nā Schema sync complete!");
194 println!("\nNext steps:");
195 println!(" 1. Import PostHog from your generated module:");
196 println!(" import posthog from './{output_path}'");
197 println!(" 2. Use typed events with autocomplete and type safety:");
198 println!(" posthog.captureTyped('event_name', {{ property: 'value' }})");
199 println!(" 3. Or use regular capture() for flexibility:");
200 println!(" posthog.capture('dynamic_event', {{ any: 'data' }})");
201 println!();
202
203 Ok(())
204}
205
206fn determine_output_path(language: Language, output_override: Option<String>) -> Result<String> {
207 if let Some(path) = output_override {
209 return Ok(normalize_output_path(&path, language));
210 }
211
212 let config = SchemaConfig::load();
214 if let Some(path) = config.get_output_path(language) {
215 return Ok(path);
216 }
217
218 let default_filename = language.default_output_path();
220 let current_dir = std::env::current_dir()
221 .ok()
222 .and_then(|p| p.to_str().map(String::from))
223 .unwrap_or_else(|| ".".to_string());
224
225 let help_message = format!(
226 "Your app will import PostHog from this file, so it should be accessible \
227 throughout your codebase (e.g., src/lib/, app/lib/, or your project root). \
228 This path will be saved in posthog.json and can be changed later. \
229 Current directory: {current_dir}"
230 );
231
232 let path = Text::new(&format!(
233 "Where should we save the {} typed PostHog module?",
234 language.display_name()
235 ))
236 .with_default(default_filename)
237 .with_help_message(&help_message)
238 .prompt()
239 .unwrap_or(default_filename.to_string());
240
241 Ok(normalize_output_path(&path, language))
242}
243
244fn normalize_output_path(path: &str, language: Language) -> String {
245 let path_obj = Path::new(path);
246
247 let should_append_filename =
249 (path_obj.exists() && path_obj.is_dir()) || path.ends_with('/') || path.ends_with('\\');
250
251 if should_append_filename {
252 path_obj
253 .join(language.default_output_path())
254 .to_string_lossy()
255 .into_owned()
256 } else {
257 path.to_string()
258 }
259}
260
261pub fn status() -> Result<()> {
262 println!("\nPostHog Schema Sync Status\n");
264
265 println!("Authentication:");
266 let config = context().config.clone();
267 println!(" ā Authenticated");
268 println!(" Host: {}", config.host);
269 println!(" Project ID: {}", config.env_id);
270 let masked_token = format!(
271 "{}****{}",
272 &config.api_key[..4],
273 &config.api_key[config.api_key.len() - 4..]
274 );
275 println!(" Token: {masked_token}");
276
277 println!();
278
279 println!("Schema:");
281 let config = SchemaConfig::load();
282
283 if config.languages.is_empty() {
284 println!(" ā No schemas synced");
285 println!(" Run: posthog-cli exp schema pull");
286 } else {
287 println!(" ā Schemas synced\n");
288
289 for (language_str, lang_config) in &config.languages {
290 let display = Language::from_str(language_str)
292 .map(|l| l.display_name())
293 .unwrap_or(language_str.as_str());
294
295 println!(" {display}:");
296 println!(" Hash: {}", lang_config.schema_hash);
297 println!(" Updated: {}", lang_config.updated_at);
298 println!(" Events: {}", lang_config.event_count);
299
300 if Path::new(&lang_config.output_path).exists() {
301 println!(" File: ā {}", lang_config.output_path);
302 } else {
303 println!(" File: ! {} (missing)", lang_config.output_path);
304 }
305 println!();
306 }
307 }
308
309 println!();
310
311 Ok(())
312}
313
314fn fetch_definitions(client: &PHClient, language: Language) -> Result<DefinitionsResponse> {
315 let url = format!(
316 "/api/projects/{}/event_definitions/{}/",
317 client.get_env_id(),
318 language.as_str()
319 );
320
321 let response = client.get(&url)?.send().context(format!(
322 "Failed to fetch {} definitions",
323 language.display_name()
324 ))?;
325
326 if !response.status().is_success() {
327 return Err(anyhow::anyhow!(
328 "Failed to fetch {} definitions: HTTP {}",
329 language.display_name(),
330 response.status()
331 ));
332 }
333
334 let json: DefinitionsResponse = response.json().context(format!(
335 "Failed to parse {} definitions response",
336 language.display_name()
337 ))?;
338
339 Ok(json)
340}
341
342fn select_language() -> Result<Language> {
343 let languages = Language::all();
344
345 if languages.len() == 1 {
346 return Ok(languages[0]);
347 }
348
349 let language_strs: Vec<&str> = languages.iter().map(|l| l.display_name()).collect();
350 let selected = Select::new("Which language would you like to download?", language_strs)
351 .prompt()
352 .context("Failed to select language")?;
353
354 languages
356 .into_iter()
357 .find(|l| l.display_name() == selected)
358 .ok_or_else(|| anyhow::anyhow!("Invalid language selection"))
359}