diff --git a/.github/workflows/gcloud-doctor.yml b/.github/workflows/gcloud-doctor.yml new file mode 100644 index 0000000..a99b5b3 --- /dev/null +++ b/.github/workflows/gcloud-doctor.yml @@ -0,0 +1,52 @@ +name: gcloud-doctor + +on: + push: + branches: [master, 'feat/gcloud-doctor-cross-platform'] + paths: + - 'plugin/skills/gcloud-doctor/**' + - 'skills/gcloud-doctor.md' + - 'skills.json' + - 'package.json' + - 'tests/gcloud-doctor.test.mjs' + - '.github/workflows/gcloud-doctor.yml' + pull_request: + paths: + - 'plugin/skills/gcloud-doctor/**' + - 'skills/gcloud-doctor.md' + - 'skills.json' + - 'package.json' + - 'tests/gcloud-doctor.test.mjs' + - '.github/workflows/gcloud-doctor.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gcloud-doctor-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: ${{ matrix.os }} / Node ${{ matrix.node }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: ['22.18.0', '24.x'] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ matrix.node }} + - name: Syntax and SDK-free help + run: | + node --check plugin/skills/gcloud-doctor/scripts/doctor.mjs + node plugin/skills/gcloud-doctor/scripts/doctor.mjs --help + - name: Fixture-based diagnostic and integration tests + run: npm run test:gcloud-doctor diff --git a/README.md b/README.md index ca66aa3..7d82367 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,28 @@ Opens **http://localhost:4599** → add a project folder → **▶ Run Codex**. Windows shortcuts: `start.cmd` / `install-shortcut.cmd`. macOS: `start.command` / `install-shortcut.command`. +### Optional Google Cloud environment checks + +`$gcloud-doctor` adds local Google Cloud diagnostics on **Windows, macOS and Linux**. +It checks the actual SDK version, PATH ambiguity, Python, project configuration, +and separate CLI/ADC evidence. It does not make Google Cloud a GitMir dependency +and does not run at startup. No login, update, registry edit, deployment or billing +operation is performed by the diagnostic CLI. + +```text +npm run doctor:gcloud +npm run doctor:gcloud -- --json +npm run test:gcloud-doctor +``` + +[Full guide, platform-specific maintenance and verification limits](plugin/skills/gcloud-doctor/references/maintenance.md). +The skill and all runtime assets are inside `plugin/skills/gcloud-doctor`, so they +also work with the standalone installed plugin. Tests use a fake SDK; a passing +local report is not proof of cloud API access, IAM permissions or valid tokens. + ### Windows maintenance recipes -[Google Cloud SDK: update to 584.0.0 and repair WinGet `Unknown`](docs/windows/gcloud-winget-version-repair/README.md) — PowerShell script, one-line command, troubleshooting, registry rollback guidance, and validation criteria. The guide is in Russian and records the Windows end-to-end validation status. This is an optional manual procedure, not a GitMir dependency or startup action. +[Google Cloud SDK: update to 584.0.0 and repair WinGet `Unknown`](docs/windows/gcloud-winget-version-repair/README.md) — PowerShell script, one-line command, troubleshooting, registry rollback guidance, and validation criteria. The guide is in Russian and records the Windows end-to-end validation status. This is an optional manual procedure, not a GitMir dependency or startup action. It is a historical fixed-version case, not the cross-platform doctor's update policy. --- @@ -81,6 +100,7 @@ macOS: `start.command` / `install-shortcut.command`. | `$context-distillation` | Messy input → `.gitmir/brief.json` | | `$legacy-maintenance` | Safe changes on coupled systems | | `$stack-port` | Port old stack → new without losing behaviour | +| `$gcloud-doctor` | Optional Windows/macOS/Linux Google Cloud environment diagnostics; explicitly authorized maintenance only | Flat copies under `skills/` are for the dashboard UI (copy-paste). Canonical Codex layout is `plugin/skills//SKILL.md`. diff --git a/package.json b/package.json index bd852e0..269f7f9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ }, "scripts": { "start": "node server.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "doctor:gcloud": "node plugin/skills/gcloud-doctor/scripts/doctor.mjs", + "test:gcloud-doctor": "node --test tests/gcloud-doctor.test.mjs" }, "devDependencies": { "@types/node": "^26.1.2", diff --git a/plugin/skills/gcloud-doctor/SKILL.md b/plugin/skills/gcloud-doctor/SKILL.md new file mode 100644 index 0000000..0f7c869 --- /dev/null +++ b/plugin/skills/gcloud-doctor/SKILL.md @@ -0,0 +1,84 @@ +--- +name: gcloud-doctor +description: >- + Use when a Google Cloud project has gcloud, Python, PATH, CLI login, ADC, + configuration or WinGet version problems, or the user explicitly requests a + Google Cloud environment check on Windows, macOS or Linux. +--- + +# Google Cloud environment doctor + +Optional local diagnostics for Google Cloud projects. Never make Google Cloud a +GitMir prerequisite. Do not run this skill at dashboard startup or for unrelated +projects. Diagnostic permission is not repair, authentication or deployment permission. + +## Run the bundled diagnostic + +Locate this skill's installed directory containing `SKILL.md`, `scripts/doctor.mjs` +and `references/maintenance.md`. In a GitMir checkout the directory is +`plugin/skills/gcloud-doctor`. A dashboard flat copy under `skills/` is not the +script's directory. Resolve the absolute script path from the installed skill or +GitMir checkout, not by guessing relative to the user's project. + +Run Node.js 22.18 or later against that script, with the user's project as working +directory. The script has no npm dependencies. From the GitMir checkout: + +```text +node plugin/skills/gcloud-doctor/scripts/doctor.mjs +node plugin/skills/gcloud-doctor/scripts/doctor.mjs --json +``` + +Use `--project` only when the expected project is supplied or established from the +project's non-secret configuration. It compares configuration; it does not change +or remotely verify a project. `--min-version` accepts a numeric X.Y.Z minimum, +not "latest". There is no hardcoded Google Cloud release and no pin operation. + +Use `--require-local-adc` only for a workflow that specifically requires a local +ADC file, not workloads using an attached service account. `--strict` makes +warnings fail CI. `--gcloud` selects an explicitly identified absolute executable +path when several installations exist. Read `--help` for the full interface. + +## Interpret evidence, not wishful success + +The JSON schema is versioned (`schemaVersion: 1`). Checks have stable IDs, a +`pass`, `warn`, `fail` or `skip` status, a summary and allowlisted details. +Exit 0 means no failing local checks; warnings may remain. Exit 1 means failures +or strict-mode warnings. Exit 2 means invalid arguments or an internal error. + +Always distinguish: + +- Actual SDK version versus the available version displayed by a package manager. +- A stored active CLI credential entry versus a successful authentication/API call. +- CLI credentials versus Application Default Credentials used by client libraries. +- A readable ADC candidate versus valid credentials, scopes, IAM or quota. +- The current Node/OS architecture versus native architecture of every SDK component. + +`cloudAccess` is always `not-tested`. Missing local ADC does not prove workload +identity or metadata-server credentials are broken. `CLOUDSDK_CONFIG` can alter +CLI credential storage; application libraries do not all resolve overrides the +same way. Verify the actual application separately. Do not request or print tokens, +credential JSON, account email addresses, environment dumps or raw gcloud logs. +Project IDs, configuration names and non-home paths remain operational metadata; +review/redact before publishing a report. + +## Repairs and GitMir task integration + +Read [maintenance.md](references/maintenance.md) before proposing any change. +Identify the installation owner and the exact installation first. Windows is not +a synonym for WinGet, macOS is not a synonym for Homebrew, and Linux is not a +synonym for APT. Never mix package-manager and SDK self-updates blindly. + +The CLI intentionally has no `--fix`, login, deploy, update or registry-write +mode. With explicit user authorization, perform only the selected maintenance +procedure from the runbook, preserve the prior state, and rerun the diagnostic. +Registry repair on Windows is a separate metadata operation, not a cloud update +or version pin. The old 584.0.0 recipe is historical, not a universal updater. + +When the user requests queued work, create the next unused `tasks/todo/NNN-*.md` +in the managed project using the existing `task-planner` shape: `Type: fix`, +`## Context`, `## Task`, `## Verify`. Include the failed check ID, sanitized +reproduction, expected result, exact authorized scope and rollback. For cloud or +machine mutations not yet authorized, create a `Type: verify` task that diagnoses +only; state the repair is blocked, rather than queueing an autonomous mutation. +Do not mark a task done until its actual verification has passed. Never auto-push +reports or change GitMir server routes, cloud resources, IAM, billing or auth. diff --git a/plugin/skills/gcloud-doctor/references/maintenance.md b/plugin/skills/gcloud-doctor/references/maintenance.md new file mode 100644 index 0000000..35047a3 --- /dev/null +++ b/plugin/skills/gcloud-doctor/references/maintenance.md @@ -0,0 +1,197 @@ +# gcloud-doctor: эксплуатация и ремонт + +Необязательный модуль GitMir для Windows, macOS и Linux. Исполняемый диагностический +код находится внутри навыка и устанавливается вместе с ним; отдельный облачный +сервер, npm-пакеты и Google Cloud account для самого GitMir не требуются. + +## Запуск + +Из корня GitMir одинаково в PowerShell, cmd, bash и zsh: + +```text +npm run doctor:gcloud +npm run doctor:gcloud -- --json +npm run doctor:gcloud -- --project example-project --min-version 584.0.0 +npm run doctor:gcloud -- --require-local-adc --strict +``` + +`example-project` и 584.0.0 — примеры параметров проверки, не настройки по умолчанию. +Для текущей установленной копии плагина вне репозитория запускайте `node` с полным +путём к `scripts/doctor.mjs` внутри установленного навыка. В Codex вызовите +`$gcloud-doctor`; в dashboard навык доступен через обычный список Skills. +Ни один диагностический запуск не обновляет SDK и не исправляет реестр. + +Параметры: `--json`, `--project ID`, `--min-version X.Y.Z`, `--gcloud ABSOLUTE_PATH`, +`--require-local-adc`, `--strict`, `--timeout-ms N` (50–120000 на одну проверку), +`--help`. Неизвестные параметры, включая `--fix`, отклоняются. Требование GitMir — +Node.js >=22.18.0. Для самой диагностики установка зависимостей не нужна. + +## Что проверяется + +| ID | Проверка | Чего результат не доказывает | +|---|---|---| +| PLATFORM | Windows, macOS или Linux, архитектура текущего процесса | Нативная архитектура всех SDK-компонентов | +| SDK_PATH | Доступный launcher, реальные дубликаты в PATH | Владение установкой конкретным пакетным менеджером | +| SDK_VERSION | Фактическая версия и заданный минимум | Что установлена самая свежая версия | +| SDK_RUNTIME | Каталог SDK, путь и версия используемого Python | Совместимость всех сторонних Python-пакетов | +| PROJECT | Текущий проект, region/zone, совпадение с ожидаемым | Существование проекта, доступ и биллинг | +| CONFIGURATION | Активная именованная конфигурация | Пригодность для каждого запуска приложения | +| CLI_CREDENTIALS | Активная локальная запись CLI без имени аккаунта | Валидность токена и успешная авторизация | +| CLI_AUTH_OVERRIDES | Наличие impersonation/credential/token overrides | Какая identity реально пройдёт запрос | +| ADC_LOCAL | Доступный файл-кандидат ADC, только stat/access | Содержимое, JSON-валидность, scopes, IAM | +| ADC_CUSTOM_CONFIG | Нестандартный CLOUDSDK_CONFIG | Что конкретная клиентская библиотека использует его | +| CLOUD_ACCESS | Явное `not-tested` | Никаких сетевых гарантий | + +Сначала учитывается `GOOGLE_APPLICATION_CREDENTIALS`. При его отсутствии проверяется +стандартный пользовательский путь: `%APPDATA%\gcloud\application_default_credentials.json` +в Windows и `$HOME/.config/gcloud/application_default_credentials.json` в macOS/Linux.[1] +Некорректный явный override — ошибка, а не повод незаметно выбрать другой файл. +Отсутствие стандартного файла — предупреждение: workload может использовать metadata +server. `--require-local-adc` повышает только это отсутствие до ошибки. +`CLOUDSDK_CONFIG` диагностируется отдельно; для нестандартного хранения рекомендуем +явный `GOOGLE_APPLICATION_CREDENTIALS` в окружении приложения после проверки источника. + +Установленный SDK запускается пятью локальными командами: `version`, `info`, +`config list`, `config configurations list`, `auth list` с ограниченными JSON-полями.[2][3][4] +Вывод аккаунтов и сырые ошибки в отчёт не копируются. Файлы credentials, `.env` и +ключи не читаются. SDK может сам создавать локальные журналы; это не файловая +песочница. Запросы Google APIs, metadata probe и сетевой тест `info --run-diagnostics` +не запускаются. Подавляются update check, usage reporting и HTTP logging дочерних +процессов, но это не замена сетевой изоляции или аудиту самого SDK. + +## Результат для человека и CI + +JSON имеет `schemaVersion: 1`, список checks и итоговые счётчики. Коды завершения: +0 — нет ошибок; 1 — ошибки, либо предупреждения с `--strict`; 2 — неверные аргументы +или внутренняя ошибка. `pass` означает только конкретную локальную проверку. +`cloudAccess: not-tested` сохраняется и при полностью зелёном отчёте. + +Домашний каталог в путях заменяется на `$HOME`. Project ID, имена конфигураций и +другие пути остаются: перед публичной публикацией отчёт нужно просмотреть. +Автоматической отправки отчётов, создания задач и записи файлов у CLI нет. +Для задач используйте `$gcloud-doctor` и существующие `$task-planner`/`$task-runner`: +у задачи должны быть воспроизведение, разрешённая область ремонта и `## Verify`. + +## Выбор процедуры обслуживания + +Сначала установите источник именно выбранного SDK. Наличие `brew`, `apt` или +`winget` на машине само по себе не устанавливает владельца пакета. Проверьте путь +SDK и список установленного пакета штатным менеджером. Несколько установок — +сначала устранение неоднозначности, затем обновление только выбранной копии. + +| Установка | Процедура после явного разрешения | +|---|---| +| SDK installer / распакованный архив | `gcloud components update --quiet` | +| Windows SDK со встроенным Python | Сначала отдельная копия Python; процедура ниже | +| macOS Homebrew cask `gcloud-cli` | `brew upgrade --cask gcloud-cli`, только если владение подтверждено | +| Linux APT, пакет `google-cloud-cli` | `sudo apt-get update`, затем `sudo apt-get install --only-upgrade google-cloud-cli` | +| Linux YUM, пакет `google-cloud-cli` | `sudo yum update google-cloud-cli` | +| Иной менеджер / контейнер / управляемый образ | Его штатная процедура или пересборка образа, без смены канала установки | + +При установке через APT/YUM менеджер компонентов SDK отключён; не пытайтесь включать +его правкой внутренних файлов.[5] Homebrew cask имеет собственную процедуру +обновления; сначала подтвердите актуальное имя установленного cask.[6] +Конкретную целевую версию SDK можно задать `--version=X.Y.Z` только после проверки +её доступности и явного согласования; это может быть и downgrade.[7] +Фиксированной обязательной версии у gcloud-doctor нет. + +## Windows: встроенный Python и non-interactive update + +Ошибка `Cannot use bundled Python installation ... in non-interactive mode` +указывает на необходимость отдельного интерпретатора. Google CLI предлагает +`gcloud components copy-bundled-python`; переменная `CLOUDSDK_PYTHON` выбирает Python.[8] +Не заменяйте системный Python и не отключайте защиту ОС ради обновления. + +Следующая функция — ручной сценарий обслуживания, не часть автоматической +диагностики. Выполнять вне каталога SDK, от той же учётной записи; для all-users +установки нужны права администратора. Без `-Version` запрашивается актуальная +доступная версия; указанная версия может понизить SDK. Сначала согласуйте операцию. + +```powershell +function Update-GitMirGcloud { + param([ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version) + $ErrorActionPreference = 'Stop' + $oldPython = $env:CLOUDSDK_PYTHON + try { + $copy = gcloud components copy-bundled-python + if ($LASTEXITCODE -ne 0) { throw 'Bundled Python copy failed.' } + $env:CLOUDSDK_PYTHON = ([string]$copy).Trim().Trim('"') + if (-not (Test-Path -LiteralPath $env:CLOUDSDK_PYTHON -PathType Leaf)) { + throw 'Copied Python executable was not found.' + } + $command = '/d /c gcloud components update --quiet' + if ($Version) { $command += " --version=$Version" } + $process = Start-Process -FilePath $env:ComSpec -ArgumentList $command -NoNewWindow -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "SDK updater failed: $($process.ExitCode)" } + } + finally { $env:CLOUDSDK_PYTHON = $oldPython } + $json = gcloud version --format=json + if ($LASTEXITCODE -ne 0) { throw 'Could not verify the installed SDK.' } + $actual = ($json | ConvertFrom-Json -ErrorAction Stop).'Google Cloud SDK' + if ($Version -and $actual -ne $Version) { throw "Expected $Version, got $actual." } + Write-Output "Installed Google Cloud SDK: $actual" +} +``` + +Вызов функции сам по себе не встроен в навык: агент выполняет его только по +разрешению пользователя. Временная копия Python может остаться после процедуры; +не удаляйте её до завершения всех процессов обновления. Нет гарантии транзакционного +отката компонентов; downgrade к сохранённой версии — отдельная согласуемая операция. + +## Windows: Unknown в WinGet — отдельная проблема + +`Unknown` — неизвестная установленная версия; число в столбце available не доказывает +наличие этой версии на диске. Исправление `DisplayVersion` — изменение metadata в +существующей uninstall-записи, не обновление SDK и не pin.[9][10] + +Порядок: прочитать реальную версию через `gcloud version --format=json`; найти +uninstall-запись в HKCU/HKLM с учётом 32/64-разрядного представления; сопоставить её +с выбранным SDK; экспортировать именно эту запись через `reg export`; записать +проверенную версию как `REG_SZ`; перечитать запись и `winget list --id Google.CloudSDK --exact`. +При нескольких записях, несовпадающих путях или неизвестном владельце остановиться. +Не подменять 583.0.0 числом 584.0.0. Для отката вернуть только предыдущий параметр +или импортировать проверенный экспорт этой записи, а не чужой снимок реестра. + +Исторический кейс в GitMir: `docs/windows/gcloud-winget-version-repair/README.md` и +`Update-GCloudAndRepairWinget.ps1`. Он жёстко ориентирован на 584.0.0, а успешный +end-to-end запуск на исходном компьютере не подтверждён. Не запускать его как +универсальный ремонт и не переносить операции реестра на macOS/Linux. + +## Ограничения и проверка + +Поддерживаются Windows, macOS и Linux с Node >=22.18.0; WSL проверяется как Linux, +а не как Windows-хост. Intel/ARM не зашиты в код; архитектура берётся у процесса. +Для Windows `.cmd/.bat` вызываются через `cmd.exe /d /s /v:off /c` с отдельным +quoting; executable paths с `%`, `!`, кавычками или shell-метасимволами отклоняются.[11] +Пустые и относительные элементы PATH намеренно не используются. Дубли через symlink +схлопываются. Shell alias/function не виден процессу Node: укажите `--gcloud`. + +Таймаут ограничивает ожидание отдельной проверки. На Windows завершение cmd-родителя +не гарантирует остановку всех порождённых им процессов; CLI не является supervisor. +При сбое не публикуйте сырой stderr, полный `gcloud info` или credential JSON. +Авторизация, SDK updates и запись реестра не входят в автоматические тесты. + +```text +npm run test:gcloud-doctor +``` + +Тесты используют фиктивный SDK и временные файлы без облачного аккаунта. Проверяются +аргументы, платформенные пути, реальный запуск fixture-процессов, ошибки, несколько +установок, минимум версии, несовпадение проекта и отсутствие секретов в выводе. +Матрица GitHub Actions: Windows/macOS/Linux, Node 22.18.0 и 24.x. Наличие workflow +не означает успешного выполнения: проверяйте статус конкретного PR/commit. +PowerShell-сценарий выше и ремонт WinGet требуют отдельного Windows acceptance test. + +## Официальные источники + +1. [ADC: порядок поиска, платформенные пути и отличие от CLI credentials](https://docs.cloud.google.com/docs/authentication/application-default-credentials) +2. [gcloud info](https://docs.cloud.google.com/sdk/gcloud/reference/info) +3. [gcloud config list](https://docs.cloud.google.com/sdk/gcloud/reference/config/list) +4. [gcloud auth list](https://docs.cloud.google.com/sdk/gcloud/reference/auth/list) +5. [Управление компонентами и пакетные менеджеры](https://docs.cloud.google.com/sdk/docs/components) +6. [Homebrew: gcloud-cli](https://formulae.brew.sh/cask/gcloud-cli) +7. [gcloud components update](https://docs.cloud.google.com/sdk/gcloud/reference/components/update) +8. [CLOUDSDK_PYTHON и запуск SDK](https://docs.cloud.google.com/sdk/gcloud/reference/topic/startup) +9. [WinGet upgrade](https://learn.microsoft.com/windows/package-manager/winget/upgrade) +10. [Windows Installer: uninstall registry key / DisplayVersion](https://learn.microsoft.com/windows/win32/msi/uninstall-registry-key) +11. [Node.js: запуск .cmd/.bat в Windows](https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows) diff --git a/plugin/skills/gcloud-doctor/scripts/doctor.mjs b/plugin/skills/gcloud-doctor/scripts/doctor.mjs new file mode 100644 index 0000000..ef2c6bf --- /dev/null +++ b/plugin/skills/gcloud-doctor/scripts/doctor.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const VERSION = /^\d+\.\d+\.\d+$/; +const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,127}$/; +const HELP = `GitMir gcloud-doctor — local configuration evidence, not a cloud access test. +Usage: node doctor.mjs [--json] [--project PROJECT_ID] [--min-version X.Y.Z] + [--gcloud ABSOLUTE_PATH] [--require-local-adc] [--strict] + [--timeout-ms 10000] [--help] +Exit: 0 = no failures; 1 = diagnostic failures (or warnings with --strict); + 2 = invalid arguments/internal error. Warnings do not certify cloud access. +No login, update, registry edits, credential export or cloud resource changes. +Repairs require explicit approval; read references/maintenance.md in this skill. +`; + +export function parseArgs(argv) { + const result = { json: false, strict: false, requireLocalAdc: false, timeoutMs: 10000 }; + const boolean = { '--json': 'json', '--strict': 'strict', '--require-local-adc': 'requireLocalAdc', '--help': 'help', '-h': 'help' }; + const values = { '--project': 'project', '--min-version': 'minVersion', '--gcloud': 'gcloud', '--timeout-ms': 'timeoutMs' }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (Object.hasOwn(boolean, arg)) result[boolean[arg]] = true; + else if (Object.hasOwn(values, arg)) { + const value = argv[++i]; + if (!value || value.startsWith('--')) throw new Error('An option is missing its value.'); + result[values[arg]] = value; + } else throw new Error('Unsupported option. Use --help; mutation flags are not supported.'); + } + if (result.project && !IDENTIFIER.test(result.project)) throw new Error('Invalid project identifier.'); + if (result.minVersion && !VERSION.test(result.minVersion)) throw new Error('Version must have the numeric form X.Y.Z.'); + if (!/^\d+$/.test(String(result.timeoutMs)) || +result.timeoutMs < 50 || +result.timeoutMs > 120000) throw new Error('Timeout must be an integer from 50 to 120000 milliseconds.'); + result.timeoutMs = +result.timeoutMs; + return result; +} + +function envValue(env, name, platform) { + const key = platform === 'win32' ? Object.keys(env).find(k => k.toUpperCase() === name.toUpperCase()) : name; + return key ? env[key] : undefined; +} + +function readableFile(filename) { + try { fs.accessSync(filename, fs.constants.R_OK); return fs.statSync(filename).isFile(); } + catch { return false; } +} + +export function findExecutables({ platform = process.platform, env = process.env, explicit } = {}) { + const p = platform === 'win32' ? path.win32 : path.posix; + if (explicit) { + if (!p.isAbsolute(explicit)) throw new Error('--gcloud requires an absolute path.'); + if (!readableFile(explicit)) return []; + return [explicit]; + } + const directories = (envValue(env, 'PATH', platform) || '').split(p.delimiter); + const names = platform === 'win32' ? ['gcloud.cmd', 'gcloud.exe', 'gcloud.bat'] : ['gcloud']; + const found = [], seen = new Set(); + for (let directory of directories) { + directory = directory.replace(/^"|"$/g, ''); + // Empty/relative PATH elements would execute untrusted project-local wrappers. + if (!p.isAbsolute(directory)) continue; + for (const name of names) { + const candidate = p.join(directory, name); + try { + if (!fs.statSync(candidate).isFile()) continue; + fs.accessSync(candidate, platform === 'win32' ? fs.constants.R_OK : fs.constants.X_OK); + const real = fs.realpathSync(candidate); + const key = platform === 'win32' ? real.toLowerCase() : real; + if (!seen.has(key)) { seen.add(key); found.push(candidate); } + } catch { /* Missing or inaccessible PATH entries are not executable candidates. */ } + } + } + return found; +} + +export function commandSpec(executable, args, platform = process.platform, env = process.env) { + if (platform === 'win32' && /\.(cmd|bat)$/i.test(executable)) { + // CMD expands %variables% even inside quotes. Reject expansion/metacharacters. + if (/["%!\r\n&|<>^]/.test(executable) || args.some(a => /["%!\r\n&|<>^]/.test(a))) throw new Error('Unsafe Windows command path or arguments.'); + const root = envValue(env, 'SystemRoot', platform) || 'C:\\Windows'; + const command = '"' + [executable, ...args].map(a => '"' + a + '"').join(' ') + '"'; + return { file: path.win32.join(root, 'System32', 'cmd.exe'), args: ['/d', '/s', '/v:off', '/c', command], options: { shell: false, windowsVerbatimArguments: true } }; + } + return { file: executable, args, options: { shell: false } }; +} + +export function adcLocation(platform, env, home) { + const p = platform === 'win32' ? path.win32 : path.posix; + const override = envValue(env, 'GOOGLE_APPLICATION_CREDENTIALS', platform); + if (override) return { path: override, source: 'environment' }; + const base = platform === 'win32' ? envValue(env, 'APPDATA', platform) || p.join(home, 'AppData', 'Roaming') : p.join(home, '.config'); + return { path: p.join(base, 'gcloud', 'application_default_credentials.json'), source: 'well-known-file' }; +} + +function childEnvironment(env, platform) { + const overrides = { CLOUDSDK_CORE_DISABLE_PROMPTS: '1', CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK: '1', CLOUDSDK_CORE_DISABLE_USAGE_REPORTING: '1', CLOUDSDK_CORE_LOG_HTTP: '0' }; + const result = {}; + for (const [key, value] of Object.entries(env)) { + const canonical = platform === 'win32' ? key.toUpperCase() : key; + if (!Object.hasOwn(overrides, canonical)) result[canonical] = value; + } + return { ...result, ...overrides }; +} + +function probe(executable, args, context, timeoutMs) { + let spec; + try { spec = commandSpec(executable, [...args, '--quiet', '--verbosity=error'], context.platform, context.env); } + catch { return { ok: false, reason: 'Command path cannot be invoked safely on this platform.' }; } + let result; + try { + result = spawnSync(spec.file, spec.args, { ...spec.options, env: childEnvironment(context.env, context.platform), cwd: context.cwd, encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, windowsHide: true }); + } catch { return { ok: false, reason: 'Could not start the SDK process.' }; } + if (result.error?.code === 'ETIMEDOUT') return { ok: false, reason: 'SDK probe timed out.' }; + if (result.error || result.status !== 0) return { ok: false, reason: 'SDK probe failed; raw output withheld to avoid exposing credentials.' }; + try { return { ok: true, value: JSON.parse(result.stdout.replace(/^\uFEFF/, '')) }; } + catch { return { ok: false, reason: 'SDK returned invalid JSON; raw output withheld.' }; } +} + +function numericCompare(a, b) { + const aa = a.split('.').map(BigInt), bb = b.split('.').map(BigInt); + for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] > bb[i] ? 1 : -1; + return 0; +} + +function safeText(value) { + return typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 1024) : null; +} + +export function diagnose(options = {}, overrides = {}) { + const context = { platform: process.platform, env: process.env, home: os.homedir(), cwd: process.cwd(), ...overrides }; + const { platform, env, home } = context; + const checks = []; + const add = (id, status, summary, details = {}) => checks.push({ id, status, summary, details }); + const report = { schemaVersion: 1, tool: 'gcloud-doctor', generatedAt: new Date().toISOString(), platform, architecture: process.arch, nodeVersion: process.versions.node, scope: 'local-configuration', cloudAccess: 'not-tested', checks }; + const finish = () => { + report.summary = { failures: checks.filter(c => c.status === 'fail').length, warnings: checks.filter(c => c.status === 'warn').length, passed: checks.filter(c => c.status === 'pass').length }; + report.exitCode = report.summary.failures || (options.strict && report.summary.warnings) ? 1 : 0; + return report; + }; + if (!['win32', 'darwin', 'linux'].includes(platform)) { + add('PLATFORM', 'fail', 'Supported platforms are Windows, macOS and Linux.'); return finish(); + } + add('PLATFORM', 'pass', 'Supported operating system.', { platform, architecture: process.arch }); + const displayPath = value => { + const text = safeText(value); + if (!text) return null; + const p = platform === 'win32' ? path.win32 : path.posix; + const a = platform === 'win32' ? text.toLowerCase() : text; + const b = platform === 'win32' ? home.toLowerCase() : home; + return a === b ? '$HOME' : a.startsWith(b + p.sep) ? '$HOME' + text.slice(home.length) : text; + }; + const adc = adcLocation(platform, env, home); + const present = readableFile(adc.path); + add('ADC_LOCAL', present ? 'pass' : adc.source === 'environment' || options.requireLocalAdc ? 'fail' : 'warn', present ? 'Readable local ADC candidate; contents and authentication not tested.' : 'Local ADC candidate unavailable; metadata/federation access is not tested.', { source: adc.source, path: displayPath(adc.path), contentsRead: false }); + const customConfig = envValue(env, 'CLOUDSDK_CONFIG', platform); + if (customConfig) add('ADC_CUSTOM_CONFIG', 'warn', 'CLOUDSDK_CONFIG is set. Client-library support for this override varies; verify the application separately.', { path: displayPath(customConfig) }); + add('CLOUD_ACCESS', 'skip', 'API access, IAM permissions, token validity, billing and metadata service are not tested.'); + let candidates; + try { candidates = findExecutables({ platform, env, explicit: options.gcloud }); } + catch { add('SDK_PATH', 'fail', 'Provide an existing absolute SDK executable path.'); return finish(); } + if (!candidates.length) { add('SDK_PATH', 'fail', 'Google Cloud CLI was not found on absolute PATH entries or at the explicit path.'); return finish(); } + const executable = candidates[0]; + add('SDK_PATH', candidates.length > 1 ? 'warn' : 'pass', candidates.length > 1 ? 'Multiple SDK launchers found. The first PATH match is diagnosed; no installation is modified.' : 'SDK launcher located.', { selected: displayPath(executable), candidates: candidates.map(displayPath) }); + const run = args => probe(executable, args, context, options.timeoutMs || 10000); + const version = run(['version', '--format=json']); + const v = version.value?.['Google Cloud SDK']; + if (!version.ok || typeof v !== 'string' || !VERSION.test(v)) { + add('SDK_VERSION', 'fail', version.reason || 'SDK version is missing or has an unsupported format.'); return finish(); + } + const tooOld = options.minVersion && numericCompare(v, options.minVersion) < 0; + add('SDK_VERSION', tooOld ? 'fail' : 'pass', tooOld ? 'Installed SDK is below the requested minimum.' : 'Actual SDK version read; latest-release availability is not tested.', { version: v, minimum: options.minVersion || null }); + const info = run(['info', '--format=json(installation.sdk_root,basic.python_location,basic.python_version)']); + if (info.ok) { + const runtime = info.value?.basic; + const python = runtime?.python_location; + const complete = typeof python === 'string' && readableFile(python) && typeof info.value?.installation?.sdk_root === 'string'; + add('SDK_RUNTIME', complete ? 'pass' : 'warn', complete ? 'SDK reports an available interpreter.' : 'SDK runtime fields are incomplete or the interpreter path is unavailable.', { sdkRoot: displayPath(info.value?.installation?.sdk_root), python: displayPath(python), pythonVersion: safeText(runtime?.python_version), pythonOverrideConfigured: Boolean(envValue(env, 'CLOUDSDK_PYTHON', platform)) }); + } else add('SDK_RUNTIME', 'warn', info.reason); + const config = run(['config', 'list', '--format=json(core.project,compute.region,compute.zone,auth.impersonate_service_account,auth.credential_file_override,auth.access_token_file)']); + if (config.ok && config.value && typeof config.value === 'object' && !Array.isArray(config.value)) { + const project = config.value.core?.project; + const valid = typeof project === 'string' && IDENTIFIER.test(project); + const matches = valid && (!options.project || project === options.project); + add('PROJECT', matches ? 'pass' : options.project ? 'fail' : 'warn', matches ? 'Effective project configuration found; project existence is not tested.' : 'Effective project is absent or differs from the expected project.', { project: valid ? project : null, expected: options.project || null, region: safeText(config.value.compute?.region), zone: safeText(config.value.compute?.zone) }); + const auth = config.value.auth || {}; + const modes = { impersonation: Boolean(auth.impersonate_service_account || envValue(env, 'CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT', platform)), credentialOverride: Boolean(auth.credential_file_override || envValue(env, 'CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE', platform)), tokenOverride: Boolean(auth.access_token_file || envValue(env, 'CLOUDSDK_AUTH_ACCESS_TOKEN_FILE', platform) || envValue(env, 'CLOUDSDK_AUTH_ACCESS_TOKEN', platform)) }; + if (Object.values(modes).some(Boolean)) add('CLI_AUTH_OVERRIDES', 'warn', 'CLI authentication overrides are configured; stored account entries do not prove the effective identity.', modes); + } else add('PROJECT', 'fail', config.reason || 'SDK returned an invalid configuration object.'); + const configurations = run(['config', 'configurations', 'list', '--filter=is_active:true', '--format=json(name,is_active)']); + const active = Array.isArray(configurations.value) ? configurations.value.filter(c => c?.is_active === true) : []; + add('CONFIGURATION', configurations.ok && active.length === 1 ? 'pass' : 'warn', configurations.ok && active.length === 1 ? 'Active named configuration found.' : 'A single active named configuration could not be confirmed.', { name: active.length === 1 ? safeText(active[0].name) : null }); + const auth = run(['auth', 'list', '--format=json(status)']); + if (auth.ok && Array.isArray(auth.value)) { + const count = auth.value.filter(a => a?.status === 'ACTIVE').length; + add('CLI_CREDENTIALS', count === 1 ? 'pass' : 'warn', count === 1 ? 'Active stored CLI credential entry found; token validity is not tested.' : 'No single active stored CLI credential entry; overrides or workload identity may apply.', { activeEntries: count, accountNamesIncluded: false }); + } else add('CLI_CREDENTIALS', 'warn', auth.reason || 'SDK returned an invalid credential list.'); + return finish(); +} + +export function main(argv = process.argv.slice(2)) { + let options; + try { + options = parseArgs(argv); + if (options.help) { process.stdout.write(HELP); return 0; } + const report = diagnose(options); + if (options.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + else { + process.stdout.write(`GitMir gcloud-doctor | ${report.platform} ${report.architecture} | local checks only\n`); + for (const check of report.checks) process.stdout.write(`[${check.status.toUpperCase()}] ${check.id}: ${check.summary}\n ${JSON.stringify(check.details)}\n`); + process.stdout.write(`Failures: ${report.summary.failures}; warnings: ${report.summary.warnings}. Cloud access NOT TESTED.\n`); + } + return report.exitCode; + } catch { + const error = { schemaVersion: 1, tool: 'gcloud-doctor', exitCode: 2, error: 'Invalid arguments or an internal error. Use --help. Raw error details are withheld.' }; + if (argv.includes('--json')) process.stdout.write(JSON.stringify(error) + '\n'); + else process.stderr.write(error.error + '\n'); + return 2; + } +} + +function isEntryPoint() { + if (!process.argv[1]) return false; + // GitMir installers use symlinks/junctions; ESM resolves the real module path. + try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } + catch { return false; } +} + +if (isEntryPoint()) process.exitCode = main(); diff --git a/skills.json b/skills.json index 44589c1..8dd0e42 100644 --- a/skills.json +++ b/skills.json @@ -68,5 +68,12 @@ "title": "stack-port", "desc": "Port an old, hand-written project onto a new stack with full feature parity — the old app is the spec, a parity ledger keeps anything from being silently dropped, and every slice is verified against the old behaviour, not just a green build.", "file": "skills/stack-port.md" + }, + { + "name": "gcloud-doctor", + "title": "gcloud-doctor", + "desc": "Optional Google Cloud environment checks for Windows, macOS and Linux: SDK/Python/PATH, project configuration, separate CLI and ADC evidence. No automatic updates, credential export or cloud writes.", + "file": "skills/gcloud-doctor.md", + "stripFrontmatter": true } ] diff --git a/skills/gcloud-doctor.md b/skills/gcloud-doctor.md new file mode 100644 index 0000000..0f7c869 --- /dev/null +++ b/skills/gcloud-doctor.md @@ -0,0 +1,84 @@ +--- +name: gcloud-doctor +description: >- + Use when a Google Cloud project has gcloud, Python, PATH, CLI login, ADC, + configuration or WinGet version problems, or the user explicitly requests a + Google Cloud environment check on Windows, macOS or Linux. +--- + +# Google Cloud environment doctor + +Optional local diagnostics for Google Cloud projects. Never make Google Cloud a +GitMir prerequisite. Do not run this skill at dashboard startup or for unrelated +projects. Diagnostic permission is not repair, authentication or deployment permission. + +## Run the bundled diagnostic + +Locate this skill's installed directory containing `SKILL.md`, `scripts/doctor.mjs` +and `references/maintenance.md`. In a GitMir checkout the directory is +`plugin/skills/gcloud-doctor`. A dashboard flat copy under `skills/` is not the +script's directory. Resolve the absolute script path from the installed skill or +GitMir checkout, not by guessing relative to the user's project. + +Run Node.js 22.18 or later against that script, with the user's project as working +directory. The script has no npm dependencies. From the GitMir checkout: + +```text +node plugin/skills/gcloud-doctor/scripts/doctor.mjs +node plugin/skills/gcloud-doctor/scripts/doctor.mjs --json +``` + +Use `--project` only when the expected project is supplied or established from the +project's non-secret configuration. It compares configuration; it does not change +or remotely verify a project. `--min-version` accepts a numeric X.Y.Z minimum, +not "latest". There is no hardcoded Google Cloud release and no pin operation. + +Use `--require-local-adc` only for a workflow that specifically requires a local +ADC file, not workloads using an attached service account. `--strict` makes +warnings fail CI. `--gcloud` selects an explicitly identified absolute executable +path when several installations exist. Read `--help` for the full interface. + +## Interpret evidence, not wishful success + +The JSON schema is versioned (`schemaVersion: 1`). Checks have stable IDs, a +`pass`, `warn`, `fail` or `skip` status, a summary and allowlisted details. +Exit 0 means no failing local checks; warnings may remain. Exit 1 means failures +or strict-mode warnings. Exit 2 means invalid arguments or an internal error. + +Always distinguish: + +- Actual SDK version versus the available version displayed by a package manager. +- A stored active CLI credential entry versus a successful authentication/API call. +- CLI credentials versus Application Default Credentials used by client libraries. +- A readable ADC candidate versus valid credentials, scopes, IAM or quota. +- The current Node/OS architecture versus native architecture of every SDK component. + +`cloudAccess` is always `not-tested`. Missing local ADC does not prove workload +identity or metadata-server credentials are broken. `CLOUDSDK_CONFIG` can alter +CLI credential storage; application libraries do not all resolve overrides the +same way. Verify the actual application separately. Do not request or print tokens, +credential JSON, account email addresses, environment dumps or raw gcloud logs. +Project IDs, configuration names and non-home paths remain operational metadata; +review/redact before publishing a report. + +## Repairs and GitMir task integration + +Read [maintenance.md](references/maintenance.md) before proposing any change. +Identify the installation owner and the exact installation first. Windows is not +a synonym for WinGet, macOS is not a synonym for Homebrew, and Linux is not a +synonym for APT. Never mix package-manager and SDK self-updates blindly. + +The CLI intentionally has no `--fix`, login, deploy, update or registry-write +mode. With explicit user authorization, perform only the selected maintenance +procedure from the runbook, preserve the prior state, and rerun the diagnostic. +Registry repair on Windows is a separate metadata operation, not a cloud update +or version pin. The old 584.0.0 recipe is historical, not a universal updater. + +When the user requests queued work, create the next unused `tasks/todo/NNN-*.md` +in the managed project using the existing `task-planner` shape: `Type: fix`, +`## Context`, `## Task`, `## Verify`. Include the failed check ID, sanitized +reproduction, expected result, exact authorized scope and rollback. For cloud or +machine mutations not yet authorized, create a `Type: verify` task that diagnoses +only; state the repair is blocked, rather than queueing an autonomous mutation. +Do not mark a task done until its actual verification has passed. Never auto-push +reports or change GitMir server routes, cloud resources, IAM, billing or auth. diff --git a/tests/gcloud-doctor.test.mjs b/tests/gcloud-doctor.test.mjs new file mode 100644 index 0000000..ec79007 --- /dev/null +++ b/tests/gcloud-doctor.test.mjs @@ -0,0 +1,265 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; +const script = fileURLToPath(new URL('../plugin/skills/gcloud-doctor/scripts/doctor.mjs', import.meta.url)); + +test('doctor implementation exists', () => assert.ok(existsSync(script), 'missing optional gcloud-doctor CLI')); +test('help succeeds without Google Cloud SDK', () => { + const p = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' }); + assert.equal(p.status, 0); + assert.match(p.stdout, /--json/); + assert.match(p.stdout, /--min-version/); +}); + +const load = () => import('../plugin/skills/gcloud-doctor/scripts/doctor.mjs'); + +test('arguments reject mutations and malformed values', async () => { + const { parseArgs } = await load(); + for (const args of [['--fix'], ['--project'], ['--min-version', 'latest'], ['--timeout-ms', '0'], ['--timeout-ms', '1e3']]) { + assert.throws(() => parseArgs(args)); + } + assert.equal(parseArgs(['--json', '--min-version', '584.0.0']).minVersion, '584.0.0'); +}); + +test('Windows command construction quotes spaces and parentheses without shell:true', async () => { + const { commandSpec } = await load(); + const spec = commandSpec('C:\\Program Files (x86)\\Google\\gcloud.cmd', ['version', '--format=json'], 'win32', { SystemRoot: 'C:\\Windows' }); + assert.equal(spec.file, 'C:\\Windows\\System32\\cmd.exe'); + assert.equal(spec.options.windowsVerbatimArguments, true); + assert.deepEqual(spec.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']); + assert.match(spec.args[4], /^""C:\\Program Files \(x86\)/); + assert.equal(spec.options.shell, false); +}); + +test('Windows shell expansion in executable paths is rejected', async () => { + const { commandSpec } = await load(); + for (const name of ['C:\\%TEMP%\\gcloud.cmd', 'C:\\a!b\\gcloud.cmd', 'C:\\x"&whoami\\gcloud.cmd']) { + assert.throws(() => commandSpec(name, ['version'], 'win32', {})); + } +}); + +test('Unix command invocation preserves argument boundaries', async () => { + const { commandSpec } = await load(); + for (const platform of ['linux', 'darwin']) { + const spec = commandSpec('/a directory/gcloud', ['info', '--format=json(basic.python_location)'], platform, {}); + assert.equal(spec.file, '/a directory/gcloud'); + assert.deepEqual(spec.args, ['info', '--format=json(basic.python_location)']); + assert.equal(spec.options.shell, false); + } +}); + +test('ADC default locations differ by OS and do not follow arbitrary SDK directories', async () => { + const { adcLocation } = await load(); + assert.equal(adcLocation('win32', { APPDATA: 'C:\\Users\\D\\AppData\\Roaming' }, 'C:\\Users\\D').path, 'C:\\Users\\D\\AppData\\Roaming\\gcloud\\application_default_credentials.json'); + for (const platform of ['linux', 'darwin']) { + assert.equal(adcLocation(platform, { CLOUDSDK_CONFIG: '/other/sdk' }, '/home/d').path, '/home/d/.config/gcloud/application_default_credentials.json'); + } + assert.equal(adcLocation('linux', { GOOGLE_APPLICATION_CREDENTIALS: '/tmp/wif.json' }, '/home/d').source, 'environment'); +}); + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function fixture(t, response = {}) { + const root = mkdtempSync(path.join(os.tmpdir(), 'gitmir doctor (test) ')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = path.join(root, 'home'); + const bin = path.join(root, 'sdk bin'); + mkdirSync(home); mkdirSync(bin); + const fake = path.join(root, 'fixture.cjs'); + writeFileSync(fake, `const a=process.argv.slice(2); const f=JSON.parse(process.env.DOCTOR_FIXTURE); const key=a[0]==='config'?(a[1]==='list'?'config':'configurations'):a[0]; if(f.mode==='hang')setInterval(()=>{},1000); else if(f.mode==='bad'){process.stdout.write('bad JSON secret refresh_token');process.exitCode=1;} else process.stdout.write(JSON.stringify(f[key]));`); + const executable = path.join(bin, process.platform === 'win32' ? 'gcloud.cmd' : 'gcloud'); + const launcher = process.platform === 'win32' + ? `@echo off\r\n"${process.execPath}" "${fake}" %*\r\nexit /b %errorlevel%\r\n` + : `#!/bin/sh\nexec "${process.execPath}" "${fake}" "$@"\n`; + writeFileSync(executable, launcher, { mode: 0o755 }); + const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(path|home|userprofile|appdata|cloudsdk_.*|google_application_credentials)$/i.test(k))); + Object.assign(env, { PATH: bin, HOME: home, USERPROFILE: home, APPDATA: path.join(home, 'AppData', 'Roaming') }); + const data = { + version: { 'Google Cloud SDK': '584.0.0', refresh_token: 'SECRET_TOKEN' }, + info: { installation: { sdk_root: bin }, basic: { python_location: process.execPath, python_version: '3.13.0' } }, + config: { core: { project: 'example-project', account: 'private@example.test' }, compute: { region: 'europe-west1' }, auth: {} }, + configurations: [{ name: 'default', is_active: true }], + auth: [{ status: 'ACTIVE', account: 'private@example.test', token: 'SECRET_TOKEN' }], + ...response, + }; + env.DOCTOR_FIXTURE = JSON.stringify(data); + return { root, home, bin, executable, env }; +} + +function cli(f, ...args) { + return spawnSync(process.execPath, [script, '--json', ...args], { env: f.env, cwd: f.home, encoding: 'utf8', timeout: 15000 }); +} + +test('real subprocess diagnostic never emits account names, raw tokens or credential file contents', async t => { + const f = fixture(t); + const adc = path.join(f.root, 'adc.json'); + writeFileSync(adc, '{"refresh_token":"FILE_SECRET"}'); + f.env.GOOGLE_APPLICATION_CREDENTIALS = adc; + const result = cli(f); + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(result.stdout); + assert.equal(report.schemaVersion, 1); + assert.equal(report.cloudAccess, 'not-tested'); + assert.equal(report.checks.find(c => c.id === 'SDK_VERSION').details.version, '584.0.0'); + assert.equal(report.checks.find(c => c.id === 'ADC_LOCAL').status, 'pass'); + assert.doesNotMatch(result.stdout, /SECRET_TOKEN|FILE_SECRET|refresh_token|private@example/); + assert.match(result.stdout, /not tested|not-tested/i); +}); + +test('actual SDK version is compared numerically, not lexically or against a pinned target', t => { + const f = fixture(t, { version: { 'Google Cloud SDK': '1000.0.0' } }); + assert.equal(cli(f, '--min-version', '584.0.0').status, 0); + assert.equal(cli(f, '--min-version', '1001.0.0').status, 1); +}); + +test('project mismatch is an error without changing configuration', t => { + const f = fixture(t); + const p = cli(f, '--project', 'different-project'); + assert.equal(p.status, 1); + assert.equal(JSON.parse(p.stdout).checks.find(c => c.id === 'PROJECT').status, 'fail'); +}); + +test('missing local ADC is a warning, not proof that metadata credentials are unavailable', t => { + const f = fixture(t); + let p = cli(f); + assert.equal(p.status, 0); + assert.equal(JSON.parse(p.stdout).checks.find(c => c.id === 'ADC_LOCAL').status, 'warn'); + p = cli(f, '--require-local-adc'); + assert.equal(p.status, 1); +}); + +test('explicit broken ADC override fails and does not fall back silently', t => { + const f = fixture(t); + f.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(f.root, 'missing.json'); + const p = cli(f); + assert.equal(p.status, 1); + const c = JSON.parse(p.stdout).checks.find(c => c.id === 'ADC_LOCAL'); + assert.equal(c.status, 'fail'); + assert.equal(c.details.source, 'environment'); +}); + +test('directory is not accepted as a credential file', t => { + const f = fixture(t); + f.env.GOOGLE_APPLICATION_CREDENTIALS = f.home; + assert.equal(cli(f).status, 1); +}); + +test('missing SDK yields structured error and does not need Google Cloud installed for help', t => { + const f = fixture(t); + f.env.PATH = f.home; + const p = cli(f); + assert.equal(p.status, 1); + assert.equal(JSON.parse(p.stdout).checks.find(c => c.id === 'SDK_PATH').status, 'fail'); +}); + +test('probe failures do not disclose stderr or raw invalid JSON', t => { + const f = fixture(t, { mode: 'bad' }); + const p = cli(f); + assert.equal(p.status, 1); + assert.doesNotMatch(p.stdout + p.stderr, /secret refresh_token/); + assert.ok(JSON.parse(p.stdout).summary.failures > 0); +}); + +test('unknown platform produces structured error without starting subprocesses', async () => { + const { diagnose } = await load(); + const r = diagnose({}, { platform: 'freebsd', env: {}, home: '/tmp' }); + assert.equal(r.exitCode, 1); + assert.equal(r.checks[0].id, 'PLATFORM'); +}); + +test('strict mode promotes configuration warnings to failing exit status', t => { + const f = fixture(t); + assert.equal(cli(f, '--strict').status, 1); +}); + +test('separate SDKs on PATH are reported, never auto-selected for update', async t => { + const f = fixture(t); + const bin2 = path.join(f.root, 'another bin'); + mkdirSync(bin2); + const target = path.join(bin2, path.basename(f.executable)); + writeFileSync(target, process.platform === 'win32' ? '@echo off\r\nexit /b 1\r\n' : '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + f.env.PATH += path.delimiter + bin2; + const p = cli(f); + const c = JSON.parse(p.stdout).checks.find(c => c.id === 'SDK_PATH'); + assert.equal(c.status, 'warn'); + assert.equal(c.details.candidates.length, 2); +}); + +test('empty PATH entries never cause current-directory executable discovery', async t => { + const f = fixture(t); + const { findExecutables } = await load(); + assert.deepEqual(findExecutables({ platform: process.platform, env: { PATH: path.delimiter }, cwd: f.bin }), []); +}); + +test('symlink aliases to one SDK do not create false ambiguity', { skip: process.platform === 'win32' }, async t => { + const f = fixture(t); + const bin2 = path.join(f.root, 'alias'); mkdirSync(bin2); + symlinkSync(f.executable, path.join(bin2, 'gcloud')); + const { findExecutables } = await load(); + assert.equal(findExecutables({ platform: process.platform, env: { PATH: f.bin + path.delimiter + bin2 } }).length, 1); +}); + +test('per-probe timeout yields failure instead of hanging', { skip: process.platform === 'win32' }, t => { + const f = fixture(t, { mode: 'hang' }); + const p = cli(f, '--timeout-ms', '100'); + assert.equal(p.status, 1); + assert.match(p.stdout, /timed out/); +}); + +import { readFileSync, cpSync } from 'node:fs'; + +test('dashboard registration, skill mirror and npm scripts remain aligned', () => { + const root = new URL('../', import.meta.url); + const registry = JSON.parse(readFileSync(new URL('skills.json', root), 'utf8')); + assert.equal(registry.filter(s => s.name === 'gcloud-doctor').length, 1); + assert.equal(new Set(registry.map(s => s.name)).size, registry.length); + const canonical = readFileSync(new URL('plugin/skills/gcloud-doctor/SKILL.md', root), 'utf8'); + assert.equal(readFileSync(new URL('skills/gcloud-doctor.md', root), 'utf8'), canonical); + assert.match(canonical, /name: gcloud-doctor/); + assert.match(canonical, /## Verify/); + const pkg = JSON.parse(readFileSync(new URL('package.json', root), 'utf8')); + assert.equal(pkg.scripts['doctor:gcloud'], 'node plugin/skills/gcloud-doctor/scripts/doctor.mjs'); + assert.equal(pkg.scripts.start, 'node server.ts'); + assert.equal(pkg.engines.node, '>=22.18.0'); + assert.equal(Object.keys(pkg.dependencies || {}).length, 0); +}); + +test('installed skill is self-contained outside the GitMir repository', t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'gitmir installed skill ')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const installed = path.join(root, 'gcloud-doctor'); + cpSync(fileURLToPath(new URL('../plugin/skills/gcloud-doctor', import.meta.url)), installed, { recursive: true }); + const p = spawnSync(process.execPath, [path.join(installed, 'scripts', 'doctor.mjs'), '--help'], { cwd: root, encoding: 'utf8' }); + assert.equal(p.status, 0, p.stderr); + assert.ok(existsSync(path.join(installed, 'references', 'maintenance.md'))); +}); + +test('credential overrides are flagged without exposing their values', t => { + const f = fixture(t, { config: { core: { project: 'example-project' }, auth: { impersonate_service_account: 'private@example.test', access_token_file: 'SECRET_TOKEN' } } }); + const p = cli(f); + const report = JSON.parse(p.stdout); + assert.equal(report.checks.find(c => c.id === 'CLI_AUTH_OVERRIDES').status, 'warn'); + assert.doesNotMatch(p.stdout, /private@example|SECRET_TOKEN/); +}); + +test('Windows process creation works with a real CMD fixture', { skip: process.platform !== 'win32' }, t => { + const f = fixture(t); + const p = cli(f); + assert.equal(p.status, 0, p.stderr); + assert.equal(JSON.parse(p.stdout).checks.find(c => c.id === 'SDK_VERSION').status, 'pass'); +}); + +test('skill executes through the symlink or junction used by GitMir installers', t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'gitmir linked skill ')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const source = fileURLToPath(new URL('../plugin/skills/gcloud-doctor', import.meta.url)); + const linked = path.join(root, 'gcloud-doctor'); + symlinkSync(source, linked, process.platform === 'win32' ? 'junction' : 'dir'); + const p = spawnSync(process.execPath, [path.join(linked, 'scripts', 'doctor.mjs'), '--help'], { cwd: root, encoding: 'utf8' }); + assert.equal(p.status, 0, p.stderr); + assert.match(p.stdout, /GitMir gcloud-doctor/); +});