mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
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:
+14
-6
@@ -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'}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
+183
-183
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user