electron-builder 27.0.0-alpha.7 → 27.0.0-alpha.9

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.
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import * as path from "path";
3
- import { AZURE_KNOWN_FIELDS, ELECTRON_DOWNLOAD_DROPPED, MAC_SIGN_FIELDS, MAC_UNIVERSAL_FIELDS, NSIS_WEB_ADVISORY, SNAP_BASES } from "./migrate-schema.js";
3
+ import { ASAR_PLATFORM_KEYS, AZURE_KNOWN_FIELDS, ELECTRON_DOWNLOAD_DROPPED, isLegacyElectronDownloadKey, MAC_ENTITLEMENTS_ADVISORY, MAC_NULL_MEANS_UNSET_FIELDS, MAC_SIGN_FIELDS, MAC_SIGN_REMOVED_FIELDS, MAC_UNIVERSAL_FIELDS, NSIS_WEB_ADVISORY, SNAP_BASES, SNAP_CORE24_UNSUPPORTED, } from "./migrate-schema.js";
4
4
  const _require = createRequire(import.meta.url);
5
5
  // `typescript` is intentionally NOT a dependency of electron-builder — it is loaded lazily (same
6
6
  // pattern as js-yaml/json5/toml) and the feature degrades to manual steps when it is unavailable.
@@ -289,13 +289,23 @@ class ConfigCodemod {
289
289
  }
290
290
  this.ruleSyncDesktopName(root);
291
291
  this.ruleNativeModules(root);
292
+ // Platform asar reads the root keys before ruleAsar edits them (edits only apply at the end, so order is for clarity).
293
+ for (const platform of ASAR_PLATFORM_KEYS) {
294
+ const p = this.getObjectProp(root, platform);
295
+ if (p != null) {
296
+ this.rulePlatformAsar(root, p, platform);
297
+ }
298
+ }
292
299
  this.ruleAsar(root);
293
300
  this.ruleAppImageSystemIntegration(root);
294
301
  this.rulePublish(root, "publish");
295
- for (const platform of ["mac", "win", "linux"]) {
296
- const p = this.getObjectProp(root, platform);
297
- if (p != null) {
298
- this.rulePublish(p, "publish", platform + ".");
302
+ // publish nested in any platform/target section (mac, win, nsis, dmg, …). `snap` is skipped: ruleSnap
303
+ // re-emits its properties verbatim, which would overlap an edit made inside it.
304
+ for (const prop of root.properties) {
305
+ const name = this.propName(prop);
306
+ const obj = name != null && name !== "snap" ? this.getObjectProp(root, name) : null;
307
+ if (obj != null) {
308
+ this.rulePublish(obj, "publish", name + ".");
299
309
  }
300
310
  }
301
311
  this.ruleSnap(root);
@@ -310,7 +320,9 @@ class ConfigCodemod {
310
320
  }
311
321
  }
312
322
  this.ruleElectronDownload(root);
323
+ this.ruleToolsets(root);
313
324
  this.ruleNsisWebAdvisory(root);
325
+ this.ruleMacEntitlementsAdvisory(root);
314
326
  }
315
327
  // ── Rules ───────────────────────────────────────────────────────────────
316
328
  // Advisory only — adds no edit, so an nsis-web-only config stays a "no-op" and is never rewritten.
@@ -326,6 +338,58 @@ class ConfigCodemod {
326
338
  }
327
339
  }
328
340
  }
341
+ // Advisory only. Mirrors migrateConfig: a mac/mas/masDev object that names no entitlements file (at the v26
342
+ // location or under `sign`) relies on the bundled default, which v27 tightened.
343
+ ruleMacEntitlementsAdvisory(root) {
344
+ const ts = this.ts;
345
+ const isNullLiteral = (prop) => prop != null && ts.isPropertyAssignment(prop) && this.unwrap(prop.initializer).kind === ts.SyntaxKind.NullKeyword;
346
+ const namesEntitlements = (obj) => {
347
+ const prop = obj == null ? null : this.getProp(obj, "entitlements");
348
+ return prop != null && !isNullLiteral(prop);
349
+ };
350
+ // Not signing with electron-builder's defaults at all: a custom signer, or identity: null (skip signing).
351
+ const bypassesDefaults = (p, sign) => {
352
+ const signProp = this.getProp(p, "sign");
353
+ const custom = signProp != null && ts.isPropertyAssignment(signProp) && sign == null && !isNullLiteral(signProp);
354
+ return custom || isNullLiteral(this.getProp(p, "identity")) || (sign != null && isNullLiteral(this.getProp(sign, "identity")));
355
+ };
356
+ for (const platform of ["mac", "mas", "masDev"]) {
357
+ const p = this.getObjectProp(root, platform);
358
+ const sign = p == null ? null : this.getObjectProp(p, "sign");
359
+ if (p != null && !namesEntitlements(p) && !namesEntitlements(sign) && !bypassesDefaults(p, sign)) {
360
+ this.advisories.push(MAC_ENTITLEMENTS_ADVISORY);
361
+ return;
362
+ }
363
+ }
364
+ }
365
+ ruleToolsets(root) {
366
+ const ts = this.ts;
367
+ const toolsets = this.getObjectProp(root, "toolsets");
368
+ if (toolsets == null) {
369
+ return;
370
+ }
371
+ for (const prop of toolsets.properties) {
372
+ const name = this.propName(prop);
373
+ if (name == null || !ts.isPropertyAssignment(prop)) {
374
+ continue;
375
+ }
376
+ const value = this.unwrap(prop.initializer);
377
+ if (value.kind === ts.SyntaxKind.NullKeyword) {
378
+ this.removeProp(prop);
379
+ this.changes.push({
380
+ key: `toolsets.${name}`,
381
+ description: `removed toolsets.${name}: null (v27 rejects null; unset resolves to "latest", the newest bundle — pin "0.0.0" to keep the legacy bundle v26 used)`,
382
+ });
383
+ }
384
+ else if (name === "appimage" && ts.isStringLiteral(value) && value.text === "1.0.2") {
385
+ this.replaceValue(prop.initializer, '"1.0.3"');
386
+ this.changes.push({
387
+ key: "toolsets.appimage",
388
+ description: 'replaced toolsets.appimage: "1.0.2" with "1.0.3" (1.0.2 is no longer offered; 1.0.3 is the same runtime with the #9598 fix)',
389
+ });
390
+ }
391
+ }
392
+ }
329
393
  hasNsisWebInTarget(value) {
330
394
  const ts = this.ts;
331
395
  if (value == null) {
@@ -389,7 +453,15 @@ class ConfigCodemod {
389
453
  }
390
454
  const nativeRebuilder = this.getProp(root, "nativeRebuilder");
391
455
  if (nativeRebuilder != null) {
392
- sources.push({ prop: nativeRebuilder, renameTo: "rebuildMode" });
456
+ if (this.stringLiteralValue(nativeRebuilder) === "legacy") {
457
+ // v27 dropped the app-builder binary rebuilder; rebuildMode only selects @electron/rebuild's mode.
458
+ this.removeProp(nativeRebuilder);
459
+ this.warnings.push('nativeRebuilder: "legacy" (the app-builder binary rebuilder) was removed in v27 — native modules are always rebuilt with @electron/rebuild. ' +
460
+ 'The value was dropped, so the default nativeModules.rebuildMode ("sequential") applies.');
461
+ }
462
+ else {
463
+ sources.push({ prop: nativeRebuilder, renameTo: "rebuildMode" });
464
+ }
393
465
  this.changes.push({ key: "nativeRebuilder", description: "renamed nativeRebuilder → nativeModules.rebuildMode" });
394
466
  }
395
467
  const npmSkip = this.getProp(root, "npmSkipBuildFromSource");
@@ -417,8 +489,17 @@ class ConfigCodemod {
417
489
  const asarIsTrue = asarVal != null && asarVal.kind === ts.SyntaxKind.TrueKeyword;
418
490
  const asarIsObject = asarVal != null && ts.isObjectLiteralExpression(asarVal);
419
491
  if (asarIsFalse) {
420
- if (this.getProp(root, "asar-unpack") != null || this.getProp(root, "asar-unpack-dir") != null || this.getProp(root, "asarUnpack") != null) {
421
- this.warnings.push("asar is false but asar-unpack/asarUnpack is also set. asar: false disables packaging entirely; remove the unpack keys or enable asar manually.");
492
+ const flatKeys = ["asar-unpack", "asar-unpack-dir", "asarUnpack", "disableSanityCheckAsar", "disableAsarIntegrity"].filter(k => this.getProp(root, k) != null);
493
+ if (flatKeys.length === 0) {
494
+ return;
495
+ }
496
+ if (this.platformReEnablesAsar(root)) {
497
+ // Those root values still applied to the re-enabling platform in v26; rulePlatformAsar already warned.
498
+ return;
499
+ }
500
+ for (const key of flatKeys) {
501
+ this.removeProp(this.getProp(root, key));
502
+ this.changes.push({ key, description: `removed ${key} (no effect with asar: false)` });
422
503
  }
423
504
  return;
424
505
  }
@@ -479,16 +560,108 @@ class ConfigCodemod {
479
560
  const entries = buildEntries(braceIndent + this.indentUnit);
480
561
  if (entries.length === 0) {
481
562
  this.removeProp(asarProp);
563
+ this.changes.push({ key: "asar", description: "removed redundant asar: true (asar is enabled by default; the explicit `true` is not needed)" });
482
564
  }
483
565
  else {
484
566
  this.replaceValue(asarProp.initializer, this.objectLiteralTextAt(entries, braceIndent));
567
+ const keys = entries.map(e => e.slice(0, e.indexOf(":")));
568
+ this.changes.push({ key: "asar", description: `replaced asar: true with an asar object carrying ${keys.join(", ")}` });
485
569
  }
486
- this.changes.push({ key: "asar", description: "replaced asar: true with asar object (true is no longer a valid value)" });
487
570
  return;
488
571
  }
489
572
  // No asar prop yet — create one on root.
490
573
  this.createChild(root, "asar", buildEntries(this.propIndentFor(root) + this.indentUnit));
491
574
  }
575
+ /** True when some platform section sets its own `asar` to anything but `false` / `null`. */
576
+ platformReEnablesAsar(root) {
577
+ const ts = this.ts;
578
+ return ASAR_PLATFORM_KEYS.some(platform => {
579
+ const p = this.getObjectProp(root, platform);
580
+ const prop = p == null ? null : this.getProp(p, "asar");
581
+ if (prop == null || !ts.isPropertyAssignment(prop)) {
582
+ return false;
583
+ }
584
+ const kind = this.unwrap(prop.initializer).kind;
585
+ return kind !== ts.SyntaxKind.FalseKeyword && kind !== ts.SyntaxKind.NullKeyword;
586
+ });
587
+ }
588
+ /**
589
+ * Platform-level `asarUnpack` → `<platform>.asar.unpack`. Only the cases where a plain move is exactly the v26
590
+ * result are rewritten: a platform-level `asar` replaces the root one in v27, so whenever root-level asar
591
+ * options exist they would have to be copied into the platform object — that is left to the user, with a warning.
592
+ */
593
+ rulePlatformAsar(root, platform, name) {
594
+ const ts = this.ts;
595
+ const unpackProp = this.getProp(platform, "asarUnpack");
596
+ const asarProp = this.getProp(platform, "asar");
597
+ const asarVal = asarProp != null && ts.isPropertyAssignment(asarProp) ? this.unwrap(asarProp.initializer) : null;
598
+ const rootAsarProp = this.getProp(root, "asar");
599
+ const rootAsarVal = rootAsarProp != null && ts.isPropertyAssignment(rootAsarProp) ? this.unwrap(rootAsarProp.initializer) : null;
600
+ const rootHasFlat = ["asar-unpack", "asar-unpack-dir", "asarUnpack", "disableSanityCheckAsar", "disableAsarIntegrity"].some(k => this.getProp(root, k) != null) ||
601
+ (rootAsarVal != null && ts.isObjectLiteralExpression(rootAsarVal) && this.getProp(rootAsarVal, "unpackDir") != null);
602
+ const ownIsNull = asarVal == null || asarVal.kind === ts.SyntaxKind.NullKeyword;
603
+ const manual = (why) => this.warnings.push(`${name}.asar: ${why} In v27 a platform-level asar replaces the root one, so copy the root asar options into ${name}.asar` +
604
+ (unpackProp != null ? ` and move ${name}.asarUnpack there manually.` : " manually."));
605
+ // Shorthand / non-literal values cannot be reasoned about statically.
606
+ if ((asarProp != null && !ts.isPropertyAssignment(asarProp)) || (rootAsarProp != null && !ts.isPropertyAssignment(rootAsarProp))) {
607
+ if (unpackProp != null) {
608
+ manual("the platform or root asar value is not a literal.");
609
+ }
610
+ return;
611
+ }
612
+ if (ownIsNull) {
613
+ if (unpackProp == null) {
614
+ return;
615
+ }
616
+ if (rootAsarVal != null && rootAsarVal.kind === ts.SyntaxKind.FalseKeyword) {
617
+ this.removeProp(unpackProp);
618
+ this.changes.push({ key: `${name}.asarUnpack`, description: `removed ${name}.asarUnpack (no effect with asar: false)` });
619
+ return;
620
+ }
621
+ const rootAsarIsTrivial = rootAsarVal == null ||
622
+ rootAsarVal.kind === ts.SyntaxKind.TrueKeyword ||
623
+ rootAsarVal.kind === ts.SyntaxKind.NullKeyword ||
624
+ (ts.isObjectLiteralExpression(rootAsarVal) && rootAsarVal.properties.length === 0);
625
+ if (rootHasFlat || !rootAsarIsTrivial) {
626
+ manual(`${name}.asarUnpack is set alongside root-level asar options.`);
627
+ return;
628
+ }
629
+ if (asarProp != null) {
630
+ this.removeProp(asarProp);
631
+ }
632
+ this.moveInto(platform, "asar", [{ prop: unpackProp, renameTo: "unpack" }], [], /* treatNullAsAbsent */ true);
633
+ this.changes.push({ key: `${name}.asarUnpack`, description: `moved ${name}.asarUnpack → ${name}.asar.unpack` });
634
+ return;
635
+ }
636
+ if (asarVal.kind === ts.SyntaxKind.FalseKeyword) {
637
+ if (unpackProp != null) {
638
+ this.removeProp(unpackProp);
639
+ this.changes.push({ key: `${name}.asarUnpack`, description: `removed ${name}.asarUnpack (no effect with ${name}.asar: false)` });
640
+ }
641
+ return;
642
+ }
643
+ // `asar: true` or an object literal
644
+ if (rootHasFlat) {
645
+ manual("root-level asarUnpack / disableSanityCheckAsar / disableAsarIntegrity applied to this platform in v26.");
646
+ return;
647
+ }
648
+ if (unpackProp == null) {
649
+ return;
650
+ }
651
+ if (asarVal.kind === ts.SyntaxKind.TrueKeyword) {
652
+ const braceIndent = this.lineIndentAt(this.start(asarProp.initializer));
653
+ this.replaceValue(asarProp.initializer, this.objectLiteralTextAt([this.entryText(unpackProp, braceIndent + this.indentUnit, "unpack")], braceIndent));
654
+ this.removeProp(unpackProp);
655
+ }
656
+ else if (ts.isObjectLiteralExpression(asarVal) && this.getProp(asarVal, "unpack") == null) {
657
+ this.moveInto(platform, "asar", [{ prop: unpackProp, renameTo: "unpack" }], []);
658
+ }
659
+ else {
660
+ manual(`${name}.asar is not a plain object literal, or already sets unpack.`);
661
+ return;
662
+ }
663
+ this.changes.push({ key: `${name}.asarUnpack`, description: `moved ${name}.asarUnpack → ${name}.asar.unpack` });
664
+ }
492
665
  ruleAppImageSystemIntegration(root) {
493
666
  const appImage = this.getObjectProp(root, "appImage");
494
667
  if (appImage == null) {
@@ -522,16 +695,34 @@ class ConfigCodemod {
522
695
  continue;
523
696
  }
524
697
  const provider = this.stringLiteralValue(this.getProp(obj, "provider"));
525
- const vPrefixed = this.getProp(obj, "vPrefixedTagName");
526
- if (vPrefixed == null || !ts.isPropertyAssignment(vPrefixed)) {
698
+ // GitLab keeps vPrefixedTagName in v27 (no tagNamePrefix equivalent) — leave it untouched.
699
+ if (provider !== "github") {
527
700
  continue;
528
701
  }
529
- if (provider === "github") {
702
+ const vPrefixed = this.getProp(obj, "vPrefixedTagName");
703
+ const tagNamePrefix = this.getProp(obj, "tagNamePrefix");
704
+ const prefixText = this.stringLiteralValue(tagNamePrefix);
705
+ if (vPrefixed != null && ts.isPropertyAssignment(vPrefixed)) {
706
+ // v26 githubTagPrefix(): a non-empty tagNamePrefix won; otherwise vPrefixedTagName (default true) chose "v" or "".
530
707
  const isFalse = vPrefixed.initializer.kind === ts.SyntaxKind.FalseKeyword;
531
- this.replaceRange(this.start(vPrefixed), vPrefixed.end, `tagNamePrefix: ${isFalse ? '""' : '"v"'}`);
708
+ if (tagNamePrefix == null) {
709
+ this.replaceRange(this.start(vPrefixed), vPrefixed.end, `tagNamePrefix: ${isFalse ? '""' : '"v"'}`);
710
+ }
711
+ else {
712
+ if (prefixText === "") {
713
+ this.replaceValue(tagNamePrefix.initializer, isFalse ? '""' : '"v"');
714
+ }
715
+ this.removeProp(vPrefixed);
716
+ }
532
717
  changed = true;
533
718
  }
534
- // GitLab keeps vPrefixedTagName in v27 (no tagNamePrefix equivalent) — leave it untouched.
719
+ else if (prefixText === "") {
720
+ // v26 ignored an empty tagNamePrefix and still tagged "v1.2.3"; v27 honors it and would tag "1.2.3".
721
+ this.replaceValue(tagNamePrefix.initializer, '"v"');
722
+ changed = true;
723
+ this.warnings.push('A GitHub publish entry set tagNamePrefix: "". v26 ignored an empty prefix and tagged releases "v<version>"; v27 honors it. ' +
724
+ 'It was rewritten to tagNamePrefix: "v" to keep your existing tag names — set it back to "" if you want unprefixed tags from now on.');
725
+ }
535
726
  }
536
727
  if (changed) {
537
728
  this.changes.push({
@@ -552,6 +743,11 @@ class ConfigCodemod {
552
743
  this.changes.push({ key: "snap", description: "removed empty snap config (use snapcraft in v27)" });
553
744
  return;
554
745
  }
746
+ // snap's properties are re-emitted verbatim below, so rulePublish skipped it — flag what it would have rewritten.
747
+ const snapPublish = this.getProp(snap, "publish");
748
+ if (snapPublish != null && /\bvPrefixedTagName\b|\btagNamePrefix\s*:\s*(""|'')/.test(this.text.slice(this.start(snapPublish), snapPublish.end))) {
749
+ this.warnings.push("snap.publish sets vPrefixedTagName or an empty tagNamePrefix; after the move, apply the GitHub tagNamePrefix change under snapcraft.<base>.publish manually.");
750
+ }
555
751
  const baseProp = this.getProp(snap, "base");
556
752
  let base = this.stringLiteralValue(baseProp);
557
753
  const restProps = snap.properties.filter((p) => p !== baseProp);
@@ -574,8 +770,16 @@ class ConfigCodemod {
574
770
  base = "core20";
575
771
  assumed = true;
576
772
  }
773
+ let nestedProps = restProps;
774
+ if (base === "core24") {
775
+ const unsupported = SNAP_CORE24_UNSUPPORTED.filter(k => this.getProp(snap, k) != null);
776
+ nestedProps = restProps.filter((p) => !unsupported.includes(this.propName(p)));
777
+ if (unsupported.length > 0) {
778
+ this.warnings.push(`snap config set [${unsupported.join(", ")}], which the v27 snapcraft.core24 options do not support (core24 builds with the snapcraft CLI directly); they were dropped.`);
779
+ }
780
+ }
577
781
  const baseChildIndent = this.propIndentFor(root) + this.indentUnit + this.indentUnit;
578
- const nestedEntries = restProps.map((p) => this.entryText(p, baseChildIndent));
782
+ const nestedEntries = nestedProps.map((p) => this.entryText(p, baseChildIndent));
579
783
  const snapcraftChildIndent = this.propIndentFor(root) + this.indentUnit;
580
784
  const nestedObject = nestedEntries.length > 0 ? this.objectLiteralTextAt(nestedEntries, snapcraftChildIndent) : "{}";
581
785
  const entries = [`base: "${base}"`, `${base}: ${nestedObject}`];
@@ -608,6 +812,12 @@ class ConfigCodemod {
608
812
  if (sq == null) {
609
813
  return;
610
814
  }
815
+ // Not auto-migrated (different bundle layout) — left in place so the build fails with a targeted message.
816
+ if (this.getProp(sq, "customSquirrelVendorDir") != null) {
817
+ this.warnings.push("squirrelWindows.customSquirrelVendorDir was removed in v27 and cannot be migrated automatically. " +
818
+ 'Supply the custom bundle via toolsets.squirrel: { url: "file:///abs/path" } — the bundle must contain an electron-winstaller/vendor/ subtree ' +
819
+ "(customSquirrelVendorDir pointed at the vendor files directly) — or remove the key to use the default Squirrel bundle.");
820
+ }
611
821
  const noMsi = this.getProp(sq, "noMsi");
612
822
  if (noMsi == null || !ts.isPropertyAssignment(noMsi)) {
613
823
  return;
@@ -626,29 +836,48 @@ class ConfigCodemod {
626
836
  if (win == null) {
627
837
  return;
628
838
  }
629
- let signSet = this.getProp(win, "sign") != null;
839
+ const signNode = this.getProp(win, "sign");
840
+ const existingSignIsNull = signNode != null && ts.isPropertyAssignment(signNode) && this.unwrap(signNode.initializer).kind === ts.SyntaxKind.NullKeyword;
841
+ // `sign: null` is "not configured" in both versions — it neither blocks nor survives a migration below.
842
+ let signSet = signNode != null && !existingSignIsNull;
843
+ // Either flag set to false skipped ALL signing in v26, so both map to win.sign: false (signAndEditExecutable
844
+ // also skipped resource editing, which always runs in v27 — that part has no equivalent).
845
+ const isFalseProp = (prop) => prop != null && ts.isPropertyAssignment(prop) && prop.initializer.kind === ts.SyntaxKind.FalseKeyword;
630
846
  const signAndEdit = this.getProp(win, "signAndEditExecutable");
847
+ const signExe = this.getProp(win, "signExecutable");
848
+ const signingDisabled = isFalseProp(signAndEdit) || isFalseProp(signExe);
631
849
  if (signAndEdit != null && ts.isPropertyAssignment(signAndEdit)) {
632
- const isFalse = signAndEdit.initializer.kind === ts.SyntaxKind.FalseKeyword;
633
850
  this.removeProp(signAndEdit);
634
- if (isFalse) {
635
- this.warnings.push("win.signAndEditExecutable: false was used to skip both resource editing and signing. In v27, resource editing always runs. To skip signing only, set win.sign: false. There is no v27 equivalent that also skips resource editing — apply resources manually if needed.");
636
- }
637
- else {
638
- this.changes.push({ key: "win.signAndEditExecutable", description: "removed win.signAndEditExecutable (resource editing always runs in v27; was the default)" });
851
+ if (isFalseProp(signAndEdit)) {
852
+ this.warnings.push("win.signAndEditExecutable: false was used to skip both resource editing and signing. In v27, resource editing always runs; signing is still skipped via win.sign: false. There is no v27 equivalent that also skips resource editing — apply resources manually if needed.");
639
853
  }
854
+ this.changes.push({ key: "win.signAndEditExecutable", description: "removed win.signAndEditExecutable (resource editing always runs in v27)" });
640
855
  }
641
- const signExe = this.getProp(win, "signExecutable");
642
856
  if (signExe != null && ts.isPropertyAssignment(signExe)) {
643
- const isFalse = signExe.initializer.kind === ts.SyntaxKind.FalseKeyword;
644
857
  this.removeProp(signExe);
645
- if (isFalse && !signSet) {
646
- this.createChild(win, "sign", [], "false");
647
- signSet = true;
648
- this.changes.push({ key: "win.signExecutable", description: "replaced win.signExecutable: false with win.sign: false (disables signing; resource editing still runs)" });
649
- }
650
- else {
651
- this.changes.push({ key: "win.signExecutable", description: "removed win.signExecutable (signing is enabled by default when credentials are available)" });
858
+ this.changes.push({ key: "win.signExecutable", description: "removed win.signExecutable (signing is enabled by default when credentials are available)" });
859
+ }
860
+ let signIsFalse = isFalseProp(signNode);
861
+ if (signingDisabled && !signSet) {
862
+ if (existingSignIsNull) {
863
+ this.removeProp(signNode);
864
+ }
865
+ this.createChild(win, "sign", [], "false");
866
+ signSet = true;
867
+ signIsFalse = true;
868
+ this.changes.push({ key: "win.sign", description: "set win.sign: false (v26 signExecutable/signAndEditExecutable: false disabled signing; resource editing still runs)" });
869
+ }
870
+ if (signIsFalse) {
871
+ // Signing is switched off, so the legacy options never applied. Only key names are reported — values can hold passwords.
872
+ const dropped = ["signtoolOptions", "azureSignOptions"].filter(k => this.getProp(win, k) != null);
873
+ if (dropped.length > 0) {
874
+ for (const k of dropped) {
875
+ this.removeProp(this.getProp(win, k));
876
+ this.changes.push({ key: `win.${k}`, description: `removed win.${k} (signing is disabled with win.sign: false)` });
877
+ }
878
+ this.warnings.push(`win.sign is false (signing disabled), so [${dropped.map(k => `win.${k}`).join(", ")}] had no effect and ${dropped.length === 1 ? "was" : "were"} removed. ` +
879
+ 'To sign in v27, replace win.sign: false with win.sign: { type: "signtool" | "azure", … } carrying those options.');
880
+ return;
652
881
  }
653
882
  }
654
883
  const azure = this.getObjectProp(win, "azureSignOptions");
@@ -658,12 +887,13 @@ class ConfigCodemod {
658
887
  if (!hasAzure && !hasSigntool) {
659
888
  return;
660
889
  }
661
- const originalSign = this.getProp(win, "sign");
662
- const signAlreadySet = signSet || (originalSign != null && ts.isPropertyAssignment(originalSign) && originalSign.initializer.kind !== ts.SyntaxKind.NullKeyword);
663
- if (signAlreadySet) {
890
+ if (signSet) {
664
891
  this.warnings.push(`win.sign is already set alongside ${hasAzure ? "win.azureSignOptions" : "win.signtoolOptions"}. Remove the legacy key manually after verifying win.sign is correct.`);
665
892
  return;
666
893
  }
894
+ if (existingSignIsNull) {
895
+ this.removeProp(signNode);
896
+ }
667
897
  if (hasAzure && hasSigntool) {
668
898
  this.warnings.push("Both win.azureSignOptions and win.signtoolOptions are set. win.signtoolOptions will be dropped and win.azureSignOptions will be migrated to win.sign: { type: 'azure', … } (Azure took priority in v26). Verify the migrated win.sign block is correct for your project.");
669
899
  this.removeProp(this.getProp(win, "signtoolOptions"));
@@ -740,6 +970,17 @@ class ConfigCodemod {
740
970
  }
741
971
  ruleMacSigning(platform, name) {
742
972
  const ts = this.ts;
973
+ // Removed outright, not moved: ElectronSignOptions omits these and the schema rejects them.
974
+ for (const field of MAC_SIGN_REMOVED_FIELDS) {
975
+ const prop = this.getProp(platform, field);
976
+ if (prop != null) {
977
+ this.removeProp(prop);
978
+ this.changes.push({
979
+ key: `${name}.${field}`,
980
+ description: `removed ${name}.${field} (@electron/osx-sign 2.x dropped the spctl --assess step; there is no ${name}.sign.${field})`,
981
+ });
982
+ }
983
+ }
743
984
  const present = MAC_SIGN_FIELDS.filter(f => this.getProp(platform, f) != null);
744
985
  const signIgnore = this.getProp(platform, "signIgnore");
745
986
  const hasSignIgnore = signIgnore != null;
@@ -762,28 +1003,53 @@ class ConfigCodemod {
762
1003
  const sources = [];
763
1004
  for (const f of present) {
764
1005
  const prop = this.getProp(platform, f);
1006
+ if (this.dropNullField(prop, f, name)) {
1007
+ continue;
1008
+ }
765
1009
  sources.push({ prop });
766
1010
  this.changes.push({ key: `${name}.${f}`, description: `moved ${name}.${f} → ${name}.sign.${f}` });
767
1011
  }
768
- if (hasSignIgnore) {
1012
+ if (hasSignIgnore && !this.dropNullField(signIgnore, "signIgnore", name)) {
769
1013
  sources.push({ prop: signIgnore, renameTo: "ignore" });
770
1014
  this.changes.push({ key: `${name}.signIgnore`, description: `renamed ${name}.signIgnore → ${name}.sign.ignore` });
771
1015
  }
772
1016
  // A bare `sign: null` should be dropped before we build the options object.
773
1017
  if (signIsNull) {
774
1018
  this.removeProp(existingSignProp);
1019
+ if (sources.length === 0) {
1020
+ this.changes.push({ key: `${name}.sign`, description: `removed ${name}.sign: null (v26 "no custom signer" = v27 default; sign: null now means skip signing)` });
1021
+ }
1022
+ }
1023
+ if (sources.length > 0) {
1024
+ this.moveInto(platform, "sign", sources, [], /* treatNullAsAbsent */ true);
775
1025
  }
776
- this.moveInto(platform, "sign", sources, [], /* treatNullAsAbsent */ true);
1026
+ }
1027
+ /** Drops a v26 `null` ("unset") for a field whose v27 type has no null branch. Returns true when dropped. */
1028
+ dropNullField(prop, field, name) {
1029
+ const ts = this.ts;
1030
+ if (!MAC_NULL_MEANS_UNSET_FIELDS.has(field) || !ts.isPropertyAssignment(prop) || this.unwrap(prop.initializer).kind !== ts.SyntaxKind.NullKeyword) {
1031
+ return false;
1032
+ }
1033
+ this.removeProp(prop);
1034
+ this.changes.push({ key: `${name}.${field}`, description: `removed ${name}.${field}: null (null meant "unset" in v26; the v27 option does not accept null)` });
1035
+ return true;
777
1036
  }
778
1037
  ruleMacUniversal(platform, name) {
779
1038
  const present = MAC_UNIVERSAL_FIELDS.filter(f => this.getProp(platform, f) != null);
780
1039
  if (present.length === 0) {
781
1040
  return;
782
1041
  }
783
- const sources = present.map(f => {
784
- this.changes.push({ key: `${name}.${f}`, description: `moved ${name}.${f} → ${name}.universal.${f}` });
785
- return { prop: this.getProp(platform, f) };
786
- });
1042
+ const sources = [];
1043
+ for (const f of present) {
1044
+ const prop = this.getProp(platform, f);
1045
+ if (!this.dropNullField(prop, f, name)) {
1046
+ this.changes.push({ key: `${name}.${f}`, description: `moved ${name}.${f} → ${name}.universal.${f}` });
1047
+ sources.push({ prop });
1048
+ }
1049
+ }
1050
+ if (sources.length === 0) {
1051
+ return;
1052
+ }
787
1053
  const ok = this.moveInto(platform, "universal", sources, []);
788
1054
  if (!ok) {
789
1055
  this.warnings.push(`${name}.universal already exists but is not an object literal; move ${present.join(", ")} into it manually.`);
@@ -791,8 +1057,22 @@ class ConfigCodemod {
791
1057
  }
792
1058
  ruleElectronDownload(root) {
793
1059
  const ts = this.ts;
794
- const prop = this.getProp(root, "electronDownload");
795
- if (prop == null || !ts.isPropertyAssignment(prop)) {
1060
+ let sourceKey = "electronDownload";
1061
+ let prop = this.getProp(root, "electronDownload");
1062
+ if (prop == null) {
1063
+ // A hand-renamed `electronGet` that still carries the v26 shape.
1064
+ const electronGet = this.getObjectProp(root, "electronGet");
1065
+ if (electronGet == null || !electronGet.properties.some((p) => isLegacyElectronDownloadKey(this.propName(p) ?? ""))) {
1066
+ return;
1067
+ }
1068
+ sourceKey = "electronGet";
1069
+ prop = this.getProp(root, "electronGet");
1070
+ }
1071
+ else if (this.getProp(root, "electronGet") != null) {
1072
+ this.warnings.push("Both electronDownload and electronGet are set; merge electronDownload into electronGet manually (mirror → mirrorOptions.mirror, isVerifyChecksum: false → unsafelyDisableChecksums: true).");
1073
+ return;
1074
+ }
1075
+ if (!ts.isPropertyAssignment(prop)) {
796
1076
  return;
797
1077
  }
798
1078
  const old = this.unwrap(prop.initializer);
@@ -852,8 +1132,10 @@ class ConfigCodemod {
852
1132
  this.removeProp(prop);
853
1133
  this.createChild(root, "electronGet", entries);
854
1134
  this.changes.push({
855
- key: "electronDownload",
856
- description: "renamed electronDownload → electronGet (mirror → mirrorOptions.mirror; isVerifyChecksum → unsafelyDisableChecksums)",
1135
+ key: sourceKey,
1136
+ description: sourceKey === "electronDownload"
1137
+ ? "renamed electronDownload → electronGet (mirror → mirrorOptions.mirror; isVerifyChecksum → unsafelyDisableChecksums)"
1138
+ : "reshaped electronGet to the v27 options (mirror → mirrorOptions.mirror; isVerifyChecksum → unsafelyDisableChecksums)",
857
1139
  });
858
1140
  }
859
1141
  // ── Edit primitives ───────────────────────────────────────────────────────