Skip to content

Commit be5ff1e

Browse files
committed
feat: allow --no-template option on init
Removes all JS-related files from repo during project instantiation, including any config files used by npm packages since they won't be installed. Also skips package manager choice since unneeded. Then sets `client = false` in environments.toml for all contracts.
1 parent 5013348 commit be5ff1e

4 files changed

Lines changed: 264 additions & 32 deletions

File tree

crates/stellar-scaffold-cli/src/commands/init/instantiate.rs

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ use crate::commands::{PackageManager, PackageManagerSpec};
1515
pub const TEMPLATES_DIR: &str = "templates";
1616
/// The directory selected template is promoted to
1717
pub const APP_DIR: &str = "app";
18+
/// Reserved `--template` value selecting the no-frontend layout. Not a
19+
/// directory under `templates/` — there is nothing to instantiate.
20+
pub const NO_FRONTEND: &str = "none";
21+
22+
/// List of every JS-related file in the monorepo root that will be removed
23+
/// by the no-template CLI option. Only Rust/Cargo workspace remains.
24+
const NO_FRONTEND_REMOVALS: &[&str] = &[
25+
TEMPLATES_DIR,
26+
"app-lib",
27+
"e2e",
28+
"tests",
29+
"scripts",
30+
".husky",
31+
"node_modules",
32+
"package.json",
33+
"package-lock.json",
34+
".prettierignore",
35+
];
1836

1937
/// The declared workspaces for the instantiated project. `e2e/` is kept (its
2038
/// self-adapting config targets the single `app/` post-init); `templates/*` is
@@ -39,6 +57,8 @@ pub enum Error {
3957
Io(#[from] std::io::Error),
4058
#[error("malformed root package.json: {0}")]
4159
PackageJson(#[from] serde_json::Error),
60+
#[error("malformed environments.toml: {0}")]
61+
EnvToml(#[from] toml_edit::TomlError),
4262
}
4363

4464
/// How `--template` (or the interactive prompt) selected a source.
@@ -52,11 +72,15 @@ pub enum TemplateSource {
5272
Framework(String),
5373
/// Community repo shorthand, e.g. `"org/repo#ref"`.
5474
Community(String),
75+
/// The reserved `none` value: contracts only, no UI layer.
76+
NoFrontend,
5577
}
5678

57-
/// Parse a `--template` value into official or community source
79+
/// Parse a `--template` value into official, community, or no-frontend source
5880
pub fn parse_template_arg(value: &str) -> TemplateSource {
59-
if value.contains('/') {
81+
if value == NO_FRONTEND {
82+
TemplateSource::NoFrontend
83+
} else if value.contains('/') {
6084
TemplateSource::Community(value.to_string())
6185
} else {
6286
TemplateSource::Framework(value.to_string())
@@ -106,6 +130,50 @@ pub fn instantiate(root: &Path, framework: &str) -> Result<(), Error> {
106130
Ok(())
107131
}
108132

133+
/// Assemble the no-frontend layout: strip every JS workspace and node config
134+
/// from the acquired monorepo, leaving only the Cargo/contracts side, and set
135+
/// `client = false` on every contract in `environments.toml` so `build`/`watch`
136+
/// never generate (or deploy for) client packages there is no app to consume.
137+
pub fn instantiate_no_frontend(root: &Path) -> Result<(), Error> {
138+
if !root.join(TEMPLATES_DIR).is_dir() {
139+
return Err(Error::NoTemplatesDir);
140+
}
141+
for name in NO_FRONTEND_REMOVALS {
142+
let path = root.join(name);
143+
if path.is_dir() {
144+
fs::remove_dir_all(&path)?;
145+
} else if path.exists() {
146+
fs::remove_file(&path)?;
147+
}
148+
}
149+
disable_clients_in_env_toml(root)
150+
}
151+
152+
/// Set `client = false` on every contract entry in every environment of
153+
/// `environments.toml`, preserving formatting and comments. Contracts absent
154+
/// from the file default to `client = true` at build time, so only listed
155+
/// entries need rewriting — the shipped monorepo lists all of its contracts.
156+
fn disable_clients_in_env_toml(root: &Path) -> Result<(), Error> {
157+
let path = root.join("environments.toml");
158+
let Ok(contents) = fs::read_to_string(&path) else {
159+
return Ok(());
160+
};
161+
let mut doc: toml_edit::DocumentMut = contents.parse()?;
162+
for (_, env) in doc.iter_mut() {
163+
let Some(contracts) = env.get_mut("contracts").and_then(|c| c.as_table_like_mut()) else {
164+
continue;
165+
};
166+
let names: Vec<String> = contracts.iter().map(|(k, _)| k.to_string()).collect();
167+
for name in names {
168+
if let Some(contract) = contracts.get_mut(&name).and_then(|c| c.as_table_like_mut()) {
169+
contract.insert("client", toml_edit::value(false));
170+
}
171+
}
172+
}
173+
fs::write(&path, doc.to_string())?;
174+
Ok(())
175+
}
176+
109177
/// Remove the committed visual-snapshot baselines from the kept `e2e/` suite.
110178
/// They are per-framework (e.g. `home-react-darwin.png`); after init only the
111179
/// single `app/` remains, so the user regenerates their own on first run.
@@ -225,6 +293,11 @@ mod tests {
225293
);
226294
}
227295

296+
#[test]
297+
fn parse_template_arg_none_is_no_frontend() {
298+
assert_eq!(parse_template_arg("none"), TemplateSource::NoFrontend);
299+
}
300+
228301
#[test]
229302
fn parse_template_arg_community_when_slash() {
230303
assert_eq!(
@@ -314,6 +387,107 @@ mod tests {
314387
assert!(dir.path().join("templates/react").exists());
315388
}
316389

390+
/// Extend the base fixture with the Cargo side + env toml the no-frontend
391+
/// layout keeps, and the extra node config it strips.
392+
fn no_frontend_fixture() -> tempfile::TempDir {
393+
let dir = fixture();
394+
let root = dir.path();
395+
write(&root.join("Cargo.toml"), "[workspace]\n");
396+
write(&root.join("scaffold.yml"), "version: 1\n");
397+
write(
398+
&root.join("environments.toml"),
399+
"# top comment\n\
400+
[development.contracts]\n\
401+
fungible = { client = true, constructor_args = \"--admin me\" }\n\
402+
\n\
403+
# section-style contract\n\
404+
[development.contracts.guess_the_number]\n\
405+
client = true\n\
406+
\n\
407+
[staging.contracts.other]\n\
408+
id = \"C123\"\n",
409+
);
410+
write(&root.join("tests/e2e/smoke.spec.ts"), "// smoke");
411+
write(&root.join("scripts/dev-guard.mjs"), "// guard");
412+
write(&root.join(".husky/pre-commit"), "npm test");
413+
write(&root.join(".prettierignore"), "target\n");
414+
dir
415+
}
416+
417+
#[test]
418+
fn no_frontend_strips_js_and_keeps_cargo_side() {
419+
let dir = no_frontend_fixture();
420+
let root = dir.path();
421+
instantiate_no_frontend(root).unwrap();
422+
423+
for gone in [
424+
"templates",
425+
"app-lib",
426+
"e2e",
427+
"tests",
428+
"scripts",
429+
".husky",
430+
"package.json",
431+
".prettierignore",
432+
] {
433+
assert!(!root.join(gone).exists(), "{gone} removed");
434+
}
435+
for kept in [
436+
"Cargo.toml",
437+
"scaffold.yml",
438+
"environments.toml",
439+
"contracts",
440+
] {
441+
assert!(root.join(kept).exists(), "{kept} kept");
442+
}
443+
assert!(!root.join(APP_DIR).exists(), "no app/ promoted");
444+
}
445+
446+
#[test]
447+
fn no_frontend_disables_clients_in_env_toml() {
448+
let dir = no_frontend_fixture();
449+
let root = dir.path();
450+
instantiate_no_frontend(root).unwrap();
451+
452+
let contents = fs::read_to_string(root.join("environments.toml")).unwrap();
453+
assert!(contents.contains("# top comment"), "comments preserved");
454+
assert!(!contents.contains("client = true"));
455+
456+
let parsed: toml::Value = toml::from_str(&contents).unwrap();
457+
for (env, name) in [
458+
("development", "fungible"),
459+
("development", "guess_the_number"),
460+
("staging", "other"),
461+
] {
462+
assert_eq!(
463+
parsed[env]["contracts"][name]["client"],
464+
toml::Value::Boolean(false),
465+
"{env}.{name} client disabled"
466+
);
467+
}
468+
assert_eq!(
469+
parsed["development"]["contracts"]["fungible"]["constructor_args"],
470+
toml::Value::String("--admin me".into()),
471+
"other settings untouched"
472+
);
473+
}
474+
475+
#[test]
476+
fn no_frontend_errors_without_templates_dir() {
477+
let dir = tempfile::tempdir().unwrap();
478+
assert!(matches!(
479+
instantiate_no_frontend(dir.path()),
480+
Err(Error::NoTemplatesDir)
481+
));
482+
}
483+
484+
#[test]
485+
fn no_frontend_ok_without_environments_toml() {
486+
let dir = fixture();
487+
instantiate_no_frontend(dir.path()).unwrap();
488+
assert!(!dir.path().join("templates").exists());
489+
}
490+
317491
fn spec(kind: PackageManager) -> PackageManagerSpec {
318492
PackageManagerSpec {
319493
kind,

crates/stellar-scaffold-cli/src/commands/init/mod.rs

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,16 @@ pub struct Cmd {
3838

3939
/// Template selector. A bare framework name (e.g. `react`) picks an official
4040
/// template from the UI monorepo; a `user/repo` shorthand (optionally with a
41-
/// `#branch`/`#tag` suffix) degits that community repo directly. Omit to
42-
/// choose a framework interactively.
41+
/// `#branch`/`#tag` suffix) degits that community repo directly; `none`
42+
/// creates a contracts-only project with no frontend. Omit to choose
43+
/// interactively.
4344
#[arg(long)]
4445
pub template: Option<String>,
4546

47+
/// Create a contracts-only project with no frontend (alias for `--template none`)
48+
#[arg(long, conflicts_with = "template")]
49+
pub no_template: bool,
50+
4651
/// Specify package manager, omitting will prompt interactively
4752
#[arg(short = 'p', long)]
4853
pub package_manager: Option<PackageManager>,
@@ -83,6 +88,8 @@ enum Source {
8388
Official(Option<String>),
8489
/// Community repo shorthand, degit'd as-is.
8590
Community(String),
91+
/// Contracts only: monorepo acquired, all JS stripped, no `app/`.
92+
NoFrontend,
8693
}
8794

8895
impl Cmd {
@@ -104,23 +111,57 @@ impl Cmd {
104111
absolute_project_path.display()
105112
));
106113

107-
// Resolve the source from --template using the slash heuristic.
108-
let source = match &self.template {
109-
None => Source::Official(None),
110-
Some(s) => match instantiate::parse_template_arg(s) {
111-
instantiate::TemplateSource::Framework(name) => Source::Official(Some(name)),
112-
instantiate::TemplateSource::Community(repo) => Source::Community(repo),
113-
},
114+
// Resolve the source from --no-template / --template (slash heuristic).
115+
let source = if self.no_template {
116+
Source::NoFrontend
117+
} else {
118+
match &self.template {
119+
None => Source::Official(None),
120+
Some(s) => match instantiate::parse_template_arg(s) {
121+
instantiate::TemplateSource::Framework(name) => Source::Official(Some(name)),
122+
instantiate::TemplateSource::Community(repo) => Source::Community(repo),
123+
instantiate::TemplateSource::NoFrontend => Source::NoFrontend,
124+
},
125+
}
114126
};
115127

116128
let repo = match &source {
117-
Source::Official(_) => ui_repo(),
129+
Source::Official(_) | Source::NoFrontend => ui_repo(),
118130
Source::Community(repo) => repo.clone(),
119131
};
120132

121133
// acquire: degit the chosen repo into the project path.
122134
acquire(&repo, &absolute_project_path).await?;
123135

136+
// Settle the framework choice before anything package-manager related:
137+
// the interactive prompt can pick "none", which has no JS to manage.
138+
let source = match source {
139+
Source::Official(None) => match select_framework(&absolute_project_path, self.yes)? {
140+
Some(framework) => Source::Official(Some(framework)),
141+
None => Source::NoFrontend,
142+
},
143+
other => other,
144+
};
145+
146+
if matches!(source, Source::NoFrontend) {
147+
// instantiate: strip the UI layer entirely; no package manager.
148+
instantiate::instantiate_no_frontend(&absolute_project_path)?;
149+
150+
// prepare: build contracts, git — no dependency install.
151+
setup::prepare(&absolute_project_path, None, global_args, self.yes).await?;
152+
153+
printer.blankln("\n\n");
154+
printer.checkln(format!(
155+
"Project successfully created at {}!",
156+
absolute_project_path.display()
157+
));
158+
printer.blankln(" You can now build your contracts with:\n");
159+
printer.blankln(format!("\tcd {}", self.project_path.display()));
160+
printer.blankln("\tstellar scaffold build");
161+
printer.blankln("\n Happy hacking! 🚀");
162+
return Ok(());
163+
}
164+
124165
// Gather the package-manager choice up front (both paths need it).
125166
// `--template` signals non-interactive intent (the framework is already
126167
// chosen), so default the package manager to npm rather than prompting;
@@ -134,22 +175,26 @@ impl Cmd {
134175

135176
// instantiate: official templates promote one framework + apply pkg-mgr fs.
136177
match &source {
137-
Source::Official(framework) => {
138-
let framework = match framework {
139-
Some(name) => name.clone(),
140-
None => select_framework(&absolute_project_path, self.yes)?,
141-
};
142-
instantiate::instantiate(&absolute_project_path, &framework)?;
178+
Source::Official(Some(framework)) => {
179+
instantiate::instantiate(&absolute_project_path, framework)?;
143180
instantiate::apply_package_manager(&absolute_project_path, &pkg_manager)?;
144181
}
145182
Source::Community(_) => {
146183
// Community repos own their own layout; just record the manager.
147184
let _ = pkg_manager.write_to_package_json(&absolute_project_path);
148185
}
186+
// Both settled above.
187+
Source::Official(None) | Source::NoFrontend => unreachable!(),
149188
}
150189

151190
// prepare: install, build, git.
152-
setup::prepare(&absolute_project_path, &pkg_manager, global_args, self.yes).await?;
191+
setup::prepare(
192+
&absolute_project_path,
193+
Some(&pkg_manager),
194+
global_args,
195+
self.yes,
196+
)
197+
.await?;
153198

154199
let pm_command = pkg_manager.kind.command();
155200
printer.blankln("\n\n");
@@ -189,21 +234,24 @@ async fn acquire(repo: &str, project_path: &Path) -> Result<(), Error> {
189234
}
190235

191236
/// Prompt for a framework from those available in the acquired monorepo, or pick
192-
/// the first when running non-interactively.
193-
fn select_framework(root: &Path, yes: bool) -> Result<String, Error> {
237+
/// the first when running non-interactively. Returns `None` when the user picks
238+
/// the "none" (no frontend) choice.
239+
fn select_framework(root: &Path, yes: bool) -> Result<Option<String>, Error> {
194240
let frameworks = instantiate::enumerate_templates(root)?;
195241
if frameworks.is_empty() {
196242
return Err(Error::Instantiate(instantiate::Error::NoTemplatesDir));
197243
}
198-
if yes || frameworks.len() == 1 {
199-
return Ok(frameworks[0].clone());
244+
if yes {
245+
return Ok(Some(frameworks[0].clone()));
200246
}
247+
let mut items = frameworks.clone();
248+
items.push(format!("{} (no frontend)", instantiate::NO_FRONTEND));
201249
let index = Select::with_theme(&ColorfulTheme::default())
202250
.with_prompt("Pick a framework")
203-
.items(&frameworks)
251+
.items(&items)
204252
.default(0)
205253
.interact()
206254
.ok()
207255
.ok_or(Error::Cancelled)?;
208-
Ok(frameworks[index].clone())
256+
Ok(frameworks.get(index).cloned())
209257
}

0 commit comments

Comments
 (0)