This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const scriptArgument = process.argv[2];
|
||||
|
||||
if (!scriptArgument) {
|
||||
throw new Error('Usage: node scripts/audit-editorial-draft.mjs <revision-script.mjs>');
|
||||
}
|
||||
|
||||
const scriptPath = isAbsolute(scriptArgument)
|
||||
? scriptArgument
|
||||
: resolve(process.cwd(), scriptArgument);
|
||||
const archive = JSON.parse(await readFile(join(webRoot, 'data', 'articles.json'), 'utf8'));
|
||||
const archiveBySlug = new Map(archive.map((article) => [article.slug, article]));
|
||||
const genericPhrases = [
|
||||
'У этой модели нет магической силы',
|
||||
'Материалы для проверки',
|
||||
'Если держать этот порядок, решение остаётся понятным',
|
||||
'В современном мире',
|
||||
'очень важно',
|
||||
'следует отметить',
|
||||
'просто нужно',
|
||||
'нужно понимать, что',
|
||||
];
|
||||
|
||||
function count(content, expression) {
|
||||
return (content.match(expression) || []).length;
|
||||
}
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bodyText(content) {
|
||||
return plainText(
|
||||
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
|
||||
);
|
||||
}
|
||||
|
||||
const cli = await execFileAsync(process.execPath, [scriptPath, '--print-revisions'], {
|
||||
cwd: dirname(scriptPath),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (cli.stderr.trim()) {
|
||||
throw new Error('CLI wrote to stderr: ' + cli.stderr.trim());
|
||||
}
|
||||
|
||||
let cliRevisions;
|
||||
try {
|
||||
cliRevisions = JSON.parse(cli.stdout);
|
||||
} catch (error) {
|
||||
throw new Error('CLI did not print JSON: ' + error.message);
|
||||
}
|
||||
|
||||
const imported = await import(pathToFileURL(scriptPath).href);
|
||||
const revisions = imported.revisions;
|
||||
|
||||
if (!Array.isArray(revisions) || revisions.length !== 3) {
|
||||
throw new Error('Module must export exactly three revisions');
|
||||
}
|
||||
|
||||
if (JSON.stringify(cliRevisions) !== JSON.stringify(revisions)) {
|
||||
throw new Error('CLI revisions do not match the import-safe module export');
|
||||
}
|
||||
|
||||
const seenSlugs = new Set();
|
||||
let failed = false;
|
||||
|
||||
for (const revision of revisions) {
|
||||
const issues = [];
|
||||
const content = revision.contentHtml || '';
|
||||
const body = bodyText(content);
|
||||
const figures = [...content.matchAll(/<figure>([\s\S]*?)<\/figure>/g)].map((match) => match[1]);
|
||||
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
||||
const proseWithoutCode = content
|
||||
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
|
||||
.replace(/<code>[\s\S]*?<\/code>/g, '');
|
||||
|
||||
if (!revision.slug || !archiveBySlug.has(revision.slug)) issues.push('slug отсутствует в базовом архиве');
|
||||
if (seenSlugs.has(revision.slug)) issues.push('slug повторяется внутри пакета');
|
||||
seenSlugs.add(revision.slug);
|
||||
if (Object.hasOwn(revision, 'date') || Object.hasOwn(revision, 'author')) {
|
||||
issues.push('ревизия не должна менять дату или автора');
|
||||
}
|
||||
if (body.length < 5000 || body.length > 15000) issues.push('основной текст вне 5 000–15 000 знаков');
|
||||
if (revision.readingMinutes < 8) issues.push('меньше 8 минут чтения');
|
||||
if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов');
|
||||
if (count(content, /<figure>/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения');
|
||||
if (count(content, /<figcaption>/g) < 1) issues.push('у рисунка нет подписи');
|
||||
if (count(content, /<table>/g) < 1 || count(content, /<thead>/g) < 1) issues.push('нет доступной таблицы');
|
||||
if (count(content, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера');
|
||||
if (count(content, /<ol>/g) < 1) issues.push('нет последовательности действий');
|
||||
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
|
||||
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет точного раздела источников');
|
||||
if (!/(проблем|ошиб|симптом|сбой|задач)/i.test(body.slice(0, 800))) {
|
||||
issues.push('проблема не названа в начале текста');
|
||||
}
|
||||
if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) {
|
||||
issues.push('в тексте есть след генерации');
|
||||
}
|
||||
|
||||
for (const phrase of genericPhrases) {
|
||||
if (content.includes(phrase)) issues.push('шаблонный оборот: «' + phrase + '»');
|
||||
}
|
||||
|
||||
for (const figure of figures) {
|
||||
const image = figure.match(/<img\b[^>]*>/);
|
||||
const alt = image?.[0].match(/\balt="([^"]*)"/);
|
||||
if (!image || !alt || alt[1].trim().length < 12) {
|
||||
issues.push('у рисунка нет содержательного alt-текста');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of imageSources.filter((value) => value.startsWith('/'))) {
|
||||
try {
|
||||
await access(join(webRoot, 'public', source));
|
||||
} catch {
|
||||
issues.push('не найден visual asset: ' + source);
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
failed = true;
|
||||
console.error('FAIL ' + revision.slug + ': ' + issues.join('; '));
|
||||
} else {
|
||||
console.log('PASS ' + revision.slug + ': ' + body.length + ' body chars');
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exitCode = 1;
|
||||
@@ -0,0 +1,425 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const paragraph = (content) => '<p>' + content + '</p>';
|
||||
const heading = (content) => '<h2>' + content + '</h2>';
|
||||
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
|
||||
const figure = (src, alt, caption) => [
|
||||
'<figure>',
|
||||
'<img src="' + src + '" alt="' + alt + '" />',
|
||||
'<figcaption>' + caption + '</figcaption>',
|
||||
'</figure>',
|
||||
].join('');
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||||
const body = rows.map((row) => (
|
||||
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
|
||||
)).join('');
|
||||
|
||||
return '<div class="table-scroll"><table><thead><tr>' + head
|
||||
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return heading('Проверяемые источники') + '<ul>' + items.map(({ label, url }) => (
|
||||
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a></li>'
|
||||
)).join('') + '</ul>';
|
||||
}
|
||||
|
||||
const sources = {
|
||||
environment: {
|
||||
label: 'Microsoft Learn — about_Environment_Variables для Windows PowerShell',
|
||||
url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables?view=powershell-5.1',
|
||||
},
|
||||
processEnvironment: {
|
||||
label: 'Microsoft Learn — Environment Variables (Win32)',
|
||||
url: 'https://learn.microsoft.com/en-us/windows/win32/procthread/environment-variables',
|
||||
},
|
||||
getCommand: {
|
||||
label: 'Microsoft Learn — Get-Command и параметр -All',
|
||||
url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/get-command?view=powershell-5.1',
|
||||
},
|
||||
where: {
|
||||
label: 'Microsoft Learn — команда where',
|
||||
url: 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/where',
|
||||
},
|
||||
chcp: {
|
||||
label: 'Microsoft Learn — команда chcp',
|
||||
url: 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/chcp',
|
||||
},
|
||||
convertToJson: {
|
||||
label: 'Microsoft Learn — ConvertTo-Json',
|
||||
url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/convertto-json?view=powershell-5.1',
|
||||
},
|
||||
compareObject: {
|
||||
label: 'Microsoft Learn — Compare-Object',
|
||||
url: 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/compare-object?view=powershell-5.1',
|
||||
},
|
||||
};
|
||||
|
||||
const practiceArticle = {
|
||||
slug: 'editorial-2018-09-practice-windows-dev-env',
|
||||
title: 'Windows. Как сохранить минимальный снимок окружения проекта',
|
||||
categories: ['Windows', 'Инструменты'],
|
||||
cover: '/assets/editorial/2018/windows-dev-env-snapshot-2018.svg',
|
||||
excerpt: 'Фиксируем то, что реально запускает проект на Windows: порядок PATH, найденные бинарники, версии и кодировку консоли — без копирования всего компьютера и без секретов.',
|
||||
readingMinutes: 11,
|
||||
contentHtml: [
|
||||
paragraph('На одном Windows-компьютере проект собирается, а на другом команда <code>npm run build</code> не находит <code>node</code> или запускает другую версию PHP. Цена такой ошибки — не только потерянный вечер: в спешке легко добавить в <code>PATH</code> случайную папку, переслать коллеге пароль из переменной среды или поставить непонятный архив с интерпретатором.'),
|
||||
paragraph('Здесь один вопрос: <strong>какой минимальный снимок окружения нужен, чтобы другой разработчик увидел тот же запуск проекта?</strong> Не будем архивировать весь диск и делать вид, что любая разница машины важна. Зафиксируем только то, что влияет на поиск команд, их версию и текст, который видит консоль.'),
|
||||
heading('Граница задачи: сохраняю запуск, а не весь компьютер'),
|
||||
paragraph('Окружение процесса — это набор строк, с которым стартует конкретная консоль и её дочерние программы. В Windows есть пользовательский, системный и процессный уровни переменных. Уже открытый PowerShell не обязан получить изменения, сделанные в окне настроек: новая консоль наследует новое значение, старая продолжает работать со своим набором. Поэтому запись «у меня установлен Node» ничего не объясняет, пока не известно, какой <code>node.exe</code> нашёл именно этот процесс.'),
|
||||
paragraph('Минимальный снимок отвечает на четыре проверяемых вопроса: какая версия Windows PowerShell запустила команду; какие папки стоят в <code>PATH</code> и какие расширения допускает <code>PATHEXT</code>; какие кандидаты вернул <code>Get-Command -All</code>; что напечатала сама программа для <code>--version</code>. В отдельной строке оставляю активную кодовую страницу консоли. Этого достаточно для первого сравнения, но в снимок не попадают токены, cookie, пароли, содержимое домашней папки и полный дамп всех переменных.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/windows-dev-env-snapshot-2018.svg',
|
||||
'Схема минимального снимка Windows: проект запускает PowerShell, он передаёт дочерним процессам PATH и PATHEXT, затем фиксируются найденный бинарник, версия и кодовая страница в JSON без секретных переменных.',
|
||||
'Снимок описывает путь запуска команды. Он не является резервной копией компьютера и перед отправкой всё равно требует просмотра.',
|
||||
),
|
||||
heading('Что кладу в файл, а что оставляю на машине'),
|
||||
dataTable(
|
||||
['Поле снимка', 'Как получить', 'Зачем оно нужно', 'Чего в нём не должно быть'],
|
||||
[
|
||||
['Версия PowerShell', '<code>$PSVersionTable.PSVersion</code>', 'От неё зависят доступные команды и формат части вывода', 'Имя пользователя и содержимое профиля'],
|
||||
['Порядок <code>PATH</code>', '<code>$env:Path -split ";"</code>', 'Показывает, какая папка может дать первый бинарник', 'Полный список случайных переменных среды'],
|
||||
['<code>PATHEXT</code>', '<code>$env:PATHEXT</code>', 'Объясняет, какие расширения Windows считает исполняемыми', 'Изменение системного значения ради эксперимента'],
|
||||
['Кандидаты команды', '<code>Get-Command node -All</code>', 'Отделяет alias или функцию от настоящего <code>node.exe</code>', 'Непроверенный вывод из чужого скриншота'],
|
||||
['Версия инструмента', '<code>node --version</code>', 'Связывает найденный путь с фактическим запуском', 'Секреты, переданные приложению аргументами'],
|
||||
['Кодовая страница', '<code>cmd /c chcp</code>', 'Помогает объяснить нечитаемый вывод старой консоли', 'Глобальное переключение кодировки без проверки'],
|
||||
],
|
||||
),
|
||||
heading('Один скрипт для Windows PowerShell 5.1'),
|
||||
paragraph('Ниже не установщик и не «починка» окружения. Он только собирает отчёт рядом с проектом. Список <code>$toolChecks</code> нужно оставить коротким: добавьте туда реальные инструменты проекта, например <code>php</code> и <code>composer</code>, а не все программы из меню Пуск. Команда <code>Get-Command -All</code> намеренно сохраняет все найденные варианты, потому что первый путь без остальных кандидатов часто скрывает причину расхождения.'),
|
||||
codeBlock([
|
||||
'# tools/Capture-Environment.ps1',
|
||||
'param(',
|
||||
" [string]$OutputPath = (Join-Path $PSScriptRoot 'environment-snapshot.json')",
|
||||
')',
|
||||
'',
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'$toolChecks = @(',
|
||||
" [pscustomobject]@{ name = 'node'; arguments = @('--version') },",
|
||||
" [pscustomobject]@{ name = 'npm'; arguments = @('--version') },",
|
||||
" [pscustomobject]@{ name = 'php'; arguments = @('--version') },",
|
||||
" [pscustomobject]@{ name = 'git'; arguments = @('--version') }",
|
||||
')',
|
||||
'',
|
||||
'function Get-CommandCandidates {',
|
||||
' param([string]$Name)',
|
||||
' @(',
|
||||
' Get-Command -Name $Name -All -ErrorAction SilentlyContinue |',
|
||||
' ForEach-Object {',
|
||||
' [ordered]@{',
|
||||
' commandType = $_.CommandType.ToString()',
|
||||
' name = $_.Name',
|
||||
' definition = $_.Definition',
|
||||
' source = $_.Source',
|
||||
' version = if ($_.Version) { $_.Version.ToString() } else { $null }',
|
||||
' }',
|
||||
' }',
|
||||
' )',
|
||||
'}',
|
||||
'',
|
||||
'function Get-VersionOutput {',
|
||||
' param([string]$Name, [string[]]$Arguments)',
|
||||
' if (-not (Get-Command -Name $Name -ErrorAction SilentlyContinue)) {',
|
||||
" return @('NOT FOUND')",
|
||||
' }',
|
||||
' try {',
|
||||
' $lines = @(& $Name @Arguments 2>&1 | Select-Object -First 3 | ForEach-Object { $_.ToString() })',
|
||||
" return @($lines + ('exitCode=' + $LASTEXITCODE))",
|
||||
' } catch {',
|
||||
" return @('FAILED: ' + $_.Exception.Message)",
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'$pathEntries = @($env:Path -split ";" | ForEach-Object { $_.Trim() } | Where-Object { $_ })',
|
||||
'$report = [ordered]@{',
|
||||
' formatVersion = 1',
|
||||
" capturedAt = (Get-Date).ToString('o')",
|
||||
' powershell = [ordered]@{',
|
||||
' version = $PSVersionTable.PSVersion.ToString()',
|
||||
' edition = $PSVersionTable.PSEdition',
|
||||
' }',
|
||||
' console = [ordered]@{',
|
||||
' activeCodePage = ((& cmd.exe /d /c chcp) -join " ").Trim()',
|
||||
' outputEncoding = [Console]::OutputEncoding.WebName',
|
||||
' }',
|
||||
' environment = [ordered]@{',
|
||||
' pathEntries = $pathEntries',
|
||||
' pathext = $env:PATHEXT',
|
||||
' }',
|
||||
' commands = @(',
|
||||
' foreach ($check in $toolChecks) {',
|
||||
' [ordered]@{',
|
||||
' name = $check.name',
|
||||
' candidates = Get-CommandCandidates $check.name',
|
||||
' versionOutput = Get-VersionOutput $check.name $check.arguments',
|
||||
' }',
|
||||
' }',
|
||||
' )',
|
||||
'}',
|
||||
'',
|
||||
'$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8',
|
||||
"Write-Host ('Written: ' + $OutputPath)",
|
||||
].join('\n')),
|
||||
paragraph('В отчёт попадает только заранее выбранный набор полей. Это полезнее, чем <code>Get-ChildItem Env:</code> целиком: там могут оказаться адрес прокси, ключ приложения или служебный путь. Сам <code>PATH</code> тоже способен раскрыть имя локального пользователя и внутренние каталоги. Перед публикацией в задаче или чате открываю JSON, заменяю такие части на нейтральные метки и сохраняю исходник только в закрытом месте.'),
|
||||
heading('Снимаю отчёт в отдельном сеансе и читаю его как данные'),
|
||||
paragraph('Для первой проверки полезен PowerShell без профиля: так случайная функция из <code>$PROFILE</code> не выдаст себя за установленный инструмент. Это не отменяет проверку обычной рабочей консоли. Наоборот, если снимки расходятся, профиль становится одной из гипотез, которую можно подтвердить отдельным запуском.'),
|
||||
codeBlock([
|
||||
'powershell.exe -NoProfile -File .\\tools\\Capture-Environment.ps1',
|
||||
'',
|
||||
'Get-Content .\\tools\\environment-snapshot.json -Raw |',
|
||||
' ConvertFrom-Json |',
|
||||
' Format-List formatVersion, powershell, console, environment, commands',
|
||||
'',
|
||||
'Get-Command node -All |',
|
||||
' Select-Object CommandType, Name, Version, Definition |',
|
||||
' Format-Table -AutoSize',
|
||||
].join('\n')),
|
||||
paragraph('Готовый запуск не обязан вернуть все четыре инструмента. Для проекта на PHP отсутствие <code>node</code> может быть нормальным, а отсутствие <code>php</code> — нет. Важен не список «зелёных» строк, а заранее оговорённый набор. Если <code>versionOutput</code> содержит <code>NOT FOUND</code>, я не дописываю путь в системные настройки наугад: сначала смотрю кандидатов и документацию самого проекта.'),
|
||||
heading('Короткий порядок работы'),
|
||||
orderedList([
|
||||
'В README назвать команды проекта и инструменты, которые им нужны: например, <code>php</code>, <code>composer</code>, <code>node</code> и <code>git</code>.',
|
||||
'Сохранить скрипт в репозитории или рядом с ним, но добавить созданный JSON в ignore, если он содержит локальные пути.',
|
||||
'Запустить снимок из обычной консоли и, при спорном случае, повторить запуск с <code>-NoProfile</code>.',
|
||||
'Проверить для каждого инструмента первый кандидат, остальные кандидаты и фактический вывод <code>--version</code>.',
|
||||
'Перед передачей отчёта убрать имя пользователя, внутренние каталоги и всё, что не требуется для разбора.',
|
||||
'После правки среды открыть новую консоль, снять новый отчёт и сравнить именно изменившиеся строки.',
|
||||
]),
|
||||
heading('Где минимальный снимок не отвечает'),
|
||||
paragraph('Такой файл не доказывает, что зависимости проекта совпадают. Он не заменяет lock-файл, исходный код, права доступа к каталогу, настройки прокси и архитектуру 32/64 bit. Кодовая страница консоли тоже не равна кодировке каждого файла: файл может быть сохранён в другой кодировке, а программа может читать его своим правилом. Если симптом связан с файлами, отдельно проверяю байты файла и настройки конкретного компилятора, а не объявляю <code>chcp</code> единственной причиной.'),
|
||||
paragraph('Не стоит хранить снимок годами как истину. После обновления PHP, Node или Git он честно меняется. Его ценность в другом: у команды есть маленькая запись «что именно запускалось в этот день» и воспроизводимый способ обновить её без переустановки всего компьютера.'),
|
||||
heading('Что считаю готовым'),
|
||||
paragraph('Задача закрыта, когда новый разработчик может открыть JSON и ответить на три вопроса: какая консоль работала, откуда она взяла нужный бинарник и какую версию тот напечатал. Если один из ответов отсутствует, следующий шаг тоже ясен — дополнить снимок конкретным инструментом, а не обмениваться фразой «у меня работает».'),
|
||||
sourceList([sources.environment, sources.getCommand, sources.chcp, sources.convertToJson]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const mechanismArticle = {
|
||||
slug: 'editorial-2018-09-mechanism-windows-dev-env',
|
||||
title: 'Windows. Почему PATH, кодировка и версия бинарника меняют запуск проекта',
|
||||
categories: ['Windows', 'Инструменты'],
|
||||
cover: '/assets/editorial/2018/windows-dev-env-resolution-2018.svg',
|
||||
excerpt: 'Разбираем один механизм: как PowerShell выбирает команду, почему where.exe не заменяет Get-Command -All и где кодовая страница действительно влияет на диагностику.',
|
||||
readingMinutes: 11,
|
||||
contentHtml: [
|
||||
paragraph('В одной консоли <code>node --version</code> показывает ожидаемую версию, в другой — старую, а русское сообщение инструмента превращается в набор знаков. Цена ошибки выше одной неудачной сборки: можно исправить не тот <code>node.exe</code>, сохранить повреждённый текстовый файл или навсегда засорить системный <code>PATH</code> ради разового опыта.'),
|
||||
paragraph('Разберём один вопрос: <strong>как Windows и PowerShell выбирают бинарник и почему рядом с этим выбором приходится проверять кодировку?</strong> Сначала увидим путь команды в текущем процессе, затем отдельно посмотрим кодовую страницу. Это две связанные проверки, но не одна «настройка окружения».'),
|
||||
heading('У процесса свой набор переменных'),
|
||||
paragraph('Переменная среды всегда строка, а дочерний процесс наследует её от родителя. На Windows значения могут существовать в системной, пользовательской и процессной области. Когда мы присваиваем <code>$env:Path</code> в текущем PowerShell, меняется только этот сеанс и программы, запущенные из него. Такой короткий опыт безопаснее постоянной правки: можно поставить папку с нужной версией первой, посмотреть результат и закрыть окно, если гипотеза не подтвердилась.'),
|
||||
paragraph('Из этого следует простой, но важный вывод. Снимок <code>PATH</code> из нового окна и снимок из уже открытой консоли могут различаться без ошибки Windows. Если правка сделана в настройках пользователя или системы, старое приложение не переписывает свой блок переменных само. Для проверки открываю новую консоль и фиксирую время снимка, а не ожидаю, что одна строка из реестра мгновенно объяснит чужой терминал.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/windows-dev-env-resolution-2018.svg',
|
||||
'Схема выбора команды в Windows PowerShell: процесс получает PATH и PATHEXT, Get-Command -All показывает функции, alias и приложения в порядке выбора, where.exe ищет файлы, а кодовая страница проверяется отдельной веткой.',
|
||||
'Путь к бинарнику и способ отображения его сообщения надо проверять отдельно: один отвечает за запуск, второй — за читаемость вывода.',
|
||||
),
|
||||
heading('PATH и PATHEXT отвечают только за часть выбора'),
|
||||
paragraph('<code>PATH</code> — это список каталогов, где система ищет исполняемые файлы. В Windows его элементы разделены точкой с запятой. <code>PATHEXT</code> задаёт расширения, которые считаются исполняемыми, поэтому имя <code>tool</code> может привести к <code>tool.exe</code> или <code>tool.cmd</code>. Но для PowerShell этого мало: перед приложением может оказаться alias, функция, cmdlet или внешний скрипт с тем же именем.'),
|
||||
paragraph('Поэтому <code>where.exe node</code> полезен, но не является окончательным ответом. Он ищет файлы в текущем каталоге и папках <code>PATH</code>; он не покажет функцию <code>node</code>, определённую в профиле PowerShell. <code>Get-Command node -All</code> выводит все варианты в порядке, который PowerShell использует при выборе. В диагностике я запускаю обе команды: первая хорошо показывает физические файлы, вторая — фактическую команду оболочки.'),
|
||||
codeBlock([
|
||||
'$names = @("node", "npm", "php", "git")',
|
||||
'',
|
||||
'foreach ($name in $names) {',
|
||||
' Write-Host ""',
|
||||
' Write-Host ("== " + $name + " ==")',
|
||||
' Get-Command -Name $name -All -ErrorAction SilentlyContinue |',
|
||||
' Select-Object CommandType, Name, Version, Source, Definition |',
|
||||
' Format-Table -AutoSize',
|
||||
'',
|
||||
' Write-Host "-- files found by where.exe --"',
|
||||
' where.exe $name 2>$null',
|
||||
'',
|
||||
' if (Get-Command -Name $name -ErrorAction SilentlyContinue) {',
|
||||
' & $name --version',
|
||||
' Write-Host ("exitCode=" + $LASTEXITCODE)',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n')),
|
||||
paragraph('У этой проверки есть ограничение: аргумент <code>--version</code> подходит не каждой утилите. Для старого проекта список аргументов лучше хранить рядом с проектом, а не использовать универсальный запуск. Если команда является функцией, её вывод ещё не доказывает путь к приложению. В таком случае сначала называю тип кандидата, затем проверяю его определение и решаю, является ли это частью проекта или случайной надстройкой профиля.'),
|
||||
heading('Кодовая страница — отдельный слой'),
|
||||
paragraph('<code>chcp</code> показывает активную кодовую страницу консоли и умеет её менять. Документация Windows отдельно оговаривает, что программы, запущенные после смены страницы, используют новое значение, а уже запущенные программы обычно сохраняют прежнее. Это объясняет частый ложный вывод «я поменял кодировку, но ошибка осталась»: проверка сделана в процессе, который стартовал раньше.'),
|
||||
paragraph('Для разбора не надо немедленно переключать консоль на 65001 или менять системный язык. Сначала записываю три наблюдения: строку <code>chcp</code>, <code>[Console]::OutputEncoding.WebName</code> и байты проблемного файла, если проблема именно в файле. Кодовая страница <code>cmd.exe</code>, настройка вывода .NET и кодировка файла — разные вещи. Совпадение двух из них ничего не гарантирует, а изменение одного не лечит остальные.'),
|
||||
codeBlock([
|
||||
'# Наблюдение, без изменения глобальных настроек.',
|
||||
'& cmd.exe /d /c chcp',
|
||||
'[Console]::OutputEncoding.WebName',
|
||||
'[Text.Encoding]::Default.WebName',
|
||||
'',
|
||||
'# Короткий опыт только в текущем PowerShell.',
|
||||
'$projectTools = Join-Path $PSScriptRoot "tools"',
|
||||
'$env:Path = $projectTools + ";" + $env:Path',
|
||||
'',
|
||||
'Get-Command node -All |',
|
||||
' Select-Object CommandType, Name, Version, Definition |',
|
||||
' Format-Table -AutoSize',
|
||||
'node --version',
|
||||
].join('\n')),
|
||||
paragraph('Последние четыре строки намеренно меняют только процессную область. Если нужный <code>node.exe</code> оказался первым и проект прошёл документированную команду, гипотеза подтверждена. Если нет, закрываю окно — постоянного следа не осталось. Лишь после такой проверки имеет смысл обсуждать установщик, путь в пользовательском <code>PATH</code> или явный путь в скрипте проекта.'),
|
||||
heading('Как не спутать три похожих симптома'),
|
||||
dataTable(
|
||||
['Наблюдение', 'Что оно означает', 'Чем проверить', 'Следующее действие'],
|
||||
[
|
||||
['<code>node --version</code> даёт старую версию', 'Выбран другой кандидат или путь в <code>PATH</code> стоит раньше', '<code>Get-Command node -All</code> и <code>where.exe node</code>', 'Временно поставить нужную папку первой в текущем сеансе и повторить версию'],
|
||||
['<code>where.exe</code> показывает два файла, а PowerShell запускает функцию', 'Физические файлы не описывают приоритет PowerShell', '<code>Get-Command node -All</code> с колонкой <code>CommandType</code>', 'Проверить профиль и не менять <code>PATH</code>, пока функция не исключена'],
|
||||
['Русский вывод нечитаем только в одной консоли', 'Различается кодовая страница или настройка вывода', '<code>chcp</code> и <code>[Console]::OutputEncoding</code>', 'Сравнить новую консоль и конкретный способ записи файла'],
|
||||
['После правки переменной результат прежний', 'Работает старый процесс с унаследованными значениями', 'Снимок из нового PowerShell без профиля', 'Открыть новый сеанс и повторить одну исходную команду'],
|
||||
['Сборка читает верный Node, но падает дальше', 'Причина не в поиске бинарника', 'Lock-файл, лог команды, права и конфигурация проекта', 'Не продолжать править <code>PATH</code>; расследовать следующий симптом'],
|
||||
],
|
||||
),
|
||||
heading('Последовательность проверки'),
|
||||
orderedList([
|
||||
'Зафиксировать одну воспроизводимую команду проекта и её полный текст ошибки, не меняя настройки заранее.',
|
||||
'В той же консоли вывести <code>Get-Command -All</code>, <code>where.exe</code> и <code>--version</code> для нужного инструмента.',
|
||||
'Открыть новый PowerShell с <code>-NoProfile</code> и повторить ровно те же три наблюдения.',
|
||||
'Если расходится путь, добавить нужный каталог только в <code>$env:Path</code> текущего сеанса и проверить исходную команду.',
|
||||
'Если расходится текст, записать <code>chcp</code>, настройку вывода и кодировку конкретного файла, не подменяя одну проверку другой.',
|
||||
'Постоянное изменение делать лишь после того, как временный опыт дал понятный результат и его можно описать в README.',
|
||||
]),
|
||||
heading('Границы такого объяснения'),
|
||||
paragraph('Механизм выбора команды не объясняет всё. Бинарники могут отличаться разрядностью, зависимыми DLL, правами запуска, содержимым каталога или настройками антивируса и прокси. Эти ограничения не повод отключать защиту и начинать с переустановки Windows. Они означают, что после совпадения пути и версии нужно смотреть следующий наблюдаемый факт — лог приложения, разрешения каталога или сетевой запрос.'),
|
||||
paragraph('Так же осторожно отношусь к кодировке. <code>chcp</code> нужен для проверки активной консоли, но не является рецептом «всегда ставить UTF-8». У старых программ и редакторов могут быть свои ожидания. Воспроизводимым считается результат, когда одна и та же программа из новой консоли выдаёт читаемый текст и открывает нужные файлы по документированному правилу.'),
|
||||
heading('Итог механизма'),
|
||||
paragraph('Имя команды не равно конкретному файлу. В Windows PowerShell между ними стоят процессное окружение, порядок <code>PATH</code>, <code>PATHEXT</code> и приоритет alias, функций, cmdlet и приложений. Рядом живёт отдельный слой отображения текста. Когда эти два пути проверены отдельными командами, «на моей машине другая версия» превращается в строку, которую можно сравнить и исправить без гадания.'),
|
||||
sourceList([sources.environment, sources.processEnvironment, sources.getCommand, sources.where, sources.chcp]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const fieldArticle = {
|
||||
slug: 'editorial-2018-09-field-windows-dev-env',
|
||||
title: 'Windows. Разбор «работает на моей машине» без переустановки среды',
|
||||
categories: ['Windows', 'Инструменты'],
|
||||
cover: '/assets/editorial/2018/windows-dev-env-diff-2018.svg',
|
||||
excerpt: 'Полевой маршрут для двух Windows-машин: сравнить снимки, отделить PATH от кодировки и версии бинарника, проверить одну гипотезу в новом сеансе и не стереть полезные доказательства.',
|
||||
readingMinutes: 12,
|
||||
contentHtml: [
|
||||
paragraph('Симптом: коллега присылает фразу «на моей машине работает», а на второй Windows-машине та же команда падает или собирает другой результат. Цена поспешного ответа — потерянный след: переустановка инструмента, очистка каталогов и правка глобального <code>PATH</code> меняют несколько переменных сразу и уже не дают понять, что было причиной.'),
|
||||
paragraph('Разберём один вопрос: <strong>как найти различие между двумя Windows-окружениями, не превращая диагностику в серию случайных установок?</strong> Ниже учебный разбор. Пути и версии в нём условные, а не рассказ о чужом компьютере; важен порядок сбора доказательств и одна обратимая проверка за раз.'),
|
||||
heading('Сначала делаю два запуска сравнимыми'),
|
||||
paragraph('До снимка фиксирую одинаковые входные данные: один commit проекта, одинаковую команду, наличие lock-файла и каталог, из которого она запущена. Если на одной машине уже изменён <code>package-lock.json</code> или добавлены локальные правки, сравнение среды смешается со сравнением проекта. В этом случае сначала сохраняю статус VCS и называю различие, а затем возвращаюсь к окружению.'),
|
||||
paragraph('На обеих машинах запускаю один и тот же <code>Capture-Environment.ps1</code> из предыдущей заметки. Две копии JSON называю <code>machine-a.json</code> и <code>machine-b.json</code>. Перед отправкой в общий канал убираю личные части путей. Нам не нужен полный профиль пользователя; для расследования нужны порядок папок, кандидаты команд, версия PowerShell и признаки консоли.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/windows-dev-env-diff-2018.svg',
|
||||
'Диагностический путь для двух Windows-машин: одинаковая команда и commit, два очищенных снимка окружения, сравнение PATH и кандидатов бинарника, временный опыт в новом PowerShell, затем повтор исходной команды.',
|
||||
'Сравнение не выбирает настройку за человека. Оно сужает вопрос до одного наблюдаемого различия и оставляет обратимый шаг проверки.',
|
||||
),
|
||||
heading('Учебный пример: один репозиторий, два кандидата node'),
|
||||
paragraph('Представим, что на машине A <code>Get-Command node -All</code> первым показывает <code>C:\\Tools\\node-6\\node.exe</code>, а на машине B — <code>C:\\Program Files\\nodejs\\node.exe</code>. В JSON это не просто две строки с разными цифрами. На машине A первая папка из <code>PATH</code> раньше; на машине B этой папки нет. Пока не известно, какую версию требует проект, нельзя объявить один компьютер «правильным» и переносить его путь на второй.'),
|
||||
dataTable(
|
||||
['Поле сравнения', 'Машина A в учебном примере', 'Машина B в учебном примере', 'Что это доказывает'],
|
||||
[
|
||||
['Первый кандидат <code>node</code>', '<code>C:\\Tools\\node-6\\node.exe</code>', '<code>C:\\Program Files\\nodejs\\node.exe</code>', 'PowerShell может запускать разные файлы при одинаковом имени команды'],
|
||||
['Позиция каталога в <code>PATH</code>', '<code>C:\\Tools\\node-6</code> стоит раньше', 'Этого каталога нет', 'Различие объясняет порядок поиска, но ещё не требуемую версию проекта'],
|
||||
['<code>node --version</code>', 'Условно <code>v6.x</code>', 'Условно <code>v8.x</code>', 'Версия должна быть зафиксирована рядом с зависимостями проекта'],
|
||||
['Кодовая страница', 'Например, один текст <code>chcp</code>', 'Другая строка <code>chcp</code>', 'Нечитаемый вывод может потребовать отдельной проверки, но не меняет путь к бинарнику'],
|
||||
['Исходная команда', 'Падает или даёт другой лог', 'Работает в учебном случае', 'Это повод проверить одну гипотезу, а не удалить всё окружение A'],
|
||||
],
|
||||
),
|
||||
heading('Превращаю JSON в сравнимые строки'),
|
||||
paragraph('В снимке есть время создания и путь каждого кандидата, поэтому сырой текст файлов неудобно сравнивать целиком. Небольшая функция ниже выбирает поля, которые относятся к запуску. Она не скрывает порядок <code>PATH</code>: каждая папка остаётся отдельной строкой. Если на машине не найден инструмент, функция записывает это явно вместо пустого объекта.'),
|
||||
codeBlock([
|
||||
'# tools/Compare-Environment.ps1',
|
||||
'function Get-SnapshotLines {',
|
||||
' param([string]$SnapshotPath)',
|
||||
'',
|
||||
' $snapshot = Get-Content -LiteralPath $SnapshotPath -Raw | ConvertFrom-Json',
|
||||
' $lines = @(',
|
||||
' "powershell=" + $snapshot.powershell.version',
|
||||
' "consoleCodePage=" + $snapshot.console.activeCodePage',
|
||||
' "outputEncoding=" + $snapshot.console.outputEncoding',
|
||||
' "pathext=" + $snapshot.environment.pathext',
|
||||
' )',
|
||||
'',
|
||||
' foreach ($entry in @($snapshot.environment.pathEntries)) {',
|
||||
' $lines += "path=" + $entry',
|
||||
' }',
|
||||
'',
|
||||
' foreach ($tool in @($snapshot.commands)) {',
|
||||
' $first = @($tool.candidates | Select-Object -First 1)',
|
||||
' if ($first.Count -eq 0) {',
|
||||
' $lines += $tool.name + "=NOT FOUND"',
|
||||
' continue',
|
||||
' }',
|
||||
' $candidate = $first[0]',
|
||||
' $lines += ("{0}={1}|{2}|{3}" -f $tool.name, $candidate.commandType, $candidate.definition, $candidate.version)',
|
||||
' }',
|
||||
'',
|
||||
' return $lines',
|
||||
'}',
|
||||
'',
|
||||
'$machineA = Get-SnapshotLines .\\machine-a.json',
|
||||
'$machineB = Get-SnapshotLines .\\machine-b.json',
|
||||
'Compare-Object -ReferenceObject $machineA -DifferenceObject $machineB',
|
||||
].join('\n')),
|
||||
paragraph('<code>Compare-Object</code> показывает строки, существующие только в одной из сторон. Его стрелки не являются диагнозом: они лишь говорят, в каком файле встретилась строка. Сначала смотрю пару «первый кандидат инструмента + соответствующая папка <code>PATH</code>», затем проверяю версию командой. Если отличаются десять строк, не исправляю все десять. Выбираю различие, которое прямо связано с исходной ошибкой.'),
|
||||
heading('Проверяю гипотезу в новом сеансе'),
|
||||
paragraph('Допустим, README проекта требует конкретную версию Node, а учебный снимок A показывает старый бинарник раньше нужного. Сначала открываю новый PowerShell и временно добавляю каталог с нужной версией в начало <code>$env:Path</code>. Это действие влияет только на текущий процесс и всё, что будет запущено из него. Если исходная команда не изменилась, гипотеза про порядок <code>PATH</code> не подтвердилась — возвращаюсь к логу, а не переношу путь в системные переменные.'),
|
||||
codeBlock([
|
||||
'# Новый PowerShell. Путь взят из документации проекта, не из случайного каталога.',
|
||||
'$requiredNode = "C:\\Tools\\node-8"',
|
||||
'if (-not (Test-Path (Join-Path $requiredNode "node.exe"))) {',
|
||||
' throw "В каталоге нет node.exe: $requiredNode"',
|
||||
'}',
|
||||
'',
|
||||
'$env:Path = $requiredNode + ";" + $env:Path',
|
||||
'Get-Command node -All |',
|
||||
' Select-Object CommandType, Name, Version, Definition |',
|
||||
' Format-Table -AutoSize',
|
||||
'node --version',
|
||||
'',
|
||||
'# Только после этих строк повторяется исходная команда проекта.',
|
||||
'npm run build',
|
||||
].join('\n')),
|
||||
paragraph('Этот пример не предлагает брать <code>C:\\Tools\\node-8</code> из статьи. Каталог и версия должны быть определены проектом или его официальной документацией. Если файл не найден, скрипт останавливается с понятной ошибкой. Он не скачивает исполняемый файл, не меняет <code>PATH</code> на уровне пользователя или системы и не требует отключать политику запуска сценариев.'),
|
||||
heading('Как отделяю PATH, кодировку и версию'),
|
||||
paragraph('Три различия часто приходят вместе, но их нельзя лечить одним действием. Старый бинарник проявляется через путь и <code>--version</code>. Кодовая страница проявляется через нечитаемый вывод и значения <code>chcp</code> или <code>OutputEncoding</code>. Версия зависимостей проекта проявляется через lock-файл и лог пакетного менеджера. Если меняется только текст сообщения, а путь и версия совпали, спор про <code>PATH</code> стоит прекратить.'),
|
||||
dataTable(
|
||||
['Гипотеза', 'Минимальное доказательство', 'Обратимое действие', 'Когда остановиться'],
|
||||
[
|
||||
['В <code>PATH</code> раньше старая папка', 'Первый кандидат и первая отличающаяся строка пути', 'Добавить документированный каталог только в <code>$env:Path</code> нового PowerShell', 'Если версия или исходная ошибка не изменились'],
|
||||
['Профиль подменяет команду', '<code>Get-Command -All</code> показывает функцию или alias', 'Сравнить запуск с <code>-NoProfile</code>', 'Если тип кандидата остаётся <code>Application</code> и путь тот же'],
|
||||
['Консоль портит сообщение', 'Различаются <code>chcp</code> и настройка вывода при одинаковом бинарнике', 'Открыть новую консоль и проверить вывод одной команды', 'Если проблема относится к сохранённому файлу, а не к консоли'],
|
||||
['Проект требует другую версию', 'README или конфигурация проекта прямо называет версию, а <code>--version</code> не совпадает', 'Использовать одобренную поставку этой версии в отдельном сеансе', 'Если после совпадения версии ошибка переходит к другой причине'],
|
||||
],
|
||||
),
|
||||
heading('Порядок полевого разбора'),
|
||||
orderedList([
|
||||
'Сохранить commit, lock-файл и точный запуск, который расходится на двух машинах.',
|
||||
'Снять очищенные отчёты из одинакового PowerShell и не публиковать в них секреты или персональные пути.',
|
||||
'Сравнить PowerShell, кодовую страницу, порядок <code>PATH</code>, <code>PATHEXT</code>, первого кандидата и вывод версии.',
|
||||
'Выбрать одно отличие, напрямую связанное с ошибкой, и проверить его в новом сеансе только через процессное <code>$env:Path</code>.',
|
||||
'Повторить исходную команду и сохранить новый снимок, если результат изменился.',
|
||||
'Лишь после подтверждения обновить README или согласованный способ установки; ненужные глобальные правки не оставлять.',
|
||||
]),
|
||||
heading('Что останется вне этого разбора'),
|
||||
paragraph('Два одинаковых снимка не гарантируют одинаковую сборку. Причина может жить в правах на каталог, сертификате прокси, сетевом доступе, разрядности, нативной библиотеке, антивирусе, содержимом кеша или строках окончания файла. Эти варианты проверяются следующими отдельными наблюдениями. Нельзя делать вывод, что защита мешает проекту, и отключать её без подтверждённой причины и разрешённого процесса.'),
|
||||
paragraph('Также не стоит превращать учебный дифф в обязательный корпоративный формат. Для маленького проекта достаточно текстового снимка и одной команды сравнения. Главное — чтобы следующий человек мог повторить путь: увидеть исходную ошибку, сравнить один набор полей, провести обратимый опыт и сказать, что именно изменилось.'),
|
||||
heading('Итог: вместо «у меня работает»'),
|
||||
paragraph('Фраза становится полезной, когда к ней приложены три вещи: команда, снимок процесса и сравнение кандидата бинарника. Тогда диагностика не начинается с переустановки. Мы сначала видим отличие, проверяем его в новом сеансе и только затем решаем, нужно ли менять постоянную настройку. В большинстве локальных случаев этого достаточно, чтобы вернуть разговор от мнений к наблюдаемым данным.'),
|
||||
sourceList([sources.environment, sources.getCommand, sources.chcp, sources.compareObject, sources.where]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||
|
||||
const isDirectExecution = Boolean(process.argv[1])
|
||||
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectExecution) {
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
} else {
|
||||
process.stderr.write('Usage: node web/scripts/upgrade-2018-09.mjs --print-revisions\n');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user