|
| 1 | +//! Target elicitation for bare app commands at a workspace root. |
| 2 | +//! |
| 3 | +//! A bare `vp dev`/`build`/`preview`/`pack` at a workspace root has no target |
| 4 | +//! and would silently run against the root. Resolution order (rfcs/cwd-flag.md): |
| 5 | +//! explicit `-C` and positional targets are handled before this code and skip |
| 6 | +//! elicitation entirely; then `defaultPackage` from the root config, then the |
| 7 | +//! workspace package listing (interactive picker planned once `vite_select` |
| 8 | +//! supports a custom prompt), then exit 1. |
| 9 | +
|
| 10 | +use std::io::IsTerminal; |
| 11 | + |
| 12 | +use vite_error::Error; |
| 13 | +use vite_path::AbsolutePathBuf; |
| 14 | +use vite_shared::output; |
| 15 | +use vite_task::ExitStatus; |
| 16 | +use vite_workspace::WorkspaceFile; |
| 17 | + |
| 18 | +use super::types::SynthesizableSubcommand; |
| 19 | + |
| 20 | +/// Where a bare app command should run. |
| 21 | +pub(super) enum AppTarget { |
| 22 | + /// No elicitation applies; run in the invocation directory as today. |
| 23 | + CurrentDir, |
| 24 | + /// Run as if invoked in this directory (implicit `-C`). |
| 25 | + Dir(AbsolutePathBuf), |
| 26 | + /// Elicitation printed its output and decided the exit code. |
| 27 | + Exit(ExitStatus), |
| 28 | +} |
| 29 | + |
| 30 | +struct PackageRow { |
| 31 | + name: String, |
| 32 | + path: String, |
| 33 | + absolute: AbsolutePathBuf, |
| 34 | + runnable: bool, |
| 35 | +} |
| 36 | + |
| 37 | +/// App commands are the single-target subcommands; everything else never |
| 38 | +/// goes through elicitation. |
| 39 | +fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static str, &[String])> { |
| 40 | + match subcommand { |
| 41 | + SynthesizableSubcommand::Dev { args } => Some(("dev", args)), |
| 42 | + SynthesizableSubcommand::Build { args } => Some(("build", args)), |
| 43 | + SynthesizableSubcommand::Preview { args } => Some(("preview", args)), |
| 44 | + SynthesizableSubcommand::Pack { args } => Some(("pack", args)), |
| 45 | + _ => None, |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +/// Bare = no positional target. A non-flag token may be a flag value |
| 50 | +/// (`--port 3000`), so any non-flag argument conservatively disables |
| 51 | +/// elicitation and forwards the invocation unchanged. |
| 52 | +fn is_bare(args: &[String]) -> bool { |
| 53 | + args.iter().all(|arg| arg.starts_with('-')) |
| 54 | +} |
| 55 | + |
| 56 | +/// Mirror of the global command picker's interactivity gate. |
| 57 | +fn is_interactive() -> bool { |
| 58 | + const CI_ENV_VARS: &[&str] = |
| 59 | + &["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL"]; |
| 60 | + std::io::stdin().is_terminal() |
| 61 | + && std::io::stdout().is_terminal() |
| 62 | + && std::env::var("TERM").map_or(true, |term| term != "dumb") |
| 63 | + && !CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some()) |
| 64 | +} |
| 65 | + |
| 66 | +/// Heuristic ranking signal: does `dir` look runnable for `command`? |
| 67 | +/// Used for ordering and single-candidate auto-selection, never for hiding. |
| 68 | +fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { |
| 69 | + const VITE_CONFIGS: &[&str] = |
| 70 | + &["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"]; |
| 71 | + let has_vite_config = VITE_CONFIGS.iter().any(|name| dir.as_path().join(name).is_file()); |
| 72 | + match command { |
| 73 | + "pack" => has_vite_config || dir.as_path().join("src/index.ts").is_file(), |
| 74 | + _ => has_vite_config || dir.as_path().join("index.html").is_file(), |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +/// `defaultPackage` from the root `vite.config.*`, read via static extraction |
| 79 | +/// so it works at roots without a vite-plus install (non-workspace framework |
| 80 | +/// repos). The value must be a static string literal. |
| 81 | +fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option<AppTarget> { |
| 82 | + let fields = vite_static_config::resolve_static_config(cwd); |
| 83 | + match fields.get("defaultPackage") { |
| 84 | + Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => { |
| 85 | + let mut target = cwd.clone(); |
| 86 | + target.push(dir.trim_start_matches("./")); |
| 87 | + if !target.as_path().is_dir() { |
| 88 | + output::error(&format!("defaultPackage points to a missing directory: {dir}")); |
| 89 | + return Some(AppTarget::Exit(ExitStatus(1))); |
| 90 | + } |
| 91 | + output::note(&format!("vp {command}: using {dir} (defaultPackage)")); |
| 92 | + Some(AppTarget::Dir(target)) |
| 93 | + } |
| 94 | + Some(vite_static_config::FieldValue::Json(other)) => { |
| 95 | + output::error(&format!("defaultPackage must be a string of a directory, got: {other}")); |
| 96 | + Some(AppTarget::Exit(ExitStatus(1))) |
| 97 | + } |
| 98 | + Some(vite_static_config::FieldValue::NonStatic) => { |
| 99 | + output::error( |
| 100 | + "defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config", |
| 101 | + ); |
| 102 | + Some(AppTarget::Exit(ExitStatus(1))) |
| 103 | + } |
| 104 | + None => None, |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +pub(super) fn resolve_app_target( |
| 109 | + subcommand: &SynthesizableSubcommand, |
| 110 | + cwd: &AbsolutePathBuf, |
| 111 | +) -> Result<AppTarget, Error> { |
| 112 | + let Some((command, args)) = app_command_parts(subcommand) else { |
| 113 | + return Ok(AppTarget::CurrentDir); |
| 114 | + }; |
| 115 | + if !is_bare(args) { |
| 116 | + return Ok(AppTarget::CurrentDir); |
| 117 | + } |
| 118 | + |
| 119 | + let (workspace_root, rel_from_root) = vite_workspace::find_workspace_root(cwd)?; |
| 120 | + if !rel_from_root.as_str().is_empty() { |
| 121 | + return Ok(AppTarget::CurrentDir); |
| 122 | + } |
| 123 | + |
| 124 | + if let Some(target) = resolve_default_package(command, cwd) { |
| 125 | + return Ok(target); |
| 126 | + } |
| 127 | + |
| 128 | + // Only real workspaces have a package list to offer; a standalone package |
| 129 | + // root keeps today's behavior. |
| 130 | + if matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) { |
| 131 | + return Ok(AppTarget::CurrentDir); |
| 132 | + } |
| 133 | + |
| 134 | + let graph = vite_workspace::load_package_graph(&workspace_root) |
| 135 | + .map_err(|e| Error::Anyhow(e.into()))?; |
| 136 | + let mut rows: Vec<PackageRow> = graph |
| 137 | + .node_weights() |
| 138 | + .filter(|info| !info.path.as_str().is_empty()) |
| 139 | + .map(|info| PackageRow { |
| 140 | + name: info.package_json.name.to_string(), |
| 141 | + path: info.path.as_str().to_string(), |
| 142 | + absolute: info.absolute_path.to_absolute_path_buf(), |
| 143 | + runnable: false, |
| 144 | + }) |
| 145 | + .collect(); |
| 146 | + if rows.is_empty() { |
| 147 | + return Ok(AppTarget::CurrentDir); |
| 148 | + } |
| 149 | + for row in &mut rows { |
| 150 | + row.runnable = looks_runnable(&row.absolute, command); |
| 151 | + } |
| 152 | + rows.sort_by(|a, b| (!a.runnable, a.path.as_str()).cmp(&(!b.runnable, b.path.as_str()))); |
| 153 | + |
| 154 | + // With exactly one likely-runnable package, an interactive terminal |
| 155 | + // auto-selects it (the degenerate picker). |
| 156 | + if is_interactive() && rows.iter().filter(|row| row.runnable).count() == 1 { |
| 157 | + let row = rows.iter().find(|row| row.runnable).expect("one runnable row exists"); |
| 158 | + println!("Selected package: {} ({})", row.name, row.path); |
| 159 | + println!("Tip: run this directly with `vp -C {} {command}`", row.path); |
| 160 | + return Ok(AppTarget::Dir(row.absolute.clone())); |
| 161 | + } |
| 162 | + |
| 163 | + output::error(&format!("`vp {command}` at the workspace root needs a target package.")); |
| 164 | + output::raw_stderr(""); |
| 165 | + output::raw_stderr(" Packages in this workspace:"); |
| 166 | + let name_width = rows.iter().map(|row| row.name.len()).max().unwrap_or(0); |
| 167 | + for row in &rows { |
| 168 | + output::raw_stderr(&format!(" {:<name_width$} {}", row.name, row.path)); |
| 169 | + } |
| 170 | + output::raw_stderr(""); |
| 171 | + let example = &rows.first().expect("rows is non-empty").path; |
| 172 | + output::raw_stderr(&format!(" Pass a directory: vp -C {example} {command}")); |
| 173 | + output::raw_stderr(&format!(" Or run every package's {command} script: vp run -r {command}")); |
| 174 | + Ok(AppTarget::Exit(ExitStatus(1))) |
| 175 | +} |
| 176 | + |
| 177 | +#[cfg(test)] |
| 178 | +mod tests { |
| 179 | + use super::*; |
| 180 | + |
| 181 | + #[test] |
| 182 | + fn bare_means_no_positional_target() { |
| 183 | + let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(); |
| 184 | + assert!(is_bare(&to_args(&[]))); |
| 185 | + assert!(is_bare(&to_args(&["--watch"]))); |
| 186 | + assert!(is_bare(&to_args(&["-w", "--minify"]))); |
| 187 | + // A positional target disables elicitation. |
| 188 | + assert!(!is_bare(&to_args(&["apps/web"]))); |
| 189 | + // A flag value is indistinguishable from a positional without knowing |
| 190 | + // the tool's flag arity, so it conservatively counts as non-bare. |
| 191 | + assert!(!is_bare(&to_args(&["--port", "3000"]))); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn only_app_commands_elicit() { |
| 196 | + let args = vec![]; |
| 197 | + for (subcommand, expected) in [ |
| 198 | + (SynthesizableSubcommand::Dev { args: args.clone() }, Some("dev")), |
| 199 | + (SynthesizableSubcommand::Build { args: args.clone() }, Some("build")), |
| 200 | + (SynthesizableSubcommand::Preview { args: args.clone() }, Some("preview")), |
| 201 | + (SynthesizableSubcommand::Pack { args: args.clone() }, Some("pack")), |
| 202 | + (SynthesizableSubcommand::Lint { args: args.clone() }, None), |
| 203 | + (SynthesizableSubcommand::Test { args: args.clone() }, None), |
| 204 | + ] { |
| 205 | + assert_eq!(app_command_parts(&subcommand).map(|(name, _)| name), expected); |
| 206 | + } |
| 207 | + } |
| 208 | +} |
0 commit comments