use super::open_next;
use super::project::parse_multiline_env_file;
use crate::config::map_windows_path_to_wsl_mnt;
use crate::strategies::WorkerDeployConfig;
use colored::Colorize;
use std::collections::{BTreeSet, HashMap};
use std::env;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
const HEROUI_PRO_DEFAULT_VERSION: &str = "1.0.0-beta.2";
const DEFAULT_WRANGLER_ARGS: &[&str] = &["--keep-vars", "--autoconfig=false"];
const HEROUI_AUTH_TOKEN: &str = "HEROUI_AUTH_TOKEN";
const PREFLIGHT_SCRIPT_CANDIDATES: &[&str] = &["predeploy", "xbp:preflight", "test:deploy"];
#[derive(Debug, Clone)]
pub struct OpenNextDeployOptions {
pub use_wsl: Option<bool>,
pub wsl_distribution: Option<String>,
pub build_script: Option<String>,
pub install_ignore_scripts: bool,
pub restore_heroui_cache: bool,
pub wrangler_args: Vec<String>,
pub build_required_env: Vec<String>,
pub preflight_script: Option<String>,
pub require_local_secrets: bool,
}
impl OpenNextDeployOptions {
pub fn from_worker_deploy(config: Option<&WorkerDeployConfig>) -> Self {
match config {
Some(c) => Self {
use_wsl: c.use_wsl,
wsl_distribution: c.wsl_distribution.clone(),
build_script: c.build_script.clone(),
install_ignore_scripts: c.install_ignore_scripts.unwrap_or(true),
restore_heroui_cache: c.restore_heroui_cache.unwrap_or(true),
wrangler_args: c.wrangler_args.clone().unwrap_or_else(default_wrangler_args),
build_required_env: c.build_required_env.clone(),
preflight_script: c.preflight_script.clone(),
require_local_secrets: c.require_local_secrets.unwrap_or(false),
},
None => Self {
use_wsl: None,
wsl_distribution: None,
build_script: None,
install_ignore_scripts: true,
restore_heroui_cache: true,
wrangler_args: default_wrangler_args(),
build_required_env: Vec::new(),
preflight_script: None,
require_local_secrets: false,
},
}
}
}
fn default_wrangler_args() -> Vec<String> {
DEFAULT_WRANGLER_ARGS
.iter()
.map(|s| (*s).to_string())
.collect()
}
pub fn is_opennext_worker(worker_root: &Path) -> bool {
if open_next::inspect(worker_root)
.ok()
.is_some_and(|i| i.present && i.provider.as_deref() == Some("@opennextjs/cloudflare"))
{
return true;
}
if package_depends_on(worker_root, "@opennextjs/cloudflare") {
return true;
}
wrangler_main_is_opennext(worker_root)
}
fn package_depends_on(worker_root: &Path, name: &str) -> bool {
let Ok(text) = fs::read_to_string(worker_root.join("package.json")) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
for key in ["dependencies", "devDependencies", "peerDependencies"] {
if value
.get(key)
.and_then(|d| d.get(name))
.is_some()
{
return true;
}
}
false
}
fn wrangler_main_is_opennext(worker_root: &Path) -> bool {
for name in ["wrangler.jsonc", "wrangler.json", "wrangler.toml"] {
let path = worker_root.join(name);
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if text.contains(".open-next/worker") || text.contains(".open-next\\\\worker") {
return true;
}
}
false
}
fn should_use_wsl(opts: &OpenNextDeployOptions) -> bool {
if let Some(forced) = opts.use_wsl {
return forced;
}
cfg!(windows)
}
pub fn opennext_entry_rel() -> &'static str {
".open-next/worker.js"
}
pub fn opennext_assets_rel_default() -> &'static str {
".open-next/assets"
}
pub fn opennext_entry_path(worker_root: &Path) -> PathBuf {
worker_root.join(".open-next").join("worker.js")
}
pub fn opennext_assets_path(worker_root: &Path) -> PathBuf {
worker_root.join(opennext_assets_directory_rel(worker_root))
}
pub fn opennext_assets_directory_rel(worker_root: &Path) -> String {
read_wrangler_assets_directory(worker_root)
.unwrap_or_else(|| opennext_assets_rel_default().to_string())
}
pub fn wrangler_declares_assets_directory(worker_root: &Path) -> bool {
read_wrangler_assets_directory(worker_root).is_some()
}
fn read_wrangler_assets_directory(worker_root: &Path) -> Option<String> {
for name in ["wrangler.jsonc", "wrangler.json", "wrangler.toml"] {
let path = worker_root.join(name);
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if let Some(dir) = parse_assets_directory_from_wrangler_text(&text) {
return Some(dir);
}
}
None
}
fn parse_assets_directory_from_wrangler_text(text: &str) -> Option<String> {
if let Some(idx) = text.find("\"assets\"") {
let slice = &text[idx..];
if let Some(dir) = capture_quoted_after_key(slice, "directory") {
return Some(dir);
}
}
if let Some(idx) = text.find("[assets]") {
let slice = &text[idx..];
if let Some(dir) = capture_toml_string_after_key(slice, "directory") {
return Some(dir);
}
}
if text.contains(".open-next/assets") || text.contains(".open-next\\\\assets") {
if let Some(dir) = capture_quoted_after_key(text, "directory") {
if dir.contains("open-next") {
return Some(dir);
}
}
if let Some(dir) = capture_toml_string_after_key(text, "directory") {
if dir.contains("open-next") {
return Some(dir);
}
}
}
None
}
fn capture_quoted_after_key(text: &str, key: &str) -> Option<String> {
let patterns = [
format!("\"{key}\""),
format!("'{key}'"),
];
for pat in patterns {
let Some(pos) = text.find(&pat) else {
continue;
};
let after = text[pos + pat.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
continue;
}
let rest = &after[1..];
let end = rest.find(quote)?;
let value = rest[..end].trim();
if !value.is_empty() {
return Some(value.replace('\\', "/"));
}
}
None
}
fn capture_toml_string_after_key(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let Some(rest) = trimmed
.strip_prefix(key)
.map(str::trim_start)
.and_then(|s| s.strip_prefix('='))
.map(str::trim_start)
else {
continue;
};
let quote = rest.chars().next()?;
if quote != '"' && quote != '\'' {
continue;
}
let inner = &rest[1..];
let end = inner.find(quote)?;
let value = inner[..end].trim();
if !value.is_empty() {
return Some(value.replace('\\', "/"));
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenNextBuildGaps {
pub missing_entry: bool,
pub missing_assets: bool,
pub assets_rel: String,
}
impl OpenNextBuildGaps {
pub fn is_empty(&self) -> bool {
!self.missing_entry && !self.missing_assets
}
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if self.missing_entry {
parts.push(opennext_entry_rel().to_string());
}
if self.missing_assets {
parts.push(format!("{} (assets.directory)", self.assets_rel));
}
if parts.is_empty() {
"complete".into()
} else {
format!("missing {}", parts.join(" · "))
}
}
}
pub fn opennext_build_gaps(worker_root: &Path) -> OpenNextBuildGaps {
let assets_rel = opennext_assets_directory_rel(worker_root);
let entry_ok = opennext_entry_path(worker_root).is_file();
let assets_declared = wrangler_declares_assets_directory(worker_root);
let assets_path = worker_root.join(&assets_rel);
let missing_assets = if assets_declared {
!assets_path.is_dir()
} else {
false
};
OpenNextBuildGaps {
missing_entry: !entry_ok,
missing_assets,
assets_rel,
}
}
pub fn opennext_build_ready_for_wrangler(worker_root: &Path) -> bool {
opennext_build_gaps(worker_root).is_empty()
}
pub fn assert_opennext_entry(worker_root: &Path) -> Result<(), String> {
let gaps = opennext_build_gaps(worker_root);
if gaps.is_empty() {
return Ok(());
}
if gaps.missing_entry && !gaps.missing_assets {
return Err(format!(
"cf_missing_entry_point: OpenNext entry not found at `{}`. Build did not produce {}. Fix OpenNext build errors (see stage log) before wrangler deploy.",
opennext_entry_path(worker_root).display(),
opennext_entry_rel()
));
}
if gaps.missing_assets && !gaps.missing_entry {
return Err(format!(
"cf_missing_assets_directory: OpenNext assets directory not found at `{}` (wrangler assets.directory=`{}`). Partial/stale `.open-next` output — re-run `pnpm run build:worker` (or OpenNext build) before wrangler deploy.",
opennext_assets_path(worker_root).display(),
gaps.assets_rel
));
}
Err(format!(
"cf_incomplete_opennext_build: OpenNext output incomplete ({}) under `{}`. Fix OpenNext build errors before wrangler deploy.",
gaps.summary(),
worker_root.display()
))
}
pub fn opennext_deploy_log_path(worker_root: &Path) -> PathBuf {
worker_root.join(".xbp").join("opennext-deploy.log")
}
pub fn run_opennext_deploy(
worker_root: &Path,
opts: &OpenNextDeployOptions,
) -> Result<(), String> {
if !is_opennext_worker(worker_root) {
return Err(format!(
"{} is not an OpenNext Cloudflare worker (missing open-next config / @opennextjs/cloudflare).",
worker_root.display()
));
}
apply_cloudflare_env_from_local(worker_root)?;
run_opennext_preflight(worker_root, opts)?;
let use_wsl = should_use_wsl(opts);
println!(
"{} OpenNext deploy ({} · prefer local build)",
"→".cyan(),
if use_wsl {
"WSL Linux toolchain"
} else {
"native host"
}
);
println!(
"{} stage log → {}",
"·".dimmed(),
opennext_deploy_log_path(worker_root).display()
);
let result = if use_wsl {
run_via_wsl(worker_root, opts)
} else {
run_native(worker_root, opts)
};
match &result {
Ok(()) => {
if let Err(e) = assert_opennext_entry(worker_root) {
return Err(format_opennext_failure(
"entry_assert",
&e,
&[],
Some(&opennext_deploy_log_path(worker_root)),
));
}
println!(
"{} OpenNext build outputs ok ({})",
"✓".green().bold(),
opennext_build_gaps(worker_root).summary()
);
Ok(())
}
Err(e) => Err(e.clone()),
}
}
pub fn run_opennext_preflight(
worker_root: &Path,
opts: &OpenNextDeployOptions,
) -> Result<(), String> {
println!(
"{} OpenNext preflight (build secrets + optional test script)",
"→".cyan()
);
let local_env = load_worker_local_env_map(worker_root);
let missing = missing_build_required_env(worker_root, opts, &local_env);
if !missing.is_empty() {
return Err(format_build_env_failure(worker_root, &missing));
}
println!(
"{} build-required env ok ({})",
"✓".green().bold(),
summarize_build_required_keys(worker_root, opts).join(", ")
);
if let Some(script) = resolve_preflight_script(worker_root, opts) {
println!(
"{} running package script `{script}` (preflight gate)",
"→".cyan()
);
run_package_script_preflight(worker_root, &script)?;
println!(
"{} preflight script `{script}` ok",
"✓".green().bold()
);
} else {
println!(
"{} no package preflight script (checked predeploy / xbp:preflight / test:deploy)",
"·".dimmed()
);
}
Ok(())
}
pub fn resolve_build_required_env_keys(
worker_root: &Path,
opts: &OpenNextDeployOptions,
) -> Vec<String> {
let mut keys = BTreeSet::new();
keys.insert("CLOUDFLARE_API_TOKEN".to_string());
for key in &opts.build_required_env {
let trimmed = key.trim();
if !trimmed.is_empty() {
keys.insert(trimmed.to_string());
}
}
if package_depends_on(worker_root, "@heroui-pro/react")
|| package_depends_on(worker_root, "@heroui-pro/styles")
{
keys.insert(HEROUI_AUTH_TOKEN.to_string());
}
keys.into_iter().collect()
}
pub fn missing_build_required_env(
worker_root: &Path,
opts: &OpenNextDeployOptions,
local_env: &HashMap<String, String>,
) -> Vec<String> {
resolve_build_required_env_keys(worker_root, opts)
.into_iter()
.filter(|key| !env_value_present(local_env, key))
.collect()
}
pub fn is_build_required_secret_name(worker_root: &Path, name: &str) -> bool {
let opts = OpenNextDeployOptions::from_worker_deploy(None);
resolve_build_required_env_keys(worker_root, &opts)
.iter()
.any(|k| k.eq_ignore_ascii_case(name))
}
fn summarize_build_required_keys(
worker_root: &Path,
opts: &OpenNextDeployOptions,
) -> Vec<String> {
resolve_build_required_env_keys(worker_root, opts)
}
fn format_build_env_failure(worker_root: &Path, missing: &[String]) -> String {
let mut lines = vec![
"OpenNext preflight failed — build-required env missing (deploy aborted before install)."
.to_string(),
format!(" missing: {}", missing.join(", ")),
format!(
" searched: process env, {}/.env.local, .env, .dev.vars",
worker_root.display()
),
" fix:".to_string(),
];
for key in missing {
if key == HEROUI_AUTH_TOKEN {
lines.push(format!(
" xbp secrets pull --key {key} --environment production"
));
lines.push(format!(
" # or export {key}=… / seed ~/.heroui/cache/react/<version>"
));
} else if key == "CLOUDFLARE_API_TOKEN" {
lines.push(
" export CLOUDFLARE_API_TOKEN=… or xbp config cloudflare set-key".into(),
);
} else {
lines.push(format!(
" xbp secrets pull --key {key} --environment production"
));
}
}
lines.join("\n")
}
fn env_value_present(local_env: &HashMap<String, String>, key: &str) -> bool {
if let Ok(v) = env::var(key) {
if !v.trim().is_empty() {
return true;
}
}
local_env
.get(key)
.is_some_and(|v| !v.trim().is_empty())
|| local_env.iter().any(|(k, v)| {
k.eq_ignore_ascii_case(key) && !v.trim().is_empty()
})
}
pub fn load_worker_local_env_map(worker_root: &Path) -> HashMap<String, String> {
let mut map = HashMap::new();
for name in [".env.local", ".env", ".dev.vars"] {
let path = worker_root.join(name);
if let Ok(parsed) = parse_multiline_env_file(&path) {
for (k, v) in parsed {
if !v.trim().is_empty() {
map.entry(k).or_insert(v);
}
}
}
}
for (k, v) in env::vars() {
if !v.trim().is_empty() {
map.insert(k, v);
}
}
map
}
fn resolve_preflight_script(
worker_root: &Path,
opts: &OpenNextDeployOptions,
) -> Option<String> {
if let Some(script) = opts
.preflight_script
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
if package_has_script(worker_root, script) {
return Some(script.to_string());
}
return Some(script.to_string());
}
for name in PREFLIGHT_SCRIPT_CANDIDATES {
if package_has_script(worker_root, name) {
return Some((*name).to_string());
}
}
None
}
fn run_package_script_preflight(worker_root: &Path, script: &str) -> Result<(), String> {
let use_pnpm = worker_root.join("pnpm-lock.yaml").is_file()
|| worker_root
.parent()
.map(|p| p.join("pnpm-lock.yaml").is_file())
.unwrap_or(false);
let mut cmd = if use_pnpm {
let mut c = Command::new("pnpm");
c.arg("run").arg(script);
c
} else {
let mut c = Command::new("npm");
c.arg("run").arg(script);
c
};
cmd.current_dir(worker_root)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = cmd
.status()
.map_err(|e| format!("Failed to run preflight script `{script}`: {e}"))?;
if !status.success() {
return Err(format!(
"OpenNext preflight script `{script}` failed with status {status}. Fix the test/script before deploy."
));
}
Ok(())
}
fn apply_cloudflare_env_from_local(worker_root: &Path) -> Result<(), String> {
use crate::config::{resolve_cloudflare_account_id, resolve_cloudflare_api_token};
for name in [".env.local", ".env", ".dev.vars"] {
let path = worker_root.join(name);
let Ok(parsed) = parse_multiline_env_file(&path) else {
continue;
};
for (key, value) in parsed {
if !value.trim().is_empty() && env::var_os(&key).is_none() {
env::set_var(key, value);
}
}
}
if env::var_os("CLOUDFLARE_API_TOKEN").is_none() {
if let Some(token) = resolve_cloudflare_api_token() {
env::set_var("CLOUDFLARE_API_TOKEN", token);
}
}
if env::var_os("CLOUDFLARE_ACCOUNT_ID").is_none() {
if let Some(id) = resolve_cloudflare_account_id() {
env::set_var("CLOUDFLARE_ACCOUNT_ID", id);
}
}
Ok(())
}
fn wsl_forward_env_pairs(
worker_root: &Path,
opts: &OpenNextDeployOptions,
) -> Vec<(String, String)> {
let mut keys: BTreeSet<String> = [
"CLOUDFLARE_API_TOKEN",
"CLOUDFLARE_ACCOUNT_ID",
HEROUI_AUTH_TOKEN,
"CI",
]
.into_iter()
.map(str::to_string)
.collect();
for key in resolve_build_required_env_keys(worker_root, opts) {
keys.insert(key);
}
for key in &opts.build_required_env {
if !key.trim().is_empty() {
keys.insert(key.trim().to_string());
}
}
let mut pairs = Vec::new();
for key in keys {
if let Ok(val) = env::var(&key) {
if !val.trim().is_empty() {
pairs.push((key, val));
}
}
}
if !pairs.iter().any(|(k, _)| k == "CI") {
pairs.push(("CI".into(), "true".into()));
}
pairs
}
fn run_native(worker_root: &Path, opts: &OpenNextDeployOptions) -> Result<(), String> {
let script = build_bash_pipeline(worker_root, opts, false)?;
let mut cmd = Command::new("bash");
cmd.arg("-lc")
.arg(&script)
.current_dir(worker_root)
.stdin(Stdio::inherit());
run_command_streaming(cmd, worker_root, "native")
}
fn run_via_wsl(worker_root: &Path, opts: &OpenNextDeployOptions) -> Result<(), String> {
let wsl_root = path_for_wsl(worker_root)?;
let script = build_bash_pipeline_for_wsl_path(&wsl_root, opts)?;
let mut cmd = Command::new("wsl");
if let Some(distro) = opts
.wsl_distribution
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
cmd.arg("-d").arg(distro);
}
let env_pairs = wsl_forward_env_pairs(worker_root, opts);
cmd.arg("-e");
if env_pairs.is_empty() {
cmd.args(["bash", "-lc", &script]);
} else {
cmd.arg("env");
for (key, val) in &env_pairs {
cmd.arg(format!("{key}={val}"));
}
cmd.args(["bash", "-lc", &script]);
}
cmd.stdin(Stdio::inherit());
run_command_streaming(cmd, worker_root, "wsl")
}
fn run_command_streaming(
mut cmd: Command,
worker_root: &Path,
mode: &str,
) -> Result<(), String> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to start OpenNext {mode} pipeline: {e}"))?;
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mut join_handles = Vec::new();
if let Some(stdout) = child.stdout.take() {
let lines_c = Arc::clone(&lines);
join_handles.push(thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
println!("{line}");
if let Ok(mut buf) = lines_c.lock() {
buf.push(line);
}
}
}));
}
if let Some(stderr) = child.stderr.take() {
let lines_c = Arc::clone(&lines);
join_handles.push(thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
eprintln!("{line}");
if let Ok(mut buf) = lines_c.lock() {
buf.push(line);
}
}
}));
}
let status = child
.wait()
.map_err(|e| format!("OpenNext {mode} pipeline wait failed: {e}"))?;
for h in join_handles {
let _ = h.join();
}
let captured = lines.lock().map(|g| g.clone()).unwrap_or_default();
let log_path = write_opennext_stage_log(worker_root, mode, &captured);
if status.success() {
return Ok(());
}
let stage = last_stage_from_log(&captured).unwrap_or_else(|| "unknown".into());
let code = match status.code() {
Some(42) => "opennext_build_failed", Some(c) if c != 0 && stage.contains("build") => "opennext_build_failed",
Some(c) if c != 0 && stage.contains("wrangler") => "cf_auth_token", _ if mode == "wsl" => "opennext_wsl_failed",
_ => "opennext_build_failed",
};
let code = if stage == "entry_assert"
|| captured.iter().any(|l| {
let lower = l.to_ascii_lowercase();
lower.contains("entry not found")
|| (lower.contains("worker.js") && lower.contains("missing"))
|| lower.contains("open-next entry")
})
{
"cf_missing_entry_point"
} else if captured.iter().any(|l| {
let lower = l.to_ascii_lowercase();
lower.contains("authentication error")
|| lower.contains("failed to fetch auth token")
|| lower.contains("grant_type=refresh_token")
}) {
"cf_auth_token"
} else {
code
};
Err(format_opennext_failure(
&stage,
&format!(
"{code}: OpenNext {mode} deploy failed at stage `{stage}` (status {status})."
),
&captured,
log_path.as_ref(),
))
}
fn write_opennext_stage_log(
worker_root: &Path,
mode: &str,
lines: &[String],
) -> Option<PathBuf> {
let dir = worker_root.join(".xbp");
let _ = fs::create_dir_all(&dir);
let path = opennext_deploy_log_path(worker_root);
let mut body = format!("# xbp opennext deploy log mode={mode}\n");
for line in lines {
body.push_str(line);
body.push('\n');
}
match fs::File::create(&path).and_then(|mut f| f.write_all(body.as_bytes())) {
Ok(()) => Some(path),
Err(_) => None,
}
}
pub fn last_stage_from_log(lines: &[String]) -> Option<String> {
for line in lines.iter().rev() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("== stage:") {
let name = rest.split_whitespace().next().unwrap_or(rest).trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
if let Some(rest) = trimmed.strip_prefix("== ") {
let key = rest.trim().to_ascii_lowercase();
if key.starts_with("opennext build") {
return Some("build".into());
}
if key.starts_with("wrangler deploy") {
return Some("wrangler".into());
}
if key.starts_with("pnpm install") || key.contains("pnpm install") {
return Some("install".into());
}
if key.starts_with("wrangler whoami") {
return Some("whoami".into());
}
if key.starts_with("toolchain") {
return Some("toolchain".into());
}
}
}
None
}
fn format_opennext_failure(
stage: &str,
head: &str,
lines: &[String],
log_path: Option<&PathBuf>,
) -> String {
const TAIL: usize = 40;
let mut msg = head.to_string();
msg.push_str(&format!("\nstage={stage}"));
if let Some(path) = log_path {
msg.push_str(&format!("\nlog={}", path.display()));
}
if !lines.is_empty() {
msg.push_str("\n--- last log lines ---");
let start = lines.len().saturating_sub(TAIL);
for line in &lines[start..] {
msg.push('\n');
msg.push_str(line);
}
msg.push_str("\n--- end ---");
}
msg
}
fn path_for_wsl(path: &Path) -> Result<String, String> {
let abs = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf());
let s = abs.to_string_lossy();
let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
if let Some(mnt) = map_windows_path_to_wsl_mnt(s) {
return Ok(mnt);
}
Ok(s.replace('\\', "/"))
}
fn build_bash_pipeline(
worker_root: &Path,
opts: &OpenNextDeployOptions,
_wsl: bool,
) -> Result<String, String> {
let root = worker_root
.canonicalize()
.unwrap_or_else(|_| worker_root.to_path_buf())
.to_string_lossy()
.replace('\\', "/");
build_bash_pipeline_for_wsl_path(&root, opts)
}
fn build_bash_pipeline_for_wsl_path(
root: &str,
opts: &OpenNextDeployOptions,
) -> Result<String, String> {
let root_q = shell_single_quote(root);
let build_cmd = resolve_build_command_fragment(root, opts)?;
let wrangler_args = opts
.wrangler_args
.iter()
.map(|a| shell_single_quote(a))
.collect::<Vec<_>>()
.join(" ");
let ignore_scripts_flag = if opts.install_ignore_scripts {
" --ignore-scripts"
} else {
""
};
let install_block = format!(
r#"
PKG_NAME=$(node -e "try{{console.log(require('./package.json').name||'')}}catch(e){{}}" 2>/dev/null || true)
INSTALL_ROOT="$(pwd)"
# Walk up for a pnpm workspace that includes this package (not a local `.` only workspace).
for up in .. ../.. ../../..; do
if [[ -f "$up/pnpm-workspace.yaml" ]] && [[ -f "$up/package.json" ]]; then
# Skip tiny self-workspaces that only list `.`
if grep -qE 'packages/|examples/' "$up/pnpm-workspace.yaml" 2>/dev/null \
|| grep -q '\*' "$up/pnpm-workspace.yaml" 2>/dev/null; then
INSTALL_ROOT="$(cd "$up" && pwd)"
break
fi
fi
done
# Windows→WSL: node_modules often has workerd-windows binaries that crash under Linux.
# Prefer linux optional deps when running inside WSL (/mnt/c paths or WSL_DISTRO_NAME).
IS_WSL=0
if [[ -n "${{WSL_DISTRO_NAME:-}}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
IS_WSL=1
fi
if [[ ! -d node_modules/.pnpm ]] || [[ ! -d node_modules/next ]] || [[ "$IS_WSL" -eq 1 ]]; then
cd "$INSTALL_ROOT"
if [[ "$IS_WSL" -eq 1 ]]; then
echo "== pnpm install with supportedArchitectures=linux/x64 (WSL, root=$INSTALL_ROOT)"
# Install both so Windows-side tooling still works when sharing the tree, but prefer linux.
"${{PNPM_BIN}}" config set supportedArchitectures.os "linux" --location project 2>/dev/null || true
"${{PNPM_BIN}}" config set supportedArchitectures.cpu "x64" --location project 2>/dev/null || true
if [[ -f pnpm-lock.yaml ]]; then
"${{PNPM_BIN}}" install --frozen-lockfile{ignore} \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu=x64 \
|| "${{PNPM_BIN}}" install{ignore} \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu=x64
else
"${{PNPM_BIN}}" install{ignore} \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu=x64
fi
# Explicit optional package for workerd when the wrong binary is still present.
if ! "${{PNPM_BIN}}" exec node -e "require('workerd')" 2>/dev/null; then
echo "== reinstall workerd for linux"
(cd "$INSTALL_ROOT" && "${{PNPM_BIN}}" add -D @cloudflare/workerd-linux-64 --config.supportedArchitectures.os=linux --config.supportedArchitectures.cpu=x64) || true
(cd "$INSTALL_ROOT" && "${{PNPM_BIN}}" rebuild workerd) || true
fi
else
if [[ -f pnpm-lock.yaml ]]; then
echo "== pnpm install --frozen-lockfile{ignore} (root=$INSTALL_ROOT)"
"${{PNPM_BIN}}" install --frozen-lockfile{ignore} || "${{PNPM_BIN}}" install{ignore}
else
echo "== pnpm install{ignore} (root=$INSTALL_ROOT, no lockfile)"
"${{PNPM_BIN}}" install{ignore}
fi
fi
cd - >/dev/null
# When installed from monorepo root, ensure the example dir has a usable node_modules.
if [[ "$INSTALL_ROOT" != "$(pwd)" ]] && [[ -n "$PKG_NAME" ]]; then
echo "== pnpm --filter $PKG_NAME install{ignore}"
(cd "$INSTALL_ROOT" && "${{PNPM_BIN}}" --filter "$PKG_NAME" install{ignore}) || true
fi
fi
"#,
ignore = ignore_scripts_flag
);
let heroui_ver = detect_heroui_pro_version(root).unwrap_or_else(|| {
HEROUI_PRO_DEFAULT_VERSION.to_string()
});
let heroui_block = if opts.restore_heroui_cache {
format!(
r#"
# Unlock @heroui-pro/react from local cache when postinstall left a stub.
# Only hard-fail when the package is actually required.
HEROUI_VER="{ver}"
NEEDS_HEROUI=0
if [[ -e node_modules/@heroui-pro/react/package.json ]] || grep -q '"@heroui-pro/react"' package.json 2>/dev/null; then
NEEDS_HEROUI=1
fi
if [[ "$NEEDS_HEROUI" -eq 1 ]] && [[ ! -f node_modules/@heroui-pro/react/dist/index.js ]]; then
CACHE_CANDIDATES=(
"${{HOME}}/.heroui/cache/react/${{HEROUI_VER}}"
)
# Windows profiles mounted under /mnt/c/Users/*
if [[ -d /mnt/c/Users ]]; then
for udir in /mnt/c/Users/*; do
[[ -d "$udir/.heroui/cache/react/${{HEROUI_VER}}" ]] && CACHE_CANDIDATES+=("$udir/.heroui/cache/react/${{HEROUI_VER}}")
done
fi
for cache in "${{CACHE_CANDIDATES[@]}}"; do
if [[ -d "${{cache}}/dist" ]]; then
echo "== restore @heroui-pro/react from ${{cache}}"
mkdir -p "${{HOME}}/.heroui/cache/react"
if [[ "${{cache}}" != "${{HOME}}/.heroui/cache/react/${{HEROUI_VER}}" ]]; then
rm -rf "${{HOME}}/.heroui/cache/react/${{HEROUI_VER}}"
cp -a "${{cache}}" "${{HOME}}/.heroui/cache/react/${{HEROUI_VER}}"
fi
PNPM_PKG=$(find node_modules/.pnpm -maxdepth 1 -type d -name '@heroui-pro+react@*' 2>/dev/null | head -1 || true)
if [[ -n "${{PNPM_PKG}}" ]]; then
mkdir -p "${{PNPM_PKG}}/node_modules/@heroui-pro/react"
cp -a "${{cache}}/." "${{PNPM_PKG}}/node_modules/@heroui-pro/react/"
fi
mkdir -p node_modules/@heroui-pro/react
cp -a "${{cache}}/." "node_modules/@heroui-pro/react/"
break
fi
done
fi
if [[ "$NEEDS_HEROUI" -eq 1 ]] && [[ ! -f node_modules/@heroui-pro/react/dist/index.js ]]; then
echo "HeroUI Pro package not unlocked. Set a valid HEROUI_AUTH_TOKEN or seed ~/.heroui/cache/react/${{HEROUI_VER}}" >&2
exit 1
fi
"#,
ver = heroui_ver
)
} else {
String::new()
};
Ok(format!(
r#"set -euo pipefail
export PNPM_HOME="${{HOME}}/.local/share/pnpm"
export PATH="${{PNPM_HOME}}/bin:/usr/local/bin:/usr/bin:/bin"
# Prefer Linux binaries; drop Windows /mnt/c PATH entries that inject pnpm.cmd
PATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd: - || true)
export PATH
hash -r
export CI="${{CI:-true}}"
export PNPM_CONFIRM_MODULES_PURGE=false
cd {root_q}
PNPM_BIN="${{PNPM_HOME}}/bin/pnpm"
if [[ ! -x "${{PNPM_BIN}}" ]]; then
if command -v pnpm >/dev/null 2>&1; then
PNPM_BIN="$(command -v pnpm)"
else
curl -fsSL https://get.pnpm.io/install.sh | SHELL=bash sh
PNPM_BIN="${{PNPM_HOME}}/bin/pnpm"
fi
fi
if [[ ! -x "${{PNPM_BIN}}" ]]; then
echo "pnpm not found after install attempt" >&2
exit 1
fi
echo "== toolchain"
echo "node=$(node -v 2>/dev/null || echo missing) pnpm=$(${{PNPM_BIN}} -v) root=$(pwd)"
echo "CLOUDFLARE_API_TOKEN is $([[ -n "${{CLOUDFLARE_API_TOKEN:-}}" ]] && echo set || echo missing)"
echo "HEROUI_AUTH_TOKEN is $([[ -n "${{HEROUI_AUTH_TOKEN:-}}" ]] && echo set || echo missing)"
# Load .env.local / .env / .dev.vars without printing secrets (does not override existing env)
load_env_file() {{
local f="$1"
[[ -f "$f" ]] || return 0
set -a
# shellcheck disable=SC1091
source <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$f" | sed 's/\r$//') || true
set +a
}}
load_env_file .env.local
load_env_file .env
load_env_file .dev.vars
if [[ -z "${{CLOUDFLARE_API_TOKEN:-}}" ]]; then
echo "CLOUDFLARE_API_TOKEN is required" >&2
exit 1
fi
# Fail-fast HeroUI gate before expensive install (host preflight also checks this).
if grep -q '"@heroui-pro/react"' package.json 2>/dev/null; then
if [[ -z "${{HEROUI_AUTH_TOKEN:-}}" ]]; then
echo "HEROUI_AUTH_TOKEN is required for @heroui-pro/react before install." >&2
echo "Run: xbp secrets pull --key HEROUI_AUTH_TOKEN --environment production" >&2
exit 1
fi
fi
# Install first so WSL gets linux workerd (Windows node_modules break workerd under WSL).
echo "== stage:install"
{install_block}
echo "== stage:heroui"
{heroui_block}
echo "== stage:whoami"
"${{PNPM_BIN}}" exec wrangler whoami
# Avoid pnpm reinstall/postinstall loops on every run
"${{PNPM_BIN}}" config set verify-deps-before-run false --location project 2>/dev/null || true
echo "== stage:build"
{build_cmd}
echo "== stage:entry_assert"
if [[ ! -f .open-next/worker.js ]]; then
echo "ERROR: OpenNext entry not found at .open-next/worker.js after build" >&2
echo "HINT: fix OpenNext build output; wrangler will not run without this file" >&2
ls -la .open-next 2>/dev/null || echo "(no .open-next directory)" >&2
exit 42
fi
echo "open-next worker present: .open-next/worker.js"
# Wrangler hard-fails when assets.directory is missing (partial .open-next is common).
if grep -Eq '"assets"\s*:|\[assets\]' wrangler.jsonc wrangler.json wrangler.toml 2>/dev/null; then
ASSETS_DIR=".open-next/assets"
# Prefer directory value from JSON-ish wrangler when present
if [[ -f wrangler.jsonc ]] || [[ -f wrangler.json ]]; then
PARSED="$(grep -Eho '"directory"\s*:\s*"[^"]+"' wrangler.jsonc wrangler.json 2>/dev/null | head -1 | sed -E 's/.*"directory"\s*:\s*"([^"]+)".*/\1/' || true)"
if [[ -n "${{PARSED:-}}" ]]; then
ASSETS_DIR="$PARSED"
fi
fi
if [[ ! -d "$ASSETS_DIR" ]]; then
echo "ERROR: OpenNext assets directory not found at $ASSETS_DIR (wrangler assets.directory)" >&2
echo "HINT: partial/stale .open-next — rebuild with build:worker / opennextjs-cloudflare build" >&2
ls -la .open-next 2>/dev/null || echo "(no .open-next directory)" >&2
exit 42
fi
echo "open-next assets present: $ASSETS_DIR"
fi
echo "== stage:wrangler"
"${{PNPM_BIN}}" exec wrangler deploy {wrangler_args}
echo "== stage:done"
echo "== OpenNext deploy done"
"#,
root_q = root_q,
install_block = install_block,
heroui_block = heroui_block,
build_cmd = build_cmd,
wrangler_args = wrangler_args,
))
}
fn resolve_build_command_fragment(
root_hint: &str,
opts: &OpenNextDeployOptions,
) -> Result<String, String> {
if let Some(script) = opts
.build_script
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Ok(format!(
r#""${{PNPM_BIN}}" run {script}"#,
script = shell_single_quote(script)
));
}
let host_root = PathBuf::from(root_hint);
let host_root = if host_root.is_dir() {
host_root
} else if let Some(win) = crate::config::map_wsl_mnt_path_to_windows(root_hint) {
PathBuf::from(win)
} else {
PathBuf::new()
};
if host_root.is_dir() {
for name in ["cf:build", "build:opennext", "build:worker", "opennext:build"] {
if package_has_script(&host_root, name) {
return Ok(format!(
r#""${{PNPM_BIN}}" run {script}"#,
script = shell_single_quote(name)
));
}
}
if host_root
.join("scripts")
.join("opennext-cloudflare.mjs")
.is_file()
{
return Ok(
r#"node scripts/opennext-cloudflare.mjs build"#.to_string(),
);
}
}
Ok(r#"
if [[ -f scripts/opennext-cloudflare.mjs ]]; then
node scripts/opennext-cloudflare.mjs build
elif "${PNPM_BIN}" run | grep -qE '^\s*cf:build'; then
"${PNPM_BIN}" run cf:build
elif "${PNPM_BIN}" run | grep -qE '^\s*build:worker'; then
"${PNPM_BIN}" run build:worker
else
"${PNPM_BIN}" exec opennextjs-cloudflare build || "${PNPM_BIN}" dlx @opennextjs/cloudflare build
fi
"#
.to_string())
}
fn detect_heroui_pro_version(root_hint: &str) -> Option<String> {
let host_root = PathBuf::from(root_hint);
let host_root = if host_root.is_dir() {
host_root
} else if let Some(win) = crate::config::map_wsl_mnt_path_to_windows(root_hint) {
PathBuf::from(win)
} else {
return None;
};
detect_heroui_pro_version_path(&host_root)
}
fn detect_heroui_pro_version_path(host_root: &Path) -> Option<String> {
let text = fs::read_to_string(host_root.join("package.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
for key in ["dependencies", "devDependencies"] {
if let Some(v) = value
.get(key)
.and_then(|d| d.get("@heroui-pro/react"))
.and_then(|v| v.as_str())
{
let ver = v.trim().trim_start_matches('^').trim_start_matches('~');
if !ver.is_empty() {
return Some(ver.to_string());
}
}
}
None
}
fn package_has_script(worker_root: &Path, name: &str) -> bool {
let Ok(text) = fs::read_to_string(worker_root.join("package.json")) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
value
.get("scripts")
.and_then(|s| s.get(name))
.and_then(|v| v.as_str())
.is_some()
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r#"'"'"'"#))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = env::temp_dir().join(format!("xbp-opennext-{label}-{nanos}"));
let _ = fs::create_dir_all(&path);
path
}
#[test]
fn detects_opennext_from_package_json() {
let dir = temp_dir("pkg");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1.0.0"}}"#,
)
.unwrap();
assert!(is_opennext_worker(&dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn heroui_dependency_requires_auth_token_when_no_cache() {
let dir = temp_dir("heroui-req");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1","@heroui-pro/react":"9.9.9-test-only"}}"#,
)
.unwrap();
let opts = OpenNextDeployOptions::from_worker_deploy(None);
let local = HashMap::new();
let previous = env::var_os(HEROUI_AUTH_TOKEN);
env::remove_var(HEROUI_AUTH_TOKEN);
let missing = missing_build_required_env(&dir, &opts, &local);
match previous {
Some(v) => env::set_var(HEROUI_AUTH_TOKEN, v),
None => env::remove_var(HEROUI_AUTH_TOKEN),
}
assert!(
missing.iter().any(|k| k == HEROUI_AUTH_TOKEN),
"expected HEROUI_AUTH_TOKEN missing, got {missing:?}"
);
assert!(is_build_required_secret_name(&dir, HEROUI_AUTH_TOKEN));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn heroui_token_in_local_env_satisfies_preflight() {
let dir = temp_dir("heroui-ok");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1","@heroui-pro/react":"1.0.0-beta.2"}}"#,
)
.unwrap();
let opts = OpenNextDeployOptions::from_worker_deploy(None);
let mut local = HashMap::new();
local.insert(HEROUI_AUTH_TOKEN.to_string(), "token".to_string());
local.insert("CLOUDFLARE_API_TOKEN".to_string(), "cfut_test".to_string());
let missing = missing_build_required_env(&dir, &opts, &local);
assert!(
!missing.iter().any(|k| k == HEROUI_AUTH_TOKEN),
"token should satisfy: {missing:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn runtime_secret_not_build_required_without_heroui() {
let dir = temp_dir("no-heroui");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1"}}"#,
)
.unwrap();
assert!(!is_build_required_secret_name(&dir, "ATHENA_GATEWAY_KEY"));
assert!(!is_build_required_secret_name(&dir, "GOOGLE_CLIENT_SECRET"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detects_opennext_from_wrangler_main() {
let dir = temp_dir("wrangler");
fs::write(
dir.join("wrangler.jsonc"),
r#"{ "main": ".open-next/worker.js" }"#,
)
.unwrap();
assert!(is_opennext_worker(&dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn non_opennext_rejected() {
let dir = temp_dir("plain");
fs::write(dir.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_opennext_worker(&dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn pipeline_contains_heroui_and_wrangler_flags() {
let opts = OpenNextDeployOptions::from_worker_deploy(None);
let script = build_bash_pipeline_for_wsl_path(
"/mnt/c/Users/floris/documents/github/speedrun-formations",
&opts,
)
.unwrap();
assert!(script.contains("@heroui-pro/react"));
assert!(script.contains("--keep-vars"));
assert!(script.contains("--autoconfig=false"));
assert!(
script.contains("== stage:build"),
"pipeline must emit stage markers"
);
assert!(
script.contains("stage:entry_assert"),
"pipeline must assert OpenNext entry before wrangler"
);
assert!(
script.contains("exit 42"),
"missing entry should exit with dedicated code"
);
let entry_idx = script.find("stage:entry_assert").expect("entry stage");
let wrangler_idx = script.find("stage:wrangler").expect("wrangler stage");
assert!(
entry_idx < wrangler_idx,
"entry_assert must run before wrangler"
);
}
#[test]
fn last_stage_from_log_prefers_stage_markers() {
let lines = vec![
"== stage:install".into(),
"…".into(),
"== stage:build".into(),
"error: boom".into(),
];
assert_eq!(last_stage_from_log(&lines).as_deref(), Some("build"));
}
#[test]
fn assert_opennext_entry_fails_when_missing() {
let dir = temp_dir("no-entry");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1"}}"#,
)
.unwrap();
let err = assert_opennext_entry(&dir).unwrap_err();
assert!(err.contains("cf_missing_entry_point") || err.contains("entry not found"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn assert_opennext_entry_ok_when_present_without_assets_config() {
let dir = temp_dir("has-entry");
fs::create_dir_all(dir.join(".open-next")).unwrap();
fs::write(dir.join(".open-next").join("worker.js"), "export default {}").unwrap();
assert!(assert_opennext_entry(&dir).is_ok());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn partial_opennext_worker_without_assets_not_ready() {
let dir = temp_dir("partial-assets");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1"}}"#,
)
.unwrap();
fs::write(
dir.join("wrangler.jsonc"),
r#"{
"main": ".open-next/worker.js",
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" }
}"#,
)
.unwrap();
fs::create_dir_all(dir.join(".open-next")).unwrap();
fs::write(dir.join(".open-next").join("worker.js"), "export default {}").unwrap();
assert!(!opennext_build_ready_for_wrangler(&dir));
let gaps = opennext_build_gaps(&dir);
assert!(gaps.missing_assets);
assert!(!gaps.missing_entry);
let err = assert_opennext_entry(&dir).unwrap_err();
assert!(
err.contains("cf_missing_assets_directory") || err.contains("assets"),
"{err}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn complete_opennext_entry_and_assets_ready() {
let dir = temp_dir("complete-opennext");
fs::write(
dir.join("package.json"),
r#"{"dependencies":{"@opennextjs/cloudflare":"^1"}}"#,
)
.unwrap();
fs::write(
dir.join("wrangler.jsonc"),
r#"{
"main": ".open-next/worker.js",
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" }
}"#,
)
.unwrap();
fs::create_dir_all(dir.join(".open-next").join("assets")).unwrap();
fs::write(dir.join(".open-next").join("worker.js"), "export default {}").unwrap();
assert!(opennext_build_ready_for_wrangler(&dir));
assert!(assert_opennext_entry(&dir).is_ok());
assert_eq!(
opennext_assets_directory_rel(&dir),
".open-next/assets"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn parses_assets_directory_from_toml() {
let dir = temp_dir("toml-assets");
fs::write(
dir.join("wrangler.toml"),
r#"
main = ".open-next/worker.js"
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
"#,
)
.unwrap();
assert_eq!(
opennext_assets_directory_rel(&dir),
".open-next/assets"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn pipeline_asserts_assets_directory_when_configured() {
let opts = OpenNextDeployOptions::from_worker_deploy(None);
let script = build_bash_pipeline_for_wsl_path("/tmp/app", &opts).unwrap();
assert!(
script.contains("assets directory") || script.contains("ASSETS_DIR"),
"pipeline must check assets.directory before wrangler"
);
}
#[test]
fn format_failure_includes_tail_and_stage() {
let lines: Vec<String> = (0..50).map(|i| format!("line-{i}")).collect();
let msg = format_opennext_failure(
"build",
"opennext_wsl_failed: boom",
&lines,
Some(&PathBuf::from("/tmp/opennext-deploy.log")),
);
assert!(msg.contains("stage=build"));
assert!(msg.contains("line-49"));
assert!(!msg.contains("line-0"), "should only keep last 40 lines");
assert!(msg.contains("log="));
}
#[test]
fn leftover_pipeline_contains_flags_continued() {
let opts = OpenNextDeployOptions::from_worker_deploy(None);
let script = build_bash_pipeline_for_wsl_path("/tmp/app", &opts).unwrap();
assert!(script.contains("wrangler deploy") || script.contains("stage:wrangler"));
assert!(script.contains("opennext") || script.contains("cf:build"));
}
#[test]
fn shell_quote_escapes() {
assert_eq!(shell_single_quote("a'b"), r#"'a'"'"'b'"#);
}
}