diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e529ef7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + build-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Validate (if compose config exists) + shell: bash + run: | + if [[ -f "compose/umbraco-compose.yaml" ]]; then + node dist/index.js validate --dir ./compose --strict + else + echo "Skipping validate: compose/umbraco-compose.yaml not found" + fi + + - name: Plan (if compose config exists) + shell: bash + run: | + if [[ -f "compose/umbraco-compose.yaml" ]]; then + node dist/index.js plan --dir ./compose + else + echo "Skipping plan: compose/umbraco-compose.yaml not found" + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..297c130 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy + +on: + workflow_dispatch: + inputs: + compose_dir: + description: "Compose root directory" + required: true + default: "./compose" + environment_alias: + description: "Environment alias to apply (e.g. dev, prod)" + required: true + default: "dev" + +concurrency: + group: deploy-${{ github.ref }}-${{ inputs.environment_alias }} + cancel-in-progress: false + +jobs: + apply: + runs-on: ubuntu-latest + environment: ${{ inputs.environment_alias }} + + steps: + - name: Require main branch + if: github.ref != 'refs/heads/main' + run: | + echo "Deploy workflow may only run from main. Current ref: $GITHUB_REF" + exit 1 + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Validate + run: node dist/index.js validate --dir "${{ inputs.compose_dir }}" --strict + + - name: Plan + run: node dist/index.js plan --dir "${{ inputs.compose_dir }}" --env "${{ inputs.environment_alias }}" + env: + COMPOSE_MGMT_CLIENT_ID: ${{ secrets.COMPOSE_MGMT_CLIENT_ID }} + COMPOSE_MGMT_CLIENT_SECRET: ${{ secrets.COMPOSE_MGMT_CLIENT_SECRET }} + COMPOSE_MGMT_SCOPE: ${{ secrets.COMPOSE_MGMT_SCOPE }} + COMPOSE_MGMT_AUDIENCE: ${{ secrets.COMPOSE_MGMT_AUDIENCE }} + + - name: Apply + run: node dist/index.js apply --dir "${{ inputs.compose_dir }}" --env "${{ inputs.environment_alias }}" + env: + COMPOSE_MGMT_CLIENT_ID: ${{ secrets.COMPOSE_MGMT_CLIENT_ID }} + COMPOSE_MGMT_CLIENT_SECRET: ${{ secrets.COMPOSE_MGMT_CLIENT_SECRET }} + COMPOSE_MGMT_SCOPE: ${{ secrets.COMPOSE_MGMT_SCOPE }} + COMPOSE_MGMT_AUDIENCE: ${{ secrets.COMPOSE_MGMT_AUDIENCE }} diff --git a/.gitignore b/.gitignore index f1a90a0..c632255 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,10 @@ dist # Vite files vite.config.js.timestamp-* vite.config.ts.timestamp-* -.vite/ \ No newline at end of file +.vite/ + +# Test build artifacts (keep only .ts test sources) +test/**/*.js +test/**/*.d.ts +test/**/*.js.map +test/**/*.d.ts.map diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..11c309c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v24.13.1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b0c83b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [1.0.0-rc.1] - 2026-02-13 + +### Added +- Core command set stabilized for IaC-style workflows: + - `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename` +- Broad automated test coverage for: + - command behavior and exit-code semantics + - multi-environment plan/apply flows + - rename safety and alias routing + - apply/pull API contract-map checks + - OpenAPI-shape assertions for key apply endpoints/payloads +- CI and deploy GitHub Actions workflows with guarded apply behavior. +- Operational docs: + - `docs/commands.md` + - `docs/testing.md` + - `docs/ci.md` + - `docs/workflow.md` + - `docs/release-v1-checklist.md` + +### Changed +- Environment identity model hardened with `compose.state.json` (`id`, `alias`, `remoteAlias`) to support safe rename convergence. +- `plan` and `pull` use `remoteAlias` routing when a rename is pending. +- `apply` performs environment rename first, then applies nested resources through the new alias. +- Apply operation output now includes explicit environment context and per-environment/final summaries. +- Non-environment rename inference expanded and guarded: + - unique one-to-one signature matches infer rename + - ambiguous matches fall back to create/delete with explicit context +- Apply convergence expanded across managed entities, including create/update/delete and rename paths where supported. + +### Fixed +- Prevented lock/state writes when apply emits warnings. +- Improved warning/plan messaging around rename and missing nested resources. +- Tightened TypeScript compatibility under strict checks (`exactOptionalPropertyTypes`, indexed access, env config narrowing). +- Resolved test harness response-shape issues around `204` delete cases. + +## [Unreleased] +- Final `1.0.0` version bump and release tagging. diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..4662428 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,61 @@ +# CI/CD Setup + +## Workflows +- CI workflow: `.github/workflows/ci.yml` + - runs on PRs and pushes to `main` + - executes build + tests + - runs validate/plan when `compose/umbraco-compose.yaml` exists + +- Deploy workflow: `.github/workflows/deploy.yml` + - manual trigger only (`workflow_dispatch`) + - main-branch-only guard + - runs validate -> plan -> apply for the selected environment alias + +## Are Workflow Files Auto-Created? +Short answer: no, not automatically by GitHub. + +How they appear in a repo: +- they must exist as committed files under `.github/workflows/` +- they can be copied from this project, a template repository, or scaffolding logic in your own tooling + +Current behavior in this CLI: +- `compose init` does not scaffold workflow files today +- users should copy/add `ci.yml` and `deploy.yml` (or use a repo template) when setting up a new canonical repo + +## Required Repository Secrets +Configure these in repository settings: +- `COMPOSE_MGMT_CLIENT_ID` +- `COMPOSE_MGMT_CLIENT_SECRET` + +Optional: +- `COMPOSE_MGMT_SCOPE` +- `COMPOSE_MGMT_AUDIENCE` + +## GitHub Environment Protection +Create GitHub Environments that match deploy aliases (for example `dev`, `prod`) and enable: +- required reviewers +- optional wait timer +- environment-scoped secrets if needed + +The deploy workflow binds `environment: ${{ inputs.environment_alias }}`, so protections apply automatically when names match. + +## Recommended Branch Protection +For `main`: +- require PR review approvals +- require status checks to pass: + - CI / build-test +- restrict direct pushes + +## Running Deploy +1. Open GitHub Actions -> `Deploy`. +2. Click `Run workflow`. +3. Choose `main` branch. +4. Set: + - `compose_dir` (usually `./compose`) + - `environment_alias` (for example `dev` or `prod`) +5. Confirm environment approval prompt if configured. + +## Operational Notes +- Deploy concurrency is keyed by branch + environment alias to avoid overlapping applies for the same target. +- Keep plan output from deploy runs for audit trail and incident review. +- Treat non-zero exits as failed deployment attempts; do not auto-retry without reviewing warnings/errors. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..4197ada --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,185 @@ +# Compose CLI Command Contract + +## Scope +This document defines the intended command behavior for the core workflow: + +- `compose init` +- `compose generate ` +- `compose validate` +- `compose clone` +- `compose pull` +- `compose status` +- `compose plan` +- `compose apply` +- `compose env rename` + +Related docs: +- `docs/workflow.md` (end-to-end usage flow) +- `docs/ci.md` (CI/deploy setup and protections) + +## Shared Behavior +- Compose project root: defaults to `./compose` and must contain `umbraco-compose.yaml`. +- Exit codes: + - `0`: success + - `1`: validation failure, planning/apply warnings, or runtime/config errors +- CI compatibility: + - all commands must be non-interactive + - machine-readable output should be available where relevant (`status --json`) +- Plan/apply operation output: + - each operation line includes explicit environment context in the format `[env=]` + - operation kinds include `CREATE`, `DELETE`, `UPDATE`, `NOOP`, `WARN` + - each environment run emits a per-environment summary line (`Plan env ...` / `Apply env ...`) + - final output includes environment aggregate counts (`Environments processed: total=..., succeeded=..., warned=...`) +- Idempotency: + - rerunning the same command with unchanged inputs should produce no new side effects + +## Recommended CI Workflow +Use this baseline pipeline for pull-request validation and main-branch deploy: + +1. `npm ci` +2. `npm run build` +3. `npm test` +4. `node dist/index.js validate --dir ./compose --strict` +5. `node dist/index.js plan --dir ./compose` +6. On approved/protected branch only: `node dist/index.js apply --dir ./compose` + +Notes: +- keep `plan` output as a build artifact or PR comment for review +- gate `apply` behind branch protection and required approvals +- treat any non-zero exit as a deployment block + +## Environment Identity Model +- Local state file: `compose.state.json` at project root. +- Purpose: provide stable local environment IDs so alias changes are handled as rename, not create. +- Model per environment: + - `id`: stable local identity + - `alias`: current local alias in config + - `remoteAlias`: last alias known to be active remotely + +Planner semantics: +- No `remoteAlias` + alias not present remotely: plan `create environment`. +- `remoteAlias` differs from local `alias`: plan `update environment` with rename intent. +- Local alias present remotely and no drift: plan `noop` for environment. + +Alias routing semantics: +- `plan` on pending rename reads nested resources using `remoteAlias` (old alias), so plan diff reflects current remote state. +- `apply` on pending rename performs environment rename first, then uses local alias (`alias`) for nested writes. + +## Command Contracts + +### `compose init` +- Status: implemented +- Responsibility: + - scaffold project directories and baseline files + - create `umbraco-compose.yaml` + - create `compose.state.json` with environment IDs +- Side effects: writes files under compose root. +- Idempotency: with `--merge`, only missing files are created. + +### `compose generate ` +- Status: implemented (MVP) +- Responsibility: + - scaffold entity files (schema/function/query/webhook/etc.) into the selected environment directory +- Implemented entities: + - `collection` + - `type-schema` + - `ingestion-function` + - `persisted-doc` + - `webhook` +- Validation: + - alias must be kebab-case + - reserved/path-unsafe aliases are rejected + - `--url` is only valid for `webhook` + - alias reuse across different entity types is allowed (uniqueness is enforced per entity type) +- Side effects: writes/updates local files only. +- Idempotency: should support deterministic output and safe reruns. + +### `compose validate` +- Status: implemented +- Responsibility: + - local structural and schema checks without API calls +- Notes: + - emits rename-risk warnings when multiple local entities share identical rename-signatures (can make non-environment rename inference ambiguous) + - `--strict` promotes these warnings to exit-code failure +- Side effects: none. +- Idempotency: fully idempotent. + +### `compose pull` +- Status: implemented (MVP) +- Responsibility: + - fetch remote state and materialize files in local format + - update `compose.state.json` to track remote alias for pulled environment +- Implemented pull scope: + - collections + - type schemas + - ingestion function registry + scripts + - persisted document registry + query files + - webhooks +- Notes: + - resolves active remote environment alias from `compose.state.json.remoteAlias` first, then local alias + - stale generated files in managed folders are removed when no longer present remotely + - updates `compose.state.json.remoteAlias` for pulled environment + - does not write `compose.lock.json` + - uses staged commits for managed files; fetch failures do not partially mutate local managed artifacts +- Side effects: writes local files. +- Idempotency: repeated pulls with unchanged remote state should produce no diffs. + +### `compose clone` +- Status: implemented (MVP) +- Responsibility: + - bootstrap a local compose directory from remote project/env state + - scaffold minimal `umbraco-compose.yaml` + - execute `pull` for the selected environment +- Side effects: writes local project files and pulled entity files. +- Idempotency: repeated clone to same non-empty directory requires `--force`. + +### `compose status` +- Status: implemented +- Responsibility: + - compare local desired state vs `compose.lock.json` + - include local state identity context (`remoteAlias`) and pending-rename signal when `remoteAlias != alias` + - no API calls +- Side effects: none. +- Idempotency: fully idempotent. + +### `compose plan` +- Status: implemented +- Responsibility: + - compute and print API operation plan without mutating remote resources + - includes environment create/rename/noop based on identity mapping +- Scope: + - `--env ` plans a single environment + - omitting `--env` plans all configured environments +- Notes: + - on pending rename, nested reads target the old remote alias (`remoteAlias`) +- Side effects: none (reads local files + remote APIs). +- Idempotency: identical inputs should produce equivalent plan output. + +### `compose apply` +- Status: implemented +- Responsibility: + - execute planned operations against management API + - perform environment rename with `POST /v1/projects/{projectAlias}/environments/{environmentAlias}/commands/rename` and body `{ "newEnvironmentAlias": "" }` when rename intent exists + - update `compose.lock.json` and `compose.state.json.remoteAlias` only when apply completes with zero warnings +- Scope: + - `--env ` applies a single environment + - omitting `--env` applies all configured environments +- Notes: + - during create/rename convergence, a `404` when listing collections is treated as non-fatal with contextual output + - collections support create/rename/update-description/delete convergence + - type schemas support create/rename/update-schema/update-description/delete convergence + - ingestion functions support create/rename/update/delete convergence + - persisted documents support create/rename/update/delete convergence + - webhooks support create/rename/update/delete convergence + - if any warning is emitted, lock/state writes are skipped for safety +- Side effects: remote writes + local lock/state updates. +- Idempotency: rerun after successful apply should converge to mostly noops. + +### `compose env rename` +- Status: implemented +- Responsibility: + - explicit local alias rename in `umbraco-compose.yaml` + - update `compose.state.json` alias while preserving prior `remoteAlias` + - optional local directory move (`--moveDir`) +- Side effects: local config/state writes, optional folder rename. +- Idempotency: one-time alias transition; rerunning same from/to pair should fail as invalid state. diff --git a/docs/contracts/apply-contract.json b/docs/contracts/apply-contract.json new file mode 100644 index 0000000..52c4f8f --- /dev/null +++ b/docs/contracts/apply-contract.json @@ -0,0 +1,162 @@ +{ + "version": 1, + "operations": { + "environmentCreate": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments", + "requiredBodyKeys": ["environmentAlias", "description"] + }, + "environmentRename": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/commands/rename", + "requiredBodyKeys": ["newEnvironmentAlias"] + }, + "collectionCreate": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/collections", + "requiredBodyKeys": ["collectionAlias", "description"] + }, + "collectionDelete": { + "method": "DELETE", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/collections/{collectionAlias}", + "requiredBodyKeys": [] + }, + "collectionUpdateDescription": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/collections/{collectionAlias}/commands/update-description", + "requiredBodyKeys": ["newDescription"] + }, + "collectionRename": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/collections/{collectionAlias}/commands/rename", + "requiredBodyKeys": ["newCollectionAlias"] + }, + "typeSchemaCreate": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas", + "requiredBodyKeys": ["typeSchemaAlias", "description", "schema"] + }, + "typeSchemaUpdateSchema": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas/{typeSchemaAlias}/commands/update-schema", + "requiredBodyKeys": [] + }, + "typeSchemaUpdateDescription": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas/{typeSchemaAlias}/commands/update-description", + "requiredBodyKeys": ["newDescription"] + }, + "typeSchemaDelete": { + "method": "DELETE", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas/{typeSchemaAlias}", + "requiredBodyKeys": [] + }, + "typeSchemaRename": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas/{typeSchemaAlias}/commands/rename", + "requiredBodyKeys": ["newTypeSchemaAlias"] + }, + "ingestionFunctionCreate": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion", + "requiredBodyKeys": ["ingestionFunctionAlias", "description", "script"] + }, + "ingestionFunctionUpdateScript": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion/{ingestionFunctionAlias}/commands/update-script", + "requiredBodyKeys": ["script"] + }, + "ingestionFunctionUpdateDescription": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion/{ingestionFunctionAlias}/commands/update-description", + "requiredBodyKeys": ["newDescription"] + }, + "ingestionFunctionDelete": { + "method": "DELETE", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion/{ingestionFunctionAlias}", + "requiredBodyKeys": [] + }, + "ingestionFunctionRename": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion/{ingestionFunctionAlias}/commands/rename", + "requiredBodyKeys": ["newIngestionFunctionAlias"] + }, + "persistedDocumentCreate": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents", + "requiredBodyKeys": ["persistedDocumentAlias", "description", "document"] + }, + "persistedDocumentUpdateDescription": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents/{persistedDocumentAlias}/commands/update-description", + "requiredBodyKeys": ["newDescription"] + }, + "persistedDocumentUpdateDocument": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents/{persistedDocumentAlias}/commands/update-document", + "requiredBodyKeys": ["newDocument"] + }, + "persistedDocumentDelete": { + "method": "DELETE", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents/{persistedDocumentAlias}", + "requiredBodyKeys": [] + }, + "persistedDocumentRename": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents/{persistedDocumentAlias}/commands/rename", + "requiredBodyKeys": ["newPersistedDocumentAlias"] + }, + "webhookCreate": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks", + "requiredBodyKeys": [ + "webhookAlias", + "url", + "eventTypes", + "collectionAliases", + "typeSchemaAliases", + "customHeaders" + ] + }, + "webhookUpdateUrl": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/update-url", + "requiredBodyKeys": ["url"] + }, + "webhookUpdateEventTypes": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/update-event-types", + "requiredBodyKeys": ["eventTypes"] + }, + "webhookUpdateCollections": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/update-collections", + "requiredBodyKeys": ["collectionAliases"] + }, + "webhookUpdateTypeSchemas": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/update-type-schemas", + "requiredBodyKeys": ["typeSchemaAliases"] + }, + "webhookUpdateHeaders": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/update-headers", + "requiredBodyKeys": ["newHeaders"] + }, + "webhookUpdateDescription": { + "method": "PUT", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/update-description", + "requiredBodyKeys": ["newDescription"] + }, + "webhookDelete": { + "method": "DELETE", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}", + "requiredBodyKeys": [] + }, + "webhookRename": { + "method": "POST", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}/commands/rename", + "requiredBodyKeys": ["newWebhookAlias"] + } + } +} diff --git a/docs/contracts/pull-contract.json b/docs/contracts/pull-contract.json new file mode 100644 index 0000000..f0d2004 --- /dev/null +++ b/docs/contracts/pull-contract.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "operations": { + "environmentList": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments" + }, + "collectionList": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/collections" + }, + "typeSchemaList": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas" + }, + "typeSchemaGet": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/type-schemas/{typeSchemaAlias}" + }, + "ingestionFunctionList": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion" + }, + "ingestionFunctionGet": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/functions/ingestion/{ingestionFunctionAlias}" + }, + "persistedDocumentList": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents" + }, + "persistedDocumentGet": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/graphql/persisted-documents/{persistedDocumentAlias}" + }, + "webhookList": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks" + }, + "webhookGet": { + "method": "GET", + "pathTemplate": "/v1/projects/{projectAlias}/environments/{environmentAlias}/webhooks/{webhookAlias}" + } + } +} diff --git a/docs/release-v1-checklist.md b/docs/release-v1-checklist.md new file mode 100644 index 0000000..950fb4a --- /dev/null +++ b/docs/release-v1-checklist.md @@ -0,0 +1,48 @@ +# v1.0.0 Release Checklist + +## Scope Freeze +- Confirm command scope for `v1.0.0`: + - `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename` +- Confirm command/behavior docs are current: + - `docs/commands.md` + - `docs/testing.md` + - `docs/contracts/apply-contract.json` + - `docs/contracts/pull-contract.json` + +## Quality Gates +- `npm run build` passes. +- `npm test` passes. +- Validate strict mode check passes on a representative project: + - `compose validate --dir ./compose --strict` +- Plan check passes on a representative project: + - `compose plan --dir ./compose` + +## Regression Focus +- Environment identity and alias routing: + - pending rename uses `remoteAlias` correctly in `plan`, `apply`, `pull`, and `status` +- Non-environment rename inference: + - unique matches infer rename + - ambiguous matches safely fall back to create/delete with contextual output +- Lock/state safety: + - lock/state writes skipped when warnings occur during apply +- Contract drift checks: + - apply and pull contract-map tests remain green + +## CI/CD Readiness +- CI runs build + tests on every PR. +- CI captures plan output for review. +- Apply is restricted to protected branch and approved runs. +- Deploy workflow is manual (`workflow_dispatch`) and main-branch-only. +- GitHub Environments are configured with required reviewers for deploy targets. +- Secret handling is configured for OAuth credentials in CI. +- CI/CD operational setup is documented in `docs/ci.md`. + +## Release Prep +- Bump version in `package.json` from `0.1.0` to `1.0.0`. +- Generate/update changelog summary for `v1.0.0`. +- Tag release (`v1.0.0`) and publish artifacts as needed. + +## Post-Release +- Run one smoke pipeline against a non-production environment. +- Confirm expected exit codes and operation output formatting. +- Capture follow-up issues as `v1.0.x` patch candidates. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c155e45 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,209 @@ +# Testing Strategy (Initial) + +## Run Tests +- `npm test` +- `npm run test:watch` + +## Current Coverage (Critical Path) +- `test/compose.state.test.ts` + - state initialization and alias mapping growth + - alias rename behavior preserving stable `id` + - `remoteAlias` tracking behavior + - state read/write roundtrip and invalid JSON handling + +- `test/compose.apply.environment-alias-routing.test.ts` + - `plan` with pending env rename reads nested resources using old remote alias path + - `apply` with pending env rename calls the rename endpoint with correct payload: + - `POST /v1/projects/{projectAlias}/environments/{environmentAlias}/commands/rename` + - `{ "newEnvironmentAlias": "" }` + - after rename in apply flow, nested resource calls use the new alias path + +- `test/commands.env-rename.test.ts` + - updates `umbraco-compose.yaml` environment key and state alias mapping + - preserves stable local env id and `remoteAlias` continuity across rename + - supports `--moveDir` for default env directories (`./env/`) + - rejects rename to existing alias without mutating config/state files + - rejects `--moveDir` when destination directory already exists (no config/state mutation) + - supports `--moveDir` when source directory is missing (config/state still updated) + +- `test/commands.apply.test.ts` + - verifies operation output includes environment context (`[env=]`) + - verifies successful apply writes `compose.lock.json` + - verifies successful apply updates `compose.state.json` with `remoteAlias` + +- `test/compose.apply.failures.test.ts` + - verifies environment list failure produces environment warning and short-circuits nested operations + - verifies environment rename failure (e.g. `409`) produces warning with status and short-circuits nested operations + +- `test/compose.apply.rename-inference.test.ts` + - verifies inferred non-environment rename guardrails: + - ambiguous signature matches do not trigger rename calls (remote-side or desired-side ambiguity) + - signature mismatches do not trigger rename calls + - includes persisted-document ambiguity coverage (file-backed signature matching path) + - includes webhook ambiguity coverage (rich signature matching path) + - asserts ambiguous-fallback delete ops carry `details: "no unique rename match"` + - fallback behavior remains create/delete convergence + +- `test/commands.plan-exit.test.ts` + - verifies plan mode sets `process.exitCode = 1` when warnings are emitted + +- `test/commands.apply.auth-failures.test.ts` + - verifies apply/plan fails with clear errors when OAuth token request returns non-200 + - verifies apply/plan fails when token response omits `access_token` + +- `test/commands.apply.create-env-failure.test.ts` + - verifies create-environment failure sets non-zero exit behavior + - verifies lock/state writes are skipped under env create failure + +- `test/commands.status-validate-exit.test.ts` + - verifies `status --failOnChanges` sets `process.exitCode = 1` when lock-backed drift exists + - verifies `validate --strict` sets `process.exitCode = 1` when warnings exist + +- `test/commands.validate-rename-risk.test.ts` + - verifies validate emits rename-risk warnings for duplicate local rename-signatures + - verifies `validate --strict` fails when those warnings are present + +- `test/commands.status-multi-env.test.ts` + - verifies `status` without `--env` reports all configured environments + - verifies mixed multi-env state (`UNCHANGED` + `NO LOCK`) sets non-zero exit with `--failOnChanges` + - verifies pending-rename identity context is surfaced: + - JSON includes `remoteAlias` and `renamePending` + - human output includes `Identity: pending rename ( -> )` + +- `test/commands.pull.test.ts` + - verifies `pull` writes local IaC files from remote responses for implemented entities + - verifies stale managed files are removed during pull + - verifies `compose.state.json.remoteAlias` is updated for pulled environment + - verifies pending-rename alias routing: pull uses state `remoteAlias` when local alias differs + - verifies pull fails when target environment is missing remotely + +- `test/commands.pull-hardening.test.ts` + - verifies `pull` idempotency for unchanged remote responses + - verifies partial-failure behavior explicitly: + - state alias metadata is not advanced on failed pull + - local managed artifacts are not partially mutated before failure + +- `test/contracts.pull-contract-map.test.ts` + - validates emitted pull read calls against `docs/contracts/pull-contract.json` + - catches drift between documented pull endpoint paths and implementation + - verifies alias-routing parity: pull contract paths use state `remoteAlias` when local alias differs + +- `test/commands.clone.test.ts` + - verifies clone bootstraps local config and delegates to pull for local file materialization + - verifies clone rejects non-empty target directories unless `--force` + +- `test/compose.management-client.test.ts` + - verifies first-call `401` triggers one auth invalidation and one retry + - verifies retry does not loop infinitely when second call is also `401` + +- `test/commands.apply.partial-failure-writes.test.ts` + - verifies any nested resource warning causes apply to skip lock/state writes + +- `test/compose.oauth-base.test.ts` + - verifies OAuth client-credentials token caching between calls + - verifies `invalidate()` forces token refresh + - verifies concurrent header requests share one in-flight token request + - verifies token endpoint failures surface clear errors + +- `test/commands.apply.output-snapshots.test.ts` + - snapshot-style assertions for exact plan/apply operation lines + - snapshot-style assertions for per-environment summary lines + - snapshot-style assertions for environment aggregate summary line + - snapshot-style assertions for final plan/apply summary lines (including `delete` count) + - snapshot-style assertions for rename messaging clarity: + - inferred rename prints explicit `UPDATE ... — rename -> ` + - ambiguous rename fallback prints `CREATE`/`DELETE` operations instead of rename update + - fallback delete lines include `— no unique rename match` context + +- `test/commands.apply-multi-env.test.ts` + - verifies plan/apply run across all configured environments when `--env` is omitted + - verifies mixed outcome behavior: successful env writes lock/state metadata while warned env skips lock writes + - verifies non-zero exit when any environment emits warnings + +- `test/contracts.apply-openapi-shape.test.ts` + - contract-shape assertions for key apply endpoints and payloads: + - environment create path + `environmentAlias` payload + - collection create path + payload (`collectionAlias`, `description`) + - collection `update-description` command payload shape (`newDescription`) + - collection delete endpoint shape + - environment rename path + `newEnvironmentAlias` payload + - persisted document create payload uses `document` field + - persisted document create emits string `description` even when local description is omitted + - persisted-document update commands (`update-document`, `update-description`) payload shape + - persisted-document delete endpoint shape + - ingestion-function create path + payload (`ingestionFunctionAlias`, `description`, `script`) + - ingestion-function update commands (`update-script`, `update-description`) payload shape + - ingestion-function delete endpoint shape + - webhook create path + payload (`webhookAlias`, `url`, `eventTypes`, filters, `customHeaders`) + - webhook update commands (`update-description`, `update-url`, `update-event-types`, `update-collections`, `update-type-schemas`, `update-headers`) payload shapes + - webhook delete endpoint shape + - type-schema `update-schema` endpoint receives raw schema JSON body + - type-schema `update-description` command payload shape (`newDescription`) + - type-schema create path + payload (`typeSchemaAlias`, `description`, `schema`) + - type-schema delete endpoint shape + - non-environment rename commands/payload keys: + - collection `commands/rename` + `newCollectionAlias` + - type schema `commands/rename` + `newTypeSchemaAlias` + - ingestion function `commands/rename` + `newIngestionFunctionAlias` + - persisted document `commands/rename` + `newPersistedDocumentAlias` + - webhook `commands/rename` + `newWebhookAlias` + +- `test/contracts.apply-contract-map.test.ts` + - validates emitted apply write calls against `docs/contracts/apply-contract.json` + - catches drift between documented endpoint/payload contract and implementation + - includes collection/type-schema/ingestion/persisted-document/webhook update/delete/rename contract-map assertions + +- `test/commands.generate.test.ts` + - verifies `compose generate` creates/updates local files for: + - `collection` + - `type-schema` + - `ingestion-function` + - `persisted-doc` + - `webhook` (default + explicit URL) + - verifies duplicate alias protection + - verifies invalid argument combinations (`--url` rejected for non-webhook entities) + - verifies alias guardrails (kebab-case validation and reserved alias rejection) + - verifies pre-existing target file collision handling: + - existing type-schema file + - existing ingestion script file + - existing persisted query file + - verifies cross-entity alias reuse policy (same alias is allowed across different entity types) + +- `test/commands.generate-flow.test.ts` + - verifies end-to-end baseline flow: + - generate core entities + - validate with `--strict` passes + - plan output contains expected create/noop operations with zero warnings + +- `test/commands.generate-apply-flow.test.ts` + - verifies generate -> apply integration: + - apply output contains expected create/noop operations with zero warnings + - successful apply writes `compose.lock.json` + - successful apply updates `compose.state.json` remote alias + +- `test/commands.generate-apply-warn-flow.test.ts` + - verifies generate -> apply warning path: + - warning is surfaced in apply output + - apply exits non-zero + - lock/state writes are skipped when warnings exist + +- `test/commands.generate-status-flow.test.ts` + - verifies generate -> status interaction transitions: + - `NO LOCK` before first apply + - `UNCHANGED` after successful apply + - `CHANGED` after local edits + - verifies `--failOnChanges` exit behavior for those transitions + +## Why This First +These tests protect the highest-risk behavior we recently fixed: +- local/remote environment alias drift reconciliation +- rename correctness against API contract +- plan/apply pathing differences that can silently regress + +## Next High-Value Additions +- command-level tests for multi-environment apply/plan orchestration once batching is implemented +- staged pull rollback edge-case tests (filesystem errors during commit) +- clone idempotency and `--force` behavior tests for pre-existing compose files + +## Release Readiness +- Use `docs/release-v1-checklist.md` as the source of truth for `v1.0.0` cutover criteria. diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..a8b5d09 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,61 @@ +# Typical User Workflow + +This page explains how teams usually use Compose CLI in practice. + +## Path A: Start From Existing Remote Project +Use this when the platform already has state and you want to adopt GitOps. + +1. Bootstrap locally: + - `compose clone --dir ./compose --project --env ` +2. Review/edit generated files: + - optional `compose generate ` for new resources + - manual edits in `compose/env//...` +3. Validate and preview: + - `compose validate --dir ./compose --strict` + - `compose plan --dir ./compose` +4. Open PR to canonical repository. +5. CI runs build/tests + validate + plan. +6. After approval/merge, deploy workflow runs: + - `validate -> plan -> apply` + +Outcome: +- canonical repo becomes source of truth for the remote state. + +## Path B: Start New Project Locally +Use this when no remote state exists yet. + +1. Initialize local project: + - `compose init --dir ./compose --project --env dev` +2. Add resources: + - `compose generate ...` + - manual edits to schemas/functions/queries/webhooks +3. Validate and preview: + - `compose validate --dir ./compose --strict` + - `compose plan --dir ./compose` +4. Push branch + open PR to canonical repository. +5. CI validates and publishes plan output for review. +6. Approved deploy runs `compose apply` to create/update remote state. + +Outcome: +- remote state is created from repo-managed IaC files. + +## How Rename Safety Works +- Environment rename: + - tracked via `compose.state.json` (`alias` + `remoteAlias`) + - `plan` reads old remote alias path, `apply` renames then writes using local alias path +- Non-environment rename: + - inferred only on unique one-to-one signature matches + - ambiguous matches fall back to create/delete with explicit context + +## CI/CD Interaction Model +- PR CI: + - non-destructive checks (`build`, `test`, `validate`, `plan`) +- Deploy workflow (manual/guarded): + - destructive step (`apply`) behind branch + environment protections +- Exit code policy: + - non-zero exits block progression in CI/CD + +## What Teams Usually Tell Reviewers +- “`validate` is local lint/shape checks.” +- “`plan` is the review artifact for what will change remotely.” +- “`apply` is only run in guarded CI, not ad-hoc from laptops.” diff --git a/package.json b/package.json index 9ab3696..c192a9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umbraco-compose-cli", - "version": "0.1.0", + "version": "1.0.0-rc.1", "type": "module", "description": "", "bin": { @@ -10,7 +10,8 @@ "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --import tsx --test test/**/*.test.ts", + "test:watch": "node --import tsx --test --watch test/**/*.test.ts" }, "keywords": [ "umbraco", diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 4485317..cc8caca 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -2,15 +2,23 @@ import path from "node:path"; import fs from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { readFileSync, readlink } from "node:fs"; +import { readFileSync } from "node:fs"; import type { CommandModule } from "yargs"; import YAML from "yaml"; import { applyEnvironment } from "../compose/apply.js"; import { computeDesiredHashes, readLock, writeLock, type LockFile } from "../compose/lock.js"; +import { + COMPOSE_STATE_FILE, + ensureStateForAliases, + findEnvironmentByAlias, + markEnvironmentRemoteAlias, + writeComposeState, + readComposeState +} from "../compose/state.js"; type Args = { dir: string; - env: string; + env?: string; clientId?: string; clientSecret?: string; scope?: string; @@ -23,10 +31,14 @@ type Args = { type ComposeConfig = { version: number; project: string; - environments: Record; + environments: Record; }; -type OpKind = "create" | "update" | "noop" | "warn"; +type OpKind = "create" | "delete" | "update" | "noop" | "warn"; + +type RunApplyArgs = Args & { + forcedPlan?: boolean; +}; export const applyCommand: CommandModule<{}, Args> = { command: "apply", @@ -39,8 +51,7 @@ export const applyCommand: CommandModule<{}, Args> = { }, env: { type: "string", - default: "dev", - describe: "Environment name to apply (must exist in umbraco-compose.yaml)" + describe: "Environment name to apply (must exist in umbraco-compose.yaml). Omit to apply all environments." }, clientId: { type: "string", @@ -70,51 +81,89 @@ export const applyCommand: CommandModule<{}, Args> = { } }, handler: async (args) => { - const rootDir = path.resolve(process.cwd(), args.dir); - - if (args.requireClean && !args.plan) { - await ensureGitClean(rootDir); - } + await runApply(args); + } +}; + +export async function runApply(rawArgs: RunApplyArgs): Promise { + const planOnly = rawArgs.forcedPlan ?? rawArgs.plan; + const rootDir = path.resolve(process.cwd(), rawArgs.dir); + + if (rawArgs.requireClean && !planOnly) { + await ensureGitClean(rootDir); + } + + const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); + + const cfg = await readComposeConfig(composeYamlPath); + if (rawArgs.env && !cfg.environments[rawArgs.env]) { + throw new Error(`Environment "${rawArgs.env}" not found in ${composeYamlPath}`); + } + const targetEnvs = rawArgs.env ? [rawArgs.env] : Object.keys(cfg.environments); + if (targetEnvs.length === 0) { + throw new Error(`No environments configured in ${composeYamlPath}`); + } - const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); + const statePath = path.join(rootDir, COMPOSE_STATE_FILE); + const stateFromDisk = await readComposeState(rootDir); + const aliases = Object.keys(cfg.environments); + const stateResult = ensureStateForAliases(stateFromDisk, cfg.project, aliases); + const state = stateResult.state; - const cfg = await readComposeConfig(composeYamlPath); - const envCfg = cfg.environments[args.env]; + const clientId = rawArgs.clientId ?? process.env.COMPOSE_MGMT_CLIENT_ID; + const clientSecret = rawArgs.clientSecret ?? process.env.COMPOSE_MGMT_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error( + "Missing OAuth credentials. Provide --clientId/--clientSecret " + + "or set COMPOSE_MGMT_CLIENT_ID and COMPOSE_MGMT_CLIENT_SECRET." + ); + } + + const scopeVal = rawArgs.scope ?? process.env.COMPOSE_MGMT_SCOPE; + const audienceVal = rawArgs.audience ?? process.env.COMPOSE_MGMT_AUDIENCE; + + const oauth: { + clientId: string; + clientSecret: string; + scope?: string; + audience?: string; + } = { + clientId, + clientSecret + }; + if (scopeVal !== undefined) oauth.scope = scopeVal; + if (audienceVal !== undefined) oauth.audience = audienceVal; + + const byKind: Record = { create: 0, delete: 0, update: 0, noop: 0, warn: 0 }; + let stateShouldWrite = false; + let succeededEnvironments = 0; + let warnedEnvironments = 0; + + for (const envAlias of targetEnvs) { + const envCfg = cfg.environments[envAlias]; if (!envCfg) { - throw new Error(`Environment "${args.env}" not found in ${composeYamlPath}`); + throw new Error(`Environment "${envAlias}" is missing from umbraco-compose.yaml.`); } + const envDir = path.resolve(rootDir, envCfg.dir); + const envDescription = envCfg.description ?? `${envAlias} Environment`; - const clientId = args.clientId ?? process.env.COMPOSE_MGMT_CLIENT_ID; - const clientSecret = args.clientSecret ?? process.env.COMPOSE_MGMT_CLIENT_SECRET; - - if (!clientId || !clientSecret) { + const envState = findEnvironmentByAlias(state, envAlias); + if (!envState) { throw new Error( - "Missing OAuth credentials. Provide --clientId/--clientSecret " + - "or set COMPOSE_MGMT_CLIENT_ID and COMPOSE_MGMT_CLIENT_SECRET." + `Environment "${envAlias}" has no entry in ${statePath}. ` + + `Run "compose env rename" for explicit alias changes or recreate ${statePath}.` ); } - const envDir = path.resolve(rootDir, envCfg.dir); - const envDescription = path.resolve(rootDir, envCfg.description); - const scopeVal = args.scope ?? process.env.COMPOSE_MGMT_SCOPE; - const audienceVal = args.audience ?? process.env.COMPOSE_MGMT_AUDIENCE; - - const oauth: { - clientId: string; - clientSecret: string; - scope?: string; - audience?: string; - } = { - clientId, - clientSecret - }; - if (scopeVal !== undefined) oauth.scope = scopeVal; - if (audienceVal !== undefined) oauth.audience = audienceVal; - const desired = await computeDesiredHashes(envDir); const prior = await readLock(envDir); - if (args.plan) { + if (targetEnvs.length > 1) { + console.log(`Environment: ${envAlias}`); + } + + if (planOnly) { console.log("State changes since last apply:"); const priorRes = prior?.resources ?? {}; printChange("collections", priorRes.collections?.hash, desired.collections.hash); @@ -127,59 +176,85 @@ export const applyCommand: CommandModule<{}, Args> = { const ops = await applyEnvironment({ project: cfg.project, - env: args.env, + env: envAlias, + ...(envState.remoteAlias ? { remoteEnvAlias: envState.remoteAlias } : {}), envDir, envDescription, baseUrl: envCfg.managementBaseUrl, oauth, - planOnly: args.plan + planOnly }); - if (!args.plan) { - const cliVersion = getCliVersionSafe(); - const gitCommit = await getGitCommit(rootDir); - - const lastApplied: NonNullable = { - at: new Date().toISOString() - }; - - if (cliVersion !== undefined) lastApplied.cliVersion = cliVersion; - if (gitCommit !== undefined) lastApplied.gitCommit = gitCommit; - - const nextLock: LockFile = { - version: 1, - project: cfg.project, - environment: args.env, - lastApplied, - resources: desired - }; - - await writeLock(envDir, nextLock); - console.log(`\nWrote lock file: ${path.join(envDir, "compose.lock.json")}`); - } - - // Print plan/apply results - const byKind: Record = { create: 0, update: 0, noop: 0, warn: 0 }; - + const envByKind: Record = { create: 0, delete: 0, update: 0, noop: 0, warn: 0 }; for (const op of ops) { - byKind[op.kind] += 1; + envByKind[op.kind] += 1; + byKind[op.kind] += 1; + } + + if (!planOnly && envByKind.warn === 0) { + const cliVersion = getCliVersionSafe(); + const gitCommit = await getGitCommit(rootDir); + + const lastApplied: NonNullable = { + at: new Date().toISOString() + }; + + if (cliVersion !== undefined) lastApplied.cliVersion = cliVersion; + if (gitCommit !== undefined) lastApplied.gitCommit = gitCommit; + + const nextLock: LockFile = { + version: 1, + project: cfg.project, + environment: envAlias, + lastApplied, + resources: desired + }; + + await writeLock(envDir, nextLock); + console.log(`\nWrote lock file: ${path.join(envDir, "compose.lock.json")}`); + + const environmentWarn = ops.some((op) => op.kind === "warn" && op.resource === "environment"); + if (!environmentWarn) { + markEnvironmentRemoteAlias(state, envState.id, envAlias); + } + stateShouldWrite = true; + } else if (!planOnly) { + console.log("\nSkipped lock/state writes because warnings were emitted."); } for (const op of ops) { const label = op.kind.toUpperCase().padEnd(6); const details = "details" in op && op.details ? ` — ${op.details}` : ""; - console.log(`${label} ${op.resource}:${op.alias}${details}`); + console.log(`${label} [env=${op.env}] ${op.resource}:${op.alias}${details}`); } - console.log(""); + if (envByKind.warn > 0) warnedEnvironments += 1; + else succeededEnvironments += 1; + console.log( - `${args.plan ? "Plan" : "Apply"} complete. ` + - `create=${byKind.create}, update=${byKind.update}, noop=${byKind.noop}, warn=${byKind.warn}` + `${planOnly ? "Plan" : "Apply"} env ${envAlias}: ` + + `create=${envByKind.create}, delete=${envByKind.delete}, update=${envByKind.update}, noop=${envByKind.noop}, warn=${envByKind.warn}` ); + console.log(""); + } - process.exitCode = byKind.warn > 0 ? 1 : 0; + if (!planOnly && stateShouldWrite) { + await writeComposeState(rootDir, state); + if (stateResult.changed) { + console.log(`Wrote state file: ${statePath}`); + } } -}; + + console.log( + `Environments processed: total=${targetEnvs.length}, succeeded=${succeededEnvironments}, warned=${warnedEnvironments}` + ); + console.log( + `${planOnly ? "Plan" : "Apply"} complete. ` + + `create=${byKind.create}, delete=${byKind.delete}, update=${byKind.update}, noop=${byKind.noop}, warn=${byKind.warn}` + ); + + process.exitCode = byKind.warn > 0 ? 1 : 0; +} async function readComposeConfig(filePath: string): Promise { const text = await fs.readFile(filePath, "utf8"); @@ -240,4 +315,3 @@ async function getGitCommit(repoDir: string): Promise { return undefined; } } - diff --git a/src/commands/clone.ts b/src/commands/clone.ts new file mode 100644 index 0000000..9af2504 --- /dev/null +++ b/src/commands/clone.ts @@ -0,0 +1,128 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CommandModule } from "yargs"; +import YAML from "yaml"; +import { runPull } from "./pull.js"; + +type Args = { + dir: string; + project: string; + env: string; + baseUrl: string; + clientId?: string; + clientSecret?: string; + scope?: string; + audience?: string; + force: boolean; +}; + +export const cloneCommand: CommandModule<{}, Args> = { + command: "clone", + describe: "Bootstrap a local Compose project from remote state", + builder: { + dir: { + type: "string", + default: "./compose", + describe: "Target local directory for the cloned project" + }, + project: { + type: "string", + demandOption: true, + describe: "Remote project alias to clone" + }, + env: { + type: "string", + default: "dev", + describe: "Environment alias to clone" + }, + baseUrl: { + type: "string", + default: "https://management.umbracocompose.com", + describe: "Compose Management API base URL" + }, + clientId: { + type: "string", + describe: "OAuth client id (or set COMPOSE_MGMT_CLIENT_ID)" + }, + clientSecret: { + type: "string", + describe: "OAuth client secret (or set COMPOSE_MGMT_CLIENT_SECRET)" + }, + scope: { + type: "string", + describe: "Optional OAuth scope" + }, + audience: { + type: "string", + describe: "Optional OAuth audience" + }, + force: { + type: "boolean", + default: false, + describe: "Allow cloning into a non-empty directory" + } + }, + handler: async (args) => { + const rootDir = path.resolve(process.cwd(), args.dir); + await ensureTargetDir(rootDir, args.force); + await scaffoldCloneRoot(rootDir, args.project, args.env, args.baseUrl); + + await runPull({ + dir: rootDir, + env: args.env, + ...(args.clientId ? { clientId: args.clientId } : {}), + ...(args.clientSecret ? { clientSecret: args.clientSecret } : {}), + ...(args.scope ? { scope: args.scope } : {}), + ...(args.audience ? { audience: args.audience } : {}) + }); + + console.log(`Cloned project "${args.project}" env "${args.env}" into ${rootDir}`); + } +}; + +async function ensureTargetDir(rootDir: string, force: boolean): Promise { + const exists = await pathExists(rootDir); + if (!exists) { + await fs.mkdir(rootDir, { recursive: true }); + return; + } + + const entries = await fs.readdir(rootDir); + if (entries.length > 0 && !force) { + throw new Error( + `Clone target directory is not empty: ${rootDir}. ` + + `Use --force to proceed anyway.` + ); + } +} + +async function scaffoldCloneRoot( + rootDir: string, + project: string, + env: string, + baseUrl: string +): Promise { + const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); + const doc = { + version: 1, + project, + environments: { + [env]: { + dir: `./env/${env}`, + description: `${env} Environment`, + managementBaseUrl: baseUrl + } + } + }; + await fs.mkdir(path.join(rootDir, "env", env), { recursive: true }); + await fs.writeFile(composeYamlPath, YAML.stringify(doc), "utf8"); +} + +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/src/commands/env-rename.ts b/src/commands/env-rename.ts new file mode 100644 index 0000000..074bf2f --- /dev/null +++ b/src/commands/env-rename.ts @@ -0,0 +1,168 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CommandModule } from "yargs"; +import YAML from "yaml"; +import { + COMPOSE_STATE_FILE, + ensureStateForAliases, + findEnvironmentByAlias, + renameEnvironmentAlias, + writeComposeState, + readComposeState +} from "../compose/state.js"; + +type Args = { + dir: string; + from: string; + to: string; + moveDir: boolean; +}; + +type ComposeConfig = { + version: number; + project: string; + environments: Record< + string, + { + dir: string; + description?: string; + managementBaseUrl: string; + ingestionBaseUrl?: string; + defaultCollection?: string; + } + >; +}; + +export const envRenameCommand: CommandModule<{}, Args> = { + command: "env rename ", + describe: "Rename an environment alias in local IaC config and state", + builder: { + dir: { + type: "string", + default: "./compose", + describe: "Root Compose IaC directory (contains umbraco-compose.yaml)" + }, + from: { + type: "string", + describe: "Current environment alias" + }, + to: { + type: "string", + describe: "New environment alias" + }, + moveDir: { + type: "boolean", + default: false, + describe: "Also rename the environment directory when it matches ./env/" + } + }, + handler: async (args) => { + if (args.from === args.to) { + throw new Error("--from and --to must be different values."); + } + + const rootDir = path.resolve(process.cwd(), args.dir); + const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); + const cfg = await readComposeConfig(composeYamlPath); + + if (!cfg.environments[args.from]) { + throw new Error(`Environment "${args.from}" not found in ${composeYamlPath}`); + } + if (cfg.environments[args.to]) { + throw new Error(`Environment "${args.to}" already exists in ${composeYamlPath}`); + } + + const statePath = path.join(rootDir, COMPOSE_STATE_FILE); + const stateOnDisk = await readComposeState(rootDir); + const ensuredState = ensureStateForAliases( + stateOnDisk, + cfg.project, + Object.keys(cfg.environments) + ).state; + + const entry = findEnvironmentByAlias(ensuredState, args.from); + if (!entry) { + throw new Error( + `Environment "${args.from}" has no entry in ${statePath}. ` + + `Recreate ${COMPOSE_STATE_FILE} or rename manually.` + ); + } + if (findEnvironmentByAlias(ensuredState, args.to)) { + throw new Error(`Environment "${args.to}" already exists in ${statePath}`); + } + + const renamed = renameEnvironmentAlias(ensuredState, args.from, args.to); + if (!renamed) { + throw new Error(`Failed to update ${statePath}.`); + } + + const envEntries = Object.entries(cfg.environments); + const renamedEntries: Array<[string, ComposeConfig["environments"][string]]> = []; + let originalDirForRename: string | null = null; + let nextDirForRename: string | null = null; + for (const [alias, value] of envEntries) { + if (alias !== args.from) { + renamedEntries.push([alias, value]); + continue; + } + + const next = { ...value }; + if (args.moveDir) { + const defaultDir = `./env/${args.from}`; + if (next.dir === defaultDir) { + originalDirForRename = defaultDir; + nextDirForRename = `./env/${args.to}`; + next.dir = `./env/${args.to}`; + } + } + renamedEntries.push([args.to, next]); + } + + cfg.environments = Object.fromEntries(renamedEntries); + + if (args.moveDir && originalDirForRename && nextDirForRename) { + const oldDir = path.resolve(rootDir, originalDirForRename); + const newDir = path.resolve(rootDir, nextDirForRename); + await maybeRenameDirectory(oldDir, newDir); + } + + await fs.writeFile(composeYamlPath, YAML.stringify(cfg), "utf8"); + await writeComposeState(rootDir, ensuredState); + + console.log(`Renamed environment alias "${args.from}" -> "${args.to}"`); + console.log(`Updated: ${composeYamlPath}`); + console.log(`Updated: ${statePath}`); + } +}; + +async function readComposeConfig(filePath: string): Promise { + const text = await fs.readFile(filePath, "utf8"); + const doc = YAML.parse(text) as ComposeConfig; + if (!doc?.project || !doc?.environments) { + throw new Error(`Invalid umbraco-compose.yaml: missing project/environments`); + } + return doc; +} + +async function maybeRenameDirectory(oldDir: string, newDir: string): Promise { + const oldExists = await exists(oldDir); + if (!oldExists) return; + + const newExists = await exists(newDir); + if (newExists) { + throw new Error( + `Cannot move directory "${oldDir}" to "${newDir}" because destination already exists.` + ); + } + + await fs.rename(oldDir, newDir); +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/src/commands/generate.ts b/src/commands/generate.ts new file mode 100644 index 0000000..187c522 --- /dev/null +++ b/src/commands/generate.ts @@ -0,0 +1,316 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CommandModule } from "yargs"; +import YAML from "yaml"; + +type Entity = "collection" | "type-schema" | "ingestion-function" | "persisted-doc" | "webhook"; + +type Args = { + dir: string; + env: string; + entity: Entity; + alias: string; + description?: string; + url?: string; +}; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +type CollectionsFile = { + collections: Array<{ alias: string; description?: string | null }>; +}; + +type IngestionRegistry = { + functions: Array<{ alias: string; description?: string | null; scriptFile: string }>; +}; + +type PersistedRegistry = { + documents: Array<{ alias: string; description?: string | null; queryFile: string }>; +}; + +type WebhooksFile = { + webhooks: Array<{ + alias: string; + description?: string; + url: string; + eventTypes: string[]; + collectionAliases?: string[]; + typeSchemaAliases?: string[]; + customHeaders?: Record; + }>; +}; + +const WINDOWS_RESERVED_NAMES = new Set([ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9" +]); + +export const generateCommand: CommandModule<{}, Args> = { + command: "generate ", + describe: "Generate local Compose entity files/entries for a specific environment", + builder: { + dir: { + type: "string", + default: "./compose", + describe: "Root Compose IaC directory (contains umbraco-compose.yaml)" + }, + env: { + type: "string", + default: "dev", + describe: "Environment alias in umbraco-compose.yaml" + }, + entity: { + type: "string", + choices: ["collection", "type-schema", "ingestion-function", "persisted-doc", "webhook"], + describe: "Entity type to generate" + }, + alias: { + type: "string", + describe: "Alias for the new entity" + }, + description: { + type: "string", + describe: "Optional description override" + }, + url: { + type: "string", + describe: "Webhook URL (only used for entity=webhook)" + } + }, + handler: async (args) => { + if (args.url && args.entity !== "webhook") { + throw new Error(`--url is only supported for entity=webhook (got entity=${args.entity})`); + } + validateAlias(args.alias); + + const rootDir = path.resolve(process.cwd(), args.dir); + const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); + const cfg = await readComposeConfig(composeYamlPath); + const envCfg = cfg.environments[args.env]; + if (!envCfg) { + throw new Error(`Environment "${args.env}" not found in ${composeYamlPath}`); + } + + const envDir = path.resolve(rootDir, envCfg.dir); + await fs.mkdir(envDir, { recursive: true }); + + switch (args.entity) { + case "collection": + await generateCollection(envDir, args.alias, args.description); + break; + case "type-schema": + await generateTypeSchema(envDir, args.alias, args.description); + break; + case "ingestion-function": + await generateIngestionFunction(envDir, args.alias, args.description); + break; + case "persisted-doc": + await generatePersistedDoc(envDir, args.alias, args.description); + break; + case "webhook": + await generateWebhook(envDir, args.alias, args.description, args.url); + break; + default: + throw new Error(`Unsupported entity: ${args.entity}`); + } + + console.log(`Generated ${args.entity}:${args.alias} for env "${args.env}"`); + } +}; + +async function generateCollection(envDir: string, alias: string, description?: string): Promise { + const filePath = path.join(envDir, "collections.json"); + const doc = await readJson(filePath, { collections: [] }); + if (doc.collections.some((c) => c.alias === alias)) { + throw new Error(`Collection "${alias}" already exists in ${filePath}`); + } + doc.collections.push({ + alias, + description: description ?? `${alias} collection` + }); + await writeJson(filePath, doc); +} + +async function generateTypeSchema(envDir: string, alias: string, description?: string): Promise { + const filePath = path.join(envDir, "type-schemas", `${alias}.schema.json`); + if (await exists(filePath)) { + throw new Error(`Type schema "${alias}" already exists at ${filePath}`); + } + const doc = { + alias, + description: description ?? `${alias} type schema`, + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: {} + } + }; + await writeJson(filePath, doc); +} + +async function generateIngestionFunction(envDir: string, alias: string, description?: string): Promise { + const registryPath = path.join(envDir, "functions", "ingestion.json"); + const scriptRel = `functions/ingestion/${alias}.js`; + const scriptAbs = path.join(envDir, scriptRel); + const reg = await readJson(registryPath, { functions: [] }); + + if (reg.functions.some((f) => f.alias === alias)) { + throw new Error(`Ingestion function "${alias}" already exists in ${registryPath}`); + } + if (await exists(scriptAbs)) { + throw new Error(`Ingestion function script already exists: ${scriptAbs}`); + } + + reg.functions.push({ + alias, + description: description ?? `${alias} ingestion function`, + scriptFile: scriptRel + }); + + await writeJson(registryPath, reg); + await writeText( + scriptAbs, + [ + "export default function(input) {", + " return input;", + "}", + "" + ].join("\n") + ); +} + +async function generatePersistedDoc(envDir: string, alias: string, description?: string): Promise { + const registryPath = path.join(envDir, "graphql", "persisted.json"); + const queryRel = `graphql/persisted/${alias}.gql`; + const queryAbs = path.join(envDir, queryRel); + const reg = await readJson(registryPath, { documents: [] }); + + if (reg.documents.some((d) => d.alias === alias)) { + throw new Error(`Persisted document "${alias}" already exists in ${registryPath}`); + } + if (await exists(queryAbs)) { + throw new Error(`Persisted document query already exists: ${queryAbs}`); + } + + reg.documents.push({ + alias, + description: description ?? `${alias} persisted document`, + queryFile: queryRel + }); + + await writeJson(registryPath, reg); + await writeText( + queryAbs, + [ + "query Example {", + " content {", + " items {", + " __typename", + " }", + " }", + "}", + "" + ].join("\n") + ); +} + +async function generateWebhook( + envDir: string, + alias: string, + description: string | undefined, + url: string | undefined +): Promise { + const filePath = path.join(envDir, "webhooks.json"); + const doc = await readJson(filePath, { webhooks: [] }); + if (doc.webhooks.some((w) => w.alias === alias)) { + throw new Error(`Webhook "${alias}" already exists in ${filePath}`); + } + doc.webhooks.push({ + alias, + ...(description ? { description } : {}), + url: url ?? "https://example.com/webhook", + eventTypes: ["content.ingested"], + collectionAliases: [], + typeSchemaAliases: [], + customHeaders: {} + }); + await writeJson(filePath, doc); +} + +async function readComposeConfig(filePath: string): Promise { + const text = await fs.readFile(filePath, "utf8"); + const doc = YAML.parse(text) as ComposeConfig; + if (!doc?.project || !doc?.environments) { + throw new Error(`Invalid umbraco-compose.yaml: missing project/environments`); + } + return doc; +} + +async function readJson(filePath: string, fallback: T): Promise { + if (!(await exists(filePath))) return fallback; + const text = await fs.readFile(filePath, "utf8"); + return JSON.parse(text) as T; +} + +async function writeJson(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +async function writeText(filePath: string, value: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, value, "utf8"); +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +function validateAlias(alias: string): void { + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(alias)) { + throw new Error( + `Invalid alias "${alias}". Alias must be kebab-case (lowercase letters, numbers, and hyphens).` + ); + } + + if (alias === "." || alias === "..") { + throw new Error(`Invalid alias "${alias}". Reserved path segment.`); + } + + if (alias.includes("/") || alias.includes("\\")) { + throw new Error(`Invalid alias "${alias}". Alias cannot contain path separators.`); + } + + if (WINDOWS_RESERVED_NAMES.has(alias.toLowerCase())) { + throw new Error(`Invalid alias "${alias}". Reserved filename.`); + } +} diff --git a/src/commands/init.ts b/src/commands/init.ts index ffae3b9..4042b1e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -18,6 +18,7 @@ import { writeYamlFile, type WriteMode } from "../iac/write.js"; +import { createInitialState, COMPOSE_STATE_FILE } from "../compose/state.js"; type Args = { dir: string; @@ -108,6 +109,8 @@ export const initCommand: CommandModule<{}, Args> = { defaultCollection: args.collection }); const yamlRes = await writeYamlFile(yamlPath, yamlObj, mode); + const statePath = path.join(rootDir, COMPOSE_STATE_FILE); + const stateRes = await writeJsonFile(statePath, createInitialState(yamlObj.project, envs), mode); // 2) env folders const envRoot = path.join(rootDir, "env"); @@ -115,6 +118,7 @@ export const initCommand: CommandModule<{}, Args> = { const writes: Array<{ file: string; wrote: boolean; reason?: string }> = []; writes.push({ file: yamlPath, wrote: yamlRes.wrote, reason: (yamlRes as any).reason }); + writes.push({ file: statePath, wrote: stateRes.wrote, reason: (stateRes as any).reason }); for (const env of envs) { const dir = path.join(envRoot, env); diff --git a/src/commands/plan.ts b/src/commands/plan.ts new file mode 100644 index 0000000..8c76c48 --- /dev/null +++ b/src/commands/plan.ts @@ -0,0 +1,56 @@ +import type { CommandModule } from "yargs"; +import { runApply } from "./apply.js"; + +type Args = { + dir: string; + env?: string; + clientId?: string; + clientSecret?: string; + scope?: string; + audience?: string; + requireClean: boolean; +}; + +export const planCommand: CommandModule<{}, Args> = { + command: "plan", + describe: "Show planned Compose IaC operations without applying changes", + builder: { + dir: { + type: "string", + default: "./compose", + describe: "Root Compose IaC directory (contains umbraco-compose.yaml)" + }, + env: { + type: "string", + describe: "Environment name to plan (must exist in umbraco-compose.yaml). Omit to plan all environments." + }, + clientId: { + type: "string", + describe: "OAuth client id (or set COMPOSE_MGMT_CLIENT_ID)" + }, + clientSecret: { + type: "string", + describe: "OAuth client secret (or set COMPOSE_MGMT_CLIENT_SECRET)" + }, + scope: { + type: "string", + describe: "Optional OAuth scope" + }, + audience: { + type: "string", + describe: "Optional OAuth audience" + }, + requireClean: { + type: "boolean", + default: false, + describe: "Fail if the git working tree has uncommitted changes" + } + }, + handler: async (args) => { + await runApply({ + ...args, + plan: true, + forcedPlan: true + }); + } +}; diff --git a/src/commands/pull.ts b/src/commands/pull.ts new file mode 100644 index 0000000..1f08183 --- /dev/null +++ b/src/commands/pull.ts @@ -0,0 +1,437 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CommandModule } from "yargs"; +import YAML from "yaml"; +import { ManagementClient } from "../compose/management-client.js"; +import { composeManagementOAuth } from "../compose/auth/providers/oauth-compose.js"; +import { + ensureStateForAliases, + findEnvironmentByAlias, + markEnvironmentRemoteAlias, + readComposeState, + writeComposeState +} from "../compose/state.js"; + +type Args = { + dir: string; + env: string; + clientId?: string; + clientSecret?: string; + scope?: string; + audience?: string; +}; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +type CollectionsFile = { collections: Array<{ alias: string; description?: string | null }> }; +type IngestionRegistry = { functions: Array<{ alias: string; description?: string | null; scriptFile: string }> }; +type PersistedRegistry = { documents: Array<{ alias: string; description?: string | null; queryFile: string }> }; +type WebhooksFile = { + webhooks: Array<{ + alias: string; + description?: string | null; + url: string; + eventTypes: string[]; + collectionAliases?: string[]; + typeSchemaAliases?: string[]; + customHeaders?: Record; + }>; +}; + +type PulledSnapshot = { + collections: CollectionsFile; + typeSchemas: Array<{ alias: string; description?: string | null; schema: unknown }>; + ingestion: { + registry: IngestionRegistry; + scripts: Array<{ alias: string; script: string }>; + }; + persisted: { + registry: PersistedRegistry; + documents: Array<{ alias: string; document: string }>; + }; + webhooks: WebhooksFile; +}; + +export const pullCommand: CommandModule<{}, Args> = { + command: "pull", + describe: "Pull remote Compose environment state into local IaC files", + builder: { + dir: { + type: "string", + default: "./compose", + describe: "Root Compose IaC directory (contains umbraco-compose.yaml)" + }, + env: { + type: "string", + default: "dev", + describe: "Environment alias in umbraco-compose.yaml" + }, + clientId: { + type: "string", + describe: "OAuth client id (or set COMPOSE_MGMT_CLIENT_ID)" + }, + clientSecret: { + type: "string", + describe: "OAuth client secret (or set COMPOSE_MGMT_CLIENT_SECRET)" + }, + scope: { + type: "string", + describe: "Optional OAuth scope" + }, + audience: { + type: "string", + describe: "Optional OAuth audience" + } + }, + handler: async (args) => { + await runPull(args); + } +}; + +export async function runPull(args: Args): Promise { + const rootDir = path.resolve(process.cwd(), args.dir); + const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); + const cfg = await readComposeConfig(composeYamlPath); + const envCfg = cfg.environments[args.env]; + if (!envCfg) { + throw new Error(`Environment "${args.env}" not found in ${composeYamlPath}`); + } + + const clientId = args.clientId ?? process.env.COMPOSE_MGMT_CLIENT_ID; + const clientSecret = args.clientSecret ?? process.env.COMPOSE_MGMT_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error( + "Missing OAuth credentials. Provide --clientId/--clientSecret or set COMPOSE_MGMT_CLIENT_ID and COMPOSE_MGMT_CLIENT_SECRET." + ); + } + + const envDir = path.resolve(rootDir, envCfg.dir); + await fs.mkdir(envDir, { recursive: true }); + const state = ensureStateForAliases(await readComposeState(rootDir), cfg.project, Object.keys(cfg.environments)).state; + const envState = findEnvironmentByAlias(state, args.env); + const stateRemoteAlias = envState?.remoteAlias; + + const client = new ManagementClient({ + baseUrl: envCfg.managementBaseUrl, + auth: composeManagementOAuth({ + clientId, + clientSecret, + baseUrl: envCfg.managementBaseUrl + }) + }); + + const resolvedRemoteEnvAlias = await resolveRemoteEnvironmentAlias( + client, + cfg.project, + args.env, + stateRemoteAlias + ); + if (!resolvedRemoteEnvAlias) { + throw new Error( + `Environment "${args.env}" was not found remotely for project "${cfg.project}" (checked aliases: ${[ + args.env, + ...(stateRemoteAlias ? [stateRemoteAlias] : []) + ] + .filter((v, i, arr) => arr.indexOf(v) === i) + .join(", ")}).` + ); + } + + const snapshot = await fetchSnapshot(client, cfg.project, resolvedRemoteEnvAlias); + const stagingDir = path.join(envDir, `.__pull_staging__${Date.now()}_${Math.random().toString(36).slice(2)}`); + + try { + await materializeSnapshotToStaging(stagingDir, snapshot); + await commitStagedSnapshot(envDir, stagingDir); + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }); + } + + if (envState) { + markEnvironmentRemoteAlias(state, envState.id, resolvedRemoteEnvAlias); + } + await writeComposeState(rootDir, state); + + const aliasMsg = + resolvedRemoteEnvAlias === args.env + ? `"${args.env}"` + : `"${resolvedRemoteEnvAlias}" (mapped to local "${args.env}")`; + console.log(`Pulled remote state for env ${aliasMsg} into ${envDir}`); +} + +async function fetchSnapshot(client: ManagementClient, project: string, env: string): Promise { + const collections = await pullCollections(client, project, env); + const typeSchemas = await pullTypeSchemas(client, project, env); + const ingestion = await pullIngestionFunctions(client, project, env); + const persisted = await pullPersistedDocs(client, project, env); + const webhooks = await pullWebhooks(client, project, env); + + return { + collections, + typeSchemas, + ingestion, + persisted, + webhooks + }; +} + +async function pullCollections(client: ManagementClient, project: string, env: string): Promise { + const res = await client.get(`${envBase(project, env)}/collections`); + if (res.status >= 400) { + throw new Error(`Failed to list collections (${res.status}).`); + } + const items = asNodes(res.data, ["collectionAlias", "alias"]); + return { + collections: items + .map((c) => ({ + alias: String(c.collectionAlias ?? c.alias), + ...(typeof c.description === "string" || c.description === null ? { description: c.description } : {}) + })) + .sort((a, b) => a.alias.localeCompare(b.alias)) + }; +} + +async function pullTypeSchemas( + client: ManagementClient, + project: string, + env: string +): Promise> { + const list = await client.get(`${envBase(project, env)}/type-schemas`); + if (list.status >= 400) { + throw new Error(`Failed to list type schemas (${list.status}).`); + } + + const items = asNodes(list.data, ["typeSchemaAlias", "alias"]); + const out: Array<{ alias: string; description?: string | null; schema: unknown }> = []; + + for (const item of items) { + const alias = String(item.typeSchemaAlias ?? item.alias); + const get = await client.get(`${envBase(project, env)}/type-schemas/${encodeURIComponent(alias)}`); + if (get.status >= 400) { + throw new Error(`Failed to get type schema "${alias}" (${get.status}).`); + } + out.push({ + alias, + description: get.data?.description ?? get.data?.typeSchema?.description ?? null, + schema: get.data?.schema ?? get.data?.typeSchema?.schema + }); + } + + out.sort((a, b) => a.alias.localeCompare(b.alias)); + return out; +} + +async function pullIngestionFunctions( + client: ManagementClient, + project: string, + env: string +): Promise<{ registry: IngestionRegistry; scripts: Array<{ alias: string; script: string }> }> { + const list = await client.get(`${envBase(project, env)}/functions/ingestion`); + if (list.status >= 400) { + throw new Error(`Failed to list ingestion functions (${list.status}).`); + } + + const items = asNodes(list.data, ["ingestionFunctionAlias", "alias"]); + const registry: IngestionRegistry = { functions: [] }; + const scripts: Array<{ alias: string; script: string }> = []; + + for (const item of items) { + const alias = String(item.ingestionFunctionAlias ?? item.alias); + const get = await client.get(`${envBase(project, env)}/functions/ingestion/${encodeURIComponent(alias)}`); + if (get.status >= 400) { + throw new Error(`Failed to get ingestion function "${alias}" (${get.status}).`); + } + + const description = get.data?.description ?? get.data?.ingestionFunction?.description ?? null; + const script = String(get.data?.script ?? get.data?.ingestionFunction?.script ?? ""); + + registry.functions.push({ + alias, + description, + scriptFile: `functions/ingestion/${alias}.js` + }); + scripts.push({ alias, script }); + } + + registry.functions.sort((a, b) => a.alias.localeCompare(b.alias)); + scripts.sort((a, b) => a.alias.localeCompare(b.alias)); + return { registry, scripts }; +} + +async function pullPersistedDocs( + client: ManagementClient, + project: string, + env: string +): Promise<{ registry: PersistedRegistry; documents: Array<{ alias: string; document: string }> }> { + const list = await client.get(`${envBase(project, env)}/graphql/persisted-documents`); + if (list.status >= 400) { + throw new Error(`Failed to list persisted docs (${list.status}).`); + } + + const items = asNodes(list.data, ["persistedDocumentAlias", "alias"]); + const registry: PersistedRegistry = { documents: [] }; + const documents: Array<{ alias: string; document: string }> = []; + + for (const item of items) { + const alias = String(item.persistedDocumentAlias ?? item.alias); + const get = await client.get( + `${envBase(project, env)}/graphql/persisted-documents/${encodeURIComponent(alias)}` + ); + if (get.status >= 400) { + throw new Error(`Failed to get persisted doc "${alias}" (${get.status}).`); + } + + registry.documents.push({ + alias, + description: get.data?.description ?? null, + queryFile: `graphql/persisted/${alias}.gql` + }); + documents.push({ + alias, + document: String(get.data?.document ?? get.data?.query ?? "") + }); + } + + registry.documents.sort((a, b) => a.alias.localeCompare(b.alias)); + documents.sort((a, b) => a.alias.localeCompare(b.alias)); + return { registry, documents }; +} + +async function pullWebhooks(client: ManagementClient, project: string, env: string): Promise { + const list = await client.get(`${envBase(project, env)}/webhooks`); + if (list.status >= 400) { + throw new Error(`Failed to list webhooks (${list.status}).`); + } + const items = asNodes(list.data, ["webhookAlias", "alias"]); + const webhooks: WebhooksFile = { webhooks: [] }; + + for (const item of items) { + const alias = String(item.webhookAlias ?? item.alias); + const get = await client.get(`${envBase(project, env)}/webhooks/${encodeURIComponent(alias)}`); + if (get.status >= 400) { + throw new Error(`Failed to get webhook "${alias}" (${get.status}).`); + } + const dto = get.data ?? {}; + webhooks.webhooks.push({ + alias, + ...(dto.description !== undefined ? { description: dto.description } : {}), + url: dto.url ?? "", + eventTypes: dto.eventTypes ?? [], + collectionAliases: dto.collectionAliases ?? [], + typeSchemaAliases: dto.typeSchemaAliases ?? [], + customHeaders: dto.customHeaders ?? {} + }); + } + + webhooks.webhooks.sort((a, b) => a.alias.localeCompare(b.alias)); + return webhooks; +} + +async function materializeSnapshotToStaging(stagingDir: string, snapshot: PulledSnapshot): Promise { + await fs.mkdir(path.join(stagingDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(stagingDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(stagingDir, "graphql", "persisted"), { recursive: true }); + + await writeJson(path.join(stagingDir, "collections.json"), snapshot.collections); + await writeJson(path.join(stagingDir, "webhooks.json"), snapshot.webhooks); + + for (const ts of snapshot.typeSchemas) { + await writeJson(path.join(stagingDir, "type-schemas", `${ts.alias}.schema.json`), ts); + } + + await writeJson(path.join(stagingDir, "functions", "ingestion.json"), snapshot.ingestion.registry); + for (const script of snapshot.ingestion.scripts) { + await writeText(path.join(stagingDir, "functions", "ingestion", `${script.alias}.js`), `${script.script}\n`); + } + + await writeJson(path.join(stagingDir, "graphql", "persisted.json"), snapshot.persisted.registry); + for (const d of snapshot.persisted.documents) { + await writeText(path.join(stagingDir, "graphql", "persisted", `${d.alias}.gql`), `${d.document}\n`); + } +} + +async function commitStagedSnapshot(envDir: string, stagingDir: string): Promise { + await replaceDir(path.join(stagingDir, "type-schemas"), path.join(envDir, "type-schemas")); + await replaceDir(path.join(stagingDir, "functions", "ingestion"), path.join(envDir, "functions", "ingestion")); + await replaceDir(path.join(stagingDir, "graphql", "persisted"), path.join(envDir, "graphql", "persisted")); + + await replaceFile(path.join(stagingDir, "collections.json"), path.join(envDir, "collections.json")); + await replaceFile(path.join(stagingDir, "webhooks.json"), path.join(envDir, "webhooks.json")); + await replaceFile(path.join(stagingDir, "functions", "ingestion.json"), path.join(envDir, "functions", "ingestion.json")); + await replaceFile(path.join(stagingDir, "graphql", "persisted.json"), path.join(envDir, "graphql", "persisted.json")); +} + +async function replaceDir(stagedDir: string, targetDir: string): Promise { + await fs.mkdir(path.dirname(targetDir), { recursive: true }); + await fs.rm(targetDir, { recursive: true, force: true }); + await fs.rename(stagedDir, targetDir); +} + +async function replaceFile(stagedFile: string, targetFile: string): Promise { + await fs.mkdir(path.dirname(targetFile), { recursive: true }); + await fs.rm(targetFile, { force: true }); + await fs.rename(stagedFile, targetFile); +} + +async function listRemoteEnvironmentAliases(client: ManagementClient, project: string): Promise> { + const res = await client.get(`/v1/projects/${encodeURIComponent(project)}/environments`); + if (res.status >= 400) { + throw new Error(`Failed to list environments (${res.status}).`); + } + const items = asNodes(res.data, ["environmentAlias", "alias"]); + const out = new Set(); + for (const item of items) out.add(String(item.environmentAlias ?? item.alias)); + return out; +} + +async function resolveRemoteEnvironmentAlias( + client: ManagementClient, + project: string, + localAlias: string, + stateRemoteAlias?: string +): Promise { + const remoteAliases = await listRemoteEnvironmentAliases(client, project); + const candidates = [stateRemoteAlias, localAlias].filter((v, i, arr): v is string => { + return typeof v === "string" && v.length > 0 && arr.indexOf(v) === i; + }); + for (const alias of candidates) { + if (remoteAliases.has(alias)) return alias; + } + return null; +} + +function asNodes(data: any, aliasKeys: string[]): any[] { + const arr = data?.items ?? data?.nodes ?? data?.edges?.map((e: any) => e?.node) ?? []; + if (!Array.isArray(arr)) return []; + return arr.filter((node) => { + if (!node || typeof node !== "object") return false; + return aliasKeys.some((k) => typeof node[k] === "string"); + }); +} + +function envBase(project: string, env: string): string { + return `/v1/projects/${encodeURIComponent(project)}/environments/${encodeURIComponent(env)}`; +} + +async function readComposeConfig(filePath: string): Promise { + const text = await fs.readFile(filePath, "utf8"); + const doc = YAML.parse(text) as ComposeConfig; + if (!doc?.project || !doc?.environments) { + throw new Error(`Invalid umbraco-compose.yaml: missing project/environments`); + } + return doc; +} + +async function writeJson(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +async function writeText(filePath: string, text: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, text, "utf8"); +} diff --git a/src/commands/status.ts b/src/commands/status.ts index 7796c15..aea831f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import type { CommandModule } from "yargs"; import YAML from "yaml"; import { computeDesiredHashes, readLock } from "../compose/lock.js"; +import { findEnvironmentByAlias, readComposeState } from "../compose/state.js"; type Args = { dir: string; @@ -35,6 +36,8 @@ type GroupResult = { type EnvStatus = { env: string; envDir: string; + remoteAlias?: string; + renamePending?: boolean; hasLock: boolean; lastAppliedAt?: string; lastAppliedCliVersion?: string; @@ -72,6 +75,7 @@ export const statusCommand: CommandModule<{}, Args> = { const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); const cfg = await readComposeConfig(composeYamlPath); + const state = await readComposeState(rootDir); const envNames = Object.keys(cfg.environments ?? {}); if (envNames.length === 0) { @@ -89,6 +93,8 @@ export const statusCommand: CommandModule<{}, Args> = { } const envDir = path.resolve(rootDir, envCfg.dir); + const envState = state ? findEnvironmentByAlias(state, envName) : undefined; + const remoteAlias = envState?.remoteAlias; const desired = await computeDesiredHashes(envDir); const lock = await readLock(envDir); @@ -104,6 +110,8 @@ export const statusCommand: CommandModule<{}, Args> = { results.push({ env: envName, envDir, + ...(remoteAlias && { remoteAlias }), + ...(remoteAlias && remoteAlias !== envName && { renamePending: true }), hasLock: !!lock, ...(lock?.lastApplied?.at && { lastAppliedAt: lock.lastApplied.at @@ -169,6 +177,11 @@ function printHuman(project: string, envs: EnvStatus[]) { for (const e of envs) { console.log(`Environment: ${e.env}`); console.log(`Dir: ${e.envDir}`); + if (e.renamePending && e.remoteAlias) { + console.log(`Identity: pending rename (${e.remoteAlias} -> ${e.env})`); + } else if (e.remoteAlias) { + console.log(`Identity: remote alias ${e.remoteAlias}`); + } if (!e.hasLock) { console.log("Lock: (none)"); } else { @@ -226,4 +239,4 @@ async function readComposeConfig(filePath: string): Promise { } return doc as ComposeConfig; -} \ No newline at end of file +} diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 81b73a8..8ca2abc 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -138,6 +138,9 @@ async function validateEnv(envName: string, envDir: string, issues: Issue[]) { // Validate schema files await validateTypeSchemas(path.join(envDir, "type-schemas"), issues); + + // Heuristic warnings for rename-inference ambiguity risk. + await validateRenameInferenceRisks(envDir, issues); } async function validateCollections(filePath: string, issues: Issue[]) { @@ -345,6 +348,143 @@ async function validateTypeSchemas(dirPath: string, issues: Issue[]) { } } +async function validateRenameInferenceRisks(envDir: string, issues: Issue[]) { + // Collections: signature is currently description-only for rename inference. + const collectionsFile = await readJsonQuiet(path.join(envDir, "collections.json")); + if (collectionsFile && Array.isArray(collectionsFile.collections)) { + const entries = collectionsFile.collections + .filter((c: any) => typeof c?.alias === "string") + .map((c: any) => ({ + alias: String(c.alias), + signature: JSON.stringify({ description: c.description ?? null }) + })); + pushRenameRiskWarnings("collections", entries, path.join(envDir, "collections.json"), issues); + } + + // Type schemas: signature uses description + schema. + const typeSchemaDir = path.join(envDir, "type-schemas"); + if (await exists(typeSchemaDir)) { + const entries: Array<{ alias: string; signature: string }> = []; + const files = await fs.readdir(typeSchemaDir, { withFileTypes: true }); + for (const f of files) { + if (!f.isFile() || !f.name.endsWith(".schema.json")) continue; + const obj = await readJsonQuiet(path.join(typeSchemaDir, f.name)); + if (!obj || typeof obj.alias !== "string") continue; + entries.push({ + alias: obj.alias, + signature: JSON.stringify({ description: obj.description ?? null, schema: obj.schema ?? null }) + }); + } + pushRenameRiskWarnings("type-schemas", entries, typeSchemaDir, issues); + } + + // Ingestion functions: signature uses description + script. + const ingestionRegistryPath = path.join(envDir, "functions", "ingestion.json"); + const ingestionReg = await readJsonQuiet(ingestionRegistryPath); + if (ingestionReg && Array.isArray(ingestionReg.functions)) { + const entries: Array<{ alias: string; signature: string }> = []; + for (const fn of ingestionReg.functions) { + if (typeof fn?.alias !== "string") continue; + if (typeof fn?.scriptFile !== "string") continue; + const scriptPath = path.resolve(path.join(envDir, fn.scriptFile)); + if (!(await exists(scriptPath))) continue; + const script = await fs.readFile(scriptPath, "utf8"); + entries.push({ + alias: fn.alias, + signature: JSON.stringify({ description: fn.description ?? "", script }) + }); + } + pushRenameRiskWarnings("ingestion-functions", entries, ingestionRegistryPath, issues); + } + + // Persisted docs: signature uses description + document. + const persistedRegistryPath = path.join(envDir, "graphql", "persisted.json"); + const persistedReg = await readJsonQuiet(persistedRegistryPath); + if (persistedReg && Array.isArray(persistedReg.documents)) { + const entries: Array<{ alias: string; signature: string }> = []; + for (const d of persistedReg.documents) { + if (typeof d?.alias !== "string") continue; + if (typeof d?.queryFile !== "string") continue; + const queryPath = path.resolve(path.join(envDir, d.queryFile)); + if (!(await exists(queryPath))) continue; + const document = await fs.readFile(queryPath, "utf8"); + entries.push({ + alias: d.alias, + signature: JSON.stringify({ description: d.description ?? "", document }) + }); + } + pushRenameRiskWarnings("persisted-docs", entries, persistedRegistryPath, issues); + } + + // Webhooks: signature uses url + description + normalized filters/headers. + const webhooksPath = path.join(envDir, "webhooks.json"); + const webhooks = await readJsonQuiet(webhooksPath); + if (webhooks && Array.isArray(webhooks.webhooks)) { + const entries = webhooks.webhooks + .filter((w: any) => typeof w?.alias === "string") + .map((w: any) => ({ + alias: String(w.alias), + signature: JSON.stringify({ + description: w.description ?? null, + url: String(w.url ?? ""), + eventTypes: normalizeStringArray(w.eventTypes), + collectionAliases: normalizeStringArray(w.collectionAliases), + typeSchemaAliases: normalizeStringArray(w.typeSchemaAliases), + customHeaders: normalizeStringMap(w.customHeaders) + }) + })); + pushRenameRiskWarnings("webhooks", entries, webhooksPath, issues); + } +} + +function pushRenameRiskWarnings( + entityName: string, + entries: Array<{ alias: string; signature: string }>, + file: string, + issues: Issue[] +) { + const groups = findDuplicateSignatureAliasGroups(entries); + if (groups.length === 0) return; + const rendered = groups.map((g) => `[${g.join(", ")}]`).join("; "); + const guidanceByEntity: Record = { + collections: "ensure collection descriptions are distinct for entities you may rename", + "type-schemas": "ensure schema+description combinations are distinct", + "ingestion-functions": "ensure script+description combinations are distinct", + "persisted-docs": "ensure document+description combinations are distinct", + webhooks: "ensure url/description/events/filters/headers combinations are distinct" + }; + const guidance = + guidanceByEntity[entityName] ?? + "ensure rename-signature inputs are distinct"; + issues.push({ + level: "warn", + file, + message: + `${entityName} contains entries with identical rename-signatures; ` + + `rename inference may be ambiguous: ${rendered}. ` + + `Resolve by: ${guidance}, or apply the intended create/delete explicitly.` + }); +} + +function findDuplicateSignatureAliasGroups( + entries: Array<{ alias: string; signature: string }> +): string[][] { + const bySignature = new Map(); + for (const entry of entries) { + const list = bySignature.get(entry.signature) ?? []; + list.push(entry.alias); + bySignature.set(entry.signature, list); + } + const groups: string[][] = []; + for (const aliases of bySignature.values()) { + if (aliases.length <= 1) continue; + aliases.sort((a, b) => a.localeCompare(b)); + groups.push(aliases); + } + groups.sort((a, b) => a.join(",").localeCompare(b.join(","))); + return groups; +} + async function readComposeConfig(filePath: string, issues: Issue[]): Promise { if (!(await exists(filePath))) { issues.push({ level: "error", file: filePath, message: "Missing umbraco-compose.yaml" }); @@ -406,6 +546,16 @@ async function readJson(filePath: string, issues: Issue[]): Promise } } +async function readJsonQuiet(filePath: string): Promise { + if (!(await exists(filePath))) return null; + try { + const text = await fs.readFile(filePath, "utf8"); + return JSON.parse(text); + } catch { + return null; + } +} + async function exists(p: string): Promise { try { await fs.stat(p); @@ -420,6 +570,18 @@ function isKebab(s: string): boolean { return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(s); } +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((v) => String(v)).sort((a, b) => a.localeCompare(b)); +} + +function normalizeStringMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = String(v); + return out; +} + function finish(issues: Issue[], strict: boolean) { const errors = issues.filter((i) => i.level === "error"); const warnings = issues.filter((i) => i.level === "warn"); diff --git a/src/compose/apply.ts b/src/compose/apply.ts index 9a50866..477f553 100644 --- a/src/compose/apply.ts +++ b/src/compose/apply.ts @@ -5,14 +5,18 @@ import { ManagementClient } from "./management-client.js"; import { composeManagementOAuth } from "./auth/providers/oauth-compose.js"; type PlanOp = - | { kind: "create"; resource: string; alias: string; details?: string } - | { kind: "update"; resource: string; alias: string; details?: string } - | { kind: "noop"; resource: string; alias: string; details?: string } - | { kind: "warn"; resource: string; alias: string; details?: string }; + | { kind: "create"; env: string; resource: string; alias: string; details?: string } + | { kind: "delete"; env: string; resource: string; alias: string; details?: string } + | { kind: "update"; env: string; resource: string; alias: string; details?: string } + | { kind: "noop"; env: string; resource: string; alias: string; details?: string } + | { kind: "warn"; env: string; resource: string; alias: string; details?: string }; export type ApplyOptions = { project: string; env: string; + remoteEnvAlias?: string; + tolerateMissingResourceLists?: boolean; + apiEnvironmentAlias?: string; envDir: string; envDescription: string; baseUrl: string; @@ -27,6 +31,7 @@ export type ApplyOptions = { async function ensureEnvironmentExists(client: ManagementClient, opts: ApplyOptions): Promise { const out: PlanOp[] = []; + const opEnv = opts.env; const listPath = `/v1/projects/${encodeURIComponent(opts.project)}/environments`; const existingRes = await client.get(listPath); @@ -34,6 +39,7 @@ async function ensureEnvironmentExists(client: ManagementClient, opts: ApplyOpti if (existingRes.status >= 400) { out.push({ kind: "warn", + env: opEnv, resource: "environment", alias: opts.env, details: `Failed to list environments (${existingRes.status}).` @@ -53,34 +59,73 @@ async function ensureEnvironmentExists(client: ManagementClient, opts: ApplyOpti return typeof alias === "string" && alias === opts.env; }); + const hasAlias = (alias: string | undefined) => + typeof alias === "string" && + items.some((e: any) => { + const value = e?.environmentAlias ?? e?.alias; + return typeof value === "string" && value === alias; + }); + + const priorAlias = opts.remoteEnvAlias; + const hasPriorAlias = hasAlias(priorAlias); + + if (priorAlias && priorAlias !== opts.env && hasPriorAlias && !exists) { + out.push({ + kind: "update", + env: opEnv, + resource: "environment", + alias: opts.env, + details: `rename ${priorAlias} -> ${opts.env}` + }); + + if (opts.planOnly) return out; + + const renameStatus = await tryRenameEnvironmentAlias(client, opts.project, priorAlias, opts.env); + if (renameStatus < 200 || renameStatus >= 300) { + out.push({ + kind: "warn", + env: opEnv, + resource: "environment", + alias: opts.env, + details: `Rename failed (from ${priorAlias}, status ${renameStatus}).` + }); + } + return out; + } + if (exists) { - out.push({ kind: "noop", resource: "environment", alias: opts.env }); + out.push({ kind: "noop", env: opEnv, resource: "environment", alias: opts.env }); return out; } - out.push({ kind: "create", resource: "environment", alias: opts.env }); + out.push({ kind: "create", env: opEnv, resource: "environment", alias: opts.env }); if (opts.planOnly) return out; const createPath = `/v1/projects/${encodeURIComponent(opts.project)}/environments`; const body: any = { environmentAlias: opts.env }; - // Apply env description if it is part of ApplyOptions - if ("envDescription" in opts && (opts as any).envDescription) { - body.description = (opts as any).envDescription; + if (opts.envDescription) { + body.description = opts.envDescription; } const res = await client.post(createPath, body); - // Some APIs return 409 if it already exists (race-safe) if (res.status === 409) { - out.push({ kind: "noop", resource: "environment", alias: opts.env, details: "already exists" }); + out.push({ + kind: "noop", + env: opEnv, + resource: "environment", + alias: opts.env, + details: "already exists" + }); return out; } if (res.status >= 400) { out.push({ kind: "warn", + env: opEnv, resource: "environment", alias: opts.env, details: `Create failed (${res.status}).` @@ -108,26 +153,49 @@ export async function applyEnvironment(opts: ApplyOptions): Promise { // Short circuit if the environment check fails badly. if (ops.some(o => o.kind === "warn" && o.resource === "environment")) return ops; + const hasEnvCreate = ops.some((o) => o.resource === "environment" && o.kind === "create"); + const hasEnvRename = ops.some( + (o) => + o.resource === "environment" && + o.kind === "update" && + typeof o.details === "string" && + o.details.startsWith("rename ") + ); + + const shouldTolerateMissingLists = hasEnvCreate || (!opts.planOnly && hasEnvRename); + + // During plan for a rename, the remote resources still live under the old alias. + // Use that alias for reads so the plan reflects existing remote state. + const apiEnvironmentAlias = + opts.planOnly && hasEnvRename && opts.remoteEnvAlias ? opts.remoteEnvAlias : opts.env; + + const nestedOpts: ApplyOptions = { + ...opts, + tolerateMissingResourceLists: shouldTolerateMissingLists, + apiEnvironmentAlias + }; + // 1) collections - ops.push(...(await applyCollections(client, opts))); + ops.push(...(await applyCollections(client, nestedOpts))); // 2) type schemas - ops.push(...(await applyTypeSchemas(client, opts))); + ops.push(...(await applyTypeSchemas(client, nestedOpts))); // 3) ingestion functions - ops.push(...(await applyIngestionFunctions(client, opts))); + ops.push(...(await applyIngestionFunctions(client, nestedOpts))); // 4) persisted docs - ops.push(...(await applyPersistedDocs(client, opts))); + ops.push(...(await applyPersistedDocs(client, nestedOpts))); // 5) webhooks - ops.push(...(await applyWebhooks(client, opts))); + ops.push(...(await applyWebhooks(client, nestedOpts))); return ops; } function envBase(opts: ApplyOptions) { - return `/v1/projects/${encodeURIComponent(opts.project)}/environments/${encodeURIComponent(opts.env)}`; + const envAlias = opts.apiEnvironmentAlias ?? opts.env; + return `/v1/projects/${encodeURIComponent(opts.project)}/environments/${encodeURIComponent(envAlias)}`; } async function readJsonFile(filePath: string): Promise { @@ -150,17 +218,33 @@ type CollectionsFile = { collections: Array<{ alias: string; description?: strin async function applyCollections(client: ManagementClient, opts: ApplyOptions): Promise { const out: PlanOp[] = []; + const opEnv = opts.env; const filePath = path.join(opts.envDir, "collections.json"); if (!(await exists(filePath))) return out; const desired = await readJsonFile(filePath); + const desiredAliases = new Set(desired.collections.map((c) => c.alias)); // list existing const listPath = `${envBase(opts)}/collections`; console.log("GET", `${envBase(opts)}/collections`); const existingRes = await client.get(listPath); - if (existingRes.status >= 400) { - out.push({ kind: "warn", resource: "collection", alias: "*", details: `Failed to list collections (${existingRes.status}).` }); + if (existingRes.status === 404 && opts.tolerateMissingResourceLists) { + out.push({ + kind: "noop", + env: opEnv, + resource: "collection", + alias: "*", + details: "List returned 404 during environment convergence; continuing with an empty remote set." + }); + } else if (existingRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias: "*", + details: `Failed to list collections (${existingRes.status}).` + }); return out; } @@ -178,48 +262,281 @@ async function applyCollections(client: ManagementClient, opts: ApplyOptions): P if (typeof alias === "string") existingAliases.set(alias, c); } + const unmatchedDesired = desired.collections.filter((c) => !existingAliases.has(c.alias)); + const unmatchedRemoteAliases = [...existingAliases.keys()].filter((alias) => !desiredAliases.has(alias)); + const desiredRenameCandidates = unmatchedDesired.map((c) => ({ + alias: c.alias, + signature: stringify({ description: c.description ?? null }) + })); + const remoteRenameCandidates: Array<{ alias: string; signature: string }> = []; + for (const alias of unmatchedRemoteAliases) { + const getPath = `${envBase(opts)}/collections/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + remoteRenameCandidates.push({ + alias, + signature: stringify({ description: existing.data?.description ?? existing.data?.collection?.description ?? null }) + }); + } + const renameAnalysis = computeRenameAnalysis(desiredRenameCandidates, remoteRenameCandidates); + const renameFromByTo = renameAnalysis.renameFromByTo; + const ambiguousRemoteAliases = renameAnalysis.ambiguousRemoteAliases; + const renamedFrom = new Set(renameFromByTo.values()); + for (const c of desired.collections) { const alias = c.alias; const found = existingAliases.get(alias); + const desiredDescription = c.description ?? null; + const renameFrom = renameFromByTo.get(alias); + if (renameFrom) { + out.push({ + kind: "update", + env: opEnv, + resource: "collection", + alias, + details: `rename ${renameFrom} -> ${alias}` + }); + if (!opts.planOnly) { + const renamePath = `${envBase(opts)}/collections/${encodeURIComponent(renameFrom)}/commands/rename`; + const renameRes = await client.post(renamePath, { newCollectionAlias: alias }); + if (renameRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias, + details: `Rename failed (${renameRes.status}).` + }); + } + } + continue; + } if (!found) { - out.push({ kind: "create", resource: "collection", alias }); + out.push({ kind: "create", env: opEnv, resource: "collection", alias }); if (!opts.planOnly) { const createPath = `${envBase(opts)}/collections`; - const body = { collectionAlias: alias, description: c.description ?? null }; + const body = { collectionAlias: alias, description: desiredDescription }; const res = await client.post(createPath, body); if (res.status >= 400) { - out.push({ kind: "warn", resource: "collection", alias, details: `Create failed (${res.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias, + details: `Create failed (${res.status}).` + }); } } } else { - // description update path varies; for MVP we no-op unless missing - out.push({ kind: "noop", resource: "collection", alias }); + const getPath = `${envBase(opts)}/collections/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + + const remoteDescription = existing.data?.description ?? existing.data?.collection?.description ?? null; + const descriptionChanged = (remoteDescription ?? null) !== desiredDescription; + + if (!descriptionChanged) { + out.push({ kind: "noop", env: opEnv, resource: "collection", alias }); + continue; + } + + out.push({ + kind: "update", + env: opEnv, + resource: "collection", + alias, + details: "description" + }); + + if (opts.planOnly) continue; + + const updateDescriptionPath = + `${envBase(opts)}/collections/${encodeURIComponent(alias)}/commands/update-description`; + const updateDescriptionRes = await client.put(updateDescriptionPath, { + newDescription: desiredDescription + }); + if (updateDescriptionRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias, + details: `Update description failed (${updateDescriptionRes.status}).` + }); + } + } + } + + for (const alias of existingAliases.keys()) { + if (desiredAliases.has(alias) || renamedFrom.has(alias)) continue; + const details = ambiguousRemoteAliases.has(alias) ? "no unique rename match" : undefined; + out.push({ + kind: "delete", + env: opEnv, + resource: "collection", + alias, + ...(details !== undefined ? { details } : {}) + }); + if (!opts.planOnly) { + const deletePath = `${envBase(opts)}/collections/${encodeURIComponent(alias)}`; + const deleteRes = await client.delete(deletePath); + if (deleteRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "collection", + alias, + details: `Delete failed (${deleteRes.status}).` + }); + } } } return out; } +async function tryRenameEnvironmentAlias( + client: ManagementClient, + project: string, + fromAlias: string, + toAlias: string +): Promise { + const renamePath = + `/v1/projects/${encodeURIComponent(project)}` + + `/environments/${encodeURIComponent(fromAlias)}/commands/rename`; + + const response = await client.post(renamePath, { + newEnvironmentAlias: toAlias + }); + + return response.status; +} + /* -------------------- Type Schemas -------------------- */ type TypeSchemaFile = { alias: string; description?: string | null; schema: any }; async function applyTypeSchemas(client: ManagementClient, opts: ApplyOptions): Promise { const out: PlanOp[] = []; + const opEnv = opts.env; const dir = path.join(opts.envDir, "type-schemas"); if (!(await exists(dir))) return out; const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".schema.json")); + const desiredEntries: TypeSchemaFile[] = []; for (const f of files) { const p = path.join(dir, f); - const desired = await readJsonFile(p); + desiredEntries.push(await readJsonFile(p)); + } + const desiredAliases = new Set(desiredEntries.map((d) => d.alias)); + + const listPath = `${envBase(opts)}/type-schemas`; + const listRes = await client.get(listPath); + if (listRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias: "*", + details: `Failed to list type schemas (${listRes.status}).` + }); + return out; + } + + const listItems = + listRes.data?.items ?? + listRes.data?.typeSchemas ?? + listRes.data?.edges?.map((e: any) => e?.node) ?? + listRes.data?.nodes ?? + []; + const existingAliases = new Set(); + for (const item of listItems) { + const alias = item?.typeSchemaAlias ?? item?.alias; + if (typeof alias === "string") existingAliases.add(alias); + } + + const unmatchedDesired = desiredEntries.filter((d) => !existingAliases.has(d.alias)); + const unmatchedRemoteAliases = [...existingAliases].filter((alias) => !desiredAliases.has(alias)); + const desiredRenameCandidates = unmatchedDesired.map((d) => ({ + alias: d.alias, + signature: stringify({ schema: d.schema, description: d.description ?? null }) + })); + const remoteRenameCandidates: Array<{ alias: string; signature: string }> = []; + for (const alias of unmatchedRemoteAliases) { + const getPath = `${envBase(opts)}/type-schemas/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + const remoteSchema = existing.data?.schema ?? existing.data?.jsonSchema ?? existing.data?.typeSchema?.schema; + const remoteDesc = existing.data?.description ?? existing.data?.typeSchema?.description ?? null; + remoteRenameCandidates.push({ + alias, + signature: stringify({ schema: remoteSchema, description: remoteDesc ?? null }) + }); + } + const renameAnalysis = computeRenameAnalysis(desiredRenameCandidates, remoteRenameCandidates); + const renameFromByTo = renameAnalysis.renameFromByTo; + const ambiguousRemoteAliases = renameAnalysis.ambiguousRemoteAliases; + const renamedFrom = new Set(renameFromByTo.values()); + + for (const desired of desiredEntries) { const alias = desired.alias; + const renameFrom = renameFromByTo.get(alias); + if (renameFrom) { + out.push({ + kind: "update", + env: opEnv, + resource: "type-schema", + alias, + details: `rename ${renameFrom} -> ${alias}` + }); + if (!opts.planOnly) { + const renamePath = `${envBase(opts)}/type-schemas/${encodeURIComponent(renameFrom)}/commands/rename`; + const renameRes = await client.post(renamePath, { newTypeSchemaAlias: alias }); + if (renameRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Rename failed (${renameRes.status}).` + }); + } + } + continue; + } const getPath = `${envBase(opts)}/type-schemas/${encodeURIComponent(alias)}`; const existing = await client.get(getPath); if (existing.status === 404) { - out.push({ kind: "create", resource: "type-schema", alias }); + out.push({ kind: "create", env: opEnv, resource: "type-schema", alias }); if (!opts.planOnly) { const createPath = `${envBase(opts)}/type-schemas`; const body = { @@ -229,14 +546,26 @@ async function applyTypeSchemas(client: ManagementClient, opts: ApplyOptions): P }; const res = await client.post(createPath, body); if (res.status >= 400) { - out.push({ kind: "warn", resource: "type-schema", alias, details: `Create failed (${res.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Create failed (${res.status}).` + }); } } continue; } if (existing.status >= 400) { - out.push({ kind: "warn", resource: "type-schema", alias, details: `Failed to read (${existing.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Failed to read (${existing.status}).` + }); continue; } @@ -247,12 +576,13 @@ async function applyTypeSchemas(client: ManagementClient, opts: ApplyOptions): P const descChanged = (remoteDesc ?? null) !== (desired.description ?? null); if (!schemaChanged && !descChanged) { - out.push({ kind: "noop", resource: "type-schema", alias }); + out.push({ kind: "noop", env: opEnv, resource: "type-schema", alias }); continue; } out.push({ kind: "update", + env: opEnv, resource: "type-schema", alias, details: `${schemaChanged ? "schema " : ""}${descChanged ? "description" : ""}`.trim() @@ -265,12 +595,58 @@ async function applyTypeSchemas(client: ManagementClient, opts: ApplyOptions): P // per spec this endpoint takes the raw schema JSON const res = await client.put(updPath, desired.schema); if (res.status >= 400) { - out.push({ kind: "warn", resource: "type-schema", alias, details: `Update schema failed (${res.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Update schema failed (${res.status}).` + }); + } + } + + if (descChanged) { + const updDescPath = `${envBase(opts)}/type-schemas/${encodeURIComponent(alias)}/commands/update-description`; + const res = await client.put(updDescPath, { + newDescription: desired.description ?? null + }); + if (res.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Update description failed (${res.status}).` + }); } } + } + + for (const item of listItems) { + const alias = item?.typeSchemaAlias ?? item?.alias; + if (typeof alias !== "string" || desiredAliases.has(alias) || renamedFrom.has(alias)) continue; - // description update endpoint name can vary; if present in spec, add later. - // For now: ignore desc updates unless we confirm the exact command path. + const details = ambiguousRemoteAliases.has(alias) ? "no unique rename match" : undefined; + out.push({ + kind: "delete", + env: opEnv, + resource: "type-schema", + alias, + ...(details !== undefined ? { details } : {}) + }); + if (!opts.planOnly) { + const deletePath = `${envBase(opts)}/type-schemas/${encodeURIComponent(alias)}`; + const deleteRes = await client.delete(deletePath); + if (deleteRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "type-schema", + alias, + details: `Delete failed (${deleteRes.status}).` + }); + } + } } return out; @@ -284,14 +660,37 @@ type IngestionRegistry = { async function applyIngestionFunctions(client: ManagementClient, opts: ApplyOptions): Promise { const out: PlanOp[] = []; + const opEnv = opts.env; const registryPath = path.join(opts.envDir, "functions", "ingestion.json"); if (!(await exists(registryPath))) return out; const reg = await readJsonFile(registryPath); + const desiredEntries = await Promise.all( + reg.functions.map(async (fn) => { + const scriptPath = path.resolve(path.join(opts.envDir, fn.scriptFile)); + const script = await fs.readFile(scriptPath, "utf8"); + return { + alias: fn.alias, + description: fn.description ?? "", + script + }; + }) + ); + const desiredAliases = new Set(desiredEntries.map((fn) => fn.alias)); // List existing const listPath = `${envBase(opts)}/functions/ingestion`; const existingRes = await client.get(listPath); + if (existingRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias: "*", + details: `Failed to list ingestion functions (${existingRes.status}).` + }); + return out; + } const existingAliases = new Map(); const items = @@ -306,29 +705,181 @@ async function applyIngestionFunctions(client: ManagementClient, opts: ApplyOpti if (typeof alias === "string") existingAliases.set(alias, fn); } - for (const fn of reg.functions) { + const unmatchedDesired = desiredEntries.filter((fn) => !existingAliases.has(fn.alias)); + const unmatchedRemoteAliases = [...existingAliases.keys()].filter((alias) => !desiredAliases.has(alias)); + const desiredRenameCandidates = unmatchedDesired.map((fn) => ({ + alias: fn.alias, + signature: stringify({ description: fn.description, script: fn.script }) + })); + const remoteRenameCandidates: Array<{ alias: string; signature: string }> = []; + for (const alias of unmatchedRemoteAliases) { + const getPath = `${envBase(opts)}/functions/ingestion/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + remoteRenameCandidates.push({ + alias, + signature: stringify({ + description: String(existing.data?.description ?? existing.data?.ingestionFunction?.description ?? ""), + script: String(existing.data?.script ?? existing.data?.ingestionFunction?.script ?? "") + }) + }); + } + const renameAnalysis = computeRenameAnalysis(desiredRenameCandidates, remoteRenameCandidates); + const renameFromByTo = renameAnalysis.renameFromByTo; + const ambiguousRemoteAliases = renameAnalysis.ambiguousRemoteAliases; + const renamedFrom = new Set(renameFromByTo.values()); + + for (const fn of desiredEntries) { const alias = fn.alias; - const scriptPath = path.resolve(path.join(opts.envDir, fn.scriptFile)); - const script = await fs.readFile(scriptPath, "utf8"); + const script = fn.script; + const desiredDescription = fn.description; + const renameFrom = renameFromByTo.get(alias); + if (renameFrom) { + out.push({ + kind: "update", + env: opEnv, + resource: "ingestion-function", + alias, + details: `rename ${renameFrom} -> ${alias}` + }); + if (!opts.planOnly) { + const renamePath = `${envBase(opts)}/functions/ingestion/${encodeURIComponent(renameFrom)}/commands/rename`; + const renameRes = await client.post(renamePath, { newIngestionFunctionAlias: alias }); + if (renameRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Rename failed (${renameRes.status}).` + }); + } + } + continue; + } const found = existingAliases.get(alias); if (!found) { - out.push({ kind: "create", resource: "ingestion-function", alias }); + out.push({ kind: "create", env: opEnv, resource: "ingestion-function", alias }); if (!opts.planOnly) { const createPath = `${envBase(opts)}/functions/ingestion`; const body = { ingestionFunctionAlias: alias, - description: fn.description ?? null, + description: desiredDescription, script }; const res = await client.post(createPath, body); if (res.status >= 400) { - out.push({ kind: "warn", resource: "ingestion-function", alias, details: `Create failed (${res.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Create failed (${res.status}).` + }); } } } else { - // We don’t know remote script field shape without a GET-by-alias; MVP: always noop. - out.push({ kind: "noop", resource: "ingestion-function", alias }); + const getPath = `${envBase(opts)}/functions/ingestion/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + + const remoteScript = String(existing.data?.script ?? existing.data?.ingestionFunction?.script ?? ""); + const remoteDescription = String( + existing.data?.description ?? existing.data?.ingestionFunction?.description ?? "" + ); + const scriptChanged = remoteScript !== script; + const descriptionChanged = remoteDescription !== desiredDescription; + + if (!scriptChanged && !descriptionChanged) { + out.push({ kind: "noop", env: opEnv, resource: "ingestion-function", alias }); + continue; + } + + out.push({ + kind: "update", + env: opEnv, + resource: "ingestion-function", + alias, + details: `${scriptChanged ? "script " : ""}${descriptionChanged ? "description" : ""}`.trim() + }); + + if (opts.planOnly) continue; + + if (scriptChanged) { + const updateScriptPath = + `${envBase(opts)}/functions/ingestion/${encodeURIComponent(alias)}/commands/update-script`; + const updateScriptRes = await client.put(updateScriptPath, { script }); + if (updateScriptRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Update script failed (${updateScriptRes.status}).` + }); + } + } + + if (descriptionChanged) { + const updateDescPath = + `${envBase(opts)}/functions/ingestion/${encodeURIComponent(alias)}/commands/update-description`; + const updateDescRes = await client.put(updateDescPath, { + newDescription: desiredDescription + }); + if (updateDescRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Update description failed (${updateDescRes.status}).` + }); + } + } + } + } + + for (const alias of existingAliases.keys()) { + if (desiredAliases.has(alias) || renamedFrom.has(alias)) continue; + const details = ambiguousRemoteAliases.has(alias) ? "no unique rename match" : undefined; + out.push({ + kind: "delete", + env: opEnv, + resource: "ingestion-function", + alias, + ...(details !== undefined ? { details } : {}) + }); + if (!opts.planOnly) { + const deletePath = `${envBase(opts)}/functions/ingestion/${encodeURIComponent(alias)}`; + const deleteRes = await client.delete(deletePath); + if (deleteRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "ingestion-function", + alias, + details: `Delete failed (${deleteRes.status}).` + }); + } } } @@ -343,14 +894,37 @@ type PersistedRegistry = { async function applyPersistedDocs(client: ManagementClient, opts: ApplyOptions): Promise { const out: PlanOp[] = []; + const opEnv = opts.env; const registryPath = path.join(opts.envDir, "graphql", "persisted.json"); if (!(await exists(registryPath))) return out; const reg = await readJsonFile(registryPath); + const desiredEntries = await Promise.all( + reg.documents.map(async (d) => { + const queryPath = path.resolve(path.join(opts.envDir, d.queryFile)); + const query = await fs.readFile(queryPath, "utf8"); + return { + alias: d.alias, + description: d.description ?? "", + document: query + }; + }) + ); + const desiredAliases = new Set(desiredEntries.map((d) => d.alias)); - // Endpoints exist; exact DTO fields can vary. MVP: create if missing, noop otherwise. const listPath = `${envBase(opts)}/graphql/persisted-documents`; const existingRes = await client.get(listPath); + if (existingRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias: "*", + details: `Failed to list persisted docs (${existingRes.status}).` + }); + return out; + } + const existingAliases = new Set(); const items = @@ -365,27 +939,186 @@ async function applyPersistedDocs(client: ManagementClient, opts: ApplyOptions): if (typeof alias === "string") existingAliases.add(alias); } - for (const d of reg.documents) { + const unmatchedDesired = desiredEntries.filter((d) => !existingAliases.has(d.alias)); + const unmatchedRemoteAliases = [...existingAliases].filter((alias) => !desiredAliases.has(alias)); + const desiredRenameCandidates = unmatchedDesired.map((d) => ({ + alias: d.alias, + signature: stringify({ description: d.description, document: d.document }) + })); + const remoteRenameCandidates: Array<{ alias: string; signature: string }> = []; + for (const alias of unmatchedRemoteAliases) { + const getPath = `${envBase(opts)}/graphql/persisted-documents/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + remoteRenameCandidates.push({ + alias, + signature: stringify({ + description: String(existing.data?.description ?? existing.data?.persistedDocument?.description ?? ""), + document: String(existing.data?.document ?? existing.data?.query ?? existing.data?.persistedDocument?.document ?? "") + }) + }); + } + const renameAnalysis = computeRenameAnalysis(desiredRenameCandidates, remoteRenameCandidates); + const renameFromByTo = renameAnalysis.renameFromByTo; + const ambiguousRemoteAliases = renameAnalysis.ambiguousRemoteAliases; + const renamedFrom = new Set(renameFromByTo.values()); + + for (const d of desiredEntries) { const alias = d.alias; - const queryPath = path.resolve(path.join(opts.envDir, d.queryFile)); - const query = await fs.readFile(queryPath, "utf8"); + const query = d.document; + const renameFrom = renameFromByTo.get(alias); + if (renameFrom) { + out.push({ + kind: "update", + env: opEnv, + resource: "persisted-doc", + alias, + details: `rename ${renameFrom} -> ${alias}` + }); + if (!opts.planOnly) { + const renamePath = + `${envBase(opts)}/graphql/persisted-documents/${encodeURIComponent(renameFrom)}/commands/rename`; + const renameRes = await client.post(renamePath, { newPersistedDocumentAlias: alias }); + if (renameRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Rename failed (${renameRes.status}).` + }); + } + } + continue; + } if (!existingAliases.has(alias)) { - out.push({ kind: "create", resource: "persisted-doc", alias }); + out.push({ kind: "create", env: opEnv, resource: "persisted-doc", alias }); if (!opts.planOnly) { const createPath = `${envBase(opts)}/graphql/persisted-documents`; const body: any = { persistedDocumentAlias: alias, - description: d.description ?? null, - query + description: d.description, + document: query }; const res = await client.post(createPath, body); if (res.status >= 400) { - out.push({ kind: "warn", resource: "persisted-doc", alias, details: `Create failed (${res.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Create failed (${res.status}).` + }); } } } else { - out.push({ kind: "noop", resource: "persisted-doc", alias }); + const getPath = `${envBase(opts)}/graphql/persisted-documents/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + + const remoteDocument = String( + existing.data?.document ?? existing.data?.query ?? existing.data?.persistedDocument?.document ?? "" + ); + const remoteDescription = String( + existing.data?.description ?? existing.data?.persistedDocument?.description ?? "" + ); + const desiredDescription = d.description; + + const documentChanged = remoteDocument !== query; + const descriptionChanged = remoteDescription !== desiredDescription; + + if (!documentChanged && !descriptionChanged) { + out.push({ kind: "noop", env: opEnv, resource: "persisted-doc", alias }); + continue; + } + + out.push({ + kind: "update", + env: opEnv, + resource: "persisted-doc", + alias, + details: `${documentChanged ? "document " : ""}${descriptionChanged ? "description" : ""}`.trim() + }); + + if (opts.planOnly) continue; + + if (documentChanged) { + const updateDocumentPath = + `${envBase(opts)}/graphql/persisted-documents/${encodeURIComponent(alias)}/commands/update-document`; + const updateDocumentRes = await client.put(updateDocumentPath, { + newDocument: query + }); + if (updateDocumentRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Update document failed (${updateDocumentRes.status}).` + }); + } + } + + if (descriptionChanged) { + const updateDescriptionPath = + `${envBase(opts)}/graphql/persisted-documents/${encodeURIComponent(alias)}/commands/update-description`; + const updateDescriptionRes = await client.put(updateDescriptionPath, { + newDescription: desiredDescription + }); + if (updateDescriptionRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Update description failed (${updateDescriptionRes.status}).` + }); + } + } + } + } + + for (const alias of existingAliases) { + if (desiredAliases.has(alias) || renamedFrom.has(alias)) continue; + const details = ambiguousRemoteAliases.has(alias) ? "no unique rename match" : undefined; + out.push({ + kind: "delete", + env: opEnv, + resource: "persisted-doc", + alias, + ...(details !== undefined ? { details } : {}) + }); + if (!opts.planOnly) { + const deletePath = `${envBase(opts)}/graphql/persisted-documents/${encodeURIComponent(alias)}`; + const deleteRes = await client.delete(deletePath); + if (deleteRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "persisted-doc", + alias, + details: `Delete failed (${deleteRes.status}).` + }); + } } } @@ -397,6 +1130,7 @@ async function applyPersistedDocs(client: ManagementClient, opts: ApplyOptions): type WebhooksFile = { webhooks: Array<{ alias: string; + description?: string | null; url: string; eventTypes: string[]; collectionAliases?: string[]; @@ -407,13 +1141,25 @@ type WebhooksFile = { async function applyWebhooks(client: ManagementClient, opts: ApplyOptions): Promise { const out: PlanOp[] = []; + const opEnv = opts.env; const filePath = path.join(opts.envDir, "webhooks.json"); if (!(await exists(filePath))) return out; const desired = await readJsonFile(filePath); + const desiredAliases = new Set(desired.webhooks.map((w) => w.alias)); const listPath = `${envBase(opts)}/webhooks`; const existingRes = await client.get(listPath); + if (existingRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias: "*", + details: `Failed to list webhooks (${existingRes.status}).` + }); + return out; + } const existingAliases = new Set(); const items = @@ -428,14 +1174,87 @@ async function applyWebhooks(client: ManagementClient, opts: ApplyOptions): Prom if (typeof alias === "string") existingAliases.add(alias); } + const unmatchedDesired = desired.webhooks.filter((w) => !existingAliases.has(w.alias)); + const unmatchedRemoteAliases = [...existingAliases].filter((alias) => !desiredAliases.has(alias)); + const desiredRenameCandidates = unmatchedDesired.map((w) => ({ + alias: w.alias, + signature: stringify({ + description: w.description ?? null, + url: w.url, + eventTypes: normalizeStringArray(w.eventTypes ?? []), + collectionAliases: normalizeStringArray(w.collectionAliases ?? []), + typeSchemaAliases: normalizeStringArray(w.typeSchemaAliases ?? []), + customHeaders: normalizeStringMap(w.customHeaders ?? {}) + }) + })); + const remoteRenameCandidates: Array<{ alias: string; signature: string }> = []; + for (const alias of unmatchedRemoteAliases) { + const getPath = `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + remoteRenameCandidates.push({ + alias, + signature: stringify({ + description: existing.data?.description ?? existing.data?.webhook?.description ?? null, + url: String(existing.data?.url ?? existing.data?.webhook?.url ?? ""), + eventTypes: normalizeStringArray(existing.data?.eventTypes ?? existing.data?.webhook?.eventTypes ?? []), + collectionAliases: normalizeStringArray( + existing.data?.collectionAliases ?? existing.data?.webhook?.collectionAliases ?? [] + ), + typeSchemaAliases: normalizeStringArray( + existing.data?.typeSchemaAliases ?? existing.data?.webhook?.typeSchemaAliases ?? [] + ), + customHeaders: normalizeStringMap(existing.data?.customHeaders ?? existing.data?.webhook?.customHeaders ?? {}) + }) + }); + } + const renameAnalysis = computeRenameAnalysis(desiredRenameCandidates, remoteRenameCandidates); + const renameFromByTo = renameAnalysis.renameFromByTo; + const ambiguousRemoteAliases = renameAnalysis.ambiguousRemoteAliases; + const renamedFrom = new Set(renameFromByTo.values()); + for (const w of desired.webhooks) { const alias = w.alias; + const renameFrom = renameFromByTo.get(alias); + if (renameFrom) { + out.push({ + kind: "update", + env: opEnv, + resource: "webhook", + alias, + details: `rename ${renameFrom} -> ${alias}` + }); + if (!opts.planOnly) { + const renamePath = `${envBase(opts)}/webhooks/${encodeURIComponent(renameFrom)}/commands/rename`; + const renameRes = await client.post(renamePath, { newWebhookAlias: alias }); + if (renameRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Rename failed (${renameRes.status}).` + }); + } + } + continue; + } if (!existingAliases.has(alias)) { - out.push({ kind: "create", resource: "webhook", alias }); + out.push({ kind: "create", env: opEnv, resource: "webhook", alias }); if (!opts.planOnly) { const createPath = `${envBase(opts)}/webhooks`; const body: any = { webhookAlias: alias, + description: w.description ?? null, url: w.url, eventTypes: w.eventTypes, collectionAliases: w.collectionAliases ?? [], @@ -444,13 +1263,256 @@ async function applyWebhooks(client: ManagementClient, opts: ApplyOptions): Prom }; const res = await client.post(createPath, body); if (res.status >= 400) { - out.push({ kind: "warn", resource: "webhook", alias, details: `Create failed (${res.status}).` }); + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Create failed (${res.status}).` + }); } } } else { - out.push({ kind: "noop", resource: "webhook", alias }); + const getPath = `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}`; + const existing = await client.get(getPath); + if (existing.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Failed to read (${existing.status}).` + }); + continue; + } + + const remoteUrl = String(existing.data?.url ?? existing.data?.webhook?.url ?? ""); + const remoteEventTypes = normalizeStringArray( + existing.data?.eventTypes ?? existing.data?.webhook?.eventTypes ?? [] + ); + const remoteCollections = normalizeStringArray( + existing.data?.collectionAliases ?? existing.data?.webhook?.collectionAliases ?? [] + ); + const remoteTypeSchemas = normalizeStringArray( + existing.data?.typeSchemaAliases ?? existing.data?.webhook?.typeSchemaAliases ?? [] + ); + const remoteHeaders = normalizeStringMap( + existing.data?.customHeaders ?? existing.data?.webhook?.customHeaders ?? {} + ); + const remoteDescription = existing.data?.description ?? existing.data?.webhook?.description ?? null; + + const desiredDescription = w.description ?? null; + const desiredUrl = w.url; + const desiredEventTypes = normalizeStringArray(w.eventTypes ?? []); + const desiredCollections = normalizeStringArray(w.collectionAliases ?? []); + const desiredTypeSchemas = normalizeStringArray(w.typeSchemaAliases ?? []); + const desiredHeaders = normalizeStringMap(w.customHeaders ?? {}); + + const descriptionChanged = (remoteDescription ?? null) !== desiredDescription; + const urlChanged = remoteUrl !== desiredUrl; + const eventTypesChanged = stringify(remoteEventTypes) !== stringify(desiredEventTypes); + const collectionsChanged = stringify(remoteCollections) !== stringify(desiredCollections); + const typeSchemasChanged = stringify(remoteTypeSchemas) !== stringify(desiredTypeSchemas); + const headersChanged = stringify(remoteHeaders) !== stringify(desiredHeaders); + + if (!descriptionChanged && !urlChanged && !eventTypesChanged && !collectionsChanged && !typeSchemasChanged && !headersChanged) { + out.push({ kind: "noop", env: opEnv, resource: "webhook", alias }); + continue; + } + + out.push({ + kind: "update", + env: opEnv, + resource: "webhook", + alias, + details: [ + descriptionChanged ? "description" : "", + urlChanged ? "url" : "", + eventTypesChanged ? "eventTypes" : "", + collectionsChanged ? "collections" : "", + typeSchemasChanged ? "typeSchemas" : "", + headersChanged ? "headers" : "" + ] + .filter(Boolean) + .join(" ") + }); + + if (opts.planOnly) continue; + + if (descriptionChanged) { + const updateDescriptionPath = + `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}/commands/update-description`; + const updateDescriptionRes = await client.put(updateDescriptionPath, { + newDescription: desiredDescription + }); + if (updateDescriptionRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Update description failed (${updateDescriptionRes.status}).` + }); + } + } + + if (urlChanged) { + const updateUrlPath = `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}/commands/update-url`; + const updateUrlRes = await client.put(updateUrlPath, { url: desiredUrl }); + if (updateUrlRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Update url failed (${updateUrlRes.status}).` + }); + } + } + + if (eventTypesChanged) { + const updateEventTypesPath = + `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}/commands/update-event-types`; + const updateEventTypesRes = await client.put(updateEventTypesPath, { + eventTypes: desiredEventTypes + }); + if (updateEventTypesRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Update event types failed (${updateEventTypesRes.status}).` + }); + } + } + + if (collectionsChanged) { + const updateCollectionsPath = + `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}/commands/update-collections`; + const updateCollectionsRes = await client.put(updateCollectionsPath, { + collectionAliases: desiredCollections + }); + if (updateCollectionsRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Update collections failed (${updateCollectionsRes.status}).` + }); + } + } + + if (typeSchemasChanged) { + const updateTypeSchemasPath = + `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}/commands/update-type-schemas`; + const updateTypeSchemasRes = await client.put(updateTypeSchemasPath, { + typeSchemaAliases: desiredTypeSchemas + }); + if (updateTypeSchemasRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Update type schemas failed (${updateTypeSchemasRes.status}).` + }); + } + } + + if (headersChanged) { + const updateHeadersPath = `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}/commands/update-headers`; + const updateHeadersRes = await client.put(updateHeadersPath, { + newHeaders: desiredHeaders + }); + if (updateHeadersRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Update headers failed (${updateHeadersRes.status}).` + }); + } + } + } + } + + for (const alias of existingAliases) { + if (desiredAliases.has(alias) || renamedFrom.has(alias)) continue; + const details = ambiguousRemoteAliases.has(alias) ? "no unique rename match" : undefined; + out.push({ + kind: "delete", + env: opEnv, + resource: "webhook", + alias, + ...(details !== undefined ? { details } : {}) + }); + if (!opts.planOnly) { + const deletePath = `${envBase(opts)}/webhooks/${encodeURIComponent(alias)}`; + const deleteRes = await client.delete(deletePath); + if (deleteRes.status >= 400) { + out.push({ + kind: "warn", + env: opEnv, + resource: "webhook", + alias, + details: `Delete failed (${deleteRes.status}).` + }); + } } } return out; } + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((v) => String(v)).sort((a, b) => a.localeCompare(b)); +} + +function normalizeStringMap(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = String(v); + } + return out; +} + +function computeRenameAnalysis( + desired: Array<{ alias: string; signature: string }>, + remote: Array<{ alias: string; signature: string }> +): { renameFromByTo: Map; ambiguousRemoteAliases: Set } { + const desiredBySignature = new Map(); + const remoteBySignature = new Map(); + + for (const d of desired) { + const arr = desiredBySignature.get(d.signature) ?? []; + arr.push(d.alias); + desiredBySignature.set(d.signature, arr); + } + + for (const r of remote) { + const arr = remoteBySignature.get(r.signature) ?? []; + arr.push(r.alias); + remoteBySignature.set(r.signature, arr); + } + + const renameFromByTo = new Map(); + const ambiguousRemoteAliases = new Set(); + for (const [signature, desiredAliases] of desiredBySignature.entries()) { + const remoteAliases = remoteBySignature.get(signature) ?? []; + const desiredAlias = desiredAliases[0]; + const remoteAlias = remoteAliases[0]; + if (desiredAliases.length === 1 && remoteAliases.length === 1 && desiredAlias && remoteAlias) { + renameFromByTo.set(desiredAlias, remoteAlias); + continue; + } + if (desiredAliases.length > 0 && remoteAliases.length > 0) { + for (const alias of remoteAliases) ambiguousRemoteAliases.add(alias); + } + } + return { renameFromByTo, ambiguousRemoteAliases }; +} diff --git a/src/compose/state.ts b/src/compose/state.ts new file mode 100644 index 0000000..8f9a202 --- /dev/null +++ b/src/compose/state.ts @@ -0,0 +1,159 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const COMPOSE_STATE_FILE = "compose.state.json"; + +export type ComposeEnvironmentState = { + id: string; + alias: string; + remoteAlias?: string; +}; + +export type ComposeStateFile = { + version: 1; + project: string; + environments: ComposeEnvironmentState[]; +}; + +export async function readComposeState(rootDir: string): Promise { + const filePath = path.join(rootDir, COMPOSE_STATE_FILE); + try { + const text = await fs.readFile(filePath, "utf8"); + const doc = JSON.parse(text) as ComposeStateFile; + return normalizeState(doc); + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + } + throw err; + } +} + +export async function writeComposeState(rootDir: string, state: ComposeStateFile): Promise { + const filePath = path.join(rootDir, COMPOSE_STATE_FILE); + const text = JSON.stringify(state, null, 2) + "\n"; + await fs.writeFile(filePath, text, "utf8"); +} + +export function ensureStateForAliases( + state: ComposeStateFile | null, + project: string, + aliases: string[] +): { state: ComposeStateFile; changed: boolean } { + if (!state) { + return { + state: createInitialState(project, aliases), + changed: true + }; + } + + if (state.project !== project) { + throw new Error( + `State project mismatch in ${COMPOSE_STATE_FILE}: expected "${project}", found "${state.project}".` + ); + } + + const next = cloneState(state); + let changed = false; + + for (const alias of aliases) { + const existing = next.environments.find((e) => e.alias === alias); + if (!existing) { + next.environments.push({ + id: newEnvId(), + alias + }); + changed = true; + } + } + + return { state: next, changed }; +} + +export function findEnvironmentByAlias( + state: ComposeStateFile, + alias: string +): ComposeEnvironmentState | undefined { + return state.environments.find((e) => e.alias === alias); +} + +export function renameEnvironmentAlias( + state: ComposeStateFile, + fromAlias: string, + toAlias: string +): boolean { + const target = state.environments.find((e) => e.alias === fromAlias); + if (!target) return false; + target.alias = toAlias; + return true; +} + +export function markEnvironmentRemoteAlias( + state: ComposeStateFile, + envId: string, + alias: string +): boolean { + const target = state.environments.find((e) => e.id === envId); + if (!target) return false; + target.remoteAlias = alias; + return true; +} + +export function createInitialState(project: string, aliases: string[]): ComposeStateFile { + return { + version: 1, + project, + environments: aliases.map((alias) => ({ + id: newEnvId(), + alias + })) + }; +} + +function cloneState(state: ComposeStateFile): ComposeStateFile { + return { + version: 1, + project: state.project, + environments: state.environments.map((e) => ({ ...e })) + }; +} + +function normalizeState(state: ComposeStateFile): ComposeStateFile { + if (state.version !== 1 || typeof state.project !== "string" || !Array.isArray(state.environments)) { + throw new Error(`Invalid ${COMPOSE_STATE_FILE}`); + } + + const seenIds = new Set(); + const seenAliases = new Set(); + const normalizedEnvs: ComposeEnvironmentState[] = []; + + for (const e of state.environments) { + const id = typeof e.id === "string" && e.id.length > 0 ? e.id : newEnvId(); + const alias = typeof e.alias === "string" ? e.alias.trim() : ""; + if (!alias.length) continue; + if (seenAliases.has(alias)) continue; + if (seenIds.has(id)) continue; + + seenIds.add(id); + seenAliases.add(alias); + + const env: ComposeEnvironmentState = { id, alias }; + if (typeof e.remoteAlias === "string" && e.remoteAlias.length > 0) { + env.remoteAlias = e.remoteAlias; + } + normalizedEnvs.push(env); + } + + return { + version: 1, + project: state.project, + environments: normalizedEnvs + }; +} + +function newEnvId() { + return `env_${crypto.randomUUID()}`; +} diff --git a/src/index.ts b/src/index.ts index 738e6be..9be501e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,23 @@ import { initCommand } from "./commands/init.js"; import { validateCommand } from "./commands/validate.js"; import { applyCommand } from "./commands/apply.js"; import { statusCommand } from "./commands/status.js"; +import { planCommand } from "./commands/plan.js"; +import { envRenameCommand } from "./commands/env-rename.js"; +import { generateCommand } from "./commands/generate.js"; +import { pullCommand } from "./commands/pull.js"; +import { cloneCommand } from "./commands/clone.js"; await yargs(hideBin(process.argv)) .scriptName("compose") .command(initCommand) .command(validateCommand) + .command(generateCommand) + .command(cloneCommand) + .command(pullCommand) + .command(planCommand) .command(applyCommand) .command(statusCommand) + .command(envRenameCommand) .demandCommand(1, "Try `compose init --help` or `compose validate --help`.") .strict() .help() diff --git a/test/commands.apply-multi-env.test.ts b/test/commands.apply-multi-env.test.ts new file mode 100644 index 0000000..b10a4e1 --- /dev/null +++ b/test/commands.apply-multi-env.test.ts @@ -0,0 +1,197 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; +import { readComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +async function createFixture(): Promise<{ root: string; devDir: string; prodDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-multi-env-")); + const devDir = path.join(root, "env", "dev"); + const prodDir = path.join(root, "env", "prod"); + await fs.mkdir(devDir, { recursive: true }); + await fs.mkdir(prodDir, { recursive: true }); + + await fs.writeFile( + path.join(devDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(devDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + await fs.writeFile( + path.join(prodDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(prodDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + }, + prod: { + dir: "./env/prod", + description: "Prod", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + + return { root, devDir, prodDir }; +} + +function installMockFetch(): () => void { + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { + edges: [{ node: { environmentAlias: "dev" } }, { node: { environmentAlias: "prod" } }] + }); + } + + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/prod/collections") { + return jsonResponse(500, { error: "prod unavailable" }); + } + + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/prod/webhooks") { + return jsonResponse(200, { edges: [] }); + } + + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + return () => { + globalThis.fetch = oldFetch; + }; +} + +test("plan without --env evaluates all configured environments and warns if any environment warns", async () => { + const { root } = await createFixture(); + const restoreFetch = installMockFetch(); + const oldExitCode = process.exitCode; + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((a) => String(a)).join(" ")); + }; + + let observedExitCode: number | undefined; + process.exitCode = 0; + try { + await runApply({ + dir: root, + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + observedExitCode = process.exitCode; + } finally { + restoreFetch(); + console.log = originalLog; + process.exitCode = oldExitCode; + } + + assert.equal(output.some((line) => line.includes("[env=dev] collection:content")), true); + assert.equal(output.some((line) => line.includes("[env=prod] collection:*")), true); + assert.equal(output.some((line) => line.includes("Plan env dev: create=1, delete=0, update=0, noop=1, warn=0")), true); + assert.equal(output.some((line) => line.includes("Plan env prod: create=0, delete=0, update=0, noop=1, warn=1")), true); + assert.equal( + output.some((line) => line.includes("Environments processed: total=2, succeeded=1, warned=1")), + true + ); + assert.equal(output.some((line) => line.includes("Plan complete.")), true); + assert.equal(observedExitCode, 1); +}); + +test("apply without --env writes lock/state for successful environments and skips failed environments", async () => { + const { root, devDir, prodDir } = await createFixture(); + const restoreFetch = installMockFetch(); + const oldExitCode = process.exitCode; + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((a) => String(a)).join(" ")); + }; + + let observedExitCode: number | undefined; + process.exitCode = 0; + try { + await runApply({ + dir: root, + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + observedExitCode = process.exitCode; + } finally { + restoreFetch(); + console.log = originalLog; + process.exitCode = oldExitCode; + } + + assert.equal(await exists(path.join(devDir, "compose.lock.json")), true); + assert.equal(await exists(path.join(prodDir, "compose.lock.json")), false); + + const state = await readComposeState(root); + assert.ok(state); + const dev = state.environments.find((e) => e.alias === "dev"); + const prod = state.environments.find((e) => e.alias === "prod"); + assert.ok(dev); + assert.ok(prod); + assert.equal(dev.remoteAlias, "dev"); + assert.equal(prod.remoteAlias, undefined); + assert.equal(output.some((line) => line.includes("Apply env dev: create=1, delete=0, update=0, noop=1, warn=0")), true); + assert.equal(output.some((line) => line.includes("Apply env prod: create=0, delete=0, update=0, noop=1, warn=1")), true); + assert.equal( + output.some((line) => line.includes("Environments processed: total=2, succeeded=1, warned=1")), + true + ); + assert.equal(observedExitCode, 1); +}); diff --git a/test/commands.apply.auth-failures.test.ts b/test/commands.apply.auth-failures.test.ts new file mode 100644 index 0000000..2316df2 --- /dev/null +++ b/test/commands.apply.auth-failures.test.ts @@ -0,0 +1,108 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createComposeRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-auth-fail-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(envDir, { recursive: true }); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + return root; +} + +test("runApply fails clearly when token endpoint returns non-200", async () => { + const root = await createComposeRoot(); + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + if (u.pathname === "/v1/auth/token") { + return new Response("bad client", { status: 401, headers: { "content-type": "text/plain" } }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await assert.rejects( + () => + runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }), + /OAuth token request failed \(401\): bad client/ + ); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("runApply fails clearly when token endpoint response lacks access_token", async () => { + const root = await createComposeRoot(); + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { token_type: "Bearer", expires_in: 3600 }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await assert.rejects( + () => + runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }), + /OAuth token response missing access_token/ + ); + } finally { + globalThis.fetch = oldFetch; + } +}); diff --git a/test/commands.apply.create-env-failure.test.ts b/test/commands.apply.create-env-failure.test.ts new file mode 100644 index 0000000..03be4c3 --- /dev/null +++ b/test/commands.apply.create-env-failure.test.ts @@ -0,0 +1,102 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; +import { readComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createComposeRootForMissingEnv(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-create-env-fail-")); + const envDir = path.join(root, "env", "new-env"); + await fs.mkdir(envDir, { recursive: true }); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + "new-env": { + dir: "./env/new-env", + description: "New Env", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + return { root, envDir }; +} + +test("apply create-environment failure sets exitCode=1 and skips lock/state writes", async () => { + const { root, envDir } = await createComposeRootForMissingEnv(); + const oldFetch = globalThis.fetch; + const oldExitCode = process.exitCode; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(500, { error: "server-error" }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "new-env", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assert.equal(process.exitCode, 1); + process.exitCode = oldExitCode; + + const lockPath = path.join(envDir, "compose.lock.json"); + const lockExists = await exists(lockPath); + assert.equal(lockExists, false); + + const state = await readComposeState(root); + assert.equal(state, null); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/commands.apply.output-snapshots.test.ts b/test/commands.apply.output-snapshots.test.ts new file mode 100644 index 0000000..b243dbc --- /dev/null +++ b/test/commands.apply.output-snapshots.test.ts @@ -0,0 +1,350 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createComposeRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-output-snapshot-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(envDir, { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + return root; +} + +async function createComposeRootWithCollections( + collections: Array<{ alias: string; description?: string | null }> +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-output-snapshot-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(envDir, { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + return root; +} + +function installMockFetch(): () => void { + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + return () => { + globalThis.fetch = oldFetch; + }; +} + +function installMockFetchWithHandler( + handler: (u: URL, method: string) => Response +): () => void { + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + return handler(u, method); + }) as typeof fetch; + + return () => { + globalThis.fetch = oldFetch; + }; +} + +function captureLogs(): { output: string[]; restore: () => void } { + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((x) => String(x)).join(" ")); + }; + return { + output, + restore: () => { + console.log = originalLog; + } + }; +} + +function operationLines(output: string[]): string[] { + return output.filter((line) => /^(CREATE|DELETE|UPDATE|NOOP|WARN)\s+\[env=/.test(line)); +} + +function finalSummaryLine(output: string[]): string { + const line = output.find((l) => /(Plan|Apply) complete\./.test(l)); + assert.ok(line); + return line; +} + +function environmentSummaryLine(output: string[]): string { + const line = output.find((l) => /^Environments processed: /.test(l)); + assert.ok(line); + return line; +} + +function perEnvironmentRunSummaryLine(output: string[]): string { + const line = output.find((l) => /^(Plan|Apply) env /.test(l)); + assert.ok(line); + return line; +} + +test("plan output operation lines and summary match expected snapshot", async () => { + const root = await createComposeRoot(); + const restoreFetch = installMockFetch(); + const { output, restore } = captureLogs(); + const oldExitCode = process.exitCode; + process.exitCode = 0; + + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + } finally { + restoreFetch(); + restore(); + process.exitCode = oldExitCode; + } + + assert.deepEqual(operationLines(output), [ + "NOOP [env=dev] environment:dev", + "CREATE [env=dev] collection:content" + ]); + assert.equal( + perEnvironmentRunSummaryLine(output), + "Plan env dev: create=1, delete=0, update=0, noop=1, warn=0" + ); + assert.equal( + environmentSummaryLine(output), + "Environments processed: total=1, succeeded=1, warned=0" + ); + assert.equal( + finalSummaryLine(output), + "Plan complete. create=1, delete=0, update=0, noop=1, warn=0" + ); +}); + +test("apply output operation lines and summary match expected snapshot", async () => { + const root = await createComposeRoot(); + const restoreFetch = installMockFetch(); + const { output, restore } = captureLogs(); + const oldExitCode = process.exitCode; + process.exitCode = 0; + + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + } finally { + restoreFetch(); + restore(); + process.exitCode = oldExitCode; + } + + assert.deepEqual(operationLines(output), [ + "NOOP [env=dev] environment:dev", + "CREATE [env=dev] collection:content" + ]); + assert.equal( + perEnvironmentRunSummaryLine(output), + "Apply env dev: create=1, delete=0, update=0, noop=1, warn=0" + ); + assert.equal( + environmentSummaryLine(output), + "Environments processed: total=1, succeeded=1, warned=0" + ); + assert.equal( + finalSummaryLine(output), + "Apply complete. create=1, delete=0, update=0, noop=1, warn=0" + ); +}); + +test("plan output shows inferred collection rename as update with explicit rename details", async () => { + const root = await createComposeRootWithCollections([{ alias: "content-new", description: "Main content" }]); + const restoreFetch = installMockFetchWithHandler((u, method) => { + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content-old" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections/content-old" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old", description: "Main content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }); + + const { output, restore } = captureLogs(); + const oldExitCode = process.exitCode; + process.exitCode = 0; + + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + } finally { + restoreFetch(); + restore(); + process.exitCode = oldExitCode; + } + + assert.deepEqual(operationLines(output), [ + "NOOP [env=dev] environment:dev", + "UPDATE [env=dev] collection:content-new — rename content-old -> content-new" + ]); + assert.equal( + perEnvironmentRunSummaryLine(output), + "Plan env dev: create=0, delete=0, update=1, noop=1, warn=0" + ); + assert.equal( + finalSummaryLine(output), + "Plan complete. create=0, delete=0, update=1, noop=1, warn=0" + ); +}); + +test("plan output for ambiguous collection rename fallback shows create/delete and no rename update line", async () => { + const root = await createComposeRootWithCollections([{ alias: "content-new", description: "Main content" }]); + const restoreFetch = installMockFetchWithHandler((u, method) => { + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { + edges: [{ node: { collectionAlias: "content-old-a" } }, { node: { collectionAlias: "content-old-b" } }] + }); + } + if ( + (u.pathname === "/v1/projects/test-project/environments/dev/collections/content-old-a" || + u.pathname === "/v1/projects/test-project/environments/dev/collections/content-old-b") && + method === "GET" + ) { + return jsonResponse(200, { description: "Main content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }); + + const { output, restore } = captureLogs(); + const oldExitCode = process.exitCode; + process.exitCode = 0; + + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + } finally { + restoreFetch(); + restore(); + process.exitCode = oldExitCode; + } + + const opLines = operationLines(output); + assert.equal( + opLines.some((line) => line.includes("UPDATE [env=dev] collection:content-new — rename")), + false + ); + assert.deepEqual(opLines, [ + "NOOP [env=dev] environment:dev", + "CREATE [env=dev] collection:content-new", + "DELETE [env=dev] collection:content-old-a — no unique rename match", + "DELETE [env=dev] collection:content-old-b — no unique rename match" + ]); + assert.equal( + perEnvironmentRunSummaryLine(output), + "Plan env dev: create=1, delete=2, update=0, noop=1, warn=0" + ); + assert.equal( + finalSummaryLine(output), + "Plan complete. create=1, delete=2, update=0, noop=1, warn=0" + ); +}); diff --git a/test/commands.apply.partial-failure-writes.test.ts b/test/commands.apply.partial-failure-writes.test.ts new file mode 100644 index 0000000..b28d9dc --- /dev/null +++ b/test/commands.apply.partial-failure-writes.test.ts @@ -0,0 +1,107 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; +import { readComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createComposeRoot(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-partial-warn-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(envDir, { recursive: true }); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + return { root, envDir }; +} + +test("apply skips lock/state writes when nested resource create emits warning", async () => { + const { root, envDir } = await createComposeRoot(); + const oldFetch = globalThis.fetch; + const oldExitCode = process.exitCode; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(500, { error: "collection-create-fail" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assert.equal(process.exitCode, 1); + process.exitCode = oldExitCode; + + const lockExists = await exists(path.join(envDir, "compose.lock.json")); + assert.equal(lockExists, false); + + const state = await readComposeState(root); + assert.equal(state, null); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/commands.apply.test.ts b/test/commands.apply.test.ts new file mode 100644 index 0000000..9a53f8f --- /dev/null +++ b/test/commands.apply.test.ts @@ -0,0 +1,183 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; +import { readComposeState } from "../src/compose/state.js"; + +type FetchCall = { + method: string; + url: string; + bodyText?: string; +}; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +async function createComposeRoot(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-cmd-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(envDir, { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Development", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify({ webhooks: [] }, null, 2), + "utf8" + ); + + return { root, envDir }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +function installMockFetch(calls: FetchCall[]): () => void { + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") { + bodyText = init.body; + } else if (init?.body instanceof URLSearchParams) { + bodyText = init.body.toString(); + } + calls.push({ method, url, bodyText }); + + const u = new URL(url); + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + return () => { + globalThis.fetch = oldFetch; + }; +} + +test("runApply prints operation lines with explicit environment context", async () => { + const { root } = await createComposeRoot(); + const calls: FetchCall[] = []; + const restoreFetch = installMockFetch(calls); + + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((x) => String(x)).join(" ")); + }; + + const originalExitCode = process.exitCode; + process.exitCode = 0; + + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + } finally { + restoreFetch(); + console.log = originalLog; + process.exitCode = originalExitCode; + } + + assert.equal( + output.some((line) => line.includes("[env=dev] environment:dev")), + true + ); + assert.equal( + output.some((line) => line.includes("[env=dev] collection:content")), + true + ); + assert.equal(calls.length > 0, true); +}); + +test("runApply writes compose.lock.json and updates compose.state.json after successful apply", async () => { + const { root, envDir } = await createComposeRoot(); + const calls: FetchCall[] = []; + const restoreFetch = installMockFetch(calls); + + const originalExitCode = process.exitCode; + process.exitCode = 0; + + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + } finally { + restoreFetch(); + process.exitCode = originalExitCode; + } + + const lockPath = path.join(envDir, "compose.lock.json"); + const lockText = await fs.readFile(lockPath, "utf8"); + const lock = JSON.parse(lockText) as { + version: number; + project: string; + environment: string; + lastApplied?: { at?: string }; + resources: Record; + }; + + assert.equal(lock.version, 1); + assert.equal(lock.project, "test-project"); + assert.equal(lock.environment, "dev"); + assert.equal(typeof lock.lastApplied?.at, "string"); + assert.equal(typeof lock.resources.collections?.hash, "string"); + assert.equal(typeof lock.resources.webhooks?.hash, "string"); + + const state = await readComposeState(root); + assert.ok(state); + const envState = state.environments.find((e) => e.alias === "dev"); + assert.ok(envState); + assert.equal(envState.remoteAlias, "dev"); +}); diff --git a/test/commands.clone.test.ts b/test/commands.clone.test.ts new file mode 100644 index 0000000..7e88b94 --- /dev/null +++ b/test/commands.clone.test.ts @@ -0,0 +1,98 @@ +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 YAML from "yaml"; +import { cloneCommand } from "../src/commands/clone.js"; +import { readComposeState } from "../src/compose/state.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +test("clone scaffolds project and pulls remote env state", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-clone-")); + const targetDir = path.join(root, "cloned-compose"); + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/my-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content", description: "Main content" } }] }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/type-schemas") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await cloneCommand.handler({ + dir: targetDir, + project: "my-project", + env: "dev", + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + force: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + const composeYamlPath = path.join(targetDir, "umbraco-compose.yaml"); + const cfg = YAML.parse(await fs.readFile(composeYamlPath, "utf8")) as { + project: string; + environments: Record; + }; + assert.equal(cfg.project, "my-project"); + assert.equal(cfg.environments.dev?.dir, "./env/dev"); + + const collectionsPath = path.join(targetDir, "env", "dev", "collections.json"); + const collections = JSON.parse(await fs.readFile(collectionsPath, "utf8")) as { + collections: Array<{ alias: string }>; + }; + assert.equal(collections.collections[0]?.alias, "content"); + + const state = await readComposeState(targetDir); + assert.ok(state); + assert.equal(state.environments.find((e) => e.alias === "dev")?.remoteAlias, "dev"); +}); + +test("clone fails when target directory is non-empty without --force", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-clone-nonempty-")); + await fs.writeFile(path.join(root, "keep.txt"), "keep\n", "utf8"); + + await assert.rejects( + () => + cloneCommand.handler({ + dir: root, + project: "my-project", + env: "dev", + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + force: false + }), + /target directory is not empty/ + ); +}); diff --git a/test/commands.env-rename.test.ts b/test/commands.env-rename.test.ts new file mode 100644 index 0000000..12dd4f2 --- /dev/null +++ b/test/commands.env-rename.test.ts @@ -0,0 +1,192 @@ +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 YAML from "yaml"; +import { envRenameCommand } from "../src/commands/env-rename.js"; +import { + createInitialState, + markEnvironmentRemoteAlias, + readComposeState, + writeComposeState +} from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +async function writeComposeConfig(rootDir: string, cfg: ComposeConfig): Promise { + await fs.writeFile(path.join(rootDir, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); +} + +async function readComposeConfig(rootDir: string): Promise { + const text = await fs.readFile(path.join(rootDir, "umbraco-compose.yaml"), "utf8"); + return YAML.parse(text) as ComposeConfig; +} + +function baseConfig(): ComposeConfig { + return { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + }, + prod: { + dir: "./env/prod", + description: "Prod", + managementBaseUrl: "https://management.example" + } + } + }; +} + +test("env rename updates umbraco-compose.yaml and compose.state.json while preserving env identity", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-rename-")); + await writeComposeConfig(root, baseConfig()); + + const state = createInitialState("test-project", ["dev", "prod"]); + const dev = state.environments.find((e) => e.alias === "dev"); + assert.ok(dev); + markEnvironmentRemoteAlias(state, dev.id, "dev"); + await writeComposeState(root, state); + + await envRenameCommand.handler({ + dir: root, + from: "dev", + to: "development", + moveDir: false + }); + + const cfgAfter = await readComposeConfig(root); + assert.ok(cfgAfter.environments.development); + assert.equal(cfgAfter.environments.dev, undefined); + assert.equal(cfgAfter.environments.development?.dir, "./env/dev"); + + const stateAfter = await readComposeState(root); + assert.ok(stateAfter); + const renamed = stateAfter.environments.find((e) => e.alias === "development"); + assert.ok(renamed); + assert.equal(renamed.id, dev.id); + assert.equal(renamed.remoteAlias, "dev"); +}); + +test("env rename with --moveDir renames default env directory path and folder", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-rename-move-")); + await writeComposeConfig(root, baseConfig()); + await writeComposeState(root, createInitialState("test-project", ["dev", "prod"])); + + await fs.mkdir(path.join(root, "env", "dev"), { recursive: true }); + await fs.writeFile(path.join(root, "env", "dev", "collections.json"), "{\"collections\":[]}\n", "utf8"); + + await envRenameCommand.handler({ + dir: root, + from: "dev", + to: "development", + moveDir: true + }); + + const cfgAfter = await readComposeConfig(root); + assert.equal(cfgAfter.environments.development?.dir, "./env/development"); + + const oldExists = await exists(path.join(root, "env", "dev")); + const newExists = await exists(path.join(root, "env", "development")); + assert.equal(oldExists, false); + assert.equal(newExists, true); +}); + +test("env rename fails when target alias already exists and leaves files unchanged", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-rename-fail-")); + const cfg = baseConfig(); + await writeComposeConfig(root, cfg); + const state = createInitialState("test-project", ["dev", "prod"]); + await writeComposeState(root, state); + + const beforeYaml = await fs.readFile(path.join(root, "umbraco-compose.yaml"), "utf8"); + const beforeState = await fs.readFile(path.join(root, "compose.state.json"), "utf8"); + + await assert.rejects(() => + envRenameCommand.handler({ + dir: root, + from: "dev", + to: "prod", + moveDir: false + }) + ); + + const afterYaml = await fs.readFile(path.join(root, "umbraco-compose.yaml"), "utf8"); + const afterState = await fs.readFile(path.join(root, "compose.state.json"), "utf8"); + assert.equal(afterYaml, beforeYaml); + assert.equal(afterState, beforeState); +}); + +test("env rename --moveDir fails on destination collision and leaves config/state unchanged", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-rename-collision-")); + await writeComposeConfig(root, baseConfig()); + await writeComposeState(root, createInitialState("test-project", ["dev", "prod"])); + + await fs.mkdir(path.join(root, "env", "dev"), { recursive: true }); + await fs.mkdir(path.join(root, "env", "development"), { recursive: true }); + + const beforeYaml = await fs.readFile(path.join(root, "umbraco-compose.yaml"), "utf8"); + const beforeState = await fs.readFile(path.join(root, "compose.state.json"), "utf8"); + + await assert.rejects( + () => + envRenameCommand.handler({ + dir: root, + from: "dev", + to: "development", + moveDir: true + }), + /destination already exists/ + ); + + const afterYaml = await fs.readFile(path.join(root, "umbraco-compose.yaml"), "utf8"); + const afterState = await fs.readFile(path.join(root, "compose.state.json"), "utf8"); + assert.equal(afterYaml, beforeYaml); + assert.equal(afterState, beforeState); +}); + +test("env rename --moveDir succeeds when source directory is missing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-rename-missing-src-")); + await writeComposeConfig(root, baseConfig()); + await writeComposeState(root, createInitialState("test-project", ["dev", "prod"])); + + const oldExistsBefore = await exists(path.join(root, "env", "dev")); + assert.equal(oldExistsBefore, false); + + await envRenameCommand.handler({ + dir: root, + from: "dev", + to: "development", + moveDir: true + }); + + const cfgAfter = await readComposeConfig(root); + assert.equal(cfgAfter.environments.development?.dir, "./env/development"); + assert.equal(cfgAfter.environments.dev, undefined); + + const stateAfter = await readComposeState(root); + assert.ok(stateAfter); + assert.ok(stateAfter.environments.find((e) => e.alias === "development")); + + const oldExists = await exists(path.join(root, "env", "dev")); + const newExists = await exists(path.join(root, "env", "development")); + assert.equal(oldExists, false); + assert.equal(newExists, false); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/commands.generate-apply-flow.test.ts b/test/commands.generate-apply-flow.test.ts new file mode 100644 index 0000000..583aa0e --- /dev/null +++ b/test/commands.generate-apply-flow.test.ts @@ -0,0 +1,182 @@ +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 YAML from "yaml"; +import { generateCommand } from "../src/commands/generate.js"; +import { runApply } from "../src/commands/apply.js"; +import { readComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-generate-apply-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return { root, envDir }; +} + +test("generate -> apply writes lock/state and reports expected apply operations", async () => { + const { root, envDir } = await createFixture(); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Content" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "type-schema", + alias: "article", + description: "Article schema" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "ingestion-function", + alias: "map-content", + description: "Map content" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "persisted-doc", + alias: "software-query", + description: "Software query" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "webhook", + alias: "send-on-create", + description: "Webhook" + }); + + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") { + return jsonResponse(404, { error: "not found" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas" && method === "POST") { + return jsonResponse(201, { typeSchemaAlias: "article" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { ingestionFunctionAlias: "map-content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { persistedDocumentAlias: "software-query" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { webhookAlias: "send-on-create" }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((x) => String(x)).join(" ")); + }; + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + assert.equal(process.exitCode, 0); + } finally { + globalThis.fetch = oldFetch; + console.log = originalLog; + process.exitCode = oldExitCode; + } + + const lockPath = path.join(envDir, "compose.lock.json"); + const lockExists = await exists(lockPath); + assert.equal(lockExists, true); + + const state = await readComposeState(root); + assert.ok(state); + const envState = state.environments.find((e) => e.alias === "dev"); + assert.ok(envState); + assert.equal(envState.remoteAlias, "dev"); + + assert.equal(output.some((line) => line.includes("CREATE [env=dev] collection:content")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] type-schema:article")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] ingestion-function:map-content")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] persisted-doc:software-query")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] webhook:send-on-create")), true); + assert.equal( + output.some((line) => line.includes("Apply complete. create=5, delete=0, update=0, noop=1, warn=0")), + true + ); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/commands.generate-apply-warn-flow.test.ts b/test/commands.generate-apply-warn-flow.test.ts new file mode 100644 index 0000000..f145060 --- /dev/null +++ b/test/commands.generate-apply-warn-flow.test.ts @@ -0,0 +1,181 @@ +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 YAML from "yaml"; +import { generateCommand } from "../src/commands/generate.js"; +import { runApply } from "../src/commands/apply.js"; +import { readComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-generate-apply-warn-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return { root, envDir }; +} + +test("generate -> apply warning path skips lock/state writes", async () => { + const { root, envDir } = await createFixture(); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Content" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "type-schema", + alias: "article", + description: "Article schema" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "ingestion-function", + alias: "map-content", + description: "Map content" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "persisted-doc", + alias: "software-query", + description: "Software query" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "webhook", + alias: "send-on-create", + description: "Webhook" + }); + + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") { + return jsonResponse(404, { error: "not found" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas" && method === "POST") { + return jsonResponse(201, { typeSchemaAlias: "article" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { ingestionFunctionAlias: "map-content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(500, { error: "persisted-create-fail" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { webhookAlias: "send-on-create" }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((x) => String(x)).join(" ")); + }; + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + assert.equal(process.exitCode, 1); + } finally { + globalThis.fetch = oldFetch; + console.log = originalLog; + process.exitCode = oldExitCode; + } + + const lockExists = await exists(path.join(envDir, "compose.lock.json")); + assert.equal(lockExists, false); + + const state = await readComposeState(root); + assert.equal(state, null); + + assert.equal( + output.some((line) => line.includes("WARN") && line.includes("[env=dev] persisted-doc:software-query")), + true + ); + assert.equal( + output.some((line) => line.includes("Skipped lock/state writes because warnings were emitted.")), + true + ); + assert.equal( + output.some((line) => line.includes("Apply complete. create=5, delete=0, update=0, noop=1, warn=1")), + true + ); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/commands.generate-flow.test.ts b/test/commands.generate-flow.test.ts new file mode 100644 index 0000000..2033183 --- /dev/null +++ b/test/commands.generate-flow.test.ts @@ -0,0 +1,168 @@ +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 YAML from "yaml"; +import { generateCommand } from "../src/commands/generate.js"; +import { validateCommand } from "../src/commands/validate.js"; +import { runApply } from "../src/commands/apply.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-generate-flow-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return { root, envDir }; +} + +test("generate -> validate --strict -> plan baseline succeeds with expected operations", async () => { + const { root } = await createFixture(); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Content" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "type-schema", + alias: "article", + description: "Article schema" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "ingestion-function", + alias: "map-content", + description: "Map content" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "persisted-doc", + alias: "software-query", + description: "Software query" + }); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "webhook", + alias: "send-on-create", + description: "Webhook" + }); + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await validateCommand.handler({ + dir: root, + strict: true + }); + assert.equal(process.exitCode, 0); + } finally { + process.exitCode = oldExitCode; + } + + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") { + return jsonResponse(404, { error: "not found" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + const originalLog = console.log; + const output: string[] = []; + console.log = (...args: unknown[]) => { + output.push(args.map((x) => String(x)).join(" ")); + }; + + const oldExitCode2 = process.exitCode; + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + assert.equal(process.exitCode, 0); + } finally { + globalThis.fetch = oldFetch; + console.log = originalLog; + process.exitCode = oldExitCode2; + } + + assert.equal(output.some((line) => line.includes("NOOP [env=dev] environment:dev")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] collection:content")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] type-schema:article")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] ingestion-function:map-content")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] persisted-doc:software-query")), true); + assert.equal(output.some((line) => line.includes("CREATE [env=dev] webhook:send-on-create")), true); + assert.equal( + output.some((line) => line.includes("Plan complete. create=5, delete=0, update=0, noop=1, warn=0")), + true + ); +}); diff --git a/test/commands.generate-status-flow.test.ts b/test/commands.generate-status-flow.test.ts new file mode 100644 index 0000000..395aede --- /dev/null +++ b/test/commands.generate-status-flow.test.ts @@ -0,0 +1,179 @@ +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 YAML from "yaml"; +import { generateCommand } from "../src/commands/generate.js"; +import { statusCommand } from "../src/commands/status.js"; +import { runApply } from "../src/commands/apply.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +type StatusJson = { + project: string; + results: Array<{ + env: string; + groups: Record; + }>; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-generate-status-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return { root, envDir }; +} + +async function runStatusJson(args: { + root: string; + failOnChanges: boolean; +}): Promise<{ data: StatusJson; exitCode: number | undefined }> { + const originalLog = console.log; + const out: string[] = []; + console.log = (...values: unknown[]) => { + out.push(values.map((v) => String(v)).join(" ")); + }; + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await statusCommand.handler({ + dir: args.root, + env: "dev", + json: true, + failOnChanges: args.failOnChanges + }); + const jsonText = out.find((line) => line.trim().startsWith("{")); + assert.ok(jsonText, "status command did not emit JSON output"); + return { + data: JSON.parse(jsonText) as StatusJson, + exitCode: process.exitCode + }; + } finally { + console.log = originalLog; + process.exitCode = oldExitCode; + } +} + +test("generate -> status reports NO LOCK and sets non-zero exit with --failOnChanges", async () => { + const { root } = await createFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Content" + }); + + const result = await runStatusJson({ root, failOnChanges: true }); + const groups = result.data.results[0]?.groups; + assert.ok(groups); + assert.equal(groups.collections?.status, "NO LOCK"); + assert.equal(groups.webhooks?.status, "NO LOCK"); + assert.equal(result.exitCode, 1); +}); + +test("generate -> apply -> status transitions from UNCHANGED to CHANGED after local edit", async () => { + const { root, envDir } = await createFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Content" + }); + + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [] }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + const oldExit = process.exitCode; + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: false, + requireClean: false + }); + assert.equal(process.exitCode, 0); + } finally { + globalThis.fetch = oldFetch; + process.exitCode = oldExit; + } + + const unchanged = await runStatusJson({ root, failOnChanges: true }); + const unchangedGroups = unchanged.data.results[0]?.groups; + assert.ok(unchangedGroups); + assert.equal(unchangedGroups.collections?.status, "UNCHANGED"); + assert.equal(unchanged.exitCode, 0); + + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify( + { collections: [{ alias: "content", description: "Content updated" }] }, + null, + 2 + ) + "\n", + "utf8" + ); + + const changed = await runStatusJson({ root, failOnChanges: true }); + const changedGroups = changed.data.results[0]?.groups; + assert.ok(changedGroups); + assert.equal(changedGroups.collections?.status, "CHANGED"); + assert.equal(changed.exitCode, 1); +}); diff --git a/test/commands.generate.test.ts b/test/commands.generate.test.ts new file mode 100644 index 0000000..3b05746 --- /dev/null +++ b/test/commands.generate.test.ts @@ -0,0 +1,392 @@ +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 YAML from "yaml"; +import { generateCommand } from "../src/commands/generate.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +async function createGenerateFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-generate-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return { root, envDir }; +} + +test("generate collection appends a collection entry", async () => { + const { root, envDir } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Main content" + }); + + const collections = JSON.parse(await fs.readFile(path.join(envDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string; description?: string }>; + }; + assert.equal(collections.collections.length, 1); + assert.equal(collections.collections[0]?.alias, "content"); + assert.equal(collections.collections[0]?.description, "Main content"); +}); + +test("generate type-schema creates schema file", async () => { + const { root, envDir } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "type-schema", + alias: "article", + description: "Article schema" + }); + + const schemaPath = path.join(envDir, "type-schemas", "article.schema.json"); + const schema = JSON.parse(await fs.readFile(schemaPath, "utf8")) as { + alias: string; + description?: string; + schema: { $schema: string; allOf: Array<{ $ref: string }>; properties: Record }; + }; + assert.equal(schema.alias, "article"); + assert.equal(schema.description, "Article schema"); + assert.equal(schema.schema.$schema, "https://umbracocompose.com/v1/schema"); + assert.ok(Array.isArray(schema.schema.allOf)); +}); + +test("generate ingestion-function creates script and updates registry", async () => { + const { root, envDir } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "ingestion-function", + alias: "map-content", + description: "Maps content" + }); + + const registry = JSON.parse(await fs.readFile(path.join(envDir, "functions", "ingestion.json"), "utf8")) as { + functions: Array<{ alias: string; description?: string; scriptFile: string }>; + }; + assert.equal(registry.functions.length, 1); + assert.equal(registry.functions[0]?.alias, "map-content"); + assert.equal(registry.functions[0]?.scriptFile, "functions/ingestion/map-content.js"); + + const script = await fs.readFile(path.join(envDir, "functions", "ingestion", "map-content.js"), "utf8"); + assert.equal(script.includes("export default function"), true); +}); + +test("generate persisted-doc creates query file and updates registry", async () => { + const { root, envDir } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "persisted-doc", + alias: "software-query", + description: "Software query" + }); + + const registry = JSON.parse(await fs.readFile(path.join(envDir, "graphql", "persisted.json"), "utf8")) as { + documents: Array<{ alias: string; description?: string; queryFile: string }>; + }; + assert.equal(registry.documents.length, 1); + assert.equal(registry.documents[0]?.alias, "software-query"); + assert.equal(registry.documents[0]?.description, "Software query"); + assert.equal(registry.documents[0]?.queryFile, "graphql/persisted/software-query.gql"); + + const query = await fs.readFile(path.join(envDir, "graphql", "persisted", "software-query.gql"), "utf8"); + assert.equal(query.includes("query Example"), true); +}); + +test("generate webhook creates webhook entry with default URL", async () => { + const { root, envDir } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "webhook", + alias: "send-on-create", + description: "My webhook" + }); + + const webhooks = JSON.parse(await fs.readFile(path.join(envDir, "webhooks.json"), "utf8")) as { + webhooks: Array<{ + alias: string; + description?: string; + url: string; + eventTypes: string[]; + collectionAliases: string[]; + typeSchemaAliases: string[]; + customHeaders: Record; + }>; + }; + + assert.equal(webhooks.webhooks.length, 1); + assert.equal(webhooks.webhooks[0]?.alias, "send-on-create"); + assert.equal(webhooks.webhooks[0]?.description, "My webhook"); + assert.equal(webhooks.webhooks[0]?.url, "https://example.com/webhook"); + assert.deepEqual(webhooks.webhooks[0]?.eventTypes, ["content.ingested"]); + assert.deepEqual(webhooks.webhooks[0]?.collectionAliases, []); + assert.deepEqual(webhooks.webhooks[0]?.typeSchemaAliases, []); + assert.deepEqual(webhooks.webhooks[0]?.customHeaders, {}); +}); + +test("generate webhook accepts explicit URL", async () => { + const { root, envDir } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "webhook", + alias: "send-on-update", + description: "URL override", + url: "https://hooks.example.com/compose" + }); + + const webhooks = JSON.parse(await fs.readFile(path.join(envDir, "webhooks.json"), "utf8")) as { + webhooks: Array<{ alias: string; url: string }>; + }; + assert.equal(webhooks.webhooks[0]?.alias, "send-on-update"); + assert.equal(webhooks.webhooks[0]?.url, "https://hooks.example.com/compose"); +}); + +test("generate rejects duplicate aliases", async () => { + const { root } = await createGenerateFixture(); + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Main content" + }); + + await assert.rejects(() => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + description: "Duplicate" + }) + ); +}); + +test("generate rejects --url for non-webhook entities", async () => { + const { root, envDir } = await createGenerateFixture(); + const before = await fs.readFile(path.join(envDir, "collections.json"), "utf8"); + + await assert.rejects( + () => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "content", + url: "https://hooks.example.com/compose" + }), + /--url is only supported for entity=webhook/ + ); + + const after = await fs.readFile(path.join(envDir, "collections.json"), "utf8"); + assert.equal(after, before); +}); + +test("generate rejects invalid alias format", async () => { + const { root, envDir } = await createGenerateFixture(); + const before = await fs.readFile(path.join(envDir, "collections.json"), "utf8"); + + await assert.rejects( + () => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "Not_Kebab" + }), + /must be kebab-case/ + ); + + const after = await fs.readFile(path.join(envDir, "collections.json"), "utf8"); + assert.equal(after, before); +}); + +test("generate rejects reserved alias names", async () => { + const { root, envDir } = await createGenerateFixture(); + const before = await fs.readFile(path.join(envDir, "collections.json"), "utf8"); + + await assert.rejects( + () => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "con" + }), + /Reserved filename/ + ); + + const after = await fs.readFile(path.join(envDir, "collections.json"), "utf8"); + assert.equal(after, before); +}); + +test("generate type-schema rejects when schema file already exists", async () => { + const { root, envDir } = await createGenerateFixture(); + const schemaPath = path.join(envDir, "type-schemas", "article.schema.json"); + await fs.writeFile( + schemaPath, + JSON.stringify( + { + alias: "article", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [], + properties: {} + } + }, + null, + 2 + ) + "\n", + "utf8" + ); + const before = await fs.readFile(schemaPath, "utf8"); + + await assert.rejects( + () => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "type-schema", + alias: "article" + }), + /already exists/ + ); + + const after = await fs.readFile(schemaPath, "utf8"); + assert.equal(after, before); +}); + +test("generate ingestion-function rejects when script file already exists", async () => { + const { root, envDir } = await createGenerateFixture(); + const scriptPath = path.join(envDir, "functions", "ingestion", "map-content.js"); + await fs.writeFile(scriptPath, "export default () => null;\n", "utf8"); + const registryPath = path.join(envDir, "functions", "ingestion.json"); + const beforeRegistry = await fs.readFile(registryPath, "utf8"); + + await assert.rejects( + () => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "ingestion-function", + alias: "map-content" + }), + /script already exists/ + ); + + const afterRegistry = await fs.readFile(registryPath, "utf8"); + assert.equal(afterRegistry, beforeRegistry); +}); + +test("generate persisted-doc rejects when query file already exists", async () => { + const { root, envDir } = await createGenerateFixture(); + const queryPath = path.join(envDir, "graphql", "persisted", "software-query.gql"); + await fs.writeFile(queryPath, "query Existing { __typename }\n", "utf8"); + const registryPath = path.join(envDir, "graphql", "persisted.json"); + const beforeRegistry = await fs.readFile(registryPath, "utf8"); + + await assert.rejects( + () => + generateCommand.handler({ + dir: root, + env: "dev", + entity: "persisted-doc", + alias: "software-query" + }), + /query already exists/ + ); + + const afterRegistry = await fs.readFile(registryPath, "utf8"); + assert.equal(afterRegistry, beforeRegistry); +}); + +test("generate allows same alias across different entity types", async () => { + const { root, envDir } = await createGenerateFixture(); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "collection", + alias: "shared-alias", + description: "Shared collection alias" + }); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "type-schema", + alias: "shared-alias", + description: "Shared schema alias" + }); + + const collections = JSON.parse(await fs.readFile(path.join(envDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + const schema = JSON.parse( + await fs.readFile(path.join(envDir, "type-schemas", "shared-alias.schema.json"), "utf8") + ) as { alias: string }; + + assert.equal(collections.collections.some((c) => c.alias === "shared-alias"), true); + assert.equal(schema.alias, "shared-alias"); +}); + +test("generate allows same alias for webhook and persisted-doc", async () => { + const { root, envDir } = await createGenerateFixture(); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "webhook", + alias: "shared", + description: "Webhook alias" + }); + + await generateCommand.handler({ + dir: root, + env: "dev", + entity: "persisted-doc", + alias: "shared", + description: "Persisted alias" + }); + + const webhooks = JSON.parse(await fs.readFile(path.join(envDir, "webhooks.json"), "utf8")) as { + webhooks: Array<{ alias: string }>; + }; + const persisted = JSON.parse(await fs.readFile(path.join(envDir, "graphql", "persisted.json"), "utf8")) as { + documents: Array<{ alias: string }>; + }; + + assert.equal(webhooks.webhooks.some((w) => w.alias === "shared"), true); + assert.equal(persisted.documents.some((d) => d.alias === "shared"), true); +}); diff --git a/test/commands.plan-exit.test.ts b/test/commands.plan-exit.test.ts new file mode 100644 index 0000000..672ec65 --- /dev/null +++ b/test/commands.plan-exit.test.ts @@ -0,0 +1,87 @@ +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 YAML from "yaml"; +import { runApply } from "../src/commands/apply.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createComposeRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-plan-exit-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(envDir, { recursive: true }); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + return root; +} + +test("plan mode sets process.exitCode=1 when warnings are produced", async () => { + const root = await createComposeRoot(); + const oldFetch = globalThis.fetch; + const oldExitCode = process.exitCode; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(500, { error: "unavailable" }); + } + if (method === "GET") return jsonResponse(200, { edges: [] }); + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + process.exitCode = 0; + try { + await runApply({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret", + plan: true, + requireClean: false + }); + assert.equal(process.exitCode, 1); + } finally { + globalThis.fetch = oldFetch; + process.exitCode = oldExitCode; + } +}); diff --git a/test/commands.pull-hardening.test.ts b/test/commands.pull-hardening.test.ts new file mode 100644 index 0000000..ab3058f --- /dev/null +++ b/test/commands.pull-hardening.test.ts @@ -0,0 +1,239 @@ +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 crypto from "node:crypto"; +import YAML from "yaml"; +import { pullCommand } from "../src/commands/pull.js"; +import { createInitialState, readComposeState, writeComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-pull-hardening-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await writeComposeState(root, createInitialState("test-project", ["dev"])); + return { root, envDir }; +} + +function installSuccessfulPullFetch() { + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content", description: "Main content" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") { + return jsonResponse(200, { + typeSchemaAlias: "article", + description: "Article schema", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "map-content", description: "Maps content" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion/map-content") { + return jsonResponse(200, { + ingestionFunctionAlias: "map-content", + description: "Maps content", + script: "export default (x) => x;" + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "software-query", description: "Software query" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents/software-query") { + return jsonResponse(200, { + persistedDocumentAlias: "software-query", + description: "Software query", + document: "query Software { content { items { __typename } } }" + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "send-on-create" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks/send-on-create") { + return jsonResponse(200, { + webhookAlias: "send-on-create", + description: "Webhook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["article"], + customHeaders: { "x-api-key": "abc123" } + }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + return () => { + globalThis.fetch = oldFetch; + }; +} + +async function snapshotDir(root: string): Promise { + const files = await listFiles(root); + const parts: Array<{ file: string; hash: string }> = []; + for (const rel of files) { + const abs = path.join(root, rel); + const text = await fs.readFile(abs, "utf8"); + parts.push({ + file: rel, + hash: sha(text) + }); + } + parts.sort((a, b) => a.file.localeCompare(b.file)); + return sha(JSON.stringify(parts)); +} + +test("pull is idempotent for unchanged remote responses", async () => { + const { root, envDir } = await createFixture(); + const restoreFetch = installSuccessfulPullFetch(); + + try { + await pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }); + const first = await snapshotDir(envDir); + + await pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }); + const second = await snapshotDir(envDir); + + assert.equal(second, first); + } finally { + restoreFetch(); + } +}); + +test("pull partial failure keeps previous state remoteAlias unchanged and does not partially mutate local files", async () => { + const { root, envDir } = await createFixture(); + // First do a successful pull to set baseline and state + const restoreSuccess = installSuccessfulPullFetch(); + try { + await pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }); + } finally { + restoreSuccess(); + } + + const stateBefore = await readComposeState(root); + assert.ok(stateBefore); + const remoteBefore = stateBefore.environments.find((e) => e.alias === "dev")?.remoteAlias; + assert.equal(remoteBefore, "dev"); + + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content", description: "Changed before fail" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") { + return jsonResponse(500, { error: "type-schema-list-fail" }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await assert.rejects( + () => + pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }), + /Failed to list type schemas \(500\)\./ + ); + } finally { + globalThis.fetch = oldFetch; + } + + const stateAfter = await readComposeState(root); + assert.ok(stateAfter); + const remoteAfter = stateAfter.environments.find((e) => e.alias === "dev")?.remoteAlias; + // state is not advanced during failed pull + assert.equal(remoteAfter, "dev"); + + const collections = JSON.parse(await fs.readFile(path.join(envDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string; description?: string }>; + }; + assert.equal(collections.collections[0]?.description, "Main content"); +}); + +async function listFiles(root: string): Promise { + const out: string[] = []; + async function walk(dir: string) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(abs); + } else if (entry.isFile()) { + out.push(path.relative(root, abs)); + } + } + } + await walk(root); + return out; +} + +function sha(s: string): string { + return crypto.createHash("sha256").update(s).digest("hex"); +} diff --git a/test/commands.pull.test.ts b/test/commands.pull.test.ts new file mode 100644 index 0000000..d4cbfbf --- /dev/null +++ b/test/commands.pull.test.ts @@ -0,0 +1,283 @@ +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 YAML from "yaml"; +import { pullCommand } from "../src/commands/pull.js"; +import { createInitialState, readComposeState, writeComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function createFixture(): Promise<{ root: string; envDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-pull-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + + // stale files that should be removed on pull + await fs.writeFile(path.join(envDir, "type-schemas", "stale.schema.json"), "{}\n", "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion", "stale.js"), "export default null;\n", "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "stale.gql"), "query Stale { __typename }\n", "utf8"); + + await writeComposeState(root, createInitialState("test-project", ["dev"])); + return { root, envDir }; +} + +test("pull writes local files from API and updates state remoteAlias", async () => { + const { root, envDir } = await createFixture(); + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { + edges: [{ node: { collectionAlias: "content", description: "Main content" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") { + return jsonResponse(200, { + typeSchemaAlias: "article", + description: "Article schema", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { + edges: [{ node: { ingestionFunctionAlias: "map-content", description: "Maps content" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion/map-content") { + return jsonResponse(200, { + ingestionFunctionAlias: "map-content", + description: "Maps content", + script: "export default (x) => x;" + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { + edges: [{ node: { persistedDocumentAlias: "software-query", description: "Software query" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents/software-query") { + return jsonResponse(200, { + persistedDocumentAlias: "software-query", + description: "Software query", + document: "query Software { content { items { __typename } } }" + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "send-on-create" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks/send-on-create") { + return jsonResponse(200, { + webhookAlias: "send-on-create", + description: "Webhook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["article"], + customHeaders: { "x-api-key": "abc123" } + }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const collections = JSON.parse(await fs.readFile(path.join(envDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string; description?: string }>; + }; + assert.equal(collections.collections[0]?.alias, "content"); + + const schema = JSON.parse(await fs.readFile(path.join(envDir, "type-schemas", "article.schema.json"), "utf8")) as { + alias: string; + }; + assert.equal(schema.alias, "article"); + assert.equal(await exists(path.join(envDir, "type-schemas", "stale.schema.json")), false); + + const ingestionRegistry = JSON.parse(await fs.readFile(path.join(envDir, "functions", "ingestion.json"), "utf8")) as { + functions: Array<{ alias: string; scriptFile: string }>; + }; + assert.equal(ingestionRegistry.functions[0]?.alias, "map-content"); + assert.equal(await exists(path.join(envDir, "functions", "ingestion", "stale.js")), false); + + const persistedRegistry = JSON.parse(await fs.readFile(path.join(envDir, "graphql", "persisted.json"), "utf8")) as { + documents: Array<{ alias: string; queryFile: string }>; + }; + assert.equal(persistedRegistry.documents[0]?.alias, "software-query"); + assert.equal(await exists(path.join(envDir, "graphql", "persisted", "stale.gql")), false); + + const webhooks = JSON.parse(await fs.readFile(path.join(envDir, "webhooks.json"), "utf8")) as { + webhooks: Array<{ alias: string }>; + }; + assert.equal(webhooks.webhooks[0]?.alias, "send-on-create"); + + const state = await readComposeState(root); + assert.ok(state); + assert.equal(state.environments.find((e) => e.alias === "dev")?.remoteAlias, "dev"); +}); + +test("pull fails when target environment is not present remotely", async () => { + const { root } = await createFixture(); + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "other" } }] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await assert.rejects( + () => + pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }), + /Environment "dev" was not found remotely/ + ); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("pull uses state remoteAlias when local alias differs during pending rename", async () => { + const { root, envDir } = await createFixture(); + + const composePath = path.join(root, "umbraco-compose.yaml"); + const cfgText = await fs.readFile(composePath, "utf8"); + const cfg = YAML.parse(cfgText) as ComposeConfig; + cfg.environments = { + "dev-renamed": { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + }; + await fs.writeFile(composePath, YAML.stringify(cfg), "utf8"); + + const state = await readComposeState(root); + assert.ok(state); + state.environments[0].alias = "dev-renamed"; + state.environments[0].remoteAlias = "dev"; + await writeComposeState(root, state); + + const oldFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { + edges: [{ node: { collectionAlias: "content", description: "Main content" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await pullCommand.handler({ + dir: root, + env: "dev-renamed", + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const collections = JSON.parse(await fs.readFile(path.join(envDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + assert.equal(collections.collections[0]?.alias, "content"); + + const after = await readComposeState(root); + assert.ok(after); + const envState = after.environments.find((e) => e.alias === "dev-renamed"); + assert.ok(envState); + assert.equal(envState.remoteAlias, "dev"); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} diff --git a/test/commands.status-multi-env.test.ts b/test/commands.status-multi-env.test.ts new file mode 100644 index 0000000..fbff439 --- /dev/null +++ b/test/commands.status-multi-env.test.ts @@ -0,0 +1,184 @@ +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 YAML from "yaml"; +import { statusCommand } from "../src/commands/status.js"; +import { computeDesiredHashes, type LockFile } from "../src/compose/lock.js"; +import { ensureStateForAliases, markEnvironmentRemoteAlias, writeComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +type StatusJson = { + project: string; + results: Array<{ + env: string; + hasLock: boolean; + remoteAlias?: string; + renamePending?: boolean; + groups: Record; + }>; +}; + +async function createEnvScaffold(envDir: string): Promise { + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); +} + +async function createFixture(): Promise<{ root: string; devDir: string; prodDir: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-status-multi-env-")); + const devDir = path.join(root, "env", "dev"); + const prodDir = path.join(root, "env", "prod"); + + await createEnvScaffold(devDir); + await createEnvScaffold(prodDir); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + managementBaseUrl: "https://management.example" + }, + prod: { + dir: "./env/prod", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + + return { root, devDir, prodDir }; +} + +async function writeMatchingLock(envDir: string): Promise { + const desired = await computeDesiredHashes(envDir); + const lock: LockFile = { + version: 1, + project: "test-project", + environment: path.basename(envDir), + resources: desired + }; + await fs.writeFile(path.join(envDir, "compose.lock.json"), JSON.stringify(lock, null, 2) + "\n", "utf8"); +} + +async function runStatusJson(args: { + root: string; + failOnChanges: boolean; +}): Promise<{ data: StatusJson; exitCode: number | undefined }> { + const originalLog = console.log; + const out: string[] = []; + console.log = (...values: unknown[]) => { + out.push(values.map((v) => String(v)).join(" ")); + }; + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await statusCommand.handler({ + dir: args.root, + json: true, + failOnChanges: args.failOnChanges + }); + const jsonText = out.find((line) => line.trim().startsWith("{")); + assert.ok(jsonText, "status command did not emit JSON output"); + return { + data: JSON.parse(jsonText) as StatusJson, + exitCode: process.exitCode + }; + } finally { + console.log = originalLog; + process.exitCode = oldExitCode; + } +} + +async function runStatusHuman(args: { + root: string; + env?: string; + failOnChanges: boolean; +}): Promise<{ output: string[]; exitCode: number | undefined }> { + const originalLog = console.log; + const out: string[] = []; + console.log = (...values: unknown[]) => { + out.push(values.map((v) => String(v)).join(" ")); + }; + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await statusCommand.handler({ + dir: args.root, + env: args.env, + json: false, + failOnChanges: args.failOnChanges + }); + return { output: out, exitCode: process.exitCode }; + } finally { + console.log = originalLog; + process.exitCode = oldExitCode; + } +} + +test("status without --env returns results for all configured environments", async () => { + const { root, devDir, prodDir } = await createFixture(); + await writeMatchingLock(devDir); + await writeMatchingLock(prodDir); + + const result = await runStatusJson({ root, failOnChanges: true }); + + const envs = result.data.results.map((r) => r.env).sort(); + assert.deepEqual(envs, ["dev", "prod"]); + assert.equal(result.exitCode, 0); +}); + +test("status across multiple environments sets exitCode=1 when any environment has changes/no lock", async () => { + const { root, devDir } = await createFixture(); + await writeMatchingLock(devDir); + // prod intentionally has no lock + + const result = await runStatusJson({ root, failOnChanges: true }); + const dev = result.data.results.find((r) => r.env === "dev"); + const prod = result.data.results.find((r) => r.env === "prod"); + + assert.ok(dev); + assert.ok(prod); + assert.equal(dev.groups.collections?.status, "UNCHANGED"); + assert.equal(prod.groups.collections?.status, "NO LOCK"); + assert.equal(result.exitCode, 1); +}); + +test("status includes state identity context for pending rename in json and human output", async () => { + const { root, devDir } = await createFixture(); + await writeMatchingLock(devDir); + + const aliases = ["dev", "prod"]; + const state = ensureStateForAliases(null, "test-project", aliases).state; + const devState = state.environments.find((e) => e.alias === "dev"); + assert.ok(devState); + const marked = markEnvironmentRemoteAlias(state, devState.id, "old-dev"); + assert.equal(marked, true); + await writeComposeState(root, state); + + const jsonResult = await runStatusJson({ root, failOnChanges: false }); + const devJson = jsonResult.data.results.find((r) => r.env === "dev"); + assert.ok(devJson); + assert.equal(devJson.remoteAlias, "old-dev"); + assert.equal(devJson.renamePending, true); + + const humanResult = await runStatusHuman({ root, env: "dev", failOnChanges: false }); + assert.equal( + humanResult.output.some((line) => line.includes("Identity: pending rename (old-dev -> dev)")), + true + ); +}); diff --git a/test/commands.status-validate-exit.test.ts b/test/commands.status-validate-exit.test.ts new file mode 100644 index 0000000..2e989cd --- /dev/null +++ b/test/commands.status-validate-exit.test.ts @@ -0,0 +1,102 @@ +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 YAML from "yaml"; +import { statusCommand } from "../src/commands/status.js"; +import { validateCommand } from "../src/commands/validate.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +async function createStatusFixture(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-status-exit-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return root; +} + +async function createValidateFixtureWithWarnings(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validate-exit-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "Not-Kebab" }] }, null, 2), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + return root; +} + +test("status --failOnChanges sets process.exitCode=1 when lock is missing", async () => { + const root = await createStatusFixture(); + const oldExitCode = process.exitCode; + process.exitCode = 0; + + try { + await statusCommand.handler({ + dir: root, + env: "dev", + json: true, + failOnChanges: true + }); + assert.equal(process.exitCode, 1); + } finally { + process.exitCode = oldExitCode; + } +}); + +test("validate --strict sets process.exitCode=1 when warnings exist", async () => { + const root = await createValidateFixtureWithWarnings(); + const oldExitCode = process.exitCode; + process.exitCode = 0; + + try { + await validateCommand.handler({ + dir: root, + strict: true + }); + assert.equal(process.exitCode, 1); + } finally { + process.exitCode = oldExitCode; + } +}); diff --git a/test/commands.validate-rename-risk.test.ts b/test/commands.validate-rename-risk.test.ts new file mode 100644 index 0000000..6557c78 --- /dev/null +++ b/test/commands.validate-rename-risk.test.ts @@ -0,0 +1,125 @@ +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 YAML from "yaml"; +import { validateCommand } from "../src/commands/validate.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +async function createFixtureWithCollectionRenameRisk(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-validate-rename-risk-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify( + { + collections: [ + { alias: "content-a", description: "Same description" }, + { alias: "content-b", description: "Same description" } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + + // Keep schema checks green so test isolates rename-risk warning behavior. + await fs.writeFile( + path.join(envDir, "type-schemas", "article.schema.json"), + JSON.stringify( + { + alias: "article", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: {} + } + }, + null, + 2 + ), + "utf8" + ); + + return root; +} + +async function runValidateCapture(args: { + root: string; + strict: boolean; +}): Promise<{ output: string[]; exitCode: number | undefined }> { + const originalLog = console.log; + const output: string[] = []; + console.log = (...values: unknown[]) => { + output.push(values.map((v) => String(v)).join(" ")); + }; + + const oldExitCode = process.exitCode; + process.exitCode = 0; + try { + await validateCommand.handler({ + dir: args.root, + strict: args.strict + }); + return { output, exitCode: process.exitCode }; + } finally { + console.log = originalLog; + process.exitCode = oldExitCode; + } +} + +test("validate warns on duplicate rename-signatures in non-strict mode without failing", async () => { + const root = await createFixtureWithCollectionRenameRisk(); + const result = await runValidateCapture({ root, strict: false }); + + assert.equal( + result.output.some((line) => line.includes("identical rename-signatures")), + true + ); + assert.equal( + result.output.some((line) => line.includes("Resolve by: ensure collection descriptions are distinct")), + true + ); + assert.equal(result.exitCode, 0); +}); + +test("validate --strict fails when duplicate rename-signature warnings are present", async () => { + const root = await createFixtureWithCollectionRenameRisk(); + const result = await runValidateCapture({ root, strict: true }); + + assert.equal( + result.output.some((line) => line.includes("identical rename-signatures")), + true + ); + assert.equal( + result.output.some((line) => line.includes("apply the intended create/delete explicitly")), + true + ); + assert.equal(result.exitCode, 1); +}); diff --git a/test/compose.apply.environment-alias-routing.test.ts b/test/compose.apply.environment-alias-routing.test.ts new file mode 100644 index 0000000..3146cf4 --- /dev/null +++ b/test/compose.apply.environment-alias-routing.test.ts @@ -0,0 +1,174 @@ +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 { applyEnvironment } from "../src/compose/apply.js"; + +type FetchCall = { + method: string; + url: string; + bodyText?: string; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function makeEnvDirWithCollections(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-env-")); + await fs.writeFile( + path.join(dir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + return dir; +} + +test("plan with pending rename reads nested resources from remote alias path", async () => { + const envDir = await makeEnvDirWithCollections(); + const calls: FetchCall[] = []; + const baseUrl = "https://management.example"; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") { + bodyText = init.body; + } else if (init?.body instanceof URLSearchParams) { + bodyText = init.body.toString(); + } + calls.push({ method, url, bodyText }); + + const u = new URL(url); + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { + edges: [{ node: { environmentAlias: "iac-dev" } }] + }); + } + if (u.pathname === "/v1/projects/proj/environments/iac-dev/collections") { + return jsonResponse(200, { edges: [] }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "iac-development", + remoteEnvAlias: "iac-dev", + envDir, + envDescription: "Development", + baseUrl, + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: true + }); + + const hasRenameOp = ops.some( + (o) => o.kind === "update" && o.resource === "environment" && typeof o.details === "string" && o.details.includes("iac-dev -> iac-development") + ); + assert.equal(hasRenameOp, true); + + assert.equal( + calls.some((c) => c.url.includes("/v1/projects/proj/environments/iac-dev/collections")), + true + ); + assert.equal( + calls.some((c) => c.url.includes("/v1/projects/proj/environments/iac-development/collections")), + false + ); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("apply with pending rename calls rename endpoint and then uses new alias path for nested resources", async () => { + const envDir = await makeEnvDirWithCollections(); + const calls: FetchCall[] = []; + const baseUrl = "https://management.example"; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") { + bodyText = init.body; + } else if (init?.body instanceof URLSearchParams) { + bodyText = init.body.toString(); + } + calls.push({ method, url, bodyText }); + + const u = new URL(url); + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { + edges: [{ node: { environmentAlias: "iac-dev" } }] + }); + } + if ( + method === "POST" && + u.pathname === "/v1/projects/proj/environments/iac-dev/commands/rename" + ) { + return jsonResponse(200, { environmentAlias: "iac-development" }); + } + if (u.pathname === "/v1/projects/proj/environments/iac-development/collections") { + return jsonResponse(200, { edges: [] }); + } + if ( + method === "POST" && + u.pathname === "/v1/projects/proj/environments/iac-development/collections" + ) { + return jsonResponse(201, { collectionAlias: "content" }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "iac-development", + remoteEnvAlias: "iac-dev", + envDir, + envDescription: "Development", + baseUrl, + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + const renameCall = calls.find((c) => + c.url.includes("/v1/projects/proj/environments/iac-dev/commands/rename") + ); + assert.ok(renameCall); + assert.equal(renameCall.method, "POST"); + assert.equal(renameCall.bodyText, JSON.stringify({ newEnvironmentAlias: "iac-development" })); + + assert.equal( + calls.some((c) => c.url.includes("/v1/projects/proj/environments/iac-development/collections")), + true + ); + assert.equal( + calls.some((c) => c.url.includes("/v1/projects/proj/environments/iac-dev/collections")), + false + ); + + assert.equal( + ops.some((o) => o.kind === "update" && o.resource === "environment"), + true + ); + } finally { + globalThis.fetch = oldFetch; + } +}); diff --git a/test/compose.apply.failures.test.ts b/test/compose.apply.failures.test.ts new file mode 100644 index 0000000..d5ddfc3 --- /dev/null +++ b/test/compose.apply.failures.test.ts @@ -0,0 +1,129 @@ +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 { applyEnvironment } from "../src/compose/apply.js"; + +type FetchCall = { + method: string; + url: string; + bodyText?: string; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function makeEnvDirWithCollections(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-fail-")); + await fs.writeFile( + path.join(dir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content" }] }, null, 2), + "utf8" + ); + return dir; +} + +test("environment list failure returns environment warning and short-circuits nested operations", async () => { + const envDir = await makeEnvDirWithCollections(); + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calls.push({ method, url }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(500, { error: "boom" }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: true + }); + + assert.equal( + ops.some((o) => o.kind === "warn" && o.resource === "environment"), + true + ); + assert.equal( + calls.some((c) => c.url.includes("/environments/dev/collections")), + false + ); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("rename failure returns environment warning with status and short-circuits nested operations", async () => { + const envDir = await makeEnvDirWithCollections(); + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "old-dev" } }] }); + } + if ( + method === "POST" && + u.pathname === "/v1/projects/proj/environments/old-dev/commands/rename" + ) { + return jsonResponse(409, { error: "conflict" }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + remoteEnvAlias: "old-dev", + envDir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + assert.equal( + ops.some((o) => o.kind === "warn" && o.resource === "environment" && (o.details ?? "").includes("status 409")), + true + ); + assert.equal( + calls.some((c) => c.url.includes("/environments/dev/collections")), + false + ); + } finally { + globalThis.fetch = oldFetch; + } +}); diff --git a/test/compose.apply.rename-inference.test.ts b/test/compose.apply.rename-inference.test.ts new file mode 100644 index 0000000..2a72645 --- /dev/null +++ b/test/compose.apply.rename-inference.test.ts @@ -0,0 +1,436 @@ +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 { applyEnvironment } from "../src/compose/apply.js"; + +type FetchCall = { + method: string; + url: string; + bodyText?: string; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function makeCollectionsEnvDir( + collections: Array<{ alias: string; description?: string | null }> +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-rename-inf-")); + await fs.writeFile(path.join(dir, "collections.json"), JSON.stringify({ collections }, null, 2), "utf8"); + return dir; +} + +async function makePersistedEnvDir( + docs: Array<{ alias: string; description?: string | null; query: string }> +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-rename-inf-persisted-")); + await fs.mkdir(path.join(dir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile( + path.join(dir, "graphql", "persisted.json"), + JSON.stringify( + { + documents: docs.map((d) => ({ + alias: d.alias, + description: d.description ?? "", + queryFile: `graphql/persisted/${d.alias}.gql` + })) + }, + null, + 2 + ), + "utf8" + ); + for (const d of docs) { + await fs.writeFile(path.join(dir, "graphql", "persisted", `${d.alias}.gql`), d.query, "utf8"); + } + return dir; +} + +test("collection rename inference skips ambiguous matches and falls back to create/delete", async () => { + const envDir = await makeCollectionsEnvDir([{ alias: "content-new", description: "Same description" }]); + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { + edges: [{ node: { collectionAlias: "content-old-a" } }, { node: { collectionAlias: "content-old-b" } }] + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old-a" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old-a", description: "Same description" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old-b" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old-b", description: "Same description" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "POST") { + return jsonResponse(201, { collectionAlias: "content-new" }); + } + if ( + (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old-a" || + u.pathname === "/v1/projects/proj/environments/dev/collections/content-old-b") && + method === "DELETE" + ) { + return new Response(null, { status: 204 }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + assert.equal(calls.some((c) => c.url.includes("/commands/rename")), false); + assert.equal(ops.some((o) => o.kind === "update" && o.resource === "collection"), false); + assert.equal(ops.some((o) => o.kind === "create" && o.resource === "collection" && o.alias === "content-new"), true); + const deleteOps = ops.filter((o) => o.kind === "delete" && o.resource === "collection"); + assert.equal(deleteOps.length, 2); + assert.equal(deleteOps.every((o) => o.details === "no unique rename match"), true); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("collection rename inference skips when signatures do not match and falls back to create/delete", async () => { + const envDir = await makeCollectionsEnvDir([{ alias: "content-new", description: "New description" }]); + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old", description: "Old description" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "POST") { + return jsonResponse(201, { collectionAlias: "content-new" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + assert.equal(calls.some((c) => c.url.includes("/commands/rename")), false); + assert.equal(ops.some((o) => o.kind === "update" && o.resource === "collection"), false); + assert.equal(ops.some((o) => o.kind === "create" && o.resource === "collection" && o.alias === "content-new"), true); + assert.equal( + ops.some((o) => o.kind === "delete" && o.resource === "collection" && o.alias === "content-old"), + true + ); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("collection rename inference skips when desired signatures are ambiguous and falls back to create/delete", async () => { + const envDir = await makeCollectionsEnvDir([ + { alias: "content-new-a", description: "Same description" }, + { alias: "content-new-b", description: "Same description" } + ]); + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old", description: "Same description" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "POST") { + return jsonResponse(201, { collectionAlias: "created" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + assert.equal(calls.some((c) => c.url.includes("/commands/rename")), false); + assert.equal(ops.some((o) => o.kind === "update" && o.resource === "collection"), false); + assert.equal( + ops.some((o) => o.kind === "create" && o.resource === "collection" && o.alias === "content-new-a"), + true + ); + assert.equal( + ops.some((o) => o.kind === "create" && o.resource === "collection" && o.alias === "content-new-b"), + true + ); + assert.equal( + ops.some((o) => o.kind === "delete" && o.resource === "collection" && o.alias === "content-old"), + true + ); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("persisted-doc rename inference skips ambiguous remote matches and falls back to create/delete", async () => { + const envDir = await makePersistedEnvDir([ + { alias: "doc-new", description: "Shared description", query: "query { ping }" } + ]); + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents" && method === "GET") { + return jsonResponse(200, { + edges: [{ node: { persistedDocumentAlias: "doc-old-a" } }, { node: { persistedDocumentAlias: "doc-old-b" } }] + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old-a" && + method === "GET" + ) { + return jsonResponse(200, { + persistedDocumentAlias: "doc-old-a", + description: "Shared description", + document: "query { ping }" + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old-b" && + method === "GET" + ) { + return jsonResponse(200, { + persistedDocumentAlias: "doc-old-b", + description: "Shared description", + document: "query { ping }" + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents" && method === "POST") { + return jsonResponse(201, { persistedDocumentAlias: "doc-new" }); + } + if ( + (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old-a" || + u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old-b") && + method === "DELETE" + ) { + return new Response(null, { status: 204 }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + assert.equal(calls.some((c) => c.url.includes("/graphql/persisted-documents/") && c.url.includes("/commands/rename")), false); + assert.equal(ops.some((o) => o.kind === "update" && o.resource === "persisted-doc"), false); + assert.equal(ops.some((o) => o.kind === "create" && o.resource === "persisted-doc" && o.alias === "doc-new"), true); + const deleteOps = ops.filter((o) => o.kind === "delete" && o.resource === "persisted-doc"); + assert.equal(deleteOps.length, 2); + assert.equal(deleteOps.every((o) => o.details === "no unique rename match"), true); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("webhook rename inference skips ambiguous remote matches and falls back to create/delete", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "compose-apply-rename-inf-webhook-")); + await fs.writeFile( + path.join(dir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "hook-new", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["article"], + customHeaders: { authorization: "Bearer abc" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + const u = new URL(url); + + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { + edges: [{ node: { webhookAlias: "hook-old-a" } }, { node: { webhookAlias: "hook-old-b" } }] + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old-a" && method === "GET") { + return jsonResponse(200, { + webhookAlias: "hook-old-a", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["article"], + customHeaders: { authorization: "Bearer abc" } + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old-b" && method === "GET") { + return jsonResponse(200, { + webhookAlias: "hook-old-b", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["article"], + customHeaders: { authorization: "Bearer abc" } + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "POST") { + return jsonResponse(201, { webhookAlias: "hook-new" }); + } + if ( + (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old-a" || + u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old-b") && + method === "DELETE" + ) { + return new Response(null, { status: 204 }); + } + + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + const ops = await applyEnvironment({ + project: "proj", + env: "dev", + envDir: dir, + envDescription: "Dev", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + + assert.equal(calls.some((c) => c.url.includes("/webhooks/") && c.url.includes("/commands/rename")), false); + assert.equal(ops.some((o) => o.kind === "update" && o.resource === "webhook"), false); + assert.equal(ops.some((o) => o.kind === "create" && o.resource === "webhook" && o.alias === "hook-new"), true); + const deleteOps = ops.filter((o) => o.kind === "delete" && o.resource === "webhook"); + assert.equal(deleteOps.length, 2); + assert.equal(deleteOps.every((o) => o.details === "no unique rename match"), true); + } finally { + globalThis.fetch = oldFetch; + } +}); diff --git a/test/compose.management-client.test.ts b/test/compose.management-client.test.ts new file mode 100644 index 0000000..1249e17 --- /dev/null +++ b/test/compose.management-client.test.ts @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ManagementClient } from "../src/compose/management-client.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +test("ManagementClient retries once on 401 and invalidates auth cache", async () => { + const oldFetch = globalThis.fetch; + let fetchCount = 0; + let invalidateCount = 0; + let getHeadersCount = 0; + + globalThis.fetch = (async () => { + fetchCount += 1; + if (fetchCount === 1) { + return jsonResponse(401, { error: "expired-token" }); + } + return jsonResponse(200, { ok: true }); + }) as typeof fetch; + + const client = new ManagementClient({ + baseUrl: "https://management.example", + auth: { + getHeaders: async () => { + getHeadersCount += 1; + return { Authorization: `Bearer token-${getHeadersCount}` }; + }, + invalidate: () => { + invalidateCount += 1; + } + } + }); + + try { + const res = await client.get<{ ok: boolean }>("/v1/projects/p/environments"); + assert.equal(res.status, 200); + assert.equal(res.data?.ok, true); + assert.equal(fetchCount, 2); + assert.equal(getHeadersCount, 2); + assert.equal(invalidateCount, 1); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("ManagementClient does not loop infinitely when retry also returns 401", async () => { + const oldFetch = globalThis.fetch; + let fetchCount = 0; + let invalidateCount = 0; + + globalThis.fetch = (async () => { + fetchCount += 1; + return jsonResponse(401, { error: "still-unauthorized" }); + }) as typeof fetch; + + const client = new ManagementClient({ + baseUrl: "https://management.example", + auth: { + getHeaders: async () => ({ Authorization: "Bearer token" }), + invalidate: () => { + invalidateCount += 1; + } + } + }); + + try { + const res = await client.get<{ error: string }>("/v1/projects/p/environments"); + assert.equal(res.status, 401); + assert.equal(res.data?.error, "still-unauthorized"); + assert.equal(fetchCount, 2); + assert.equal(invalidateCount, 1); + } finally { + globalThis.fetch = oldFetch; + } +}); diff --git a/test/compose.oauth-base.test.ts b/test/compose.oauth-base.test.ts new file mode 100644 index 0000000..f902787 --- /dev/null +++ b/test/compose.oauth-base.test.ts @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { oauthClientCredentialsAuth } from "../src/compose/auth/providers/oauth-base.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +test("oauthClientCredentialsAuth caches token between calls until invalidated", async () => { + const oldFetch = globalThis.fetch; + let tokenCallCount = 0; + + globalThis.fetch = (async () => { + tokenCallCount += 1; + return jsonResponse(200, { + access_token: `token-${tokenCallCount}`, + expires_in: 3600 + }); + }) as typeof fetch; + + try { + const auth = oauthClientCredentialsAuth({ + tokenUrl: "https://auth.example/token", + clientId: "client", + clientSecret: "secret" + }); + + const h1 = await auth.getHeaders(); + const h2 = await auth.getHeaders(); + + assert.equal(h1.Authorization, "Bearer token-1"); + assert.equal(h2.Authorization, "Bearer token-1"); + assert.equal(tokenCallCount, 1); + + auth.invalidate?.(); + const h3 = await auth.getHeaders(); + assert.equal(h3.Authorization, "Bearer token-2"); + assert.equal(tokenCallCount, 2); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("oauthClientCredentialsAuth deduplicates concurrent token requests", async () => { + const oldFetch = globalThis.fetch; + let tokenCallCount = 0; + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + + globalThis.fetch = (async () => { + tokenCallCount += 1; + await gate; + return jsonResponse(200, { + access_token: "shared-token", + expires_in: 3600 + }); + }) as typeof fetch; + + try { + const auth = oauthClientCredentialsAuth({ + tokenUrl: "https://auth.example/token", + clientId: "client", + clientSecret: "secret" + }); + + const p1 = auth.getHeaders(); + const p2 = auth.getHeaders(); + const p3 = auth.getHeaders(); + + release?.(); + const [h1, h2, h3] = await Promise.all([p1, p2, p3]); + + assert.equal(h1.Authorization, "Bearer shared-token"); + assert.equal(h2.Authorization, "Bearer shared-token"); + assert.equal(h3.Authorization, "Bearer shared-token"); + assert.equal(tokenCallCount, 1); + } finally { + globalThis.fetch = oldFetch; + } +}); + +test("oauthClientCredentialsAuth surfaces token endpoint errors", async () => { + const oldFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("unauthorized", { + status: 401, + headers: { "content-type": "text/plain" } + })) as typeof fetch; + + try { + const auth = oauthClientCredentialsAuth({ + tokenUrl: "https://auth.example/token", + clientId: "client", + clientSecret: "secret" + }); + + await assert.rejects( + () => auth.getHeaders(), + /OAuth token request failed \(401\): unauthorized/ + ); + } finally { + globalThis.fetch = oldFetch; + } +}); diff --git a/test/compose.state.test.ts b/test/compose.state.test.ts new file mode 100644 index 0000000..c7fa655 --- /dev/null +++ b/test/compose.state.test.ts @@ -0,0 +1,70 @@ +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 { + COMPOSE_STATE_FILE, + ensureStateForAliases, + findEnvironmentByAlias, + markEnvironmentRemoteAlias, + readComposeState, + renameEnvironmentAlias, + writeComposeState +} from "../src/compose/state.js"; + +test("ensureStateForAliases creates and extends state while preserving existing env ids", () => { + const first = ensureStateForAliases(null, "proj-a", ["dev"]).state; + assert.equal(first.project, "proj-a"); + assert.equal(first.environments.length, 1); + assert.equal(first.environments[0]?.alias, "dev"); + + const originalId = first.environments[0]?.id; + assert.ok(originalId); + + const second = ensureStateForAliases(first, "proj-a", ["dev", "stage"]).state; + assert.equal(second.environments.length, 2); + assert.equal(second.environments.find((e) => e.alias === "dev")?.id, originalId); + assert.ok(second.environments.find((e) => e.alias === "stage")?.id); +}); + +test("renameEnvironmentAlias updates alias and keeps remoteAlias mapping stable by env id", () => { + const state = ensureStateForAliases(null, "proj-b", ["iac-dev"]).state; + const env = findEnvironmentByAlias(state, "iac-dev"); + assert.ok(env); + + const marked = markEnvironmentRemoteAlias(state, env.id, "iac-dev"); + assert.equal(marked, true); + + const renamed = renameEnvironmentAlias(state, "iac-dev", "iac-development"); + assert.equal(renamed, true); + + const after = findEnvironmentByAlias(state, "iac-development"); + assert.ok(after); + assert.equal(after.id, env.id); + assert.equal(after.remoteAlias, "iac-dev"); +}); + +test("readComposeState returns null when file is missing and throws on invalid JSON", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-state-test-")); + const missing = await readComposeState(tmpRoot); + assert.equal(missing, null); + + const statePath = path.join(tmpRoot, COMPOSE_STATE_FILE); + await fs.writeFile(statePath, "{invalid json", "utf8"); + await assert.rejects(() => readComposeState(tmpRoot)); +}); + +test("writeComposeState and readComposeState roundtrip", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compose-state-roundtrip-")); + const initial = ensureStateForAliases(null, "proj-c", ["dev", "prod"]).state; + await writeComposeState(tmpRoot, initial); + + const read = await readComposeState(tmpRoot); + assert.ok(read); + assert.equal(read.project, "proj-c"); + assert.deepEqual( + read.environments.map((e) => e.alias).sort(), + ["dev", "prod"] + ); +}); diff --git a/test/contracts.apply-contract-map.test.ts b/test/contracts.apply-contract-map.test.ts new file mode 100644 index 0000000..f96ab01 --- /dev/null +++ b/test/contracts.apply-contract-map.test.ts @@ -0,0 +1,1299 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { applyEnvironment } from "../src/compose/apply.js"; + +type ContractMap = { + version: number; + operations: Record< + string, + { + method: string; + pathTemplate: string; + requiredBodyKeys: string[]; + } + >; +}; + +type CapturedCall = { + method: string; + pathname: string; + body?: Record | unknown; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function readContractMap(): Promise { + const filePath = path.resolve(process.cwd(), "docs/contracts/apply-contract.json"); + const text = await fs.readFile(filePath, "utf8"); + return JSON.parse(text) as ContractMap; +} + +function resolvePath(template: string, params: Record): string { + let out = template; + for (const [k, v] of Object.entries(params)) { + out = out.replaceAll(`{${k}}`, v); + } + return out; +} + +function assertContractCall( + contracts: ContractMap, + operation: keyof ContractMap["operations"], + calls: CapturedCall[], + params: Record +): void { + const contract = contracts.operations[operation]; + assert.ok(contract, `Missing contract entry for ${operation}`); + + const path = resolvePath(contract.pathTemplate, params); + const call = calls.find((c) => c.method === contract.method && c.pathname === path); + assert.ok(call, `Did not find contract call for ${operation}: ${contract.method} ${path}`); + + if (contract.requiredBodyKeys.length > 0) { + assert.ok(call.body && typeof call.body === "object", `${operation} body was not an object`); + const body = call.body as Record; + for (const key of contract.requiredBodyKeys) { + assert.ok(key in body, `${operation} body missing required key: ${key}`); + } + } +} + +async function makeFullEnvDir(): Promise { + const envDir = await fs.mkdtemp(path.join(os.tmpdir(), "compose-contract-map-full-")); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content", description: "Main content" }] }, null, 2), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "type-schemas", "software.schema.json"), + JSON.stringify( + { + alias: "software", + description: "Software", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { name: { type: "string" } } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "type-schemas", "article.schema.json"), + JSON.stringify( + { + alias: "article", + description: "Article", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify( + { + functions: [ + { + alias: "map-content", + description: "Maps content", + scriptFile: "functions/ingestion/map-content.js" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion", "map-content.js"), + "export default (x) => x;", + "utf8" + ); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { + documents: [ + { alias: "doc-1", description: "Query", queryFile: "graphql/persisted/doc-1.gql" } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-1.gql"), "query { ping }", "utf8"); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "send-on-create", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["software"], + customHeaders: { "x-api-key": "abc123" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + return envDir; +} + +async function mkEnvDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +test("apply emitted calls match contract map for currently implemented write operations", async () => { + const contracts = await readContractMap(); + const envDir = await makeFullEnvDir(); + const calls: CapturedCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/software") { + return jsonResponse(404, { error: "not found" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article") { + return jsonResponse(200, { + typeSchemaAlias: "article", + description: "Article", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { oldField: { type: "string" } } + } + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "POST") { + return jsonResponse(201, { typeSchemaAlias: "software" }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article/commands/update-schema" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { ingestionFunctionAlias: "map-content" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { persistedDocumentAlias: "doc-1" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { webhookAlias: "send-on-create" }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + const params = { + projectAlias: "proj", + environmentAlias: "dev", + typeSchemaAlias: "article" + }; + + assertContractCall(contracts, "collectionCreate", calls, params); + assertContractCall(contracts, "typeSchemaCreate", calls, params); + assertContractCall(contracts, "typeSchemaUpdateSchema", calls, params); + assertContractCall(contracts, "ingestionFunctionCreate", calls, params); + assertContractCall(contracts, "persistedDocumentCreate", calls, params); + assertContractCall(contracts, "webhookCreate", calls, params); +}); + +test("environment create and rename calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-env-"); + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { environmentAlias: "dev" }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "environmentCreate", calls, { + projectAlias: "proj" + }); + + const renameCalls: CapturedCall[] = []; + const oldFetch2 = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + renameCalls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "old-dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/old-dev/commands/rename" && method === "POST") { + return jsonResponse(200, { environmentAlias: "dev" }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + remoteEnvAlias: "old-dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch2; + } + + assertContractCall(contracts, "environmentRename", renameCalls, { + projectAlias: "proj", + environmentAlias: "old-dev" + }); +}); + +test("ingestion update calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-ingestion-update-"); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify( + { + functions: [ + { + alias: "fn-a", + description: "New desc", + scriptFile: "functions/ingestion/fn-a.js" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "functions", "ingestion", "fn-a.js"), "export default (x) => x;", "utf8"); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion") return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "fn-a" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/fn-a") { + return jsonResponse(200, { ingestionFunctionAlias: "fn-a", description: "Old desc", script: "export default () => null;" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/fn-a/commands/update-script" && method === "PUT") { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/fn-a/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "ingestionFunctionUpdateScript", calls, { + projectAlias: "proj", + environmentAlias: "dev", + ingestionFunctionAlias: "fn-a" + }); + assertContractCall(contracts, "ingestionFunctionUpdateDescription", calls, { + projectAlias: "proj", + environmentAlias: "dev", + ingestionFunctionAlias: "fn-a" + }); +}); + +test("collection update-description calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-collection-update-desc-"); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content", description: "New description" }] }, null, 2), + "utf8" + ); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") body = JSON.parse(init.body); + if (init?.body instanceof URLSearchParams) body = init.body.toString(); + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content", description: "Old description" }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/collections/content/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "collectionUpdateDescription", calls, { + projectAlias: "proj", + environmentAlias: "dev", + collectionAlias: "content" + }); +}); + +test("type-schema update-description calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-type-schema-update-desc-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "type-schemas", "article.schema.json"), + JSON.stringify( + { + alias: "article", + description: "New type-schema description", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }, + null, + 2 + ), + "utf8" + ); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") body = JSON.parse(init.body); + if (init?.body instanceof URLSearchParams) body = init.body.toString(); + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article" && method === "GET") { + return jsonResponse(200, { + typeSchemaAlias: "article", + description: "Old type-schema description", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "typeSchemaUpdateDescription", calls, { + projectAlias: "proj", + environmentAlias: "dev", + typeSchemaAlias: "article" + }); +}); + +test("ingestion delete calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-ingestion-delete-"); + await fs.mkdir(path.join(envDir, "functions"), { recursive: true }); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "legacy-ingest" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/legacy-ingest" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "ingestionFunctionDelete", calls, { + projectAlias: "proj", + environmentAlias: "dev", + ingestionFunctionAlias: "legacy-ingest" + }); +}); + +test("persisted-document update calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-persisted-update-"); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { + documents: [ + { + alias: "doc-a", + description: "New persisted doc description", + queryFile: "graphql/persisted/doc-a.gql" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-a.gql"), "query { updated }", "utf8"); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-a" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-a") { + return jsonResponse(200, { + persistedDocumentAlias: "doc-a", + description: "Old persisted doc description", + document: "query { old }" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-a/commands/update-document" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-a/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "persistedDocumentUpdateDocument", calls, { + projectAlias: "proj", + environmentAlias: "dev", + persistedDocumentAlias: "doc-a" + }); + assertContractCall(contracts, "persistedDocumentUpdateDescription", calls, { + projectAlias: "proj", + environmentAlias: "dev", + persistedDocumentAlias: "doc-a" + }); +}); + +test("webhook update calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-webhook-update-"); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "send-on-create", + description: "New webhook description", + url: "https://example.com/new", + eventTypes: ["content.deleted"], + collectionAliases: ["articles"], + typeSchemaAliases: ["article"], + customHeaders: { authorization: "Bearer abc" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "send-on-create" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create" && method === "GET") { + return jsonResponse(200, { + webhookAlias: "send-on-create", + description: "Old webhook description", + url: "https://example.com/old", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["software"], + customHeaders: { authorization: "Bearer old" } + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-url" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-event-types" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-collections" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-type-schemas" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-headers" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + const params = { + projectAlias: "proj", + environmentAlias: "dev", + webhookAlias: "send-on-create" + }; + assertContractCall(contracts, "webhookUpdateDescription", calls, params); + assertContractCall(contracts, "webhookUpdateUrl", calls, params); + assertContractCall(contracts, "webhookUpdateEventTypes", calls, params); + assertContractCall(contracts, "webhookUpdateCollections", calls, params); + assertContractCall(contracts, "webhookUpdateTypeSchemas", calls, params); + assertContractCall(contracts, "webhookUpdateHeaders", calls, params); +}); + +test("persisted-document delete calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-persisted-delete-"); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify({ documents: [] }, null, 2), + "utf8" + ); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-a" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-a" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "persistedDocumentDelete", calls, { + projectAlias: "proj", + environmentAlias: "dev", + persistedDocumentAlias: "doc-a" + }); +}); + +test("webhook delete calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-webhook-delete-"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "send-on-create" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "webhookDelete", calls, { + projectAlias: "proj", + environmentAlias: "dev", + webhookAlias: "send-on-create" + }); +}); + +test("collection delete calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-collection-delete-"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "collectionDelete", calls, { + projectAlias: "proj", + environmentAlias: "dev", + collectionAlias: "content" + }); +}); + +test("type-schema delete calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-type-schema-delete-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "legacy-schema" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/legacy-schema" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + assertContractCall(contracts, "typeSchemaDelete", calls, { + projectAlias: "proj", + environmentAlias: "dev", + typeSchemaAlias: "legacy-schema" + }); +}); + +test("non-environment rename calls match contract map", async () => { + const contracts = await readContractMap(); + const envDir = await mkEnvDir("compose-contract-map-rename-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content-new", description: "Main content" }] }, null, 2), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "type-schemas", "article-new.schema.json"), + JSON.stringify( + { + alias: "article-new", + description: "Article schema", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify( + { + functions: [ + { + alias: "map-content-new", + description: "Maps content", + scriptFile: "functions/ingestion/map-content-new.js" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion", "map-content-new.js"), + "export default (x) => x;", + "utf8" + ); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { + documents: [ + { + alias: "doc-new", + description: "Main query", + queryFile: "graphql/persisted/doc-new.gql" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-new.gql"), "query { ping }", "utf8"); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "hook-new", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content-new"], + typeSchemaAliases: ["article-new"], + customHeaders: { authorization: "Bearer abc" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + const oldFetch = globalThis.fetch; + const calls: CapturedCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + let body: unknown; + if (typeof init?.body === "string") { + body = JSON.parse(init.body); + } else if (init?.body instanceof URLSearchParams) { + body = init.body.toString(); + } + calls.push({ method, pathname: u.pathname, body }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old", description: "Main content" }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/collections/content-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { collectionAlias: "content-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article-old" && method === "GET") { + return jsonResponse(200, { + typeSchemaAlias: "article-old", + description: "Article schema", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { typeSchemaAlias: "article-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "map-content-old" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/map-content-old" && + method === "GET" + ) { + return jsonResponse(200, { + ingestionFunctionAlias: "map-content-old", + description: "Maps content", + script: "export default (x) => x;" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/functions/ingestion/map-content-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { ingestionFunctionAlias: "map-content-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-old" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old" && + method === "GET" + ) { + return jsonResponse(200, { + persistedDocumentAlias: "doc-old", + description: "Main query", + document: "query { ping }" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { persistedDocumentAlias: "doc-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "hook-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old" && method === "GET") { + return jsonResponse(200, { + webhookAlias: "hook-old", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content-new"], + typeSchemaAliases: ["article-new"], + customHeaders: { authorization: "Bearer abc" } + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old/commands/rename" && method === "POST") { + return jsonResponse(200, { webhookAlias: "hook-new" }); + } + + return jsonResponse(200, { edges: [] }); + }) as typeof fetch; + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + globalThis.fetch = oldFetch; + } + + const params = { projectAlias: "proj", environmentAlias: "dev" }; + assertContractCall(contracts, "collectionRename", calls, { + ...params, + collectionAlias: "content-old" + }); + assertContractCall(contracts, "typeSchemaRename", calls, { + ...params, + typeSchemaAlias: "article-old" + }); + assertContractCall(contracts, "ingestionFunctionRename", calls, { + ...params, + ingestionFunctionAlias: "map-content-old" + }); + assertContractCall(contracts, "persistedDocumentRename", calls, { + ...params, + persistedDocumentAlias: "doc-old" + }); + assertContractCall(contracts, "webhookRename", calls, { + ...params, + webhookAlias: "hook-old" + }); +}); diff --git a/test/contracts.apply-openapi-shape.test.ts b/test/contracts.apply-openapi-shape.test.ts new file mode 100644 index 0000000..09a9981 --- /dev/null +++ b/test/contracts.apply-openapi-shape.test.ts @@ -0,0 +1,1416 @@ +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 { applyEnvironment } from "../src/compose/apply.js"; + +type FetchCall = { + method: string; + url: string; + bodyText?: string; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function mkEnvDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +function installFetchMock(handler: (u: URL, method: string, bodyText?: string) => Response) { + const calls: FetchCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + let bodyText: string | undefined; + if (typeof init?.body === "string") bodyText = init.body; + if (init?.body instanceof URLSearchParams) bodyText = init.body.toString(); + calls.push({ method, url, bodyText }); + + const u = new URL(url); + if (u.pathname === "/v1/auth/token") { + return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + } + return handler(u, method, bodyText); + }) as typeof fetch; + + return { + calls, + restore: () => { + globalThis.fetch = oldFetch; + } + }; +} + +test("environment create uses expected endpoint and payload shape", async () => { + const envDir = await mkEnvDir("compose-contract-create-env-"); + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { environmentAlias: "dev" }); + } + return jsonResponse(404, { error: "unexpected path" }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => c.method === "POST" && c.url.includes("/v1/projects/proj/environments") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.environmentAlias, "dev"); + assert.equal(body.description, "Development"); +}); + +test("collection create uses expected endpoint and payload shape", async () => { + const envDir = await mkEnvDir("compose-contract-collection-create-"); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content", description: "Main content" }] }, null, 2), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { collectionAlias: "content" }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => c.method === "POST" && c.url.includes("/v1/projects/proj/environments/dev/collections") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.collectionAlias, "content"); + assert.equal(body.description, "Main content"); +}); + +test("collection update uses expected update-description command payload", async () => { + const envDir = await mkEnvDir("compose-contract-collection-update-desc-"); + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content", description: "New description" }] }, null, 2), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content", description: "Old description" }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/collections/content/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const updateCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/collections/content/commands/update-description") + ); + assert.ok(updateCall); + const body = JSON.parse(updateCall.bodyText ?? "{}") as Record; + assert.equal(body.newDescription, "New description"); +}); + +test("collection delete uses expected endpoint", async () => { + const envDir = await mkEnvDir("compose-contract-collection-delete-"); + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections") { + if (method === "GET") return jsonResponse(200, { edges: [{ node: { collectionAlias: "content" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const deleteCall = calls.find( + (c) => c.method === "DELETE" && c.url.includes("/v1/projects/proj/environments/dev/collections/content") + ); + assert.ok(deleteCall); +}); + +test("environment rename uses expected endpoint and payload key", async () => { + const envDir = await mkEnvDir("compose-contract-rename-env-"); + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "old-dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/old-dev/commands/rename" && method === "POST") { + return jsonResponse(200, { environmentAlias: "dev" }); + } + return jsonResponse(404, { error: "unexpected path" }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + remoteEnvAlias: "old-dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const renameCall = calls.find( + (c) => c.method === "POST" && c.url.includes("/v1/projects/proj/environments/old-dev/commands/rename") + ); + assert.ok(renameCall); + const body = JSON.parse(renameCall.bodyText ?? "{}") as Record; + assert.deepEqual(body, { newEnvironmentAlias: "dev" }); +}); + +test("persisted-document create uses expected payload field 'document'", async () => { + const envDir = await mkEnvDir("compose-contract-persisted-doc-"); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { documents: [{ alias: "doc-1", description: "desc", queryFile: "graphql/persisted/doc-1.gql" }] }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-1.gql"), "query { ping }", "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { persistedDocumentAlias: "doc-1" }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes("/v1/projects/proj/environments/dev/graphql/persisted-documents") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.persistedDocumentAlias, "doc-1"); + assert.equal(body.description, "desc"); + assert.equal(body.document, "query { ping }"); + assert.equal("query" in body, false); +}); + +test("persisted-document create sends string description when local description is omitted", async () => { + const envDir = await mkEnvDir("compose-contract-persisted-doc-missing-desc-"); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { documents: [{ alias: "doc-2", queryFile: "graphql/persisted/doc-2.gql" }] }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-2.gql"), "query { pong }", "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { persistedDocumentAlias: "doc-2" }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes("/v1/projects/proj/environments/dev/graphql/persisted-documents") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.persistedDocumentAlias, "doc-2"); + assert.equal(body.description, ""); + assert.equal(body.document, "query { pong }"); +}); + +test("persisted-document update uses expected update-document and update-description commands", async () => { + const envDir = await mkEnvDir("compose-contract-persisted-doc-update-"); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { + documents: [ + { + alias: "doc-3", + description: "New description", + queryFile: "graphql/persisted/doc-3.gql" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-3.gql"), "query { newDoc }", "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-3" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-3") { + return jsonResponse(200, { + persistedDocumentAlias: "doc-3", + description: "Old description", + document: "query { oldDoc }" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-3/commands/update-document" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-3/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const updateDocumentCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-3/commands/update-document") + ); + assert.ok(updateDocumentCall); + const updateDocumentBody = JSON.parse(updateDocumentCall.bodyText ?? "{}") as Record; + assert.equal(updateDocumentBody.newDocument, "query { newDoc }"); + + const updateDescriptionCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes( + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-3/commands/update-description" + ) + ); + assert.ok(updateDescriptionCall); + const updateDescriptionBody = JSON.parse(updateDescriptionCall.bodyText ?? "{}") as Record; + assert.equal(updateDescriptionBody.newDescription, "New description"); +}); + +test("persisted-document delete uses expected endpoint", async () => { + const envDir = await mkEnvDir("compose-contract-persisted-doc-delete-"); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile(path.join(envDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-4" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-4" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const deleteCall = calls.find( + (c) => + c.method === "DELETE" && + c.url.includes("/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-4") + ); + assert.ok(deleteCall); +}); + +test("ingestion-function create uses expected endpoint and payload shape", async () => { + const envDir = await mkEnvDir("compose-contract-ingestion-create-"); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify( + { + functions: [ + { + alias: "map-content", + description: "Maps content", + scriptFile: "functions/ingestion/map-content.js" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion", "map-content.js"), + "export default function(x){ return x; }", + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { ingestionFunctionAlias: "map-content" }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => c.method === "POST" && c.url.includes("/v1/projects/proj/environments/dev/functions/ingestion") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.ingestionFunctionAlias, "map-content"); + assert.equal(body.description, "Maps content"); + assert.equal(typeof body.script, "string"); +}); + +test("ingestion-function update uses expected update-script and update-description commands", async () => { + const envDir = await mkEnvDir("compose-contract-ingestion-update-"); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify( + { + functions: [ + { + alias: "map-content", + description: "New description", + scriptFile: "functions/ingestion/map-content.js" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion", "map-content.js"), + "export default function(input){ return input; }", + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "map-content" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/map-content") { + return jsonResponse(200, { + ingestionFunctionAlias: "map-content", + description: "Old description", + script: "export default () => null;" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/functions/ingestion/map-content/commands/update-script" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/functions/ingestion/map-content/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const updateScriptCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/functions/ingestion/map-content/commands/update-script") + ); + assert.ok(updateScriptCall); + const updateScriptBody = JSON.parse(updateScriptCall.bodyText ?? "{}") as Record; + assert.equal(typeof updateScriptBody.script, "string"); + + const updateDescriptionCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes( + "/v1/projects/proj/environments/dev/functions/ingestion/map-content/commands/update-description" + ) + ); + assert.ok(updateDescriptionCall); + const updateDescriptionBody = JSON.parse(updateDescriptionCall.bodyText ?? "{}") as Record; + assert.equal(updateDescriptionBody.newDescription, "New description"); +}); + +test("ingestion-function delete uses expected endpoint", async () => { + const envDir = await mkEnvDir("compose-contract-ingestion-delete-"); + await fs.mkdir(path.join(envDir, "functions"), { recursive: true }); + await fs.writeFile(path.join(envDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "legacy-ingest" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/legacy-ingest" && + method === "DELETE" + ) { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const deleteCall = calls.find( + (c) => + c.method === "DELETE" && + c.url.includes("/v1/projects/proj/environments/dev/functions/ingestion/legacy-ingest") + ); + assert.ok(deleteCall); +}); + +test("webhook create uses expected endpoint and payload shape", async () => { + const envDir = await mkEnvDir("compose-contract-webhook-create-"); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "send-on-create", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["article"], + customHeaders: { "x-api-key": "abc123" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks") { + if (method === "GET") return jsonResponse(200, { edges: [] }); + if (method === "POST") return jsonResponse(201, { webhookAlias: "send-on-create" }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => c.method === "POST" && c.url.includes("/v1/projects/proj/environments/dev/webhooks") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.webhookAlias, "send-on-create"); + assert.equal(body.url, "https://example.com/hook"); + assert.deepEqual(body.eventTypes, ["content.ingested"]); + assert.deepEqual(body.collectionAliases, ["content"]); + assert.deepEqual(body.typeSchemaAliases, ["article"]); + assert.deepEqual(body.customHeaders, { "x-api-key": "abc123" }); +}); + +test("webhook update uses expected command endpoints and payload shapes", async () => { + const envDir = await mkEnvDir("compose-contract-webhook-update-"); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "send-on-create", + description: "New webhook description", + url: "https://example.com/new", + eventTypes: ["content.deleted"], + collectionAliases: ["articles"], + typeSchemaAliases: ["article"], + customHeaders: { authorization: "Bearer abc" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "send-on-create" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create" && method === "GET") { + return jsonResponse(200, { + webhookAlias: "send-on-create", + description: "Old webhook description", + url: "https://example.com/old", + eventTypes: ["content.ingested"], + collectionAliases: ["content"], + typeSchemaAliases: ["software"], + customHeaders: { authorization: "Bearer old" } + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-url" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-event-types" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-collections" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-type-schemas" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-headers" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const updateDescriptionCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-description") + ); + assert.ok(updateDescriptionCall); + assert.deepEqual(JSON.parse(updateDescriptionCall.bodyText ?? "{}"), { newDescription: "New webhook description" }); + + const updateUrlCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-url") + ); + assert.ok(updateUrlCall); + assert.deepEqual(JSON.parse(updateUrlCall.bodyText ?? "{}"), { url: "https://example.com/new" }); + + const updateEventTypesCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-event-types") + ); + assert.ok(updateEventTypesCall); + assert.deepEqual(JSON.parse(updateEventTypesCall.bodyText ?? "{}"), { eventTypes: ["content.deleted"] }); + + const updateCollectionsCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-collections") + ); + assert.ok(updateCollectionsCall); + assert.deepEqual(JSON.parse(updateCollectionsCall.bodyText ?? "{}"), { collectionAliases: ["articles"] }); + + const updateTypeSchemasCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-type-schemas") + ); + assert.ok(updateTypeSchemasCall); + assert.deepEqual(JSON.parse(updateTypeSchemasCall.bodyText ?? "{}"), { typeSchemaAliases: ["article"] }); + + const updateHeadersCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create/commands/update-headers") + ); + assert.ok(updateHeadersCall); + assert.deepEqual(JSON.parse(updateHeadersCall.bodyText ?? "{}"), { + newHeaders: { authorization: "Bearer abc" } + }); +}); + +test("webhook delete uses expected endpoint", async () => { + const envDir = await mkEnvDir("compose-contract-webhook-delete-"); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "send-on-create" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/send-on-create" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const deleteCall = calls.find( + (c) => c.method === "DELETE" && c.url.includes("/v1/projects/proj/environments/dev/webhooks/send-on-create") + ); + assert.ok(deleteCall); +}); + +test("type-schema update uses raw schema body at update-schema command endpoint", async () => { + const envDir = await mkEnvDir("compose-contract-type-schema-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + + const desiredSchema = { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { name: { type: "string" } } + }; + + await fs.writeFile( + path.join(envDir, "type-schemas", "article.schema.json"), + JSON.stringify( + { + alias: "article", + description: "Article", + schema: desiredSchema + }, + null, + 2 + ), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method, bodyText) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article" && method === "GET") { + return jsonResponse(200, { + typeSchemaAlias: "article", + description: "Article", + schema: { ...desiredSchema, properties: { title: { type: "string" } } } + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article/commands/update-schema" && + method === "PUT" + ) { + const parsed = JSON.parse(bodyText ?? "{}"); + return jsonResponse(200, parsed); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const updateCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/type-schemas/article/commands/update-schema") + ); + assert.ok(updateCall); + const body = JSON.parse(updateCall.bodyText ?? "{}") as Record; + assert.deepEqual(body, desiredSchema); + assert.equal("schema" in body, false); +}); + +test("type-schema update uses expected update-description command payload", async () => { + const envDir = await mkEnvDir("compose-contract-type-schema-update-desc-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + + const desiredSchema = { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { name: { type: "string" } } + }; + + await fs.writeFile( + path.join(envDir, "type-schemas", "article.schema.json"), + JSON.stringify( + { + alias: "article", + description: "New description", + schema: desiredSchema + }, + null, + 2 + ), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article" && method === "GET") { + return jsonResponse(200, { + typeSchemaAlias: "article", + description: "Old description", + schema: desiredSchema + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article/commands/update-description" && + method === "PUT" + ) { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const updateCall = calls.find( + (c) => + c.method === "PUT" && + c.url.includes("/v1/projects/proj/environments/dev/type-schemas/article/commands/update-description") + ); + assert.ok(updateCall); + const body = JSON.parse(updateCall.bodyText ?? "{}") as Record; + assert.equal(body.newDescription, "New description"); +}); + +test("type-schema create uses expected endpoint and payload shape", async () => { + const envDir = await mkEnvDir("compose-contract-type-schema-create-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + + const desiredSchema = { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { name: { type: "string" } } + }; + + await fs.writeFile( + path.join(envDir, "type-schemas", "software.schema.json"), + JSON.stringify( + { + alias: "software", + description: "Software schema", + schema: desiredSchema + }, + null, + 2 + ), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/software" && method === "GET") { + return jsonResponse(404, { error: "not found" }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "POST") { + return jsonResponse(201, { typeSchemaAlias: "software" }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const createCall = calls.find( + (c) => c.method === "POST" && c.url.includes("/v1/projects/proj/environments/dev/type-schemas") + ); + assert.ok(createCall); + const body = JSON.parse(createCall.bodyText ?? "{}") as Record; + assert.equal(body.typeSchemaAlias, "software"); + assert.equal(body.description, "Software schema"); + assert.deepEqual(body.schema, desiredSchema); +}); + +test("type-schema delete uses expected endpoint", async () => { + const envDir = await mkEnvDir("compose-contract-type-schema-delete-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "legacy-schema" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/legacy-schema" && method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const deleteCall = calls.find( + (c) => + c.method === "DELETE" && + c.url.includes("/v1/projects/proj/environments/dev/type-schemas/legacy-schema") + ); + assert.ok(deleteCall); +}); + +test("non-environment rename commands use expected endpoints and payload keys", async () => { + const envDir = await mkEnvDir("compose-contract-entity-rename-"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + await fs.writeFile( + path.join(envDir, "collections.json"), + JSON.stringify({ collections: [{ alias: "content-new", description: "Main content" }] }, null, 2), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "type-schemas", "article-new.schema.json"), + JSON.stringify( + { + alias: "article-new", + description: "Article schema", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify( + { + functions: [ + { + alias: "map-content-new", + description: "Maps content", + scriptFile: "functions/ingestion/map-content-new.js" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(envDir, "functions", "ingestion", "map-content-new.js"), + "export default (x) => x;", + "utf8" + ); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify( + { + documents: [ + { + alias: "doc-new", + description: "Main query", + queryFile: "graphql/persisted/doc-new.gql" + } + ] + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile(path.join(envDir, "graphql", "persisted", "doc-new.gql"), "query { ping }", "utf8"); + await fs.writeFile( + path.join(envDir, "webhooks.json"), + JSON.stringify( + { + webhooks: [ + { + alias: "hook-new", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content-new"], + typeSchemaAliases: ["article-new"], + customHeaders: { authorization: "Bearer abc" } + } + ] + }, + null, + 2 + ), + "utf8" + ); + + const { calls, restore } = installFetchMock((u, method) => { + if (u.pathname === "/v1/projects/proj/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/collections" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/collections/content-old" && method === "GET") { + return jsonResponse(200, { collectionAlias: "content-old", description: "Main content" }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/collections/content-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { collectionAlias: "content-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article-old" && method === "GET") { + return jsonResponse(200, { + typeSchemaAlias: "article-old", + description: "Article schema", + schema: { + $schema: "https://umbracocompose.com/v1/schema", + allOf: [{ $ref: "https://umbracocompose.com/v1/node" }], + properties: { title: { type: "string" } } + } + }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/type-schemas/article-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { typeSchemaAlias: "article-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "map-content-old" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/functions/ingestion/map-content-old" && + method === "GET" + ) { + return jsonResponse(200, { + ingestionFunctionAlias: "map-content-old", + description: "Maps content", + script: "export default (x) => x;" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/functions/ingestion/map-content-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { ingestionFunctionAlias: "map-content-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-old" } }] }); + } + if ( + u.pathname === "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old" && + method === "GET" + ) { + return jsonResponse(200, { + persistedDocumentAlias: "doc-old", + description: "Main query", + document: "query { ping }" + }); + } + if ( + u.pathname === + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old/commands/rename" && + method === "POST" + ) { + return jsonResponse(200, { persistedDocumentAlias: "doc-new" }); + } + + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks" && method === "GET") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "hook-old" } }] }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old" && method === "GET") { + return jsonResponse(200, { + webhookAlias: "hook-old", + description: "Notify hook", + url: "https://example.com/hook", + eventTypes: ["content.ingested"], + collectionAliases: ["content-new"], + typeSchemaAliases: ["article-new"], + customHeaders: { authorization: "Bearer abc" } + }); + } + if (u.pathname === "/v1/projects/proj/environments/dev/webhooks/hook-old/commands/rename" && method === "POST") { + return jsonResponse(200, { webhookAlias: "hook-new" }); + } + + return jsonResponse(200, { edges: [] }); + }); + + try { + await applyEnvironment({ + project: "proj", + env: "dev", + envDir, + envDescription: "Development", + baseUrl: "https://management.example", + oauth: { clientId: "client", clientSecret: "secret" }, + planOnly: false + }); + } finally { + restore(); + } + + const collectionRenameCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes("/v1/projects/proj/environments/dev/collections/content-old/commands/rename") + ); + assert.ok(collectionRenameCall); + assert.deepEqual(JSON.parse(collectionRenameCall.bodyText ?? "{}"), { newCollectionAlias: "content-new" }); + + const typeSchemaRenameCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes("/v1/projects/proj/environments/dev/type-schemas/article-old/commands/rename") + ); + assert.ok(typeSchemaRenameCall); + assert.deepEqual(JSON.parse(typeSchemaRenameCall.bodyText ?? "{}"), { newTypeSchemaAlias: "article-new" }); + + const ingestionRenameCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes("/v1/projects/proj/environments/dev/functions/ingestion/map-content-old/commands/rename") + ); + assert.ok(ingestionRenameCall); + assert.deepEqual(JSON.parse(ingestionRenameCall.bodyText ?? "{}"), { + newIngestionFunctionAlias: "map-content-new" + }); + + const persistedRenameCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes( + "/v1/projects/proj/environments/dev/graphql/persisted-documents/doc-old/commands/rename" + ) + ); + assert.ok(persistedRenameCall); + assert.deepEqual(JSON.parse(persistedRenameCall.bodyText ?? "{}"), { + newPersistedDocumentAlias: "doc-new" + }); + + const webhookRenameCall = calls.find( + (c) => + c.method === "POST" && + c.url.includes("/v1/projects/proj/environments/dev/webhooks/hook-old/commands/rename") + ); + assert.ok(webhookRenameCall); + assert.deepEqual(JSON.parse(webhookRenameCall.bodyText ?? "{}"), { newWebhookAlias: "hook-new" }); +}); diff --git a/test/contracts.pull-contract-map.test.ts b/test/contracts.pull-contract-map.test.ts new file mode 100644 index 0000000..c304b63 --- /dev/null +++ b/test/contracts.pull-contract-map.test.ts @@ -0,0 +1,249 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import YAML from "yaml"; +import { pullCommand } from "../src/commands/pull.js"; +import { createInitialState, readComposeState, writeComposeState } from "../src/compose/state.js"; + +type ContractMap = { + version: number; + operations: Record< + string, + { + method: string; + pathTemplate: string; + } + >; +}; + +type CapturedCall = { + method: string; + pathname: string; +}; + +type ComposeConfig = { + version: number; + project: string; + environments: Record; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +async function readContractMap(): Promise { + const filePath = path.resolve(process.cwd(), "docs/contracts/pull-contract.json"); + const text = await fs.readFile(filePath, "utf8"); + return JSON.parse(text) as ContractMap; +} + +function resolvePath(template: string, params: Record): string { + let out = template; + for (const [k, v] of Object.entries(params)) { + out = out.replaceAll(`{${k}}`, v); + } + return out; +} + +function assertContractCall( + contracts: ContractMap, + operation: keyof ContractMap["operations"], + calls: CapturedCall[], + params: Record +): void { + const contract = contracts.operations[operation]; + assert.ok(contract, `Missing contract entry for ${operation}`); + const expectedPath = resolvePath(contract.pathTemplate, params); + const call = calls.find((c) => c.method === contract.method && c.pathname === expectedPath); + assert.ok(call, `Missing call for ${operation}: ${contract.method} ${expectedPath}`); +} + +async function createFixture(): Promise<{ root: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-pull-contract-")); + const envDir = path.join(root, "env", "dev"); + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await writeComposeState(root, createInitialState("test-project", ["dev"])); + return { root }; +} + +test("pull emitted read calls match pull contract map", async () => { + const contracts = await readContractMap(); + const { root } = await createFixture(); + const calls: CapturedCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + calls.push({ method, pathname: u.pathname }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") return jsonResponse(200, { edges: [] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") return jsonResponse(200, { typeSchemaAlias: "article", schema: {}, description: null }); + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "fn-a" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion/fn-a") return jsonResponse(200, { ingestionFunctionAlias: "fn-a", script: "export default () => null;" }); + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-a" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents/doc-a") return jsonResponse(200, { persistedDocumentAlias: "doc-a", document: "query { __typename }" }); + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") return jsonResponse(200, { edges: [{ node: { webhookAlias: "hook-a" } }] }); + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks/hook-a") return jsonResponse(200, { webhookAlias: "hook-a", url: "https://example.com/hook", eventTypes: [], collectionAliases: [], typeSchemaAliases: [], customHeaders: {} }); + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await pullCommand.handler({ + dir: root, + env: "dev", + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const baseParams = { + projectAlias: "test-project", + environmentAlias: "dev", + typeSchemaAlias: "article", + ingestionFunctionAlias: "fn-a", + persistedDocumentAlias: "doc-a", + webhookAlias: "hook-a" + }; + + assertContractCall(contracts, "environmentList", calls, baseParams); + assertContractCall(contracts, "collectionList", calls, baseParams); + assertContractCall(contracts, "typeSchemaList", calls, baseParams); + assertContractCall(contracts, "typeSchemaGet", calls, baseParams); + assertContractCall(contracts, "ingestionFunctionList", calls, baseParams); + assertContractCall(contracts, "ingestionFunctionGet", calls, baseParams); + assertContractCall(contracts, "persistedDocumentList", calls, baseParams); + assertContractCall(contracts, "persistedDocumentGet", calls, baseParams); + assertContractCall(contracts, "webhookList", calls, baseParams); + assertContractCall(contracts, "webhookGet", calls, baseParams); +}); + +test("pull contract-map calls use state remoteAlias path when local alias differs", async () => { + const contracts = await readContractMap(); + const { root } = await createFixture(); + + const composePath = path.join(root, "umbraco-compose.yaml"); + const cfgText = await fs.readFile(composePath, "utf8"); + const cfg = YAML.parse(cfgText) as ComposeConfig; + cfg.environments = { + "dev-renamed": { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + }; + await fs.writeFile(composePath, YAML.stringify(cfg), "utf8"); + + const state = await readComposeState(root); + assert.ok(state); + state.environments[0].alias = "dev-renamed"; + state.environments[0].remoteAlias = "dev"; + await writeComposeState(root, state); + + const calls: CapturedCall[] = []; + const oldFetch = globalThis.fetch; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const u = new URL(url); + calls.push({ method, pathname: u.pathname }); + + if (u.pathname === "/v1/auth/token") return jsonResponse(200, { access_token: "token", expires_in: 3600 }); + if (u.pathname === "/v1/projects/test-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas") { + return jsonResponse(200, { edges: [{ node: { typeSchemaAlias: "article" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/type-schemas/article") { + return jsonResponse(200, { typeSchemaAlias: "article", schema: {}, description: null }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion") { + return jsonResponse(200, { edges: [{ node: { ingestionFunctionAlias: "fn-a" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/functions/ingestion/fn-a") { + return jsonResponse(200, { ingestionFunctionAlias: "fn-a", script: "export default () => null;" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents") { + return jsonResponse(200, { edges: [{ node: { persistedDocumentAlias: "doc-a" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/graphql/persisted-documents/doc-a") { + return jsonResponse(200, { persistedDocumentAlias: "doc-a", document: "query { __typename }" }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks") { + return jsonResponse(200, { edges: [{ node: { webhookAlias: "hook-a" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/webhooks/hook-a") { + return jsonResponse(200, { + webhookAlias: "hook-a", + url: "https://example.com/hook", + eventTypes: [], + collectionAliases: [], + typeSchemaAliases: [], + customHeaders: {} + }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await pullCommand.handler({ + dir: root, + env: "dev-renamed", + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const baseParams = { + projectAlias: "test-project", + environmentAlias: "dev", + typeSchemaAlias: "article", + ingestionFunctionAlias: "fn-a", + persistedDocumentAlias: "doc-a", + webhookAlias: "hook-a" + }; + + assertContractCall(contracts, "environmentList", calls, baseParams); + assertContractCall(contracts, "collectionList", calls, baseParams); + assertContractCall(contracts, "typeSchemaList", calls, baseParams); + assertContractCall(contracts, "typeSchemaGet", calls, baseParams); + assertContractCall(contracts, "ingestionFunctionList", calls, baseParams); + assertContractCall(contracts, "ingestionFunctionGet", calls, baseParams); + assertContractCall(contracts, "persistedDocumentList", calls, baseParams); + assertContractCall(contracts, "persistedDocumentGet", calls, baseParams); + assertContractCall(contracts, "webhookList", calls, baseParams); + assertContractCall(contracts, "webhookGet", calls, baseParams); +}); diff --git a/tsconfig.json b/tsconfig.json index a666cd2..eb40821 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ // See also https://aka.ms/tsconfig/module "module": "nodenext", "target": "esnext", - "types": [], + // "types": [], // For nodejs: // "lib": ["esnext"], "types": ["node"], @@ -39,6 +39,8 @@ "isolatedModules": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", - "skipLibCheck": true, - } + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "test", "docs", ".github", "tmp-compose", "node_modules"] }