revise September 2018 environment articles
Build and deploy / deploy (push) Successful in 22s

This commit is contained in:
2026-07-31 09:52:09 +03:00
parent 7c53ffe6bc
commit 2c7f9d37d7
9 changed files with 856 additions and 2 deletions
+141
View File
@@ -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;
+425
View File
@@ -0,0 +1,425 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.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');
}
}