aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-16 23:22:37 +0300
committerGitHub <noreply@github.com>2026-09-16 22:22:37 +0200
commit266d6f6d876ab3eb020b88ef6caee0f91776db37 (patch)
treed5ca72f86c30fac4211209a1d2377511e1107fc2 /src/integration
parent956f23ff6b72d16fe66169730b76bf565d12d449 (diff)
fix: respect opencode tui.json registrations (#4250)
refs #4240 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/actions.rs4
-rw-r--r--src/integration/opencode_config.rs72
-rw-r--r--src/integration/targets.rs14
-rw-r--r--src/integration/tests.rs118
-rw-r--r--src/integration/types.rs3
5 files changed, 186 insertions, 25 deletions
diff --git a/src/integration/actions.rs b/src/integration/actions.rs
index d51682f8..e57fc0c2 100644
--- a/src/integration/actions.rs
+++ b/src/integration/actions.rs
@@ -556,10 +556,10 @@ pub(crate) fn uninstall_target(
result.tui_plugin_path.display()
)
});
- if result.updated_tui_config {
+ for path in result.updated_tui_configs {
messages.push(format!(
"removed herdr opencode plugin entry from {}",
- result.tui_config_path.display()
+ path.display()
));
}
messages
diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs
index b062365b..888efa13 100644
--- a/src/integration/opencode_config.rs
+++ b/src/integration/opencode_config.rs
@@ -10,12 +10,17 @@ use super::config_file::{check_config_target, write_config};
const TUI_CONFIG_NAME: &str = "tui.jsonc";
-pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf {
- config_dir.join(TUI_CONFIG_NAME)
+fn tui_config_paths(config_dir: &Path) -> [PathBuf; 2] {
+ [
+ config_dir.join(TUI_CONFIG_NAME),
+ config_dir.join("tui.json"),
+ ]
}
pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> {
- validate_plugin_config(&tui_config_path(config_dir), "plugin")?;
+ for path in tui_config_paths(config_dir) {
+ validate_plugin_config(&path, "plugin")?;
+ }
validate_plugin_config(&config_dir.join("cli.json"), "plugins")
}
@@ -37,7 +42,13 @@ fn validate_plugin_config(config_path: &Path, key: &str) -> io::Result<()> {
}
pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> {
- add_plugin(tui_config_path(config_dir), "plugin", plugin_spec)
+ for path in tui_config_paths(config_dir) {
+ if plugin_is_configured(&path, "plugin", plugin_spec) {
+ return Ok(path);
+ }
+ }
+ // Keep tui.json absent on fresh installs so OpenCode can migrate its settings.
+ add_plugin(config_dir.join(TUI_CONFIG_NAME), "plugin", plugin_spec)
}
pub(crate) fn add_cli_plugin(
@@ -98,8 +109,21 @@ fn add_plugin(config_path: PathBuf, key: &str, plugin_spec: &str) -> io::Result<
Ok(config_path)
}
-pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
- remove_plugin(&tui_config_path(config_dir), "plugin", plugin_spec)
+pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<Vec<PathBuf>> {
+ let mut updated = Vec::new();
+ let mut errors = Vec::new();
+ for path in tui_config_paths(config_dir) {
+ match remove_plugin(&path, "plugin", plugin_spec) {
+ Ok(true) => updated.push(path),
+ Ok(false) => {}
+ Err(err) => errors.push(err.to_string()),
+ }
+ }
+ if errors.is_empty() {
+ Ok(updated)
+ } else {
+ Err(io::Error::other(errors.join("; ")))
+ }
}
pub(crate) fn remove_cli_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
@@ -143,7 +167,9 @@ fn remove_plugin(config_path: &Path, key: &str, plugin_spec: &str) -> io::Result
}
pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
- plugin_is_configured(&tui_config_path(config_dir), "plugin", plugin_spec)
+ tui_config_paths(config_dir)
+ .iter()
+ .any(|path| plugin_is_configured(path, "plugin", plugin_spec))
}
pub(crate) fn cli_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
@@ -323,7 +349,10 @@ mod tests {
])
);
- assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert_eq!(
+ remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(),
+ vec![config_path.clone()]
+ );
let removed_content = fs::read_to_string(&config_path).unwrap();
assert!(removed_content.contains("// Keep this comment."));
let removed = parse_config(&config_path);
@@ -364,7 +393,10 @@ mod tests {
let dir = unique_dir();
let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
- assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert_eq!(
+ remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(),
+ vec![config_path.clone()]
+ );
assert!(config_path.is_file());
assert_eq!(parse_config(&config_path), json!({}));
@@ -372,6 +404,23 @@ mod tests {
}
#[test]
+ fn configured_tui_plugin_accepts_json_registration() {
+ let dir = unique_dir();
+ fs::write(
+ dir.join("tui.json"),
+ r#"{"plugin":["./herdr-tui-session.js"]}"#,
+ )
+ .unwrap();
+
+ let configured = tui_plugin_is_configured(&dir, "./herdr-tui-session.js");
+ fs::remove_dir_all(dir).unwrap();
+ assert!(
+ configured,
+ "OpenCode loads plugin registrations from tui.json"
+ );
+ }
+
+ #[test]
fn configured_tui_plugin_accepts_option_tuple() {
let dir = unique_dir();
fs::write(
@@ -381,7 +430,10 @@ mod tests {
.unwrap();
assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js"));
- assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert_eq!(
+ remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(),
+ vec![dir.join(TUI_CONFIG_NAME)]
+ );
fs::remove_dir_all(dir).unwrap();
}
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index 6cb90c18..ec605866 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -29,7 +29,7 @@ use super::file_ops::{
make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
};
use super::opencode_config::{
- add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin, tui_config_path,
+ add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin,
validate_tui_plugin_config,
};
use super::types::{
@@ -461,7 +461,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
let dir = opencode_dir()?;
- check_config_targets(&dir, &["tui.jsonc", "cli.json"])?;
+ check_config_targets(&dir, &["tui.jsonc", "tui.json", "cli.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"opencode config directory not found at {}. install opencode first",
@@ -844,8 +844,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
let dir = opencode_dir()?;
- check_config_targets(&dir, &["tui.jsonc", "cli.json"])?;
- let tui_config_path = tui_config_path(&dir);
+ check_config_targets(&dir, &["tui.jsonc", "tui.json", "cli.json"])?;
let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
let mut errors = Vec::new();
@@ -858,10 +857,10 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
errors.push(format!("failed to remove {}: {err}", v2_dir.display()));
false
});
- let updated_tui_config =
+ let updated_tui_configs =
remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| {
errors.push(err.to_string());
- false
+ Vec::new()
});
let removed_plugin = remove_file_if_exists(&plugin_path).unwrap_or_else(|err| {
errors.push(format!("failed to remove {}: {err}", plugin_path.display()));
@@ -881,10 +880,9 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
Ok(OpenCodeUninstallResult {
plugin_path,
tui_plugin_path,
- tui_config_path,
removed_plugin,
removed_tui_plugin,
- updated_tui_config,
+ updated_tui_configs,
})
}
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 1a102992..f7e2154e 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -2326,6 +2326,69 @@ fn install_opencode_writes_server_and_tui_plugins() {
let _ = fs::remove_dir_all(base);
}
+#[cfg(unix)]
+#[test]
+fn opencode_reuses_json_registration_in_symlinked_config_directory() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let dotfiles = base.join("dotfiles");
+ let dir = home.join(".config/opencode");
+ fs::create_dir_all(home.join(".config")).unwrap();
+ fs::create_dir_all(&dotfiles).unwrap();
+ std::os::unix::fs::symlink(&dotfiles, &dir).unwrap();
+ std::env::set_var("HOME", &home);
+ let json_path = dir.join("tui.json");
+ let original = "{\n // User preferences\n \"theme\":\"system\",\n \"plugin\":[\"other\",[\"./herdr-tui-session.js\",{\"enabled\":true}]]\n}\n";
+ fs::write(&json_path, original).unwrap();
+
+ for _ in 0..2 {
+ let installed = install_opencode().unwrap();
+ assert_eq!(installed.tui_config_path, json_path);
+ assert!(installed.cli_config_path.is_none());
+ assert!(!dir.join("tui.jsonc").exists());
+ assert_eq!(fs::read_to_string(&json_path).unwrap(), original);
+ assert_eq!(
+ integration_status_at(
+ crate::api::schema::IntegrationTarget::Opencode,
+ installed.plugin_path,
+ OPENCODE_INTEGRATION_VERSION,
+ )
+ .state,
+ IntegrationStatusKind::Current
+ );
+ }
+
+ // Older installs may have registered the same plugin in both files.
+ let jsonc_path = dir.join("tui.jsonc");
+ fs::write(
+ &jsonc_path,
+ r#"{"plugin":["./herdr-tui-session.js","another"]}"#,
+ )
+ .unwrap();
+ assert_eq!(install_opencode().unwrap().tui_config_path, jsonc_path);
+ let result = uninstall_opencode().unwrap();
+ assert_eq!(
+ result.updated_tui_configs,
+ vec![jsonc_path.clone(), json_path.clone()]
+ );
+ let json = fs::read_to_string(&json_path).unwrap();
+ assert!(json.contains("// User preferences"));
+ assert!(!json.contains("herdr-tui-session.js"));
+ assert!(json.contains("\"other\""));
+ assert!(json.contains("\"system\""));
+ assert_eq!(
+ serde_json::from_str::<Value>(&fs::read_to_string(jsonc_path).unwrap()).unwrap(),
+ json!({"plugin":["another"]})
+ );
+ assert!(uninstall_opencode().unwrap().updated_tui_configs.is_empty());
+ assert_eq!(fs::read_link(&dir).unwrap(), dotfiles);
+ assert!(!result.plugin_path.exists());
+ assert!(!result.tui_plugin_path.exists());
+ std::env::remove_var("HOME");
+ fs::remove_dir_all(base).unwrap();
+}
+
#[test]
fn opencode_install_defers_v2_registration_while_migration_pending() {
let _lock = integration_env_lock();
@@ -2427,6 +2490,42 @@ fn opencode_hard_link_rejection_precedes_install_and_uninstall_asset_changes() {
fs::remove_dir_all(base).unwrap();
}
+#[test]
+fn opencode_json_config_validation_precedes_asset_changes() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let dir = home.join(".config/opencode");
+ fs::create_dir_all(dir.join("plugins")).unwrap();
+ std::env::set_var("HOME", &home);
+ let plugin = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin, "previous integration").unwrap();
+ let config = dir.join("tui.json");
+ fs::write(&config, r#"{"plugin":{}}"#).unwrap();
+ assert!(install_opencode()
+ .unwrap_err()
+ .to_string()
+ .contains("plugin list"));
+ assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration");
+ let original = r#"{"plugin":["./herdr-tui-session.js"]}"#;
+ fs::write(&config, original).unwrap();
+ let alias = base.join("linked-config");
+ fs::hard_link(&config, &alias).unwrap();
+ for error in [
+ install_opencode().unwrap_err(),
+ uninstall_opencode().unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("multiple hard links"));
+ assert!(error.to_string().contains("tui.json"));
+ }
+ assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration");
+ assert_eq!(fs::read_to_string(&alias).unwrap(), original);
+ assert!(!dir.join("tui.jsonc").exists());
+ assert!(!dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists());
+ std::env::remove_var("HOME");
+ fs::remove_dir_all(base).unwrap();
+}
+
#[cfg(windows)]
#[test]
fn opencode_recovery_copy_blocks_retry_before_parsing_or_asset_changes() {
@@ -2539,12 +2638,15 @@ fn uninstall_opencode_removes_plugins_and_managed_tui_config_entry() {
assert!(result.removed_plugin);
assert!(result.removed_tui_plugin);
- assert!(result.updated_tui_config);
+ assert_eq!(
+ result.updated_tui_configs,
+ vec![installed.tui_config_path.clone()]
+ );
assert!(!result.plugin_path.exists());
assert!(!result.tui_plugin_path.exists());
- assert!(result.tui_config_path.exists());
+ assert!(installed.tui_config_path.exists());
let tui_config: Value =
- serde_json::from_str(&fs::read_to_string(&result.tui_config_path).unwrap()).unwrap();
+ serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap();
assert_eq!(tui_config, json!({}));
assert_eq!(installed.plugin_path, result.plugin_path);
@@ -2588,6 +2690,12 @@ fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() {
fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET).unwrap();
fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap();
fs::write(opencode_dir.join("tui.jsonc"), "{\"plugin\":").unwrap();
+ let json_path = opencode_dir.join("tui.json");
+ fs::write(
+ &json_path,
+ r#"{"plugin":["./herdr-tui-session.js","other"]}"#,
+ )
+ .unwrap();
std::env::set_var("HOME", &home);
let err = uninstall_opencode().unwrap_err().to_string();
@@ -2595,6 +2703,10 @@ fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() {
assert!(err.contains("failed to parse OpenCode TUI config"));
assert!(!plugin_path.exists());
assert!(!tui_plugin_path.exists());
+ assert_eq!(
+ serde_json::from_str::<Value>(&fs::read_to_string(json_path).unwrap()).unwrap(),
+ json!({"plugin":["other"]})
+ );
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
diff --git a/src/integration/types.rs b/src/integration/types.rs
index 928675f6..8e42a5da 100644
--- a/src/integration/types.rs
+++ b/src/integration/types.rs
@@ -259,10 +259,9 @@ pub(crate) struct DroidUninstallResult {
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
pub tui_plugin_path: PathBuf,
- pub tui_config_path: PathBuf,
pub removed_plugin: bool,
pub removed_tui_plugin: bool,
- pub updated_tui_config: bool,
+ pub updated_tui_configs: Vec<PathBuf>,
}
#[derive(Debug)]