From 7016f6e5235b7ebd379ef6bba3f49e824fbaff98 Mon Sep 17 00:00:00 2001 From: Dimkox Date: Thu, 10 Sep 2026 22:15:34 +0300 Subject: [PATCH 1/2] feat: add optional cross-platform gcloud-doctor skill Add dependency-free local SDK, Python, PATH, project, CLI and ADC diagnostics for Windows, macOS and Linux. Bundle runtime and maintenance reference inside the installed skill, register the dashboard entry, and expose npm commands without changing server startup or dependencies. Add 25 fixture-based tests and a Windows/macOS/Linux matrix for Node 22.18.0 and 24.x. Local Linux result: 24 passed, 1 Windows-only test skipped. Live cloud authentication, package updates and original Windows registry repair are not claimed as verified. --- .github/workflows/gcloud-doctor.yml | 52 ++++ README.md | 22 +- package.json | 4 +- plugin/skills/gcloud-doctor/SKILL.md | 84 ++++++ .../gcloud-doctor/references/maintenance.md | 197 ++++++++++++++ .../skills/gcloud-doctor/scripts/doctor.mjs | 223 +++++++++++++++ skills.json | 7 + skills/gcloud-doctor.md | 84 ++++++ tests/gcloud-doctor.test.mjs | 254 ++++++++++++++++++ 9 files changed, 925 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/gcloud-doctor.yml create mode 100644 plugin/skills/gcloud-doctor/SKILL.md create mode 100644 plugin/skills/gcloud-doctor/references/maintenance.md create mode 100644 plugin/skills/gcloud-doctor/scripts/doctor.mjs create mode 100644 skills/gcloud-doctor.md create mode 100644 tests/gcloud-doctor.test.mjs 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..e013cd0 --- /dev/null +++ b/plugin/skills/gcloud-doctor/scripts/doctor.mjs @@ -0,0 +1,223 @@ +#!/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 reads 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; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) 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..9fecefb --- /dev/null +++ b/tests/gcloud-doctor.test.mjs @@ -0,0 +1,254 @@ +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'); +}); From cead3596b8050aecf7bbdac3ce0bedf982d1b3ad Mon Sep 17 00:00:00 2001 From: Dimkox Date: Thu, 10 Sep 2026 22:21:05 +0300 Subject: [PATCH 2/2] fix: execute gcloud-doctor through installed skill links Resolve both entry-point paths before comparing them so the symlink/junction layout used by GitMir installers actually runs the CLI. Add a regression test that fails with empty stdout before the fix and passes afterward. Local verification: syntax check plus 26 tests; 25 passed, 1 Windows-only test skipped, zero failures. Remote cross-platform matrix is rerun for this commit. --- plugin/skills/gcloud-doctor/scripts/doctor.mjs | 11 +++++++++-- tests/gcloud-doctor.test.mjs | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/plugin/skills/gcloud-doctor/scripts/doctor.mjs b/plugin/skills/gcloud-doctor/scripts/doctor.mjs index e013cd0..ef2c6bf 100644 --- a/plugin/skills/gcloud-doctor/scripts/doctor.mjs +++ b/plugin/skills/gcloud-doctor/scripts/doctor.mjs @@ -13,7 +13,7 @@ Usage: node doctor.mjs [--json] [--project PROJECT_ID] [--min-version X.Y.Z] [--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 reads or cloud resource changes. +No login, update, registry edits, credential export or cloud resource changes. Repairs require explicit approval; read references/maintenance.md in this skill. `; @@ -220,4 +220,11 @@ export function main(argv = process.argv.slice(2)) { } } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(); +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/tests/gcloud-doctor.test.mjs b/tests/gcloud-doctor.test.mjs index 9fecefb..ec79007 100644 --- a/tests/gcloud-doctor.test.mjs +++ b/tests/gcloud-doctor.test.mjs @@ -252,3 +252,14 @@ test('Windows process creation works with a real CMD fixture', { skip: process.p 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/); +});