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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ Generates projects with:
Install with [Homebrew](https://brew.sh/):

```bash
brew install uv node git docker aws-cdk
brew install uv node git docker docker-compose colima aws-cdk
```

You also need SSH access to the `co-cddo` GitHub organisation (for private CDK construct dependencies).

`idea-app init` will check all prerequisites are installed before creating a project. If anything is missing, it will tell you what to install.

## Installation

`idea-app` is installed as a global CLI tool, not as a per-project dependency:
Expand Down Expand Up @@ -174,6 +176,32 @@ aws_role_arn = "arn:aws:iam::123456789012:role/your-dev-role"
aws_region = "eu-west-2"
```

## Troubleshooting

### `docker compose` not found / unknown shorthand flag `-f`

If `docker compose version` fails or you see `unknown shorthand flag: 'f' in -f` when running `idea-app smoke-test`, the Docker Compose plugin is not registered with the Docker CLI.

`brew install docker-compose` installs the binary but does not automatically register it as a Docker CLI plugin. You need to tell Docker where to find it.

Add the following to `~/.docker/config.json` (create the file if it doesn't exist):

```json
{
"cliPluginsExtraDirs": [
"/opt/homebrew/lib/docker/cli-plugins"
]
}
```

Then verify:

```bash
docker compose version
```

This should print something like `Docker Compose version v2.x.x`.

## Development

```bash
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "gds-idea-app-kit"
version = "0.1.0"
version = "0.2.0"
description = "CLI tool for scaffolding and maintaining GDS IDEA web apps on AWS"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -16,6 +16,7 @@ dependencies = [

[project.scripts]
idea-app = "gds_idea_app_kit.cli:cli"
idea_app = "gds_idea_app_kit.cli:cli"

[tool.hatch.build.targets.wheel]
packages = ["src/gds_idea_app_kit"]
Expand Down
17 changes: 16 additions & 1 deletion src/gds_idea_app_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,23 @@

from gds_idea_app_kit import DEFAULT_PYTHON_VERSION, __version__

# Allow underscores as aliases for hyphenated commands, so that both
# ``idea-app smoke-test`` and ``idea-app smoke_test`` work.
_ALIASES: dict[str, str] = {
"smoke_test": "smoke-test",
"provide_role": "provide-role",
}

@click.group()

class AliasGroup(click.Group):
"""Click group that silently resolves underscore command aliases."""

def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
cmd_name = _ALIASES.get(cmd_name, cmd_name)
return super().get_command(ctx, cmd_name)


@click.group(cls=AliasGroup)
@click.version_option(version=__version__, prog_name="idea-app")
def cli():
"""GDS IDEA App Kit - scaffold and maintain web apps on AWS."""
Expand Down
4 changes: 4 additions & 0 deletions src/gds_idea_app_kit/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
__version__,
)
from gds_idea_app_kit.manifest import build_manifest, write_manifest
from gds_idea_app_kit.prerequisites import check_prerequisites


def _sanitize_app_name(name: str) -> str:
Expand Down Expand Up @@ -217,6 +218,9 @@ def run_init(framework: str, app_name: str, python_version: str) -> None:
click.echo(f"Error: Directory already exists: {project_dir}", err=True)
sys.exit(1)

# -- Check prerequisites before creating anything --
check_prerequisites()

click.echo(f"Scaffolding {framework} app: {app_name}")
click.echo(f" Directory: {repo_name}/")
click.echo(f" Python: {python_version}")
Expand Down
6 changes: 6 additions & 0 deletions src/gds_idea_app_kit/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import re
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path
Expand Down Expand Up @@ -199,6 +200,11 @@ def run_migrate() -> None:
click.echo("Removing template/ directory...")
_remove_template_dir(project_dir)

# Sync the environment so old entry points (smoke_test, configure, etc.)
# installed via [project.scripts] are removed.
click.echo("Syncing environment...")
subprocess.run(["uv", "sync"], cwd=project_dir, check=True, capture_output=True, text=True)

click.echo("Migration complete.")
click.echo()

Expand Down
62 changes: 62 additions & 0 deletions src/gds_idea_app_kit/prerequisites.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Pre-requisite checks for external tools.

Verifies that tools like cdk, uv, git, docker, and the docker compose
plugin are installed and available before starting work. Used by both
``init`` (checks everything) and ``smoke-test`` (checks docker only).
"""

import subprocess
import sys

import click

# Each entry is (display_name, check_command, install_hint, optional_url).
PREREQUISITES: list[tuple[str, list[str], str, str | None]] = [
("cdk", ["cdk", "--version"], "brew install aws-cdk", None),
("uv", ["uv", "--version"], "brew install uv", None),
("git", ["git", "--version"], "brew install git", None),
("docker", ["docker", "--version"], "brew install docker", None),
(
"docker compose",
["docker", "compose", "version"],
"brew install docker-compose",
"https://github.com/co-cddo/gds-idea-app-kit#docker-compose-not-found--unknown-shorthand-flag--f",
),
]


def check_prerequisites(only: list[str] | None = None) -> None:
"""Verify that required external tools are installed.

Checks every tool and reports all missing ones at once, so the user
can fix everything in a single pass rather than hitting errors one
at a time.

Args:
only: If provided, only check tools whose display name is in
this list. If None, check all prerequisites.
"""
to_check = PREREQUISITES
if only is not None:
to_check = [p for p in PREREQUISITES if p[0] in only]

missing: list[tuple[str, str, str | None]] = []

for name, check_cmd, install_hint, url in to_check:
try:
subprocess.run(check_cmd, capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
missing.append((name, install_hint, url))

if not missing:
return

click.echo("Error: missing required tools:", err=True)
click.echo("", err=True)
for name, hint, url in missing:
click.echo(f" {name:20s} {hint}", err=True)
if url:
click.echo(f" {'':20s} {url}", err=True)
click.echo("", err=True)
click.echo("Install the missing tools and try again.", err=True)
sys.exit(1)
5 changes: 5 additions & 0 deletions src/gds_idea_app_kit/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

import click

from gds_idea_app_kit.prerequisites import check_prerequisites

COMPOSE_FILE = ".devcontainer/docker-compose.yml"
SERVICE_NAME = "app"
CONTAINER_PORT = 8080
Expand Down Expand Up @@ -175,6 +177,9 @@ def run_smoke_test(build_only: bool, wait: bool = False) -> None:
"""
project_dir = Path.cwd()

# -- Check docker compose is available --
check_prerequisites(only=["docker", "docker compose"])

# -- Validate configuration --
click.echo("Loading configuration...")
framework = _get_framework(project_dir)
Expand Down
26 changes: 26 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,29 @@ def test_migrate_runs(cli_runner):
def test_unknown_command(cli_runner):
result = cli_runner.invoke(cli, ["nonexistent"])
assert result.exit_code != 0


# ---- underscore aliases ----


def test_smoke_test_underscore_alias(cli_runner):
"""smoke_test (underscore) works as an alias for smoke-test."""
with patch("gds_idea_app_kit.smoke_test.run_smoke_test") as mock:
result = cli_runner.invoke(cli, ["smoke_test"])
assert result.exit_code == 0
mock.assert_called_once_with(build_only=False, wait=False)


def test_provide_role_underscore_alias(cli_runner):
"""provide_role (underscore) works as an alias for provide-role."""
with patch("gds_idea_app_kit.provide_role.run_provide_role") as mock:
result = cli_runner.invoke(cli, ["provide_role"])
assert result.exit_code == 0
mock.assert_called_once_with(use_profile=False, duration=3600)


def test_underscore_aliases_not_in_help(cli_runner):
"""Underscore aliases do not appear in --help output."""
result = cli_runner.invoke(cli, ["--help"])
assert "smoke_test" not in result.output
assert "provide_role" not in result.output
29 changes: 28 additions & 1 deletion tests/test_migrate.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the migrate command."""

import os
import subprocess
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -239,9 +240,13 @@ def test_migrate_full_flow(old_project):
os.chdir(old_project)

# Simulate user confirming migration but declining update
with patch("gds_idea_app_kit.migrate.click") as mock_click:
with (
patch("gds_idea_app_kit.migrate.click") as mock_click,
patch("gds_idea_app_kit.migrate.subprocess.run") as mock_run,
):
mock_click.confirm = lambda msg, **kwargs: msg.startswith("Continue")
mock_click.echo = click_echo_noop
mock_run.return_value = subprocess.CompletedProcess([], 0)

run_migrate()

Expand Down Expand Up @@ -309,6 +314,28 @@ def test_migrate_aborts_on_decline(old_project):
assert read_manifest(old_project) == {}


# ---- uv sync ----


def test_migrate_runs_uv_sync(old_project):
"""Migration runs 'uv sync' to remove old entry points from the environment."""
os.chdir(old_project)

with (
patch("gds_idea_app_kit.migrate.click") as mock_click,
patch("gds_idea_app_kit.migrate.subprocess.run") as mock_run,
):
mock_click.confirm = lambda msg, **kwargs: msg.startswith("Continue")
mock_click.echo = click_echo_noop
mock_run.return_value = subprocess.CompletedProcess([], 0)

run_migrate()

mock_run.assert_called_once_with(
["uv", "sync"], cwd=old_project, check=True, capture_output=True, text=True
)


# ---- helper ----


Expand Down
Loading