Release ForgeFlow 0.8.2 with binary auto-update

This commit is contained in:
NuklearRabbit
2026-07-26 00:58:24 +02:00
parent 4ad698c4eb
commit 3e5e3d2a8b
16 changed files with 1778 additions and 522 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.8.2 - 2026-07-26
- enabled checksum-verified binary auto-update for installed and portable Windows builds;
- added an external binary updater that waits for ForgeFlow to close, applies the verified release and restarts the application;
- added reproducible SHA-256 sidecars and authenticated Gitea release publishing;
- made packaged update checks fail clearly when a matching published release asset is incomplete.
## 0.8.1 - 2026-07-26
- introduced a refined premium visual system with clearer hierarchy, richer depth, responsive density and reduced-motion support;
+16 -12
View File
@@ -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
+11
View File
@@ -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.
+14
View File
@@ -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:
+338 -187
View File
@@ -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();
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "forgeflow",
"version": "0.8.1",
"version": "0.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "forgeflow",
"version": "0.8.1",
"version": "0.8.2",
"dependencies": {
"ssh2": "1.17.0"
},
+8 -2
View File
@@ -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"
},
+78
View File
@@ -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
}
+149
View File
@@ -0,0 +1,149 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { app, safeStorage } = require("electron");
const root = path.resolve(__dirname, "..");
const configuredUserData =
process.env.FORGEFLOW_USER_DATA ||
path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", path.resolve(configuredUserData));
async function api(baseUrl, token, pathname, options = {}) {
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
...options,
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
...(options.headers || {}),
},
signal: AbortSignal.timeout(options.timeout || 180_000),
});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok)
throw new Error(
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
);
return data;
}
app.whenReady().then(async () => {
try {
const manifest = JSON.parse(
await fs.readFile(path.join(root, "package.json"), "utf8"),
);
const config = JSON.parse(
await fs.readFile(
path.join(configuredUserData, "forgeflow-config.json"),
"utf8",
),
);
const token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
const baseUrl = String(config.gitea.baseUrl).replace(/\/+$/, "");
const version = manifest.version;
const tag = `v${version}`;
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: root,
encoding: "utf8",
}).trim();
const remote = execFileSync(
"git",
["ls-remote", "origin", "refs/heads/main"],
{ cwd: root, encoding: "utf8" },
)
.trim()
.split(/\s+/)[0];
if (commit !== remote)
throw new Error("Local HEAD is not the published origin/main commit.");
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
const body = await fs.readFile(notesPath, "utf8");
let release;
try {
release = await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/tags/${encodeURIComponent(tag)}`,
);
} catch (error) {
if (!/HTTP 404/.test(error.message)) throw error;
release = await api(baseUrl, token, "/repos/Jens/ForgeFlow/releases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
target_commitish: commit,
name: `ForgeFlow ${version}`,
body,
draft: false,
prerelease: false,
}),
});
}
const binaries = [
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
];
for (const binaryPath of binaries) {
const binaryName = path.basename(binaryPath);
const binary = await fs.readFile(binaryPath);
const checksumPath = `${binaryPath}.sha256`;
const checksumName = `${binaryName}.sha256`;
const checksum = await fs.readFile(checksumPath);
for (const [name, bytes, type] of [
[binaryName, binary, "application/vnd.microsoft.portable-executable"],
[checksumName, checksum, "text/plain"],
]) {
const existing = (release.assets || []).find(
(asset) => asset.name === name,
);
if (existing && Number(existing.size) === bytes.length) {
console.log(`SKIP ${name} already published`);
continue;
}
if (existing) {
await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(
baseUrl,
token,
`/repos/Jens/ForgeFlow/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{
method: "POST",
body: form,
timeout: 300_000,
},
);
release.assets = [
...(release.assets || []).filter((asset) => asset.name !== name),
uploaded,
];
console.log(`PASS published ${name}`);
}
}
console.log(
`PASS ForgeFlow ${version} binary release published for ${commit.slice(0, 7)}`,
);
app.exit(0);
} catch (error) {
console.error(`FAIL ${error.message}`);
app.exit(1);
}
});
+6 -2
View File
@@ -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"),
+20
View File
@@ -0,0 +1,20 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
const binary = await readFile(path.join(root, "dist", name));
const sha256 = createHash("sha256").update(binary).digest("hex");
await writeFile(
path.join(root, "dist", `${name}.sha256`),
`${sha256} ${name}\n`,
"utf8",
);
console.log(`${name}: ${sha256}`);
}
+310 -106
View File
@@ -1,7 +1,10 @@
'use strict';
"use strict";
const { normalizeBaseUrl, assertBranchName } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs');
const {
normalizeBaseUrl,
assertBranchName,
} = require("../shared/validation.cjs");
const { redactSecrets } = require("./log-redaction.cjs");
class GiteaService {
constructor(store, diagnostics = null) {
@@ -10,67 +13,120 @@ class GiteaService {
}
async request(pathname, options = {}) {
const baseUrl = normalizeBaseUrl(options.baseUrl || this.store.data.gitea.baseUrl);
const baseUrl = normalizeBaseUrl(
options.baseUrl || this.store.data.gitea.baseUrl,
);
const token = options.token || this.store.getToken();
if (!token && options.auth !== false) throw new Error('No Gitea access token is available.');
if (!token && options.auth !== false)
throw new Error("No Gitea access token is available.");
const headers = {
Accept: options.accept || 'application/json',
...(token && options.auth !== false ? { Authorization: `token ${token}` } : {}),
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {})
Accept: options.accept || "application/json",
...(token && options.auth !== false
? { Authorization: `token ${token}` }
: {}),
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
};
const started = Date.now();
let response;
try {
response = await fetch(`${baseUrl}/api/v1${pathname}`, {
method: options.method || 'GET',
method: options.method || "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(options.timeout || 30_000),
redirect: 'follow'
redirect: "follow",
});
} catch (error) {
const wrapped = new Error(`Could not reach Gitea: ${redactSecrets(error.message, [token])}`);
wrapped.code = error.code || 'GITEA_NETWORK_ERROR';
await this.diagnostics?.warning('gitea.request.failed', { method: options.method || 'GET', pathname, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
const wrapped = new Error(
`Could not reach Gitea: ${redactSecrets(error.message, [token])}`,
);
wrapped.code = error.code || "GITEA_NETWORK_ERROR";
await this.diagnostics?.warning("gitea.request.failed", {
method: options.method || "GET",
pathname,
durationMs: Date.now() - started,
code: wrapped.code,
message: wrapped.message,
});
throw wrapped;
}
let text = '';
let text = "";
let payload = null;
if (options.responseType === 'buffer') {
if (options.responseType === "buffer") {
payload = Buffer.from(await response.arrayBuffer());
} else {
text = await response.text();
if (text) {
if (options.responseType === 'text') payload = text;
if (options.responseType === "text") payload = text;
else {
try { payload = JSON.parse(text); } catch { payload = text; }
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
}
}
if (!response.ok) {
const detail = typeof payload === 'object' && !Buffer.isBuffer(payload) && payload?.message ? payload.message : text || response.statusText;
const error = new Error(`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`);
const detail =
typeof payload === "object" &&
!Buffer.isBuffer(payload) &&
payload?.message
? payload.message
: text || response.statusText;
const error = new Error(
`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`,
);
error.status = response.status;
error.payload = payload;
await this.diagnostics?.warning('gitea.request.rejected', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started, message: error.message });
await this.diagnostics?.warning("gitea.request.rejected", {
method: options.method || "GET",
pathname,
status: response.status,
durationMs: Date.now() - started,
message: error.message,
});
throw error;
}
await this.diagnostics?.debug('gitea.request.completed', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started });
return { status: response.status, headers: response.headers, data: payload };
await this.diagnostics?.debug("gitea.request.completed", {
method: options.method || "GET",
pathname,
status: response.status,
durationMs: Date.now() - started,
});
return {
status: response.status,
headers: response.headers,
data: payload,
};
}
async validateConnection(baseUrl, token) {
const normalized = normalizeBaseUrl(baseUrl);
const user = await this.request('/user', { baseUrl: normalized, token });
const repositories = await this.listRepositories({ baseUrl: normalized, token, limitPages: 1 });
const version = await this.request('/version', { baseUrl: normalized, token }).then((result) => result.data?.version || null).catch(() => null);
return { baseUrl: normalized, user: user.data, repositoryCount: repositories.length, version };
const user = await this.request("/user", { baseUrl: normalized, token });
const repositories = await this.listRepositories({
baseUrl: normalized,
token,
limitPages: 1,
});
const version = await this.request("/version", {
baseUrl: normalized,
token,
})
.then((result) => result.data?.version || null)
.catch(() => null);
return {
baseUrl: normalized,
user: user.data,
repositoryCount: repositories.length,
version,
};
}
async listRepositories(options = {}) {
@@ -78,7 +134,10 @@ class GiteaService {
const pageSize = 50;
const limitPages = options.limitPages || 20;
for (let page = 1; page <= limitPages; page += 1) {
const result = await this.request(`/user/repos?limit=${pageSize}&page=${page}&sort=updated`, options);
const result = await this.request(
`/user/repos?limit=${pageSize}&page=${page}&sort=updated`,
options,
);
const batch = Array.isArray(result.data) ? result.data : [];
repositories.push(...batch);
if (batch.length < pageSize) break;
@@ -87,14 +146,23 @@ class GiteaService {
}
async getRepository(owner, repo) {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`)).data;
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
)
).data;
}
async repositoryFileExists({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const encodedPath = String(filePath || "")
.split("/")
.map(encodeURIComponent)
.join("/");
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
try {
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`);
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
);
return true;
} catch (error) {
if (error.status === 404) return false;
@@ -102,18 +170,26 @@ class GiteaService {
}
}
async getBranch(owner, repo, branch) {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data;
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`,
)
).data;
}
async getBranchProtection(owner, repo, branch) {
const branchInfo = await this.getBranch(owner, repo, branch);
let rule = null;
try {
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`);
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
);
const rules = Array.isArray(result.data) ? result.data : [];
rule = rules.find((item) => item.branch_name === branch || item.rule_name === branch) || null;
rule =
rules.find(
(item) => item.branch_name === branch || item.rule_name === branch,
) || null;
} catch (error) {
if (![403, 404].includes(error.status)) throw error;
}
@@ -124,42 +200,97 @@ class GiteaService {
enableForcePush: rule?.enable_force_push ?? false,
requiredApprovals: Number(rule?.required_approvals || 0),
requireSignedCommits: Boolean(rule?.require_signed_commits),
rule
rule,
};
}
async listPullRequests({ owner, repo, state = 'open', limit = 30 } = {}) {
const query = new URLSearchParams({ state, limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)) });
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`);
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)),
});
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`,
);
return Array.isArray(result.data) ? result.data : [];
}
async createPullRequest({ owner, repo, head, base, title, body = '' }) {
const cleanTitle = String(title || '').trim();
if (!cleanTitle || cleanTitle.length > 255) throw new Error('Pull request title must contain 1-255 characters.');
const cleanBody = String(body || '').trim().slice(0, 50_000);
async createPullRequest({ owner, repo, head, base, title, body = "" }) {
const cleanTitle = String(title || "").trim();
if (!cleanTitle || cleanTitle.length > 255)
throw new Error("Pull request title must contain 1-255 characters.");
const cleanBody = String(body || "")
.trim()
.slice(0, 50_000);
const source = assertBranchName(head);
const target = assertBranchName(base);
if (source === target) throw new Error('Pull request source and target branches must be different.');
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, { method: 'POST', body: { head: source, base: target, title: cleanTitle, body: cleanBody }, timeout: 60_000 });
if (source === target)
throw new Error(
"Pull request source and target branches must be different.",
);
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
{
method: "POST",
body: {
head: source,
base: target,
title: cleanTitle,
body: cleanBody,
},
timeout: 60_000,
},
);
return result.data;
}
async getRepositoryFile({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
const payload = (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`)).data;
if (!payload || Array.isArray(payload)) throw new Error(`Repository path ${filePath} is not a file.`);
if (payload.encoding === 'base64' && typeof payload.content === 'string') {
return { ...payload, decoded: Buffer.from(payload.content.replace(/\s/g, ''), 'base64').toString('utf8') };
const encodedPath = String(filePath || "")
.split("/")
.map(encodeURIComponent)
.join("/");
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const payload = (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
)
).data;
if (!payload || Array.isArray(payload))
throw new Error(`Repository path ${filePath} is not a file.`);
if (payload.encoding === "base64" && typeof payload.content === "string") {
return {
...payload,
decoded: Buffer.from(
payload.content.replace(/\s/g, ""),
"base64",
).toString("utf8"),
};
}
if (typeof payload.content === 'string') return { ...payload, decoded: payload.content };
if (typeof payload.content === "string")
return { ...payload, decoded: payload.content };
throw new Error(`Gitea did not return readable content for ${filePath}.`);
}
async getLatestRelease(owner, repo) {
try {
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`)).data;
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`,
)
).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
async getReleaseByTag(owner, repo, tag) {
try {
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
)
).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
@@ -172,40 +303,57 @@ class GiteaService {
const token = this.store.getToken();
let target = new URL(url, `${baseUrl}/`);
for (let redirects = 0; redirects <= 5; redirects += 1) {
if (target.origin !== base.origin) throw new Error('Refusing to send the Gitea token to a different origin.');
if (target.origin !== base.origin)
throw new Error(
"Refusing to send the Gitea token to a different origin.",
);
const response = await fetch(target, {
headers: { Authorization: `token ${token}`, Accept: 'application/octet-stream' },
headers: {
Authorization: `token ${token}`,
Accept: "application/octet-stream",
},
signal: AbortSignal.timeout(timeout),
redirect: 'manual'
redirect: "manual",
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location) throw new Error('The update download redirect did not contain a destination.');
const location = response.headers.get("location");
if (!location)
throw new Error(
"The update download redirect did not contain a destination.",
);
target = new URL(location, target);
continue;
}
if (!response.ok) throw new Error(`Update download failed with HTTP ${response.status}.`);
if (!response.ok)
throw new Error(`Update download failed with HTTP ${response.status}.`);
return Buffer.from(await response.arrayBuffer());
}
throw new Error('The update download exceeded the redirect limit.');
throw new Error("The update download exceeded the redirect limit.");
}
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
{ method: 'POST', body: { ref, inputs }, timeout: 60_000 }
{ method: "POST", body: { ref, inputs }, timeout: 60_000 },
);
return { accepted: [200, 201, 204].includes(result.status), status: result.status };
return {
accepted: [200, 201, 204].includes(result.status),
status: result.status,
};
}
normalizeRun(run) {
if (!run || typeof run !== 'object') return null;
const status = String(run.status || run.conclusion || '').toLowerCase();
const conclusion = String(run.conclusion || '').toLowerCase() || (['success', 'failure', 'cancelled', 'skipped'].includes(status) ? status : null);
if (!run || typeof run !== "object") return null;
const status = String(run.status || run.conclusion || "").toLowerCase();
const conclusion =
String(run.conclusion || "").toLowerCase() ||
(["success", "failure", "cancelled", "skipped"].includes(status)
? status
: null);
return {
id: run.id ?? run.run_id ?? run.task_id ?? null,
runNumber: run.run_number ?? run.index ?? run.id ?? null,
name: run.name || run.workflow_name || run.workflow_id || 'Workflow',
name: run.name || run.workflow_name || run.workflow_id || "Workflow",
event: run.event || null,
status,
conclusion,
@@ -213,11 +361,12 @@ class GiteaService {
headBranch: run.head_branch || run.ref || run.branch || null,
workflowPath: run.path || run.workflow_path || run.workflow_file || null,
displayTitle: run.display_title || run.title || run.name || null,
actor: run.actor?.login || run.trigger_user?.login || run.user?.login || null,
actor:
run.actor?.login || run.trigger_user?.login || run.user?.login || null,
createdAt: run.created_at || run.started || run.start_time || null,
updatedAt: run.updated_at || run.stopped || run.end_time || null,
htmlUrl: run.html_url || run.url || null,
raw: run
raw: run,
};
}
@@ -225,8 +374,8 @@ class GiteaService {
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
const normalizedLimit = String(Math.min(Math.max(limit, 1), 100));
const filtered = new URLSearchParams({ limit: normalizedLimit });
if (sha) filtered.set('head_sha', sha);
if (branch) filtered.set('branch', branch);
if (sha) filtered.set("head_sha", sha);
if (branch) filtered.set("branch", branch);
const basic = new URLSearchParams({ limit: normalizedLimit });
const tryEndpoint = async (endpoint) => {
@@ -235,78 +384,133 @@ class GiteaService {
} catch (error) {
// Action API query support differs across Gitea releases. Retry without
// optional filters and apply SHA/branch matching locally.
if (![400, 422].includes(error.status) || String(filtered) === String(basic)) throw error;
if (
![400, 422].includes(error.status) ||
String(filtered) === String(basic)
)
throw error;
return this.request(`${base}/${endpoint}?${basic}`);
}
};
let result;
let source = 'runs';
let source = "runs";
try {
result = await tryEndpoint('runs');
result = await tryEndpoint("runs");
} catch (error) {
if (![404, 405].includes(error.status)) throw error;
source = 'tasks';
result = await tryEndpoint('tasks');
source = "tasks";
result = await tryEndpoint("tasks");
}
const data = result.data;
const items = Array.isArray(data) ? data : data?.workflow_runs || data?.runs || data?.tasks || [];
return { source, runs: items.map((item) => this.normalizeRun(item)).filter(Boolean), totalCount: data?.total_count ?? items.length };
const items = Array.isArray(data)
? data
: data?.workflow_runs || data?.runs || data?.tasks || [];
return {
source,
runs: items.map((item) => this.normalizeRun(item)).filter(Boolean),
totalCount: data?.total_count ?? items.length,
};
}
async findWorkflowRun({ owner, repo, sha, branch, workflowFile, dispatchedAt, excludeRunIds = [] }) {
const { runs, source } = await this.listWorkflowRuns({ owner, repo, sha, branch, limit: 50 });
const earliest = dispatchedAt ? new Date(dispatchedAt).getTime() - 120_000 : 0;
const workflowBase = String(workflowFile || '').split('/').pop();
const excluded = new Set((excludeRunIds || []).map((value) => String(value)));
async findWorkflowRun({
owner,
repo,
sha,
branch,
workflowFile,
dispatchedAt,
excludeRunIds = [],
}) {
const { runs, source } = await this.listWorkflowRuns({
owner,
repo,
sha,
branch,
limit: 50,
});
const earliest = dispatchedAt
? new Date(dispatchedAt).getTime() - 120_000
: 0;
const workflowBase = String(workflowFile || "")
.split("/")
.pop();
const excluded = new Set(
(excludeRunIds || []).map((value) => String(value)),
);
const candidates = runs.filter((run) => {
if (run.id !== null && run.id !== undefined && excluded.has(String(run.id))) return false;
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase()) return false;
if (branch && run.headBranch && run.headBranch.replace(/^refs\/heads\//, '') !== branch) return false;
if (earliest && run.createdAt && new Date(run.createdAt).getTime() < earliest) return false;
if (
run.id !== null &&
run.id !== undefined &&
excluded.has(String(run.id))
)
return false;
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase())
return false;
if (
branch &&
run.headBranch &&
run.headBranch.replace(/^refs\/heads\//, "") !== branch
)
return false;
if (
earliest &&
run.createdAt &&
new Date(run.createdAt).getTime() < earliest
)
return false;
if (workflowBase && run.workflowPath) {
const runBase = String(run.workflowPath).split('/').pop();
const runBase = String(run.workflowPath).split("/").pop();
if (runBase && runBase !== workflowBase) return false;
}
return true;
});
candidates.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
candidates.sort(
(a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0),
);
return { source, run: candidates[0] || null };
}
async listWorkflowJobs({ owner, repo, runNumber }) {
if (runNumber === null || runNumber === undefined) return [];
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`);
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`,
);
const data = result.data;
const jobs = Array.isArray(data) ? data : data?.jobs || [];
return jobs.map((job) => ({
id: job.id,
name: job.name || job.job_name || `Job ${job.id}`,
status: String(job.status || '').toLowerCase(),
conclusion: String(job.conclusion || '').toLowerCase() || null,
status: String(job.status || "").toLowerCase(),
conclusion: String(job.conclusion || "").toLowerCase() || null,
startedAt: job.started_at || null,
completedAt: job.completed_at || null,
steps: Array.isArray(job.steps) ? job.steps.map((step) => ({
name: step.name,
status: String(step.status || '').toLowerCase(),
conclusion: String(step.conclusion || '').toLowerCase() || null,
number: step.number
})) : []
steps: Array.isArray(job.steps)
? job.steps.map((step) => ({
name: step.name,
status: String(step.status || "").toLowerCase(),
conclusion: String(step.conclusion || "").toLowerCase() || null,
number: step.number,
}))
: [],
}));
}
async getJobLogs({ owner, repo, jobId }) {
if (!jobId) return '';
if (!jobId) return "";
try {
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`, {
accept: 'text/plain, application/octet-stream',
responseType: 'text',
timeout: 60_000
});
return String(result.data || '').slice(-500_000);
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`,
{
accept: "text/plain, application/octet-stream",
responseType: "text",
timeout: 60_000,
},
);
return String(result.data || "").slice(-500_000);
} catch (error) {
if ([404, 410].includes(error.status)) return '';
if ([404, 410].includes(error.status)) return "";
throw error;
}
}
+471 -101
View File
@@ -1,15 +1,16 @@
'use strict';
"use strict";
const fs = require('node:fs/promises');
const fsSync = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { spawn } = require('node:child_process');
const { isNewerVersion } = require('../shared/semver.cjs');
const fs = require("node:fs/promises");
const fsSync = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const { isNewerVersion } = require("../shared/semver.cjs");
function safeRepositoryPart(value, label) {
const text = String(value || '').trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) throw new Error(`${label} contains unsupported characters.`);
const text = String(value || "").trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(text))
throw new Error(`${label} contains unsupported characters.`);
return text;
}
@@ -20,63 +21,114 @@ function delay(ms) {
function resolveWindowsPowerShellPath(environment = process.env) {
const windowsRoot = environment.SystemRoot || environment.WINDIR;
if (windowsRoot) {
const absolute = path.join(windowsRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
const absolute = path.join(
windowsRoot,
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
if (fsSync.existsSync(absolute)) return absolute;
}
return 'powershell.exe';
return "powershell.exe";
}
async function readJsonFile(filePath) {
try { return JSON.parse(await fs.readFile(filePath, 'utf8')); }
catch { return null; }
try {
return JSON.parse(await fs.readFile(filePath, "utf8"));
} catch {
return null;
}
}
async function readLogTail(filePath, maxLines = 12) {
if (!filePath) return '';
if (!filePath) return "";
try {
const text = await fs.readFile(filePath, 'utf8');
return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join('\n');
} catch { return ''; }
const text = await fs.readFile(filePath, "utf8");
return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n");
} catch {
return "";
}
}
async function updaterStartupError(message, code, { statusPath, logPath, expectedUpdateId } = {}) {
async function updaterStartupError(
message,
code,
{ statusPath, logPath, expectedUpdateId } = {},
) {
const status = statusPath ? await readJsonFile(statusPath) : null;
const logTail = await readLogTail(logPath);
const details = [];
if (status?.updateId && expectedUpdateId && status.updateId !== expectedUpdateId) details.push('The helper wrote a status for a different update request.');
if (
status?.updateId &&
expectedUpdateId &&
status.updateId !== expectedUpdateId
)
details.push("The helper wrote a status for a different update request.");
if (status?.message) details.push(status.message);
if (logTail) details.push(`Update helper log:\n${logTail}`);
const error = new Error([message, ...details].filter(Boolean).join('\n\n'));
const error = new Error([message, ...details].filter(Boolean).join("\n\n"));
error.code = code;
error.status = status;
error.logPath = logPath || null;
return error;
}
async function waitForUpdaterStarted(statusPath, {
timeoutMs = 15000,
pollMs = 100,
childState = null,
expectedUpdateId = null,
logPath = null
} = {}) {
async function waitForUpdaterStarted(
statusPath,
{
timeoutMs = 15000,
pollMs = 100,
childState = null,
expectedUpdateId = null,
logPath = null,
} = {},
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const status = await readJsonFile(statusPath);
const belongsToRequest = !expectedUpdateId || status?.updateId === expectedUpdateId;
if (status && belongsToRequest && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) {
const belongsToRequest =
!expectedUpdateId || status?.updateId === expectedUpdateId;
if (
status &&
belongsToRequest &&
[
"started",
"waiting-for-exit",
"backing-up",
"extracting",
"applying",
"validating",
].includes(status.state)
) {
return status;
}
if (status && belongsToRequest && ['failed', 'rolled-back'].includes(status.state)) {
throw await updaterStartupError('The update helper reported a failure before ForgeFlow could close.', 'UPDATE_HELPER_START_FAILED', { statusPath, logPath, expectedUpdateId });
if (
status &&
belongsToRequest &&
["failed", "rolled-back"].includes(status.state)
) {
throw await updaterStartupError(
"The update helper reported a failure before ForgeFlow could close.",
"UPDATE_HELPER_START_FAILED",
{ statusPath, logPath, expectedUpdateId },
);
}
if (childState?.error) throw childState.error;
if (childState?.exited) {
throw await updaterStartupError(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`, 'UPDATE_HELPER_EXITED_EARLY', { statusPath, logPath, expectedUpdateId });
throw await updaterStartupError(
`The update helper exited before it confirmed startup (exit code ${childState.code ?? "unknown"}).`,
"UPDATE_HELPER_EXITED_EARLY",
{ statusPath, logPath, expectedUpdateId },
);
}
await delay(pollMs);
}
throw await updaterStartupError('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.', 'UPDATE_HELPER_START_TIMEOUT', { statusPath, logPath, expectedUpdateId });
throw await updaterStartupError(
"The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.",
"UPDATE_HELPER_START_TIMEOUT",
{ statusPath, logPath, expectedUpdateId },
);
}
class UpdateService {
@@ -91,14 +143,14 @@ class UpdateService {
spawnProcess = spawn,
powershellPath = null,
handshakeTimeoutMs = 12000,
handshakePollMs = 100
handshakePollMs = 100,
}) {
this.store = store;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.appInfo = appInfo;
this.sourcePath = sourcePath;
this.updateDirectory = path.join(userDataPath, 'updates');
this.updateDirectory = path.join(userDataPath, "updates");
this.platform = platform;
this.spawnProcess = spawnProcess;
this.powershellPath = powershellPath;
@@ -109,20 +161,41 @@ class UpdateService {
async check() {
const settings = this.store.data.updates || {};
const owner = safeRepositoryPart(settings.owner || 'Jens', 'Update repository owner');
const repo = safeRepositoryPart(settings.repo || 'ForgeFlow', 'Update repository name');
const branchName = String(settings.branch || 'main').trim();
const owner = safeRepositoryPart(
settings.owner || "Jens",
"Update repository owner",
);
const repo = safeRepositoryPart(
settings.repo || "ForgeFlow",
"Update repository name",
);
const branchName = String(settings.branch || "main").trim();
const branch = await this.gitea.getBranch(owner, repo, branchName);
const remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ''))) throw new Error('Gitea did not return a full commit SHA for the update branch.');
const remoteSha =
branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || "")))
throw new Error(
"Gitea did not return a full commit SHA for the update branch.",
);
const file = await this.gitea.getRepositoryFile({ owner, repo, filePath: 'package.json', ref: remoteSha });
const file = await this.gitea.getRepositoryFile({
owner,
repo,
filePath: "package.json",
ref: remoteSha,
});
let manifest;
try { manifest = JSON.parse(file.decoded); }
catch { throw new Error('The remote ForgeFlow package.json is not valid JSON.'); }
if (manifest.name !== 'forgeflow') throw new Error('The configured update repository is not a ForgeFlow source repository.');
const remoteVersion = String(manifest.version || '').trim();
const currentVersion = String(this.appInfo.version || '').trim();
try {
manifest = JSON.parse(file.decoded);
} catch {
throw new Error("The remote ForgeFlow package.json is not valid JSON.");
}
if (manifest.name !== "forgeflow")
throw new Error(
"The configured update repository is not a ForgeFlow source repository.",
);
const remoteVersion = String(manifest.version || "").trim();
const currentVersion = String(this.appInfo.version || "").trim();
const available = isNewerVersion(remoteVersion, currentVersion);
const result = {
checkedAt: new Date().toISOString(),
@@ -135,90 +208,222 @@ class UpdateService {
shortSha: remoteSha.slice(0, 7),
available,
packaged: Boolean(this.appInfo.packaged),
mode: this.appInfo.packaged ? 'packaged' : 'source'
mode: this.appInfo.packaged ? "packaged" : "source",
};
this.store.data.updates.lastCheckedAt = result.checkedAt;
await this.store.save();
await this.diagnostics?.info('updates.checked', {
await this.diagnostics?.info("updates.checked", {
repository: `${owner}/${repo}`,
branch: branchName,
currentVersion,
remoteVersion,
remoteSha,
available,
mode: result.mode
mode: result.mode,
});
return result;
}
async download(expected = null) {
const update = expected?.remoteSha ? expected : await this.check();
if (!update.available) return { ...update, downloaded: false, reason: 'up-to-date' };
if (!update.available)
return { ...update, downloaded: false, reason: "up-to-date" };
if (this.appInfo.packaged) {
const error = new Error('This developer release uses source updates. Install a signed packaged release before using binary auto-update.');
error.code = 'PACKAGED_UPDATE_NOT_CONFIGURED';
throw error;
return this.downloadPackaged(update);
}
await fs.mkdir(this.updateDirectory, { recursive: true });
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b) throw new Error('The downloaded update is not a valid ZIP archive.');
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
const archivePath = path.join(this.updateDirectory, `ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`);
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b)
throw new Error("The downloaded update is not a valid ZIP archive.");
const sha256 = crypto.createHash("sha256").update(archive).digest("hex");
const archivePath = path.join(
this.updateDirectory,
`ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`,
);
const metadataPath = `${archivePath}.json`;
await fs.writeFile(archivePath, archive, { mode: 0o600 });
const metadata = { ...update, archivePath, sha256, downloadedAt: new Date().toISOString() };
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
const metadata = {
...update,
archivePath,
sha256,
downloadedAt: new Date().toISOString(),
};
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), {
mode: 0o600,
});
this.staged = metadata;
await this.diagnostics?.info('updates.downloaded', {
await this.diagnostics?.info("updates.downloaded", {
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
bytes: archive.length,
sha256
sha256,
});
return { ...metadata, downloaded: true };
}
async apply(staged = null) {
const update = staged?.archivePath ? staged : this.staged;
if (!update?.archivePath) throw new Error('Download an update before applying it.');
if (this.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
async downloadPackaged(update) {
if (this.platform !== "win32")
throw new Error("Packaged auto-update currently supports Windows only.");
const release =
(await this.gitea.getReleaseByTag(
update.owner,
update.repo,
`v${update.remoteVersion}`,
)) ||
(await this.gitea.getReleaseByTag(
update.owner,
update.repo,
update.remoteVersion,
));
if (!release || release.draft || release.prerelease) {
const error = new Error(
`ForgeFlow ${update.remoteVersion} has no published binary release yet.`,
);
error.code = "BINARY_RELEASE_NOT_FOUND";
throw error;
}
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
const portable = Boolean(this.appInfo.portableExecutablePath);
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
const checksumName = `${assetName}.sha256`;
const assets = Array.isArray(release.assets) ? release.assets : [];
const asset = assets.find((item) => item.name === assetName);
const checksumAsset = assets.find((item) => item.name === checksumName);
if (!asset?.browser_download_url || !checksumAsset?.browser_download_url) {
const error = new Error(
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
);
error.code = "BINARY_RELEASE_INCOMPLETE";
throw error;
}
const [binary, checksumBytes] = await Promise.all([
this.gitea.downloadAuthenticated(asset.browser_download_url),
this.gitea.downloadAuthenticated(checksumAsset.browser_download_url),
]);
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
throw new Error(
"The downloaded Windows update is not a valid executable.",
);
}
const expectedSha256 = checksumBytes
.toString("utf8")
.trim()
.split(/\s+/)[0]
?.toLowerCase();
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
throw new Error("The release SHA-256 file is invalid.");
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
if (sha256 !== expectedSha256)
throw new Error(
"The downloaded Windows update failed SHA-256 verification.",
);
await fs.mkdir(this.updateDirectory, { recursive: true });
const binaryPath = path.join(this.updateDirectory, assetName);
await fs.writeFile(binaryPath, binary, { mode: 0o600 });
const metadata = {
...update,
kind: "binary",
binaryPath,
assetName,
sha256,
portable,
executablePath: portable
? this.appInfo.portableExecutablePath
: this.appInfo.executablePath,
releaseTag: release.tag_name,
downloadedAt: new Date().toISOString(),
downloaded: true,
};
await fs.writeFile(
`${binaryPath}.json`,
JSON.stringify(metadata, null, 2),
{ mode: 0o600 },
);
this.staged = metadata;
await this.diagnostics?.info("updates.binary-downloaded", {
remoteVersion: update.remoteVersion,
assetName,
bytes: binary.length,
sha256,
portable,
});
return metadata;
}
async apply(staged = null) {
const update =
staged?.archivePath || staged?.binaryPath ? staged : this.staged;
if (!update?.archivePath && !update?.binaryPath)
throw new Error("Download an update before applying it.");
if (this.platform !== "win32")
throw new Error(
"The integrated updater currently supports Windows only.",
);
if (update.kind === "binary") return this.applyPackaged(update);
const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile())
throw new Error("The staged update archive is no longer available.");
const scriptPath = path.join(
this.sourcePath,
"scripts",
"apply-source-update.ps1",
);
const scriptStat = await fs.stat(scriptPath).catch(() => null);
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
if (!scriptStat?.isFile())
throw new Error("The source update helper is missing.");
await fs.mkdir(this.updateDirectory, { recursive: true });
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
const logPath = path.join(this.updateDirectory, `apply-${updateId}.log`);
const statusPath = path.join(this.updateDirectory, `apply-${updateId}.status.json`);
const statusPath = path.join(
this.updateDirectory,
`apply-${updateId}.status.json`,
);
const launching = {
schemaVersion: 1,
updateId,
state: 'launching',
state: "launching",
expectedVersion: update.remoteVersion,
sourcePath: this.sourcePath,
logPath,
statusPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
updatedAt: new Date().toISOString(),
};
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), { mode: 0o600 });
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), {
mode: 0o600,
});
const executable = this.powershellPath || resolveWindowsPowerShellPath();
const args = [
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
'-SourcePath', this.sourcePath,
'-ArchivePath', update.archivePath,
'-ExpectedVersion', update.remoteVersion,
'-ExpectedSha256', update.sha256,
'-ParentPid', String(process.pid),
'-LogPath', logPath,
'-StatusPath', statusPath,
'-UpdateId', updateId
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
"-SourcePath",
this.sourcePath,
"-ArchivePath",
update.archivePath,
"-ExpectedVersion",
update.remoteVersion,
"-ExpectedSha256",
update.sha256,
"-ParentPid",
String(process.pid),
"-LogPath",
logPath,
"-StatusPath",
statusPath,
"-UpdateId",
updateId,
];
const childState = { exited: false, code: null, error: null };
@@ -226,17 +431,22 @@ class UpdateService {
try {
child = this.spawnProcess(executable, args, {
detached: true,
stdio: 'ignore',
stdio: "ignore",
windowsHide: true,
cwd: this.sourcePath
cwd: this.sourcePath,
});
} catch (error) {
error.code ||= 'UPDATE_HELPER_SPAWN_FAILED';
error.code ||= "UPDATE_HELPER_SPAWN_FAILED";
throw error;
}
child.once?.('error', (error) => { childState.error = error; });
child.once?.('exit', (code) => { childState.exited = true; childState.code = code; });
child.once?.("error", (error) => {
childState.error = error;
});
child.once?.("exit", (code) => {
childState.exited = true;
childState.code = code;
});
await new Promise((resolve, reject) => {
let settled = false;
const finish = (handler, value) => {
@@ -245,9 +455,19 @@ class UpdateService {
clearTimeout(timer);
handler(value);
};
const timer = setTimeout(() => finish(reject, Object.assign(new Error('Windows did not start the update helper process.'), { code: 'UPDATE_HELPER_SPAWN_TIMEOUT' })), 5000);
child.once?.('spawn', () => finish(resolve));
child.once?.('error', (error) => finish(reject, error));
const timer = setTimeout(
() =>
finish(
reject,
Object.assign(
new Error("Windows did not start the update helper process."),
{ code: "UPDATE_HELPER_SPAWN_TIMEOUT" },
),
),
5000,
);
child.once?.("spawn", () => finish(resolve));
child.once?.("error", (error) => finish(reject, error));
if (!child.once) finish(resolve);
});
@@ -256,28 +476,171 @@ class UpdateService {
pollMs: this.handshakePollMs,
childState,
expectedUpdateId: updateId,
logPath
logPath,
});
child.unref?.();
await this.diagnostics?.info('updates.apply-started', {
await this.diagnostics?.info("updates.apply-started", {
updateId,
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
logPath,
statusPath,
helperPid: child.pid,
helperState: started.state
helperState: started.state,
});
return { launched: true, confirmed: true, updateId, version: update.remoteVersion, logPath, statusPath };
return {
launched: true,
confirmed: true,
updateId,
version: update.remoteVersion,
logPath,
statusPath,
};
}
async applyPackaged(update) {
const stat = await fs.stat(update.binaryPath).catch(() => null);
if (!stat?.isFile())
throw new Error("The staged Windows update is no longer available.");
const actualSha256 = crypto
.createHash("sha256")
.update(await fs.readFile(update.binaryPath))
.digest("hex");
if (actualSha256 !== update.sha256)
throw new Error(
"The staged Windows update failed its final SHA-256 check.",
);
const helperRoot = this.sourcePath.toLowerCase().endsWith("app.asar")
? `${this.sourcePath}.unpacked`
: this.sourcePath;
const scriptPath = path.join(
helperRoot,
"scripts",
"apply-binary-update.ps1",
);
if (!(await fs.stat(scriptPath).catch(() => null))?.isFile())
throw new Error("The binary update helper is missing.");
await fs.mkdir(this.updateDirectory, { recursive: true });
const updateId = `${Date.now()}-${crypto.randomUUID()}`;
const logPath = path.join(this.updateDirectory, `binary-${updateId}.log`);
const statusPath = path.join(
this.updateDirectory,
`binary-${updateId}.status.json`,
);
const launching = {
schemaVersion: 1,
updateId,
state: "launching",
expectedVersion: update.remoteVersion,
logPath,
statusPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await fs.writeFile(statusPath, JSON.stringify(launching, null, 2), {
mode: 0o600,
});
const executable = this.powershellPath || resolveWindowsPowerShellPath();
const args = [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
"-BinaryPath",
update.binaryPath,
"-ExpectedSha256",
update.sha256,
"-ExpectedVersion",
update.remoteVersion,
"-CurrentExecutable",
update.executablePath || this.appInfo.executablePath,
"-Portable",
String(Boolean(update.portable)),
"-ParentPid",
String(process.pid),
"-LogPath",
logPath,
"-StatusPath",
statusPath,
"-UpdateId",
updateId,
];
const child = this.spawnProcess(executable, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
cwd: this.updateDirectory,
});
const childState = { exited: false, code: null, error: null };
child.once?.("error", (error) => {
childState.error = error;
});
child.once?.("exit", (code) => {
childState.exited = true;
childState.code = code;
});
await new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
Object.assign(
new Error("Windows did not start the binary update helper."),
{ code: "UPDATE_HELPER_SPAWN_TIMEOUT" },
),
),
5000,
);
child.once?.("spawn", () => {
clearTimeout(timer);
resolve();
});
child.once?.("error", (error) => {
clearTimeout(timer);
reject(error);
});
if (!child.once) {
clearTimeout(timer);
resolve();
}
});
const started = await waitForUpdaterStarted(statusPath, {
timeoutMs: this.handshakeTimeoutMs,
pollMs: this.handshakePollMs,
childState,
expectedUpdateId: updateId,
logPath,
});
child.unref?.();
await this.diagnostics?.info("updates.binary-apply-started", {
updateId,
remoteVersion: update.remoteVersion,
assetName: update.assetName,
helperState: started.state,
});
return {
launched: true,
confirmed: true,
updateId,
version: update.remoteVersion,
logPath,
statusPath,
};
}
async consumeLatestResult() {
await fs.mkdir(this.updateDirectory, { recursive: true });
const entries = await fs.readdir(this.updateDirectory, { withFileTypes: true }).catch(() => []);
const entries = await fs
.readdir(this.updateDirectory, { withFileTypes: true })
.catch(() => []);
const candidates = [];
for (const entry of entries) {
if (!entry.isFile() || !/^apply-.*\.status\.json$/i.test(entry.name)) continue;
if (!entry.isFile() || !/^(?:apply|binary)-.*\.status\.json$/i.test(entry.name))
continue;
const filePath = path.join(this.updateDirectory, entry.name);
const stat = await fs.stat(filePath).catch(() => null);
if (stat) candidates.push({ filePath, mtimeMs: stat.mtimeMs });
@@ -285,17 +648,24 @@ class UpdateService {
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
for (const candidate of candidates) {
const status = await readJsonFile(candidate.filePath);
if (!status || status.acknowledgedAt || !['success', 'rolled-back', 'failed'].includes(status.state)) continue;
if (
!status ||
status.acknowledgedAt ||
!["success", "rolled-back", "failed"].includes(status.state)
)
continue;
status.acknowledgedAt = new Date().toISOString();
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), { mode: 0o600 });
await fs.writeFile(candidate.filePath, JSON.stringify(status, null, 2), {
mode: 0o600,
});
return {
state: status.state,
expectedVersion: status.expectedVersion || null,
installedVersion: status.installedVersion || null,
message: status.message || '',
message: status.message || "",
logPath: status.logPath || null,
restartLaunched: Boolean(status.restartLaunched),
completedAt: status.completedAt || status.updatedAt || null
completedAt: status.completedAt || status.updatedAt || null,
};
}
return null;
@@ -308,5 +678,5 @@ module.exports = {
resolveWindowsPowerShellPath,
waitForUpdaterStarted,
readJsonFile,
readLogTail
readLogTail,
};
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -564,7 +564,7 @@
await wait(80);
snapshot();
return {
appVersion: "0.8.1-demo",
appVersion: "0.8.2-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
+346 -108
View File
@@ -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}`,
);
}
});