From 8f2a99db7dad53a9746517a568699f80b45137ee Mon Sep 17 00:00:00 2001 From: Ethan Pailes Date: Sat, 9 May 2026 17:45:35 +0000 Subject: [PATCH] feat: add --startup-cmd flag This patch adds a new --startup-cmd flag which allows you to register a setup hook for a session so you can immediately get to work after session creation. Startup cmds support variable templates the same way that session names do, allowing your setup hooks to depend on shpool variables. While I was at it I added template support to --dir and --cmd since I realized it was a bit weird to support templates in --startup-cmd but not either of those flags. I also documented the template feature in the README. --- README.md | 51 ++++++- libshpool/src/attach.rs | 126 +++++++++++++----- libshpool/src/daemon/mod.rs | 2 +- libshpool/src/daemon/server.rs | 17 ++- libshpool/src/daemon/shell.rs | 7 +- .../src/daemon/{prompt.rs => shell_inject.rs} | 18 ++- libshpool/src/lib.rs | 29 +++- shpool-protocol/src/lib.rs | 10 ++ shpool/tests/attach.rs | 102 ++++++++++++++ shpool/tests/support/daemon.rs | 5 + 10 files changed, 313 insertions(+), 54 deletions(-) rename libshpool/src/daemon/{prompt.rs => shell_inject.rs} (94%) diff --git a/README.md b/README.md index 7efbed28..0c1862a3 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,38 @@ You can customize some of `shpool`s behavior by editing your `~/.config/shpool/config.toml` file. For an in depth discussion of configuration options see [CONFIG.md](./CONFIG.md). +### Templates + +`shpool` supports a template syntax for generating values based on a +central list of variables. This operates much like the shell environment. +In shpool templates, variable substitution used `{var}` syntax. + +Currently templates are supported in the following places: + +* session names +* the `attach --dir` flag +* the `attach --cmd` flag +* the `attach --start-cmd` flag + +The main purpose of templates is to support switching multiple sessions at +once. Whenever a variable is changed with `shpool var set +`, the shpool daemon will broadcast the complete variable set +to all `shpool attach` processes so they can recompute all their templates. +If the session name has changed as a result of this re-evaluation process, +the `shpool attach` process will automatically hang up and reconnect to the +new session. This allows you to have multiple terminals open that all switch +the shpool session they are attached to at once. For example, if you start +with the variable setting `{workspace=shpool}` and then run `shpool attach +'{workspace}-edit'` in one terminal and `shpool attach '{workspace}-main'`, +you would initially connect to the `shpool-edit` and `shpool-main` sessions. +You use these sessions to work on a patch for shpool for a while, but then +halfway through you have to quickly fix a bug in your company's codebase +so you run `shpool var set workspace yourco`. `shpool` will automatically +disconnect from `shpool-edit` and `shpool-main` and connect to `yourco-edit` +and `yourco-main`. After you finish your quick fix, you run `shpool var set +workspace shpool` and you're right back where you were when you were working +on the shpool patch. + ### Keybindings `shpool` supports keybindings (well really for the moment it @@ -129,6 +161,10 @@ to your `~/.bashrc`. ### Subcommands +#### shpool version + +Show the current shpool version. + #### shpool daemon The `daemon` subcommand causes `shpool` to run in daemon mode. When running in @@ -147,7 +183,8 @@ session will last. #### shpool list -Lists all the current shell sessions. +Lists all the current shell sessions. Supports a --json flag for a more machine +friendly output format. #### shpool detach @@ -159,6 +196,18 @@ session with no session name arguments. Kills a named shell session. +#### shpool var + +Manipulate shpool variables. Variables can be used in shpool session names using +`{var}` syntax. See the templates section above for more on how to use shpool +variables. + +#### shpool set-log-level + +Dynamically change the logging level of the shpool daemon. This is a diagnostic +tool to aid in debugging when the daemon gets in a bad state without having to +run at a verbose logging level all the time. + ### (Optional) Automatically Connect to shpool #### Explicitly named sessions diff --git a/libshpool/src/attach.rs b/libshpool/src/attach.rs index 3f15d22d..f96bec3d 100644 --- a/libshpool/src/attach.rs +++ b/libshpool/src/attach.rs @@ -41,6 +41,7 @@ const MAX_FORCE_RETRIES: usize = 20; #[allow(clippy::too_many_arguments)] pub fn run( + socket: PathBuf, config_manager: config::Manager, name: String, force: bool, @@ -48,15 +49,32 @@ pub fn run( ttl: Option, cmd: Option, dir: Option, - socket: PathBuf, + start_cmd: Option, ) -> anyhow::Result<()> { info!("\n\n======================== STARTING ATTACH ============================\n\n"); test_hooks::emit("attach-startup"); - let session_name_tmpl = template::Template::new(&name).context("parsing session name tmpl")?; + let tmpls = Templates { + session_name: template::Template::new(&name).context("parsing session name tmpl")?, + dir: if let Some(d) = dir { + Some(template::Template::new(&d).context("parsing dir tmpl")?) + } else { + None + }, + cmd: if let Some(c) = cmd { + Some(template::Template::new(&c).context("parsing cmd tmpl")?) + } else { + None + }, + start_cmd: if let Some(c) = start_cmd { + Some(template::Template::new(&c).context("parsing start cmd tmpl")?) + } else { + None + }, + }; let ttl = match &ttl { - Some(src) => match duration::parse(src.as_str()) { + Some(src) => match duration::parse(src) { Ok(d) => Some(d), Err(e) => { bail!("could not parse ttl: {:?}", e); @@ -65,20 +83,17 @@ pub fn run( None => None, }; - let attach = - Attach { config_manager, session_name_tmpl, force, background, ttl, cmd, dir, socket }; + let attach = Attach { config_manager, force, background, ttl, tmpls, socket }; attach.run() } struct Attach { config_manager: config::Manager, - session_name_tmpl: template::Template, force: bool, background: bool, ttl: Option, - cmd: Option, - dir: Option, + tmpls: Templates, socket: PathBuf, } @@ -93,49 +108,49 @@ impl Attach { let mut maybe_switch: MaybeSwitch = client.read_reply().context("reading reply")?; let var_map = maybe_switch.vars.iter().cloned().collect(); - let mut resolved_name = self.session_name_tmpl.apply(&var_map); + let mut resolved = self.tmpls.apply(&var_map); let sig_handler_session_name_slot = if !self.background { - Some(SignalHandler::new(resolved_name.clone(), self.socket.clone()).spawn()?) + Some(SignalHandler::new(resolved.session_name.clone(), self.socket.clone()).spawn()?) } else { None }; info!("looping on attach_with_name"); loop { - info!("attaching to '{}'", resolved_name); - match self.attach_with_name(resolved_name) { + info!("attaching to '{}'", resolved.session_name); + match self.attach_resolved(resolved) { Ok(AttachResult::Done) => return Ok(()), Ok(AttachResult::Switch(s)) => maybe_switch = s, Err(e) => return Err(e), } let var_map = maybe_switch.vars.iter().cloned().collect(); - resolved_name = self.session_name_tmpl.apply(&var_map); + resolved = self.tmpls.apply(&var_map); if let Some(ref slot) = sig_handler_session_name_slot { let mut slot = slot.lock().unwrap(); - *slot = resolved_name.clone(); + *slot = resolved.session_name.clone(); } } } /// Attach with the given resolved name. This will run until exit or until /// we need to reconnect due to - pub fn attach_with_name(&self, resolved_name: String) -> anyhow::Result { - if resolved_name.is_empty() { + pub fn attach_resolved(&self, resolved: ResolvedTemplates) -> anyhow::Result { + if resolved.session_name.is_empty() { eprintln!("blank session names are not allowed"); return Ok(AttachResult::Done); } - if resolved_name.contains(char::is_whitespace) { - eprintln!("session name '{}' may not have whitespace", resolved_name); + if resolved.session_name.contains(char::is_whitespace) { + eprintln!("session name '{}' may not have whitespace", resolved.session_name); return Ok(AttachResult::Done); } - if resolved_name.chars().any(|c| '/' == c) { + if resolved.session_name.chars().any(|c| '/' == c) { eprintln!("session names may not contain slashes"); return Ok(AttachResult::Done); } - if resolved_name == "." || resolved_name == ".." { + if resolved.session_name == "." || resolved.session_name == ".." { eprintln!("session names may not be special directory names"); return Ok(AttachResult::Done); } @@ -143,11 +158,14 @@ impl Attach { let mut detached = false; let mut tries = 0; let attach_client = loop { - match self.dial_attach(resolved_name.as_str()) { + match self.dial_attach(&resolved) { Ok(client) => break client, Err(err) => match err.downcast() { Ok(BusyError) if !self.force => { - eprintln!("session '{resolved_name}' already has a terminal attached"); + eprintln!( + "session '{}' already has a terminal attached", + resolved.session_name + ); return Ok(AttachResult::Done); } Ok(BusyError) => { @@ -155,13 +173,16 @@ impl Attach { let mut client = self.dial_client(true)?; client .write_connect_header(ConnectHeader::Detach(DetachRequest { - sessions: vec![resolved_name.clone()], + sessions: vec![resolved.session_name.clone()], })) .context("writing detach request header")?; let detach_reply: DetachReply = client.read_reply().context("reading reply")?; if !detach_reply.not_found_sessions.is_empty() { - warn!("could not find session '{}' to detach it", resolved_name); + warn!( + "could not find session '{}' to detach it", + resolved.session_name + ); } detached = true; @@ -169,7 +190,8 @@ impl Attach { thread::sleep(time::Duration::from_millis(100)); if tries > MAX_FORCE_RETRIES { - eprintln!("session '{resolved_name}' already has a terminal which remains attached even after attempting to detach it"); + eprintln!("session '{}' already has a terminal which remains attached even after attempting to detach it", + resolved.session_name); return Err(anyhow!("could not detach session, forced attach failed")); } tries += 1; @@ -188,27 +210,27 @@ impl Attach { let mut client = self.dial_client(true)?; client .write_connect_header(ConnectHeader::Detach(DetachRequest { - sessions: vec![resolved_name.clone()], + sessions: vec![resolved.session_name.clone()], })) .context("writing detach request header")?; let detach_reply: DetachReply = client.read_reply().context("reading reply")?; if !detach_reply.not_found_sessions.is_empty() { - warn!("could not find session '{}' to detach it", resolved_name); + warn!("could not find session '{}' to detach it", resolved.session_name); } if !detach_reply.not_attached_sessions.is_empty() { debug!( "session '{}' was already detached while processing background detach request (expected)", - resolved_name + resolved.session_name ); } return Ok(AttachResult::Done); } info!("entering bidi streaming mode"); - let session_name_tmpl = self.session_name_tmpl.clone(); + let session_name_tmpl = self.tmpls.session_name.clone(); match attach_client.pipe_bytes(move |maybe_switch: &MaybeSwitch| { let var_map: HashMap = maybe_switch.vars.iter().cloned().collect(); - session_name_tmpl.apply(&var_map) != resolved_name + session_name_tmpl.apply(&var_map) != resolved.session_name }) { Ok(PipeBytesResult::Exit(exit_status)) => std::process::exit(exit_status), Ok(PipeBytesResult::MaybeSwitch(s)) => Ok(AttachResult::Switch(s)), @@ -218,7 +240,7 @@ impl Attach { /// Attach to a session and return the connected client without piping /// stdio. - fn dial_attach(&self, name: &str) -> anyhow::Result { + fn dial_attach(&self, resolved: &ResolvedTemplates) -> anyhow::Result { let mut client = self.dial_client(true)?; let tty_size = match TtySize::from_fd(0) { @@ -241,7 +263,7 @@ impl Attach { let cwd = String::from(env::current_dir().context("getting cwd")?.to_string_lossy()); let default_dir = self.config_manager.get().default_dir.clone().unwrap_or(String::from("$HOME")); - let start_dir = match (default_dir.as_str(), self.dir.as_deref()) { + let start_dir = match (default_dir.as_str(), resolved.dir.as_deref()) { (".", None) => Some(cwd), ("$HOME", None) => None, (d, None) => Some(String::from(d)), @@ -251,7 +273,7 @@ impl Attach { client .write_connect_header(ConnectHeader::Attach(AttachHeader { - name: String::from(name), + name: resolved.session_name.clone(), local_tty_size: tty_size, local_env: local_env_keys .into_iter() @@ -261,8 +283,9 @@ impl Attach { }) .collect::>(), ttl_secs: self.ttl.map(|d| d.as_secs()), - cmd: self.cmd.clone(), + cmd: resolved.cmd.clone(), dir: start_dir, + start_cmd: resolved.start_cmd.clone(), })) .context("writing attach header")?; @@ -283,16 +306,20 @@ impl Attach { for warning in warnings.into_iter() { eprintln!("shpool: warn: {warning}"); } - info!("attached to an existing session: '{}'", name); + info!("attached to an existing session: '{}'", resolved.session_name); } Created { warnings } => { for warning in warnings.into_iter() { eprintln!("shpool: warn: {warning}"); } - info!("created a new session: '{}'", name); + info!("created a new session: '{}'", resolved.session_name); } UnexpectedError(err) => { - return Err(anyhow!("BUG: unexpected error attaching to '{}': {}", name, err)); + return Err(anyhow!( + "BUG: unexpected error attaching to '{}': {}", + resolved.session_name, + err + )); } } } @@ -347,6 +374,31 @@ impl Attach { } } +struct Templates { + session_name: template::Template, + cmd: Option, + dir: Option, + start_cmd: Option, +} + +struct ResolvedTemplates { + session_name: String, + cmd: Option, + dir: Option, + start_cmd: Option, +} + +impl Templates { + fn apply(&self, var_map: &HashMap) -> ResolvedTemplates { + ResolvedTemplates { + session_name: self.session_name.apply(var_map), + cmd: self.cmd.as_ref().map(|t| t.apply(var_map)), + dir: self.dir.as_ref().map(|t| t.apply(var_map)), + start_cmd: self.start_cmd.as_ref().map(|t| t.apply(var_map)), + } + } +} + #[derive(Debug)] struct BusyError; impl fmt::Display for BusyError { diff --git a/libshpool/src/daemon/mod.rs b/libshpool/src/daemon/mod.rs index 7b52ec71..27e2368a 100644 --- a/libshpool/src/daemon/mod.rs +++ b/libshpool/src/daemon/mod.rs @@ -23,9 +23,9 @@ mod etc_environment; mod exit_notify; pub mod keybindings; mod pager; -mod prompt; mod server; mod shell; +mod shell_inject; mod show_motd; mod signals; mod systemd; diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs index 5c8d9cfc..536deddf 100644 --- a/libshpool/src/daemon/server.rs +++ b/libshpool/src/daemon/server.rs @@ -48,8 +48,8 @@ use crate::{ config::MotdDisplayMode, consts, daemon::{ - etc_environment, exit_notify::ExitNotifier, hooks, pager, pager::PagerError, prompt, shell, - show_motd, ttl_reaper, + etc_environment, exit_notify::ExitNotifier, hooks, pager, pager::PagerError, shell, + shell_inject, show_motd, ttl_reaper, }, protocol, test_hooks, tty, user, }; @@ -1038,8 +1038,10 @@ impl Server { let prompt_prefix_is_blank = self.config.get().prompt_prefix.as_ref().map(|p| p.is_empty()).unwrap_or(false); - let supports_sentinels = - header.cmd.is_none() && !prompt_prefix_is_blank && !does_not_support_sentinels(&shell); + let supports_sentinels = header.cmd.is_none() + && (header.start_cmd.as_ref().map(|c| !c.is_empty()).unwrap_or(false) + || !prompt_prefix_is_blank) + && !does_not_support_sentinels(&shell); info!("supports_sentianls={}", supports_sentinels); // Inject the prompt prefix, if any. For custom commands, avoid doing this @@ -1053,7 +1055,12 @@ impl Server { .prompt_prefix .clone() .unwrap_or(String::from(DEFAULT_PROMPT_PREFIX)); - if let Err(err) = prompt::maybe_inject_prefix(&mut fork, &prompt_prefix, &header.name) { + if let Err(err) = shell_inject::maybe_setup( + &mut fork, + &prompt_prefix, + header.start_cmd.as_ref().map(|c| c.as_ref()).unwrap_or(""), + &header.name, + ) { warn!("issue injecting prefix: {:?}", err); } } diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index c4062949..cc3ab283 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -34,7 +34,9 @@ use tracing::{debug, error, info, instrument, span, trace, warn, Level}; use crate::{ common, consts, - daemon::{config, exit_notify::ExitNotifier, keybindings, pager::PagerCtl, prompt, show_motd}, + daemon::{ + config, exit_notify::ExitNotifier, keybindings, pager::PagerCtl, shell_inject, show_motd, + }, protocol, protocol::ChunkExt as _, session_restore, test_hooks, @@ -225,7 +227,8 @@ impl SessionInner { args: ShellToClientArgs, ) -> anyhow::Result>> { let term_db = Arc::clone(&self.term_db); - let mut prompt_sentinel_scanner = prompt::SentinelScanner::new(consts::PROMPT_SENTINEL); + let mut prompt_sentinel_scanner = + shell_inject::SentinelScanner::new(consts::PROMPT_SENTINEL); // We only scan for the prompt sentinel if the user has not set up a // custom command or blanked out the prompt_prefix config option. diff --git a/libshpool/src/daemon/prompt.rs b/libshpool/src/daemon/shell_inject.rs similarity index 94% rename from libshpool/src/daemon/prompt.rs rename to libshpool/src/daemon/shell_inject.rs index bc52f39e..7cd90d02 100644 --- a/libshpool/src/daemon/prompt.rs +++ b/libshpool/src/daemon/shell_inject.rs @@ -47,15 +47,17 @@ enum KnownShell { Fish, } -/// Inject the given prefix into the given shell subprocess, using -/// the shell path in `shell` to decide the right way to go about +/// Inject the given prefix and startup cmdn into the given shell subprocess, +/// using the shell path in `shell` to decide the right way to go about /// injecting the prefix. /// -/// If the prefix is blank, this is a noop. +/// If either the prefix or startup cmd are blank, we do nothing for that +/// option. #[instrument(skip_all)] -pub fn maybe_inject_prefix( +pub fn maybe_setup( pty_master: &mut shpool_pty::fork::Fork, prompt_prefix: &str, + start_cmd: &str, session_name: &str, ) -> anyhow::Result<()> { let shell_pid = pty_master.child_pid().ok_or(anyhow!("no child pid"))?; @@ -117,6 +119,12 @@ pub fn maybe_inject_prefix( } }; + if !start_cmd.is_empty() { + script.push('\n'); + script.push_str(start_cmd); + script.push('\n'); + } + // With this magic env var set, `shpool daemon` will just // print the prompt sentinel and immediately exit. We do // this rather than `echo $PROMPT_SENTINEL` because different @@ -127,7 +135,7 @@ pub fn maybe_inject_prefix( let sentinel_cmd = format!("\n {}=prompt {} daemon\n", SENTINEL_FLAG_VAR, exe_path); script.push_str(sentinel_cmd.as_str()); - debug!("injecting prefix script '{}'", script); + debug!("injecting shell startup script '{}'", script); pty_master.write_all(script.as_bytes()).context("running prefix script")?; Ok(()) diff --git a/libshpool/src/lib.rs b/libshpool/src/lib.rs index aad548d6..8f01b552 100644 --- a/libshpool/src/lib.rs +++ b/libshpool/src/lib.rs @@ -168,7 +168,30 @@ pass to the binary using the shell-words crate." $HOME by default. Use '.' for pwd." )] dir: Option, - #[clap(help = "The name of the shell session to create or attach to")] + #[clap( + short, + long, + long_help = "A command to run in the shell immediately on startup + +This differs from the --cmd flag in that it is run +directly in the shell rather than replacing the shell. Think of +it as running `source cmd` rather than `exec cmd`. The main +usecase is to be able to automatically enter some useful context +such as a particular directory with a python virtual environment +already set up for example. + +--start-cmd supports templates in the same way that session names do, +so you can parameterize your command with shpool variables. For example, +you could do --start-cmd 'cd /workspace/path/prefix/{workspace}' to have +your session start in your workspace root depending on a 'workspace' var. +See 'shpool help var' for more details." + )] + start_cmd: Option, + #[clap(long_help = "The name of the shell session to create or attach to + +Session names support templates, and if a session name changes based on a +variable change, 'shpool attach' will automatically hang up and re-dial the +new session name. See 'shpool help var' for more details.")] name: String, }, @@ -425,8 +448,8 @@ pub fn run(args: Args, hooks: Option>) -> an log_level_handle, socket, ), - Commands::Attach { force, background, ttl, cmd, dir, name } => { - attach::run(config_manager, name, force, background, ttl, cmd, dir, socket) + Commands::Attach { force, background, ttl, cmd, dir, start_cmd, name } => { + attach::run(socket, config_manager, name, force, background, ttl, cmd, dir, start_cmd) } Commands::Detach { sessions } => detach::run(sessions, socket), Commands::Kill { sessions } => kill::run(sessions, socket), diff --git a/shpool-protocol/src/lib.rs b/shpool-protocol/src/lib.rs index 29fb62a4..49683ae9 100644 --- a/shpool-protocol/src/lib.rs +++ b/shpool-protocol/src/lib.rs @@ -232,6 +232,16 @@ pub struct AttachHeader { /// should be used. #[serde(default)] pub dir: Option, + /// If specified, shpool will inject the given command into the shell + /// when it first starts up. This option is ignored for reattaches. + /// Note that this differs from the cmd option in that it is run + /// directly in the shell rather than replacing the shell. Think of + /// it as running `source cmd` rather than `exec cmd`. The main + /// usecase is to be able to automatically enter some useful context + /// such as a particular directory with a python virtual environment + /// already set up for example. + #[serde(default)] + pub start_cmd: Option, } impl AttachHeader { diff --git a/shpool/tests/attach.rs b/shpool/tests/attach.rs index 302d6c78..0b5ae401 100644 --- a/shpool/tests/attach.rs +++ b/shpool/tests/attach.rs @@ -1870,3 +1870,105 @@ fn templated_session_name_no_switch_on_unrelated_var() -> anyhow::Result<()> { Ok(()) } + +#[test] +#[timeout(30000)] +fn start_cmd() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + let mut attach_proc = daemon_proc + .attach( + "sh1", + AttachArgs { + start_cmd: Some(String::from("export STARTUP_CMD_RAN=true")), + ..Default::default() + }, + ) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + attach_proc.run_cmd("echo $STARTUP_CMD_RAN")?; + line_matcher.scan_until_re("true$")?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn start_cmd_template() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + + daemon_proc.var_set("myvar", "myval")?; + + let mut attach_proc = daemon_proc + .attach( + "sh1", + AttachArgs { + start_cmd: Some(String::from("export STARTUP_CMD_VAR={myvar}")), + ..Default::default() + }, + ) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + attach_proc.run_cmd("echo $STARTUP_CMD_VAR")?; + line_matcher.scan_until_re("myval$")?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn custom_cmd_template() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + + daemon_proc.var_set("myarg", "templated_arg")?; + + let script = support::testdata_file("echo_stop.sh"); + let mut attach_proc = daemon_proc + .attach( + "sh1", + AttachArgs { + cmd: Some(format!("{} {{myarg}}", script.into_os_string().into_string().unwrap())), + ..Default::default() + }, + ) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + // the script first echos the arg we gave it + line_matcher.scan_until_re("templated_arg$")?; + + attach_proc.run_cmd("stop")?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn dir_template() -> anyhow::Result<()> { + let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) + .context("starting daemon proc")?; + + let target_dir = daemon_proc.tmp_dir.path().join("templated_dir"); + fs::create_dir(&target_dir)?; + + daemon_proc.var_set("mydir", target_dir.to_str().unwrap())?; + + let mut attach_proc = daemon_proc + .attach("sh1", AttachArgs { dir: Some(String::from("{mydir}")), ..Default::default() }) + .context("starting attach proc")?; + + let mut line_matcher = attach_proc.line_matcher()?; + + attach_proc.run_cmd("pwd")?; + let expected_path = target_dir.to_str().unwrap(); + line_matcher.scan_until_re(&format!("{}$", regex::escape(expected_path)))?; + + Ok(()) +} diff --git a/shpool/tests/support/daemon.rs b/shpool/tests/support/daemon.rs index 102761b2..df768e33 100644 --- a/shpool/tests/support/daemon.rs +++ b/shpool/tests/support/daemon.rs @@ -54,6 +54,7 @@ pub struct AttachArgs { pub ttl: Option, pub cmd: Option, pub dir: Option, + pub start_cmd: Option, pub null_stdin: bool, } @@ -348,6 +349,10 @@ impl Proc { cmd.arg("--dir"); cmd.arg(dir); } + if let Some(start_cmd) = &args.start_cmd { + cmd.arg("--start-cmd"); + cmd.arg(start_cmd); + } let proc = cmd.arg(name).spawn().context(format!("spawning attach proc for {name}"))?; let events = Events::new(&test_hook_socket_path)?;