Files
progcode/web/scripts/upgrade-2018-09.mjs
huncode 2c7f9d37d7
Build and deploy / deploy (push) Successful in 22s
revise September 2018 environment articles
2026-07-31 09:52:09 +03:00

426 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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');
}
}