sesame/
workspace.rs

1use anyhow::Context;
2use comfy_table::{Table, presets::UTF8_FULL};
3use core_types::TrustProfileName;
4use owo_colors::OwoColorize;
5use zeroize::Zeroize;
6
7use crate::cli::resolve_workspace_path;
8use crate::cli::{WorkspaceCmd, WorkspaceConfigCmd, WorkspaceListFormat};
9use crate::ipc::{connect, fetch_multi_profile_secrets, parse_profile_specs};
10
11/// Check if creating a path requires privilege escalation.
12///
13/// Walks up the directory tree to find the first existing ancestor and
14/// checks if it is owned by the current user.
15#[cfg(target_os = "linux")]
16fn needs_privilege(path: &std::path::Path) -> bool {
17    let uid = unsafe { libc::getuid() };
18    let mut check = path.to_path_buf();
19    loop {
20        if check.exists() {
21            return std::fs::metadata(&check)
22                .map(|m| {
23                    use std::os::unix::fs::MetadataExt;
24                    m.uid() != uid
25                })
26                .unwrap_or(true);
27        }
28        if !check.pop() {
29            return true;
30        }
31    }
32}
33
34/// Compare two git remote URLs, normalizing `.git` suffix and trailing slashes.
35fn urls_match(a: &str, b: &str) -> bool {
36    fn normalize(url: &str) -> String {
37        url.trim_end_matches('/')
38            .trim_end_matches(".git")
39            .to_lowercase()
40    }
41    normalize(a) == normalize(b)
42}
43
44pub(crate) async fn cmd_workspace(cmd: WorkspaceCmd) -> anyhow::Result<()> {
45    match cmd {
46        WorkspaceCmd::Init { root, user } => {
47            let user =
48                user.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "user".into()));
49
50            #[cfg(target_os = "linux")]
51            {
52                // Confirm before privilege escalation.
53                if !root.exists() && needs_privilege(&root) {
54                    eprintln!(
55                        "Workspace root '{}' does not exist and requires elevated privileges to create.",
56                        root.display()
57                    );
58                    eprint!("Continue? [y/N] ");
59                    use std::io::Write;
60                    std::io::stderr().flush()?;
61                    let mut answer = String::new();
62                    std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut answer)
63                        .context("failed to read confirmation")?;
64                    if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
65                        println!("Cancelled.");
66                        return Ok(());
67                    }
68                }
69
70                use sesame_workspace::platform::WorkspacePlatform;
71                let platform = sesame_workspace::platform::linux::LinuxPlatform;
72                platform
73                    .ensure_root(&root)
74                    .map_err(|e| anyhow::anyhow!("{e}"))?;
75            }
76
77            #[cfg(not(target_os = "linux"))]
78            {
79                std::fs::create_dir_all(&root).context("failed to create workspace root")?;
80            }
81
82            let user_dir = root.join(&user);
83            std::fs::create_dir_all(&user_dir).context("failed to create user directory")?;
84
85            let mut config = core_config::load_workspace_config().unwrap_or_default();
86            config.settings.root = root.clone();
87            config.settings.user = user.clone();
88            core_config::save_workspace_config(&config).map_err(|e| anyhow::anyhow!("{e}"))?;
89
90            println!("Workspace initialized: {}", user_dir.display());
91            println!(
92                "Config written: {}",
93                core_config::config_dir().join("workspaces.toml").display()
94            );
95            Ok(())
96        }
97
98        WorkspaceCmd::Clone {
99            url,
100            depth,
101            profile,
102            adopt,
103        } => {
104            let config = core_config::load_workspace_config().unwrap_or_default();
105            let root = sesame_workspace::config::resolve_root(&config);
106            let user = sesame_workspace::config::resolve_user(&config);
107
108            let conv = sesame_workspace::convention::parse_url(&url)
109                .map_err(|e| anyhow::anyhow!("{e}"))?;
110            let target = sesame_workspace::convention::canonical_path(&root, &user, &conv);
111
112            // Check if the target directory already exists and can be adopted.
113            let target_path = match &target {
114                sesame_workspace::CloneTarget::Regular(p) => p.clone(),
115                sesame_workspace::CloneTarget::WorkspaceGit(p) => p.clone(),
116            };
117
118            let adopted = if target_path.exists()
119                && sesame_workspace::git::is_git_repo(&target_path)
120                && adopt
121            {
122                // Verify the remote matches the requested URL.
123                let existing_remote = sesame_workspace::git::remote_url(&target_path)
124                    .map_err(|e| anyhow::anyhow!("{e}"))?;
125                match existing_remote {
126                    Some(ref remote) if urls_match(remote, &url) => true,
127                    Some(ref remote) => {
128                        anyhow::bail!(
129                            "directory exists with different remote:\n  existing: {remote}\n  requested: {url}\nRemove the directory or fix the remote manually."
130                        );
131                    }
132                    None => {
133                        anyhow::bail!(
134                            "directory exists as a git repo but has no 'origin' remote: {}",
135                            target_path.display()
136                        );
137                    }
138                }
139            } else {
140                false
141            };
142
143            let result_path = if adopted {
144                println!(
145                    "\x1b[32mAdopted\x1b[0m existing repository: {}",
146                    target_path.display()
147                );
148                target_path
149            } else {
150                let rp = sesame_workspace::git::clone_repo(&url, &target, depth)
151                    .map_err(|e| anyhow::anyhow!("{e}"))?;
152
153                // Contextual output based on clone target type.
154                match &target {
155                    sesame_workspace::CloneTarget::WorkspaceGit(_) => {
156                        println!("Cloned workspace.git to org directory: {}", rp.display());
157                        println!("  Peer repos will be cloned as siblings inside this directory.");
158                    }
159                    sesame_workspace::CloneTarget::Regular(_) => {
160                        println!("Cloned to: {}", rp.display());
161                    }
162                }
163                rp
164            };
165
166            // Link to profile if requested.
167            if let Some(ref profile_name) = profile {
168                let _validated = TrustProfileName::try_from(profile_name.as_str())
169                    .map_err(|e| anyhow::anyhow!("invalid profile name: {e}"))?;
170                let mut ws_config = core_config::load_workspace_config().unwrap_or_default();
171                sesame_workspace::config::add_link(
172                    &mut ws_config,
173                    &result_path.display().to_string(),
174                    profile_name,
175                );
176                core_config::save_workspace_config(&ws_config)
177                    .map_err(|e| anyhow::anyhow!("{e}"))?;
178                println!("Linked -> profile \"{}\"", profile_name);
179            }
180
181            Ok(())
182        }
183
184        WorkspaceCmd::List {
185            server,
186            org,
187            profile,
188            format,
189        } => {
190            let config = core_config::load_workspace_config().unwrap_or_default();
191            let mut workspaces = sesame_workspace::discover::discover_workspaces(&config)
192                .map_err(|e| anyhow::anyhow!("{e}"))?;
193
194            if let Some(ref s) = server {
195                workspaces.retain(|w| w.convention.server == *s);
196            }
197            if let Some(ref o) = org {
198                workspaces.retain(|w| w.convention.org == *o);
199            }
200            if let Some(ref p) = profile {
201                workspaces.retain(|w| w.linked_profile.as_deref() == Some(p.as_str()));
202            }
203
204            match format {
205                WorkspaceListFormat::Table => {
206                    if workspaces.is_empty() {
207                        println!("No workspaces found.");
208                        return Ok(());
209                    }
210                    let mut table = Table::new();
211                    table.load_preset(UTF8_FULL);
212                    table.set_header(vec!["SERVER", "ORG", "REPO", "PROFILE", "PATH"]);
213                    for ws in &workspaces {
214                        let repo = ws.convention.repo.as_deref().unwrap_or("(workspace)");
215                        let ws_profile = ws.linked_profile.as_deref().unwrap_or("-");
216                        table.add_row(vec![
217                            &ws.convention.server,
218                            &ws.convention.org,
219                            repo,
220                            ws_profile,
221                            &ws.path.display().to_string(),
222                        ]);
223                    }
224                    println!("{table}");
225                }
226                WorkspaceListFormat::Json => {
227                    let json: Vec<serde_json::Value> = workspaces
228                        .iter()
229                        .map(|ws| {
230                            serde_json::json!({
231                                "server": ws.convention.server,
232                                "org": ws.convention.org,
233                                "repo": ws.convention.repo,
234                                "profile": ws.linked_profile,
235                                "path": ws.path.display().to_string(),
236                                "is_workspace_git": ws.is_workspace_git,
237                            })
238                        })
239                        .collect();
240                    println!("{}", serde_json::to_string_pretty(&json)?);
241                }
242            }
243            Ok(())
244        }
245
246        WorkspaceCmd::Status { path, verbose } => {
247            let path = resolve_workspace_path(path)?;
248            let config = core_config::load_workspace_config().unwrap_or_default();
249            let root = sesame_workspace::config::resolve_root(&config);
250
251            let conv = sesame_workspace::convention::parse_path(&root, &path)
252                .map_err(|e| anyhow::anyhow!("{e}"))?;
253            let remote = sesame_workspace::git::remote_url(&path)
254                .ok()
255                .flatten()
256                .unwrap_or_else(|| "unknown".into());
257            let branch =
258                sesame_workspace::git::current_branch(&path).unwrap_or_else(|_| "unknown".into());
259            let clean = sesame_workspace::git::is_clean(&path).unwrap_or(false);
260
261            // Use effective config for profile resolution.
262            let effective =
263                sesame_workspace::config::resolve_effective_config(&config, &path, &root)
264                    .map_err(|e| anyhow::anyhow!("{e}"))?;
265            let in_ws_git = sesame_workspace::convention::is_inside_workspace_git(&path);
266
267            println!("Workspace:  {}", path.display());
268            println!("Remote:     {remote}");
269            println!("Branch:     {branch}");
270
271            // Color-coded status.
272            let status_str = if clean {
273                "clean".green().to_string()
274            } else {
275                "dirty".yellow().to_string()
276            };
277            println!("Status:     {status_str}");
278            println!(
279                "Profile:    {}",
280                effective.profile.as_deref().unwrap_or("(none)")
281            );
282            println!(
283                "Namespace:  {} ({})",
284                conv.org,
285                if in_ws_git {
286                    "workspace.git"
287                } else {
288                    "no workspace.git"
289                }
290            );
291
292            if verbose {
293                println!(
294                    "Convention: {} / {} / {} / {} / {}",
295                    root.display(),
296                    config.settings.user,
297                    conv.server,
298                    conv.org,
299                    conv.repo.as_deref().unwrap_or("(workspace.git)")
300                );
301
302                // Disk usage.
303                if let Ok(output) = std::process::Command::new("du")
304                    .arg("-sh")
305                    .arg("--")
306                    .arg(&path)
307                    .output()
308                    && let Ok(s) = String::from_utf8(output.stdout)
309                    && let Some(size) = s.split_whitespace().next()
310                {
311                    println!("Disk:       {size}");
312                }
313            }
314            Ok(())
315        }
316
317        WorkspaceCmd::Link { profile, path } => {
318            let _validated = TrustProfileName::try_from(profile.as_str())
319                .map_err(|e| anyhow::anyhow!("invalid profile name: {e}"))?;
320
321            let path = resolve_workspace_path(path)?;
322
323            let mut config = core_config::load_workspace_config().unwrap_or_default();
324            sesame_workspace::config::add_link(&mut config, &path.display().to_string(), &profile);
325            core_config::save_workspace_config(&config).map_err(|e| anyhow::anyhow!("{e}"))?;
326            println!("Linked {} -> profile \"{}\"", path.display(), profile);
327            Ok(())
328        }
329
330        WorkspaceCmd::Unlink { path } => {
331            let path = resolve_workspace_path(path)?;
332            let mut config = core_config::load_workspace_config().unwrap_or_default();
333            let path_str = path.display().to_string();
334            if sesame_workspace::config::remove_link(&mut config, &path_str) {
335                core_config::save_workspace_config(&config).map_err(|e| anyhow::anyhow!("{e}"))?;
336                println!("Unlinked {}", path.display());
337            } else {
338                println!("No link found for {}", path.display());
339            }
340            Ok(())
341        }
342
343        WorkspaceCmd::Shell {
344            profile,
345            path,
346            shell,
347            prefix,
348            command,
349        } => {
350            let path = resolve_workspace_path(path)?;
351            let config = core_config::load_workspace_config().unwrap_or_default();
352            let root = sesame_workspace::config::resolve_root(&config);
353
354            // Use effective config for profile resolution.
355            let effective =
356                sesame_workspace::config::resolve_effective_config(&config, &path, &root)
357                    .map_err(|e| anyhow::anyhow!("{e}"))?;
358
359            // Profile resolution: CLI flag > effective config > SESAME_PROFILES env > "default"
360            let profile_csv = profile
361                .or(effective.profile)
362                .or_else(|| std::env::var("SESAME_PROFILES").ok())
363                .unwrap_or_else(|| core_types::DEFAULT_PROFILE_NAME.into());
364
365            let specs = parse_profile_specs(&profile_csv);
366            let secret_prefix = prefix.or(effective.secret_prefix);
367
368            // Connect to IPC and fetch secrets from all profiles.
369            let client = connect().await?;
370            let env_vars =
371                fetch_multi_profile_secrets(&client, &specs, secret_prefix.as_deref()).await?;
372
373            // Determine what to spawn.
374            let (bin, args, is_interactive) = if !command.is_empty() {
375                (command[0].clone(), command[1..].to_vec(), false)
376            } else {
377                let shell_bin = shell
378                    .or_else(|| std::env::var("SHELL").ok())
379                    .unwrap_or_else(|| "/bin/sh".into());
380                (shell_bin, Vec::new(), true)
381            };
382
383            let mut cmd = std::process::Command::new(&bin);
384            cmd.args(&args);
385            cmd.current_dir(&path);
386            cmd.env("SESAME_PROFILES", &profile_csv);
387            cmd.env("SESAME_WORKSPACE", path.display().to_string());
388
389            // Inject effective env vars from .sesame.toml layers.
390            for (k, v) in &effective.env {
391                cmd.env(k, v);
392            }
393
394            // Inject secrets.
395            for (k, v) in &env_vars {
396                let val_str = String::from_utf8_lossy(v);
397                cmd.env(k, val_str.as_ref());
398            }
399
400            if is_interactive {
401                println!(
402                    "Entering workspace shell (profiles: {profile_csv}, {} secrets injected)",
403                    env_vars.len()
404                );
405            }
406            let status = cmd.status().context("failed to spawn command")?;
407
408            // Zeroize secrets.
409            for (_, mut v) in env_vars {
410                v.zeroize();
411            }
412
413            std::process::exit(status.code().unwrap_or(1));
414        }
415
416        WorkspaceCmd::Config(sub) => match sub {
417            WorkspaceConfigCmd::Show { path } => {
418                let path = resolve_workspace_path(path)?;
419                let config = core_config::load_workspace_config().unwrap_or_default();
420                let root = sesame_workspace::config::resolve_root(&config);
421
422                let effective =
423                    sesame_workspace::config::resolve_effective_config(&config, &path, &root)
424                        .map_err(|e| anyhow::anyhow!("{e}"))?;
425
426                println!("Workspace:      {}", path.display());
427                println!(
428                    "Profile:        {} (source: {})",
429                    effective.profile.as_deref().unwrap_or("(none)"),
430                    if effective.provenance.profile_source.is_empty() {
431                        "default"
432                    } else {
433                        effective.provenance.profile_source
434                    }
435                );
436                if let Some(ref prefix) = effective.secret_prefix {
437                    println!(
438                        "Secret prefix:  {prefix} (source: {})",
439                        effective.provenance.secret_prefix_source
440                    );
441                }
442                if !effective.env.is_empty() {
443                    println!("Environment:");
444                    for (k, v) in &effective.env {
445                        println!("  {k}={v}");
446                    }
447                }
448                if !effective.tags.is_empty() {
449                    println!("Tags:           {}", effective.tags.join(", "));
450                }
451                Ok(())
452            }
453        },
454    }
455}