Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ 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 <alias>` for local-only environment creation after project initialization.
- `compose init` no longer seeds a default "wordpress" collection unless `--collection` is explicitly provided.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ You can also pass `--clientId`, `--clientSecret`, `--scope`, and `--audience` on
- `compose plan`
- `compose apply`
- `compose env rename <from> <to>`
- `compose env add <alias>`
- `compose env list-remote`
- `compose env add-remote <alias>`

Expand Down
15 changes: 14 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This document defines the intended command behavior for the core workflow:
- `compose validate`
- `compose clone`
- `compose pull`
- `compose env add <alias>`
- `compose env add-remote`
- `compose env list-remote`
- `compose status`
Expand Down Expand Up @@ -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 <alias>`
- 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`

Expand Down
2 changes: 1 addition & 1 deletion docs/release-v1-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Scope Freeze
- Confirm command scope for `v1.0.0`:
- `init`, `generate`, `validate`, `clone`, `pull`, `status`, `plan`, `apply`, `env rename`, `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`
Expand Down
6 changes: 6 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <env> --pull`
- for a brand-new local environment (not yet remote): `compose env add <env>`
4. Review/edit generated files:
- optional `compose generate <entity>` for new resources
- manual edits in `env/<env>/...`
Expand Down
128 changes: 128 additions & 0 deletions src/commands/env-add.ts
Original file line number Diff line number Diff line change
@@ -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 <alias>",
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<ComposeConfig> {
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<void> {
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");
}
4 changes: 3 additions & 1 deletion src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subcommand>",
Expand All @@ -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 () => {}
};
7 changes: 3 additions & 4 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type Args = {
dir: string;
project?: string;
env: string;
collection: string;
collection?: string;
force: boolean;
merge: boolean;
gitignore: boolean;
Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions src/iac/templates.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type InitTemplateOptions = {
project?: string | undefined;
envs: string[];
defaultCollection: string;
defaultCollection?: string;
};

export function composeYaml(opts: InitTemplateOptions) {
Expand All @@ -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 } : {})
};
}

Expand All @@ -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: [
{
Expand Down
109 changes: 109 additions & 0 deletions test/commands.env-add.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
try {
await fs.stat(p);
return true;
} catch {
return false;
}
}
Loading