use std::path::Path;
use std::process::{Command, ExitCode};
use bynk_emit::project::{BuildTarget, CompileOptions, ProjectFailure, read_project_paths};
use crate::compiler::Compiler;
use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
use crate::probe::{self, DetectOpts, Provenance, Toolbox};
use crate::report::{self, Format};
#[derive(Debug, Clone, Default)]
pub struct DevOptions {
pub context: Option<String>,
pub inspect: bool,
pub inspect_port: u16,
pub wrangler_args: Vec<String>,
}
pub fn run(
tb: &dyn Toolbox,
compiler: &Compiler,
project_root: &Path,
src_rel: &Path,
node_floor: u32,
opts: &DevOptions,
) -> ExitCode {
let ctx = Context {
project_root: Some(project_root.to_path_buf()),
in_repo: false,
node_floor,
};
let preflight_opts = DoctorOptions {
only: Some(Capability::Deploy),
strict: false,
};
let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
if report.exit_nonzero(&preflight_opts) {
eprint!("{}", preflight_failure_message(&report));
return ExitCode::FAILURE;
}
let build_dir = project_root.join(".bynk").join("dev");
if let Err(e) = prepare_build_dir(project_root, &build_dir) {
eprintln!("bynk: could not prepare build directory: {e}");
return ExitCode::FAILURE;
}
let src = project_root.join(src_rel);
let used_override = matches!(compiler.origin, Some(crate::compiler::Origin::Override));
if let (true, Some(bynkc)) = (used_override, compiler.path.as_deref()) {
let status = Command::new(bynkc)
.arg("compile")
.arg(&src)
.arg("--output")
.arg(&build_dir)
.arg("--target")
.arg("workers")
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => return ExitCode::from(exit_byte(s.code())),
Err(e) => {
eprintln!("bynk: could not run bynkc ({}): {e}", bynkc.display());
return ExitCode::FAILURE;
}
}
} else {
let options = dev_compile_options(&src);
let output = match bynk_emit::project::compile_project(&options) {
Ok(out) => out,
Err(failure) => {
render_project_failure(&failure);
return ExitCode::FAILURE;
}
};
if let Err(e) = bynk_emit::write_output(&output, &build_dir) {
eprintln!(
"bynk: could not write build output under `{}`: {e}",
build_dir.display()
);
return ExitCode::FAILURE;
}
}
let workers_dir = build_dir.join("workers");
let available = discover_workers(&workers_dir);
let worker = match select_context(&available, opts.context.as_deref()) {
Ok(w) => w,
Err(e) => {
eprintln!("bynk: {e}");
return ExitCode::FAILURE;
}
};
let worker_dir = workers_dir.join(&worker);
let probe = probe::detect(
tb,
"wrangler",
DetectOpts {
project_root: Some(project_root),
allow_npx: true,
},
);
let mut cmd = match wrangler_command(&probe.provenance) {
Some(cmd) => cmd,
None => {
eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
return ExitCode::FAILURE;
}
};
if matches!(probe.provenance, Provenance::Npx) {
eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
}
cmd.current_dir(&worker_dir);
for arg in inspector_args(opts) {
cmd.arg(arg);
}
if opts.inspect {
let port = opts.inspect_port;
eprintln!("bynk dev --inspect: the worker runs with the V8 inspector enabled.");
eprintln!(" Attach a JavaScript debugger to the inspector on port {port} (CDP discovery:");
eprintln!(" http://127.0.0.1:{port}/json). Breakpoints set in `.bynk` sources resolve");
eprintln!(" through the emitted source maps. A hand-rolled CDP client must send an");
eprintln!(" `Origin` header — VS Code's JavaScript debugger does this for you.");
}
for arg in &opts.wrangler_args {
cmd.arg(arg);
}
match cmd.status() {
Ok(s) => ExitCode::from(exit_byte(s.code())),
Err(e) => {
eprintln!("bynk: could not run wrangler: {e}");
ExitCode::FAILURE
}
}
}
pub fn preflight_failure_message(report: &Report) -> String {
format!(
"bynk: environment not ready for `dev` — see below.\n\n{}",
report::render(report, Format::Human)
)
}
fn prepare_build_dir(project_root: &Path, build_dir: &Path) -> std::io::Result<()> {
let bynk_dir = project_root.join(".bynk");
std::fs::create_dir_all(&bynk_dir)?;
let gitignore = bynk_dir.join(".gitignore");
if !gitignore.exists() {
std::fs::write(&gitignore, "*\n")?;
}
let workers = build_dir.join("workers");
match std::fs::remove_dir_all(&workers) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn discover_workers(workers_dir: &Path) -> Vec<String> {
let mut names = Vec::new();
let Ok(entries) = std::fs::read_dir(workers_dir) else {
return names;
};
for entry in entries.flatten() {
let path = entry.path();
if path.join("wrangler.toml").is_file()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
names.push(name.to_string());
}
}
names.sort();
names
}
#[derive(Debug, PartialEq, Eq)]
pub enum SelectError {
NoneBuilt,
Ambiguous(Vec<String>),
NotFound {
requested: String,
available: Vec<String>,
},
}
impl std::fmt::Display for SelectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SelectError::NoneBuilt => {
write!(
f,
"no workers were built — does the project define any contexts?"
)
}
SelectError::Ambiguous(available) => write!(
f,
"this project has several contexts — pass --context to choose one of: {}",
available.join(", ")
),
SelectError::NotFound {
requested,
available,
} => write!(
f,
"no context `{requested}` — available: {}",
available.join(", ")
),
}
}
}
pub fn select_context(
available: &[String],
requested: Option<&str>,
) -> Result<String, SelectError> {
match requested {
Some(name) => {
let dashed = name.replace('.', "-");
available
.iter()
.find(|d| d.as_str() == name || d.as_str() == dashed)
.cloned()
.ok_or_else(|| SelectError::NotFound {
requested: name.to_string(),
available: available.to_vec(),
})
}
None => match available {
[] => Err(SelectError::NoneBuilt),
[one] => Ok(one.clone()),
many => Err(SelectError::Ambiguous(many.to_vec())),
},
}
}
fn wrangler_command(provenance: &Provenance) -> Option<Command> {
match provenance {
Provenance::Path(p) | Provenance::ProjectLocal(p) => {
let mut cmd = Command::new(p);
cmd.arg("dev");
Some(cmd)
}
Provenance::Npx => {
let mut cmd = Command::new("npx");
cmd.arg("--yes").arg("wrangler").arg("dev");
Some(cmd)
}
Provenance::Missing => None,
}
}
fn exit_byte(code: Option<i32>) -> u8 {
code.unwrap_or(0).clamp(0, 255) as u8
}
fn inspector_args(opts: &DevOptions) -> Vec<String> {
if opts.inspect {
vec![
"--inspector-port".to_string(),
opts.inspect_port.to_string(),
]
} else {
Vec::new()
}
}
fn dev_compile_options(src: &Path) -> CompileOptions {
if src.join("bynk.toml").exists() || src.join("src").is_dir() {
CompileOptions::split(src.to_path_buf(), read_project_paths(src))
} else {
CompileOptions::single(src.to_path_buf())
}
.target(BuildTarget::Workers)
}
fn render_project_failure(failure: &ProjectFailure) {
let texts: std::collections::HashMap<&Path, &str> = failure
.snapshots
.iter()
.map(|(p, t)| (p.as_path(), t.as_str()))
.collect();
for ae in &failure.errors {
match ae
.source_path
.as_deref()
.and_then(|p| texts.get(p).map(|t| (p, *t)))
{
Some((path, text)) => {
let label = path.to_string_lossy().replace('\\', "/");
bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
}
None => {
eprintln!("[{}] {}", ae.error.category, ae.error.message);
for note in &ae.error.notes {
eprintln!(" note: {note}");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn sole_context_is_served_without_a_flag() {
assert_eq!(
select_context(&names(&["links"]), None),
Ok("links".to_string())
);
}
#[test]
fn ambiguous_without_context_lists_the_options() {
assert_eq!(
select_context(&names(&["api", "worker"]), None),
Err(SelectError::Ambiguous(names(&["api", "worker"])))
);
}
#[test]
fn no_workers_is_its_own_error() {
assert_eq!(select_context(&[], None), Err(SelectError::NoneBuilt));
}
#[test]
fn context_flag_selects_by_raw_or_dasherised_name() {
let avail = names(&["api", "commerce-payment"]);
assert_eq!(
select_context(&avail, Some("commerce-payment")),
Ok("commerce-payment".to_string())
);
assert_eq!(
select_context(&avail, Some("commerce.payment")),
Ok("commerce-payment".to_string())
);
}
#[test]
fn unknown_context_reports_what_is_available() {
assert_eq!(
select_context(&names(&["api"]), Some("nope")),
Err(SelectError::NotFound {
requested: "nope".to_string(),
available: names(&["api"]),
})
);
}
#[test]
fn exit_byte_maps_codes_and_signals() {
assert_eq!(exit_byte(Some(0)), 0);
assert_eq!(exit_byte(Some(1)), 1);
assert_eq!(exit_byte(None), 0);
}
#[test]
fn inspect_injects_the_inspector_port() {
let off = DevOptions::default();
assert!(
inspector_args(&off).is_empty(),
"no inspector args without --inspect"
);
let on = DevOptions {
inspect: true,
inspect_port: 9229,
..Default::default()
};
assert_eq!(
inspector_args(&on),
vec!["--inspector-port".to_string(), "9229".to_string()]
);
}
}