From ff460e11d0b46d381110eb4089f4efccb3903916 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Thu, 16 Jul 2026 10:44:45 +0100 Subject: [PATCH 1/2] feat(cli): add gateway restart command --- docs/reference/cli.md | 9 ++ docs/services.md | 12 +++ docs/telegram.md | 17 ++-- src/main.rs | 24 +++++- src/restart.rs | 194 ++++++++++++++++++++++++++++++++++++++++++ tests/init_cli.rs | 57 +++++++++++++ 6 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 src/restart.rs diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cd51845..b884774 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -9,6 +9,7 @@ The default is `~/.push/config.toml`. | `push init [path]` | Create and Git-initialize the one assistant repository; defaults to `./assistant` | | `push` | Start the configured channel gateway and scheduler | | `push doctor` | Validate config, paths, channel requirements, and required backend binaries | +| `push restart` | Restart the managed gateway to load updated config | | `push job validate` | Validate every installed job; exits non-zero if any are invalid | | `push job list` | List valid and invalid jobs with backend or error | | `push job show ` | Print the parsed installed job | @@ -21,6 +22,7 @@ Examples: push init ~/Code/assistant push doctor push +push restart push job validate push job run repo-review push job runs repo-review @@ -29,6 +31,13 @@ push job runs repo-review Unknown commands and missing values fail with the accepted command forms. The CLI does not currently provide shell completion or a generated `--help` page. +`push restart` targets the service definitions documented by Push: +`com.owainlewis.push` under launchd on macOS and the `push.service` user unit +under systemd on Linux. The service definition controls its config path, +environment, and executable; `--config` does not override the service definition +for this command. Run `push doctor` separately when you want to validate those +settings from the current shell. + `push init` accepts an empty target, the selected config by itself, or a complete existing assistant layout. It refuses unrelated and partial non-empty directories, never overwrites an existing assistant file, persists one diff --git a/docs/services.md b/docs/services.md index 82d5b9d..7491862 100644 --- a/docs/services.md +++ b/docs/services.md @@ -104,6 +104,12 @@ launchctl print gui/$(id -u)/com.owainlewis.push tail -f ~/Library/Logs/push.err.log ~/Library/Logs/push.out.log ``` +After editing `~/.push/config.toml`, restart the gateway with: + +```sh +push restart +``` + After changing the plist: ```sh @@ -158,6 +164,12 @@ systemctl --user status push.service journalctl --user -u push.service -f ``` +After editing `~/.push/config.toml`, restart the gateway with: + +```sh +push restart +``` + For voice support, prefer `voice.openai_api_key` in `~/.push/config.toml`. As an alternative, create the optional private environment file: diff --git a/docs/telegram.md b/docs/telegram.md index 3587361..ab36041 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -60,21 +60,16 @@ voice transcription and spoken replies: openai_api_key = "your-api-key" ``` -Validate the config, then restart the one existing Push process: +Restart the managed gateway to load the updated config: ```sh -push doctor +push restart ``` -For a foreground process, stop it and run `push` again. For launchd, use: - -```sh -launchctl kickstart -k gui/$(id -u)/com.owainlewis.push -``` - -See [Running Push as a Service](services.md) for the full launchd and systemd -restart commands. Do not start a foreground gateway while the service is -running because Telegram permits only one poller for a bot token. +For a foreground process, stop it, run `push doctor`, and run `push` again. +See [Running Push as a Service](services.md) for launchd and systemd setup. Do +not start a foreground gateway while the service is running because Telegram +permits only one poller for a bot token. `OPENAI_API_KEY` remains available for CI and service secret injection. A non-empty environment value overrides the configured key. diff --git a/src/main.rs b/src/main.rs index 56f4b0e..c7ede3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod imessage; mod jobs; mod pi; mod rehydration; +mod restart; mod soul; mod store; mod telegram; @@ -63,6 +64,7 @@ async fn main() -> Result<()> { Ok(()) } Command::Doctor => doctor::doctor(&args.config_path), + Command::Restart => restart::gateway(), Command::Job(command) => run_job_command(&args.config_path, command).await, Command::Run => { let cfg = load_run_config(&args.config_path)?; @@ -122,6 +124,7 @@ enum Command { Run, Init(String), Doctor, + Restart, Job(JobCommand), } @@ -159,6 +162,7 @@ impl Args { ["init"] => Command::Init("./assistant".to_string()), ["init", path] => Command::Init((*path).to_string()), ["doctor"] => Command::Doctor, + ["restart"] => Command::Restart, ["job", "validate"] => Command::Job(JobCommand::Validate), ["job", "list"] => Command::Job(JobCommand::List), ["job", "show", name] => Command::Job(JobCommand::Show((*name).to_string())), @@ -166,7 +170,7 @@ impl Args { ["job", "runs"] => Command::Job(JobCommand::Runs(None)), ["job", "runs", name] => Command::Job(JobCommand::Runs(Some((*name).to_string()))), _ => bail!( - "unknown command; expected init [path], doctor, job validate, job list, job show , job run , job runs [], or --config " + "unknown command; expected init [path], doctor, restart, job validate, job list, job show , job run , job runs [], or --config " ), }; Ok(Self { @@ -306,6 +310,24 @@ mod tests { ); } + #[test] + fn parses_restart_with_config_path() { + let args = Args::parse(vec![ + "--config".to_string(), + "custom.toml".to_string(), + "restart".to_string(), + ]) + .unwrap(); + + assert_eq!( + args, + Args { + command: Command::Restart, + config_path: "custom.toml".to_string(), + } + ); + } + #[test] fn parses_all_job_commands_with_config_anywhere() { assert_eq!( diff --git a/src/restart.rs b/src/restart.rs new file mode 100644 index 0000000..3551802 --- /dev/null +++ b/src/restart.rs @@ -0,0 +1,194 @@ +use std::process::Command; + +use anyhow::{bail, Context, Result}; + +const LAUNCHD_LABEL: &str = "com.owainlewis.push"; +const SYSTEMD_UNIT: &str = "push.service"; + +pub fn gateway() -> Result<()> { + let command = platform_command()?; + let message = execute(&command, |command| { + let mut process = Command::new(command.program); + process.args(&command.args); + let status = process.status()?; + Ok(ProcessStatus { + success: status.success(), + description: status.to_string(), + }) + })?; + println!("{message}"); + Ok(()) +} + +struct ProcessStatus { + success: bool, + description: String, +} + +#[derive(Debug, PartialEq, Eq)] +struct PlatformCommand { + program: &'static str, + args: Vec, +} + +impl PlatformCommand { + fn display(&self) -> String { + std::iter::once(self.program.to_string()) + .chain(self.args.iter().cloned()) + .collect::>() + .join(" ") + } +} + +fn execute( + command: &PlatformCommand, + runner: impl FnOnce(&PlatformCommand) -> std::io::Result, +) -> Result<&'static str> { + let status = runner(command).with_context(|| format!("run {}", command.display()))?; + if !status.success { + bail!( + "gateway restart failed: {} exited with {}", + command.display(), + status.description + ); + } + Ok("Restarted the Push gateway.") +} + +fn platform_command() -> Result { + command_for(std::env::consts::OS, effective_user_id()?.as_deref()) +} + +fn command_for(os: &str, user_id: Option<&str>) -> Result { + match os { + "macos" => { + let user_id = user_id.context("determine current user id for launchd")?; + Ok(PlatformCommand { + program: "launchctl", + args: vec![ + "kickstart".to_string(), + "-k".to_string(), + format!("gui/{user_id}/{LAUNCHD_LABEL}"), + ], + }) + } + "linux" => Ok(PlatformCommand { + program: "systemctl", + args: vec![ + "--user".to_string(), + "restart".to_string(), + SYSTEMD_UNIT.to_string(), + ], + }), + _ => bail!("gateway restart is supported only on macOS and Linux"), + } +} + +fn effective_user_id() -> Result> { + if std::env::consts::OS != "macos" { + return Ok(None); + } + let output = Command::new("id").arg("-u").output().context("run id -u")?; + if !output.status.success() { + bail!("id -u exited with {}", output.status); + } + let user_id = String::from_utf8(output.stdout).context("read user id")?; + let user_id = user_id.trim(); + if user_id.is_empty() || !user_id.bytes().all(|byte| byte.is_ascii_digit()) { + bail!("id -u returned an invalid user id"); + } + Ok(Some(user_id.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_restarts_the_documented_launchd_service() { + assert_eq!( + command_for("macos", Some("501")).unwrap(), + PlatformCommand { + program: "launchctl", + args: vec![ + "kickstart".to_string(), + "-k".to_string(), + "gui/501/com.owainlewis.push".to_string(), + ], + } + ); + } + + #[test] + fn linux_restarts_the_documented_user_service() { + assert_eq!( + command_for("linux", None).unwrap(), + PlatformCommand { + program: "systemctl", + args: vec![ + "--user".to_string(), + "restart".to_string(), + "push.service".to_string(), + ], + } + ); + } + + #[test] + fn unsupported_platform_reports_the_supported_hosts() { + let error = command_for("windows", None).unwrap_err(); + + assert!(error + .to_string() + .contains("supported only on macOS and Linux")); + } + + #[test] + fn successful_restart_reports_completion() { + let command = command_for("linux", None).unwrap(); + + let message = execute(&command, |_| { + Ok(ProcessStatus { + success: true, + description: "exit status: 0".to_string(), + }) + }) + .unwrap(); + + assert_eq!(message, "Restarted the Push gateway."); + } + + #[test] + fn failed_restart_reports_the_command_and_status() { + let command = command_for("linux", None).unwrap(); + + let error = execute(&command, |_| { + Ok(ProcessStatus { + success: false, + description: "exit status: 5".to_string(), + }) + }) + .unwrap_err(); + + assert!(error + .to_string() + .contains("systemctl --user restart push.service exited with exit status: 5")); + } + + #[test] + fn restart_spawn_failure_reports_the_command() { + let command = command_for("linux", None).unwrap(); + + let error = execute(&command, |_| { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing service manager", + )) + }) + .unwrap_err(); + + assert!(error + .to_string() + .contains("run systemctl --user restart push.service")); + } +} diff --git a/tests/init_cli.rs b/tests/init_cli.rs index 704ecbd..643e844 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -163,6 +163,63 @@ fn job_without_default_config_reports_init_guidance() { assert_missing_default_config_guidance(&["job", "list"]); } +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[test] +fn restart_invokes_the_platform_service_manager() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_dir("restart-service-manager"); + let bin_dir = root.join("bin"); + let args_path = root.join("args"); + std::fs::create_dir(&bin_dir).unwrap(); + let manager = if cfg!(target_os = "macos") { + "launchctl" + } else { + "systemctl" + }; + let manager_path = bin_dir.join(manager); + std::fs::write( + &manager_path, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > {:?}\n", args_path), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&manager_path).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&manager_path, permissions).unwrap(); + let path = std::env::join_paths( + std::iter::once(bin_dir.clone()).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )), + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .arg("restart") + .env("PATH", path) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "Restarted the Push gateway." + ); + let args = std::fs::read_to_string(args_path).unwrap(); + if cfg!(target_os = "macos") { + let lines = args.lines().collect::>(); + assert_eq!(&lines[..2], &["kickstart", "-k"]); + assert!(lines[2].starts_with("gui/")); + assert!(lines[2].ends_with("/com.owainlewis.push")); + } else { + assert_eq!(args, "--user\nrestart\npush.service\n"); + } + let _ = std::fs::remove_dir_all(root); +} + fn assert_missing_default_config_guidance(args: &[&str]) { let root = temp_dir("missing-default-config-command"); let home = root.join("home"); From 17b627d661284992701a154120475e9aef760da3 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Thu, 16 Jul 2026 10:53:09 +0100 Subject: [PATCH 2/2] fix(test): avoid shell path interpolation --- tests/init_cli.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/init_cli.rs b/tests/init_cli.rs index 643e844..3f65693 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -180,7 +180,7 @@ fn restart_invokes_the_platform_service_manager() { let manager_path = bin_dir.join(manager); std::fs::write( &manager_path, - format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > {:?}\n", args_path), + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$PUSH_RESTART_ARGS_PATH\"\n", ) .unwrap(); let mut permissions = std::fs::metadata(&manager_path).unwrap().permissions(); @@ -196,6 +196,7 @@ fn restart_invokes_the_platform_service_manager() { let output = Command::new(env!("CARGO_BIN_EXE_push")) .arg("restart") .env("PATH", path) + .env("PUSH_RESTART_ARGS_PATH", &args_path) .output() .unwrap();