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]
|
||||
optimize syncback like we did serve.
|
||||
|
||||
[atlas-project.mdc]
|
||||
we need to remove atlas-project.mdc from the main shared agent rules and migrate it to being manually cloned into newly created projects.....
|
||||
[sourcemap / luau-lsp]
|
||||
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...
|
||||
400
src/cli/clone.rs
400
src/cli/clone.rs
|
|
@ -1,22 +1,30 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use clap::Parser;
|
||||
use fs_err as fs;
|
||||
|
||||
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::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
|
||||
/// folder. Equivalent to `rojo init` + `rojo syncback --download`.
|
||||
/// With a single place ID, behaves identically to the original clone flow
|
||||
/// (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)]
|
||||
pub struct CloneCommand {
|
||||
/// The place ID to download and sync back.
|
||||
pub placeid: u64,
|
||||
/// One or more place IDs to download and sync back.
|
||||
#[clap(required = true, num_args = 1..)]
|
||||
pub placeids: Vec<u64>,
|
||||
|
||||
/// Path to create the project in. If omitted, a directory is
|
||||
/// auto-generated from the experience name.
|
||||
|
|
@ -42,45 +50,27 @@ pub struct CloneCommand {
|
|||
|
||||
impl CloneCommand {
|
||||
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 {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref());
|
||||
let name = match auth {
|
||||
Some(a) => roblox_api::fetch_experience_name(self.placeid, &a)?
|
||||
.context("Could not fetch experience name from Roblox API")?,
|
||||
None => bail!(
|
||||
"No --opencloud API key or Roblox cookie available to fetch experience name. \
|
||||
Use --path to specify a folder manually, or pass --opencloud <KEY>."
|
||||
),
|
||||
};
|
||||
let folder = sanitize_name(&name);
|
||||
if folder.is_empty() {
|
||||
bail!(
|
||||
"Could not derive a folder name from experience '{}'. \
|
||||
Use --path to specify one manually.",
|
||||
name
|
||||
);
|
||||
}
|
||||
if Path::new(&folder).exists() {
|
||||
bail!(
|
||||
"Directory '{}' already exists. \
|
||||
Remove it or use --path to specify a different location.",
|
||||
folder
|
||||
);
|
||||
}
|
||||
println!("Using folder: {folder}");
|
||||
PathBuf::from(folder)
|
||||
}
|
||||
None => resolve_folder_from_experience(place_id, global.opencloud.as_deref())?,
|
||||
};
|
||||
|
||||
let skip_git = self.skip_git;
|
||||
|
||||
let init = InitCommand {
|
||||
let init = super::init::InitCommand {
|
||||
path: path.clone(),
|
||||
kind: self.kind,
|
||||
skip_git,
|
||||
placeid: Some(self.placeid),
|
||||
placeid: Some(place_id),
|
||||
skip_rules: self.skip_rules,
|
||||
skip_cd: true,
|
||||
};
|
||||
|
|
@ -90,7 +80,7 @@ impl CloneCommand {
|
|||
let syncback = SyncbackCommand {
|
||||
project: PathBuf::from("default.project.json5"),
|
||||
input: PathBuf::from("Project.rbxl"),
|
||||
download: Some(self.placeid),
|
||||
download: Some(place_id),
|
||||
list: false,
|
||||
dry_run: false,
|
||||
interactive: false,
|
||||
|
|
@ -112,6 +102,267 @@ impl CloneCommand {
|
|||
|
||||
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 {
|
||||
Some(a) => roblox_api::fetch_experience_name(place_id, &a)?
|
||||
.context("Could not fetch experience name from Roblox API")?,
|
||||
None => bail!(
|
||||
"No --opencloud API key or Roblox cookie available to fetch experience name. \
|
||||
Use --path to specify a folder manually, or pass --opencloud <KEY>."
|
||||
),
|
||||
};
|
||||
let folder = sanitize_name(&name);
|
||||
if folder.is_empty() {
|
||||
bail!(
|
||||
"Could not derive a folder name from experience '{}'. \
|
||||
Use --path to specify one manually.",
|
||||
name
|
||||
);
|
||||
}
|
||||
if Path::new(&folder).exists() {
|
||||
bail!(
|
||||
"Directory '{}' already exists. \
|
||||
Remove it or use --path to specify a different location.",
|
||||
folder
|
||||
);
|
||||
}
|
||||
println!("Using folder: {folder}");
|
||||
Ok(PathBuf::from(folder))
|
||||
}
|
||||
|
||||
/// Sanitize a Roblox experience name into a valid folder name.
|
||||
|
|
@ -121,7 +372,6 @@ impl CloneCommand {
|
|||
/// 3. Keep only ASCII alphanumeric and spaces
|
||||
/// 4. Collapse whitespace, lowercase, join with hyphens
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
// Remove [...] and (...) sections (handles nesting)
|
||||
let mut cleaned = String::with_capacity(name.len());
|
||||
let mut bracket_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
|
||||
.split(|c: char| !(c.is_ascii_alphanumeric() || c == ' '))
|
||||
.collect::<String>()
|
||||
|
|
@ -148,6 +397,19 @@ fn sanitize_name(name: &str) -> String {
|
|||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -189,4 +451,64 @@ mod tests {
|
|||
assert_eq!(sanitize_name("TESTING My 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
287
src/cli/init.rs
287
src/cli/init.rs
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
|
|
@ -53,8 +54,6 @@ pub struct InitCommand {
|
|||
|
||||
impl InitCommand {
|
||||
pub fn run(self) -> anyhow::Result<()> {
|
||||
let template = self.kind.template();
|
||||
|
||||
let base_path = resolve_path(&self.path);
|
||||
|
||||
if base_path.exists() {
|
||||
|
|
@ -75,132 +74,18 @@ impl InitCommand {
|
|||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("new-project");
|
||||
|
||||
let project_params = ProjectParams {
|
||||
name: project_name.to_owned(),
|
||||
place_id: self.placeid,
|
||||
};
|
||||
println!("Creating new {:?} project '{}'", self.kind, project_name);
|
||||
|
||||
println!(
|
||||
"Creating new {:?} project '{}'",
|
||||
self.kind, project_params.name
|
||||
);
|
||||
write_template_files(
|
||||
&base_path,
|
||||
self.kind,
|
||||
project_name,
|
||||
self.placeid,
|
||||
self.skip_git,
|
||||
&HashSet::new(),
|
||||
)?;
|
||||
|
||||
let vfs = Vfs::new(template);
|
||||
vfs.set_watch_enabled(false);
|
||||
|
||||
let mut queue = VecDeque::with_capacity(8);
|
||||
for entry in vfs.read_dir("")? {
|
||||
queue.push_back(entry?.path().to_path_buf())
|
||||
}
|
||||
|
||||
while let Some(mut path) = queue.pop_front() {
|
||||
let metadata = vfs.metadata(&path)?;
|
||||
if metadata.is_dir() {
|
||||
fs_err::create_dir(base_path.join(&path))?;
|
||||
for entry in vfs.read_dir(&path)? {
|
||||
queue.push_back(entry?.path().to_path_buf());
|
||||
}
|
||||
} else {
|
||||
let content = vfs.read_to_string_lf_normalized(&path)?;
|
||||
if let Some(file_stem) = path.file_name().and_then(OsStr::to_str) {
|
||||
if file_stem == GIT_IGNORE_PLACEHOLDER {
|
||||
if self.skip_git {
|
||||
continue;
|
||||
} else {
|
||||
path.set_file_name(".gitignore");
|
||||
}
|
||||
}
|
||||
}
|
||||
write_if_not_exists(
|
||||
&base_path.join(&path),
|
||||
&project_params.render_template(&content),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let rules_dir = base_path.join(".cursor/rules");
|
||||
fs::create_dir_all(&rules_dir)?;
|
||||
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() {
|
||||
log::debug!("Initializing Git repository...");
|
||||
|
||||
crate::git::git_init_repo(&base_path).context("Failed to initialize git repository")?;
|
||||
|
||||
true
|
||||
} else {
|
||||
!self.skip_git
|
||||
};
|
||||
|
||||
if did_git_init {
|
||||
crate::git::git_add_all_and_commit(&base_path, "Initial commit");
|
||||
}
|
||||
|
||||
if !self.skip_rules && did_git_init {
|
||||
log::debug!("Adding agent submodules...");
|
||||
|
||||
let submodules: &[(&str, &str)] = &[
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-rules.git",
|
||||
".cursor/rules/shared",
|
||||
),
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-commands.git",
|
||||
".cursor/commands/shared",
|
||||
),
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-skills.git",
|
||||
".cursor/skills/shared",
|
||||
),
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-docs.git",
|
||||
".cursor/docs/shared",
|
||||
),
|
||||
];
|
||||
|
||||
// Shallow-clone all repos in parallel (network I/O).
|
||||
let clone_results: Vec<anyhow::Result<()>> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = submodules
|
||||
.iter()
|
||||
.map(|(url, path)| {
|
||||
let target = base_path.join(path);
|
||||
s.spawn(move || crate::git::git_clone_shallow(url, &target))
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
h.join()
|
||||
.unwrap_or_else(|_| Err(anyhow::anyhow!("thread panicked")))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Register each clone as a submodule (sequential, index-lock safe).
|
||||
let mut any_failed = false;
|
||||
for ((url, path), clone_res) in submodules.iter().zip(clone_results) {
|
||||
if let Err(e) = clone_res {
|
||||
log::warn!("Failed to clone {path}: {e}");
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = crate::git::git_submodule_add(&base_path, url, path) {
|
||||
log::warn!("Failed to register submodule {path}: {e}");
|
||||
any_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = crate::git::git_config_set(&base_path, "submodule.recurse", "true") {
|
||||
log::warn!("Failed to set submodule.recurse: {e}");
|
||||
any_failed = true;
|
||||
}
|
||||
|
||||
if !any_failed {
|
||||
println!("Added agent submodules successfully.");
|
||||
}
|
||||
|
||||
crate::git::git_add_all_and_commit(&base_path, "Add agent submodules");
|
||||
}
|
||||
setup_git_and_rules(&base_path, self.skip_git, self.skip_rules)?;
|
||||
|
||||
println!("Created project successfully.");
|
||||
|
||||
|
|
@ -213,6 +98,154 @@ impl InitCommand {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
name: project_name.to_owned(),
|
||||
place_id,
|
||||
};
|
||||
|
||||
let vfs = Vfs::new(template);
|
||||
vfs.set_watch_enabled(false);
|
||||
|
||||
let mut queue = VecDeque::with_capacity(8);
|
||||
for entry in vfs.read_dir("")? {
|
||||
queue.push_back(entry?.path().to_path_buf())
|
||||
}
|
||||
|
||||
while let Some(mut path) = queue.pop_front() {
|
||||
let metadata = vfs.metadata(&path)?;
|
||||
if metadata.is_dir() {
|
||||
fs_err::create_dir_all(base_path.join(&path))?;
|
||||
for entry in vfs.read_dir(&path)? {
|
||||
queue.push_back(entry?.path().to_path_buf());
|
||||
}
|
||||
} 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)?;
|
||||
if let Some(file_name) = path.file_name().and_then(OsStr::to_str) {
|
||||
if file_name == GIT_IGNORE_PLACEHOLDER {
|
||||
if skip_git {
|
||||
continue;
|
||||
} else {
|
||||
path.set_file_name(".gitignore");
|
||||
}
|
||||
}
|
||||
}
|
||||
write_if_not_exists(
|
||||
&base_path.join(&path),
|
||||
&project_params.render_template(&content),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let rules_dir = base_path.join(".cursor/rules");
|
||||
fs::create_dir_all(&rules_dir)?;
|
||||
write_if_not_exists(&rules_dir.join("atlas-project.mdc"), ATLAS_PROJECT_MDC)?;
|
||||
|
||||
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...");
|
||||
crate::git::git_init_repo(base_path).context("Failed to initialize git repository")?;
|
||||
true
|
||||
} else {
|
||||
!skip_git
|
||||
};
|
||||
|
||||
if did_git_init {
|
||||
crate::git::git_add_all_and_commit(base_path, "Initial commit");
|
||||
}
|
||||
|
||||
if !skip_rules && did_git_init {
|
||||
log::debug!("Adding agent submodules...");
|
||||
|
||||
let submodules: &[(&str, &str)] = &[
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-rules.git",
|
||||
".cursor/rules/shared",
|
||||
),
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-commands.git",
|
||||
".cursor/commands/shared",
|
||||
),
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-skills.git",
|
||||
".cursor/skills/shared",
|
||||
),
|
||||
(
|
||||
"https://github.com/UserGeneratedLLC/agent-docs.git",
|
||||
".cursor/docs/shared",
|
||||
),
|
||||
];
|
||||
|
||||
let clone_results: Vec<anyhow::Result<()>> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = submodules
|
||||
.iter()
|
||||
.map(|(url, path)| {
|
||||
let target = base_path.join(path);
|
||||
s.spawn(move || crate::git::git_clone_shallow(url, &target))
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
h.join()
|
||||
.unwrap_or_else(|_| Err(anyhow::anyhow!("thread panicked")))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let mut any_failed = false;
|
||||
for ((url, path), clone_res) in submodules.iter().zip(clone_results) {
|
||||
if let Err(e) = clone_res {
|
||||
log::warn!("Failed to clone {path}: {e}");
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = crate::git::git_submodule_add(base_path, url, path) {
|
||||
log::warn!("Failed to register submodule {path}: {e}");
|
||||
any_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = crate::git::git_config_set(base_path, "submodule.recurse", "true") {
|
||||
log::warn!("Failed to set submodule.recurse: {e}");
|
||||
any_failed = true;
|
||||
}
|
||||
|
||||
if !any_failed {
|
||||
println!("Added agent submodules successfully.");
|
||||
}
|
||||
|
||||
crate::git::git_add_all_and_commit(base_path, "Add agent submodules");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The templates we support for initializing a Rojo project.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum InitKind {
|
||||
|
|
@ -289,7 +322,7 @@ impl ProjectParams {
|
|||
}
|
||||
|
||||
/// 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 mut file = match file_res {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::{self, Write as _};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
|
|
@ -250,6 +251,56 @@ pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result
|
|||
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(
|
||||
req: reqwest::blocking::RequestBuilder,
|
||||
auth: &RobloxAuth,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue