From a7623b74cf4c2c618226480246cce89ba562de00 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 22 Oct 2026 15:50:32 -0700 Subject: [PATCH 01/15] feat(plugins): add Git install command (NEXUS-3717) Signed-off-by: Daniel Rouleau Co-authored-by: Codex --- crates/plugin-manager/src/cli.rs | 337 +++++++++++++++++++++++++++ crates/plugin-manager/src/imports.rs | 79 +++++++ crates/plugin-manager/src/lib.rs | 1 + crates/plugin-manager/src/main.rs | 276 +--------------------- docs/plugin-architecture.md | 31 ++- src-tauri/src/main.rs | 13 ++ 6 files changed, 456 insertions(+), 281 deletions(-) create mode 100644 crates/plugin-manager/src/cli.rs diff --git a/crates/plugin-manager/src/cli.rs b/crates/plugin-manager/src/cli.rs new file mode 100644 index 0000000000..3530d1bd2c --- /dev/null +++ b/crates/plugin-manager/src/cli.rs @@ -0,0 +1,337 @@ +use crate::{ + imports::prepare_git, release_manifest, sign_release_saved, valid_id, Catalog, Manager, + Manifest, Result, +}; +use serde_json::json; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +const HELP: &str = "buzzodz [--home ABSOLUTE_PATH] [--profile NAME] plugin COMMAND + +Commands: + init DIRECTORY ID NAME Create a page-plugin source project + build DIRECTORY Run the project's pnpm build script + sign DIST_DIRECTORY Sign with your saved Buzz human identity + install DIRECTORY Install a built artifact (new plugins start disabled) + install-git REPO REF [PATH] [--enable] + Install a Git plugin (PATH defaults to .) + reload ID Reload a folder plugin, preserving its enabled state + list List installed pages and their enabled state + enable ID | disable ID | remove ID | rollback ID + recover Back up settings and reset to bundled defaults + +Options: + --home PATH Plugin data directory (absolute path) + --profile NAME Local profile (default: default) + --help, -h Show this help + +The desktop and CLI also read BUZZODZ_HOME and BUZZODZ_PROFILE. +Launch the desktop with BUZZODZ_SAFE_MODE=1 to skip external pages."; + +pub fn run(mut args: Vec) -> Result<()> { + if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { + println!("{HELP}"); + return Ok(()); + } + let mut home = None; + let mut profile = std::env::var("BUZZODZ_PROFILE").unwrap_or_else(|_| "default".into()); + while args.first().is_some_and(|s| s.starts_with("--")) { + let flag = args.remove(0); + if args.is_empty() { + return Err(format!("{flag} requires a value. See buzzodz --help.")); + } + let value = args.remove(0); + match flag.as_str() { + "--home" => home = Some(PathBuf::from(value)), + "--profile" => profile = value, + _ => return Err(format!("Unknown option {flag}. See buzzodz --help.")), + } + } + if args.first().map(String::as_str) != Some("plugin") || args.len() < 2 { + return Err("Expected plugin COMMAND. See buzzodz --help.".into()); + } + let action = &args[1]; + let rest = &args[2..]; + // Source creation/building does not require a local installation profile. + match (action.as_str(), rest) { + ("init", [directory, id, name]) => { + init(Path::new(directory), id, name)?; + println!("Created {id} in {directory}\nNext: add the host-matched @buzz/author preview archive with pnpm add -D /absolute/path/buzz-author-VERSION.tgz, then buzzodz plugin build DIRECTORY."); + return Ok(()); + } + ("build", [directory]) => { + build(Path::new(directory))?; + println!("Built {}", Path::new(directory).join("dist").display()); + return Ok(()); + } + ("sign", [directory]) => { + let publisher = sign_release_saved(Path::new(directory))?; + println!("Signed release in {directory} by {publisher}"); + return Ok(()); + } + ("sign", _) => return Err("Usage: buzzodz plugin sign DIST_DIRECTORY".into()), + _ => {} + } + let manager = Manager::open(home, &profile, false)?; + match (action.as_str(), rest) { + ("install", [directory]) => { + let manifest = release_manifest(Path::new(directory))?; + let catalog = manager.install(Path::new(directory))?; + let installed = catalog + .plugins + .iter() + .find(|p| p.manifest.id == manifest.id) + .ok_or("Installed plugin missing from catalog")?; + println!( + "Installed {} ({}) in profile {profile}. Publisher: {}", + installed.manifest.id, + if installed.enabled { + "enabled" + } else { + "disabled" + }, + installed.publisher.as_deref().unwrap_or("unsigned") + ); + } + ("install-git", [repository, reference, options @ ..]) => { + let (path, enable) = git_options(options)?; + let source = prepare_git(repository, reference, None)?; + let candidate = source + .preview + .candidates + .iter() + .find(|candidate| candidate.path == path) + .ok_or("No plugin at this path. Specify a plugin folder from the repository.")?; + println!( + "Host access: {}", + serde_json::to_string_pretty(&candidate.manifest.host).map_err(|e| e.to_string())? + ); + source.install(&manager, &source.preview.token, path)?; + if enable { + manager.change("enable", &candidate.manifest.id)?; + } + println!( + "Installed {} from {repository} ({reference}) in profile {profile}.", + candidate.manifest.id + ); + } + ("reload", [id]) => { + let before = manager.catalog()?; + let after = manager.reload_live(id)?; + for (label, catalog) in [ + ("Previous host access", &before), + ("Updated host access", &after), + ] { + let grants = catalog + .plugins + .iter() + .find(|p| p.manifest.id == *id) + .and_then(|p| p.manifest.host.as_ref()); + println!( + "{label}: {}", + serde_json::to_string_pretty(&grants).map_err(|e| e.to_string())? + ); + } + println!("Reloaded {id} in profile {profile}. The running app picks up changed builds automatically."); + } + ("reload", _) => return Err("Usage: buzzodz plugin reload ID".into()), + ("list", []) => print_catalog(&manager.catalog()?)?, + ("recover", []) => { + let catalog = manager.recover()?; + println!("Reset profile {profile} to bundled defaults.\nAny previous settings were backed up in {}.\nExternal artifacts remain available for reinstallation.", catalog.location); + } + ("enable" | "disable" | "remove" | "rollback", [id]) => { + manager.change(action, id)?; + let verb = match action.as_str() { + "enable" => "Enabled", + "disable" => "Disabled", + "remove" => "Removed", + _ => "Rolled back", + }; + println!("{verb} {id} in profile {profile}."); + } + _ => { + return Err(format!( + "Unknown command or incorrect arguments: plugin {}. See buzzodz --help.", + args[1..].join(" ") + )) + } + } + Ok(()) +} + +fn git_options(options: &[String]) -> Result<(&str, bool)> { + match options { + [] => Ok((".", false)), + [flag] if flag == "--enable" => Ok((".", true)), + [path] if !path.starts_with('-') => Ok((path, false)), + [path, flag] if !path.starts_with('-') && flag == "--enable" => Ok((path, true)), + _ => Err("Usage: plugin install-git REPO REF [PATH] [--enable]".into()), + } +} + +fn print_catalog(catalog: &Catalog) -> Result<()> { + println!( + "Profile: {}\nDirectory: {}\n", + catalog.profile, catalog.location + ); + for plugin in &catalog.plugins { + println!( + "{:<24} {:<10} {:<10} {}", + plugin.manifest.id, + if plugin.enabled { + "enabled" + } else { + "disabled" + }, + plugin.source, + plugin.manifest.name + ); + if let Some(error) = &plugin.error { + println!(" Problem: {error}"); + } + if plugin.source == "external" { + println!( + " Publisher: {}", + plugin + .publisher + .as_deref() + .unwrap_or(if plugin.error.is_some() { + "unverified" + } else { + "unsigned" + }) + ); + } + } + Ok(()) +} +fn read_manifest(directory: &Path) -> Result { + let manifest: Manifest = serde_json::from_slice( + &std::fs::read(directory.join("manifest.json")).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + manifest.validate()?; + Ok(manifest) +} +fn init(directory: &Path, id: &str, name: &str) -> Result<()> { + valid_id(id)?; + let manifest = Manifest { + id: id.into(), + name: name.into(), + api_version: 1, + description: None, + discovery: None, + host: None, + }; + manifest.validate()?; + // Never overwrite an existing source directory. + std::fs::create_dir(directory).map_err(|e| e.to_string())?; + std::fs::create_dir(directory.join("src")).map_err(|e| e.to_string())?; + let package = json!({"name": id, "private": true, "type": "module", "scripts": {"build": "tsc && vite build"}, "devDependencies": {"vite": "8.2.2", "typescript": "7.0.2", "@types/react": "19.2.18"}, "engines": {"node": ">=24"}}); + for (file, text) in [ + ("manifest.json", serde_json::to_string_pretty(&manifest).unwrap()), + ("package.json", serde_json::to_string_pretty(&package).unwrap()), + (".gitignore", "node_modules/\ndist/\n".into()), + ("tsconfig.json", r#"{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react","strict":true,"noEmit":true},"include":["src"]}"#.into()), + ("src/index.tsx", r#"import type { Context } from '@buzz/author'; + +export const inject = ['react', 'pages']; + +export function apply(ctx: Context) { + const React = ctx.react; + // Plugin resources belong in ctx.effect(() => cleanup). + // React effects belong to the visible page and stop when navigating away. + // primary gives the page a sidebar row; every active page is still listed in search. + ctx.pages.register({ id: "main", title: "My page", primary: true, component: function Page() { + const [count, setCount] = React.useState(0); + return
+

My page

+ +
; + }}); +} +"#.into()), + // A normal, editable Vite config in the author's project, not injected code. + ("vite.config.ts", r#"import { defineConfig } from 'vite'; +import manifest from './manifest.json'; + +export default defineConfig({ + publicDir: false, + oxc: { jsx: { runtime: 'classic' } }, + build: { + lib: { entry: 'src/index.tsx', formats: ['es'], fileName: () => 'plugin.js' }, + rolldownOptions: { output: { codeSplitting: false } }, + }, + plugins: [{ + name: 'page-contract', + enforce: 'pre', + resolveId(id) { + if (/^react(?:-dom)?(?:\/|$)/.test(id) || id === '@buzz/author' || id === '@deepseek-ai/cordis' || id.startsWith('@deepseek-ai/cordis/')) { + throw new Error('Host capabilities are supplied by Buzz; use type-only imports'); + } + }, + generateBundle(_options, bundle) { + const outputs = Object.values(bundle); + const page = outputs[0]; + if (outputs.length !== 1 || page?.type !== 'chunk' || page.imports.length) { + throw new Error('Page API v1 requires one self-contained JS module, without separate assets'); + } + if (!page.exports.includes('apply')) throw new Error('Export apply(ctx)'); + this.emitFile({ type: 'asset', fileName: 'manifest.json', source: JSON.stringify(manifest, null, 2) }); + }, + }], +}); +"#.into()), + ] { std::fs::write(directory.join(file), text).map_err(|e| e.to_string())?; } + Ok(()) +} +fn build(directory: &Path) -> Result<()> { + read_manifest(directory)?; + let status = Command::new(if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }) + .args(["run", "build"]) + .current_dir(directory) + .status() + .map_err(|e| { + format!("Could not start pnpm: {e}. Install Node and pnpm to build plugins.") + })?; + if !status.success() { + return Err("Plugin build failed. See the build output above.".into()); + } + // A successful script must actually produce the artifact that install consumes. + read_manifest(&directory.join("dist"))?; + if !directory.join("dist/plugin.js").is_file() { + return Err("Build did not produce dist/plugin.js".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_install_options_select_the_folder_and_enable_only_when_requested() { + for (args, expected) in [ + (vec![], (".", false)), + (vec!["--enable"], (".", true)), + (vec!["plugins/example"], ("plugins/example", false)), + ( + vec!["plugins/example", "--enable"], + ("plugins/example", true), + ), + ] { + let args = args.into_iter().map(String::from).collect::>(); + assert_eq!(git_options(&args).unwrap(), expected); + } + for args in [ + vec!["--unknown"], + vec![".", "--unknown"], + vec!["--enable", "."], + ] { + assert!(git_options(&args.into_iter().map(String::from).collect::>()).is_err()); + } + } +} diff --git a/crates/plugin-manager/src/imports.rs b/crates/plugin-manager/src/imports.rs index 77048bb2e6..ad9190a5ab 100644 --- a/crates/plugin-manager/src/imports.rs +++ b/crates/plugin-manager/src/imports.rs @@ -590,6 +590,7 @@ struct Git { deadline: Instant, /// Repository URL and its NIP-98 token. Git sends the header only to that URL prefix. authorization: Option<(String, String)>, + ssh_agent: Option, } impl Git { fn new() -> Result { @@ -597,6 +598,7 @@ impl Git { scratch: tempfile::tempdir().map_err(err)?, deadline: Instant::now() + Duration::from_secs(60), authorization: None, + ssh_agent: None, }) } fn command(&self) -> Command { @@ -609,6 +611,9 @@ impl Git { command.env(key, value); } } + if let Some(agent) = &self.ssh_agent { + command.env("SSH_AUTH_SOCK", agent); + } let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; command .env("GIT_CONFIG_NOSYSTEM", "1") @@ -657,6 +662,27 @@ impl Git { command.args(args); self.run_command(command, limit) } + fn configure_ssh_agent(&mut self, repository: &str) -> Result<()> { + let url = url::Url::parse(repository).map_err(err)?; + if url.scheme() != "ssh" { + return Ok(()); + } + let mut command = Command::new("ssh"); + command.arg("-G"); + if !url.username().is_empty() { + command.args(["-l", url.username()]); + } + command.args([ + "-p", + &url.port().unwrap_or(22).to_string(), + "--", + url.host_str().ok_or("Missing SSH host")?, + ]); + let output = self.run_command(command, 64 * 1024)?; + let config = std::str::from_utf8(&output).map_err(err)?; + self.ssh_agent = configured_agent(config, |key| std::env::var_os(key))?; + Ok(()) + } fn run_command(&self, mut command: Command, limit: u64) -> Result> { let output = tempfile::tempfile().map_err(err)?; let errors = tempfile::tempfile().map_err(err)?; @@ -865,6 +891,23 @@ impl Git { } } +fn configured_agent( + config: &str, + get_env: impl Fn(&str) -> Option, +) -> Result> { + match config + .lines() + .find_map(|line| line.strip_prefix("identityagent ")) + { + None | Some("SSH_AUTH_SOCK") => Ok(None), + Some("none") => Ok(Some("".into())), + Some(variable) if variable.starts_with('$') => get_env(&variable[1..]) + .map(Some) + .ok_or_else(|| "The SSH IdentityAgent environment variable is not set.".into()), + Some(path) => Ok(Some(path.into())), + } +} + /// `authorization` is a NIP-98 token that the caller signed for exactly this repository URL. pub fn prepare_git( repository: &str, @@ -894,6 +937,7 @@ pub fn prepare_git( } git.authorization = Some((url.clone(), token.into())); } + git.configure_ssh_agent(&url)?; let mut args = vec![ "clone", "--depth=1", @@ -956,6 +1000,41 @@ fn directory_size(root: &Path) -> Result { #[cfg(test)] mod tests { + #[test] + fn configured_agent_selects_only_the_agent_socket() { + use super::configured_agent; + use std::ffi::OsString; + + let no_env = |_: &str| None; + assert_eq!( + configured_agent( + "hostname other\nidentityagent /tmp/agent.sock\nproxycommand ignored", + no_env + ) + .unwrap(), + Some(OsString::from("/tmp/agent.sock")) + ); + assert_eq!( + configured_agent("identityagent none", no_env).unwrap(), + Some(OsString::new()) + ); + assert_eq!( + configured_agent("identityagent SSH_AUTH_SOCK", no_env).unwrap(), + None + ); + assert_eq!( + configured_agent("hostname github.com", no_env).unwrap(), + None + ); + assert_eq!( + configured_agent("identityagent $BLOCK_AGENT", |key| (key == "BLOCK_AGENT") + .then(|| OsString::from("/tmp/block.sock"))) + .unwrap(), + Some(OsString::from("/tmp/block.sock")) + ); + assert!(configured_agent("identityagent $MISSING", no_env).is_err()); + } + use super::*; fn uploaded(files: &[(&str, &str)]) -> FolderUpload { FolderUpload { diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index f5e8c4cda1..266677bddd 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -13,6 +13,7 @@ use std::{ path::{Path, PathBuf}, }; +pub mod cli; pub mod imports; mod migrate_agent_flag; diff --git a/crates/plugin-manager/src/main.rs b/crates/plugin-manager/src/main.rs index d5388c96c0..584c7d90d4 100644 --- a/crates/plugin-manager/src/main.rs +++ b/crates/plugin-manager/src/main.rs @@ -1,280 +1,6 @@ -use buzzodz_plugins::{ - release_manifest, sign_release_saved, valid_id, Catalog, Manager, Manifest, Result, -}; -use serde_json::json; -use std::{ - path::{Path, PathBuf}, - process::Command, -}; - -const HELP: &str = "buzzodz [--home ABSOLUTE_PATH] [--profile NAME] plugin COMMAND - -Commands: - init DIRECTORY ID NAME Create a page-plugin source project - build DIRECTORY Run the project's pnpm build script - sign DIST_DIRECTORY Sign with your saved Buzz human identity - install DIRECTORY Install a built artifact (new plugins start disabled) - reload ID Reload a folder plugin, preserving its enabled state - list List installed pages and their enabled state - enable ID | disable ID | remove ID | rollback ID - recover Back up settings and reset to bundled defaults - -Options: - --home PATH Plugin data directory (absolute path) - --profile NAME Local profile (default: default) - --help, -h Show this help - -The desktop and CLI also read BUZZODZ_HOME and BUZZODZ_PROFILE. -Launch the desktop with BUZZODZ_SAFE_MODE=1 to skip external pages."; - fn main() { - if let Err(message) = run() { + if let Err(message) = buzzodz_plugins::cli::run(std::env::args().skip(1).collect()) { eprintln!("Error: {message}"); std::process::exit(1); } } -fn run() -> Result<()> { - let mut args = std::env::args().skip(1).collect::>(); - if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { - println!("{HELP}"); - return Ok(()); - } - let mut home = None; - let mut profile = std::env::var("BUZZODZ_PROFILE").unwrap_or_else(|_| "default".into()); - while args.first().is_some_and(|s| s.starts_with("--")) { - let flag = args.remove(0); - if args.is_empty() { - return Err(format!("{flag} requires a value. See buzzodz --help.")); - } - let value = args.remove(0); - match flag.as_str() { - "--home" => home = Some(PathBuf::from(value)), - "--profile" => profile = value, - _ => return Err(format!("Unknown option {flag}. See buzzodz --help.")), - } - } - if args.first().map(String::as_str) != Some("plugin") || args.len() < 2 { - return Err("Expected plugin COMMAND. See buzzodz --help.".into()); - } - let action = &args[1]; - let rest = &args[2..]; - // Source creation/building does not require a local installation profile. - match (action.as_str(), rest) { - ("init", [directory, id, name]) => { - init(Path::new(directory), id, name)?; - println!("Created {id} in {directory}\nNext: add the host-matched @buzz/author preview archive with pnpm add -D /absolute/path/buzz-author-VERSION.tgz, then buzzodz plugin build DIRECTORY."); - return Ok(()); - } - ("build", [directory]) => { - build(Path::new(directory))?; - println!("Built {}", Path::new(directory).join("dist").display()); - return Ok(()); - } - ("sign", [directory]) => { - let publisher = sign_release_saved(Path::new(directory))?; - println!("Signed release in {directory} by {publisher}"); - return Ok(()); - } - ("sign", _) => return Err("Usage: buzzodz plugin sign DIST_DIRECTORY".into()), - _ => {} - } - let manager = Manager::open(home, &profile, false)?; - match (action.as_str(), rest) { - ("install", [directory]) => { - let manifest = release_manifest(Path::new(directory))?; - let catalog = manager.install(Path::new(directory))?; - let installed = catalog - .plugins - .iter() - .find(|p| p.manifest.id == manifest.id) - .ok_or("Installed plugin missing from catalog")?; - println!( - "Installed {} ({}) in profile {profile}. Publisher: {}", - installed.manifest.id, - if installed.enabled { - "enabled" - } else { - "disabled" - }, - installed.publisher.as_deref().unwrap_or("unsigned") - ); - } - ("reload", [id]) => { - let before = manager.catalog()?; - let after = manager.reload_live(id)?; - for (label, catalog) in [ - ("Previous host access", &before), - ("Updated host access", &after), - ] { - let grants = catalog - .plugins - .iter() - .find(|p| p.manifest.id == *id) - .and_then(|p| p.manifest.host.as_ref()); - println!( - "{label}: {}", - serde_json::to_string_pretty(&grants).map_err(|e| e.to_string())? - ); - } - println!("Reloaded {id} in profile {profile}. The running app picks up changed builds automatically."); - } - ("reload", _) => return Err("Usage: buzzodz plugin reload ID".into()), - ("list", []) => print_catalog(&manager.catalog()?)?, - ("recover", []) => { - let catalog = manager.recover()?; - println!("Reset profile {profile} to bundled defaults.\nAny previous settings were backed up in {}.\nExternal artifacts remain available for reinstallation.", catalog.location); - } - ("enable" | "disable" | "remove" | "rollback", [id]) => { - manager.change(action, id)?; - let verb = match action.as_str() { - "enable" => "Enabled", - "disable" => "Disabled", - "remove" => "Removed", - _ => "Rolled back", - }; - println!("{verb} {id} in profile {profile}."); - } - _ => { - return Err(format!( - "Unknown command or incorrect arguments: plugin {}. See buzzodz --help.", - args[1..].join(" ") - )) - } - } - Ok(()) -} -fn print_catalog(catalog: &Catalog) -> Result<()> { - println!( - "Profile: {}\nDirectory: {}\n", - catalog.profile, catalog.location - ); - for plugin in &catalog.plugins { - println!( - "{:<24} {:<10} {:<10} {}", - plugin.manifest.id, - if plugin.enabled { - "enabled" - } else { - "disabled" - }, - plugin.source, - plugin.manifest.name - ); - if let Some(error) = &plugin.error { - println!(" Problem: {error}"); - } - if plugin.source == "external" { - println!( - " Publisher: {}", - plugin - .publisher - .as_deref() - .unwrap_or(if plugin.error.is_some() { - "unverified" - } else { - "unsigned" - }) - ); - } - } - Ok(()) -} -fn read_manifest(directory: &Path) -> Result { - let manifest: Manifest = serde_json::from_slice( - &std::fs::read(directory.join("manifest.json")).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - manifest.validate()?; - Ok(manifest) -} -fn init(directory: &Path, id: &str, name: &str) -> Result<()> { - valid_id(id)?; - let manifest = Manifest { - id: id.into(), - name: name.into(), - api_version: 1, - description: None, - discovery: None, - host: None, - }; - manifest.validate()?; - // Never overwrite an existing source directory. - std::fs::create_dir(directory).map_err(|e| e.to_string())?; - std::fs::create_dir(directory.join("src")).map_err(|e| e.to_string())?; - let package = json!({"name": id, "private": true, "type": "module", "scripts": {"build": "tsc && vite build"}, "devDependencies": {"vite": "8.2.2", "typescript": "7.0.2", "@types/react": "19.2.18"}, "engines": {"node": ">=24"}}); - for (file, text) in [ - ("manifest.json", serde_json::to_string_pretty(&manifest).unwrap()), - ("package.json", serde_json::to_string_pretty(&package).unwrap()), - (".gitignore", "node_modules/\ndist/\n".into()), - ("tsconfig.json", r#"{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react","strict":true,"noEmit":true},"include":["src"]}"#.into()), - ("src/index.tsx", r#"import type { Context } from '@buzz/author'; - -export const inject = ['react', 'pages']; - -export function apply(ctx: Context) { - const React = ctx.react; - // Plugin resources belong in ctx.effect(() => cleanup). - // React effects belong to the visible page and stop when navigating away. - // primary gives the page a sidebar row; every active page is still listed in search. - ctx.pages.register({ id: "main", title: "My page", primary: true, component: function Page() { - const [count, setCount] = React.useState(0); - return
-

My page

- -
; - }}); -} -"#.into()), - // A normal, editable Vite config in the author's project, not injected code. - ("vite.config.ts", r#"import { defineConfig } from 'vite'; -import manifest from './manifest.json'; - -export default defineConfig({ - publicDir: false, - oxc: { jsx: { runtime: 'classic' } }, - build: { - lib: { entry: 'src/index.tsx', formats: ['es'], fileName: () => 'plugin.js' }, - rolldownOptions: { output: { codeSplitting: false } }, - }, - plugins: [{ - name: 'page-contract', - enforce: 'pre', - resolveId(id) { - if (/^react(?:-dom)?(?:\/|$)/.test(id) || id === '@buzz/author' || id === '@deepseek-ai/cordis' || id.startsWith('@deepseek-ai/cordis/')) { - throw new Error('Host capabilities are supplied by Buzz; use type-only imports'); - } - }, - generateBundle(_options, bundle) { - const outputs = Object.values(bundle); - const page = outputs[0]; - if (outputs.length !== 1 || page?.type !== 'chunk' || page.imports.length) { - throw new Error('Page API v1 requires one self-contained JS module, without separate assets'); - } - if (!page.exports.includes('apply')) throw new Error('Export apply(ctx)'); - this.emitFile({ type: 'asset', fileName: 'manifest.json', source: JSON.stringify(manifest, null, 2) }); - }, - }], -}); -"#.into()), - ] { std::fs::write(directory.join(file), text).map_err(|e| e.to_string())?; } - Ok(()) -} -fn build(directory: &Path) -> Result<()> { - read_manifest(directory)?; - let status = Command::new(if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }) - .args(["run", "build"]) - .current_dir(directory) - .status() - .map_err(|e| { - format!("Could not start pnpm: {e}. Install Node and pnpm to build plugins.") - })?; - if !status.success() { - return Err("Plugin build failed. See the build output above.".into()); - } - // A successful script must actually produce the artifact that install consumes. - read_manifest(&directory.join("dist"))?; - if !directory.join("dist/plugin.js").is_file() { - return Err("Build did not produce dist/plugin.js".into()); - } - Ok(()) -} diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 98778ff166..227e3ac547 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -100,6 +100,22 @@ source imports are not a versioned external SDK. See ## The `buzzodz` CLI +The installed desktop executable also accepts these commands. On macOS, install +a published Git plugin without a compiler or a separate tool: + +```sh +"/Applications/Buzz Foundation.app/Contents/MacOS/buzz-foundation" plugin install-git org-49461806@github.com:squareup/buzz-nexus.git plugin-release --enable +``` + +`install-git REPO REF [PATH] [--enable]` uses the same importer as Settings. +`PATH` defaults to the repository root. It saves the Git source for later updates +in Settings and retains the previous revision for rollback. Stop active plugin +workers first. The command prints declared host access. `--enable` accepts that +access and enables the selected plugin. Without it, new installs start disabled +and existing enabled states stay the same. A retry with `--enable` also finishes +enablement after a partial installation failure. The command does not start or +restart the desktop UI, read an identity, or connect to a community relay. + Run `pnpm buzzodz --help` from this repository, or install the standalone executable: ```sh @@ -1082,10 +1098,10 @@ requires deleting the plugin and adding it again. Git checks remain explicit in pane, using the remembered repository and branch/tag with fresh authorization; credentials/tokens are never saved with source metadata. SSH accepts explicit URLs and `user@host:owner/repository.git` shorthand, including organization-specific -usernames. The importer uses the desktop process's inherited `SSH_AUTH_SOCK` and -known hosts, with interactive prompts and SSH config disabled. If normal Git uses -an `IdentityAgent` from SSH config, launch the app with that socket in -`SSH_AUTH_SOCK`; otherwise terminal Git can authenticate while the app cannot. Each check reads metadata from its own temporary clone; only Update retains an +usernames. Buzz reads `IdentityAgent` through `ssh -G` and passes that socket to +the transport. Without that setting, it uses the inherited `SSH_AUTH_SOCK`. +The transport uses existing known hosts and disables interactive prompts. +Each check reads metadata from its own temporary clone; only Update retains an immutable installation preview. The source follows the current revision through update and rollback. Rollback requires confirmation in Settings and consumes the previous revision: it cannot toggle forward again. The @@ -1116,8 +1132,11 @@ Discovery never evaluates modules. Git is a required local tool: a shallow no-checkout clone reads committed blobs, without hooks, filters, submodules, LFS downloads or project scripts. Only HTTPS/SSH are allowed; inherited Git config, URL rewrites, credential helpers and interactive prompts are disabled. -SSH uses the existing agent/known hosts, not user SSH config; password entry, HTTPS -private-repo sign-in and custom SSH-config aliases are not supported in this slice. +For SSH, Buzz runs `ssh -G` to read the configured `IdentityAgent` socket. OpenSSH +evaluates the user's local SSH configuration for that lookup, including `Match exec`. +Only the socket value reaches the Git transport. The transport disables SSH config +and uses existing known hosts. Password entry, HTTPS private-repo sign-in, and +custom SSH-config aliases are not supported in this slice. Repository URLs/refs are passed as arguments, not a shell command. Private/internal HTTPS hosts are not prohibited; this is not a public-network-only policy. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index caba166905..9288699155 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,18 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.first().is_some_and(|arg| { + matches!( + arg.as_str(), + "plugin" | "--home" | "--profile" | "--help" | "-h" + ) + }) { + if let Err(message) = buzzodz_plugins::cli::run(args) { + eprintln!("Error: {message}"); + std::process::exit(1); + } + return; + } buzz_foundation_lib::run(); } From 4acb8050d9aa5575ca839720c8915a573eab9937 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 22 Oct 2026 16:01:25 -0700 Subject: [PATCH 02/15] fix(plugins): enable the imported revision atomically Signed-off-by: Daniel Rouleau Co-authored-by: Codex --- crates/plugin-manager/src/cli.rs | 5 ++- crates/plugin-manager/src/imports.rs | 64 +++++++++++++++++++++++++++- crates/plugin-manager/src/lib.rs | 4 +- docs/plugin-architecture.md | 4 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 6 +++ 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/plugin-manager/src/cli.rs b/crates/plugin-manager/src/cli.rs index 3530d1bd2c..842ac3aea5 100644 --- a/crates/plugin-manager/src/cli.rs +++ b/crates/plugin-manager/src/cli.rs @@ -108,9 +108,10 @@ pub fn run(mut args: Vec) -> Result<()> { "Host access: {}", serde_json::to_string_pretty(&candidate.manifest.host).map_err(|e| e.to_string())? ); - source.install(&manager, &source.preview.token, path)?; if enable { - manager.change("enable", &candidate.manifest.id)?; + source.install_enabled(&manager, &source.preview.token, path)?; + } else { + source.install(&manager, &source.preview.token, path)?; } println!( "Installed {} from {repository} ({reference}) in profile {profile}.", diff --git a/crates/plugin-manager/src/imports.rs b/crates/plugin-manager/src/imports.rs index ad9190a5ab..11d913c149 100644 --- a/crates/plugin-manager/src/imports.rs +++ b/crates/plugin-manager/src/imports.rs @@ -139,6 +139,18 @@ impl PreparedImport { Ok(self) } pub fn install(&self, manager: &Manager, token: &str, path: &str) -> Result { + self.install_with_activation(manager, token, path, false) + } + pub fn install_enabled(&self, manager: &Manager, token: &str, path: &str) -> Result { + self.install_with_activation(manager, token, path, true) + } + fn install_with_activation( + &self, + manager: &Manager, + token: &str, + path: &str, + enable: bool, + ) -> Result { if token != self.preview.token { return Err("This import preview expired. Choose the source again.".into()); } @@ -157,7 +169,7 @@ impl PreparedImport { InstallSource::Git(source) }) }); - manager.install_artifact(release, source) + manager.install_artifact(release, source, enable) } } @@ -1033,9 +1045,59 @@ mod tests { Some(OsString::from("/tmp/block.sock")) ); assert!(configured_agent("identityagent $MISSING", no_env).is_err()); + assert_eq!( + configured_agent("identityagent /tmp/agent%u.sock", no_env).unwrap(), + Some(OsString::from("/tmp/agent%u.sock")) + ); } use super::*; + #[test] + fn install_enabled_saves_the_selected_revision_and_activation_together() { + let home = tempfile::tempdir().unwrap(); + let manager = Manager::open(Some(home.path().into()), "test", false).unwrap(); + let manifest = r#"{"id":"example","name":"Example","apiVersion":1}"#; + let first = uploaded(&[ + ("manifest.json", manifest), + ("plugin.js", "export default {old:true};"), + ]); + let first = prepare_upload(first).unwrap(); + let before = first.install(&manager, &first.preview.token, ".").unwrap(); + let previous = before + .plugins + .iter() + .find(|p| p.manifest.id == "example") + .unwrap() + .revision + .clone(); + let selected = uploaded(&[ + ("manifest.json", manifest), + ("plugin.js", "export default {selected:true};"), + ]); + let selected = prepare_upload(selected).unwrap(); + let catalog = selected + .install_enabled(&manager, &selected.preview.token, ".") + .unwrap(); + let plugin = catalog + .plugins + .iter() + .find(|p| p.manifest.id == "example") + .unwrap(); + assert_eq!(plugin.revision, selected.preview.candidates[0].revision); + assert_eq!(plugin.previous.as_ref(), Some(&previous)); + assert!(plugin.enabled); + assert!( + manager + .catalog() + .unwrap() + .plugins + .iter() + .find(|p| p.manifest.id == "example") + .unwrap() + .enabled + ); + } + fn uploaded(files: &[(&str, &str)]) -> FolderUpload { FolderUpload { source: "example".into(), diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 266677bddd..a23679107d 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -784,12 +784,14 @@ impl Manager { self.install_artifact( &prepare_artifact(directory)?, Some(InstallSource::Folder(source)), + false, ) } fn install_artifact( &self, release: &Release, source: Option, + enable: bool, ) -> Result { let bytes = &release.bytes; let artifact = release.artifact()?; @@ -817,7 +819,7 @@ impl Manager { (Some(p.current.clone()), p.current_source.clone()) } }); - let enabled = old.is_some_and(|p| p.enabled); + let enabled = enable || old.is_some_and(|p| p.enabled); let previous_signature = old.and_then(|p| { if p.current == revision { p.previous_signature.clone() diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 227e3ac547..9cdd22ac9d 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -112,8 +112,8 @@ a published Git plugin without a compiler or a separate tool: in Settings and retains the previous revision for rollback. Stop active plugin workers first. The command prints declared host access. `--enable` accepts that access and enables the selected plugin. Without it, new installs start disabled -and existing enabled states stay the same. A retry with `--enable` also finishes -enablement after a partial installation failure. The command does not start or +and existing enabled states stay the same. Install and enable commit together. +The command does not start or restart the desktop UI, read an identity, or connect to a community relay. Run `pnpm buzzodz --help` from this repository, or install the standalone executable: diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 81391ff18a..566d49b0e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -100,7 +100,7 @@ objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std webview2-com = "=0.39.1" windows-core = "=0.62.2" tauri-winrt-notification = "=0.8.1" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } [target.'cfg(target_os = "linux")'.dependencies] # Deep-link 2.6.2 requires rust-ini's two-field ParseOption from 0.21.1. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9288699155..3f7504fa4f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,12 @@ fn main() { "plugin" | "--home" | "--profile" | "--help" | "-h" ) }) { + #[cfg(all(windows, not(debug_assertions)))] + unsafe { + windows_sys::Win32::System::Console::AttachConsole( + windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS, + ); + } if let Err(message) = buzzodz_plugins::cli::run(args) { eprintln!("Error: {message}"); std::process::exit(1); From d5cf4153ab27369ba47df4043229174bc50cf911 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 16:38:02 -0700 Subject: [PATCH 03/15] docs: use the shipped Buzz bundle path Signed-off-by: Daniel Rouleau Co-authored-by: Codex --- docs/plugin-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 9cdd22ac9d..e0cdaaf0b7 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -104,7 +104,7 @@ The installed desktop executable also accepts these commands. On macOS, install a published Git plugin without a compiler or a separate tool: ```sh -"/Applications/Buzz Foundation.app/Contents/MacOS/buzz-foundation" plugin install-git org-49461806@github.com:squareup/buzz-nexus.git plugin-release --enable +"/Applications/Buzz.app/Contents/MacOS/buzz-foundation" plugin install-git org-49461806@github.com:squareup/buzz-nexus.git plugin-release --enable ``` `install-git REPO REF [PATH] [--enable]` uses the same importer as Settings. From 6554fb0d7b08e2de7ec927c161faf31ef91036f9 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 16:56:30 -0700 Subject: [PATCH 04/15] refactor(plugins): share only the Git install handler Signed-off-by: Daniel Rouleau Co-authored-by: Codex --- crates/plugin-manager/src/cli.rs | 321 +++--------------------------- crates/plugin-manager/src/main.rs | 279 +++++++++++++++++++++++++- docs/plugin-architecture.md | 6 +- src-tauri/src/main.rs | 14 +- 4 files changed, 316 insertions(+), 304 deletions(-) diff --git a/crates/plugin-manager/src/cli.rs b/crates/plugin-manager/src/cli.rs index 842ac3aea5..a72f4ac44d 100644 --- a/crates/plugin-manager/src/cli.rs +++ b/crates/plugin-manager/src/cli.rs @@ -1,165 +1,36 @@ -use crate::{ - imports::prepare_git, release_manifest, sign_release_saved, valid_id, Catalog, Manager, - Manifest, Result, -}; -use serde_json::json; -use std::{ - path::{Path, PathBuf}, - process::Command, -}; +use crate::{imports::prepare_git, Manager, Result}; -const HELP: &str = "buzzodz [--home ABSOLUTE_PATH] [--profile NAME] plugin COMMAND +const USAGE: &str = "Usage: plugin install-git REPO REF [PATH] [--enable]"; -Commands: - init DIRECTORY ID NAME Create a page-plugin source project - build DIRECTORY Run the project's pnpm build script - sign DIST_DIRECTORY Sign with your saved Buzz human identity - install DIRECTORY Install a built artifact (new plugins start disabled) - install-git REPO REF [PATH] [--enable] - Install a Git plugin (PATH defaults to .) - reload ID Reload a folder plugin, preserving its enabled state - list List installed pages and their enabled state - enable ID | disable ID | remove ID | rollback ID - recover Back up settings and reset to bundled defaults - -Options: - --home PATH Plugin data directory (absolute path) - --profile NAME Local profile (default: default) - --help, -h Show this help - -The desktop and CLI also read BUZZODZ_HOME and BUZZODZ_PROFILE. -Launch the desktop with BUZZODZ_SAFE_MODE=1 to skip external pages."; - -pub fn run(mut args: Vec) -> Result<()> { - if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { - println!("{HELP}"); +pub fn install_git(manager: &Manager, args: &[String]) -> Result<()> { + if matches!(args, [flag] if flag == "--help" || flag == "-h") { + println!("{USAGE}"); return Ok(()); } - let mut home = None; - let mut profile = std::env::var("BUZZODZ_PROFILE").unwrap_or_else(|_| "default".into()); - while args.first().is_some_and(|s| s.starts_with("--")) { - let flag = args.remove(0); - if args.is_empty() { - return Err(format!("{flag} requires a value. See buzzodz --help.")); - } - let value = args.remove(0); - match flag.as_str() { - "--home" => home = Some(PathBuf::from(value)), - "--profile" => profile = value, - _ => return Err(format!("Unknown option {flag}. See buzzodz --help.")), - } - } - if args.first().map(String::as_str) != Some("plugin") || args.len() < 2 { - return Err("Expected plugin COMMAND. See buzzodz --help.".into()); - } - let action = &args[1]; - let rest = &args[2..]; - // Source creation/building does not require a local installation profile. - match (action.as_str(), rest) { - ("init", [directory, id, name]) => { - init(Path::new(directory), id, name)?; - println!("Created {id} in {directory}\nNext: add the host-matched @buzz/author preview archive with pnpm add -D /absolute/path/buzz-author-VERSION.tgz, then buzzodz plugin build DIRECTORY."); - return Ok(()); - } - ("build", [directory]) => { - build(Path::new(directory))?; - println!("Built {}", Path::new(directory).join("dist").display()); - return Ok(()); - } - ("sign", [directory]) => { - let publisher = sign_release_saved(Path::new(directory))?; - println!("Signed release in {directory} by {publisher}"); - return Ok(()); - } - ("sign", _) => return Err("Usage: buzzodz plugin sign DIST_DIRECTORY".into()), - _ => {} - } - let manager = Manager::open(home, &profile, false)?; - match (action.as_str(), rest) { - ("install", [directory]) => { - let manifest = release_manifest(Path::new(directory))?; - let catalog = manager.install(Path::new(directory))?; - let installed = catalog - .plugins - .iter() - .find(|p| p.manifest.id == manifest.id) - .ok_or("Installed plugin missing from catalog")?; - println!( - "Installed {} ({}) in profile {profile}. Publisher: {}", - installed.manifest.id, - if installed.enabled { - "enabled" - } else { - "disabled" - }, - installed.publisher.as_deref().unwrap_or("unsigned") - ); - } - ("install-git", [repository, reference, options @ ..]) => { - let (path, enable) = git_options(options)?; - let source = prepare_git(repository, reference, None)?; - let candidate = source - .preview - .candidates - .iter() - .find(|candidate| candidate.path == path) - .ok_or("No plugin at this path. Specify a plugin folder from the repository.")?; - println!( - "Host access: {}", - serde_json::to_string_pretty(&candidate.manifest.host).map_err(|e| e.to_string())? - ); - if enable { - source.install_enabled(&manager, &source.preview.token, path)?; - } else { - source.install(&manager, &source.preview.token, path)?; - } - println!( - "Installed {} from {repository} ({reference}) in profile {profile}.", - candidate.manifest.id - ); - } - ("reload", [id]) => { - let before = manager.catalog()?; - let after = manager.reload_live(id)?; - for (label, catalog) in [ - ("Previous host access", &before), - ("Updated host access", &after), - ] { - let grants = catalog - .plugins - .iter() - .find(|p| p.manifest.id == *id) - .and_then(|p| p.manifest.host.as_ref()); - println!( - "{label}: {}", - serde_json::to_string_pretty(&grants).map_err(|e| e.to_string())? - ); - } - println!("Reloaded {id} in profile {profile}. The running app picks up changed builds automatically."); - } - ("reload", _) => return Err("Usage: buzzodz plugin reload ID".into()), - ("list", []) => print_catalog(&manager.catalog()?)?, - ("recover", []) => { - let catalog = manager.recover()?; - println!("Reset profile {profile} to bundled defaults.\nAny previous settings were backed up in {}.\nExternal artifacts remain available for reinstallation.", catalog.location); - } - ("enable" | "disable" | "remove" | "rollback", [id]) => { - manager.change(action, id)?; - let verb = match action.as_str() { - "enable" => "Enabled", - "disable" => "Disabled", - "remove" => "Removed", - _ => "Rolled back", - }; - println!("{verb} {id} in profile {profile}."); - } - _ => { - return Err(format!( - "Unknown command or incorrect arguments: plugin {}. See buzzodz --help.", - args[1..].join(" ") - )) - } + let [repository, reference, options @ ..] = args else { + return Err(USAGE.into()); + }; + let (path, enable) = git_options(options)?; + let source = prepare_git(repository, reference, None)?; + let candidate = source + .preview + .candidates + .iter() + .find(|candidate| candidate.path == path) + .ok_or("No plugin at this path. Specify a plugin folder from the repository.")?; + println!( + "Host access: {}", + serde_json::to_string_pretty(&candidate.manifest.host).map_err(|e| e.to_string())? + ); + if enable { + source.install_enabled(manager, &source.preview.token, path)?; + } else { + source.install(manager, &source.preview.token, path)?; } + println!( + "Installed {} from {repository} ({reference}) in profile {}.", + candidate.manifest.id, manager.profile + ); Ok(()) } @@ -173,142 +44,6 @@ fn git_options(options: &[String]) -> Result<(&str, bool)> { } } -fn print_catalog(catalog: &Catalog) -> Result<()> { - println!( - "Profile: {}\nDirectory: {}\n", - catalog.profile, catalog.location - ); - for plugin in &catalog.plugins { - println!( - "{:<24} {:<10} {:<10} {}", - plugin.manifest.id, - if plugin.enabled { - "enabled" - } else { - "disabled" - }, - plugin.source, - plugin.manifest.name - ); - if let Some(error) = &plugin.error { - println!(" Problem: {error}"); - } - if plugin.source == "external" { - println!( - " Publisher: {}", - plugin - .publisher - .as_deref() - .unwrap_or(if plugin.error.is_some() { - "unverified" - } else { - "unsigned" - }) - ); - } - } - Ok(()) -} -fn read_manifest(directory: &Path) -> Result { - let manifest: Manifest = serde_json::from_slice( - &std::fs::read(directory.join("manifest.json")).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - manifest.validate()?; - Ok(manifest) -} -fn init(directory: &Path, id: &str, name: &str) -> Result<()> { - valid_id(id)?; - let manifest = Manifest { - id: id.into(), - name: name.into(), - api_version: 1, - description: None, - discovery: None, - host: None, - }; - manifest.validate()?; - // Never overwrite an existing source directory. - std::fs::create_dir(directory).map_err(|e| e.to_string())?; - std::fs::create_dir(directory.join("src")).map_err(|e| e.to_string())?; - let package = json!({"name": id, "private": true, "type": "module", "scripts": {"build": "tsc && vite build"}, "devDependencies": {"vite": "8.2.2", "typescript": "7.0.2", "@types/react": "19.2.18"}, "engines": {"node": ">=24"}}); - for (file, text) in [ - ("manifest.json", serde_json::to_string_pretty(&manifest).unwrap()), - ("package.json", serde_json::to_string_pretty(&package).unwrap()), - (".gitignore", "node_modules/\ndist/\n".into()), - ("tsconfig.json", r#"{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react","strict":true,"noEmit":true},"include":["src"]}"#.into()), - ("src/index.tsx", r#"import type { Context } from '@buzz/author'; - -export const inject = ['react', 'pages']; - -export function apply(ctx: Context) { - const React = ctx.react; - // Plugin resources belong in ctx.effect(() => cleanup). - // React effects belong to the visible page and stop when navigating away. - // primary gives the page a sidebar row; every active page is still listed in search. - ctx.pages.register({ id: "main", title: "My page", primary: true, component: function Page() { - const [count, setCount] = React.useState(0); - return
-

My page

- -
; - }}); -} -"#.into()), - // A normal, editable Vite config in the author's project, not injected code. - ("vite.config.ts", r#"import { defineConfig } from 'vite'; -import manifest from './manifest.json'; - -export default defineConfig({ - publicDir: false, - oxc: { jsx: { runtime: 'classic' } }, - build: { - lib: { entry: 'src/index.tsx', formats: ['es'], fileName: () => 'plugin.js' }, - rolldownOptions: { output: { codeSplitting: false } }, - }, - plugins: [{ - name: 'page-contract', - enforce: 'pre', - resolveId(id) { - if (/^react(?:-dom)?(?:\/|$)/.test(id) || id === '@buzz/author' || id === '@deepseek-ai/cordis' || id.startsWith('@deepseek-ai/cordis/')) { - throw new Error('Host capabilities are supplied by Buzz; use type-only imports'); - } - }, - generateBundle(_options, bundle) { - const outputs = Object.values(bundle); - const page = outputs[0]; - if (outputs.length !== 1 || page?.type !== 'chunk' || page.imports.length) { - throw new Error('Page API v1 requires one self-contained JS module, without separate assets'); - } - if (!page.exports.includes('apply')) throw new Error('Export apply(ctx)'); - this.emitFile({ type: 'asset', fileName: 'manifest.json', source: JSON.stringify(manifest, null, 2) }); - }, - }], -}); -"#.into()), - ] { std::fs::write(directory.join(file), text).map_err(|e| e.to_string())?; } - Ok(()) -} -fn build(directory: &Path) -> Result<()> { - read_manifest(directory)?; - let status = Command::new(if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }) - .args(["run", "build"]) - .current_dir(directory) - .status() - .map_err(|e| { - format!("Could not start pnpm: {e}. Install Node and pnpm to build plugins.") - })?; - if !status.success() { - return Err("Plugin build failed. See the build output above.".into()); - } - // A successful script must actually produce the artifact that install consumes. - read_manifest(&directory.join("dist"))?; - if !directory.join("dist/plugin.js").is_file() { - return Err("Build did not produce dist/plugin.js".into()); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/plugin-manager/src/main.rs b/crates/plugin-manager/src/main.rs index 584c7d90d4..dcfd0a1fe7 100644 --- a/crates/plugin-manager/src/main.rs +++ b/crates/plugin-manager/src/main.rs @@ -1,6 +1,283 @@ +use buzzodz_plugins::{ + release_manifest, sign_release_saved, valid_id, Catalog, Manager, Manifest, Result, +}; +use serde_json::json; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +const HELP: &str = "buzzodz [--home ABSOLUTE_PATH] [--profile NAME] plugin COMMAND + +Commands: + init DIRECTORY ID NAME Create a page-plugin source project + build DIRECTORY Run the project's pnpm build script + sign DIST_DIRECTORY Sign with your saved Buzz human identity + install DIRECTORY Install a built artifact (new plugins start disabled) + install-git REPO REF [PATH] [--enable] + Install a Git plugin (PATH defaults to .) + reload ID Reload a folder plugin, preserving its enabled state + list List installed pages and their enabled state + enable ID | disable ID | remove ID | rollback ID + recover Back up settings and reset to bundled defaults + +Options: + --home PATH Plugin data directory (absolute path) + --profile NAME Local profile (default: default) + --help, -h Show this help + +The desktop and CLI also read BUZZODZ_HOME and BUZZODZ_PROFILE. +Launch the desktop with BUZZODZ_SAFE_MODE=1 to skip external pages."; + fn main() { - if let Err(message) = buzzodz_plugins::cli::run(std::env::args().skip(1).collect()) { + if let Err(message) = run() { eprintln!("Error: {message}"); std::process::exit(1); } } +fn run() -> Result<()> { + let mut args = std::env::args().skip(1).collect::>(); + if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { + println!("{HELP}"); + return Ok(()); + } + let mut home = None; + let mut profile = std::env::var("BUZZODZ_PROFILE").unwrap_or_else(|_| "default".into()); + while args.first().is_some_and(|s| s.starts_with("--")) { + let flag = args.remove(0); + if args.is_empty() { + return Err(format!("{flag} requires a value. See buzzodz --help.")); + } + let value = args.remove(0); + match flag.as_str() { + "--home" => home = Some(PathBuf::from(value)), + "--profile" => profile = value, + _ => return Err(format!("Unknown option {flag}. See buzzodz --help.")), + } + } + if args.first().map(String::as_str) != Some("plugin") || args.len() < 2 { + return Err("Expected plugin COMMAND. See buzzodz --help.".into()); + } + let action = &args[1]; + let rest = &args[2..]; + // Source creation/building does not require a local installation profile. + match (action.as_str(), rest) { + ("init", [directory, id, name]) => { + init(Path::new(directory), id, name)?; + println!("Created {id} in {directory}\nNext: add the host-matched @buzz/author preview archive with pnpm add -D /absolute/path/buzz-author-VERSION.tgz, then buzzodz plugin build DIRECTORY."); + return Ok(()); + } + ("build", [directory]) => { + build(Path::new(directory))?; + println!("Built {}", Path::new(directory).join("dist").display()); + return Ok(()); + } + ("sign", [directory]) => { + let publisher = sign_release_saved(Path::new(directory))?; + println!("Signed release in {directory} by {publisher}"); + return Ok(()); + } + ("sign", _) => return Err("Usage: buzzodz plugin sign DIST_DIRECTORY".into()), + _ => {} + } + let manager = Manager::open(home, &profile, false)?; + match (action.as_str(), rest) { + ("install", [directory]) => { + let manifest = release_manifest(Path::new(directory))?; + let catalog = manager.install(Path::new(directory))?; + let installed = catalog + .plugins + .iter() + .find(|p| p.manifest.id == manifest.id) + .ok_or("Installed plugin missing from catalog")?; + println!( + "Installed {} ({}) in profile {profile}. Publisher: {}", + installed.manifest.id, + if installed.enabled { + "enabled" + } else { + "disabled" + }, + installed.publisher.as_deref().unwrap_or("unsigned") + ); + } + ("install-git", args) => buzzodz_plugins::cli::install_git(&manager, args)?, + ("reload", [id]) => { + let before = manager.catalog()?; + let after = manager.reload_live(id)?; + for (label, catalog) in [ + ("Previous host access", &before), + ("Updated host access", &after), + ] { + let grants = catalog + .plugins + .iter() + .find(|p| p.manifest.id == *id) + .and_then(|p| p.manifest.host.as_ref()); + println!( + "{label}: {}", + serde_json::to_string_pretty(&grants).map_err(|e| e.to_string())? + ); + } + println!("Reloaded {id} in profile {profile}. The running app picks up changed builds automatically."); + } + ("reload", _) => return Err("Usage: buzzodz plugin reload ID".into()), + ("list", []) => print_catalog(&manager.catalog()?)?, + ("recover", []) => { + let catalog = manager.recover()?; + println!("Reset profile {profile} to bundled defaults.\nAny previous settings were backed up in {}.\nExternal artifacts remain available for reinstallation.", catalog.location); + } + ("enable" | "disable" | "remove" | "rollback", [id]) => { + manager.change(action, id)?; + let verb = match action.as_str() { + "enable" => "Enabled", + "disable" => "Disabled", + "remove" => "Removed", + _ => "Rolled back", + }; + println!("{verb} {id} in profile {profile}."); + } + _ => { + return Err(format!( + "Unknown command or incorrect arguments: plugin {}. See buzzodz --help.", + args[1..].join(" ") + )) + } + } + Ok(()) +} +fn print_catalog(catalog: &Catalog) -> Result<()> { + println!( + "Profile: {}\nDirectory: {}\n", + catalog.profile, catalog.location + ); + for plugin in &catalog.plugins { + println!( + "{:<24} {:<10} {:<10} {}", + plugin.manifest.id, + if plugin.enabled { + "enabled" + } else { + "disabled" + }, + plugin.source, + plugin.manifest.name + ); + if let Some(error) = &plugin.error { + println!(" Problem: {error}"); + } + if plugin.source == "external" { + println!( + " Publisher: {}", + plugin + .publisher + .as_deref() + .unwrap_or(if plugin.error.is_some() { + "unverified" + } else { + "unsigned" + }) + ); + } + } + Ok(()) +} +fn read_manifest(directory: &Path) -> Result { + let manifest: Manifest = serde_json::from_slice( + &std::fs::read(directory.join("manifest.json")).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + manifest.validate()?; + Ok(manifest) +} +fn init(directory: &Path, id: &str, name: &str) -> Result<()> { + valid_id(id)?; + let manifest = Manifest { + id: id.into(), + name: name.into(), + api_version: 1, + description: None, + discovery: None, + host: None, + }; + manifest.validate()?; + // Never overwrite an existing source directory. + std::fs::create_dir(directory).map_err(|e| e.to_string())?; + std::fs::create_dir(directory.join("src")).map_err(|e| e.to_string())?; + let package = json!({"name": id, "private": true, "type": "module", "scripts": {"build": "tsc && vite build"}, "devDependencies": {"vite": "8.2.2", "typescript": "7.0.2", "@types/react": "19.2.18"}, "engines": {"node": ">=24"}}); + for (file, text) in [ + ("manifest.json", serde_json::to_string_pretty(&manifest).unwrap()), + ("package.json", serde_json::to_string_pretty(&package).unwrap()), + (".gitignore", "node_modules/\ndist/\n".into()), + ("tsconfig.json", r#"{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","jsx":"react","strict":true,"noEmit":true},"include":["src"]}"#.into()), + ("src/index.tsx", r#"import type { Context } from '@buzz/author'; + +export const inject = ['react', 'pages']; + +export function apply(ctx: Context) { + const React = ctx.react; + // Plugin resources belong in ctx.effect(() => cleanup). + // React effects belong to the visible page and stop when navigating away. + // primary gives the page a sidebar row; every active page is still listed in search. + ctx.pages.register({ id: "main", title: "My page", primary: true, component: function Page() { + const [count, setCount] = React.useState(0); + return
+

My page

+ +
; + }}); +} +"#.into()), + // A normal, editable Vite config in the author's project, not injected code. + ("vite.config.ts", r#"import { defineConfig } from 'vite'; +import manifest from './manifest.json'; + +export default defineConfig({ + publicDir: false, + oxc: { jsx: { runtime: 'classic' } }, + build: { + lib: { entry: 'src/index.tsx', formats: ['es'], fileName: () => 'plugin.js' }, + rolldownOptions: { output: { codeSplitting: false } }, + }, + plugins: [{ + name: 'page-contract', + enforce: 'pre', + resolveId(id) { + if (/^react(?:-dom)?(?:\/|$)/.test(id) || id === '@buzz/author' || id === '@deepseek-ai/cordis' || id.startsWith('@deepseek-ai/cordis/')) { + throw new Error('Host capabilities are supplied by Buzz; use type-only imports'); + } + }, + generateBundle(_options, bundle) { + const outputs = Object.values(bundle); + const page = outputs[0]; + if (outputs.length !== 1 || page?.type !== 'chunk' || page.imports.length) { + throw new Error('Page API v1 requires one self-contained JS module, without separate assets'); + } + if (!page.exports.includes('apply')) throw new Error('Export apply(ctx)'); + this.emitFile({ type: 'asset', fileName: 'manifest.json', source: JSON.stringify(manifest, null, 2) }); + }, + }], +}); +"#.into()), + ] { std::fs::write(directory.join(file), text).map_err(|e| e.to_string())?; } + Ok(()) +} +fn build(directory: &Path) -> Result<()> { + read_manifest(directory)?; + let status = Command::new(if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }) + .args(["run", "build"]) + .current_dir(directory) + .status() + .map_err(|e| { + format!("Could not start pnpm: {e}. Install Node and pnpm to build plugins.") + })?; + if !status.success() { + return Err("Plugin build failed. See the build output above.".into()); + } + // A successful script must actually produce the artifact that install consumes. + read_manifest(&directory.join("dist"))?; + if !directory.join("dist/plugin.js").is_file() { + return Err("Build did not produce dist/plugin.js".into()); + } + Ok(()) +} diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index e0cdaaf0b7..1299f42b29 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -100,7 +100,7 @@ source imports are not a versioned external SDK. See ## The `buzzodz` CLI -The installed desktop executable also accepts these commands. On macOS, install +The installed desktop executable accepts `plugin install-git`. On macOS, install a published Git plugin without a compiler or a separate tool: ```sh @@ -137,8 +137,8 @@ The CLI prints readable results and reports errors with a nonzero exit code. existing enabled state. `recover` backs up management settings and resets to bundled defaults, preserving artifacts for reinstallation. -`--home ABSOLUTE_PATH` and `--profile NAME` precede `plugin`. The desktop and CLI -also read `BUZZODZ_HOME` and `BUZZODZ_PROFILE` (default: `default`). Match these to +In the standalone CLI, `--home ABSOLUTE_PATH` and `--profile NAME` precede `plugin`. +Both commands read `BUZZODZ_HOME` and `BUZZODZ_PROFILE` (default: `default`). Match these to target the same instance. Otherwise, profiles live under the OS application-data directory in `dev.local.buzz.foundation/profiles`. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3f7504fa4f..843f7c9042 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,19 +2,19 @@ fn main() { let args: Vec = std::env::args().skip(1).collect(); - if args.first().is_some_and(|arg| { - matches!( - arg.as_str(), - "plugin" | "--home" | "--profile" | "--help" | "-h" - ) - }) { + if args.first().map(String::as_str) == Some("plugin") { #[cfg(all(windows, not(debug_assertions)))] unsafe { windows_sys::Win32::System::Console::AttachConsole( windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS, ); } - if let Err(message) = buzzodz_plugins::cli::run(args) { + let result = match args.get(1).map(String::as_str) { + Some("install-git") => buzzodz_plugins::Manager::from_env() + .and_then(|manager| buzzodz_plugins::cli::install_git(&manager, &args[2..])), + _ => Err("Use plugin install-git REPO REF [PATH] [--enable].".into()), + }; + if let Err(message) = result { eprintln!("Error: {message}"); std::process::exit(1); } From 86b55f68d5d39b60921ce7e80fddb7b82a4b9239 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 18:29:32 -0700 Subject: [PATCH 05/15] fix: retain inherited agent when SSH lookup fails Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- crates/plugin-manager/src/imports.rs | 21 ++++++++++++++++++++- docs/plugin-architecture.md | 3 ++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/plugin-manager/src/imports.rs b/crates/plugin-manager/src/imports.rs index 11d913c149..364b5b585f 100644 --- a/crates/plugin-manager/src/imports.rs +++ b/crates/plugin-manager/src/imports.rs @@ -690,7 +690,10 @@ impl Git { "--", url.host_str().ok_or("Missing SSH host")?, ]); - let output = self.run_command(command, 64 * 1024)?; + // Git can use the inherited agent even when local SSH configuration is invalid. + let Ok(output) = self.run_command(command, 64 * 1024) else { + return Ok(()); + }; let config = std::str::from_utf8(&output).map_err(err)?; self.ssh_agent = configured_agent(config, |key| std::env::var_os(key))?; Ok(()) @@ -1012,6 +1015,22 @@ fn directory_size(root: &Path) -> Result { #[cfg(test)] mod tests { + #[test] + fn failed_ssh_lookup_preserves_the_inherited_agent() { + let mut git = Git::new().unwrap(); + // OpenSSH rejects port zero before it reads configuration or connects. + git.configure_ssh_agent("ssh://git@example.invalid:0/repo") + .unwrap(); + assert!(git.ssh_agent.is_none()); + let command = git.command(); + let socket = command + .get_envs() + .find(|(key, _)| *key == "SSH_AUTH_SOCK") + .and_then(|(_, value)| value); + assert_eq!(socket, std::env::var_os("SSH_AUTH_SOCK").as_deref()); + assert!(git.run(&["--version"], LIMIT).is_ok()); + } + #[test] fn configured_agent_selects_only_the_agent_socket() { use super::configured_agent; diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index e32c3ba1c6..e783572095 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -1186,7 +1186,8 @@ pane, using the remembered repository and branch/tag with fresh authorization; credentials/tokens are never saved with source metadata. SSH accepts explicit URLs and `user@host:owner/repository.git` shorthand, including organization-specific usernames. Buzz reads `IdentityAgent` through `ssh -G` and passes that socket to -the transport. Without that setting, it uses the inherited `SSH_AUTH_SOCK`. +the transport. If the lookup fails or has no setting, it uses the inherited +`SSH_AUTH_SOCK`. The transport uses existing known hosts and disables interactive prompts. Each check reads metadata from its own temporary clone; only Update retains an immutable installation preview. The source From 65933841654a59eec7bbff79e6955bc84bfd96dc Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 18:29:57 -0700 Subject: [PATCH 06/15] docs: use the current Buzz executable name Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/plugin-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index e783572095..6b92585629 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -106,7 +106,7 @@ The installed desktop executable accepts `plugin install-git`. On macOS, install a published Git plugin without a compiler or a separate tool: ```sh -"/Applications/Buzz.app/Contents/MacOS/buzz-foundation" plugin install-git org-49461806@github.com:squareup/buzz-nexus.git plugin-release --enable +"/Applications/Buzz.app/Contents/MacOS/Buzz" plugin install-git org-49461806@github.com:squareup/buzz-nexus.git plugin-release --enable ``` `install-git REPO REF [PATH] [--enable]` uses the same importer as Settings. From 94d4a3ec0fba8eec6d1de771ab13bfd05c2428a8 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 18:30:37 -0700 Subject: [PATCH 07/15] fix: use the current plugin manager constructor Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src-tauri/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 843f7c9042..4d2f58f04c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,7 +10,7 @@ fn main() { ); } let result = match args.get(1).map(String::as_str) { - Some("install-git") => buzzodz_plugins::Manager::from_env() + Some("install-git") => buzzodz_plugins::Manager::from_env(None) .and_then(|manager| buzzodz_plugins::cli::install_git(&manager, &args[2..])), _ => Err("Use plugin install-git REPO REF [PATH] [--enable].".into()), }; From 52089e347db26add6fb7faa8de80b7070c37323e Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 09:35:48 -0700 Subject: [PATCH 08/15] fix: validate SSH input and cancel CLI acquisition Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- crates/plugin-manager/src/cli.rs | 53 +++++++++++++- crates/plugin-manager/src/imports.rs | 30 ++++++++ crates/plugin-manager/src/lib.rs | 2 +- docs/plugin-architecture.md | 2 +- tests/integration/plugin-cli.test.mjs | 101 +++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 5 deletions(-) diff --git a/crates/plugin-manager/src/cli.rs b/crates/plugin-manager/src/cli.rs index a72f4ac44d..1b249e8927 100644 --- a/crates/plugin-manager/src/cli.rs +++ b/crates/plugin-manager/src/cli.rs @@ -11,7 +11,14 @@ pub fn install_git(manager: &Manager, args: &[String]) -> Result<()> { return Err(USAGE.into()); }; let (path, enable) = git_options(options)?; - let source = prepare_git(repository, reference, None)?; + let source = { + #[cfg(unix)] + let _interrupt = interrupt::Handler::install()?; + let source = prepare_git(repository, reference, None)?; + #[cfg(unix)] + interrupt::check()?; + source + }; let candidate = source .preview .candidates @@ -34,6 +41,50 @@ pub fn install_git(manager: &Manager, args: &[String]) -> Result<()> { Ok(()) } +#[cfg(unix)] +pub(crate) mod interrupt { + use crate::Result; + use std::sync::atomic::{AtomicBool, Ordering}; + + static INTERRUPTED: AtomicBool = AtomicBool::new(false); + + extern "C" fn handle(_: libc::c_int) { + INTERRUPTED.store(true, Ordering::Relaxed); + } + + pub fn check() -> Result<()> { + if INTERRUPTED.load(Ordering::Relaxed) { + Err("Plugin installation interrupted".into()) + } else { + Ok(()) + } + } + + pub struct Handler(libc::sigaction); + + impl Handler { + pub fn install() -> Result { + INTERRUPTED.store(false, Ordering::Relaxed); + unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + let mut previous = std::mem::zeroed(); + action.sa_sigaction = handle as *const () as usize; + libc::sigemptyset(&mut action.sa_mask); + if libc::sigaction(libc::SIGINT, &action, &mut previous) != 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(Self(previous)) + } + } + } + + impl Drop for Handler { + fn drop(&mut self) { + unsafe { libc::sigaction(libc::SIGINT, &self.0, std::ptr::null_mut()) }; + } + } +} + fn git_options(options: &[String]) -> Result<(&str, bool)> { match options { [] => Ok((".", false)), diff --git a/crates/plugin-manager/src/imports.rs b/crates/plugin-manager/src/imports.rs index 364b5b585f..5af73aa4b4 100644 --- a/crates/plugin-manager/src/imports.rs +++ b/crates/plugin-manager/src/imports.rs @@ -590,6 +590,19 @@ pub fn repository_url(input: &str) -> Result { "Use an HTTPS or SSH repository URL without a password, query or fragment".into(), ); } + if url.scheme() == "ssh" + && (!url + .host_str() + .unwrap_or_default() + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".-_:[]".contains(&byte)) + || !url + .username() + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))) + { + return Err("Use an SSH host and username without shell characters or URL escapes".into()); + } if url.host_str() == Some("github.com") && url.path().trim_matches('/').split('/').count() != 2 { return Err("Use the GitHub repository URL, not a tree/file URL. Enter the branch or tag separately, then select a plugin folder.".into()); @@ -699,6 +712,8 @@ impl Git { Ok(()) } fn run_command(&self, mut command: Command, limit: u64) -> Result> { + #[cfg(unix)] + crate::cli::interrupt::check()?; let output = tempfile::tempfile().map_err(err)?; let errors = tempfile::tempfile().map_err(err)?; command @@ -714,6 +729,8 @@ impl Git { .spawn() .map_err(|e| format!("Could not start Git: {e}. Install Git to load repositories."))?; let result = (|| loop { + #[cfg(unix)] + crate::cli::interrupt::check()?; if Instant::now() >= self.deadline { return Err("Repository import exceeded 60 seconds".into()); } @@ -1740,6 +1757,19 @@ mod tests { "ssh://dev.user@git.example.org/team/repo.git" ); assert!(repository_url("https://git.example.org/team/repo.git").is_ok()); + for authority in [ + "git@host;command", + "git@host`command`", + "git@host$(command)", + "git@host&command", + "git@host|command", + "git@host'command'", + "git@host%3bcommand", + "git%3bcommand@example.invalid", + "git;command@example.invalid", + ] { + assert!(repository_url(&format!("ssh://{authority}/owner/repo")).is_err()); + } for input in [ "/tmp/repo", "file:///tmp/repo", diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 8750e2d19e..28f6b6009d 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -633,7 +633,7 @@ fn default_home() -> Result { }; let home = data.join(home); if !home.exists() && older.iter().any(|old| data.join(old).exists()) { - return Err(format!("{open}, then try again, or pass --home.")); + return Err(format!("{open}, then try again, or set BUZZODZ_HOME.")); } Ok(home) } diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 6b92585629..25e72d587c 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -106,7 +106,7 @@ The installed desktop executable accepts `plugin install-git`. On macOS, install a published Git plugin without a compiler or a separate tool: ```sh -"/Applications/Buzz.app/Contents/MacOS/Buzz" plugin install-git org-49461806@github.com:squareup/buzz-nexus.git plugin-release --enable +"/Applications/Buzz.app/Contents/MacOS/Buzz" plugin install-git git@github.com:OWNER/PLUGIN.git main --enable ``` `install-git REPO REF [PATH] [--enable]` uses the same importer as Settings. diff --git a/tests/integration/plugin-cli.test.mjs b/tests/integration/plugin-cli.test.mjs index ca9b092869..eea83157e5 100644 --- a/tests/integration/plugin-cli.test.mjs +++ b/tests/integration/plugin-cli.test.mjs @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; +import { createServer } from "node:net"; +import { once } from "node:events"; import { Context } from "@deepseek-ai/cordis"; const root = fileURLToPath(new URL("../../", import.meta.url)); @@ -214,3 +216,98 @@ test("failed source builds preserve the installed revision", async () => { } }); }); + +test("Ctrl-C stops Git descendants and removes the temporary clone", { + skip: process.platform === "win32", + timeout: 15000, +}, async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "bz-interrupt-")); + const socketPath = path.join(directory, "s"); + const sockets = []; + const transports = []; + const server = createServer(); + const ready = new Promise((resolve) => + server.on("connection", (socket) => { + sockets.push(socket); + let data = ""; + socket.on("data", (chunk) => { + data += chunk; + if (data.includes("\n")) { + transports.push(JSON.parse(data)); + if (transports.length === 2) resolve(); + } + }); + }), + ); + server.listen(socketPath); + await once(server, "listening", { signal: t.signal }); + const fixture = path.join(directory, "git"); + await writeFile( + fixture, + `#!/usr/bin/env node +const net = require("node:net"); +if (!process.argv.includes("--descendant")) { + require("node:child_process").spawn(process.execPath, [__filename, "--descendant"], { stdio: "ignore" }); +} +const socket = net.connect(${JSON.stringify(socketPath)}, () => { + socket.write(JSON.stringify({ pid: process.pid, cwd: process.cwd(), descendant: process.argv.includes("--descendant") }) + "\\n"); +}); +`, + { mode: 0o700 }, + ); + const child = spawn( + binary, + ["plugin", "install-git", "https://example.invalid/repo", "main"], + { + cwd: root, + env: { + ...process.env, + PATH: `${directory}${path.delimiter}${process.env.PATH}`, + BUZZODZ_HOME: directory, + }, + }, + ); + let errors = ""; + child.stderr.on("data", (chunk) => { + errors += chunk; + }); + const exited = once(child, "exit", { signal: t.signal }); + try { + await Promise.race([ + ready, + exited.then(() => { + throw new Error(errors || "CLI exits before Git starts"); + }), + ]); + const closed = sockets.map((socket) => + once(socket, "close", { signal: t.signal }), + ); + child.kill("SIGINT"); + const [code, signal] = await exited; + await Promise.all(closed); + assert.equal(code, 1, errors); + assert.equal(signal, null); + assert.match(errors, /Plugin installation interrupted/); + const transport = transports.find((entry) => !entry.descendant); + assert.throws(() => process.kill(transport.pid, 0), { code: "ESRCH" }); + assert.equal(existsSync(transport.cwd), false); + assert.equal( + existsSync(path.join(directory, "profiles/default/registry.json")), + false, + ); + } finally { + child.kill("SIGKILL"); + const transport = transports.find((entry) => !entry.descendant); + if (transport) { + try { + process.kill(-transport.pid, "SIGKILL"); + } catch (error) { + assert.equal(error.code, "ESRCH"); + } + await rm(transport.cwd, { recursive: true, force: true }); + } + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + } +}); From 6f2510ae2da85428eb8cf21a0679d159bc304d2d Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 11:53:31 -0700 Subject: [PATCH 09/15] feat: open Git plugin imports from deep links Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- crates/plugin-manager/src/cli.rs | 124 ------------- crates/plugin-manager/src/imports.rs | 192 +-------------------- crates/plugin-manager/src/lib.rs | 7 +- crates/plugin-manager/src/main.rs | 3 - docs/deep-links.md | 9 + docs/plugin-architecture.md | 42 ++--- src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 19 -- src/app/PluginImport.test.tsx | 69 ++++++++ src/app/PluginImport.tsx | 21 ++- src/app/Settings.tsx | 12 ++ src/features/navigation/buzz-links.test.ts | 51 ++++++ src/features/navigation/buzz-links.ts | 33 +++- src/features/navigation/deep-links.ts | 9 + src/features/navigation/targets.test.ts | 19 ++ src/features/navigation/targets.ts | 45 ++++- tests/browser/plugin-import.spec.mjs | 116 ++++++++++++- tests/integration/plugin-cli.test.mjs | 101 +---------- 18 files changed, 399 insertions(+), 475 deletions(-) delete mode 100644 crates/plugin-manager/src/cli.rs diff --git a/crates/plugin-manager/src/cli.rs b/crates/plugin-manager/src/cli.rs deleted file mode 100644 index 1b249e8927..0000000000 --- a/crates/plugin-manager/src/cli.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::{imports::prepare_git, Manager, Result}; - -const USAGE: &str = "Usage: plugin install-git REPO REF [PATH] [--enable]"; - -pub fn install_git(manager: &Manager, args: &[String]) -> Result<()> { - if matches!(args, [flag] if flag == "--help" || flag == "-h") { - println!("{USAGE}"); - return Ok(()); - } - let [repository, reference, options @ ..] = args else { - return Err(USAGE.into()); - }; - let (path, enable) = git_options(options)?; - let source = { - #[cfg(unix)] - let _interrupt = interrupt::Handler::install()?; - let source = prepare_git(repository, reference, None)?; - #[cfg(unix)] - interrupt::check()?; - source - }; - let candidate = source - .preview - .candidates - .iter() - .find(|candidate| candidate.path == path) - .ok_or("No plugin at this path. Specify a plugin folder from the repository.")?; - println!( - "Host access: {}", - serde_json::to_string_pretty(&candidate.manifest.host).map_err(|e| e.to_string())? - ); - if enable { - source.install_enabled(manager, &source.preview.token, path)?; - } else { - source.install(manager, &source.preview.token, path)?; - } - println!( - "Installed {} from {repository} ({reference}) in profile {}.", - candidate.manifest.id, manager.profile - ); - Ok(()) -} - -#[cfg(unix)] -pub(crate) mod interrupt { - use crate::Result; - use std::sync::atomic::{AtomicBool, Ordering}; - - static INTERRUPTED: AtomicBool = AtomicBool::new(false); - - extern "C" fn handle(_: libc::c_int) { - INTERRUPTED.store(true, Ordering::Relaxed); - } - - pub fn check() -> Result<()> { - if INTERRUPTED.load(Ordering::Relaxed) { - Err("Plugin installation interrupted".into()) - } else { - Ok(()) - } - } - - pub struct Handler(libc::sigaction); - - impl Handler { - pub fn install() -> Result { - INTERRUPTED.store(false, Ordering::Relaxed); - unsafe { - let mut action: libc::sigaction = std::mem::zeroed(); - let mut previous = std::mem::zeroed(); - action.sa_sigaction = handle as *const () as usize; - libc::sigemptyset(&mut action.sa_mask); - if libc::sigaction(libc::SIGINT, &action, &mut previous) != 0 { - return Err(std::io::Error::last_os_error().to_string()); - } - Ok(Self(previous)) - } - } - } - - impl Drop for Handler { - fn drop(&mut self) { - unsafe { libc::sigaction(libc::SIGINT, &self.0, std::ptr::null_mut()) }; - } - } -} - -fn git_options(options: &[String]) -> Result<(&str, bool)> { - match options { - [] => Ok((".", false)), - [flag] if flag == "--enable" => Ok((".", true)), - [path] if !path.starts_with('-') => Ok((path, false)), - [path, flag] if !path.starts_with('-') && flag == "--enable" => Ok((path, true)), - _ => Err("Usage: plugin install-git REPO REF [PATH] [--enable]".into()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn git_install_options_select_the_folder_and_enable_only_when_requested() { - for (args, expected) in [ - (vec![], (".", false)), - (vec!["--enable"], (".", true)), - (vec!["plugins/example"], ("plugins/example", false)), - ( - vec!["plugins/example", "--enable"], - ("plugins/example", true), - ), - ] { - let args = args.into_iter().map(String::from).collect::>(); - assert_eq!(git_options(&args).unwrap(), expected); - } - for args in [ - vec!["--unknown"], - vec![".", "--unknown"], - vec!["--enable", "."], - ] { - assert!(git_options(&args.into_iter().map(String::from).collect::>()).is_err()); - } - } -} diff --git a/crates/plugin-manager/src/imports.rs b/crates/plugin-manager/src/imports.rs index 5af73aa4b4..77048bb2e6 100644 --- a/crates/plugin-manager/src/imports.rs +++ b/crates/plugin-manager/src/imports.rs @@ -139,18 +139,6 @@ impl PreparedImport { Ok(self) } pub fn install(&self, manager: &Manager, token: &str, path: &str) -> Result { - self.install_with_activation(manager, token, path, false) - } - pub fn install_enabled(&self, manager: &Manager, token: &str, path: &str) -> Result { - self.install_with_activation(manager, token, path, true) - } - fn install_with_activation( - &self, - manager: &Manager, - token: &str, - path: &str, - enable: bool, - ) -> Result { if token != self.preview.token { return Err("This import preview expired. Choose the source again.".into()); } @@ -169,7 +157,7 @@ impl PreparedImport { InstallSource::Git(source) }) }); - manager.install_artifact(release, source, enable) + manager.install_artifact(release, source) } } @@ -590,19 +578,6 @@ pub fn repository_url(input: &str) -> Result { "Use an HTTPS or SSH repository URL without a password, query or fragment".into(), ); } - if url.scheme() == "ssh" - && (!url - .host_str() - .unwrap_or_default() - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b".-_:[]".contains(&byte)) - || !url - .username() - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))) - { - return Err("Use an SSH host and username without shell characters or URL escapes".into()); - } if url.host_str() == Some("github.com") && url.path().trim_matches('/').split('/').count() != 2 { return Err("Use the GitHub repository URL, not a tree/file URL. Enter the branch or tag separately, then select a plugin folder.".into()); @@ -615,7 +590,6 @@ struct Git { deadline: Instant, /// Repository URL and its NIP-98 token. Git sends the header only to that URL prefix. authorization: Option<(String, String)>, - ssh_agent: Option, } impl Git { fn new() -> Result { @@ -623,7 +597,6 @@ impl Git { scratch: tempfile::tempdir().map_err(err)?, deadline: Instant::now() + Duration::from_secs(60), authorization: None, - ssh_agent: None, }) } fn command(&self) -> Command { @@ -636,9 +609,6 @@ impl Git { command.env(key, value); } } - if let Some(agent) = &self.ssh_agent { - command.env("SSH_AUTH_SOCK", agent); - } let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; command .env("GIT_CONFIG_NOSYSTEM", "1") @@ -687,33 +657,7 @@ impl Git { command.args(args); self.run_command(command, limit) } - fn configure_ssh_agent(&mut self, repository: &str) -> Result<()> { - let url = url::Url::parse(repository).map_err(err)?; - if url.scheme() != "ssh" { - return Ok(()); - } - let mut command = Command::new("ssh"); - command.arg("-G"); - if !url.username().is_empty() { - command.args(["-l", url.username()]); - } - command.args([ - "-p", - &url.port().unwrap_or(22).to_string(), - "--", - url.host_str().ok_or("Missing SSH host")?, - ]); - // Git can use the inherited agent even when local SSH configuration is invalid. - let Ok(output) = self.run_command(command, 64 * 1024) else { - return Ok(()); - }; - let config = std::str::from_utf8(&output).map_err(err)?; - self.ssh_agent = configured_agent(config, |key| std::env::var_os(key))?; - Ok(()) - } fn run_command(&self, mut command: Command, limit: u64) -> Result> { - #[cfg(unix)] - crate::cli::interrupt::check()?; let output = tempfile::tempfile().map_err(err)?; let errors = tempfile::tempfile().map_err(err)?; command @@ -729,8 +673,6 @@ impl Git { .spawn() .map_err(|e| format!("Could not start Git: {e}. Install Git to load repositories."))?; let result = (|| loop { - #[cfg(unix)] - crate::cli::interrupt::check()?; if Instant::now() >= self.deadline { return Err("Repository import exceeded 60 seconds".into()); } @@ -923,23 +865,6 @@ impl Git { } } -fn configured_agent( - config: &str, - get_env: impl Fn(&str) -> Option, -) -> Result> { - match config - .lines() - .find_map(|line| line.strip_prefix("identityagent ")) - { - None | Some("SSH_AUTH_SOCK") => Ok(None), - Some("none") => Ok(Some("".into())), - Some(variable) if variable.starts_with('$') => get_env(&variable[1..]) - .map(Some) - .ok_or_else(|| "The SSH IdentityAgent environment variable is not set.".into()), - Some(path) => Ok(Some(path.into())), - } -} - /// `authorization` is a NIP-98 token that the caller signed for exactly this repository URL. pub fn prepare_git( repository: &str, @@ -969,7 +894,6 @@ pub fn prepare_git( } git.authorization = Some((url.clone(), token.into())); } - git.configure_ssh_agent(&url)?; let mut args = vec![ "clone", "--depth=1", @@ -1032,108 +956,7 @@ fn directory_size(root: &Path) -> Result { #[cfg(test)] mod tests { - #[test] - fn failed_ssh_lookup_preserves_the_inherited_agent() { - let mut git = Git::new().unwrap(); - // OpenSSH rejects port zero before it reads configuration or connects. - git.configure_ssh_agent("ssh://git@example.invalid:0/repo") - .unwrap(); - assert!(git.ssh_agent.is_none()); - let command = git.command(); - let socket = command - .get_envs() - .find(|(key, _)| *key == "SSH_AUTH_SOCK") - .and_then(|(_, value)| value); - assert_eq!(socket, std::env::var_os("SSH_AUTH_SOCK").as_deref()); - assert!(git.run(&["--version"], LIMIT).is_ok()); - } - - #[test] - fn configured_agent_selects_only_the_agent_socket() { - use super::configured_agent; - use std::ffi::OsString; - - let no_env = |_: &str| None; - assert_eq!( - configured_agent( - "hostname other\nidentityagent /tmp/agent.sock\nproxycommand ignored", - no_env - ) - .unwrap(), - Some(OsString::from("/tmp/agent.sock")) - ); - assert_eq!( - configured_agent("identityagent none", no_env).unwrap(), - Some(OsString::new()) - ); - assert_eq!( - configured_agent("identityagent SSH_AUTH_SOCK", no_env).unwrap(), - None - ); - assert_eq!( - configured_agent("hostname github.com", no_env).unwrap(), - None - ); - assert_eq!( - configured_agent("identityagent $BLOCK_AGENT", |key| (key == "BLOCK_AGENT") - .then(|| OsString::from("/tmp/block.sock"))) - .unwrap(), - Some(OsString::from("/tmp/block.sock")) - ); - assert!(configured_agent("identityagent $MISSING", no_env).is_err()); - assert_eq!( - configured_agent("identityagent /tmp/agent%u.sock", no_env).unwrap(), - Some(OsString::from("/tmp/agent%u.sock")) - ); - } - use super::*; - #[test] - fn install_enabled_saves_the_selected_revision_and_activation_together() { - let home = tempfile::tempdir().unwrap(); - let manager = Manager::open(Some(home.path().into()), "test", false).unwrap(); - let manifest = r#"{"id":"example","name":"Example","apiVersion":1}"#; - let first = uploaded(&[ - ("manifest.json", manifest), - ("plugin.js", "export default {old:true};"), - ]); - let first = prepare_upload(first).unwrap(); - let before = first.install(&manager, &first.preview.token, ".").unwrap(); - let previous = before - .plugins - .iter() - .find(|p| p.manifest.id == "example") - .unwrap() - .revision - .clone(); - let selected = uploaded(&[ - ("manifest.json", manifest), - ("plugin.js", "export default {selected:true};"), - ]); - let selected = prepare_upload(selected).unwrap(); - let catalog = selected - .install_enabled(&manager, &selected.preview.token, ".") - .unwrap(); - let plugin = catalog - .plugins - .iter() - .find(|p| p.manifest.id == "example") - .unwrap(); - assert_eq!(plugin.revision, selected.preview.candidates[0].revision); - assert_eq!(plugin.previous.as_ref(), Some(&previous)); - assert!(plugin.enabled); - assert!( - manager - .catalog() - .unwrap() - .plugins - .iter() - .find(|p| p.manifest.id == "example") - .unwrap() - .enabled - ); - } - fn uploaded(files: &[(&str, &str)]) -> FolderUpload { FolderUpload { source: "example".into(), @@ -1757,19 +1580,6 @@ mod tests { "ssh://dev.user@git.example.org/team/repo.git" ); assert!(repository_url("https://git.example.org/team/repo.git").is_ok()); - for authority in [ - "git@host;command", - "git@host`command`", - "git@host$(command)", - "git@host&command", - "git@host|command", - "git@host'command'", - "git@host%3bcommand", - "git%3bcommand@example.invalid", - "git;command@example.invalid", - ] { - assert!(repository_url(&format!("ssh://{authority}/owner/repo")).is_err()); - } for input in [ "/tmp/repo", "file:///tmp/repo", diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 28f6b6009d..cdcb27a3a6 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -13,7 +13,6 @@ use std::{ path::{Path, PathBuf}, }; -pub mod cli; pub mod imports; mod migrate_agent_flag; @@ -633,7 +632,7 @@ fn default_home() -> Result { }; let home = data.join(home); if !home.exists() && older.iter().any(|old| data.join(old).exists()) { - return Err(format!("{open}, then try again, or set BUZZODZ_HOME.")); + return Err(format!("{open}, then try again, or pass --home.")); } Ok(home) } @@ -841,14 +840,12 @@ impl Manager { self.install_artifact( &prepare_artifact(directory)?, Some(InstallSource::Folder(source)), - false, ) } fn install_artifact( &self, release: &Release, source: Option, - enable: bool, ) -> Result { let bytes = &release.bytes; let artifact = release.artifact()?; @@ -876,7 +873,7 @@ impl Manager { (Some(p.current.clone()), p.current_source.clone()) } }); - let enabled = enable || old.is_some_and(|p| p.enabled); + let enabled = old.is_some_and(|p| p.enabled); let previous_signature = old.and_then(|p| { if p.current == revision { p.previous_signature.clone() diff --git a/crates/plugin-manager/src/main.rs b/crates/plugin-manager/src/main.rs index dcfd0a1fe7..d5388c96c0 100644 --- a/crates/plugin-manager/src/main.rs +++ b/crates/plugin-manager/src/main.rs @@ -14,8 +14,6 @@ Commands: build DIRECTORY Run the project's pnpm build script sign DIST_DIRECTORY Sign with your saved Buzz human identity install DIRECTORY Install a built artifact (new plugins start disabled) - install-git REPO REF [PATH] [--enable] - Install a Git plugin (PATH defaults to .) reload ID Reload a folder plugin, preserving its enabled state list List installed pages and their enabled state enable ID | disable ID | remove ID | rollback ID @@ -101,7 +99,6 @@ fn run() -> Result<()> { installed.publisher.as_deref().unwrap_or("unsigned") ); } - ("install-git", args) => buzzodz_plugins::cli::install_git(&manager, args)?, ("reload", [id]) => { let before = manager.catalog()?; let after = manager.reload_live(id)?; diff --git a/docs/deep-links.md b/docs/deep-links.md index a235a83aaf..fd08a129c6 100644 --- a/docs/deep-links.md +++ b/docs/deep-links.md @@ -18,6 +18,7 @@ its `#buzz=` address form. The OS ingress accepts the Buzz link forms and nothing else: - `buzz://message?channel=&id=[&thread=]` +- `buzz://plugin-install?repository=[&ref=]` - `buzz://channel/` - `buzz://channel//` (the original desktop message alias) - `buzz://repo?owner=<64-hex key>&d=[&tab=
]` @@ -27,6 +28,14 @@ The OS ingress accepts the Buzz link forms and nothing else: - `buzz://connect?relay=` - `buzz://add-community?relay=[&name=]` +Plugin install links open **Settings > Plugins > Manage plugins** and the existing +**Add a plugin** dialog. They prefill the Git repository and optional branch or tag. +Use an explicit HTTPS or SSH URL and percent-encode each query value. Select +**Find plugins**, review the preview and computer access, then select **Install plugin**. +Opening the link does not fetch, install, enable, or grant access. This Settings +link needs no selected community or signed-in identity. An omitted ref uses the +repository default. Duplicate known parameters and repository credentials are rejected. + Both query-based invite and message links also accept a single `/` before `?`, as supplied by Windows URL activation (for example, `buzz://join/?relay=…&code=…`). Non-root paths remain unsupported for these forms; channel paths are unchanged. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 25e72d587c..b8a0110350 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -100,23 +100,21 @@ create a second connection, cache or outbox inside a page. The conversation prev source imports are not a versioned external SDK. See [conversation component ownership](channels.md#reusing-conversation-ui). -## The `buzzodz` CLI +## Open a Git import from a link -The installed desktop executable accepts `plugin install-git`. On macOS, install -a published Git plugin without a compiler or a separate tool: +Open `buzz://plugin-install?repository=&ref=` to prefill +**Settings > Plugins > Manage plugins > Add**. Use an explicit HTTPS or SSH URL. +Omit `ref` to use the repository default. For example, on macOS: ```sh -"/Applications/Buzz.app/Contents/MacOS/Buzz" plugin install-git git@github.com:OWNER/PLUGIN.git main --enable +open 'buzz://plugin-install?repository=https%3A%2F%2Fgithub.com%2FOWNER%2FPLUGIN.git&ref=main' ``` -`install-git REPO REF [PATH] [--enable]` uses the same importer as Settings. -`PATH` defaults to the repository root. It saves the Git source for later updates -in Settings and retains the previous revision for rollback. Stop active plugin -workers first. The command prints declared host access. `--enable` accepts that -access and enables the selected plugin. Without it, new installs start disabled -and existing enabled states stay the same. Install and enable commit together. -The command does not start or -restart the desktop UI, read an identity, or connect to a community relay. +The link opens the existing form. Select **Find plugins**, review the plugin and +computer access, then select **Install plugin**. Opening the link does not fetch +or install code. See [OS deep links](deep-links.md). + +## The `buzzodz` CLI Run `pnpm buzzodz --help` from this repository, or install the standalone executable: @@ -139,8 +137,8 @@ The CLI prints readable results and reports errors with a nonzero exit code. existing enabled state. `recover` backs up management settings and resets to bundled defaults, preserving artifacts for reinstallation. -In the standalone CLI, `--home ABSOLUTE_PATH` and `--profile NAME` precede `plugin`. -Both commands read `BUZZODZ_HOME` and `BUZZODZ_PROFILE` (default: `default`). Match these to +`--home ABSOLUTE_PATH` and `--profile NAME` precede `plugin`. The desktop and CLI +also read `BUZZODZ_HOME` and `BUZZODZ_PROFILE` (default: `default`). Match these to target the same instance. Otherwise, the desktop keeps profiles in its own application-data folder (`xyz.block.buzz.desktop/profiles` for the installed app, `xyz.block.buzz.desktop.dev/profiles` for development launches). A debug CLI @@ -1185,11 +1183,10 @@ requires deleting the plugin and adding it again. Git checks remain explicit in pane, using the remembered repository and branch/tag with fresh authorization; credentials/tokens are never saved with source metadata. SSH accepts explicit URLs and `user@host:owner/repository.git` shorthand, including organization-specific -usernames. Buzz reads `IdentityAgent` through `ssh -G` and passes that socket to -the transport. If the lookup fails or has no setting, it uses the inherited -`SSH_AUTH_SOCK`. -The transport uses existing known hosts and disables interactive prompts. -Each check reads metadata from its own temporary clone; only Update retains an +usernames. The importer uses the desktop process's inherited `SSH_AUTH_SOCK` and +known hosts, with interactive prompts and SSH config disabled. If normal Git uses +an `IdentityAgent` from SSH config, launch the app with that socket in +`SSH_AUTH_SOCK`; otherwise terminal Git can authenticate while the app cannot. Each check reads metadata from its own temporary clone; only Update retains an immutable installation preview. The source follows the current revision through update and rollback. Rollback requires confirmation in Settings and consumes the previous revision: it cannot toggle forward again. The @@ -1220,11 +1217,8 @@ Discovery never evaluates modules. Git is a required local tool: a shallow no-checkout clone reads committed blobs, without hooks, filters, submodules, LFS downloads or project scripts. Only HTTPS/SSH are allowed; inherited Git config, URL rewrites, credential helpers and interactive prompts are disabled. -For SSH, Buzz runs `ssh -G` to read the configured `IdentityAgent` socket. OpenSSH -evaluates the user's local SSH configuration for that lookup, including `Match exec`. -Only the socket value reaches the Git transport. The transport disables SSH config -and uses existing known hosts. Password entry, HTTPS private-repo sign-in, and -custom SSH-config aliases are not supported in this slice. +SSH uses the existing agent/known hosts, not user SSH config; password entry, HTTPS +private-repo sign-in and custom SSH-config aliases are not supported in this slice. Repository URLs/refs are passed as arguments, not a shell command. Private/internal HTTPS hosts are not prohibited; this is not a public-network-only policy. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 727da62c78..b99a1688cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -100,7 +100,7 @@ objc2-web-kit = { version = "=0.3.2", default-features = false, features = ["std webview2-com = "=0.39.1" windows-core = "=0.62.2" tauri-winrt-notification = "=0.8.1" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } [target.'cfg(target_os = "linux")'.dependencies] # Deep-link 2.6.2 requires rust-ini's two-field ParseOption from 0.21.1. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4d2f58f04c..caba166905 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,24 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - let args: Vec = std::env::args().skip(1).collect(); - if args.first().map(String::as_str) == Some("plugin") { - #[cfg(all(windows, not(debug_assertions)))] - unsafe { - windows_sys::Win32::System::Console::AttachConsole( - windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS, - ); - } - let result = match args.get(1).map(String::as_str) { - Some("install-git") => buzzodz_plugins::Manager::from_env(None) - .and_then(|manager| buzzodz_plugins::cli::install_git(&manager, &args[2..])), - _ => Err("Use plugin install-git REPO REF [PATH] [--enable].".into()), - }; - if let Err(message) = result { - eprintln!("Error: {message}"); - std::process::exit(1); - } - return; - } buzz_foundation_lib::run(); } diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index 8871d8ed46..20416ab806 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -1953,3 +1953,72 @@ it.each(["folder", "git"])( if (source === "git") expect(authorizeGit).toHaveBeenCalledTimes(3); }, ); + +it("prefills a linked Git source but waits for preview and install confirmation", async () => { + const source = { + repository: "https://github.com/example/plugin", + reference: "release/stable", + }; + const preview: ImportPreview = { + token: "linked-preview", + source: source.repository, + commit: "a".repeat(40), + warnings: [], + candidates: [ + { + path: ".", + manifest: { + id: "example.plugin", + name: "Example", + apiVersion: 1, + host: { files: true }, + }, + revision: "one", + }, + ], + }; + const git = vi.fn(async () => preview); + const installImport = vi.fn(async () => true); + const manager = { + imports: { git, discard: vi.fn(async () => {}) }, + installImport, + change: vi.fn(async () => {}), + } as unknown as PluginManager; + const view = render( + + + , + ); + const dialog = screen.getByRole("dialog", { name: "Add a plugin" }); + expect(within(dialog).getByLabelText("Git or GitHub repository")).toHaveValue( + source.repository, + ); + expect(within(dialog).getByLabelText("Branch or tag (optional)")).toHaveValue( + source.reference, + ); + expect(git).not.toHaveBeenCalled(); + expect(installImport).not.toHaveBeenCalled(); + await userEvent.click( + within(dialog).getByRole("button", { name: "Find plugins" }), + ); + const install = await screen.findByRole("button", { name: "Install plugin" }); + expect(git).toHaveBeenCalledExactlyOnceWith( + source.repository, + source.reference, + ); + expect(screen.getByRole("region", { name: "Computer access" })).toBeVisible(); + expect(installImport).not.toHaveBeenCalled(); + await userEvent.click(install); + await waitFor(() => + expect(installImport).toHaveBeenCalledExactlyOnceWith( + "linked-preview", + ".", + ), + ); + view.unmount(); +}); diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 50f0b3f710..36de789a5b 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -20,6 +20,7 @@ import { FolderOpenIcon } from "../shared/design-system/icons/index"; import { PluginAccessReview } from "./PluginAccessReview"; import type { PluginManager } from "../plugins/manager"; import type { Catalog, ImportPreview, PluginInfo } from "../plugins/types"; +import type { GitPluginSource } from "../features/navigation/targets"; function importLocation( preview: Pick, @@ -34,6 +35,8 @@ function importLocation( } export function PluginImport({ + initialGit, + requestSignal, initialUpdateAvailable = false, plugins, catalog, @@ -49,6 +52,8 @@ export function PluginImport({ onReviewHandled, returnFocus, }: { + initialGit?: GitPluginSource | undefined; + requestSignal?: AbortSignal | undefined; initialUpdateAvailable?: boolean; plugins: PluginManager; catalog: Catalog; @@ -68,7 +73,7 @@ export function PluginImport({ ) => Promise<{ repository: string; token: string } | null>; }) { const imports = plugins.imports; - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(!!initialGit); const [dragging, setDragging] = useState(false); const trigger = useRef(null); const checkAgain = useRef(null); @@ -77,11 +82,21 @@ export function PluginImport({ ); const savedSource = currentPlugin?.updateSource; const [repository, setRepository] = useState( - savedSource && "repository" in savedSource ? savedSource.repository : "", + initialGit?.repository ?? + (savedSource && "repository" in savedSource + ? savedSource.repository + : ""), ); const [reference, setReference] = useState( - savedSource && "reference" in savedSource ? savedSource.reference : "", + initialGit?.reference ?? + (savedSource && "reference" in savedSource ? savedSource.reference : ""), ); + useEffect(() => { + if (!initialGit || requestSignal?.aborted) return; + setRepository(initialGit.repository); + setReference(initialGit.reference); + setOpen(true); + }, [initialGit, requestSignal]); // Snapshot at review entry: our own install catalog refresh must not turn a // new install into an update while its enable operation is still pending. const [existing, setExisting] = useState(); diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index b2b7e83ee6..512fb5212f 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1308,6 +1308,18 @@ export function Settings({ )} {catalog ? ( { + const target = { + version: 1, + kind: "settings", + section: "plugins", + pluginImport: { repository: pluginRepository, reference: "release/stable" }, + }; + expect(buzzLinkKind(pluginLink)).toBe("buzz"); + expect(buzzLinkTarget(pluginLink, scope)).toEqual(target); + expect(deepLinkStep(pluginLink, { selected: null })).toEqual({ + open: target, + }); + expect( + buzzLinkTarget( + pluginLink.replace("plugin-install?", "plugin-install/?"), + scope, + ), + ).toEqual(target); + expect( + buzzLinkTarget(`${pluginLink}&enable=true&enable=false`, scope), + ).toEqual(target); +}); +it.each([ + "buzz://plugin-install", + `${pluginLink}&ref=other`, + `${pluginLink}&repository=https://example.com/plugin`, + pluginLink.replace("plugin-install?", "plugin-install/other?"), + pluginLink.replace("plugin-install?", "plugin-install/other/../?"), + ...[ + "file:///tmp/plugin", + "http://example.com/plugin", + "https://user:secret@example.com/plugin", + "https://example.com/plugin?token=secret", + "https://example.com/plugin#fragment", + ].map( + (repository) => + `buzz://plugin-install?repository=${encodeURIComponent(repository)}`, + ), +])("rejects invalid or ambiguous plugin import links: %s", (href) => { + expect(parseBuzzLink(href)).toBeNull(); + expect(deepLinkStep(href, clientForPluginLink)).toEqual({ + fail: "invalid-target", + }); +}); +const clientForPluginLink = { + selected: scope.communityOrigin, + viewer: scope.viewer, +}; diff --git a/src/features/navigation/buzz-links.ts b/src/features/navigation/buzz-links.ts index edbaec9147..43547e5a2c 100644 --- a/src/features/navigation/buzz-links.ts +++ b/src/features/navigation/buzz-links.ts @@ -3,9 +3,15 @@ import { parseEntityLink, type EntityRoute, } from "../repos/routes"; -import { parseOpenTarget, type NavigationScope } from "./targets"; +import { + parseGitPluginSource, + parseOpenTarget, + type GitPluginSource, + type NavigationScope, +} from "./targets"; type BuzzLink = + | { format: "plugin"; source: GitPluginSource } | { format: "removed" } | { format: "entity"; route: EntityRoute } | { @@ -39,6 +45,24 @@ export function parseBuzzLink(href: string): BuzzLink | null { url.hash ) return null; + if (url.hostname === "plugin-install") { + if ( + (url.pathname !== "" && + (url.pathname !== "/" || + !/^buzz:\/\/plugin-install\/\?/i.test(href))) || + ["repository", "ref"].some( + (key) => url.searchParams.getAll(key).length > 1, + ) + ) + return null; + return { + format: "plugin", + source: parseGitPluginSource({ + repository: url.searchParams.get("repository") ?? "", + reference: url.searchParams.get("ref") ?? "", + }), + }; + } if ( url.hostname === "project" && (url.pathname === "" || url.pathname === "/") @@ -111,6 +135,13 @@ export function buzzLinkKind(href: string) { export function buzzLinkTarget(href: string, scope: NavigationScope) { const link = parseBuzzLink(href); if (!link) return null; + if (link.format === "plugin") + return parseOpenTarget({ + version: 1, + kind: "settings", + section: "plugins", + pluginImport: link.source, + }); if (link.format === "entity") return entityTarget(link.route, scope); if (link.format === "removed") return { diff --git a/src/features/navigation/deep-links.ts b/src/features/navigation/deep-links.ts index 32242c2fff..dd91bc42d1 100644 --- a/src/features/navigation/deep-links.ts +++ b/src/features/navigation/deep-links.ts @@ -47,6 +47,15 @@ export function deepLinkStep( // of its own beyond the Buzz ones. if (!link) return { fail: "invalid-target" }; try { + if (link.format === "plugin") + return { + open: parseOpenTarget({ + version: 1, + kind: "settings", + section: "plugins", + pluginImport: link.source, + }), + }; if (!client.viewer || !client.selected) return { fail: "unavailable" }; if (link.format === "removed") return { diff --git a/src/features/navigation/targets.test.ts b/src/features/navigation/targets.test.ts index 30a5f3196f..7c860e884a 100644 --- a/src/features/navigation/targets.test.ts +++ b/src/features/navigation/targets.test.ts @@ -212,3 +212,22 @@ it("keeps explicit Personal space distinct from an unspecified page scope", () = expect(targetKey(personal)).not.toBe(targetKey(page)); expect(() => parseOpenTarget({ ...conversation, scope: null })).toThrow(); }); + +it("retains copied Git source fields only on the Plugins settings destination", () => { + const source = { + repository: "https://github.com/example/plugin", + reference: "release", + }; + const target = parseOpenTarget({ + version: 1, + kind: "settings", + section: "plugins", + pluginImport: source, + }); + source.reference = "changed"; + expect(target).toMatchObject({ pluginImport: { reference: "release" } }); + expect(() => parseOpenTarget({ ...target, section: "profile" })).toThrow(); + expect(() => + parseOpenTarget({ ...target, pluginImport: { ...source, enable: true } }), + ).toThrow(); +}); diff --git a/src/features/navigation/targets.ts b/src/features/navigation/targets.ts index 264bd0bad2..671394a2d7 100644 --- a/src/features/navigation/targets.ts +++ b/src/features/navigation/targets.ts @@ -11,6 +11,10 @@ export type NavigationScope = Readonly<{ viewer: string; communityOrigin: string; }>; +export type GitPluginSource = Readonly<{ + repository: string; + reference: string; +}>; export type OpenTarget = | Readonly<{ version: 1; kind: "home" }> | Readonly<{ @@ -18,6 +22,7 @@ export type OpenTarget = kind: "settings"; section?: string; scope?: NavigationScope | null; + pluginImport?: GitPluginSource; }> | Readonly<{ version: 1; @@ -69,6 +74,39 @@ function text(value: unknown, pattern = token): string { if (typeof value !== "string" || !pattern.test(value)) throw invalid(); return value; } +export function parseGitPluginSource(input: unknown): GitPluginSource { + const value = record(input); + fields(value, ["repository", "reference"]); + if ( + typeof value.repository !== "string" || + value.repository.length > 2048 || + typeof value.reference !== "string" || + value.reference.length > 256 + ) + throw invalid(); + if ( + /\s/.test(value.repository) || + [...(value.repository + value.reference)].some( + (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127, + ) + ) + throw invalid(); + const repository = new URL(value.repository); + if ( + !["https:", "ssh:"].includes(repository.protocol) || + !repository.hostname || + !repository.pathname.replaceAll("/", "") || + repository.password || + (repository.protocol === "https:" && repository.username) || + repository.search || + repository.hash + ) + throw invalid(); + return Object.freeze({ + repository: repository.href, + reference: value.reference, + }); +} function boundScope(input: unknown): NavigationScope { const value = record(input); fields(value, ["viewer", "communityOrigin"]); @@ -125,7 +163,9 @@ export function parseOpenTarget(input: unknown): OpenTarget { target = { version: 1, kind: "home" }; break; case "settings": - fields(value, ["version", "kind", "section", "scope"]); + fields(value, ["version", "kind", "section", "scope", "pluginImport"]); + if (value.pluginImport !== undefined && value.section !== "plugins") + throw invalid(); target = { version: 1, kind: "settings", @@ -135,6 +175,9 @@ export function parseOpenTarget(input: unknown): OpenTarget { ...(value.scope !== undefined ? { scope: value.scope === null ? null : boundScope(value.scope) } : {}), + ...(value.pluginImport !== undefined + ? { pluginImport: parseGitPluginSource(value.pluginImport) } + : {}), }; break; case "page": { diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index 46aa057478..8201857d85 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -27,9 +27,14 @@ async function openPlugins(page, origin) { await selectSettingsSection(page, "Manage plugins"); } // Native IPC is the boundary fixture; Settings -> manager -> platform adapter are production. -async function nativeImports(page, samples = [], previewOptions = {}) { - const input = { samples, previewOptions }; - await page.addInitScript(({ samples, previewOptions }) => { +async function nativeImports( + page, + samples = [], + previewOptions = {}, + links = [], +) { + const input = { samples, previewOptions, links }; + await page.addInitScript(({ samples, previewOptions, links }) => { window.isTauri = true; window.importCalls = []; window.importDelay = false; @@ -94,7 +99,7 @@ async function nativeImports(page, samples = [], previewOptions = {}) { }, invoke: async (command, args) => { window.importCalls.push({ command, args }); - if (command === "deep_link_take") return []; + if (command === "deep_link_take") return links.splice(0); if (command === "deep_link_watch") { const wireChannel = args.onEvent.toJSON(); if ( @@ -102,6 +107,14 @@ async function nativeImports(page, samples = [], previewOptions = {}) { !callbacks.has(args.onEvent.id) ) throw new Error("Invalid deep-link channel"); + let index = 0; + window.deliverPluginLink = (url) => { + links.push(url); + callbacks.get(args.onEvent.id)({ + index: index++, + message: { pending: links.length }, + }); + }; return; } if (command === "plugin_catalog") return ready(); @@ -1516,3 +1529,98 @@ for (const source of ["folder", "git"]) { ).toBeFocused(); }); } + +for (const mode of ["Light", "Dark"]) { + test(`plugin deep link opens the consent flow in ${mode} mode`, async ({ + page, + app, + }, info) => { + const repository = "ssh://git@github.com/example/plugin.git"; + const reference = "release/stable"; + const link = `buzz://plugin-install?repository=${encodeURIComponent(repository)}&ref=${encodeURIComponent(reference)}`; + await nativeImports( + page, + [ + { + path: ".", + manifest: { + id: "example.linked", + name: "Linked plugin", + apiVersion: 1, + host: { files: true }, + }, + revision: "one", + }, + ], + { warnings: [] }, + [link], + ); + await page.goto(app.origin); + const dialog = page.getByRole("dialog", { + name: "Add a plugin", + exact: true, + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Git or GitHub repository")).toHaveValue( + repository, + ); + await expect(dialog.getByLabel("Branch or tag (optional)")).toHaveValue( + reference, + ); + await expect + .poll(() => + page.evaluate( + () => + window.importCalls.filter((call) => + call.command.startsWith("plugin_import_"), + ).length, + ), + ) + .toBe(0); + await dialog.press("Escape"); + await expect(dialog).toBeHidden(); + await selectSettingsSection(page, "Appearance"); + await chooseColorMode(page, mode); + await page.evaluate((link) => window.deliverPluginLink(link), link); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Git or GitHub repository")).toHaveValue( + repository, + ); + await dialog.press("Escape"); + await expect(dialog).toBeHidden(); + await page.evaluate((link) => window.deliverPluginLink(link), link); + await expect(dialog).toBeVisible(); + await dialog + .getByRole("button", { name: "Find plugins", exact: true }) + .click(); + const install = page.getByRole("button", { + name: "Install plugin", + exact: true, + }); + await expect(install).toBeVisible(); + await expect( + page.getByRole("region", { name: "Computer access" }), + ).toBeVisible(); + expect( + await page.evaluate(() => + window.importCalls.filter( + (call) => call.command === "plugin_import_install", + ), + ), + ).toHaveLength(0); + await page.screenshot({ + path: info.outputPath(`plugin-link-${mode.toLowerCase()}.png`), + }); + await install.click(); + await expect + .poll(() => + page.evaluate( + () => + window.importCalls.filter( + (call) => call.command === "plugin_import_install", + ).length, + ), + ) + .toBe(1); + }); +} diff --git a/tests/integration/plugin-cli.test.mjs b/tests/integration/plugin-cli.test.mjs index eea83157e5..ca9b092869 100644 --- a/tests/integration/plugin-cli.test.mjs +++ b/tests/integration/plugin-cli.test.mjs @@ -1,13 +1,11 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { spawn, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; -import { createServer } from "node:net"; -import { once } from "node:events"; import { Context } from "@deepseek-ai/cordis"; const root = fileURLToPath(new URL("../../", import.meta.url)); @@ -216,98 +214,3 @@ test("failed source builds preserve the installed revision", async () => { } }); }); - -test("Ctrl-C stops Git descendants and removes the temporary clone", { - skip: process.platform === "win32", - timeout: 15000, -}, async (t) => { - const directory = await mkdtemp(path.join(tmpdir(), "bz-interrupt-")); - const socketPath = path.join(directory, "s"); - const sockets = []; - const transports = []; - const server = createServer(); - const ready = new Promise((resolve) => - server.on("connection", (socket) => { - sockets.push(socket); - let data = ""; - socket.on("data", (chunk) => { - data += chunk; - if (data.includes("\n")) { - transports.push(JSON.parse(data)); - if (transports.length === 2) resolve(); - } - }); - }), - ); - server.listen(socketPath); - await once(server, "listening", { signal: t.signal }); - const fixture = path.join(directory, "git"); - await writeFile( - fixture, - `#!/usr/bin/env node -const net = require("node:net"); -if (!process.argv.includes("--descendant")) { - require("node:child_process").spawn(process.execPath, [__filename, "--descendant"], { stdio: "ignore" }); -} -const socket = net.connect(${JSON.stringify(socketPath)}, () => { - socket.write(JSON.stringify({ pid: process.pid, cwd: process.cwd(), descendant: process.argv.includes("--descendant") }) + "\\n"); -}); -`, - { mode: 0o700 }, - ); - const child = spawn( - binary, - ["plugin", "install-git", "https://example.invalid/repo", "main"], - { - cwd: root, - env: { - ...process.env, - PATH: `${directory}${path.delimiter}${process.env.PATH}`, - BUZZODZ_HOME: directory, - }, - }, - ); - let errors = ""; - child.stderr.on("data", (chunk) => { - errors += chunk; - }); - const exited = once(child, "exit", { signal: t.signal }); - try { - await Promise.race([ - ready, - exited.then(() => { - throw new Error(errors || "CLI exits before Git starts"); - }), - ]); - const closed = sockets.map((socket) => - once(socket, "close", { signal: t.signal }), - ); - child.kill("SIGINT"); - const [code, signal] = await exited; - await Promise.all(closed); - assert.equal(code, 1, errors); - assert.equal(signal, null); - assert.match(errors, /Plugin installation interrupted/); - const transport = transports.find((entry) => !entry.descendant); - assert.throws(() => process.kill(transport.pid, 0), { code: "ESRCH" }); - assert.equal(existsSync(transport.cwd), false); - assert.equal( - existsSync(path.join(directory, "profiles/default/registry.json")), - false, - ); - } finally { - child.kill("SIGKILL"); - const transport = transports.find((entry) => !entry.descendant); - if (transport) { - try { - process.kill(-transport.pid, "SIGKILL"); - } catch (error) { - assert.equal(error.code, "ESRCH"); - } - await rm(transport.cwd, { recursive: true, force: true }); - } - for (const socket of sockets) socket.destroy(); - await new Promise((resolve) => server.close(resolve)); - await rm(directory, { recursive: true, force: true }); - } -}); From 6df398fd66b8abdb76da657b6d1c33f6966a712b Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 12:37:33 -0700 Subject: [PATCH 10/15] feat: preview linked Git plugins automatically Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/deep-links.md | 14 +++++------ docs/plugin-architecture.md | 8 +++--- src/app/PluginImport.test.tsx | 6 +---- src/app/PluginImport.tsx | 37 +++++++++++++++++++--------- tests/browser/plugin-import.spec.mjs | 34 ++++++++++--------------- 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/docs/deep-links.md b/docs/deep-links.md index fd08a129c6..dd62d4143d 100644 --- a/docs/deep-links.md +++ b/docs/deep-links.md @@ -28,13 +28,13 @@ The OS ingress accepts the Buzz link forms and nothing else: - `buzz://connect?relay=` - `buzz://add-community?relay=[&name=]` -Plugin install links open **Settings > Plugins > Manage plugins** and the existing -**Add a plugin** dialog. They prefill the Git repository and optional branch or tag. -Use an explicit HTTPS or SSH URL and percent-encode each query value. Select -**Find plugins**, review the preview and computer access, then select **Install plugin**. -Opening the link does not fetch, install, enable, or grant access. This Settings -link needs no selected community or signed-in identity. An omitted ref uses the -repository default. Duplicate known parameters and repository credentials are rejected. +Plugin install links open **Settings > Plugins > Manage plugins** and fetch a +preview from the Git repository and optional branch or tag. Use an explicit HTTPS +or SSH URL and percent-encode each query value. Review the plugin and computer +access, then select **Install plugin**. Opening the link does not install, enable, +or grant access. This Settings link needs no selected community or signed-in +identity. An omitted ref uses the repository default. Duplicate known parameters +and repository credentials are rejected. Both query-based invite and message links also accept a single `/` before `?`, as supplied by Windows URL activation (for example, `buzz://join/?relay=…&code=…`). diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 17414abbcc..ea42818a52 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -102,7 +102,7 @@ source imports are not a versioned external SDK. See ## Open a Git import from a link -Open `buzz://plugin-install?repository=&ref=` to prefill +Open `buzz://plugin-install?repository=&ref=` to open a preview in **Settings > Plugins > Manage plugins > Add**. Use an explicit HTTPS or SSH URL. Omit `ref` to use the repository default. For example, on macOS: @@ -110,9 +110,9 @@ Omit `ref` to use the repository default. For example, on macOS: open 'buzz://plugin-install?repository=https%3A%2F%2Fgithub.com%2FOWNER%2FPLUGIN.git&ref=main' ``` -The link opens the existing form. Select **Find plugins**, review the plugin and -computer access, then select **Install plugin**. Opening the link does not fetch -or install code. See [OS deep links](deep-links.md). +The link fetches a preview through the existing importer. Review the plugin and +computer access, then select **Install plugin**. Opening the link does not install, +enable, or grant access. See [OS deep links](deep-links.md). ## The `buzzodz` CLI diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index 20416ab806..e3134eda40 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -1954,7 +1954,7 @@ it.each(["folder", "git"])( }, ); -it("prefills a linked Git source but waits for preview and install confirmation", async () => { +it("previews a linked Git source once and waits for install confirmation", async () => { const source = { repository: "https://github.com/example/plugin", reference: "release/stable", @@ -2001,11 +2001,7 @@ it("prefills a linked Git source but waits for preview and install confirmation" expect(within(dialog).getByLabelText("Branch or tag (optional)")).toHaveValue( source.reference, ); - expect(git).not.toHaveBeenCalled(); expect(installImport).not.toHaveBeenCalled(); - await userEvent.click( - within(dialog).getByRole("button", { name: "Find plugins" }), - ); const install = await screen.findByRole("button", { name: "Install plugin" }); expect(git).toHaveBeenCalledExactlyOnceWith( source.repository, diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 36de789a5b..440f884cba 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -279,6 +279,22 @@ export function PluginImport({ }); }, [reviewRequested]); + const linkedRequest = useRef(undefined); + const startLinkedGit = useEffectEvent(() => { + if (!initialGit || requestSignal?.aborted || !imports || busy) return; + const request = requestSignal ?? initialGit; + if (linkedRequest.current === request) return; + linkedRequest.current = request; + void loadGit(initialGit.repository, initialGit.reference); + }); + useEffect(() => { + if (!initialGit || requestSignal?.aborted || !imports || busy) return; + const current = lifetime.current; + queueMicrotask(() => { + if (current.active) startLinkedGit(); + }); + }, [initialGit, requestSignal, imports, busy]); + const blocked = busy || loading || discarding; if (!imports) return null; @@ -356,6 +372,15 @@ export function PluginImport({ } } } + async function loadGit(repository: string, reference: string) { + if (!imports) return; + await load(async () => { + const signed = await authorizeGit?.(repository); + return signed + ? imports.git(signed.repository, reference, signed.token) + : imports.git(repository, reference); + }); + } async function dismiss() { if (blocked || pending.current || !preview) return; pending.current = true; @@ -837,17 +862,7 @@ export function PluginImport({ className={styles.git} onSubmit={(event) => { event.preventDefault(); - if (repository.trim()) - void load(async () => { - const signed = await authorizeGit?.(repository); - return signed - ? imports.git( - signed.repository, - reference, - signed.token, - ) - : imports.git(repository, reference); - }); + if (repository.trim()) void loadGit(repository, reference); }} > diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index 8201857d85..b233741495 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -1556,43 +1556,35 @@ for (const mode of ["Light", "Dark"]) { [link], ); await page.goto(app.origin); + const review = page.getByRole("dialog", { + name: "Review plugin access", + exact: true, + }); + await expect(review).toBeVisible(); + await expect( + page.getByRole("region", { name: "Computer access" }), + ).toBeVisible(); + await review.getByRole("button", { name: "Back", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Add a plugin", exact: true, }); - await expect(dialog).toBeVisible(); await expect(dialog.getByLabel("Git or GitHub repository")).toHaveValue( repository, ); await expect(dialog.getByLabel("Branch or tag (optional)")).toHaveValue( reference, ); - await expect - .poll(() => - page.evaluate( - () => - window.importCalls.filter((call) => - call.command.startsWith("plugin_import_"), - ).length, - ), - ) - .toBe(0); await dialog.press("Escape"); await expect(dialog).toBeHidden(); await selectSettingsSection(page, "Appearance"); await chooseColorMode(page, mode); await page.evaluate((link) => window.deliverPluginLink(link), link); - await expect(dialog).toBeVisible(); - await expect(dialog.getByLabel("Git or GitHub repository")).toHaveValue( - repository, - ); - await dialog.press("Escape"); - await expect(dialog).toBeHidden(); + await expect(review).toBeVisible(); + await review.press("Escape"); + await expect(review).toBeHidden(); await page.evaluate((link) => window.deliverPluginLink(link), link); - await expect(dialog).toBeVisible(); - await dialog - .getByRole("button", { name: "Find plugins", exact: true }) - .click(); + await expect(review).toBeVisible(); const install = page.getByRole("button", { name: "Install plugin", exact: true, From d4a4a7e9f32e1ddf4cb0d431c2b9c7502d564387 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 12:48:19 -0700 Subject: [PATCH 11/15] fix: retire completed plugin import visits Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/app/App.tsx | 3 + src/app/PluginImport.test.tsx | 123 +++++++++++++++++++++++++++ src/app/PluginImport.tsx | 18 ++-- src/app/Settings.tsx | 3 + src/app/navigation.ts | 7 ++ tests/browser/plugin-import.spec.mjs | 9 ++ 6 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 008c16734c..964a665a25 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -575,6 +575,9 @@ function ConnectedApp({ services }: { services: AppServices }) { ); }} onSection={openSettingsSection} + onPluginImportClosed={() => + route.dismissPluginImport(route.request) + } onProvider={route.setSettingsProvider} onUnsavedChange={setSettingsUnsaved} /> diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index e3134eda40..9d738634f2 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -2018,3 +2018,126 @@ it("previews a linked Git source once and waits for install confirmation", async ); view.unmount(); }); + +it.each([true, false])( + "keeps the confirmed enable choice after a new link replaces the dialog (%s)", + async (enabled) => { + let finish!: (success: boolean) => void; + const installed = new Promise((resolve) => { + finish = resolve; + }); + const change = vi.fn(async () => {}); + const onClosed = vi.fn(); + const manager = { + imports: { + git: vi.fn(async () => ({ + ...rememberedPreview, + candidates: [rememberedPreview.candidates[0]], + })), + discard: vi.fn(async () => {}), + }, + installImport: vi.fn(() => installed), + change, + } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const view = render( + , + ); + const install = await screen.findByRole("button", { + name: "Install plugin", + }); + if (!enabled) + await userEvent.click( + screen.getByRole("switch", { name: "Enable plugin" }), + ); + await userEvent.click(install); + expect(manager.installImport).toHaveBeenCalledTimes(1); + view.rerender( + , + ); + await act(async () => finish(true)); + if (enabled) + expect(change).toHaveBeenCalledExactlyOnceWith( + "enable", + rememberedPreview.candidates[0]?.manifest.id, + ); + else expect(change).not.toHaveBeenCalled(); + expect(onClosed).toHaveBeenCalledTimes(1); + }, +); + +it("keeps a late close bound to the replaced import", async () => { + let finish!: () => void; + const discarded = new Promise((resolve) => { + finish = resolve; + }); + const firstClosed = vi.fn(); + const secondClosed = vi.fn(); + const manager = { + imports: { + git: vi.fn(async () => ({ + ...rememberedPreview, + candidates: [rememberedPreview.candidates[0]], + })), + discard: vi.fn(() => discarded), + }, + } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const view = render( + , + ); + const review = await screen.findByRole("dialog", { + name: "Review plugin access", + }); + fireEvent.keyDown(review, { key: "Escape" }); + expect(manager.imports?.discard).toHaveBeenCalledTimes(1); + view.rerender( + , + ); + await screen.findByRole("dialog", { name: "Review plugin access" }); + await act(async () => finish()); + expect(firstClosed).toHaveBeenCalledTimes(1); + expect(secondClosed).not.toHaveBeenCalled(); + expect( + screen.getByRole("dialog", { name: "Review plugin access" }), + ).toBeVisible(); +}); diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 440f884cba..be2b2789af 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -399,13 +399,18 @@ export function PluginImport({ } async function close() { if (blocked || pending.current) return false; + const current = lifetime.current; const returnTo = document.activeElement; pending.current = true; setDiscarding(true); try { if (lifetime.current.token) await imports?.discard(lifetime.current.token); - lifetime.current.token = ""; + current.token = ""; + if (!current.active) { + onClosed?.(); + return false; + } setPreview(null); errorTrigger.current = null; setError(null); @@ -415,11 +420,11 @@ export function PluginImport({ onClosed?.(); return true; } catch (reason) { - showError(reason, returnTo); + if (current.active) showError(reason, returnTo); return false; } finally { pending.current = false; - setDiscarding(false); + if (current.active) setDiscarding(false); } } const rollbackWarning = @@ -743,8 +748,8 @@ export function PluginImport({ preview.token, candidate.path, ); - if (!current.active) return; if (!success) { + if (!current.active) return; showError( plugins.snapshot().error ?? "Plugin installation wasn’t confirmed. Check the preview and try again.", @@ -757,7 +762,10 @@ export function PluginImport({ await imports.discard(preview.token).catch(() => {}); if (!existing && enableOnInstall) await plugins.change("enable", candidate.manifest.id); - if (!current.active) return; + if (!current.active) { + onClosed?.(); + return; + } if (!updateId) setPreview(null); else if (existing) setExisting({ diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 65a0b089dd..b0f30e6066 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -592,6 +592,7 @@ export function Settings({ navigation, onSection, onProvider, + onPluginImportClosed, navigationPane = false, onDeparturePending, onUnsavedChange, @@ -613,6 +614,7 @@ export function Settings({ | undefined; onSection?: (section: string) => void; onProvider?: ((provider?: string) => void) | undefined; + onPluginImportClosed?: (() => void) | undefined; navigationPane?: boolean; onDeparturePending?: (pending: boolean) => void; onUnsavedChange?: (unsaved: boolean) => void; @@ -1327,6 +1329,7 @@ export function Settings({ : undefined } requestSignal={navigation?.signal} + onClosed={() => onPluginImportClosed?.()} actionTarget={pluginImportActions} plugins={plugins} catalog={catalog} diff --git a/src/app/navigation.ts b/src/app/navigation.ts index f587fc8d63..72f26c8bae 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -412,6 +412,13 @@ export function useAppNavigation( }, settings); } }, + dismissPluginImport(request: PageNavigation | undefined) { + const current = request?.target; + if (!request || current?.kind !== "settings" || !current.pluginImport) + return; + const { pluginImport: _pluginImport, ...settings } = current; + navigation.dismiss([request.entryId], settings); + }, leaveSettings() { const current = navigation.snapshot().entry.target; if (current.kind !== "settings") return; diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index b233741495..9032b43d3f 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -1578,6 +1578,10 @@ for (const mode of ["Light", "Dark"]) { await dialog.press("Escape"); await expect(dialog).toBeHidden(); await selectSettingsSection(page, "Appearance"); + await page.goBack(); + await expect(dialog).toBeHidden(); + await expect(review).toBeHidden(); + await selectSettingsSection(page, "Appearance"); await chooseColorMode(page, mode); await page.evaluate((link) => window.deliverPluginLink(link), link); await expect(review).toBeVisible(); @@ -1614,5 +1618,10 @@ for (const mode of ["Light", "Dark"]) { ), ) .toBe(1); + await expect(review).toBeHidden(); + await selectSettingsSection(page, "Appearance"); + await page.goBack(); + await expect(review).toBeHidden(); + await expect(dialog).toBeHidden(); }); } From ae9469557206709f78eb0d259a31d87983321027 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 12:54:43 -0700 Subject: [PATCH 12/15] fix: name the plugin in the access review Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/app/PluginImport.test.tsx | 8 +++--- src/app/PluginImport.tsx | 40 +++++++++++++++++----------- tests/browser/plugin-import.spec.mjs | 6 ++--- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index 9d738634f2..b105dddda5 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -1873,7 +1873,7 @@ it.each(["acquisition", "authorization"])( fireEvent.click( screen.getByRole("button", { name: "Browse plugin folder" }), ); - await screen.findByRole("dialog", { name: "Review plugin access" }); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); await act(async () => { release(); await gate; @@ -2118,7 +2118,7 @@ it("keeps a late close bound to the replaced import", async () => { />, ); const review = await screen.findByRole("dialog", { - name: "Review plugin access", + name: /^Review .+ access$/, }); fireEvent.keyDown(review, { key: "Escape" }); expect(manager.imports?.discard).toHaveBeenCalledTimes(1); @@ -2133,11 +2133,11 @@ it("keeps a late close bound to the replaced import", async () => { }} />, ); - await screen.findByRole("dialog", { name: "Review plugin access" }); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); await act(async () => finish()); expect(firstClosed).toHaveBeenCalledTimes(1); expect(secondClosed).not.toHaveBeenCalled(); expect( - screen.getByRole("dialog", { name: "Review plugin access" }), + screen.getByRole("dialog", { name: /^Review .+ access$/ }), ).toBeVisible(); }); diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index be2b2789af..2dbc853c96 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -602,21 +602,31 @@ export function PluginImport({ if (!next) void close(); }} title={ - showUnchanged - ? "Unchanged declarations" - : error || !preview - ? updateId - ? "Update plugin" - : "Add a plugin" - : reviewing - ? duplicate - ? "Already installed" - : existing - ? `Update ${existing.manifest.name}` - : "Review plugin access" - : preview.candidates.length - ? "Choose a plugin" - : "No plugins found" + showUnchanged ? ( + "Unchanged declarations" + ) : error || !preview ? ( + updateId ? ( + "Update plugin" + ) : ( + "Add a plugin" + ) + ) : reviewing ? ( + duplicate ? ( + "Already installed" + ) : existing ? ( + `Update ${existing.manifest.name}` + ) : ( + <> + Review{" "} + {candidate?.manifest.name ?? "Unknown"} Plugin{" "} + access + + ) + ) : preview.candidates.length ? ( + "Choose a plugin" + ) : ( + "No plugins found" + ) } description={ error || showUnchanged diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index 9032b43d3f..fc01700876 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -635,7 +635,7 @@ test("folder/Git preview selects the exact subfolder and preserves enabled state .screenshot({ path: info.outputPath("plugin-choose-light.png") }); await button(page, "Continue").click(); await expect(page.getByRole("dialog")).toHaveAccessibleName( - "Review plugin access", + /^Review .+ access$/, ); await expect(page.getByRole("dialog")).toHaveAccessibleDescription( "Only install code you trust.", @@ -1221,7 +1221,7 @@ test("Add keeps a rejected install reachable and retries the same preview", asyn const dialog = page .getByRole("dialog") .and(page.locator('[aria-modal="true"]')); - await expect(dialog).toHaveAccessibleName("Review plugin access"); + await expect(dialog).toHaveAccessibleName(/^Review .+ access$/); await expect(dialog.getByRole("radio")).toHaveCount(0); await expect( dialog.getByText("Choose a plugin folder", { exact: true }), @@ -1557,7 +1557,7 @@ for (const mode of ["Light", "Dark"]) { ); await page.goto(app.origin); const review = page.getByRole("dialog", { - name: "Review plugin access", + name: "Review Linked plugin Plugin access", exact: true, }); await expect(review).toBeVisible(); From 05f3212a933a8286483d5a583e9a6f5e5b06d50a Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 13:02:44 -0700 Subject: [PATCH 13/15] fix: wait for pending plugin acquisition Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/app/PluginImport.test.tsx | 58 +++++++++++++++++++++++++++++++++++ src/app/PluginImport.tsx | 25 +++++++++++++-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index b105dddda5..f01aa725db 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -2141,3 +2141,61 @@ it("keeps a late close bound to the replaced import", async () => { screen.getByRole("dialog", { name: /^Review .+ access$/ }), ).toBeVisible(); }); + +it("waits for an occupied native acquisition slot and previews the latest link", async () => { + let finish!: (preview: ImportPreview) => void; + const first = new Promise((resolve) => { + finish = resolve; + }); + let acquiring = false; + const git = vi.fn(async (repository: string) => { + if (acquiring) throw new Error("Another import is in progress"); + acquiring = true; + try { + return repository.endsWith("first.git") + ? await first + : { + ...rememberedPreview, + token: "latest", + candidates: rememberedPreview.candidates.slice(0, 1), + }; + } finally { + acquiring = false; + } + }); + const discard = vi.fn(async () => {}); + const manager = { imports: { git, discard } } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const source = (name: string) => ({ + repository: `https://example.com/${name}.git`, + reference: "", + }); + const view = render( + , + ); + await waitFor(() => expect(git).toHaveBeenCalledTimes(1)); + view.rerender( + , + ); + await screen.findByRole("status"); + view.rerender( + , + ); + await act(async () => + finish({ + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }), + ); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); + expect(git.mock.calls.map(([repository]) => repository)).toEqual([ + "https://example.com/first.git", + "https://example.com/latest.git", + ]); + expect(discard).toHaveBeenCalledWith("remembered"); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 2dbc853c96..004f66d9fc 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -22,6 +22,9 @@ import type { PluginManager } from "../plugins/manager"; import type { Catalog, ImportPreview, PluginInfo } from "../plugins/types"; import type { GitPluginSource } from "../features/navigation/targets"; +// The native importer has one acquisition slot, shared by successive dialogs. +const acquisitions = new WeakMap>(); + function importLocation( preview: Pick, path: string | undefined, @@ -299,7 +302,7 @@ export function PluginImport({ if (!imports) return null; async function load(operation: () => Promise) { - if (pending.current || blocked) return; + if (!imports || pending.current || blocked) return; const returnTo = document.activeElement as HTMLElement | null; sourceTrigger.current = returnTo; pending.current = true; @@ -309,8 +312,26 @@ export function PluginImport({ setPreview(null); setShowUnchanged(false); setEnableOnInstall(true); - try { + const previous = acquisitions.get(imports); + const acquisition = (async () => { + if (previous) await previous; + if (!current.active) return null; const result = await operation(); + if (!current.active && result) { + await imports?.discard(result.token); + return null; + } + return result; + })(); + acquisitions.set( + imports, + acquisition.then( + () => {}, + () => {}, + ), + ); + try { + const result = await acquisition; if (!result && updateId && savedSource) throw new Error("No update source found. Try again."); const next = From 8de5337553d935dcdbae55bac0a5af5669524f55 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 13:07:55 -0700 Subject: [PATCH 14/15] fix: retain confirmed plugin previews until install Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- src/app/PluginImport.test.tsx | 28 ++++++++++++++++++++-------- src/app/PluginImport.tsx | 27 ++++++++++++++++++++------- tests/browser/plugin-import.spec.mjs | 6 ++++-- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index f01aa725db..5f1da70e66 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -2028,15 +2028,24 @@ it.each([true, false])( }); const change = vi.fn(async () => {}); const onClosed = vi.fn(); + const tokens = new Set(); + const git = vi.fn(async (repository: string) => { + tokens.add(repository); + return { + ...rememberedPreview, + token: repository, + candidates: rememberedPreview.candidates.slice(0, 1), + }; + }); + const discard = vi.fn(async (token: string) => { + tokens.delete(token); + }); const manager = { - imports: { - git: vi.fn(async () => ({ - ...rememberedPreview, - candidates: [rememberedPreview.candidates[0]], - })), - discard: vi.fn(async () => {}), - }, - installImport: vi.fn(() => installed), + imports: { git, discard }, + installImport: vi.fn(async (token: string) => { + await installed; + return tokens.has(token); + }), change, } as unknown as PluginManager; const props = { @@ -2074,6 +2083,9 @@ it.each([true, false])( }} />, ); + await act(async () => {}); + expect(discard).not.toHaveBeenCalled(); + expect(git).toHaveBeenCalledTimes(1); await act(async () => finish(true)); if (enabled) expect(change).toHaveBeenCalledExactlyOnceWith( diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 004f66d9fc..fcf59e7a27 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -22,8 +22,8 @@ import type { PluginManager } from "../plugins/manager"; import type { Catalog, ImportPreview, PluginInfo } from "../plugins/types"; import type { GitPluginSource } from "../features/navigation/targets"; -// The native importer has one acquisition slot, shared by successive dialogs. -const acquisitions = new WeakMap>(); +// The native importer has one slot, shared by successive dialogs. +const importOperations = new WeakMap>(); function importLocation( preview: Pick, @@ -169,7 +169,7 @@ export function PluginImport({ setError(String(reason)); } const [enableOnInstall, setEnableOnInstall] = useState(true); - const lifetime = useRef({ active: true, token: "" }); + const lifetime = useRef({ active: true, token: "", installing: false }); const pending = useRef(false); async function checkSource(operation: () => Promise) { if (checkPending.current) return; @@ -202,7 +202,7 @@ export function PluginImport({ void checkSource(() => imports.checkFolder(updateId)); }); useEffect(() => { - const current = { active: true, token: "" }; + const current = { active: true, token: "", installing: false }; lifetime.current = current; // Defer until effects settle so Strict Mode's retired mount never acquires. queueMicrotask(() => { @@ -210,7 +210,8 @@ export function PluginImport({ }); return () => { current.active = false; - if (current.token) void imports?.discard(current.token).catch(() => {}); + if (current.token && !current.installing) + void imports?.discard(current.token).catch(() => {}); }; }, [imports]); @@ -312,7 +313,7 @@ export function PluginImport({ setPreview(null); setShowUnchanged(false); setEnableOnInstall(true); - const previous = acquisitions.get(imports); + const previous = importOperations.get(imports); const acquisition = (async () => { if (previous) await previous; if (!current.active) return null; @@ -323,7 +324,7 @@ export function PluginImport({ } return result; })(); - acquisitions.set( + importOperations.set( imports, acquisition.then( () => {}, @@ -771,6 +772,12 @@ export function PluginImport({ onClick={async () => { if (blocked || pending.current) return; const current = lifetime.current; + current.installing = true; + let release!: () => void; + const settled = new Promise((resolve) => { + release = resolve; + }); + importOperations.set(imports, settled); pending.current = true; setLoading(true); setError(null); @@ -806,6 +813,12 @@ export function PluginImport({ setOpen(false); onClosed?.(); } finally { + current.installing = false; + if (!current.active && current.token) { + await imports.discard(current.token).catch(() => {}); + current.token = ""; + } + release(); pending.current = false; if (current.active) setLoading(false); } diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index fc01700876..2307d07378 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -1221,13 +1221,15 @@ test("Add keeps a rejected install reachable and retries the same preview", asyn const dialog = page .getByRole("dialog") .and(page.locator('[aria-modal="true"]')); - await expect(dialog).toHaveAccessibleName(/^Review .+ access$/); + await expect(dialog).toHaveAccessibleName( + "Review Retry plugin Plugin access", + ); await expect(dialog.getByRole("radio")).toHaveCount(0); await expect( dialog.getByText("Choose a plugin folder", { exact: true }), ).toHaveCount(0); await expect( - dialog.getByRole("heading", { name: "Retry plugin" }), + dialog.getByRole("heading", { name: "Retry plugin", exact: true }), ).toHaveCount(0); await expect(dialog.getByText("example.retry", { exact: true })).toHaveCount( 0, From f422c4fd4b37b450e1aec5e1ef1c51645e339c34 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Fri, 30 Oct 2026 08:38:29 -0700 Subject: [PATCH 15/15] fix: unblock linked plugin previews Co-authored-by: Codex Signed-off-by: Daniel Rouleau --- docs/deep-links.md | 18 ++-- docs/plugin-architecture.md | 18 +++- src/app/PluginImport.test.tsx | 119 +++++++++++++++++++++ src/app/PluginImport.tsx | 31 +++++- src/features/navigation/buzz-links.test.ts | 19 ++++ src/features/navigation/deep-links.test.ts | 52 +++++++++ src/features/navigation/deep-links.ts | 24 +++-- src/features/navigation/targets.ts | 15 ++- tests/browser/plugin-import.spec.mjs | 71 +++++++++++- 9 files changed, 338 insertions(+), 29 deletions(-) diff --git a/docs/deep-links.md b/docs/deep-links.md index dd62d4143d..4507716f1c 100644 --- a/docs/deep-links.md +++ b/docs/deep-links.md @@ -29,12 +29,18 @@ The OS ingress accepts the Buzz link forms and nothing else: - `buzz://add-community?relay=[&name=]` Plugin install links open **Settings > Plugins > Manage plugins** and fetch a -preview from the Git repository and optional branch or tag. Use an explicit HTTPS -or SSH URL and percent-encode each query value. Review the plugin and computer -access, then select **Install plugin**. Opening the link does not install, enable, -or grant access. This Settings link needs no selected community or signed-in -identity. An omitted ref uses the repository default. Duplicate known parameters -and repository credentials are rejected. +preview from the Git repository and optional branch or tag. The repository accepts +HTTPS, `ssh://`, `git@host:path`, `github.com/owner/repo`, or `owner/repo`. +Percent-encode each query value. Review the plugin and computer access, then select +**Install plugin**. Opening the link does not install, enable, or grant access. +You can close a linked preview while Git loads; its late result is discarded. +An omitted `ref` uses the repository default. Duplicate known parameters and +repository credentials are rejected. + +The Settings target has no community scope. A new installation completes identity +setup before the app can show it. Repositories hosted by a Buzz community require +that community to be selected and connected for authentication. See +[Git imports and SSH agents](plugin-architecture.md#open-a-git-import-from-a-link). Both query-based invite and message links also accept a single `/` before `?`, as supplied by Windows URL activation (for example, `buzz://join/?relay=…&code=…`). diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index ea42818a52..f2ee769c81 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -102,17 +102,29 @@ source imports are not a versioned external SDK. See ## Open a Git import from a link -Open `buzz://plugin-install?repository=&ref=` to open a preview in -**Settings > Plugins > Manage plugins > Add**. Use an explicit HTTPS or SSH URL. +Open `buzz://plugin-install?repository=[&ref=]` +to open a preview in **Settings > Plugins > Manage plugins > Add**. The repository +accepts HTTPS, `ssh://`, `git@host:path`, `github.com/owner/repo`, or `owner/repo`. Omit `ref` to use the repository default. For example, on macOS: ```sh open 'buzz://plugin-install?repository=https%3A%2F%2Fgithub.com%2FOWNER%2FPLUGIN.git&ref=main' +open 'buzz://plugin-install?repository=git%40github.com%3AOWNER%2FPLUGIN.git&ref=main' ``` The link fetches a preview through the existing importer. Review the plugin and computer access, then select **Install plugin**. Opening the link does not install, -enable, or grant access. See [OS deep links](deep-links.md). +enable, or grant access. You can close the dialog while the preview loads. A late +result is discarded. Confirmed installation remains protected from dismissal. + +SSH uses the desktop process's inherited `SSH_AUTH_SOCK` and known hosts. +The importer disables SSH config and interactive prompts. If terminal Git uses +an `IdentityAgent`, launch Buzz with that socket as `SSH_AUTH_SOCK`. + +A new installation completes identity setup before the app can show the linked +preview. For a repository hosted by a Buzz community, select that community and +wait for its relay connection before opening the link. Other repositories do not +require a selected community. See [OS deep links](deep-links.md). ## The `buzzodz` CLI diff --git a/src/app/PluginImport.test.tsx b/src/app/PluginImport.test.tsx index 5f1da70e66..31eb8af13b 100644 --- a/src/app/PluginImport.test.tsx +++ b/src/app/PluginImport.test.tsx @@ -2211,3 +2211,122 @@ it("waits for an occupied native acquisition slot and previews the latest link", expect(discard).toHaveBeenCalledWith("remembered"); expect(screen.queryByRole("alert")).toBeNull(); }); + +it.each(["Escape", "Close"])( + "abandons a linked preview with %s and discards its late result", + async (action) => { + let finish!: (preview: ImportPreview) => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const git = vi + .fn() + .mockReturnValueOnce(gate) + .mockResolvedValue({ + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }); + const discard = vi.fn(async () => {}); + const installImport = vi.fn(); + const onClosed = vi.fn(); + const manager = { + imports: { git, discard }, + installImport, + } as unknown as PluginManager; + const props = { + plugins: manager, + catalog: { profile: "test", location: "test", plugins: [] }, + busy: false, + }; + const view = render( + , + ); + try { + await waitFor(() => expect(git).toHaveBeenCalledTimes(1)); + const dialog = screen.getByRole("dialog", { name: "Add a plugin" }); + expect( + within(dialog).getByRole("button", { name: "Close" }), + ).toBeEnabled(); + if (action === "Escape") fireEvent.keyDown(dialog, { key: "Escape" }); + else + await userEvent.click( + within(dialog).getByRole("button", { name: "Close" }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(onClosed).toHaveBeenCalledTimes(1); + finish({ + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }); + await waitFor(() => expect(discard).toHaveBeenCalledWith("remembered")); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(installImport).not.toHaveBeenCalled(); + view.rerender( + , + ); + await screen.findByRole("dialog", { name: /^Review .+ access$/ }); + expect(git).toHaveBeenCalledTimes(2); + } finally { + finish({ ...rememberedPreview, candidates: [] }); + } + }, +); + +it("keeps a manual acquisition protected after returning from a linked preview", async () => { + let finish!: (preview: ImportPreview) => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const preview = { + ...rememberedPreview, + candidates: rememberedPreview.candidates.slice(0, 1), + }; + const git = vi.fn().mockResolvedValueOnce(preview).mockReturnValueOnce(gate); + const manager = { + imports: { git, discard: vi.fn(async () => {}) }, + } as unknown as PluginManager; + render( + , + ); + try { + const review = await screen.findByRole("dialog", { + name: /^Review .+ access$/, + }); + await userEvent.click(within(review).getByRole("button", { name: "Back" })); + const dialog = screen.getByRole("dialog", { name: "Add a plugin" }); + await userEvent.click( + within(dialog).getByRole("button", { name: "Find plugins" }), + ); + await waitFor(() => expect(git).toHaveBeenCalledTimes(2)); + expect( + within(dialog).getByRole("button", { name: "Close" }), + ).toBeDisabled(); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(dialog).toBeVisible(); + } finally { + await act(async () => finish(preview)); + } +}); diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index fcf59e7a27..8f89e3ebb3 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -104,6 +104,7 @@ export function PluginImport({ // new install into an update while its enable operation is still pending. const [existing, setExisting] = useState(); const [loading, setLoading] = useState(false); + const [abandonable, setAbandonable] = useState(false); const [preview, setPreview] = useState(null); const [checkedRevision, setCheckedRevision] = useState(); const [checking, setChecking] = useState(false); @@ -289,7 +290,7 @@ export function PluginImport({ const request = requestSignal ?? initialGit; if (linkedRequest.current === request) return; linkedRequest.current = request; - void loadGit(initialGit.repository, initialGit.reference); + void loadGit(initialGit.repository, initialGit.reference, true); }); useEffect(() => { if (!initialGit || requestSignal?.aborted || !imports || busy) return; @@ -300,15 +301,25 @@ export function PluginImport({ }, [initialGit, requestSignal, imports, busy]); const blocked = busy || loading || discarding; + const canAbandonPreview = + abandonable && + loading && + !busy && + !discarding && + !lifetime.current.installing; if (!imports) return null; - async function load(operation: () => Promise) { + async function load( + operation: () => Promise, + linked = false, + ) { if (!imports || pending.current || blocked) return; const returnTo = document.activeElement as HTMLElement | null; sourceTrigger.current = returnTo; pending.current = true; const current = lifetime.current; setLoading(true); + setAbandonable(linked); if (!savedSource) setError(null); setPreview(null); setShowUnchanged(false); @@ -394,14 +405,18 @@ export function PluginImport({ } } } - async function loadGit(repository: string, reference: string) { + async function loadGit( + repository: string, + reference: string, + linked = false, + ) { if (!imports) return; await load(async () => { const signed = await authorizeGit?.(repository); return signed ? imports.git(signed.repository, reference, signed.token) : imports.git(repository, reference); - }); + }, linked); } async function dismiss() { if (blocked || pending.current || !preview) return; @@ -420,6 +435,12 @@ export function PluginImport({ } } async function close() { + if (canAbandonPreview) { + lifetime.current.active = false; + setOpen(false); + onClosed?.(); + return true; + } if (blocked || pending.current) return false; const current = lifetime.current; const returnTo = document.activeElement; @@ -836,7 +857,7 @@ export function PluginImport({ return true; }} dismissOnOutsideClick - preventClose={blocked} + preventClose={blocked && !canAbandonPreview} finalFocus={() => returnFocus?.() ?? trigger.current ?? checkAgain.current } diff --git a/src/features/navigation/buzz-links.test.ts b/src/features/navigation/buzz-links.test.ts index 010128aae7..5c63539d50 100644 --- a/src/features/navigation/buzz-links.test.ts +++ b/src/features/navigation/buzz-links.test.ts @@ -236,3 +236,22 @@ const clientForPluginLink = { selected: scope.communityOrigin, viewer: scope.viewer, }; + +it.each([ + ["git@github.com:org/repo.git", "ssh://git@github.com/org/repo.git"], + ["github.com/org/repo", "https://github.com/org/repo"], + ["org/repo", "https://github.com/org/repo"], +])( + "normalizes a Settings repository address in a plugin link: %s", + (input, repository) => { + const href = `buzz://plugin-install?repository=${encodeURIComponent(input)}`; + const target = { + version: 1, + kind: "settings", + section: "plugins", + pluginImport: { repository, reference: "" }, + }; + expect(buzzLinkTarget(href, scope)).toEqual(target); + expect(deepLinkStep(href, { selected: null })).toEqual({ open: target }); + }, +); diff --git a/src/features/navigation/deep-links.test.ts b/src/features/navigation/deep-links.test.ts index 3507896d9f..f18138b12a 100644 --- a/src/features/navigation/deep-links.test.ts +++ b/src/features/navigation/deep-links.test.ts @@ -743,3 +743,55 @@ it("does not revive a disposed bridge when native registration finishes", async await invoked.fn.mock.results[0]?.value; expect(listener).not.toHaveBeenCalled(); }); + +const localPluginLink = "buzz://plugin-install?repository=org%2Frepo"; +it.each(["viewer", "selected"] as const)( + "keeps a plugin link across a %s change while loading", + async (key) => { + const t = harness({ ...ready, status: "loading" }, [localPluginLink]); + try { + await vi.waitFor(() => expect(t.take).toHaveBeenCalledTimes(1)); + t.become({ + ...ready, + [key]: key === "viewer" ? "f".repeat(64) : elsewhere, + }); + await vi.waitFor(() => + expect(t.host.navigation.open).toHaveBeenCalledTimes(1), + ); + expect(t.host.fail).not.toHaveBeenCalled(); + expect(t.host.navigation.snapshot().entry.target).toMatchObject({ + kind: "settings", + section: "plugins", + }); + } finally { + t.stop(); + } + }, +); +it.each(["viewer", "selected"] as const)( + "keeps a pending plugin shell read across a %s change", + async (key) => { + const t = harness(ready); + let release!: (urls: string[]) => void; + try { + await vi.waitFor(() => expect(t.take).toHaveBeenCalledTimes(1)); + t.take.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + t.arrive(localPluginLink); + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + t.become({ [key]: key === "viewer" ? "f".repeat(64) : elsewhere }); + release([localPluginLink]); + await vi.waitFor(() => + expect(t.host.navigation.open).toHaveBeenCalledTimes(1), + ); + expect(t.host.fail).not.toHaveBeenCalled(); + } finally { + release?.([]); + t.stop(); + } + }, +); diff --git a/src/features/navigation/deep-links.ts b/src/features/navigation/deep-links.ts index dd91bc42d1..c7fe842ba9 100644 --- a/src/features/navigation/deep-links.ts +++ b/src/features/navigation/deep-links.ts @@ -110,6 +110,7 @@ export function bindDeepLinks( | { url: string; invite: InviteLink | null; + plugin: boolean; viewer: string | undefined; selected: string | null; reported: boolean; @@ -123,10 +124,11 @@ export function bindDeepLinks( const client = communities.snapshot(); const incoming = held; if ( - (incoming.viewer && incoming.viewer !== client.viewer) || - (!incoming.invite && - incoming.selected && - incoming.selected !== client.selected) + !incoming.plugin && + ((incoming.viewer && incoming.viewer !== client.viewer) || + (!incoming.invite && + incoming.selected && + incoming.selected !== client.selected)) ) { held = undefined; host.fail("denied"); @@ -161,8 +163,9 @@ export function bindDeepLinks( held.viewer ??= client.viewer; held.selected ??= client.selected; if ( - (held.viewer && held.viewer !== client.viewer) || - (!held.invite && held.selected && held.selected !== client.selected) + !held.plugin && + ((held.viewer && held.viewer !== client.viewer) || + (!held.invite && held.selected && held.selected !== client.selected)) ) { held = undefined; host.fail("denied"); @@ -190,11 +193,7 @@ export function bindDeepLinks( console.error("Could not read pending deep links", error); continue; } - if ( - closed || - epoch !== clientEpoch || - started.id !== host.navigation.snapshot().attempt.id - ) + if (closed || started.id !== host.navigation.snapshot().attempt.id) continue; let url: string | undefined; for (let index = urls.length - 1; index >= 0; index--) { @@ -205,10 +204,13 @@ export function bindDeepLinks( } } if (url === undefined) continue; + const plugin = parseBuzzLink(url)?.format === "plugin"; + if (epoch !== clientEpoch && !plugin) continue; const client = communities.snapshot(); held = { url, invite: parseInviteLink(url), + plugin, viewer: client.viewer, selected: client.selected, reported: false, diff --git a/src/features/navigation/targets.ts b/src/features/navigation/targets.ts index 078edc6cde..00f1af622c 100644 --- a/src/features/navigation/targets.ts +++ b/src/features/navigation/targets.ts @@ -96,10 +96,23 @@ export function parseGitPluginSource(input: unknown): GitPluginSource { ) ) throw invalid(); - const repository = new URL(value.repository); + const repositoryText = value.repository; + const scp = /^([^/@:]+@[^/:]+):(.+)$/.exec(repositoryText); + const expanded = + !repositoryText.includes("://") && scp + ? `ssh://${scp[1]}/${scp[2]}` + : repositoryText.startsWith("github.com/") + ? `https://${repositoryText}` + : !repositoryText.includes(":") && + repositoryText.split("/").length === 2 + ? `https://github.com/${repositoryText}` + : repositoryText; + const repository = new URL(expanded); if ( !["https:", "ssh:"].includes(repository.protocol) || !repository.hostname || + repository.hostname.startsWith("-") || + repository.username.startsWith("-") || !repository.pathname.replaceAll("/", "") || repository.password || (repository.protocol === "https:" && repository.username) || diff --git a/tests/browser/plugin-import.spec.mjs b/tests/browser/plugin-import.spec.mjs index 2307d07378..05f4d6e05b 100644 --- a/tests/browser/plugin-import.spec.mjs +++ b/tests/browser/plugin-import.spec.mjs @@ -32,12 +32,13 @@ async function nativeImports( samples = [], previewOptions = {}, links = [], + hold = false, ) { - const input = { samples, previewOptions, links }; - await page.addInitScript(({ samples, previewOptions, links }) => { + const input = { samples, previewOptions, links, hold }; + await page.addInitScript(({ samples, previewOptions, links, hold }) => { window.isTauri = true; window.importCalls = []; - window.importDelay = false; + window.importDelay = hold; const plugins = ["channels", "github", "bestie", "repos", "workflows"].map( (name) => ({ manifest: { id: `buzz.${name}`, name, apiVersion: 1 }, @@ -1627,3 +1628,67 @@ for (const mode of ["Light", "Dark"]) { await expect(dialog).toBeHidden(); }); } + +test("a linked preview can close during acquisition and a later link still opens", async ({ + page, + app, +}) => { + const link = "buzz://plugin-install?repository=org%2Frepo&ref=first"; + await nativeImports( + page, + [ + { + path: ".", + manifest: { + id: "example.linked", + name: "Linked", + apiVersion: 1, + host: { files: true }, + }, + revision: "one", + }, + ], + { warnings: [] }, + [link], + true, + ); + await page.goto(app.origin); + const loading = page.getByRole("dialog", { + name: "Add a plugin", + exact: true, + }); + await expect(loading).toBeVisible(); + await expect + .poll(() => page.evaluate(() => typeof window.resolveImport)) + .toBe("function"); + await expect( + loading.getByRole("button", { name: "Close", exact: true }), + ).toBeEnabled(); + await loading.press("Escape"); + await expect(loading).toBeHidden(); + await page.evaluate((link) => { + window.importDelay = false; + window.deliverPluginLink(link.replace("ref=first", "ref=next")); + }, link); + await expect(loading).toBeVisible(); + await page.evaluate(() => window.resolveImport()); + const review = page.getByRole("dialog", { + name: "Review Linked Plugin access", + exact: true, + }); + await expect(review).toBeVisible(); + expect( + await page.evaluate(() => + window.importCalls.filter( + (call) => call.command === "plugin_import_install", + ), + ), + ).toHaveLength(0); + expect( + await page.evaluate(() => + window.importCalls + .filter((call) => call.command === "plugin_import_git") + .map((call) => call.args.reference), + ), + ).toEqual(["first", "next"]); +});