Release ForgeFlow 0.8.2 with binary auto-update
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.2 - 2026-07-26
|
||||
|
||||
- enabled checksum-verified binary auto-update for installed and portable Windows builds;
|
||||
- added an external binary updater that waits for ForgeFlow to close, applies the verified release and restarts the application;
|
||||
- added reproducible SHA-256 sidecars and authenticated Gitea release publishing;
|
||||
- made packaged update checks fail clearly when a matching published release asset is incomplete.
|
||||
|
||||
## 0.8.1 - 2026-07-26
|
||||
|
||||
- introduced a refined premium visual system with clearer hierarchy, richer depth, responsive density and reduced-motion support;
|
||||
|
||||
+16
-12
@@ -1,4 +1,4 @@
|
||||
ForgeFlow 0.8.1 source manifest
|
||||
ForgeFlow 0.8.2 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
|
||||
@@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
||||
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
|
||||
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
|
||||
994243db23370527fd4cf1eeb2ec9c1cd5609daad55c1499a1158b911657bfdf 7811 CHANGELOG.md
|
||||
973e58a92fb2fdaff471dbe7a20549c9fc79e6014933cd56dce971ad7d474bd4 8234 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
@@ -39,6 +39,7 @@ d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721
|
||||
f3d04f2d3419a7a010d5399cdd9351ff85ab2b3fdf8023977e559b0a5f8bcdc3 2571 docs/RELEASE_NOTES_0.7.0.md
|
||||
d7bdc61d9b617ad5acf0b2d468eda547fd7509d4af08f33d2661393f25bdcb5a 576 docs/RELEASE_NOTES_0.8.0.md
|
||||
1e056bfcf2105843402f4b14c63480240cc55456a4a63e229b3fdbaf3156b803 754 docs/RELEASE_NOTES_0.8.1.md
|
||||
7f1d7c8bc895d309dad2f8ab444d6d2ba3e68c8daa240fecd2abdd9d8a56ba20 729 docs/RELEASE_NOTES_0.8.2.md
|
||||
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
|
||||
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
|
||||
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
|
||||
@@ -51,7 +52,7 @@ bcb1e4daf1eeedc5b3f61d2406f1a65312dba130082528007e1629d9df99570a 153240
|
||||
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
|
||||
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
|
||||
4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md
|
||||
42f3bcb264fa772849782f163fdcaac28aedad15d754b93ed52ab3ab41477c3d 2377 docs/UPDATING.md
|
||||
28f42ed6352a01e034c39c5a2a2f461f3f540aa37abc339e5b00afcc81e7ba7b 3273 docs/UPDATING.md
|
||||
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
|
||||
4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml
|
||||
1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy
|
||||
@@ -60,20 +61,23 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
|
||||
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
7c7790e229bbe6035a47c29d17cddd187482e7cda03c5672ec9d7aca16bc4a5c 11357 main.cjs
|
||||
6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
e4c91f198af6235ea035f58268b85588bc1cbf0952c28785a30f15ee883feb84 134141 package-lock.json
|
||||
f85119969c32226a5d5094616400936aaa161db65ed35753894a5bc75dc50f0b 3301 package.json
|
||||
ebb0b154137c113205351216e79dc0aad79949ea465b1734f41dd524f913cb34 134141 package-lock.json
|
||||
4ad40664dd3ed8526685fc7ca75be8f8c21acbad515b375ba4f04f3e2c4dcfe1 3559 package.json
|
||||
3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs
|
||||
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
|
||||
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
|
||||
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
|
||||
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
|
||||
5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs
|
||||
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
|
||||
a2733f653e6abea7f27c3198631e6144c98458874b338704f99252451cce235b 11999 scripts/verify.mjs
|
||||
e6def656ba61e6ac705bc87e59abc607f4870702f0cbd609bc3e122bb01a9939 12150 scripts/verify.mjs
|
||||
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs
|
||||
@@ -82,7 +86,7 @@ a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
||||
113612b23f9c812e1dbe33eaaf398725c351678419e8304a8dfb4df881b862ff 15048 src/main/gitea-service.cjs
|
||||
f5b4e468c92eb0d96d02357290ac4bfe2d30eef9c7287267882bc146237a8693 16559 src/main/gitea-service.cjs
|
||||
b2d768a9dfd1e494edee6609a233469e60c31c362143d5c37c3c9f908b7bca79 40559 src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
@@ -91,14 +95,14 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508
|
||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
||||
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
|
||||
720103f14cbedd7fd2776e49fd970a634f14e03d548d90bf93bcd878b6b3c674 58758 src/main/unraid-deployment-service.cjs
|
||||
e87647c45cf06e2aa58e319adff96af0f927ca278ff0877eac3b8ff97d690ea8 13103 src/main/update-service.cjs
|
||||
aab6597f0efd72cb27c12aab9866fb5cfe87710b9fbca335e2b5dd767c8bab13 191016 src/renderer/app.js
|
||||
36cc05deda3395e5e9de92b880c8315f508cb88e9b080ae34705057ae696804f 20696 src/main/update-service.cjs
|
||||
2df4cd7b7d685871e40ce86ce4f75d8451cfdfca6184ce6cae5eaa6651ce97da 191018 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
|
||||
24a32724ad412e9c3a2b93f2ddf4cb8db0cddf421968cc620b0552caac39076c 50942 src/renderer/mock-bridge.js
|
||||
d72bca0e649392dbf5c0e9c676dd08b80d5c9f4493f59a32d9201763c139b690 50942 src/renderer/mock-bridge.js
|
||||
51f6777fd7d2dc73dc3ddd96c91a11882483099b0a62e0ce172e25e3dce474a6 59851 src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
@@ -136,7 +140,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
54f641103a91d98974c41a3c0a568c617b76fa9913a0e20710cd11adc39b0deb 18092 tests/unraid-deployment.test.mjs
|
||||
4ea1acea5ca92e1360bcf1263f65d1be0de80ab44adc9af2b09f408951e0d9fa 10454 tests/update-service.test.mjs
|
||||
0c49d3222ea362dbef171f5ad556a335bad670b47adef660ede101d84814d05e 14849 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# ForgeFlow 0.8.2
|
||||
|
||||
ForgeFlow 0.8.2 activates binary auto-update for packaged Windows releases.
|
||||
|
||||
- Installed builds download the matching NSIS installer from the authenticated Gitea release.
|
||||
- Portable builds download and safely replace the original portable executable.
|
||||
- Every executable requires a separately published SHA-256 sidecar and is verified again immediately before installation.
|
||||
- The updater runs outside ForgeFlow, waits for the old process to exit and records a durable success, failure or rollback result.
|
||||
- Release publishing verifies that local `HEAD` equals `origin/main` before uploading artifacts.
|
||||
|
||||
Users of 0.8.1 or older must install 0.8.2 once manually. Updates after 0.8.2 can use the built-in updater.
|
||||
@@ -28,6 +28,20 @@ A failed validation restores the previous source. A successful installation is n
|
||||
|
||||
Update logs and status files are stored beneath ForgeFlow's local user-data `updates` folder and exclude the Gitea token.
|
||||
|
||||
## Packaged Windows updates
|
||||
|
||||
ForgeFlow 0.8.2 and newer use authenticated Gitea release assets when running from the installer or portable executable. The updater selects the installer or portable artifact that matches the current installation mode, requires its `.sha256` sidecar, validates the Windows executable header and SHA-256 digest, then verifies the digest again immediately before applying it. An external PowerShell helper waits for ForgeFlow to exit, installs or replaces the executable and restarts it.
|
||||
|
||||
Publish a verified binary release after pushing its source commit:
|
||||
|
||||
```powershell
|
||||
npm run dist:win
|
||||
$env:FORGEFLOW_USER_DATA = "$env:APPDATA\forgeflow"
|
||||
npm run release:binary
|
||||
```
|
||||
|
||||
The publisher refuses to upload when local `HEAD` differs from `origin/main`. Users on 0.8.1 or older need one manual 0.8.2 installation because those versions deliberately disabled packaged updates.
|
||||
|
||||
## Publishing a release from Downloads
|
||||
|
||||
Extract the complete source ZIP so this file exists:
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const path = require('node:path');
|
||||
const { app, BrowserWindow, shell, session, safeStorage, Tray, Menu, Notification } = require('electron');
|
||||
const { ConfigStore } = require('./src/main/config-store.cjs');
|
||||
const { GitService } = require('./src/main/git-service.cjs');
|
||||
const { GiteaService } = require('./src/main/gitea-service.cjs');
|
||||
const { RepositoryService } = require('./src/main/repository-service.cjs');
|
||||
const { DeploymentService } = require('./src/main/deployment-service.cjs');
|
||||
const { RepositoryMonitor } = require('./src/main/repository-monitor.cjs');
|
||||
const { DiagnosticsService } = require('./src/main/diagnostics-service.cjs');
|
||||
const { PreflightService } = require('./src/main/preflight-service.cjs');
|
||||
const { UpdateService } = require('./src/main/update-service.cjs');
|
||||
const { SshService } = require('./src/main/ssh-service.cjs');
|
||||
const { UnraidDeploymentService } = require('./src/main/unraid-deployment-service.cjs');
|
||||
const { AuditService } = require('./src/main/audit-service.cjs');
|
||||
const { ExternalToolsService } = require('./src/main/external-tools-service.cjs');
|
||||
const { registerIpc } = require('./src/main/ipc.cjs');
|
||||
const path = require("node:path");
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
shell,
|
||||
session,
|
||||
safeStorage,
|
||||
Tray,
|
||||
Menu,
|
||||
Notification,
|
||||
} = require("electron");
|
||||
const { ConfigStore } = require("./src/main/config-store.cjs");
|
||||
const { GitService } = require("./src/main/git-service.cjs");
|
||||
const { GiteaService } = require("./src/main/gitea-service.cjs");
|
||||
const { RepositoryService } = require("./src/main/repository-service.cjs");
|
||||
const { DeploymentService } = require("./src/main/deployment-service.cjs");
|
||||
const { RepositoryMonitor } = require("./src/main/repository-monitor.cjs");
|
||||
const { DiagnosticsService } = require("./src/main/diagnostics-service.cjs");
|
||||
const { PreflightService } = require("./src/main/preflight-service.cjs");
|
||||
const { UpdateService } = require("./src/main/update-service.cjs");
|
||||
const { SshService } = require("./src/main/ssh-service.cjs");
|
||||
const {
|
||||
UnraidDeploymentService,
|
||||
} = require("./src/main/unraid-deployment-service.cjs");
|
||||
const { AuditService } = require("./src/main/audit-service.cjs");
|
||||
const {
|
||||
ExternalToolsService,
|
||||
} = require("./src/main/external-tools-service.cjs");
|
||||
const { registerIpc } = require("./src/main/ipc.cjs");
|
||||
|
||||
let mainWindow;
|
||||
let repositoryMonitor;
|
||||
@@ -39,28 +52,48 @@ function showMainWindow() {
|
||||
}
|
||||
|
||||
function notify(title, body) {
|
||||
if (!configStore?.data.preferences.notificationsEnabled || !Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body, icon: path.join(__dirname, 'build', 'icon.png') });
|
||||
notification.on('click', showMainWindow);
|
||||
if (
|
||||
!configStore?.data.preferences.notificationsEnabled ||
|
||||
!Notification.isSupported()
|
||||
)
|
||||
return;
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
icon: path.join(__dirname, "build", "icon.png"),
|
||||
});
|
||||
notification.on("click", showMainWindow);
|
||||
notification.show();
|
||||
}
|
||||
|
||||
function configureDesktopIntegration() {
|
||||
const preferences = configStore?.data.preferences || {};
|
||||
if (preferences.trayEnabled && !tray) {
|
||||
tray = new Tray(path.join(__dirname, 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png'));
|
||||
tray.setToolTip('ForgeFlow');
|
||||
tray.on('double-click', showMainWindow);
|
||||
tray = new Tray(
|
||||
path.join(
|
||||
__dirname,
|
||||
"build",
|
||||
process.platform === "win32" ? "icon.ico" : "icon.png",
|
||||
),
|
||||
);
|
||||
tray.setToolTip("ForgeFlow");
|
||||
tray.on("double-click", showMainWindow);
|
||||
} else if (!preferences.trayEnabled && tray) {
|
||||
tray.destroy(); tray = null;
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (tray) tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: 'Open ForgeFlow', click: showMainWindow },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: () => app.quit() }
|
||||
]));
|
||||
if (app.isPackaged && ['win32', 'darwin'].includes(process.platform)) {
|
||||
app.setLoginItemSettings({ openAtLogin: Boolean(preferences.startAtLogin) });
|
||||
if (tray)
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: "Open ForgeFlow", click: showMainWindow },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit", click: () => app.quit() },
|
||||
]),
|
||||
);
|
||||
if (app.isPackaged && ["win32", "darwin"].includes(process.platform)) {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: Boolean(preferences.startAtLogin),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,202 +104,320 @@ function createWindow() {
|
||||
minWidth: 1120,
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
backgroundColor: '#0b0e14',
|
||||
title: 'ForgeFlow',
|
||||
icon: path.join(__dirname, 'build', 'icon.png'),
|
||||
backgroundColor: "#0b0e14",
|
||||
title: "ForgeFlow",
|
||||
icon: path.join(__dirname, "build", "icon.png"),
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
preload: path.join(__dirname, "preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
spellcheck: false
|
||||
}
|
||||
spellcheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, 'src', 'renderer', 'index.html'));
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.loadFile(path.join(__dirname, "src", "renderer", "index.html"));
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
diagnostics?.info('window.ready', { size: mainWindow.getSize() });
|
||||
diagnostics?.info("window.ready", { size: mainWindow.getSize() });
|
||||
});
|
||||
mainWindow.on('unresponsive', () => diagnostics?.warning('window.unresponsive', {}));
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => diagnostics?.error('renderer.process.gone', details));
|
||||
mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => diagnostics?.error('renderer.load.failed', { code, description, validatedUrl }));
|
||||
mainWindow.on("unresponsive", () =>
|
||||
diagnostics?.warning("window.unresponsive", {}),
|
||||
);
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) =>
|
||||
diagnostics?.error("renderer.process.gone", details),
|
||||
);
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, code, description, validatedUrl) =>
|
||||
diagnostics?.error("renderer.load.failed", {
|
||||
code,
|
||||
description,
|
||||
validatedUrl,
|
||||
}),
|
||||
);
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) shell.openExternal(url).catch((error) => diagnostics?.warning('external-link.open.failed', { url, message: error.message }));
|
||||
return { action: 'deny' };
|
||||
if (/^https?:\/\//i.test(url))
|
||||
shell
|
||||
.openExternal(url)
|
||||
.catch((error) =>
|
||||
diagnostics?.warning("external-link.open.failed", {
|
||||
url,
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
return { action: "deny" };
|
||||
});
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||
if (url !== mainWindow.webContents.getURL()) event.preventDefault();
|
||||
});
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!quitCleanupStarted && configStore?.data.preferences.closeToTray && configStore?.data.preferences.trayEnabled) {
|
||||
mainWindow.on("close", (event) => {
|
||||
if (
|
||||
!quitCleanupStarted &&
|
||||
configStore?.data.preferences.closeToTray &&
|
||||
configStore?.data.preferences.trayEnabled
|
||||
) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'"
|
||||
]
|
||||
}
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'",
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
const store = new ConfigStore(userDataPath);
|
||||
configStore = store;
|
||||
await store.load();
|
||||
diagnostics = new DiagnosticsService({
|
||||
userDataPath,
|
||||
appInfo: { name: app.getName(), version: app.getVersion(), packaged: app.isPackaged },
|
||||
secretProvider: () => [
|
||||
store.getToken(),
|
||||
...(store.data.servers || []).flatMap((server) => {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const store = new ConfigStore(userDataPath);
|
||||
configStore = store;
|
||||
await store.load();
|
||||
diagnostics = new DiagnosticsService({
|
||||
userDataPath,
|
||||
appInfo: {
|
||||
name: app.getName(),
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
},
|
||||
secretProvider: () => [
|
||||
store.getToken(),
|
||||
...(store.data.servers || []).flatMap((server) => {
|
||||
try {
|
||||
const credentials = store.getServerCredentials(server.id);
|
||||
return [credentials.password, credentials.passphrase];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
],
|
||||
preferencesProvider: () => store.data.preferences,
|
||||
});
|
||||
await diagnostics.initialize();
|
||||
const audit = new AuditService({
|
||||
userDataPath,
|
||||
appInfo: { version: app.getVersion() },
|
||||
});
|
||||
await audit.initialize();
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
diagnostics
|
||||
?.error("process.uncaught-exception", error)
|
||||
.finally(() => app.exit(1));
|
||||
});
|
||||
process.on("unhandledRejection", (reason) =>
|
||||
diagnostics?.error(
|
||||
"process.unhandled-rejection",
|
||||
reason instanceof Error ? reason : { reason },
|
||||
),
|
||||
);
|
||||
|
||||
const git = new GitService();
|
||||
const externalTools = new ExternalToolsService(store);
|
||||
const gitea = new GiteaService(store, diagnostics);
|
||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
||||
const ssh = new SshService({ store, diagnostics });
|
||||
const auditedOperationStates = new Set();
|
||||
const reportOperationChange = (payload) => {
|
||||
broadcast("operations:changed", payload);
|
||||
const operation = payload?.operation;
|
||||
if (
|
||||
operation &&
|
||||
["success", "failed", "rolled-back"].includes(operation.status)
|
||||
) {
|
||||
const key = `${operation.id}:${operation.status}`;
|
||||
if (!auditedOperationStates.has(key)) {
|
||||
auditedOperationStates.add(key);
|
||||
notify(
|
||||
`Deployment ${operation.status}`,
|
||||
`${operation.repository || "Repository"} · ${operation.shortSha || operation.sha?.slice(0, 7) || ""}`,
|
||||
);
|
||||
audit
|
||||
.append("deployment.completed", {
|
||||
repository: operation.repository,
|
||||
profileId: operation.profileId,
|
||||
sha: operation.sha,
|
||||
result: operation.status,
|
||||
note: operation.releaseNote || "",
|
||||
})
|
||||
.catch((error) => diagnostics.warning("audit.write.failed", error));
|
||||
}
|
||||
}
|
||||
};
|
||||
const unraid = new UnraidDeploymentService({
|
||||
store,
|
||||
ssh,
|
||||
git,
|
||||
diagnostics,
|
||||
sourcePath: app.getAppPath(),
|
||||
onOperationChange: reportOperationChange,
|
||||
});
|
||||
const updates = new UpdateService({
|
||||
store,
|
||||
gitea,
|
||||
diagnostics,
|
||||
appInfo: {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
executablePath: process.execPath,
|
||||
portableExecutablePath: process.env.PORTABLE_EXECUTABLE_FILE || null,
|
||||
},
|
||||
sourcePath: app.getAppPath(),
|
||||
userDataPath,
|
||||
});
|
||||
const preflight = new PreflightService({
|
||||
store,
|
||||
git,
|
||||
gitea,
|
||||
deployments,
|
||||
diagnostics,
|
||||
userDataPath,
|
||||
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
});
|
||||
repositoryMonitor = new RepositoryMonitor({
|
||||
store,
|
||||
git,
|
||||
diagnostics,
|
||||
onChange: (payload) => broadcast("repositories:changed", payload),
|
||||
});
|
||||
repositoryMonitor.restart();
|
||||
registerIpc({
|
||||
store,
|
||||
git,
|
||||
gitea,
|
||||
repositories,
|
||||
deployments,
|
||||
unraid,
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
diagnostics,
|
||||
audit,
|
||||
externalTools,
|
||||
monitor: repositoryMonitor,
|
||||
onPreferencesChanged: configureDesktopIntegration,
|
||||
});
|
||||
configureDesktopIntegration();
|
||||
createWindow();
|
||||
|
||||
if (
|
||||
store.data.setupComplete &&
|
||||
store.data.updates?.autoCheck &&
|
||||
store.getToken()
|
||||
) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const credentials = store.getServerCredentials(server.id);
|
||||
return [credentials.password, credentials.passphrase];
|
||||
} catch { return []; }
|
||||
})
|
||||
],
|
||||
preferencesProvider: () => store.data.preferences
|
||||
});
|
||||
await diagnostics.initialize();
|
||||
const audit = new AuditService({ userDataPath, appInfo: { version: app.getVersion() } });
|
||||
await audit.initialize();
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
diagnostics?.error('process.uncaught-exception', error).finally(() => app.exit(1));
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => diagnostics?.error('process.unhandled-rejection', reason instanceof Error ? reason : { reason }));
|
||||
|
||||
const git = new GitService();
|
||||
const externalTools = new ExternalToolsService(store);
|
||||
const gitea = new GiteaService(store, diagnostics);
|
||||
const repositories = new RepositoryService(store, git, gitea, diagnostics);
|
||||
const deployments = new DeploymentService(store, gitea, git, diagnostics);
|
||||
const ssh = new SshService({ store, diagnostics });
|
||||
const auditedOperationStates = new Set();
|
||||
const reportOperationChange = (payload) => {
|
||||
broadcast('operations:changed', payload);
|
||||
const operation = payload?.operation;
|
||||
if (operation && ['success', 'failed', 'rolled-back'].includes(operation.status)) {
|
||||
const key = `${operation.id}:${operation.status}`;
|
||||
if (!auditedOperationStates.has(key)) {
|
||||
auditedOperationStates.add(key);
|
||||
notify(`Deployment ${operation.status}`, `${operation.repository || 'Repository'} · ${operation.shortSha || operation.sha?.slice(0, 7) || ''}`);
|
||||
audit.append('deployment.completed', { repository: operation.repository, profileId: operation.profileId, sha: operation.sha, result: operation.status, note: operation.releaseNote || '' }).catch((error) => diagnostics.warning('audit.write.failed', error));
|
||||
}
|
||||
const status = await updates.check();
|
||||
broadcast("updates:changed", status);
|
||||
} catch (error) {
|
||||
await diagnostics.warning("updates.startup-check.failed", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
}, 2500).unref?.();
|
||||
}
|
||||
};
|
||||
const unraid = new UnraidDeploymentService({ store, ssh, git, diagnostics, sourcePath: app.getAppPath(), onOperationChange: reportOperationChange });
|
||||
const updates = new UpdateService({
|
||||
store,
|
||||
gitea,
|
||||
diagnostics,
|
||||
appInfo: { version: app.getVersion(), packaged: app.isPackaged },
|
||||
sourcePath: app.getAppPath(),
|
||||
userDataPath
|
||||
});
|
||||
const preflight = new PreflightService({
|
||||
store,
|
||||
git,
|
||||
gitea,
|
||||
deployments,
|
||||
diagnostics,
|
||||
userDataPath,
|
||||
secureStorageAvailable: () => safeStorage.isEncryptionAvailable()
|
||||
});
|
||||
repositoryMonitor = new RepositoryMonitor({
|
||||
store,
|
||||
git,
|
||||
diagnostics,
|
||||
onChange: (payload) => broadcast('repositories:changed', payload)
|
||||
});
|
||||
repositoryMonitor.restart();
|
||||
registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, audit, externalTools, monitor: repositoryMonitor, onPreferencesChanged: configureDesktopIntegration });
|
||||
configureDesktopIntegration();
|
||||
createWindow();
|
||||
|
||||
if (store.data.setupComplete && store.data.updates?.autoCheck && store.getToken()) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const status = await updates.check();
|
||||
broadcast('updates:changed', status);
|
||||
} catch (error) {
|
||||
await diagnostics.warning('updates.startup-check.failed', { message: error.message, code: error.code });
|
||||
}
|
||||
}, 2500).unref?.();
|
||||
}
|
||||
const gitAvailability = await git.isAvailable();
|
||||
await diagnostics.info("app.ready", {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
setupComplete: store.data.setupComplete,
|
||||
git: gitAvailability,
|
||||
secureStorageAvailable: safeStorage.isEncryptionAvailable(),
|
||||
});
|
||||
|
||||
const gitAvailability = await git.isAvailable();
|
||||
await diagnostics.info('app.ready', {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
setupComplete: store.data.setupComplete,
|
||||
git: gitAvailability,
|
||||
secureStorageAvailable: safeStorage.isEncryptionAvailable()
|
||||
});
|
||||
|
||||
const scheduleOperationPoll = () => {
|
||||
if (operationTimer) clearTimeout(operationTimer);
|
||||
const intervalMs = Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) * 1000;
|
||||
operationTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (store.data.setupComplete) {
|
||||
const active = store.data.operations.some((item) => item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
|
||||
if (active) {
|
||||
const [actions, sshOperations] = await Promise.all([
|
||||
store.getToken() ? deployments.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.actions.failed', error); return []; }) : [],
|
||||
unraid.refreshActiveOperations().catch(async (error) => { await diagnostics.error('operation-monitor.unraid.failed', error); return []; })
|
||||
]);
|
||||
const updated = [...actions, ...sshOperations];
|
||||
if (updated.length) {
|
||||
broadcast('operations:changed', { operations: updated });
|
||||
for (const operation of updated.filter((item) => ['success', 'failed', 'rolled-back'].includes(item.status))) reportOperationChange({ operation });
|
||||
const scheduleOperationPoll = () => {
|
||||
if (operationTimer) clearTimeout(operationTimer);
|
||||
const intervalMs =
|
||||
Math.max(3, Number(store.data.preferences.operationPollSeconds) || 5) *
|
||||
1000;
|
||||
operationTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (store.data.setupComplete) {
|
||||
const active = store.data.operations.some(
|
||||
(item) =>
|
||||
item.type === "deployment" &&
|
||||
!["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
item.status,
|
||||
),
|
||||
);
|
||||
if (active) {
|
||||
const [actions, sshOperations] = await Promise.all([
|
||||
store.getToken()
|
||||
? deployments
|
||||
.refreshActiveOperations()
|
||||
.catch(async (error) => {
|
||||
await diagnostics.error(
|
||||
"operation-monitor.actions.failed",
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
})
|
||||
: [],
|
||||
unraid.refreshActiveOperations().catch(async (error) => {
|
||||
await diagnostics.error(
|
||||
"operation-monitor.unraid.failed",
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
const updated = [...actions, ...sshOperations];
|
||||
if (updated.length) {
|
||||
broadcast("operations:changed", { operations: updated });
|
||||
for (const operation of updated.filter((item) =>
|
||||
["success", "failed", "rolled-back"].includes(item.status),
|
||||
))
|
||||
reportOperationChange({ operation });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await diagnostics.error("operation-monitor.tick.failed", error);
|
||||
} finally {
|
||||
if (!quitCleanupStarted) scheduleOperationPoll();
|
||||
}
|
||||
} catch (error) {
|
||||
await diagnostics.error('operation-monitor.tick.failed', error);
|
||||
} finally {
|
||||
if (!quitCleanupStarted) scheduleOperationPoll();
|
||||
}
|
||||
}, intervalMs);
|
||||
operationTimer.unref?.();
|
||||
};
|
||||
scheduleOperationPoll();
|
||||
}, intervalMs);
|
||||
operationTimer.unref?.();
|
||||
};
|
||||
scheduleOperationPoll();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("[startup]", error);
|
||||
await diagnostics?.error("app.startup.failed", error);
|
||||
await diagnostics?.flush();
|
||||
app.exit(1);
|
||||
});
|
||||
}).catch(async (error) => {
|
||||
console.error('[startup]', error);
|
||||
await diagnostics?.error('app.startup.failed', error);
|
||||
await diagnostics?.flush();
|
||||
app.exit(1);
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitCleanupStarted) return;
|
||||
event.preventDefault();
|
||||
quitCleanupStarted = true;
|
||||
repositoryMonitor?.stop();
|
||||
if (operationTimer) clearTimeout(operationTimer);
|
||||
Promise.resolve()
|
||||
.then(() => diagnostics?.info('app.quitting', {}))
|
||||
.then(() => diagnostics?.info("app.quitting", {}))
|
||||
.then(() => diagnostics?.flush())
|
||||
.finally(() => app.quit());
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -11,12 +11,13 @@
|
||||
"demo": "node scripts/serve-demo.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"verify": "node scripts/verify.mjs",
|
||||
"dist:win": "electron-builder --win nsis portable",
|
||||
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs",
|
||||
"dist:linux": "electron-builder --linux AppImage",
|
||||
"dist:mac": "electron-builder --mac dmg",
|
||||
"doctor": "node scripts/doctor.mjs",
|
||||
"acceptance": "node scripts/acceptance.mjs",
|
||||
"connections:check": "electron scripts/validate-installed-connections.cjs",
|
||||
"release:binary": "electron scripts/publish-binary-release.cjs",
|
||||
"manifest": "node scripts/generate-source-manifest.mjs",
|
||||
"check": "npm run verify && npm test"
|
||||
},
|
||||
@@ -50,6 +51,7 @@
|
||||
"docs/RELEASE_NOTES_0.3.2.md",
|
||||
"docs/UPDATING.md",
|
||||
"scripts/apply-source-update.ps1",
|
||||
"scripts/apply-binary-update.ps1",
|
||||
"docs/RELEASE_NOTES_0.4.0.md",
|
||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||
@@ -71,8 +73,12 @@
|
||||
"docs/RELEASE_NOTES_0.7.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.md",
|
||||
"docs/RELEASE_NOTES_0.8.2.md",
|
||||
"docs/ACCEPTANCE.md"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"scripts/apply-binary-update.ps1"
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BinaryPath,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedSha256,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedVersion,
|
||||
[Parameter(Mandatory = $true)][string]$CurrentExecutable,
|
||||
[Parameter(Mandatory = $true)][string]$Portable,
|
||||
[Parameter(Mandatory = $true)][int]$ParentPid,
|
||||
[Parameter(Mandatory = $true)][string]$LogPath,
|
||||
[Parameter(Mandatory = $true)][string]$StatusPath,
|
||||
[Parameter(Mandatory = $true)][string]$UpdateId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$isPortable = $Portable -eq "True"
|
||||
|
||||
function Write-UpdateState {
|
||||
param([string]$State, [string]$Message = "", [bool]$RestartLaunched = $false)
|
||||
$payload = [ordered]@{
|
||||
schemaVersion = 1
|
||||
updateId = $UpdateId
|
||||
state = $State
|
||||
expectedVersion = $ExpectedVersion
|
||||
installedVersion = if ($State -eq "success") { $ExpectedVersion } else { $null }
|
||||
message = $Message
|
||||
restartLaunched = $RestartLaunched
|
||||
logPath = $LogPath
|
||||
updatedAt = [DateTime]::UtcNow.ToString("o")
|
||||
}
|
||||
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
|
||||
$temporary = "$StatusPath.$PID.tmp"
|
||||
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporary -Encoding UTF8
|
||||
if (Test-Path -LiteralPath $StatusPath) { [IO.File]::Replace($temporary, $StatusPath, $null) }
|
||||
else { Move-Item -LiteralPath $temporary -Destination $StatusPath }
|
||||
}
|
||||
|
||||
function Write-Log([string]$Message) {
|
||||
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $BinaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
||||
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
||||
|
||||
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
|
||||
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
|
||||
if (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { throw "ForgeFlow did not close within 60 seconds." }
|
||||
}
|
||||
|
||||
if ($isPortable) {
|
||||
Write-UpdateState -State "applying" -Message "Replacing the portable executable."
|
||||
$backupPath = "$CurrentExecutable.previous"
|
||||
Copy-Item -LiteralPath $CurrentExecutable -Destination $backupPath -Force
|
||||
try {
|
||||
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
||||
} catch {
|
||||
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
||||
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message
|
||||
throw
|
||||
}
|
||||
} else {
|
||||
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
|
||||
$installer = Start-Process -FilePath $BinaryPath -ArgumentList "/S" -PassThru -Wait -WindowStyle Hidden
|
||||
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
|
||||
}
|
||||
|
||||
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru
|
||||
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)."
|
||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
|
||||
} catch {
|
||||
Write-Log $_.Exception.Message
|
||||
$current = $null
|
||||
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
|
||||
if ($current.state -ne "rolled-back") { Write-UpdateState -State "failed" -Message $_.Exception.Message }
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app, safeStorage } = require("electron");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const configuredUserData =
|
||||
process.env.FORGEFLOW_USER_DATA ||
|
||||
path.join(app.getPath("appData"), "forgeflow");
|
||||
app.setPath("userData", path.resolve(configuredUserData));
|
||||
|
||||
async function api(baseUrl, token, pathname, options = {}) {
|
||||
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(options.timeout || 180_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const config = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(configuredUserData, "forgeflow-config.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const token = safeStorage.decryptString(
|
||||
Buffer.from(config.gitea.encryptedToken, "base64"),
|
||||
);
|
||||
const baseUrl = String(config.gitea.baseUrl).replace(/\/+$/, "");
|
||||
const version = manifest.version;
|
||||
const tag = `v${version}`;
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const remote = execFileSync(
|
||||
"git",
|
||||
["ls-remote", "origin", "refs/heads/main"],
|
||||
{ cwd: root, encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split(/\s+/)[0];
|
||||
if (commit !== remote)
|
||||
throw new Error("Local HEAD is not the published origin/main commit.");
|
||||
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
|
||||
const body = await fs.readFile(notesPath, "utf8");
|
||||
let release;
|
||||
try {
|
||||
release = await api(
|
||||
baseUrl,
|
||||
token,
|
||||
`/repos/Jens/ForgeFlow/releases/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!/HTTP 404/.test(error.message)) throw error;
|
||||
release = await api(baseUrl, token, "/repos/Jens/ForgeFlow/releases", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
target_commitish: commit,
|
||||
name: `ForgeFlow ${version}`,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const binaries = [
|
||||
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
|
||||
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
|
||||
];
|
||||
for (const binaryPath of binaries) {
|
||||
const binaryName = path.basename(binaryPath);
|
||||
const binary = await fs.readFile(binaryPath);
|
||||
const checksumPath = `${binaryPath}.sha256`;
|
||||
const checksumName = `${binaryName}.sha256`;
|
||||
const checksum = await fs.readFile(checksumPath);
|
||||
for (const [name, bytes, type] of [
|
||||
[binaryName, binary, "application/vnd.microsoft.portable-executable"],
|
||||
[checksumName, checksum, "text/plain"],
|
||||
]) {
|
||||
const existing = (release.assets || []).find(
|
||||
(asset) => asset.name === name,
|
||||
);
|
||||
if (existing && Number(existing.size) === bytes.length) {
|
||||
console.log(`SKIP ${name} already published`);
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
await api(
|
||||
baseUrl,
|
||||
token,
|
||||
`/repos/Jens/ForgeFlow/releases/${release.id}/assets/${existing.id}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("attachment", new Blob([bytes], { type }), name);
|
||||
const uploaded = await api(
|
||||
baseUrl,
|
||||
token,
|
||||
`/repos/Jens/ForgeFlow/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
timeout: 300_000,
|
||||
},
|
||||
);
|
||||
release.assets = [
|
||||
...(release.assets || []).filter((asset) => asset.name !== name),
|
||||
uploaded,
|
||||
];
|
||||
console.log(`PASS published ${name}`);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`PASS ForgeFlow ${version} binary release published for ${commit.slice(0, 7)}`,
|
||||
);
|
||||
app.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${error.message}`);
|
||||
app.exit(1);
|
||||
}
|
||||
});
|
||||
+6
-2
@@ -44,6 +44,8 @@ const required = [
|
||||
"src/shared/deployment-policy.cjs",
|
||||
"scripts/acceptance.mjs",
|
||||
"scripts/validate-installed-connections.cjs",
|
||||
"scripts/publish-binary-release.cjs",
|
||||
"scripts/write-release-checksums.mjs",
|
||||
"scripts/generate-source-manifest.mjs",
|
||||
"setup-windows.ps1",
|
||||
"START-FORGEFLOW-OVERLAY.ps1",
|
||||
@@ -51,6 +53,7 @@ const required = [
|
||||
"build-windows.ps1",
|
||||
"UPDATE_FROM_0.3.2.md",
|
||||
"scripts/apply-source-update.ps1",
|
||||
"scripts/apply-binary-update.ps1",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/SECURITY.md",
|
||||
"docs/ROADMAP.md",
|
||||
@@ -58,6 +61,7 @@ const required = [
|
||||
"docs/ACCEPTANCE.md",
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.md",
|
||||
"docs/RELEASE_NOTES_0.8.2.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -96,9 +100,9 @@ for (const file of required) await access(path.join(root, file));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.8.1")
|
||||
if (packageJson.version !== "0.8.2")
|
||||
throw new Error(
|
||||
`Expected package version 0.8.1, got ${packageJson.version}.`,
|
||||
`Expected package version 0.8.2, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
for (const kind of ["Setup", "Portable"]) {
|
||||
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
|
||||
const binary = await readFile(path.join(root, "dist", name));
|
||||
const sha256 = createHash("sha256").update(binary).digest("hex");
|
||||
await writeFile(
|
||||
path.join(root, "dist", `${name}.sha256`),
|
||||
`${sha256} ${name}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.log(`${name}: ${sha256}`);
|
||||
}
|
||||
+310
-106
@@ -1,7 +1,10 @@
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const { normalizeBaseUrl, assertBranchName } = require('../shared/validation.cjs');
|
||||
const { redactSecrets } = require('./log-redaction.cjs');
|
||||
const {
|
||||
normalizeBaseUrl,
|
||||
assertBranchName,
|
||||
} = require("../shared/validation.cjs");
|
||||
const { redactSecrets } = require("./log-redaction.cjs");
|
||||
|
||||
class GiteaService {
|
||||
constructor(store, diagnostics = null) {
|
||||
@@ -10,67 +13,120 @@ class GiteaService {
|
||||
}
|
||||
|
||||
async request(pathname, options = {}) {
|
||||
const baseUrl = normalizeBaseUrl(options.baseUrl || this.store.data.gitea.baseUrl);
|
||||
const baseUrl = normalizeBaseUrl(
|
||||
options.baseUrl || this.store.data.gitea.baseUrl,
|
||||
);
|
||||
const token = options.token || this.store.getToken();
|
||||
if (!token && options.auth !== false) throw new Error('No Gitea access token is available.');
|
||||
if (!token && options.auth !== false)
|
||||
throw new Error("No Gitea access token is available.");
|
||||
|
||||
const headers = {
|
||||
Accept: options.accept || 'application/json',
|
||||
...(token && options.auth !== false ? { Authorization: `token ${token}` } : {}),
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers || {})
|
||||
Accept: options.accept || "application/json",
|
||||
...(token && options.auth !== false
|
||||
? { Authorization: `token ${token}` }
|
||||
: {}),
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/api/v1${pathname}`, {
|
||||
method: options.method || 'GET',
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: AbortSignal.timeout(options.timeout || 30_000),
|
||||
redirect: 'follow'
|
||||
redirect: "follow",
|
||||
});
|
||||
} catch (error) {
|
||||
const wrapped = new Error(`Could not reach Gitea: ${redactSecrets(error.message, [token])}`);
|
||||
wrapped.code = error.code || 'GITEA_NETWORK_ERROR';
|
||||
await this.diagnostics?.warning('gitea.request.failed', { method: options.method || 'GET', pathname, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
|
||||
const wrapped = new Error(
|
||||
`Could not reach Gitea: ${redactSecrets(error.message, [token])}`,
|
||||
);
|
||||
wrapped.code = error.code || "GITEA_NETWORK_ERROR";
|
||||
await this.diagnostics?.warning("gitea.request.failed", {
|
||||
method: options.method || "GET",
|
||||
pathname,
|
||||
durationMs: Date.now() - started,
|
||||
code: wrapped.code,
|
||||
message: wrapped.message,
|
||||
});
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
let text = '';
|
||||
let text = "";
|
||||
let payload = null;
|
||||
if (options.responseType === 'buffer') {
|
||||
if (options.responseType === "buffer") {
|
||||
payload = Buffer.from(await response.arrayBuffer());
|
||||
} else {
|
||||
text = await response.text();
|
||||
if (text) {
|
||||
if (options.responseType === 'text') payload = text;
|
||||
if (options.responseType === "text") payload = text;
|
||||
else {
|
||||
try { payload = JSON.parse(text); } catch { payload = text; }
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = typeof payload === 'object' && !Buffer.isBuffer(payload) && payload?.message ? payload.message : text || response.statusText;
|
||||
const error = new Error(`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`);
|
||||
const detail =
|
||||
typeof payload === "object" &&
|
||||
!Buffer.isBuffer(payload) &&
|
||||
payload?.message
|
||||
? payload.message
|
||||
: text || response.statusText;
|
||||
const error = new Error(
|
||||
`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`,
|
||||
);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
await this.diagnostics?.warning('gitea.request.rejected', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started, message: error.message });
|
||||
await this.diagnostics?.warning("gitea.request.rejected", {
|
||||
method: options.method || "GET",
|
||||
pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - started,
|
||||
message: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.diagnostics?.debug('gitea.request.completed', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started });
|
||||
return { status: response.status, headers: response.headers, data: payload };
|
||||
await this.diagnostics?.debug("gitea.request.completed", {
|
||||
method: options.method || "GET",
|
||||
pathname,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
data: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async validateConnection(baseUrl, token) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
const user = await this.request('/user', { baseUrl: normalized, token });
|
||||
const repositories = await this.listRepositories({ baseUrl: normalized, token, limitPages: 1 });
|
||||
const version = await this.request('/version', { baseUrl: normalized, token }).then((result) => result.data?.version || null).catch(() => null);
|
||||
return { baseUrl: normalized, user: user.data, repositoryCount: repositories.length, version };
|
||||
const user = await this.request("/user", { baseUrl: normalized, token });
|
||||
const repositories = await this.listRepositories({
|
||||
baseUrl: normalized,
|
||||
token,
|
||||
limitPages: 1,
|
||||
});
|
||||
const version = await this.request("/version", {
|
||||
baseUrl: normalized,
|
||||
token,
|
||||
})
|
||||
.then((result) => result.data?.version || null)
|
||||
.catch(() => null);
|
||||
return {
|
||||
baseUrl: normalized,
|
||||
user: user.data,
|
||||
repositoryCount: repositories.length,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
async listRepositories(options = {}) {
|
||||
@@ -78,7 +134,10 @@ class GiteaService {
|
||||
const pageSize = 50;
|
||||
const limitPages = options.limitPages || 20;
|
||||
for (let page = 1; page <= limitPages; page += 1) {
|
||||
const result = await this.request(`/user/repos?limit=${pageSize}&page=${page}&sort=updated`, options);
|
||||
const result = await this.request(
|
||||
`/user/repos?limit=${pageSize}&page=${page}&sort=updated`,
|
||||
options,
|
||||
);
|
||||
const batch = Array.isArray(result.data) ? result.data : [];
|
||||
repositories.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
@@ -87,14 +146,23 @@ class GiteaService {
|
||||
}
|
||||
|
||||
async getRepository(owner, repo) {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`)).data;
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
|
||||
)
|
||||
).data;
|
||||
}
|
||||
|
||||
async repositoryFileExists({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
||||
const encodedPath = String(filePath || "")
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
try {
|
||||
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`);
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return false;
|
||||
@@ -102,18 +170,26 @@ class GiteaService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getBranch(owner, repo, branch) {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data;
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`,
|
||||
)
|
||||
).data;
|
||||
}
|
||||
|
||||
async getBranchProtection(owner, repo, branch) {
|
||||
const branchInfo = await this.getBranch(owner, repo, branch);
|
||||
let rule = null;
|
||||
try {
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`);
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
|
||||
);
|
||||
const rules = Array.isArray(result.data) ? result.data : [];
|
||||
rule = rules.find((item) => item.branch_name === branch || item.rule_name === branch) || null;
|
||||
rule =
|
||||
rules.find(
|
||||
(item) => item.branch_name === branch || item.rule_name === branch,
|
||||
) || null;
|
||||
} catch (error) {
|
||||
if (![403, 404].includes(error.status)) throw error;
|
||||
}
|
||||
@@ -124,42 +200,97 @@ class GiteaService {
|
||||
enableForcePush: rule?.enable_force_push ?? false,
|
||||
requiredApprovals: Number(rule?.required_approvals || 0),
|
||||
requireSignedCommits: Boolean(rule?.require_signed_commits),
|
||||
rule
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
async listPullRequests({ owner, repo, state = 'open', limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({ state, limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)) });
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`);
|
||||
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({
|
||||
state,
|
||||
limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)),
|
||||
});
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`,
|
||||
);
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async createPullRequest({ owner, repo, head, base, title, body = '' }) {
|
||||
const cleanTitle = String(title || '').trim();
|
||||
if (!cleanTitle || cleanTitle.length > 255) throw new Error('Pull request title must contain 1-255 characters.');
|
||||
const cleanBody = String(body || '').trim().slice(0, 50_000);
|
||||
async createPullRequest({ owner, repo, head, base, title, body = "" }) {
|
||||
const cleanTitle = String(title || "").trim();
|
||||
if (!cleanTitle || cleanTitle.length > 255)
|
||||
throw new Error("Pull request title must contain 1-255 characters.");
|
||||
const cleanBody = String(body || "")
|
||||
.trim()
|
||||
.slice(0, 50_000);
|
||||
const source = assertBranchName(head);
|
||||
const target = assertBranchName(base);
|
||||
if (source === target) throw new Error('Pull request source and target branches must be different.');
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, { method: 'POST', body: { head: source, base: target, title: cleanTitle, body: cleanBody }, timeout: 60_000 });
|
||||
if (source === target)
|
||||
throw new Error(
|
||||
"Pull request source and target branches must be different.",
|
||||
);
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
head: source,
|
||||
base: target,
|
||||
title: cleanTitle,
|
||||
body: cleanBody,
|
||||
},
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async getRepositoryFile({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
||||
const payload = (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`)).data;
|
||||
if (!payload || Array.isArray(payload)) throw new Error(`Repository path ${filePath} is not a file.`);
|
||||
if (payload.encoding === 'base64' && typeof payload.content === 'string') {
|
||||
return { ...payload, decoded: Buffer.from(payload.content.replace(/\s/g, ''), 'base64').toString('utf8') };
|
||||
const encodedPath = String(filePath || "")
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const payload = (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
|
||||
)
|
||||
).data;
|
||||
if (!payload || Array.isArray(payload))
|
||||
throw new Error(`Repository path ${filePath} is not a file.`);
|
||||
if (payload.encoding === "base64" && typeof payload.content === "string") {
|
||||
return {
|
||||
...payload,
|
||||
decoded: Buffer.from(
|
||||
payload.content.replace(/\s/g, ""),
|
||||
"base64",
|
||||
).toString("utf8"),
|
||||
};
|
||||
}
|
||||
if (typeof payload.content === 'string') return { ...payload, decoded: payload.content };
|
||||
if (typeof payload.content === "string")
|
||||
return { ...payload, decoded: payload.content };
|
||||
throw new Error(`Gitea did not return readable content for ${filePath}.`);
|
||||
}
|
||||
|
||||
async getLatestRelease(owner, repo) {
|
||||
try {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`)).data;
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`,
|
||||
)
|
||||
).data;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getReleaseByTag(owner, repo, tag) {
|
||||
try {
|
||||
return (
|
||||
await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
|
||||
)
|
||||
).data;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return null;
|
||||
throw error;
|
||||
@@ -172,40 +303,57 @@ class GiteaService {
|
||||
const token = this.store.getToken();
|
||||
let target = new URL(url, `${baseUrl}/`);
|
||||
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||
if (target.origin !== base.origin) throw new Error('Refusing to send the Gitea token to a different origin.');
|
||||
if (target.origin !== base.origin)
|
||||
throw new Error(
|
||||
"Refusing to send the Gitea token to a different origin.",
|
||||
);
|
||||
const response = await fetch(target, {
|
||||
headers: { Authorization: `token ${token}`, Accept: 'application/octet-stream' },
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
redirect: 'manual'
|
||||
redirect: "manual",
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) throw new Error('The update download redirect did not contain a destination.');
|
||||
const location = response.headers.get("location");
|
||||
if (!location)
|
||||
throw new Error(
|
||||
"The update download redirect did not contain a destination.",
|
||||
);
|
||||
target = new URL(location, target);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Update download failed with HTTP ${response.status}.`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Update download failed with HTTP ${response.status}.`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
throw new Error('The update download exceeded the redirect limit.');
|
||||
throw new Error("The update download exceeded the redirect limit.");
|
||||
}
|
||||
|
||||
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
|
||||
{ method: 'POST', body: { ref, inputs }, timeout: 60_000 }
|
||||
{ method: "POST", body: { ref, inputs }, timeout: 60_000 },
|
||||
);
|
||||
return { accepted: [200, 201, 204].includes(result.status), status: result.status };
|
||||
return {
|
||||
accepted: [200, 201, 204].includes(result.status),
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
normalizeRun(run) {
|
||||
if (!run || typeof run !== 'object') return null;
|
||||
const status = String(run.status || run.conclusion || '').toLowerCase();
|
||||
const conclusion = String(run.conclusion || '').toLowerCase() || (['success', 'failure', 'cancelled', 'skipped'].includes(status) ? status : null);
|
||||
if (!run || typeof run !== "object") return null;
|
||||
const status = String(run.status || run.conclusion || "").toLowerCase();
|
||||
const conclusion =
|
||||
String(run.conclusion || "").toLowerCase() ||
|
||||
(["success", "failure", "cancelled", "skipped"].includes(status)
|
||||
? status
|
||||
: null);
|
||||
return {
|
||||
id: run.id ?? run.run_id ?? run.task_id ?? null,
|
||||
runNumber: run.run_number ?? run.index ?? run.id ?? null,
|
||||
name: run.name || run.workflow_name || run.workflow_id || 'Workflow',
|
||||
name: run.name || run.workflow_name || run.workflow_id || "Workflow",
|
||||
event: run.event || null,
|
||||
status,
|
||||
conclusion,
|
||||
@@ -213,11 +361,12 @@ class GiteaService {
|
||||
headBranch: run.head_branch || run.ref || run.branch || null,
|
||||
workflowPath: run.path || run.workflow_path || run.workflow_file || null,
|
||||
displayTitle: run.display_title || run.title || run.name || null,
|
||||
actor: run.actor?.login || run.trigger_user?.login || run.user?.login || null,
|
||||
actor:
|
||||
run.actor?.login || run.trigger_user?.login || run.user?.login || null,
|
||||
createdAt: run.created_at || run.started || run.start_time || null,
|
||||
updatedAt: run.updated_at || run.stopped || run.end_time || null,
|
||||
htmlUrl: run.html_url || run.url || null,
|
||||
raw: run
|
||||
raw: run,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,8 +374,8 @@ class GiteaService {
|
||||
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
|
||||
const normalizedLimit = String(Math.min(Math.max(limit, 1), 100));
|
||||
const filtered = new URLSearchParams({ limit: normalizedLimit });
|
||||
if (sha) filtered.set('head_sha', sha);
|
||||
if (branch) filtered.set('branch', branch);
|
||||
if (sha) filtered.set("head_sha", sha);
|
||||
if (branch) filtered.set("branch", branch);
|
||||
const basic = new URLSearchParams({ limit: normalizedLimit });
|
||||
|
||||
const tryEndpoint = async (endpoint) => {
|
||||
@@ -235,78 +384,133 @@ class GiteaService {
|
||||
} catch (error) {
|
||||
// Action API query support differs across Gitea releases. Retry without
|
||||
// optional filters and apply SHA/branch matching locally.
|
||||
if (![400, 422].includes(error.status) || String(filtered) === String(basic)) throw error;
|
||||
if (
|
||||
![400, 422].includes(error.status) ||
|
||||
String(filtered) === String(basic)
|
||||
)
|
||||
throw error;
|
||||
return this.request(`${base}/${endpoint}?${basic}`);
|
||||
}
|
||||
};
|
||||
|
||||
let result;
|
||||
let source = 'runs';
|
||||
let source = "runs";
|
||||
try {
|
||||
result = await tryEndpoint('runs');
|
||||
result = await tryEndpoint("runs");
|
||||
} catch (error) {
|
||||
if (![404, 405].includes(error.status)) throw error;
|
||||
source = 'tasks';
|
||||
result = await tryEndpoint('tasks');
|
||||
source = "tasks";
|
||||
result = await tryEndpoint("tasks");
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const items = Array.isArray(data) ? data : data?.workflow_runs || data?.runs || data?.tasks || [];
|
||||
return { source, runs: items.map((item) => this.normalizeRun(item)).filter(Boolean), totalCount: data?.total_count ?? items.length };
|
||||
const items = Array.isArray(data)
|
||||
? data
|
||||
: data?.workflow_runs || data?.runs || data?.tasks || [];
|
||||
return {
|
||||
source,
|
||||
runs: items.map((item) => this.normalizeRun(item)).filter(Boolean),
|
||||
totalCount: data?.total_count ?? items.length,
|
||||
};
|
||||
}
|
||||
|
||||
async findWorkflowRun({ owner, repo, sha, branch, workflowFile, dispatchedAt, excludeRunIds = [] }) {
|
||||
const { runs, source } = await this.listWorkflowRuns({ owner, repo, sha, branch, limit: 50 });
|
||||
const earliest = dispatchedAt ? new Date(dispatchedAt).getTime() - 120_000 : 0;
|
||||
const workflowBase = String(workflowFile || '').split('/').pop();
|
||||
const excluded = new Set((excludeRunIds || []).map((value) => String(value)));
|
||||
async findWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
branch,
|
||||
workflowFile,
|
||||
dispatchedAt,
|
||||
excludeRunIds = [],
|
||||
}) {
|
||||
const { runs, source } = await this.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
branch,
|
||||
limit: 50,
|
||||
});
|
||||
const earliest = dispatchedAt
|
||||
? new Date(dispatchedAt).getTime() - 120_000
|
||||
: 0;
|
||||
const workflowBase = String(workflowFile || "")
|
||||
.split("/")
|
||||
.pop();
|
||||
const excluded = new Set(
|
||||
(excludeRunIds || []).map((value) => String(value)),
|
||||
);
|
||||
const candidates = runs.filter((run) => {
|
||||
if (run.id !== null && run.id !== undefined && excluded.has(String(run.id))) return false;
|
||||
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase()) return false;
|
||||
if (branch && run.headBranch && run.headBranch.replace(/^refs\/heads\//, '') !== branch) return false;
|
||||
if (earliest && run.createdAt && new Date(run.createdAt).getTime() < earliest) return false;
|
||||
if (
|
||||
run.id !== null &&
|
||||
run.id !== undefined &&
|
||||
excluded.has(String(run.id))
|
||||
)
|
||||
return false;
|
||||
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase())
|
||||
return false;
|
||||
if (
|
||||
branch &&
|
||||
run.headBranch &&
|
||||
run.headBranch.replace(/^refs\/heads\//, "") !== branch
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
earliest &&
|
||||
run.createdAt &&
|
||||
new Date(run.createdAt).getTime() < earliest
|
||||
)
|
||||
return false;
|
||||
if (workflowBase && run.workflowPath) {
|
||||
const runBase = String(run.workflowPath).split('/').pop();
|
||||
const runBase = String(run.workflowPath).split("/").pop();
|
||||
if (runBase && runBase !== workflowBase) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
candidates.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
||||
candidates.sort(
|
||||
(a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0),
|
||||
);
|
||||
return { source, run: candidates[0] || null };
|
||||
}
|
||||
|
||||
async listWorkflowJobs({ owner, repo, runNumber }) {
|
||||
if (runNumber === null || runNumber === undefined) return [];
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`);
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`,
|
||||
);
|
||||
const data = result.data;
|
||||
const jobs = Array.isArray(data) ? data : data?.jobs || [];
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name || job.job_name || `Job ${job.id}`,
|
||||
status: String(job.status || '').toLowerCase(),
|
||||
conclusion: String(job.conclusion || '').toLowerCase() || null,
|
||||
status: String(job.status || "").toLowerCase(),
|
||||
conclusion: String(job.conclusion || "").toLowerCase() || null,
|
||||
startedAt: job.started_at || null,
|
||||
completedAt: job.completed_at || null,
|
||||
steps: Array.isArray(job.steps) ? job.steps.map((step) => ({
|
||||
name: step.name,
|
||||
status: String(step.status || '').toLowerCase(),
|
||||
conclusion: String(step.conclusion || '').toLowerCase() || null,
|
||||
number: step.number
|
||||
})) : []
|
||||
steps: Array.isArray(job.steps)
|
||||
? job.steps.map((step) => ({
|
||||
name: step.name,
|
||||
status: String(step.status || "").toLowerCase(),
|
||||
conclusion: String(step.conclusion || "").toLowerCase() || null,
|
||||
number: step.number,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async getJobLogs({ owner, repo, jobId }) {
|
||||
if (!jobId) return '';
|
||||
if (!jobId) return "";
|
||||
try {
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`, {
|
||||
accept: 'text/plain, application/octet-stream',
|
||||
responseType: 'text',
|
||||
timeout: 60_000
|
||||
});
|
||||
return String(result.data || '').slice(-500_000);
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`,
|
||||
{
|
||||
accept: "text/plain, application/octet-stream",
|
||||
responseType: "text",
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
return String(result.data || "").slice(-500_000);
|
||||
} catch (error) {
|
||||
if ([404, 410].includes(error.status)) return '';
|
||||
if ([404, 410].includes(error.status)) return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+471
-101
@@ -1,15 +1,16 @@
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const fsSync = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { isNewerVersion } = require('../shared/semver.cjs');
|
||||
const fs = require("node:fs/promises");
|
||||
const fsSync = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { isNewerVersion } = require("../shared/semver.cjs");
|
||||
|
||||
function safeRepositoryPart(value, label) {
|
||||
const text = String(value || '').trim();
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) throw new Error(`${label} contains unsupported characters.`);
|
||||
const text = String(value || "").trim();
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(text))
|
||||
throw new Error(`${label} contains unsupported characters.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -20,63 +21,114 @@ function delay(ms) {
|
||||
function resolveWindowsPowerShellPath(environment = process.env) {
|
||||
const windowsRoot = environment.SystemRoot || environment.WINDIR;
|
||||
if (windowsRoot) {
|
||||
const absolute = path.join(windowsRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
const absolute = path.join(
|
||||
windowsRoot,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
);
|
||||
if (fsSync.existsSync(absolute)) return absolute;
|
||||
}
|
||||
return 'powershell.exe';
|
||||
return "powershell.exe";
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
try { return JSON.parse(await fs.readFile(filePath, 'utf8')); }
|
||||
catch { return null; }
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLogTail(filePath, maxLines = 12) {
|
||||
if (!filePath) return '';
|
||||
if (!filePath) return "";
|
||||
try {
|
||||
const text = await fs.readFile(filePath, 'utf8');
|
||||
return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join('\n');
|
||||
} catch { return ''; }
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function updaterStartupError(message, code, { statusPath, logPath, expectedUpdateId } = {}) {
|
||||
async function updaterStartupError(
|
||||
message,
|
||||
code,
|
||||
{ statusPath, logPath, expectedUpdateId } = {},
|
||||
) {
|
||||
const status = statusPath ? await readJsonFile(statusPath) : null;
|
||||
const logTail = await readLogTail(logPath);
|
||||
const details = [];
|
||||
if (status?.updateId && expectedUpdateId && status.updateId !== expectedUpdateId) details.push('The helper wrote a status for a different update request.');
|
||||
if (
|
||||
status?.updateId &&
|
||||
expectedUpdateId &&
|
||||
status.updateId !== expectedUpdateId
|
||||
)
|
||||
details.push("The helper wrote a status for a different update request.");
|
||||
if (status?.message) details.push(status.message);
|
||||
if (logTail) details.push(`Update helper log:\n${logTail}`);
|
||||
const error = new Error([message, ...details].filter(Boolean).join('\n\n'));
|
||||
const error = new Error([message, ...details].filter(Boolean).join("\n\n"));
|
||||
error.code = code;
|
||||
error.status = status;
|
||||
error.logPath = logPath || null;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs = 15000,
|
||||
pollMs = 100,
|
||||
childState = null,
|
||||
expectedUpdateId = null,
|
||||
logPath = null
|
||||
} = {}) {
|
||||
async function waitForUpdaterStarted(
|
||||
statusPath,
|
||||
{
|
||||
timeoutMs = 15000,
|
||||
pollMs = 100,
|
||||
childState = null,
|
||||
expectedUpdateId = null,
|
||||
logPath = null,
|
||||
} = {},
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await readJsonFile(statusPath);
|
||||
const belongsToRequest = !expectedUpdateId || status?.updateId === expectedUpdateId;
|
||||
if (status && belongsToRequest && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) {
|
||||
const belongsToRequest =
|
||||
!expectedUpdateId || status?.updateId === expectedUpdateId;
|
||||
if (
|
||||
status &&
|
||||
belongsToRequest &&
|
||||
[
|
||||
"started",
|
||||
"waiting-for-exit",
|
||||
"backing-up",
|
||||
"extracting",
|
||||
"applying",
|
||||
"validating",
|
||||
].includes(status.state)
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
if (status && belongsToRequest && ['failed', 'rolled-back'].includes(status.state)) {
|
||||
throw await updaterStartupError('The update helper reported a failure before ForgeFlow could close.', 'UPDATE_HELPER_START_FAILED', { statusPath, logPath, expectedUpdateId });
|
||||
if (
|
||||
status &&
|
||||
belongsToRequest &&
|
||||
["failed", "rolled-back"].includes(status.state)
|
||||
) {
|
||||
throw await updaterStartupError(
|
||||
"The update helper reported a failure before ForgeFlow could close.",
|
||||
"UPDATE_HELPER_START_FAILED",
|
||||
{ statusPath, logPath, expectedUpdateId },
|
||||
);
|
||||
}
|
||||
if (childState?.error) throw childState.error;
|
||||
if (childState?.exited) {
|
||||
throw await updaterStartupError(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`, 'UPDATE_HELPER_EXITED_EARLY', { statusPath, logPath, expectedUpdateId });
|
||||
throw await updaterStartupError(
|
||||
`The update helper exited before it confirmed startup (exit code ${childState.code ?? "unknown"}).`,
|
||||
"UPDATE_HELPER_EXITED_EARLY",
|
||||
{ statusPath, logPath, expectedUpdateId },
|
||||
);
|
||||
}
|
||||
await delay(pollMs);
|
||||
}
|
||||
throw await updaterStartupError('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.', 'UPDATE_HELPER_START_TIMEOUT', { statusPath, logPath, expectedUpdateId });
|
||||
throw await updaterStartupError(
|
||||
"The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.",
|
||||
"UPDATE_HELPER_START_TIMEOUT",
|
||||
{ statusPath, logPath, expectedUpdateId },
|
||||
);
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
@@ -91,14 +143,14 @@ class UpdateService {
|
||||
spawnProcess = spawn,
|
||||
powershellPath = null,
|
||||
handshakeTimeoutMs = 12000,
|
||||
handshakePollMs = 100
|
||||
handshakePollMs = 100,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.appInfo = appInfo;
|
||||
this.sourcePath = sourcePath;
|
||||
this.updateDirectory = path.join(userDataPath, 'updates');
|
||||
this.updateDirectory = path.join(userDataPath, "updates");
|
||||
this.platform = platform;
|
||||
this.spawnProcess = spawnProcess;
|
||||
this.powershellPath = powershellPath;
|
||||
@@ -109,20 +161,41 @@ class UpdateService {
|
||||
|
||||
async check() {
|
||||
const settings = this.store.data.updates || {};
|
||||
const owner = safeRepositoryPart(settings.owner || 'Jens', 'Update repository owner');
|
||||
const repo = safeRepositoryPart(settings.repo || 'ForgeFlow', 'Update repository name');
|
||||
const branchName = String(settings.branch || 'main').trim();
|
||||
const owner = safeRepositoryPart(
|
||||
settings.owner || "Jens",
|
||||
"Update repository owner",
|
||||
);
|
||||
const repo = safeRepositoryPart(
|
||||
settings.repo || "ForgeFlow",
|
||||
"Update repository name",
|
||||
);
|
||||
const branchName = String(settings.branch || "main").trim();
|
||||
const branch = await this.gitea.getBranch(owner, repo, branchName);
|
||||
const remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ''))) throw new Error('Gitea did not return a full commit SHA for the update branch.');
|
||||
const remoteSha =
|
||||
branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || "")))
|
||||
throw new Error(
|
||||
"Gitea did not return a full commit SHA for the update branch.",
|
||||
);
|
||||
|
||||
const file = await this.gitea.getRepositoryFile({ owner, repo, filePath: 'package.json', ref: remoteSha });
|
||||
const file = await this.gitea.getRepositoryFile({
|
||||
owner,
|
||||
repo,
|
||||
filePath: "package.json",
|
||||
ref: remoteSha,
|
||||
});
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(file.decoded); }
|
||||
catch { throw new Error('The remote ForgeFlow package.json is not valid JSON.'); }
|
||||
if (manifest.name !== 'forgeflow') throw new Error('The configured update repository is not a ForgeFlow source repository.');
|
||||
const remoteVersion = String(manifest.version || '').trim();
|
||||
const currentVersion = String(this.appInfo.version || '').trim();
|
||||
try {
|
||||
manifest = JSON.parse(file.decoded);
|
||||
} catch {
|
||||
throw new Error("The remote ForgeFlow package.json is not valid JSON.");
|
||||
}
|
||||
if (manifest.name !== "forgeflow")
|
||||
throw new Error(
|
||||
"The configured update repository is not a ForgeFlow source repository.",
|
||||
);
|
||||
const remoteVersion = String(manifest.version || "").trim();
|
||||
const currentVersion = String(this.appInfo.version || "").trim();
|
||||
const available = isNewerVersion(remoteVersion, currentVersion);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
@@ -135,90 +208,222 @@ class UpdateService {
|
||||
shortSha: remoteSha.slice(0, 7),
|
||||
available,
|
||||
packaged: Boolean(this.appInfo.packaged),
|
||||
mode: this.appInfo.packaged ? 'packaged' : 'source'
|
||||
mode: this.appInfo.packaged ? "packaged" : "source",
|
||||
};
|
||||
this.store.data.updates.lastCheckedAt = result.checkedAt;
|
||||
await this.store.save();
|
||||
await this.diagnostics?.info('updates.checked', {
|
||||
await this.diagnostics?.info("updates.checked", {
|
||||
repository: `${owner}/${repo}`,
|
||||
branch: branchName,
|
||||
currentVersion,
|
||||
remoteVersion,
|
||||
remoteSha,
|
||||
available,
|
||||
mode: result.mode
|
||||
mode: result.mode,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async download(expected = null) {
|
||||
const update = expected?.remoteSha ? expected : await this.check();
|
||||
if (!update.available) return { ...update, downloaded: false, reason: 'up-to-date' };
|
||||
if (!update.available)
|
||||
return { ...update, downloaded: false, reason: "up-to-date" };
|
||||
if (this.appInfo.packaged) {
|
||||
const error = new Error('This developer release uses source updates. Install a signed packaged release before using binary auto-update.');
|
||||
error.code = 'PACKAGED_UPDATE_NOT_CONFIGURED';
|
||||
throw error;
|
||||
return this.downloadPackaged(update);
|
||||
}
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
|
||||
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
|
||||
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
|
||||
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b) throw new Error('The downloaded update is not a valid ZIP archive.');
|
||||
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
|
||||
const archivePath = path.join(this.updateDirectory, `ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`);
|
||||
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b)
|
||||
throw new Error("The downloaded update is not a valid ZIP archive.");
|
||||
const sha256 = crypto.createHash("sha256").update(archive).digest("hex");
|
||||
const archivePath = path.join(
|
||||
this.updateDirectory,
|
||||
`ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`,
|
||||
);
|
||||
const metadataPath = `${archivePath}.json`;
|
||||
await fs.writeFile(archivePath, archive, { mode: 0o600 });
|
||||
const metadata = { ...update, archivePath, sha256, downloadedAt: new Date().toISOString() };
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
||||
const metadata = {
|
||||
...update,
|
||||
archivePath,
|
||||
sha256,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
};
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
this.staged = metadata;
|
||||
await this.diagnostics?.info('updates.downloaded', {
|
||||
await this.diagnostics?.info("updates.downloaded", {
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
bytes: archive.length,
|
||||
sha256
|
||||
sha256,
|
||||
});
|
||||
return { ...metadata, downloaded: true };
|
||||
}
|
||||
|
||||
async apply(staged = null) {
|
||||
const update = staged?.archivePath ? staged : this.staged;
|
||||
if (!update?.archivePath) throw new Error('Download an update before applying it.');
|
||||
if (this.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
|
||||
const stat = await fs.stat(update.archivePath).catch(() => null);
|
||||
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
|
||||
async downloadPackaged(update) {
|
||||
if (this.platform !== "win32")
|
||||
throw new Error("Packaged auto-update currently supports Windows only.");
|
||||
const release =
|
||||
(await this.gitea.getReleaseByTag(
|
||||
update.owner,
|
||||
update.repo,
|
||||
`v${update.remoteVersion}`,
|
||||
)) ||
|
||||
(await this.gitea.getReleaseByTag(
|
||||
update.owner,
|
||||
update.repo,
|
||||
update.remoteVersion,
|
||||
));
|
||||
if (!release || release.draft || release.prerelease) {
|
||||
const error = new Error(
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
|
||||
const portable = Boolean(this.appInfo.portableExecutablePath);
|
||||
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
||||
const checksumName = `${assetName}.sha256`;
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const asset = assets.find((item) => item.name === assetName);
|
||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
||||
if (!asset?.browser_download_url || !checksumAsset?.browser_download_url) {
|
||||
const error = new Error(
|
||||
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [binary, checksumBytes] = await Promise.all([
|
||||
this.gitea.downloadAuthenticated(asset.browser_download_url),
|
||||
this.gitea.downloadAuthenticated(checksumAsset.browser_download_url),
|
||||
]);
|
||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||
throw new Error(
|
||||
"The downloaded Windows update is not a valid executable.",
|
||||
);
|
||||
}
|
||||
const expectedSha256 = checksumBytes
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split(/\s+/)[0]
|
||||
?.toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
||||
throw new Error("The release SHA-256 file is invalid.");
|
||||
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
if (sha256 !== expectedSha256)
|
||||
throw new Error(
|
||||
"The downloaded Windows update failed SHA-256 verification.",
|
||||
);
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const binaryPath = path.join(this.updateDirectory, assetName);
|
||||
await fs.writeFile(binaryPath, binary, { mode: 0o600 });
|
||||
const metadata = {
|
||||
...update,
|
||||
kind: "binary",
|
||||
binaryPath,
|
||||
assetName,
|
||||
sha256,
|
||||
portable,
|
||||
executablePath: portable
|
||||
? this.appInfo.portableExecutablePath
|
||||
: this.appInfo.executablePath,
|
||||
releaseTag: release.tag_name,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
downloaded: true,
|
||||
};
|
||||
await fs.writeFile(
|
||||
`${binaryPath}.json`,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
this.staged = metadata;
|
||||
await this.diagnostics?.info("updates.binary-downloaded", {
|
||||
remoteVersion: update.remoteVersion,
|
||||
assetName,
|
||||
bytes: binary.length,
|
||||
sha256,
|
||||
portable,
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async apply(staged = null) {
|
||||
const update =
|
||||
staged?.archivePath || staged?.binaryPath ? staged : this.staged;
|
||||
if (!update?.archivePath && !update?.binaryPath)
|
||||
throw new Error("Download an update before applying it.");
|
||||
if (this.platform !== "win32")
|
||||
throw new Error(
|
||||
"The integrated updater currently supports Windows only.",
|
||||
);
|
||||
if (update.kind === "binary") return this.applyPackaged(update);
|
||||
const stat = await fs.stat(update.archivePath).catch(() => null);
|
||||
if (!stat?.isFile())
|
||||
throw new Error("The staged update archive is no longer available.");
|
||||
|
||||
const scriptPath = path.join(
|
||||
this.sourcePath,
|
||||
"scripts",
|
||||
"apply-source-update.ps1",
|
||||
);
|
||||
const scriptStat = await fs.stat(scriptPath).catch(() => null);
|
||||
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
|
||||
if (!scriptStat?.isFile())
|
||||
throw new Error("The source update helper is missing.");
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
const logPath = path.join(this.updateDirectory, `apply-${updateId}.log`);
|
||||
const statusPath = path.join(this.updateDirectory, `apply-${updateId}.status.json`);
|
||||
const statusPath = path.join(
|
||||
this.updateDirectory,
|
||||
`apply-${updateId}.status.json`,
|
||||
);
|
||||
const launching = {
|
||||
schemaVersion: 1,
|
||||
updateId,
|
||||
state: 'launching',
|
||||
state: "launching",
|
||||
expectedVersion: update.remoteVersion,
|
||||
sourcePath: this.sourcePath,
|
||||
logPath,
|
||||
statusPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { mode: 0o600 });
|
||||
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const executable = this.powershellPath || resolveWindowsPowerShellPath();
|
||||
const args = [
|
||||
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-SourcePath', this.sourcePath,
|
||||
'-ArchivePath', update.archivePath,
|
||||
'-ExpectedVersion', update.remoteVersion,
|
||||
'-ExpectedSha256', update.sha256,
|
||||
'-ParentPid', String(process.pid),
|
||||
'-LogPath', logPath,
|
||||
'-StatusPath', statusPath,
|
||||
'-UpdateId', updateId
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
scriptPath,
|
||||
"-SourcePath",
|
||||
this.sourcePath,
|
||||
"-ArchivePath",
|
||||
update.archivePath,
|
||||
"-ExpectedVersion",
|
||||
update.remoteVersion,
|
||||
"-ExpectedSha256",
|
||||
update.sha256,
|
||||
"-ParentPid",
|
||||
String(process.pid),
|
||||
"-LogPath",
|
||||
logPath,
|
||||
"-StatusPath",
|
||||
statusPath,
|
||||
"-UpdateId",
|
||||
updateId,
|
||||
];
|
||||
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
@@ -226,17 +431,22 @@ class UpdateService {
|
||||
try {
|
||||
child = this.spawnProcess(executable, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
cwd: this.sourcePath
|
||||
cwd: this.sourcePath,
|
||||
});
|
||||
} catch (error) {
|
||||
error.code ||= 'UPDATE_HELPER_SPAWN_FAILED';
|
||||
error.code ||= "UPDATE_HELPER_SPAWN_FAILED";
|
||||
throw error;
|
||||
}
|
||||
|
||||
child.once?.('error', (error) => { childState.error = error; });
|
||||
child.once?.('exit', (code) => { childState.exited = true; childState.code = code; });
|
||||
child.once?.("error", (error) => {
|
||||
childState.error = error;
|
||||
});
|
||||
child.once?.("exit", (code) => {
|
||||
childState.exited = true;
|
||||
childState.code = code;
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (handler, value) => {
|
||||
@@ -245,9 +455,19 @@ class UpdateService {
|
||||
clearTimeout(timer);
|
||||
handler(value);
|
||||
};
|
||||
const timer = setTimeout(() => finish(reject, Object.assign(new Error('Windows did not start the update helper process.'), { code: 'UPDATE_HELPER_SPAWN_TIMEOUT' })), 5000);
|
||||
child.once?.('spawn', () => finish(resolve));
|
||||
child.once?.('error', (error) => finish(reject, error));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
reject,
|
||||
Object.assign(
|
||||
new Error("Windows did not start the update helper process."),
|
||||
{ code: "UPDATE_HELPER_SPAWN_TIMEOUT" },
|
||||
),
|
||||
),
|
||||
5000,
|
||||
);
|
||||
child.once?.("spawn", () => finish(resolve));
|
||||
child.once?.("error", (error) => finish(reject, error));
|
||||
if (!child.once) finish(resolve);
|
||||
});
|
||||
|
||||
@@ -256,28 +476,171 @@ class UpdateService {
|
||||
pollMs: this.handshakePollMs,
|
||||
childState,
|
||||
expectedUpdateId: updateId,
|
||||
logPath
|
||||
logPath,
|
||||
});
|
||||
child.unref?.();
|
||||
|
||||
await this.diagnostics?.info('updates.apply-started', {
|
||||
await this.diagnostics?.info("updates.apply-started", {
|
||||
updateId,
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
logPath,
|
||||
statusPath,
|
||||
helperPid: child.pid,
|
||||
helperState: started.state
|
||||
helperState: started.state,
|
||||
});
|
||||
return { launched: true, confirmed: true, updateId, version: update.remoteVersion, logPath, statusPath };
|
||||
return {
|
||||
launched: true,
|
||||
confirmed: true,
|
||||
updateId,
|
||||
version: update.remoteVersion,
|
||||
logPath,
|
||||
statusPath,
|
||||
};
|
||||
}
|
||||
|
||||
async applyPackaged(update) {
|
||||
const stat = await fs.stat(update.binaryPath).catch(() => null);
|
||||
if (!stat?.isFile())
|
||||
throw new Error("The staged Windows update is no longer available.");
|
||||
const actualSha256 = crypto
|
||||
.createHash("sha256")
|
||||
.update(await fs.readFile(update.binaryPath))
|
||||
.digest("hex");
|
||||
if (actualSha256 !== update.sha256)
|
||||
throw new Error(
|
||||
"The staged Windows update failed its final SHA-256 check.",
|
||||
);
|
||||
const helperRoot = this.sourcePath.toLowerCase().endsWith("app.asar")
|
||||
? `${this.sourcePath}.unpacked`
|
||||
: this.sourcePath;
|
||||
const scriptPath = path.join(
|
||||
helperRoot,
|
||||
"scripts",
|
||||
"apply-binary-update.ps1",
|
||||
);
|
||||
if (!(await fs.stat(scriptPath).catch(() => null))?.isFile())
|
||||
throw new Error("The binary update helper is missing.");
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
|
||||
const logPath = path.join(this.updateDirectory, `binary-${updateId}.log`);
|
||||
const statusPath = path.join(
|
||||
this.updateDirectory,
|
||||
`binary-${updateId}.status.json`,
|
||||
);
|
||||
const launching = {
|
||||
schemaVersion: 1,
|
||||
updateId,
|
||||
state: "launching",
|
||||
expectedVersion: update.remoteVersion,
|
||||
logPath,
|
||||
statusPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
const executable = this.powershellPath || resolveWindowsPowerShellPath();
|
||||
const args = [
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
scriptPath,
|
||||
"-BinaryPath",
|
||||
update.binaryPath,
|
||||
"-ExpectedSha256",
|
||||
update.sha256,
|
||||
"-ExpectedVersion",
|
||||
update.remoteVersion,
|
||||
"-CurrentExecutable",
|
||||
update.executablePath || this.appInfo.executablePath,
|
||||
"-Portable",
|
||||
String(Boolean(update.portable)),
|
||||
"-ParentPid",
|
||||
String(process.pid),
|
||||
"-LogPath",
|
||||
logPath,
|
||||
"-StatusPath",
|
||||
statusPath,
|
||||
"-UpdateId",
|
||||
updateId,
|
||||
];
|
||||
const child = this.spawnProcess(executable, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
cwd: this.updateDirectory,
|
||||
});
|
||||
const childState = { exited: false, code: null, error: null };
|
||||
child.once?.("error", (error) => {
|
||||
childState.error = error;
|
||||
});
|
||||
child.once?.("exit", (code) => {
|
||||
childState.exited = true;
|
||||
childState.code = code;
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
Object.assign(
|
||||
new Error("Windows did not start the binary update helper."),
|
||||
{ code: "UPDATE_HELPER_SPAWN_TIMEOUT" },
|
||||
),
|
||||
),
|
||||
5000,
|
||||
);
|
||||
child.once?.("spawn", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.once?.("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
if (!child.once) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const started = await waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: this.handshakeTimeoutMs,
|
||||
pollMs: this.handshakePollMs,
|
||||
childState,
|
||||
expectedUpdateId: updateId,
|
||||
logPath,
|
||||
});
|
||||
child.unref?.();
|
||||
await this.diagnostics?.info("updates.binary-apply-started", {
|
||||
updateId,
|
||||
remoteVersion: update.remoteVersion,
|
||||
assetName: update.assetName,
|
||||
helperState: started.state,
|
||||
});
|
||||
return {
|
||||
launched: true,
|
||||
confirmed: true,
|
||||
updateId,
|
||||
version: update.remoteVersion,
|
||||
logPath,
|
||||
statusPath,
|
||||
};
|
||||
}
|
||||
|
||||
async consumeLatestResult() {
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const entries = await fs.readdir(this.updateDirectory, { withFileTypes: true }).catch(() => []);
|
||||
const entries = await fs
|
||||
.readdir(this.updateDirectory, { withFileTypes: true })
|
||||
.catch(() => []);
|
||||
const candidates = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^apply-.*\.status\.json$/i.test(entry.name)) continue;
|
||||
if (!entry.isFile() || !/^(?:apply|binary)-.*\.status\.json$/i.test(entry.name))
|
||||
continue;
|
||||
const filePath = path.join(this.updateDirectory, entry.name);
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (stat) candidates.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
@@ -285,17 +648,24 @@ class UpdateService {
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
for (const candidate of candidates) {
|
||||
const status = await readJsonFile(candidate.filePath);
|
||||
if (!status || status.acknowledgedAt || !['success', 'rolled-back', 'failed'].includes(status.state)) continue;
|
||||
if (
|
||||
!status ||
|
||||
status.acknowledgedAt ||
|
||||
!["success", "rolled-back", "failed"].includes(status.state)
|
||||
)
|
||||
continue;
|
||||
status.acknowledgedAt = new Date().toISOString();
|
||||
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), { mode: 0o600 });
|
||||
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
return {
|
||||
state: status.state,
|
||||
expectedVersion: status.expectedVersion || null,
|
||||
installedVersion: status.installedVersion || null,
|
||||
message: status.message || '',
|
||||
message: status.message || "",
|
||||
logPath: status.logPath || null,
|
||||
restartLaunched: Boolean(status.restartLaunched),
|
||||
completedAt: status.completedAt || status.updatedAt || null
|
||||
completedAt: status.completedAt || status.updatedAt || null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -308,5 +678,5 @@ module.exports = {
|
||||
resolveWindowsPowerShellPath,
|
||||
waitForUpdaterStarted,
|
||||
readJsonFile,
|
||||
readLogTail
|
||||
readLogTail,
|
||||
};
|
||||
|
||||
+1
-1
@@ -2500,7 +2500,7 @@ app.addEventListener("click", async (event) => {
|
||||
ui.updateStatus = await window.forgeflow.downloadUpdate();
|
||||
showToast(
|
||||
"Update downloaded",
|
||||
`Version ${ui.updateStatus.remoteVersion} passed the archive check.`,
|
||||
`Version ${ui.updateStatus.remoteVersion} passed the integrity check.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.8.1-demo",
|
||||
appVersion: "0.8.2-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
|
||||
+346
-108
@@ -1,66 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createHash } from "node:crypto";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { UpdateService, waitForUpdaterStarted } = require('../src/main/update-service.cjs');
|
||||
const {
|
||||
UpdateService,
|
||||
waitForUpdaterStarted,
|
||||
} = require("../src/main/update-service.cjs");
|
||||
|
||||
test('update check pins version to an exact branch commit', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-'));
|
||||
test("update check pins version to an exact branch commit", async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-"));
|
||||
const saved = [];
|
||||
const store = {
|
||||
data: {
|
||||
gitea: { baseUrl: 'https://gitea.example.test' },
|
||||
updates: { owner: 'Jens', repo: 'ForgeFlow', branch: 'main', autoCheck: true }
|
||||
gitea: { baseUrl: "https://gitea.example.test" },
|
||||
updates: {
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
branch: "main",
|
||||
autoCheck: true,
|
||||
},
|
||||
},
|
||||
async save() {
|
||||
saved.push(true);
|
||||
},
|
||||
async save() { saved.push(true); }
|
||||
};
|
||||
const calls = [];
|
||||
const gitea = {
|
||||
async getBranch(owner, repo, branch) {
|
||||
calls.push(['branch', owner, repo, branch]);
|
||||
return { commit: { id: 'a'.repeat(40) } };
|
||||
calls.push(["branch", owner, repo, branch]);
|
||||
return { commit: { id: "a".repeat(40) } };
|
||||
},
|
||||
async getRepositoryFile(input) {
|
||||
calls.push(['file', input]);
|
||||
return { decoded: JSON.stringify({ name: 'forgeflow', version: '0.4.1' }) };
|
||||
}
|
||||
calls.push(["file", input]);
|
||||
return {
|
||||
decoded: JSON.stringify({ name: "forgeflow", version: "0.4.1" }),
|
||||
};
|
||||
},
|
||||
};
|
||||
const service = new UpdateService({
|
||||
store, gitea, diagnostics: null,
|
||||
appInfo: { version: '0.4.0', packaged: false },
|
||||
sourcePath: temp, userDataPath: temp
|
||||
store,
|
||||
gitea,
|
||||
diagnostics: null,
|
||||
appInfo: { version: "0.4.0", packaged: false },
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
});
|
||||
const result = await service.check();
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.remoteSha, 'a'.repeat(40));
|
||||
assert.equal(calls[1][1].ref, 'a'.repeat(40));
|
||||
assert.equal(result.remoteSha, "a".repeat(40));
|
||||
assert.equal(calls[1][1].ref, "a".repeat(40));
|
||||
assert.equal(saved.length, 1);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('update repository parts reject path injection', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-'));
|
||||
test("update repository parts reject path injection", async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-"));
|
||||
const service = new UpdateService({
|
||||
store: { data: { updates: { owner: '../Jens', repo: 'ForgeFlow', branch: 'main' } }, save: async () => {} },
|
||||
gitea: {}, diagnostics: null, appInfo: { version: '0.4.0', packaged: false }, sourcePath: temp, userDataPath: temp
|
||||
store: {
|
||||
data: {
|
||||
updates: { owner: "../Jens", repo: "ForgeFlow", branch: "main" },
|
||||
},
|
||||
save: async () => {},
|
||||
},
|
||||
gitea: {},
|
||||
diagnostics: null,
|
||||
appInfo: { version: "0.4.0", packaged: false },
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
});
|
||||
await assert.rejects(() => service.check(), /unsupported characters/);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
test('source updater confirms an external STARTED marker before ForgeFlow may close', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-handshake-'));
|
||||
const source = path.join(temp, 'source');
|
||||
const scripts = path.join(source, 'scripts');
|
||||
const archive = path.join(temp, 'update.zip');
|
||||
test("source updater confirms an external STARTED marker before ForgeFlow may close", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-update-handshake-"),
|
||||
);
|
||||
const source = path.join(temp, "source");
|
||||
const scripts = path.join(source, "scripts");
|
||||
const archive = path.join(temp, "update.zip");
|
||||
await mkdir(scripts, { recursive: true });
|
||||
await writeFile(path.join(scripts, 'apply-source-update.ps1'), '# test helper');
|
||||
await writeFile(archive, 'PK fake archive');
|
||||
await writeFile(
|
||||
path.join(scripts, "apply-source-update.ps1"),
|
||||
"# test helper",
|
||||
);
|
||||
await writeFile(archive, "PK fake archive");
|
||||
|
||||
let capturedArgs = null;
|
||||
const spawnProcess = (_command, args) => {
|
||||
@@ -68,71 +97,116 @@ test('source updater confirms an external STARTED marker before ForgeFlow may cl
|
||||
const child = new EventEmitter();
|
||||
child.pid = 4321;
|
||||
child.unref = () => {};
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
const statusIndex = args.indexOf('-StatusPath');
|
||||
queueMicrotask(() => child.emit("spawn"));
|
||||
const statusIndex = args.indexOf("-StatusPath");
|
||||
const statusPath = args[statusIndex + 1];
|
||||
const updateIdIndex = args.indexOf('-UpdateId');
|
||||
const updateIdIndex = args.indexOf("-UpdateId");
|
||||
const updateId = args[updateIdIndex + 1];
|
||||
setTimeout(() => writeFile(statusPath, JSON.stringify({ state: 'started', expectedVersion: '0.5.3', updateId })), 30);
|
||||
setTimeout(
|
||||
() =>
|
||||
writeFile(
|
||||
statusPath,
|
||||
JSON.stringify({
|
||||
state: "started",
|
||||
expectedVersion: "0.5.3",
|
||||
updateId,
|
||||
}),
|
||||
),
|
||||
30,
|
||||
);
|
||||
return child;
|
||||
};
|
||||
|
||||
const service = new UpdateService({
|
||||
store: { data: { updates: {}, gitea: { baseUrl: 'https://example.test' } }, save: async () => {} },
|
||||
gitea: {}, diagnostics: null,
|
||||
appInfo: { version: '0.5.2', packaged: false },
|
||||
store: {
|
||||
data: { updates: {}, gitea: { baseUrl: "https://example.test" } },
|
||||
save: async () => {},
|
||||
},
|
||||
gitea: {},
|
||||
diagnostics: null,
|
||||
appInfo: { version: "0.5.2", packaged: false },
|
||||
sourcePath: source,
|
||||
userDataPath: temp,
|
||||
platform: 'win32',
|
||||
platform: "win32",
|
||||
spawnProcess,
|
||||
powershellPath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
powershellPath:
|
||||
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
handshakeTimeoutMs: 1000,
|
||||
handshakePollMs: 10
|
||||
handshakePollMs: 10,
|
||||
});
|
||||
service.staged = { archivePath: archive, remoteVersion: '0.5.3', remoteSha: 'a'.repeat(40), sha256: 'b'.repeat(64) };
|
||||
service.staged = {
|
||||
archivePath: archive,
|
||||
remoteVersion: "0.5.3",
|
||||
remoteSha: "a".repeat(40),
|
||||
sha256: "b".repeat(64),
|
||||
};
|
||||
const result = await service.apply();
|
||||
assert.equal(result.confirmed, true);
|
||||
assert.ok(capturedArgs.includes('-StatusPath'));
|
||||
assert.ok(capturedArgs.includes('-UpdateId'));
|
||||
assert.ok(capturedArgs.includes("-StatusPath"));
|
||||
assert.ok(capturedArgs.includes("-UpdateId"));
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('source updater leaves ForgeFlow open when no STARTED marker arrives', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-timeout-'));
|
||||
const statusPath = path.join(temp, 'status.json');
|
||||
await writeFile(statusPath, JSON.stringify({ state: 'launching' }));
|
||||
test("source updater leaves ForgeFlow open when no STARTED marker arrives", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-update-timeout-"),
|
||||
);
|
||||
const statusPath = path.join(temp, "status.json");
|
||||
await writeFile(statusPath, JSON.stringify({ state: "launching" }));
|
||||
await assert.rejects(
|
||||
() => waitForUpdaterStarted(statusPath, { timeoutMs: 80, pollMs: 10, childState: { exited: false, error: null } }),
|
||||
(error) => error.code === 'UPDATE_HELPER_START_TIMEOUT'
|
||||
() =>
|
||||
waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: 80,
|
||||
pollMs: 10,
|
||||
childState: { exited: false, error: null },
|
||||
}),
|
||||
(error) => error.code === "UPDATE_HELPER_START_TIMEOUT",
|
||||
);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('completed source update result is returned once and acknowledged', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-result-'));
|
||||
const updates = path.join(temp, 'updates');
|
||||
test("completed source update result is returned once and acknowledged", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-update-result-"),
|
||||
);
|
||||
const updates = path.join(temp, "updates");
|
||||
await mkdir(updates, { recursive: true });
|
||||
const statusPath = path.join(updates, 'apply-test.status.json');
|
||||
await writeFile(statusPath, JSON.stringify({
|
||||
state: 'success', expectedVersion: '0.5.3', installedVersion: '0.5.3', restartLaunched: false,
|
||||
message: 'installed', logPath: 'C:\\log.txt', updatedAt: new Date().toISOString()
|
||||
}));
|
||||
const statusPath = path.join(updates, "apply-test.status.json");
|
||||
await writeFile(
|
||||
statusPath,
|
||||
JSON.stringify({
|
||||
state: "success",
|
||||
expectedVersion: "0.5.3",
|
||||
installedVersion: "0.5.3",
|
||||
restartLaunched: false,
|
||||
message: "installed",
|
||||
logPath: "C:\\log.txt",
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
const service = new UpdateService({
|
||||
store: { data: { updates: {} }, save: async () => {} }, gitea: {}, diagnostics: null,
|
||||
appInfo: { version: '0.5.3', packaged: false }, sourcePath: temp, userDataPath: temp
|
||||
store: { data: { updates: {} }, save: async () => {} },
|
||||
gitea: {},
|
||||
diagnostics: null,
|
||||
appInfo: { version: "0.5.3", packaged: false },
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
});
|
||||
const first = await service.consumeLatestResult();
|
||||
const second = await service.consumeLatestResult();
|
||||
assert.equal(first.state, 'success');
|
||||
assert.equal(first.state, "success");
|
||||
assert.equal(first.restartLaunched, false);
|
||||
assert.equal(second, null);
|
||||
const persisted = JSON.parse(await readFile(statusPath, 'utf8'));
|
||||
const persisted = JSON.parse(await readFile(statusPath, "utf8"));
|
||||
assert.ok(persisted.acknowledgedAt);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('PowerShell update helper writes lifecycle status before waiting for ForgeFlow exit', async () => {
|
||||
const script = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url), 'utf8');
|
||||
test("PowerShell update helper writes lifecycle status before waiting for ForgeFlow exit", async () => {
|
||||
const script = await readFile(
|
||||
new URL("../scripts/apply-source-update.ps1", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(script, /\[string\]\$StatusPath/);
|
||||
assert.match(script, /Write-UpdateState -State "started"/);
|
||||
assert.match(script, /Write-UpdateState -State "success"/);
|
||||
@@ -141,23 +215,29 @@ test('PowerShell update helper writes lifecycle status before waiting for ForgeF
|
||||
assert.match(script, /WriteAllText/);
|
||||
});
|
||||
|
||||
|
||||
test('PowerShell update helper starts with param and has no BOM or stray leading slash', async () => {
|
||||
const bytes = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url));
|
||||
assert.notDeepEqual([...bytes.subarray(0, 3)], [0xEF, 0xBB, 0xBF]);
|
||||
const text = bytes.toString('utf8');
|
||||
test("PowerShell update helper starts with param and has no BOM or stray leading slash", async () => {
|
||||
const bytes = await readFile(
|
||||
new URL("../scripts/apply-source-update.ps1", import.meta.url),
|
||||
);
|
||||
assert.notDeepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
|
||||
const text = bytes.toString("utf8");
|
||||
assert.match(text.trimStart(), /^param\(/);
|
||||
assert.doesNotMatch(text.trimStart(), /^\\/);
|
||||
assert.match(text, /node_modules\\electron\\dist\\electron\.exe/);
|
||||
assert.match(text, /npm ci --no-audit --no-fund/);
|
||||
assert.match(text, /package-lock\.json/);
|
||||
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
|
||||
assert.ok(text.indexOf('Write-UpdateState -State "success"') < text.indexOf('Start-ForgeFlow -WorkingDirectory $SourcePath'));
|
||||
|
||||
assert.ok(
|
||||
text.indexOf('Write-UpdateState -State "success"') <
|
||||
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
|
||||
);
|
||||
});
|
||||
|
||||
test('release publisher verifies Gitea and bootstraps the installed updater service and helper', async () => {
|
||||
const script = await readFile(new URL('../Publish-ForgeFlow-Release.ps1', import.meta.url), 'utf8');
|
||||
test("release publisher verifies Gitea and bootstraps the installed updater service and helper", async () => {
|
||||
const script = await readFile(
|
||||
new URL("../Publish-ForgeFlow-Release.ps1", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(script, /npm install --no-audit --no-fund/);
|
||||
assert.match(script, /package-lock\.json/);
|
||||
assert.match(script, /non-reproducible update/);
|
||||
@@ -175,47 +255,205 @@ test('release publisher verifies Gitea and bootstraps the installed updater serv
|
||||
assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/);
|
||||
});
|
||||
|
||||
|
||||
test('PowerShell helper replaces an existing launching status with a Windows-safe file API', async () => {
|
||||
const script = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url), 'utf8');
|
||||
assert.match(script, /System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$null\)/);
|
||||
assert.match(script, /System\.IO\.File\]::Copy\(\$temporary, \$StatusPath, \$true\)/);
|
||||
assert.doesNotMatch(script, /Move-Item -LiteralPath \$temporary -Destination \$StatusPath -Force/);
|
||||
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
|
||||
const script = await readFile(
|
||||
new URL("../scripts/apply-source-update.ps1", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$null\)/,
|
||||
);
|
||||
assert.match(
|
||||
script,
|
||||
/System\.IO\.File\]::Copy\(\$temporary, \$StatusPath, \$true\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
script,
|
||||
/Move-Item -LiteralPath \$temporary -Destination \$StatusPath -Force/,
|
||||
);
|
||||
assert.match(script, /\[switch\]\$HandshakeOnly/);
|
||||
assert.match(script, /Handshake-only verification completed successfully/);
|
||||
});
|
||||
|
||||
test('early helper exit reports the helper log instead of only an exit code', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-log-tail-'));
|
||||
const statusPath = path.join(temp, 'status.json');
|
||||
const logPath = path.join(temp, 'apply.log');
|
||||
await writeFile(statusPath, JSON.stringify({ state: 'launching', updateId: 'request-1' }));
|
||||
await writeFile(logPath, 'first line\nactual helper failure\n');
|
||||
test("early helper exit reports the helper log instead of only an exit code", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-update-log-tail-"),
|
||||
);
|
||||
const statusPath = path.join(temp, "status.json");
|
||||
const logPath = path.join(temp, "apply.log");
|
||||
await writeFile(
|
||||
statusPath,
|
||||
JSON.stringify({ state: "launching", updateId: "request-1" }),
|
||||
);
|
||||
await writeFile(logPath, "first line\nactual helper failure\n");
|
||||
await assert.rejects(
|
||||
() => waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: 100,
|
||||
pollMs: 5,
|
||||
childState: { exited: true, code: 0, error: null },
|
||||
expectedUpdateId: 'request-1',
|
||||
logPath
|
||||
}),
|
||||
(error) => error.code === 'UPDATE_HELPER_EXITED_EARLY' && /actual helper failure/.test(error.message)
|
||||
() =>
|
||||
waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: 100,
|
||||
pollMs: 5,
|
||||
childState: { exited: true, code: 0, error: null },
|
||||
expectedUpdateId: "request-1",
|
||||
logPath,
|
||||
}),
|
||||
(error) =>
|
||||
error.code === "UPDATE_HELPER_EXITED_EARLY" &&
|
||||
/actual helper failure/.test(error.message),
|
||||
);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('updater handshake rejects a stale status from another update request', async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-id-'));
|
||||
const statusPath = path.join(temp, 'status.json');
|
||||
await writeFile(statusPath, JSON.stringify({ state: 'started', updateId: 'old-request' }));
|
||||
test("updater handshake rejects a stale status from another update request", async () => {
|
||||
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-id-"));
|
||||
const statusPath = path.join(temp, "status.json");
|
||||
await writeFile(
|
||||
statusPath,
|
||||
JSON.stringify({ state: "started", updateId: "old-request" }),
|
||||
);
|
||||
await assert.rejects(
|
||||
() => waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: 50,
|
||||
pollMs: 5,
|
||||
childState: { exited: false, code: null, error: null },
|
||||
expectedUpdateId: 'new-request'
|
||||
}),
|
||||
(error) => error.code === 'UPDATE_HELPER_START_TIMEOUT'
|
||||
() =>
|
||||
waitForUpdaterStarted(statusPath, {
|
||||
timeoutMs: 50,
|
||||
pollMs: 5,
|
||||
childState: { exited: false, code: null, error: null },
|
||||
expectedUpdateId: "new-request",
|
||||
}),
|
||||
(error) => error.code === "UPDATE_HELPER_START_TIMEOUT",
|
||||
);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("packaged updater downloads only a published checksum-matched Windows asset", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-binary-update-"),
|
||||
);
|
||||
const binary = Buffer.alloc(1_100_000, 0x5a);
|
||||
binary[0] = 0x4d;
|
||||
binary[1] = 0x5a;
|
||||
const sha256 = createHash("sha256").update(binary).digest("hex");
|
||||
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
|
||||
const gitea = {
|
||||
async getReleaseByTag(_owner, _repo, tag) {
|
||||
if (tag !== "v0.8.2") return null;
|
||||
return {
|
||||
tag_name: tag,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{ name: assetName, browser_download_url: "https://gitea.test/setup" },
|
||||
{
|
||||
name: `${assetName}.sha256`,
|
||||
browser_download_url: "https://gitea.test/checksum",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadAuthenticated(url) {
|
||||
return url.endsWith("/checksum")
|
||||
? Buffer.from(`${sha256} ${assetName}\n`)
|
||||
: binary;
|
||||
},
|
||||
};
|
||||
const service = new UpdateService({
|
||||
store: {
|
||||
data: { gitea: { baseUrl: "https://gitea.test" } },
|
||||
save: async () => {},
|
||||
},
|
||||
gitea,
|
||||
diagnostics: null,
|
||||
appInfo: {
|
||||
version: "0.8.1",
|
||||
packaged: true,
|
||||
executablePath: "C:\\ForgeFlow\\ForgeFlow.exe",
|
||||
},
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
platform: "win32",
|
||||
});
|
||||
const result = await service.downloadPackaged({
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
remoteVersion: "0.8.2",
|
||||
});
|
||||
assert.equal(result.downloaded, true);
|
||||
assert.equal(result.sha256, sha256);
|
||||
assert.equal(result.portable, false);
|
||||
assert.equal((await readFile(result.binaryPath)).length, binary.length);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("packaged updater rejects a binary whose checksum does not match", async () => {
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "forgeflow-binary-mismatch-"),
|
||||
);
|
||||
const binary = Buffer.alloc(1_100_000, 0x5a);
|
||||
binary[0] = 0x4d;
|
||||
binary[1] = 0x5a;
|
||||
const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe";
|
||||
const service = new UpdateService({
|
||||
store: { data: { gitea: {} }, save: async () => {} },
|
||||
gitea: {
|
||||
async getReleaseByTag() {
|
||||
return {
|
||||
tag_name: "v0.8.2",
|
||||
assets: [
|
||||
{
|
||||
name: assetName,
|
||||
browser_download_url: "https://gitea.test/portable",
|
||||
},
|
||||
{
|
||||
name: `${assetName}.sha256`,
|
||||
browser_download_url: "https://gitea.test/checksum",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async downloadAuthenticated(url) {
|
||||
return url.endsWith("/checksum")
|
||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
||||
: binary;
|
||||
},
|
||||
},
|
||||
diagnostics: null,
|
||||
appInfo: {
|
||||
version: "0.8.1",
|
||||
packaged: true,
|
||||
portableExecutablePath: "C:\\ForgeFlow-Portable.exe",
|
||||
},
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
platform: "win32",
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.downloadPackaged({
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
remoteVersion: "0.8.2",
|
||||
}),
|
||||
/SHA-256 verification/,
|
||||
);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("binary update helper verifies, waits, applies and records restart state", async () => {
|
||||
const helper = await readFile(
|
||||
new URL("../scripts/apply-binary-update.ps1", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"Get-FileHash",
|
||||
"Wait-Process",
|
||||
'Write-UpdateState -State "started"',
|
||||
'Write-UpdateState -State "waiting-for-exit"',
|
||||
'Write-UpdateState -State "applying"',
|
||||
'Write-UpdateState -State "success"',
|
||||
'Start-Process -FilePath $BinaryPath -ArgumentList "/S"',
|
||||
"Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable",
|
||||
]) {
|
||||
assert.ok(
|
||||
helper.includes(marker),
|
||||
`missing binary updater marker: ${marker}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user