sesame/
workspace.rs

1use anyhow::Context;
2use core_types::TrustProfileName;
3use owo_colors::OwoColorize;
4use zeroize::Zeroize;
5
6use crate::cli::resolve_workspace_path;
7use crate::cli::{WorkspaceCmd, WorkspaceConfigCmd, WorkspaceListFormat};
8use crate::ipc::{connect, fetch_multi_profile_secrets, parse_profile_specs};
9
10/// Check if creating a path requires privilege escalation.
11///
12/// Walks up the directory tree to find the first existing ancestor and
13/// checks if it is owned by the current user.
14#[cfg(target_os = "linux")]
15fn needs_privilege(path: &std::path::Path) -> bool {
16    let uid = unsafe { libc::getuid() };
17    let mut check = path.to_path_buf();
18    loop {
19        if check.exists() {
20            return std::fs::metadata(&check)
21                .map(|m| {
22                    use std::os::unix::fs::MetadataExt;
23                    m.uid() != uid
24                })
25                .unwrap_or(true);
26        }
27        if !check.pop() {
28            return true;
29        }
30    }
31}
32
33/// Compare two git remote URLs, normalizing `.git` suffix and trailing slashes.
34fn urls_match(a: &str, b: &str) -> bool {
35    fn normalize(url: &str) -> String {
36        url.trim_end_matches('/')
37            .trim_end_matches(".git")
38            .to_lowercase()
39    }
40    normalize(a) == normalize(b)
41}
42
43pub(crate) async fn cmd_workspace(cmd: WorkspaceCmd) -> anyhow::Result<()> {
44    match cmd {
45        WorkspaceCmd::Init { root, user } => {
46            let user =
47                user.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "user".into()));
48
49            #[cfg(target_os = "linux")]
50            {
51                // Confirm before privilege escalation.
52                if !root.exists() && needs_privilege(&root) {
53                    eprintln!(
54                        "Workspace root '{}' does not exist and requires elevated privileges to create.",
55                        root.display()
56                    );
57                    eprint!("Continue? [y/N] ");
58                    use std::io::Write;
59                    std::io::stderr().flush()?;
60                    let mut answer = String::new();
61                    std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut answer)
62                        .context("failed to read confirmation")?;
63                    if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
64                        println!("Cancelled.");
65                        return Ok(());
66                    }
67                }
68
69                use sesame_workspace::platform::WorkspacePlatform;
70                let platform = sesame_workspace::platform::linux::LinuxPlatform;
71                platform
72                    .ensure_root(&root)
73                    .map_err(|e| anyhow::anyhow!("{e}"))?;
74            }
75
76            #[cfg(not(target_os = "linux"))]
77            {
78                std::fs::create_dir_all(&root).context("failed to create workspace root")?;
79            }
80
81            let user_dir = root.join(&user);
82            std::fs::create_dir_all(&user_dir).context("failed to create user directory")?;
83
84            let mut config = core_config::load_workspace_config().unwrap_or_default();
85            config.settings.root = root.clone();
86            config.settings.user = user.clone();
87            core_config::save_workspace_config(&config).map_err(|e| anyhow::anyhow!("{e}"))?;
88
89            println!("Workspace initialized: {}", user_dir.display());
90            println!(
91                "Config written: {}",
92                core_config::config_dir().join("workspaces.toml").display()
93            );
94            Ok(())
95        }
96
97        WorkspaceCmd::Clone {
98            url,
99            depth,
100            profile,
101            adopt,
102            workspace_init,
103            workspace_update,
104            no_workspace,
105            force,
106            project,
107            include_forks,
108            include_archived,
109        } => {
110            let config = core_config::load_workspace_config().unwrap_or_default();
111            let root = sesame_workspace::config::resolve_root(&config);
112            let user = sesame_workspace::config::resolve_user(&config);
113
114            // For --project, accept org-only URLs (e.g. https://github.com/ScopeCreep-zip)
115            // by appending a placeholder repo component that parse_url requires.
116            let parse_url = if project {
117                let trimmed = url.trim_end_matches('/');
118                // Count path segments after the scheme. org-only has 2 (server/org).
119                let without_scheme = trimmed
120                    .strip_prefix("https://")
121                    .or_else(|| trimmed.strip_prefix("http://"))
122                    .unwrap_or(trimmed);
123                let segments = without_scheme.split('/').count();
124                if segments == 2 {
125                    format!("{trimmed}/_placeholder")
126                } else {
127                    url.clone()
128                }
129            } else {
130                url.clone()
131            };
132
133            let conv = sesame_workspace::convention::parse_url(&parse_url)
134                .map_err(|e| anyhow::anyhow!("{e}"))?;
135
136            // --project: clone all repos in the org via forge API.
137            if project {
138                let forge =
139                    sesame_workspace::forge::forge_for_server(&conv.server).ok_or_else(|| {
140                        anyhow::anyhow!(
141                            "forge API not supported for server: {} (supported: github.com)",
142                            conv.server
143                        )
144                    })?;
145                let opts = sesame_workspace::forge::ListOptions {
146                    include_forks,
147                    include_archived,
148                };
149                let repos = forge
150                    .list_org_repos(&conv.org, &opts)
151                    .map_err(|e| anyhow::anyhow!("{e}"))?;
152
153                eprintln!(
154                    "Found {} repositories in {}/{}",
155                    repos.len(),
156                    conv.server,
157                    conv.org,
158                );
159
160                // Set up workspace.git first if available (reuse auto-discovery logic).
161                let org_dir = root.join(&user).join(&conv.server).join(&conv.org);
162                if !org_dir.join(".git").is_dir() {
163                    let ws_repo_name = &config.settings.workspace_repo;
164                    let ws_url =
165                        format!("https://{}/{}/{}.git", conv.server, conv.org, ws_repo_name,);
166                    if sesame_workspace::git::probe_remote(&ws_url) {
167                        eprintln!(
168                            "Setting up org workspace from {}/{}/{}.git...",
169                            conv.server, conv.org, ws_repo_name,
170                        );
171                        let ws_conv = sesame_workspace::convention::WorkspaceConvention {
172                            server: conv.server.clone(),
173                            org: conv.org.clone(),
174                            repo: None,
175                            is_workspace_git: true,
176                        };
177                        let ws_target =
178                            sesame_workspace::convention::canonical_path(&root, &user, &ws_conv);
179                        match sesame_workspace::git::clone_repo(&ws_url, &ws_target, None, force) {
180                            Ok(p) => eprintln!("  Workspace initialized: {}", p.display()),
181                            Err(e) => eprintln!("  Warning: workspace.git setup failed: {e}"),
182                        }
183                    }
184                }
185
186                // Filter out the workspace repo itself — it's managed separately.
187                let ws_repo_name = &config.settings.workspace_repo;
188                let repos: Vec<_> = repos
189                    .into_iter()
190                    .filter(|r| r.name != *ws_repo_name)
191                    .collect();
192
193                let mut success = 0usize;
194                let mut skipped = 0usize;
195                let mut failed = 0usize;
196
197                for repo_info in &repos {
198                    let repo_url = &repo_info.clone_url;
199                    let repo_conv = match sesame_workspace::convention::parse_url(repo_url) {
200                        Ok(c) => c,
201                        Err(e) => {
202                            eprintln!("  Skipping {}: {e}", repo_info.name);
203                            failed += 1;
204                            continue;
205                        }
206                    };
207                    let repo_target =
208                        sesame_workspace::convention::canonical_path(&root, &user, &repo_conv);
209                    let repo_path = repo_target.path().to_path_buf();
210
211                    if repo_path.exists() && sesame_workspace::git::is_git_repo(&repo_path) {
212                        eprintln!("  {} (exists)", repo_info.name.dimmed());
213                        skipped += 1;
214                        continue;
215                    }
216
217                    match sesame_workspace::git::clone_repo(repo_url, &repo_target, depth, force) {
218                        Ok(_) => {
219                            eprintln!("  {} {}", "Cloned".green(), repo_info.name);
220                            success += 1;
221                        }
222                        Err(e) => {
223                            let hint = if e.to_string().contains("auth")
224                                || e.to_string().contains("401")
225                                || e.to_string().contains("403")
226                            {
227                                " (may be a private repo — check GITHUB_TOKEN)"
228                            } else {
229                                ""
230                            };
231                            eprintln!("  {} {}: {e}{hint}", "Failed".red(), repo_info.name,);
232                            failed += 1;
233                        }
234                    }
235                }
236
237                eprintln!("\n{success} cloned, {skipped} skipped (exist), {failed} failed",);
238                return Ok(());
239            }
240
241            let target = sesame_workspace::convention::canonical_path(&root, &user, &conv);
242
243            // Workspace.git auto-discovery.
244            //
245            // Behavior is driven by config (`workspace_auto`) with CLI flag overrides:
246            //   --no-workspace      → skip everything
247            //   --workspace-init    → force init even if org dir exists
248            //   --workspace-update  → force pull if behind
249            //
250            // Config modes:
251            //   "auto"   → init on new org dir, inform on existing
252            //   "always" → init or update without asking
253            //   "never"  → no probes, no informs
254            //   "prompt" → interactive confirmation (TODO: future)
255            //
256            // This block is best-effort — probe failures or clone failures are
257            // non-fatal. The project repo clone always proceeds.
258            if !conv.is_workspace_git && !no_workspace {
259                let mode = if workspace_init || workspace_update {
260                    "always" // CLI flags override config
261                } else {
262                    config.settings.workspace_auto.as_str()
263                };
264
265                if mode != "never" {
266                    let org_dir = root.join(&user).join(&conv.server).join(&conv.org);
267                    let ws_repo_name = &config.settings.workspace_repo;
268                    let ws_url =
269                        format!("https://{}/{}/{}.git", conv.server, conv.org, ws_repo_name,);
270
271                    let has_workspace_git = org_dir.join(".git").is_dir();
272                    let org_dir_exists = org_dir.exists();
273
274                    if has_workspace_git {
275                        // Workspace.git already initialized.
276                        if workspace_update || mode == "always" {
277                            // User or config requested update — pull.
278                            eprintln!("Updating org workspace at {}...", org_dir.display());
279                            match sesame_workspace::git::pull_ff_only(&org_dir) {
280                                Ok(()) => eprintln!("  Workspace updated."),
281                                Err(e) => eprintln!("  Warning: workspace pull failed: {e}"),
282                            }
283                        } else if mode == "auto" {
284                            // Auto mode: show local vs remote state, don't modify.
285                            let local = sesame_workspace::git::head_commit_short(&org_dir)
286                                .ok()
287                                .flatten()
288                                .unwrap_or_else(|| "(unborn)".into());
289                            let branch = sesame_workspace::git::current_branch(&org_dir)
290                                .unwrap_or_else(|_| "main".into());
291                            let tracking = sesame_workspace::git::remote_tracking_commit_short(
292                                &org_dir, &branch,
293                            )
294                            .ok()
295                            .flatten();
296
297                            if let Some(ref remote_commit) = tracking
298                                && *remote_commit != local
299                            {
300                                eprintln!(
301                                    "Note: org workspace at {} is at {local}, origin/{branch} is at {remote_commit}",
302                                    org_dir.display(),
303                                );
304                                eprintln!(
305                                    "  Update with: sesame workspace clone {} --workspace-update",
306                                    url,
307                                );
308                            }
309                        }
310                    } else if !org_dir_exists {
311                        // Org dir is new (doesn't exist) — safe to init assertively.
312                        if sesame_workspace::git::probe_remote(&ws_url) {
313                            eprintln!(
314                                "Detected {}/{}/{}.git — setting up org workspace...",
315                                conv.server, conv.org, ws_repo_name,
316                            );
317                            let ws_conv = sesame_workspace::convention::WorkspaceConvention {
318                                server: conv.server.clone(),
319                                org: conv.org.clone(),
320                                repo: None,
321                                is_workspace_git: true,
322                            };
323                            let ws_target = sesame_workspace::convention::canonical_path(
324                                &root, &user, &ws_conv,
325                            );
326                            match sesame_workspace::git::clone_repo(
327                                &ws_url, &ws_target, None, force,
328                            ) {
329                                Ok(p) => eprintln!("  Workspace initialized: {}", p.display()),
330                                Err(e) => eprintln!("  Warning: workspace.git setup failed: {e}"),
331                            }
332                        }
333                    } else if (workspace_init || mode == "always") && org_dir_exists {
334                        // Org dir exists without .git — user or config wants init.
335                        // This will overwrite existing files (.envrc, .gitignore, etc.)
336                        // so require --force for explicit consent.
337                        if !force {
338                            if sesame_workspace::git::probe_remote(&ws_url) {
339                                eprintln!(
340                                    "Warning: --workspace-init would overwrite files in {}",
341                                    org_dir.display(),
342                                );
343                                eprintln!(
344                                    "  Add --force to proceed: sesame workspace clone {} --workspace-init --force",
345                                    url,
346                                );
347                            }
348                        } else if sesame_workspace::git::probe_remote(&ws_url) {
349                            eprintln!(
350                                "Initializing workspace.git around existing {}...",
351                                org_dir.display(),
352                            );
353                            let ws_conv = sesame_workspace::convention::WorkspaceConvention {
354                                server: conv.server.clone(),
355                                org: conv.org.clone(),
356                                repo: None,
357                                is_workspace_git: true,
358                            };
359                            let ws_target = sesame_workspace::convention::canonical_path(
360                                &root, &user, &ws_conv,
361                            );
362                            match sesame_workspace::git::clone_repo(
363                                &ws_url, &ws_target, None, force,
364                            ) {
365                                Ok(p) => eprintln!("  Workspace initialized: {}", p.display()),
366                                Err(e) => eprintln!("  Warning: workspace.git setup failed: {e}"),
367                            }
368                        }
369                    } else if mode == "auto" && org_dir_exists {
370                        // Org dir exists without .git, auto mode — inform only.
371                        if sesame_workspace::git::probe_remote(&ws_url) {
372                            eprintln!(
373                                "Tip: {}/{}/{}.git is available for this org.",
374                                conv.server, conv.org, ws_repo_name,
375                            );
376                            eprintln!(
377                                "  Initialize with: sesame workspace clone {} --workspace-init",
378                                url,
379                            );
380                            eprintln!("  Or directly: sesame workspace clone {ws_url}",);
381                        }
382                    }
383                }
384            }
385
386            // Check if the target directory already exists and can be adopted.
387            let target_path = match &target {
388                sesame_workspace::CloneTarget::Regular(p) => p.clone(),
389                sesame_workspace::CloneTarget::WorkspaceGit(p) => p.clone(),
390            };
391
392            let adopted = if target_path.exists()
393                && sesame_workspace::git::is_git_repo(&target_path)
394                && adopt
395            {
396                // Verify the remote matches the requested URL.
397                let existing_remote = sesame_workspace::git::remote_url(&target_path)
398                    .map_err(|e| anyhow::anyhow!("{e}"))?;
399                match existing_remote {
400                    Some(ref remote) if urls_match(remote, &url) => true,
401                    Some(ref remote) => {
402                        anyhow::bail!(
403                            "directory exists with different remote:\n  existing: {remote}\n  requested: {url}\nRemove the directory or fix the remote manually."
404                        );
405                    }
406                    None => {
407                        anyhow::bail!(
408                            "directory exists as a git repo but has no 'origin' remote: {}",
409                            target_path.display()
410                        );
411                    }
412                }
413            } else {
414                false
415            };
416
417            let result_path = if adopted {
418                println!(
419                    "\x1b[32mAdopted\x1b[0m existing repository: {}",
420                    target_path.display()
421                );
422                target_path
423            } else {
424                let rp = sesame_workspace::git::clone_repo(&url, &target, depth, force)
425                    .map_err(|e| anyhow::anyhow!("{e}"))?;
426
427                // Contextual output based on clone target type.
428                match &target {
429                    sesame_workspace::CloneTarget::WorkspaceGit(_) => {
430                        println!("Cloned workspace.git to org directory: {}", rp.display());
431                        println!("  Peer repos will be cloned as siblings inside this directory.");
432                    }
433                    sesame_workspace::CloneTarget::Regular(_) => {
434                        println!("Cloned to: {}", rp.display());
435                    }
436                }
437                rp
438            };
439
440            // Link to profile if requested.
441            if let Some(ref profile_name) = profile {
442                let _validated = TrustProfileName::try_from(profile_name.as_str())
443                    .map_err(|e| anyhow::anyhow!("invalid profile name: {e}"))?;
444                let mut ws_config = core_config::load_workspace_config().unwrap_or_default();
445                sesame_workspace::config::add_link(
446                    &mut ws_config,
447                    &result_path.display().to_string(),
448                    profile_name,
449                );
450                core_config::save_workspace_config(&ws_config)
451                    .map_err(|e| anyhow::anyhow!("{e}"))?;
452                println!("Linked -> profile \"{}\"", profile_name);
453            }
454
455            Ok(())
456        }
457
458        WorkspaceCmd::List {
459            server,
460            org,
461            profile,
462            format,
463        } => {
464            let config = core_config::load_workspace_config().unwrap_or_default();
465            let mut workspaces = sesame_workspace::discover::discover_workspaces(&config)
466                .map_err(|e| anyhow::anyhow!("{e}"))?;
467
468            if let Some(ref s) = server {
469                workspaces.retain(|w| w.convention.server == *s);
470            }
471            if let Some(ref o) = org {
472                workspaces.retain(|w| w.convention.org == *o);
473            }
474            if let Some(ref p) = profile {
475                workspaces.retain(|w| w.linked_profile.as_deref() == Some(p.as_str()));
476            }
477
478            match format {
479                WorkspaceListFormat::Table => {
480                    if workspaces.is_empty() {
481                        println!("No workspaces found.");
482                        return Ok(());
483                    }
484
485                    // Group by server/org for hierarchical display.
486                    let mut groups: std::collections::BTreeMap<
487                        (String, String),
488                        Vec<&sesame_workspace::DiscoveredWorkspace>,
489                    > = std::collections::BTreeMap::new();
490                    for ws in &workspaces {
491                        let key = (ws.convention.server.clone(), ws.convention.org.clone());
492                        groups.entry(key).or_default().push(ws);
493                    }
494
495                    let mut total_repos = 0usize;
496                    for ((srv, org_name), entries) in &groups {
497                        // Section header: server/org with workspace indicator.
498                        let has_ws = entries.iter().any(|e| e.is_workspace_git);
499                        let ws_tag = if has_ws {
500                            format!(" {}", "(workspace)".dimmed())
501                        } else {
502                            String::new()
503                        };
504                        println!("{}{ws_tag}", format!("{srv}/{org_name}").bold(),);
505
506                        // Repo lines under this org.
507                        for ws in entries {
508                            if ws.is_workspace_git {
509                                continue; // Don't list workspace.git as a repo row.
510                            }
511                            total_repos += 1;
512                            let repo_name = ws.convention.repo.as_deref().unwrap_or("?");
513
514                            let branch = sesame_workspace::git::current_branch(&ws.path)
515                                .unwrap_or_else(|_| "?".into());
516                            let commit = sesame_workspace::git::head_commit_short(&ws.path)
517                                .ok()
518                                .flatten()
519                                .unwrap_or_else(|| "?".into());
520                            let clean = sesame_workspace::git::is_clean(&ws.path).unwrap_or(true);
521                            let status_str = if clean {
522                                "clean".green().to_string()
523                            } else {
524                                "dirty".yellow().to_string()
525                            };
526
527                            let profile_str = match &ws.linked_profile {
528                                Some(p) => format!("  profile: {}", p.green()),
529                                None => String::new(),
530                            };
531
532                            println!(
533                                "  {:<20} {:<14} {}  {}{profile_str}",
534                                repo_name,
535                                branch,
536                                commit.dimmed(),
537                                status_str,
538                            );
539                        }
540                    }
541
542                    let org_count = groups.len();
543                    let server_count = groups
544                        .keys()
545                        .map(|(s, _)| s.as_str())
546                        .collect::<std::collections::BTreeSet<_>>()
547                        .len();
548                    println!(
549                        "\n{}",
550                        format!(
551                            "{server_count} server{}, {org_count} org{}, {total_repos} repo{}",
552                            if server_count != 1 { "s" } else { "" },
553                            if org_count != 1 { "s" } else { "" },
554                            if total_repos != 1 { "s" } else { "" },
555                        )
556                        .dimmed(),
557                    );
558                }
559                WorkspaceListFormat::Json => {
560                    let json: Vec<serde_json::Value> = workspaces
561                        .iter()
562                        .map(|ws| {
563                            serde_json::json!({
564                                "server": ws.convention.server,
565                                "org": ws.convention.org,
566                                "repo": ws.convention.repo,
567                                "profile": ws.linked_profile,
568                                "path": ws.path.display().to_string(),
569                                "is_workspace_git": ws.is_workspace_git,
570                            })
571                        })
572                        .collect();
573                    println!("{}", serde_json::to_string_pretty(&json)?);
574                }
575            }
576            Ok(())
577        }
578
579        WorkspaceCmd::Status { path, verbose } => {
580            let path = resolve_workspace_path(path)?;
581            let config = core_config::load_workspace_config().unwrap_or_default();
582            let root = sesame_workspace::config::resolve_root(&config);
583
584            let conv = sesame_workspace::convention::parse_path(&root, &path)
585                .map_err(|e| anyhow::anyhow!("{e}"))?;
586            let remote = sesame_workspace::git::remote_url(&path)
587                .ok()
588                .flatten()
589                .unwrap_or_else(|| "unknown".into());
590            let branch =
591                sesame_workspace::git::current_branch(&path).unwrap_or_else(|_| "unknown".into());
592            let clean = sesame_workspace::git::is_clean(&path).unwrap_or(false);
593
594            // Use effective config for profile resolution.
595            let effective =
596                sesame_workspace::config::resolve_effective_config(&config, &path, &root)
597                    .map_err(|e| anyhow::anyhow!("{e}"))?;
598            let in_ws_git = sesame_workspace::convention::is_inside_workspace_git(&path);
599
600            println!("Workspace:  {}", path.display());
601            println!("Remote:     {remote}");
602            println!("Branch:     {branch}");
603
604            // Commit info.
605            let head_short = sesame_workspace::git::head_commit_short(&path)
606                .ok()
607                .flatten()
608                .unwrap_or_else(|| "(unborn)".into());
609            let head_summary = sesame_workspace::git::head_commit_summary(&path)
610                .ok()
611                .flatten()
612                .unwrap_or_default();
613            let tracking_short =
614                sesame_workspace::git::remote_tracking_commit_short(&path, &branch)
615                    .ok()
616                    .flatten();
617            print!("Commit:     {head_short}");
618            if !head_summary.is_empty() {
619                print!(" {head_summary}");
620            }
621            println!();
622            if let Some(ref tracking) = tracking_short {
623                if *tracking != head_short {
624                    println!(
625                        "Tracking:   {} (origin/{branch} — {})",
626                        tracking,
627                        "behind".yellow(),
628                    );
629                } else {
630                    println!("Tracking:   {tracking} (origin/{branch} — up to date)");
631                }
632            }
633
634            // Color-coded status.
635            let status_str = if clean {
636                "clean".green().to_string()
637            } else {
638                "dirty".yellow().to_string()
639            };
640            println!("Status:     {status_str}");
641            println!(
642                "Profile:    {}",
643                effective.profile.as_deref().unwrap_or("(none)")
644            );
645            println!(
646                "Namespace:  {} ({})",
647                conv.org,
648                if in_ws_git {
649                    "workspace.git"
650                } else {
651                    "no workspace.git"
652                }
653            );
654
655            if verbose {
656                println!(
657                    "Convention: {} / {} / {} / {} / {}",
658                    root.display(),
659                    config.settings.user,
660                    conv.server,
661                    conv.org,
662                    conv.repo.as_deref().unwrap_or("(workspace.git)")
663                );
664
665                // Disk usage.
666                if let Ok(output) = std::process::Command::new("du")
667                    .arg("-sh")
668                    .arg("--")
669                    .arg(&path)
670                    .output()
671                    && let Ok(s) = String::from_utf8(output.stdout)
672                    && let Some(size) = s.split_whitespace().next()
673                {
674                    println!("Disk:       {size}");
675                }
676            }
677            Ok(())
678        }
679
680        WorkspaceCmd::Link { profile, path } => {
681            let _validated = TrustProfileName::try_from(profile.as_str())
682                .map_err(|e| anyhow::anyhow!("invalid profile name: {e}"))?;
683
684            let path = resolve_workspace_path(path)?;
685
686            let mut config = core_config::load_workspace_config().unwrap_or_default();
687            sesame_workspace::config::add_link(&mut config, &path.display().to_string(), &profile);
688            core_config::save_workspace_config(&config).map_err(|e| anyhow::anyhow!("{e}"))?;
689            println!("Linked {} -> profile \"{}\"", path.display(), profile);
690            Ok(())
691        }
692
693        WorkspaceCmd::Unlink { path } => {
694            let path = resolve_workspace_path(path)?;
695            let mut config = core_config::load_workspace_config().unwrap_or_default();
696            let path_str = path.display().to_string();
697            if sesame_workspace::config::remove_link(&mut config, &path_str) {
698                core_config::save_workspace_config(&config).map_err(|e| anyhow::anyhow!("{e}"))?;
699                println!("Unlinked {}", path.display());
700            } else {
701                println!("No link found for {}", path.display());
702            }
703            Ok(())
704        }
705
706        WorkspaceCmd::Shell {
707            profile,
708            path,
709            shell,
710            prefix,
711            command,
712        } => {
713            let path = resolve_workspace_path(path)?;
714            let config = core_config::load_workspace_config().unwrap_or_default();
715            let root = sesame_workspace::config::resolve_root(&config);
716
717            // Use effective config for profile resolution.
718            let effective =
719                sesame_workspace::config::resolve_effective_config(&config, &path, &root)
720                    .map_err(|e| anyhow::anyhow!("{e}"))?;
721
722            // Profile resolution: CLI flag > effective config > SESAME_PROFILES env > "default"
723            let profile_csv = profile
724                .or(effective.profile)
725                .or_else(|| std::env::var("SESAME_PROFILES").ok())
726                .unwrap_or_else(|| core_types::DEFAULT_PROFILE_NAME.into());
727
728            let specs = parse_profile_specs(&profile_csv);
729            let secret_prefix = prefix.or(effective.secret_prefix);
730
731            // Connect to IPC and fetch secrets from all profiles.
732            let client = connect().await?;
733            let env_vars =
734                fetch_multi_profile_secrets(&client, &specs, secret_prefix.as_deref()).await?;
735
736            // Determine what to spawn.
737            let (bin, args, is_interactive) = if !command.is_empty() {
738                (command[0].clone(), command[1..].to_vec(), false)
739            } else {
740                let shell_bin = shell
741                    .or_else(|| std::env::var("SHELL").ok())
742                    .unwrap_or_else(|| "/bin/sh".into());
743                (shell_bin, Vec::new(), true)
744            };
745
746            let mut cmd = std::process::Command::new(&bin);
747            cmd.args(&args);
748            cmd.current_dir(&path);
749            cmd.env("SESAME_PROFILES", &profile_csv);
750            cmd.env("SESAME_WORKSPACE", path.display().to_string());
751
752            // Inject effective env vars from .sesame.toml layers.
753            for (k, v) in &effective.env {
754                cmd.env(k, v);
755            }
756
757            // Inject secrets.
758            for (k, v) in &env_vars {
759                let val_str = String::from_utf8_lossy(v);
760                cmd.env(k, val_str.as_ref());
761            }
762
763            if is_interactive {
764                println!(
765                    "Entering workspace shell (profiles: {profile_csv}, {} secrets injected)",
766                    env_vars.len()
767                );
768            }
769            let status = cmd.status().context("failed to spawn command")?;
770
771            // Zeroize secrets.
772            for (_, mut v) in env_vars {
773                v.zeroize();
774            }
775
776            std::process::exit(status.code().unwrap_or(1));
777        }
778
779        WorkspaceCmd::Config(sub) => match sub {
780            WorkspaceConfigCmd::Show { path } => {
781                let path = resolve_workspace_path(path)?;
782                let config = core_config::load_workspace_config().unwrap_or_default();
783                let root = sesame_workspace::config::resolve_root(&config);
784
785                let effective =
786                    sesame_workspace::config::resolve_effective_config(&config, &path, &root)
787                        .map_err(|e| anyhow::anyhow!("{e}"))?;
788
789                println!("Workspace:      {}", path.display());
790                println!(
791                    "Profile:        {} (source: {})",
792                    effective.profile.as_deref().unwrap_or("(none)"),
793                    if effective.provenance.profile_source.is_empty() {
794                        "default"
795                    } else {
796                        effective.provenance.profile_source
797                    }
798                );
799                if let Some(ref prefix) = effective.secret_prefix {
800                    println!(
801                        "Secret prefix:  {prefix} (source: {})",
802                        effective.provenance.secret_prefix_source
803                    );
804                }
805                if !effective.env.is_empty() {
806                    println!("Environment:");
807                    for (k, v) in &effective.env {
808                        println!("  {k}={v}");
809                    }
810                }
811                if !effective.tags.is_empty() {
812                    println!("Tags:           {}", effective.tags.join(", "));
813                }
814                Ok(())
815            }
816        },
817    }
818}