OR-215 Use GitHub CLI for publishing (#254)

* OR-215 use GitHub CLI for publishing

* Isolate dev slot caches

* Color GitHub CLI connection states
This commit is contained in:
Daniel Kim
2026-08-26 17:41:55 -07:00
committed by GitHub
parent 04b5d05590
commit 76c671c3b2
14 changed files with 566 additions and 522 deletions
+14 -6
View File
@@ -110,12 +110,22 @@ function pathsFor(info, slot = null) {
backendPort: 4900 + slot,
uiPort: 5200 + slot,
dataDir: path.join(allocatorRoot, slotKey),
cacheDir: path.join(allocatorRoot, slotKey, 'cache'),
configDir: path.join(allocatorRoot, `${slotKey}-config`),
statePath: path.join(allocatorRoot, `${slotKey}-state.json`),
logsDir: path.join(allocatorRoot, 'logs', slotKey),
}
}
export function slotEnvironment(slotPaths) {
return {
ORX_DATA_DIR: slotPaths.dataDir,
ORX_CACHE_DIR: slotPaths.cacheDir,
XDG_CONFIG_HOME: slotPaths.configDir,
CARGO_TARGET_DIR: slotPaths.cargoTargetDir,
}
}
function atomicWriteJson(destination, value, mode = 0o600) {
mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 })
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`
@@ -331,9 +341,7 @@ function configurationFor(info, slotPaths) {
name: `orx-${slotPaths.slot}-${info.label}`,
runtimeExecutable: 'env',
runtimeArgs: [
`ORX_DATA_DIR=${slotPaths.dataDir}`,
`XDG_CONFIG_HOME=${slotPaths.configDir}`,
`CARGO_TARGET_DIR=${slotPaths.cargoTargetDir}`,
...Object.entries(slotEnvironment(slotPaths)).map(([key, value]) => `${key}=${value}`),
'cargo', 'run', '--manifest-path', info.manifestPath,
'--', 'up', '--no-browser', '--port', String(slotPaths.backendPort),
],
@@ -435,6 +443,7 @@ async function reserveSlot(info, dbMode) {
state.phase = 'preparing'
atomicWriteJson(slotPaths.statePath, state)
const database = initializeDatabase(dbMode, slotPaths.dataDir)
mkdirSync(slotPaths.cacheDir, { recursive: true, mode: 0o700 })
mkdirSync(slotPaths.configDir, { recursive: true, mode: 0o700 })
mkdirSync(slotPaths.cargoTargetDir, { recursive: true, mode: 0o700 })
mkdirSync(slotPaths.logsDir, { recursive: true, mode: 0o700 })
@@ -611,9 +620,7 @@ async function startUnlocked(info, dbMode, openBrowser) {
cwd: info.worktreePath,
env: {
...process.env,
ORX_DATA_DIR: slotPaths.dataDir,
XDG_CONFIG_HOME: slotPaths.configDir,
CARGO_TARGET_DIR: slotPaths.cargoTargetDir,
...slotEnvironment(slotPaths),
},
}, state.backendLog)
saveState(slotPaths, state)
@@ -694,6 +701,7 @@ function printStatus(info) {
console.log(` Backend: ${listenerPids(slotPaths.backendPort).length > 0 ? 'running' : 'stopped'} on ${slotPaths.backendPort}`)
console.log(` UI: ${listenerPids(slotPaths.uiPort).length > 0 ? 'running' : 'stopped'} on ${slotPaths.uiPort}`)
console.log(` Data: ${slotPaths.dataDir}`)
console.log(` Cache: ${slotPaths.cacheDir}`)
console.log(` State: ${state ? slotPaths.statePath : 'legacy/unmanaged'}`)
}
+15
View File
@@ -11,6 +11,7 @@ import {
managedStateMatches,
parseArgs,
resolveLiveDataDir,
slotEnvironment,
sqliteBackupCommand,
supervisorCommandMatches,
} from './dev-slot.mjs'
@@ -34,6 +35,20 @@ test('empty mode creates no database', () => {
}
})
test('slot environment isolates data, cache, config, and build artifacts', () => {
assert.deepEqual(slotEnvironment({
dataDir: '/dev/slot-3',
cacheDir: '/dev/slot-3/cache',
configDir: '/dev/slot-3-config',
cargoTargetDir: '/dev/cargo-target',
}), {
ORX_DATA_DIR: '/dev/slot-3',
ORX_CACHE_DIR: '/dev/slot-3/cache',
XDG_CONFIG_HOME: '/dev/slot-3-config',
CARGO_TARGET_DIR: '/dev/cargo-target',
})
})
test('copy mode takes a SQLite backup and copies run logs', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'orx-dev-slot-copy-'))
try {
+48 -92
View File
@@ -395,10 +395,6 @@ fn router(state: AppState) -> Router {
"/api/settings/git",
get(git_settings).post(set_git_settings),
)
.route(
"/api/settings/git/token",
post(set_git_token).delete(delete_git_token),
)
.route(
"/api/settings/projects",
get(project_defaults).post(set_project_defaults),
@@ -1186,7 +1182,7 @@ async fn create_project(
drop(create_admission);
let (project, github_publication_error) = if github_sync_enabled {
match push_project_for_sync(project.clone()).await {
Ok(project) => (project, None),
Ok((project, _)) => (project, None),
Err(error) => {
let project = Store::open()?
.get_local_project(&project.id)?
@@ -1211,17 +1207,10 @@ async fn get_project(Path(id): Path<String>) -> ApiResult {
Ok(Json(json!({ "project": project_json(&project) })))
}
fn github_token_source() -> Option<&'static str> {
if std::env::var("GITHUB_TOKEN").is_ok_and(|token| !token.trim().is_empty()) {
Some("env")
} else if crate::config::synced_env_var("GITHUB_TOKEN").is_some() {
Some("stored")
} else {
local::git::resolve_github_token().map(|_| "gh")
}
}
fn project_git_json(project: &local::model::LocalProject) -> Value {
fn project_git_json(
project: &local::model::LocalProject,
github_status: local::github::Status,
) -> Value {
let path = std::path::Path::new(&project.repo_path);
let initialized = local::git::is_repository(path);
let branch = initialized
@@ -1267,8 +1256,8 @@ fn project_git_json(project: &local::model::LocalProject) -> Value {
"emailSource": email_source,
},
"github": {
"authenticated": github_token_source().is_some(),
"tokenSource": github_token_source(),
"ghInstalled": github_status.installed,
"authenticated": github_status.authenticated,
"enabled": project.github_enabled(),
"owner": project.github_owner,
"repo": project.github_repo,
@@ -1279,11 +1268,12 @@ fn project_git_json(project: &local::model::LocalProject) -> Value {
}
async fn project_git_status(Path(id): Path<String>) -> ApiResult {
let github_status = local::github::status().await;
tokio::task::spawn_blocking(move || {
let project = Store::open()?
.get_local_project(&id)?
.ok_or_else(|| anyhow!("project not found"))?;
Ok(Json(project_git_json(&project)))
Ok(Json(project_git_json(&project, github_status)))
})
.await
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?
@@ -1301,6 +1291,7 @@ async fn initialize_project_git(
reject_if_moving(&state)?;
let _lock = project_publication_lock(&state, &id).await;
let _creation_guard = state.project_creation_lock.lock().await;
let github_status = local::github::status().await;
tokio::task::spawn_blocking(move || {
let store = Store::open()?;
let mut project = store
@@ -1311,7 +1302,7 @@ async fn initialize_project_git(
local::git::validate_project_repository(path)?;
project.baseline_branch = local::git::require_current_branch(path)?;
store.update_local_project(&project)?;
Ok(Json(project_git_json(&project)))
Ok(Json(project_git_json(&project, github_status)))
})
.await
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?
@@ -1361,7 +1352,7 @@ async fn create_independent_project_repository(
let reroot_shallow = local::git::prepare_shallow_repository_for_publication(
std::path::Path::new(&project.repo_path),
)?;
let (owner, repo, _) = local::github::create_project_repo(&project.slug).await?;
let (owner, repo) = local::github::create_project_repo(&project.slug).await?;
if reroot_shallow {
local::git::reroot_shallow_repository(
std::path::Path::new(&project.repo_path),
@@ -1378,17 +1369,21 @@ async fn create_independent_project_repository(
async fn push_project_for_sync(
mut project: local::model::LocalProject,
) -> Result<local::model::LocalProject> {
if local::git::resolve_github_token().is_none() {
) -> Result<(local::model::LocalProject, local::github::Status)> {
let github_status = local::github::status().await;
if !github_status.installed {
return Err(anyhow!(
"Connect GitHub first with `gh auth login` or a GitHub token."
"GitHub CLI (`gh`) is required — install it from https://cli.github.com."
));
}
if !github_status.authenticated {
return Err(anyhow!("Authenticate GitHub first with `gh auth login`."));
}
let mut using_existing_repository = project.has_github_repository();
if using_existing_repository {
let can_push = local::github::repo_meta(&project.github_owner, &project.github_repo)
.await
.await?
.is_some_and(|meta| meta.can_push && !meta.archived);
if !can_push {
project = create_independent_project_repository(project).await?;
@@ -1419,7 +1414,7 @@ async fn push_project_for_sync(
project.github_sync_enabled = true;
Store::open()?.update_local_project(&project)?;
Ok(project)
Ok((project, github_status))
}
async fn enable_project_github(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
@@ -1434,8 +1429,8 @@ async fn enable_project_github(State(state): State<AppState>, Path(id): Path<Str
let project = store
.get_local_project(&id)?
.ok_or_else(|| not_found("project"))?;
let project = push_project_for_sync(project).await.map_err(bad_request)?;
let git_status = project_git_json(&project);
let (project, github_status) = push_project_for_sync(project).await.map_err(bad_request)?;
let git_status = project_git_json(&project, github_status);
Ok(Json(
json!({ "project": project_json(&project), "git": git_status }),
))
@@ -1458,9 +1453,10 @@ async fn disable_project_github(
.ok_or_else(|| not_found("project"))?;
project.github_sync_enabled = false;
store.update_local_project(&project)?;
let github_status = local::github::status().await;
Ok(Json(json!({
"project": project_json(&project),
"git": project_git_json(&project),
"git": project_git_json(&project, github_status),
})))
}
@@ -1476,9 +1472,10 @@ async fn push_project_github(State(state): State<AppState>, Path(id): Path<Strin
.get_local_project(&id)?
.ok_or_else(|| not_found("project"))?;
let project_for_push = project.clone();
let github_status = local::github::status().await;
let git_status = tokio::task::spawn_blocking(move || -> Result<Value> {
push_project(&project_for_push)?;
Ok(project_git_json(&project_for_push))
Ok(project_git_json(&project_for_push, github_status))
})
.await
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?
@@ -1490,7 +1487,7 @@ async fn push_project_github(State(state): State<AppState>, Path(id): Path<Strin
async fn github_account() -> ApiResult {
Ok(Json(
json!({ "login": local::github::viewer_login().await }),
json!({ "login": local::github::viewer_login().await.ok() }),
))
}
@@ -1501,7 +1498,9 @@ struct ProjectRepoPreviewQuery {
async fn github_project_repo_preview(Query(q): Query<ProjectRepoPreviewQuery>) -> ApiResult {
let candidate = local::projects::project_slug_preview(&Store::open()?, q.name.trim())?;
let repo = local::github::available_project_repo_name(&candidate).await;
let repo = local::github::available_project_repo_name(&candidate)
.await
.map_err(bad_request)?;
Ok(Json(json!({ "repo": repo })))
}
@@ -1517,7 +1516,9 @@ async fn github_repo_access(Query(q): Query<RepoAccessQuery>) -> ApiResult {
if owner.is_empty() || repo.is_empty() {
return Err(bad_request("owner and repo are required"));
}
let meta = local::github::repo_meta(owner, repo).await;
let meta = local::github::repo_meta(owner, repo)
.await
.map_err(bad_request)?;
Ok(Json(json!({
"canPush": meta.is_some_and(|meta| meta.can_push && !meta.archived),
})))
@@ -3941,32 +3942,27 @@ fn git_out(args: &[&str]) -> Option<String> {
(!s.is_empty()).then_some(s)
}
fn git_settings_json() -> Value {
let gh_installed = std::process::Command::new("gh")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success());
fn git_settings_json(github_status: local::github::Status) -> Value {
json!({
"gitVersion": git_out(&["--version"]),
"userName": git_out(&["config", "--global", "user.name"]),
"userEmail": git_out(&["config", "--global", "user.email"]),
"ghInstalled": gh_installed,
"githubTokenSource": github_token_source(),
"ghInstalled": github_status.installed,
"githubAuthenticated": github_status.authenticated,
})
}
fn project_defaults_json() -> Value {
let token_source = github_token_source();
fn project_defaults_json(github_status: local::github::Status) -> Value {
json!({
"githubForNewProjects": crate::config::github_for_new_projects(),
"githubDefaultPromptSeen": crate::config::github_default_prompt_seen(),
"githubAuthenticated": token_source.is_some(),
"githubTokenSource": token_source,
"ghInstalled": github_status.installed,
"githubAuthenticated": github_status.authenticated,
})
}
async fn project_defaults() -> ApiResult {
Ok(Json(project_defaults_json()))
Ok(Json(project_defaults_json(local::github::status().await)))
}
#[derive(Deserialize)]
@@ -3978,7 +3974,8 @@ struct SetProjectDefaultsReq {
}
async fn set_project_defaults(Json(req): Json<SetProjectDefaultsReq>) -> ApiResult {
if req.github_for_new_projects && github_token_source().is_none() {
let github_status = local::github::status().await;
if req.github_for_new_projects && !github_status.authenticated {
return Err(bad_request(
"Connect GitHub before enabling it by default for new projects.",
));
@@ -3987,54 +3984,12 @@ async fn set_project_defaults(Json(req): Json<SetProjectDefaultsReq>) -> ApiResu
if let Some(seen) = req.github_default_prompt_seen {
crate::config::set_github_default_prompt_seen(seen)?;
}
Ok(Json(project_defaults_json()))
}
#[derive(Deserialize)]
struct SetGitTokenReq {
token: String,
}
async fn set_git_token(Json(req): Json<SetGitTokenReq>) -> ApiResult {
let token = req.token.trim().to_string();
if token.is_empty() {
return Err(bad_request("token is required"));
}
let response = reqwest::Client::new()
.get("https://api.github.com/user")
.header("User-Agent", "orx")
.bearer_auth(&token)
.send()
.await
.map_err(|error| bad_request(format!("Could not reach api.github.com: {error}")))?;
if !response.status().is_success() {
return Err(bad_request(format!(
"GitHub rejected the token ({}).",
response.status()
)));
}
let scopes = response
.headers()
.get("x-oauth-scopes")
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
if !scopes.trim().is_empty() && !scopes.split(',').any(|scope| scope.trim() == "repo") {
return Err(bad_request(
"Token is valid but lacks the `repo` scope needed for private repositories.",
));
}
crate::config::write_synced_env_var("GITHUB_TOKEN", &token)?;
Ok(Json(git_settings_json()))
}
async fn delete_git_token() -> ApiResult {
crate::config::remove_synced_env_var("GITHUB_TOKEN")?;
Ok(Json(git_settings_json()))
Ok(Json(project_defaults_json(github_status)))
}
async fn git_settings() -> ApiResult {
tokio::task::spawn_blocking(|| Ok(Json(git_settings_json())))
let github_status = local::github::status().await;
tokio::task::spawn_blocking(move || Ok(Json(git_settings_json(github_status))))
.await
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
}
@@ -4054,6 +4009,7 @@ async fn set_git_settings(Json(req): Json<SetGitSettingsReq>) -> ApiResult {
"nothing to update: pass userName and/or userEmail",
));
}
let github_status = local::github::status().await;
tokio::task::spawn_blocking(move || {
for (key, value) in [("user.name", name), ("user.email", email)] {
if let Some(v) = value.filter(|v| !v.is_empty()) {
@@ -4067,7 +4023,7 @@ async fn set_git_settings(Json(req): Json<SetGitSettingsReq>) -> ApiResult {
}
}
}
Ok(Json(git_settings_json()))
Ok(Json(git_settings_json(github_status)))
})
.await
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
+47 -37
View File
@@ -1038,24 +1038,6 @@ pub fn identity(
(name, email, name_source, email_source)
}
pub fn resolve_github_token() -> Option<String> {
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
let token = token.trim().to_string();
if !token.is_empty() {
return Some(token);
}
}
if let Some(token) = crate::config::synced_env_var("GITHUB_TOKEN") {
return Some(token);
}
let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!token.is_empty()).then_some(token)
}
/// Fail early on a typo'd baseline branch — otherwise it only surfaces much
/// later as an opaque `git push` refspec error on the first run.
fn assert_branch_exists(dir: &Path, owner: &str, repo: &str, branch: &str) -> Result<()> {
@@ -1441,8 +1423,7 @@ pub fn prepare_shallow_repository_for_publication(repo_path: &Path) -> Result<bo
Ok(true)
}
const GITHUB_CREDENTIAL_HELPER: &str =
"!f() { host=; while IFS='=' read key value; do [ \"$key\" = host ] && host=$value; done; [ \"$host\" = github.com ] || exit 0; echo username=x-access-token; echo \"password=$ORX_GITHUB_TOKEN\"; }; f";
const GITHUB_CREDENTIAL_HELPER: &str = "!gh auth git-credential";
fn redact_remote_urls(text: &str) -> String {
text.split_whitespace()
@@ -1458,28 +1439,34 @@ fn redact_remote_urls(text: &str) -> String {
.join(" ")
}
fn authenticated_git(repo_path: &Path, args: &[&str], timeout: Duration) -> Result<String> {
fn authenticated_git_command(repo_path: &Path) -> Command {
let mut command = Command::new("git");
command
.current_dir(repo_path)
.env("GH_HOST", "github.com")
.env("GIT_TERMINAL_PROMPT", "0");
if let Some(paths) = super::shell_env::search_path() {
command.env("PATH", paths);
}
if std::env::var_os("GIT_SSH_COMMAND").is_none() && std::env::var_os("GIT_SSH").is_none() {
command.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes -oConnectTimeout=15");
}
let token = resolve_github_token();
if let Some(token) = &token {
command
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_KEY_0", "credential.helper")
.env("GIT_CONFIG_VALUE_0", "")
.env("GIT_CONFIG_KEY_1", "credential.helper")
.env("GIT_CONFIG_VALUE_1", GITHUB_CREDENTIAL_HELPER)
.env("GIT_CONFIG_KEY_2", "core.hooksPath")
.env("GIT_CONFIG_VALUE_2", "/dev/null")
.env("ORX_GITHUB_TOKEN", token);
}
command
.env("GH_PROMPT_DISABLED", "1")
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_KEY_0", "credential.helper")
.env("GIT_CONFIG_VALUE_0", "")
.env("GIT_CONFIG_KEY_1", "credential.helper")
.env("GIT_CONFIG_VALUE_1", GITHUB_CREDENTIAL_HELPER)
.env("GIT_CONFIG_KEY_2", "core.hooksPath")
.env("GIT_CONFIG_VALUE_2", "/dev/null");
#[cfg(unix)]
command.process_group(0);
command
}
fn authenticated_git(repo_path: &Path, args: &[&str], timeout: Duration) -> Result<String> {
let mut command = authenticated_git_command(repo_path);
let mut child = command
.args(args)
.stdout(Stdio::piped())
@@ -1527,10 +1514,7 @@ fn authenticated_git(repo_path: &Path, args: &[&str], timeout: Duration) -> Resu
));
}
if !status.success() {
let mut error = String::from_utf8_lossy(&stderr).trim().to_string();
if let Some(token) = token {
error = error.replace(&token, "[redacted]");
}
let error = String::from_utf8_lossy(&stderr).trim().to_string();
return Err(anyhow!(
"git {} failed: {}",
args.first().copied().unwrap_or("command"),
@@ -1628,6 +1612,9 @@ pub fn spawn_branch_publication(
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(paths) = super::shell_env::search_path() {
command.env("PATH", paths);
}
#[cfg(unix)]
command.process_group(0);
let mut child = command
@@ -2180,6 +2167,29 @@ pub fn file_bytes_at_capped(
mod tests {
use super::*;
#[test]
fn github_git_uses_only_the_command_scoped_gh_credential_helper() {
use std::ffi::OsStr;
let command = authenticated_git_command(Path::new("."));
let search_path = crate::local::shell_env::search_path();
let env = |key: &str| {
command
.get_envs()
.find(|(name, _)| *name == OsStr::new(key))
.and_then(|(_, value)| value)
};
assert_eq!(env("GIT_CONFIG_COUNT"), Some(OsStr::new("3")));
assert_eq!(env("GH_HOST"), Some(OsStr::new("github.com")));
assert_eq!(env("PATH"), search_path.as_deref());
assert_eq!(env("GIT_CONFIG_VALUE_0"), Some(OsStr::new("")));
assert_eq!(
env("GIT_CONFIG_VALUE_1"),
Some(OsStr::new("!gh auth git-credential"))
);
}
#[test]
fn managed_large_file_ignores_are_anchored_escaped_and_idempotent() {
let paths = vec![b"data set/checkpoint[1].bin".to_vec()];
+187 -140
View File
@@ -1,40 +1,113 @@
//! Minimal GitHub REST calls for optional project publication.
//! GitHub CLI calls for optional project publication.
use std::time::Duration;
use serde_json::{json, Value};
use serde_json::Value;
use tokio::process::Command;
use super::git::resolve_github_token;
use crate::error::{anyhow, Error, Result};
use crate::error::{anyhow, Result};
const UA: &str = concat!("orx/", env!("CARGO_PKG_VERSION"));
pub const SHALLOW_CLONE_THRESHOLD_KB: u64 = 250 * 1024;
#[derive(Clone, Copy)]
pub struct Status {
pub installed: bool,
pub authenticated: bool,
}
pub fn should_shallow_clone(size_kb: Option<u64>) -> bool {
size_kb.is_some_and(|size| size >= SHALLOW_CLONE_THRESHOLD_KB)
}
#[derive(Debug)]
struct RepositoryNameExists;
impl std::fmt::Display for RepositoryNameExists {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("repository name already exists")
pub async fn status() -> Status {
let installed = gh(&["--version"], Duration::from_secs(5)).await.is_ok();
let authenticated = installed
&& gh(
&["auth", "status", "--active", "--hostname", "github.com"],
Duration::from_secs(10),
)
.await
.is_ok();
Status {
installed,
authenticated,
}
}
impl std::error::Error for RepositoryNameExists {}
async fn gh(args: &[&str], timeout: Duration) -> Result<String> {
let mut command = match super::shell_env::find_on_path("gh") {
Some(path) => Command::new(path),
None => Command::new("gh"),
};
command
.args(args)
.env("GH_HOST", "github.com")
.env("GH_PROMPT_DISABLED", "1")
.kill_on_drop(true);
if let Some(paths) = super::shell_env::search_path() {
command.env("PATH", paths);
}
let output = tokio::time::timeout(timeout, command.output())
.await
.map_err(|_| {
anyhow!(
"gh {} timed out",
args.first().copied().unwrap_or("command")
)
})?
.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
anyhow!("GitHub CLI (`gh`) is required — install it from https://cli.github.com.")
} else {
anyhow!("Could not run GitHub CLI: {error}")
}
})?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!(
"gh {} failed: {}",
args.first().copied().unwrap_or("command"),
if detail.is_empty() {
"unknown error"
} else {
&detail
}
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn create_project_repo(repo: &str) -> Result<(String, String, String)> {
fn repository_candidate(repo: &str, suffix: usize) -> String {
if suffix == 1 {
repo.to_string()
} else {
format!("{repo}-{suffix}")
}
}
fn repository_endpoint(owner: &str, repo: &str) -> String {
format!(
"repos/{}/{}",
urlencoding::encode(owner),
urlencoding::encode(repo)
)
}
pub async fn create_project_repo(repo: &str) -> Result<(String, String)> {
let owner = viewer_login().await?;
for suffix in 1..=100 {
let candidate = if suffix == 1 {
repo.to_string()
} else {
format!("{repo}-{suffix}")
};
match create_repo_api(&candidate, false).await {
Err(error) if error.downcast_ref::<RepositoryNameExists>().is_some() => continue,
result => return result,
let candidate = repository_candidate(repo, suffix);
let name_with_owner = format!("{owner}/{candidate}");
match gh(
&["repo", "create", &name_with_owner, "--private"],
Duration::from_secs(30),
)
.await
{
Ok(_) => return Ok((owner, candidate)),
Err(error) if repository_name_exists(&error.to_string()) => continue,
Err(error) => return Err(error),
}
}
Err(anyhow!(
@@ -42,36 +115,17 @@ pub async fn create_project_repo(repo: &str) -> Result<(String, String, String)>
))
}
pub async fn available_project_repo_name(repo: &str) -> String {
let Some(owner) = viewer_login().await else {
return repo.to_string();
};
pub async fn available_project_repo_name(repo: &str) -> Result<String> {
let owner = viewer_login().await?;
for suffix in 1..=100 {
let candidate = if suffix == 1 {
repo.to_string()
} else {
format!("{repo}-{suffix}")
};
if repo_meta(&owner, &candidate).await.is_none() {
return candidate;
let candidate = repository_candidate(repo, suffix);
if repo_meta(&owner, &candidate).await?.is_none() {
return Ok(candidate);
}
}
repo.to_string()
}
async fn authed_get(url: &str) -> Option<reqwest::Response> {
let token = resolve_github_token()?;
reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.ok()?
.get(url)
.bearer_auth(&token)
.header("user-agent", UA)
.header("accept", "application/vnd.github+json")
.send()
.await
.ok()
Err(anyhow!(
"Could not find an available GitHub repository name for '{repo}'."
))
}
pub async fn public_repo_size_kb(url: &str) -> Option<u64> {
@@ -80,7 +134,7 @@ pub async fn public_repo_size_kb(url: &str) -> Option<u64> {
.timeout(Duration::from_secs(10))
.build()
.ok()?;
let mut request = client
let response = client
.get(format!(
"https://api.github.com/repos/{}/{}",
urlencoding::encode(&owner),
@@ -88,11 +142,10 @@ pub async fn public_repo_size_kb(url: &str) -> Option<u64> {
))
.header("user-agent", UA)
.header("accept", "application/vnd.github+json")
.header("x-github-api-version", "2022-11-28");
if let Some(token) = resolve_github_token() {
request = request.bearer_auth(token);
}
let response = request.send().await.ok()?;
.header("x-github-api-version", "2022-11-28")
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
@@ -105,94 +158,55 @@ pub struct RepoMeta {
pub archived: bool,
}
pub async fn viewer_login() -> Option<String> {
let response = authed_get("https://api.github.com/user").await?;
if !response.status().is_success() {
return None;
}
let body: Value = response.json().await.ok()?;
body.get("login")
.and_then(Value::as_str)
.filter(|login| !login.is_empty())
.map(str::to_string)
}
pub async fn repo_meta(owner: &str, repo: &str) -> Option<RepoMeta> {
let response = authed_get(&format!(
"https://api.github.com/repos/{}/{}",
urlencoding::encode(owner),
urlencoding::encode(repo)
))
.await?;
match response.status() {
reqwest::StatusCode::NOT_FOUND => None,
status if status.is_success() => {
let body: Value = response.json().await.ok()?;
Some(RepoMeta {
can_push: body
.pointer("/permissions/push")
.and_then(Value::as_bool)
.unwrap_or(false),
archived: body
.get("archived")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
_ => None,
}
}
async fn create_repo_api(repo: &str, auto_init: bool) -> Result<(String, String, String)> {
let token = resolve_github_token().ok_or_else(|| {
anyhow!("Creating a GitHub repo needs credentials — run `gh auth login` or connect a GitHub token.")
})?;
let response = reqwest::Client::new()
.post("https://api.github.com/user/repos")
.bearer_auth(&token)
.header("user-agent", UA)
.header("accept", "application/vnd.github+json")
.json(&json!({ "name": repo, "private": true, "auto_init": auto_init }))
.send()
pub async fn viewer_login() -> Result<String> {
gh(&["api", "user", "--jq", ".login"], Duration::from_secs(10))
.await
.map_err(|error| anyhow!("GitHub API unreachable: {error}"))?;
let status = response.status();
let body: Value = response.json().await.unwrap_or_default();
if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
let detail = body
.pointer("/errors/0/message")
.and_then(Value::as_str)
.unwrap_or("invalid repository name");
let code = body.pointer("/errors/0/code").and_then(Value::as_str);
if code == Some("already_exists") || detail.to_ascii_lowercase().contains("already exists")
{
return Err(Error::new(RepositoryNameExists));
}
return Err(anyhow!("Could not create '{repo}': {detail}."));
}
if !status.is_success() {
let message = body
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error");
return Err(anyhow!("GitHub repo create failed ({status}): {message}"));
}
let owner = body
.pointer("/owner/login")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("GitHub response missing owner login"))?
.to_string();
let name = body
.get("name")
.and_then(Value::as_str)
.unwrap_or(repo)
.to_string();
let default_branch = body
.get("default_branch")
.and_then(Value::as_str)
.unwrap_or("main")
.to_string();
Ok((owner, name, default_branch))
.and_then(|login| {
if login.is_empty() {
Err(anyhow!("GitHub CLI returned an empty account login."))
} else {
Ok(login)
}
})
}
pub async fn repo_meta(owner: &str, repo: &str) -> Result<Option<RepoMeta>> {
let body = match gh(
&["api", &repository_endpoint(owner, repo)],
Duration::from_secs(10),
)
.await
{
Ok(body) => body,
Err(error) if github_api_not_found(&error.to_string()) => return Ok(None),
Err(error) => return Err(error),
};
parse_repo_meta(&body).map(Some)
}
fn parse_repo_meta(body: &str) -> Result<RepoMeta> {
let body: Value = serde_json::from_str(body)
.map_err(|error| anyhow!("Could not parse GitHub repository metadata: {error}"))?;
Ok(RepoMeta {
can_push: body
.pointer("/permissions/push")
.and_then(Value::as_bool)
.unwrap_or(false),
archived: body
.get("archived")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
fn github_api_not_found(error: &str) -> bool {
error.contains("(HTTP 404)")
}
fn repository_name_exists(error: &str) -> bool {
error
.to_ascii_lowercase()
.contains("name already exists on this account")
}
#[cfg(test)]
@@ -205,4 +219,37 @@ mod tests {
assert!(!should_shallow_clone(Some(SHALLOW_CLONE_THRESHOLD_KB - 1)));
assert!(should_shallow_clone(Some(SHALLOW_CLONE_THRESHOLD_KB)));
}
#[test]
fn repository_names_and_endpoints_are_safe() {
assert_eq!(repository_candidate("project", 1), "project");
assert_eq!(repository_candidate("project", 2), "project-2");
assert_eq!(
repository_endpoint("owner/name", "repo name"),
"repos/owner%2Fname/repo%20name"
);
}
#[test]
fn repository_metadata_defaults_to_no_access() {
let meta =
parse_repo_meta(r#"{"permissions":{"push":true},"archived":false}"#).expect("metadata");
assert!(meta.can_push);
assert!(!meta.archived);
let meta = parse_repo_meta("{}").expect("metadata");
assert!(!meta.can_push);
assert!(!meta.archived);
}
#[test]
fn github_api_errors_preserve_missing_and_collision_signals() {
assert!(github_api_not_found("gh: Not Found (HTTP 404)"));
assert!(!github_api_not_found(
"gh: API rate limit exceeded (HTTP 403)"
));
assert!(repository_name_exists(
"GraphQL: Name already exists on this account"
));
}
}
+2 -3
View File
@@ -455,9 +455,8 @@ struct Auth<'a> {
token: &'a str,
}
/// Answers only for the host the project was linked with, mirroring
/// `git::GITHUB_CREDENTIAL_HELPER`: git feeds the helper the host on stdin, and
/// a project URL naming somewhere else gets nothing.
/// Answers only for the host the project was linked with: git feeds the helper
/// the host on stdin, and a project URL naming somewhere else gets nothing.
const CREDENTIAL_HELPER: &str = "!f() { host=; while IFS='=' read key value; do [ \"$key\" = host ] && host=$value; done; [ \"$host\" = \"$ORX_OVERLEAF_HOST\" ] || exit 0; echo username=git; echo \"password=$ORX_OVERLEAF_TOKEN\"; }; f";
/// A git run. The token reaches the child only through its environment — never
+2 -2
View File
@@ -13,8 +13,8 @@
//! process environment unchanged.
//!
//! Scope is orx's own resolution and the children it spawns. The other things
//! orx shells out to — `git`, `gh`, `kubectl`, `ssh`, the detached
//! `publish-branch` worker — still inherit the process environment.
//! orx shells out to — `git`, `kubectl`, and `ssh` — still inherit the process
//! environment.
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-D3oeDVCt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPmIAxXd.css">
<script type="module" crossorigin src="/assets/index-BYSs-W4T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D5LbnO_X.css">
</head>
<body>
<div
+3 -10
View File
@@ -999,7 +999,7 @@ export interface GitSettings {
userName: string | null;
userEmail: string | null;
ghInstalled: boolean;
githubTokenSource: "env" | "stored" | "gh" | null;
githubAuthenticated: boolean;
}
export const getGitSettings = () => get<GitSettings>("/api/settings/git");
@@ -1007,13 +1007,6 @@ export const getGitSettings = () => get<GitSettings>("/api/settings/git");
export const saveGitSettings = (body: { userName?: string; userEmail?: string }) =>
post<GitSettings>("/api/settings/git", body);
/** Validate + persist a pasted GitHub token (stored in the synced env file). */
export const saveGitToken = (token: string) =>
post<GitSettings>("/api/settings/git/token", { token });
export const removeGitToken = () =>
fetch("/api/settings/git/token", { method: "DELETE" }).then((r) => json<GitSettings>(r));
/** A paper linked to the researcher profile during onboarding. */
export interface LinkedPaper {
paperId: string;
@@ -1048,8 +1041,8 @@ export const setLitSources = (body: LitSourcesSettings) =>
export interface ProjectDefaultsSettings {
githubForNewProjects: boolean;
githubDefaultPromptSeen: boolean;
ghInstalled: boolean;
githubAuthenticated: boolean;
githubTokenSource: "env" | "stored" | "gh" | null;
}
export const getProjectDefaults = () =>
@@ -1079,8 +1072,8 @@ export interface ProjectGitStatus {
emailSource: "local" | "global" | null;
};
github: {
ghInstalled: boolean;
authenticated: boolean;
tokenSource: "env" | "stored" | "gh" | null;
enabled: boolean;
owner: string;
repo: string;
+2 -20
View File
@@ -1,22 +1,17 @@
import { useState } from "react";
import { saveGitToken, type GitSettings } from "../api";
import { BUTTON_CLASS_NAME } from "../styleClasses";
/** Paste-a-token form, shared by the GitHub and Overleaf cards: one password
* field, server-side validation, and a link to where the token is minted. */
/** Paste an Overleaf Git token with a link to where the token is minted. */
export function TokenForm<T>({
save,
onSaved,
placeholder,
createHref,
/** GitHub validates the token as it saves; Overleaf cannot, so it just saves. */
busyLabel = "Checking…",
}: {
save: (token: string) => Promise<T>;
onSaved: (result: T) => void;
placeholder: string;
createHref: string;
busyLabel?: string;
}) {
const [token, setToken] = useState("");
const [saving, setSaving] = useState(false);
@@ -47,7 +42,7 @@ export function TokenForm<T>({
autoComplete="off"
/>
<button type="submit" className={BUTTON_CLASS_NAME} disabled={saving || !token.trim()}>
{saving ? busyLabel : "Save"}
{saving ? "Saving…" : "Save"}
</button>
<a href={createHref} target="_blank" rel="noreferrer">
Create a token ↗
@@ -56,16 +51,3 @@ export function TokenForm<T>({
</form>
);
}
/** Paste-a-PAT fallback for GitHub access — validated server-side, stored in
* the synced env file. Reports the refreshed git settings on success. */
export function GitTokenForm({ onSaved }: { onSaved: (g: GitSettings) => void }) {
return (
<TokenForm
save={saveGitToken}
onSaved={onSaved}
placeholder="ghp_… personal access token"
createHref="https://github.com/settings/tokens/new?scopes=repo,workflow&description=orx"
/>
);
}
+1 -1
View File
@@ -648,7 +648,7 @@ export function NewProjectForm({
{githubAction} <code className="font-mono text-[0.92em] font-medium text-text bg-panel border border-border-variant rounded-xs py-px px-[5px] wrap-anywhere">{githubRepository}</code>.
</span>
<span>Experiment branches will be pushed to the remote GitHub repository.</span>
{githubLogin === null && <span>Connect GitHub before creating the project.</span>}
{githubLogin === null && <span>Run <code className={MONO_CLASS_NAME}>gh auth login</code> before creating the project.</span>}
</span>
</label>
)}
+59 -25
View File
@@ -94,7 +94,8 @@ import {
import { onDataDirMove, onHarnessAuth } from "../events";
import { useUpdateStatus } from "./UpdateBanner";
import { useThemePreference, type ThemePreference } from "../theme";
import { GitTokenForm, TokenForm } from "./GitTokenForm";
import { TokenForm } from "./GitTokenForm";
import { renderNote } from "./agentNote";
import { BackendBadge, BackendLogo } from "./BackendLogos";
import { ProgressBar } from "./ProgressBar";
import { StatusBadge } from "./StatusBadge";
@@ -2299,11 +2300,11 @@ function ProjectDefaultsTab() {
const load = () => {
setError(null);
void getProjectDefaults()
return getProjectDefaults()
.then(setSettings)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
useEffect(load, []);
useEffect(() => void load(), []);
const toggle = () => {
if (!settings || saving) return;
@@ -2326,8 +2327,8 @@ function ProjectDefaultsTab() {
<div className="settings-card [&_>_.error]:text-accent-red [&_>_.error]:text-md [&_>_.error]:whitespace-pre-wrap bg-background border border-border rounded-lg py-4 px-4.5 mb-4 [&_h3]:mt-0 [&_h3]:mx-0 [&_h3]:mb-2.5 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-text [&_.settings-sub]:mb-3 [&_.kv]:gap-y-1.5 [&_.kv]:gap-x-4.5 [&_>_.project-default-row:first-child]:pt-0 [&_>_.project-default-row:first-child]:border-t-0 project-defaults-card [&_.settings-card-head]:justify-between [&_.settings-card-head]:mb-0 [&_.settings-card-head]:pb-3 [&_.settings-card-head_h3]:m-0">
<div className="settings-card-head flex items-center gap-2.5 mb-3">
<h3>GitHub publishing</h3>
<span className={`${BADGE_CLASS_NAME} ${settings.githubAuthenticated ? "ok" : ""}`}>
{settings.githubAuthenticated ? `Connected via ${settings.githubTokenSource}` : "Not connected"}
<span className={`${BADGE_CLASS_NAME} ${settings.githubAuthenticated ? "ok" : settings.ghInstalled ? "warn" : "err"}`}>
{settings.githubAuthenticated ? "Connected via GitHub CLI" : "Not connected"}
</span>
</div>
<div className={PROJECT_DEFAULT_ROW_CLASS_NAME}>
@@ -2352,9 +2353,8 @@ function ProjectDefaultsTab() {
</button>
</div>
{!settings.githubAuthenticated && (
<div className="project-default-connect [&_p]:mt-[3px] [&_p]:mx-0 [&_p]:mb-0 [&_p]:text-muted [&_p]:text-sm mt-3.5 pt-3.5 border-t border-t-border-variant [&_.onb-token-form]:mt-2.5">
<p>Connect GitHub to make publishing the default for new projects.</p>
<GitTokenForm onSaved={load} />
<div className="mt-3.5 pt-3.5 border-t border-t-border-variant">
<GitHubCliHelp ghInstalled={settings.ghInstalled} onCheck={load} />
</div>
)}
{error && <div className="error">{error}</div>}
@@ -2364,9 +2364,47 @@ function ProjectDefaultsTab() {
);
}
/** The Overleaf Git authentication token, which is machine-wide like the GitHub
* PAT above it. Which Overleaf *project* a paper pushes to is per-paper, and
* lives on the .tex tab instead. */
function GitHubCliHelp({
ghInstalled,
onCheck,
}: {
ghInstalled: boolean;
onCheck: () => Promise<void>;
}) {
const [checking, setChecking] = useState(false);
const check = () => {
setChecking(true);
void onCheck().finally(() => setChecking(false));
};
return (
<>
<p className="git-card-helper text-subtext text-sm m-0">
{renderNote(ghInstalled
? "Run `gh auth login` in your terminal."
: "Install GitHub CLI, then run `gh auth login` in your terminal.")}
</p>
<div className="flex flex-wrap gap-2 mt-2.5">
{!ghInstalled && (
<a
className={PRIMARY_BUTTON_CLASS_NAME}
href="https://cli.github.com/"
target="_blank"
rel="noreferrer"
>
Install GitHub CLI <ExternalLink size={12} />
</a>
)}
<button type="button" className={`${BUTTON_CLASS_NAME} ${ghInstalled ? "text-accent-amber border-accent-amber" : ""}`} disabled={checking} onClick={check}>
{checking ? "Checking…" : "Check again"}
</button>
</div>
</>
);
}
/** The Overleaf Git authentication token is machine-wide. Which Overleaf
* *project* a paper pushes to is per-paper, and lives on the .tex tab. */
function OverleafCard() {
const [hasToken, setHasToken] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
@@ -2393,7 +2431,7 @@ function OverleafCard() {
With a token saved, a paper opened in the dashboard can be kept in step with an Overleaf
project, in both directions. Overleaf&apos;s Git integration comes with a paid Overleaf
plan; without one, a paper can still be uploaded to Overleaf as a new project. The token
stays on this machine — unlike the GitHub one, it is not sent to compute backends.
stays on this machine and is not sent to compute backends.
</p>
{hasToken ? (
<div className={GIT_CARD_ACTIONS_CLASS_NAME}>
@@ -2418,7 +2456,6 @@ function OverleafCard() {
onSaved={(result) => setHasToken(result.hasToken)}
placeholder="Overleaf Git authentication token"
createHref="https://www.overleaf.com/user/settings"
busyLabel="Saving…"
/>
)}
{error && <div className="error">{error}</div>}
@@ -2446,12 +2483,12 @@ function GitTab({
const seqRef = useRef(0);
const hasGithubRepository = Boolean(status?.github.owner && status.github.repo);
const load = () => {
const load = (clear = true) => {
const request = ++seqRef.current;
setStatus(null);
if (clear) setStatus(null);
setError(null);
if (!project) return;
void getProjectGitStatus(project.id)
if (!project) return Promise.resolve();
return getProjectGitStatus(project.id)
.then((projectStatus) => {
if (request !== seqRef.current) return;
setStatus(projectStatus);
@@ -2462,7 +2499,7 @@ function GitTab({
}
});
};
useEffect(load, [project?.id]);
useEffect(() => void load(), [project?.id]);
const syncErrorMessage = (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
@@ -2538,17 +2575,14 @@ function GitTab({
<div className={GIT_SETTINGS_CARD_CLASS_NAME}>
<h3>GitHub</h3>
<div className={KV_CLASS_NAME}>
<span className="k">Authentication</span><span className="v"><span className={`${BADGE_CLASS_NAME} ${status.github.authenticated ? "ok" : ""}`}>{status.github.authenticated ? "Connected" : "Not connected"}</span>{status.github.authenticated && <span className="git-detail-meta text-muted text-sm">via {status.github.tokenSource}</span>}</span>
<span className="k">Authentication</span><span className="v"><span className={`${BADGE_CLASS_NAME} ${status.github.authenticated ? "ok" : status.github.ghInstalled ? "warn" : "err"}`}>{status.github.authenticated ? "Connected via GitHub CLI" : "Not connected"}</span></span>
<span className="k">Project</span><span className="v">{hasGithubRepository ? <><span className={MONO_CLASS_NAME}>{status.github.owner}/{status.github.repo}</span>{!status.github.enabled && <span className="badge inline-flex items-center font-sans font-medium py-px px-[7px] border border-border rounded-sm [&.ok]:text-accent-green [&.ok]:border-accent-green [&.ok]:bg-accent-green-subtle [&.err]:text-accent-red [&.err]:border-accent-red [&.err]:bg-accent-red-subtle [&.warn]:text-accent-amber [&.warn]:border-accent-amber [&.warn]:bg-accent-amber-subtle git-detail-meta text-muted text-sm">Syncing off</span>}</> : <span className={BADGE_CLASS_NAME}>Local only</span>}</span>
{status.github.enabled && <><span className="k">Sync</span><span className="v">{status.github.syncStatus}</span></>}
</div>
{!status.github.authenticated && (
<>
<p className="git-card-helper text-muted text-sm mt-3.5 mx-0 mb-0">
GitHub is optional. Connect only when you want a hosted copy for collaboration.
</p>
<GitTokenForm onSaved={() => load()} />
</>
<div className="mt-3.5 pt-3.5 border-t border-t-border-variant">
<GitHubCliHelp ghInstalled={status.github.ghInstalled} onCheck={() => load(false)} />
</div>
)}
{status.github.authenticated && !status.github.enabled && (
<>