argument parsing, skeleton structure, basic project

This commit is contained in:
2026-05-19 20:50:58 +02:00
parent b2a30d7900
commit beb4797119
24 changed files with 1013 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

474
Cargo.lock generated Normal file
View File

@@ -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"

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["p", "agent"]
resolver = "2"

11
agent/Cargo.toml Normal file
View File

@@ -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"

4
agent/src/main.rs Normal file
View File

@@ -0,0 +1,4 @@
fn main() {
println!("p-agent — worker-side agent for p");
println!("(not yet implemented)");
}

20
p/Cargo.toml Normal file
View File

@@ -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"

86
p/src/cli.rs Normal file
View File

@@ -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 -- <command>
Run on a specific worker:
p <worker> -- <command>
Skip directory sync:
p -n -- <command>"
)]
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<String>,
},
/// 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<String>,
},
/// List registered workers and their reachability status
Workers,
/// Set the default worker
Default {
/// Worker name
worker: String,
},
}

6
p/src/commands/attach.rs Normal file
View File

@@ -0,0 +1,6 @@
use anyhow::Result;
pub fn execute(job_id: &str) -> Result<()> {
let _ = job_id;
anyhow::bail!("not yet implemented")
}

6
p/src/commands/logs.rs Normal file
View File

@@ -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")
}

5
p/src/commands/ls.rs Normal file
View File

@@ -0,0 +1,5 @@
use anyhow::Result;
pub fn execute() -> Result<()> {
anyhow::bail!("not yet implemented")
}

10
p/src/commands/mod.rs Normal file
View File

@@ -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;

6
p/src/commands/pull.rs Normal file
View File

@@ -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")
}

View File

@@ -0,0 +1,6 @@
use anyhow::Result;
pub fn execute(connection: &str, name: Option<&str>) -> Result<()> {
let _ = (connection, name);
anyhow::bail!("not yet implemented")
}

6
p/src/commands/rm.rs Normal file
View File

@@ -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")
}

6
p/src/commands/run.rs Normal file
View File

@@ -0,0 +1,6 @@
use anyhow::Result;
pub fn execute(worker: Option<&str>, cmd: Vec<String>, no_sync: bool) -> Result<()> {
let _ = (worker, cmd, no_sync);
anyhow::bail!("not yet implemented")
}

View File

@@ -0,0 +1,6 @@
use anyhow::Result;
pub fn execute(worker: &str) -> Result<()> {
let _ = worker;
anyhow::bail!("not yet implemented")
}

6
p/src/commands/stop.rs Normal file
View File

@@ -0,0 +1,6 @@
use anyhow::Result;
pub fn execute(job_id: &str) -> Result<()> {
let _ = job_id;
anyhow::bail!("not yet implemented")
}

View File

@@ -0,0 +1,5 @@
use anyhow::Result;
pub fn execute() -> Result<()> {
anyhow::bail!("not yet implemented")
}

75
p/src/config.rs Normal file
View File

@@ -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<String>,
#[serde(default)]
pub workers: Vec<WorkerConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WorkerConfig {
pub name: String,
pub connection: String,
}
// ── Path helpers ──────────────────────────────────────────────────────────────
pub fn config_path() -> Result<PathBuf> {
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<Config> {
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 <connection>' first"
)),
}
}
}

113
p/src/db.rs Normal file
View File

@@ -0,0 +1,113 @@
/// Per-job JSON storage under ~/.local/share/p/jobs/<uuid>.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<PathBuf> {
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<PathBuf> {
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<Option<Job>> {
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<Option<Job>> {
let dir = jobs_dir()?;
if !dir.exists() {
return Ok(None);
}
let matches: Vec<PathBuf> = 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<Vec<Job>> {
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::<Job>(&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(())
}

44
p/src/job.rs Normal file
View File

@@ -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<i64>,
pub exit_code: Option<i32>,
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,
}
}
}

67
p/src/main.rs Normal file
View File

@@ -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<String> = std::env::args().collect();
// `p [flags] [worker] -- <command...>`
// 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<String> = raw[sep + 1..].to_vec();
if cmd.is_empty() {
eprintln!("error: no command specified after --");
eprintln!("usage: p [-n] [<worker>] -- <command>");
std::process::exit(1);
}
let mut no_sync = false;
let mut worker: Option<String> = 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] [<worker>] -- <command>", 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),
}
}

45
p/src/ssh.rs Normal file
View File

@@ -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<u16>) {
// 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::<u16>() {
return (format!("{}@{}", &conn[..at], host), Some(port));
}
}
} else if let Some((host, port_str)) = conn.rsplit_once(':') {
if let Ok(port) = port_str.parse::<u16>() {
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<String> {
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
}

2
p/src/sync.rs Normal file
View File

@@ -0,0 +1,2 @@
// Directory sync via rsync over SSH.
// Full implementation in the run-command commit.