From 51f284f0e0de0b7448b4db998c1f375d01bca5b2 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Fri, 13 Feb 2026 12:37:41 -0500 Subject: [PATCH 01/10] Allow cloning multiple environments and listing remote environments --- CHANGELOG.md | 3 +- docs/commands.md | 16 +++- docs/release-v1-checklist.md | 2 +- docs/testing.md | 6 ++ docs/workflow.md | 1 + src/commands/clone.ts | 110 ++++++++++++++++++---- src/commands/env-list-remote.ts | 88 ++++++++++++++++++ src/commands/pull.ts | 6 +- src/index.ts | 2 + test/commands.clone.test.ts | 129 ++++++++++++++++++++++++++ test/commands.env-list-remote.test.ts | 85 +++++++++++++++++ 11 files changed, 422 insertions(+), 26 deletions(-) create mode 100644 src/commands/env-list-remote.ts create mode 100644 test/commands.env-list-remote.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0c83b..56ea234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project are documented in this file. ### 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` - Broad automated test coverage for: - command behavior and exit-code semantics - multi-environment plan/apply flows @@ -25,6 +25,7 @@ All notable changes to this project are documented in this file. - 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. +- `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 diff --git a/docs/commands.md b/docs/commands.md index 4197ada..22f6f93 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -8,6 +8,7 @@ This document defines the intended command behavior for the core workflow: - `compose validate` - `compose clone` - `compose pull` +- `compose env list-remote` - `compose status` - `compose plan` - `compose apply` @@ -129,10 +130,23 @@ 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 ` 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 + - `--json` emits machine-readable payload +- Side effects: none (remote read-only call). +- Idempotency: fully idempotent. + ### `compose status` - Status: implemented - Responsibility: diff --git a/docs/release-v1-checklist.md b/docs/release-v1-checklist.md index 950fb4a..fdc3508 100644 --- a/docs/release-v1-checklist.md +++ b/docs/release-v1-checklist.md @@ -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` - Confirm command/behavior docs are current: - `docs/commands.md` - `docs/testing.md` diff --git a/docs/testing.md b/docs/testing.md index c155e45..403b067 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -91,6 +91,12 @@ - `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 `--json` machine-readable output shape - `test/compose.management-client.test.ts` - verifies first-call `401` triggers one auth invalidation and one retry diff --git a/docs/workflow.md b/docs/workflow.md index a8b5d09..2e67473 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -7,6 +7,7 @@ Use this when the platform already has state and you want to adopt GitOps. 1. Bootstrap locally: - `compose clone --dir ./compose --project --env ` + - or clone multiple/all: `compose clone --dir ./compose --project --env dev,prod` or `--allEnvs` 2. Review/edit generated files: - optional `compose generate ` for new resources - manual edits in `compose/env//...` diff --git a/src/commands/clone.ts b/src/commands/clone.ts index 9af2504..8985a1e 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -2,12 +2,15 @@ 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"; +import { runPull, listRemoteEnvironmentAliases } from "./pull.js"; +import { ManagementClient } from "../compose/management-client.js"; +import { composeManagementOAuth } from "../compose/auth/providers/oauth-compose.js"; type Args = { dir: string; project: string; - env: string; + env?: string; + allEnvs: boolean; baseUrl: string; clientId?: string; clientSecret?: string; @@ -32,8 +35,12 @@ export const cloneCommand: CommandModule<{}, Args> = { }, env: { type: "string", - default: "dev", - describe: "Environment alias to clone" + describe: "Environment alias(es) to clone (comma-separated). Defaults to dev." + }, + allEnvs: { + type: "boolean", + default: false, + describe: "Clone all remote environments for the project" }, baseUrl: { type: "string", @@ -63,20 +70,30 @@ export const cloneCommand: CommandModule<{}, Args> = { } }, handler: async (args) => { + if (args.allEnvs && args.env) { + throw new Error(`Use either --allEnvs or --env, not both.`); + } + + const envAliases = args.allEnvs + ? await resolveAllRemoteEnvs(args) + : parseEnvAliases(args.env ?? "dev"); + const rootDir = path.resolve(process.cwd(), args.dir); await ensureTargetDir(rootDir, args.force); - await scaffoldCloneRoot(rootDir, args.project, args.env, args.baseUrl); + await scaffoldCloneRoot(rootDir, args.project, envAliases, 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 } : {}) - }); + for (const envAlias of envAliases) { + await runPull({ + dir: rootDir, + env: envAlias, + ...(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}`); + console.log(`Cloned project "${args.project}" env(s) "${envAliases.join(", ")}" into ${rootDir}`); } }; @@ -99,25 +116,78 @@ async function ensureTargetDir(rootDir: string, force: boolean): Promise { async function scaffoldCloneRoot( rootDir: string, project: string, - env: string, + envAliases: string[], baseUrl: string ): Promise { const composeYamlPath = path.join(rootDir, "umbraco-compose.yaml"); const doc = { version: 1, project, - environments: { - [env]: { + environments: Object.fromEntries( + envAliases.map((env) => [ + env, + { dir: `./env/${env}`, description: `${env} Environment`, managementBaseUrl: baseUrl - } - } + } + ]) + ) }; - await fs.mkdir(path.join(rootDir, "env", env), { recursive: true }); + for (const env of envAliases) { + await fs.mkdir(path.join(rootDir, "env", env), { recursive: true }); + } await fs.writeFile(composeYamlPath, YAML.stringify(doc), "utf8"); } +function parseEnvAliases(raw: string): string[] { + const aliases = raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + const unique = Array.from(new Set(aliases)); + if (unique.length === 0) { + throw new Error(`No environments provided. Example: --env dev,prod`); + } + return unique; +} + +async function resolveAllRemoteEnvs(args: Args): Promise { + 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 oauth: { + clientId: string; + clientSecret: string; + scope?: string; + audience?: string; + } = { clientId, clientSecret }; + if (args.scope !== undefined) oauth.scope = args.scope; + if (args.audience !== undefined) oauth.audience = args.audience; + + const client = new ManagementClient({ + baseUrl: args.baseUrl, + auth: composeManagementOAuth({ + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + baseUrl: args.baseUrl, + ...(oauth.scope !== undefined ? { scope: oauth.scope } : {}), + ...(oauth.audience !== undefined ? { audience: oauth.audience } : {}) + }) + }); + + const aliases = await listRemoteEnvironmentAliases(client, args.project); + if (aliases.length === 0) { + throw new Error(`No remote environments were found for project "${args.project}".`); + } + return aliases; +} + async function pathExists(p: string): Promise { try { await fs.stat(p); diff --git a/src/commands/env-list-remote.ts b/src/commands/env-list-remote.ts new file mode 100644 index 0000000..e24e070 --- /dev/null +++ b/src/commands/env-list-remote.ts @@ -0,0 +1,88 @@ +import type { CommandModule } from "yargs"; +import { ManagementClient } from "../compose/management-client.js"; +import { composeManagementOAuth } from "../compose/auth/providers/oauth-compose.js"; +import { listRemoteEnvironmentAliases } from "./pull.js"; + +type Args = { + project: string; + baseUrl: string; + clientId?: string; + clientSecret?: string; + scope?: string; + audience?: string; + json: boolean; +}; + +export const envListRemoteCommand: CommandModule<{}, Args> = { + command: "env list-remote", + describe: "List remote environment aliases for a project", + builder: { + project: { + type: "string", + demandOption: true, + describe: "Remote project alias" + }, + 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" + }, + json: { + type: "boolean", + default: false, + describe: "Emit aliases as JSON" + } + }, + handler: async (args) => { + 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 client = new ManagementClient({ + baseUrl: args.baseUrl, + auth: composeManagementOAuth({ + clientId, + clientSecret, + baseUrl: args.baseUrl, + ...(args.scope !== undefined ? { scope: args.scope } : {}), + ...(args.audience !== undefined ? { audience: args.audience } : {}) + }) + }); + + const aliases = await listRemoteEnvironmentAliases(client, args.project); + if (args.json) { + console.log(JSON.stringify({ project: args.project, environments: aliases }, null, 2)); + return; + } + + if (aliases.length === 0) { + console.log(`No remote environments found for project "${args.project}".`); + return; + } + + console.log(`Remote environments for "${args.project}":`); + for (const alias of aliases) { + console.log(`- ${alias}`); + } + } +}; diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 1f08183..0727fe6 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -377,7 +377,7 @@ async function replaceFile(stagedFile: string, targetFile: string): Promise> { +export 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}).`); @@ -385,7 +385,7 @@ async function listRemoteEnvironmentAliases(client: ManagementClient, project: s 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; + return Array.from(out).sort((a, b) => a.localeCompare(b)); } async function resolveRemoteEnvironmentAlias( @@ -394,7 +394,7 @@ async function resolveRemoteEnvironmentAlias( localAlias: string, stateRemoteAlias?: string ): Promise { - const remoteAliases = await listRemoteEnvironmentAliases(client, project); + const remoteAliases = new Set(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; }); diff --git a/src/index.ts b/src/index.ts index 9be501e..e717408 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ 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"; +import { envListRemoteCommand } from "./commands/env-list-remote.js"; await yargs(hideBin(process.argv)) .scriptName("compose") @@ -23,6 +24,7 @@ import { cloneCommand } from "./commands/clone.js"; .command(applyCommand) .command(statusCommand) .command(envRenameCommand) + .command(envListRemoteCommand) .demandCommand(1, "Try `compose init --help` or `compose validate --help`.") .strict() .help() diff --git a/test/commands.clone.test.ts b/test/commands.clone.test.ts index 7e88b94..3109f6c 100644 --- a/test/commands.clone.test.ts +++ b/test/commands.clone.test.ts @@ -50,6 +50,7 @@ test("clone scaffolds project and pulls remote env state", async () => { dir: targetDir, project: "my-project", env: "dev", + allEnvs: false, baseUrl: "https://management.example", clientId: "client", clientSecret: "secret", @@ -88,6 +89,7 @@ test("clone fails when target directory is non-empty without --force", async () dir: root, project: "my-project", env: "dev", + allEnvs: false, baseUrl: "https://management.example", clientId: "client", clientSecret: "secret", @@ -96,3 +98,130 @@ test("clone fails when target directory is non-empty without --force", async () /target directory is not empty/ ); }); + +test("clone supports comma-separated env aliases", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-clone-multi-")); + 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" } }, { node: { environmentAlias: "prod" } }] + }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "dev-content" } }] }); + } + if (u.pathname === "/v1/projects/my-project/environments/prod/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "prod-content" } }] }); + } + if (u.pathname.endsWith("/type-schemas")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/functions/ingestion")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/graphql/persisted-documents")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/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,prod", + allEnvs: false, + 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 { + environments: Record; + }; + assert.equal(cfg.environments.dev?.dir, "./env/dev"); + assert.equal(cfg.environments.prod?.dir, "./env/prod"); + + const devCollections = JSON.parse(await fs.readFile(path.join(targetDir, "env", "dev", "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + const prodCollections = JSON.parse( + await fs.readFile(path.join(targetDir, "env", "prod", "collections.json"), "utf8") + ) as { collections: Array<{ alias: string }> }; + + assert.equal(devCollections.collections[0]?.alias, "dev-content"); + assert.equal(prodCollections.collections[0]?.alias, "prod-content"); +}); + +test("clone --allEnvs discovers and clones all remote environments", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-clone-all-")); + 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: "stage" } }, { node: { environmentAlias: "dev" } }] + }); + } + if (u.pathname === "/v1/projects/my-project/environments/dev/collections") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname === "/v1/projects/my-project/environments/stage/collections") { + return jsonResponse(200, { edges: [] }); + } + if (u.pathname.endsWith("/type-schemas")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/functions/ingestion")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/graphql/persisted-documents")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/webhooks")) return jsonResponse(200, { edges: [] }); + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await cloneCommand.handler({ + dir: targetDir, + project: "my-project", + allEnvs: true, + 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 { + environments: Record; + }; + assert.deepEqual(Object.keys(cfg.environments).sort(), ["dev", "stage"]); +}); + +test("clone rejects using --env and --allEnvs together", async () => { + await assert.rejects( + () => + cloneCommand.handler({ + dir: "./compose", + project: "my-project", + env: "dev", + allEnvs: true, + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + force: false + }), + /either --allEnvs or --env/ + ); +}); diff --git a/test/commands.env-list-remote.test.ts b/test/commands.env-list-remote.test.ts new file mode 100644 index 0000000..aa69bd8 --- /dev/null +++ b/test/commands.env-list-remote.test.ts @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { envListRemoteCommand } from "../src/commands/env-list-remote.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +test("env list-remote prints aliases", async () => { + const oldFetch = globalThis.fetch; + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((v) => String(v)).join(" ")); + }; + + 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: "prod" } }, { node: { environmentAlias: "dev" } }] + }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await envListRemoteCommand.handler({ + project: "my-project", + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + json: false + }); + } finally { + globalThis.fetch = oldFetch; + console.log = originalLog; + } + + assert.equal(lines[0], 'Remote environments for "my-project":'); + assert.equal(lines.some((line) => line === "- dev"), true); + assert.equal(lines.some((line) => line === "- prod"), true); +}); + +test("env list-remote --json emits machine-readable output", async () => { + const oldFetch = globalThis.fetch; + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((v) => String(v)).join(" ")); + }; + + 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" } }] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await envListRemoteCommand.handler({ + project: "my-project", + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + json: true + }); + } finally { + globalThis.fetch = oldFetch; + console.log = originalLog; + } + + assert.equal(lines.length, 1); + const payload = JSON.parse(lines[0] ?? "{}") as { project: string; environments: string[] }; + assert.equal(payload.project, "my-project"); + assert.deepEqual(payload.environments, ["dev"]); +}); From 54ace9c96b00b32fcf719eb22e788ce4160e7b33 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 10:06:07 -0500 Subject: [PATCH 02/10] Support multi-env pulls --- CHANGELOG.md | 1 + docs/commands.md | 3 + docs/testing.md | 3 + src/commands/pull.ts | 140 ++++++++++++++++++++++++------------- test/commands.pull.test.ts | 137 ++++++++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ea234..eb2ae7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ 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`). - `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. diff --git a/docs/commands.md b/docs/commands.md index 22f6f93..a92606c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -110,6 +110,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 ` 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 diff --git a/docs/testing.md b/docs/testing.md index 403b067..e196143 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 0727fe6..8ab645b 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -14,7 +14,8 @@ import { type Args = { dir: string; - env: string; + env?: string; + allEnvs?: boolean; clientId?: string; clientSecret?: string; scope?: string; @@ -67,8 +68,12 @@ export const pullCommand: CommandModule<{}, Args> = { }, env: { type: "string", - default: "dev", - describe: "Environment alias in umbraco-compose.yaml" + describe: "Environment alias(es) in umbraco-compose.yaml (comma-separated). Defaults to dev." + }, + allEnvs: { + type: "boolean", + default: false, + describe: "Pull all environments declared in umbraco-compose.yaml" }, clientId: { type: "string", @@ -93,13 +98,14 @@ export const pullCommand: CommandModule<{}, Args> = { }; export async function runPull(args: Args): Promise { + if (args.allEnvs && args.env) { + throw new Error(`Use either --allEnvs or --env, not both.`); + } + 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 targetEnvs = resolveTargetEnvs(cfg, composeYamlPath, args); const clientId = args.clientId ?? process.env.COMPOSE_MGMT_CLIENT_ID; const clientSecret = args.clientSecret ?? process.env.COMPOSE_MGMT_CLIENT_SECRET; @@ -109,58 +115,92 @@ export async function runPull(args: Args): Promise { ); } - 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 - }) - }); + for (const localEnvAlias of targetEnvs) { + const envCfg = cfg.environments[localEnvAlias]; + if (!envCfg) { + throw new Error(`Environment "${localEnvAlias}" not found in ${composeYamlPath}`); + } - 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 envDir = path.resolve(rootDir, envCfg.dir); + await fs.mkdir(envDir, { recursive: true }); + const envState = findEnvironmentByAlias(state, localEnvAlias); + 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, + localEnvAlias, + stateRemoteAlias ); - } + if (!resolvedRemoteEnvAlias) { + throw new Error( + `Environment "${localEnvAlias}" was not found remotely for project "${cfg.project}" (checked aliases: ${[ + localEnvAlias, + ...(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)}`); - 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); + } - try { - await materializeSnapshotToStaging(stagingDir, snapshot); - await commitStagedSnapshot(envDir, stagingDir); - } finally { - await fs.rm(stagingDir, { recursive: true, force: true }); + const aliasMsg = + resolvedRemoteEnvAlias === localEnvAlias + ? `"${localEnvAlias}"` + : `"${resolvedRemoteEnvAlias}" (mapped to local "${localEnvAlias}")`; + console.log(`Pulled remote state for env ${aliasMsg} into ${envDir}`); } +} - if (envState) { - markEnvironmentRemoteAlias(state, envState.id, resolvedRemoteEnvAlias); +function resolveTargetEnvs(cfg: ComposeConfig, composeYamlPath: string, args: Args): string[] { + const aliases = args.allEnvs + ? Object.keys(cfg.environments) + : parseEnvAliases(args.env ?? "dev"); + if (aliases.length === 0) { + throw new Error(`No environments configured in ${composeYamlPath}`); + } + for (const alias of aliases) { + if (!cfg.environments[alias]) { + throw new Error(`Environment "${alias}" not found in ${composeYamlPath}`); + } } - await writeComposeState(rootDir, state); + return aliases; +} - const aliasMsg = - resolvedRemoteEnvAlias === args.env - ? `"${args.env}"` - : `"${resolvedRemoteEnvAlias}" (mapped to local "${args.env}")`; - console.log(`Pulled remote state for env ${aliasMsg} into ${envDir}`); +function parseEnvAliases(raw: string): string[] { + const aliases = raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + const unique = Array.from(new Set(aliases)); + if (unique.length === 0) { + throw new Error(`No environments provided. Example: --env dev,prod`); + } + return unique; } async function fetchSnapshot(client: ManagementClient, project: string, env: string): Promise { diff --git a/test/commands.pull.test.ts b/test/commands.pull.test.ts index d4cbfbf..3154dbe 100644 --- a/test/commands.pull.test.ts +++ b/test/commands.pull.test.ts @@ -54,6 +54,32 @@ async function createFixture(): Promise<{ root: string; envDir: string }> { return { root, envDir }; } +async function createMultiEnvFixture(): Promise<{ root: string; devDir: string; prodDir: string }> { + const { root } = await createFixture(); + const devDir = path.join(root, "env", "dev"); + const prodDir = path.join(root, "env", "prod"); + + await fs.mkdir(path.join(prodDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(prodDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(prodDir, "graphql", "persisted"), { recursive: true }); + await fs.writeFile(path.join(prodDir, "collections.json"), JSON.stringify({ collections: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(prodDir, "webhooks.json"), JSON.stringify({ webhooks: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(prodDir, "functions", "ingestion.json"), JSON.stringify({ functions: [] }, null, 2), "utf8"); + await fs.writeFile(path.join(prodDir, "graphql", "persisted.json"), JSON.stringify({ documents: [] }, null, 2), "utf8"); + + const composePath = path.join(root, "umbraco-compose.yaml"); + const cfg = YAML.parse(await fs.readFile(composePath, "utf8")) as ComposeConfig; + cfg.environments.prod = { + dir: "./env/prod", + description: "Prod", + managementBaseUrl: "https://management.example" + }; + await fs.writeFile(composePath, YAML.stringify(cfg), "utf8"); + + await writeComposeState(root, createInitialState("test-project", ["dev", "prod"])); + return { root, devDir, prodDir }; +} + test("pull writes local files from API and updates state remoteAlias", async () => { const { root, envDir } = await createFixture(); const oldFetch = globalThis.fetch; @@ -273,6 +299,117 @@ test("pull uses state remoteAlias when local alias differs during pending rename assert.equal(envState.remoteAlias, "dev"); }); +test("pull supports comma-separated env aliases", async () => { + const { root, devDir, prodDir } = await createMultiEnvFixture(); + 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" } }, { node: { environmentAlias: "prod" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "dev-content" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/prod/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "prod-content" } }] }); + } + if (u.pathname.endsWith("/type-schemas")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/functions/ingestion")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/graphql/persisted-documents")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/webhooks")) return jsonResponse(200, { edges: [] }); + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await pullCommand.handler({ + dir: root, + env: "dev,prod", + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const devCollections = JSON.parse(await fs.readFile(path.join(devDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + const prodCollections = JSON.parse(await fs.readFile(path.join(prodDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + assert.equal(devCollections.collections[0]?.alias, "dev-content"); + assert.equal(prodCollections.collections[0]?.alias, "prod-content"); +}); + +test("pull --allEnvs pulls all configured environments", async () => { + const { root, devDir, prodDir } = await createMultiEnvFixture(); + 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" } }, { node: { environmentAlias: "prod" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/dev/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "dev-content" } }] }); + } + if (u.pathname === "/v1/projects/test-project/environments/prod/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "prod-content" } }] }); + } + if (u.pathname.endsWith("/type-schemas")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/functions/ingestion")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/graphql/persisted-documents")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/webhooks")) return jsonResponse(200, { edges: [] }); + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await pullCommand.handler({ + dir: root, + allEnvs: true, + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const devCollections = JSON.parse(await fs.readFile(path.join(devDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + const prodCollections = JSON.parse(await fs.readFile(path.join(prodDir, "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + assert.equal(devCollections.collections[0]?.alias, "dev-content"); + assert.equal(prodCollections.collections[0]?.alias, "prod-content"); +}); + +test("pull rejects using --env and --allEnvs together", async () => { + const { root } = await createFixture(); + await assert.rejects( + () => + pullCommand.handler({ + dir: root, + env: "dev", + allEnvs: true, + clientId: "client", + clientSecret: "secret" + }), + /either --allEnvs or --env/ + ); +}); + async function exists(p: string): Promise { try { await fs.stat(p); From 065f33dbfcca01db7484bd57242423f9fa72c8a8 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 10:24:54 -0500 Subject: [PATCH 03/10] Improve UX for working with remote envs --- CHANGELOG.md | 3 +- docs/commands.md | 13 ++ docs/release-v1-checklist.md | 2 +- docs/testing.md | 5 + docs/workflow.md | 2 + src/commands/env-add-remote.ts | 171 +++++++++++++++++++++++++++ src/index.ts | 2 + test/commands.env-add-remote.test.ts | 167 ++++++++++++++++++++++++++ 8 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 src/commands/env-add-remote.ts create mode 100644 test/commands.env-add-remote.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2ae7f..1cd68d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project are documented in this file. ### Added - Core command set stabilized for IaC-style workflows: - - `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`, `env list-remote` + - `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 @@ -25,6 +25,7 @@ All notable changes to this project are documented in this file. - 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`. - `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. diff --git a/docs/commands.md b/docs/commands.md index a92606c..4392120 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -8,6 +8,7 @@ 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` @@ -150,6 +151,18 @@ Alias routing semantics: - Side effects: none (remote read-only call). - Idempotency: fully idempotent. +### `compose env add-remote ` +- 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. + ### `compose status` - Status: implemented - Responsibility: diff --git a/docs/release-v1-checklist.md b/docs/release-v1-checklist.md index fdc3508..bc61a71 100644 --- a/docs/release-v1-checklist.md +++ b/docs/release-v1-checklist.md @@ -2,7 +2,7 @@ ## Scope Freeze - Confirm command scope for `v1.0.0`: - - `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`, `env list-remote` + - `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` diff --git a/docs/testing.md b/docs/testing.md index e196143..be2e4fd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -101,6 +101,11 @@ - verifies remote environment alias listing output - verifies `--json` machine-readable output shape +- `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 - verifies retry does not loop infinitely when second call is also `401` diff --git a/docs/workflow.md b/docs/workflow.md index 2e67473..8ee8700 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -8,6 +8,8 @@ Use this when the platform already has state and you want to adopt GitOps. 1. Bootstrap locally: - `compose clone --dir ./compose --project --env ` - or clone multiple/all: `compose clone --dir ./compose --project --env dev,prod` or `--allEnvs` + - if starting shallow, later adopt another remote alias with: + `compose env add-remote --dir ./compose --pull` 2. Review/edit generated files: - optional `compose generate ` for new resources - manual edits in `compose/env//...` diff --git a/src/commands/env-add-remote.ts b/src/commands/env-add-remote.ts new file mode 100644 index 0000000..7b6ba2a --- /dev/null +++ b/src/commands/env-add-remote.ts @@ -0,0 +1,171 @@ +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 { + COMPOSE_STATE_FILE, + ensureStateForAliases, + findEnvironmentByAlias, + markEnvironmentRemoteAlias, + readComposeState, + writeComposeState +} from "../compose/state.js"; +import { listRemoteEnvironmentAliases, runPull } from "./pull.js"; + +type Args = { + dir: string; + alias: string; + baseUrl?: string; + pull: boolean; + clientId?: string; + clientSecret?: string; + scope?: string; + audience?: string; +}; + +type ComposeConfig = { + version: number; + project: string; + environments: Record< + string, + { + dir: string; + description?: string; + managementBaseUrl: string; + ingestionBaseUrl?: string; + defaultCollection?: string; + } + >; +}; + +const DEFAULT_MGMT_BASE_URL = "https://management.umbracocompose.com"; + +export const envAddRemoteCommand: CommandModule<{}, Args> = { + command: "env add-remote ", + describe: "Add a remote environment alias to local config/state", + builder: { + dir: { + type: "string", + default: "./compose", + describe: "Root Compose IaC directory (contains umbraco-compose.yaml)" + }, + alias: { + type: "string", + describe: "Remote environment alias to add locally" + }, + baseUrl: { + type: "string", + describe: "Override management API base URL for the new local environment entry" + }, + pull: { + type: "boolean", + default: false, + describe: "Immediately pull remote state for the added environment" + }, + 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) => { + 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.alias]) { + throw new Error(`Environment "${args.alias}" already exists 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 baseUrl = args.baseUrl ?? inferBaseUrl(cfg); + const client = new ManagementClient({ + baseUrl, + auth: composeManagementOAuth({ + clientId, + clientSecret, + baseUrl, + ...(args.scope !== undefined ? { scope: args.scope } : {}), + ...(args.audience !== undefined ? { audience: args.audience } : {}) + }) + }); + + const remoteAliases = await listRemoteEnvironmentAliases(client, cfg.project); + if (!remoteAliases.includes(args.alias)) { + throw new Error( + `Remote environment "${args.alias}" was not found for project "${cfg.project}". ` + + `Available: ${remoteAliases.join(", ") || "(none)"}` + ); + } + + cfg.environments[args.alias] = { + dir: `./env/${args.alias}`, + description: `${args.alias} Environment`, + managementBaseUrl: baseUrl + }; + + const statePath = path.join(rootDir, COMPOSE_STATE_FILE); + const state = ensureStateForAliases( + await readComposeState(rootDir), + cfg.project, + Object.keys(cfg.environments) + ).state; + const envState = findEnvironmentByAlias(state, args.alias); + if (!envState) { + throw new Error(`Failed to add "${args.alias}" to ${statePath}.`); + } + markEnvironmentRemoteAlias(state, envState.id, args.alias); + + await fs.writeFile(composeYamlPath, YAML.stringify(cfg), "utf8"); + await writeComposeState(rootDir, state); + + console.log(`Added remote environment "${args.alias}" to local config/state`); + console.log(`Updated: ${composeYamlPath}`); + console.log(`Updated: ${statePath}`); + + if (args.pull) { + await runPull({ + dir: rootDir, + env: args.alias, + clientId, + clientSecret, + ...(args.scope !== undefined ? { scope: args.scope } : {}), + ...(args.audience !== undefined ? { audience: args.audience } : {}) + }); + } + } +}; + +function inferBaseUrl(cfg: ComposeConfig): string { + const first = Object.values(cfg.environments)[0]; + return first?.managementBaseUrl ?? DEFAULT_MGMT_BASE_URL; +} + +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; +} diff --git a/src/index.ts b/src/index.ts index e717408..9f33a03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { generateCommand } from "./commands/generate.js"; import { pullCommand } from "./commands/pull.js"; import { cloneCommand } from "./commands/clone.js"; import { envListRemoteCommand } from "./commands/env-list-remote.js"; +import { envAddRemoteCommand } from "./commands/env-add-remote.js"; await yargs(hideBin(process.argv)) .scriptName("compose") @@ -25,6 +26,7 @@ import { envListRemoteCommand } from "./commands/env-list-remote.js"; .command(statusCommand) .command(envRenameCommand) .command(envListRemoteCommand) + .command(envAddRemoteCommand) .demandCommand(1, "Try `compose init --help` or `compose validate --help`.") .strict() .help() diff --git a/test/commands.env-add-remote.test.ts b/test/commands.env-add-remote.test.ts new file mode 100644 index 0000000..7c727c6 --- /dev/null +++ b/test/commands.env-add-remote.test.ts @@ -0,0 +1,167 @@ +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 { envAddRemoteCommand } from "../src/commands/env-add-remote.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 { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-add-remote-")); + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example" + } + } + }; + await fs.mkdir(path.join(root, "env", "dev"), { recursive: true }); + await fs.writeFile(path.join(root, "umbraco-compose.yaml"), YAML.stringify(cfg), "utf8"); + await writeComposeState(root, createInitialState("test-project", ["dev"])); + return root; +} + +test("env add-remote adds environment to yaml and state", 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: "dev" } }, { node: { environmentAlias: "staging" } }] + }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await envAddRemoteCommand.handler({ + dir: root, + alias: "staging", + pull: false, + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const cfg = YAML.parse(await fs.readFile(path.join(root, "umbraco-compose.yaml"), "utf8")) as ComposeConfig; + assert.equal(cfg.environments.staging?.dir, "./env/staging"); + assert.equal(cfg.environments.staging?.managementBaseUrl, "https://management.example"); + + const state = await readComposeState(root); + assert.ok(state); + const added = state.environments.find((e) => e.alias === "staging"); + assert.ok(added); + assert.equal(added.remoteAlias, "staging"); +}); + +test("env add-remote --pull adds environment and pulls local files", 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: "dev" } }, { node: { environmentAlias: "staging" } }] + }); + } + if (u.pathname === "/v1/projects/test-project/environments/staging/collections") { + return jsonResponse(200, { edges: [{ node: { collectionAlias: "content" } }] }); + } + if (u.pathname.endsWith("/type-schemas")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/functions/ingestion")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/graphql/persisted-documents")) return jsonResponse(200, { edges: [] }); + if (u.pathname.endsWith("/webhooks")) return jsonResponse(200, { edges: [] }); + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await envAddRemoteCommand.handler({ + dir: root, + alias: "staging", + pull: true, + clientId: "client", + clientSecret: "secret" + }); + } finally { + globalThis.fetch = oldFetch; + } + + const collections = JSON.parse( + await fs.readFile(path.join(root, "env", "staging", "collections.json"), "utf8") + ) as { collections: Array<{ alias: string }> }; + assert.equal(collections.collections[0]?.alias, "content"); +}); + +test("env add-remote fails when alias already exists locally", async () => { + const root = await createFixture(); + await assert.rejects( + () => + envAddRemoteCommand.handler({ + dir: root, + alias: "dev", + pull: false, + clientId: "client", + clientSecret: "secret" + }), + /already exists/ + ); +}); + +test("env add-remote fails when remote alias is not found", 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: "dev" } }] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await assert.rejects( + () => + envAddRemoteCommand.handler({ + dir: root, + alias: "staging", + pull: false, + clientId: "client", + clientSecret: "secret" + }), + /was not found/ + ); + } finally { + globalThis.fetch = oldFetch; + } +}); From 25f6792a54c39511e6547e2d789dc08f030de67d Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 11:28:36 -0500 Subject: [PATCH 04/10] Fix nested env command issue --- src/commands/env-add-remote.ts | 2 +- src/commands/env-list-remote.ts | 2 +- src/commands/env-rename.ts | 2 +- src/commands/env.ts | 17 +++++++++++++++++ src/index.ts | 8 ++------ 5 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 src/commands/env.ts diff --git a/src/commands/env-add-remote.ts b/src/commands/env-add-remote.ts index 7b6ba2a..668a058 100644 --- a/src/commands/env-add-remote.ts +++ b/src/commands/env-add-remote.ts @@ -43,7 +43,7 @@ type ComposeConfig = { const DEFAULT_MGMT_BASE_URL = "https://management.umbracocompose.com"; export const envAddRemoteCommand: CommandModule<{}, Args> = { - command: "env add-remote ", + command: "add-remote ", describe: "Add a remote environment alias to local config/state", builder: { dir: { diff --git a/src/commands/env-list-remote.ts b/src/commands/env-list-remote.ts index e24e070..bdd30eb 100644 --- a/src/commands/env-list-remote.ts +++ b/src/commands/env-list-remote.ts @@ -14,7 +14,7 @@ type Args = { }; export const envListRemoteCommand: CommandModule<{}, Args> = { - command: "env list-remote", + command: "list-remote", describe: "List remote environment aliases for a project", builder: { project: { diff --git a/src/commands/env-rename.ts b/src/commands/env-rename.ts index 074bf2f..33938a4 100644 --- a/src/commands/env-rename.ts +++ b/src/commands/env-rename.ts @@ -34,7 +34,7 @@ type ComposeConfig = { }; export const envRenameCommand: CommandModule<{}, Args> = { - command: "env rename ", + command: "rename ", describe: "Rename an environment alias in local IaC config and state", builder: { dir: { diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 0000000..a8907ec --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,17 @@ +import type { CommandModule } from "yargs"; +import { envRenameCommand } from "./env-rename.js"; +import { envListRemoteCommand } from "./env-list-remote.js"; +import { envAddRemoteCommand } from "./env-add-remote.js"; + +export const envCommand: CommandModule = { + command: "env ", + describe: "Environment-related commands", + builder: (yargs) => + yargs + .command(envRenameCommand) + .command(envListRemoteCommand) + .command(envAddRemoteCommand) + .demandCommand(1, "Try `compose env rename --help` or `compose env list-remote --help`.") + .strict(), + handler: async () => {} +}; diff --git a/src/index.ts b/src/index.ts index 9f33a03..0a06702 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,10 @@ 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"; -import { envListRemoteCommand } from "./commands/env-list-remote.js"; -import { envAddRemoteCommand } from "./commands/env-add-remote.js"; +import { envCommand } from "./commands/env.js"; await yargs(hideBin(process.argv)) .scriptName("compose") @@ -24,9 +22,7 @@ import { envAddRemoteCommand } from "./commands/env-add-remote.js"; .command(planCommand) .command(applyCommand) .command(statusCommand) - .command(envRenameCommand) - .command(envListRemoteCommand) - .command(envAddRemoteCommand) + .command(envCommand) .demandCommand(1, "Try `compose init --help` or `compose validate --help`.") .strict() .help() From f8da8ffd86a7565d8736321efb1c2dbd5a05e4ca Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 11:42:57 -0500 Subject: [PATCH 05/10] Standardize UX around dir and project flags --- CHANGELOG.md | 1 + docs/commands.md | 3 + docs/testing.md | 3 + src/commands/env-list-remote.ts | 52 ++++++++++++++--- test/commands.env-list-remote.test.ts | 84 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd68d0..8229adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to this project are documented in this file. - `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. - `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. diff --git a/docs/commands.md b/docs/commands.md index 4392120..d195234 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -148,6 +148,9 @@ Alias routing semantics: - Scope: - default output is human-readable list - `--json` emits machine-readable payload + - 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. diff --git a/docs/testing.md b/docs/testing.md index be2e4fd..e40a376 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -100,6 +100,9 @@ - `test/commands.env-list-remote.test.ts` - verifies remote environment alias listing output - verifies `--json` machine-readable output shape + - 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 diff --git a/src/commands/env-list-remote.ts b/src/commands/env-list-remote.ts index bdd30eb..426a162 100644 --- a/src/commands/env-list-remote.ts +++ b/src/commands/env-list-remote.ts @@ -1,10 +1,14 @@ +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 { listRemoteEnvironmentAliases } from "./pull.js"; type Args = { - project: string; + project?: string; + dir: string; baseUrl: string; clientId?: string; clientSecret?: string; @@ -15,12 +19,16 @@ type Args = { export const envListRemoteCommand: CommandModule<{}, Args> = { command: "list-remote", - describe: "List remote environment aliases for a project", + describe: "List remote environment aliases for a project (from --project or local compose dir)", builder: { project: { type: "string", - demandOption: true, - describe: "Remote project alias" + describe: "Remote project alias (optional when --dir contains umbraco-compose.yaml)" + }, + dir: { + type: "string", + default: "./compose", + describe: "Compose root directory used to infer project when --project is omitted" }, baseUrl: { type: "string", @@ -58,6 +66,20 @@ export const envListRemoteCommand: CommandModule<{}, Args> = { ); } + const projectFromDir = await resolveProjectFromDir(args.dir); + if (args.project && projectFromDir && args.project !== projectFromDir) { + throw new Error( + `Project mismatch: --project "${args.project}" does not match ` + + `"${projectFromDir}" from ${path.join(path.resolve(process.cwd(), args.dir), "umbraco-compose.yaml")}.` + ); + } + const project = args.project ?? projectFromDir; + if (!project) { + throw new Error( + `Missing project. Provide --project or ensure ${path.join(path.resolve(process.cwd(), args.dir), "umbraco-compose.yaml")} exists with a valid "project".` + ); + } + const client = new ManagementClient({ baseUrl: args.baseUrl, auth: composeManagementOAuth({ @@ -69,20 +91,34 @@ export const envListRemoteCommand: CommandModule<{}, Args> = { }) }); - const aliases = await listRemoteEnvironmentAliases(client, args.project); + const aliases = await listRemoteEnvironmentAliases(client, project); if (args.json) { - console.log(JSON.stringify({ project: args.project, environments: aliases }, null, 2)); + console.log(JSON.stringify({ project, environments: aliases }, null, 2)); return; } if (aliases.length === 0) { - console.log(`No remote environments found for project "${args.project}".`); + console.log(`No remote environments found for project "${project}".`); return; } - console.log(`Remote environments for "${args.project}":`); + console.log(`Remote environments for "${project}":`); for (const alias of aliases) { console.log(`- ${alias}`); } } }; + +async function resolveProjectFromDir(dir: string): Promise { + const composeYamlPath = path.join(path.resolve(process.cwd(), dir), "umbraco-compose.yaml"); + try { + const text = await fs.readFile(composeYamlPath, "utf8"); + const doc = YAML.parse(text) as { project?: unknown }; + return typeof doc?.project === "string" && doc.project.trim().length > 0 ? doc.project.trim() : null; + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + } + throw err; + } +} diff --git a/test/commands.env-list-remote.test.ts b/test/commands.env-list-remote.test.ts index aa69bd8..85d91ae 100644 --- a/test/commands.env-list-remote.test.ts +++ b/test/commands.env-list-remote.test.ts @@ -1,5 +1,9 @@ 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 { envListRemoteCommand } from "../src/commands/env-list-remote.js"; function jsonResponse(status: number, body: unknown): Response { @@ -32,6 +36,7 @@ test("env list-remote prints aliases", async () => { try { await envListRemoteCommand.handler({ project: "my-project", + dir: "./compose", baseUrl: "https://management.example", clientId: "client", clientSecret: "secret", @@ -68,6 +73,7 @@ test("env list-remote --json emits machine-readable output", async () => { try { await envListRemoteCommand.handler({ project: "my-project", + dir: "./compose", baseUrl: "https://management.example", clientId: "client", clientSecret: "secret", @@ -83,3 +89,81 @@ test("env list-remote --json emits machine-readable output", async () => { assert.equal(payload.project, "my-project"); assert.deepEqual(payload.environments, ["dev"]); }); + +test("env list-remote infers project from --dir when --project is omitted", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-list-remote-")); + await fs.writeFile( + path.join(root, "umbraco-compose.yaml"), + YAML.stringify({ version: 1, project: "dir-project", environments: {} }), + "utf8" + ); + + const oldFetch = globalThis.fetch; + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((v) => String(v)).join(" ")); + }; + + 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/dir-project/environments") { + return jsonResponse(200, { edges: [{ node: { environmentAlias: "dev" } }] }); + } + return jsonResponse(404, { error: "unexpected path" }); + }) as typeof fetch; + + try { + await envListRemoteCommand.handler({ + dir: root, + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + json: false + }); + } finally { + globalThis.fetch = oldFetch; + console.log = originalLog; + } + + assert.equal(lines[0], 'Remote environments for "dir-project":'); +}); + +test("env list-remote fails when --project conflicts with dir project", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-list-remote-mismatch-")); + await fs.writeFile( + path.join(root, "umbraco-compose.yaml"), + YAML.stringify({ version: 1, project: "dir-project", environments: {} }), + "utf8" + ); + + await assert.rejects( + () => + envListRemoteCommand.handler({ + project: "flag-project", + dir: root, + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + json: false + }), + /Project mismatch/ + ); +}); + +test("env list-remote fails when neither --project nor dir project is available", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-list-remote-missing-")); + await assert.rejects( + () => + envListRemoteCommand.handler({ + dir: root, + baseUrl: "https://management.example", + clientId: "client", + clientSecret: "secret", + json: false + }), + /Missing project/ + ); +}); From eeec115a5209f2736a5576bcbfaa5ab583231b79 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 12:01:03 -0500 Subject: [PATCH 06/10] Set the default directory for nonbootstrapping commands to current directory --- CHANGELOG.md | 1 + docs/commands.md | 10 ++++++++- docs/release-v1-checklist.md | 4 ++-- docs/workflow.md | 38 ++++++++++++++++++--------------- src/commands/apply.ts | 12 +++++++---- src/commands/env-add-remote.ts | 4 ++-- src/commands/env-list-remote.ts | 2 +- src/commands/env-rename.ts | 4 ++-- src/commands/generate.ts | 4 ++-- src/commands/plan.ts | 4 ++-- src/commands/pull.ts | 4 ++-- src/commands/status.ts | 4 ++-- src/commands/validate.ts | 4 ++-- test/commands.apply.test.ts | 5 ++++- 14 files changed, 60 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8229adf..01c8ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project are documented in this file. - `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. +- 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. diff --git a/docs/commands.md b/docs/commands.md index d195234..e7a7751 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -20,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 @@ -166,6 +168,12 @@ Alias routing semantics: - 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: diff --git a/docs/release-v1-checklist.md b/docs/release-v1-checklist.md index bc61a71..a3523f8 100644 --- a/docs/release-v1-checklist.md +++ b/docs/release-v1-checklist.md @@ -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: diff --git a/docs/workflow.md b/docs/workflow.md index 8ee8700..1428a27 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -8,17 +8,19 @@ Use this when the platform already has state and you want to adopt GitOps. 1. Bootstrap locally: - `compose clone --dir ./compose --project --env ` - or clone multiple/all: `compose clone --dir ./compose --project --env dev,prod` or `--allEnvs` - - if starting shallow, later adopt another remote alias with: - `compose env add-remote --dir ./compose --pull` -2. Review/edit generated files: +2. Enter the project directory: + - `cd ./compose` +3. (Optional) If starting shallow, adopt another remote alias: + - `compose env add-remote --pull` +4. 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: + - manual edits in `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: @@ -29,15 +31,17 @@ Use this when no remote state exists yet. 1. Initialize local project: - `compose init --dir ./compose --project --env dev` -2. Add resources: +2. Enter the project directory: + - `cd ./compose` +3. 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. +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. diff --git a/src/commands/apply.ts b/src/commands/apply.ts index cc8caca..7d5f2be 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -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"; @@ -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", @@ -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"; } diff --git a/src/commands/env-add-remote.ts b/src/commands/env-add-remote.ts index 668a058..a70a847 100644 --- a/src/commands/env-add-remote.ts +++ b/src/commands/env-add-remote.ts @@ -48,8 +48,8 @@ export const envAddRemoteCommand: 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)" }, alias: { type: "string", diff --git a/src/commands/env-list-remote.ts b/src/commands/env-list-remote.ts index 426a162..7a52368 100644 --- a/src/commands/env-list-remote.ts +++ b/src/commands/env-list-remote.ts @@ -27,7 +27,7 @@ export const envListRemoteCommand: CommandModule<{}, Args> = { }, dir: { type: "string", - default: "./compose", + default: ".", describe: "Compose root directory used to infer project when --project is omitted" }, baseUrl: { diff --git a/src/commands/env-rename.ts b/src/commands/env-rename.ts index 33938a4..13ca24e 100644 --- a/src/commands/env-rename.ts +++ b/src/commands/env-rename.ts @@ -39,8 +39,8 @@ export const envRenameCommand: 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)" }, from: { type: "string", diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 187c522..57aa378 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -75,8 +75,8 @@ export const generateCommand: 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", diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 8c76c48..1c4d5d7 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -17,8 +17,8 @@ export const planCommand: 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", diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 8ab645b..444ab9a 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -63,8 +63,8 @@ export const pullCommand: 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", diff --git a/src/commands/status.ts b/src/commands/status.ts index aea831f..4262445 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -51,8 +51,8 @@ export const statusCommand: 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", diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 8ca2abc..ad9b082 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -35,8 +35,8 @@ export const validateCommand: 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", diff --git a/test/commands.apply.test.ts b/test/commands.apply.test.ts index 9a53f8f..f585dc3 100644 --- a/test/commands.apply.test.ts +++ b/test/commands.apply.test.ts @@ -160,11 +160,13 @@ test("runApply writes compose.lock.json and updates compose.state.json after suc const lockPath = path.join(envDir, "compose.lock.json"); const lockText = await fs.readFile(lockPath, "utf8"); + const pkgText = await fs.readFile(path.join(process.cwd(), "package.json"), "utf8"); + const pkg = JSON.parse(pkgText) as { version?: string }; const lock = JSON.parse(lockText) as { version: number; project: string; environment: string; - lastApplied?: { at?: string }; + lastApplied?: { at?: string; cliVersion?: string }; resources: Record; }; @@ -172,6 +174,7 @@ test("runApply writes compose.lock.json and updates compose.state.json after suc assert.equal(lock.project, "test-project"); assert.equal(lock.environment, "dev"); assert.equal(typeof lock.lastApplied?.at, "string"); + assert.equal(lock.lastApplied?.cliVersion, pkg.version); assert.equal(typeof lock.resources.collections?.hash, "string"); assert.equal(typeof lock.resources.webhooks?.hash, "string"); From 2fae2cbf00edd922adf38bb37c03d05e08b25583 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 12:48:32 -0500 Subject: [PATCH 07/10] Include remote-only info in env list-remote output --- CHANGELOG.md | 1 + docs/commands.md | 7 +++-- docs/testing.md | 3 ++- src/commands/env-list-remote.ts | 38 +++++++++++++++++++++------ test/commands.env-list-remote.test.ts | 29 +++++++++++++++----- 5 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c8ca6..1a08d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project are documented in this file. - `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`). diff --git a/docs/commands.md b/docs/commands.md index e7a7751..c31f88f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -148,8 +148,11 @@ Alias routing semantics: - Responsibility: - list remote environment aliases for a project - Scope: - - default output is human-readable list - - `--json` emits machine-readable payload + - 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 diff --git a/docs/testing.md b/docs/testing.md index e40a376..9c38649 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,7 +99,8 @@ - `test/commands.env-list-remote.test.ts` - verifies remote environment alias listing output - - verifies `--json` machine-readable output shape + - 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 diff --git a/src/commands/env-list-remote.ts b/src/commands/env-list-remote.ts index 7a52368..86e4410 100644 --- a/src/commands/env-list-remote.ts +++ b/src/commands/env-list-remote.ts @@ -17,6 +17,11 @@ type Args = { json: boolean; }; +type ComposeManifest = { + project?: unknown; + environments?: Record; +}; + export const envListRemoteCommand: CommandModule<{}, Args> = { command: "list-remote", describe: "List remote environment aliases for a project (from --project or local compose dir)", @@ -66,7 +71,8 @@ export const envListRemoteCommand: CommandModule<{}, Args> = { ); } - const projectFromDir = await resolveProjectFromDir(args.dir); + const manifest = await readComposeManifest(args.dir); + const projectFromDir = resolveProjectFromManifest(manifest); if (args.project && projectFromDir && args.project !== projectFromDir) { throw new Error( `Project mismatch: --project "${args.project}" does not match ` + @@ -92,29 +98,34 @@ export const envListRemoteCommand: CommandModule<{}, Args> = { }); const aliases = await listRemoteEnvironmentAliases(client, project); + const localAliases = localAliasesFromManifest(manifest); + const environments = aliases.map((alias) => ({ + alias, + inLocalManifest: localAliases.has(alias) + })); if (args.json) { - console.log(JSON.stringify({ project, environments: aliases }, null, 2)); + console.log(JSON.stringify({ project, environments }, null, 2)); return; } - if (aliases.length === 0) { + if (environments.length === 0) { console.log(`No remote environments found for project "${project}".`); return; } console.log(`Remote environments for "${project}":`); - for (const alias of aliases) { - console.log(`- ${alias}`); + for (const env of environments) { + const label = env.inLocalManifest ? "[local]" : "[remote-only]"; + console.log(`- ${env.alias} ${label}`); } } }; -async function resolveProjectFromDir(dir: string): Promise { +async function readComposeManifest(dir: string): Promise { const composeYamlPath = path.join(path.resolve(process.cwd(), dir), "umbraco-compose.yaml"); try { const text = await fs.readFile(composeYamlPath, "utf8"); - const doc = YAML.parse(text) as { project?: unknown }; - return typeof doc?.project === "string" && doc.project.trim().length > 0 ? doc.project.trim() : null; + return YAML.parse(text) as ComposeManifest; } catch (err) { if (err && typeof err === "object" && "code" in err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; @@ -122,3 +133,14 @@ async function resolveProjectFromDir(dir: string): Promise { throw err; } } + +function resolveProjectFromManifest(manifest: ComposeManifest | null): string | null { + const project = manifest?.project; + return typeof project === "string" && project.trim().length > 0 ? project.trim() : null; +} + +function localAliasesFromManifest(manifest: ComposeManifest | null): Set { + const raw = manifest?.environments; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return new Set(); + return new Set(Object.keys(raw)); +} diff --git a/test/commands.env-list-remote.test.ts b/test/commands.env-list-remote.test.ts index 85d91ae..9ab653f 100644 --- a/test/commands.env-list-remote.test.ts +++ b/test/commands.env-list-remote.test.ts @@ -14,6 +14,13 @@ function jsonResponse(status: number, body: unknown): Response { } test("env list-remote prints aliases", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-list-remote-output-")); + await fs.writeFile( + path.join(root, "umbraco-compose.yaml"), + YAML.stringify({ version: 1, project: "my-project", environments: { dev: {} } }), + "utf8" + ); + const oldFetch = globalThis.fetch; const originalLog = console.log; const lines: string[] = []; @@ -36,7 +43,7 @@ test("env list-remote prints aliases", async () => { try { await envListRemoteCommand.handler({ project: "my-project", - dir: "./compose", + dir: root, baseUrl: "https://management.example", clientId: "client", clientSecret: "secret", @@ -48,11 +55,18 @@ test("env list-remote prints aliases", async () => { } assert.equal(lines[0], 'Remote environments for "my-project":'); - assert.equal(lines.some((line) => line === "- dev"), true); - assert.equal(lines.some((line) => line === "- prod"), true); + assert.equal(lines.some((line) => line === "- dev [local]"), true); + assert.equal(lines.some((line) => line === "- prod [remote-only]"), true); }); test("env list-remote --json emits machine-readable output", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-list-remote-json-")); + await fs.writeFile( + path.join(root, "umbraco-compose.yaml"), + YAML.stringify({ version: 1, project: "my-project", environments: { dev: {} } }), + "utf8" + ); + const oldFetch = globalThis.fetch; const originalLog = console.log; const lines: string[] = []; @@ -73,7 +87,7 @@ test("env list-remote --json emits machine-readable output", async () => { try { await envListRemoteCommand.handler({ project: "my-project", - dir: "./compose", + dir: root, baseUrl: "https://management.example", clientId: "client", clientSecret: "secret", @@ -85,9 +99,12 @@ test("env list-remote --json emits machine-readable output", async () => { } assert.equal(lines.length, 1); - const payload = JSON.parse(lines[0] ?? "{}") as { project: string; environments: string[] }; + const payload = JSON.parse(lines[0] ?? "{}") as { + project: string; + environments: Array<{ alias: string; inLocalManifest: boolean }>; + }; assert.equal(payload.project, "my-project"); - assert.deepEqual(payload.environments, ["dev"]); + assert.deepEqual(payload.environments, [{ alias: "dev", inLocalManifest: true }]); }); test("env list-remote infers project from --dir when --project is omitted", async () => { From ee107478d416e9e3d0dfbab4deaa0969726f0579 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 12:50:19 -0500 Subject: [PATCH 08/10] Update docs to reflect new default directory --- docs/commands.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/commands.md b/docs/commands.md index c31f88f..bd76f49 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -48,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 From 9d1ad63f8fb40934c05a0d9cc8f79422fd50abfb Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 12:57:36 -0500 Subject: [PATCH 09/10] Bump version to v.1.0.0-rc.2 --- CHANGELOG.md | 17 +++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a08d63..f9f2095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project are documented in this file. +## [1.0.0-rc.2] - 2026-02-14 + +### Added +- `env add-remote ` 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 ` 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 diff --git a/package-lock.json b/package-lock.json index 966981d..2fdd7cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "umbraco-compose-cli", - "version": "0.1.0", + "version": "1.0.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "umbraco-compose-cli", - "version": "0.1.0", + "version": "1.0.0-rc.2", "license": "MIT", "dependencies": { "fast-json-stable-stringify": "^2.1.0", diff --git a/package.json b/package.json index c192a9c..56b81b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umbraco-compose-cli", - "version": "1.0.0-rc.1", + "version": "1.0.0-rc.2", "type": "module", "description": "", "bin": { From aa81d16eef76a8fc31f5af98c53dc049fd86db1d Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Sat, 14 Feb 2026 13:04:16 -0500 Subject: [PATCH 10/10] Add README and prepare v1.0.0-rc.3 --- CHANGELOG.md | 10 ++++++ README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f2095..d09b854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5295f8f --- /dev/null +++ b/README.md @@ -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 --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 --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 --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 ` +- `compose validate` +- `compose pull` +- `compose status` +- `compose plan` +- `compose apply` +- `compose env rename ` +- `compose env list-remote` +- `compose env add-remote ` + +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` diff --git a/package-lock.json b/package-lock.json index 2fdd7cf..d421c4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "umbraco-compose-cli", - "version": "1.0.0-rc.2", + "version": "1.0.0-rc.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "umbraco-compose-cli", - "version": "1.0.0-rc.2", + "version": "1.0.0-rc.3", "license": "MIT", "dependencies": { "fast-json-stable-stringify": "^2.1.0", diff --git a/package.json b/package.json index 56b81b6..a6b6d52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umbraco-compose-cli", - "version": "1.0.0-rc.2", + "version": "1.0.0-rc.3", "type": "module", "description": "", "bin": {