Skip to content
Draft
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
31 changes: 29 additions & 2 deletions sidecar/scripts/stage-vendor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Stage claude-code + codex + gh + glab into `sidecar/dist/vendor/`
// Stage claude-code + codex + gh + glab + tea into `sidecar/dist/vendor/`
// for Tauri to ship as bundle resources. macOS host only.
//
// Cross-arch staging: in CI the host is always Apple Silicon (macos-26
Expand Down Expand Up @@ -35,6 +35,7 @@ const BUNDLE_CACHE = join(SIDECAR_ROOT, ".bundle-cache");
// Bumping any version: update SHA256 below + wipe sidecar/.bundle-cache.
// gh: github.com/cli/cli/releases/download/v$VER/gh_${VER}_checksums.txt
// glab: gitlab.com/gitlab-org/cli/-/releases/v$VER/downloads/checksums.txt
// tea: gitea.com/gitea/tea/releases/download/v$VER/checksums.txt
// codex: shasum -a 256 of the npm tarball at
// registry.npmjs.org/@openai/codex/-/codex-$VER-darwin-{arm64,x64}.tgz
// claude-code: shasum -a 256 of the npm tarballs at
Expand All @@ -52,6 +53,12 @@ const GLAB_SHA256 = {
amd64: "79d1a4f933919689c5fb7774feb1dd08f30b9c896dff4283b4a7387689ee0531",
} as const;

const TEA_VERSION = "0.14.1";
const TEA_SHA256 = {
arm64: "52c482b964de63977b5a836b4976f0b098a1dfde6486ca7b8d0a6ee29b4f7945",
amd64: "4b6828c7dec67cfbd4b911e9391fc9e32eddeea693025ee99a20c444c251f53a",
} as const;

// Codex version is whatever sidecar/package.json pulled in. The SHAs below
// must match THAT version — bump them together (or staging cross-arch will
// abort with a clear error).
Expand Down Expand Up @@ -114,6 +121,8 @@ interface TargetInfo {
ghArch: "arm64" | "amd64";
/** `glab` release naming: `arm64` / `amd64`. */
glabArch: "arm64" | "amd64";
/** `tea` release naming: `arm64` / `amd64`. */
teaArch: "arm64" | "amd64";
}

function infoForArch(arch: DarwinArch): TargetInfo {
Expand All @@ -127,6 +136,7 @@ function infoForArch(arch: DarwinArch): TargetInfo {
codexNpmSuffix: "darwin-arm64",
ghArch: "arm64",
glabArch: "arm64",
teaArch: "arm64",
};
}
return {
Expand All @@ -138,6 +148,7 @@ function infoForArch(arch: DarwinArch): TargetInfo {
codexNpmSuffix: "darwin-x64",
ghArch: "amd64",
glabArch: "amd64",
teaArch: "amd64",
};
}

Expand Down Expand Up @@ -352,6 +363,20 @@ function stageGlabBinary(arch: "arm64" | "amd64"): string {
return binDest;
}

function stageTeaBinary(arch: "arm64" | "amd64"): string {
ensureCacheDir();
const slug = `tea-${TEA_VERSION}-darwin-${arch}`;
const archive = join(BUNDLE_CACHE, slug);
const url = `https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/${slug}`;
downloadAndVerify(url, archive, TEA_SHA256[arch]);

const binDest = join(DIST_VENDOR, "tea", "tea");
copyFile(archive, binDest);
chmodSync(binDest, 0o755);
maybeSignMacBinary(binDest, false);
return binDest;
}

// ---------------------------------------------------------------------------
// claude-code — prefer the platform sub-package already on disk; fall back to
// downloading the npm tarball when staging for a non-host architecture.
Expand Down Expand Up @@ -695,9 +720,10 @@ stageClaudeCodeBinary(target);
// ----- Codex -----
stageCodexBinary(target);

// ----- gh + glab (forge CLIs) -----
// ----- gh + glab + tea (forge CLIs) -----
stageGhBinary(target.ghArch);
stageGlabBinary(target.glabArch);
stageTeaBinary(target.teaArch);

// ----- llama.cpp (local LLM server for auto-rename / Local AI) -----
stageLlamaCppBinaries(target);
Expand All @@ -709,3 +735,4 @@ console.log(` codex ${humanSize(join(DIST_VENDOR, "codex"))}`);
console.log(` gh ${humanSize(join(DIST_VENDOR, "gh"))}`);
console.log(` glab ${humanSize(join(DIST_VENDOR, "glab"))}`);
console.log(` llama-cpp ${humanSize(join(DIST_VENDOR, "llama-cpp"))}`);
console.log(` tea ${humanSize(join(DIST_VENDOR, "tea"))}`);
25 changes: 18 additions & 7 deletions src-tauri/src/forge/accounts.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! Per-repo gh/glab account binding — orchestration layer.
//! Per-repo gh/glab/tea account binding — orchestration layer.
//!
//! Mirrors the [`super::provider::WorkspaceForgeBackend`] pattern: a
//! [`ForgeAccountBackend`] trait sits in the `forge::` umbrella, with
//! provider-specific implementations living under [`super::github::accounts`]
//! and [`super::gitlab::accounts`]. Top-level helpers in this file
//! and [`super::gitlab::accounts`] / [`super::gitea::accounts`]. Top-level helpers in this file
//! dispatch by provider so cross-cutting callers (the auto-bind hook,
//! the Settings → Account panel, the right-top workspace chip) never
//! need to branch on `ForgeProvider` themselves.
Expand All @@ -17,7 +17,7 @@ use super::remote::parse_remote;
use super::types::ForgeProvider;
use crate::repos;

/// Public profile of a single gh/glab account, surfaced to the
/// Public profile of a single gh/glab/tea account, surfaced to the
/// frontend's Settings → Account panel. `active` is true for the gh
/// account currently marked active by `gh auth switch`; for GitLab
/// (one-account-per-host) it's always true.
Expand Down Expand Up @@ -73,9 +73,9 @@ pub(crate) enum RepoAccess {
}

/// Provider-agnostic account operations. Each method may interpret
/// `host` / `login` slightly differently — GitLab ignores `login` since
/// it has at most one account per host, while GitHub uses `(host,
/// login)` as the full identity.
/// `host` / `login` slightly differently — GitHub uses `(host, login)`
/// as the full identity, while GitLab and Gitea use one account per
/// host and ignore `login` for API execution.
pub(crate) trait ForgeAccountBackend: Sync {
/// Enumerate all accounts (with profile) for this forge.
/// `hosts_hint` is ignored by GitHub (gh exposes its own host list)
Expand Down Expand Up @@ -112,13 +112,14 @@ pub(crate) fn backend_for(provider: ForgeProvider) -> Option<&'static dyn ForgeA
match provider {
ForgeProvider::Github => Some(&super::github::accounts::BACKEND),
ForgeProvider::Gitlab => Some(&super::gitlab::accounts::BACKEND),
ForgeProvider::Gitea => Some(&super::gitea::accounts::BACKEND),
ForgeProvider::Unknown => None,
}
}

// ---------------- Top-level dispatchers ----------------

/// All gh accounts plus one glab account per `gitlab_hosts` entry.
/// All gh accounts plus one glab/tea account per known host entry.
/// Errors from individual backends are logged and skipped so a transient
/// problem with one CLI doesn't blank the whole panel.
pub(crate) fn list_forge_accounts(gitlab_hosts: &[String]) -> Vec<ForgeAccount> {
Expand All @@ -141,6 +142,15 @@ pub(crate) fn list_forge_accounts(gitlab_hosts: &[String]) -> Vec<ForgeAccount>
),
}
}
if let Some(backend) = backend_for(ForgeProvider::Gitea) {
match backend.list_accounts(gitlab_hosts) {
Ok(items) => accounts.extend(items),
Err(error) => tracing::warn!(
error = %format!("{error:#}"),
"Failed to enumerate Gitea accounts"
),
}
}
accounts
}

Expand All @@ -153,6 +163,7 @@ pub(crate) fn invalidate_caches_for_host(provider: ForgeProvider, host: &str) {
match provider {
ForgeProvider::Github => crate::forge::github::accounts::invalidate_caches_for_host(host),
ForgeProvider::Gitlab => crate::forge::gitlab::accounts::invalidate_caches_for_host(host),
ForgeProvider::Gitea => crate::forge::gitea::accounts::invalidate_caches_for_host(host),
ForgeProvider::Unknown => {}
}
}
Expand Down
14 changes: 13 additions & 1 deletion src-tauri/src/forge/bundled.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
//! Paths to bundled `gh` / `glab` inside `Resources/vendor/`.
//! Paths to bundled `gh` / `glab` / `tea` inside `Resources/vendor/`.

use std::path::{Path, PathBuf};
use std::sync::OnceLock;

pub const GH_PATH_ENV: &str = "HELMOR_GH_BIN_PATH";
pub const GLAB_PATH_ENV: &str = "HELMOR_GLAB_BIN_PATH";
pub const TEA_PATH_ENV: &str = "HELMOR_TEA_BIN_PATH";

#[derive(Debug, Default, Clone)]
pub struct BundledForgeCliPaths {
pub gh: Option<PathBuf>,
pub glab: Option<PathBuf>,
pub tea: Option<PathBuf>,
}

static BUNDLED_PATHS: OnceLock<BundledForgeCliPaths> = OnceLock::new();
Expand All @@ -23,6 +25,7 @@ pub fn init() {
tracing::info!(
gh = ?paths.and_then(|p| p.gh.as_deref()),
glab = ?paths.and_then(|p| p.glab.as_deref()),
tea = ?paths.and_then(|p| p.tea.as_deref()),
"Resolved bundled forge CLI paths"
);
}
Expand All @@ -41,6 +44,7 @@ pub fn bundled_path_for(program: &str) -> Option<PathBuf> {
match program {
"gh" => cached.gh.clone(),
"glab" => cached.glab.clone(),
"tea" => cached.tea.clone(),
_ => None,
}
}
Expand All @@ -49,6 +53,7 @@ fn env_key_for(program: &str) -> Option<&'static str> {
match program {
"gh" => Some(GH_PATH_ENV),
"glab" => Some(GLAB_PATH_ENV),
"tea" => Some(TEA_PATH_ENV),
_ => None,
}
}
Expand Down Expand Up @@ -77,13 +82,16 @@ fn resolve_for_exe(exe: &Path) -> Option<BundledForgeCliPaths> {

let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" };
let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" };
let tea_name = if cfg!(windows) { "tea.exe" } else { "tea" };

let gh = resources_dir.join(format!("vendor/gh/{gh_name}"));
let glab = resources_dir.join(format!("vendor/glab/{glab_name}"));
let tea = resources_dir.join(format!("vendor/tea/{tea_name}"));

Some(BundledForgeCliPaths {
gh: gh.is_file().then_some(gh),
glab: glab.is_file().then_some(glab),
tea: tea.is_file().then_some(tea),
})
}

Expand All @@ -93,6 +101,7 @@ impl BundledForgeCliPaths {
BundledForgeCliPaths {
gh: self.gh.or(fallback.gh),
glab: self.glab.or(fallback.glab),
tea: self.tea.or(fallback.tea),
}
}
}
Expand All @@ -110,13 +119,16 @@ fn resolve_for_dev_workspace(workspace_root: &Path) -> BundledForgeCliPaths {
let vendor = workspace_root.join("sidecar/dist/vendor");
let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" };
let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" };
let tea_name = if cfg!(windows) { "tea.exe" } else { "tea" };

let gh = vendor.join(format!("gh/{gh_name}"));
let glab = vendor.join(format!("glab/{glab_name}"));
let tea = vendor.join(format!("tea/{tea_name}"));

BundledForgeCliPaths {
gh: gh.is_file().then_some(gh),
glab: glab.is_file().then_some(glab),
tea: tea.is_file().then_some(tea),
}
}

Expand Down
54 changes: 45 additions & 9 deletions src-tauri/src/forge/cli_status.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Terminal-side helpers for the gh / glab auth-login flow:
//! Terminal-side helpers for the gh / glab / tea auth-login flow:
//! - [`forge_cli_auth_command`] — produces the shell command we hand
//! off to the embedded Helmor terminal session.
//! - [`labels_for`] — provider-name / cli-name / connect-action
Expand All @@ -22,21 +22,41 @@ pub(crate) fn forge_cli_auth_command(
Ok(match provider {
ForgeProvider::Github => format!("{} auth login", bundled_program_token("gh")?),
ForgeProvider::Gitlab => {
let host = host.unwrap_or("gitlab.com");
// Reject obviously broken hostnames before they reach AppleScript:
// a newline would let the user inject extra `do script` commands.
if host.contains(['\n', '\r']) {
bail!("Invalid hostname (contains newline): {host:?}");
}
let host = validate_shell_host(host.unwrap_or("gitlab.com"))?;
format!(
"{} auth login --hostname {host}",
bundled_program_token("glab")?
"{} auth login --hostname {}",
bundled_program_token("glab")?,
shell_single_quote(host)
)
}
ForgeProvider::Gitea => {
let host = validate_shell_host(host.unwrap_or("gitea.com"))?;
let login_name = format!("helmor-{host}");
format!(
"{} login add --name {} --url {}",
bundled_program_token("tea")?,
shell_single_quote(&login_name),
shell_single_quote(&format!("https://{host}"))
)
}
ForgeProvider::Unknown => bail!("Unknown forge provider."),
})
}

fn validate_shell_host(host: &str) -> Result<&str> {
let host = host.trim();
if host.is_empty() {
bail!("Invalid hostname (empty)");
}
if !host
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | ':'))
{
bail!("Invalid hostname: {host:?}");
}
Ok(host)
}

/// Absolute bundled path (shell-quoted). In release builds, missing the
/// bundled binary means the .app payload is broken — fail loudly rather
/// than spawning a Terminal session that immediately dies on
Expand Down Expand Up @@ -83,6 +103,13 @@ pub(crate) fn labels_for(provider: ForgeProvider) -> ForgeLabels {
change_request_full_name: "merge request".to_string(),
connect_action: "Connect GitLab".to_string(),
},
ForgeProvider::Gitea => ForgeLabels {
provider_name: "Gitea".to_string(),
cli_name: "tea".to_string(),
change_request_name: "PR".to_string(),
change_request_full_name: "pull request".to_string(),
connect_action: "Connect Gitea".to_string(),
},
ForgeProvider::Unknown => ForgeLabels {
provider_name: "Git".to_string(),
cli_name: String::new(),
Expand All @@ -106,4 +133,13 @@ mod tests {
);
assert_eq!(shell_single_quote("a'b'c"), "'a'\\''b'\\''c'");
}

#[test]
fn validate_shell_host_rejects_shell_metacharacters() {
assert!(validate_shell_host("gitea.example.com").is_ok());
assert!(validate_shell_host("gitea.example.com:3000").is_ok());
assert!(validate_shell_host("gitea.example.com;open -a Calculator").is_err());
assert!(validate_shell_host("gitea.example.com && whoami").is_err());
assert!(validate_shell_host("").is_err());
}
}
Loading