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