Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions scripts/screenshot/specs/onboarding-prerequisites.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as React from "react";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import userEvent from "@testing-library/user-event";
import { it, onTestFinished } from "vitest";
import { Dashboard } from "../../../src/components/Dashboard";
import { render, screen, within } from "../../../test/test-utils";
import { createTestRepo, openRepo } from "../../../test/utils";
import { captureDocument } from "../capture";

const FAKE_TOOL = path.resolve(
__dirname,
"../../../test/fake-agent/fake-agent.sh",
);

/**
* Points the backend's tool check at a directory holding only `tools` (plus a
* link to the system `which`, which the lookup runs from the same PATH).
*/
function useSearchPathWith(tools: string[]) {
const searchDir = fs.mkdtempSync(path.join(os.tmpdir(), "treq-prereq-"));
const which = (process.env.PATH ?? "")
.split(path.delimiter)
.map((dir) => path.join(dir, "which"))
.find((candidate) => fs.existsSync(candidate));
if (!which) throw new Error("`which` must be on PATH");
fs.symlinkSync(which, path.join(searchDir, "which"));
for (const tool of tools) {
fs.symlinkSync(FAKE_TOOL, path.join(searchDir, tool));
}
process.env.TREQ_TEST_PREREQ_PATH = searchDir;
onTestFinished(() => {
delete process.env.TREQ_TEST_PREREQ_PATH;
fs.rmSync(searchDir, { recursive: true, force: true });
});
}

const findChecklist = () =>
screen.findByRole("region", { name: "Setup checklist" });

it("captures the setup checklist with Git and one agent installed", async () => {
useSearchPathWith(["git", "claude"]);
window.history.replaceState({}, "", "/");
render(<Dashboard />);

const checklist = await findChecklist();
await within(
await within(checklist).findByRole("listitem", { name: "Git" }),
).findByText("Installed");
await within(checklist).findByRole("button", { name: "Install Codex" });

await captureDocument(document, {
name: "onboarding-prerequisites-01-checklist",
expectations: [
"Below the 'Open Repository' button, a 'Before you start' list shows Git and Claude Code with green checks and 'Installed'.",
"Codex, Cursor Agent, GitHub Copilot CLI and GitHub CLI each show an 'Install' link instead of 'Installed'.",
"The rows sit under 'Required', 'Agent CLI (install at least one)' and 'Optional' headings, and GitHub CLI notes 'Needed for pull requests and issues'.",
],
});
});

it("captures the tool checklist on the Application settings tab", async () => {
useSearchPathWith(["git", "codex", "gh"]);
const { repoPath } = createTestRepo(false);
openRepo(repoPath);
const user = userEvent.setup();
render(<Dashboard />);

await user.click(await screen.findByLabelText("Settings"));
await user.click(await screen.findByRole("tab", { name: /application/i }));
const checklist = await findChecklist();
await within(
await within(checklist).findByRole("listitem", { name: "Codex" }),
).findByText("Installed");

await captureDocument(document, {
name: "onboarding-prerequisites-03-settings",
viewport: { width: 1440, height: 1100 },
expectations: [
"The Application settings tab shows a 'Command-line tools' checklist below the Default Agent picker.",
"Git, Codex and GitHub CLI show green checks with 'Installed'; the other agents show 'Install' links.",
],
});
});

it("captures the setup checklist with nothing installed", async () => {
useSearchPathWith([]);
window.history.replaceState({}, "", "/");
render(<Dashboard />);

const checklist = await findChecklist();
await within(checklist).findByRole("button", { name: "Install Git" });

await captureDocument(document, {
name: "onboarding-prerequisites-02-nothing-installed",
expectations: [
"Git shows a red X icon and an 'Install' link.",
"The 'Agent CLI (install at least one)' heading is red, and every agent row shows an 'Install' link.",
],
});
});
88 changes: 47 additions & 41 deletions src-tauri/src/binary_paths.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::env;
use std::ffi::OsStr;
use std::process::Command;
use std::sync::OnceLock;

Expand Down Expand Up @@ -30,21 +31,7 @@ pub fn get_extended_path() -> String {
return all_paths.join(";");
}

// Common binary locations to add
let additional_paths = [
"/opt/homebrew/bin", // macOS ARM Homebrew
"/usr/local/bin", // macOS Intel Homebrew, common
"~/.cargo/bin", // Rust tools
"/usr/bin", // System binaries
"/bin", // System binaries
];

// Expand ~ to home directory
let home = env::var("HOME").unwrap_or_default();
let expanded_paths: Vec<String> = additional_paths
.iter()
.map(|p| p.replace('~', &home))
.collect();
let expanded_paths = unix_extra_bin_dirs(&env::var("HOME").unwrap_or_default());

// Combine existing PATH with additional paths (deduplicating)
let mut all_paths: Vec<String> = current_path
Expand Down Expand Up @@ -73,6 +60,22 @@ pub fn get_extended_path() -> String {
all_paths.join(":")
}

/// Install locations searched after PATH on Unix, with `~` expanded to
/// `home`. An app opened from the macOS Finder gets a PATH that misses them.
fn unix_extra_bin_dirs(home: &str) -> Vec<String> {
[
"/opt/homebrew/bin", // macOS ARM Homebrew
"/usr/local/bin", // macOS Intel Homebrew, common
"~/.cargo/bin", // Rust tools
"~/.local/bin", // Claude Code and Cursor Agent installers
"/usr/bin", // System binaries
"/bin", // System binaries
]
.iter()
.map(|p| p.replace('~', home))
.collect()
}

/// Get the directory containing the running treq executable
pub fn get_exe_dir() -> Option<String> {
let exe_path = std::env::current_exe().ok()?;
Expand All @@ -91,37 +94,28 @@ pub fn detect_binary(name: &str) -> Option<String> {
// `which` isn't available by default on Windows, and `get_extended_path`
// joins entries with `:` (a Unix path separator), so neither applies here.
// `where` uses the process's own PATH and is present on all supported Windows versions.
let output = Command::new("where").arg(name).output().ok()?;

if output.status.success() {
let stdout = String::from_utf8(output.stdout).ok()?;
let path = stdout.lines().next().unwrap_or("").trim().to_string();
if !path.is_empty() {
return Some(path);
}
}

return None;
return first_output_line(Command::new("where").arg(name));
}

let extended_path = get_extended_path();
detect_binary_in(name, get_extended_path())
}

// Try using `which` with extended PATH
let output = Command::new("which")
.arg(name)
.env("PATH", extended_path)
.output()
.ok()?;
/// Finds `name` on `search_path` (a PATH-style list) and nowhere else, with
/// the same `which` (Unix) or `where` (Windows) lookup as [`detect_binary`].
pub fn detect_binary_in(name: &str, search_path: impl AsRef<OsStr>) -> Option<String> {
let finder = if cfg!(windows) { "where" } else { "which" };
first_output_line(Command::new(finder).arg(name).env("PATH", search_path))
}

if output.status.success() {
let path = String::from_utf8(output.stdout).ok()?;
let path = path.trim().to_string();
if !path.is_empty() {
return Some(path);
}
/// The first line a successful lookup printed: the binary's path.
fn first_output_line(command: &mut Command) -> Option<String> {
let output = command.output().ok()?;
if !output.status.success() {
return None;
}

None
let stdout = String::from_utf8(output.stdout).ok()?;
let path = stdout.lines().next()?.trim();
(!path.is_empty()).then(|| path.to_string())
}

/// Initialize binary paths cache with detected paths
Expand Down Expand Up @@ -162,3 +156,15 @@ pub fn init_editor_apps_cache(apps: HashMap<String, bool>) {
pub fn get_editor_apps_cache() -> Option<HashMap<String, bool>> {
EDITOR_APPS_CACHE.get().cloned()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extra_bin_dirs_include_user_local_bin() {
let dirs = unix_extra_bin_dirs("/home/dev");

assert!(dirs.contains(&"/home/dev/.local/bin".to_string()));
}
}
8 changes: 8 additions & 0 deletions src-tauri/src/commands/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ pub fn detect_editor_apps(state: State<'_, AppState>) -> Result<EditorAppsRespon
})
}

/// Installed state of Git, the agent CLIs and `gh`, for the first-run checklist.
#[tauri::command]
pub async fn check_prerequisites() -> Result<Vec<crate::core::PrerequisiteStatus>, String> {
tauri::async_runtime::spawn_blocking(crate::core::check_prerequisites)
.await
.map_err(|e| format!("Failed to join check_prerequisites task: {e}"))
}

/// Return the directory containing the running treq binary
#[tauri::command]
pub fn get_treq_bin_dir() -> Result<String, String> {
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod feature_preview;
pub mod files;
pub mod github_issues;
pub mod idempotency_store;
pub mod prerequisites;
pub mod pty_remote_supervisor;
pub mod remote;
pub mod remote_control_plane;
Expand Down Expand Up @@ -41,6 +42,7 @@ pub use checks::*;
pub use checks_logs::*;
pub use commits::*;
pub use files::*;
pub use prerequisites::*;
pub use repo::*;
pub use resolve::*;
pub use skills::*;
Expand Down
120 changes: 120 additions & 0 deletions src-tauri/src/core/prerequisites.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Reports which command-line tools Treq relies on are installed, for the
//! first-run checklist.

use serde::Serialize;

/// Executables the checklist reports on, in display order.
pub const PREREQUISITE_BINARIES: [&str; 6] =
["git", "claude", "codex", "cursor-agent", "copilot", "gh"];

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PrerequisiteStatus {
pub binary: String,
pub installed: bool,
}

/// Looks up every prerequisite on the user's PATH and the usual install
/// directories, the same way Treq finds `gh` and the agent CLIs.
pub fn check_prerequisites() -> Vec<PrerequisiteStatus> {
#[cfg(feature = "tauri-test")]
if let Some(search_path) = std::env::var_os("TREQ_TEST_PREREQ_PATH") {
return check_prerequisites_with(|name| {
crate::binary_paths::detect_binary_in(name, &search_path)
});
}
check_prerequisites_with(crate::binary_paths::detect_binary)
}

/// Looks up every prerequisite with `find`, which returns a tool's path.
pub fn check_prerequisites_with(find: impl Fn(&str) -> Option<String>) -> Vec<PrerequisiteStatus> {
PREREQUISITE_BINARIES
.iter()
.map(|binary| PrerequisiteStatus {
binary: binary.to_string(),
installed: find(binary).is_some(),
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::binary_paths::detect_binary_in;
use tempfile::TempDir;

fn installed(statuses: &[PrerequisiteStatus], binary: &str) -> bool {
statuses
.iter()
.find(|status| status.binary == binary)
.unwrap_or_else(|| panic!("no status reported for {binary}"))
.installed
}

/// A whole search PATH, holding a link to the `which` the lookup runs.
#[cfg(unix)]
fn search_dir() -> TempDir {
let dir = TempDir::new().expect("temp dir");
let which = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
.map(|entry| entry.join("which"))
.find(|candidate| candidate.is_file())
.expect("`which` must be on PATH");
std::os::unix::fs::symlink(which, dir.path().join("which")).expect("link which");
dir
}

#[cfg(unix)]
fn add_tool(dir: &TempDir, name: &str, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let path = dir.path().join(name);
std::fs::write(&path, "#!/bin/sh\n").expect("write fake tool");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod");
}

#[cfg(unix)]
#[test]
fn reports_tools_on_the_search_path_as_installed() {
let dir = search_dir();
add_tool(&dir, "claude", 0o755);
add_tool(&dir, "gh", 0o755);

let statuses = check_prerequisites_with(|name| detect_binary_in(name, dir.path()));

assert!(installed(&statuses, "claude"));
assert!(installed(&statuses, "gh"));
assert!(!installed(&statuses, "git"));
assert!(!installed(&statuses, "codex"));
assert!(!installed(&statuses, "cursor-agent"));
assert!(!installed(&statuses, "copilot"));
}

#[cfg(unix)]
#[test]
fn reports_non_executable_files_as_missing() {
let dir = search_dir();
add_tool(&dir, "git", 0o644);

let statuses = check_prerequisites_with(|name| detect_binary_in(name, dir.path()));

assert!(!installed(&statuses, "git"));
}

#[cfg(windows)]
#[test]
fn reports_exe_files_on_the_search_path_as_installed() {
let dir = TempDir::new().expect("temp dir");
std::fs::write(dir.path().join("codex.exe"), "").expect("write fake tool");

let statuses = check_prerequisites_with(|name| detect_binary_in(name, dir.path()));

assert!(installed(&statuses, "codex"));
assert!(!installed(&statuses, "git"));
}

#[test]
fn reports_every_tool_in_checklist_order() {
let statuses = check_prerequisites_with(|_| None);

let binaries: Vec<&str> = statuses.iter().map(|s| s.binary.as_str()).collect();
assert_eq!(binaries, PREREQUISITE_BINARIES);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ pub fn run() {
commands::acknowledge_agent_dispatch,
commands::detect_editor_apps,
commands::get_treq_bin_dir,
commands::check_prerequisites,
commands::get_workspaces,
commands::create_workspace,
commands::open_or_create_workspace_from_pr,
Expand Down
Loading
Loading