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.
@@ -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
 
@@ -21,6 +22,10 @@ fn run() -> Result<()> {
21
22
  Commands::Doctor(args) => commands::doctor::run(args),
22
23
  Commands::Init(args) => commands::init::run(args),
23
24
  Commands::Inspect(args) => commands::inspect::run(args),
25
+ Commands::Make(args) => commands::make::run(args),
26
+ Commands::Package(args) => commands::package::run(args),
24
27
  Commands::Plan(args) => commands::plan::run(args),
28
+ Commands::Publish(args) => commands::publish::run(args),
29
+ Commands::Start(args) => commands::start::run(args),
25
30
  }
26
31
  }
package/src/project.rs CHANGED
@@ -9,12 +9,14 @@ use camino::Utf8PathBuf;
9
9
  use serde::Serialize;
10
10
  use serde_json::Value;
11
11
 
12
- #[derive(Debug, Serialize)]
12
+ #[derive(Clone, Debug, Serialize)]
13
13
  pub struct ProjectSnapshot {
14
14
  pub root: Utf8PathBuf,
15
15
  pub package_json: Option<Utf8PathBuf>,
16
16
  pub name: Option<String>,
17
17
  pub version: Option<String>,
18
+ pub repository: Option<String>,
19
+ pub license: Option<String>,
18
20
  pub main: Option<String>,
19
21
  pub package_manager: Option<String>,
20
22
  pub scripts: BTreeMap<String, String>,
@@ -128,6 +130,12 @@ pub fn inspect(cwd: &Path) -> Result<ProjectSnapshot> {
128
130
  .and_then(|package| package.get("version"))
129
131
  .and_then(Value::as_str)
130
132
  .map(ToOwned::to_owned),
133
+ repository: package_json.as_ref().and_then(repository_value),
134
+ license: package_json
135
+ .as_ref()
136
+ .and_then(|package| package.get("license"))
137
+ .and_then(Value::as_str)
138
+ .map(ToOwned::to_owned),
131
139
  main: package_json
132
140
  .as_ref()
133
141
  .and_then(|package| package.get("main"))
@@ -145,6 +153,17 @@ pub fn inspect(cwd: &Path) -> Result<ProjectSnapshot> {
145
153
  })
146
154
  }
147
155
 
156
+ fn repository_value(package: &Value) -> Option<String> {
157
+ match package.get("repository") {
158
+ Some(Value::String(repository)) => Some(repository.clone()),
159
+ Some(Value::Object(repository)) => repository
160
+ .get("url")
161
+ .and_then(Value::as_str)
162
+ .map(ToOwned::to_owned),
163
+ _ => None,
164
+ }
165
+ }
166
+
148
167
  fn string_map(value: Option<&Value>) -> BTreeMap<String, String> {
149
168
  value
150
169
  .and_then(Value::as_object)
@@ -261,6 +280,25 @@ mod tests {
261
280
  assert!(!map.contains_key("bad"));
262
281
  }
263
282
 
283
+ #[test]
284
+ fn inspects_repository_url() {
285
+ let root = unique_temp_dir("repository");
286
+ fs::write(
287
+ root.join("package.json"),
288
+ r#"{"name":"repo-app","repository":{"type":"git","url":"git+https://github.com/Ikana/electron-cli.git"}}"#,
289
+ )
290
+ .expect("package.json should be written");
291
+
292
+ let snapshot = inspect(&root).expect("project should inspect");
293
+
294
+ assert_eq!(
295
+ snapshot.repository.as_deref(),
296
+ Some("git+https://github.com/Ikana/electron-cli.git")
297
+ );
298
+
299
+ let _ = fs::remove_dir_all(root);
300
+ }
301
+
264
302
  #[test]
265
303
  fn builds_electron_signals() {
266
304
  let mut scripts = BTreeMap::new();
@@ -317,4 +355,17 @@ mod tests {
317
355
  .signals
318
356
  .contains(&"electron command found in package scripts".to_string()));
319
357
  }
358
+
359
+ fn unique_temp_dir(label: &str) -> PathBuf {
360
+ let nanos = std::time::SystemTime::now()
361
+ .duration_since(std::time::UNIX_EPOCH)
362
+ .expect("clock should be after epoch")
363
+ .as_nanos();
364
+ let path = std::env::temp_dir().join(format!(
365
+ "electron-cli-project-{label}-{}-{nanos}",
366
+ std::process::id()
367
+ ));
368
+ fs::create_dir_all(&path).expect("temp dir should be created");
369
+ path
370
+ }
320
371
  }
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ out
3
+ dist
4
+ .DS_Store
5
+ *.log