Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions src/builds.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::auth::AuthManager;
use crate::config::{self, WavedashConfig};
use crate::config::{self, EngineKind, WavedashConfig};
use crate::dev::entrypoint::{fetch_entrypoint_params, locate_html_entrypoint};
use crate::file_staging::FileStaging;
use anyhow::Result;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -157,14 +158,47 @@ pub async fn handle_build_push(config_path: PathBuf, verbose: bool, message: Opt
anyhow::bail!("No files found in {}", upload_dir.display());
}

// Get temporary R2 credentials (includes build size)
let engine_kind = wavedash_config.engine_type()?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upload_dir is still not canonicalized in handle_build_push — only resolve_defold_entrypoint's internal canonicalize-containment check runs, while dev/mod.rs:101 canonicalizes once and the rest of the flow operates on a clean absolute path.

This was raised in a prior review and is moot for path-escape safetyresolve_defold_entrypoint re-canonicalizes both sides before its starts_with check (entrypoint.rs:92-104), so escapes are rejected. But the broader symmetry concern still applies:

  1. The returned html_path at entrypoint.rs:106 is the un-canonicalized upload_dir.join(relative_path). It's read via fs::read_to_string (fine), but it flows into fetch_entrypoint_params and any error messages downstream — the two flows disagree on the textual path for the same project.
  2. Error messages and diagnostic output differ between wavedash dev and wavedash build push for the same misconfigurationdev reports a /private/var/... absolute path; build push reports ./build/default/.... That asymmetry makes user-reported failures harder to triage.
  3. R2Uploader at line 215 walks the non-canonical path; pre-PR behavior, but worth confirming the new Defold flow doesn't introduce sensitivity to symlinks under upload_dir.

Restoring the canonicalize block (the same one added in a291f10 and removed in 1aa904f) is the simplest fix and keeps the two flows symmetric:

let upload_dir = upload_dir
    .canonicalize()
    .with_context(|| format!("Failed to canonicalize upload_dir: {}", upload_dir.display()))?;

(requires restoring use anyhow::Context;.)

let engine_version = wavedash_config.engine_version();
let entrypoint_params = match engine_kind {
Some(EngineKind::Defold) => {
let html_entrypoint = locate_html_entrypoint(&upload_dir);
let html_path = html_entrypoint.as_deref().ok_or_else(|| {
anyhow::anyhow!("No HTML file found in upload_dir; required for DEFOLD builds")
})?;
let ver = engine_version
.ok_or_else(|| anyhow::anyhow!("DEFOLD engine requires a version"))?;
let html_relative_path = html_path
.strip_prefix(&upload_dir)
.unwrap_or(html_path)
.to_string_lossy()
.replace('\\', "/");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upload_dir is not canonicalized here, unlike dev/mod.rs:101. Both call sites compute html_relative_path the same way, but only handle_dev resolves symlinks/.//.. first. The unwrap_or(html_path) fallback means a strip-prefix failure silently sends the absolute filesystem path to the server as htmlPath — leaking the local layout and almost certainly producing a 4xx.

Easiest fix: canonicalize upload_dir here too (mirror the dev/mod.rs block), or replace unwrap_or with an explicit error so a mismatch becomes a clear bug rather than bad data.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upload_dir is not canonicalized here (unlike dev/mod.rs:101), and the .unwrap_or(html_path) fallback silently leaks an absolute filesystem path on strip_prefix failure.

handle_dev resolves upload_dir via .canonicalize()? before walking it (src/dev/mod.rs:101). handle_build_push skips that step, so this call site walks a potentially-relative-or-symlinked path. If strip_prefix(&upload_dir) ever fails (mismatch in normalization between the path returned by WalkDir and upload_dir), unwrap_or(html_path) falls back to the full path — that absolute filesystem path gets sent to the server as htmlPath, leaking the local layout and almost certainly producing a 4xx.

Either canonicalize upload_dir first (mirror dev/mod.rs:101), or replace the fallback with an explicit error so a path mismatch surfaces as a clear bug:

let upload_dir = upload_dir.canonicalize().with_context(|| {
    format!("Failed to canonicalize upload_dir: {}", upload_dir.display())
})?;
// ...
let html_relative_path = html_path
    .strip_prefix(&upload_dir)
    .with_context(|| format!("HTML entrypoint {} is not under upload_dir {}", html_path.display(), upload_dir.display()))?
    .to_string_lossy()
    .replace('\\', "/");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upload_dir is not canonicalized here (unlike dev/mod.rs:101), and .unwrap_or(html_path) silently leaks the absolute filesystem path on strip_prefix failure.

handle_dev resolves upload_dir via .canonicalize()? before walking it (src/dev/mod.rs:101). handle_build_push skips that step, so this call site walks a potentially-relative-or-symlinked path. WalkDir returns absolute paths in some cases (e.g. when given an absolute root) and relative in others — if strip_prefix(&upload_dir) mismatches normalization, unwrap_or(html_path) falls back to the full path, sending an absolute filesystem path to the server as htmlPath. That leaks the local layout ($HOME/projects/<game>/...) and almost certainly produces a 4xx on the server.

Mirror the dev/mod.rs canonicalize step, or replace the fallback with an explicit error:

let upload_dir = upload_dir.canonicalize().with_context(|| {
    format!("Failed to canonicalize upload_dir: {}", upload_dir.display())
})?;
// ...
let html_relative_path = html_path
    .strip_prefix(&upload_dir)
    .with_context(|| format!("HTML entrypoint {} is not under upload_dir {}", html_path.display(), upload_dir.display()))?
    .to_string_lossy()
    .replace('\\', "/");

Fix builds.rs canonicalize →

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression in 1aa904f ("remove out of scope changes"): the canonicalize safety net for this strip_prefix was deleted.

Commit a291f10 ("path safety") added a canonicalize() block just above the file-staging step with the rationale "so a strip_prefix miss can't leak an absolute path as htmlPath (mirrors wavedash dev)." Commit 1aa904f removed that block, calling it out of scope — but the strip_prefix(&upload_dir).unwrap_or(html_path) right here was introduced by this same PR to produce htmlPath for Defold. So the canonicalize wasn't out of scope; it was the guard for new code this PR added.

What goes wrong now:

  • upload_dir = config_dir.join(&wavedash_config.upload_dir) is unnormalized. With a symlinked path (macOS /var/private/var, /tmp symlinks), .. segments, or platform-specific normalization, WalkDir can emit entries whose textual prefix doesn't match upload_dir exactly.
  • strip_prefix(&upload_dir) returns Err; .unwrap_or(html_path) falls through to the full absolute path.
  • The server receives htmlPath: "/home/<dev>/projects/<game>/dist/MyGame/wasm-web/MyGame.html" (leaks the dev's layout) instead of MyGame/wasm-web/MyGame.html, and almost certainly produces a 4xx because nothing under R2 is keyed that way.
  • wavedash dev is unaffected (it still canonicalizes at dev/mod.rs:101), so the two flows silently disagree on htmlPath for the same project.

Two fixes; either is fine:

Option A — restore the canonicalize block above FileStaging::prepare (mirrors dev):

let upload_dir = upload_dir
    .canonicalize()
    .with_context(|| format!("Failed to canonicalize upload_dir: {}", upload_dir.display()))?;

(also restore use anyhow::{Context, Result}; at the top of the file.)

Option B — replace the unwrap_or fallback here with an explicit error so a mismatch surfaces loudly instead of leaking a path:

Suggested change
let html_relative_path = html_path
.strip_prefix(&upload_dir)
.unwrap_or(html_path)
.to_string_lossy()
.replace('\\', "/");
let html_relative_path = html_path
.strip_prefix(&upload_dir)
.with_context(|| format!("HTML entrypoint {} is not under upload_dir {}", html_path.display(), upload_dir.display()))?
.to_string_lossy()
.replace('\\', "/");

Restore canonicalize in builds.rs →

Some(
fetch_entrypoint_params(
EngineKind::Defold.as_label(),
ver,
html_path,
Some(&html_relative_path),
)
.await?,
)
Comment thread
cloud9c marked this conversation as resolved.
}
Comment on lines +178 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileStaging::prepare at line 153 doesn't validate the Defold entrypoint because wavedash_config.entrypoint() returns None for any engine (see config.rs:292-301: the method only returns the entrypoint when no engine is configured). For Custom builds, FileStaging::prepare enforces .html/.htm/.js extension and file existence. For Defold, both checks are skipped, and validation happens later inside resolve_defold_entrypoint (line 60), which only checks is_file() — extension is never validated.

Result: a user who sets entrypoint = "wasm-web/<game>/something.png" (typo or wrong asset) sails through FileStaging::prepare, gets to resolve_defold_entrypoint which passes is_file(), then fetch_entrypoint_params reads a binary file and POSTs it as htmlContent. The server most likely returns a parse error; the user sees an opaque API failure instead of "Entrypoint must be an HTML file".

Two ways to close this:

  1. Move the Defold validation into FileStaging::prepare so it mirrors the Custom path (check extension + existence early, with a clean error message).
  2. Or have resolve_defold_entrypoint itself validate the extension before reading the file.

Either way, the gap is in FileStaging::preparewavedash_config.entrypoint() (the method) silently filters Defold out of validation, while wavedash_config.entrypoint (the field) is what actually drives Defold behavior at lines 166 and dev/mod.rs:135. Two access patterns for the same field that can drift.

Some(EngineKind::JsDos | EngineKind::Ruffle | EngineKind::RenPy) => {
wavedash_config.executable_entrypoint_params()
}
// Explicit (not `_`) so a new EngineKind forces a decision. Godot/Unity
// params are computed server-side.
Some(EngineKind::Godot | EngineKind::Unity) => None,
None => None,
};

// Get temporary R2 credentials (includes build size)
let creds = get_temp_credentials(
&wavedash_config.game_id,
engine_kind.map(|e| e.as_label()),
wavedash_config.engine_version(),
engine_version,
wavedash_config.entrypoint(),
wavedash_config.executable_entrypoint_params(),
entrypoint_params,
message.as_deref(),
total_bytes,
&api_key,
Expand Down
16 changes: 15 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ pub struct UnitySection {
pub version: String,
}

#[derive(Debug, Deserialize)]
pub struct DefoldSection {
pub version: String,
}

/// Shape for engines whose runtime is fetched as a single executable file
/// (plus an optional loader script). Used by JSDOS, Ruffle, and Ren'Py.
#[derive(Debug, Deserialize)]
Expand All @@ -168,6 +173,9 @@ pub struct WavedashConfig {
#[serde(rename = "unity")]
pub unity: Option<UnitySection>,

#[serde(rename = "defold")]
pub defold: Option<DefoldSection>,

#[serde(rename = "jsdos")]
pub jsdos: Option<ExecutableEngineSection>,

Expand All @@ -182,6 +190,7 @@ pub struct WavedashConfig {
pub enum EngineKind {
Godot,
Unity,
Defold,
JsDos,
Ruffle,
RenPy,
Expand All @@ -192,6 +201,7 @@ impl EngineKind {
match self {
EngineKind::Godot => "GODOT",
EngineKind::Unity => "UNITY",
EngineKind::Defold => "DEFOLD",
EngineKind::JsDos => "JSDOS",
EngineKind::Ruffle => "RUFFLE",
EngineKind::RenPy => "RENPY",
Expand Down Expand Up @@ -236,6 +246,7 @@ impl WavedashConfig {
let engines: Vec<EngineKind> = [
self.godot.is_some().then_some(EngineKind::Godot),
self.unity.is_some().then_some(EngineKind::Unity),
self.defold.is_some().then_some(EngineKind::Defold),
self.jsdos.is_some().then_some(EngineKind::JsDos),
self.ruffle.is_some().then_some(EngineKind::Ruffle),
self.renpy.is_some().then_some(EngineKind::RenPy),
Expand All @@ -248,7 +259,7 @@ impl WavedashConfig {
0 => Ok(None),
1 => Ok(Some(engines[0])),
_ => anyhow::bail!(
"Config must have at most one engine section: [godot], [unity], [jsdos], [ruffle], or [renpy]"
"Config must have at most one engine section: [godot], [unity], [defold], [jsdos], [ruffle], or [renpy]"
),
}
}
Expand All @@ -270,6 +281,9 @@ impl WavedashConfig {
if let Some(unity) = &self.unity {
return Some(&unity.version);
}
if let Some(defold) = &self.defold {
return Some(&defold.version);
}
self.executable_section().map(|s| s.version.as_str())
}

Expand Down
54 changes: 51 additions & 3 deletions src/dev/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ struct EntrypointParamsResponse {
entrypoint_params: Value,
}

// A root `index.html` short-circuits; the ranking below only matters for nested
// multi-HTML Defold dists.
pub fn locate_html_entrypoint(upload_dir: &Path) -> Option<PathBuf> {
let default_index = upload_dir.join("index.html");
if default_index.is_file() {
return Some(default_index);
}
Comment on lines 22 to 26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root index.html short-circuit still pre-empts wasm-web/index.html for Defold.

The comment at lines 17–18 documents the intent: this short-circuit is for "single-HTML" exports (Godot/Unity) and the ranking below is "for nested multi-HTML Defold dists." But the short-circuit doesn't check engine kind — it runs for everyone. So in a Defold project:

  • Any stale index.html at upload_dir/ (leftover from a previous engine, a redirect placed at the root, a hand-written launcher, a build-tool artifact, even an OS-Indexer-generated file) wins immediately.
  • The properly nested MyGame/wasm-web/index.html is never considered.
  • fetch_entrypoint_params then parses the wrong HTML; the server returns params for a non-existent build, and the playtest URL renders blank / 404s on missing chunks.

For Defold migrations specifically this is a real footgun — a dist/index.html from a previous threejs/phaser setup silently steals the entrypoint. Gating on engine kind closes it cleanly:

pub fn locate_html_entrypoint(upload_dir: &Path, engine: Option<EngineKind>) -> Option<PathBuf> {
    if !matches!(engine, Some(EngineKind::Defold)) {
        let default_index = upload_dir.join("index.html");
        if default_index.is_file() {
            return Some(default_index);
        }
    }
    // ... walk + sort for Defold (and as a fallback)
}

Callers in dev/mod.rs:111 and builds.rs:165 would pass engine_kind through.

Fix root index.html short-circuit for Defold →


let mut html_files: Vec<PathBuf> = Vec::new();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unchanged early-return on lines 17–21 short-circuits the new architecture-aware selection. If upload_dir/index.html exists for any reason — a stale leftover from a previous engine, a redirect a build tool placed at the root, a user-written launcher — it wins immediately, and wasm-web/index.html is never considered.

For a Defold project migrating onto wavedash, this is a real footgun: any pre-existing root index.html silently pre-empts the proper Defold entrypoint, and fetch_entrypoint_params parses the wrong HTML. Consider gating the early-return on engine kind, or only using it when no nested wasm-web/ / js-web/ directory is present.

for entry in WalkDir::new(upload_dir)
.min_depth(1)
.into_iter()
Expand All @@ -28,16 +31,60 @@ pub fn locate_html_entrypoint(upload_dir: &Path) -> Option<PathBuf> {
if entry.file_type().is_file() {
if let Some(ext) = entry.path().extension() {
if ext.eq_ignore_ascii_case("html") {
return Some(entry.into_path());
html_files.push(entry.into_path());
}
}
}
}

None
// Match the arch folder as any path segment (Defold nests it under a game dir).
let architecture_score = |relative: &str| {
let mut segments = relative.split('/');
if segments.clone().any(|s| s == "wasm-web") {
0
} else if segments.any(|s| s == "js-web") {
1
} else {
2
}
};

// Compute each candidate's sort key once (stat + strip_prefix), so sorting
// doesn't re-stat on every comparison.
let mut ranked: Vec<(std::time::SystemTime, i32, String, PathBuf)> = html_files
.into_iter()
.map(|path| {
let modified = path
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
let relative = path
.strip_prefix(upload_dir)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let arch = architecture_score(&relative);
(modified, arch, relative, path)
})
.collect();

// Newest export wins; wasm-web beats js-web only on ties, then path. Unreadable
// mtime falls back to UNIX_EPOCH (oldest) so it can't sort as "newest".
ranked.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| a.1.cmp(&b.1))
.then_with(|| a.2.cmp(&b.2))
});
Comment thread
cloud9c marked this conversation as resolved.
Outdated

ranked.into_iter().next().map(|(_, _, _, path)| path)
}
Comment on lines +49 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_defold_entrypoint joins a user-controlled string straight into upload_dir with no normalization or upload-dir containment check — the entrypoint from wavedash.toml can escape the build directory via .. segments or be an absolute path.

Two concrete misconfigurations:

  1. entrypoint = "../../something/index.html" in wavedash.tomlupload_dir.join("../../something/index.html") resolves above upload_dir. If that file happens to exist, is_file() passes; fs::read_to_string(&html_path) reads its content; and html_relative_path = "../../something/index.html" is sent verbatim to the server as htmlPath. The build then references assets via that escape path on the playsite — almost certainly serving a broken bundle.

  2. entrypoint = "/Users/dev/somethingelse/index.html" → Rust's Path::join with an absolute argument replaces the receiver, so upload_dir.join("/Users/dev/…") is just /Users/dev/…. is_file() and read_to_string both succeed on the absolute path; html_relative_path = "/Users/dev/…" leaks the dev's filesystem layout to the server as htmlPath.

The user "owns their own machine" so this isn't a security exploit, but it surfaces typos and copy-paste mistakes as silent wrong-file uploads instead of clear errors. A cheap guard: canonicalize both and check containment:

let html_path = upload_dir.join(&relative_path);
if !html_path.is_file() {
    anyhow::bail!(/* same as today */);
}
// Reject anything that escapes upload_dir.
let canon_html = html_path
    .canonicalize()
    .with_context(|| format!("Failed to canonicalize {}", html_path.display()))?;
let canon_upload = upload_dir
    .canonicalize()
    .with_context(|| format!("Failed to canonicalize {}", upload_dir.display()))?;
if !canon_html.starts_with(&canon_upload) {
    anyhow::bail!(
        "Defold entrypoint `{}` resolves outside upload_dir ({}). The path must be relative to upload_dir and stay inside it.",
        entrypoint,
        upload_dir.display()
    );
}

Bonus: also reject paths whose lowercase extension isn't .html / .htmFileStaging::prepare does this for Custom builds but skips Defold entirely because wavedash_config.entrypoint() returns None for engines.


pub async fn fetch_entrypoint_params(engine: &str, engine_version: &str, html_path: &Path) -> Result<Value> {
pub async fn fetch_entrypoint_params(
engine: &str,
engine_version: &str,
html_path: &Path,
html_relative_path: Option<&str>,
) -> Result<Value> {
let html_content = fs::read_to_string(html_path)
.with_context(|| format!("Failed to read {}", html_path.display()))?;
let api_host = config::get("api_host")?;
Expand All @@ -53,6 +100,7 @@ pub async fn fetch_entrypoint_params(engine: &str, engine_version: &str, html_pa
"engine": engine,
"engineVersion": engine_version,
"htmlContent": html_content,
"htmlPath": html_relative_path,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

htmlPath is serialized unconditionallyOption<&str> with None produces "htmlPath": null in the JSON body, not an omitted field. Both halves of the contract change for Godot/Unity (carrying the field at all, and emitting it as null rather than skipping) are encoded here — so even after dev/mod.rs is updated to pass None for Godot/Unity, the request body would still carry "htmlPath": null.

To make htmlPath truly Defold-only, build the JSON body conditionally before posting:

let mut body = serde_json::json!({
    "engine": engine,
    "engineVersion": engine_version,
    "htmlContent": html_content,
});
if let Some(html_path) = html_relative_path {
    body["htmlPath"] = serde_json::json!(html_path);
}

let response = client.post(&endpoint).json(&body).send().await
    .with_context(|| "Failed to call CLI entrypoint params endpoint")?;

Combined with the dev/mod.rs change to pass None for Godot/Unity, Godot/Unity requests go back to the pre-PR three-field shape, and only Defold sends htmlPath.

}))
.send()
.await
Expand Down
14 changes: 11 additions & 3 deletions src/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::config::{self, EngineKind, WavedashConfig};
use crate::file_staging::FileStaging;

mod dev_app;
mod entrypoint;
pub(crate) mod entrypoint;
mod launcher;

use dev_app::{ensure_dev_app, user_data_dir};
Expand Down Expand Up @@ -111,7 +111,7 @@ pub async fn handle_dev(config_path: Option<PathBuf>, verbose: bool) -> Result<(
let html_entrypoint = locate_html_entrypoint(&upload_dir);
let engine_version = wavedash_config.engine_version();
let entrypoint_params = match engine_kind {
Some(EngineKind::Godot | EngineKind::Unity) => {
Some(EngineKind::Godot | EngineKind::Unity | EngineKind::Defold) => {
let engine_label = engine_kind.unwrap().as_label();
let html_path = html_entrypoint.as_deref().ok_or_else(|| {
anyhow::anyhow!(
Expand All @@ -121,7 +121,15 @@ pub async fn handle_dev(config_path: Option<PathBuf>, verbose: bool) -> Result<(
})?;
let ver = engine_version
.ok_or_else(|| anyhow::anyhow!("{} engine requires a version", engine_label))?;
Some(fetch_entrypoint_params(engine_label, ver, html_path).await?)
let html_relative_path = html_path
.strip_prefix(&upload_dir)
.unwrap_or(html_path)
.to_string_lossy()
.replace('\\', "/");
Some(
fetch_entrypoint_params(engine_label, ver, html_path, Some(&html_relative_path))
.await?,
)
}
Comment on lines +125 to +136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

htmlPath is now sent for Godot/Unity uploads — explicitly flagged by @calvinstudebaker on the prior pass: "let's not change the shape of Unity or Godot uploads. Keep the new Defold logic isolated to the Defold case."

The Godot/Unity branch (lines 113–132) computes html_relative_path and passes Some(&html_relative_path) into fetch_entrypoint_params. Combined with fetch_entrypoint_params always serializing "htmlPath": … unconditionally, the JSON request body for established Godot/Unity callers grew a new htmlPath field that wasn't there pre-PR — exactly the contract change the maintainer asked to avoid.

Suggested isolation: keep locate_html_entrypoint use for Godot/Unity, but pass None for html_relative_path (and have fetch_entrypoint_params skip the field when None — see separate comment at entrypoint.rs:91). Concretely:

Some(kind @ (EngineKind::Godot | EngineKind::Unity)) => {
    let engine_label = kind.as_label();
    let html_path = locate_html_entrypoint(&upload_dir).ok_or_else(|| {
        anyhow::anyhow!(
            "No HTML file found in upload_dir; required for {} builds",
            engine_label
        )
    })?;
    let ver = engine_version
        .ok_or_else(|| anyhow::anyhow!("{} engine requires a version", engine_label))?;
    Some(fetch_entrypoint_params(engine_label, ver, &html_path, None).await?)
}

This also makes the strip_prefix(&upload_dir).unwrap_or(&html_path) block at lines 123–127 unnecessary (and removes its silent-leak fallback in the case strip_prefix ever fails).

Fix Godot/Unity request-shape regression →

Some(EngineKind::JsDos | EngineKind::Ruffle | EngineKind::RenPy) => {
wavedash_config.executable_entrypoint_params()
Expand Down
31 changes: 30 additions & 1 deletion src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct GamesResponse {
enum EngineType {
Godot,
Unity,
Defold,
Custom,
}

Expand All @@ -47,6 +48,8 @@ impl EngineType {
match self {
EngineType::Godot => "build",
EngineType::Unity => "build",
// Defold reserves top-level build/ for its build cache, so default elsewhere.
EngineType::Defold => "dist",
Comment thread
cloud9c marked this conversation as resolved.
Comment thread
cloud9c marked this conversation as resolved.
EngineType::Custom => "dist",
Comment thread
cloud9c marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -130,10 +133,25 @@ fn detect_unity(dir: &Path) -> Option<DetectedEngine> {
None
}

/// Look for a Defold project marker.
fn detect_defold(dir: &Path) -> Option<DetectedEngine> {
if dir.join("game.project").is_file() {
return Some(DetectedEngine {
engine_type: EngineType::Defold,
version_hint: None,
});
}

None
}
Comment thread
cloud9c marked this conversation as resolved.
Comment thread
cloud9c marked this conversation as resolved.
Comment thread
cloud9c marked this conversation as resolved.

fn detect_engine(dir: &Path) -> DetectedEngine {
if let Some(engine) = detect_godot(dir) {
return engine;
}
if let Some(engine) = detect_defold(dir) {
return engine;
}
if let Some(engine) = detect_unity(dir) {
return engine;
}
Expand Down Expand Up @@ -233,6 +251,10 @@ fn generate_toml(
let version = engine_version.unwrap_or("2022.3");
toml.push_str(&format!("\n[unity]\nversion = \"{}\"\n", version));
}
EngineType::Defold => {
let version = engine_version.unwrap_or("1.12.4");
toml.push_str(&format!("\n[defold]\nversion = \"{}\"\n", version));
}
Comment on lines +254 to +263
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wavedash init for Defold writes a comment-only entrypoint hint, not an actual entrypoint = … line. A user who runs init and then immediately tries wavedash dev / build push hits the error from resolve_defold_entrypoint ("Defold builds need an entrypoint in wavedash.toml…") before anything works. There's no signal during init that more editing is required.

The error message is clear once you trigger it, but the UX cliff is:

  1. wavedash init — completes successfully, prints "Created wavedash.toml! Next steps: → Run wavedash dev to test locally".
  2. User runs wavedash dev — errors out because entrypoint is missing.
  3. User opens wavedash.toml, sees a hint in a comment, fills it in.

Two cheaper alternatives, in order of effort:

  • Tell the user during init ("Defold requires an entrypoint — edit wavedash.toml before running wavedash dev") via cliclack::log::info or a prefilled-but-required prompt.
  • Better: actually prompt for the entrypoint during init so the produced wavedash.toml works end-to-end on a clean run. The path is dynamic (wasm-web/<GameTitle>/index.html) but the user knows it; the same prompt that asks for upload_dir can ask for entrypoint for Defold.

Right now wavedash init for Defold is the only engine where the next step the outro promises doesn't work without manual file editing. Worth at least an info line so the user isn't surprised.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wavedash init for Defold still produces a TOML where the next step (wavedash dev) errors out — the new comment acknowledges it but the cliff is unchanged.

The comment at lines 256–258 documents the design ("the dev fills in the export they want to ship; wavedash dev / build push errors clearly until they do"), and the strict "no inference" rule in resolve_defold_entrypoint is sound. But the resulting UX:

  1. wavedash init completes successfully and the outro at line 472-475 promises "Next steps: → Run wavedash dev to test locally"
  2. The user runs wavedash dev
  3. It errors with the long "Defold builds need an entrypoint…" message from entrypoint.rs:53-57
  4. The user opens wavedash.toml, finds a #-prefixed hint, edits the file

This is the only engine where the next step the outro promises doesn't work without manual file editing. Two cheap mitigations, in order of effort:

  • Surface a cliclack::log::info line during init telling the user to edit wavedash.toml before running wavedash dev. Costs ~3 lines, fully removes surprise.
  • Prompt during init: add a cliclack::input("Defold HTML5 export path") with a placeholder like wasm-web/<game>/index.html and emit the line uncommented. Costs ~10 lines, lets wavedash dev work end-to-end after init.

Either is fine — the goal is just to make the cliff visible before the user hits it. Acknowledged in the previous review pass; flagging once more since the latest commit ("edge cases") had room to address it.

Add init prompt for Defold entrypoint →

EngineType::Custom => {
toml.push_str("\nentrypoint = \"index.html\"\n");
}
Expand Down Expand Up @@ -279,7 +301,7 @@ pub async fn handle_init() -> Result<()> {
let current_dir = std::env::current_dir()?;
let detected = detect_engine(&current_dir);

// Only prompt for version when we detect Godot/Unity.
// Only prompt for version when we detect a first-class engine.
// For web builds (threejs, phaser, custom, etc.) no engine config is needed.
let engine_version: Option<String> = match &detected.engine_type {
EngineType::Godot => {
Expand All @@ -302,6 +324,13 @@ pub async fn handle_init() -> Result<()> {
Some(version)
}
}
EngineType::Defold => {
let version: String = cliclack::input("Defold version")
.placeholder("1.12.4")
.default_input("1.12.4")
.interact()?;
Some(version)
}
Comment thread
cloud9c marked this conversation as resolved.
EngineType::Custom => None,
};

Expand Down
Loading