electron-cli 0.3.0-alpha.1 → 0.3.0-alpha.3

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.
@@ -0,0 +1,784 @@
1
+ use std::{
2
+ fs,
3
+ path::{Path, PathBuf},
4
+ process::Command,
5
+ };
6
+
7
+ use anyhow::{bail, Context, Result};
8
+ use camino::Utf8PathBuf;
9
+ use serde::Serialize;
10
+
11
+ use crate::{
12
+ cli::{InitArgs, PackageManager},
13
+ output,
14
+ };
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
+
23
+ #[derive(Debug, Serialize)]
24
+ struct InitReport {
25
+ cwd: Utf8PathBuf,
26
+ target_dir: Utf8PathBuf,
27
+ target_arg: String,
28
+ template: String,
29
+ template_kind: InitTemplateKind,
30
+ generator: String,
31
+ package_manager: String,
32
+ electron_version: Option<String>,
33
+ dry_run: bool,
34
+ command: Vec<String>,
35
+ command_display: String,
36
+ create_files: Vec<String>,
37
+ post_create_files: Vec<String>,
38
+ next_steps: Vec<String>,
39
+ warnings: Vec<String>,
40
+ status: InitStatus,
41
+ }
42
+
43
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44
+ #[serde(rename_all = "kebab-case")]
45
+ enum InitTemplateKind {
46
+ Native,
47
+ Forge,
48
+ }
49
+
50
+ #[derive(Debug, Serialize)]
51
+ #[serde(rename_all = "kebab-case")]
52
+ enum InitStatus {
53
+ Planned,
54
+ Created,
55
+ }
56
+
57
+ #[derive(Debug, Serialize)]
58
+ struct ElectronCliConfig {
59
+ version: &'static str,
60
+ generator: String,
61
+ template: String,
62
+ package_manager: String,
63
+ electron_version: Option<String>,
64
+ }
65
+
66
+ pub fn run(args: InitArgs) -> Result<()> {
67
+ let plan = build_plan(&args)?;
68
+
69
+ if args.dry_run {
70
+ return print_report(&plan, args.json);
71
+ }
72
+
73
+ ensure_target_can_be_created(&plan, args.force)?;
74
+ execute_plan(&plan)?;
75
+ write_project_config(&plan)?;
76
+
77
+ let report = InitReport {
78
+ status: InitStatus::Created,
79
+ ..plan
80
+ };
81
+
82
+ print_report(&report, args.json)
83
+ }
84
+
85
+ fn build_plan(args: &InitArgs) -> Result<InitReport> {
86
+ let cwd = args
87
+ .cwd
88
+ .canonicalize()
89
+ .with_context(|| format!("Could not resolve {}", args.cwd.display()))?;
90
+
91
+ let target_dir = if args.dir.is_absolute() {
92
+ args.dir.clone()
93
+ } else {
94
+ cwd.join(&args.dir)
95
+ };
96
+
97
+ let target_arg = path_arg(&args.dir);
98
+ let package_manager = args
99
+ .package_manager
100
+ .unwrap_or_else(|| detect_package_manager(&cwd));
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
+ };
126
+ let target_label = target_arg.clone();
127
+
128
+ let mut warnings = Vec::new();
129
+ if target_dir.exists() {
130
+ warnings.push(format!(
131
+ "Target directory already exists: {}",
132
+ target_dir.display()
133
+ ));
134
+ }
135
+
136
+ if target_dir.exists() && !args.force {
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));
152
+ }
153
+ next_steps.push(start_command(package_manager));
154
+ next_steps.push("electron-cli doctor --json".to_string());
155
+
156
+ Ok(InitReport {
157
+ cwd: utf8_path(cwd)?,
158
+ target_dir: utf8_path(target_dir)?,
159
+ target_arg,
160
+ template: args.template.clone(),
161
+ template_kind,
162
+ generator,
163
+ package_manager: package_manager.as_str().to_string(),
164
+ electron_version,
165
+ dry_run: args.dry_run,
166
+ command,
167
+ command_display,
168
+ create_files,
169
+ post_create_files: vec![".electron-cli.json".to_string()],
170
+ next_steps,
171
+ warnings,
172
+ status: InitStatus::Planned,
173
+ })
174
+ }
175
+
176
+ fn create_forge_command(
177
+ package_manager: PackageManager,
178
+ target_arg: &str,
179
+ args: &InitArgs,
180
+ ) -> Vec<String> {
181
+ let mut command = match package_manager {
182
+ PackageManager::Npm => vec![
183
+ "npx".to_string(),
184
+ "-y".to_string(),
185
+ "create-electron-app@latest".to_string(),
186
+ target_arg.to_string(),
187
+ ],
188
+ PackageManager::Pnpm => vec![
189
+ "pnpm".to_string(),
190
+ "dlx".to_string(),
191
+ "create-electron-app@latest".to_string(),
192
+ target_arg.to_string(),
193
+ ],
194
+ PackageManager::Yarn => vec![
195
+ "yarn".to_string(),
196
+ "dlx".to_string(),
197
+ "create-electron-app@latest".to_string(),
198
+ target_arg.to_string(),
199
+ ],
200
+ PackageManager::Bun => vec![
201
+ "bunx".to_string(),
202
+ "create-electron-app@latest".to_string(),
203
+ target_arg.to_string(),
204
+ ],
205
+ };
206
+
207
+ command.push("--template".to_string());
208
+ command.push(args.template.clone());
209
+
210
+ if args.copy_ci_files {
211
+ command.push("--copy-ci-files".to_string());
212
+ }
213
+
214
+ if args.force {
215
+ command.push("--force".to_string());
216
+ }
217
+
218
+ if args.skip_git {
219
+ command.push("--skip-git".to_string());
220
+ }
221
+
222
+ if let Some(electron_version) = &args.electron_version {
223
+ command.push("--electron-version".to_string());
224
+ command.push(electron_version.clone());
225
+ }
226
+
227
+ command
228
+ }
229
+
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<()> {
238
+ let (program, args) = plan
239
+ .command
240
+ .split_first()
241
+ .context("Init command could not be constructed")?;
242
+
243
+ let status = Command::new(program)
244
+ .args(args)
245
+ .current_dir(&plan.cwd)
246
+ .status()
247
+ .with_context(|| format!("Could not execute {}", plan.command_display))?;
248
+
249
+ if !status.success() {
250
+ bail!(
251
+ "Init command failed with {status}: {}",
252
+ plan.command_display
253
+ );
254
+ }
255
+
256
+ Ok(())
257
+ }
258
+
259
+ fn ensure_target_can_be_created(plan: &InitReport, force: bool) -> Result<()> {
260
+ let target = Path::new(plan.target_dir.as_str());
261
+
262
+ if target.exists() && !target.is_dir() {
263
+ bail!("Target exists but is not a directory: {}", plan.target_dir);
264
+ }
265
+
266
+ if target.exists() && !force {
267
+ bail!(
268
+ "Target directory already exists: {}. Use --force to overwrite it.",
269
+ plan.target_dir
270
+ );
271
+ }
272
+
273
+ Ok(())
274
+ }
275
+
276
+ fn write_project_config(plan: &InitReport) -> Result<()> {
277
+ let config = ElectronCliConfig {
278
+ version: env!("CARGO_PKG_VERSION"),
279
+ generator: plan.generator.clone(),
280
+ template: plan.template.clone(),
281
+ package_manager: plan.package_manager.clone(),
282
+ electron_version: plan.electron_version.clone(),
283
+ };
284
+ let config_path = Path::new(plan.target_dir.as_str()).join(".electron-cli.json");
285
+ let json = serde_json::to_string_pretty(&config)?;
286
+
287
+ fs::write(&config_path, format!("{json}\n"))
288
+ .with_context(|| format!("Could not write {}", config_path.display()))?;
289
+
290
+ Ok(())
291
+ }
292
+
293
+ fn print_report(report: &InitReport, json: bool) -> Result<()> {
294
+ if json {
295
+ return output::json(report);
296
+ }
297
+
298
+ println!("electron-cli init");
299
+ println!();
300
+ println!("Project");
301
+ println!(" cwd: {}", report.cwd);
302
+ println!(" target: {}", report.target_dir);
303
+ println!(" template: {}", report.template);
304
+ println!(" generator: {}", report.generator);
305
+ println!(" package manager: {}", report.package_manager);
306
+ if let Some(electron_version) = &report.electron_version {
307
+ println!(" electron: {electron_version}");
308
+ }
309
+ println!(" status: {}", report.status.as_str());
310
+
311
+ println!();
312
+ println!("Action");
313
+ println!(" {}", report.command_display);
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
+
323
+ if !report.post_create_files.is_empty() {
324
+ println!();
325
+ println!("Post-Create Files");
326
+ for file in &report.post_create_files {
327
+ println!(" {file}");
328
+ }
329
+ }
330
+
331
+ if !report.next_steps.is_empty() {
332
+ println!();
333
+ println!("Next Steps");
334
+ for step in &report.next_steps {
335
+ println!(" {step}");
336
+ }
337
+ }
338
+
339
+ if !report.warnings.is_empty() {
340
+ println!();
341
+ println!("Warnings");
342
+ for warning in &report.warnings {
343
+ println!(" {warning}");
344
+ }
345
+ }
346
+
347
+ Ok(())
348
+ }
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
+
539
+ fn detect_package_manager(cwd: &Path) -> PackageManager {
540
+ if cwd.join("pnpm-lock.yaml").exists() {
541
+ PackageManager::Pnpm
542
+ } else if cwd.join("yarn.lock").exists() {
543
+ PackageManager::Yarn
544
+ } else if cwd.join("bun.lock").exists() || cwd.join("bun.lockb").exists() {
545
+ PackageManager::Bun
546
+ } else {
547
+ PackageManager::Npm
548
+ }
549
+ }
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
+
560
+ fn start_command(package_manager: PackageManager) -> String {
561
+ match package_manager {
562
+ PackageManager::Npm => "npm start".to_string(),
563
+ PackageManager::Pnpm => "pnpm start".to_string(),
564
+ PackageManager::Yarn => "yarn start".to_string(),
565
+ PackageManager::Bun => "bun run start".to_string(),
566
+ }
567
+ }
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
+
588
+ fn display_command(command: &[String]) -> String {
589
+ command
590
+ .iter()
591
+ .map(|arg| shell_quote(arg))
592
+ .collect::<Vec<_>>()
593
+ .join(" ")
594
+ }
595
+
596
+ fn shell_quote(value: &str) -> String {
597
+ if value
598
+ .chars()
599
+ .all(|char| char.is_ascii_alphanumeric() || matches!(char, '.' | '/' | '-' | '_' | '@'))
600
+ {
601
+ value.to_string()
602
+ } else {
603
+ format!("'{}'", value.replace('\'', "'\\''"))
604
+ }
605
+ }
606
+
607
+ fn path_arg(path: &Path) -> String {
608
+ path.to_string_lossy().to_string()
609
+ }
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
+
642
+ fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
643
+ Utf8PathBuf::from_path_buf(path).map_err(|path| {
644
+ anyhow::anyhow!(
645
+ "Path contains invalid UTF-8 and cannot be represented in JSON: {}",
646
+ path.display()
647
+ )
648
+ })
649
+ }
650
+
651
+ impl InitStatus {
652
+ fn as_str(&self) -> &'static str {
653
+ match self {
654
+ InitStatus::Planned => "planned",
655
+ InitStatus::Created => "created",
656
+ }
657
+ }
658
+ }
659
+
660
+ #[cfg(test)]
661
+ mod tests {
662
+ use super::*;
663
+
664
+ #[test]
665
+ fn builds_default_npm_init_plan() {
666
+ let args = InitArgs {
667
+ dir: PathBuf::from("my-app"),
668
+ cwd: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
669
+ template: "minimal".to_string(),
670
+ package_manager: Some(PackageManager::Npm),
671
+ electron_version: None,
672
+ copy_ci_files: false,
673
+ force: false,
674
+ skip_git: true,
675
+ dry_run: true,
676
+ json: true,
677
+ };
678
+
679
+ let plan = build_plan(&args).expect("plan should build");
680
+
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()));
685
+ assert_eq!(plan.package_manager, "npm");
686
+ assert_eq!(
687
+ plan.next_steps.first().map(String::as_str),
688
+ Some("cd my-app")
689
+ );
690
+ assert_eq!(
691
+ plan.next_steps.get(1).map(String::as_str),
692
+ Some("npm install")
693
+ );
694
+ }
695
+
696
+ #[test]
697
+ fn includes_optional_forge_create_flags() {
698
+ let args = InitArgs {
699
+ dir: PathBuf::from("desktop app"),
700
+ cwd: PathBuf::from(env!("CARGO_MANIFEST_DIR")),
701
+ template: "webpack".to_string(),
702
+ package_manager: Some(PackageManager::Pnpm),
703
+ electron_version: Some("latest".to_string()),
704
+ copy_ci_files: true,
705
+ force: true,
706
+ skip_git: true,
707
+ dry_run: true,
708
+ json: false,
709
+ };
710
+
711
+ let plan = build_plan(&args).expect("plan should build");
712
+
713
+ assert_eq!(plan.template_kind, InitTemplateKind::Forge);
714
+ assert_eq!(plan.generator, "create-electron-app@latest");
715
+ assert_eq!(plan.command[0], "pnpm");
716
+ assert!(plan.command.contains(&"--copy-ci-files".to_string()));
717
+ assert!(plan.command.contains(&"--force".to_string()));
718
+ assert!(plan.command.contains(&"--electron-version".to_string()));
719
+ assert!(plan.command_display.contains("'desktop app'"));
720
+ assert_eq!(
721
+ plan.next_steps.get(1).map(String::as_str),
722
+ Some("pnpm start")
723
+ );
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
+ }
784
+ }