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

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,1126 @@
1
+ use std::{
2
+ fs,
3
+ fs::File,
4
+ path::{Path, PathBuf},
5
+ time::{Duration, SystemTime, UNIX_EPOCH},
6
+ };
7
+
8
+ use anyhow::{bail, Context, Result};
9
+ use camino::Utf8PathBuf;
10
+ use serde::{de::DeserializeOwned, Deserialize, Serialize};
11
+
12
+ use crate::{
13
+ cli::{MakeArgs, PublishArgs, PublishTarget},
14
+ commands::make::{self, MakeReport},
15
+ output,
16
+ };
17
+
18
+ #[derive(Debug, Serialize)]
19
+ struct PublishReport {
20
+ make: MakeReport,
21
+ publisher: String,
22
+ channel: String,
23
+ local: Option<LocalPublishPlan>,
24
+ github: Option<GithubPublishPlan>,
25
+ skip_make: bool,
26
+ dry_run: bool,
27
+ status: PublishStatus,
28
+ published_at_unix_seconds: Option<u64>,
29
+ warnings: Vec<String>,
30
+ }
31
+
32
+ #[derive(Debug, Serialize)]
33
+ struct LocalPublishPlan {
34
+ destination_dir: Utf8PathBuf,
35
+ destination_artifact: Utf8PathBuf,
36
+ manifest: Utf8PathBuf,
37
+ }
38
+
39
+ #[derive(Debug, Serialize)]
40
+ struct GithubPublishPlan {
41
+ repo: String,
42
+ tag: String,
43
+ release_name: String,
44
+ draft: bool,
45
+ prerelease: bool,
46
+ api_url: String,
47
+ artifact_name: String,
48
+ release_url: Option<String>,
49
+ asset_url: Option<String>,
50
+ }
51
+
52
+ #[derive(Debug, Serialize)]
53
+ #[serde(rename_all = "kebab-case")]
54
+ enum PublishStatus {
55
+ Planned,
56
+ Published,
57
+ }
58
+
59
+ #[derive(Debug, Serialize)]
60
+ struct PublishManifest {
61
+ schema_version: u8,
62
+ publisher: String,
63
+ channel: String,
64
+ app_name: String,
65
+ package_name: Option<String>,
66
+ package_version: Option<String>,
67
+ platform: String,
68
+ arch: String,
69
+ target: String,
70
+ published_at_unix_seconds: u64,
71
+ artifacts: Vec<PublishedArtifact>,
72
+ }
73
+
74
+ #[derive(Debug, Serialize)]
75
+ struct PublishedArtifact {
76
+ file: String,
77
+ path: Utf8PathBuf,
78
+ size: u64,
79
+ }
80
+
81
+ pub fn run(args: PublishArgs) -> Result<()> {
82
+ let mut report = build_report(&args)?;
83
+
84
+ if args.dry_run {
85
+ return print_report(&report, args.json);
86
+ }
87
+
88
+ execute_publish(&mut report, &args)?;
89
+ report.status = PublishStatus::Published;
90
+
91
+ print_report(&report, args.json)
92
+ }
93
+
94
+ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
95
+ let make_args = MakeArgs {
96
+ cwd: args.cwd.clone(),
97
+ out_dir: args.out_dir.clone(),
98
+ name: args.name.clone(),
99
+ platform: args.platform.clone(),
100
+ arch: args.arch.clone(),
101
+ target: args.target,
102
+ skip_package: false,
103
+ force: args.force,
104
+ dry_run: false,
105
+ json: false,
106
+ };
107
+ let make = make::build_report(&make_args)?;
108
+ let root = Path::new(make.package().project().root.as_str());
109
+ let artifact_name = make
110
+ .artifact()
111
+ .file_name()
112
+ .context("Make artifact path has no UTF-8 file name")?
113
+ .to_string();
114
+
115
+ let mut warnings = make.warnings().to_vec();
116
+ if args.skip_make && !Path::new(make.artifact().as_str()).exists() {
117
+ warnings.push(format!(
118
+ "Make artifact does not exist: {}.",
119
+ make.artifact()
120
+ ));
121
+ }
122
+ let (local, github) = match args.publisher {
123
+ PublishTarget::Local => {
124
+ let local = build_local_plan(root, args, &make, &artifact_name)?;
125
+ if Path::new(local.destination_artifact.as_str()).exists() && !args.force {
126
+ warnings.push(format!(
127
+ "Publish artifact already exists: {}. Use --force to overwrite it.",
128
+ local.destination_artifact
129
+ ));
130
+ }
131
+ if Path::new(local.manifest.as_str()).exists() && !args.force {
132
+ warnings.push(format!(
133
+ "Publish manifest already exists: {}. Use --force to overwrite it.",
134
+ local.manifest
135
+ ));
136
+ }
137
+ (Some(local), None)
138
+ }
139
+ PublishTarget::Github => {
140
+ let github = build_github_plan(args, &make, &artifact_name, &mut warnings)?;
141
+ (None, Some(github))
142
+ }
143
+ };
144
+
145
+ Ok(PublishReport {
146
+ make,
147
+ publisher: args.publisher.as_str().to_string(),
148
+ channel: args.channel.clone(),
149
+ local,
150
+ github,
151
+ skip_make: args.skip_make,
152
+ dry_run: args.dry_run,
153
+ status: PublishStatus::Planned,
154
+ published_at_unix_seconds: None,
155
+ warnings,
156
+ })
157
+ }
158
+
159
+ fn build_local_plan(
160
+ root: &Path,
161
+ args: &PublishArgs,
162
+ make: &MakeReport,
163
+ artifact_name: &str,
164
+ ) -> Result<LocalPublishPlan> {
165
+ let publish_root = resolve_destination(root, &args.to);
166
+ let destination_dir = publish_root
167
+ .join(&args.channel)
168
+ .join(make.package().platform())
169
+ .join(make.package().arch());
170
+ let destination_artifact = destination_dir.join(artifact_name);
171
+ let manifest = destination_dir.join("manifest.json");
172
+
173
+ Ok(LocalPublishPlan {
174
+ destination_dir: utf8_path(destination_dir)?,
175
+ destination_artifact: utf8_path(destination_artifact)?,
176
+ manifest: utf8_path(manifest)?,
177
+ })
178
+ }
179
+
180
+ fn build_github_plan(
181
+ args: &PublishArgs,
182
+ make: &MakeReport,
183
+ artifact_name: &str,
184
+ warnings: &mut Vec<String>,
185
+ ) -> Result<GithubPublishPlan> {
186
+ let repo = args
187
+ .github_repo
188
+ .clone()
189
+ .or_else(|| {
190
+ make.package()
191
+ .project()
192
+ .repository
193
+ .as_deref()
194
+ .and_then(github_repo_from_repository)
195
+ })
196
+ .unwrap_or_default();
197
+ if repo.is_empty() {
198
+ warnings.push(
199
+ "GitHub repository is not configured. Pass --github-repo OWNER/REPO or set package.json repository.".to_string(),
200
+ );
201
+ } else if !valid_github_repo(&repo) {
202
+ warnings.push(format!(
203
+ "GitHub repository should use OWNER/REPO form: {repo}."
204
+ ));
205
+ }
206
+
207
+ let tag = args
208
+ .github_tag
209
+ .clone()
210
+ .unwrap_or_else(|| default_github_tag(make, &args.channel));
211
+ let release_name = args
212
+ .github_release_name
213
+ .clone()
214
+ .unwrap_or_else(|| tag.clone());
215
+ let prerelease = args.github_prerelease || tag.contains('-');
216
+
217
+ Ok(GithubPublishPlan {
218
+ repo,
219
+ tag,
220
+ release_name,
221
+ draft: args.github_draft,
222
+ prerelease,
223
+ api_url: args.github_api_url.trim_end_matches('/').to_string(),
224
+ artifact_name: artifact_name.to_string(),
225
+ release_url: None,
226
+ asset_url: None,
227
+ })
228
+ }
229
+
230
+ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
231
+ if !args.skip_make {
232
+ let make_args = MakeArgs {
233
+ cwd: args.cwd.clone(),
234
+ out_dir: args.out_dir.clone(),
235
+ name: args.name.clone(),
236
+ platform: args.platform.clone(),
237
+ arch: args.arch.clone(),
238
+ target: args.target,
239
+ skip_package: false,
240
+ force: args.force,
241
+ dry_run: false,
242
+ json: false,
243
+ };
244
+ make::execute_make(&mut report.make, &make_args)?;
245
+ report.make.mark_made()?;
246
+ } else if !Path::new(report.make.artifact().as_str()).exists() {
247
+ bail!(
248
+ "Make artifact does not exist: {}. Run without --skip-make or run electron-cli make first.",
249
+ report.make.artifact()
250
+ );
251
+ }
252
+
253
+ let published_at_unix_seconds = now_unix_seconds()?;
254
+ report.published_at_unix_seconds = Some(published_at_unix_seconds);
255
+
256
+ match args.publisher {
257
+ PublishTarget::Local => execute_local_publish(report, args, published_at_unix_seconds),
258
+ PublishTarget::Github => execute_github_publish(report, args),
259
+ }
260
+ }
261
+
262
+ fn execute_local_publish(
263
+ report: &PublishReport,
264
+ args: &PublishArgs,
265
+ published_at_unix_seconds: u64,
266
+ ) -> Result<()> {
267
+ let local = report
268
+ .local
269
+ .as_ref()
270
+ .context("Local publish plan was not built")?;
271
+ let destination_artifact = Path::new(local.destination_artifact.as_str());
272
+ let manifest = Path::new(local.manifest.as_str());
273
+
274
+ for path in [destination_artifact, manifest] {
275
+ if path.exists() {
276
+ if args.force {
277
+ fs::remove_file(path)
278
+ .with_context(|| format!("Could not remove {}", path.display()))?;
279
+ } else {
280
+ bail!(
281
+ "Publish output already exists: {}. Use --force to overwrite it.",
282
+ path.display()
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ fs::create_dir_all(local.destination_dir.as_str())
289
+ .with_context(|| format!("Could not create {}", local.destination_dir))?;
290
+ fs::copy(report.make.artifact().as_str(), destination_artifact).with_context(|| {
291
+ format!(
292
+ "Could not publish {} to {}",
293
+ report.make.artifact(),
294
+ destination_artifact.display()
295
+ )
296
+ })?;
297
+
298
+ let manifest_json =
299
+ serde_json::to_string_pretty(&build_manifest(report, published_at_unix_seconds)?)?;
300
+ fs::write(manifest, format!("{manifest_json}\n"))
301
+ .with_context(|| format!("Could not write {}", manifest.display()))?;
302
+
303
+ Ok(())
304
+ }
305
+
306
+ fn execute_github_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
307
+ let token = github_token()?;
308
+ let agent = github_agent();
309
+ publish_to_github(report, args, &token, &agent)
310
+ }
311
+
312
+ #[derive(Debug, Serialize)]
313
+ struct CreateGithubRelease<'a> {
314
+ tag_name: &'a str,
315
+ name: &'a str,
316
+ body: &'a str,
317
+ draft: bool,
318
+ prerelease: bool,
319
+ }
320
+
321
+ #[derive(Debug, Deserialize)]
322
+ struct GithubRelease {
323
+ html_url: String,
324
+ upload_url: String,
325
+ #[serde(default)]
326
+ assets: Vec<GithubAsset>,
327
+ }
328
+
329
+ #[derive(Debug, Deserialize)]
330
+ struct GithubAsset {
331
+ id: u64,
332
+ name: String,
333
+ browser_download_url: String,
334
+ }
335
+
336
+ #[derive(Debug, Deserialize)]
337
+ struct GithubErrorBody {
338
+ message: Option<String>,
339
+ }
340
+
341
+ fn publish_to_github(
342
+ report: &mut PublishReport,
343
+ args: &PublishArgs,
344
+ token: &str,
345
+ agent: &ureq::Agent,
346
+ ) -> Result<()> {
347
+ let artifact_path = Path::new(report.make.artifact().as_str());
348
+ let github = report
349
+ .github
350
+ .as_mut()
351
+ .context("GitHub publish plan was not built")?;
352
+ if github.repo.is_empty() || !valid_github_repo(&github.repo) {
353
+ bail!("GitHub repository must be configured as OWNER/REPO.");
354
+ }
355
+
356
+ let release = get_or_create_github_release(agent, token, github)?;
357
+ if let Some(asset) = release
358
+ .assets
359
+ .iter()
360
+ .find(|asset| asset.name == github.artifact_name)
361
+ {
362
+ if args.force {
363
+ delete_github_asset(agent, token, github, asset.id)?;
364
+ } else {
365
+ bail!(
366
+ "GitHub release asset already exists: {}. Use --force to replace it.",
367
+ github.artifact_name
368
+ );
369
+ }
370
+ }
371
+
372
+ let asset = upload_github_asset(agent, token, github, &release.upload_url, artifact_path)?;
373
+ github.release_url = Some(release.html_url);
374
+ github.asset_url = Some(asset.browser_download_url);
375
+
376
+ Ok(())
377
+ }
378
+
379
+ fn get_or_create_github_release(
380
+ agent: &ureq::Agent,
381
+ token: &str,
382
+ github: &GithubPublishPlan,
383
+ ) -> Result<GithubRelease> {
384
+ let get_url = format!(
385
+ "{}/repos/{}/releases/tags/{}",
386
+ github.api_url,
387
+ github.repo,
388
+ encode_url_component(&github.tag)
389
+ );
390
+ let response = github_request(agent.get(&get_url), token)
391
+ .call()
392
+ .with_context(|| format!("Could not query GitHub release {}", github.tag))?;
393
+ match response.status().as_u16() {
394
+ 200 => parse_github_response(response, "Could not parse GitHub release"),
395
+ 404 => create_github_release(agent, token, github),
396
+ status => github_status_error(response, status, "Could not query GitHub release"),
397
+ }
398
+ }
399
+
400
+ fn create_github_release(
401
+ agent: &ureq::Agent,
402
+ token: &str,
403
+ github: &GithubPublishPlan,
404
+ ) -> Result<GithubRelease> {
405
+ let url = format!("{}/repos/{}/releases", github.api_url, github.repo);
406
+ let body = format!(
407
+ "{} {} {} artifact published by electron-cli.",
408
+ github.artifact_name, github.tag, github.repo
409
+ );
410
+ let request = CreateGithubRelease {
411
+ tag_name: &github.tag,
412
+ name: &github.release_name,
413
+ body: &body,
414
+ draft: github.draft,
415
+ prerelease: github.prerelease,
416
+ };
417
+ let response = github_request(agent.post(&url), token)
418
+ .content_type("application/json")
419
+ .send_json(&request)
420
+ .with_context(|| format!("Could not create GitHub release {}", github.tag))?;
421
+ let status = response.status().as_u16();
422
+ if status == 201 {
423
+ parse_github_response(response, "Could not parse created GitHub release")
424
+ } else {
425
+ github_status_error(response, status, "Could not create GitHub release")
426
+ }
427
+ }
428
+
429
+ fn delete_github_asset(
430
+ agent: &ureq::Agent,
431
+ token: &str,
432
+ github: &GithubPublishPlan,
433
+ asset_id: u64,
434
+ ) -> Result<()> {
435
+ let url = format!(
436
+ "{}/repos/{}/releases/assets/{}",
437
+ github.api_url, github.repo, asset_id
438
+ );
439
+ let response = github_request(agent.delete(&url), token)
440
+ .call()
441
+ .with_context(|| format!("Could not delete GitHub release asset {asset_id}"))?;
442
+ let status = response.status().as_u16();
443
+ if status == 204 {
444
+ Ok(())
445
+ } else {
446
+ github_status_error(response, status, "Could not delete GitHub release asset")
447
+ }
448
+ }
449
+
450
+ fn upload_github_asset(
451
+ agent: &ureq::Agent,
452
+ token: &str,
453
+ github: &GithubPublishPlan,
454
+ upload_url: &str,
455
+ artifact_path: &Path,
456
+ ) -> Result<GithubAsset> {
457
+ let url = github_asset_upload_url(upload_url, &github.artifact_name);
458
+ let file = File::open(artifact_path)
459
+ .with_context(|| format!("Could not open {}", artifact_path.display()))?;
460
+ let response = github_request(agent.post(&url), token)
461
+ .content_type("application/octet-stream")
462
+ .send(file)
463
+ .with_context(|| format!("Could not upload {}", artifact_path.display()))?;
464
+ let status = response.status().as_u16();
465
+ if status == 201 {
466
+ parse_github_response(response, "Could not parse uploaded GitHub asset")
467
+ } else {
468
+ github_status_error(response, status, "Could not upload GitHub release asset")
469
+ }
470
+ }
471
+
472
+ fn github_request<T>(request: ureq::RequestBuilder<T>, token: &str) -> ureq::RequestBuilder<T> {
473
+ request
474
+ .header("Accept", "application/vnd.github+json")
475
+ .header("Authorization", format!("Bearer {token}"))
476
+ .header(
477
+ "User-Agent",
478
+ format!("electron-cli/{}", env!("CARGO_PKG_VERSION")),
479
+ )
480
+ }
481
+
482
+ fn parse_github_response<T: DeserializeOwned>(
483
+ mut response: ureq::http::Response<ureq::Body>,
484
+ context: &str,
485
+ ) -> Result<T> {
486
+ response
487
+ .body_mut()
488
+ .read_json::<T>()
489
+ .with_context(|| context.to_string())
490
+ }
491
+
492
+ fn github_status_error<T>(
493
+ mut response: ureq::http::Response<ureq::Body>,
494
+ status: u16,
495
+ context: &str,
496
+ ) -> Result<T> {
497
+ let body = response.body_mut().read_to_string().unwrap_or_default();
498
+ let message = serde_json::from_str::<GithubErrorBody>(&body)
499
+ .ok()
500
+ .and_then(|body| body.message)
501
+ .filter(|message| !message.trim().is_empty())
502
+ .unwrap_or(body);
503
+ bail!("{context}: HTTP {status}: {message}")
504
+ }
505
+
506
+ fn github_agent() -> ureq::Agent {
507
+ ureq::Agent::config_builder()
508
+ .http_status_as_error(false)
509
+ .timeout_global(Some(Duration::from_secs(300)))
510
+ .build()
511
+ .into()
512
+ }
513
+
514
+ fn github_token() -> Result<String> {
515
+ std::env::var("GITHUB_TOKEN")
516
+ .or_else(|_| std::env::var("GH_TOKEN"))
517
+ .context("GitHub publisher requires GITHUB_TOKEN or GH_TOKEN")
518
+ }
519
+
520
+ fn build_manifest(
521
+ report: &PublishReport,
522
+ published_at_unix_seconds: u64,
523
+ ) -> Result<PublishManifest> {
524
+ let local = report
525
+ .local
526
+ .as_ref()
527
+ .context("Local publish manifest requires a local publish plan")?;
528
+ let destination_artifact = Path::new(local.destination_artifact.as_str());
529
+ let artifact_size = fs::metadata(destination_artifact)
530
+ .with_context(|| format!("Could not stat {}", destination_artifact.display()))?
531
+ .len();
532
+ let artifact_file = destination_artifact
533
+ .file_name()
534
+ .and_then(|name| name.to_str())
535
+ .context("Published artifact path has no UTF-8 file name")?
536
+ .to_string();
537
+
538
+ Ok(PublishManifest {
539
+ schema_version: 1,
540
+ publisher: report.publisher.clone(),
541
+ channel: report.channel.clone(),
542
+ app_name: report.make.package().app_name().to_string(),
543
+ package_name: report.make.package().project().name.clone(),
544
+ package_version: report.make.package().project().version.clone(),
545
+ platform: report.make.package().platform().to_string(),
546
+ arch: report.make.package().arch().to_string(),
547
+ target: report.make.target().to_string(),
548
+ published_at_unix_seconds,
549
+ artifacts: vec![PublishedArtifact {
550
+ file: artifact_file,
551
+ path: local.destination_artifact.clone(),
552
+ size: artifact_size,
553
+ }],
554
+ })
555
+ }
556
+
557
+ fn print_report(report: &PublishReport, json: bool) -> Result<()> {
558
+ if json {
559
+ return output::json(report);
560
+ }
561
+
562
+ println!("electron-cli publish");
563
+ println!();
564
+ println!("Project");
565
+ println!(" root: {}", report.make.package().project().root);
566
+ match report.make.package().project().package_label() {
567
+ Some(label) => println!(" package: {label}"),
568
+ None => println!(" package: not found"),
569
+ }
570
+ println!(" app name: {}", report.make.package().app_name());
571
+ println!(
572
+ " target: {} {} {}",
573
+ report.make.target(),
574
+ report.make.package().platform(),
575
+ report.make.package().arch()
576
+ );
577
+ println!(" publisher: {}", report.publisher);
578
+ println!(" channel: {}", report.channel);
579
+ println!(" status: {}", report.status.as_str());
580
+
581
+ println!();
582
+ println!("Publish");
583
+ if let Some(local) = &report.local {
584
+ println!(" artifact: {}", local.destination_artifact);
585
+ println!(" manifest: {}", local.manifest);
586
+ }
587
+ if let Some(github) = &report.github {
588
+ println!(" repository: {}", github.repo);
589
+ println!(" tag: {}", github.tag);
590
+ println!(" release: {}", github.release_name);
591
+ println!(" artifact: {}", github.artifact_name);
592
+ if let Some(url) = &github.release_url {
593
+ println!(" release url: {url}");
594
+ }
595
+ if let Some(url) = &github.asset_url {
596
+ println!(" asset url: {url}");
597
+ }
598
+ }
599
+
600
+ if !report.warnings.is_empty() {
601
+ println!();
602
+ println!("Warnings");
603
+ for warning in &report.warnings {
604
+ println!(" {warning}");
605
+ }
606
+ }
607
+
608
+ Ok(())
609
+ }
610
+
611
+ fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
612
+ if destination.is_absolute() {
613
+ destination.to_path_buf()
614
+ } else {
615
+ root.join(destination)
616
+ }
617
+ }
618
+
619
+ fn default_github_tag(make: &MakeReport, channel: &str) -> String {
620
+ make.package()
621
+ .project()
622
+ .version
623
+ .as_deref()
624
+ .map(|version| {
625
+ if version.starts_with('v') {
626
+ version.to_string()
627
+ } else {
628
+ format!("v{version}")
629
+ }
630
+ })
631
+ .unwrap_or_else(|| channel.to_string())
632
+ }
633
+
634
+ fn valid_github_repo(repo: &str) -> bool {
635
+ let mut parts = repo.split('/');
636
+ let Some(owner) = parts.next() else {
637
+ return false;
638
+ };
639
+ let Some(name) = parts.next() else {
640
+ return false;
641
+ };
642
+ parts.next().is_none() && valid_github_path_part(owner) && valid_github_path_part(name)
643
+ }
644
+
645
+ fn valid_github_path_part(part: &str) -> bool {
646
+ !part.is_empty()
647
+ && part
648
+ .chars()
649
+ .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
650
+ }
651
+
652
+ fn github_repo_from_repository(repository: &str) -> Option<String> {
653
+ let mut value = repository.trim().trim_start_matches("git+").to_string();
654
+ if let Some(fragment) = value.find('#') {
655
+ value.truncate(fragment);
656
+ }
657
+ let value = value.trim_end_matches(".git");
658
+ let path = value
659
+ .split_once("github.com:")
660
+ .map(|(_, path)| path)
661
+ .or_else(|| value.split_once("github.com/").map(|(_, path)| path))?;
662
+ let mut parts = path.trim_matches('/').split('/');
663
+ let owner = parts.next()?.trim();
664
+ let repo = parts.next()?.trim();
665
+ if owner.is_empty() || repo.is_empty() {
666
+ None
667
+ } else {
668
+ Some(format!("{owner}/{repo}"))
669
+ }
670
+ }
671
+
672
+ fn github_asset_upload_url(upload_url: &str, artifact_name: &str) -> String {
673
+ let base = upload_url.split('{').next().unwrap_or(upload_url);
674
+ let separator = if base.contains('?') { '&' } else { '?' };
675
+ format!(
676
+ "{base}{separator}name={}",
677
+ encode_url_component(artifact_name)
678
+ )
679
+ }
680
+
681
+ fn encode_url_component(value: &str) -> String {
682
+ let mut encoded = String::new();
683
+ for byte in value.bytes() {
684
+ if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
685
+ encoded.push(byte as char);
686
+ } else {
687
+ encoded.push_str(&format!("%{byte:02X}"));
688
+ }
689
+ }
690
+ encoded
691
+ }
692
+
693
+ fn now_unix_seconds() -> Result<u64> {
694
+ Ok(SystemTime::now()
695
+ .duration_since(UNIX_EPOCH)
696
+ .context("System clock is before the Unix epoch")?
697
+ .as_secs())
698
+ }
699
+
700
+ fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
701
+ Utf8PathBuf::from_path_buf(path).map_err(|path| {
702
+ anyhow::anyhow!(
703
+ "Path contains invalid UTF-8 and cannot be represented in JSON: {}",
704
+ path.display()
705
+ )
706
+ })
707
+ }
708
+
709
+ impl PublishStatus {
710
+ fn as_str(&self) -> &'static str {
711
+ match self {
712
+ PublishStatus::Planned => "planned",
713
+ PublishStatus::Published => "published",
714
+ }
715
+ }
716
+ }
717
+
718
+ #[cfg(test)]
719
+ mod tests {
720
+ use super::*;
721
+ use std::{
722
+ io::{Read, Write},
723
+ net::{TcpListener, TcpStream},
724
+ sync::{Arc, Mutex},
725
+ thread,
726
+ };
727
+
728
+ #[test]
729
+ fn builds_local_publish_report() {
730
+ let root = unique_temp_dir("plan");
731
+ write_package_json(&root);
732
+ write_app_file(&root);
733
+ write_fake_electron_dist(&root);
734
+
735
+ let args = publish_args(root.clone(), true);
736
+ let report = build_report(&args).expect("report should build");
737
+
738
+ assert_eq!(report.publisher, "local");
739
+ assert_eq!(report.channel, "default");
740
+ let local = report.local.as_ref().expect("local plan should exist");
741
+ assert!(Path::new(local.destination_artifact.as_str()).ends_with(
742
+ PathBuf::from("out")
743
+ .join("publish")
744
+ .join("local")
745
+ .join("default")
746
+ .join(report.make.package().platform())
747
+ .join(report.make.package().arch())
748
+ .join(format!(
749
+ "starter-app-{}-{}.zip",
750
+ report.make.package().platform(),
751
+ report.make.package().arch()
752
+ ))
753
+ ));
754
+
755
+ let _ = fs::remove_dir_all(root);
756
+ }
757
+
758
+ #[test]
759
+ fn publishes_make_artifact_to_local_directory() {
760
+ let root = unique_temp_dir("execute");
761
+ write_package_json(&root);
762
+ write_app_file(&root);
763
+ write_fake_electron_dist(&root);
764
+
765
+ let args = publish_args(root.clone(), false);
766
+ let mut report = build_report(&args).expect("report should build");
767
+
768
+ execute_publish(&mut report, &args).expect("publish should succeed");
769
+
770
+ let local = report.local.as_ref().expect("local plan should exist");
771
+ assert!(Path::new(local.destination_artifact.as_str()).exists());
772
+ assert!(Path::new(local.manifest.as_str()).exists());
773
+ let manifest = fs::read_to_string(local.manifest.as_str()).expect("manifest should read");
774
+ assert!(manifest.contains("\"publisher\": \"local\""));
775
+ assert!(manifest.contains("\"app_name\": \"starter-app\""));
776
+
777
+ let _ = fs::remove_dir_all(root);
778
+ }
779
+
780
+ #[test]
781
+ fn builds_github_publish_report_from_package_repository() {
782
+ let root = unique_temp_dir("github-plan");
783
+ write_github_package_json(&root);
784
+ write_app_file(&root);
785
+ write_fake_electron_dist(&root);
786
+
787
+ let args = github_publish_args(root.clone(), true, "http://127.0.0.1:9");
788
+ let report = build_report(&args).expect("report should build");
789
+
790
+ assert_eq!(report.publisher, "github");
791
+ assert!(report.local.is_none());
792
+ let github = report.github.as_ref().expect("github plan should exist");
793
+ assert_eq!(github.repo, "Ikana/electron-cli");
794
+ assert_eq!(github.tag, "v0.1.0");
795
+ assert_eq!(github.release_name, "v0.1.0");
796
+ assert!(!github.draft);
797
+ assert!(!github.prerelease);
798
+ assert_eq!(
799
+ github.artifact_name,
800
+ format!(
801
+ "starter-app-{}-{}.zip",
802
+ report.make.package().platform(),
803
+ report.make.package().arch()
804
+ )
805
+ );
806
+
807
+ let _ = fs::remove_dir_all(root);
808
+ }
809
+
810
+ #[test]
811
+ fn publishes_make_artifact_to_github_release() {
812
+ let server = MockGithubServer::new(3);
813
+ let root = unique_temp_dir("github-execute");
814
+ write_github_package_json(&root);
815
+ write_app_file(&root);
816
+ write_fake_electron_dist(&root);
817
+
818
+ let mut args = github_publish_args(root.clone(), false, &server.api_url);
819
+ args.skip_make = true;
820
+ let mut report = build_report(&args).expect("report should build");
821
+ let artifact = Path::new(report.make.artifact().as_str());
822
+ fs::create_dir_all(artifact.parent().expect("artifact parent should exist"))
823
+ .expect("artifact parent should be created");
824
+ fs::write(artifact, b"artifact bytes").expect("artifact should be written");
825
+
826
+ let agent = github_agent();
827
+ publish_to_github(&mut report, &args, "test-token", &agent)
828
+ .expect("github publish should succeed");
829
+
830
+ let github = report.github.as_ref().expect("github plan should exist");
831
+ let artifact_name = github.artifact_name.clone();
832
+ assert_eq!(
833
+ github.release_url.as_deref(),
834
+ Some("https://github.com/Ikana/electron-cli/releases/tag/v0.1.0")
835
+ );
836
+ assert_eq!(
837
+ github.asset_url.as_deref(),
838
+ Some("https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip")
839
+ );
840
+
841
+ let requests = server.finish();
842
+ assert_eq!(requests.len(), 3);
843
+ assert_eq!(requests[0].method, "GET");
844
+ assert_eq!(
845
+ requests[0].path,
846
+ "/repos/Ikana/electron-cli/releases/tags/v0.1.0"
847
+ );
848
+ assert_eq!(
849
+ requests[0].header("authorization").as_deref(),
850
+ Some("Bearer test-token")
851
+ );
852
+ assert_eq!(requests[1].method, "POST");
853
+ assert_eq!(requests[1].path, "/repos/Ikana/electron-cli/releases");
854
+ let release_body =
855
+ String::from_utf8(requests[1].body.clone()).expect("release body should be utf-8");
856
+ assert!(release_body.contains("\"tag_name\": \"v0.1.0\""));
857
+ assert_eq!(requests[2].method, "POST");
858
+ assert_eq!(
859
+ requests[2].path,
860
+ format!("/uploads/1?name={}", encode_url_component(&artifact_name))
861
+ );
862
+ assert_eq!(requests[2].body, b"artifact bytes");
863
+
864
+ let _ = fs::remove_dir_all(root);
865
+ }
866
+
867
+ #[test]
868
+ fn skip_make_requires_existing_artifact() {
869
+ let root = unique_temp_dir("skip-make");
870
+ write_package_json(&root);
871
+ write_app_file(&root);
872
+ write_fake_electron_dist(&root);
873
+
874
+ let mut args = publish_args(root.clone(), false);
875
+ args.skip_make = true;
876
+ let mut report = build_report(&args).expect("report should build");
877
+
878
+ assert!(execute_publish(&mut report, &args).is_err());
879
+
880
+ let _ = fs::remove_dir_all(root);
881
+ }
882
+
883
+ #[test]
884
+ fn parses_github_repository_urls() {
885
+ assert_eq!(
886
+ github_repo_from_repository("git+https://github.com/Ikana/electron-cli.git"),
887
+ Some("Ikana/electron-cli".to_string())
888
+ );
889
+ assert_eq!(
890
+ github_repo_from_repository("git@github.com:Ikana/electron-cli.git"),
891
+ Some("Ikana/electron-cli".to_string())
892
+ );
893
+ assert_eq!(
894
+ github_repo_from_repository("https://example.com/Ikana/electron-cli.git"),
895
+ None
896
+ );
897
+ }
898
+
899
+ fn publish_args(root: PathBuf, dry_run: bool) -> PublishArgs {
900
+ PublishArgs {
901
+ cwd: root,
902
+ out_dir: PathBuf::from("out"),
903
+ name: None,
904
+ platform: None,
905
+ arch: None,
906
+ target: crate::cli::MakeTarget::Zip,
907
+ publisher: crate::cli::PublishTarget::Local,
908
+ to: PathBuf::from("out/publish/local"),
909
+ github_repo: None,
910
+ github_tag: None,
911
+ github_release_name: None,
912
+ github_draft: false,
913
+ github_prerelease: false,
914
+ github_api_url: "https://api.github.com".to_string(),
915
+ channel: "default".to_string(),
916
+ skip_make: false,
917
+ force: false,
918
+ dry_run,
919
+ json: true,
920
+ }
921
+ }
922
+
923
+ fn github_publish_args(root: PathBuf, dry_run: bool, api_url: &str) -> PublishArgs {
924
+ let mut args = publish_args(root, dry_run);
925
+ args.publisher = crate::cli::PublishTarget::Github;
926
+ args.github_api_url = api_url.to_string();
927
+ args
928
+ }
929
+
930
+ fn write_package_json(root: &Path) {
931
+ fs::write(
932
+ root.join("package.json"),
933
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
934
+ )
935
+ .expect("package.json should be written");
936
+ }
937
+
938
+ fn write_github_package_json(root: &Path) {
939
+ fs::write(
940
+ root.join("package.json"),
941
+ r#"{"name":"starter-app","version":"0.1.0","repository":{"type":"git","url":"git+https://github.com/Ikana/electron-cli.git"},"main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
942
+ )
943
+ .expect("package.json should be written");
944
+ }
945
+
946
+ fn write_app_file(root: &Path) {
947
+ fs::create_dir_all(root.join("src")).expect("src should be created");
948
+ fs::write(root.join("src/main.js"), "console.log('hello');")
949
+ .expect("main file should be written");
950
+ }
951
+
952
+ fn write_fake_electron_dist(root: &Path) {
953
+ let dist = root.join("node_modules/electron/dist");
954
+ if cfg!(target_os = "macos") {
955
+ let app = dist.join("Electron.app/Contents/MacOS");
956
+ fs::create_dir_all(&app).expect("fake macOS electron app should be created");
957
+ fs::write(app.join("Electron"), "").expect("fake macOS binary should be written");
958
+ } else if cfg!(target_os = "windows") {
959
+ fs::create_dir_all(&dist).expect("fake electron dist should be created");
960
+ fs::write(dist.join("electron.exe"), "").expect("fake exe should be written");
961
+ } else {
962
+ fs::create_dir_all(&dist).expect("fake electron dist should be created");
963
+ fs::write(dist.join("electron"), "").expect("fake binary should be written");
964
+ }
965
+ }
966
+
967
+ fn unique_temp_dir(label: &str) -> PathBuf {
968
+ let nanos = std::time::SystemTime::now()
969
+ .duration_since(std::time::UNIX_EPOCH)
970
+ .expect("clock should be after epoch")
971
+ .as_nanos();
972
+ let path = std::env::temp_dir().join(format!(
973
+ "electron-cli-publish-{label}-{}-{nanos}",
974
+ std::process::id()
975
+ ));
976
+ fs::create_dir_all(&path).expect("temp dir should be created");
977
+ path
978
+ }
979
+
980
+ #[derive(Debug, Clone)]
981
+ struct RecordedRequest {
982
+ method: String,
983
+ path: String,
984
+ headers: Vec<(String, String)>,
985
+ body: Vec<u8>,
986
+ }
987
+
988
+ impl RecordedRequest {
989
+ fn header(&self, name: &str) -> Option<String> {
990
+ self.headers
991
+ .iter()
992
+ .find(|(key, _)| key.eq_ignore_ascii_case(name))
993
+ .map(|(_, value)| value.clone())
994
+ }
995
+ }
996
+
997
+ struct MockGithubServer {
998
+ api_url: String,
999
+ requests: Arc<Mutex<Vec<RecordedRequest>>>,
1000
+ handle: Option<thread::JoinHandle<()>>,
1001
+ }
1002
+
1003
+ impl MockGithubServer {
1004
+ fn new(request_count: usize) -> Self {
1005
+ let listener = TcpListener::bind("127.0.0.1:0").expect("server should bind");
1006
+ let address = listener.local_addr().expect("server address should read");
1007
+ let api_url = format!("http://{address}");
1008
+ let requests = Arc::new(Mutex::new(Vec::new()));
1009
+ let thread_requests = Arc::clone(&requests);
1010
+ let thread_api_url = api_url.clone();
1011
+ let handle = thread::spawn(move || {
1012
+ for index in 0..request_count {
1013
+ let (mut stream, _) = listener.accept().expect("request should connect");
1014
+ let request = read_http_request(&mut stream);
1015
+ thread_requests
1016
+ .lock()
1017
+ .expect("requests should lock")
1018
+ .push(request);
1019
+
1020
+ match index {
1021
+ 0 => write_http_response(&mut stream, 404, r#"{"message":"Not Found"}"#),
1022
+ 1 => write_http_response(
1023
+ &mut stream,
1024
+ 201,
1025
+ &format!(
1026
+ r#"{{
1027
+ "html_url":"https://github.com/Ikana/electron-cli/releases/tag/v0.1.0",
1028
+ "upload_url":"{}/uploads/1{{?name,label}}",
1029
+ "assets":[]
1030
+ }}"#,
1031
+ thread_api_url
1032
+ ),
1033
+ ),
1034
+ _ => write_http_response(
1035
+ &mut stream,
1036
+ 201,
1037
+ r#"{
1038
+ "id":42,
1039
+ "name":"starter-app",
1040
+ "browser_download_url":"https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip"
1041
+ }"#,
1042
+ ),
1043
+ }
1044
+ }
1045
+ });
1046
+
1047
+ Self {
1048
+ api_url,
1049
+ requests,
1050
+ handle: Some(handle),
1051
+ }
1052
+ }
1053
+
1054
+ fn finish(mut self) -> Vec<RecordedRequest> {
1055
+ if let Some(handle) = self.handle.take() {
1056
+ handle.join().expect("server thread should finish");
1057
+ }
1058
+ self.requests.lock().expect("requests should lock").clone()
1059
+ }
1060
+ }
1061
+
1062
+ fn read_http_request(stream: &mut TcpStream) -> RecordedRequest {
1063
+ let mut bytes = Vec::new();
1064
+ let header_end = loop {
1065
+ let mut buffer = [0; 1024];
1066
+ let read = stream.read(&mut buffer).expect("request should read");
1067
+ assert!(read > 0, "connection closed before request headers");
1068
+ bytes.extend_from_slice(&buffer[..read]);
1069
+ if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
1070
+ break position + 4;
1071
+ }
1072
+ };
1073
+
1074
+ let headers_text =
1075
+ String::from_utf8(bytes[..header_end].to_vec()).expect("headers should be utf-8");
1076
+ let mut lines = headers_text.split("\r\n");
1077
+ let request_line = lines.next().expect("request line should exist");
1078
+ let mut request_parts = request_line.split_whitespace();
1079
+ let method = request_parts
1080
+ .next()
1081
+ .expect("request method should exist")
1082
+ .to_string();
1083
+ let path = request_parts
1084
+ .next()
1085
+ .expect("request path should exist")
1086
+ .to_string();
1087
+ let headers = lines
1088
+ .filter_map(|line| line.split_once(':'))
1089
+ .map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
1090
+ .collect::<Vec<_>>();
1091
+ let content_length = headers
1092
+ .iter()
1093
+ .find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
1094
+ .and_then(|(_, value)| value.parse::<usize>().ok())
1095
+ .unwrap_or(0);
1096
+ while bytes.len() < header_end + content_length {
1097
+ let mut buffer = [0; 1024];
1098
+ let read = stream.read(&mut buffer).expect("request body should read");
1099
+ assert!(read > 0, "connection closed before request body");
1100
+ bytes.extend_from_slice(&buffer[..read]);
1101
+ }
1102
+ let body = bytes[header_end..header_end + content_length].to_vec();
1103
+
1104
+ RecordedRequest {
1105
+ method,
1106
+ path,
1107
+ headers,
1108
+ body,
1109
+ }
1110
+ }
1111
+
1112
+ fn write_http_response(stream: &mut TcpStream, status: u16, body: &str) {
1113
+ let reason = match status {
1114
+ 201 => "Created",
1115
+ 404 => "Not Found",
1116
+ _ => "OK",
1117
+ };
1118
+ write!(
1119
+ stream,
1120
+ "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1121
+ body.len(),
1122
+ body
1123
+ )
1124
+ .expect("response should write");
1125
+ }
1126
+ }