fix(scripts): isMainModule compares real paths of argv and module (#733)

process.argv[1] keeps the path as typed, so a script started through a
symlink (or from a symlinked directory) still carries the link path there,
while Node builds import.meta.url of the main module from the real path.
The two never matched, and every CLI guarded by isMainModule silently did
nothing and exited 0. Both sides are now resolved with realpathSync before
the pathToFileURL comparison; a path that does not exist is compared as is,
without throwing, exactly as before.

The unit test writes a CLI and a module it imports into a temporary
directory, launches the CLI directly, through a directory link (a junction
on Windows, no admin rights needed) and through a file symlink (skipped on
EPERM), and checks that only the launched script runs its main. It is red
on the previous implementation.

Issue: #733
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-30 23:19:47 +00:00
committed by claude[bot]
parent 34423a6236
commit 40607aa37f
2 changed files with 66 additions and 3 deletions
+15 -2
View File
@@ -10,13 +10,26 @@
// экранирования: `node -e "…"` с кавычками и пробелами разваливается. Оболочка
// нужна ТОЛЬКО для `.cmd`/`.bat` (Node ≥ 18.20 иначе бросает EINVAL), то есть
// для `npm` на Windows; `node` и `git` запускаются напрямую.
//
// #733: `process.argv[1]` — путь, КАК его набрали (симлинк остаётся симлинком),
// а `import.meta.url` главного модуля Node строит по реальному пути. Запуск
// через симлинк (или из каталога-симлинка) давал `false`, и CLI молча выходил
// с кодом 0. Поэтому сравниваются реальные пути обеих сторон.
import { pathToFileURL } from 'node:url';
import { realpathSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
/** Реальный путь; для несуществующего — сам путь, как было до #733. */
function realOrSame(path) {
try { return realpathSync(path); } catch { return path; }
}
/** Скрипт запущен как CLI, а не импортирован (переносимая форма). */
export function isMainModule(metaUrl, argv1 = process.argv[1]) {
if (!argv1) return false;
try { return pathToFileURL(argv1).href === metaUrl; } catch { return false; }
try {
return pathToFileURL(realOrSame(argv1)).href === pathToFileURL(realOrSame(fileURLToPath(metaUrl))).href;
} catch { return false; }
}
/** Что и как запускать: `{ cmd, shell }` для `spawn`/`spawnSync`. */
+51 -1
View File
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { isMainModule, portableCommand } from '../scripts/spawn-portable.mjs';
@@ -47,6 +50,53 @@ test('isMainModule сравнивает через pathToFileURL и терпит
assert.equal(isMainModule(import.meta.url, ''), false);
});
test('isMainModule: запуск через симлинк исполняет main, импортированный модуль — нет (#733)', () => {
// argv[1] остаётся путём симлинка, import.meta.url главного модуля — реальный
// путь. До #733 такой запуск молча выходил с кодом 0, ничего не сделав.
const root = mkdtempSync(join(tmpdir(), 'hp-733-main-'));
try {
const real = join(root, 'real');
mkdirSync(real);
const portable = pathToFileURL(fileURLToPath(new URL('../scripts/spawn-portable.mjs', import.meta.url))).href;
const probe = (label) => `import { isMainModule } from ${JSON.stringify(portable)};\n`
+ `if (isMainModule(import.meta.url)) console.log(${JSON.stringify(label)});\n`;
writeFileSync(join(real, 'lib.mjs'), probe('lib-main'));
writeFileSync(join(real, 'cli.mjs'), `import './lib.mjs';\n${probe('cli-main')}`);
// Каталог-симлинк: на Windows это junction, прав администратора он не требует.
const dirLink = join(root, 'dir-link');
symlinkSync(real, dirLink, 'junction');
const launches = [join(real, 'cli.mjs'), join(dirLink, 'cli.mjs')];
// Симлинк на сам файл: на Windows без режима разработчика — EPERM, тогда
// проверяется только каталог-симлинк.
const fileLink = join(root, 'cli-link.mjs');
try {
symlinkSync(join(real, 'cli.mjs'), fileLink, 'file');
launches.push(fileLink);
} catch (error) {
if (error.code !== 'EPERM') throw error;
}
for (const script of launches) {
const run = spawnSync(process.execPath, [script], { encoding: 'utf8' });
assert.equal(run.status, 0, `${script}: ${run.stderr}`);
assert.deepEqual(run.stdout.trim().split(/\r?\n/), ['cli-main'],
`${script}: main исполняется ровно у запущенного скрипта, не у импортированного`);
}
// Та же развилка без дочернего процесса.
const cliUrl = pathToFileURL(join(real, 'cli.mjs')).href;
const libUrl = pathToFileURL(join(real, 'lib.mjs')).href;
assert.equal(isMainModule(cliUrl, join(dirLink, 'cli.mjs')), true);
assert.equal(isMainModule(libUrl, join(dirLink, 'cli.mjs')), false);
// Несуществующий путь — прежнее сравнение как есть, без исключения.
assert.equal(isMainModule(cliUrl, join(root, 'missing.mjs')), false);
assert.equal(isMainModule(pathToFileURL(join(root, 'missing.mjs')).href, join(root, 'missing.mjs')), true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('тесты с временным git-репозиторием изолируют переводы строк от глобального конфига (#496)', () => {
const text = readFileSync(new URL('./rebase-on-dev.test.mjs', import.meta.url), 'utf8');
assert.match(text, /GIT_CONFIG_KEY_0: 'core\.autocrlf', GIT_CONFIG_VALUE_0: 'false'/);