Skip to content
Merged
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,38 @@

All notable changes to this project are documented in this file.

## [1.0.0-rc.3] - 2026-02-14

### Added
- Added a top-level `README.md` with:
- project overview and IaC workflow summary
- quick-start paths for new and existing remote projects
- auth requirements for API-backed commands
- command overview and directory-default behavior
- links to detailed docs (`commands`, `workflow`, `ci`, `testing`, release checklist)

## [1.0.0-rc.2] - 2026-02-14

### Added
- `env add-remote <alias>` command for adopting an existing remote environment into local `umbraco-compose.yaml` and `compose.state.json`, with optional immediate `--pull`.

### Changed
- `pull` now supports multi-target execution via `--env <a,b,...>` and `--allEnvs`.
- `env list-remote` now supports project resolution from `--project` or `--dir` (`umbraco-compose.yaml`), with mismatch validation when both are supplied.
- `env list-remote` output now indicates local adoption state:
- human output markers: `[local]` and `[remote-only]`
- JSON output includes `inLocalManifest`.
- Local-state command directory defaults were standardized to current directory (`--dir .`) for in-repo workflows.
- `env` command routing was normalized under a proper parent command tree (`env ...`) to avoid subcommand ambiguity.

### Fixed
- Lock metadata CLI version detection now resolves package version from the CLI module path instead of `process.cwd()`, so `compose.lock.json` records `lastApplied.cliVersion` correctly when running inside compose project directories.

## [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`
- `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`, `env list-remote`, `env add-remote`
- Broad automated test coverage for:
- command behavior and exit-code semantics
- multi-environment plan/apply flows
Expand All @@ -24,7 +51,13 @@ All notable changes to this project are documented in this file.
### 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.
- `pull` now supports explicit multi-env pulls (`--env dev,prod`) and all-configured-env pulls (`--allEnvs`).
- new `env add-remote` command adopts existing remote aliases into local yaml/state, with optional immediate `--pull`.
- `env list-remote` now supports project inference from `--dir` compose config, with explicit-project mismatch guardrails.
- `env list-remote` now marks aliases as `[local]` or `[remote-only]`, and JSON output includes `inLocalManifest`.
- local-state commands now default `--dir` to current directory (`.`) to streamline in-repo workflows after `cd`.
- `apply` performs environment rename first, then applies nested resources through the new alias.
- `clone` now supports explicit multi-env cloning (`--env dev,prod`) and full remote discovery cloning (`--allEnvs`).
- 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
Expand Down
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Umbraco Compose CLI

A CLI for managing Umbraco Compose environments with an IaC-style workflow.

It helps teams:
- scaffold and organize local Compose project files
- validate local configuration before API calls
- preview remote changes with `plan`
- apply changes safely in CI/CD
- keep environment identity stable across renames

## Core Workflow

1. Create or clone a Compose project.
2. Edit local files in source control.
3. Run `validate` and `plan`.
4. Run `apply` in a guarded pipeline.

## Quick Start

### Start New

```bash
node dist/index.js init --dir ./compose --project <project-alias> --env dev
cd ./compose
node ../dist/index.js validate --strict
node ../dist/index.js plan
```

### Start From Existing Remote

```bash
node dist/index.js clone --dir ./compose --project <project-alias> --allEnvs
cd ./compose
node ../dist/index.js validate --strict
node ../dist/index.js plan
```

If you cloned a subset of environments and later need another:

```bash
node ../dist/index.js env add-remote <env-alias> --pull
```

## Authentication

Commands that call the management API require OAuth client credentials.

Required:
- `COMPOSE_MGMT_CLIENT_ID`
- `COMPOSE_MGMT_CLIENT_SECRET`

Optional:
- `COMPOSE_MGMT_SCOPE`
- `COMPOSE_MGMT_AUDIENCE`

You can also pass `--clientId`, `--clientSecret`, `--scope`, and `--audience` on commands that support them.

## Command Overview

- `compose init`
- `compose clone`
- `compose generate <entity> <alias>`
- `compose validate`
- `compose pull`
- `compose status`
- `compose plan`
- `compose apply`
- `compose env rename <from> <to>`
- `compose env list-remote`
- `compose env add-remote <alias>`

Directory defaults:
- Local-state commands default `--dir` to current directory (`.`).
- Bootstrap commands (`init`, `clone`) default `--dir` to `./compose`.

## CI/CD Model

- PR CI: `build`, `test`, `validate`, `plan`
- Deploy workflow: guarded `apply` (manual/protected branch + environment approvals)

See:
- `docs/commands.md`
- `docs/workflow.md`
- `docs/ci.md`
- `docs/testing.md`
- `docs/release-v1-checklist.md`
49 changes: 47 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This document defines the intended command behavior for the core workflow:
- `compose validate`
- `compose clone`
- `compose pull`
- `compose env add-remote`
- `compose env list-remote`
- `compose status`
- `compose plan`
- `compose apply`
Expand All @@ -18,7 +20,9 @@ Related docs:
- `docs/ci.md` (CI/deploy setup and protections)

## Shared Behavior
- Compose project root: defaults to `./compose` and must contain `umbraco-compose.yaml`.
- Compose project root:
- local-state commands default `--dir` to current directory (`.`), which must contain `umbraco-compose.yaml`
- bootstrap commands (`init`, `clone`) still default `--dir` to `./compose`
- Exit codes:
- `0`: success
- `1`: validation failure, planning/apply warnings, or runtime/config errors
Expand All @@ -44,6 +48,7 @@ Use this baseline pipeline for pull-request validation and main-branch deploy:
6. On approved/protected branch only: `node dist/index.js apply --dir ./compose`

Notes:
- CI examples keep explicit `--dir ./compose` because workflows usually run from repository root, while the compose project lives in a subdirectory.
- 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
Expand Down Expand Up @@ -109,6 +114,9 @@ Alias routing semantics:
- Responsibility:
- fetch remote state and materialize files in local format
- update `compose.state.json` to track remote alias for pulled environment
- Scope:
- `--env <alias[,alias...]>` pulls one or more configured local environments (defaults to `dev`)
- `--allEnvs` pulls all environments declared in `umbraco-compose.yaml`
- Implemented pull scope:
- collections
- type schemas
Expand All @@ -129,10 +137,47 @@ Alias routing semantics:
- Responsibility:
- bootstrap a local compose directory from remote project/env state
- scaffold minimal `umbraco-compose.yaml`
- execute `pull` for the selected environment
- execute `pull` for selected environment(s)
- Scope:
- `--env <alias[,alias...]>` clones one or more explicit environments (defaults to `dev`)
- `--allEnvs` discovers remote aliases and clones all of them
- Side effects: writes local project files and pulled entity files.
- Idempotency: repeated clone to same non-empty directory requires `--force`.

### `compose env list-remote`
- Status: implemented
- Responsibility:
- list remote environment aliases for a project
- Scope:
- default output is human-readable list with local-manifest markers:
- `[local]` when alias exists in local `umbraco-compose.yaml`
- `[remote-only]` when alias exists remotely but not locally
- `--json` emits machine-readable payload:
- `environments: [{ alias, inLocalManifest }]`
- project resolution: `--project` takes precedence; otherwise project is inferred from `--dir/umbraco-compose.yaml`
- Notes:
- when both `--project` and `--dir` project are present, a mismatch fails fast with a clear error
- Side effects: none (remote read-only call).
- Idempotency: fully idempotent.

### `compose env add-remote <alias>`
- Status: implemented
- Responsibility:
- adopt an existing remote environment into local `umbraco-compose.yaml` and `compose.state.json`
- optional immediate sync via `--pull`
- Notes:
- verifies the target alias exists remotely before mutating local config/state
- sets `compose.state.json.remoteAlias` for the adopted environment
- defaults `managementBaseUrl` from existing local environments unless overridden with `--baseUrl`
- Side effects: local config/state writes, and optional pull file materialization.
- Idempotency: adding an already-present alias fails; rerun is not a no-op by design.

### Directory Default Notes
- Current-directory default (`--dir .`) applies to:
- `generate`, `validate`, `pull`, `status`, `plan`, `apply`, `env rename`, `env add-remote`, `env list-remote` (for project inference)
- Bootstrap defaults (`--dir ./compose`) apply to:
- `init`, `clone`

### `compose status`
- Status: implemented
- Responsibility:
Expand Down
6 changes: 3 additions & 3 deletions docs/release-v1-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Scope Freeze
- Confirm command scope for `v1.0.0`:
- `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`
- `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`, `env list-remote`, `env add-remote`
- Confirm command/behavior docs are current:
- `docs/commands.md`
- `docs/testing.md`
Expand All @@ -13,9 +13,9 @@
- `npm run build` passes.
- `npm test` passes.
- Validate strict mode check passes on a representative project:
- `compose validate --dir ./compose --strict`
- `compose validate --strict`
- Plan check passes on a representative project:
- `compose plan --dir ./compose`
- `compose plan`

## Regression Focus
- Environment identity and alias routing:
Expand Down
18 changes: 18 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
- 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
- verifies pull supports multi-environment explicit selection (`--env dev,prod`)
- verifies pull supports pulling all configured environments (`--allEnvs`)
- verifies pull rejects conflicting targeting flags (`--env` with `--allEnvs`)

- `test/commands.pull-hardening.test.ts`
- verifies `pull` idempotency for unchanged remote responses
Expand All @@ -91,6 +94,21 @@
- `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`
- verifies clone supports multi-environment explicit selection (`--env dev,prod`)
- verifies clone supports remote discovery mode (`--allEnvs`)

- `test/commands.env-list-remote.test.ts`
- verifies remote environment alias listing output
- verifies local-manifest markers in human output (`[local]` / `[remote-only]`)
- verifies `--json` machine-readable output shape (`{ alias, inLocalManifest }`)
- verifies project inference from `--dir` compose config
- verifies mismatch failure when `--project` conflicts with dir-inferred project
- verifies clear failure when neither source can provide project

- `test/commands.env-add-remote.test.ts`
- verifies remote environment adoption updates local yaml/state
- verifies `--pull` option performs immediate file materialization
- verifies local-exists and remote-missing failure paths

- `test/compose.management-client.test.ts`
- verifies first-call `401` triggers one auth invalidation and one retry
Expand Down
37 changes: 22 additions & 15 deletions docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ Use this when the platform already has state and you want to adopt GitOps.

1. Bootstrap locally:
- `compose clone --dir ./compose --project <project> --env <env>`
2. Review/edit generated files:
- or clone multiple/all: `compose clone --dir ./compose --project <project> --env dev,prod` or `--allEnvs`
2. Enter the project directory:
- `cd ./compose`
3. (Optional) If starting shallow, adopt another remote alias:
- `compose env add-remote <env> --pull`
4. Review/edit generated files:
- optional `compose generate <entity>` for new resources
- manual edits in `compose/env/<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:
- manual edits in `env/<env>/...`
5. Validate and preview:
- `compose validate --strict`
- `compose plan`
6. Open PR to canonical repository.
7. CI runs build/tests + validate + plan.
8. After approval/merge, deploy workflow runs:
- `validate -> plan -> apply`

Outcome:
Expand All @@ -26,15 +31,17 @@ Use this when no remote state exists yet.

1. Initialize local project:
- `compose init --dir ./compose --project <project> --env dev`
2. Add resources:
2. Enter the project directory:
- `cd ./compose`
3. Add resources:
- `compose generate <entity> ...`
- 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.
4. Validate and preview:
- `compose validate --strict`
- `compose plan`
5. Push branch + open PR to canonical repository.
6. CI validates and publishes plan output for review.
7. Approved deploy runs `compose apply` to create/update remote state.

Outcome:
- remote state is created from repo-managed IaC files.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "umbraco-compose-cli",
"version": "1.0.0-rc.1",
"version": "1.0.0-rc.3",
"type": "module",
"description": "",
"bin": {
Expand Down
12 changes: 8 additions & 4 deletions src/commands/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { CommandModule } from "yargs";
import YAML from "yaml";
import { applyEnvironment } from "../compose/apply.js";
Expand Down Expand Up @@ -46,8 +47,8 @@ export const applyCommand: CommandModule<{}, Args> = {
builder: {
dir: {
type: "string",
default: "./compose",
describe: "Root Compose IaC directory (contains umbraco-compose.yaml)"
default: ".",
describe: "Root Compose IaC directory (current directory by default; contains umbraco-compose.yaml)"
},
env: {
type: "string",
Expand Down Expand Up @@ -279,9 +280,12 @@ function printChange(name: string, before: string | undefined, after: string) {

function getCliVersionSafe(): string {
try {
const pkgPath = path.resolve(process.cwd(), "package.json");
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const pkgPath = path.resolve(moduleDir, "../../package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string };
return pkg.version;
return typeof pkg.version === "string" && pkg.version.length > 0
? pkg.version
: "Unknown Package Version";
} catch {
return "Unknown Package Version";
}
Expand Down
Loading