diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 009bc09..dc82808 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -7,7 +7,8 @@ permissions: contents: write concurrency: - group: dev-release-${{ github.sha }} + # One group for all commits: concurrent runs could publish latest-dev.json out of order. + group: dev-release cancel-in-progress: false jobs: diff --git a/Cargo.lock b/Cargo.lock index e192fc6..8a173aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3406,6 +3406,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.2", "itoa", "memchr", "serde", diff --git a/crates/wizard-cli/src/agent.rs b/crates/wizard-cli/src/agent.rs index b7519a6..198ee18 100644 --- a/crates/wizard-cli/src/agent.rs +++ b/crates/wizard-cli/src/agent.rs @@ -91,6 +91,11 @@ pub(crate) fn run( AgentCommand::Codex { command } => { let (mode, args) = routing_mode(&command); let settings = settings::load_from_file()?; + // The CLI cannot update ChatGPT's profile or the GUI's listener that it uses. + ensure!( + settings.codex_proxy_enabled.is_none(), + "Настройка Codex доступна только в GUI, пока в нём включён прокси для ChatGPT." + ); let Some(token) = resolve_token( runtime, yes, diff --git a/crates/wizard-core/Cargo.toml b/crates/wizard-core/Cargo.toml index dd97635..9b0cfdc 100644 --- a/crates/wizard-core/Cargo.toml +++ b/crates/wizard-core/Cargo.toml @@ -20,7 +20,7 @@ log.workspace = true regex.workspace = true reqwest.workspace = true serde.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } # keep key order in user files semver.workspace = true sha2.workspace = true jsonc-parser.workspace = true diff --git a/crates/wizard-core/src/agents/claude.rs b/crates/wizard-core/src/agents/claude.rs index 55dd348..ccb1491 100644 --- a/crates/wizard-core/src/agents/claude.rs +++ b/crates/wizard-core/src/agents/claude.rs @@ -189,7 +189,7 @@ fn remove_inactive_routing_settings(env: &mut Map, current_mode: ], }; for key in previous_keys { - env.remove(*key); + env.shift_remove(*key); } } diff --git a/crates/wizard-core/src/agents/claude_desktop.rs b/crates/wizard-core/src/agents/claude_desktop.rs index 60633ea..4f258e7 100644 --- a/crates/wizard-core/src/agents/claude_desktop.rs +++ b/crates/wizard-core/src/agents/claude_desktop.rs @@ -172,9 +172,12 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result { } let proxy_url = format!("http://{}", wizard_proxy::CLAUDE_DESKTOP_ADDRESS); + // Wizard points every profile it configures at its proxy. + let managed = json::parse(&config.after)?["egressProxyUrl"] == proxy_url; json::update(&mut config.after, |config| { - let remove = mode != RoutingMode::Hub; - let remove_proxy = mode == RoutingMode::Disabled && config["egressProxyUrl"] == proxy_url; + // Remove only CoreInfra's connection; another gateway belongs to the user and is not + // backed up. Claude ignores inference settings in the 1p mode that Sub selects. + let remove = mode != RoutingMode::Hub && has_hub_connection(config); let object = config .as_object_mut() @@ -188,27 +191,31 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result { object.insert("inferenceGatewayAuthScheme".into(), json!("x-api-key")); } else if remove { for key in CONNECTION_KEYS { - object.remove(key); + object.shift_remove(key); } } if mode == RoutingMode::Sub { object.insert("disableAutoUpdates".into(), json!(true)); + } else if managed { + // Undo Sub's override only in a profile Wizard configured. + object.shift_remove("disableAutoUpdates"); } if mode != RoutingMode::Disabled { object.insert("egressProxyUrl".into(), json!(proxy_url)); - object.remove("egressProxyPacUrl"); - } else if remove_proxy { - object.remove("egressProxyUrl"); + object.shift_remove("egressProxyPacUrl"); + } else if managed { + object.shift_remove("egressProxyUrl"); } Ok(()) })?; - if mode != RoutingMode::Disabled { + // Disabled returns a Wizard profile to the Claude account (1p), where sign-in works. + if mode != RoutingMode::Disabled || managed { json::update(&mut preferences.after, |preferences| { preferences["deploymentMode"] = - json!(if mode == RoutingMode::Sub { "1p" } else { "3p" }); + json!(if mode == RoutingMode::Hub { "3p" } else { "1p" }); Ok(()) })?; } diff --git a/crates/wizard-core/src/agents/claude_desktop_install.rs b/crates/wizard-core/src/agents/claude_desktop_install.rs index 04460dc..ca7b3f6 100644 --- a/crates/wizard-core/src/agents/claude_desktop_install.rs +++ b/crates/wizard-core/src/agents/claude_desktop_install.rs @@ -1087,6 +1087,12 @@ fn install_release_inner(download: &DownloadedRelease) -> Result<()> { )?; verify_bundle(&staged, &release.version)?; + // Claude may have updated itself since the check; never replace a different version. + let installed = detect_target(release.target.path.as_deref())?; + ensure!( + installed.version == release.target.version, + "Claude Desktop изменился после проверки обновления; проверьте обновление снова" + ); // Check all Claude instances, including a running app at a different path. Never terminate them. ensure!( !app_is_running(BUNDLE_ID), diff --git a/crates/wizard-core/src/agents/codex.rs b/crates/wizard-core/src/agents/codex.rs index 61c3b83..14fb8bc 100644 --- a/crates/wizard-core/src/agents/codex.rs +++ b/crates/wizard-core/src/agents/codex.rs @@ -9,7 +9,8 @@ use crate::{ changes::{FileChange, FileSnapshot}, }, platform::{ - command_output, daemon_command_status, env_var_not_empty, find_executable, is_executable, + append_node_path, command_output, daemon_command_status, env_var_not_empty, + find_executable, is_executable, }, prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep}, }; @@ -119,6 +120,7 @@ pub fn restart_daemon(executable: &Path, codex_home: &Path) -> Result<()> { // daemon on updates. It keeps variables later removed from `.env`, and one started by an // older Wizard stays attributed to Wizard by macOS. Bootstrap resets remote control, though. let mut command = tokio::process::Command::new(executable); + append_node_path(&mut command)?; command .args(["app-server", "daemon", "bootstrap"]) .env("CODEX_HOME", codex_home); @@ -163,7 +165,8 @@ fn managed_daemon_running(executable: &Path, codex_home: &Path) -> bool { // TODO: Distinguish an absent daemon from command failures and unsupported versions. // For now, spawn errors, timeouts, nonzero exit statuses and unmanaged servers all mean // "no daemon". - command_output(probe, Duration::from_secs(15)) + append_node_path(&mut probe) + .and_then(|()| command_output(probe, Duration::from_secs(15))) .ok() .filter(|output| output.status.success()) .and_then(|output| serde_json::from_slice::(&output.stdout).ok()) @@ -242,18 +245,33 @@ pub fn prepare_routing_for_gui( settings.last_paths.codex_desktop.as_deref(), ) { AgentDetection::Found(_) => true, - AgentDetection::NotFound => return Ok(plan), + // Clean up a configuration left from before ChatGPT was removed. + AgentDetection::NotFound => false, AgentDetection::Error(error) => { anyhow::bail!("failed to detect ChatGPT for local proxy: {error}") } } }; - let path = desktop_preferences_path()?.context("ChatGPT profile location is unavailable")?; - let change = prepare_desktop_proxy(path, enabled)?; - plan.steps.insert(0, PreparedStep::ChangeFile { change }); - plan.steps.push(PreparedStep::SetCodexProxyEnabled { - token: enabled.then(|| token.to_owned()), - }); + // Without ChatGPT installed (Windows), there is no profile to clean up when disabling. + match desktop_preferences_path()? { + Some(path) => { + let change = prepare_desktop_proxy(path, enabled)?; + plan.steps.insert(0, PreparedStep::ChangeFile { change }); + } + None => ensure!(!enabled, "ChatGPT profile location is unavailable"), + } + // Before the daemon restart: it can fail after Preferences already points at the listener. + let restart = plan + .steps + .iter() + .position(|step| matches!(step, PreparedStep::CodexRestartDaemon { .. })) + .unwrap_or(plan.steps.len()); + plan.steps.insert( + restart, + PreparedStep::SetCodexProxyEnabled { + token: enabled.then(|| token.to_owned()), + }, + ); Ok(plan) } @@ -297,7 +315,13 @@ fn prepare_desktop_proxy(path: PathBuf, enabled: bool) -> Result { use serde_json::json; let mut change = FileChange::read(std::path::absolute(path)?)?; + // ChatGPT creates its profile on first launch; until then there is nothing to clean up. if matches!(change.after, FileSnapshot::Missing) { + // Linux detection accepts any `chatgpt` executable, which may be another program. + ensure!( + !enabled || cfg!(target_os = "linux"), + "ChatGPT ещё не запускался: откройте его один раз и повторите." + ); log::info!("ChatGPT proxy configuration skipped: profile Preferences file is missing"); return Ok(change); } @@ -326,7 +350,7 @@ fn prepare_desktop_proxy(path: PathBuf, enabled: bool) -> Result { config .as_object_mut() .context("ChatGPT Preferences must contain an object")? - .remove("proxy"); + .shift_remove("proxy"); } Ok(()) })?; @@ -373,7 +397,7 @@ fn daemon_executable(settings: &Settings) -> Result { for relative in candidates { let path = resources.join(relative); - if is_executable(&path)? { + if is_executable(&path) { return std::path::absolute(path).context("failed to resolve bundled Codex executable"); } } diff --git a/crates/wizard-core/src/agents/detection.rs b/crates/wizard-core/src/agents/detection.rs index f10ffbb..2d8c67a 100644 --- a/crates/wizard-core/src/agents/detection.rs +++ b/crates/wizard-core/src/agents/detection.rs @@ -3,7 +3,7 @@ use super::AgentDetection; #[cfg(target_os = "macos")] use crate::platform::env_var_not_empty; -use crate::platform::{append_command_path, command_output, find_executable}; +use crate::platform::{append_node_path, command_output, find_executable}; use anyhow::{Context as _, Result}; use core::time::Duration; use serde::Serialize; @@ -90,12 +90,7 @@ fn cli_version(agent: Agent, path: &Path) -> Result { .arg("--version") .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let Some(node) = find_executable("node", vec![], None)? { - let directory = node - .parent() - .context("Node executable has no parent directory")?; - append_command_path(&mut command, &[directory])?; - } + append_node_path(&mut command)?; let output = command_output(command, Duration::from_secs(5))?; anyhow::ensure!( output.status.success(), @@ -130,10 +125,12 @@ pub(super) fn detect_desktop( ) -> AgentDetection { #[cfg(target_os = "macos")] { - let app_path = Path::new(match agent { - DesktopAgent::Codex => "/Applications/ChatGPT.app", - DesktopAgent::Claude => CLAUDE_APP_PATH, - }); + let (app_path, bundle_id) = match agent { + // The classic ChatGPT app (com.openai.chat) has the same name. + DesktopAgent::Codex => ("/Applications/ChatGPT.app", Some("com.openai.codex")), + DesktopAgent::Claude => (CLAUDE_APP_PATH, None), + }; + let app_path = Path::new(app_path); let app_name = app_path .file_name() .expect("desktop app path has a filename"); @@ -149,10 +146,17 @@ pub(super) fn detect_desktop( Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return AgentDetection::failed(&path, &error), } - return match crate::platform::macos::read_app_version(&path) { - Ok(version) => AgentDetection::Found(AgentInfo { path, version }), - Err(error) => AgentDetection::failed(&path, &error), + let info = match crate::platform::macos::read_app_info(&path) { + Ok(info) => info, + Err(error) => return AgentDetection::failed(&path, &error), }; + if bundle_id.is_some_and(|id| info.identifier.as_deref() != Some(id)) { + continue; + } + return AgentDetection::Found(AgentInfo { + path, + version: info.version, + }); } AgentDetection::NotFound } @@ -176,18 +180,15 @@ pub(super) fn detect_desktop( .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); - let output = match command_output(command, Duration::from_secs(5)) { - Ok(output) => output, - Err(error) => return AgentDetection::failed(&path, &error), - }; - if output.status.success() { - String::from_utf8(output.stdout).ok().and_then(|text| { + // The version is optional: without dpkg-query (non-Debian systems) it is unknown. + command_output(command, Duration::from_secs(5)) + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|text| { text.strip_prefix("install ok installed ") .map(|version| version.trim().to_owned()) }) - } else { - None - } } else { None }; diff --git a/crates/wizard-core/src/agents/opencode.rs b/crates/wizard-core/src/agents/opencode.rs index 4feaf2e..d5bc36c 100644 --- a/crates/wizard-core/src/agents/opencode.rs +++ b/crates/wizard-core/src/agents/opencode.rs @@ -4,10 +4,7 @@ use super::{ }; use crate::{ config_files::{self, changes::FileChange}, - platform::{ - append_command_path, command_output, command_output_with_stdin, env_var_not_empty, - find_executable, - }, + platform::{append_node_path, command_output, command_output_with_stdin, env_var_not_empty}, prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep}, }; use anyhow::{Context as _, Result, bail, ensure}; @@ -238,7 +235,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result Result<()> { let cwd = tempfile::tempdir().context("failed to create OpenCode install working directory")?; let mut command = tokio::process::Command::new(executable); - if let Some(node) = find_executable("node", vec![], None)? { - append_command_path( - &mut command, - &[node.parent().context("Node executable has no parent")?], - )?; - } + append_node_path(&mut command)?; command .args(["--pure", "plugin", "-g", "--force", INSTALL_SOURCE]) .env( @@ -477,6 +469,7 @@ fn run_cli( let cwd = tempfile::tempdir().context("failed to create OpenCode working directory")?; let mut command = tokio::process::Command::new(executable); + append_node_path(&mut command)?; command .args(args) .env_remove("OPENCODE_CONFIG") @@ -517,6 +510,7 @@ pub fn add_plugin(executable: &Path, config_dir: &Path) -> Result<()> { let cwd = tempfile::tempdir().context("failed to create OpenCode install working directory")?; let mut command = tokio::process::Command::new(executable); + append_node_path(&mut command)?; command .args(["plugin", "add", INSTALL_SOURCE]) .env( diff --git a/crates/wizard-core/src/agents/pi.rs b/crates/wizard-core/src/agents/pi.rs index 03204d5..c6755ba 100644 --- a/crates/wizard-core/src/agents/pi.rs +++ b/crates/wizard-core/src/agents/pi.rs @@ -122,7 +122,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result Result Result<()> { } fn run_package_command(executable: &Path, dir: &Path, action: &str) -> Result<()> { - let node = find_executable("node", vec![], None)?.context("Node executable not found")?; - let npm = find_executable("npm", vec![], None)?.context("npm executable not found")?; + // Version managers (nvm, fnm, Volta) keep node and npm next to the Pi CLI. + let near: Vec<_> = executable + .parent() + .map(Path::to_owned) + .into_iter() + .collect(); + let node = find_executable("node", near.clone(), None)?.context("Node executable not found")?; + let npm = find_executable("npm", near, None)?.context("npm executable not found")?; let paths = [ node.parent().context("Node executable has no parent")?, npm.parent().context("npm executable has no parent")?, diff --git a/crates/wizard-core/src/config_files/backups.rs b/crates/wizard-core/src/config_files/backups.rs index 4013ea5..3ff7b1c 100644 --- a/crates/wizard-core/src/config_files/backups.rs +++ b/crates/wizard-core/src/config_files/backups.rs @@ -91,9 +91,7 @@ impl BackupManager { return Ok(()); } - let mut state = State::load(agent)?; - state.create_backup_if_needed(changes)?; - state.save() + State::load(agent)?.create_backup_if_needed(changes) } pub fn save_last_written(agent: AgentKind, changes: &[FileChange]) -> Result<()> { @@ -120,6 +118,8 @@ impl BackupManager { change.after = snapshot.clone(); changes.push(change); } + // Like a plan, back up only what the restore changes. + changes.retain(FileChange::is_changed); // Restore existing files first; delete files absent from the backup last. changes.sort_by_key(|change| matches!(change.after, FileSnapshot::Missing)); Self::apply_changes(agent, &changes).context("failed to restore backup") @@ -193,10 +193,10 @@ impl State { return Ok(()); } + // Save the next ID first, so a failed state save can never reuse an existing backup's ID. let id = self.get_next_backup_id(); - let backup = Backup::from_changes(id, changes); - backup.save(self.agent)?; - Ok(()) + self.save()?; + Backup::from_changes(id, changes).save(self.agent) } } @@ -251,6 +251,12 @@ impl Backup { let directory = agent.ensure_directory()?; let mut change = FileChange::read(directory.join(format!("{}.json", self.id)))?; + // Another Wizard process may have used this ID; never replace its backup. + ensure!( + matches!(change.before(), FileSnapshot::Missing), + "backup {} already exists", + self.id + ); change.after = FileSnapshot::new(content, 0o600); FileChange::apply_all(&[change]).context("failed to save backup") } diff --git a/crates/wizard-core/src/config_files/changes.rs b/crates/wizard-core/src/config_files/changes.rs index 049f978..a60127a 100644 --- a/crates/wizard-core/src/config_files/changes.rs +++ b/crates/wizard-core/src/config_files/changes.rs @@ -60,18 +60,14 @@ impl FileSnapshot { } } - /// Reads the current UTF-8 file and its permissions without changing it. + /// Reads the current UTF-8 file and its permissions without changing it, following symlinks. /// A missing file is distinct from an existing empty file. pub(crate) fn read(path: &Path) -> Result { - let metadata = match fs::symlink_metadata(path) { + let metadata = match fs::metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Self::Missing), Err(error) => return Err(error).context("failed to inspect file"), }; - ensure!( - !metadata.file_type().is_symlink(), - "file is a symlink; refusing to read it" - ); ensure!(metadata.is_file(), "file must be a regular file"); let mut file = fs::File::open(path).context("failed to open file")?; @@ -157,7 +153,14 @@ impl FileChange { &self.path } - fn verify_before(&self) -> Result<()> { + pub(crate) fn verify_before(&self) -> Result<()> { + // Replacing a symlink would turn it into a regular file, so writes refuse symlinks. + let symlink = fs::symlink_metadata(&self.path).is_ok_and(|metadata| metadata.is_symlink()); + ensure!( + !symlink, + "{} is a symlink; refusing to change it", + self.path.display() + ); let current = FileSnapshot::read(&self.path) .with_context(|| format!("failed to reread {}", self.path.display()))?; ensure!( diff --git a/crates/wizard-core/src/config_files/json.rs b/crates/wizard-core/src/config_files/json.rs index dc4ba93..cbb19c8 100644 --- a/crates/wizard-core/src/config_files/json.rs +++ b/crates/wizard-core/src/config_files/json.rs @@ -24,6 +24,7 @@ pub(crate) fn parse(snapshot: &FileSnapshot) -> Result { } /// Edits only the working snapshot. A semantic no-op preserves the original text. +/// Keys keep their order; remove them with `shift_remove`, since `remove` moves the last key. pub(crate) fn update( after: &mut FileSnapshot, edit: impl FnOnce(&mut Value) -> Result<()>, diff --git a/crates/wizard-core/src/config_files/toml.rs b/crates/wizard-core/src/config_files/toml.rs index ef05fd0..c071edc 100644 --- a/crates/wizard-core/src/config_files/toml.rs +++ b/crates/wizard-core/src/config_files/toml.rs @@ -24,15 +24,6 @@ pub(crate) fn get_string<'a>(doc: &'a DocumentMut, keys: &[&str]) -> Option<&'a item.as_str() } -#[expect(dead_code)] -pub(crate) fn get_value<'a>(doc: &'a DocumentMut, keys: &[&str]) -> Option<&'a Value> { - let mut item = doc.as_item(); - for key in keys { - item = item.as_table_like()?.get(key)?; - } - item.as_value() -} - /// Updates a value, preserving its comments and unrelated table entries. pub(crate) fn set_value( doc: &mut DocumentMut, diff --git a/crates/wizard-core/src/hub/login.rs b/crates/wizard-core/src/hub/login.rs index 6549679..de0d42d 100644 --- a/crates/wizard-core/src/hub/login.rs +++ b/crates/wizard-core/src/hub/login.rs @@ -29,6 +29,8 @@ const CALLBACK_PATH: &str = "/callback"; const LOGIN_LINK_PARAMETER: &str = "login_link"; const LOGIN_PATH: &str = "/hub/api/login"; const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(15); +/// A browser's callback request is small; never buffer an endless one. +const MAX_REQUEST_HEAD: usize = 16 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LoginError { @@ -201,6 +203,9 @@ async fn read_request_head(stream: &mut TcpStream) -> Option> { } let searched = head.len().saturating_sub(3); head.extend_from_slice(&buffer[..size]); + if head.len() > MAX_REQUEST_HEAD { + return None; + } if let Some(end) = head[searched..] .windows(4) .position(|part| part == b"\r\n\r\n") diff --git a/crates/wizard-core/src/platform/macos.rs b/crates/wizard-core/src/platform/macos.rs index df361e8..ade53ab 100644 --- a/crates/wizard-core/src/platform/macos.rs +++ b/crates/wizard-core/src/platform/macos.rs @@ -24,19 +24,27 @@ pub(crate) fn app_is_running(bundle_id: &str) -> bool { } #[derive(Deserialize)] -struct AppInfo { +pub(crate) struct AppInfo { + #[serde(rename = "CFBundleIdentifier")] + pub identifier: Option, #[serde(rename = "CFBundleShortVersionString")] - version: Option, + pub version: Option, } -/// Reads an app bundle's version without launching the application. +/// Reads an app bundle's identifier and version without launching the application. /// `plist` handles both XML and binary Info.plist files. -pub(crate) fn read_app_version(app: &Path) -> Result> { +pub(crate) fn read_app_info(app: &Path) -> Result { let path = app.join("Contents/Info.plist"); log::debug!("reading application metadata: {}", path.display()); - let info: AppInfo = + let mut info: AppInfo = plist::from_file(&path).with_context(|| format!("failed to read {}", path.display()))?; - Ok(info.version.filter(|version| !version.trim().is_empty())) + info.version = info.version.filter(|version| !version.trim().is_empty()); + Ok(info) +} + +/// Reads an app bundle's version without launching the application. +pub(crate) fn read_app_version(app: &Path) -> Result> { + Ok(read_app_info(app)?.version) } #[cfg(test)] diff --git a/crates/wizard-core/src/platform/mod.rs b/crates/wizard-core/src/platform/mod.rs index 445e874..e5e4f31 100644 --- a/crates/wizard-core/src/platform/mod.rs +++ b/crates/wizard-core/src/platform/mod.rs @@ -42,6 +42,22 @@ pub(crate) fn append_command_path( Ok(()) } +/// Appends the program's directory and Node's to the command's PATH, so npm launchers +/// (`#!/usr/bin/env node`) also start from a GUI app with a minimal PATH. Version managers +/// (nvm, fnm, Volta) keep node next to the CLI. +pub(crate) fn append_node_path(command: &mut tokio::process::Command) -> Result<()> { + let program = PathBuf::from(command.as_std().get_program()); + let mut directories: Vec<&Path> = program.parent().into_iter().collect(); + let node = find_executable("node", vec![], None)?; + if let Some(node) = &node { + directories.push( + node.parent() + .context("Node executable has no parent directory")?, + ); + } + append_command_path(command, &directories) +} + /// Checks the last found executable first, then searches common directories, /// extra directories, the per-user Nix profile, and PATH. /// Expands a leading `~` path component. On Windows, also expands leading `%VAR%/` @@ -52,7 +68,7 @@ pub(crate) fn find_executable( last_found: Option<&Path>, ) -> Result> { if let Some(path) = last_found - && is_executable(path)? + && is_executable(path) { return Ok(Some(std::path::absolute(path)?)); } @@ -139,7 +155,7 @@ pub(crate) fn find_executable( }; for filename in &filenames { let candidate = directory.join(filename); - if is_executable(&candidate)? { + if is_executable(&candidate) { return Ok(Some(std::path::absolute(candidate)?)); } } @@ -148,25 +164,23 @@ pub(crate) fn find_executable( Ok(None) } -pub(crate) fn is_executable(path: &Path) -> Result { - let metadata = match std::fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(error).with_context(|| format!("failed to inspect {}", path.display())); - } +/// Any error means not executable: a PATH entry may be missing, a file or unreadable, and a +/// shell skips it too. +pub(crate) fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; }; if !metadata.is_file() { - return Ok(false); + return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; if metadata.permissions().mode() & 0o111 == 0 { - return Ok(false); + return false; } } - Ok(true) + true } /// Runs a prepared command with bounded execution time and no stdin. diff --git a/crates/wizard-core/src/prepared_plan.rs b/crates/wizard-core/src/prepared_plan.rs index 5985308..fc2cdcb 100644 --- a/crates/wizard-core/src/prepared_plan.rs +++ b/crates/wizard-core/src/prepared_plan.rs @@ -153,6 +153,17 @@ impl PreparedPlan { ) -> Result<()> { let _guard = self.agent.lock()?; + // Refuse a write (symlink, file changed since preparation) before an install step runs. + // Each write checks again. + for step in &self.steps { + if let PreparedStep::ChangeFile { change } | PreparedStep::PiApplyAuth { change, .. } = + step + && change.is_changed() + { + change.verify_before()?; + } + } + let backup_agent = match self.agent { PreparedAgent::Codex => Some(AgentKind::Codex), PreparedAgent::Claude => Some(AgentKind::Claude), @@ -162,8 +173,11 @@ impl PreparedPlan { let changes: Vec<_> = self .steps .iter() + // Only changed files: restoring an untouched file would roll back unrelated edits. .filter_map(|step| match step { - PreparedStep::ChangeFile { change } => Some(change.clone()), + PreparedStep::ChangeFile { change } if change.is_changed() => { + Some(change.clone()) + } _ => None, }) .collect(); diff --git a/crates/wizard-gui/src/agents.rs b/crates/wizard-gui/src/agents.rs index bc1dd76..da878eb 100644 --- a/crates/wizard-gui/src/agents.rs +++ b/crates/wizard-gui/src/agents.rs @@ -1,6 +1,6 @@ //! Tauri commands for agent discovery and configuration. -use crate::{hub_tokens, revision_signal::RevisionSignal, settings}; +use crate::{hub_tokens, proxy, revision_signal::RevisionSignal, settings}; use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; use tauri::Manager as _; @@ -42,12 +42,15 @@ pub(crate) async fn get_agent_state(app: tauri::AppHandle) -> Result HubTokens { + Err(TokensError::Request(HubError::SignedOut)) if signed_out => HubTokens { generation, ..HubTokens::default() }, @@ -151,14 +153,14 @@ pub(crate) async fn issue(app: &tauri::AppHandle, name: &str) -> Result Result<()> { updater::get_update_state, updater::request_update, updater::retry_update_check, - settings::get_settings_state, hub_auth::get_hub_account_state, hub_auth::login_hub_account, hub_auth::logout_hub_account, diff --git a/crates/wizard-gui/src/proxy.rs b/crates/wizard-gui/src/proxy.rs index 0184e37..66e5352 100644 --- a/crates/wizard-gui/src/proxy.rs +++ b/crates/wizard-gui/src/proxy.rs @@ -16,7 +16,7 @@ pub(crate) struct Proxy { } #[derive(Clone, Copy)] -enum App { +pub(crate) enum App { ClaudeDesktop, ChatGpt, } @@ -132,21 +132,30 @@ pub(crate) fn raise_open_file_limit() { ); } -/// Called at startup and after a change of either app's proxy setting. Each listener runs while -/// its app has a token and is restarted when that token changes; one failure does not skip the other. +/// Called at startup for both apps; one failure does not skip the other. pub(crate) async fn sync(app: &tauri::AppHandle) -> Result<()> { - let state = app.state::(); - let mut listeners = state.0.lock().await; - let settings = settings::get_state(app)?; - let mut results = Vec::new(); for proxy_app in App::ALL { - let token = proxy_app.token(&settings); - results.push(sync_app(app, listeners.get(proxy_app), proxy_app, token).await); + results.push(sync_one(app, proxy_app).await); } combine(results) } +/// Called after applying the app's proxy setting. Its listener runs while the app has a token and +/// is restarted when that token changes; a stopped or failed listener is started again. +pub(crate) async fn sync_one(app: &tauri::AppHandle, proxy_app: App) -> Result<()> { + let state = app.state::(); + let mut listeners = state.0.lock().await; + let settings = settings::get_state(app)?; + sync_app( + app, + listeners.get(proxy_app), + proxy_app, + proxy_app.token(&settings), + ) + .await +} + async fn sync_app( app: &tauri::AppHandle, slot: &mut Option, diff --git a/crates/wizard-gui/src/settings.rs b/crates/wizard-gui/src/settings.rs index 9b80550..a0e1eb6 100644 --- a/crates/wizard-gui/src/settings.rs +++ b/crates/wizard-gui/src/settings.rs @@ -1,4 +1,4 @@ -use crate::revision_signal::{RevisionSignal, RevisionSnapshot}; +use crate::revision_signal::RevisionSignal; use anyhow::{Context as _, Result, anyhow, bail}; use std::sync::Mutex; use tauri::Manager as _; @@ -17,20 +17,6 @@ pub(crate) async fn initialize_state(app: &tauri::AppHandle) -> Result<()> { Ok(()) } -#[tauri::command] -pub(crate) async fn get_settings_state( - last_revision: Option, - signal: tauri::State<'_, RevisionSignal>, -) -> Result, String> { - let mut snapshot = signal - .wait(last_revision) - .await - .map_err(|error| format!("{error:#}"))?; - // Clear only the IPC copy; the persisted session remains backend-only. - snapshot.state.hub_session.clear(); - Ok(snapshot) -} - /// TODO: maybe update state when file changes pub(crate) fn get_state(app: &tauri::AppHandle) -> Result { let signal = app @@ -57,29 +43,19 @@ pub(crate) async fn update_state( if next == current { return Ok(()); } - let proxy_changed = next.claude_proxy_enabled != current.claude_proxy_enabled - || next.codex_proxy_enabled != current.codex_proxy_enabled; #[cfg(any(target_os = "macos", target_os = "windows"))] let proxy_enabled = next.local_proxy_enabled(); settings::save_to_file(&next)?; signal.notify(next); - if !proxy_changed { - return Ok(()); - } - let proxy_result = tauri::async_runtime::block_on(crate::proxy::sync(&app)); - if let Err(error) = &proxy_result { - log::error!("proxy update failed: {error:#}"); - } - + // Listeners are synced by the caller that changes a proxy setting. #[cfg(any(target_os = "macos", target_os = "windows"))] if proxy_enabled != current.local_proxy_enabled() { crate::tray::set_enabled(&app, proxy_enabled)?; crate::autostart::set_enabled(&app, proxy_enabled)?; } - - proxy_result + Ok(()) }) .await .context("settings save task failed")? diff --git a/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx b/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx index c9da814..2858add 100644 --- a/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx +++ b/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx @@ -3,7 +3,7 @@ import { type AgentChange, type AgentDetection, type AgentId, type AgentStates, import { PreparedPlanDialog } from "./PreparedPlanDialog"; import { BackupDialog } from "./BackupDialog"; import { ClaudeDesktopInstall, useClaudeDesktopInstall } from "./ClaudeDesktopInstall"; -import { type HubTokensState, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens"; +import { type HubTokensState, refreshHubTokens, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens"; import type { HubAccountControl } from "../settings/HubAccount"; import { type Proxy, useProxy } from "./proxy"; import { reportError } from "../logs/log"; @@ -122,7 +122,16 @@ function TokenField({ token, hub, disabled }: { ))} {!hub.signedIn && Войдите в CoreInfra Hub для выбора токена.} - {hub.status === "error" && {hub.error}} + {hub.status === "error" && ( + + {hub.error}{" "} + + + )} ); } diff --git a/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts b/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts index 1c43271..ed9c5d6 100644 --- a/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts +++ b/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts @@ -41,7 +41,6 @@ export type BackupAgent = "codex" | "claude"; export type Backup = { id: number; - backup_version: number; time_created: string; files: Record; }; @@ -138,21 +137,22 @@ export function useAgentState(): DetectionState { let stopped = false; async function observe() { - try { - while (!stopped) { + while (!stopped) { + try { const snapshot = await getSnapshot(); if (stopped) return; debug(`received agent state at revision ${snapshot.revision}`); setState({ status: "ready", data: snapshot }); await invoke("wait_for_update", { lastRevision: snapshot.revision }); + } catch (cause: unknown) { + if (stopped) return; + reportError("failed to observe agent state", cause); + setState({ + status: "error", + message: cause instanceof Error ? cause.message : String(cause), + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 sec } - } catch (cause: unknown) { - if (stopped) return; - reportError("failed to observe agent state", cause); - setState({ - status: "error", - message: cause instanceof Error ? cause.message : String(cause), - }); } } diff --git a/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts b/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts index c33b06c..65611b0 100644 --- a/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts +++ b/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts @@ -12,6 +12,11 @@ export type HubTokensState = | { status: "error"; tokens: HubToken[]; error: string } | { status: "ready"; tokens: HubToken[] }; +// Reloads the list after a failed load; the result arrives through `useHubTokens`. +export function refreshHubTokens(): Promise { + return invoke("refresh_hub_tokens"); +} + export function useHubTokens(): HubTokensState { const [state, setState] = useState({ status: "loading", tokens: [] }); diff --git a/crates/wizard-gui/wizard-ui/src/settings/settings.ts b/crates/wizard-gui/wizard-ui/src/settings/settings.ts deleted file mode 100644 index 77a938f..0000000 --- a/crates/wizard-gui/wizard-ui/src/settings/settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useEffect, useState } from "react"; -import { reportError } from "../logs/log"; - -export type Settings = { claude_proxy_enabled?: string; codex_proxy_enabled?: string }; -type SettingsSnapshot = Settings & { revision: number }; - -export function useSettings(): { settings: Settings | undefined; error: string | undefined } { - const [settings, setSettings] = useState(); - const [error, setError] = useState(); - - useEffect(() => { - let stopped = false; - - async function observe() { - let lastRevision: number | null = null; - while (!stopped) { - try { - const next: SettingsSnapshot = await invoke("get_settings_state", { lastRevision }); - if (stopped) return; - setSettings(next); - setError(undefined); - lastRevision = next.revision; - } catch (cause: unknown) { - if (stopped) return; - const message = "Не удалось получить настройки"; - reportError(message, cause); - setError(`${message}: ${cause instanceof Error ? cause.message : String(cause)}`); - lastRevision = null; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - } - - void observe(); - return () => { - stopped = true; - }; - }, []); - - return { settings, error }; -} diff --git a/xtask/src/dev_tag.rs b/xtask/src/dev_tag.rs index 3e49142..17f9a78 100644 --- a/xtask/src/dev_tag.rs +++ b/xtask/src/dev_tag.rs @@ -57,35 +57,31 @@ pub(crate) fn parse_version(value: &str) -> Result { fn find(repository: &str, stable: &Version) -> Result> { let prefix = format!("v{stable}-dev."); + // This endpoint is not paginated: it ignores `page` and returns all matching refs at once. + let response = gh_api_json(&format!( + "repos/{repository}/git/matching-refs/tags/{prefix}" + ))?; + let refs = response + .as_array() + .context("GitHub matching refs response must be an array")?; let mut tags = Vec::new(); - for page in 1.. { - let endpoint = - format!("repos/{repository}/git/matching-refs/tags/{prefix}?per_page=100&page={page}"); - let response = gh_api_json(&endpoint)?; - let refs = response - .as_array() - .context("GitHub matching refs response must be an array")?; - for reference in refs { - let Some(sequence) = reference["ref"] - .as_str() - .and_then(|value| value.strip_prefix("refs/tags/")) - .and_then(|value| value.strip_prefix(&prefix)) - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - let Some(commit) = reference["object"]["sha"].as_str() else { - continue; - }; - tags.push(DevTag { - version: Version::parse(&format!("{stable}-dev.{sequence}"))?, - sequence, - commit: commit.to_owned(), - }); - } - if refs.len() < 100 { - break; - } + for reference in refs { + let Some(sequence) = reference["ref"] + .as_str() + .and_then(|value| value.strip_prefix("refs/tags/")) + .and_then(|value| value.strip_prefix(&prefix)) + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(commit) = reference["object"]["sha"].as_str() else { + continue; + }; + tags.push(DevTag { + version: Version::parse(&format!("{stable}-dev.{sequence}"))?, + sequence, + commit: commit.to_owned(), + }); } Ok(tags) }