electron-cli 0.3.0-alpha.14 → 0.3.0-alpha.15

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
@@ -376,7 +376,7 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
376
376
 
377
377
  [[package]]
378
378
  name = "electron-cli"
379
- version = "0.3.0-alpha.14"
379
+ version = "0.3.0-alpha.15"
380
380
  dependencies = [
381
381
  "anyhow",
382
382
  "apple-dmg",
@@ -386,6 +386,7 @@ dependencies = [
386
386
  "fatfs",
387
387
  "flate2",
388
388
  "fscommon",
389
+ "json5",
389
390
  "md5",
390
391
  "msi",
391
392
  "plist",
@@ -805,6 +806,16 @@ dependencies = [
805
806
  "wasm-bindgen",
806
807
  ]
807
808
 
809
+ [[package]]
810
+ name = "json5"
811
+ version = "1.3.1"
812
+ source = "registry+https://github.com/rust-lang/crates.io-index"
813
+ checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
814
+ dependencies = [
815
+ "serde",
816
+ "ucd-trie",
817
+ ]
818
+
808
819
  [[package]]
809
820
  name = "keccak"
810
821
  version = "0.1.6"
@@ -1448,6 +1459,12 @@ version = "1.20.1"
1448
1459
  source = "registry+https://github.com/rust-lang/crates.io-index"
1449
1460
  checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1450
1461
 
1462
+ [[package]]
1463
+ name = "ucd-trie"
1464
+ version = "0.1.7"
1465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1466
+ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
1467
+
1451
1468
  [[package]]
1452
1469
  name = "unicode-ident"
1453
1470
  version = "1.0.24"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "electron-cli"
3
- version = "0.3.0-alpha.14"
3
+ version = "0.3.0-alpha.15"
4
4
  edition = "2021"
5
5
  description = "Experimental Rust CLI for Electron project diagnostics and workflow automation"
6
6
  license = "MIT"
@@ -15,6 +15,7 @@ clap = { version = "4.6", features = ["derive"] }
15
15
  fatfs = "0.3"
16
16
  flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
17
17
  fscommon = "0.1"
18
+ json5 = "1"
18
19
  md5 = "0.7"
19
20
  msi = "0.10"
20
21
  plist = "1"
package/README.md CHANGED
@@ -35,9 +35,9 @@ The Rust-native flow currently owns:
35
35
 
36
36
  - `init --template minimal`: writes a local Electron starter without Electron Forge.
37
37
  - `start`: launches the installed Electron runtime directly.
38
- - `package`: copies the installed Electron runtime, app files, installed production dependency closure, app metadata, macOS icon, and extra resources into a local app bundle for the current platform and architecture.
39
- - `make`: runs `package` and writes distributables under `out/make/<target>/<platform>/<arch>/`; it reads JSON-shaped `config.forge.makers` / `electronCli.makers` arrays when `--target` is omitted, and `--target` still forces one maker. ZIP works on all platforms, `--target dmg` writes a basic macOS disk image, `--target deb` / `--target rpm` write Linux packages, and `--target msi` writes a basic Windows Installer package.
40
- - `publish`: runs `make` and publishes distributables to a local directory with a manifest or to GitHub Releases; it reads JSON-shaped `config.forge.publishers` / `electronCli.publishers` arrays when `--publisher` is omitted, and `--publisher` still forces one publisher.
38
+ - `package`: copies the installed Electron runtime, app files, installed production dependency closure, app metadata, macOS icon, and extra resources into a local app bundle for the current platform and architecture; it reads package metadata from `package.json`, JSON-shaped Forge config, and static Forge config files.
39
+ - `make`: runs `package` and writes distributables under `out/make/<target>/<platform>/<arch>/`; it reads JSON-shaped `config.forge.makers` / `electronCli.makers` arrays and static Forge config files when `--target` is omitted, and `--target` still forces one maker. ZIP works on all platforms, `--target dmg` writes a basic macOS disk image, `--target deb` / `--target rpm` write Linux packages, and `--target msi` writes a basic Windows Installer package.
40
+ - `publish`: runs `make` and publishes distributables to a local directory with a manifest or to GitHub Releases; it reads JSON-shaped `config.forge.publishers` / `electronCli.publishers` arrays and static Forge config files when `--publisher` is omitted, and `--publisher` still forces one publisher.
41
41
 
42
42
  The GitHub publisher creates or reuses a release, uploads selected make artifacts, and can replace an existing asset with `--force`. It reads `GITHUB_TOKEN` or `GH_TOKEN` and can infer `OWNER/REPO` from package metadata, Forge GitHub publisher config, or `package.json` `repository`. You can also pass `--github-repo`.
43
43
 
@@ -87,7 +87,9 @@ Package metadata can be configured in `package.json`:
87
87
  }
88
88
  ```
89
89
 
90
- The package command also reads JSON-shaped `config.forge.packagerConfig` and `electronPackagerConfig` entries for the same fields. The make command maps JSON-shaped Forge maker names to the Rust-native targets it supports: zip, dmg, deb, rpm, and wix/msi. The publish command maps JSON-shaped publisher names to local and GitHub. JavaScript Forge config files are not evaluated.
90
+ The package command also reads JSON-shaped `config.forge.packagerConfig` and `electronPackagerConfig` entries for the same fields. The make command maps JSON-shaped Forge maker names to the Rust-native targets it supports: zip, dmg, deb, rpm, and wix/msi. The publish command maps JSON-shaped publisher names to local and GitHub.
91
+
92
+ Static `forge.config.js`, `forge.config.cjs`, `forge.config.mjs`, and `forge.config.ts` files are parsed in Rust when they export an object literal directly or via a local `const`/`let`/`var` identifier. Dynamic JavaScript config that calls functions, reads environment state, or computes the config at runtime is not evaluated.
91
93
 
92
94
  ## Install
93
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-cli",
3
- "version": "0.3.0-alpha.14",
3
+ "version": "0.3.0-alpha.15",
4
4
  "description": "Experimental Rust CLI for Electron project diagnostics and workflow automation",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -228,24 +228,13 @@ fn resolve_make_targets(
228
228
  }
229
229
 
230
230
  fn configured_makers(snapshot: &ProjectSnapshot) -> Result<Vec<ConfiguredMaker>> {
231
- let Some(package_json_path) = &snapshot.package_json else {
232
- return Ok(Vec::new());
233
- };
234
- let package_json_path = Path::new(package_json_path.as_str());
235
- let raw = fs::read_to_string(package_json_path)
236
- .with_context(|| format!("Could not read {}", package_json_path.display()))?;
237
- let package = serde_json::from_str::<JsonValue>(&raw)
238
- .with_context(|| format!("Could not parse {}", package_json_path.display()))?;
231
+ let project_config = crate::forge_config::read(snapshot)?;
239
232
 
240
233
  let mut makers = Vec::new();
241
234
  for value in [
242
- package
243
- .get("config")
244
- .and_then(|config| config.get("forge"))
245
- .and_then(|forge| forge.get("makers")),
246
- package
247
- .get("electronCli")
248
- .or_else(|| package.get("electron-cli"))
235
+ project_config.forge().and_then(|forge| forge.get("makers")),
236
+ project_config
237
+ .electron_cli()
249
238
  .and_then(|config| config.get("makers")),
250
239
  ]
251
240
  .into_iter()
@@ -2421,6 +2410,46 @@ mod tests {
2421
2410
  let _ = fs::remove_dir_all(root);
2422
2411
  }
2423
2412
 
2413
+ #[test]
2414
+ fn builds_make_reports_from_static_forge_config_js() {
2415
+ let root = unique_temp_dir("configured-makers-js");
2416
+ write_package_json(&root);
2417
+ fs::write(
2418
+ root.join("forge.config.js"),
2419
+ r#"
2420
+ module.exports = {
2421
+ makers: [
2422
+ { name: '@electron-forge/maker-zip' },
2423
+ { name: '@electron-forge/maker-rpm', platforms: ['linux'] },
2424
+ ],
2425
+ };
2426
+ "#,
2427
+ )
2428
+ .expect("forge config should be written");
2429
+ write_app_file(&root);
2430
+ write_fake_electron_dist(&root);
2431
+
2432
+ let args = MakeArgs {
2433
+ cwd: root.clone(),
2434
+ out_dir: PathBuf::from("out"),
2435
+ name: None,
2436
+ platform: Some("linux".to_string()),
2437
+ arch: Some("x64".to_string()),
2438
+ target: None,
2439
+ skip_package: false,
2440
+ force: false,
2441
+ dry_run: true,
2442
+ json: true,
2443
+ };
2444
+ let reports = build_reports(&args).expect("reports should build");
2445
+
2446
+ assert_eq!(reports.len(), 2);
2447
+ assert_eq!(reports[0].target(), "zip");
2448
+ assert_eq!(reports[1].target(), "rpm");
2449
+
2450
+ let _ = fs::remove_dir_all(root);
2451
+ }
2452
+
2424
2453
  #[test]
2425
2454
  fn explicit_make_target_overrides_configured_makers() {
2426
2455
  let root = unique_temp_dir("target-override");
@@ -67,6 +67,7 @@ struct PackageJsonConfig {
67
67
  product_name: Option<String>,
68
68
  app_version: Option<String>,
69
69
  packager: PackagerConfig,
70
+ warnings: Vec<String>,
70
71
  }
71
72
 
72
73
  #[derive(Debug, Default)]
@@ -133,7 +134,7 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
133
134
  &platform,
134
135
  )?;
135
136
 
136
- let mut warnings = Vec::new();
137
+ let mut warnings = package_config.warnings.clone();
137
138
  if snapshot.package_json.is_none() {
138
139
  warnings.push("No package.json found.".to_string());
139
140
  }
@@ -346,44 +347,40 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
346
347
  }
347
348
 
348
349
  fn read_package_json_config(snapshot: &ProjectSnapshot) -> Result<PackageJsonConfig> {
349
- let Some(package_json_path) = &snapshot.package_json else {
350
- return Ok(PackageJsonConfig::default());
351
- };
352
-
353
- let package_json_path = Path::new(package_json_path.as_str());
354
- let raw = fs::read_to_string(package_json_path)
355
- .with_context(|| format!("Could not read {}", package_json_path.display()))?;
356
- let package = serde_json::from_str::<JsonValue>(&raw)
357
- .with_context(|| format!("Could not parse {}", package_json_path.display()))?;
350
+ let project_config = crate::forge_config::read(snapshot)?;
358
351
 
359
352
  let mut packager = PackagerConfig::default();
360
- if let Some(config) = package
361
- .get("config")
362
- .and_then(|config| config.get("forge"))
353
+ if let Some(config) = project_config
354
+ .forge()
363
355
  .and_then(|forge| forge.get("packagerConfig"))
364
356
  {
365
357
  packager.merge(parse_packager_config(config));
366
358
  }
367
- if let Some(config) = package.get("electronPackagerConfig") {
359
+ if let Some(config) = project_config
360
+ .package()
361
+ .and_then(|package| package.get("electronPackagerConfig"))
362
+ {
368
363
  packager.merge(parse_packager_config(config));
369
364
  }
370
- if let Some(config) = package
371
- .get("electronCli")
372
- .or_else(|| package.get("electron-cli"))
365
+ if let Some(config) = project_config
366
+ .electron_cli()
373
367
  .and_then(|config| config.get("packagerConfig"))
374
368
  {
375
369
  packager.merge(parse_packager_config(config));
376
370
  }
377
371
 
378
372
  Ok(PackageJsonConfig {
379
- product_name: package
380
- .get("productName")
373
+ product_name: project_config
374
+ .package()
375
+ .and_then(|package| package.get("productName"))
381
376
  .and_then(JsonValue::as_str)
382
377
  .map(ToOwned::to_owned),
383
- app_version: package
384
- .get("version")
378
+ app_version: project_config
379
+ .package()
380
+ .and_then(|package| package.get("version"))
385
381
  .and_then(JsonValue::as_str)
386
382
  .map(ToOwned::to_owned),
383
+ warnings: project_config.warnings().to_vec(),
387
384
  packager,
388
385
  })
389
386
  }
@@ -1380,6 +1377,52 @@ mod tests {
1380
1377
  let _ = fs::remove_dir_all(root);
1381
1378
  }
1382
1379
 
1380
+ #[test]
1381
+ fn plans_packager_metadata_from_forge_config_js() {
1382
+ let root = unique_temp_dir("forge-config-metadata");
1383
+ write_package_json(&root);
1384
+ fs::write(
1385
+ root.join("forge.config.js"),
1386
+ r#"
1387
+ module.exports = {
1388
+ packagerConfig: {
1389
+ name: 'Forge Config App',
1390
+ executableName: 'ForgeExec',
1391
+ appBundleId: 'com.example.forge-config',
1392
+ },
1393
+ };
1394
+ "#,
1395
+ )
1396
+ .expect("forge config should be written");
1397
+ write_app_file(&root);
1398
+ write_fake_electron_dist(&root);
1399
+
1400
+ let args = PackageArgs {
1401
+ cwd: root.clone(),
1402
+ out_dir: PathBuf::from("out"),
1403
+ name: None,
1404
+ platform: None,
1405
+ arch: None,
1406
+ force: false,
1407
+ dry_run: true,
1408
+ json: true,
1409
+ };
1410
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1411
+ let report = build_report(snapshot, &args).expect("report should build");
1412
+
1413
+ assert_eq!(report.app_name, "Forge Config App");
1414
+ assert_eq!(
1415
+ report.executable_name,
1416
+ executable_name("ForgeExec", &report.platform)
1417
+ );
1418
+ assert_eq!(
1419
+ report.metadata.bundle_identifier,
1420
+ "com.example.forge-config"
1421
+ );
1422
+
1423
+ let _ = fs::remove_dir_all(root);
1424
+ }
1425
+
1383
1426
  #[test]
1384
1427
  fn packages_macos_info_plist_metadata() {
1385
1428
  if current_platform() != "darwin" {
@@ -449,24 +449,15 @@ fn resolved_publisher_from_args(
449
449
  }
450
450
 
451
451
  fn configured_publishers(project: &ProjectSnapshot) -> Result<Vec<ConfiguredPublisher>> {
452
- let Some(package_json_path) = &project.package_json else {
453
- return Ok(Vec::new());
454
- };
455
- let package_json_path = Path::new(package_json_path.as_str());
456
- let raw = fs::read_to_string(package_json_path)
457
- .with_context(|| format!("Could not read {}", package_json_path.display()))?;
458
- let package = serde_json::from_str::<JsonValue>(&raw)
459
- .with_context(|| format!("Could not parse {}", package_json_path.display()))?;
452
+ let project_config = crate::forge_config::read(project)?;
460
453
 
461
454
  let mut publishers = Vec::new();
462
455
  for value in [
463
- package
464
- .get("config")
465
- .and_then(|config| config.get("forge"))
456
+ project_config
457
+ .forge()
466
458
  .and_then(|forge| forge.get("publishers")),
467
- package
468
- .get("electronCli")
469
- .or_else(|| package.get("electron-cli"))
459
+ project_config
460
+ .electron_cli()
470
461
  .and_then(|config| config.get("publishers")),
471
462
  ]
472
463
  .into_iter()
@@ -1394,6 +1385,46 @@ mod tests {
1394
1385
  let _ = fs::remove_dir_all(root);
1395
1386
  }
1396
1387
 
1388
+ #[test]
1389
+ fn builds_github_publish_report_from_static_forge_config_js() {
1390
+ let root = unique_temp_dir("configured-github-js-plan");
1391
+ write_package_json(&root);
1392
+ fs::write(
1393
+ root.join("forge.config.js"),
1394
+ r#"
1395
+ module.exports = {
1396
+ publishers: [
1397
+ {
1398
+ name: '@electron-forge/publisher-github',
1399
+ config: {
1400
+ repository: { owner: 'Ikana', name: 'electron-cli' },
1401
+ tagPrefix: 'release-',
1402
+ prerelease: true,
1403
+ },
1404
+ },
1405
+ ],
1406
+ };
1407
+ "#,
1408
+ )
1409
+ .expect("forge config should be written");
1410
+ write_app_file(&root);
1411
+ write_fake_electron_dist(&root);
1412
+
1413
+ let mut args = publish_args(root.clone(), true);
1414
+ args.publisher = None;
1415
+ args.github_api_url = None;
1416
+ args.channel = None;
1417
+ let report = build_report(&args).expect("report should build");
1418
+
1419
+ assert_eq!(report.publisher, "github");
1420
+ let github = report.github.as_ref().expect("github plan should exist");
1421
+ assert_eq!(github.repo, "Ikana/electron-cli");
1422
+ assert_eq!(github.tag, "release-0.1.0");
1423
+ assert!(github.prerelease);
1424
+
1425
+ let _ = fs::remove_dir_all(root);
1426
+ }
1427
+
1397
1428
  #[test]
1398
1429
  fn builds_publish_reports_from_configured_makers_and_publishers() {
1399
1430
  let root = unique_temp_dir("configured-publishers");
@@ -0,0 +1,547 @@
1
+ use std::{
2
+ fs,
3
+ path::{Path, PathBuf},
4
+ };
5
+
6
+ use anyhow::{anyhow, Context, Result};
7
+ use serde_json::Value as JsonValue;
8
+
9
+ use crate::project::ProjectSnapshot;
10
+
11
+ #[derive(Clone, Debug, Default)]
12
+ pub(crate) struct ProjectConfig {
13
+ package: Option<JsonValue>,
14
+ forge: Option<JsonValue>,
15
+ electron_cli: Option<JsonValue>,
16
+ warnings: Vec<String>,
17
+ }
18
+
19
+ pub(crate) fn read(snapshot: &ProjectSnapshot) -> Result<ProjectConfig> {
20
+ let package = read_package_json(snapshot)?;
21
+ let root = Path::new(snapshot.root.as_str());
22
+ let mut warnings = Vec::new();
23
+ let forge = resolve_forge_config(root, package.as_ref(), &mut warnings);
24
+ let electron_cli = package
25
+ .as_ref()
26
+ .and_then(|package| {
27
+ package
28
+ .get("electronCli")
29
+ .or_else(|| package.get("electron-cli"))
30
+ })
31
+ .cloned();
32
+
33
+ Ok(ProjectConfig {
34
+ package,
35
+ forge,
36
+ electron_cli,
37
+ warnings,
38
+ })
39
+ }
40
+
41
+ fn read_package_json(snapshot: &ProjectSnapshot) -> Result<Option<JsonValue>> {
42
+ let Some(package_json_path) = &snapshot.package_json else {
43
+ return Ok(None);
44
+ };
45
+ let package_json_path = Path::new(package_json_path.as_str());
46
+ let raw = fs::read_to_string(package_json_path)
47
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
48
+ serde_json::from_str::<JsonValue>(&raw)
49
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))
50
+ .map(Some)
51
+ }
52
+
53
+ fn resolve_forge_config(
54
+ root: &Path,
55
+ package: Option<&JsonValue>,
56
+ warnings: &mut Vec<String>,
57
+ ) -> Option<JsonValue> {
58
+ match package
59
+ .and_then(|package| package.get("config"))
60
+ .and_then(|config| config.get("forge"))
61
+ {
62
+ Some(JsonValue::Object(_)) => {
63
+ return package
64
+ .and_then(|package| package.get("config"))
65
+ .and_then(|config| config.get("forge"))
66
+ .cloned()
67
+ }
68
+ Some(JsonValue::String(path)) => {
69
+ return read_forge_config_file(root, Path::new(path), warnings);
70
+ }
71
+ Some(_) => {
72
+ warnings.push(
73
+ "package.json config.forge must be an object or relative config file path."
74
+ .to_string(),
75
+ );
76
+ return None;
77
+ }
78
+ None => {}
79
+ }
80
+
81
+ for candidate in [
82
+ "forge.config.js",
83
+ "forge.config.cjs",
84
+ "forge.config.mjs",
85
+ "forge.config.ts",
86
+ ] {
87
+ let path = root.join(candidate);
88
+ if path.exists() {
89
+ return read_forge_config_file(root, &PathBuf::from(candidate), warnings);
90
+ }
91
+ }
92
+
93
+ None
94
+ }
95
+
96
+ fn read_forge_config_file(
97
+ root: &Path,
98
+ configured_path: &Path,
99
+ warnings: &mut Vec<String>,
100
+ ) -> Option<JsonValue> {
101
+ let path = if configured_path.is_absolute() {
102
+ configured_path.to_path_buf()
103
+ } else {
104
+ root.join(configured_path)
105
+ };
106
+ let display = path.display();
107
+
108
+ let raw = match fs::read_to_string(&path) {
109
+ Ok(raw) => raw,
110
+ Err(error) => {
111
+ warnings.push(format!(
112
+ "Could not read Forge config file {display}: {error}."
113
+ ));
114
+ return None;
115
+ }
116
+ };
117
+
118
+ match parse_forge_config_file(&raw, &path) {
119
+ Ok(config) => Some(config),
120
+ Err(error) => {
121
+ warnings.push(format!(
122
+ "Could not parse Forge config file {display} without JavaScript execution: {error}."
123
+ ));
124
+ None
125
+ }
126
+ }
127
+ }
128
+
129
+ fn parse_forge_config_file(raw: &str, path: &Path) -> Result<JsonValue> {
130
+ if path.extension().and_then(|extension| extension.to_str()) == Some("json") {
131
+ return serde_json::from_str(raw).with_context(|| "Forge JSON config is not valid JSON");
132
+ }
133
+
134
+ let object_literal = extract_static_config_object(raw)
135
+ .ok_or_else(|| anyhow!("expected a static object export"))?;
136
+ json5::from_str::<JsonValue>(&object_literal)
137
+ .with_context(|| "static Forge config object is not valid JSON5")
138
+ }
139
+
140
+ fn extract_static_config_object(source: &str) -> Option<String> {
141
+ for marker in ["module.exports", "exports.default"] {
142
+ if let Some(object) = extract_assignment_object(source, marker) {
143
+ return Some(object);
144
+ }
145
+ }
146
+
147
+ if let Some(object) = extract_export_default_object(source) {
148
+ return Some(object);
149
+ }
150
+
151
+ if let Some(identifier) = export_default_identifier(source)
152
+ .or_else(|| assignment_identifier(source, "module.exports"))
153
+ .or_else(|| assignment_identifier(source, "exports.default"))
154
+ {
155
+ return extract_variable_object(source, &identifier);
156
+ }
157
+
158
+ None
159
+ }
160
+
161
+ fn extract_assignment_object(source: &str, marker: &str) -> Option<String> {
162
+ let marker_index = source.find(marker)?;
163
+ let after_marker = &source[marker_index + marker.len()..];
164
+ let equals = after_marker.find('=')?;
165
+ let after_equals_start = marker_index + marker.len() + equals + 1;
166
+ let object_start = find_next_object_start(source, after_equals_start)?;
167
+ extract_balanced_object(source, object_start)
168
+ }
169
+
170
+ fn extract_export_default_object(source: &str) -> Option<String> {
171
+ let marker = "export default";
172
+ let marker_index = source.find(marker)?;
173
+ let after_marker = marker_index + marker.len();
174
+ let object_start = find_next_object_start(source, after_marker)?;
175
+ let identifier = read_identifier(source, skip_whitespace(source, after_marker)).0;
176
+ if identifier.is_some() {
177
+ return None;
178
+ }
179
+ extract_balanced_object(source, object_start)
180
+ }
181
+
182
+ fn export_default_identifier(source: &str) -> Option<String> {
183
+ let marker = "export default";
184
+ let marker_index = source.find(marker)?;
185
+ let start = skip_whitespace(source, marker_index + marker.len());
186
+ read_identifier(source, start).0
187
+ }
188
+
189
+ fn assignment_identifier(source: &str, marker: &str) -> Option<String> {
190
+ let marker_index = source.find(marker)?;
191
+ let after_marker = &source[marker_index + marker.len()..];
192
+ let equals = after_marker.find('=')?;
193
+ let start = skip_whitespace(source, marker_index + marker.len() + equals + 1);
194
+ read_identifier(source, start).0
195
+ }
196
+
197
+ fn extract_variable_object(source: &str, identifier: &str) -> Option<String> {
198
+ for keyword in ["const", "let", "var"] {
199
+ for (keyword_index, _) in source.match_indices(keyword) {
200
+ if !is_word_boundary(source, keyword_index, keyword.len()) {
201
+ continue;
202
+ }
203
+ let start = skip_whitespace(source, keyword_index + keyword.len());
204
+ let (name, after_name) = read_identifier(source, start);
205
+ if name.as_deref() != Some(identifier) {
206
+ continue;
207
+ }
208
+ let rest = &source[after_name..];
209
+ let equals = rest.find('=')?;
210
+ let object_start = find_next_object_start(source, after_name + equals + 1)?;
211
+ return extract_balanced_object(source, object_start);
212
+ }
213
+ }
214
+
215
+ None
216
+ }
217
+
218
+ fn find_next_object_start(source: &str, start: usize) -> Option<usize> {
219
+ let bytes = source.as_bytes();
220
+ let mut index = start;
221
+ while index < bytes.len() {
222
+ match bytes[index] {
223
+ b'{' => return Some(index),
224
+ b';' | b'\n' if !source[start..index].trim().is_empty() => return None,
225
+ _ => index += 1,
226
+ }
227
+ }
228
+ None
229
+ }
230
+
231
+ fn extract_balanced_object(source: &str, object_start: usize) -> Option<String> {
232
+ let bytes = source.as_bytes();
233
+ let mut index = object_start;
234
+ let mut depth = 0usize;
235
+ let mut state = ScanState::Normal;
236
+
237
+ while index < bytes.len() {
238
+ match state {
239
+ ScanState::Normal => match bytes[index] {
240
+ b'{' => {
241
+ depth += 1;
242
+ index += 1;
243
+ }
244
+ b'}' => {
245
+ depth = depth.checked_sub(1)?;
246
+ index += 1;
247
+ if depth == 0 {
248
+ return Some(source[object_start..index].to_string());
249
+ }
250
+ }
251
+ b'\'' | b'"' | b'`' => {
252
+ state = ScanState::String(bytes[index]);
253
+ index += 1;
254
+ }
255
+ b'/' if bytes.get(index + 1) == Some(&b'/') => {
256
+ state = ScanState::LineComment;
257
+ index += 2;
258
+ }
259
+ b'/' if bytes.get(index + 1) == Some(&b'*') => {
260
+ state = ScanState::BlockComment;
261
+ index += 2;
262
+ }
263
+ _ => index += 1,
264
+ },
265
+ ScanState::String(quote) => {
266
+ if bytes[index] == b'\\' {
267
+ index = (index + 2).min(bytes.len());
268
+ } else if bytes[index] == quote {
269
+ state = ScanState::Normal;
270
+ index += 1;
271
+ } else {
272
+ index += 1;
273
+ }
274
+ }
275
+ ScanState::LineComment => {
276
+ if bytes[index] == b'\n' {
277
+ state = ScanState::Normal;
278
+ }
279
+ index += 1;
280
+ }
281
+ ScanState::BlockComment => {
282
+ if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') {
283
+ state = ScanState::Normal;
284
+ index += 2;
285
+ } else {
286
+ index += 1;
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ None
293
+ }
294
+
295
+ fn skip_whitespace(source: &str, start: usize) -> usize {
296
+ let bytes = source.as_bytes();
297
+ let mut index = start;
298
+ while index < bytes.len() && bytes[index].is_ascii_whitespace() {
299
+ index += 1;
300
+ }
301
+ index
302
+ }
303
+
304
+ fn read_identifier(source: &str, start: usize) -> (Option<String>, usize) {
305
+ let bytes = source.as_bytes();
306
+ if bytes.get(start).is_none_or(|byte| !identifier_start(*byte)) {
307
+ return (None, start);
308
+ }
309
+
310
+ let mut index = start + 1;
311
+ while index < bytes.len() && identifier_continue(bytes[index]) {
312
+ index += 1;
313
+ }
314
+
315
+ (Some(source[start..index].to_string()), index)
316
+ }
317
+
318
+ fn is_word_boundary(source: &str, start: usize, len: usize) -> bool {
319
+ let bytes = source.as_bytes();
320
+ let before = start
321
+ .checked_sub(1)
322
+ .and_then(|index| bytes.get(index))
323
+ .copied();
324
+ let after = bytes.get(start + len).copied();
325
+ before.is_none_or(|byte| !identifier_continue(byte))
326
+ && after.is_none_or(|byte| !identifier_continue(byte))
327
+ }
328
+
329
+ fn identifier_start(byte: u8) -> bool {
330
+ byte.is_ascii_alphabetic() || matches!(byte, b'_' | b'$')
331
+ }
332
+
333
+ fn identifier_continue(byte: u8) -> bool {
334
+ identifier_start(byte) || byte.is_ascii_digit()
335
+ }
336
+
337
+ #[derive(Clone, Copy, Debug)]
338
+ enum ScanState {
339
+ Normal,
340
+ String(u8),
341
+ LineComment,
342
+ BlockComment,
343
+ }
344
+
345
+ impl ProjectConfig {
346
+ pub(crate) fn package(&self) -> Option<&JsonValue> {
347
+ self.package.as_ref()
348
+ }
349
+
350
+ pub(crate) fn forge(&self) -> Option<&JsonValue> {
351
+ self.forge.as_ref()
352
+ }
353
+
354
+ pub(crate) fn electron_cli(&self) -> Option<&JsonValue> {
355
+ self.electron_cli.as_ref()
356
+ }
357
+
358
+ pub(crate) fn warnings(&self) -> &[String] {
359
+ &self.warnings
360
+ }
361
+ }
362
+
363
+ #[cfg(test)]
364
+ mod tests {
365
+ use super::*;
366
+ use camino::Utf8PathBuf;
367
+
368
+ #[test]
369
+ fn parses_commonjs_static_forge_config() {
370
+ let config = parse_forge_config_file(
371
+ r#"
372
+ module.exports = {
373
+ packagerConfig: {
374
+ name: 'Desk Tool',
375
+ },
376
+ makers: [
377
+ { name: '@electron-forge/maker-zip' },
378
+ ],
379
+ };
380
+ "#,
381
+ Path::new("forge.config.js"),
382
+ )
383
+ .expect("config should parse");
384
+
385
+ assert_eq!(
386
+ config
387
+ .get("packagerConfig")
388
+ .and_then(|config| config.get("name"))
389
+ .and_then(JsonValue::as_str),
390
+ Some("Desk Tool")
391
+ );
392
+ }
393
+
394
+ #[test]
395
+ fn parses_typescript_exported_config_identifier() {
396
+ let config = parse_forge_config_file(
397
+ r#"
398
+ import type { ForgeConfig } from '@electron-forge/shared-types';
399
+
400
+ const config: ForgeConfig = {
401
+ publishers: [
402
+ {
403
+ name: '@electron-forge/publisher-github',
404
+ platforms: ['darwin'],
405
+ config: { repository: { owner: 'Ikana', name: 'electron-cli' } },
406
+ },
407
+ ],
408
+ };
409
+
410
+ export default config;
411
+ "#,
412
+ Path::new("forge.config.ts"),
413
+ )
414
+ .expect("config should parse");
415
+
416
+ assert_eq!(
417
+ config
418
+ .get("publishers")
419
+ .and_then(JsonValue::as_array)
420
+ .and_then(|publishers| publishers.first())
421
+ .and_then(|publisher| publisher.get("platforms"))
422
+ .and_then(JsonValue::as_array)
423
+ .and_then(|platforms| platforms.first())
424
+ .and_then(JsonValue::as_str),
425
+ Some("darwin")
426
+ );
427
+ }
428
+
429
+ #[test]
430
+ fn reads_config_path_from_package_json() {
431
+ let root = unique_temp_dir("config-path");
432
+ fs::write(
433
+ root.join("package.json"),
434
+ r#"{"name":"app","config":{"forge":"./build/forge.config.js"}}"#,
435
+ )
436
+ .expect("package.json should be written");
437
+ fs::create_dir_all(root.join("build")).expect("build dir should be created");
438
+ fs::write(
439
+ root.join("build/forge.config.js"),
440
+ "module.exports = { makers: [{ name: '@electron-forge/maker-deb' }] };",
441
+ )
442
+ .expect("forge config should be written");
443
+
444
+ let snapshot = snapshot(&root);
445
+ let config = read(&snapshot).expect("config should read");
446
+
447
+ assert_eq!(
448
+ config
449
+ .forge()
450
+ .and_then(|forge| forge.get("makers"))
451
+ .and_then(JsonValue::as_array)
452
+ .and_then(|makers| makers.first())
453
+ .and_then(|maker| maker.get("name"))
454
+ .and_then(JsonValue::as_str),
455
+ Some("@electron-forge/maker-deb")
456
+ );
457
+ assert!(config.warnings().is_empty());
458
+
459
+ let _ = fs::remove_dir_all(root);
460
+ }
461
+
462
+ #[test]
463
+ fn discovers_default_forge_config_js() {
464
+ let root = unique_temp_dir("default-config");
465
+ fs::write(root.join("package.json"), r#"{"name":"app"}"#)
466
+ .expect("package.json should be written");
467
+ fs::write(
468
+ root.join("forge.config.js"),
469
+ "module.exports = { packagerConfig: { executableName: 'desk-tool' } };",
470
+ )
471
+ .expect("forge config should be written");
472
+
473
+ let snapshot = snapshot(&root);
474
+ let config = read(&snapshot).expect("config should read");
475
+
476
+ assert_eq!(
477
+ config
478
+ .forge()
479
+ .and_then(|forge| forge.get("packagerConfig"))
480
+ .and_then(|packager| packager.get("executableName"))
481
+ .and_then(JsonValue::as_str),
482
+ Some("desk-tool")
483
+ );
484
+
485
+ let _ = fs::remove_dir_all(root);
486
+ }
487
+
488
+ #[test]
489
+ fn warns_when_config_requires_javascript_execution() {
490
+ let root = unique_temp_dir("dynamic-config");
491
+ fs::write(root.join("package.json"), r#"{"name":"app"}"#)
492
+ .expect("package.json should be written");
493
+ fs::write(
494
+ root.join("forge.config.js"),
495
+ "module.exports = buildConfig(process.env.NODE_ENV);",
496
+ )
497
+ .expect("forge config should be written");
498
+
499
+ let snapshot = snapshot(&root);
500
+ let config = read(&snapshot).expect("config should read");
501
+
502
+ assert!(config.forge().is_none());
503
+ assert!(config
504
+ .warnings()
505
+ .iter()
506
+ .any(|warning| warning.contains("without JavaScript execution")));
507
+
508
+ let _ = fs::remove_dir_all(root);
509
+ }
510
+
511
+ fn snapshot(root: &Path) -> ProjectSnapshot {
512
+ ProjectSnapshot {
513
+ root: Utf8PathBuf::from_path_buf(root.to_path_buf()).expect("root should be utf-8"),
514
+ package_json: Some(
515
+ Utf8PathBuf::from_path_buf(root.join("package.json"))
516
+ .expect("package path should be utf-8"),
517
+ ),
518
+ name: Some("app".to_string()),
519
+ version: None,
520
+ repository: None,
521
+ license: None,
522
+ main: Some("src/main.js".to_string()),
523
+ package_manager: None,
524
+ scripts: Default::default(),
525
+ dependencies: Default::default(),
526
+ dev_dependencies: Default::default(),
527
+ optional_dependencies: Default::default(),
528
+ peer_dependencies: Default::default(),
529
+ electron_dependency: Some("30.0.0".to_string()),
530
+ forge_dependencies: Default::default(),
531
+ signals: Vec::new(),
532
+ }
533
+ }
534
+
535
+ fn unique_temp_dir(label: &str) -> PathBuf {
536
+ let nanos = std::time::SystemTime::now()
537
+ .duration_since(std::time::UNIX_EPOCH)
538
+ .expect("clock should be after epoch")
539
+ .as_nanos();
540
+ let path = std::env::temp_dir().join(format!(
541
+ "electron-cli-forge-config-{label}-{}-{nanos}",
542
+ std::process::id()
543
+ ));
544
+ fs::create_dir_all(&path).expect("temp dir should be created");
545
+ path
546
+ }
547
+ }
package/src/main.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  mod cli;
2
2
  mod commands;
3
+ mod forge_config;
3
4
  mod output;
4
5
  mod project;
5
6