mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 22:11:38 +00:00
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:
parent
8fe908740f
commit
2b2ada219e
32 changed files with 1180 additions and 125 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue