mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-22 21:41:37 +00:00
Enhance project cloning and initialization process
- Updated CloneCommand to support multiple place IDs, allowing the creation of multi-place projects with individual project files for each place. - Refactored the initialization logic in InitCommand to streamline project setup and ensure proper handling of project templates. - Added a new function to fetch place names from the Roblox API, improving the user experience during project creation. - Updated TODO.txt with a new entry for fixing the luau-lsp sourcemap generator command.
This commit is contained in:
parent
cf31e9b914
commit
093e62af8e
4 changed files with 575 additions and 168 deletions
5
TODO.txt
5
TODO.txt
|
|
@ -100,5 +100,6 @@ we will probably have to start dynamically generating the x.project.json5 rather
|
||||||
[syncback]
|
[syncback]
|
||||||
optimize syncback like we did serve.
|
optimize syncback like we did serve.
|
||||||
|
|
||||||
[atlas-project.mdc]
|
[sourcemap / luau-lsp]
|
||||||
we need to remove atlas-project.mdc from the main shared agent rules and migrate it to being manually cloned into newly created projects.....
|
we need to fix "luau-lsp.sourcemap.generatorCommand": "atlas sourcemap default.project.json5 --output sourcemap.json --include-non-scripts --watch",
|
||||||
|
and make luau-lsp actually use the 'atlas' cli...
|
||||||
428
src/cli/clone.rs
428
src/cli/clone.rs
|
|
@ -1,22 +1,30 @@
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use fs_err as fs;
|
||||||
|
|
||||||
use crate::roblox_api;
|
use crate::roblox_api;
|
||||||
|
|
||||||
use super::init::{InitCommand, InitKind};
|
use super::init::{setup_git_and_rules, write_if_not_exists, write_template_files, InitKind};
|
||||||
use super::syncback::SyncbackCommand;
|
use super::syncback::SyncbackCommand;
|
||||||
use super::GlobalOptions;
|
use super::GlobalOptions;
|
||||||
|
|
||||||
/// Initializes a new Rojo project from a Roblox place and syncs it back.
|
/// Initializes a new Rojo project from one or more Roblox places and syncs them back.
|
||||||
///
|
///
|
||||||
/// Fetches the experience name from Roblox to auto-generate the project
|
/// With a single place ID, behaves identically to the original clone flow
|
||||||
/// folder. Equivalent to `rojo init` + `rojo syncback --download`.
|
/// (init + syncback into `default.project.json5` with `$path: "src"`).
|
||||||
|
///
|
||||||
|
/// With multiple place IDs (must belong to the same universe), creates a
|
||||||
|
/// multi-place project where each place gets its own `<name>.project.json5`
|
||||||
|
/// and `<name>/` directory.
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
pub struct CloneCommand {
|
pub struct CloneCommand {
|
||||||
/// The place ID to download and sync back.
|
/// One or more place IDs to download and sync back.
|
||||||
pub placeid: u64,
|
#[clap(required = true, num_args = 1..)]
|
||||||
|
pub placeids: Vec<u64>,
|
||||||
|
|
||||||
/// Path to create the project in. If omitted, a directory is
|
/// Path to create the project in. If omitted, a directory is
|
||||||
/// auto-generated from the experience name.
|
/// auto-generated from the experience name.
|
||||||
|
|
@ -42,12 +50,296 @@ pub struct CloneCommand {
|
||||||
|
|
||||||
impl CloneCommand {
|
impl CloneCommand {
|
||||||
pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> {
|
pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> {
|
||||||
|
if self.placeids.len() == 1 {
|
||||||
|
return self.run_single(global);
|
||||||
|
}
|
||||||
|
self.run_multi(global)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_single(self, global: GlobalOptions) -> anyhow::Result<()> {
|
||||||
|
let place_id = self.placeids[0];
|
||||||
|
|
||||||
let path = match self.path {
|
let path = match self.path {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => resolve_folder_from_experience(place_id, global.opencloud.as_deref())?,
|
||||||
let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref());
|
};
|
||||||
|
|
||||||
|
let skip_git = self.skip_git;
|
||||||
|
|
||||||
|
let init = super::init::InitCommand {
|
||||||
|
path: path.clone(),
|
||||||
|
kind: self.kind,
|
||||||
|
skip_git,
|
||||||
|
placeid: Some(place_id),
|
||||||
|
skip_rules: self.skip_rules,
|
||||||
|
skip_cd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
init.run()?;
|
||||||
|
|
||||||
|
let syncback = SyncbackCommand {
|
||||||
|
project: PathBuf::from("default.project.json5"),
|
||||||
|
input: PathBuf::from("Project.rbxl"),
|
||||||
|
download: Some(place_id),
|
||||||
|
list: false,
|
||||||
|
dry_run: false,
|
||||||
|
interactive: false,
|
||||||
|
incremental: false,
|
||||||
|
sourcemap: false,
|
||||||
|
working_dir: path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
syncback.run(global)?;
|
||||||
|
|
||||||
|
if !skip_git {
|
||||||
|
crate::git::git_add_all_and_commit(&path, "syncback");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.skip_cd {
|
||||||
|
std::env::set_current_dir(&path)
|
||||||
|
.with_context(|| format!("Failed to cd into {}", path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_multi(self, global: GlobalOptions) -> anyhow::Result<()> {
|
||||||
|
let auth = roblox_api::resolve_auth(global.opencloud.as_deref())?;
|
||||||
|
|
||||||
|
// Validate all places belong to the same universe.
|
||||||
|
println!("Validating place IDs...");
|
||||||
|
let universe_ids: Vec<u64> = self
|
||||||
|
.placeids
|
||||||
|
.iter()
|
||||||
|
.map(|id| {
|
||||||
|
roblox_api::get_universe_id(*id, &auth)
|
||||||
|
.with_context(|| format!("Failed to resolve universe for place {id}"))
|
||||||
|
})
|
||||||
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
let first_universe = universe_ids[0];
|
||||||
|
for (i, uid) in universe_ids.iter().enumerate() {
|
||||||
|
if *uid != first_universe {
|
||||||
|
bail!(
|
||||||
|
"Place {} belongs to universe {} but place {} belongs to universe {}. \
|
||||||
|
All place IDs must be from the same experience.",
|
||||||
|
self.placeids[i],
|
||||||
|
uid,
|
||||||
|
self.placeids[0],
|
||||||
|
first_universe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve root folder.
|
||||||
|
let path = match self.path {
|
||||||
|
Some(p) => p,
|
||||||
|
None => resolve_folder_from_experience(self.placeids[0], global.opencloud.as_deref())?,
|
||||||
|
};
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let is_empty = path.read_dir()?.next().is_none();
|
||||||
|
if !is_empty {
|
||||||
|
bail!(
|
||||||
|
"Directory '{}' is not empty. Please use an empty directory.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(&path)?;
|
||||||
|
|
||||||
|
// Fetch place names.
|
||||||
|
println!("Fetching place names...");
|
||||||
|
let place_names = roblox_api::fetch_place_names(&self.placeids, &auth)?;
|
||||||
|
|
||||||
|
let places: Vec<PlaceEntry> = build_place_entries(&self.placeids, &place_names);
|
||||||
|
|
||||||
|
println!("Cloning {} places into '{}':", places.len(), path.display());
|
||||||
|
for p in &places {
|
||||||
|
println!(" {} -> {}.project.json5", p.place_id, p.dir_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write shared template files (everything except default.project.json5).
|
||||||
|
let canonical = fs::canonicalize(&path)?;
|
||||||
|
let project_name = canonical
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("new-project");
|
||||||
|
|
||||||
|
let exclude: HashSet<&str> = ["default.project.json5"].into();
|
||||||
|
write_template_files(
|
||||||
|
&path,
|
||||||
|
self.kind,
|
||||||
|
project_name,
|
||||||
|
None,
|
||||||
|
self.skip_git,
|
||||||
|
&exclude,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Generate a project file for each place.
|
||||||
|
for entry in &places {
|
||||||
|
let project_content = generate_project_json5(entry);
|
||||||
|
let project_path = path.join(format!("{}.project.json5", entry.dir_name));
|
||||||
|
write_if_not_exists(&project_path, &project_content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git init + cursor rules.
|
||||||
|
setup_git_and_rules(&path, self.skip_git, self.skip_rules)?;
|
||||||
|
|
||||||
|
// Download all places in parallel.
|
||||||
|
println!("Downloading {} places...", places.len());
|
||||||
|
let download_timer = Instant::now();
|
||||||
|
let auth_ref = &auth;
|
||||||
|
let temp_files: Vec<(u64, anyhow::Result<tempfile::NamedTempFile>)> =
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
let handles: Vec<_> = places
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let pid = entry.place_id;
|
||||||
|
s.spawn(move || (pid, roblox_api::download_place(pid, auth_ref)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| {
|
||||||
|
h.join()
|
||||||
|
.unwrap_or_else(|_| (0, Err(anyhow::anyhow!("thread panicked"))))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
"Downloaded in {:.02}s",
|
||||||
|
download_timer.elapsed().as_secs_f32()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect results, bail on any download failure.
|
||||||
|
let mut downloaded: HashMap<u64, tempfile::NamedTempFile> =
|
||||||
|
HashMap::with_capacity(places.len());
|
||||||
|
for (pid, result) in temp_files {
|
||||||
|
let temp = result.with_context(|| format!("Failed to download place {pid}"))?;
|
||||||
|
downloaded.insert(pid, temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncback each place sequentially.
|
||||||
|
for entry in &places {
|
||||||
|
println!(
|
||||||
|
"Syncing back place {} ({})...",
|
||||||
|
entry.dir_name, entry.place_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let temp = downloaded.remove(&entry.place_id).unwrap();
|
||||||
|
let input_path = temp.path().to_path_buf();
|
||||||
|
|
||||||
|
let syncback = SyncbackCommand {
|
||||||
|
project: PathBuf::from(format!("{}.project.json5", entry.dir_name)),
|
||||||
|
input: input_path,
|
||||||
|
download: None,
|
||||||
|
list: false,
|
||||||
|
dry_run: false,
|
||||||
|
interactive: false,
|
||||||
|
incremental: false,
|
||||||
|
sourcemap: false,
|
||||||
|
working_dir: path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
syncback.run(GlobalOptions {
|
||||||
|
verbosity: global.verbosity,
|
||||||
|
color: global.color,
|
||||||
|
opencloud: global.opencloud.clone(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.skip_git {
|
||||||
|
crate::git::git_add_all_and_commit(&path, "syncback");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Created multi-place project successfully.");
|
||||||
|
|
||||||
|
if !self.skip_cd {
|
||||||
|
std::env::set_current_dir(&path)
|
||||||
|
.with_context(|| format!("Failed to cd into {}", path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaceEntry {
|
||||||
|
place_id: u64,
|
||||||
|
dir_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_place_entries(place_ids: &[u64], names: &HashMap<u64, String>) -> Vec<PlaceEntry> {
|
||||||
|
let mut entries = Vec::with_capacity(place_ids.len());
|
||||||
|
let mut taken: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
for (i, &pid) in place_ids.iter().enumerate() {
|
||||||
|
let fallback = || format!("place-{}", i + 1);
|
||||||
|
|
||||||
|
let base = match names.get(&pid) {
|
||||||
|
Some(api_name) => {
|
||||||
|
let sanitized = sanitize_place_name(api_name);
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
fallback()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => fallback(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir_name = if taken.contains(&base) {
|
||||||
|
let mut n = 2u32;
|
||||||
|
loop {
|
||||||
|
let candidate = format!("{base}-{n}");
|
||||||
|
if !taken.contains(&candidate) {
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
};
|
||||||
|
|
||||||
|
taken.insert(dir_name.clone());
|
||||||
|
entries.push(PlaceEntry {
|
||||||
|
place_id: pid,
|
||||||
|
dir_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_project_json5(entry: &PlaceEntry) -> String {
|
||||||
|
format!(
|
||||||
|
"{{\n\
|
||||||
|
\tname: \"{name}\",\n\
|
||||||
|
\tservePlaceIds: [{id}],\n\
|
||||||
|
\tsyncScriptsOnly: true,\n\
|
||||||
|
\tglobIgnorePaths: [\"{name}/ServerStorage/RBX_ANIMSAVES/**\", \"{name}/ServerStorage/MoonAnimator2Saves/**\"],\n\
|
||||||
|
\tsyncbackRules: {{\n\
|
||||||
|
\t\tignoreTrees: [\"ServerStorage/RBX_ANIMSAVES/**\", \"ServerStorage/MoonAnimator2Saves/**\"],\n\
|
||||||
|
\t}},\n\
|
||||||
|
\ttree: {{\n\
|
||||||
|
\t\t$className: \"DataModel\",\n\
|
||||||
|
\t\t$path: \"{name}\"\n\
|
||||||
|
\t}}\n\
|
||||||
|
}}\n",
|
||||||
|
name = entry.dir_name,
|
||||||
|
id = entry.place_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a folder name from the experience name for a given place ID.
|
||||||
|
fn resolve_folder_from_experience(
|
||||||
|
place_id: u64,
|
||||||
|
opencloud_key: Option<&str>,
|
||||||
|
) -> anyhow::Result<PathBuf> {
|
||||||
|
let auth = roblox_api::try_resolve_auth(opencloud_key);
|
||||||
let name = match auth {
|
let name = match auth {
|
||||||
Some(a) => roblox_api::fetch_experience_name(self.placeid, &a)?
|
Some(a) => roblox_api::fetch_experience_name(place_id, &a)?
|
||||||
.context("Could not fetch experience name from Roblox API")?,
|
.context("Could not fetch experience name from Roblox API")?,
|
||||||
None => bail!(
|
None => bail!(
|
||||||
"No --opencloud API key or Roblox cookie available to fetch experience name. \
|
"No --opencloud API key or Roblox cookie available to fetch experience name. \
|
||||||
|
|
@ -70,48 +362,7 @@ impl CloneCommand {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!("Using folder: {folder}");
|
println!("Using folder: {folder}");
|
||||||
PathBuf::from(folder)
|
Ok(PathBuf::from(folder))
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let skip_git = self.skip_git;
|
|
||||||
|
|
||||||
let init = InitCommand {
|
|
||||||
path: path.clone(),
|
|
||||||
kind: self.kind,
|
|
||||||
skip_git,
|
|
||||||
placeid: Some(self.placeid),
|
|
||||||
skip_rules: self.skip_rules,
|
|
||||||
skip_cd: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
init.run()?;
|
|
||||||
|
|
||||||
let syncback = SyncbackCommand {
|
|
||||||
project: PathBuf::from("default.project.json5"),
|
|
||||||
input: PathBuf::from("Project.rbxl"),
|
|
||||||
download: Some(self.placeid),
|
|
||||||
list: false,
|
|
||||||
dry_run: false,
|
|
||||||
interactive: false,
|
|
||||||
incremental: false,
|
|
||||||
sourcemap: false,
|
|
||||||
working_dir: path.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
syncback.run(global)?;
|
|
||||||
|
|
||||||
if !skip_git {
|
|
||||||
crate::git::git_add_all_and_commit(&path, "syncback");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.skip_cd {
|
|
||||||
std::env::set_current_dir(&path)
|
|
||||||
.with_context(|| format!("Failed to cd into {}", path.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitize a Roblox experience name into a valid folder name.
|
/// Sanitize a Roblox experience name into a valid folder name.
|
||||||
|
|
@ -121,7 +372,6 @@ impl CloneCommand {
|
||||||
/// 3. Keep only ASCII alphanumeric and spaces
|
/// 3. Keep only ASCII alphanumeric and spaces
|
||||||
/// 4. Collapse whitespace, lowercase, join with hyphens
|
/// 4. Collapse whitespace, lowercase, join with hyphens
|
||||||
fn sanitize_name(name: &str) -> String {
|
fn sanitize_name(name: &str) -> String {
|
||||||
// Remove [...] and (...) sections (handles nesting)
|
|
||||||
let mut cleaned = String::with_capacity(name.len());
|
let mut cleaned = String::with_capacity(name.len());
|
||||||
let mut bracket_depth: u32 = 0;
|
let mut bracket_depth: u32 = 0;
|
||||||
let mut paren_depth: u32 = 0;
|
let mut paren_depth: u32 = 0;
|
||||||
|
|
@ -137,7 +387,6 @@ fn sanitize_name(name: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep only ASCII alphanumeric + spaces, then collapse/join
|
|
||||||
cleaned
|
cleaned
|
||||||
.split(|c: char| !(c.is_ascii_alphanumeric() || c == ' '))
|
.split(|c: char| !(c.is_ascii_alphanumeric() || c == ' '))
|
||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
|
|
@ -148,6 +397,19 @@ fn sanitize_name(name: &str) -> String {
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sanitize a Roblox place name into a valid directory name.
|
||||||
|
///
|
||||||
|
/// Simpler than experience name sanitization: keeps alphanumeric + spaces,
|
||||||
|
/// collapses whitespace, lowercases, joins with hyphens.
|
||||||
|
fn sanitize_place_name(name: &str) -> String {
|
||||||
|
name.split(|c: char| !(c.is_ascii_alphanumeric() || c == ' '))
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -189,4 +451,64 @@ mod tests {
|
||||||
assert_eq!(sanitize_name("TESTING My Game"), "my-game");
|
assert_eq!(sanitize_name("TESTING My Game"), "my-game");
|
||||||
assert_eq!(sanitize_name("My testing Game"), "my-game");
|
assert_eq!(sanitize_name("My testing Game"), "my-game");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_place_name_simple() {
|
||||||
|
assert_eq!(sanitize_place_name("Game"), "game");
|
||||||
|
assert_eq!(sanitize_place_name("Lobby"), "lobby");
|
||||||
|
assert_eq!(sanitize_place_name("Main Menu"), "main-menu");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_place_name_special_chars() {
|
||||||
|
assert_eq!(sanitize_place_name("Game! 🎮"), "game");
|
||||||
|
assert_eq!(sanitize_place_name("Test: Level 1"), "test-level-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_entries_uses_api_names() {
|
||||||
|
let mut names = HashMap::new();
|
||||||
|
names.insert(111, "Game".to_string());
|
||||||
|
names.insert(222, "Lobby".to_string());
|
||||||
|
|
||||||
|
let entries = build_place_entries(&[111, 222], &names);
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert_eq!(entries[0].dir_name, "game");
|
||||||
|
assert_eq!(entries[0].place_id, 111);
|
||||||
|
assert_eq!(entries[1].dir_name, "lobby");
|
||||||
|
assert_eq!(entries[1].place_id, 222);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_entries_dedup_collision() {
|
||||||
|
let mut names = HashMap::new();
|
||||||
|
names.insert(111, "Game".to_string());
|
||||||
|
names.insert(222, "Game".to_string());
|
||||||
|
|
||||||
|
let entries = build_place_entries(&[111, 222], &names);
|
||||||
|
assert_eq!(entries[0].dir_name, "game");
|
||||||
|
assert_eq!(entries[1].dir_name, "game-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_entries_fallback_when_no_name() {
|
||||||
|
let names = HashMap::new();
|
||||||
|
|
||||||
|
let entries = build_place_entries(&[111, 222], &names);
|
||||||
|
assert_eq!(entries[0].dir_name, "place-1");
|
||||||
|
assert_eq!(entries[1].dir_name, "place-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_project_json5_format() {
|
||||||
|
let entry = PlaceEntry {
|
||||||
|
place_id: 12345,
|
||||||
|
dir_name: "game".to_string(),
|
||||||
|
};
|
||||||
|
let content = generate_project_json5(&entry);
|
||||||
|
assert!(content.contains("name: \"game\""));
|
||||||
|
assert!(content.contains("servePlaceIds: [12345]"));
|
||||||
|
assert!(content.contains("$path: \"game\""));
|
||||||
|
assert!(content.contains("syncScriptsOnly: true"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
|
|
@ -53,8 +54,6 @@ pub struct InitCommand {
|
||||||
|
|
||||||
impl InitCommand {
|
impl InitCommand {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
let template = self.kind.template();
|
|
||||||
|
|
||||||
let base_path = resolve_path(&self.path);
|
let base_path = resolve_path(&self.path);
|
||||||
|
|
||||||
if base_path.exists() {
|
if base_path.exists() {
|
||||||
|
|
@ -75,16 +74,47 @@ impl InitCommand {
|
||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.unwrap_or("new-project");
|
.unwrap_or("new-project");
|
||||||
|
|
||||||
|
println!("Creating new {:?} project '{}'", self.kind, project_name);
|
||||||
|
|
||||||
|
write_template_files(
|
||||||
|
&base_path,
|
||||||
|
self.kind,
|
||||||
|
project_name,
|
||||||
|
self.placeid,
|
||||||
|
self.skip_git,
|
||||||
|
&HashSet::new(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
setup_git_and_rules(&base_path, self.skip_git, self.skip_rules)?;
|
||||||
|
|
||||||
|
println!("Created project successfully.");
|
||||||
|
|
||||||
|
if !self.skip_cd {
|
||||||
|
std::env::set_current_dir(&base_path)
|
||||||
|
.with_context(|| format!("Failed to cd into {}", base_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write template files from the baked-in template to `base_path`.
|
||||||
|
///
|
||||||
|
/// `exclude_files` is a set of template filenames to skip (e.g. `"default.project.json5"`).
|
||||||
|
pub fn write_template_files(
|
||||||
|
base_path: &Path,
|
||||||
|
kind: InitKind,
|
||||||
|
project_name: &str,
|
||||||
|
place_id: Option<u64>,
|
||||||
|
skip_git: bool,
|
||||||
|
exclude_files: &HashSet<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let template = kind.template();
|
||||||
let project_params = ProjectParams {
|
let project_params = ProjectParams {
|
||||||
name: project_name.to_owned(),
|
name: project_name.to_owned(),
|
||||||
place_id: self.placeid,
|
place_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
|
||||||
"Creating new {:?} project '{}'",
|
|
||||||
self.kind, project_params.name
|
|
||||||
);
|
|
||||||
|
|
||||||
let vfs = Vfs::new(template);
|
let vfs = Vfs::new(template);
|
||||||
vfs.set_watch_enabled(false);
|
vfs.set_watch_enabled(false);
|
||||||
|
|
||||||
|
|
@ -96,15 +126,21 @@ impl InitCommand {
|
||||||
while let Some(mut path) = queue.pop_front() {
|
while let Some(mut path) = queue.pop_front() {
|
||||||
let metadata = vfs.metadata(&path)?;
|
let metadata = vfs.metadata(&path)?;
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
fs_err::create_dir(base_path.join(&path))?;
|
fs_err::create_dir_all(base_path.join(&path))?;
|
||||||
for entry in vfs.read_dir(&path)? {
|
for entry in vfs.read_dir(&path)? {
|
||||||
queue.push_back(entry?.path().to_path_buf());
|
queue.push_back(entry?.path().to_path_buf());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if let Some(file_name) = path.file_name().and_then(OsStr::to_str) {
|
||||||
|
if exclude_files.contains(file_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let content = vfs.read_to_string_lf_normalized(&path)?;
|
let content = vfs.read_to_string_lf_normalized(&path)?;
|
||||||
if let Some(file_stem) = path.file_name().and_then(OsStr::to_str) {
|
if let Some(file_name) = path.file_name().and_then(OsStr::to_str) {
|
||||||
if file_stem == GIT_IGNORE_PLACEHOLDER {
|
if file_name == GIT_IGNORE_PLACEHOLDER {
|
||||||
if self.skip_git {
|
if skip_git {
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
path.set_file_name(".gitignore");
|
path.set_file_name(".gitignore");
|
||||||
|
|
@ -122,21 +158,28 @@ impl InitCommand {
|
||||||
fs::create_dir_all(&rules_dir)?;
|
fs::create_dir_all(&rules_dir)?;
|
||||||
write_if_not_exists(&rules_dir.join("atlas-project.mdc"), ATLAS_PROJECT_MDC)?;
|
write_if_not_exists(&rules_dir.join("atlas-project.mdc"), ATLAS_PROJECT_MDC)?;
|
||||||
|
|
||||||
let did_git_init = if !self.skip_git && crate::git::git_repo_root(&base_path).is_none() {
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize git repository, make initial commit, and optionally add agent submodules.
|
||||||
|
pub fn setup_git_and_rules(
|
||||||
|
base_path: &Path,
|
||||||
|
skip_git: bool,
|
||||||
|
skip_rules: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let did_git_init = if !skip_git && crate::git::git_repo_root(base_path).is_none() {
|
||||||
log::debug!("Initializing Git repository...");
|
log::debug!("Initializing Git repository...");
|
||||||
|
crate::git::git_init_repo(base_path).context("Failed to initialize git repository")?;
|
||||||
crate::git::git_init_repo(&base_path).context("Failed to initialize git repository")?;
|
|
||||||
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
!self.skip_git
|
!skip_git
|
||||||
};
|
};
|
||||||
|
|
||||||
if did_git_init {
|
if did_git_init {
|
||||||
crate::git::git_add_all_and_commit(&base_path, "Initial commit");
|
crate::git::git_add_all_and_commit(base_path, "Initial commit");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.skip_rules && did_git_init {
|
if !skip_rules && did_git_init {
|
||||||
log::debug!("Adding agent submodules...");
|
log::debug!("Adding agent submodules...");
|
||||||
|
|
||||||
let submodules: &[(&str, &str)] = &[
|
let submodules: &[(&str, &str)] = &[
|
||||||
|
|
@ -158,7 +201,6 @@ impl InitCommand {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Shallow-clone all repos in parallel (network I/O).
|
|
||||||
let clone_results: Vec<anyhow::Result<()>> = std::thread::scope(|s| {
|
let clone_results: Vec<anyhow::Result<()>> = std::thread::scope(|s| {
|
||||||
let handles: Vec<_> = submodules
|
let handles: Vec<_> = submodules
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -176,7 +218,6 @@ impl InitCommand {
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register each clone as a submodule (sequential, index-lock safe).
|
|
||||||
let mut any_failed = false;
|
let mut any_failed = false;
|
||||||
for ((url, path), clone_res) in submodules.iter().zip(clone_results) {
|
for ((url, path), clone_res) in submodules.iter().zip(clone_results) {
|
||||||
if let Err(e) = clone_res {
|
if let Err(e) = clone_res {
|
||||||
|
|
@ -184,13 +225,13 @@ impl InitCommand {
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = crate::git::git_submodule_add(&base_path, url, path) {
|
if let Err(e) = crate::git::git_submodule_add(base_path, url, path) {
|
||||||
log::warn!("Failed to register submodule {path}: {e}");
|
log::warn!("Failed to register submodule {path}: {e}");
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = crate::git::git_config_set(&base_path, "submodule.recurse", "true") {
|
if let Err(e) = crate::git::git_config_set(base_path, "submodule.recurse", "true") {
|
||||||
log::warn!("Failed to set submodule.recurse: {e}");
|
log::warn!("Failed to set submodule.recurse: {e}");
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
}
|
}
|
||||||
|
|
@ -199,19 +240,11 @@ impl InitCommand {
|
||||||
println!("Added agent submodules successfully.");
|
println!("Added agent submodules successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::git::git_add_all_and_commit(&base_path, "Add agent submodules");
|
crate::git::git_add_all_and_commit(base_path, "Add agent submodules");
|
||||||
}
|
|
||||||
|
|
||||||
println!("Created project successfully.");
|
|
||||||
|
|
||||||
if !self.skip_cd {
|
|
||||||
std::env::set_current_dir(&base_path)
|
|
||||||
.with_context(|| format!("Failed to cd into {}", base_path.display()))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// The templates we support for initializing a Rojo project.
|
/// The templates we support for initializing a Rojo project.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|
@ -289,7 +322,7 @@ impl ProjectParams {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a file if it does not exist yet, otherwise, leave it alone.
|
/// Write a file if it does not exist yet, otherwise, leave it alone.
|
||||||
fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
|
pub fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
|
||||||
let file_res = OpenOptions::new().write(true).create_new(true).open(path);
|
let file_res = OpenOptions::new().write(true).create_new(true).open(path);
|
||||||
|
|
||||||
let mut file = match file_res {
|
let mut file = match file_res {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::io::{self, Write as _};
|
use std::io::{self, Write as _};
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
|
|
@ -250,6 +251,56 @@ pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PlaceDetail {
|
||||||
|
#[serde(rename = "placeId")]
|
||||||
|
place_id: u64,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch place names for one or more place IDs via the multiget-place-details endpoint.
|
||||||
|
///
|
||||||
|
/// Returns a map of place_id -> place_name. Places that cannot be resolved are omitted.
|
||||||
|
/// Falls back to no-auth if API key auth fails (games.roblox.com may not support it).
|
||||||
|
pub fn fetch_place_names(
|
||||||
|
place_ids: &[u64],
|
||||||
|
auth: &RobloxAuth,
|
||||||
|
) -> anyhow::Result<HashMap<u64, String>> {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
|
||||||
|
let mut url = "https://games.roblox.com/v1/games/multiget-place-details?".to_string();
|
||||||
|
for (i, id) in place_ids.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
url.push('&');
|
||||||
|
}
|
||||||
|
url.push_str(&format!("placeIds={id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let try_parse = |resp: reqwest::blocking::Response| -> Option<HashMap<u64, String>> {
|
||||||
|
let details: Vec<PlaceDetail> = resp.json().ok()?;
|
||||||
|
let map: HashMap<u64, String> = details.into_iter().map(|d| (d.place_id, d.name)).collect();
|
||||||
|
Some(map)
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = apply_auth(client.get(&url), auth);
|
||||||
|
if let Ok(resp) = req.send() {
|
||||||
|
if let Some(map) = try_parse(resp) {
|
||||||
|
return Ok(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(auth, RobloxAuth::ApiKey(_)) {
|
||||||
|
let req = client.get(&url);
|
||||||
|
if let Ok(resp) = req.send() {
|
||||||
|
if let Some(map) = try_parse(resp) {
|
||||||
|
return Ok(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_auth(
|
fn apply_auth(
|
||||||
req: reqwest::blocking::RequestBuilder,
|
req: reqwest::blocking::RequestBuilder,
|
||||||
auth: &RobloxAuth,
|
auth: &RobloxAuth,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue