Pin the Node toolchain, remove runtime npm repair, make CI and E2E truthful
Template tests / tests (pull_request) Failing after 1m26s

Phase 0 of the improvement plan (ai_prompts/prompt4.md): make the baseline
reproducible and stop the test runner from masking real failures.

- Pin Node >= 22.12 (engines + .nvmrc + engine-strict); every entry point
  fails fast with clear guidance instead of dying late with ERR_REQUIRE_ESM
  inside the packaging dependency graph.
- electron-launcher.js is diagnostics-only: all runtime npm install/rebuild/
  repair paths are removed. npm ci on the pinned toolchain is the only
  supported install path (README + GETTING_STARTED updated).
- Refuse to silently launch unsandboxed on Linux: --no-sandbox now requires
  an explicit STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX opt-in and
  is otherwise a hard error with actionable fixes; user-namespace sandboxing
  is detected and preferred.
- Click-capture E2E no longer converts startup crashes into "SKIPPED": the
  only allowed skip is the upfront absence of a display server. A missing
  shared library or crash now fails with the startup log. Same guard added
  to the startup smoke check.
- GitHub CI: run on pull_request, pin Node from .nvmrc, drop the macOS matrix
  entry (not a support target), and audit production and full dependency
  trees as separate signals. Gitea CI: pull_request trigger + pinned Node.
- Refresh package-lock on Node 22/npm 10 and remediate the form-data and
  undici advisories (npm audit: 0 vulnerabilities, prod and full tree).
- Stop tracking generated machine-specific build reports
  (build/build_report.md, build/artifacts_manifest.json).

Verified: 203 unit tests pass; repo-structure, startup-smoke, unit-workflows,
sample-artifacts, and build-release checks pass locally with a real Electron
launch. The click self-test now truthfully reports the pre-existing Linux
arm/debounce capture failures (also red on Gitea CI main run 177) instead of
hiding behind SKIPPED; that defect is scheduled for the capture-fix PR.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-03 13:09:51 -07:00
co-authored by Claude Fable 5
parent 534a28ece8
commit 0f966a5fd0
19 changed files with 704 additions and 595 deletions
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Hard prerequisite check for the supported Node toolchain.
//
// The locked dependency graph (notably the electron-builder packaging
// toolchain) requires Node >= 22.12. Older Nodes fail late with confusing
// errors (ERR_REQUIRE_ESM deep inside dependencies) instead of a clear
// message, so every entry point calls this first.
const fs = require('node:fs');
const path = require('node:path');
function parseVersion(version) {
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(a, b) {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function requiredNodeVersion(projectRoot = path.join(__dirname, '..')) {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
const range = pkg.engines && pkg.engines.node ? String(pkg.engines.node) : null;
if (!range) return null;
const match = /(\d+\.\d+\.\d+)/.exec(range);
return match ? match[1] : null;
}
function checkNodeVersion({
currentVersion = process.versions.node,
projectRoot = path.join(__dirname, '..'),
} = {}) {
const required = requiredNodeVersion(projectRoot);
if (!required) return { ok: true, required: null, current: currentVersion };
const current = parseVersion(currentVersion);
const minimum = parseVersion(required);
if (!current || !minimum) return { ok: true, required, current: currentVersion };
return {
ok: compareVersions(current, minimum) >= 0,
required,
current: currentVersion,
};
}
function assertSupportedNode(options = {}) {
const result = checkNodeVersion(options);
if (result.ok) return result;
const message = [
`StepForge requires Node ${result.required} or newer; this is Node ${result.current}.`,
'',
'Install the pinned toolchain (see .nvmrc) and reinstall dependencies:',
'',
' nvm install && nvm use # or install Node 22 LTS another way',
' npm ci',
'',
'Older Nodes fail unpredictably inside the packaging dependency graph,',
'so this check stops early instead.',
].join('\n');
const error = new Error(message);
error.code = 'STEPFORGE_UNSUPPORTED_NODE';
throw error;
}
module.exports = {
assertSupportedNode,
checkNodeVersion,
compareVersions,
parseVersion,
requiredNodeVersion,
};
+93 -185
View File
@@ -1,6 +1,14 @@
'use strict';
const { spawnSync } = require('node:child_process');
// Diagnostics-only Electron launcher helpers.
//
// This module never installs, rebuilds, or repairs dependencies at runtime.
// The only supported dependency installation path is `npm ci` on the pinned
// Node toolchain (see .nvmrc / package.json engines). A desktop launcher that
// mutates node_modules silently drifts away from package-lock.json and can
// download code at runtime; when the runtime is missing we fail with
// actionable diagnostics instead.
const fs = require('node:fs');
const path = require('node:path');
@@ -60,24 +68,83 @@ function sanitizeElectronEnv(baseEnv = process.env) {
return env;
}
// True only when the caller has explicitly marked this as a development or
// CI environment where launching without the Chromium sandbox is acceptable.
function noSandboxExplicitlyAllowed(env = process.env) {
return env.STEPFORGE_ALLOW_NO_SANDBOX === '1' || env.ELECTRON_DISABLE_SANDBOX === '1';
}
function sandboxHelperUsable(electronPath, statSync = fs.statSync) {
if (!electronPath) return false;
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox');
try {
const stat = statSync(helperPath);
return stat.uid === 0 && Boolean(stat.mode & 0o4000);
} catch {
return false;
}
}
// Decide how to launch on Linux with respect to the Chromium sandbox.
// { args: [] } sandbox is available, launch normally
// { args: ['--no-sandbox'] } explicitly allowed dev/CI launch
// throws sandbox unavailable and not explicitly
// allowed: refuse to normalize an
// unsandboxed launch, explain how to fix it
function linuxSandboxLaunchArgs({
electronPath,
platform = process.platform,
statSync = fs.statSync,
env = process.env,
userNamespaces = userNamespacesAvailable,
} = {}) {
if (platform !== 'linux') return [];
if (!electronPath) return ['--no-sandbox'];
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox');
// Modern kernels with unprivileged user namespaces do not need the setuid
// helper; Chromium falls back to the namespace sandbox on its own. The
// setuid helper check below covers kernels where that is disabled.
if (sandboxHelperUsable(electronPath, statSync)) return [];
if (userNamespaces()) return [];
if (noSandboxExplicitlyAllowed(env)) return ['--no-sandbox'];
const helperPath = electronPath
? path.join(path.dirname(electronPath), 'chrome-sandbox')
: '<node_modules/electron/dist>/chrome-sandbox';
throw new Error(
[
'The Chromium sandbox is not available on this system, and StepForge',
'refuses to silently launch unsandboxed.',
'',
'Fix one of the following:',
` 1. Make the setuid sandbox helper usable:`,
` sudo chown root:root "${helperPath}"`,
` sudo chmod 4755 "${helperPath}"`,
' 2. Enable unprivileged user namespaces (kernel/sysctl dependent):',
' sudo sysctl -w kernel.unprivileged_userns_clone=1',
'',
'For development or CI only, you may explicitly opt in to an',
'unsandboxed launch with STEPFORGE_ALLOW_NO_SANDBOX=1.',
].join('\n')
);
}
function userNamespacesAvailable() {
try {
const stat = statSync(helperPath);
const ownedByRoot = stat.uid === 0;
const hasSetuid = Boolean(stat.mode & 0o4000);
if (ownedByRoot && hasSetuid) return [];
// Debian/Ubuntu specific knob; absent elsewhere (treated as enabled).
const knob = '/proc/sys/kernel/unprivileged_userns_clone';
if (fs.existsSync(knob)) {
return fs.readFileSync(knob, 'utf8').trim() === '1';
}
// Ubuntu 23.10+ AppArmor restriction on unprivileged user namespaces.
const apparmorKnob = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
if (fs.existsSync(apparmorKnob)) {
return fs.readFileSync(apparmorKnob, 'utf8').trim() === '0';
}
return fs.existsSync('/proc/self/ns/user');
} catch {
// Missing or unreadable helper: fall back to the unsandboxed launcher.
return false;
}
return ['--no-sandbox'];
}
function electronBinaryCandidates({ packageRoot, distDir, platform }) {
@@ -95,118 +162,21 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
return candidatePaths;
}
function runNpmCommand({
packageRoot,
npmArgs,
errorLabel,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
if (!npmExecPath) {
return false;
}
const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
cwd: packageRoot,
env: sanitizeElectronEnv(),
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.signal) {
throw new Error(`${errorLabel} was interrupted by ${result.signal}`);
}
if (result.status !== 0) {
throw new Error(`${errorLabel} failed with exit code ${result.status ?? 1}`);
}
return true;
}
function runNpmRebuild({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
return runNpmCommand({
packageRoot,
npmArgs: ['rebuild', 'electron', '--force', '--foreground-scripts'],
errorLabel: 'Electron rebuild',
npmExecPath,
npmNodeExecPath,
});
}
function runNpmInstall({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
return runNpmCommand({
packageRoot,
npmArgs: [
'install',
'--include=dev',
'--ignore-scripts=false',
'--foreground-scripts',
'--no-audit',
'--no-fund',
'--package-lock=false',
],
errorLabel: 'Electron dependency install',
npmExecPath,
npmNodeExecPath,
});
}
function repairElectronInstall({
packageRoot,
}) {
const installScript = path.join(packageRoot, 'install.js');
if (!fs.existsSync(installScript)) {
return false;
}
const result = spawnSync(process.execPath, [installScript], {
cwd: packageRoot,
env: sanitizeElectronEnv(),
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.signal) {
throw new Error(`Electron repair was interrupted by ${result.signal}`);
}
if (result.status !== 0) {
throw new Error(`Electron repair failed with exit code ${result.status ?? 1}`);
}
return true;
}
function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
const tried = candidatePaths.map((candidate) => ` - ${candidate}`).join('\n');
const tried = (candidatePaths || []).map((candidate) => ` - ${candidate}`).join('\n');
return [
'Electron could not be started because the desktop runtime is missing.',
'',
`Looked under: ${packageRoot}`,
`Expected the binary in: ${distDir}`,
`Looked under: ${packageRoot || '(electron package not installed)'}`,
`Expected the binary in: ${distDir || '(unknown)'}`,
'',
'Try reinstalling dependencies from the repo root:',
'StepForge never installs dependencies at runtime. Reinstall them from',
'the repo root on the pinned Node toolchain (see .nvmrc):',
'',
' npm install',
' npm rebuild electron --force --foreground-scripts',
' make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set',
' npm ci',
'',
'If that does not help, delete node_modules/electron and install again.',
'Make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set while installing.',
'If the problem persists, delete node_modules entirely and run npm ci again.',
'',
'Searched:',
tried,
@@ -219,99 +189,37 @@ function resolveElectronBinary({
platform = process.platform,
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
} = {}) {
const repairErrors = [];
function resolveCurrentPackageRoot() {
if (packageRoot) return packageRoot;
if (!packageRoot) {
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
packageRoot = conventionalRoot;
return packageRoot;
}
packageRoot = resolveElectronPackageRoot();
return packageRoot;
}
function tryRepair(label, repairFn) {
try {
if (!repairFn()) {
return null;
}
} catch (error) {
repairErrors.push(`${label}: ${error && error.message ? error.message : String(error)}`);
return null;
}
const currentPackageRoot = resolveCurrentPackageRoot();
if (!currentPackageRoot && !overrideDistPath) {
return null;
}
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
return electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
}
let currentPackageRoot = resolveCurrentPackageRoot();
if (!currentPackageRoot && !overrideDistPath) {
const installed = tryRepair('Electron dependency install', () =>
runNpmInstall({ packageRoot: projectRoot })
);
if (installed) {
return installed;
}
currentPackageRoot = resolveCurrentPackageRoot();
}
if (!currentPackageRoot && !overrideDistPath) {
if (!packageRoot && !overrideDistPath) {
throw new Error(
'Electron could not be started because node_modules/electron is not installed.\n\n' +
'Run `npm install` from the repo root, then try `npm start` again.'
'StepForge never installs dependencies at runtime. Run `npm ci` from the\n' +
'repo root on the pinned Node toolchain (see .nvmrc), then try again.'
);
}
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
const distDir = overrideDistPath || path.join(packageRoot, 'dist');
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
if (resolved) {
return resolved;
}
const repairAttempts = [
['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })],
['Electron install repair', () => repairElectronInstall({ packageRoot: currentPackageRoot })],
['Electron dependency install', () => runNpmInstall({ packageRoot: projectRoot })],
];
for (const [label, repairFn] of repairAttempts) {
const repaired = tryRepair(label, repairFn);
if (repaired) {
return repaired;
}
}
throw new Error(
buildMissingElectronError({
packageRoot: currentPackageRoot,
distDir,
candidatePaths,
}) +
(repairErrors.length
? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}`
: '')
);
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
}
module.exports = {
buildMissingElectronError,
electronBinaryCandidates,
readElectronPathHint,
repairElectronInstall,
runNpmRebuild,
runNpmInstall,
sanitizeElectronEnv,
noSandboxExplicitlyAllowed,
linuxSandboxLaunchArgs,
resolveElectronBinary,
resolveElectronPackageRoot,
+9
View File
@@ -4,6 +4,15 @@ const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { assertSupportedNode } = require('./check-node-version');
try {
assertSupportedNode();
} catch (error) {
console.error(error.message);
process.exit(1);
}
function collectTestFiles(rootDir) {
const files = [];
if (!fs.existsSync(rootDir)) return files;
+8 -2
View File
@@ -3,6 +3,7 @@
const { spawn } = require('node:child_process');
const { assertSupportedNode } = require('./check-node-version');
const {
linuxSandboxLaunchArgs,
resolveElectronBinary,
@@ -10,16 +11,21 @@ const {
} = require('./electron-launcher');
let electronPath;
let sandboxArgs;
try {
assertSupportedNode();
electronPath = resolveElectronBinary();
sandboxArgs = linuxSandboxLaunchArgs({ electronPath });
} catch (error) {
console.error(error && error.message ? error.message : error);
process.exit(1);
}
const env = sanitizeElectronEnv();
const sandboxArgs = linuxSandboxLaunchArgs({ electronPath });
if (sandboxArgs.includes('--no-sandbox')) {
console.warn('[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox');
console.warn(
'[stepforge] launching WITHOUT the Chromium sandbox (explicitly allowed via ' +
'STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX — development/CI only)'
);
}
// On Linux, prefer the native Ozone path when available and enable PipeWire-