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#[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
34fn 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 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 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 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 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 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 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 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 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 let effective =
356 sesame_workspace::config::resolve_effective_config(&config, &path, &root)
357 .map_err(|e| anyhow::anyhow!("{e}"))?;
358
359 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 let client = connect().await?;
370 let env_vars =
371 fetch_multi_profile_secrets(&client, &specs, secret_prefix.as_deref()).await?;
372
373 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 for (k, v) in &effective.env {
391 cmd.env(k, v);
392 }
393
394 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 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}