electron-cli 0.3.0-alpha.2 → 0.3.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,22 +13,40 @@ use crate::{
13
13
  output,
14
14
  };
15
15
 
16
+ const NATIVE_TEMPLATE_NAME: &str = "minimal";
17
+ const MAIN_JS: &str = include_str!("../../templates/minimal/src/main.js");
18
+ const PRELOAD_JS: &str = include_str!("../../templates/minimal/src/preload.js");
19
+ const INDEX_HTML: &str = include_str!("../../templates/minimal/src/index.html");
20
+ const RENDERER_JS: &str = include_str!("../../templates/minimal/src/renderer.js");
21
+ const GITIGNORE: &str = include_str!("../../templates/minimal/gitignore");
22
+
16
23
  #[derive(Debug, Serialize)]
17
24
  struct InitReport {
18
25
  cwd: Utf8PathBuf,
19
26
  target_dir: Utf8PathBuf,
20
27
  target_arg: String,
21
28
  template: String,
29
+ template_kind: InitTemplateKind,
30
+ generator: String,
22
31
  package_manager: String,
32
+ electron_version: Option<String>,
23
33
  dry_run: bool,
24
34
  command: Vec<String>,
25
35
  command_display: String,
36
+ create_files: Vec<String>,
26
37
  post_create_files: Vec<String>,
27
38
  next_steps: Vec<String>,
28
39
  warnings: Vec<String>,
29
40
  status: InitStatus,
30
41
  }
31
42
 
43
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44
+ #[serde(rename_all = "kebab-case")]
45
+ enum InitTemplateKind {
46
+ Native,
47
+ Forge,
48
+ }
49
+
32
50
  #[derive(Debug, Serialize)]
33
51
  #[serde(rename_all = "kebab-case")]
34
52
  enum InitStatus {
@@ -39,9 +57,10 @@ enum InitStatus {
39
57
  #[derive(Debug, Serialize)]
40
58
  struct ElectronCliConfig {
41
59
  version: &'static str,
42
- generator: &'static str,
60
+ generator: String,
43
61
  template: String,
44
62
  package_manager: String,
63
+ electron_version: Option<String>,
45
64
  }
46
65
 
47
66
  pub fn run(args: InitArgs) -> Result<()> {
@@ -79,8 +98,31 @@ fn build_plan(args: &InitArgs) -> Result<InitReport> {
79
98
  let package_manager = args
80
99
  .package_manager
81
100
  .unwrap_or_else(|| detect_package_manager(&cwd));
82
- let command = create_command(package_manager, &target_arg, args);
83
- let command_display = display_command(&command);
101
+ let template_kind = template_kind(&args.template);
102
+ let electron_version = match template_kind {
103
+ InitTemplateKind::Native => Some(
104
+ args.electron_version
105
+ .clone()
106
+ .unwrap_or_else(|| "latest".to_string()),
107
+ ),
108
+ InitTemplateKind::Forge => args.electron_version.clone(),
109
+ };
110
+ let command = match template_kind {
111
+ InitTemplateKind::Native => Vec::new(),
112
+ InitTemplateKind::Forge => create_forge_command(package_manager, &target_arg, args),
113
+ };
114
+ let command_display = match template_kind {
115
+ InitTemplateKind::Native => "write built-in minimal template files".to_string(),
116
+ InitTemplateKind::Forge => display_command(&command),
117
+ };
118
+ let generator = match template_kind {
119
+ InitTemplateKind::Native => "electron-cli".to_string(),
120
+ InitTemplateKind::Forge => "create-electron-app@latest".to_string(),
121
+ };
122
+ let create_files = match template_kind {
123
+ InitTemplateKind::Native => native_template_paths(args.copy_ci_files),
124
+ InitTemplateKind::Forge => Vec::new(),
125
+ };
84
126
  let target_label = target_arg.clone();
85
127
 
86
128
  let mut warnings = Vec::new();
@@ -92,31 +134,46 @@ fn build_plan(args: &InitArgs) -> Result<InitReport> {
92
134
  }
93
135
 
94
136
  if target_dir.exists() && !args.force {
95
- warnings
96
- .push("Use --force to allow create-electron-app to overwrite the target.".to_string());
137
+ let force_message = match template_kind {
138
+ InitTemplateKind::Native => {
139
+ "Use --force to overwrite files generated by the native template."
140
+ }
141
+ InitTemplateKind::Forge => {
142
+ "Use --force to allow create-electron-app to overwrite the target."
143
+ }
144
+ };
145
+ warnings.push(force_message.to_string());
146
+ }
147
+
148
+ let mut next_steps = Vec::new();
149
+ next_steps.push(format!("cd {target_label}"));
150
+ if template_kind == InitTemplateKind::Native {
151
+ next_steps.push(install_command(package_manager));
97
152
  }
153
+ next_steps.push(start_command(package_manager));
154
+ next_steps.push("electron-cli doctor --json".to_string());
98
155
 
99
156
  Ok(InitReport {
100
157
  cwd: utf8_path(cwd)?,
101
158
  target_dir: utf8_path(target_dir)?,
102
159
  target_arg,
103
160
  template: args.template.clone(),
161
+ template_kind,
162
+ generator,
104
163
  package_manager: package_manager.as_str().to_string(),
164
+ electron_version,
105
165
  dry_run: args.dry_run,
106
166
  command,
107
167
  command_display,
168
+ create_files,
108
169
  post_create_files: vec![".electron-cli.json".to_string()],
109
- next_steps: vec![
110
- format!("cd {target_label}"),
111
- start_command(package_manager),
112
- "electron-cli doctor --json".to_string(),
113
- ],
170
+ next_steps,
114
171
  warnings,
115
172
  status: InitStatus::Planned,
116
173
  })
117
174
  }
118
175
 
119
- fn create_command(
176
+ fn create_forge_command(
120
177
  package_manager: PackageManager,
121
178
  target_arg: &str,
122
179
  args: &InitArgs,
@@ -171,6 +228,13 @@ fn create_command(
171
228
  }
172
229
 
173
230
  fn execute_plan(plan: &InitReport) -> Result<()> {
231
+ match plan.template_kind {
232
+ InitTemplateKind::Native => write_native_template(plan),
233
+ InitTemplateKind::Forge => execute_forge_plan(plan),
234
+ }
235
+ }
236
+
237
+ fn execute_forge_plan(plan: &InitReport) -> Result<()> {
174
238
  let (program, args) = plan
175
239
  .command
176
240
  .split_first()
@@ -195,6 +259,10 @@ fn execute_plan(plan: &InitReport) -> Result<()> {
195
259
  fn ensure_target_can_be_created(plan: &InitReport, force: bool) -> Result<()> {
196
260
  let target = Path::new(plan.target_dir.as_str());
197
261
 
262
+ if target.exists() && !target.is_dir() {
263
+ bail!("Target exists but is not a directory: {}", plan.target_dir);
264
+ }
265
+
198
266
  if target.exists() && !force {
199
267
  bail!(
200
268
  "Target directory already exists: {}. Use --force to overwrite it.",
@@ -208,9 +276,10 @@ fn ensure_target_can_be_created(plan: &InitReport, force: bool) -> Result<()> {
208
276
  fn write_project_config(plan: &InitReport) -> Result<()> {
209
277
  let config = ElectronCliConfig {
210
278
  version: env!("CARGO_PKG_VERSION"),
211
- generator: "create-electron-app@latest",
279
+ generator: plan.generator.clone(),
212
280
  template: plan.template.clone(),
213
281
  package_manager: plan.package_manager.clone(),
282
+ electron_version: plan.electron_version.clone(),
214
283
  };
215
284
  let config_path = Path::new(plan.target_dir.as_str()).join(".electron-cli.json");
216
285
  let json = serde_json::to_string_pretty(&config)?;
@@ -232,13 +301,25 @@ fn print_report(report: &InitReport, json: bool) -> Result<()> {
232
301
  println!(" cwd: {}", report.cwd);
233
302
  println!(" target: {}", report.target_dir);
234
303
  println!(" template: {}", report.template);
304
+ println!(" generator: {}", report.generator);
235
305
  println!(" package manager: {}", report.package_manager);
306
+ if let Some(electron_version) = &report.electron_version {
307
+ println!(" electron: {electron_version}");
308
+ }
236
309
  println!(" status: {}", report.status.as_str());
237
310
 
238
311
  println!();
239
- println!("Command");
312
+ println!("Action");
240
313
  println!(" {}", report.command_display);
241
314
 
315
+ if !report.create_files.is_empty() {
316
+ println!();
317
+ println!("Create Files");
318
+ for file in &report.create_files {
319
+ println!(" {file}");
320
+ }
321
+ }
322
+
242
323
  if !report.post_create_files.is_empty() {
243
324
  println!();
244
325
  println!("Post-Create Files");
@@ -266,6 +347,195 @@ fn print_report(report: &InitReport, json: bool) -> Result<()> {
266
347
  Ok(())
267
348
  }
268
349
 
350
+ fn write_native_template(plan: &InitReport) -> Result<()> {
351
+ let target = Path::new(plan.target_dir.as_str());
352
+ fs::create_dir_all(target).with_context(|| format!("Could not create {}", target.display()))?;
353
+
354
+ for file in native_template_files(plan)? {
355
+ write_template_file(target, file.path, &file.contents)?;
356
+ }
357
+
358
+ Ok(())
359
+ }
360
+
361
+ struct TemplateFile {
362
+ path: &'static str,
363
+ contents: String,
364
+ }
365
+
366
+ fn native_template_files(plan: &InitReport) -> Result<Vec<TemplateFile>> {
367
+ let package_name = package_name(plan);
368
+ let electron_version = plan.electron_version.as_deref().unwrap_or("latest");
369
+ let package_manager = package_manager_from_str(&plan.package_manager)?;
370
+ let mut files = vec![
371
+ TemplateFile {
372
+ path: "package.json",
373
+ contents: package_json(&package_name, electron_version)?,
374
+ },
375
+ TemplateFile {
376
+ path: "README.md",
377
+ contents: readme(&package_name, package_manager),
378
+ },
379
+ TemplateFile {
380
+ path: ".gitignore",
381
+ contents: GITIGNORE.to_string(),
382
+ },
383
+ TemplateFile {
384
+ path: "src/main.js",
385
+ contents: MAIN_JS.to_string(),
386
+ },
387
+ TemplateFile {
388
+ path: "src/preload.js",
389
+ contents: PRELOAD_JS.to_string(),
390
+ },
391
+ TemplateFile {
392
+ path: "src/index.html",
393
+ contents: INDEX_HTML.to_string(),
394
+ },
395
+ TemplateFile {
396
+ path: "src/renderer.js",
397
+ contents: RENDERER_JS.to_string(),
398
+ },
399
+ ];
400
+
401
+ if plan
402
+ .create_files
403
+ .iter()
404
+ .any(|path| path == ".github/workflows/ci.yml")
405
+ {
406
+ files.push(TemplateFile {
407
+ path: ".github/workflows/ci.yml",
408
+ contents: ci_workflow(package_manager),
409
+ });
410
+ }
411
+
412
+ Ok(files)
413
+ }
414
+
415
+ fn native_template_paths(copy_ci_files: bool) -> Vec<String> {
416
+ let mut files = vec![
417
+ "package.json",
418
+ "README.md",
419
+ ".gitignore",
420
+ "src/main.js",
421
+ "src/preload.js",
422
+ "src/index.html",
423
+ "src/renderer.js",
424
+ ]
425
+ .into_iter()
426
+ .map(str::to_string)
427
+ .collect::<Vec<_>>();
428
+
429
+ if copy_ci_files {
430
+ files.push(".github/workflows/ci.yml".to_string());
431
+ }
432
+
433
+ files
434
+ }
435
+
436
+ fn write_template_file(target: &Path, relative_path: &str, contents: &str) -> Result<()> {
437
+ let path = target.join(relative_path);
438
+ if let Some(parent) = path.parent() {
439
+ fs::create_dir_all(parent)
440
+ .with_context(|| format!("Could not create {}", parent.display()))?;
441
+ }
442
+
443
+ fs::write(&path, ensure_trailing_newline(contents))
444
+ .with_context(|| format!("Could not write {}", path.display()))?;
445
+
446
+ Ok(())
447
+ }
448
+
449
+ fn package_json(package_name: &str, electron_version: &str) -> Result<String> {
450
+ let package = serde_json::json!({
451
+ "name": package_name,
452
+ "version": "0.1.0",
453
+ "private": true,
454
+ "description": "Minimal Electron app generated by electron-cli",
455
+ "main": "src/main.js",
456
+ "scripts": {
457
+ "start": "electron .",
458
+ "smoke": "electron --version"
459
+ },
460
+ "devDependencies": {
461
+ "electron": electron_version
462
+ }
463
+ });
464
+
465
+ Ok(serde_json::to_string_pretty(&package)?)
466
+ }
467
+
468
+ fn readme(package_name: &str, package_manager: PackageManager) -> String {
469
+ format!(
470
+ r#"# {package_name}
471
+
472
+ Minimal Electron app generated by electron-cli.
473
+
474
+ ## Scripts
475
+
476
+ ```sh
477
+ {install}
478
+ {start}
479
+ {smoke}
480
+ ```
481
+ "#,
482
+ install = install_command(package_manager),
483
+ start = start_command(package_manager),
484
+ smoke = run_script_command(package_manager, "smoke"),
485
+ )
486
+ }
487
+
488
+ fn ci_workflow(package_manager: PackageManager) -> String {
489
+ let setup = match package_manager {
490
+ PackageManager::Npm => {
491
+ " - uses: actions/setup-node@v6\n with:\n node-version: 22\n"
492
+ .to_string()
493
+ }
494
+ PackageManager::Pnpm | PackageManager::Yarn => {
495
+ " - uses: actions/setup-node@v6\n with:\n node-version: 22\n - run: corepack enable\n"
496
+ .to_string()
497
+ }
498
+ PackageManager::Bun => " - uses: oven-sh/setup-bun@v2\n".to_string(),
499
+ };
500
+
501
+ format!(
502
+ r#"name: CI
503
+
504
+ on:
505
+ pull_request:
506
+ push:
507
+ branches: [main]
508
+
509
+ jobs:
510
+ smoke:
511
+ runs-on: ubuntu-latest
512
+ steps:
513
+ - uses: actions/checkout@v6
514
+ {setup} - run: {install}
515
+ - run: {smoke}
516
+ "#,
517
+ setup = setup,
518
+ install = install_command(package_manager),
519
+ smoke = run_script_command(package_manager, "smoke"),
520
+ )
521
+ }
522
+
523
+ fn ensure_trailing_newline(contents: &str) -> String {
524
+ if contents.ends_with('\n') {
525
+ contents.to_string()
526
+ } else {
527
+ format!("{contents}\n")
528
+ }
529
+ }
530
+
531
+ fn template_kind(template: &str) -> InitTemplateKind {
532
+ if template == NATIVE_TEMPLATE_NAME {
533
+ InitTemplateKind::Native
534
+ } else {
535
+ InitTemplateKind::Forge
536
+ }
537
+ }
538
+
269
539
  fn detect_package_manager(cwd: &Path) -> PackageManager {
270
540
  if cwd.join("pnpm-lock.yaml").exists() {
271
541
  PackageManager::Pnpm
@@ -278,6 +548,15 @@ fn detect_package_manager(cwd: &Path) -> PackageManager {
278
548
  }
279
549
  }
280
550
 
551
+ fn install_command(package_manager: PackageManager) -> String {
552
+ match package_manager {
553
+ PackageManager::Npm => "npm install".to_string(),
554
+ PackageManager::Pnpm => "pnpm install".to_string(),
555
+ PackageManager::Yarn => "yarn install".to_string(),
556
+ PackageManager::Bun => "bun install".to_string(),
557
+ }
558
+ }
559
+
281
560
  fn start_command(package_manager: PackageManager) -> String {
282
561
  match package_manager {
283
562
  PackageManager::Npm => "npm start".to_string(),
@@ -287,6 +566,25 @@ fn start_command(package_manager: PackageManager) -> String {
287
566
  }
288
567
  }
289
568
 
569
+ fn run_script_command(package_manager: PackageManager, script: &str) -> String {
570
+ match package_manager {
571
+ PackageManager::Npm => format!("npm run {script}"),
572
+ PackageManager::Pnpm => format!("pnpm run {script}"),
573
+ PackageManager::Yarn => format!("yarn {script}"),
574
+ PackageManager::Bun => format!("bun run {script}"),
575
+ }
576
+ }
577
+
578
+ fn package_manager_from_str(package_manager: &str) -> Result<PackageManager> {
579
+ match package_manager {
580
+ "npm" => Ok(PackageManager::Npm),
581
+ "pnpm" => Ok(PackageManager::Pnpm),
582
+ "yarn" => Ok(PackageManager::Yarn),
583
+ "bun" => Ok(PackageManager::Bun),
584
+ _ => bail!("Unknown package manager in init plan: {package_manager}"),
585
+ }
586
+ }
587
+
290
588
  fn display_command(command: &[String]) -> String {
291
589
  command
292
590
  .iter()
@@ -310,6 +608,37 @@ fn path_arg(path: &Path) -> String {
310
608
  path.to_string_lossy().to_string()
311
609
  }
312
610
 
611
+ fn package_name(plan: &InitReport) -> String {
612
+ let raw_name = Path::new(plan.target_dir.as_str())
613
+ .file_name()
614
+ .and_then(|name| name.to_str())
615
+ .unwrap_or("electron-app");
616
+
617
+ sanitize_package_name(raw_name)
618
+ }
619
+
620
+ fn sanitize_package_name(raw_name: &str) -> String {
621
+ let name = raw_name
622
+ .to_ascii_lowercase()
623
+ .chars()
624
+ .map(|char| {
625
+ if char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.') {
626
+ char
627
+ } else {
628
+ '-'
629
+ }
630
+ })
631
+ .collect::<String>()
632
+ .trim_matches(['-', '.', '_'])
633
+ .to_string();
634
+
635
+ if name.is_empty() {
636
+ "electron-app".to_string()
637
+ } else {
638
+ name
639
+ }
640
+ }
641
+
313
642
  fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
314
643
  Utf8PathBuf::from_path_buf(path).map_err(|path| {
315
644
  anyhow::anyhow!(
@@ -337,7 +666,7 @@ mod tests {
337
666
  let args = InitArgs {
338
667
  dir: PathBuf::from("my-app"),
339
668
  cwd: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
340
- template: "vite-typescript".to_string(),
669
+ template: "minimal".to_string(),
341
670
  package_manager: Some(PackageManager::Npm),
342
671
  electron_version: None,
343
672
  copy_ci_files: false,
@@ -349,27 +678,23 @@ mod tests {
349
678
 
350
679
  let plan = build_plan(&args).expect("plan should build");
351
680
 
352
- assert_eq!(
353
- plan.command,
354
- vec![
355
- "npx",
356
- "-y",
357
- "create-electron-app@latest",
358
- "my-app",
359
- "--template",
360
- "vite-typescript",
361
- "--skip-git",
362
- ]
363
- );
681
+ assert_eq!(plan.template_kind, InitTemplateKind::Native);
682
+ assert_eq!(plan.generator, "electron-cli");
683
+ assert!(plan.command.is_empty());
684
+ assert!(plan.create_files.contains(&"package.json".to_string()));
364
685
  assert_eq!(plan.package_manager, "npm");
365
686
  assert_eq!(
366
687
  plan.next_steps.first().map(String::as_str),
367
688
  Some("cd my-app")
368
689
  );
690
+ assert_eq!(
691
+ plan.next_steps.get(1).map(String::as_str),
692
+ Some("npm install")
693
+ );
369
694
  }
370
695
 
371
696
  #[test]
372
- fn includes_optional_create_flags() {
697
+ fn includes_optional_forge_create_flags() {
373
698
  let args = InitArgs {
374
699
  dir: PathBuf::from("desktop app"),
375
700
  cwd: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
@@ -385,6 +710,8 @@ mod tests {
385
710
 
386
711
  let plan = build_plan(&args).expect("plan should build");
387
712
 
713
+ assert_eq!(plan.template_kind, InitTemplateKind::Forge);
714
+ assert_eq!(plan.generator, "create-electron-app@latest");
388
715
  assert_eq!(plan.command[0], "pnpm");
389
716
  assert!(plan.command.contains(&"--copy-ci-files".to_string()));
390
717
  assert!(plan.command.contains(&"--force".to_string()));
@@ -395,4 +722,63 @@ mod tests {
395
722
  Some("pnpm start")
396
723
  );
397
724
  }
725
+
726
+ #[test]
727
+ fn writes_native_template_files() {
728
+ let cwd = unique_temp_dir();
729
+ let args = InitArgs {
730
+ dir: PathBuf::from("native app"),
731
+ cwd: cwd.clone(),
732
+ template: "minimal".to_string(),
733
+ package_manager: Some(PackageManager::Npm),
734
+ electron_version: Some("30.0.0".to_string()),
735
+ copy_ci_files: true,
736
+ force: false,
737
+ skip_git: true,
738
+ dry_run: false,
739
+ json: false,
740
+ };
741
+
742
+ let plan = build_plan(&args).expect("plan should build");
743
+ ensure_target_can_be_created(&plan, args.force).expect("target should be available");
744
+ execute_plan(&plan).expect("template should write");
745
+ write_project_config(&plan).expect("config should write");
746
+
747
+ let target = cwd.join("native app");
748
+ let package_json =
749
+ fs::read_to_string(target.join("package.json")).expect("package.json should exist");
750
+ let config =
751
+ fs::read_to_string(target.join(".electron-cli.json")).expect("config should exist");
752
+
753
+ assert!(target.join("src/main.js").exists());
754
+ assert!(target.join("src/preload.js").exists());
755
+ assert!(target.join("src/index.html").exists());
756
+ assert!(target.join("src/renderer.js").exists());
757
+ assert!(target.join(".github/workflows/ci.yml").exists());
758
+ assert!(package_json.contains("\"name\": \"native-app\""));
759
+ assert!(package_json.contains("\"electron\": \"30.0.0\""));
760
+ assert!(config.contains("\"generator\": \"electron-cli\""));
761
+
762
+ let _ = fs::remove_dir_all(cwd);
763
+ }
764
+
765
+ #[test]
766
+ fn sanitizes_package_names() {
767
+ assert_eq!(sanitize_package_name("Native App"), "native-app");
768
+ assert_eq!(sanitize_package_name("..."), "electron-app");
769
+ assert_eq!(sanitize_package_name("@Scope/App"), "scope-app");
770
+ }
771
+
772
+ fn unique_temp_dir() -> PathBuf {
773
+ let nanos = std::time::SystemTime::now()
774
+ .duration_since(std::time::UNIX_EPOCH)
775
+ .expect("clock should be after epoch")
776
+ .as_nanos();
777
+ let path = std::env::temp_dir().join(format!(
778
+ "electron-cli-init-test-{}-{nanos}",
779
+ std::process::id()
780
+ ));
781
+ fs::create_dir_all(&path).expect("temp dir should be created");
782
+ path
783
+ }
398
784
  }