1use std::path::Path;
18use std::process::{Command, ExitCode};
19
20use bynk_emit::project::{BuildTarget, CompileOptions, ProjectFailure, read_project_paths};
21
22use crate::compiler::Compiler;
23use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
24use crate::probe::{self, DetectOpts, Provenance, Toolbox};
25use crate::report::{self, Format};
26
27#[derive(Debug, Clone, Default)]
30pub struct DevOptions {
31 pub context: Option<String>,
33 pub inspect: bool,
37 pub inspect_port: u16,
39 pub wrangler_args: Vec<String>,
41}
42
43pub fn run(
47 tb: &dyn Toolbox,
48 compiler: &Compiler,
49 project_root: &Path,
50 src_rel: &Path,
51 node_floor: u32,
52 opts: &DevOptions,
53) -> ExitCode {
54 let ctx = Context {
58 project_root: Some(project_root.to_path_buf()),
59 in_repo: false,
60 node_floor,
61 };
62 let preflight_opts = DoctorOptions {
63 only: Some(Capability::Deploy),
64 strict: false,
65 };
66 let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
67 if report.exit_nonzero(&preflight_opts) {
68 eprint!("{}", preflight_failure_message(&report));
69 return ExitCode::FAILURE;
70 }
71 let build_dir = project_root.join(".bynk").join("dev");
77 if let Err(e) = prepare_build_dir(project_root, &build_dir) {
78 eprintln!("bynk: could not prepare build directory: {e}");
79 return ExitCode::FAILURE;
80 }
81 let src = project_root.join(src_rel);
82 let used_override = matches!(compiler.origin, Some(crate::compiler::Origin::Override));
88 if let (true, Some(bynkc)) = (used_override, compiler.path.as_deref()) {
89 let status = Command::new(bynkc)
90 .arg("compile")
91 .arg(&src)
92 .arg("--output")
93 .arg(&build_dir)
94 .arg("--target")
95 .arg("workers")
96 .status();
97 match status {
98 Ok(s) if s.success() => {}
99 Ok(s) => return ExitCode::from(exit_byte(s.code())),
100 Err(e) => {
101 eprintln!("bynk: could not run bynkc ({}): {e}", bynkc.display());
102 return ExitCode::FAILURE;
103 }
104 }
105 } else {
106 let options = dev_compile_options(&src);
107 let output = match bynk_emit::project::compile_project(&options) {
108 Ok(out) => out,
109 Err(failure) => {
110 render_project_failure(&failure);
115 return ExitCode::FAILURE;
116 }
117 };
118 if let Err(e) = bynk_emit::write_output(&output, &build_dir) {
119 eprintln!(
120 "bynk: could not write build output under `{}`: {e}",
121 build_dir.display()
122 );
123 return ExitCode::FAILURE;
124 }
125 }
126
127 let workers_dir = build_dir.join("workers");
129 let available = discover_workers(&workers_dir);
130 let worker = match select_context(&available, opts.context.as_deref()) {
131 Ok(w) => w,
132 Err(e) => {
133 eprintln!("bynk: {e}");
134 return ExitCode::FAILURE;
135 }
136 };
137 let worker_dir = workers_dir.join(&worker);
138
139 let probe = probe::detect(
145 tb,
146 "wrangler",
147 DetectOpts {
148 project_root: Some(project_root),
149 allow_npx: true,
150 },
151 );
152 let mut cmd = match wrangler_command(&probe.provenance) {
153 Some(cmd) => cmd,
154 None => {
155 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
157 return ExitCode::FAILURE;
158 }
159 };
160 if matches!(probe.provenance, Provenance::Npx) {
161 eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
162 }
163 cmd.current_dir(&worker_dir);
164 for arg in inspector_args(opts) {
170 cmd.arg(arg);
171 }
172 if opts.inspect {
173 let port = opts.inspect_port;
174 eprintln!("bynk dev --inspect: the worker runs with the V8 inspector enabled.");
175 eprintln!(" Attach a JavaScript debugger to the inspector on port {port} (CDP discovery:");
176 eprintln!(" http://127.0.0.1:{port}/json). Breakpoints set in `.bynk` sources resolve");
177 eprintln!(" through the emitted source maps. A hand-rolled CDP client must send an");
178 eprintln!(" `Origin` header — VS Code's JavaScript debugger does this for you.");
179 }
180 for arg in &opts.wrangler_args {
181 cmd.arg(arg);
182 }
183
184 match cmd.status() {
189 Ok(s) => ExitCode::from(exit_byte(s.code())),
190 Err(e) => {
191 eprintln!("bynk: could not run wrangler: {e}");
192 ExitCode::FAILURE
193 }
194 }
195}
196
197pub fn preflight_failure_message(report: &Report) -> String {
202 format!(
203 "bynk: environment not ready for `dev` — see below.\n\n{}",
204 report::render(report, Format::Human)
205 )
206}
207
208fn prepare_build_dir(project_root: &Path, build_dir: &Path) -> std::io::Result<()> {
212 let bynk_dir = project_root.join(".bynk");
213 std::fs::create_dir_all(&bynk_dir)?;
214 let gitignore = bynk_dir.join(".gitignore");
215 if !gitignore.exists() {
216 std::fs::write(&gitignore, "*\n")?;
217 }
218 let workers = build_dir.join("workers");
219 match std::fs::remove_dir_all(&workers) {
220 Ok(()) => Ok(()),
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
222 Err(e) => Err(e),
223 }
224}
225
226fn discover_workers(workers_dir: &Path) -> Vec<String> {
229 let mut names = Vec::new();
230 let Ok(entries) = std::fs::read_dir(workers_dir) else {
231 return names;
232 };
233 for entry in entries.flatten() {
234 let path = entry.path();
235 if path.join("wrangler.toml").is_file()
236 && let Some(name) = path.file_name().and_then(|n| n.to_str())
237 {
238 names.push(name.to_string());
239 }
240 }
241 names.sort();
242 names
243}
244
245#[derive(Debug, PartialEq, Eq)]
247pub enum SelectError {
248 NoneBuilt,
250 Ambiguous(Vec<String>),
252 NotFound {
254 requested: String,
255 available: Vec<String>,
256 },
257}
258
259impl std::fmt::Display for SelectError {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 match self {
262 SelectError::NoneBuilt => {
263 write!(
264 f,
265 "no workers were built — does the project define any contexts?"
266 )
267 }
268 SelectError::Ambiguous(available) => write!(
269 f,
270 "this project has several contexts — pass --context to choose one of: {}",
271 available.join(", ")
272 ),
273 SelectError::NotFound {
274 requested,
275 available,
276 } => write!(
277 f,
278 "no context `{requested}` — available: {}",
279 available.join(", ")
280 ),
281 }
282 }
283}
284
285pub fn select_context(
293 available: &[String],
294 requested: Option<&str>,
295) -> Result<String, SelectError> {
296 match requested {
297 Some(name) => {
298 let dashed = name.replace('.', "-");
299 available
300 .iter()
301 .find(|d| d.as_str() == name || d.as_str() == dashed)
302 .cloned()
303 .ok_or_else(|| SelectError::NotFound {
304 requested: name.to_string(),
305 available: available.to_vec(),
306 })
307 }
308 None => match available {
309 [] => Err(SelectError::NoneBuilt),
310 [one] => Ok(one.clone()),
311 many => Err(SelectError::Ambiguous(many.to_vec())),
312 },
313 }
314}
315
316fn wrangler_command(provenance: &Provenance) -> Option<Command> {
320 match provenance {
321 Provenance::Path(p) | Provenance::ProjectLocal(p) => {
322 let mut cmd = Command::new(p);
323 cmd.arg("dev");
324 Some(cmd)
325 }
326 Provenance::Npx => {
327 let mut cmd = Command::new("npx");
328 cmd.arg("--yes").arg("wrangler").arg("dev");
329 Some(cmd)
330 }
331 Provenance::Missing => None,
332 }
333}
334
335fn exit_byte(code: Option<i32>) -> u8 {
339 code.unwrap_or(0).clamp(0, 255) as u8
340}
341
342fn inspector_args(opts: &DevOptions) -> Vec<String> {
346 if opts.inspect {
347 vec![
348 "--inspector-port".to_string(),
349 opts.inspect_port.to_string(),
350 ]
351 } else {
352 Vec::new()
353 }
354}
355
356fn dev_compile_options(src: &Path) -> CompileOptions {
361 if src.join("bynk.toml").exists() || src.join("src").is_dir() {
362 CompileOptions::split(src.to_path_buf(), read_project_paths(src))
363 } else {
364 CompileOptions::single(src.to_path_buf())
365 }
366 .target(BuildTarget::Workers)
367}
368
369fn render_project_failure(failure: &ProjectFailure) {
375 let texts: std::collections::HashMap<&Path, &str> = failure
376 .snapshots
377 .iter()
378 .map(|(p, t)| (p.as_path(), t.as_str()))
379 .collect();
380 for ae in &failure.errors {
381 match ae
382 .source_path
383 .as_deref()
384 .and_then(|p| texts.get(p).map(|t| (p, *t)))
385 {
386 Some((path, text)) => {
387 let label = path.to_string_lossy().replace('\\', "/");
388 bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
389 }
390 None => {
391 eprintln!("[{}] {}", ae.error.category, ae.error.message);
392 for note in &ae.error.notes {
393 eprintln!(" note: {note}");
394 }
395 }
396 }
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn names(v: &[&str]) -> Vec<String> {
405 v.iter().map(|s| s.to_string()).collect()
406 }
407
408 #[test]
409 fn sole_context_is_served_without_a_flag() {
410 assert_eq!(
411 select_context(&names(&["links"]), None),
412 Ok("links".to_string())
413 );
414 }
415
416 #[test]
417 fn ambiguous_without_context_lists_the_options() {
418 assert_eq!(
419 select_context(&names(&["api", "worker"]), None),
420 Err(SelectError::Ambiguous(names(&["api", "worker"])))
421 );
422 }
423
424 #[test]
425 fn no_workers_is_its_own_error() {
426 assert_eq!(select_context(&[], None), Err(SelectError::NoneBuilt));
427 }
428
429 #[test]
430 fn context_flag_selects_by_raw_or_dasherised_name() {
431 let avail = names(&["api", "commerce-payment"]);
432 assert_eq!(
433 select_context(&avail, Some("commerce-payment")),
434 Ok("commerce-payment".to_string())
435 );
436 assert_eq!(
438 select_context(&avail, Some("commerce.payment")),
439 Ok("commerce-payment".to_string())
440 );
441 }
442
443 #[test]
444 fn unknown_context_reports_what_is_available() {
445 assert_eq!(
446 select_context(&names(&["api"]), Some("nope")),
447 Err(SelectError::NotFound {
448 requested: "nope".to_string(),
449 available: names(&["api"]),
450 })
451 );
452 }
453
454 #[test]
455 fn exit_byte_maps_codes_and_signals() {
456 assert_eq!(exit_byte(Some(0)), 0);
457 assert_eq!(exit_byte(Some(1)), 1);
458 assert_eq!(exit_byte(None), 0);
460 }
461
462 #[test]
463 fn inspect_injects_the_inspector_port() {
464 let off = DevOptions::default();
465 assert!(
466 inspector_args(&off).is_empty(),
467 "no inspector args without --inspect"
468 );
469
470 let on = DevOptions {
471 inspect: true,
472 inspect_port: 9229,
473 ..Default::default()
474 };
475 assert_eq!(
476 inspector_args(&on),
477 vec!["--inspector-port".to_string(), "9229".to_string()]
478 );
479 }
480}