Skip to content

Commit 5afbba5

Browse files
authored
fix: guard showcase installs from incompatible Node versions (#77)
1 parent eae08c1 commit 5afbba5

3 files changed

Lines changed: 62 additions & 7 deletions

File tree

lynxtron-go/src/main/desktop/preload-showcase-service.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import os from 'os';
44
import path from 'path';
55
import {
66
buildShowcaseInstallEnv,
7+
formatShowcaseInstallNodeCompatibilityError,
78
getShowcaseDependencyStatus as computeShowcaseDependencyStatus,
89
getShowcaseTargets,
910
hasShowcaseScript,
1011
hasShowcaseSourceChangesSinceBuild,
1112
hasShowcaseWebSourceChangesSinceBuild,
1213
isShowcaseWebBuilt,
14+
isNodeVersionSatisfied,
15+
SHOWCASE_INSTALL_NODE_RANGE,
1316
} from './showcase-install';
1417
import { readInstallState, writeInstallState } from './preload-config-store';
1518
import type { DebugLogger } from './preload-log';
@@ -202,6 +205,29 @@ function getShowcaseDependencyStatus(showcasePath: string, dbg: DebugLogger) {
202205
return status;
203206
}
204207

208+
function readInstallNodeVersion(env: NodeJS.ProcessEnv): string | null {
209+
const nodeCommand = process.platform === 'win32' ? 'node.exe' : 'node';
210+
try {
211+
return execFileSync(nodeCommand, ['--version'], {
212+
env,
213+
encoding: 'utf-8',
214+
stdio: ['ignore', 'pipe', 'ignore'],
215+
}).trim().replace(/^v/i, '') || null;
216+
} catch {
217+
return null;
218+
}
219+
}
220+
221+
function assertCompatibleInstallNode(env: NodeJS.ProcessEnv) {
222+
const nodeVersion = readInstallNodeVersion(env);
223+
if (!nodeVersion) {
224+
throw new Error('Node.js was not detected. Install Node.js (including npm), then retry.');
225+
}
226+
if (!isNodeVersionSatisfied(nodeVersion, SHOWCASE_INSTALL_NODE_RANGE)) {
227+
throw new Error(formatShowcaseInstallNodeCompatibilityError(nodeVersion));
228+
}
229+
}
230+
205231
async function ensureShowcaseDependencies(
206232
showcasePath: string,
207233
dbg: DebugLogger,
@@ -214,6 +240,8 @@ async function ensureShowcaseDependencies(
214240
}
215241

216242
const commandText = `${status.installPlan.command} ${status.installPlan.args.join(' ')}`;
243+
const installEnv = buildShowcaseInstallEnv(status.installPlan.userConfigPath);
244+
assertCompatibleInstallNode(installEnv);
217245
dbg(
218246
`showcase.install: cwd=${status.installPlan.cwd} reason=${force ? 'forced' : status.reason} command=${commandText}`
219247
+ (status.installPlan.userConfigPath ? ` userconfig=${status.installPlan.userConfigPath}` : '')
@@ -226,7 +254,7 @@ async function ensureShowcaseDependencies(
226254
command: status.installPlan.command,
227255
args: status.installPlan.args,
228256
cwd: status.installPlan.cwd,
229-
env: buildShowcaseInstallEnv(status.installPlan.userConfigPath),
257+
env: installEnv,
230258
outputBuffer,
231259
});
232260
} catch (error: any) {

lynxtron-go/src/main/desktop/showcase-install.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'path';
66
import { afterEach, describe, expect, it } from 'vitest';
77
import {
88
buildShowcaseInstallEnv,
9+
formatShowcaseInstallNodeCompatibilityError,
910
formatNodeVersionRequirementError,
1011
getShowcaseTargets,
1112
getShowcaseDependencyStatus,
@@ -17,6 +18,7 @@ import {
1718
hasShowcaseWebSourceChangesSinceBuild,
1819
isShowcaseWebBuilt,
1920
isNodeVersionSatisfied,
21+
SHOWCASE_INSTALL_NODE_RANGE,
2022
} from './showcase-install';
2123

2224
function writeJson(filePath: string, value: unknown) {
@@ -315,13 +317,28 @@ describe('showcase install helpers', () => {
315317
expect(isNodeVersionSatisfied('23.0.0', '>=22 <23')).toBe(false);
316318
});
317319

320+
it('rejects only Node versions affected by the legacy extract-zip stream regression', () => {
321+
expect(isNodeVersionSatisfied('22.0.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(true);
322+
expect(isNodeVersionSatisfied('24.15.99', SHOWCASE_INSTALL_NODE_RANGE)).toBe(true);
323+
expect(isNodeVersionSatisfied('24.16.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(false);
324+
expect(isNodeVersionSatisfied('24.17.99', SHOWCASE_INSTALL_NODE_RANGE)).toBe(false);
325+
expect(isNodeVersionSatisfied('24.18.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(true);
326+
expect(isNodeVersionSatisfied('25.9.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(true);
327+
expect(isNodeVersionSatisfied('26.0.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(true);
328+
expect(isNodeVersionSatisfied('26.1.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(false);
329+
expect(isNodeVersionSatisfied('26.7.0', SHOWCASE_INSTALL_NODE_RANGE)).toBe(false);
330+
});
331+
332+
it('formats an actionable showcase installer compatibility error', () => {
333+
expect(formatShowcaseInstallNodeCompatibilityError('26.7.0'))
334+
.toBe(`Node.js version 26.7.0 is incompatible with the Lynxtron showcase installer. Use a Node.js version matching ${SHOWCASE_INSTALL_NODE_RANGE}, then retry.`);
335+
});
336+
318337
it('formats a user-facing node version mismatch error', () => {
319-
// npm runs with Lynxtron-as-node — the message must blame the runtime,
320-
// not the system Node install.
321338
expect(formatNodeVersionRequirementError({ range: '>=22', sourceKind: 'engines' }, '20.11.1'))
322-
.toBe("Lynxtron's Node.js version 20.11.1 does not satisfy required version >=22. Update Lynxtron to a build shipping Node 22 or newer.");
339+
.toBe('Node.js version 20.11.1 does not satisfy required version >=22. Update Node to 22 or newer, then retry.');
323340
expect(formatNodeVersionRequirementError({ range: '22', sourceKind: 'nvmrc' }, null))
324-
.toBe("Lynxtron's Node.js runtime was not detected. Reinstall or update Lynxtron and retry.");
341+
.toBe('Node.js was not detected. Install Node 22.x (including npm), then retry.');
325342
});
326343

327344
it('detects saved source changes newer than dist', () => {

lynxtron-go/src/main/desktop/showcase-install.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ export interface NodeVersionRequirement {
3333

3434
export type ShowcaseTarget = 'desktop' | 'web';
3535

36+
// @lynx-js/lynxtron <= 0.0.15 installs its runtime with extract-zip@2.0.1,
37+
// whose yauzl@2.x stream can hang on Node 24.16-24.17 and Node >=26.1.
38+
// Node 24.18 reverted the stream regression; keep the upper bound conservative
39+
// until the Node 26 line ships and verifies the corresponding fix.
40+
export const SHOWCASE_INSTALL_NODE_RANGE = '>=22 <24.16.0 || >=24.18.0 <26.1.0';
41+
3642
const SOURCE_SKIP_DIRS = new Set([
3743
'.git',
3844
'node_modules',
@@ -285,6 +291,10 @@ export function isNodeVersionSatisfied(currentVersion: string, range: string): b
285291
return disjunctions.some(part => tokenizeNodeRange(part).every(token => satisfiesRangeToken(versionParts, token)));
286292
}
287293

294+
export function formatShowcaseInstallNodeCompatibilityError(currentVersion: string): string {
295+
return `Node.js version ${currentVersion} is incompatible with the Lynxtron showcase installer. Use a Node.js version matching ${SHOWCASE_INSTALL_NODE_RANGE}, then retry.`;
296+
}
297+
288298
function describeNodeVersionRange(range: string): string {
289299
const trimmed = range.trim();
290300
const atLeastMatch = trimmed.match(/^>=\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
@@ -307,10 +317,10 @@ export function formatNodeVersionRequirementError(
307317
): string {
308318
const installHint = describeNodeVersionRange(requirement.range);
309319
if (!currentVersion) {
310-
return `Node.js was not detected. Install Node ${installHint} or newer (including npm), then retry.`;
320+
return `Node.js was not detected. Install Node ${installHint} (including npm), then retry.`;
311321
}
312322

313-
return `Node.js version ${currentVersion} does not satisfy required version ${requirement.range}. Update Node to ${installHint} or newer, then retry.`;
323+
return `Node.js version ${currentVersion} does not satisfy required version ${requirement.range}. Update Node to ${installHint}, then retry.`;
314324
}
315325

316326
export function getShowcaseInstallPlan(showcasePath: string): ShowcaseInstallPlan {

0 commit comments

Comments
 (0)