From 4cc56a7d1d7e4ac16c1e39ce904e021e7482696f Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Mon, 16 Feb 2026 10:25:36 -0500 Subject: [PATCH 1/2] Support adding local environments --- CHANGELOG.md | 2 +- README.md | 1 + docs/commands.md | 15 +++- docs/release-v1-checklist.md | 2 +- docs/testing.md | 6 ++ docs/workflow.md | 1 + src/commands/env-add.ts | 128 ++++++++++++++++++++++++++++++++++ src/commands/env.ts | 4 +- test/commands.env-add.test.ts | 109 +++++++++++++++++++++++++++++ 9 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 src/commands/env-add.ts create mode 100644 test/commands.env-add.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d09b854..3000f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,4 +71,4 @@ All notable changes to this project are documented in this file. - Resolved test harness response-shape issues around `204` delete cases. ## [Unreleased] -- Final `1.0.0` version bump and release tagging. +- Added `compose env add ` for local-only environment creation after project initialization. diff --git a/README.md b/README.md index 665b1a6..e0bfa13 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ You can also pass `--clientId`, `--clientSecret`, `--scope`, and `--audience` on - `compose plan` - `compose apply` - `compose env rename ` +- `compose env add ` - `compose env list-remote` - `compose env add-remote ` diff --git a/docs/commands.md b/docs/commands.md index bd76f49..6a79d7a 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 ` - `compose env add-remote` - `compose env list-remote` - `compose status` @@ -172,9 +173,21 @@ 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. +### `compose env add ` +- Status: implemented +- Responsibility: + - add a brand-new local environment entry to `umbraco-compose.yaml` + - scaffold required local environment files/directories + - update `compose.state.json` with a stable local environment id +- Notes: + - defaults are inferred from existing local environment config where available + - supports `--baseUrl` and `--description` overrides +- Side effects: local config/state and local environment file scaffolding. +- 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) + - `generate`, `validate`, `pull`, `status`, `plan`, `apply`, `env rename`, `env add`, `env add-remote`, `env list-remote` (for project inference) - Bootstrap defaults (`--dir ./compose`) apply to: - `init`, `clone` diff --git a/docs/release-v1-checklist.md b/docs/release-v1-checklist.md index a3523f8..29b485a 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`, `env add-remote` + - `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`, `env add`, `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 9c38649..f623158 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -110,6 +110,12 @@ - verifies `--pull` option performs immediate file materialization - verifies local-exists and remote-missing failure paths +- `test/commands.env-add.test.ts` + - verifies local environment add scaffolds required directories/files + - verifies yaml/state updates for newly added environment + - verifies `--baseUrl`/`--description` overrides + - verifies existing-alias failure path + - `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 1428a27..c689cba 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -12,6 +12,7 @@ Use this when the platform already has state and you want to adopt GitOps. - `cd ./compose` 3. (Optional) If starting shallow, adopt another remote alias: - `compose env add-remote --pull` + - for a brand-new local environment (not yet remote): `compose env add ` 4. Review/edit generated files: - optional `compose generate ` for new resources - manual edits in `env//...` diff --git a/src/commands/env-add.ts b/src/commands/env-add.ts new file mode 100644 index 0000000..b91e386 --- /dev/null +++ b/src/commands/env-add.ts @@ -0,0 +1,128 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CommandModule } from "yargs"; +import YAML from "yaml"; +import { collectionsJson, ingestionRegistryJson, persistedRegistryJson, webhooksJson } from "../iac/templates.js"; +import { ensureStateForAliases, readComposeState, writeComposeState } from "../compose/state.js"; + +type Args = { + dir: string; + alias: string; + description?: string; + baseUrl?: 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 envAddCommand: CommandModule<{}, Args> = { + command: "add ", + describe: "Add a new local environment to umbraco-compose.yaml and scaffold its files", + builder: { + dir: { + type: "string", + default: ".", + describe: "Root Compose IaC directory (current directory by default; contains umbraco-compose.yaml)" + }, + alias: { + type: "string", + describe: "New local environment alias" + }, + description: { + type: "string", + describe: "Optional environment description override" + }, + baseUrl: { + type: "string", + describe: "Override management API base URL for the new environment" + } + }, + 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 baseTemplate = inferTemplateEnvironment(cfg); + const managementBaseUrl = args.baseUrl ?? baseTemplate?.managementBaseUrl ?? DEFAULT_MGMT_BASE_URL; + const description = args.description ?? `${args.alias} Environment`; + const nextEnv = { + dir: `./env/${args.alias}`, + description, + managementBaseUrl, + ...(baseTemplate?.ingestionBaseUrl ? { ingestionBaseUrl: baseTemplate.ingestionBaseUrl } : {}), + ...(baseTemplate?.defaultCollection ? { defaultCollection: baseTemplate.defaultCollection } : {}) + }; + cfg.environments[args.alias] = nextEnv; + + const envDir = path.resolve(rootDir, nextEnv.dir); + await scaffoldEnvironmentDir(envDir, baseTemplate?.defaultCollection); + + const state = ensureStateForAliases( + await readComposeState(rootDir), + cfg.project, + Object.keys(cfg.environments) + ).state; + + await fs.writeFile(composeYamlPath, YAML.stringify(cfg), "utf8"); + await writeComposeState(rootDir, state); + + console.log(`Added local environment "${args.alias}"`); + console.log(`Created: ${envDir}`); + console.log(`Updated: ${composeYamlPath}`); + console.log(`Updated: ${path.join(rootDir, "compose.state.json")}`); + } +}; + +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; +} + +function inferTemplateEnvironment( + cfg: ComposeConfig +): ComposeConfig["environments"][string] | undefined { + const firstAlias = Object.keys(cfg.environments)[0]; + return firstAlias ? cfg.environments[firstAlias] : undefined; +} + +async function scaffoldEnvironmentDir(envDir: string, defaultCollection?: string): Promise { + await fs.mkdir(path.join(envDir, "type-schemas"), { recursive: true }); + await fs.mkdir(path.join(envDir, "functions", "ingestion"), { recursive: true }); + await fs.mkdir(path.join(envDir, "graphql", "persisted"), { recursive: true }); + + const collections = defaultCollection ? collectionsJson(defaultCollection) : { collections: [] }; + await fs.writeFile(path.join(envDir, "collections.json"), JSON.stringify(collections, null, 2) + "\n", "utf8"); + await fs.writeFile( + path.join(envDir, "functions", "ingestion.json"), + JSON.stringify(ingestionRegistryJson(), null, 2) + "\n", + "utf8" + ); + await fs.writeFile( + path.join(envDir, "graphql", "persisted.json"), + JSON.stringify(persistedRegistryJson(), null, 2) + "\n", + "utf8" + ); + await fs.writeFile(path.join(envDir, "webhooks.json"), JSON.stringify(webhooksJson(), null, 2) + "\n", "utf8"); +} diff --git a/src/commands/env.ts b/src/commands/env.ts index a8907ec..819506b 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -2,6 +2,7 @@ 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"; +import { envAddCommand } from "./env-add.js"; export const envCommand: CommandModule = { command: "env ", @@ -10,8 +11,9 @@ export const envCommand: CommandModule = { yargs .command(envRenameCommand) .command(envListRemoteCommand) + .command(envAddCommand) .command(envAddRemoteCommand) - .demandCommand(1, "Try `compose env rename --help` or `compose env list-remote --help`.") + .demandCommand(1, "Try `compose env add --help` or `compose env rename --help`.") .strict(), handler: async () => {} }; diff --git a/test/commands.env-add.test.ts b/test/commands.env-add.test.ts new file mode 100644 index 0000000..c815f1f --- /dev/null +++ b/test/commands.env-add.test.ts @@ -0,0 +1,109 @@ +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 { envAddCommand } from "../src/commands/env-add.js"; +import { createInitialState, readComposeState, writeComposeState } from "../src/compose/state.js"; + +type ComposeConfig = { + version: number; + project: string; + environments: Record< + string, + { + dir: string; + description?: string; + managementBaseUrl: string; + ingestionBaseUrl?: string; + defaultCollection?: string; + } + >; +}; + +async function createFixture(): Promise<{ root: string; composePath: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-env-add-")); + const composePath = path.join(root, "umbraco-compose.yaml"); + const cfg: ComposeConfig = { + version: 1, + project: "test-project", + environments: { + dev: { + dir: "./env/dev", + description: "Dev", + managementBaseUrl: "https://management.example", + ingestionBaseUrl: "https://ingest.example", + defaultCollection: "content" + } + } + }; + await fs.mkdir(path.join(root, "env", "dev", "type-schemas"), { recursive: true }); + await fs.writeFile(composePath, YAML.stringify(cfg), "utf8"); + await writeComposeState(root, createInitialState("test-project", ["dev"])); + return { root, composePath }; +} + +test("env add scaffolds a new environment and updates yaml/state", async () => { + const { root, composePath } = await createFixture(); + + await envAddCommand.handler({ + dir: root, + alias: "staging" + }); + + const cfg = YAML.parse(await fs.readFile(composePath, "utf8")) as ComposeConfig; + const staging = cfg.environments.staging; + assert.ok(staging); + assert.equal(staging.dir, "./env/staging"); + assert.equal(staging.managementBaseUrl, "https://management.example"); + assert.equal(staging.defaultCollection, "content"); + + 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"); + assert.equal(await exists(path.join(root, "env", "staging", "webhooks.json")), true); + assert.equal(await exists(path.join(root, "env", "staging", "functions", "ingestion.json")), true); + assert.equal(await exists(path.join(root, "env", "staging", "graphql", "persisted.json")), true); + assert.equal(await exists(path.join(root, "env", "staging", "type-schemas")), true); + + const state = await readComposeState(root); + assert.ok(state); + assert.equal(state.environments.some((e) => e.alias === "staging"), true); +}); + +test("env add honors explicit baseUrl and description", async () => { + const { root, composePath } = await createFixture(); + await envAddCommand.handler({ + dir: root, + alias: "qa", + description: "QA Env", + baseUrl: "https://custom-mgmt.example" + }); + + const cfg = YAML.parse(await fs.readFile(composePath, "utf8")) as ComposeConfig; + assert.equal(cfg.environments.qa?.description, "QA Env"); + assert.equal(cfg.environments.qa?.managementBaseUrl, "https://custom-mgmt.example"); +}); + +test("env add fails when alias already exists", async () => { + const { root } = await createFixture(); + await assert.rejects( + () => + envAddCommand.handler({ + dir: root, + alias: "dev" + }), + /already exists/ + ); +}); + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} From de748e479f1b27d59b7f6f5d04b03b6c48e19f33 Mon Sep 17 00:00:00 2001 From: Allen Smith Date: Mon, 16 Feb 2026 10:35:52 -0500 Subject: [PATCH 2/2] Remove default collection boilerplate text. --- CHANGELOG.md | 1 + src/commands/init.ts | 7 ++--- src/iac/templates.ts | 11 ++++++-- test/commands.init.test.ts | 58 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 test/commands.init.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3000f5a..23d4d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,3 +72,4 @@ All notable changes to this project are documented in this file. ## [Unreleased] - Added `compose env add ` for local-only environment creation after project initialization. +- `compose init` no longer seeds a default "wordpress" collection unless `--collection` is explicitly provided. diff --git a/src/commands/init.ts b/src/commands/init.ts index 4042b1e..b77e609 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -24,7 +24,7 @@ type Args = { dir: string; project?: string; env: string; - collection: string; + collection?: string; force: boolean; merge: boolean; gitignore: boolean; @@ -51,8 +51,7 @@ export const initCommand: CommandModule<{}, Args> = { }, collection: { type: "string", - default: "wordpress", - describe: "Default collection alias to scaffold" + describe: "Optional default collection alias to scaffold" }, force: { type: "boolean", @@ -106,7 +105,7 @@ export const initCommand: CommandModule<{}, Args> = { const yamlObj = composeYaml({ project: args.project, envs, - defaultCollection: args.collection + ...(args.collection !== undefined ? { defaultCollection: args.collection } : {}) }); const yamlRes = await writeYamlFile(yamlPath, yamlObj, mode); const statePath = path.join(rootDir, COMPOSE_STATE_FILE); diff --git a/src/iac/templates.ts b/src/iac/templates.ts index 4b091e1..f581574 100644 --- a/src/iac/templates.ts +++ b/src/iac/templates.ts @@ -1,7 +1,7 @@ export type InitTemplateOptions = { project?: string | undefined; envs: string[]; - defaultCollection: string; + defaultCollection?: string; }; export function composeYaml(opts: InitTemplateOptions) { @@ -15,7 +15,7 @@ export function composeYaml(opts: InitTemplateOptions) { description: `${env} Environment`, managementBaseUrl: "https://management.umbracocompose.com", ingestionBaseUrl: "https://ingest.germanywestcentral.umbracocompose.com", - defaultCollection: opts.defaultCollection + ...(opts.defaultCollection ? { defaultCollection: opts.defaultCollection } : {}) }; } @@ -26,7 +26,12 @@ export function composeYaml(opts: InitTemplateOptions) { }; } -export function collectionsJson(defaultCollection: string) { +export function collectionsJson(defaultCollection?: string) { + if (!defaultCollection) { + return { + collections: [] + }; + } return { collections: [ { diff --git a/test/commands.init.test.ts b/test/commands.init.test.ts new file mode 100644 index 0000000..bbcb510 --- /dev/null +++ b/test/commands.init.test.ts @@ -0,0 +1,58 @@ +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 { initCommand } from "../src/commands/init.js"; + +test("init defaults to empty collections without seeded examples", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-init-empty-")); + const target = path.join(root, "compose"); + + await initCommand.handler({ + dir: target, + project: "test-project", + env: "dev", + force: false, + merge: false, + gitignore: false, + envExample: false + }); + + const cfg = YAML.parse(await fs.readFile(path.join(target, "umbraco-compose.yaml"), "utf8")) as { + environments: Record; + }; + assert.equal("defaultCollection" in (cfg.environments.dev ?? {}), false); + + const collections = JSON.parse(await fs.readFile(path.join(target, "env", "dev", "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + assert.deepEqual(collections.collections, []); +}); + +test("init still supports explicit --collection seeding", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "compose-init-seed-")); + const target = path.join(root, "compose"); + + await initCommand.handler({ + dir: target, + project: "test-project", + env: "dev", + collection: "content", + force: false, + merge: false, + gitignore: false, + envExample: false + }); + + const cfg = YAML.parse(await fs.readFile(path.join(target, "umbraco-compose.yaml"), "utf8")) as { + environments: Record; + }; + assert.equal(cfg.environments.dev?.defaultCollection, "content"); + + const collections = JSON.parse(await fs.readFile(path.join(target, "env", "dev", "collections.json"), "utf8")) as { + collections: Array<{ alias: string }>; + }; + assert.equal(collections.collections[0]?.alias, "content"); +});