electron-cli 0.3.0-alpha.5 → 0.3.0-alpha.6

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.
package/Cargo.lock CHANGED
@@ -136,7 +136,7 @@ dependencies = [
136
136
 
137
137
  [[package]]
138
138
  name = "electron-cli"
139
- version = "0.3.0-alpha.5"
139
+ version = "0.3.0-alpha.6"
140
140
  dependencies = [
141
141
  "anyhow",
142
142
  "camino",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "electron-cli"
3
- version = "0.3.0-alpha.5"
3
+ version = "0.3.0-alpha.6"
4
4
  edition = "2021"
5
5
  description = "Experimental Rust CLI for Electron project diagnostics and workflow automation"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -41,11 +41,11 @@ The Rust-native flow currently owns:
41
41
 
42
42
  - `init --template minimal`: writes a local Electron starter without Electron Forge.
43
43
  - `start`: launches the installed Electron runtime directly.
44
- - `package`: copies the installed Electron runtime and app files into a local app bundle for the current platform and architecture. The first package pass supports apps without production `dependencies`; dependency pruning and bundled runtime dependencies are still TODO.
44
+ - `package`: copies the installed Electron runtime, app files, and installed production dependency closure into a local app bundle for the current platform and architecture.
45
45
  - `make`: runs `package` and writes a ZIP distributable under `out/make/zip/<platform>/<arch>/`.
46
46
  - `publish`: runs `make` and publishes the distributable to a local directory with a manifest.
47
47
 
48
- Remote publishers such as GitHub Releases are not implemented yet. They are the next publisher targets to replace.
48
+ Remote publishers such as GitHub Releases are not implemented yet. Platform-specific makers, app metadata, signing, and notarization are also still TODO.
49
49
 
50
50
  ## Install
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-cli",
3
- "version": "0.3.0-alpha.5",
3
+ "version": "0.3.0-alpha.6",
4
4
  "description": "Experimental Rust CLI for Electron project diagnostics and workflow automation",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  use std::{
2
+ collections::{BTreeMap, BTreeSet, VecDeque},
2
3
  fs,
3
4
  path::{Path, PathBuf},
4
5
  };
@@ -6,6 +7,7 @@ use std::{
6
7
  use anyhow::{bail, Context, Result};
7
8
  use camino::Utf8PathBuf;
8
9
  use serde::Serialize;
10
+ use serde_json::Value;
9
11
 
10
12
  use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
11
13
 
@@ -87,13 +89,6 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
87
89
  warnings.push("No package.json main field found.".to_string());
88
90
  }
89
91
 
90
- if has_runtime_dependencies(&snapshot) {
91
- warnings.push(
92
- "Packaging production node_modules is not implemented yet; this project declares runtime dependencies."
93
- .to_string(),
94
- );
95
- }
96
-
97
92
  if !electron_source.exists() {
98
93
  warnings.push(format!(
99
94
  "Electron runtime was not found at {}.",
@@ -115,11 +110,19 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
115
110
  ));
116
111
  }
117
112
 
113
+ warnings.extend(runtime_dependency_warnings(root, &snapshot));
114
+
118
115
  let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
119
- let copy_steps = [
116
+ let mut copy_steps = vec![
120
117
  (electron_source, bundle_dir.clone()),
121
118
  (root.to_path_buf(), app_resources_dir.join("app")),
122
119
  ];
120
+ if has_runtime_dependencies(&snapshot) {
121
+ copy_steps.push((
122
+ root.join("node_modules"),
123
+ app_resources_dir.join("app/node_modules"),
124
+ ));
125
+ }
123
126
 
124
127
  Ok(PackageReport {
125
128
  project: snapshot,
@@ -159,12 +162,6 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
159
162
  bail!("No electron dependency found. Install Electron before packaging the app.");
160
163
  }
161
164
 
162
- if has_runtime_dependencies(&report.project) {
163
- bail!(
164
- "Packaging production node_modules is not implemented yet. Remove runtime dependencies or wait for dependency pruning support."
165
- );
166
- }
167
-
168
165
  if report.platform != current_platform() {
169
166
  bail!(
170
167
  "Cross-platform packaging is not implemented yet. Requested {}, host is {}.",
@@ -223,6 +220,11 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
223
220
  &app_dir,
224
221
  Path::new(report.output_dir.as_str()),
225
222
  )?;
223
+ copy_runtime_dependencies(
224
+ Path::new(report.project.root.as_str()),
225
+ &app_dir,
226
+ &report.project,
227
+ )?;
226
228
 
227
229
  Ok(())
228
230
  }
@@ -300,6 +302,180 @@ fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> R
300
302
  Ok(())
301
303
  }
302
304
 
305
+ #[derive(Debug)]
306
+ struct DependencyRequest {
307
+ name: String,
308
+ requested_by: Option<PathBuf>,
309
+ optional: bool,
310
+ }
311
+
312
+ fn copy_runtime_dependencies(
313
+ root: &Path,
314
+ app_dir: &Path,
315
+ snapshot: &ProjectSnapshot,
316
+ ) -> Result<()> {
317
+ if !has_runtime_dependencies(snapshot) {
318
+ return Ok(());
319
+ }
320
+
321
+ let root_node_modules = root.join("node_modules");
322
+ let app_node_modules = app_dir.join("node_modules");
323
+ let mut queue = VecDeque::new();
324
+ let mut copied_paths = BTreeSet::new();
325
+
326
+ for name in snapshot.dependencies.keys() {
327
+ queue.push_back(DependencyRequest {
328
+ name: name.clone(),
329
+ requested_by: None,
330
+ optional: false,
331
+ });
332
+ }
333
+
334
+ for name in snapshot.optional_dependencies.keys() {
335
+ queue.push_back(DependencyRequest {
336
+ name: name.clone(),
337
+ requested_by: None,
338
+ optional: true,
339
+ });
340
+ }
341
+
342
+ while let Some(request) = queue.pop_front() {
343
+ let Some(package_dir) = resolve_dependency_dir(
344
+ &root_node_modules,
345
+ request.requested_by.as_deref(),
346
+ &request.name,
347
+ ) else {
348
+ if request.optional {
349
+ continue;
350
+ }
351
+
352
+ bail!(
353
+ "Runtime dependency '{}' is not installed. Run your package manager install first.",
354
+ request.name
355
+ );
356
+ };
357
+
358
+ let canonical_package_dir = package_dir
359
+ .canonicalize()
360
+ .with_context(|| format!("Could not resolve {}", package_dir.display()))?;
361
+ let canonical_root_node_modules = root_node_modules
362
+ .canonicalize()
363
+ .with_context(|| format!("Could not resolve {}", root_node_modules.display()))?;
364
+ if !copied_paths.insert(canonical_package_dir.clone()) {
365
+ continue;
366
+ }
367
+
368
+ let relative_path = canonical_package_dir
369
+ .strip_prefix(&canonical_root_node_modules)
370
+ .with_context(|| {
371
+ format!(
372
+ "Could not make dependency {} relative to {}",
373
+ canonical_package_dir.display(),
374
+ canonical_root_node_modules.display()
375
+ )
376
+ })?;
377
+ let destination = app_node_modules.join(relative_path);
378
+ copy_recursively(&canonical_package_dir, &destination).with_context(|| {
379
+ format!(
380
+ "Could not copy runtime dependency {} to {}",
381
+ canonical_package_dir.display(),
382
+ destination.display()
383
+ )
384
+ })?;
385
+
386
+ let package_json = read_dependency_package_json(&canonical_package_dir)?;
387
+ for name in string_map(package_json.get("dependencies")).keys() {
388
+ queue.push_back(DependencyRequest {
389
+ name: name.clone(),
390
+ requested_by: Some(canonical_package_dir.clone()),
391
+ optional: false,
392
+ });
393
+ }
394
+ for name in string_map(package_json.get("optionalDependencies")).keys() {
395
+ queue.push_back(DependencyRequest {
396
+ name: name.clone(),
397
+ requested_by: Some(canonical_package_dir.clone()),
398
+ optional: true,
399
+ });
400
+ }
401
+ }
402
+
403
+ Ok(())
404
+ }
405
+
406
+ fn runtime_dependency_warnings(root: &Path, snapshot: &ProjectSnapshot) -> Vec<String> {
407
+ let mut warnings = Vec::new();
408
+ let root_node_modules = root.join("node_modules");
409
+
410
+ for name in snapshot.dependencies.keys() {
411
+ if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
412
+ warnings.push(format!(
413
+ "Runtime dependency is not installed and packaging will fail: {name}."
414
+ ));
415
+ }
416
+ }
417
+
418
+ for name in snapshot.optional_dependencies.keys() {
419
+ if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
420
+ warnings.push(format!(
421
+ "Optional runtime dependency is not installed and will be skipped: {name}."
422
+ ));
423
+ }
424
+ }
425
+
426
+ warnings
427
+ }
428
+
429
+ fn resolve_dependency_dir(
430
+ root_node_modules: &Path,
431
+ requested_by: Option<&Path>,
432
+ name: &str,
433
+ ) -> Option<PathBuf> {
434
+ let relative_path = dependency_relative_path(name);
435
+
436
+ if let Some(requested_by) = requested_by {
437
+ let nested = requested_by.join("node_modules").join(&relative_path);
438
+ if nested.exists() {
439
+ return Some(nested);
440
+ }
441
+ }
442
+
443
+ let hoisted = root_node_modules.join(relative_path);
444
+ hoisted.exists().then_some(hoisted)
445
+ }
446
+
447
+ fn dependency_relative_path(name: &str) -> PathBuf {
448
+ let mut path = PathBuf::new();
449
+ for part in name.split('/') {
450
+ if !part.is_empty() {
451
+ path.push(part);
452
+ }
453
+ }
454
+ path
455
+ }
456
+
457
+ fn read_dependency_package_json(package_dir: &Path) -> Result<Value> {
458
+ let package_json_path = package_dir.join("package.json");
459
+ let raw = fs::read_to_string(&package_json_path)
460
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
461
+ serde_json::from_str::<Value>(&raw)
462
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))
463
+ }
464
+
465
+ fn string_map(value: Option<&Value>) -> BTreeMap<String, String> {
466
+ value
467
+ .and_then(Value::as_object)
468
+ .map(|object| {
469
+ object
470
+ .iter()
471
+ .filter_map(|(key, value)| {
472
+ value.as_str().map(|value| (key.clone(), value.to_string()))
473
+ })
474
+ .collect()
475
+ })
476
+ .unwrap_or_default()
477
+ }
478
+
303
479
  fn copy_recursively(source: &Path, destination: &Path) -> Result<()> {
304
480
  if source.is_dir() {
305
481
  fs::create_dir_all(destination)
@@ -622,7 +798,55 @@ mod tests {
622
798
  }
623
799
 
624
800
  #[test]
625
- fn refuses_runtime_dependencies_until_pruning_exists() {
801
+ fn packages_runtime_dependency_closure_from_node_modules() {
802
+ let root = unique_temp_dir("runtime-deps");
803
+ fs::write(
804
+ root.join("package.json"),
805
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"dep-a":"1.0.0"},"devDependencies":{"electron":"30.0.0","dev-only":"1.0.0"}}"#,
806
+ )
807
+ .expect("package.json should be written");
808
+ write_app_file(&root);
809
+ write_fake_electron_dist(&root);
810
+ write_dependency_package(
811
+ &root,
812
+ "dep-a",
813
+ r#"{"name":"dep-a","version":"1.0.0","dependencies":{"dep-b":"1.0.0"}}"#,
814
+ );
815
+ write_dependency_package(&root, "dep-b", r#"{"name":"dep-b","version":"1.0.0"}"#);
816
+ write_dependency_package(
817
+ &root,
818
+ "dev-only",
819
+ r#"{"name":"dev-only","version":"1.0.0"}"#,
820
+ );
821
+
822
+ let args = PackageArgs {
823
+ cwd: root.clone(),
824
+ out_dir: PathBuf::from("out"),
825
+ name: None,
826
+ platform: None,
827
+ arch: None,
828
+ force: false,
829
+ dry_run: false,
830
+ json: false,
831
+ };
832
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
833
+ let report = build_report(snapshot, &args).expect("report should build");
834
+
835
+ assert!(report.warnings.is_empty());
836
+ execute_package(&report, false).expect("package should succeed");
837
+
838
+ let app_node_modules = Path::new(report.app_resources_dir.as_str())
839
+ .join("app")
840
+ .join("node_modules");
841
+ assert!(app_node_modules.join("dep-a/package.json").exists());
842
+ assert!(app_node_modules.join("dep-b/package.json").exists());
843
+ assert!(!app_node_modules.join("dev-only").exists());
844
+
845
+ let _ = fs::remove_dir_all(root);
846
+ }
847
+
848
+ #[test]
849
+ fn missing_required_runtime_dependency_fails() {
626
850
  let root = unique_temp_dir("runtime-deps");
627
851
  fs::write(
628
852
  root.join("package.json"),
@@ -645,18 +869,55 @@ mod tests {
645
869
  let snapshot = crate::project::inspect(&root).expect("project should inspect");
646
870
  let report = build_report(snapshot, &args).expect("report should build");
647
871
 
648
- assert!(report
649
- .warnings
650
- .contains(&"Packaging production node_modules is not implemented yet; this project declares runtime dependencies.".to_string()));
872
+ assert!(report.warnings.contains(
873
+ &"Runtime dependency is not installed and packaging will fail: left-pad.".to_string()
874
+ ));
651
875
  assert!(execute_package(&report, false).is_err());
652
876
 
653
877
  let _ = fs::remove_dir_all(root);
654
878
  }
655
879
 
880
+ #[test]
881
+ fn missing_optional_runtime_dependency_is_skipped() {
882
+ let root = unique_temp_dir("optional-runtime-deps");
883
+ fs::write(
884
+ root.join("package.json"),
885
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","optionalDependencies":{"optional-native":"1.0.0"},"devDependencies":{"electron":"30.0.0"}}"#,
886
+ )
887
+ .expect("package.json should be written");
888
+ write_app_file(&root);
889
+ write_fake_electron_dist(&root);
890
+
891
+ let args = PackageArgs {
892
+ cwd: root.clone(),
893
+ out_dir: PathBuf::from("out"),
894
+ name: None,
895
+ platform: None,
896
+ arch: None,
897
+ force: false,
898
+ dry_run: false,
899
+ json: false,
900
+ };
901
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
902
+ let report = build_report(snapshot, &args).expect("report should build");
903
+
904
+ assert!(report.warnings.contains(
905
+ &"Optional runtime dependency is not installed and will be skipped: optional-native."
906
+ .to_string()
907
+ ));
908
+ execute_package(&report, false).expect("optional dependency should be skipped");
909
+
910
+ let _ = fs::remove_dir_all(root);
911
+ }
912
+
656
913
  #[test]
657
914
  fn cleans_scoped_package_names_for_bundle_paths() {
658
915
  assert_eq!(clean_app_name("@scope/app"), "scope-app");
659
916
  assert_eq!(sanitize_artifact_name("Starter App"), "starter-app");
917
+ assert_eq!(
918
+ dependency_relative_path("@scope/app"),
919
+ PathBuf::from("@scope/app")
920
+ );
660
921
  }
661
922
 
662
923
  fn write_package_json(root: &Path) {
@@ -673,6 +934,17 @@ mod tests {
673
934
  .expect("main file should be written");
674
935
  }
675
936
 
937
+ fn write_dependency_package(root: &Path, name: &str, package_json: &str) {
938
+ let package_dir = root
939
+ .join("node_modules")
940
+ .join(dependency_relative_path(name));
941
+ fs::create_dir_all(&package_dir).expect("dependency package dir should be created");
942
+ fs::write(package_dir.join("package.json"), package_json)
943
+ .expect("dependency package.json should be written");
944
+ fs::write(package_dir.join("index.js"), "module.exports = true;")
945
+ .expect("dependency index should be written");
946
+ }
947
+
676
948
  fn write_fake_electron_dist(root: &Path) {
677
949
  let dist = root.join("node_modules/electron/dist");
678
950
  if current_platform() == "darwin" {