diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2424526 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,474 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "p" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dirs", + "serde", + "serde_json", + "serde_yaml", +] + +[[package]] +name = "p-agent" +version = "0.1.0" +dependencies = [ + "anyhow", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..19bc466 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["p", "agent"] +resolver = "2" diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000..e6b3a96 --- /dev/null +++ b/agent/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "p-agent" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "p-agent" +path = "src/main.rs" + +[dependencies] +anyhow = "1" diff --git a/agent/src/main.rs b/agent/src/main.rs new file mode 100644 index 0000000..0391491 --- /dev/null +++ b/agent/src/main.rs @@ -0,0 +1,4 @@ +fn main() { + println!("p-agent — worker-side agent for p"); + println!("(not yet implemented)"); +} diff --git a/p/Cargo.toml b/p/Cargo.toml new file mode 100644 index 0000000..46c4483 --- /dev/null +++ b/p/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "p" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "p" +path = "src/main.rs" + +[dependencies] +# CLI +clap = { version = "4", features = ["derive"] } +# Error handling +anyhow = "1" +# Config file (YAML) + job records (JSON) +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1" +# Platform directories (~/.config, ~/.local/share) +dirs = "5" diff --git a/p/src/cli.rs b/p/src/cli.rs new file mode 100644 index 0000000..9462785 --- /dev/null +++ b/p/src/cli.rs @@ -0,0 +1,86 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "p", + about = "Push jobs to remote worker machines", + long_about = "\ +Push jobs to remote worker machines with directory sync and attach/detach support. + +Run a command on the default worker: + p -- + +Run on a specific worker: + p -- + +Skip directory sync: + p -n -- " +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// List all jobs across all workers + Ls, + + /// Re-attach to the console of a running job + Attach { + /// Job ID or unambiguous prefix + job_id: String, + }, + + /// Print captured output of a job (running or finished) + Logs { + /// Job ID or unambiguous prefix + job_id: String, + /// Follow output in real time (running jobs only) + #[arg(short, long)] + follow: bool, + }, + + /// Kill a running job + Stop { + /// Job ID or unambiguous prefix + job_id: String, + }, + + /// Copy a file or directory from a job's work directory back to the client + Pull { + /// Job ID or unambiguous prefix + job_id: String, + /// Path relative to the job's work directory on the worker + remote_path: String, + /// Local destination (defaults to current directory) + local_dest: Option, + }, + + /// Remove a job record and its remote work directory + Rm { + /// Job ID or unambiguous prefix + job_id: String, + /// Force removal even if the job is still running + #[arg(short, long)] + force: bool, + }, + + /// Register a remote worker + Register { + /// SSH connection string: user@host, user@host:port, or an SSH config alias + connection: String, + /// Worker name (defaults to the hostname part of the connection string) + #[arg(short, long)] + name: Option, + }, + + /// List registered workers and their reachability status + Workers, + + /// Set the default worker + Default { + /// Worker name + worker: String, + }, +} diff --git a/p/src/commands/attach.rs b/p/src/commands/attach.rs new file mode 100644 index 0000000..82bfc4e --- /dev/null +++ b/p/src/commands/attach.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(job_id: &str) -> Result<()> { + let _ = job_id; + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/logs.rs b/p/src/commands/logs.rs new file mode 100644 index 0000000..dcb03dc --- /dev/null +++ b/p/src/commands/logs.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(job_id: &str, follow: bool) -> Result<()> { + let _ = (job_id, follow); + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/ls.rs b/p/src/commands/ls.rs new file mode 100644 index 0000000..3a9aa7c --- /dev/null +++ b/p/src/commands/ls.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +pub fn execute() -> Result<()> { + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/mod.rs b/p/src/commands/mod.rs new file mode 100644 index 0000000..c5c0a6c --- /dev/null +++ b/p/src/commands/mod.rs @@ -0,0 +1,10 @@ +pub mod attach; +pub mod logs; +pub mod ls; +pub mod pull; +pub mod register; +pub mod rm; +pub mod run; +pub mod set_default; +pub mod stop; +pub mod workers; diff --git a/p/src/commands/pull.rs b/p/src/commands/pull.rs new file mode 100644 index 0000000..baaa30d --- /dev/null +++ b/p/src/commands/pull.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(job_id: &str, remote_path: &str, local_dest: Option<&str>) -> Result<()> { + let _ = (job_id, remote_path, local_dest); + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/register.rs b/p/src/commands/register.rs new file mode 100644 index 0000000..dc097f6 --- /dev/null +++ b/p/src/commands/register.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(connection: &str, name: Option<&str>) -> Result<()> { + let _ = (connection, name); + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/rm.rs b/p/src/commands/rm.rs new file mode 100644 index 0000000..54f453a --- /dev/null +++ b/p/src/commands/rm.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(job_id: &str, force: bool) -> Result<()> { + let _ = (job_id, force); + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/run.rs b/p/src/commands/run.rs new file mode 100644 index 0000000..dcce2e6 --- /dev/null +++ b/p/src/commands/run.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(worker: Option<&str>, cmd: Vec, no_sync: bool) -> Result<()> { + let _ = (worker, cmd, no_sync); + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/set_default.rs b/p/src/commands/set_default.rs new file mode 100644 index 0000000..bb49439 --- /dev/null +++ b/p/src/commands/set_default.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(worker: &str) -> Result<()> { + let _ = worker; + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/stop.rs b/p/src/commands/stop.rs new file mode 100644 index 0000000..82bfc4e --- /dev/null +++ b/p/src/commands/stop.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn execute(job_id: &str) -> Result<()> { + let _ = job_id; + anyhow::bail!("not yet implemented") +} diff --git a/p/src/commands/workers.rs b/p/src/commands/workers.rs new file mode 100644 index 0000000..3a9aa7c --- /dev/null +++ b/p/src/commands/workers.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +pub fn execute() -> Result<()> { + anyhow::bail!("not yet implemented") +} diff --git a/p/src/config.rs b/p/src/config.rs new file mode 100644 index 0000000..4059092 --- /dev/null +++ b/p/src/config.rs @@ -0,0 +1,75 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +// ── Config types ────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct Config { + pub default_worker: Option, + #[serde(default)] + pub workers: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkerConfig { + pub name: String, + pub connection: String, +} + +// ── Path helpers ────────────────────────────────────────────────────────────── + +pub fn config_path() -> Result { + Ok(dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine config directory"))? + .join("p") + .join("config.yaml")) +} + +// ── Load / save ─────────────────────────────────────────────────────────────── + +pub fn load() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(Config::default()); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("failed to read {}: {}", path.display(), e))?; + serde_yaml::from_str(&content) + .map_err(|e| anyhow::anyhow!("failed to parse config: {}", e)) +} + +pub fn save(config: &Config) -> Result<()> { + let path = config_path()?; + std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")))?; + let content = serde_yaml::to_string(config) + .map_err(|e| anyhow::anyhow!("failed to serialise config: {}", e))?; + std::fs::write(&path, content) + .map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e)) +} + +// ── Lookup helpers ──────────────────────────────────────────────────────────── + +impl Config { + pub fn get_worker(&self, name: &str) -> Option<&WorkerConfig> { + self.workers.iter().find(|w| w.name == name) + } + + pub fn default_worker(&self) -> Option<&WorkerConfig> { + self.get_worker(self.default_worker.as_deref()?) + } + + /// Resolve a worker by optional name, falling back to the default. + pub fn resolve_worker(&self, name: Option<&str>) -> Result<&WorkerConfig> { + match name { + Some(n) => self + .get_worker(n) + .ok_or_else(|| anyhow::anyhow!("unknown worker '{}'", n)), + None => self + .default_worker() + .ok_or_else(|| anyhow::anyhow!( + "no default worker configured — run 'p register ' first" + )), + } + } +} diff --git a/p/src/db.rs b/p/src/db.rs new file mode 100644 index 0000000..debe337 --- /dev/null +++ b/p/src/db.rs @@ -0,0 +1,113 @@ +/// Per-job JSON storage under ~/.local/share/p/jobs/.json +/// +/// Each job is a self-contained file. This makes records human-readable, +/// trivially debuggable with `cat`, and requires no schema migrations. +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::job::Job; + +// ── Paths ───────────────────────────────────────────────────────────────────── + +pub fn jobs_dir() -> Result { + Ok(dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine data directory"))? + .join("p") + .join("jobs")) +} + +fn job_path(id: &str) -> Result { + Ok(jobs_dir()?.join(format!("{}.json", id))) +} + +// ── CRUD ────────────────────────────────────────────────────────────────────── + +/// Write (or overwrite) a job record to disk. +pub fn save(job: &Job) -> Result<()> { + let dir = jobs_dir()?; + std::fs::create_dir_all(&dir)?; + let content = serde_json::to_string_pretty(job).context("failed to serialise job")?; + std::fs::write(dir.join(format!("{}.json", job.id)), content) + .with_context(|| format!("failed to write job {}", job.id)) +} + +/// Load a job by exact UUID. +pub fn load(id: &str) -> Result> { + let path = job_path(id)?; + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read job {}", id))?; + serde_json::from_str(&content) + .with_context(|| format!("failed to parse job {}", id)) + .map(Some) +} + +/// Find a job by UUID prefix. Errors if the prefix is ambiguous. +pub fn find(prefix: &str) -> Result> { + let dir = jobs_dir()?; + if !dir.exists() { + return Ok(None); + } + + let matches: Vec = std::fs::read_dir(&dir) + .context("failed to read jobs directory")? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.extension().and_then(|e| e.to_str()) == Some("json") + && p.file_stem() + .and_then(|s| s.to_str()) + .map(|stem| stem.starts_with(prefix)) + .unwrap_or(false) + }) + .collect(); + + match matches.len() { + 0 => Ok(None), + 1 => { + let content = std::fs::read_to_string(&matches[0]) + .with_context(|| format!("failed to read {:?}", matches[0]))?; + serde_json::from_str(&content) + .context("failed to parse job") + .map(Some) + } + n => anyhow::bail!("ambiguous job ID '{}' matches {} jobs", prefix, n), + } +} + +/// List all jobs, sorted newest-first. +pub fn list() -> Result> { + let dir = jobs_dir()?; + if !dir.exists() { + return Ok(vec![]); + } + + let mut jobs = Vec::new(); + for entry in std::fs::read_dir(&dir).context("failed to read jobs directory")? { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + match std::fs::read_to_string(&path).and_then(|c| { + serde_json::from_str::(&c).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + }) { + Ok(job) => jobs.push(job), + Err(e) => eprintln!("warning: skipping {:?}: {}", path, e), + } + } + + jobs.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + Ok(jobs) +} + +/// Delete a job record from disk. +pub fn delete(id: &str) -> Result<()> { + let path = job_path(id)?; + if path.exists() { + std::fs::remove_file(&path) + .with_context(|| format!("failed to delete job {}", id))?; + } + Ok(()) +} diff --git a/p/src/job.rs b/p/src/job.rs new file mode 100644 index 0000000..dd58371 --- /dev/null +++ b/p/src/job.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// A job submitted to a remote worker. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Job { + pub id: String, + pub worker: String, + pub cwd: String, + pub command: String, + pub started_at: i64, + pub finished_at: Option, + pub exit_code: Option, + pub status: JobStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JobStatus { + Running, + Done, + Failed, + /// Status not yet reconciled with the worker. + Unknown, +} + +impl JobStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Running => "running", + Self::Done => "done", + Self::Failed => "failed", + Self::Unknown => "unknown", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "running" => Self::Running, + "done" => Self::Done, + "failed" => Self::Failed, + _ => Self::Unknown, + } + } +} diff --git a/p/src/main.rs b/p/src/main.rs new file mode 100644 index 0000000..7606061 --- /dev/null +++ b/p/src/main.rs @@ -0,0 +1,67 @@ +mod cli; +mod commands; +mod config; +mod db; +mod job; +mod ssh; +mod sync; + +use anyhow::Result; +use clap::Parser; + +fn main() -> Result<()> { + let raw: Vec = std::env::args().collect(); + + // `p [flags] [worker] -- ` + // We detect the "--" separator manually so clap never sees it. + if let Some(sep) = raw.iter().position(|a| a == "--") { + let pre = &raw[1..sep]; + let cmd: Vec = raw[sep + 1..].to_vec(); + + if cmd.is_empty() { + eprintln!("error: no command specified after --"); + eprintln!("usage: p [-n] [] -- "); + std::process::exit(1); + } + + let mut no_sync = false; + let mut worker: Option = None; + + for arg in pre { + match arg.as_str() { + "-n" | "--no-sync" => no_sync = true, + flag if flag.starts_with('-') => { + eprintln!("error: unknown flag '{}'\nusage: p [-n] [] -- ", flag); + std::process::exit(1); + } + name => { + if worker.is_some() { + eprintln!("error: multiple worker names before '--'"); + std::process::exit(1); + } + worker = Some(name.to_string()); + } + } + } + + return commands::run::execute(worker.as_deref(), cmd, no_sync); + } + + // All other subcommands go through clap. + let cli = cli::Cli::parse(); + match cli.command { + cli::Command::Ls => commands::ls::execute(), + cli::Command::Attach { job_id } => commands::attach::execute(&job_id), + cli::Command::Logs { job_id, follow } => commands::logs::execute(&job_id, follow), + cli::Command::Stop { job_id } => commands::stop::execute(&job_id), + cli::Command::Pull { job_id, remote_path, local_dest } => { + commands::pull::execute(&job_id, &remote_path, local_dest.as_deref()) + } + cli::Command::Rm { job_id, force } => commands::rm::execute(&job_id, force), + cli::Command::Register { connection, name } => { + commands::register::execute(&connection, name.as_deref()) + } + cli::Command::Workers => commands::workers::execute(), + cli::Command::Default { worker } => commands::set_default::execute(&worker), + } +} diff --git a/p/src/ssh.rs b/p/src/ssh.rs new file mode 100644 index 0000000..1bafc0a --- /dev/null +++ b/p/src/ssh.rs @@ -0,0 +1,45 @@ +/// SSH helpers: run commands, check reachability, poll job status. +/// Full implementation in the run-command commit. +use crate::config::WorkerConfig; + +/// Parse a connection string into (user@host, optional port). +/// Handles "user@host:port", "user@host", "host:port", "host". +pub fn parse_connection(conn: &str) -> (String, Option) { + // Only treat the last ':something' as a port if 'something' is a number. + // This avoids misinterpreting plain SSH config aliases. + if let Some(at) = conn.rfind('@') { + let host_part = &conn[at + 1..]; + if let Some((host, port_str)) = host_part.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + return (format!("{}@{}", &conn[..at], host), Some(port)); + } + } + } else if let Some((host, port_str)) = conn.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + return (host.to_string(), Some(port)); + } + } + (conn.to_string(), None) +} + +/// Extract the bare hostname from a connection string (used as a default worker name). +pub fn hostname_from_connection(conn: &str) -> String { + let (user_host, _port) = parse_connection(conn); + // Strip "user@" prefix if present + match user_host.rsplit_once('@') { + Some((_user, host)) => host.to_string(), + None => user_host, + } +} + +/// Build the base ssh argument list for a worker (handles custom port). +pub fn ssh_args(worker: &WorkerConfig) -> Vec { + let (user_host, port) = parse_connection(&worker.connection); + let mut args = Vec::new(); + if let Some(p) = port { + args.push("-p".to_string()); + args.push(p.to_string()); + } + args.push(user_host); + args +} diff --git a/p/src/sync.rs b/p/src/sync.rs new file mode 100644 index 0000000..4c1ca8d --- /dev/null +++ b/p/src/sync.rs @@ -0,0 +1,2 @@ +// Directory sync via rsync over SSH. +// Full implementation in the run-command commit.