Release ForgeFlow 0.6.0

This commit is contained in:
NuklearRabbit
2026-07-25 05:59:07 +02:00
parent cf1f67a823
commit 9d3933c878
48 changed files with 2208 additions and 466 deletions
+165 -15
View File
@@ -1,6 +1,7 @@
'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');
@@ -12,14 +13,73 @@ function safeRepositoryPart(value, label) {
return text;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, 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');
if (fsSync.existsSync(absolute)) return absolute;
}
return 'powershell.exe';
}
async function readJsonFile(filePath) {
try { return JSON.parse(await fs.readFile(filePath, 'utf8')); }
catch { return null; }
}
async function waitForUpdaterStarted(statusPath, {
timeoutMs = 12000,
pollMs = 100,
childState = null
} = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const status = await readJsonFile(statusPath);
if (status && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) {
return status;
}
if (childState?.error) throw childState.error;
if (childState?.exited) {
const error = new Error(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`);
error.code = 'UPDATE_HELPER_EXITED_EARLY';
throw error;
}
await delay(pollMs);
}
const error = new Error('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.');
error.code = 'UPDATE_HELPER_START_TIMEOUT';
throw error;
}
class UpdateService {
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
constructor({
store,
gitea,
diagnostics,
appInfo,
sourcePath,
userDataPath,
platform = process.platform,
spawnProcess = spawn,
powershellPath = null,
handshakeTimeoutMs = 12000,
handshakePollMs = 100
}) {
this.store = store;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.appInfo = appInfo;
this.sourcePath = sourcePath;
this.updateDirectory = path.join(userDataPath, 'updates');
this.platform = platform;
this.spawnProcess = spawnProcess;
this.powershellPath = powershellPath;
this.handshakeTimeoutMs = handshakeTimeoutMs;
this.handshakePollMs = handshakePollMs;
this.staged = null;
}
@@ -99,37 +159,127 @@ class UpdateService {
async apply(staged = null) {
const update = staged?.archivePath ? staged : this.staged;
if (!update?.archivePath) throw new Error('Download an update before applying it.');
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
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.');
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.');
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
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 launching = {
schemaVersion: 1,
updateId,
state: 'launching',
expectedVersion: update.remoteVersion,
sourcePath: this.sourcePath,
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 = [
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
'-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
'-LogPath', logPath,
'-StatusPath', statusPath,
'-UpdateId', updateId
];
const child = spawn('powershell.exe', args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
cwd: this.sourcePath
const childState = { exited: false, code: null, error: null };
let child;
try {
child = this.spawnProcess(executable, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
cwd: this.sourcePath
});
} catch (error) {
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; });
await new Promise((resolve, reject) => {
let settled = false;
const finish = (handler, value) => {
if (settled) return;
settled = true;
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));
if (!child.once) finish(resolve);
});
child.unref();
await this.diagnostics?.info('updates.apply-launched', {
child.unref?.();
const started = await waitForUpdaterStarted(statusPath, {
timeoutMs: this.handshakeTimeoutMs,
pollMs: this.handshakePollMs,
childState
});
await this.diagnostics?.info('updates.apply-started', {
updateId,
remoteVersion: update.remoteVersion,
remoteSha: update.remoteSha,
logPath
logPath,
statusPath,
helperPid: child.pid,
helperState: started.state
});
return { launched: true, version: update.remoteVersion, logPath };
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 candidates = [];
for (const entry of entries) {
if (!entry.isFile() || !/^apply-.*\.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 });
}
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;
status.acknowledgedAt = new Date().toISOString();
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 || '',
logPath: status.logPath || null,
restartLaunched: Boolean(status.restartLaunched),
completedAt: status.completedAt || status.updatedAt || null
};
}
return null;
}
}
module.exports = { UpdateService, safeRepositoryPart };
module.exports = {
UpdateService,
safeRepositoryPart,
resolveWindowsPowerShellPath,
waitForUpdaterStarted,
readJsonFile
};