feat: config file support for deterministic re-generation#56
Conversation
- New config module (config.py): find, load, save .bootstrap-iac.yaml - Auto-detects config in workspace root; --config for explicit path - Config values serve as defaults; CLI flags override them - --save-config writes answers to .bootstrap-iac.yaml after generation - Added pyyaml dependency - 21 new tests (unit + CLI integration) covering load, save, roundtrip, normalisation, flag override, auto-detection, dry-run skip - Updated cli/README.md with Config File section and new flags
There was a problem hiding this comment.
Pull request overview
Adds YAML config-file support to the bootstrap-iac CLI so teams can commit interview answers and deterministically re-generate outputs across runs.
Changes:
- Introduces
cli/bootstrap_iac/config.pyto find/load/save.bootstrap-iac.{yaml,yml}config files with value normalisation. - Extends the CLI with
--config(explicit path) and--save-config(persist answers) and merges config defaults into the existing override flow. - Adds a new test suite for config functionality and CLI integration, and documents the config file format in
cli/README.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
cli/bootstrap_iac/config.py |
New config module for discovering, parsing, normalising, and writing config files. |
cli/bootstrap_iac/cli.py |
Wires config defaults + save-config into the CLI execution path. |
cli/tests/test_config.py |
Adds unit + integration tests around config and CLI precedence behavior. |
cli/pyproject.toml |
Adds PyYAML dependency needed for config support. |
cli/README.md |
Documents config file usage and new CLI flags. |
|
|
||
| result = cli_runner.invoke(main, [ | ||
| "--workspace", str(ws), | ||
| "--output-dir", str(tmp_path / "out"), | ||
| "--company", "FlagCorp", | ||
| "--target", "copilot", | ||
| "--non-interactive", | ||
| ]) | ||
| assert result.exit_code == 0 | ||
| # FlagCorp should be used, not ConfigCorp | ||
|
|
||
|
|
There was a problem hiding this comment.
test_cli_flag_overrides_config doesn’t assert that the CLI flag values actually override the config defaults (the test ends after exit_code and a comment). This will pass even if precedence is broken; add an assertion that verifies the generated output (or derived context) reflects --company FlagCorp/--target copilot instead of the config values.
| result = cli_runner.invoke(main, [ | |
| "--workspace", str(ws), | |
| "--output-dir", str(tmp_path / "out"), | |
| "--company", "FlagCorp", | |
| "--target", "copilot", | |
| "--non-interactive", | |
| ]) | |
| assert result.exit_code == 0 | |
| # FlagCorp should be used, not ConfigCorp | |
| out_dir = tmp_path / "out" | |
| result = cli_runner.invoke(main, [ | |
| "--workspace", str(ws), | |
| "--output-dir", str(out_dir), | |
| "--company", "FlagCorp", | |
| "--target", "copilot", | |
| "--non-interactive", | |
| ]) | |
| assert result.exit_code == 0 | |
| # --target copilot should win over config target: both. | |
| assert (out_dir / ".github").exists() | |
| assert not (out_dir / "CLAUDE.md").exists() | |
| # --company FlagCorp should be rendered into generated output instead of ConfigCorp. | |
| generated_text = "\n".join( | |
| path.read_text() | |
| for path in out_dir.rglob("*") | |
| if path.is_file() | |
| ) | |
| assert "FlagCorp" in generated_text | |
| assert "ConfigCorp" not in generated_text |
| "CI_CD_PLATFORM": "GitHub Actions", | ||
| "AUTH_PATTERN": "OIDC", | ||
| "STATE_BACKEND": "azurerm", | ||
| "NAMING_PATTERN": "{prefix}-{resource}-{suffix}", |
There was a problem hiding this comment.
The saved NAMING_PATTERN example uses {resource} here, but the rest of the CLI/interview defaults use {resource_abbreviation}. Using a different placeholder in new tests/examples is likely to confuse users and can propagate an incorrect convention; consider aligning this value with the canonical placeholder name.
| "NAMING_PATTERN": "{prefix}-{resource}-{suffix}", | |
| "NAMING_PATTERN": "{prefix}-{resource_abbreviation}-{suffix}", |
| ci_cd: github-actions | ||
| auth: workload-identity | ||
| state_backend: azurerm | ||
| naming: "${var.prefix}-${var.resource_type}-${local.suffix}" |
There was a problem hiding this comment.
The README config example uses ${var.resource_type} in the naming field, but the CLI’s default naming pattern placeholder is {resource_abbreviation} (see interview defaults). This example should be updated to match the documented/implemented placeholder, otherwise users may commit a config that doesn’t match the generated instructions.
| naming: "${var.prefix}-${var.resource_type}-${local.suffix}" | |
| naming: "${var.prefix}-{resource_abbreviation}-${local.suffix}" |
| company: Acme Corp | ||
| cloud: azure | ||
| module_prefix: tf-module | ||
| orchestration: terragrunt | ||
| orchestration_dir: infrastructure-config |
There was a problem hiding this comment.
The README’s config example shows cloud: azure, orchestration: terragrunt, ci_cd: github-actions, but --save-config currently writes the title-cased interview values (e.g. Azure, Terragrunt, GitHub Actions). Either document that config values are case/format-insensitive or update the example to match the actual saved output to avoid confusion.
| str_val = str(value) | ||
|
|
There was a problem hiding this comment.
load_config coerces all YAML values via str(value). If a user sets a key to null, this becomes the literal string 'None' and will be treated as a real override (breaking enum handling like CLOUD_PROVIDER/TARGET). Consider treating None (and possibly empty/whitespace-only strings) as “unset” and either skipping non-scalar values or raising a validation error.
| str_val = str(value) | |
| if value is None: | |
| continue # treat YAML null as unset | |
| if isinstance(value, str): | |
| str_val = value.strip() | |
| if not str_val: | |
| continue # treat empty/whitespace-only strings as unset | |
| elif isinstance(value, (bool, int, float)): | |
| str_val = str(value) | |
| else: | |
| raise ValueError( | |
| f"Config key '{file_key}' must be a YAML scalar, got {type(value).__name__}" | |
| ) |
| # Normalise known enum values | ||
| if upper_key == "CLOUD_PROVIDER": | ||
| str_val = _CLOUD_MAP.get(str_val.lower(), str_val) | ||
| elif upper_key == "ORCHESTRATION_TOOL": | ||
| str_val = _ORCH_MAP.get(str_val.lower(), str_val) | ||
| elif upper_key == "CI_CD_PLATFORM": |
There was a problem hiding this comment.
Only cloud/orchestration/ci_cd get normalised, but target is passed through as-is. Since generate_files() only recognises copilot|claude|both (lowercase), a config value like Both would result in generating nothing. Consider normalising/validating TARGET (e.g., .lower() + allowed-values check) the same way as the other enums.
| config[file_key] = value | ||
|
|
||
| with open(path, "w", encoding="utf-8") as fh: | ||
| yaml.dump(config, fh, default_flow_style=False, sort_keys=True, allow_unicode=True) |
There was a problem hiding this comment.
save_config uses yaml.dump(...). Since this file is intended to be human-edited config, yaml.safe_dump(...) is a safer default (avoids emitting Python-specific tags if value types ever change) and better matches the use of safe_load on read.
| yaml.dump(config, fh, default_flow_style=False, sort_keys=True, allow_unicode=True) | |
| yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=True, allow_unicode=True) |
| if cfg_file: | ||
| click.echo(f" Loading config: {cfg_file}") | ||
| config_defaults = load_config(cfg_file) |
There was a problem hiding this comment.
load_config(cfg_file) can raise yaml.YAMLError/ValueError (e.g., invalid YAML or non-mapping root), which will currently bubble up as an unhandled exception/traceback. Consider catching these errors here and exiting with a user-friendly message and a non-zero status code (similar to the “config not found” handling).
| if save_config and not dry_run: | ||
| config_out = ws_path / ".bootstrap-iac.yaml" | ||
| write_config(answers, config_out) | ||
| click.echo(f" Saved config: {config_out}") | ||
|
|
There was a problem hiding this comment.
The --save-config help text says the config is written “after generation”, but this block runs before build_context()/generate_files(). If generation fails, the config may still be written, and the behavior doesn’t match the flag description; consider moving the write to after successful generation (and only when not dry-run).
…ngthen tests
- config.py: skip null/empty values, reject non-scalar types, normalise
TARGET to lowercase, use yaml.safe_dump, rename save_config→write_config
- cli.py: catch yaml.YAMLError/ValueError with user-friendly error, move
--save-config after generation, update import to write_config
- test_config.py: strengthen test_cli_flag_overrides_config with real
assertions (output dir checks, company name in generated files), fix
NAMING_PATTERN to use {resource_abbreviation}, rename save_config refs
- README.md: update config example to match --save-config output
(title-cased values, {resource_abbreviation} naming pattern)
| # Normalise known enum values | ||
| if upper_key == "CLOUD_PROVIDER": | ||
| str_val = _CLOUD_MAP.get(str_val.lower(), str_val) | ||
| elif upper_key == "ORCHESTRATION_TOOL": | ||
| str_val = _ORCH_MAP.get(str_val.lower(), str_val) | ||
| elif upper_key == "CI_CD_PLATFORM": | ||
| str_val = _CICD_MAP.get(str_val.lower(), str_val) | ||
| elif upper_key == "TARGET": | ||
| str_val = str_val.lower() | ||
|
|
||
| overrides[upper_key] = str_val |
There was a problem hiding this comment.
load_config() currently normalizes known enum-like values (cloud/orchestration/ci_cd/target) but accepts unknown values without validation. Because run_interview() returns override values without checking them, an invalid target (or other enum) from config can lead to silently generating zero files (e.g., generate_files() filters by target). Consider validating these keys against the supported choice sets and raising ValueError for unknown values so CI/config-driven runs fail fast with a clear message.
| result = cli_runner.invoke(main, [ | ||
| "--workspace", str(ws), | ||
| "--output-dir", str(tmp_path / "out"), | ||
| "--non-interactive", | ||
| ]) | ||
| assert result.exit_code == 0 | ||
| assert "Loading config" in result.output | ||
| assert "ConfigCorp" in result.output or (tmp_path / "out" / ".github").exists() | ||
|
|
||
|
|
There was a problem hiding this comment.
test_cli_loads_config_file() doesn’t actually assert that config values were applied: "ConfigCorp" in result.output is unlikely to be true (generation doesn’t print file contents), and the fallback or (out/.github).exists() will pass even if defaults weren’t read correctly. It would be more robust to read a generated output file (e.g., .github/copilot-instructions.md) and assert it contains ConfigCorp / expected normalized values from the config.
| result = cli_runner.invoke(main, [ | |
| "--workspace", str(ws), | |
| "--output-dir", str(tmp_path / "out"), | |
| "--non-interactive", | |
| ]) | |
| assert result.exit_code == 0 | |
| assert "Loading config" in result.output | |
| assert "ConfigCorp" in result.output or (tmp_path / "out" / ".github").exists() | |
| out_dir = tmp_path / "out" | |
| result = cli_runner.invoke(main, [ | |
| "--workspace", str(ws), | |
| "--output-dir", str(out_dir), | |
| "--non-interactive", | |
| ]) | |
| assert result.exit_code == 0 | |
| assert "Loading config" in result.output | |
| copilot_instructions = out_dir / ".github" / "copilot-instructions.md" | |
| assert copilot_instructions.is_file() | |
| rendered = copilot_instructions.read_text() | |
| assert "ConfigCorp" in rendered | |
| assert "Azure" in rendered |
- load_config() now rejects unsupported values for cloud, orchestration, ci_cd, and target with clear error messages listing valid options - Accepts both slug keys (github-actions) and display values (GitHub Actions) so hand-written and --save-config-generated configs both work - test_cli_loads_config_file asserts ConfigCorp and Azure appear in the rendered copilot-instructions.md output - Added 4 tests for invalid enum value rejection
| # --save-config: write answers to .bootstrap-iac.yaml # | ||
| # ------------------------------------------------------------------ # | ||
| if save_config and not dry_run: | ||
| config_out = ws_path / ".bootstrap-iac.yaml" |
There was a problem hiding this comment.
--save-config always writes to <workspace>/.bootstrap-iac.yaml with mode w, which will overwrite any existing config (including a committed .bootstrap-iac.yml) without warning. Consider writing back to the detected/explicit --config path when present, and/or refusing to overwrite unless --overwrite (or a dedicated --overwrite-config) is set.
| # --save-config: write answers to .bootstrap-iac.yaml # | |
| # ------------------------------------------------------------------ # | |
| if save_config and not dry_run: | |
| config_out = ws_path / ".bootstrap-iac.yaml" | |
| # --save-config: write answers to the detected config path, or default # | |
| # ------------------------------------------------------------------ # | |
| if save_config and not dry_run: | |
| existing_config = find_config(ws_path) | |
| config_out = existing_config or (ws_path / ".bootstrap-iac.yaml") | |
| if config_out.exists() and not overwrite: | |
| raise click.ClickException( | |
| f"Refusing to overwrite existing config: {config_out}. " | |
| "Re-run with --overwrite to replace it." | |
| ) |
| if cfg_file: | ||
| click.echo(f" Loading config: {cfg_file}") | ||
| try: | ||
| config_defaults = load_config(cfg_file) |
There was a problem hiding this comment.
If the config file exists but can’t be read (permissions, broken symlink, etc.), load_config() will raise OSError and the CLI will crash with a traceback because only yaml.YAMLError/ValueError are caught here. Catch OSError as well and surface a friendly Click error message + exit code 1.
| config_defaults = load_config(cfg_file) | |
| config_defaults = load_config(cfg_file) | |
| except OSError as exc: | |
| click.secho(f" ✗ Unable to read config file: {exc}", fg="red") | |
| sys.exit(1) |
| # Values that need normalisation from CLI lowercase to interview title-case | ||
| _CLOUD_MAP: dict[str, str] = {"azure": "Azure", "aws": "AWS", "gcp": "GCP"} | ||
| _ORCH_MAP: dict[str, str] = { | ||
| "terragrunt": "Terragrunt", | ||
| "terramate": "Terramate", | ||
| "pulumi": "Pulumi", | ||
| "none": "None", | ||
| } | ||
| _CICD_MAP: dict[str, str] = { | ||
| "github-actions": "GitHub Actions", | ||
| "azure-devops": "Azure DevOps", | ||
| "gitlab-ci": "GitLab CI", | ||
| "atlantis": "Atlantis", | ||
| } |
There was a problem hiding this comment.
This module re-defines the cloud/orchestration/CI-CD normalization maps that are also defined in bootstrap_iac.cli for flag normalization. Having two sources of truth risks drift (e.g., adding a new CI/CD choice in one place). Consider centralizing these mappings (e.g., export them from this module and have cli.py reuse them).
| "target": "TARGET", | ||
| } | ||
|
|
||
| # Reverse mapping for save_config |
There was a problem hiding this comment.
Comment says “Reverse mapping for save_config”, but the public helper is named write_config. Consider updating the comment to match the current function name to avoid confusion.
| # Reverse mapping for save_config | |
| # Reverse mapping for write_config |
| # --------------------------------------------------------------------------- | ||
| # CLI integration (config file loading via CliRunner) | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| from click.testing import CliRunner | ||
| from bootstrap_iac.cli import main | ||
|
|
||
| cli_runner = CliRunner() | ||
|
|
There was a problem hiding this comment.
Imports for CliRunner and main are placed mid-file after other test definitions. Other test modules in this repo keep imports at the top; moving these to the top improves readability and avoids potential circular-import surprises during collection.
…SError - config.py: fix stale 'save_config' comment → 'write_config' - cli.py: derive Click choices from config.py maps (single source of truth), catch OSError when reading config, --save-config writes back to detected config path and refuses overwrite without --overwrite flag - test_config.py: move mid-file imports to top, add tests for overwrite guard, --overwrite flag, and .yml write-back behaviour
| from bootstrap_iac import __version__ | ||
| from bootstrap_iac.config import ( | ||
| _CICD_MAP, | ||
| _CLOUD_MAP, | ||
| _ORCH_MAP, | ||
| find_config, | ||
| load_config, | ||
| write_config, | ||
| ) |
There was a problem hiding this comment.
cli.py imports _CLOUD_MAP/_ORCH_MAP/_CICD_MAP from bootstrap_iac.config, but these are underscored (private) constants, and cli.py still defines its own _cloud_map/_orch_map/_cicd_map later for normalization. This duplication increases the chance of the CLI choices and normalization drifting apart. Consider making the maps public in config.py (no leading underscore) and reusing a single shared source for both click Choice values and CLI normalization (or moving the shared constants to a dedicated module).
| # --save-config: write answers to config file # | ||
| # ------------------------------------------------------------------ # | ||
| if save_config and not dry_run: | ||
| config_out = cfg_file if cfg_file else (ws_path / ".bootstrap-iac.yaml") | ||
| if config_out.exists() and not overwrite: | ||
| click.secho( | ||
| f" ✗ Refusing to overwrite existing config: {config_out}. " | ||
| "Re-run with --overwrite to replace it.", |
There was a problem hiding this comment.
--save-config relies on the global --overwrite flag to permit overwriting an existing config file, but the --overwrite help text (and README option table) only mentions generated files. This makes the flag semantics ambiguous for users. Consider either documenting that --overwrite also applies to the config file when --save-config is set, or introducing a dedicated flag (e.g., --overwrite-config) to keep behaviors explicit.
| # --save-config: write answers to config file # | |
| # ------------------------------------------------------------------ # | |
| if save_config and not dry_run: | |
| config_out = cfg_file if cfg_file else (ws_path / ".bootstrap-iac.yaml") | |
| if config_out.exists() and not overwrite: | |
| click.secho( | |
| f" ✗ Refusing to overwrite existing config: {config_out}. " | |
| "Re-run with --overwrite to replace it.", | |
| # --save-config: write answers to config file. # | |
| # The global --overwrite flag also applies to the saved config file. # | |
| # ------------------------------------------------------------------ # | |
| if save_config and not dry_run: | |
| config_out = cfg_file if cfg_file else (ws_path / ".bootstrap-iac.yaml") | |
| if config_out.exists() and not overwrite: | |
| click.secho( | |
| f" ✗ Refusing to overwrite existing config: {config_out}. " | |
| "Re-run with --overwrite to replace it; this global flag " | |
| "applies to both generated files and the saved config.", |
| config[file_key] = value | ||
|
|
||
| with open(path, "w", encoding="utf-8") as fh: | ||
| yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=True, allow_unicode=True) |
There was a problem hiding this comment.
write_config() sorts _REVERSE_KEY_MAP.items() and then calls yaml.safe_dump(..., sort_keys=True), which is redundant (and can make the intended ordering unclear). Consider choosing one ordering mechanism: either rely on sort_keys=True without pre-sorting, or set sort_keys=False and keep the explicit sort in Python.
| yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=True, allow_unicode=True) | |
| yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False, allow_unicode=True) |
…rite scope - config.py: rename _CLOUD_MAP/_ORCH_MAP/_CICD_MAP to public CLOUD_MAP/ ORCH_MAP/CICD_MAP; remove redundant sort_keys=True from write_config - cli.py: remove duplicate local normalization maps, reuse config.py maps for both Click choices and flag normalization; update --overwrite help to clarify it applies to both generated files and saved config - README.md: update --overwrite description to match
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
cli/bootstrap_iac/cli.py:40
get_templates_diris imported but never used in this module (searching the file shows no references beyond the import). Consider removing the unused import to avoid confusion and keep the CLI entrypoint minimal.
)
from bootstrap_iac.discovery import scan_workspace
from bootstrap_iac.generator import generate_files, get_templates_dir
from bootstrap_iac.interview import build_context, run_interview
from bootstrap_iac.validator import validate_directory, validate_file
| "auth": "AUTH_PATTERN", | ||
| "state_backend": "STATE_BACKEND", | ||
| "naming": "NAMING_PATTERN", | ||
| "tag_strategy": "TAG_STRATEGY", |
There was a problem hiding this comment.
The config key mapping does not include STANDARD_VARIABLES, but run_interview() collects this answer and build_context() consumes it for template rendering. Without supporting it here (load + write), --save-config can’t round-trip all interview answers, so re-generation won’t be deterministic for users who customized standard variables. Consider adding a standard_variables key to _KEY_MAP/_REVERSE_KEY_MAP, updating load_config() to accept it, and extending tests + README example accordingly.
| "tag_strategy": "TAG_STRATEGY", | |
| "tag_strategy": "TAG_STRATEGY", | |
| "standard_variables": "STANDARD_VARIABLES", |
- config.py: add standard_variables → STANDARD_VARIABLES to _KEY_MAP - README.md: add standard_variables to config file example - test_config.py: add STANDARD_VARIABLES to all_fields and roundtrip tests
| "--save-config", | ||
| is_flag=True, | ||
| default=False, | ||
| help="Write interview answers to .bootstrap-iac.yaml in the workspace after generation.", |
There was a problem hiding this comment.
--save-config help text says it writes to .bootstrap-iac.yaml in the workspace, but the implementation writes to cfg_file when --config PATH is provided (which may be outside the workspace). Either change the behavior to always write to the workspace (and only use detected .yml/.yaml there), or update the flag help/README to document that --save-config writes back to the loaded --config path when provided.
| help="Write interview answers to .bootstrap-iac.yaml in the workspace after generation.", | |
| help=( | |
| "Write interview answers after generation. Saves back to --config PATH " | |
| "when provided; otherwise writes .bootstrap-iac.yaml in the workspace." | |
| ), |
| if save_config and not dry_run: | ||
| config_out = cfg_file if cfg_file else (ws_path / ".bootstrap-iac.yaml") | ||
| if config_out.exists() and not overwrite: | ||
| click.secho( | ||
| f" \u2717 Refusing to overwrite existing config: {config_out}. " | ||
| "Re-run with --overwrite to replace it; this flag " | ||
| "applies to both generated files and the saved config.", | ||
| fg="red", | ||
| ) | ||
| else: | ||
| write_config(answers, config_out) | ||
| click.echo(f" Saved config: {config_out}") |
There was a problem hiding this comment.
--save-config uses config_out = cfg_file if cfg_file else (ws_path / ".bootstrap-iac.yaml"). When --config points to an arbitrary path (e.g., a shared config outside the workspace), this will overwrite that file, which contradicts the current --save-config help text and could be surprising. Consider writing only to the workspace root by default (or gating write-back to cfg_file behind an explicit flag), and documenting the chosen precedence.
| def write_config(answers: dict[str, str], path: Path) -> None: | ||
| """Write interview answers to a YAML config file.""" |
There was a problem hiding this comment.
PR description mentions a save_config(answers, path) helper, but the implementation exports write_config(...) and tests import write_config. To avoid confusion for API consumers and future maintainers, align the function name with the documented API (rename to save_config, or update the PR description/docs to consistently use write_config).
- cli.py: --save-config help now documents it writes back to --config PATH when provided, otherwise .bootstrap-iac.yaml in the workspace - README.md: update options table to match Note: PR description references save_config() but the code consistently uses write_config() — the rename happened during earlier review rounds.
| if config_path: | ||
| cfg_file = Path(config_path).resolve() | ||
| if not cfg_file.is_file(): | ||
| click.secho(f" ✗ Config file not found: {cfg_file}", fg="red") | ||
| sys.exit(1) | ||
| else: | ||
| cfg_file = find_config(ws_path) | ||
|
|
||
| if cfg_file: |
There was a problem hiding this comment.
--config PATH currently requires the file to already exist (exits 1 if missing). That prevents a common workflow implied by --save-config (generate answers and write a new config to the provided PATH). Consider only requiring existence when you actually intend to load defaults (e.g., when --config is provided without --save-config, or when the file exists); otherwise treat a missing file as “no defaults” and allow write_config to create it after generation.
| if config_path: | |
| cfg_file = Path(config_path).resolve() | |
| if not cfg_file.is_file(): | |
| click.secho(f" ✗ Config file not found: {cfg_file}", fg="red") | |
| sys.exit(1) | |
| else: | |
| cfg_file = find_config(ws_path) | |
| if cfg_file: | |
| should_load_config = False | |
| if config_path: | |
| cfg_file = Path(config_path).resolve() | |
| if cfg_file.is_file(): | |
| should_load_config = True | |
| elif not save_config: | |
| click.secho(f" ✗ Config file not found: {cfg_file}", fg="red") | |
| sys.exit(1) | |
| else: | |
| cfg_file = find_config(ws_path) | |
| should_load_config = cfg_file is not None | |
| if should_load_config and cfg_file: |
Summary
Closes #52 — adds config file support for deterministic re-generation.
Changes
New module
cli/bootstrap_iac/config.py:find_config(workspace)— auto-detects.bootstrap-iac.yaml/.bootstrap-iac.ymlin workspace rootload_config(path)— loads YAML config, normalises values (e.g.azure→Azure,github-actions→GitHub Actions)save_config(answers, path)— writes interview answers back to YAMLCLI integration (
cli.py):--config PATHflag to specify a config file explicitly--save-configflag to write answers after generation--dry-runAdded
pyyaml>=6.0dependency (pyproject.toml)21 new tests (
cli/tests/test_config.py):find_config: auto-detection, priority (.yaml over .yml), missing fileload_config: basic load, normalisation (cloud/orchestration/cicd), unknown keys, empty file, non-mapping rejection, all fieldssave_config: roundtrip, skips empty values, save-then-load roundtrip--configpath, missing config error,--save-config, dry-run skips saveUpdated
cli/README.md: new Config File section, added--configand--save-configto options tableConfig File Format
Precedence Order
CLI flags > config file > discovery defaults > interactive prompts
Test Results
132 tests passing (111 existing + 21 new)