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

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,203 @@ 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 product_name = product_name(package_name);
451
+ let app_bundle_id = format!("com.electron.{package_name}");
452
+ let package = serde_json::json!({
453
+ "name": package_name,
454
+ "productName": product_name,
455
+ "version": "0.1.0",
456
+ "private": true,
457
+ "description": "Minimal Electron app generated by electron-cli",
458
+ "main": "src/main.js",
459
+ "scripts": {
460
+ "start": "electron .",
461
+ "smoke": "electron --version"
462
+ },
463
+ "devDependencies": {
464
+ "electron": electron_version
465
+ },
466
+ "electronCli": {
467
+ "packagerConfig": {
468
+ "appBundleId": app_bundle_id
469
+ }
470
+ }
471
+ });
472
+
473
+ Ok(serde_json::to_string_pretty(&package)?)
474
+ }
475
+
476
+ fn readme(package_name: &str, package_manager: PackageManager) -> String {
477
+ format!(
478
+ r#"# {package_name}
479
+
480
+ Minimal Electron app generated by electron-cli.
481
+
482
+ ## Scripts
483
+
484
+ ```sh
485
+ {install}
486
+ {start}
487
+ {smoke}
488
+ ```
489
+ "#,
490
+ install = install_command(package_manager),
491
+ start = start_command(package_manager),
492
+ smoke = run_script_command(package_manager, "smoke"),
493
+ )
494
+ }
495
+
496
+ fn ci_workflow(package_manager: PackageManager) -> String {
497
+ let setup = match package_manager {
498
+ PackageManager::Npm => {
499
+ " - uses: actions/setup-node@v6\n with:\n node-version: 22\n"
500
+ .to_string()
501
+ }
502
+ PackageManager::Pnpm | PackageManager::Yarn => {
503
+ " - uses: actions/setup-node@v6\n with:\n node-version: 22\n - run: corepack enable\n"
504
+ .to_string()
505
+ }
506
+ PackageManager::Bun => " - uses: oven-sh/setup-bun@v2\n".to_string(),
507
+ };
508
+
509
+ format!(
510
+ r#"name: CI
511
+
512
+ on:
513
+ pull_request:
514
+ push:
515
+ branches: [main]
516
+
517
+ jobs:
518
+ smoke:
519
+ runs-on: ubuntu-latest
520
+ steps:
521
+ - uses: actions/checkout@v6
522
+ {setup} - run: {install}
523
+ - run: {smoke}
524
+ "#,
525
+ setup = setup,
526
+ install = install_command(package_manager),
527
+ smoke = run_script_command(package_manager, "smoke"),
528
+ )
529
+ }
530
+
531
+ fn ensure_trailing_newline(contents: &str) -> String {
532
+ if contents.ends_with('\n') {
533
+ contents.to_string()
534
+ } else {
535
+ format!("{contents}\n")
536
+ }
537
+ }
538
+
539
+ fn template_kind(template: &str) -> InitTemplateKind {
540
+ if template == NATIVE_TEMPLATE_NAME {
541
+ InitTemplateKind::Native
542
+ } else {
543
+ InitTemplateKind::Forge
544
+ }
545
+ }
546
+
269
547
  fn detect_package_manager(cwd: &Path) -> PackageManager {
270
548
  if cwd.join("pnpm-lock.yaml").exists() {
271
549
  PackageManager::Pnpm
@@ -278,6 +556,15 @@ fn detect_package_manager(cwd: &Path) -> PackageManager {
278
556
  }
279
557
  }
280
558
 
559
+ fn install_command(package_manager: PackageManager) -> String {
560
+ match package_manager {
561
+ PackageManager::Npm => "npm install".to_string(),
562
+ PackageManager::Pnpm => "pnpm install".to_string(),
563
+ PackageManager::Yarn => "yarn install".to_string(),
564
+ PackageManager::Bun => "bun install".to_string(),
565
+ }
566
+ }
567
+
281
568
  fn start_command(package_manager: PackageManager) -> String {
282
569
  match package_manager {
283
570
  PackageManager::Npm => "npm start".to_string(),
@@ -287,6 +574,25 @@ fn start_command(package_manager: PackageManager) -> String {
287
574
  }
288
575
  }
289
576
 
577
+ fn run_script_command(package_manager: PackageManager, script: &str) -> String {
578
+ match package_manager {
579
+ PackageManager::Npm => format!("npm run {script}"),
580
+ PackageManager::Pnpm => format!("pnpm run {script}"),
581
+ PackageManager::Yarn => format!("yarn {script}"),
582
+ PackageManager::Bun => format!("bun run {script}"),
583
+ }
584
+ }
585
+
586
+ fn package_manager_from_str(package_manager: &str) -> Result<PackageManager> {
587
+ match package_manager {
588
+ "npm" => Ok(PackageManager::Npm),
589
+ "pnpm" => Ok(PackageManager::Pnpm),
590
+ "yarn" => Ok(PackageManager::Yarn),
591
+ "bun" => Ok(PackageManager::Bun),
592
+ _ => bail!("Unknown package manager in init plan: {package_manager}"),
593
+ }
594
+ }
595
+
290
596
  fn display_command(command: &[String]) -> String {
291
597
  command
292
598
  .iter()
@@ -310,6 +616,56 @@ fn path_arg(path: &Path) -> String {
310
616
  path.to_string_lossy().to_string()
311
617
  }
312
618
 
619
+ fn package_name(plan: &InitReport) -> String {
620
+ let raw_name = Path::new(plan.target_dir.as_str())
621
+ .file_name()
622
+ .and_then(|name| name.to_str())
623
+ .unwrap_or("electron-app");
624
+
625
+ sanitize_package_name(raw_name)
626
+ }
627
+
628
+ fn sanitize_package_name(raw_name: &str) -> String {
629
+ let name = raw_name
630
+ .to_ascii_lowercase()
631
+ .chars()
632
+ .map(|char| {
633
+ if char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.') {
634
+ char
635
+ } else {
636
+ '-'
637
+ }
638
+ })
639
+ .collect::<String>()
640
+ .trim_matches(['-', '.', '_'])
641
+ .to_string();
642
+
643
+ if name.is_empty() {
644
+ "electron-app".to_string()
645
+ } else {
646
+ name
647
+ }
648
+ }
649
+
650
+ fn product_name(package_name: &str) -> String {
651
+ package_name
652
+ .split(['-', '_', '.'])
653
+ .filter(|part| !part.is_empty())
654
+ .map(|part| {
655
+ let mut chars = part.chars();
656
+ match chars.next() {
657
+ Some(first) => {
658
+ let mut word = first.to_uppercase().collect::<String>();
659
+ word.push_str(chars.as_str());
660
+ word
661
+ }
662
+ None => String::new(),
663
+ }
664
+ })
665
+ .collect::<Vec<_>>()
666
+ .join(" ")
667
+ }
668
+
313
669
  fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
314
670
  Utf8PathBuf::from_path_buf(path).map_err(|path| {
315
671
  anyhow::anyhow!(
@@ -337,7 +693,7 @@ mod tests {
337
693
  let args = InitArgs {
338
694
  dir: PathBuf::from("my-app"),
339
695
  cwd: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
340
- template: "vite-typescript".to_string(),
696
+ template: "minimal".to_string(),
341
697
  package_manager: Some(PackageManager::Npm),
342
698
  electron_version: None,
343
699
  copy_ci_files: false,
@@ -349,27 +705,23 @@ mod tests {
349
705
 
350
706
  let plan = build_plan(&args).expect("plan should build");
351
707
 
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
- );
708
+ assert_eq!(plan.template_kind, InitTemplateKind::Native);
709
+ assert_eq!(plan.generator, "electron-cli");
710
+ assert!(plan.command.is_empty());
711
+ assert!(plan.create_files.contains(&"package.json".to_string()));
364
712
  assert_eq!(plan.package_manager, "npm");
365
713
  assert_eq!(
366
714
  plan.next_steps.first().map(String::as_str),
367
715
  Some("cd my-app")
368
716
  );
717
+ assert_eq!(
718
+ plan.next_steps.get(1).map(String::as_str),
719
+ Some("npm install")
720
+ );
369
721
  }
370
722
 
371
723
  #[test]
372
- fn includes_optional_create_flags() {
724
+ fn includes_optional_forge_create_flags() {
373
725
  let args = InitArgs {
374
726
  dir: PathBuf::from("desktop app"),
375
727
  cwd: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
@@ -385,6 +737,8 @@ mod tests {
385
737
 
386
738
  let plan = build_plan(&args).expect("plan should build");
387
739
 
740
+ assert_eq!(plan.template_kind, InitTemplateKind::Forge);
741
+ assert_eq!(plan.generator, "create-electron-app@latest");
388
742
  assert_eq!(plan.command[0], "pnpm");
389
743
  assert!(plan.command.contains(&"--copy-ci-files".to_string()));
390
744
  assert!(plan.command.contains(&"--force".to_string()));
@@ -395,4 +749,66 @@ mod tests {
395
749
  Some("pnpm start")
396
750
  );
397
751
  }
752
+
753
+ #[test]
754
+ fn writes_native_template_files() {
755
+ let cwd = unique_temp_dir();
756
+ let args = InitArgs {
757
+ dir: PathBuf::from("native app"),
758
+ cwd: cwd.clone(),
759
+ template: "minimal".to_string(),
760
+ package_manager: Some(PackageManager::Npm),
761
+ electron_version: Some("30.0.0".to_string()),
762
+ copy_ci_files: true,
763
+ force: false,
764
+ skip_git: true,
765
+ dry_run: false,
766
+ json: false,
767
+ };
768
+
769
+ let plan = build_plan(&args).expect("plan should build");
770
+ ensure_target_can_be_created(&plan, args.force).expect("target should be available");
771
+ execute_plan(&plan).expect("template should write");
772
+ write_project_config(&plan).expect("config should write");
773
+
774
+ let target = cwd.join("native app");
775
+ let package_json =
776
+ fs::read_to_string(target.join("package.json")).expect("package.json should exist");
777
+ let config =
778
+ fs::read_to_string(target.join(".electron-cli.json")).expect("config should exist");
779
+
780
+ assert!(target.join("src/main.js").exists());
781
+ assert!(target.join("src/preload.js").exists());
782
+ assert!(target.join("src/index.html").exists());
783
+ assert!(target.join("src/renderer.js").exists());
784
+ assert!(target.join(".github/workflows/ci.yml").exists());
785
+ assert!(package_json.contains("\"name\": \"native-app\""));
786
+ assert!(package_json.contains("\"productName\": \"Native App\""));
787
+ assert!(package_json.contains("\"electron\": \"30.0.0\""));
788
+ assert!(package_json.contains("\"appBundleId\": \"com.electron.native-app\""));
789
+ assert!(config.contains("\"generator\": \"electron-cli\""));
790
+
791
+ let _ = fs::remove_dir_all(cwd);
792
+ }
793
+
794
+ #[test]
795
+ fn sanitizes_package_names() {
796
+ assert_eq!(sanitize_package_name("Native App"), "native-app");
797
+ assert_eq!(sanitize_package_name("..."), "electron-app");
798
+ assert_eq!(sanitize_package_name("@Scope/App"), "scope-app");
799
+ assert_eq!(product_name("native-app"), "Native App");
800
+ }
801
+
802
+ fn unique_temp_dir() -> PathBuf {
803
+ let nanos = std::time::SystemTime::now()
804
+ .duration_since(std::time::UNIX_EPOCH)
805
+ .expect("clock should be after epoch")
806
+ .as_nanos();
807
+ let path = std::env::temp_dir().join(format!(
808
+ "electron-cli-init-test-{}-{nanos}",
809
+ std::process::id()
810
+ ));
811
+ fs::create_dir_all(&path).expect("temp dir should be created");
812
+ path
813
+ }
398
814
  }