use std::fs;
use std::io::{self, BufRead, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use netsky_core::config::{self, Config as RuntimeConfig};
use netsky_io::EmailAccessCmd;
use netsky_io::sources::imessage::{access as imessage_access, db as imessage_db};
const CHANNEL_NAMES: [&str; 6] = ["email", "imessage", "calendar", "tasks", "drive", "iroh"];
pub fn run() -> netsky_core::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut input = stdin.lock();
let mut output = stdout.lock();
run_with_io(&mut input, &mut output)
}
fn run_with_io<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> netsky_core::Result<()> {
let existing = ExistingConfig::load()?;
writeln!(output, "netsky onboard").map_err(netsky_core::Error::Io)?;
writeln!(output).map_err(netsky_core::Error::Io)?;
let machine_label = prompt_machine_label(input, output, Some(existing.active_host.as_str()))?;
let imessage_handle = prompt_string(
input,
output,
"Owner iMessage handle",
existing.owner.owner.imessage_handle.as_deref(),
)?;
let email_addresses = prompt_list(
input,
output,
"Owner email addresses (comma separated)",
&existing.owner.owner.email_addresses,
)?;
let github_username = prompt_string(
input,
output,
"GitHub username",
existing.owner.owner.github_username.as_deref(),
)?;
let github_orgs = prompt_list(
input,
output,
"GitHub orgs (comma separated)",
&existing.owner.owner.github_orgs,
)?;
let enabled_channels = prompt_channels(input, output, &existing.enabled_channels)?;
let configure_oauth = prompt_bool(
input,
output,
"Configure Google OAuth client now",
existing.oauth_env_exists,
)?;
let oauth = if configure_oauth {
Some(prompt_oauth(input, output, &existing)?)
} else {
None
};
write_owner_toml(
&imessage_handle,
&email_addresses,
&github_username,
&github_orgs,
existing.owner.owner.claude_owner_ref.as_deref(),
)?;
write_channels_toml(&enabled_channels)?;
write_host_file(&machine_label)?;
write_active_host(&machine_label)?;
ensure_addendum_files(&machine_label)?;
if let Some(ref oauth) = oauth {
write_oauth_env(&oauth.client_id, &oauth.client_secret)?;
}
run_imessage_gates(
input,
output,
&enabled_channels,
&imessage_handle,
&machine_label,
)?;
run_iroh_pairing(input, output, &enabled_channels)?;
writeln!(output).map_err(netsky_core::Error::Io)?;
writeln!(output, "wrote {}", config::owner_path().display()).map_err(netsky_core::Error::Io)?;
writeln!(output, "wrote {}", config::channels_path().display())
.map_err(netsky_core::Error::Io)?;
writeln!(
output,
"wrote {}",
config::host_path(&machine_label).display()
)
.map_err(netsky_core::Error::Io)?;
writeln!(output, "wrote {}", config::active_host_path().display())
.map_err(netsky_core::Error::Io)?;
if enabled_channels.contains(&"imessage") {
writeln!(
output,
"updated {}",
imessage_access::access_file()
.map_err(|err| netsky_core::Error::Message(format!("access path: {err}")))?
.display()
)
.map_err(netsky_core::Error::Io)?;
}
if let Some(ref oauth) = oauth {
writeln!(output, "wrote {}", oauth.path.display()).map_err(netsky_core::Error::Io)?;
if !email_addresses.is_empty()
&& enabled_channels
.iter()
.any(|name| *name == "email" || *name == "calendar")
&& prompt_bool(
input,
output,
"Run OAuth setup for configured email accounts now",
true,
)?
{
for account in &email_addresses {
writeln!(output, "starting oauth-init for {account}")
.map_err(netsky_core::Error::Io)?;
netsky_io::sources::email::access_cli(EmailAccessCmd::OauthInit {
account: account.clone(),
})
.map_err(|err| netsky_core::Error::Message(format!("oauth-init failed: {err}")))?;
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct ExistingConfig {
owner: RuntimeConfig,
active_host: String,
enabled_channels: Vec<&'static str>,
oauth_env_exists: bool,
}
impl ExistingConfig {
fn load() -> netsky_core::Result<Self> {
let owner = RuntimeConfig::load()
.map_err(|err| netsky_core::Error::Message(format!("load runtime config: {err}")))?;
let active_host = config::active_host_label()
.map_err(|err| netsky_core::Error::Message(format!("load active host: {err}")))?
.or_else(|| owner.host.as_ref().map(|host| host.label.clone()))
.unwrap_or_else(|| "personal".to_string());
let enabled_channels = CHANNEL_NAMES
.into_iter()
.filter(|name| owner.channels.is_enabled_source(name))
.collect();
let oauth_env_exists = netsky_io::sources::email::config::oauth_env_file()
.map(|path| path.exists())
.unwrap_or(false);
Ok(Self {
owner,
active_host,
enabled_channels,
oauth_env_exists,
})
}
}
#[derive(Debug, Clone)]
struct OauthConfig {
client_id: String,
client_secret: String,
path: PathBuf,
}
fn prompt_string<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
label: &str,
default: Option<&str>,
) -> netsky_core::Result<String> {
loop {
if let Some(default) = default
&& !default.is_empty()
{
write!(output, "{label} [{default}]: ").map_err(netsky_core::Error::Io)?;
} else {
write!(output, "{label}: ").map_err(netsky_core::Error::Io)?;
}
output.flush().map_err(netsky_core::Error::Io)?;
let mut line = String::new();
input.read_line(&mut line).map_err(netsky_core::Error::Io)?;
let trimmed = line.trim();
if trimmed.is_empty()
&& let Some(default) = default
&& !default.is_empty()
{
return Ok(default.to_string());
}
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
writeln!(output, "value required").map_err(netsky_core::Error::Io)?;
}
}
fn prompt_machine_label<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
default: Option<&str>,
) -> netsky_core::Result<String> {
loop {
let label = prompt_string(input, output, "Machine label", default)?;
match config::validate_host_label(&label) {
Ok(valid) => return Ok(valid.to_string()),
Err(err) => writeln!(output, "{err}").map_err(netsky_core::Error::Io)?,
}
}
}
fn prompt_list<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
label: &str,
defaults: &[String],
) -> netsky_core::Result<Vec<String>> {
let default = (!defaults.is_empty()).then(|| defaults.join(", "));
let line = prompt_line(input, output, label, default.as_deref())?;
if line.trim().is_empty() {
return Ok(defaults.to_vec());
}
Ok(parse_csv(&line))
}
fn prompt_bool<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
label: &str,
default: bool,
) -> netsky_core::Result<bool> {
let suffix = if default { "Y/n" } else { "y/N" };
loop {
write!(output, "{label} [{suffix}]: ").map_err(netsky_core::Error::Io)?;
output.flush().map_err(netsky_core::Error::Io)?;
let mut line = String::new();
input.read_line(&mut line).map_err(netsky_core::Error::Io)?;
let trimmed = line.trim().to_ascii_lowercase();
if trimmed.is_empty() {
return Ok(default);
}
match trimmed.as_str() {
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => writeln!(output, "enter y or n").map_err(netsky_core::Error::Io)?,
}
}
}
fn prompt_channels<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
defaults: &[&'static str],
) -> netsky_core::Result<Vec<&'static str>> {
writeln!(
output,
"Channels: email, imessage, calendar, tasks, drive, iroh"
)
.map_err(netsky_core::Error::Io)?;
let default = if defaults.is_empty() {
None
} else {
Some(defaults.join(", "))
};
let raw = prompt_line(
input,
output,
"Enable channels (comma separated, agent is always on)",
default.as_deref(),
)?;
if raw.trim().is_empty() {
return Ok(defaults.to_vec());
}
let wanted = parse_csv(&raw);
let mut enabled = Vec::new();
for name in CHANNEL_NAMES {
if wanted.iter().any(|value| value == name) {
enabled.push(name);
}
}
Ok(enabled)
}
fn prompt_oauth<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
existing: &ExistingConfig,
) -> netsky_core::Result<OauthConfig> {
let path = netsky_io::sources::email::config::oauth_env_file()
.map_err(|err| netsky_core::Error::Message(format!("oauth env path: {err}")))?;
let existing_env = fs::read_to_string(&path).unwrap_or_default();
let client_id_default = dotenv_value(&existing_env, "NETSKY_OAUTH_CLIENT_ID");
let client_secret_default = dotenv_value(&existing_env, "NETSKY_OAUTH_CLIENT_SECRET");
writeln!(output, "OAuth env file: {}", path.display()).map_err(netsky_core::Error::Io)?;
let client_id = prompt_string(
input,
output,
"Desktop OAuth client id",
client_id_default.as_deref(),
)?;
let client_secret = prompt_string(
input,
output,
"Desktop OAuth client secret",
client_secret_default.as_deref(),
)?;
if client_id.is_empty() || client_secret.is_empty() {
return Err(netsky_core::Error::Invalid(
"oauth client id and secret are required".into(),
));
}
let _ = existing;
Ok(OauthConfig {
client_id,
client_secret,
path,
})
}
fn run_imessage_gates<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
enabled_channels: &[&str],
imessage_handle: &str,
machine_label: &str,
) -> netsky_core::Result<()> {
if !enabled_channels.contains(&"imessage") {
return Ok(());
}
writeln!(output).map_err(netsky_core::Error::Io)?;
writeln!(output, "iMessage setup").map_err(netsky_core::Error::Io)?;
writeln!(
output,
"Grant Full Disk Access to your terminal: System Settings -> Privacy & Security -> Full Disk Access."
)
.map_err(netsky_core::Error::Io)?;
writeln!(
output,
"Restart the terminal if macOS asks, then return here."
)
.map_err(netsky_core::Error::Io)?;
wait_for_enter(
input,
output,
"Press Enter after Full Disk Access is enabled",
)?;
let conn = imessage_db::open().map_err(|err| {
netsky_core::Error::Message(format!(
"cannot read iMessage chat.db after Full Disk Access prompt: {err}"
))
})?;
let mut access = imessage_access::load();
let normalized = imessage_handle.trim().to_lowercase();
if !normalized.is_empty()
&& !access
.self_
.handles
.iter()
.any(|value| value == &normalized)
{
access.self_.handles.push(normalized.clone());
}
let candidates = discover_self_chat_guids(&conn, &normalized)
.map_err(|err| netsky_core::Error::Message(format!("discover self chat: {err}")))?;
if !candidates.is_empty() {
let candidate = candidates[0].clone();
writeln!(output, "Self-chat candidate: {candidate}").map_err(netsky_core::Error::Io)?;
if candidates.len() > 1 {
writeln!(output, "Other candidates: {}", candidates[1..].join(", "))
.map_err(netsky_core::Error::Io)?;
}
if prompt_bool(input, output, "Use this self-chat guid", true)? {
access.self_.chat_id = Some(candidate);
}
} else {
writeln!(
output,
"No self-chat guid found for {machine_label}. Open Messages.app, message yourself, then rerun bin/onboard."
)
.map_err(netsky_core::Error::Io)?;
}
imessage_access::save(&access)
.map_err(|err| netsky_core::Error::Message(format!("save imessage access: {err}")))?;
Ok(())
}
fn run_iroh_pairing<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
enabled_channels: &[&str],
) -> netsky_core::Result<()> {
if !enabled_channels.contains(&"iroh") {
return Ok(());
}
writeln!(output).map_err(netsky_core::Error::Io)?;
writeln!(output, "iroh pairing").map_err(netsky_core::Error::Io)?;
let (keyfile_path, key) = netsky_io::sources::iroh::keyfile::bootstrap()
.map_err(|err| netsky_core::Error::Message(format!("iroh keyfile bootstrap: {err}")))?;
let local_id = key.public().to_string();
writeln!(output, "this machine's iroh EndpointId: {local_id}")
.map_err(netsky_core::Error::Io)?;
writeln!(output, "(keyfile: {})", keyfile_path.display()).map_err(netsky_core::Error::Io)?;
writeln!(
output,
"Share this hex with the peer machine via an owner-trusted channel (in-person, self-iMessage)."
)
.map_err(netsky_core::Error::Io)?;
if !prompt_bool(input, output, "Pair with a peer netsky now", false)? {
writeln!(
output,
"Skipping iroh pairing. Pair later with: netsky io iroh pair add <label> <node_id>"
)
.map_err(netsky_core::Error::Io)?;
return Ok(());
}
loop {
let label = prompt_string(input, output, "Peer label (e.g. netsky0, work)", None)?;
let node_id = prompt_string(
input,
output,
"Peer iroh EndpointId (hex from `netsky io iroh whoami` on the peer)",
None,
)?;
match netsky_io::sources::iroh::pair_add(&label, &node_id) {
Ok(()) => writeln!(output, "paired `{label}`").map_err(netsky_core::Error::Io)?,
Err(err) => {
writeln!(output, "iroh pairing incomplete: pair add failed: {err}")
.map_err(netsky_core::Error::Io)?;
return Err(netsky_core::Error::Message(format!(
"pair add failed: {err}"
)));
}
}
if !prompt_bool(input, output, "Add another peer", false)? {
break;
}
}
match netsky_io::sources::iroh::peers::pair_list() {
Ok(entries) => {
writeln!(output, "current pairs:").map_err(netsky_core::Error::Io)?;
if entries.is_empty() {
writeln!(output, " (none)").map_err(netsky_core::Error::Io)?;
} else {
for entry in entries {
writeln!(
output,
" - {}: {} ({})",
entry.label,
entry.node_id,
entry.source.as_str()
)
.map_err(netsky_core::Error::Io)?;
}
}
}
Err(err) => {
writeln!(output, "iroh pairing incomplete: pair list failed: {err}")
.map_err(netsky_core::Error::Io)?;
return Err(netsky_core::Error::Message(format!(
"pair list failed: {err}"
)));
}
}
Ok(())
}
fn write_owner_toml(
imessage_handle: &str,
email_addresses: &[String],
github_username: &str,
github_orgs: &[String],
claude_owner_ref: Option<&str>,
) -> netsky_core::Result<()> {
let mut body = String::new();
body.push_str(&format!(
"imessage_handle = {}\n",
toml_string(imessage_handle)
));
body.push_str(&format!(
"email_addresses = {}\n",
toml_array(email_addresses)
));
body.push_str(&format!(
"github_username = {}\n",
toml_string(github_username)
));
body.push_str(&format!("github_orgs = {}\n", toml_array(github_orgs)));
if let Some(value) = claude_owner_ref
&& !value.trim().is_empty()
{
body.push_str(&format!("claude_owner_ref = {}\n", toml_string(value)));
}
body.push('\n');
for address in email_addresses {
body.push_str("[[email_accounts]]\n");
body.push_str(&format!("primary = {}\n", toml_string(address)));
body.push_str(&format!("send_as = [{}]\n\n", toml_string(address)));
}
write_mode_600(&config::owner_path(), &body)
}
fn discover_self_chat_guids(
conn: &rusqlite::Connection,
handle: &str,
) -> anyhow::Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
if !handle.is_empty() {
out.extend(imessage_db::dm_chats_for_handle(conn, handle)?);
}
if out.is_empty() {
let mut stmt = conn
.prepare("SELECT guid FROM chat WHERE guid LIKE 'any;%' ORDER BY ROWID DESC LIMIT 5")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
for guid in rows {
out.push(guid?);
}
}
out.sort();
out.dedup();
Ok(out)
}
fn write_channels_toml(enabled_channels: &[&str]) -> netsky_core::Result<()> {
let mut body = String::new();
body.push_str("[channels.agent]\nenabled = true\n\n");
for name in CHANNEL_NAMES {
let enabled = enabled_channels.contains(&name);
body.push_str(&format!("[channels.{name}]\nenabled = {enabled}\n\n"));
}
write_mode_600(&config::channels_path(), body.trim_end_matches('\n'))
}
fn write_host_file(machine_label: &str) -> netsky_core::Result<()> {
let path = config::host_path(machine_label);
if path.exists() {
return Ok(());
}
write_mode_600(
&path,
&format!("# host-specific overrides for {machine_label}\n"),
)
}
fn write_active_host(machine_label: &str) -> netsky_core::Result<()> {
write_mode_600(&config::active_host_path(), &format!("{machine_label}\n"))
}
fn ensure_addendum_files(machine_label: &str) -> netsky_core::Result<()> {
ensure_file_if_missing(
&config::addendum_path(),
"# base addendum: shared owner context\n",
)?;
ensure_file_if_missing(
&config::host_addendum_path(machine_label),
&format!("# host addendum for {machine_label}\n"),
)
}
fn write_oauth_env(client_id: &str, client_secret: &str) -> netsky_core::Result<()> {
let path = netsky_io::sources::email::config::oauth_env_file()
.map_err(|err| netsky_core::Error::Message(format!("oauth env path: {err}")))?;
let body = format!(
"export NETSKY_OAUTH_CLIENT_ID={}\nexport NETSKY_OAUTH_CLIENT_SECRET={}\n",
shell_quote(client_id),
shell_quote(client_secret)
);
write_mode_600(&path, &body)
}
fn ensure_file_if_missing(path: &Path, content: &str) -> netsky_core::Result<()> {
if path.exists() {
return Ok(());
}
write_mode_600(path, content)
}
fn write_mode_600(path: &Path, content: &str) -> netsky_core::Result<()> {
let parent = path.parent().ok_or_else(|| {
netsky_core::Error::Invalid(format!("path has no parent: {}", path.display()))
})?;
fs::create_dir_all(parent).map_err(netsky_core::Error::Io)?;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).ok();
let filename = path.file_name().ok_or_else(|| {
netsky_core::Error::Invalid(format!("path has no file name: {}", path.display()))
})?;
let tmp = parent.join(format!(".{}.tmp", filename.to_string_lossy()));
{
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.map_err(netsky_core::Error::Io)?;
file.write_all(content.as_bytes())
.map_err(netsky_core::Error::Io)?;
file.flush().map_err(netsky_core::Error::Io)?;
}
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).ok();
fs::rename(&tmp, path).map_err(netsky_core::Error::Io)?;
Ok(())
}
fn parse_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn prompt_line<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
label: &str,
default: Option<&str>,
) -> netsky_core::Result<String> {
if let Some(default) = default
&& !default.is_empty()
{
write!(output, "{label} [{default}]: ").map_err(netsky_core::Error::Io)?;
} else {
write!(output, "{label}: ").map_err(netsky_core::Error::Io)?;
}
output.flush().map_err(netsky_core::Error::Io)?;
let mut line = String::new();
input.read_line(&mut line).map_err(netsky_core::Error::Io)?;
let trimmed = line.trim();
if trimmed.is_empty()
&& let Some(default) = default
{
return Ok(default.to_string());
}
Ok(trimmed.to_string())
}
fn wait_for_enter<R: BufRead, W: Write>(
input: &mut R,
output: &mut W,
label: &str,
) -> netsky_core::Result<()> {
write!(output, "{label}: ").map_err(netsky_core::Error::Io)?;
output.flush().map_err(netsky_core::Error::Io)?;
let mut line = String::new();
input.read_line(&mut line).map_err(netsky_core::Error::Io)?;
Ok(())
}
fn toml_string(value: &str) -> String {
toml::Value::String(value.to_string()).to_string()
}
fn toml_array(values: &[String]) -> String {
let values = values
.iter()
.cloned()
.map(toml::Value::String)
.collect::<Vec<_>>();
toml::Value::Array(values).to_string()
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn dotenv_value(raw: &str, key: &str) -> Option<String> {
raw.lines().find_map(|line| {
let trimmed = line.trim();
let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let (left, right) = trimmed.split_once('=')?;
if left.trim() != key {
return None;
}
Some(
right
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::sync::{Mutex, MutexGuard, OnceLock};
use tempfile::TempDir;
struct TestEnv {
_tmp: TempDir,
_guard: MutexGuard<'static, ()>,
prior_xdg: Option<String>,
prior_state: Option<String>,
prior_oauth: Option<String>,
prior_imessage_db: Option<String>,
prior_iroh_keyfile: Option<String>,
}
impl TestEnv {
fn new() -> Self {
let guard = test_lock().lock().unwrap_or_else(|err| err.into_inner());
let tmp = TempDir::new().unwrap();
let prior_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let prior_state = std::env::var("NETSKY_IO_STATE_DIR").ok();
let prior_oauth = std::env::var("NETSKY_OAUTH_ENV_FILE").ok();
let prior_imessage_db = std::env::var("IMESSAGE_DB_PATH").ok();
let prior_iroh_keyfile = std::env::var("NETSKY_IROH_KEYFILE").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
std::env::set_var("NETSKY_IO_STATE_DIR", tmp.path().join("netsky-io"));
std::env::set_var(
"NETSKY_OAUTH_ENV_FILE",
tmp.path().join("netsky").join("oauth-desktop.env"),
);
std::env::set_var(
"NETSKY_IROH_KEYFILE",
tmp.path().join("netsky-io").join("iroh").join("test.key"),
);
}
Self {
_tmp: tmp,
_guard: guard,
prior_xdg,
prior_state,
prior_oauth,
prior_imessage_db,
prior_iroh_keyfile,
}
}
}
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
impl Drop for TestEnv {
fn drop(&mut self) {
unsafe {
match &self.prior_xdg {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match &self.prior_state {
Some(value) => std::env::set_var("NETSKY_IO_STATE_DIR", value),
None => std::env::remove_var("NETSKY_IO_STATE_DIR"),
}
match &self.prior_oauth {
Some(value) => std::env::set_var("NETSKY_OAUTH_ENV_FILE", value),
None => std::env::remove_var("NETSKY_OAUTH_ENV_FILE"),
}
match &self.prior_imessage_db {
Some(value) => std::env::set_var("IMESSAGE_DB_PATH", value),
None => std::env::remove_var("IMESSAGE_DB_PATH"),
}
match &self.prior_iroh_keyfile {
Some(value) => std::env::set_var("NETSKY_IROH_KEYFILE", value),
None => std::env::remove_var("NETSKY_IROH_KEYFILE"),
}
}
}
}
#[test]
fn parse_csv_trims_and_drops_empty_items() {
assert_eq!(
parse_csv("email, imessage,, drive"),
vec!["email", "imessage", "drive"]
);
}
#[test]
fn run_with_io_writes_config_files() {
let _env = TestEnv::new();
write_imessage_fixture("owner@example.com", "any;-;owner@example.com");
let mut input = io::Cursor::new(
"work\nowner@example.com\nowner@example.com, alias@example.com\ncody\ndkdc\nemail, imessage\ny\nclient-id\nclient-secret\n\nY\nn\n",
);
let mut output = Vec::new();
run_with_io(&mut input, &mut output).unwrap();
let owner = fs::read_to_string(config::owner_path()).unwrap();
assert!(owner.contains("owner@example.com"));
assert!(owner.contains("alias@example.com"));
let channels = fs::read_to_string(config::channels_path()).unwrap();
assert!(channels.contains("[channels.agent]"));
assert!(channels.contains("[channels.email]"));
assert!(channels.contains("[channels.imessage]"));
assert!(channels.contains("[channels.drive]\nenabled = false"));
assert_eq!(
fs::read_to_string(config::active_host_path()).unwrap(),
"work\n"
);
assert!(config::host_path("work").exists());
assert!(config::addendum_path().exists());
assert!(config::host_addendum_path("work").exists());
let access = imessage_access::load();
assert!(
access
.self_
.handles
.contains(&"owner@example.com".to_string())
);
assert_eq!(
access.self_.chat_id.as_deref(),
Some("any;-;owner@example.com")
);
let oauth =
fs::read_to_string(netsky_io::sources::email::config::oauth_env_file().unwrap())
.unwrap();
assert!(oauth.contains("NETSKY_OAUTH_CLIENT_ID='client-id'"));
}
#[test]
fn run_with_io_reprompts_after_invalid_machine_label() {
let _env = TestEnv::new();
let mut input = io::Cursor::new(
"../../../tmp/pwn\nwork\nowner@example.com\nowner@example.com\ncody\ndkdc\nemail\nn\n",
);
let mut output = Vec::new();
run_with_io(&mut input, &mut output).unwrap();
let rendered = String::from_utf8_lossy(&output);
assert!(
rendered.contains("machine label cannot"),
"output: {rendered}"
);
assert_eq!(
fs::read_to_string(config::active_host_path()).unwrap(),
"work\n"
);
assert!(config::host_path("work").exists());
assert!(
!config::config_dir()
.join("..")
.join("..")
.join("..")
.join("tmp")
.join("pwn")
.exists()
);
}
#[test]
fn discover_self_chat_guid_prefers_handle_match() {
let _env = TestEnv::new();
let path = write_imessage_fixture("owner@example.com", "any;-;owner@example.com");
unsafe {
std::env::set_var("IMESSAGE_DB_PATH", &path);
}
let conn = imessage_db::open().unwrap();
let guids = discover_self_chat_guids(&conn, "owner@example.com").unwrap();
assert_eq!(guids, vec!["any;-;owner@example.com".to_string()]);
}
#[test]
fn cli_parses_onboard_subcommand() {
let cli = crate::cli::Cli::parse_from(["netsky", "onboard"]);
assert!(matches!(cli.command, Some(crate::cli::Command::Onboard)));
}
#[test]
fn run_iroh_pairing_skips_when_channel_disabled() {
let _env = TestEnv::new();
let mut input = io::Cursor::new("");
let mut output: Vec<u8> = Vec::new();
run_iroh_pairing(&mut input, &mut output, &["email", "imessage"]).unwrap();
assert!(
output.is_empty(),
"iroh disabled should produce no output: {}",
String::from_utf8_lossy(&output)
);
}
#[test]
fn run_iroh_pairing_pair_add_writes_peers_toml() {
let _env = TestEnv::new();
let (_path, key) = netsky_io::sources::iroh::keyfile::bootstrap().unwrap();
let peer_hex = key.public().to_string();
let script = format!("y\nnetsky0\n{peer_hex}\nn\n");
let mut input = io::Cursor::new(script);
let mut output: Vec<u8> = Vec::new();
run_iroh_pairing(&mut input, &mut output, &["iroh"]).unwrap();
let rendered = String::from_utf8_lossy(&output);
assert!(rendered.contains("paired `netsky0`"), "output: {rendered}");
let peers = netsky_io::sources::iroh::peers::pair_list().unwrap();
assert!(
peers.iter().any(|entry| entry.label == "netsky0"),
"pair_list missing netsky0: {peers:?}"
);
}
#[test]
fn run_iroh_pairing_propagates_pair_add_failure() {
let _env = TestEnv::new();
let mut input = io::Cursor::new("y\nnetsky0\nnot-a-node-id\nn\n");
let mut output: Vec<u8> = Vec::new();
let err = run_iroh_pairing(&mut input, &mut output, &["iroh"])
.unwrap_err()
.to_string();
assert!(err.contains("pair add failed"), "err: {err}");
let rendered = String::from_utf8_lossy(&output);
assert!(
rendered.contains("iroh pairing incomplete: pair add failed"),
"output: {rendered}"
);
}
fn write_imessage_fixture(handle: &str, guid: &str) -> PathBuf {
let path = config::config_dir().parent().unwrap().join("chat.db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE message (ROWID INTEGER PRIMARY KEY, account TEXT, is_from_me INTEGER);
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);
CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, guid TEXT, style INTEGER);
CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);",
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, account, is_from_me) VALUES (1, ?1, 1)",
[format!("E:{handle}")],
)
.unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, ?1)", [handle])
.unwrap();
conn.execute(
"INSERT INTO chat (ROWID, guid, style) VALUES (1, ?1, 45)",
[guid],
)
.unwrap();
conn.execute(
"INSERT INTO chat_handle_join (chat_id, handle_id) VALUES (1, 1)",
[],
)
.unwrap();
unsafe {
std::env::set_var("IMESSAGE_DB_PATH", &path);
}
path
}
}