diff --git a/.github/workflows/google-cloud-tests.yml b/.github/workflows/google-cloud-tests.yml new file mode 100644 index 0000000..b2c1664 --- /dev/null +++ b/.github/workflows/google-cloud-tests.yml @@ -0,0 +1,38 @@ +name: Google Cloud integration tests +on: + push: + paths: + - 'plugin/skills/google-cloud/**' + - 'skills/google-cloud.md' + - 'skills.json' + - 'package.json' + - 'tests/google-cloud.test.mjs' + - '.github/workflows/google-cloud-tests.yml' + pull_request: + paths: + - 'plugin/skills/google-cloud/**' + - 'skills/google-cloud.md' + - 'skills.json' + - 'package.json' + - 'tests/google-cloud.test.mjs' + - '.github/workflows/google-cloud-tests.yml' +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.18.0' + - name: Check syntax and run dependency-free tests + run: | + node --check plugin/skills/google-cloud/scripts/gcloud.mjs + node --check plugin/skills/google-cloud/scripts/doctor.mjs + npm run test:gcloud diff --git a/README.md b/README.md index ca66aa3..c1d8382 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,28 @@ 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 | +| `$google-cloud` | SDK/context inspection, explicit GCP project binding, opt-in project-read verification and cloud task planning | Flat copies under `skills/` are for the dashboard UI (copy-paste). Canonical Codex layout is `plugin/skills//SKILL.md`. --- +## Google Cloud integration + +Google Cloud is an optional integration for projects managed by GitMir, **not a requirement to host GitMir in the cloud**. The existing dashboard skill catalog and the installable Codex plugin both expose `$google-cloud`. + +From the GitMir checkout, inspect the local SDK and CLI context: + +```bash +npm run gcloud:doctor -- --directory "/path/to/your/project" +``` + +The helper reads `.gitmir/google-cloud.json` in that project when present. Add `--verify-project` only to explicitly read the bound project's metadata through Google Cloud API. No deployment, billing changes, global project switching, token export or SDK update is performed. Local metadata is **not** proof of working cloud credentials or deployment permissions. + +[Full setup, commands, limitations and verification](docs/google-cloud.md) · [Windows SDK / WinGet maintenance recipe](docs/windows/gcloud-winget-version-repair/README.md) + +--- + ## Typical flow ```text diff --git a/docs/google-cloud.md b/docs/google-cloud.md new file mode 100644 index 0000000..882ede8 --- /dev/null +++ b/docs/google-cloud.md @@ -0,0 +1,158 @@ +# Google Cloud в GitMir + +## Что интегрировано + +GitMir получает отдельный навык `$google-cloud`, запись Google Cloud в существующем каталоге навыков панели и исполняемую диагностику на Node.js. Это интеграция для **проектов, которыми управляет GitMir**, а не перенос самой панели в Google Cloud. + +Диагностика читает версию SDK и локальный контекст `gcloud`, связывает локальный репозиторий с явно указанным GCP project ID и по отдельному флагу проверяет чтение метаданных этого проекта. Навык передаёт дальнейшую облачную работу в существующие `$task-planner`, `$task-runner` и модель GitMir. + +Windows-рецепт исправления WinGet `Unknown` теперь имеет определённое место: **обслуживание локального инструментария этой интеграции**. Он не является этапом запуска GitMir и не выполняется автоматически. + +## Установка и точки входа + +Требования проекта остаются прежними: Node.js 22.18+; для диагностики нужен установленный `gcloud`. На Windows используется системный Windows PowerShell для запуска `gcloud.cmd`. Дополнительных runtime-зависимостей npm нет. Права администратора для обычной диагностики не нужны; не запускайте её под другой учётной записью без необходимости. + +После получения ветки с интеграцией выполните существующий `install.ps1` на Windows или `install.sh` на macOS/Linux и перезапустите Codex. Установщики связывают целые каталоги навыков, поэтому вспомогательные скрипты устанавливаются вместе с `SKILL.md`. В Codex: + +```text +$google-cloud Проверь локальный SDK и контекст этого проекта. Ничего в облаке не меняй. +``` + +В панели GitMir используйте запись **Google Cloud** в существующем каталоге навыков. Это обычный навык панели, а не новая вкладка с live-метриками, OAuth-окно или облачный API на сервере GitMir. Backend и frontend панели не переписаны. + +Из каталога GitMir доступна команда: + +```powershell +npm run gcloud:doctor -- --directory "C:\Projects\my-app" +``` + +На macOS/Linux та же команда принимает POSIX-путь: + +```bash +npm run gcloud:doctor -- --directory "/home/user/projects/my-app" +``` + +`--directory` указывает именно на **управляемый проект**. По умолчанию используется текущий рабочий каталог процесса; при `npm run` это обычно корень GitMir. Несуществующий каталог считается ошибкой, а не новым пустым проектом. + +## Привязка проекта + +В каталоге управляемого проекта создайте `.gitmir/google-cloud.json` с подтверждёнными значениями. Полный пример формата: + +```json +{ + "projectId": "demo-project-123", + "region": "europe-west1" +} +``` + +Это демонстрационные значения, не идентификаторы пользователя. `projectId` обязателен при наличии файла, `region` необязателен. Разрешены только эти поля; неизвестные поля отвергаются. Поддерживаются стандартные project ID длиной 6–30 символов из строчных латинских букв, цифр и дефисов. Числовые project numbers и старые domain-scoped IDs здесь не поддерживаются. + +Файл ограничен 16 KiB и должен содержать JSON. UTF-8 BOM поддерживается. `region` проходит только проверку формата: доступность региона для конкретной службы не проверяется, глобальная команда `projects describe` его не использует. + +Без файла возможна локальная диагностика. Проверка Cloud API без явной привязки запрещена: случайный активный проект в `gcloud` не становится целью. Диагностика сама файл не создаёт и не меняет. + +Токены, OAuth client secrets, JSON-ключи service accounts, пароли и содержимое ADC в этот файл не помещаются. Сам project ID также может быть внутренней информацией: решение о коммите привязки остаётся за владельцем проекта. + +## Режимы запуска + +Локальная диагностика в человекочитаемом виде: + +```powershell +npm run gcloud:doctor -- --directory "C:\Projects\my-app" +``` + +Чистый JSON без служебного вывода npm: + +```powershell +node plugin/skills/google-cloud/scripts/doctor.mjs --directory "C:\Projects\my-app" --json +``` + +Явная проверка доступа к привязанному проекту через Google Cloud API: + +```powershell +npm run gcloud:doctor -- --directory "C:\Projects\my-app" --verify-project --json +``` + +Справка: + +```powershell +npm run gcloud:doctor -- --help +``` + +Обычный запуск выполняет только три команды чтения локального контекста: `gcloud version`, `gcloud config list` и `gcloud auth list`. Две последние команды возвращают свойства активной конфигурации и сведения о сохранённых аккаунтах, а не доказательство актуальности credentials.[1][2] + +`--verify-project` добавляет `gcloud projects describe` с project ID одновременно в позиционном аргументе и `--project`. Команда запрашивает метаданные конкретного проекта и может завершиться ошибкой из-за отсутствия проекта или прав.[3] Глобальная конфигурация CLI при этом не меняется. Используются текущая конфигурация аутентификации, environment overrides и настроенная impersonation; это не изолированный вход в другой аккаунт. + +## Значение результата + +| Статус | Что доказано | Что не доказано | +|---|---|---| +| `local-only` | SDK ответил, локальные команды отработали и выдали ожидаемую структуру | Доступ к Cloud API, ADC, биллинг, права деплоя | +| `verified-project-read` | Метаданные именно привязанного проекта прочитаны; lifecycleState — ACTIVE | Права на Cloud Run, IAM, Artifact Registry, Google Ads API, квоты и биллинг | +| `blocked` | Один из этапов не завершён успешно | Любая готовность, зависящая от этого этапа | + +Коды завершения: **0** — запрошенные проверки выполнены; **1** — ошибка проверки; **2** — ошибка аргументов или привязки. Код 0 в локальном режиме не означает «облако готово к деплою». Поле `cloudAccessVerified` относится только к чтению метаданных проекта. + +В отчёт попадают версия SDK, project binding, выбранный аккаунт и проект CLI, настроенная impersonation, количество локальных аккаунтов, предупреждения и код ошибки этапа. Сырые JSON-ответы CLI и stderr в отчёт не копируются. Тем не менее email аккаунта и project ID нельзя считать автоматически пригодными к публичной публикации. Отчёты не записываются в проект и не коммитятся автоматически. + +ADC всегда помечается как `not-checked`. Аутентификация `gcloud` и Application Default Credentials — разные конфигурации: успешный вход в CLI не доказывает работу SDK приложения, а отсутствие локального аккаунта CLI не доказывает отсутствие ADC или workload identity.[4] + +## Ошибки и диагностика + +`GCLOUD_NOT_FOUND`: `gcloud` не найден в PATH процесса; на Windows проверьте наличие `gcloud.cmd` и системного Windows PowerShell. Перезапустите терминал после изменения PATH. Не подменяйте отсутствующий CLI скачанным из случайного источника скриптом. + +`GCLOUD_COMMAND_FAILED`: команда завершилась ошибкой. Определите этап (`version`, `config`, `auth`, `project`) и при необходимости выполните соответствующую команду локально для просмотра ошибки. Не публикуйте полный debug-log без проверки на секреты. + +`GCLOUD_TIMEOUT`: процесс команды превысил лимит 15 секунд. Это лимит одного вызова, а не обещание времени выполнения всей диагностики. На Windows остановка PowerShell по тайм-ауту не является гарантией завершения всех потомков; при зависшем SDK проверьте процессы перед повторным запуском. + +`GCLOUD_OUTPUT_LIMIT`: stdout/stderr превысил 1 MiB. `INVALID_JSON` и `INVALID_RESPONSE`: формат ответа не соответствует ожиданиям; не записывайте фиктивную версию или фиктивный статус, чтобы обойти проверку. + +`CLI_PROJECT_DIFFERS_FROM_BINDING` — предупреждение, не команда переключить глобальный проект. `NO_LOCAL_CREDENTIALS` и `CONFIGURED_ACCOUNT_NOT_LISTED` не проверяют валидность ADC. Настроенная service-account impersonation отмечается отдельно. + +## Как это связано с моделью и задачами GitMir + +Для подготовки облачного изменения навык использует фактическую привязку и диагностику, затем передаёт задачу существующему планировщику. Локальные изменения приложения отделяются от удалённых операций. В задачах должны быть указаны ресурс, project ID, необходимые API/IAM-права, возможные расходы, откат и исполняемые проверки `## Verify`. + +Сам исполняемый helper **не содержит** команд deploy/delete, IAM, services enable, billing, auth login, print-access-token, config set или components update. Он не принимает произвольные аргументы `gcloud` из файла проекта. Его process adapter допускает только три локальные команды и одно чтение проекта с валидным ID. + +Граница helper обеспечивается кодом, а правила поведения Codex — инструкциями навыка. Навык не является технической песочницей для всего Codex: общие разрешения агента по-прежнему определяются его средой выполнения и настройками оператора. + +На Windows вызов `gcloud.cmd` идёт через фиксированный PowerShell-скрипт с `-NoProfile`, `-NonInteractive`, `-EncodedCommand` и отдельным quoting аргументов; прямой `execFile` для `.cmd` не применяется. Особенность запуска `.cmd` в Windows описана Node.js.[5] Execution Policy системы не отключается. + +## Обслуживание Windows SDK и WinGet + +[Рецепт обновления до 584.0.0 и исправления Unknown](windows/gcloud-winget-version-repair/README.md) сохранён отдельно. Он не запускается ни установщиком навыка, ни doctor, ни стартом панели. + +Версия **584.0.0** в том рецепте — историческая цель конкретного разбора, а не актуальная минимальная версия этой интеграции. Doctor не требует её установки. Нельзя понижать более свежий SDK ради совпадения с примером. Успешный end-to-end прогон исходного Windows-рецепта на компьютере пользователя ранее не был подтверждён; добавление интеграции этого статуса не меняет. + +Принцип обслуживания: сначала проверить реальную версию; при необходимости и по отдельному решению обновить SDK; только затем исправлять metadata `DisplayVersion`. Не путать отсутствующие сведения WinGet с pinning и не записывать неустановленную версию. + +## Проверки и границы валидации + +```powershell +npm run test:gcloud +``` + +Тесты используют встроенный `node:test`. Они проверяют локальный режим без удалённых команд, явную привязку, отказ при неверной схеме, фильтрацию секретных полей, ошибки SDK и API, запуск реального дочернего процесса с подставным `gcloud`, пути с пробелами, ссылки/junctions установки, CLI exit codes и совпадение навыка в двух каталогах. + +Workflow `.github/workflows/google-cloud-tests.yml` запускает этот набор на Ubuntu, Windows и macOS с Node.js 22.18.0. Наличие workflow само по себе не означает, что конкретный CI-прогон уже зелёный: смотрите Checks у коммита или PR. + +Подставной CLI — не реальный Google Cloud SDK. Проверка на настоящем аккаунте, Windows-ремонт и визуальный запуск панели не заявляются выполненными без соответствующего вывода. Полный typecheck существующего приложения относится к отдельному набору проверок; этот модуль не меняет TypeScript backend/frontend. + +## Исходники + +- `plugin/skills/google-cloud/SKILL.md` — основной навык Codex. +- `skills/google-cloud.md` — идентичная копия для панели, зарегистрированная в `skills.json`. +- `plugin/skills/google-cloud/scripts/doctor.mjs` — привязка, диагностика, отчёт и CLI. +- `plugin/skills/google-cloud/scripts/gcloud.mjs` — ограниченный process adapter. +- `tests/google-cloud.test.mjs` — unit/integration/packaging проверки. + +## Официальные источники + +[1]: https://docs.cloud.google.com/sdk/gcloud/reference/config/list +[2]: https://docs.cloud.google.com/sdk/gcloud/reference/auth/list +[3]: https://docs.cloud.google.com/sdk/gcloud/reference/projects/describe +[4]: https://docs.cloud.google.com/sdk/docs/authenticate +[5]: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows + +[1] [gcloud config list][1] · [2] [gcloud auth list][2] · [3] [gcloud projects describe][3] · [4] [CLI authentication и ADC][4] · [5] [Node.js: запуск .cmd в Windows][5]. diff --git a/package.json b/package.json index bd852e0..08f9074 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ }, "scripts": { "start": "node server.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "gcloud:doctor": "node plugin/skills/google-cloud/scripts/doctor.mjs", + "test:gcloud": "node --test tests/google-cloud.test.mjs" }, "devDependencies": { "@types/node": "^26.1.2", diff --git a/plugin/skills/google-cloud/SKILL.md b/plugin/skills/google-cloud/SKILL.md new file mode 100644 index 0000000..ee352e7 --- /dev/null +++ b/plugin/skills/google-cloud/SKILL.md @@ -0,0 +1,64 @@ +--- +name: google-cloud +description: Use when a GitMir-managed project uses Google Cloud, needs gcloud SDK or account diagnostics, has an ambiguous GCP project target, needs cloud task planning, or has Windows WinGet Unknown metadata for Google Cloud SDK. +--- + +# Google Cloud for GitMir + +Connect a local project's GitMir model and verified task workflow to an explicit Google Cloud project. GitMir remains local; this skill does not deploy the dashboard to Google Cloud. + +## Start with evidence + +Identify the local project directory from the current task. Read its `AGENTS.md` and existing `.gitmir/model/` context when present. Do not infer a cloud target from a repository name, an account email, or an unrelated project in the CLI configuration. + +Use the bundled `scripts/doctor.mjs` relative to this skill's directory. Resolve its absolute path and run Node with `--directory` pointing to the local project and `--json`. Quote paths as arguments in the current shell. The helper travels with the skill through GitMir's directory-link installers and plugin package. + +For a dashboard copy of this prompt, locate the installed `~/.agents/skills/google-cloud/scripts/doctor.mjs`, or the same file under `plugin/skills/google-cloud/scripts/` in the user's GitMir checkout. Do not invent a path. If neither exists, report that the helper is missing; do not silently download and execute a remote script. + +The normal inspection only invokes `gcloud version`, `gcloud config list` and `gcloud auth list` with bounded JSON output. It does not authenticate, switch CLI configuration, update the SDK, alter the registry, print tokens or create cloud resources. + +## Bind the project explicitly + +The binding is `.gitmir/google-cloud.json` **inside the managed project**, not necessarily inside the GitMir checkout: + +```json +{ + "projectId": "demo-project-123", + "region": "europe-west1" +} +``` + +These are example identifiers, not approved deployment targets. Use values already supplied by the operator or authoritative project configuration. The file only accepts `projectId` and optional `region`; never put passwords, service-account keys, refresh tokens, OAuth client secrets or ADC contents in it. Region is descriptive context, not a verified service location. + +If the operator requests verification against Google Cloud, use the helper's `--verify-project` flag. It requires the binding and performs only `gcloud projects describe` against the bound project. A different global CLI project is reported, not silently changed. Without a requested cloud read, remain in local inspection mode. + +## Interpret results without inflating confidence + +- `local-only`: local commands completed. Cloud access, billing, API availability and deployment permissions remain unverified, even with exit code 0. +- `verified-project-read`: the bound project's metadata was read and its lifecycle was ACTIVE. This does not prove Cloud Run, IAM, Artifact Registry, Google Ads API or billing permissions. +- `blocked`: a check failed. Use the reported step and error code; do not invent a successful result or change identities to bypass the failure. + +`gcloud` authentication and Application Default Credentials are distinct configurations. The helper deliberately reports ADC as `not-checked`. An empty `gcloud auth list` is not proof that ADC or a workload identity cannot work. Likewise, a listed account is not proof that its credentials remain valid. + +Do not persist reports or account identities into a public repository automatically. The report excludes raw credentials, but project IDs and account names can still be sensitive. + +## Feed the existing GitMir workflow + +For cloud implementation work, pass the operator's goal, explicit project binding and actual diagnostic result to `$task-planner`. Separate local code/configuration changes from remote cloud changes. Every remote task must state the exact project/resource, required API/IAM permissions, cost exposure, rollback and `## Verify` commands before it can be executed. + +Use `$task-runner` only within the approved scope. An inspection request never authorizes deployment, IAM changes, service enablement, billing changes, secret creation, resource deletion or SDK maintenance. Do not deploy automatically. Model updates must follow the existing model schema and preserve its stable IDs. Record verified resource references and checks, not credentials or invented readiness percentages. + +## Windows SDK maintenance + +WinGet's available version is not necessarily the installed version. `Unknown` is not a pin. Read the actual SDK version with `gcloud version --format=json` before editing `DisplayVersion`. + +In the GitMir checkout, `docs/windows/gcloud-winget-version-repair/README.md` documents the separate, opt-in 584.0.0 maintenance recipe: copy bundled Python, temporarily set `CLOUDSDK_PYTHON`, update, verify the real version, then change the existing uninstall metadata. Its original machine's successful end-to-end run was not confirmed. Never trigger it from the diagnostic helper, downgrade a newer SDK to match that historical example, or stamp a version that is not installed. + +The plugin-only package may not contain the repository's `docs/` tree; report that limitation rather than fabricating or executing a missing maintenance script. + +## References + +- [gcloud config list](https://docs.cloud.google.com/sdk/gcloud/reference/config/list) +- [gcloud auth list](https://docs.cloud.google.com/sdk/gcloud/reference/auth/list) +- [gcloud projects describe](https://docs.cloud.google.com/sdk/gcloud/reference/projects/describe) +- [gcloud CLI authentication and ADC](https://docs.cloud.google.com/sdk/docs/authenticate) diff --git a/plugin/skills/google-cloud/scripts/doctor.mjs b/plugin/skills/google-cloud/scripts/doctor.mjs new file mode 100644 index 0000000..c75e672 --- /dev/null +++ b/plugin/skills/google-cloud/scripts/doctor.mjs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { COMMANDS, PROJECT_ID, projectCommand, runGcloud } from './gcloud.mjs'; + +const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const text = value => typeof value === 'string' && value.length <= 320 && !/[\x00-\x1f\x7f]/.test(value) ? value : null; +const invalid = () => Object.assign(new Error('Unexpected gcloud response schema.'), { code: 'INVALID_RESPONSE' }); + +function validateBinding(value) { + if (!isObject(value) || Object.keys(value).some(key => !['projectId', 'region'].includes(key)) + || typeof value.projectId !== 'string' || !PROJECT_ID.test(value.projectId) + || (value.region !== undefined && (typeof value.region !== 'string' || !/^[a-z][a-z0-9-]{1,62}$/.test(value.region)))) { + throw new Error('Invalid .gitmir/google-cloud.json: only a valid projectId and optional region are accepted; no credentials.'); + } + return value.region === undefined ? { projectId: value.projectId } : { projectId: value.projectId, region: value.region }; +} + +export async function readBinding(directory) { + const base = path.resolve(directory); + const directoryInfo = await fs.stat(base).catch(() => null); + if (!directoryInfo?.isDirectory()) throw new Error('Target directory does not exist or is not accessible.'); + const filename = path.join(base, '.gitmir', 'google-cloud.json'); + let handle; + try { + handle = await fs.open(filename, 'r'); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw new Error('Cannot read the project binding. Check file permissions.'); + } + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > 16384) throw new Error('Invalid binding file.'); + const content = await handle.readFile('utf8'); + return validateBinding(JSON.parse(content.replace(/^\uFEFF/, ''))); + } catch { + throw new Error('Invalid .gitmir/google-cloud.json: expected a small JSON object with projectId and optional region only.'); + } finally { + await handle.close(); + } +} + +/** Local metadata and project-read access are different levels of evidence. */ +export async function inspectGoogleCloud({ binding = null, verifyProject = false, run = runGcloud } = {}) { + if (binding !== null) binding = validateBinding(binding); + if (verifyProject && !binding) throw new Error('--verify-project requires an explicit .gitmir/google-cloud.json binding.'); + const report = { + schemaVersion: 1, + checkedAt: new Date().toISOString(), + status: 'local-only', + sdkVersion: null, + binding, + cli: { account: null, projectId: null, impersonatedServiceAccount: null }, + credentialedAccounts: 0, + configuredAccountListed: false, + adc: 'not-checked', + cloudAccessVerified: false, + projectState: null, + warnings: [], + errors: [], + }; + if (!binding) report.warnings.push('NO_PROJECT_BINDING'); + let step = 'version'; + try { + const version = await run(COMMANDS.version); + if (!isObject(version) || typeof version['Google Cloud SDK'] !== 'string' || !/^\d+\.\d+\.\d+$/.test(version['Google Cloud SDK'])) throw invalid(); + report.sdkVersion = version['Google Cloud SDK']; + step = 'config'; + const config = await run(COMMANDS.config); + if (!isObject(config) || (config.core !== undefined && !isObject(config.core)) || (config.auth !== undefined && !isObject(config.auth))) throw invalid(); + report.cli = { + account: text(config.core?.account), + projectId: text(config.core?.project), + impersonatedServiceAccount: text(config.auth?.impersonate_service_account), + }; + if (binding && report.cli.projectId && binding.projectId !== report.cli.projectId) report.warnings.push('CLI_PROJECT_DIFFERS_FROM_BINDING'); + if (report.cli.impersonatedServiceAccount) report.warnings.push('SERVICE_ACCOUNT_IMPERSONATION_CONFIGURED'); + step = 'auth'; + const accounts = await run(COMMANDS.auth); + if (!Array.isArray(accounts) || accounts.some(account => !isObject(account) || !text(account.account))) throw invalid(); + report.credentialedAccounts = accounts.length; + report.configuredAccountListed = accounts.some(account => account.account === report.cli.account); + if (!accounts.length) report.warnings.push('NO_LOCAL_CREDENTIALS'); + else if (!report.configuredAccountListed) report.warnings.push('CONFIGURED_ACCOUNT_NOT_LISTED'); + if (verifyProject) { + step = 'project'; + const project = await run(projectCommand(binding.projectId)); + if (!isObject(project) || project.projectId !== binding.projectId || project.lifecycleState !== 'ACTIVE') throw invalid(); + report.projectState = 'ACTIVE'; + report.cloudAccessVerified = true; + report.status = 'verified-project-read'; + } + } catch (error) { + const known = ['GCLOUD_NOT_FOUND', 'GCLOUD_TIMEOUT', 'GCLOUD_OUTPUT_LIMIT', 'GCLOUD_COMMAND_FAILED', 'INVALID_JSON', 'INVALID_RESPONSE']; + const code = error.code === 'ENOENT' ? 'GCLOUD_NOT_FOUND' : known.includes(error.code) ? error.code : 'GCLOUD_COMMAND_FAILED'; + report.errors.push({ step, code }); + report.status = 'blocked'; + } + return report; +} + +export function parseOptions(args) { + const options = { directory: process.cwd(), json: false, verifyProject: false, help: false }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--json') options.json = true; + else if (args[i] === '--verify-project') options.verifyProject = true; + else if (args[i] === '--help' || args[i] === '-h') options.help = true; + else if (args[i] === '--directory') { + const value = args[++i]; + if (!value || value.startsWith('--')) throw new Error('--directory requires a directory path.'); + options.directory = value; + } else throw new Error('Unknown argument. Use --help for supported options.'); + } + return options; +} + +export async function main(args = process.argv.slice(2)) { + let options; + try { + options = parseOptions(args); + if (options.help) { + console.log('GitMir Google Cloud doctor\nUsage: node doctor.mjs [--directory PATH] [--json] [--verify-project]\nDefault: local SDK, config and credential metadata only. No cloud writes.\n--verify-project: explicitly read the bound project through Google Cloud API.\nExit codes: 0 = checks completed (local-only is NOT cloud readiness), 1 = check failed, 2 = invalid input.'); + return 0; + } + const binding = await readBinding(options.directory); + const report = await inspectGoogleCloud({ binding, verifyProject: options.verifyProject }); + if (options.json) console.log(JSON.stringify(report, null, 2)); + else { + console.log(`GitMir Google Cloud: ${report.status}\nSDK: ${report.sdkVersion || 'unavailable'}\nAccount: ${report.cli.account || 'not configured'}\nBound project: ${report.binding?.projectId || 'not configured'}\nCLI default project: ${report.cli.projectId || 'not configured'}\nProject read verified: ${report.cloudAccessVerified}\nADC, billing and deployment permissions: not checked`); + for (const warning of report.warnings) console.log(`Warning: ${warning}`); + for (const error of report.errors) console.log(`Error: ${error.step}: ${error.code}`); + } + return report.status === 'blocked' ? 1 : 0; + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid input.'; + if (options?.json || args.includes('--json')) console.log(JSON.stringify({ schemaVersion: 1, status: 'blocked', error: 'INVALID_INPUT', message })); + else console.error(message); + return 2; + } +} + +// Installers link the whole skill directory; resolve that link before identifying the entrypoint. +const entrypoint = process.argv[1] ? await fs.realpath(process.argv[1]).catch(() => null) : null; +if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) { + process.exitCode = await main(); +} diff --git a/plugin/skills/google-cloud/scripts/gcloud.mjs b/plugin/skills/google-cloud/scripts/gcloud.mjs new file mode 100644 index 0000000..9ac0f27 --- /dev/null +++ b/plugin/skills/google-cloud/scripts/gcloud.mjs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import os from 'node:os'; +import path from 'node:path'; + +const execute = promisify(execFile); +export const PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/; +export const COMMANDS = Object.freeze({ + version: Object.freeze(['version', '--format=json', '--quiet']), + config: Object.freeze(['config', 'list', '--format=json(core.account,core.project,auth.impersonate_service_account)', '--quiet']), + auth: Object.freeze(['auth', 'list', '--format=json(account,status)', '--quiet']), +}); + +export function projectCommand(projectId) { + if (typeof projectId !== 'string' || !PROJECT_ID.test(projectId)) throw new Error('Invalid project ID.'); + return ['projects', 'describe', projectId, `--project=${projectId}`, '--format=json(projectId,lifecycleState)', '--quiet']; +} + +/** Keep the process boundary narrower than a generic gcloud command runner. */ +export function buildInvocation(args, { platform = process.platform, env = process.env } = {}) { + if (!Array.isArray(args) || !args.every(arg => typeof arg === 'string')) throw new Error('Command is not allowed.'); + const same = allowed => args.length === allowed.length && args.every((arg, i) => arg === allowed[i]); + const isLocal = Object.values(COMMANDS).some(same); + const isProjectRead = args[0] === 'projects' && PROJECT_ID.test(args[2] || '') && same(projectCommand(args[2])); + if (!isLocal && !isProjectRead) throw new Error('Command is not allowed.'); + if (platform !== 'win32') return { file: 'gcloud', args: [...args] }; + + // .cmd launchers need a Windows command interpreter. Encode a fixed PowerShell + // script with individually quoted, allowlisted arguments; do not interpolate cmd.exe text. + const quoted = args.map(arg => "'" + arg.replaceAll("'", "''") + "'").join(' '); + const script = "$ErrorActionPreference='Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $OutputEncoding=[Console]::OutputEncoding; if (-not (Get-Command gcloud.cmd -CommandType Application -ErrorAction SilentlyContinue)) { exit 127 }; & gcloud.cmd " + quoted + '; exit $LASTEXITCODE'; + const systemRoot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'; + return { + file: path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + }; +} + +/** Execute bounded, noninteractive reads. Raw stdout/stderr never enter errors. */ +export async function runGcloud(args, { env = process.env, timeout = 15000 } = {}) { + const invocation = buildInvocation(args, { env }); + let stdout; + try { + ({ stdout } = await execute(invocation.file, invocation.args, { + cwd: os.homedir(), + env: { + ...env, + CLOUDSDK_CORE_DISABLE_PROMPTS: '1', + CLOUDSDK_CORE_DISABLE_USAGE_REPORTING: '1', + CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK: '1', + CLOUDSDK_CORE_DISABLE_FILE_LOGGING: '1', + PYTHONIOENCODING: 'utf-8', + }, + shell: false, + windowsHide: true, + encoding: 'utf8', + timeout, + maxBuffer: 1024 * 1024, + })); + } catch (error) { + const code = error.code === 'ENOENT' || error.code === 127 ? 'GCLOUD_NOT_FOUND' + : error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? 'GCLOUD_OUTPUT_LIMIT' + : error.killed ? 'GCLOUD_TIMEOUT' + : 'GCLOUD_COMMAND_FAILED'; + throw Object.assign(new Error(code), { code }); + } + try { + return JSON.parse(stdout.replace(/^\uFEFF/, '')); + } catch { + throw Object.assign(new Error('gcloud returned invalid JSON.'), { code: 'INVALID_JSON' }); + } +} diff --git a/skills.json b/skills.json index 44589c1..a3376dd 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": "google-cloud", + "title": "Google Cloud", + "desc": "Inspect the Google Cloud SDK, bind this local project to an explicit GCP project, verify project-read access on request, and plan cloud work through GitMir. No automatic deployment or SDK updates.", + "file": "skills/google-cloud.md", + "stripFrontmatter": true } ] diff --git a/skills/google-cloud.md b/skills/google-cloud.md new file mode 100644 index 0000000..ee352e7 --- /dev/null +++ b/skills/google-cloud.md @@ -0,0 +1,64 @@ +--- +name: google-cloud +description: Use when a GitMir-managed project uses Google Cloud, needs gcloud SDK or account diagnostics, has an ambiguous GCP project target, needs cloud task planning, or has Windows WinGet Unknown metadata for Google Cloud SDK. +--- + +# Google Cloud for GitMir + +Connect a local project's GitMir model and verified task workflow to an explicit Google Cloud project. GitMir remains local; this skill does not deploy the dashboard to Google Cloud. + +## Start with evidence + +Identify the local project directory from the current task. Read its `AGENTS.md` and existing `.gitmir/model/` context when present. Do not infer a cloud target from a repository name, an account email, or an unrelated project in the CLI configuration. + +Use the bundled `scripts/doctor.mjs` relative to this skill's directory. Resolve its absolute path and run Node with `--directory` pointing to the local project and `--json`. Quote paths as arguments in the current shell. The helper travels with the skill through GitMir's directory-link installers and plugin package. + +For a dashboard copy of this prompt, locate the installed `~/.agents/skills/google-cloud/scripts/doctor.mjs`, or the same file under `plugin/skills/google-cloud/scripts/` in the user's GitMir checkout. Do not invent a path. If neither exists, report that the helper is missing; do not silently download and execute a remote script. + +The normal inspection only invokes `gcloud version`, `gcloud config list` and `gcloud auth list` with bounded JSON output. It does not authenticate, switch CLI configuration, update the SDK, alter the registry, print tokens or create cloud resources. + +## Bind the project explicitly + +The binding is `.gitmir/google-cloud.json` **inside the managed project**, not necessarily inside the GitMir checkout: + +```json +{ + "projectId": "demo-project-123", + "region": "europe-west1" +} +``` + +These are example identifiers, not approved deployment targets. Use values already supplied by the operator or authoritative project configuration. The file only accepts `projectId` and optional `region`; never put passwords, service-account keys, refresh tokens, OAuth client secrets or ADC contents in it. Region is descriptive context, not a verified service location. + +If the operator requests verification against Google Cloud, use the helper's `--verify-project` flag. It requires the binding and performs only `gcloud projects describe` against the bound project. A different global CLI project is reported, not silently changed. Without a requested cloud read, remain in local inspection mode. + +## Interpret results without inflating confidence + +- `local-only`: local commands completed. Cloud access, billing, API availability and deployment permissions remain unverified, even with exit code 0. +- `verified-project-read`: the bound project's metadata was read and its lifecycle was ACTIVE. This does not prove Cloud Run, IAM, Artifact Registry, Google Ads API or billing permissions. +- `blocked`: a check failed. Use the reported step and error code; do not invent a successful result or change identities to bypass the failure. + +`gcloud` authentication and Application Default Credentials are distinct configurations. The helper deliberately reports ADC as `not-checked`. An empty `gcloud auth list` is not proof that ADC or a workload identity cannot work. Likewise, a listed account is not proof that its credentials remain valid. + +Do not persist reports or account identities into a public repository automatically. The report excludes raw credentials, but project IDs and account names can still be sensitive. + +## Feed the existing GitMir workflow + +For cloud implementation work, pass the operator's goal, explicit project binding and actual diagnostic result to `$task-planner`. Separate local code/configuration changes from remote cloud changes. Every remote task must state the exact project/resource, required API/IAM permissions, cost exposure, rollback and `## Verify` commands before it can be executed. + +Use `$task-runner` only within the approved scope. An inspection request never authorizes deployment, IAM changes, service enablement, billing changes, secret creation, resource deletion or SDK maintenance. Do not deploy automatically. Model updates must follow the existing model schema and preserve its stable IDs. Record verified resource references and checks, not credentials or invented readiness percentages. + +## Windows SDK maintenance + +WinGet's available version is not necessarily the installed version. `Unknown` is not a pin. Read the actual SDK version with `gcloud version --format=json` before editing `DisplayVersion`. + +In the GitMir checkout, `docs/windows/gcloud-winget-version-repair/README.md` documents the separate, opt-in 584.0.0 maintenance recipe: copy bundled Python, temporarily set `CLOUDSDK_PYTHON`, update, verify the real version, then change the existing uninstall metadata. Its original machine's successful end-to-end run was not confirmed. Never trigger it from the diagnostic helper, downgrade a newer SDK to match that historical example, or stamp a version that is not installed. + +The plugin-only package may not contain the repository's `docs/` tree; report that limitation rather than fabricating or executing a missing maintenance script. + +## References + +- [gcloud config list](https://docs.cloud.google.com/sdk/gcloud/reference/config/list) +- [gcloud auth list](https://docs.cloud.google.com/sdk/gcloud/reference/auth/list) +- [gcloud projects describe](https://docs.cloud.google.com/sdk/gcloud/reference/projects/describe) +- [gcloud CLI authentication and ADC](https://docs.cloud.google.com/sdk/docs/authenticate) diff --git a/tests/google-cloud.test.mjs b/tests/google-cloud.test.mjs new file mode 100644 index 0000000..54a0eba --- /dev/null +++ b/tests/google-cloud.test.mjs @@ -0,0 +1,217 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const doctorFile = path.join(root, 'plugin/skills/google-cloud/scripts/doctor.mjs'); +const runFile = promisify(execFile); +const implementationExists = await fs.access(doctorFile).then(() => true, () => false); + +test('Google Cloud doctor is packaged with the installable skill', () => { + assert.equal(implementationExists, true, 'Missing packaged Google Cloud doctor'); +}); + +if (implementationExists) { + const { inspectGoogleCloud, readBinding, parseOptions } = await import(pathToFileURL(doctorFile).href); + const { buildInvocation, runGcloud, COMMANDS } = await import('../plugin/skills/google-cloud/scripts/gcloud.mjs'); + const binding = { projectId: 'demo-project-123', region: 'europe-west1' }; + const outputs = { + version: { 'Google Cloud SDK': '583.0.0', extra: 'must-not-leak' }, + config: { core: { account: 'operator@example.invalid', project: 'other-project' }, proxy: { password: 'SECRET-PROXY' }, auth: { access_token: 'SECRET-TOKEN' } }, + auth: [{ account: 'operator@example.invalid', status: 'ACTIVE', refresh_token: 'SECRET-REFRESH' }], + projects: { projectId: binding.projectId, lifecycleState: 'ACTIVE' }, + }; + const fake = (overrides = {}, calls = []) => async args => { + calls.push(args); + const value = Object.hasOwn(overrides, args[0]) ? overrides[args[0]] : outputs[args[0]]; + if (value instanceof Error) throw value; + return value; + }; + + test('local inspection executes exactly three read-only local commands', async () => { + const calls = []; + const report = await inspectGoogleCloud({ binding, run: fake({}, calls) }); + assert.equal(report.status, 'local-only'); + assert.equal(report.sdkVersion, '583.0.0'); + assert.equal(report.cloudAccessVerified, false); + assert.deepEqual(calls, [COMMANDS.version, COMMANDS.config, COMMANDS.auth]); + assert.equal(report.binding.projectId, binding.projectId); + assert.ok(report.warnings.includes('CLI_PROJECT_DIFFERS_FROM_BINDING')); + }); + + test('reports whitelist fields rather than exporting credentials or raw CLI data', async () => { + const report = await inspectGoogleCloud({ binding, run: fake() }); + const text = JSON.stringify(report); + for (const value of ['SECRET-PROXY', 'SECRET-TOKEN', 'SECRET-REFRESH', 'must-not-leak']) assert.ok(!text.includes(value)); + assert.equal(report.adc, 'not-checked'); + }); + + test('project access is checked only with explicit opt-in and an explicit binding', async () => { + const calls = []; + const report = await inspectGoogleCloud({ binding, verifyProject: true, run: fake({}, calls) }); + assert.equal(report.status, 'verified-project-read'); + assert.equal(report.cloudAccessVerified, true); + assert.deepEqual(calls.at(-1), ['projects', 'describe', binding.projectId, `--project=${binding.projectId}`, '--format=json(projectId,lifecycleState)', '--quiet']); + }); + + test('remote verification refuses ambient project fallback before running anything', async () => { + const calls = []; + await assert.rejects(inspectGoogleCloud({ verifyProject: true, run: fake({}, calls) }), /binding/i); + assert.equal(calls.length, 0); + }); + + test('missing gcloud is a blocked result, not a green readiness assertion', async () => { + const report = await inspectGoogleCloud({ binding, run: fake({ version: Object.assign(new Error('secret'), { code: 'ENOENT' }) }) }); + assert.equal(report.status, 'blocked'); + assert.equal(report.errors[0].code, 'GCLOUD_NOT_FOUND'); + assert.ok(!JSON.stringify(report).includes('secret')); + }); + + test('permission errors do not leak stderr or pretend verification succeeded', async () => { + const report = await inspectGoogleCloud({ binding, verifyProject: true, run: fake({ projects: new Error('Bearer SECRET') }) }); + assert.equal(report.status, 'blocked'); + assert.equal(report.cloudAccessVerified, false); + assert.ok(!JSON.stringify(report).includes('SECRET')); + }); + + test('project mismatch and inactive lifecycle cannot pass verification', async () => { + for (const projects of [{ projectId: 'wrong-project', lifecycleState: 'ACTIVE' }, { projectId: binding.projectId, lifecycleState: 'DELETE_REQUESTED' }]) { + const report = await inspectGoogleCloud({ binding, verifyProject: true, run: fake({ projects }) }); + assert.equal(report.status, 'blocked'); + assert.equal(report.cloudAccessVerified, false); + } + }); + + test('invalid version/config/auth schemas fail closed', async () => { + for (const overrides of [{ version: {} }, { version: { 'Google Cloud SDK': 'Unknown' } }, { config: [] }, { auth: {} }]) { + const report = await inspectGoogleCloud({ binding, run: fake(overrides) }); + assert.equal(report.status, 'blocked'); + assert.equal(report.cloudAccessVerified, false); + } + }); + + test('empty local accounts remain unverified and are not proof of ADC failure', async () => { + const report = await inspectGoogleCloud({ run: fake({ auth: [], config: {} }) }); + assert.equal(report.status, 'local-only'); + assert.equal(report.cloudAccessVerified, false); + assert.equal(report.adc, 'not-checked'); + assert.ok(report.warnings.includes('NO_LOCAL_CREDENTIALS')); + assert.ok(report.warnings.includes('NO_PROJECT_BINDING')); + }); + + test('binding reader accepts UTF-8 BOM and returns null for a missing file', async t => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitmir-gcloud-binding-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + assert.equal(await readBinding(dir), null); + await fs.mkdir(path.join(dir, '.gitmir')); + await fs.writeFile(path.join(dir, '.gitmir/google-cloud.json'), '\uFEFF' + JSON.stringify(binding)); + assert.deepEqual(await readBinding(dir), binding); + }); + + test('invalid binding and credential-like fields are rejected before execution', async t => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitmir-gcloud-invalid-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + await fs.mkdir(path.join(dir, '.gitmir')); + for (const value of [{ ...binding, token: 'secret' }, { projectId: 'x; calc.exe' }, { projectId: '--help' }, { projectId: 123 }, [], { ...binding, region: '$(bad)' }]) { + await fs.writeFile(path.join(dir, '.gitmir/google-cloud.json'), JSON.stringify(value)); + await assert.rejects(readBinding(dir)); + } + }); + + test('CLI only accepts documented flags and does not accept arbitrary gcloud commands', () => { + assert.deepEqual(parseOptions(['--directory', 'folder with spaces', '--json', '--verify-project']), { directory: 'folder with spaces', json: true, verifyProject: true, help: false }); + for (const args of [['--directory'], ['--delete'], ['--directory', '--json'], ['deploy']]) assert.throws(() => parseOptions(args)); + }); + + test('process boundary rejects writes, token export, shell metacharacters and unrelated flags', () => { + for (const args of [['auth', 'print-access-token'], ['components', 'update'], ['run', 'deploy'], ['projects', 'describe', 'a;calc'], [...COMMANDS.version, '--log-http'], ['version', '&&', 'calc']]) { + assert.throws(() => buildInvocation(args)); + } + }); + + test('Windows uses noninteractive encoded PowerShell instead of an interpolated cmd line', () => { + const invocation = buildInvocation(COMMANDS.version, { platform: 'win32', env: { SystemRoot: 'C:\\Windows' } }); + assert.match(invocation.file, /WindowsPowerShell\\v1\.0\\powershell\.exe$/); + assert.ok(invocation.args.includes('-NoProfile')); + assert.ok(invocation.args.includes('-NonInteractive')); + const script = Buffer.from(invocation.args.at(-1), 'base64').toString('utf16le'); + assert.match(script, /gcloud\.cmd/); + assert.match(script, /exit \$LASTEXITCODE/); + assert.match(script, /--format=json/); + }); + + test('POSIX passes an argument array without enabling a shell', () => { + const invocation = buildInvocation(COMMANDS.version, { platform: 'linux' }); + assert.equal(invocation.file, 'gcloud'); + assert.deepEqual(invocation.args, COMMANDS.version); + }); + + test('real subprocess adapter and CLI operate from a directory with spaces', async t => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitmir cloud fixture ')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const bin = path.join(dir, 'bin'); + await fs.mkdir(bin); + await fs.mkdir(path.join(dir, '.gitmir')); + await fs.writeFile(path.join(dir, '.gitmir/google-cloud.json'), JSON.stringify(binding)); + const fixture = path.join(bin, 'fixture.mjs'); + await fs.writeFile(fixture, `const data=${JSON.stringify(outputs)}; if(process.env.GITMIR_FAKE_FAIL){console.error('SECRET-STDERR');process.exit(3);}else if(process.env.GITMIR_FAKE_LARGE){process.stdout.write('x'.repeat(2*1024*1024));}else if(process.env.GITMIR_FAKE_BROKEN){console.log('invalid json');}else{console.log(JSON.stringify(data[process.argv[2]]));}`); + if (process.platform === 'win32') { + await fs.writeFile(path.join(bin, 'gcloud.cmd'), `@echo off\r\n"${process.execPath}" "${fixture}" %*\r\n`); + } else { + const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"; + await fs.writeFile(path.join(bin, 'gcloud'), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(fixture)} "$@"\n`, { mode: 0o755 }); + } + const env = { ...process.env, PATH: bin + path.delimiter + process.env.PATH }; + const version = await runGcloud(COMMANDS.version, { env }); + assert.equal(version['Google Cloud SDK'], '583.0.0'); + const result = await runFile(process.execPath, [doctorFile, '--directory', dir, '--json', '--verify-project'], { env }); + assert.equal(JSON.parse(result.stdout).status, 'verified-project-read'); + await assert.rejects(runGcloud(COMMANDS.version, { env: { ...env, GITMIR_FAKE_BROKEN: '1' } }), { code: 'INVALID_JSON' }); + await assert.rejects(runGcloud(COMMANDS.version, { env: { ...env, GITMIR_FAKE_LARGE: '1' } }), { code: 'GCLOUD_OUTPUT_LIMIT' }); + const local = await runFile(process.execPath, [doctorFile, '--directory', dir, '--json'], { env }); + assert.equal(JSON.parse(local.stdout).cloudAccessVerified, false); + await assert.rejects(runFile(process.execPath, [doctorFile, '--directory', dir, '--json'], { env: { ...env, GITMIR_FAKE_FAIL: '1' } }), error => { + assert.equal(error.code, 1); + assert.equal(JSON.parse(error.stdout).status, 'blocked'); + assert.ok(!error.stdout.includes('SECRET-STDERR')); + return true; + }); + await assert.rejects(runFile(process.execPath, [doctorFile, '--directory', dir, '--json', '--invalid'], { env }), error => { + assert.equal(error.code, 2); + assert.equal(JSON.parse(error.stdout).error, 'INVALID_INPUT'); + return true; + }); + }); + + test('installed skill symlinks and Windows junctions still execute the CLI entrypoint', async t => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitmir-skill-link-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const link = path.join(dir, 'google-cloud'); + await fs.symlink(path.join(root, 'plugin/skills/google-cloud'), link, process.platform === 'win32' ? 'junction' : 'dir'); + const result = await runFile(process.execPath, [path.join(link, 'scripts/doctor.mjs'), '--help']); + assert.match(result.stdout, /GitMir Google Cloud doctor/); + }); + + test('a mistyped target directory is not treated as an unbound valid project', async () => { + await assert.rejects(readBinding(path.join(os.tmpdir(), 'gitmir-nonexistent-' + Date.now())), /directory/i); + }); + + test('packaging registers the same skill in the dashboard and the Codex plugin', async () => { + const registry = JSON.parse(await fs.readFile(path.join(root, 'skills.json'), 'utf8')); + const item = registry.find(skill => skill.name === 'google-cloud'); + assert.ok(item); + assert.equal(item.file, 'skills/google-cloud.md'); + const canonical = await fs.readFile(path.join(root, 'plugin/skills/google-cloud/SKILL.md'), 'utf8'); + const mirror = await fs.readFile(path.join(root, item.file), 'utf8'); + assert.equal(canonical, mirror); + assert.match(canonical, /name: google-cloud/); + assert.match(canonical, /not.*deploy|never.*deploy/i); + const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); + assert.equal(pkg.scripts['gcloud:doctor'], 'node plugin/skills/google-cloud/scripts/doctor.mjs'); + }); +}