From 3e5e3d2a8b0f1c6abad4d0d8042d831fd4b9d128 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:58:24 +0200 Subject: [PATCH] Release ForgeFlow 0.8.2 with binary auto-update --- CHANGELOG.md | 7 + SOURCE_MANIFEST.txt | 28 +- docs/RELEASE_NOTES_0.8.2.md | 11 + docs/UPDATING.md | 14 + main.cjs | 525 ++++++++++++++++--------- package-lock.json | 4 +- package.json | 10 +- scripts/apply-binary-update.ps1 | 78 ++++ scripts/publish-binary-release.cjs | 149 ++++++++ scripts/verify.mjs | 8 +- scripts/write-release-checksums.mjs | 20 + src/main/gitea-service.cjs | 416 ++++++++++++++------ src/main/update-service.cjs | 572 +++++++++++++++++++++++----- src/renderer/app.js | 2 +- src/renderer/mock-bridge.js | 2 +- tests/update-service.test.mjs | 454 ++++++++++++++++------ 16 files changed, 1778 insertions(+), 522 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.8.2.md create mode 100644 scripts/apply-binary-update.ps1 create mode 100644 scripts/publish-binary-release.cjs create mode 100644 scripts/write-release-checksums.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08eded4..a2e80c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 9d11790..52662cd 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -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 diff --git a/docs/RELEASE_NOTES_0.8.2.md b/docs/RELEASE_NOTES_0.8.2.md new file mode 100644 index 0000000..22516da --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.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. diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 4cedd3c..29f9dba 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -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: diff --git a/main.cjs b/main.cjs index 71fd3f7..b6f7a73 100644 --- a/main.cjs +++ b/main.cjs @@ -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(); }); diff --git a/package-lock.json b/package-lock.json index 1db6099..c515a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index e560f26..1ed44f5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/apply-binary-update.ps1 b/scripts/apply-binary-update.ps1 new file mode 100644 index 0000000..257ccfc --- /dev/null +++ b/scripts/apply-binary-update.ps1 @@ -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 +} diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs new file mode 100644 index 0000000..71996bf --- /dev/null +++ b/scripts/publish-binary-release.cjs @@ -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); + } +}); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 34169ea..a4052d2 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -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"), diff --git a/scripts/write-release-checksums.mjs b/scripts/write-release-checksums.mjs new file mode 100644 index 0000000..5d48fe9 --- /dev/null +++ b/scripts/write-release-checksums.mjs @@ -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}`); +} diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs index c127b12..fd07dd1 100644 --- a/src/main/gitea-service.cjs +++ b/src/main/gitea-service.cjs @@ -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; } } diff --git a/src/main/update-service.cjs b/src/main/update-service.cjs index 5406ec6..ef1ba0e 100644 --- a/src/main/update-service.cjs +++ b/src/main/update-service.cjs @@ -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, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index b03a75b..3595e08 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -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) { diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index a95247b..0d1c94a 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -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" }, diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs index f881dd4..425e7b8 100644 --- a/tests/update-service.test.mjs +++ b/tests/update-service.test.mjs @@ -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}`, + ); + } +});