Implement auto-download feature for syncback command

- Enhanced the `syncback` command to automatically download Roblox place files from the asset delivery API, allowing for zero-argument usage.
- Updated `Cargo.toml` to include compression features for `reqwest` and moved `tempfile` to dependencies.
- Introduced a new `download` argument in `SyncbackCommand` to specify place IDs for downloading.
- Implemented logic to resolve input file paths, prioritizing downloads when necessary.
- Added sourcemap generation after successful syncback and refreshed the git index if in a repository.
- Updated tests to validate the new download functionality and sourcemap creation.
This commit is contained in:
Joe 2026-02-01 09:46:34 -06:00
parent 8fe908740f
commit 2b2ada219e
32 changed files with 1180 additions and 125 deletions

View file

@ -115,7 +115,7 @@ impl SourcemapCommand {
}
}
fn filter_nothing(_instance: &InstanceWithMeta) -> bool {
pub(crate) fn filter_nothing(_instance: &InstanceWithMeta) -> bool {
true
}
@ -229,7 +229,7 @@ fn recurse_create_node<'a>(
})
}
fn write_sourcemap(
pub(crate) fn write_sourcemap(
session: &ServeSession,
output: Option<&Path>,
filter: fn(&InstanceWithMeta) -> bool,

View file

@ -2,23 +2,31 @@ use std::{
io::{self, BufReader, Write as _},
mem::forget,
path::{Path, PathBuf},
process::{Command, Stdio},
time::Instant,
};
use anyhow::Context;
use anyhow::{bail, Context};
use clap::Parser;
use fs_err::File;
use memofs::Vfs;
use rbx_dom_weak::{InstanceBuilder, WeakDom};
use reqwest::header::{CACHE_CONTROL, COOKIE, PRAGMA, USER_AGENT};
use tempfile::NamedTempFile;
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
use crate::{
path_serializer::display_absolute,
project::Project,
serve_session::ServeSession,
syncback::{syncback_loop, FsSnapshot},
};
use super::{resolve_path, GlobalOptions};
use super::{
resolve_path,
sourcemap::{filter_nothing, write_sourcemap},
GlobalOptions,
};
const UNKNOWN_INPUT_KIND_ERR: &str = "Could not detect what kind of file was inputted. \
Expected input file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx.";
@ -34,16 +42,21 @@ const UNKNOWN_INPUT_KIND_ERR: &str = "Could not detect what kind of file was inp
#[derive(Debug, Parser)]
pub struct SyncbackCommand {
/// Path to the project to sync back to.
#[clap(default_value = "")]
#[clap(default_value = "default.project.json5")]
pub project: PathBuf,
/// Path to the Roblox file to pull Instances from.
#[clap(long, short)]
#[clap(long, short = 'f', default_value = "Project.rbxl")]
pub input: PathBuf,
/// Download the place file from Roblox with the specified place ID,
/// ignoring any existing input file.
#[clap(long, short = 'd')]
pub download: Option<u64>,
/// If provided, a list all of the files and directories that will be
/// added or removed is emitted into stdout.
#[clap(long, short)]
#[clap(long, short = 'l')]
pub list: bool,
/// If provided, syncback will not actually write anything to the file
@ -51,9 +64,10 @@ pub struct SyncbackCommand {
#[clap(long)]
pub dry_run: bool,
/// If provided, the prompt for writing to the file system is skipped.
#[clap(long, short = 'y')]
pub non_interactive: bool,
/// If provided, prompts before writing to the file system.
/// By default, syncback runs non-interactively.
#[clap(long, short = 'i')]
pub interactive: bool,
/// If provided, syncback will preserve existing file structure and middleware
/// formats when possible. Without this flag (default), syncback creates a fresh
@ -65,7 +79,53 @@ pub struct SyncbackCommand {
impl SyncbackCommand {
pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> {
let path_old = resolve_path(&self.project);
let path_new = resolve_path(&self.input);
// Determine if we need to download the input file
let resolved_input = resolve_path(&self.input);
let _temp_file: Option<NamedTempFile>;
// Logic:
// - If --download=PLACEID: always download that specific place
// - If input file exists: use it
// - If input file doesn't exist: auto-download using servePlaceIds
let path_new = match &self.download {
Some(place_id) => {
// --download=PLACEID: always download this specific place
eprintln!("Downloading place {}...", place_id);
let download_timer = Instant::now();
let temp = download_place(*place_id)?;
eprintln!(
"Downloaded in {:.02}s",
download_timer.elapsed().as_secs_f32()
);
let temp_path = temp.path().to_path_buf();
_temp_file = Some(temp);
temp_path
}
None if resolved_input.exists() => {
// No --download flag, input file exists: use it
_temp_file = None;
resolved_input.into_owned()
}
None => {
// No --download flag, input file doesn't exist: auto-download
let place_id = get_place_id_from_project(&path_old)?;
eprintln!(
"Input file '{}' not found, downloading place {}...",
resolved_input.display(),
place_id
);
let download_timer = Instant::now();
let temp = download_place(place_id)?;
eprintln!(
"Downloaded in {:.02}s",
download_timer.elapsed().as_secs_f32()
);
let temp_path = temp.path().to_path_buf();
_temp_file = Some(temp);
temp_path
}
};
let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?;
let dom_start_timer = Instant::now();
@ -127,8 +187,12 @@ impl SyncbackCommand {
list_files(&snapshot, global.color.into(), base_path)?;
}
// Drop dom_old early to release the mutex - we don't need it anymore
// and write_sourcemap needs to acquire the same lock
drop(dom_old);
if !self.dry_run {
if !self.non_interactive {
if self.interactive {
eprintln!(
"Would write {} files/folders and remove {} files/folders.",
snapshot.added_paths().len(),
@ -146,7 +210,14 @@ impl SyncbackCommand {
}
eprintln!("Writing to the file system...");
snapshot.write_to_vfs(base_path, session_old.vfs())?;
eprintln!("Finished syncback.")
eprintln!("Finished syncback.");
// Generate sourcemap after successful syncback
let sourcemap_path = base_path.join("sourcemap.json");
write_sourcemap(&session_old, Some(&sourcemap_path), filter_nothing, false)?;
// Refresh git index if in a git repository
refresh_git_index(base_path);
} else {
eprintln!(
"Would write {} files/folders and remove {} files/folders.",
@ -159,13 +230,124 @@ impl SyncbackCommand {
// It is potentially prohibitively expensive to drop a ServeSession,
// and the program is about to exit anyway so we're just going to forget
// about it.
drop(dom_old);
forget(session_old);
// Temp file is automatically cleaned up when _temp_file is dropped
Ok(())
}
}
/// Gets the first place ID from the project's servePlaceIds field.
fn get_place_id_from_project(project_path: &Path) -> anyhow::Result<u64> {
// Use oneshot Vfs to avoid file watching issues
let temp_vfs = Vfs::new_oneshot();
let project =
Project::load_fuzzy(&temp_vfs, project_path)?.context("Could not find project file")?;
let serve_place_ids = project.serve_place_ids.as_ref().context(
"No servePlaceIds in project file. Add servePlaceIds to your project or use --download=PLACEID",
)?;
// Get the smallest ID for deterministic behavior
serve_place_ids
.iter()
.min()
.copied()
.context("servePlaceIds is empty in project file")
}
/// Downloads a place file from Roblox's asset delivery API.
///
/// Uses rbx_cookie to get the authentication cookie from the system.
fn download_place(place_id: u64) -> anyhow::Result<NamedTempFile> {
let cookie = rbx_cookie::get_value()
.context("Could not find Roblox authentication cookie. Please log into Roblox Studio.")?;
let url = format!("https://assetdelivery.roblox.com/v1/asset/?id={}", place_id);
let client = reqwest::blocking::Client::builder()
.gzip(true)
.brotli(true)
.deflate(true)
.build()?;
let response = client
.get(&url)
.header(COOKIE, format!(".ROBLOSECURITY={}", cookie))
.header(CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.header(PRAGMA, "no-cache")
.header("Expires", "0")
.header(USER_AGENT, "Rojo")
.send()?;
let status = response.status();
if !status.is_success() {
bail!(
"Failed to download place {}: HTTP {} - {}",
place_id,
status,
response.text().unwrap_or_default()
);
}
// Create temp file with .rbxl extension
let mut temp_file = tempfile::Builder::new()
.prefix("rojo-syncback-")
.suffix(".rbxl")
.tempfile()
.context("Failed to create temporary file")?;
// Write response body to temp file
let bytes = response.bytes()?;
io::copy(&mut bytes.as_ref(), &mut temp_file)?;
temp_file.flush()?;
log::debug!(
"Downloaded {} bytes to {}",
bytes.len(),
temp_file.path().display()
);
Ok(temp_file)
}
/// Refreshes the git index if the project is in a git repository.
///
/// This is useful because syncback may rewrite files with identical content,
/// which can cause git to report them as modified due to timestamp changes.
fn refresh_git_index(project_dir: &Path) {
// Check if .git exists in project dir or any parent
let mut check_dir = Some(project_dir);
let mut is_git_repo = false;
while let Some(dir) = check_dir {
if dir.join(".git").exists() {
is_git_repo = true;
break;
}
check_dir = dir.parent();
}
if is_git_repo {
log::debug!("Refreshing git index...");
let result = Command::new("git")
.args(["update-index", "--refresh"])
.current_dir(project_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
match result {
Ok(status) => {
if !status.success() {
log::debug!("git update-index --refresh exited with: {}", status);
}
}
Err(e) => {
log::warn!("Failed to run git update-index --refresh: {}", e);
}
}
}
}
fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result<WeakDom> {
let content = BufReader::new(File::open(path)?);
match file_kind {