Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.g3/
analysis/
requirements.md

node_modules
dist
Expand All @@ -24,4 +27,3 @@ dist-ssr
*.sln
*.sw?
.claude/

14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,23 @@ The installer will:
- Install dependencies
- Build the application
- Install to `/Applications/staged.app`
- Install the `staged` CLI to `/usr/local/bin`

**Note**: This will build from source, which takes a few minutes. Requires git to be installed.

### Command Line Usage

After installation, you can launch Staged from the terminal:

```bash
staged # Open in current directory
staged /path/to/repo # Open in specified directory
```

Each invocation opens a new window, so you can have multiple repos open simultaneously.

If you installed manually (not via the install script), copy `bin/staged` to somewhere in your PATH (e.g., `/usr/local/bin`).

## Development

### Prerequisites
Expand Down
46 changes: 46 additions & 0 deletions bin/staged
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/bin/bash
# CLI launcher for Staged app
# Usage: staged [path]
# path - Optional directory to open (defaults to current directory)

set -e

# Resolve the target path
if [ -n "$1" ]; then
TARGET_PATH="$1"
else
TARGET_PATH="."
fi

# Convert to absolute path
TARGET_PATH=$(cd "$TARGET_PATH" 2>/dev/null && pwd) || {
echo "Error: '$1' is not a valid directory" >&2
exit 1
}

# Find the Staged app
# Priority: /Applications > ~/Applications > built app in repo
if [ -d "/Applications/staged.app" ]; then
APP_PATH="/Applications/staged.app"
elif [ -d "$HOME/Applications/staged.app" ]; then
APP_PATH="$HOME/Applications/staged.app"
else
# Try to find it relative to this script (for development)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"

if [ -d "$REPO_ROOT/src-tauri/target/release/bundle/macos/staged.app" ]; then
APP_PATH="$REPO_ROOT/src-tauri/target/release/bundle/macos/staged.app"
elif [ -d "$REPO_ROOT/src-tauri/target/debug/bundle/macos/staged.app" ]; then
APP_PATH="$REPO_ROOT/src-tauri/target/debug/bundle/macos/staged.app"
else
echo "Error: Could not find staged.app" >&2
echo "Install it to /Applications or build with 'just build'" >&2
exit 1
fi
fi

# Launch the app with the target path as argument
# Run in background and detach so CLI returns immediately
"$APP_PATH/Contents/MacOS/staged" "$TARGET_PATH" &>/dev/null &
disown
26 changes: 25 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,27 @@ install_to_system() {
fi
}

# Install CLI command
install_cli() {
if [ "$OS" = "macos" ]; then
CLI_PATH="bin/staged"
INSTALL_PATH="/usr/local/bin/staged"

print_info "Installing CLI to $INSTALL_PATH..."

# Create /usr/local/bin if it doesn't exist
if [ ! -d "/usr/local/bin" ]; then
sudo mkdir -p /usr/local/bin
fi

if sudo cp "$CLI_PATH" "$INSTALL_PATH" && sudo chmod +x "$INSTALL_PATH"; then
print_success "CLI installed to $INSTALL_PATH"
else
print_warning "Failed to install CLI (you can manually copy bin/staged to your PATH)"
fi
fi
}

# Cleanup
cleanup() {
if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then
Expand All @@ -188,6 +209,7 @@ main() {
install_deps
build_app
install_to_system
install_cli
cleanup

echo ""
Expand All @@ -196,7 +218,9 @@ main() {

if [ "$OS" = "macos" ]; then
echo "You can now launch Staged from your Applications folder,"
echo "or run it from the command line with: open -a staged"
echo "or from the command line:"
echo " staged # opens in current directory"
echo " staged /path # opens in specified directory"
fi

echo ""
Expand Down
200 changes: 195 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Tauri commands for the Staged diff viewer.
//!
//! This module provides the bridge between the frontend and the git/github modules.
//! Supports CLI arguments: `staged [path]` opens the app with the specified directory.

pub mod ai;
pub mod git;
Expand Down Expand Up @@ -781,6 +782,161 @@ fn get_window_label(window: tauri::Window) -> String {
window.label().to_string()
}

/// Get the initial repository path from CLI arguments.
/// Returns the canonicalized path if a valid directory was provided, otherwise None.
#[tauri::command]
fn get_initial_path() -> Option<String> {
let args: Vec<String> = std::env::args().collect();

// Skip the binary name, look for a path argument (not starting with -)
for arg in args.iter().skip(1) {
if arg.starts_with('-') {
continue;
}

// Try to canonicalize the path
let path = std::path::Path::new(arg);
if let Ok(canonical) = path.canonicalize() {
if canonical.is_dir() {
return canonical.to_str().map(|s| s.to_string());
}
}
}
None
}

/// Install the CLI command to /usr/local/bin using a helper script with sudo.
/// Returns Ok(path) on success, Err(message) on failure.
#[tauri::command]
fn install_cli() -> Result<String, String> {
let install_path = Path::new("/usr/local/bin/staged");
install_cli_to(install_path, true)
}

fn install_cli_to(install_path: &Path, use_admin: bool) -> Result<String, String> {
let cli_script = include_str!("../../bin/staged");

// Write script to a temp file first
let temp_path = std::env::temp_dir().join("staged-cli-install");
std::fs::write(&temp_path, cli_script)
.map_err(|e| format!("Failed to write temp file: {}", e))?;

if use_admin {
#[cfg(target_os = "macos")]
{
let script = format!(
r#"do shell script "cp '{}' '{}' && chmod +x '{}'" with administrator privileges"#,
temp_path.display(),
install_path.display(),
install_path.display()
);

let output = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.map_err(|e| format!("Failed to run installer: {}", e))?;

let _ = std::fs::remove_file(&temp_path);

if output.status.success() {
Ok(install_path.display().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("User canceled") || stderr.contains("(-128)") {
Err("Installation cancelled".to_string())
} else {
Err(format!("Installation failed: {}", stderr))
}
}
}

#[cfg(not(target_os = "macos"))]
{
let _ = std::fs::remove_file(&temp_path);
Err(
"CLI installation is only supported on macOS. Copy bin/staged to your PATH manually."
.to_string(),
)
}
} else {
// Non-admin install: direct copy (for testing or user-writable paths)
std::fs::copy(&temp_path, install_path)
.map_err(|e| format!("Failed to copy CLI script: {}", e))?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(install_path)
.map_err(|e| format!("Failed to get permissions: {}", e))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(install_path, perms)
.map_err(|e| format!("Failed to set permissions: {}", e))?;
}

let _ = std::fs::remove_file(&temp_path);
Ok(install_path.display().to_string())
}
}

#[cfg(test)]
mod install_cli_tests {
use super::*;
use std::fs;
use tempfile::tempdir;

#[test]
fn test_install_cli_writes_executable_script() {
let temp_dir = tempdir().unwrap();
let install_path = temp_dir.path().join("staged");

let result = install_cli_to(&install_path, false);
assert!(result.is_ok(), "install_cli_to failed: {:?}", result);
assert!(install_path.exists(), "CLI script was not created");

let content = fs::read_to_string(&install_path).unwrap();
assert!(content.contains("#!/bin/bash"), "Script missing shebang");
assert!(
content.contains("staged.app"),
"Script missing app reference"
);
}

#[test]
#[cfg(unix)]
fn test_install_cli_sets_executable_permission() {
use std::os::unix::fs::PermissionsExt;

let temp_dir = tempdir().unwrap();
let install_path = temp_dir.path().join("staged");

install_cli_to(&install_path, false).unwrap();

let perms = fs::metadata(&install_path).unwrap().permissions();
let mode = perms.mode();
assert!(mode & 0o111 != 0, "Script is not executable: {:o}", mode);
}

#[test]
fn test_install_cli_returns_install_path() {
let temp_dir = tempdir().unwrap();
let install_path = temp_dir.path().join("staged");

let result = install_cli_to(&install_path, false).unwrap();
assert_eq!(result, install_path.display().to_string());
}

#[test]
fn test_install_cli_fails_on_invalid_path() {
let install_path = Path::new("/nonexistent/directory/staged");

let result = install_cli_to(install_path, false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to copy"));
}
}

// =============================================================================
// Menu System
// =============================================================================
Expand All @@ -791,14 +947,22 @@ fn build_menu(app: &AppHandle) -> Result<Menu<Wry>, Box<dyn std::error::Error>>

// macOS app menu (required for Cmd+Q, Cmd+H, etc.)
#[cfg(target_os = "macos")]
{
let app_menu = Submenu::with_items(
let app_menu = {
Submenu::with_items(
app,
"Staged",
true,
&[
&PredefinedMenuItem::about(app, Some("About Staged"), None)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(
app,
"install-cli",
"Install CLI Command...",
true,
None::<&str>,
)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::services(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
Expand All @@ -807,9 +971,28 @@ fn build_menu(app: &AppHandle) -> Result<Menu<Wry>, Box<dyn std::error::Error>>
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
],
)?;
menu.append(&app_menu)?;
}
)?
};

#[cfg(not(target_os = "macos"))]
let app_menu = {
Submenu::with_items(
app,
"Staged",
true,
&[
&MenuItem::with_id(
app,
"install-cli",
"Install CLI Command...",
true,
None::<&str>,
)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
],
)?
};

let file_menu = Submenu::with_items(
app,
Expand Down Expand Up @@ -847,6 +1030,7 @@ fn build_menu(app: &AppHandle) -> Result<Menu<Wry>, Box<dyn std::error::Error>>
],
)?;

menu.append(&app_menu)?;
menu.append(&file_menu)?;
menu.append(&edit_menu)?;
Ok(menu)
Expand All @@ -864,6 +1048,9 @@ fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
"close-window" => {
let _ = app.emit("menu:close-window", ());
}
"install-cli" => {
let _ = app.emit("menu:install-cli", ());
}
_ => {}
}
}
Expand Down Expand Up @@ -960,6 +1147,9 @@ pub fn run() {
unwatch_repo,
// Window commands
get_window_label,
// CLI commands
get_initial_path,
install_cli,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
Loading
Loading