From ad35cf01507bfbb949e7c2302ede68613c97cb34 Mon Sep 17 00:00:00 2001 From: Will Crichton Date: Wed, 25 Sep 2024 17:31:55 -0400 Subject: [PATCH] Inherit env vars from user's shell on unix platforms --- js/packages/repo-quest/src/index.tsx | 59 ++++++++++++---------- rs/Cargo.lock | 20 +------- rs/crates/repo-quest/Cargo.toml | 2 +- rs/crates/repo-quest/src/lib.rs | 21 ++++++-- rs/crates/rq-core/Cargo.toml | 4 +- rs/crates/rq-core/src/command.rs | 37 ++++++++++++++ rs/crates/rq-core/src/git.rs | 73 ++++++++++++++++------------ rs/crates/rq-core/src/github.rs | 30 +++--------- rs/crates/rq-core/src/lib.rs | 1 + rs/crates/rq-core/src/package.rs | 5 +- rs/crates/rq-core/src/quest.rs | 46 ++++++++++++++++-- rs/crates/rq-core/src/template.rs | 11 ++++- 12 files changed, 195 insertions(+), 114 deletions(-) create mode 100644 rs/crates/rq-core/src/command.rs diff --git a/js/packages/repo-quest/src/index.tsx b/js/packages/repo-quest/src/index.tsx index 5114cb4..89109c1 100644 --- a/js/packages/repo-quest/src/index.tsx +++ b/js/packages/repo-quest/src/index.tsx @@ -392,33 +392,35 @@ let QuestView: React.FC<{ -
- -
+ {initialState.can_skip && ( +
+ +
+ )} @@ -557,3 +559,6 @@ let App = () => { }; ReactDOM.createRoot(document.getElementById("root")!).render(); + +/* @ts-ignore */ +window.devDump = async () => console.log(await commands.devDump()); diff --git a/rs/Cargo.lock b/rs/Cargo.lock index 1f5148c..e10dcb8 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -3261,6 +3261,7 @@ version = "0.1.6" dependencies = [ "anyhow", "async-trait", + "cfg-if", "flate2", "futures-util", "home", @@ -3278,7 +3279,6 @@ dependencies = [ "toml 0.8.19", "tracing", "tracing-subscriber", - "which", ] [[package]] @@ -5110,18 +5110,6 @@ dependencies = [ "windows-core 0.58.0", ] -[[package]] -name = "which" -version = "6.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" -dependencies = [ - "either", - "home", - "rustix", - "winsafe", -] - [[package]] name = "winapi" version = "0.3.9" @@ -5510,12 +5498,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wry" version = "0.43.1" diff --git a/rs/crates/repo-quest/Cargo.toml b/rs/crates/repo-quest/Cargo.toml index 1dd03be..ea9473e 100644 --- a/rs/crates/repo-quest/Cargo.toml +++ b/rs/crates/repo-quest/Cargo.toml @@ -8,7 +8,7 @@ default-run = "repo-quest" tauri-build = { version = "2.0.0-rc", features = ["config-toml"] } [dependencies] -tauri = { version = "2.0.0-rc", features = ["config-toml"] } +tauri = { version = "2.0.0-rc", features = ["config-toml", "devtools"] } tauri-plugin-dialog = "2.0.0-rc" tauri-plugin-shell = "2.0.0-rc" tokio = { workspace = true } diff --git a/rs/crates/repo-quest/src/lib.rs b/rs/crates/repo-quest/src/lib.rs index b12f182..62b876d 100644 --- a/rs/crates/repo-quest/src/lib.rs +++ b/rs/crates/repo-quest/src/lib.rs @@ -1,7 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::{env, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, env, path::PathBuf, sync::Arc}; use rq_core::{ github::{self, GithubToken}, @@ -131,12 +131,26 @@ async fn refresh_state(quest: State<'_, Arc>) -> Result<(), String> { #[tauri::command] #[specta::specta] -async fn hard_reset(quest: State<'_, Arc>, stage: u32) -> Result<(), String> { +async fn skip_to_stage(quest: State<'_, Arc>, stage: u32) -> Result<(), String> { let stage = usize::try_from(stage).unwrap(); fmt_err(quest.skip_to_stage(stage).await)?; Ok(()) } +#[derive(Serialize, Deserialize, Type)] +struct DevDump { + env: HashMap, + token: GithubToken, +} + +#[tauri::command] +#[specta::specta] +fn dev_dump() -> DevDump { + let env = env::vars().collect::>(); + let token = github::get_github_token(); + DevDump { env, token } +} + pub fn specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .commands(tauri_specta::collect_commands![ @@ -148,7 +162,8 @@ pub fn specta_builder() -> tauri_specta::Builder { file_feature_and_issue, file_solution, refresh_state, - hard_reset + skip_to_stage, + dev_dump ]) .events(collect_events![StateEvent]) } diff --git a/rs/crates/rq-core/Cargo.toml b/rs/crates/rq-core/Cargo.toml index f97c298..13fa3a5 100644 --- a/rs/crates/rq-core/Cargo.toml +++ b/rs/crates/rq-core/Cargo.toml @@ -16,13 +16,13 @@ tokio = { workspace = true, features = ["macros"] } tokio-retry = "0.3.0" toml = "0.8.15" specta = { workspace = true, features = ["serde_json", "derive"] } -which = "6.0.3" anyhow = { workspace = true } tracing = { workspace = true } flate2 = "1.0.33" async-trait = "0.1.82" -shlex = "1.3.0" semver = { version = "1.0.23", features = ["serde"] } +cfg-if = "1.0.0" +shlex = "1.3.0" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/rs/crates/rq-core/src/command.rs b/rs/crates/rq-core/src/command.rs new file mode 100644 index 0000000..c2acf87 --- /dev/null +++ b/rs/crates/rq-core/src/command.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, ops::Deref, path::Path, process::Command, sync::LazyLock}; + +use cfg_if::cfg_if; + +#[cfg(unix)] +fn get_user_env() -> HashMap { + let shell = env!("SHELL"); + let output = Command::new(shell) + .args(["-c", "env"]) + .output() + .expect("Failed to get shell env"); + let stdout = String::from_utf8(output.stdout).expect("Env vars not utf8"); + stdout + .lines() + .map(|line| { + let (key, value) = line.split_once("=").expect("Failed to parse env k/v"); + (key.to_string(), value.to_string()) + }) + .collect() +} + +static ENV: LazyLock> = LazyLock::new(|| { + cfg_if! { + if #[cfg(unix)] { + get_user_env() + } else { + HashMap::default() + } + } +}); + +pub fn command(args: &str, dir: &Path) -> Command { + let mut arg_vec = shlex::split(args).expect("Invalid command"); + let mut cmd = Command::new(arg_vec.remove(0)); + cmd.current_dir(dir).envs(ENV.deref()).args(arg_vec); + cmd +} diff --git a/rs/crates/rq-core/src/git.rs b/rs/crates/rq-core/src/git.rs index 175bac0..690a4dd 100644 --- a/rs/crates/rq-core/src/git.rs +++ b/rs/crates/rq-core/src/git.rs @@ -3,12 +3,13 @@ use std::{ fs, io::Write, path::{Path, PathBuf}, - process::{Command, Stdio}, + process::Stdio, }; use anyhow::{ensure, Context, Result}; use crate::{ + command::command, github::{GitProtocol, GithubRepo}, package::QuestPackage, template::QuestTemplate, @@ -30,20 +31,16 @@ pub enum MergeType { macro_rules! git { ($self:expr, $($arg:tt)*) => {{ let arg = format!($($arg)*); - $self.git(|cmd| { - tracing::debug!("git: {arg}"); - cmd.args(shlex::split(&arg).unwrap()); - }).with_context(|| format!("git failed: {arg}")) + tracing::debug!("git: {arg}"); + $self.git(&arg).with_context(|| format!("git failed: {arg}")) }} } macro_rules! git_output { ($self:expr, $($arg:tt)*) => {{ let arg = format!($($arg)*); - $self.git_output(|cmd| { - tracing::debug!("git: {arg}"); - cmd.args(shlex::split(&format!($($arg)*)).unwrap()); - }).with_context(|| format!("git failed: {arg}")) + tracing::debug!("git: {arg}"); + $self.git_output(&arg).with_context(|| format!("git failed: {arg}")) }} } @@ -54,10 +51,18 @@ impl GitRepo { } } - fn git_core(&self, f: impl FnOnce(&mut Command), capture: bool) -> Result> { - let mut cmd = Command::new("git"); - cmd.current_dir(&self.path); - f(&mut cmd); + pub fn clone(path: &Path, url: &str) -> Result { + let output = command(&format!("git clone {url}"), path.parent().unwrap()).output()?; + ensure!( + output.status.success(), + "`git clone {url}` failed, stderr:\n{}", + String::from_utf8(output.stderr)? + ); + Ok(GitRepo::new(path)) + } + + fn git_core(&self, args: &str, capture: bool) -> Result> { + let mut cmd = command(&format!("git {args}"), &self.path); cmd.stderr(Stdio::piped()); if capture { cmd.stdout(Stdio::piped()); @@ -79,12 +84,12 @@ impl GitRepo { Ok(stdout) } - fn git(&self, f: impl FnOnce(&mut Command)) -> Result<()> { - self.git_core(f, false).map(|_| ()) + fn git(&self, args: &str) -> Result<()> { + self.git_core(args, false).map(|_| ()) } - fn git_output(&self, f: impl FnOnce(&mut Command)) -> Result { - self.git_core(f, true).map(|s| s.unwrap()) + fn git_output(&self, args: &str) -> Result { + self.git_core(args, true).map(|s| s.unwrap()) } pub fn setup_upstream(&self, upstream: &GithubRepo) -> Result<()> { @@ -95,9 +100,7 @@ impl GitRepo { } pub fn has_upstream(&self) -> Result { - let status = Command::new("git") - .args(["remote", "get-url", UPSTREAM]) - .current_dir(&self.path) + let status = command(&format!("git remote get-url {UPSTREAM}"), &self.path) .status() .context("`git remote` failed")?; Ok(status.success()) @@ -105,17 +108,20 @@ impl GitRepo { fn apply(&self, patch: &str) -> Result<()> { tracing::trace!("Applying patch:\n{patch}"); - let mut cmd = Command::new("git"); - cmd - .args(["apply", "-"]) - .current_dir(&self.path) - .stdin(Stdio::piped()); - let mut child = cmd.spawn()?; + let mut child = command("git apply -", &self.path) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; let mut stdin = child.stdin.take().unwrap(); stdin.write_all(patch.as_bytes())?; drop(stdin); - let status = child.wait()?; - ensure!(status.success(), "git apply failed"); + let output = child.wait_with_output()?; + ensure!( + output.status.success(), + "git apply failed with stderr:\n{}", + String::from_utf8(output.stderr)? + ); + tracing::trace!("wtf: {}", String::from_utf8(output.stderr)?); Ok(()) } @@ -209,10 +215,14 @@ impl GitRepo { } pub fn show_bin(&self, branch: &str, file: &str) -> Result> { - let output = Command::new("git") - .args(["show", &format!("{branch}:{file}")]) + let output = command(&format!("git show {branch}:{file}"), &self.path) .output() .with_context(|| format!("Failed to `git show {branch}:{file}"))?; + ensure!( + output.status.success(), + "git show failed with stderr:\n{}", + String::from_utf8(output.stderr)? + ); Ok(output.stdout) } @@ -293,8 +303,7 @@ impl GitRepo { if hooks_dir.exists() { let post_checkout = hooks_dir.join("post-checkout"); if post_checkout.exists() { - let status = Command::new(post_checkout) - .current_dir(&self.path) + let status = command(&post_checkout.display().to_string(), &self.path) .status() .context("post-checkout hook failed")?; ensure!(status.success(), "post-checkout hook failed"); diff --git a/rs/crates/rq-core/src/github.rs b/rs/crates/rq-core/src/github.rs index c1be7bd..b6bcce3 100644 --- a/rs/crates/rq-core/src/github.rs +++ b/rs/crates/rq-core/src/github.rs @@ -18,11 +18,12 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; -use std::{fs, path::Path, process::Command, sync::Arc, time::Duration}; +use std::{env, fs, path::Path, sync::Arc, time::Duration}; use tokio::{time::timeout, try_join}; use tracing::warn; use crate::{ + command::command, git::{GitRepo, MergeType}, package::QuestPackage, utils, @@ -79,7 +80,7 @@ pub async fn load_user() -> Result { .current() .user() .await - .context("Failed to get current user")?; + .context("Failed to query Github connector for current user")?; Ok(user.login) } @@ -198,17 +199,7 @@ impl GithubRepo { pub fn clone(&self, path: &Path) -> Result { let remote = self.remote(GitProtocol::Ssh); - let output = Command::new("git") - .args(["clone", &remote]) - .current_dir(path) - .output()?; - ensure!( - output.status.success(), - "`git clone {remote}` failed, stderr:\n{}", - String::from_utf8(output.stderr)? - ); - let repo = GitRepo::new(&path.join(&self.name)); - Ok(repo) + GitRepo::clone(&path.join(&self.name), &remote) } // There is some unknown delay between creating a repo from a template and its contents being added. @@ -584,19 +575,14 @@ fn read_github_token_from_fs() -> GithubToken { } fn generate_github_token_from_cli() -> GithubToken { - let gh_path_res = which::which("gh"); - match gh_path_res { - Ok(gh_path) => { - let token_output = token_try!(Command::new(gh_path) - .args(["auth", "token"]) - .output() - .context("Failed to run `gh auth token`")); + let res = command("gh auth token", &env::current_dir().unwrap()).output(); + match res { + Ok(token_output) if token_output.status.success() => { let token = token_try!(String::from_utf8(token_output.stdout)); let token_clean = token.trim_end().to_string(); GithubToken::Found(token_clean) } - Err(which::Error::CannotFindBinaryPath) => GithubToken::NotFound, - Err(err) => GithubToken::Error(format!("{err:?}")), + _ => GithubToken::NotFound, } } diff --git a/rs/crates/rq-core/src/lib.rs b/rs/crates/rq-core/src/lib.rs index 7940e23..736854b 100644 --- a/rs/crates/rq-core/src/lib.rs +++ b/rs/crates/rq-core/src/lib.rs @@ -1,3 +1,4 @@ +mod command; pub mod git; pub mod github; pub mod package; diff --git a/rs/crates/rq-core/src/package.rs b/rs/crates/rq-core/src/package.rs index 95a1199..714aaf4 100644 --- a/rs/crates/rq-core/src/package.rs +++ b/rs/crates/rq-core/src/package.rs @@ -44,7 +44,7 @@ fn version() -> Version { impl QuestPackage { pub async fn build(path: &Path) -> Result { let git_repo = GitRepo::new(path); - let config = QuestConfig::load(&git_repo, "origin")?; + let config = QuestConfig::load(&git_repo, None)?; let gh_repo = GithubRepo::load(&config.author, &config.repo).await?; let initial = git_repo.read_initial_files()?; @@ -91,7 +91,8 @@ impl QuestPackage { fn deserialize(t: T) -> Result { let mut decoder = GzDecoder::new(t); - let mut package: QuestPackage = serde_json::from_reader(&mut decoder)?; + let mut package: QuestPackage = + serde_json::from_reader(&mut decoder).context("Failed to parse JSON")?; package.patch_map = package .patches .iter() diff --git a/rs/crates/rq-core/src/quest.rs b/rs/crates/rq-core/src/quest.rs index 7d4c552..a496f70 100644 --- a/rs/crates/rq-core/src/quest.rs +++ b/rs/crates/rq-core/src/quest.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::PathBuf, time::Duration}; +use std::{borrow::Cow, collections::HashMap, path::PathBuf, time::Duration}; use crate::{ git::{GitRepo, UPSTREAM}, @@ -52,8 +52,12 @@ pub struct StageState { } impl QuestConfig { - pub fn load(repo: &GitRepo, remote: &str) -> Result { - let contents = repo.show(&format!("{remote}/meta"), "rqst.toml")?; + pub fn load(repo: &GitRepo, remote: Option<&str>) -> Result { + let branch = match remote { + Some(remote) => Cow::Owned(format!("{remote}/meta")), + None => Cow::Borrowed("meta"), + }; + let contents = repo.show(&branch, "rqst.toml")?; let config = toml::de::from_str::(&contents) .context("Failed to parse quest configuration")?; Ok(config) @@ -87,6 +91,7 @@ pub struct StateDescriptor { dir: PathBuf, stages: Vec, state: QuestState, + can_skip: bool, } pub enum CreateSource { @@ -160,7 +165,7 @@ impl Quest { pub async fn load(dir: PathBuf, state_event: Box) -> Result { let user = load_user().await?; let origin_git = GitRepo::new(&dir); - let config = QuestConfig::load(&origin_git, "origin").context("Failed to load quest config")?; + let config = QuestConfig::load(&origin_git, None).context("Failed to load quest config")?; let origin = GithubRepo::load(&user, &config.repo) .await .context("Failed to load GitHub repo")?; @@ -331,6 +336,7 @@ impl Quest { dir: self.dir.clone(), stages: self.stage_states(), state, + can_skip: self.template.can_skip(), }) } @@ -509,10 +515,12 @@ impl Quest { mod test { use super::*; use crate::github::{self, GithubToken}; + use anyhow::ensure; use env::current_dir; use std::{ env, fs, path::Path, + process::Command, sync::{Arc, Once}, }; use tracing_subscriber::{fmt, layer::SubscriberExt, prelude::*, EnvFilter}; @@ -629,7 +637,33 @@ mod test { #[tokio::test(flavor = "multi_thread")] #[ignore] async fn local_playthrough() -> Result<()> { - let package = QuestPackage::load_from_file(Path::new("rqst-test.json.gz"))?; + let status = Command::new("git") + .args([ + "clone", + "--mirror", + &format!("https://github.com/{TEST_ORG}/{TEST_REPO}"), + TEST_REPO, + ]) + .status()?; + ensure!(status.success(), "clone failed"); + + let repo_path = env::current_dir().unwrap().join(TEST_REPO); + let status = Command::new("cargo") + .args([ + "run", + "-p", + "rq-cli", + "--", + "pack", + &repo_path.display().to_string(), + ]) + .status()?; + ensure!(status.success(), "pack failed"); + + fs::remove_dir_all(repo_path)?; + + let package_path = Path::new(&format!("{TEST_REPO}.json.gz")); + let package = QuestPackage::load_from_file(package_path)?; test_quest!(quest, CreateSource::Package(package)); state_is!(quest, 0, StagePart::Starter, StagePartStatus::Start); @@ -652,6 +686,8 @@ mod test { quest.origin.close_issue(&issue).await?; state_is!(quest, 2, StagePart::Starter, StagePartStatus::Start); + fs::remove_file(package_path)?; + Ok(()) } diff --git a/rs/crates/rq-core/src/template.rs b/rs/crates/rq-core/src/template.rs index cb04212..cd6e6e7 100644 --- a/rs/crates/rq-core/src/template.rs +++ b/rs/crates/rq-core/src/template.rs @@ -29,6 +29,7 @@ pub trait QuestTemplate: Send + Sync + 'static { target_branch: &str, ) -> Result; fn reference_solution_pr_url(&self, stage: &Stage) -> Option; + fn can_skip(&self) -> bool; } pub struct RepoTemplate(pub GithubRepo); @@ -43,7 +44,7 @@ impl QuestTemplate for RepoTemplate { origin_git .setup_upstream(&self.0) .context("Failed to setup upstream")?; - let config = QuestConfig::load(&origin_git, "upstream") + let config = QuestConfig::load(&origin_git, Some("upstream")) .context("Failed to load quest config from upstream")?; Ok(InstanceOutputs { origin, @@ -82,6 +83,10 @@ impl QuestTemplate for RepoTemplate { )) .map(|pr| pr.data.html_url.as_ref().unwrap().to_string()) } + + fn can_skip(&self) -> bool { + true + } } pub struct PackageTemplate(pub QuestPackage); @@ -138,4 +143,8 @@ impl QuestTemplate for PackageTemplate { fn reference_solution_pr_url(&self, _stage: &Stage) -> Option { None } + + fn can_skip(&self) -> bool { + false + } }