Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers
This commit is contained in:
parent
89c5849539
commit
1208c5d1b7
29 changed files with 4098 additions and 339 deletions
357
bread-cli/src/hooks_git.rs
Normal file
357
bread-cli/src/hooks_git.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! `bread hooks install git` — installs small, non-blocking git hooks that
|
||||
//! emit normalized events (via the `bread-emit` fire-and-forget binary) on
|
||||
//! commit and branch-change activity.
|
||||
//!
|
||||
//! Design constraints this module is built around:
|
||||
//!
|
||||
//! - Never touch a hook file bread doesn't own. Frameworks like Husky or the
|
||||
//! `pre-commit` tool, or a developer's own scripts, commonly already
|
||||
//! occupy `post-commit` / `post-checkout` / `post-merge`. We only ever
|
||||
//! overwrite a hook file if it already carries our marker comment (meaning
|
||||
//! we wrote it on a previous install); otherwise we skip it and tell the
|
||||
//! user exactly what to add by hand.
|
||||
//! - Never make git itself slower or block a commit/checkout/merge because
|
||||
//! breadd is slow or down. The installed scripts background `bread-emit`
|
||||
//! and unconditionally exit 0.
|
||||
//! - Respect `core.hooksPath`. If the user has repointed hooks elsewhere, we
|
||||
//! do not silently write into `.git/hooks` where nothing will ever run
|
||||
//! them — see [`install_git`] for the exact behavior.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Distinctive marker comment written as the second line of every hook
|
||||
/// script bread installs. Its presence is how we tell "a hook we installed
|
||||
/// previously, safe to overwrite" apart from "someone else's hook, hands
|
||||
/// off." Keep this stable across versions — changing it would make bread
|
||||
/// think its own previously-installed hooks belong to someone else.
|
||||
pub const MARKER: &str = "# bread-managed-hook";
|
||||
|
||||
/// The three git hooks bread installs, in a stable order for display.
|
||||
const HOOK_NAMES: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
|
||||
|
||||
/// Outcome of attempting to install a single hook file.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum HookOutcome {
|
||||
Installed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Install bread's git hooks (`post-commit`, `post-checkout`, `post-merge`)
|
||||
/// into the current working directory's git repository.
|
||||
///
|
||||
/// This only ever touches the repo rooted at the current directory (via
|
||||
/// `git rev-parse`, which correctly follows worktrees/submodules to the
|
||||
/// real git dir) — never a global `core.hooksPath`, never other repos.
|
||||
pub fn install_git() -> Result<()> {
|
||||
let git_dir = git_dir()?;
|
||||
let toplevel = show_toplevel()?;
|
||||
|
||||
if let Some(configured) = hooks_path_override()? {
|
||||
print_hooks_path_warning(&configured);
|
||||
bail!(
|
||||
"bread: refusing to install into '{}/hooks' while core.hooksPath is set to '{}'",
|
||||
git_dir.display(),
|
||||
configured
|
||||
);
|
||||
}
|
||||
|
||||
let hooks_dir = git_dir.join("hooks");
|
||||
fs::create_dir_all(&hooks_dir)
|
||||
.with_context(|| format!("failed to create {}", hooks_dir.display()))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
|
||||
for &name in HOOK_NAMES.iter() {
|
||||
let path = hooks_dir.join(name);
|
||||
let script = hook_script(name);
|
||||
match install_one_hook(&path, &script)? {
|
||||
HookOutcome::Installed => installed.push(path),
|
||||
HookOutcome::Skipped => skipped.push(path),
|
||||
}
|
||||
}
|
||||
|
||||
print_summary(&toplevel, &installed, &skipped);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install (or skip) a single hook file at `path` with contents `script`.
|
||||
///
|
||||
/// Never overwrites an existing file unless it already carries our marker.
|
||||
fn install_one_hook(path: &Path, script: &str) -> Result<HookOutcome> {
|
||||
if path.exists() {
|
||||
let existing = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read existing hook {}", path.display()))?;
|
||||
if !is_bread_managed(&existing) {
|
||||
eprintln!(
|
||||
"bread: '{}' already exists and was not installed by bread — leaving it \
|
||||
untouched.\n To also emit bread events from it, add this line to the end \
|
||||
of the existing script:\n\n {}\n",
|
||||
path.display(),
|
||||
emit_line_for(path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
|
||||
);
|
||||
return Ok(HookOutcome::Skipped);
|
||||
}
|
||||
// It's ours from a previous install — safe to overwrite.
|
||||
}
|
||||
|
||||
fs::write(path, script).with_context(|| format!("failed to write hook {}", path.display()))?;
|
||||
let mut perms = fs::metadata(path)
|
||||
.with_context(|| format!("failed to stat {}", path.display()))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms)
|
||||
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
|
||||
|
||||
Ok(HookOutcome::Installed)
|
||||
}
|
||||
|
||||
/// Whether `contents` was written by a previous bread install (contains the
|
||||
/// marker comment anywhere in the file).
|
||||
fn is_bread_managed(contents: &str) -> bool {
|
||||
contents.lines().any(|line| line.trim() == MARKER)
|
||||
}
|
||||
|
||||
/// The bare `bread-emit` invocation for a branch checkout, with no guard —
|
||||
/// callers that already run inside a `[ "$3" = "1" ]` check (our own
|
||||
/// generated hook script) use this directly.
|
||||
fn branch_changed_emit_line() -> String {
|
||||
"bread-emit bread.git.branch.changed --source git --kind branch.changed --data \
|
||||
\"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"previous_ref\\\":\\\"$1\\\"}\" >/dev/null 2>&1 &"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The single `bread-emit` invocation line appropriate for hook `name`,
|
||||
/// suggested to users who already have their own script at that hook (so it
|
||||
/// must stand alone, including its own guard where relevant).
|
||||
fn emit_line_for(name: &str) -> String {
|
||||
match name {
|
||||
"post-checkout" => format!("[ \"$3\" = \"1\" ] && {}", branch_changed_emit_line()),
|
||||
_ => commit_created_emit_line(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `bread-emit` invocation shared by `post-commit` and `post-merge`
|
||||
/// (both are "HEAD moved to a new commit" signals).
|
||||
fn commit_created_emit_line() -> String {
|
||||
"bread-emit bread.git.commit.created --source git --kind commit.created --data \
|
||||
\"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"sha\\\":\\\"$(git rev-parse HEAD)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"message\\\":\\\"$(git log -1 --pretty=%s | sed 's/\"/\\\\\\\\\"/g')\\\"}\" >/dev/null 2>&1 &"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build the full contents of the hook script for hook `name`.
|
||||
///
|
||||
/// Every script: starts with the marker (so future installs recognize it as
|
||||
/// ours), backgrounds the `bread-emit` call so a slow/down daemon can never
|
||||
/// delay the git operation, and unconditionally exits 0 so bread being
|
||||
/// unavailable can never fail a `git commit`/`checkout`/`merge` for the user.
|
||||
fn hook_script(name: &str) -> String {
|
||||
match name {
|
||||
"post-commit" => format!(
|
||||
"#!/bin/sh\n{marker}\n# Emits bread.git.commit.created on every commit. Backgrounded and\n\
|
||||
# always exits 0 so bread can never slow down or block `git commit`.\n\
|
||||
{emit}\nexit 0\n",
|
||||
marker = MARKER,
|
||||
emit = commit_created_emit_line(),
|
||||
),
|
||||
"post-checkout" => format!(
|
||||
"#!/bin/sh\n{marker}\n# git passes: $1=previous HEAD, $2=new HEAD, $3=1 if a branch\n\
|
||||
# checkout (0 for a plain file checkout). Only emit on real branch\n\
|
||||
# switches. previous_branch is not resolvable from a ref alone here,\n\
|
||||
# so we report the previous HEAD's raw SHA ($1) as previous_ref instead\n\
|
||||
# of a branch name.\n\
|
||||
if [ \"$3\" = \"1\" ]; then\n {emit}\nfi\nexit 0\n",
|
||||
marker = MARKER,
|
||||
emit = branch_changed_emit_line(),
|
||||
),
|
||||
"post-merge" => format!(
|
||||
"#!/bin/sh\n{marker}\n# A merge moves HEAD to a new commit, same as post-commit; emit the\n\
|
||||
# same bread.git.commit.created shape so a merge (fast-forward or not)\n\
|
||||
# also surfaces as a commit-created event. Backgrounded and always\n\
|
||||
# exits 0 so bread can never slow down or block `git merge`.\n\
|
||||
{emit}\nexit 0\n",
|
||||
marker = MARKER,
|
||||
emit = commit_created_emit_line(),
|
||||
),
|
||||
other => unreachable!("unknown hook name: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git rev-parse --git-dir`, resolved to an absolute path. This is the
|
||||
/// correct git directory even inside worktrees or submodules (unlike
|
||||
/// hardcoding `.git`).
|
||||
fn git_dir() -> Result<PathBuf> {
|
||||
let out = run_git(&["rev-parse", "--git-dir"]).context(
|
||||
"bread: this does not look like a git repository. Run 'bread hooks install git' from \
|
||||
inside a git work tree.",
|
||||
)?;
|
||||
let raw = PathBuf::from(out);
|
||||
if raw.is_absolute() {
|
||||
Ok(raw)
|
||||
} else {
|
||||
// `--git-dir` is often relative to CWD (e.g. ".git"); resolve it.
|
||||
std::env::current_dir()
|
||||
.map(|cwd| cwd.join(raw))
|
||||
.context("failed to resolve current directory")
|
||||
}
|
||||
}
|
||||
|
||||
/// `git rev-parse --show-toplevel` — the repo root, used only for display.
|
||||
fn show_toplevel() -> Result<PathBuf> {
|
||||
run_git(&["rev-parse", "--show-toplevel"])
|
||||
.map(PathBuf::from)
|
||||
.context(
|
||||
"bread: this does not look like a git repository. Run 'bread hooks install git' \
|
||||
from inside a git work tree.",
|
||||
)
|
||||
}
|
||||
|
||||
/// `git config --get core.hooksPath`, if set to something non-default.
|
||||
/// Returns `Ok(None)` when unset (the common case).
|
||||
fn hooks_path_override() -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.args(["config", "--get", "core.hooksPath"])
|
||||
.output()
|
||||
.context("failed to run 'git config --get core.hooksPath' (is git installed?)")?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Exit code 1 from `git config --get` means "key not set" — that's
|
||||
// the normal, expected case, not an error.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
|
||||
fn print_hooks_path_warning(configured: &str) {
|
||||
eprintln!(
|
||||
"bread: this repo has 'core.hooksPath' set to '{configured}', so hooks placed in \
|
||||
'.git/hooks' will never run.\n\
|
||||
bread will not silently write into '.git/hooks' where they'd be dead code, and it \
|
||||
will not write into your configured hooksPath without being asked to.\n\n\
|
||||
To proceed, either:\n\
|
||||
\x20 - point core.hooksPath back at the default: git config --unset core.hooksPath\n\
|
||||
\x20 (or set it explicitly to .git/hooks), then re-run this command; or\n\
|
||||
\x20 - install the three hook scripts into '{configured}' yourself (see \
|
||||
`bread hooks install git --help` for the exact script contents this command \
|
||||
would otherwise write).\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_summary(toplevel: &Path, installed: &[PathBuf], skipped: &[PathBuf]) {
|
||||
println!("bread: git hooks for {}", toplevel.display());
|
||||
if installed.is_empty() {
|
||||
println!(" installed: (none)");
|
||||
} else {
|
||||
println!(" installed:");
|
||||
for path in installed {
|
||||
println!(" {}", path.display());
|
||||
}
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
println!(" skipped (already exist, not bread-managed):");
|
||||
for path in skipped {
|
||||
println!(" {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `git <args>` in the current directory and return trimmed stdout.
|
||||
/// Fails if git is missing or the command exits non-zero.
|
||||
fn run_git(args: &[&str]) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to run 'git {}' (is git installed?)", args.join(" ")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("'git {}' failed", args.join(" "));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_own_marker() {
|
||||
let script = format!("#!/bin/sh\n{MARKER}\necho hi\n");
|
||||
assert!(is_bread_managed(&script));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_falsely_detect_marker() {
|
||||
let script = "#!/bin/sh\n# some other tool's hook\necho hi\n";
|
||||
assert!(!is_bread_managed(script));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_must_match_whole_trimmed_line() {
|
||||
// A substring match would be a false positive risk; require the
|
||||
// trimmed line to equal the marker exactly.
|
||||
let script = "#!/bin/sh\n# this mentions bread-managed-hook in passing\n";
|
||||
assert!(!is_bread_managed(script));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_is_not_managed() {
|
||||
assert!(!is_bread_managed(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_hook_scripts_start_with_shebang_and_marker() {
|
||||
for name in HOOK_NAMES {
|
||||
let script = hook_script(name);
|
||||
let mut lines = script.lines();
|
||||
assert_eq!(lines.next(), Some("#!/bin/sh"));
|
||||
assert_eq!(lines.next(), Some(MARKER));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_hook_scripts_exit_0_unconditionally() {
|
||||
for name in HOOK_NAMES {
|
||||
let script = hook_script(name);
|
||||
assert!(
|
||||
script.trim_end().ends_with("exit 0"),
|
||||
"hook {name} does not unconditionally exit 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_commit_and_post_merge_emit_commit_created() {
|
||||
for name in ["post-commit", "post-merge"] {
|
||||
let script = hook_script(name);
|
||||
assert!(script.contains("bread.git.commit.created"));
|
||||
assert!(script.contains("--kind commit.created"));
|
||||
// Must be backgrounded so a slow/down daemon can't block git.
|
||||
assert!(script.contains("&\nexit 0") || script.contains(" &\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_checkout_only_emits_on_branch_checkout() {
|
||||
let script = hook_script("post-checkout");
|
||||
assert!(script.contains("bread.git.branch.changed"));
|
||||
assert!(script.contains("--kind branch.changed"));
|
||||
assert!(script.contains("\"$3\" = \"1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_script_rejects_unknown_name() {
|
||||
let result = std::panic::catch_unwind(|| hook_script("pre-push"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
512
bread-cli/src/hooks_shell.rs
Normal file
512
bread-cli/src/hooks_shell.rs
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
//! `bread hooks install shell` — generates a shell hook script that reports
|
||||
//! terminal telemetry (command start/finish, cwd changes, remote-session
|
||||
//! start/end) to the bread daemon.
|
||||
//!
|
||||
//! # Why this exists as a generated file, not an rc-file edit
|
||||
//!
|
||||
//! This module deliberately **never touches the user's `.bashrc`, `.zshrc`,
|
||||
//! or fish `config.fish`.** It only writes self-contained hook script(s)
|
||||
//! under `~/.config/bread/hooks/` and prints the one-line `source` snippet
|
||||
//! the user needs to add themselves. Editing a user's shell startup file
|
||||
//! automatically is the kind of silent, hard-to-audit change that belongs
|
||||
//! to the user, not to a CLI subcommand — if `bread` got it wrong, or the
|
||||
//! user later doesn't want it, an rc-file edit is much harder to notice and
|
||||
//! undo than a printed snippet they chose to paste in.
|
||||
//!
|
||||
//! # Why bread-emit, not `bread emit`
|
||||
//!
|
||||
//! The generated hooks shell out to the separate `bread-emit` binary
|
||||
//! (`bread-emit/src/main.rs`), not `bread emit`. `bread-emit` skips clap
|
||||
//! parsing and the Tokio runtime entirely and never waits for a reply — it
|
||||
//! is cheap enough to call on every single shell prompt. The full `bread`
|
||||
//! CLI spins up an async runtime per invocation and would be perceptible
|
||||
//! latency if called twice per prompt.
|
||||
//!
|
||||
//! # Why both a `.sh` and a `.fish` file are always written
|
||||
//!
|
||||
//! `install_shell` always regenerates both `shell-hook.sh` (bash + zsh, a
|
||||
//! single file with an `if [ -n "$ZSH_VERSION" ]; ... elif [ -n
|
||||
//! "$BASH_VERSION" ]; ...` branch) and `shell-hook.fish` (fish has
|
||||
//! meaningfully different hook primitives and cannot source a POSIX-ish
|
||||
//! script anyway), regardless of which shell was detected or requested.
|
||||
//! This keeps both files present and up to date at fixed, predictable
|
||||
//! paths so a user who switches shells later doesn't need to re-run
|
||||
//! install — they just add the one extra `source` line to the new shell's
|
||||
//! rc file. Only the *printed* snippet is specific to the detected/forced
|
||||
//! shell.
|
||||
//!
|
||||
//! Existing hook files at these paths are always overwritten. Unlike git
|
||||
//! hooks, there is no ecosystem of third-party `shell-hook.sh` files a user
|
||||
//! might have installed by other means — this file is entirely owned and
|
||||
//! generated by this command, so overwriting on every run is safe and is
|
||||
//! in fact required to pick up script changes across `bread` upgrades.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Which shell to print the `source` snippet for. Detected from `$SHELL`
|
||||
/// unless the caller forces one via `bread hooks install shell --shell
|
||||
/// <name>`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TargetShell {
|
||||
Bash,
|
||||
Zsh,
|
||||
Fish,
|
||||
/// Detection failed (unrecognized or unset `$SHELL`); both snippets are
|
||||
/// printed and the user picks the one that matches their shell.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TargetShell {
|
||||
fn parse(name: &str) -> Result<TargetShell> {
|
||||
match name {
|
||||
"bash" => Ok(TargetShell::Bash),
|
||||
"zsh" => Ok(TargetShell::Zsh),
|
||||
"fish" => Ok(TargetShell::Fish),
|
||||
other => bail!(
|
||||
"bread: unrecognized shell '{}' (expected one of: bash, zsh, fish)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the caller's shell from `$SHELL`'s basename. This is a best
|
||||
/// effort: `$SHELL` reflects the user's login shell, which is normally
|
||||
/// also the shell running the CLI, but that's not guaranteed (e.g.
|
||||
/// invoked from inside a script run under a different interpreter).
|
||||
fn detect_from_env() -> TargetShell {
|
||||
let Ok(shell_path) = std::env::var("SHELL") else {
|
||||
return TargetShell::Unknown;
|
||||
};
|
||||
let name = PathBuf::from(shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
match name.as_str() {
|
||||
"bash" => TargetShell::Bash,
|
||||
"zsh" => TargetShell::Zsh,
|
||||
"fish" => TargetShell::Fish,
|
||||
_ => TargetShell::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default hooks directory: `~/.config/bread/hooks`.
|
||||
pub fn hooks_dir() -> PathBuf {
|
||||
if let Some(cfg) = dirs::config_dir() {
|
||||
return cfg.join("bread").join("hooks");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("bread")
|
||||
.join("hooks");
|
||||
}
|
||||
PathBuf::from(".config/bread/hooks")
|
||||
}
|
||||
|
||||
/// Install (or regenerate) the shell hook scripts and print the snippet the
|
||||
/// user needs to add to their own rc file. `shell` optionally forces
|
||||
/// bash/zsh/fish; otherwise the target shell is auto-detected from `$SHELL`
|
||||
/// purely to decide which snippet to print — both hook files are written
|
||||
/// either way (see module docs).
|
||||
pub fn install_shell(shell: Option<String>) -> Result<()> {
|
||||
let target = match shell {
|
||||
Some(name) => TargetShell::parse(&name)?,
|
||||
None => TargetShell::detect_from_env(),
|
||||
};
|
||||
|
||||
let dir = hooks_dir();
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create {}: {}", dir.display(), e))?;
|
||||
|
||||
let sh_path = dir.join("shell-hook.sh");
|
||||
write_hook_file(&sh_path, BASH_ZSH_HOOK_SCRIPT)?;
|
||||
|
||||
let fish_path = dir.join("shell-hook.fish");
|
||||
write_hook_file(&fish_path, FISH_HOOK_SCRIPT)?;
|
||||
|
||||
println!();
|
||||
match target {
|
||||
TargetShell::Bash | TargetShell::Zsh => {
|
||||
println!("Add this line to your ~/.bashrc or ~/.zshrc:");
|
||||
println!();
|
||||
println!(" source {}", sh_path.display());
|
||||
}
|
||||
TargetShell::Fish => {
|
||||
println!("Add this line to your ~/.config/fish/config.fish:");
|
||||
println!();
|
||||
println!(" source {}", fish_path.display());
|
||||
}
|
||||
TargetShell::Unknown => {
|
||||
println!(
|
||||
"bread: could not detect your shell from $SHELL; add whichever \
|
||||
of these matches the shell you use interactively:"
|
||||
);
|
||||
println!();
|
||||
println!(" bash/zsh — add to ~/.bashrc or ~/.zshrc:");
|
||||
println!(" source {}", sh_path.display());
|
||||
println!();
|
||||
println!(" fish — add to ~/.config/fish/config.fish:");
|
||||
println!(" source {}", fish_path.display());
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
"bread never edits rc files on its own — that line above is something \
|
||||
only you should add."
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_hook_file(path: &PathBuf, contents: &str) -> Result<()> {
|
||||
let existed = path.exists();
|
||||
fs::write(path, contents)
|
||||
.map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e))?;
|
||||
if existed {
|
||||
println!("bread: regenerated {}", path.display());
|
||||
} else {
|
||||
println!("bread: wrote {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook script bodies
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Both scripts below shell out to `bread-emit` (not `bread emit`) for every
|
||||
// event, always backgrounded and with output discarded (`>/dev/null 2>&1
|
||||
// &`), so a down, slow, or stalled daemon can never block the interactive
|
||||
// shell waiting for the next prompt.
|
||||
//
|
||||
// Event/kind pairs emitted (kept in sync with the daemon's normalizer):
|
||||
// bread.terminal.command.started (kind command.started)
|
||||
// bread.terminal.command.finished (kind command.finished)
|
||||
// bread.terminal.cwd.changed (kind cwd.changed)
|
||||
// bread.remote.session.started (kind session.started)
|
||||
// bread.remote.session.ended (kind session.ended)
|
||||
//
|
||||
// `bread-emit`'s positional <event> argument is technically redundant once
|
||||
// --source/--kind are both given (the daemon derives the real event name
|
||||
// from source+kind and ignores the literal positional string in that
|
||||
// mode), but a sensible name is still passed since bread-emit requires a
|
||||
// positional argument.
|
||||
|
||||
/// Combined bash + zsh hook script written to `~/.config/bread/hooks/shell-hook.sh`.
|
||||
///
|
||||
/// zsh has native `preexec`/`precmd`/`chpwd` hooks; bash has none of the
|
||||
/// three, so its branch approximates them with a `trap ... DEBUG` (guarded
|
||||
/// so it only fires once per prompt, not once per pipeline stage) and a
|
||||
/// `PROMPT_COMMAND` that both plays the precmd role and polls `$PWD` against
|
||||
/// a remembered previous value to emulate `chpwd`.
|
||||
const BASH_ZSH_HOOK_SCRIPT: &str = r#"# bread shell hook — generated by `bread hooks install shell`.
|
||||
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
|
||||
#
|
||||
# Reports terminal telemetry (command start/finish, cwd changes, remote
|
||||
# session start/end) to the bread daemon via the lightweight `bread-emit`
|
||||
# binary. Every call below is backgrounded and has its output discarded so a
|
||||
# down or slow daemon can never delay the shell prompt.
|
||||
#
|
||||
# Assumes GNU date (for `date +%s%3N`, millisecond epoch) and GNU sed (for
|
||||
# the JSON-escaping helper below) — both are standard on Arch Linux.
|
||||
|
||||
# Escape a string for embedding inside a JSON string literal: backslashes,
|
||||
# double quotes, tabs, carriage returns, and embedded newlines. Backslashes
|
||||
# must be escaped first, before any escape sequence that introduces new
|
||||
# backslashes, or the newly-added backslashes would themselves get doubled.
|
||||
_bread_json_escape() {
|
||||
printf '%s' "$1" \
|
||||
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' \
|
||||
| sed ':a;N;$!ba;s/\n/\\n/g'
|
||||
}
|
||||
|
||||
# --- Remote session detection (runs once at shell-init time) --------------
|
||||
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
|
||||
# so nested subshells (and the EXIT trap that fires only in this branch)
|
||||
# don't each fire their own started/ended pair.
|
||||
if { [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; } && [ -z "$BREAD_SSH_ANNOUNCED" ]; then
|
||||
export BREAD_SSH_ANNOUNCED=1
|
||||
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
|
||||
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
|
||||
_bread_ssh_host=$(printf '%s' "${SSH_CONNECTION:-$SSH_CLIENT}" | awk '{print $1}')
|
||||
|
||||
_bread_esc_host=$(_bread_json_escape "$_bread_ssh_host")
|
||||
bread-emit bread.remote.session.started --source remote --kind session.started \
|
||||
--data "{\"host\":\"$_bread_esc_host\"}" >/dev/null 2>&1 &
|
||||
|
||||
_bread_ssh_exit_hook() {
|
||||
_bread_esc_host_exit=$(_bread_json_escape "$_bread_ssh_host")
|
||||
bread-emit bread.remote.session.ended --source remote --kind session.ended \
|
||||
--data "{\"host\":\"$_bread_esc_host_exit\"}" >/dev/null 2>&1 &
|
||||
}
|
||||
trap _bread_ssh_exit_hook EXIT
|
||||
fi
|
||||
|
||||
if [ -n "$ZSH_VERSION" ]; then
|
||||
# --- zsh: preexec / precmd / chpwd are native ---------------------------
|
||||
autoload -Uz add-zsh-hook
|
||||
|
||||
_bread_preexec() {
|
||||
_bread_cmd_start=$(date +%s%3N)
|
||||
_bread_cmd_cwd="$PWD"
|
||||
_bread_cmd_str="$1"
|
||||
local esc_cmd esc_cwd
|
||||
esc_cmd=$(_bread_json_escape "$1")
|
||||
esc_cwd=$(_bread_json_escape "$PWD")
|
||||
bread-emit bread.terminal.command.started --source terminal --kind command.started \
|
||||
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
|
||||
}
|
||||
add-zsh-hook preexec _bread_preexec
|
||||
|
||||
_bread_precmd() {
|
||||
local exit_code=$?
|
||||
if [ -n "$_bread_cmd_str" ]; then
|
||||
local now duration_ms esc_cmd esc_cwd
|
||||
now=$(date +%s%3N)
|
||||
duration_ms=$(( now - _bread_cmd_start ))
|
||||
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
|
||||
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
|
||||
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
|
||||
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
|
||||
_bread_cmd_str=""
|
||||
fi
|
||||
}
|
||||
add-zsh-hook precmd _bread_precmd
|
||||
|
||||
_bread_chpwd() {
|
||||
local esc_cwd esc_prev
|
||||
esc_cwd=$(_bread_json_escape "$PWD")
|
||||
esc_prev=$(_bread_json_escape "${OLDPWD:-$PWD}")
|
||||
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
|
||||
--data "{\"cwd\":\"$esc_cwd\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
|
||||
}
|
||||
add-zsh-hook chpwd _bread_chpwd
|
||||
|
||||
elif [ -n "$BASH_VERSION" ]; then
|
||||
# --- bash: no native preexec/precmd/chpwd; approximate them -------------
|
||||
# trap ... DEBUG fires before every simple command. `_bread_preexec_guard`
|
||||
# ensures only the first simple command between prompts is recorded, not
|
||||
# every stage of a pipeline or every `;`-separated command on one line.
|
||||
# This relies on bash's default of NOT propagating the DEBUG trap into
|
||||
# function calls (i.e. `set -o functrace`/`shopt -s extdebug` are off);
|
||||
# if something in the user's environment turns those on, the guard below
|
||||
# could fire more than once per prompt.
|
||||
_bread_preexec() {
|
||||
[ -n "$_bread_preexec_guard" ] && return
|
||||
case "$BASH_COMMAND" in
|
||||
_bread_precmd*|"$PROMPT_COMMAND") return ;;
|
||||
esac
|
||||
_bread_preexec_guard=1
|
||||
_bread_cmd_start=$(date +%s%3N)
|
||||
_bread_cmd_cwd="$PWD"
|
||||
_bread_cmd_str="$BASH_COMMAND"
|
||||
local esc_cmd esc_cwd
|
||||
esc_cmd=$(_bread_json_escape "$BASH_COMMAND")
|
||||
esc_cwd=$(_bread_json_escape "$PWD")
|
||||
bread-emit bread.terminal.command.started --source terminal --kind command.started \
|
||||
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
|
||||
}
|
||||
trap '_bread_preexec' DEBUG
|
||||
|
||||
_bread_precmd() {
|
||||
local exit_code=$?
|
||||
if [ -n "$_bread_preexec_guard" ]; then
|
||||
local now duration_ms esc_cmd esc_cwd
|
||||
now=$(date +%s%3N)
|
||||
duration_ms=$(( now - _bread_cmd_start ))
|
||||
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
|
||||
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
|
||||
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
|
||||
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
|
||||
fi
|
||||
_bread_preexec_guard=""
|
||||
|
||||
# chpwd emulation: bash has no native hook, so compare $PWD against a
|
||||
# remembered previous value on every prompt.
|
||||
if [ "$PWD" != "${_bread_prev_pwd:-$PWD}" ]; then
|
||||
local esc_cwd2 esc_prev
|
||||
esc_cwd2=$(_bread_json_escape "$PWD")
|
||||
esc_prev=$(_bread_json_escape "${_bread_prev_pwd:-$PWD}")
|
||||
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
|
||||
--data "{\"cwd\":\"$esc_cwd2\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
|
||||
_bread_prev_pwd="$PWD"
|
||||
fi
|
||||
}
|
||||
PROMPT_COMMAND="_bread_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
|
||||
_bread_prev_pwd="$PWD"
|
||||
fi
|
||||
"#;
|
||||
|
||||
/// Fish hook script written to `~/.config/bread/hooks/shell-hook.fish`.
|
||||
///
|
||||
/// Fish's job-control primitives are meaningfully nicer than bash/zsh here:
|
||||
/// `fish_preexec`/`fish_postexec` are real events (no DEBUG-trap games),
|
||||
/// `$status` in the postexec handler gives the exit code directly, and
|
||||
/// `$CMD_DURATION` is already a builtin millisecond duration — no manual
|
||||
/// timestamp math needed. `string escape --style=json` (fish >= 3.3) does
|
||||
/// the JSON-string-literal escaping, quotes included, in one call.
|
||||
const FISH_HOOK_SCRIPT: &str = r#"# bread shell hook (fish) — generated by `bread hooks install shell`.
|
||||
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
|
||||
#
|
||||
# Reports terminal telemetry (command start/finish, cwd changes, remote
|
||||
# session start/end) to the bread daemon via the lightweight `bread-emit`
|
||||
# binary. Every call below is backgrounded and has its output discarded so a
|
||||
# down or slow daemon can never delay the shell prompt.
|
||||
#
|
||||
# Requires fish >= 3.3 for `string escape --style=json`.
|
||||
|
||||
function _bread_json_escape --description 'Escape a value as a JSON string literal (surrounding quotes included)'
|
||||
string escape --style=json -- $argv[1]
|
||||
end
|
||||
|
||||
function _bread_preexec --on-event fish_preexec --description 'bread: emit command.started'
|
||||
set -g _bread_cmd_cwd $PWD
|
||||
set -l cmd_json (_bread_json_escape $argv[1])
|
||||
set -l cwd_json (_bread_json_escape $PWD)
|
||||
bread-emit bread.terminal.command.started --source terminal --kind command.started \
|
||||
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json}" >/dev/null 2>&1 &
|
||||
end
|
||||
|
||||
function _bread_postexec --on-event fish_postexec --description 'bread: emit command.finished'
|
||||
# $status must be captured first, before any other command in this
|
||||
# function has a chance to overwrite it.
|
||||
set -l exit_code $status
|
||||
set -l cmd_json (_bread_json_escape $argv[1])
|
||||
set -l cwd_json (_bread_json_escape $_bread_cmd_cwd)
|
||||
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
|
||||
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json, \"exit_code\": $exit_code, \"duration_ms\": $CMD_DURATION}" >/dev/null 2>&1 &
|
||||
end
|
||||
|
||||
function _bread_pwd_changed --on-variable PWD --description 'bread: emit cwd.changed'
|
||||
status is-interactive; or return
|
||||
set -l cwd_json (_bread_json_escape $PWD)
|
||||
set -l prev_json (_bread_json_escape $_bread_prev_pwd)
|
||||
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
|
||||
--data "{\"cwd\": $cwd_json, \"prev_cwd\": $prev_json}" >/dev/null 2>&1 &
|
||||
set -g _bread_prev_pwd $PWD
|
||||
end
|
||||
set -g _bread_prev_pwd $PWD
|
||||
|
||||
# --- Remote session detection (runs once at shell-init time) --------------
|
||||
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
|
||||
# so nested fish subshells (and the fish_exit handler, which is only
|
||||
# registered in this branch) don't each fire their own started/ended pair.
|
||||
set -l _bread_is_ssh 0
|
||||
if test -n "$SSH_TTY"
|
||||
set _bread_is_ssh 1
|
||||
else if test -n "$SSH_CONNECTION"
|
||||
set _bread_is_ssh 1
|
||||
end
|
||||
|
||||
if test $_bread_is_ssh -eq 1; and test -z "$BREAD_SSH_ANNOUNCED"
|
||||
set -gx BREAD_SSH_ANNOUNCED 1
|
||||
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
|
||||
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
|
||||
set -l _bread_conn_str $SSH_CONNECTION
|
||||
if test -z "$_bread_conn_str"
|
||||
set _bread_conn_str $SSH_CLIENT
|
||||
end
|
||||
set -g _bread_ssh_host (string split ' ' -- $_bread_conn_str)[1]
|
||||
|
||||
set -l host_json (_bread_json_escape $_bread_ssh_host)
|
||||
bread-emit bread.remote.session.started --source remote --kind session.started \
|
||||
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
|
||||
|
||||
function _bread_ssh_exit_hook --on-event fish_exit --description 'bread: emit remote session.ended'
|
||||
set -l host_json (_bread_json_escape $_bread_ssh_host)
|
||||
bread-emit bread.remote.session.ended --source remote --kind session.ended \
|
||||
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
|
||||
end
|
||||
end
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_known_shells() {
|
||||
assert_eq!(TargetShell::parse("bash").unwrap(), TargetShell::Bash);
|
||||
assert_eq!(TargetShell::parse("zsh").unwrap(), TargetShell::Zsh);
|
||||
assert_eq!(TargetShell::parse("fish").unwrap(), TargetShell::Fish);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unknown_shell() {
|
||||
assert!(TargetShell::parse("powershell").is_err());
|
||||
assert!(TargetShell::parse("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_from_env_reads_shell_basename() {
|
||||
std::env::set_var("SHELL", "/usr/bin/zsh");
|
||||
assert_eq!(TargetShell::detect_from_env(), TargetShell::Zsh);
|
||||
|
||||
std::env::set_var("SHELL", "/bin/bash");
|
||||
assert_eq!(TargetShell::detect_from_env(), TargetShell::Bash);
|
||||
|
||||
std::env::set_var("SHELL", "/usr/bin/fish");
|
||||
assert_eq!(TargetShell::detect_from_env(), TargetShell::Fish);
|
||||
|
||||
std::env::set_var("SHELL", "/usr/bin/tcsh");
|
||||
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
|
||||
|
||||
std::env::remove_var("SHELL");
|
||||
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_scripts_reference_bread_emit_not_bread_emit_cli() {
|
||||
// Guard against accidentally shelling out to the slow `bread emit`
|
||||
// subcommand instead of the lightweight `bread-emit` binary.
|
||||
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
|
||||
assert!(script.contains("bread-emit "));
|
||||
assert!(!script.contains("bread emit "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_scripts_background_every_emit_call() {
|
||||
// Every bread-emit invocation must be backgrounded with output
|
||||
// discarded so a stalled daemon can never block the shell. A single
|
||||
// invocation can span multiple physical lines via a trailing `\`
|
||||
// continuation, so continuation lines are joined into one logical
|
||||
// statement before checking, rather than inspecting each line in
|
||||
// isolation.
|
||||
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
|
||||
for statement in join_line_continuations(script) {
|
||||
if statement.contains("bread-emit ") {
|
||||
assert!(
|
||||
statement.contains(">/dev/null 2>&1 &"),
|
||||
"statement not backgrounded/discarded: {statement}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins physical lines ending in a trailing `\` continuation into a
|
||||
/// single logical statement, so multi-line shell invocations can be
|
||||
/// checked as a whole rather than line-by-line.
|
||||
fn join_line_continuations(script: &str) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
let mut current = String::new();
|
||||
for line in script.lines() {
|
||||
let trimmed_end = line.trim_end();
|
||||
if let Some(rest) = trimmed_end.strip_suffix('\\') {
|
||||
current.push_str(rest);
|
||||
current.push(' ');
|
||||
} else {
|
||||
current.push_str(trimmed_end);
|
||||
statements.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
statements.push(current);
|
||||
}
|
||||
statements
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
mod hooks_git;
|
||||
mod hooks_shell;
|
||||
mod modules_mgmt;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
@ -58,6 +60,11 @@ enum Commands {
|
|||
#[command(subcommand)]
|
||||
subcommand: ModulesCommand,
|
||||
},
|
||||
/// Install shell/git hook integrations that feed events into breadd
|
||||
Hooks {
|
||||
#[command(subcommand)]
|
||||
subcommand: HooksCommand,
|
||||
},
|
||||
/// List available profiles
|
||||
ProfileList,
|
||||
/// Activate a profile
|
||||
|
|
@ -67,6 +74,13 @@ enum Commands {
|
|||
event: String,
|
||||
#[arg(short, long, default_value = "{}")]
|
||||
data: String,
|
||||
/// Source to tag the event with (terminal/git/remote); routes through
|
||||
/// the normalizer instead of being tagged System. Requires --kind.
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
/// Adapter-specific raw kind (e.g. "command.started"); only used with --source.
|
||||
#[arg(long)]
|
||||
kind: Option<String>,
|
||||
},
|
||||
/// Health check daemon connectivity
|
||||
Ping,
|
||||
|
|
@ -80,6 +94,19 @@ enum Commands {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum HooksCommand {
|
||||
/// Install shell integration hooks (precmd/preexec/chpwd + SSH session
|
||||
/// detection) for the current or a named shell
|
||||
InstallShell {
|
||||
/// Force bash/zsh/fish instead of auto-detecting from $SHELL
|
||||
shell: Option<String>,
|
||||
},
|
||||
/// Install git hooks (post-commit, post-checkout, post-merge) into the
|
||||
/// current repository
|
||||
InstallGit,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ModulesCommand {
|
||||
/// Install a module from a local directory
|
||||
|
|
@ -137,6 +164,10 @@ async fn main() -> Result<()> {
|
|||
Commands::Modules { subcommand } => {
|
||||
handle_modules_cmd(subcommand, &socket).await?;
|
||||
}
|
||||
Commands::Hooks { subcommand } => match subcommand {
|
||||
HooksCommand::InstallShell { shell } => hooks_shell::install_shell(shell)?,
|
||||
HooksCommand::InstallGit => hooks_git::install_git()?,
|
||||
},
|
||||
Commands::ProfileList => {
|
||||
let response = send_request(&socket, "profile.list", json!({})).await?;
|
||||
print_json(&response)?;
|
||||
|
|
@ -146,17 +177,24 @@ async fn main() -> Result<()> {
|
|||
send_request(&socket, "profile.activate", json!({ "name": name })).await?;
|
||||
print_json(&response)?;
|
||||
}
|
||||
Commands::Emit { event, data } => {
|
||||
Commands::Emit {
|
||||
event,
|
||||
data,
|
||||
source,
|
||||
kind,
|
||||
} => {
|
||||
let parsed = serde_json::from_str::<Value>(&data).unwrap_or_else(|_| json!({}));
|
||||
let response = send_request(
|
||||
&socket,
|
||||
"emit",
|
||||
json!({
|
||||
"event": event,
|
||||
"data": parsed,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let mut params = json!({
|
||||
"event": event,
|
||||
"data": parsed,
|
||||
});
|
||||
if let Some(source) = source {
|
||||
params["source"] = json!(source);
|
||||
}
|
||||
if let Some(kind) = kind {
|
||||
params["kind"] = json!(kind);
|
||||
}
|
||||
let response = send_request(&socket, "emit", params).await?;
|
||||
print_json(&response)?;
|
||||
}
|
||||
Commands::Ping => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue