pub mod console;
mod agent;
mod env_config;
mod detect;
mod doctor;
mod generate;
mod init;
mod install;
mod rag;
mod scss;
mod session;
mod upgrade;
mod watcher;
use clap::{Parser, Subcommand};
use colored::Colorize;
use crate::console::{icon_eye, icon_fail, icon_info, icon_ok, icon_play, icon_warn};
#[derive(Parser)]
#[command(
name = "tina4",
version = env!("CARGO_PKG_VERSION"),
about = "Tina4 — Unified CLI for Python, PHP, Ruby, and Node.js",
long_about = "The Tina4 CLI detects your project language, manages runtimes,\ncompiles SCSS, watches files for dev-reload, and delegates\nto the language-specific CLI (tina4python, tina4php, tina4ruby, tina4nodejs)."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Doctor,
Install {
lang: String,
},
Init {
lang: Option<String>,
path: Option<String>,
},
Serve {
#[arg(short, long)]
port: Option<u16>,
#[arg(long, default_value = "0.0.0.0")]
host: String,
#[arg(long)]
dev: bool,
#[arg(long)]
production: bool,
#[arg(long)]
no_browser: bool,
},
Scss {
#[arg(short, long, default_value = "src/scss")]
input: String,
#[arg(short, long, default_value = "src/public/css")]
output: String,
#[arg(short, long)]
minify: bool,
#[arg(short, long)]
watch: bool,
},
Migrate {
#[arg(long)]
create: Option<String>,
},
Test,
Routes,
Generate {
#[arg()]
what: String,
#[arg()]
name: String,
},
Ai {
#[arg(long)]
all: bool,
#[arg(long)]
force: bool,
},
#[command(name = "i-want-to-stop-using-v2-and-switch-to-v3")]
IWantToStopUsingV2AndSwitchToV3,
Update,
Books,
Docs,
Console,
Agent {
#[arg(short, long)]
port: Option<u16>,
},
Env {
#[arg(long)]
sync: bool,
#[arg(long)]
example: bool,
#[arg(long)]
list: bool,
},
}
fn main() {
console::enable_ansi();
let cli = Cli::parse();
match cli.command {
Commands::Doctor => doctor::run(),
Commands::Install { lang } => install::run(&lang),
Commands::Init { lang, path } => init::run(lang.as_deref(), path.as_deref()),
Commands::Serve { port, host, dev, production, no_browser } => handle_serve(port, &host, dev, production, no_browser),
Commands::Scss {
input,
output,
minify,
watch,
} => {
scss::compile_dir(&input, &output, minify);
if watch {
println!(
"{} Watching {} for SCSS changes...",
icon_play().green(),
input.cyan()
);
watcher::watch_scss(&input, &output, minify);
}
}
Commands::Migrate { create } => {
delegate_command(if let Some(desc) = create {
vec!["migrate:create".into(), desc]
} else {
vec!["migrate".into()]
});
}
Commands::Test => delegate_command(vec!["test".into()]),
Commands::Routes => delegate_command(vec!["routes".into()]),
Commands::Generate { what, name } => generate::run(&what, &name),
Commands::Agent { port } => {
let default_port = 9145u16; agent::run(port.unwrap_or(default_port));
}
Commands::Ai { all, force } => {
if is_tina4js_project() {
handle_tina4js_ai(all, force);
} else {
let mut args = vec!["ai".to_string()];
if all { args.push("--all".into()); }
if force { args.push("--force".into()); }
delegate_command(args);
}
}
Commands::IWantToStopUsingV2AndSwitchToV3 => upgrade::run(),
Commands::Update => handle_update(),
Commands::Console => delegate_command(vec!["console".into()]),
Commands::Books => handle_books(),
Commands::Docs => handle_docs(),
Commands::Env { sync, example, list } => env_config::run(sync, example, list),
}
}
pub fn handle_serve(port: Option<u16>, host: &str, force_dev: bool, force_production: bool, no_browser: bool) {
std::thread::spawn(|| {
if let Some(latest_tag) = get_latest_version() {
let latest = latest_tag.trim_start_matches('v');
if latest != CURRENT_VERSION {
eprintln!(
"\n{} Tina4 CLI {} available (you have {}). Run: tina4 update\n",
icon_warn().yellow(),
latest.cyan(),
CURRENT_VERSION.dimmed()
);
}
}
});
let lang = detect::detect_language();
let info = match lang {
Some(i) => i,
None => {
eprintln!(
"{} No Tina4 project detected. Run: tina4 init <language> <path>",
icon_fail().red()
);
std::process::exit(1);
}
};
let requested_port = port.unwrap_or_else(|| {
if let Ok(contents) = std::fs::read_to_string(".env") {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if std::env::var(key).is_err() {
std::env::set_var(key, value);
}
}
}
}
std::env::var("TINA4_PORT")
.or_else(|_| std::env::var("PORT"))
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or_else(|| info.default_port())
});
let explicit_port = port.is_some();
let port = if explicit_port {
if std::net::TcpListener::bind(("127.0.0.1", requested_port)).is_err() {
println!(
"{} Port {} in use — killing existing process...",
icon_warn().yellow(),
requested_port.to_string().cyan()
);
if console::kill_port(requested_port) {
println!(
"{} Port {} freed",
icon_ok().green(),
requested_port.to_string().cyan()
);
} else {
eprintln!(
"{} Could not free port {} — process may require manual termination",
icon_fail().red(),
requested_port
);
std::process::exit(1);
}
}
requested_port
} else {
if !std::net::TcpListener::bind(("127.0.0.1", requested_port)).is_ok() {
println!(
"{} Port {} in use — killing existing process...",
icon_warn().yellow(),
requested_port.to_string().cyan()
);
if console::kill_port(requested_port) {
println!(
"{} Port {} freed",
icon_ok().green(),
requested_port.to_string().cyan()
);
} else {
eprintln!(
"{} Could not free port {} — process may require manual termination",
icon_fail().red(),
requested_port
);
std::process::exit(1);
}
}
requested_port
};
println!(
"{} Detected {} project",
icon_ok().green(),
info.language.cyan()
);
if force_dev {
std::env::set_var("TINA4_DEBUG", "true");
println!(
"{} Dev mode forced — production server detection disabled",
icon_info().blue()
);
}
if force_production {
std::env::set_var("TINA4_DEBUG", "false");
println!(
"{} Production mode — installing best server if needed",
icon_play().green()
);
install_production_server(&info);
}
let scss_dir = "src/scss";
let css_dir = "src/public/css";
if std::path::Path::new(scss_dir).exists() {
scss::compile_dir(scss_dir, css_dir, false);
}
let debug_mode = std::env::var("TINA4_DEBUG")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes" | "on"))
.unwrap_or(false);
let allow_agents = debug_mode && !force_production;
if allow_agents {
let agent_port = port + 2000;
std::thread::spawn(move || {
match std::panic::catch_unwind(|| {
agent::run(agent_port);
}) {
Ok(_) => {}
Err(e) => {
eprintln!(" {} Agent server crashed: {:?}", console::icon_warn(), e);
}
}
});
} else {
let reason = if force_production {
"--production flag set"
} else {
"TINA4_DEBUG=false"
};
println!(
" {} Agent server disabled ({})",
icon_info().blue(),
reason
);
}
let cli = info.cli_name();
println!(
"{} Starting {} on {}:{}",
icon_play().green(),
cli.cyan(),
host.yellow(),
port.to_string().yellow()
);
let mut server = match start_language_server(&info, port, host) {
Some(child) => child,
None => {
eprintln!("{} Failed to start server", icon_fail().red());
std::process::exit(1);
}
};
std::thread::sleep(std::time::Duration::from_secs(2));
let url = format!("http://localhost:{}", port);
let env_no_browser = read_dotenv_bool("TINA4_NO_BROWSER");
let os_no_browser = std::env::var("TINA4_NO_BROWSER")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(false);
if no_browser || env_no_browser || os_no_browser {
println!("{} Server ready: {}", icon_ok().green(), url.cyan());
} else {
console::open_browser(&url);
println!("{} Browser opened: {}", icon_ok().green(), url.cyan());
}
match info.language.as_str() {
"tina4js" => println!(
"{} Vite HMR active — press Ctrl+C to stop",
icon_eye().green()
),
_ => println!(
"{} File watcher active — press Ctrl+C to stop",
icon_eye().green()
),
}
if std::path::Path::new(scss_dir).exists() {
let scss_in = scss_dir.to_string();
let css_out = css_dir.to_string();
std::thread::spawn(move || {
watcher::watch_scss(&scss_in, &css_out, false);
});
}
if info.language != "tina4js" {
let reload_port = port;
std::thread::spawn(move || {
watcher::watch_and_reload(reload_port);
});
}
match server.wait() {
Ok(status) => {
if !status.success() {
let code = status.code().unwrap_or(-1);
eprintln!(
"\n{} Server process exited with code {}. Check logs/error.log or your PHP/Python error log for details.",
icon_fail().red(),
code
);
std::process::exit(code);
}
}
Err(e) => {
eprintln!("\n{} Server process error: {}", icon_fail().red(), e);
std::process::exit(1);
}
}
}
fn read_dotenv_bool_from<P: AsRef<std::path::Path>>(path: P, key: &str) -> bool {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
let v = v.trim().trim_matches('"').trim_matches('\'').to_lowercase();
return matches!(v.as_str(), "true" | "1" | "yes");
}
}
}
false
}
fn read_dotenv_bool(key: &str) -> bool {
read_dotenv_bool_from(".env", key)
}
fn install_production_server(info: &detect::ProjectInfo) {
let (name, check_fn, install_cmd): (&str, Box<dyn Fn() -> bool>, &str) = match info.language.as_str() {
"python" => ("uvicorn", Box::new(|| which::which("uvicorn").is_ok()), "uv add uvicorn"),
"php" => ("opcache", Box::new(|| true), ""), "ruby" => ("puma", Box::new(|| {
console::shell_output("gem list puma")
.map(|o| !o.stdout.is_empty() && String::from_utf8_lossy(&o.stdout).contains("puma"))
.unwrap_or(false)
}), "gem install puma --no-doc"),
"nodejs" => ("cluster", Box::new(|| true), ""), "tina4js" => ("vite", Box::new(|| true), ""), _ => return,
};
if check_fn() {
println!(" {} {} already installed", icon_ok().green(), name.cyan());
return;
}
println!(
" {} Installing {}...",
icon_play().green(),
name.cyan()
);
match console::shell_exec(install_cmd) {
Ok(s) if s.success() => println!(" {} {} installed", icon_ok().green(), name.cyan()),
_ => println!(" {} Failed to install {} — using dev server", icon_warn().yellow(), name),
}
}
#[cfg(unix)]
fn set_process_group(cmd: &mut std::process::Command) -> &mut std::process::Command {
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
cmd
}
#[cfg(not(unix))]
fn set_process_group(cmd: &mut std::process::Command) -> &mut std::process::Command {
cmd
}
fn start_language_server(
info: &detect::ProjectInfo,
port: u16,
host: &str,
) -> Option<std::process::Child> {
let port_s = port.to_string();
let result = match info.language.as_str() {
"python" => {
if std::path::Path::new(".venv").exists() {
let mut cmd = std::process::Command::new("uv");
cmd.args(["run", "python", "app.py", "--managed"])
.env("PORT", &port_s)
.env("HOST", host)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
} else {
let mut cmd = std::process::Command::new(console::python_cmd());
cmd.args(["app.py", "--managed"])
.env("PORT", &port_s)
.env("HOST", host)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
}
}
"php" => {
if !std::path::Path::new("vendor").exists() {
eprintln!(
"{} Dependencies not installed. Run: {}",
icon_fail().red(),
"composer install".cyan()
);
return None;
}
let (cmd_name, mut cmd_args) = resolve_cli(info);
cmd_args.extend([
"serve".into(),
"--managed".into(),
"--host".into(), host.into(),
"--port".into(), port.to_string(),
]);
let mut cmd = std::process::Command::new(&cmd_name);
cmd.args(&cmd_args)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
}
"ruby" => {
if std::path::Path::new("Gemfile").exists() {
if !std::path::Path::new("Gemfile.lock").exists() {
eprintln!(
"{} Dependencies not installed. Run: {}",
icon_fail().red(),
"bundle install".cyan()
);
return None;
}
let mut cmd = std::process::Command::new(console::resolve_cmd("bundle"));
cmd.args(["exec", "ruby", "app.rb", "--managed"])
.env("PORT", &port_s)
.env("HOST", host)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
} else {
let mut cmd = std::process::Command::new("ruby");
cmd.args(["app.rb", "--managed"])
.env("PORT", &port_s)
.env("HOST", host)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
}
}
"nodejs" => {
if !std::path::Path::new("node_modules").exists() {
eprintln!(
"{} Dependencies not installed. Run: {}",
icon_fail().red(),
"npm install".cyan()
);
return None;
}
let entry = if std::path::Path::new("app.ts").exists() { "app.ts" } else { "app.js" };
let mut cmd = std::process::Command::new(console::resolve_cmd("npx"));
cmd.args(["tsx", entry, "--managed"])
.env("PORT", &port_s)
.env("HOST", host)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
}
"tina4js" => {
if !std::path::Path::new("node_modules").exists() {
eprintln!(
"{} Dependencies not installed. Run: {}",
icon_fail().red(),
"npm install".cyan()
);
return None;
}
let mut cmd = std::process::Command::new("npx");
cmd.args(["vite", "--port", &port_s, "--host", host, "--strictPort"])
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
set_process_group(&mut cmd).spawn()
}
_ => return None,
};
result.ok()
}
fn resolve_cli(info: &detect::ProjectInfo) -> (String, Vec<String>) {
match info.language.as_str() {
"php" => {
let vendor_path = console::php_vendor_bin("tina4php");
let cli_path = if std::path::Path::new(&vendor_path).exists() {
vendor_path
} else if std::path::Path::new("bin/tina4php").exists() {
"bin/tina4php".to_string()
} else {
return ("tina4php".into(), vec![]);
};
("php".into(), vec![cli_path])
}
"python" => {
if std::path::Path::new("uv.lock").exists() || std::path::Path::new("pyproject.toml").exists() {
if which::which("uv").is_ok() {
return ("uv".into(), vec!["run".into(), "tina4python".into()]);
}
}
let venv_cli = if cfg!(windows) { ".venv/Scripts/tina4python.exe" } else { ".venv/bin/tina4python" };
if std::path::Path::new(venv_cli).exists() {
(venv_cli.into(), vec![])
} else {
("tina4python".into(), vec![])
}
}
"ruby" => {
if std::path::Path::new("Gemfile.lock").exists() {
if which::which("bundle").is_ok() {
return ("bundle".into(), vec!["exec".into(), "tina4ruby".into()]);
}
}
("tina4ruby".into(), vec![])
}
"nodejs" => {
if std::path::Path::new("node_modules").exists() {
if which::which("npx").is_ok() {
return ("npx".into(), vec!["tina4nodejs".into()]);
}
}
("tina4nodejs".into(), vec![])
}
_ => (info.cli_name().into(), vec![]),
}
}
fn delegate_command(args: Vec<String>) {
match detect::detect_language() {
Some(info) => {
if info.language == "php" && !std::path::Path::new("vendor").exists() {
eprintln!(
"{} Dependencies not installed. Run: {}",
icon_fail().red(),
"composer install".cyan()
);
std::process::exit(1);
}
let (cmd, mut cmd_args) = resolve_cli(&info);
cmd_args.extend(args);
match std::process::Command::new(&cmd).args(&cmd_args).status() {
Ok(s) if !s.success() => std::process::exit(s.code().unwrap_or(1)),
Err(e) => {
eprintln!("{} Failed to run {} {}: {}", icon_fail().red(), cmd, cmd_args.join(" "), e);
std::process::exit(1);
}
_ => {}
}
}
None => {
eprintln!(
"{} No Tina4 project detected in current directory",
icon_fail().red()
);
std::process::exit(1);
}
}
}
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const REPO: &str = "tina4stack/tina4";
const BOOK_REPO: &str = "tina4stack/tina4-book";
fn handle_docs() {
let info = match detect::detect_language() {
Some(i) => i,
None => {
eprintln!(
"{} No Tina4 project detected. Run {} in a Tina4 project directory.",
icon_fail().red(),
"tina4 docs".cyan()
);
std::process::exit(1);
}
};
let book_dir = match info.language.as_str() {
"python" => "book-1-python",
"php" => "book-2-php",
"ruby" => "book-3-ruby",
"nodejs" => "book-4-nodejs",
"tina4js" => "book-5-javascript",
_ => {
eprintln!("{} Unsupported language: {}", icon_fail().red(), info.language);
return;
}
};
let dest = std::path::Path::new(".tina4-docs");
if dest.exists() {
std::fs::remove_dir_all(dest).ok();
}
let zip_url = format!(
"https://github.com/{}/archive/refs/heads/main.zip",
BOOK_REPO
);
let zip_path = std::path::PathBuf::from(".tina4-docs.zip");
println!(
"{} Downloading {} documentation...",
icon_play().green(),
info.language.cyan()
);
if !download_file(&zip_url, &zip_path) {
eprintln!("{} Download failed.", icon_fail().red());
return;
}
let tmp_dir = std::path::Path::new(".tina4-docs-tmp");
if tmp_dir.exists() {
std::fs::remove_dir_all(tmp_dir).ok();
}
let extracted = if console::is_windows() {
std::process::Command::new("powershell")
.args([
"-NoProfile", "-Command",
&format!("Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
zip_path.display(), tmp_dir.display()),
])
.status()
} else {
std::process::Command::new("unzip")
.args(["-qo", &zip_path.to_string_lossy(), "-d", &tmp_dir.to_string_lossy()])
.status()
};
if !matches!(extracted, Ok(s) if s.success()) {
eprintln!("{} Failed to extract archive", icon_fail().red());
std::fs::remove_file(&zip_path).ok();
std::fs::remove_dir_all(tmp_dir).ok();
return;
}
let chapters_src = tmp_dir.join("tina4-book-main").join(book_dir).join("chapters");
if chapters_src.exists() {
std::fs::create_dir_all(dest).ok();
if let Ok(entries) = std::fs::read_dir(&chapters_src) {
for entry in entries.flatten() {
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
std::fs::copy(&src_path, &dest_path).ok();
}
}
} else {
eprintln!("{} Book chapters not found for {}", icon_warn().yellow(), info.language);
}
std::fs::remove_file(&zip_path).ok();
std::fs::remove_dir_all(tmp_dir).ok();
let count = std::fs::read_dir(dest)
.map(|entries| entries.count())
.unwrap_or(0);
println!(
"{} {} docs downloaded to {} ({} chapters)",
icon_ok().green(),
info.language.cyan(),
".tina4-docs/".cyan(),
count.to_string().cyan()
);
println!(
" {} Available in dev overlay at {}",
icon_info().blue(),
"/__dev → Docs".cyan()
);
}
fn handle_books() {
let dest = std::path::Path::new("tina4-book");
if dest.exists() {
eprintln!(
"{} A {} directory already exists. Remove it first if you want a fresh copy.",
icon_warn().yellow(),
"tina4-book/".cyan()
);
return;
}
let zip_url = format!(
"https://github.com/{}/archive/refs/heads/main.zip",
BOOK_REPO
);
let zip_path = std::path::PathBuf::from("tina4-book.zip");
println!(
"{} Downloading Tina4 book...",
icon_play().green()
);
if !download_file(&zip_url, &zip_path) {
eprintln!(
"{} Download failed. Check your connection or visit:\n https://github.com/{}",
icon_fail().red(),
BOOK_REPO
);
return;
}
println!("{} Extracting...", icon_play().green());
let extracted = if console::is_windows() {
std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"Expand-Archive -Path '{}' -DestinationPath '.' -Force",
zip_path.display()
),
])
.status()
} else {
std::process::Command::new("unzip")
.args(["-qo", &zip_path.to_string_lossy(), "-d", "."])
.status()
};
if !matches!(extracted, Ok(s) if s.success()) {
eprintln!("{} Failed to extract archive", icon_fail().red());
std::fs::remove_file(&zip_path).ok();
return;
}
let extracted_dir = std::path::Path::new("tina4-book-main");
if extracted_dir.exists() && std::fs::rename(extracted_dir, dest).is_err() {
eprintln!(
"{} Could not rename {} to {}",
icon_fail().red(),
"tina4-book-main".dimmed(),
"tina4-book/".cyan()
);
}
std::fs::remove_file(&zip_path).ok();
println!(
"{} Tina4 book downloaded to {}",
icon_ok().green(),
"tina4-book/".cyan()
);
}
fn handle_update() {
println!("{} Checking for updates...", icon_play().green());
clean_v2_binaries();
let latest_tag = match get_latest_version() {
Some(tag) => tag,
None => {
eprintln!(
"{} Could not check latest version. Download manually from:\n https://github.com/{}/releases",
icon_warn().yellow(), REPO
);
return;
}
};
let latest_ver = latest_tag.trim_start_matches('v');
println!(
" {} Current: {} Latest: {}",
icon_info().blue(),
CURRENT_VERSION.cyan(),
latest_ver.cyan()
);
if latest_ver == CURRENT_VERSION {
println!("{} CLI already up to date", icon_ok().green());
update_framework_package();
return;
}
let candidates = get_binary_name_candidates();
let current_exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("{} Cannot determine current executable path: {}", icon_fail().red(), e);
return;
}
};
let tmp_path = current_exe.with_extension("tmp");
let mut downloaded = false;
for name in &candidates {
let url = format!(
"https://github.com/{}/releases/download/{}/{}",
REPO, latest_tag, name
);
println!(
"{} Trying {} ...",
icon_play().green(),
name.cyan()
);
if download_file(&url, &tmp_path) {
downloaded = true;
break;
}
}
if !downloaded {
eprintln!(
"{} Download failed (tried: {}). Download manually from:\n https://github.com/{}/releases",
icon_fail().red(), candidates.join(", "), REPO
);
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&tmp_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&tmp_path, perms).ok();
}
}
let backup_path = current_exe.with_extension("old");
if std::fs::rename(¤t_exe, &backup_path).is_err() {
if std::fs::copy(¤t_exe, &backup_path).is_err() {
eprintln!("{} Cannot backup current binary", icon_fail().red());
std::fs::remove_file(&tmp_path).ok();
return;
}
}
if std::fs::rename(&tmp_path, ¤t_exe).is_err() {
if std::fs::copy(&tmp_path, ¤t_exe).is_err() {
eprintln!("{} Cannot replace binary — restoring backup", icon_fail().red());
std::fs::rename(&backup_path, ¤t_exe).ok();
std::fs::remove_file(&tmp_path).ok();
return;
}
std::fs::remove_file(&tmp_path).ok();
}
std::fs::remove_file(&backup_path).ok();
println!(
"{} Updated tina4 CLI {} → {}",
icon_ok().green(),
CURRENT_VERSION.dimmed(),
latest_ver.cyan()
);
update_framework_package();
}
fn update_framework_package() {
use std::io::Write;
let info = match detect::detect_language() {
Some(i) => i,
None => return, };
let lang = info.language.as_str();
let pkg: &str = match lang {
"python" => "tina4-python",
"php" => "tina4stack/tina4php",
"ruby" => "tina4ruby",
"nodejs" => "tina4-nodejs",
_ => return,
};
println!();
print!(
" Also update {} framework package? [Y/n]: ",
pkg.cyan()
);
std::io::stdout().flush().ok();
let mut input = String::new();
let should_update = match std::io::stdin().read_line(&mut input) {
Ok(0) | Err(_) => false,
_ => {
let trimmed = input.trim().to_lowercase();
trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
}
};
if !should_update {
let hint = match lang {
"python" => "uv lock --upgrade-package tina4-python && uv sync".to_string(),
"php" => "composer update tina4stack/tina4php".to_string(),
"ruby" => "bundle update tina4ruby".to_string(),
"nodejs" => "npm update tina4-nodejs".to_string(),
_ => return,
};
println!(" Skipped. To update later: {}", hint);
return;
}
println!(
"{} Updating {}...",
icon_play().green(),
pkg.cyan()
);
let success = match lang {
"python" => {
let lock_ok = std::process::Command::new("uv")
.args(["lock", "--upgrade-package", "tina4-python"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if lock_ok {
std::process::Command::new("uv")
.args(["sync"])
.status()
.map(|s| s.success())
.unwrap_or(false)
} else {
false
}
}
"php" => std::process::Command::new(console::resolve_cmd("composer"))
.args(["update", "tina4stack/tina4php"])
.status()
.map(|s| s.success())
.unwrap_or(false),
"ruby" => std::process::Command::new(console::resolve_cmd("bundle"))
.args(["update", "tina4ruby"])
.status()
.map(|s| s.success())
.unwrap_or(false),
"nodejs" => std::process::Command::new(console::resolve_cmd("npm"))
.args(["update", "tina4-nodejs"])
.status()
.map(|s| s.success())
.unwrap_or(false),
_ => false,
};
if success {
println!("{} {} updated", icon_ok().green(), pkg.cyan());
} else {
eprintln!(
"{} Framework update failed. Check the output above for details.",
icon_warn().yellow(),
);
}
}
fn clean_v2_binaries() {
let stale_names = ["tina4python", "tina4php", "tina4ruby", "tina4nodejs"];
let mut found_any = false;
for name in &stale_names {
if let Ok(path) = which::which(name) {
let path_str = path.to_string_lossy();
if path_str.contains("vendor") || path_str.contains(".venv") || path_str.contains("node_modules") {
continue;
}
let is_v2 = std::process::Command::new(&path)
.arg("--version")
.output()
.map(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string()
+ &String::from_utf8_lossy(&o.stderr);
out.contains("Thor") || out.contains("Deprecation") || out.contains("1.") || out.contains("2.")
})
.unwrap_or(false);
if is_v2 {
if !found_any {
println!(
"\n{} Found old v2 CLI binaries on PATH:",
icon_warn().yellow()
);
found_any = true;
}
println!(" {} {} ({})", icon_fail().red(), name, path_str.dimmed());
match std::fs::remove_file(&path) {
Ok(_) => println!(" {} Removed", icon_ok().green()),
Err(_) => {
let bat_path = path.with_extension("bat");
std::fs::remove_file(&bat_path).ok();
println!(
" {} Cannot remove — delete manually: {}",
icon_warn().yellow(),
path_str
);
}
}
}
}
}
if let Ok(tina4_path) = which::which("tina4") {
let current_exe = std::env::current_exe().unwrap_or_default();
if tina4_path != current_exe {
let is_old = std::process::Command::new(&tina4_path)
.arg("--version")
.output()
.map(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string()
+ &String::from_utf8_lossy(&o.stderr);
out.contains("Thor") || out.contains("Deprecation") || !out.contains("tina4")
})
.unwrap_or(false);
if is_old {
if !found_any {
println!(
"\n{} Found old v2 CLI binaries on PATH:",
icon_warn().yellow()
);
}
let path_str = tina4_path.to_string_lossy();
println!(" {} tina4 ({})", icon_fail().red(), path_str.dimmed());
match std::fs::remove_file(&tina4_path) {
Ok(_) => {
let bat = tina4_path.with_extension("bat");
std::fs::remove_file(bat).ok();
println!(" {} Removed", icon_ok().green());
}
Err(_) => println!(
" {} Cannot remove — delete manually: {}",
icon_warn().yellow(),
path_str
),
}
}
}
}
if found_any {
println!();
}
}
fn get_latest_version() -> Option<String> {
let api_url = format!("https://api.github.com/repos/{}/releases/latest", REPO);
let output = if console::is_windows() {
std::process::Command::new("powershell")
.args(["-NoProfile", "-Command",
&format!("(Invoke-RestMethod -Uri '{}' -Headers @{{'User-Agent'='tina4-cli'}}).tag_name", api_url)])
.output()
.ok()?
} else {
std::process::Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: tina4-cli", "-H", "Accept: application/vnd.github+json", &api_url])
.output()
.ok()?
};
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
if text.is_empty() {
return None;
}
if console::is_windows() {
if text.starts_with('v') {
return Some(text);
}
}
if let Some(pos) = text.find("\"tag_name\"") {
let after = &text[pos..];
let mut in_value = false;
let mut start = 0;
for (i, ch) in after.char_indices() {
if ch == ':' && !in_value {
in_value = true;
continue;
}
if in_value && ch == '"' && start == 0 {
start = i + 1;
continue;
}
if in_value && ch == '"' && start > 0 {
return Some(after[start..i].to_string());
}
}
}
None
}
fn get_binary_name_candidates() -> Vec<String> {
let ext = if cfg!(target_os = "windows") { ".exe" } else { "" };
let os_variants: Vec<&str> = if cfg!(target_os = "macos") {
vec!["darwin", "macos"]
} else if cfg!(target_os = "windows") {
vec!["windows"]
} else {
vec!["linux"]
};
let arch_variants: Vec<&str> = if cfg!(target_arch = "aarch64") {
vec!["arm64", "aarch64"]
} else {
vec!["amd64", "x86_64"]
};
let mut names = Vec::new();
for os in &os_variants {
for arch in &arch_variants {
names.push(format!("tina4-{}-{}{}", os, arch, ext));
}
}
names
}
fn download_file(url: &str, dest: &std::path::Path) -> bool {
let dest_str = dest.to_string_lossy();
let status = if console::is_windows() {
let curl_path = "C:\\Windows\\System32\\curl.exe";
if std::path::Path::new(curl_path).exists() {
std::process::Command::new(curl_path)
.args(["-fsSL", "-o", &dest_str, url])
.status()
} else {
std::process::Command::new("powershell")
.args(["-NoProfile", "-Command",
&format!("[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing", url, dest_str)])
.status()
}
} else {
std::process::Command::new("curl")
.args(["-fsSL", "-o", &dest_str, url])
.status()
};
matches!(status, Ok(s) if s.success())
}
fn is_tina4js_project() -> bool {
if let Ok(content) = std::fs::read_to_string("package.json") {
content.contains("\"tina4js\"") && !std::path::Path::new("app.ts").exists()
} else {
false
}
}
fn handle_tina4js_ai(_all: bool, force: bool) {
use std::fs;
use std::path::Path;
println!(" {} Detected tina4-js (frontend) project", icon_info());
let claude_path = Path::new("CLAUDE.md");
if claude_path.exists() && !force {
println!(" {} CLAUDE.md already exists (use --force to overwrite)", icon_warn());
} else {
let content = r#"# Tina4-js Project
Frontend project using tina4-js — the sub-3KB reactive framework.
## Build & Dev
- Install: `npm install`
- Dev: `npm run dev`
- Build: `npm run build`
## Tina4-js Features
- Signals for reactive state
- HTML tagged templates
- Tina4Element for web components
- Built-in routing (hash and history mode)
- WebSocket client with auto-reconnect
- API client with auth headers
- Zero dependencies, ~13KB bundled
## Skills
Always read and follow `.claude/skills/tina4-js/SKILL.md` when working with this project.
"#;
if fs::write(claude_path, content).is_ok() {
println!(" {} Created CLAUDE.md", icon_ok());
}
}
let skill_dir = Path::new(".claude/skills/tina4-js");
if skill_dir.exists() && !force {
println!(" {} tina4-js skill already installed", icon_ok());
} else {
let skill_source = std::env::var("HOME").ok()
.map(|h| Path::new(&h).join("IdeaProjects/tina4-js/.claude/skills/tina4-js"))
.filter(|p| p.exists());
if let Some(src) = skill_source {
fn copy_dir(src: &Path, dst: &Path) {
let _ = fs::create_dir_all(dst);
if let Ok(entries) = fs::read_dir(src) {
for entry in entries.flatten() {
let path = entry.path();
let dest = dst.join(entry.file_name());
if path.is_dir() {
copy_dir(&path, &dest);
} else {
let _ = fs::copy(&path, &dest);
}
}
}
}
copy_dir(&src, skill_dir);
println!(" {} Installed tina4-js skill from local repo", icon_ok());
} else {
let _ = fs::create_dir_all(skill_dir);
let skill_content = "# tina4-js Skill\n\nUse tina4-js signals, Tina4Element, html tagged templates, and the built-in router.\n\nSee https://tina4.com for documentation.\n";
let _ = fs::write(skill_dir.join("SKILL.md"), skill_content);
println!(" {} Created basic tina4-js skill", icon_ok());
}
}
println!(" {} AI context installed for tina4-js project", icon_ok());
}
#[cfg(test)]
mod tests {
use super::*;
fn with_env_file<F: FnOnce(&std::path::Path)>(name: &str, contents: &str, f: F) {
let path = std::env::temp_dir().join(format!("tina4-dotenv-{}-{}-{}", std::process::id(), name, fastrand_like()));
std::fs::write(&path, contents).unwrap();
f(&path);
let _ = std::fs::remove_file(&path);
}
fn fastrand_like() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
N.fetch_add(1, Ordering::Relaxed)
}
#[test]
fn dotenv_bool_reads_true() {
with_env_file("true", "TINA4_NO_BROWSER=true\n", |p| {
assert!(read_dotenv_bool_from(p, "TINA4_NO_BROWSER"));
});
}
#[test]
fn dotenv_bool_reads_quoted_true() {
with_env_file("qtrue", "TINA4_NO_BROWSER=\"true\"\n", |p| {
assert!(read_dotenv_bool_from(p, "TINA4_NO_BROWSER"));
});
}
#[test]
fn dotenv_bool_reads_yes_and_one() {
with_env_file("yesone", "A=yes\nB=1\n", |p| {
assert!(read_dotenv_bool_from(p, "A"));
assert!(read_dotenv_bool_from(p, "B"));
});
}
#[test]
fn dotenv_bool_returns_false_for_false() {
with_env_file("false", "TINA4_NO_BROWSER=false\n", |p| {
assert!(!read_dotenv_bool_from(p, "TINA4_NO_BROWSER"));
});
}
#[test]
fn dotenv_bool_returns_false_for_missing_key() {
with_env_file("missing", "SOMETHING_ELSE=true\n", |p| {
assert!(!read_dotenv_bool_from(p, "TINA4_NO_BROWSER"));
});
}
#[test]
fn dotenv_bool_returns_false_for_no_env_file() {
assert!(!read_dotenv_bool_from("/does/not/exist/.env", "TINA4_NO_BROWSER"));
}
#[test]
fn dotenv_bool_ignores_comments_and_blanks() {
with_env_file("comments", "# comment\n\nTINA4_NO_BROWSER=yes\n# TINA4_DEBUG=true\n", |p| {
assert!(read_dotenv_bool_from(p, "TINA4_NO_BROWSER"));
assert!(!read_dotenv_bool_from(p, "TINA4_DEBUG"));
});
}
}