feat(extensions): scaffold config templates on extension add/enable - #2000
feat(extensions): scaffold config templates on extension add/enable#2000mvanhorn wants to merge 2 commits into
Conversation
|
On a side note you moved us into the two thousands! ;) |
There was a problem hiding this comment.
Pull request overview
This PR updates the extension lifecycle so that configuration templates declared by an extension (provides.config) are automatically scaffolded into a project’s .specify/ directory during specify extension add and specify extension enable, without overwriting existing user config.
Changes:
- Add
ExtensionManifest.configto exposeprovides.configfromextension.yml. - Add
ExtensionManager.scaffold_config()to copy declared config templates into.specify/, skipping existing files. - Invoke config scaffolding from
extension addandextension enable, and add tests for scaffold behavior.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/specify_cli/extensions.py |
Adds manifest accessor for config and implements scaffold_config() to deploy templates. |
src/specify_cli/__init__.py |
Wires scaffolding into extension add and extension enable CLI flows and prints status output. |
tests/test_extensions.py |
Adds new tests covering config scaffolding deploy/preserve/no-config/missing-template cases. |
docs/screenshots/scaffold-config-demo.gif |
Adds demo asset showing scaffolding behavior. |
Comments suppressed due to low confidence (1)
src/specify_cli/extensions.py:1158
scaffold_config()assumes everyconfig_entryis a dict (config_entry.get(...)). Sinceprovides.configisn’t currently validated, a non-dict entry will raiseAttributeErrorhere. Even with validation, it’s safer to defensively skip non-dict entries (or raiseValidationError) to keep lifecycle commands from crashing on bad extensions.
for config_entry in manifest.config:
template_name = config_entry.get("template", "")
target_name = config_entry.get("name", template_name)
if not template_name:
continue
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Ha, PR #2000! Didn't even notice. Lucky number. |
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback. If not applicable, please explain why.
|
Addressed all Copilot feedback in 86606ef:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
src/specify_cli/extensions.py:1164
template_name/target_nameare assumed to be strings, but manifest data is untrusted input. If either value is non-string (e.g., int/None),Path(template_name)will raiseTypeErrorand abort scaffolding. Add type checks (or coerce tostronly when appropriate) and skip invalid entries to keep scaffolding robust.
for config_entry in manifest.config:
template_name = config_entry.get("template", "")
target_name = config_entry.get("name", template_name)
if not template_name:
continue
src/specify_cli/extensions.py:1174
- The current path traversal protection relies on
Path(...).is_absolute()and checking for'..'segments. This misses some edge cases (e.g., Windows drive-relative paths likeC:foo, and symlinks inside the extension that resolve outsideext_dir). Prefer the sameresolve()+relative_to()containment pattern already used for ZIP extraction in this file, and consider rejecting symlinks for templates before copying.
# Reject path traversal and absolute paths
if Path(template_name).is_absolute() or ".." in Path(template_name).parts:
continue
if Path(target_name).is_absolute() or ".." in Path(target_name).parts:
continue
template_path = ext_dir / template_name
if not template_path.exists() or not template_path.is_file():
continue
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
86606ef to
2197746
Compare
|
Rebased and addressed the review. Since #3014 split On the eight comments:
|
Deploy an extension's provides.config templates into .specify/ when the extension is added or enabled. Existing files are never overwritten, so user customizations are preserved. Addresses the review on github#2000: - ExtensionManifest.config returns [] unless provides.config is a list of dicts, so a malformed manifest cannot crash callers. - scaffold_config returns a consistent (deployed, skipped_existing, failed) tuple on every path, including a missing manifest. - Template paths must resolve inside the extension dir and targets inside .specify/; symlinks and non-regular files are rejected. - Callers distinguish "already exists (preserved)" from "not scaffolded", and extension_enable no longer crashes on a corrupt manifest. - Tests cover traversal, absolute paths, symlinks, directory templates, malformed provides.config, and the missing-manifest tuple shape. Ported onto the extensions package introduced by github#3014: the manager and manifest changes land in extensions/__init__.py and the CLI wiring in extensions/_commands.py.
|
Please address Copilot feedback |
Deploy an extension's provides.config templates into .specify/ when the extension is added or enabled. Existing files are never overwritten, so user customizations are preserved. Addresses the review on github#2000: - ExtensionManifest.config returns [] unless provides.config is a list of dicts, so a malformed manifest cannot crash callers. - scaffold_config returns a consistent (deployed, skipped_existing, failed) tuple on every path, including a missing manifest. - Template paths must resolve inside the extension dir and targets inside .specify/; symlinks and non-regular files are rejected. - Callers distinguish "already exists (preserved)" from "not scaffolded", and extension_enable no longer crashes on a corrupt manifest. - Tests cover traversal, absolute paths, symlinks, directory templates, malformed provides.config, and the missing-manifest tuple shape. Ported onto the extensions package introduced by github#3014: the manager and manifest changes land in extensions/__init__.py and the CLI wiring in extensions/_commands.py.
Addresses @Copilot's review. Config now lands in .specify/extensions/<id>/ rather than the .specify/ root. ConfigManager._get_project_config() reads .specify/extensions/<id>/<id>-config.yml, and the bundled scripts and READMEs use the same path, so a scaffolded git-config.yml was being written somewhere the git extension never looks. Containment is checked component by component before .specify is used as the root. Resolving it first and trusting the result let a symlinked component point outside the project, after which every target satisfied relative_to and copy2 wrote externally. This matches the project safe-write path in shared_infra. mkdir moved inside the OSError handler. A nested target like foo/config.yml raised out of scaffolding when its parent could not be created, and on extension add that happened after the extension was already installed. The 'Configuration may be required' warning is now conditional. It ran unconditionally after the scaffolding block, so it contradicted the success output directly above it and fired for extensions with no provides.config at all. Tests cover the corrected location, a symlinked config root, and an uncreatable nested target.
2197746 to
1db0301
Compare
|
1db0301 addresses these, and rebases onto main since the branch had drifted 75 commits. The config location was the real one. Containment is now checked component by component before
The "Configuration may be required" warning is conditional now. It ran unconditionally right after the scaffolding output, so it contradicted a successful scaffold and fired for extensions with no New tests cover the corrected location, a symlinked config root, and an uncreatable nested target. Suite is 6019 passing. The earlier comments on |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (6)
src/specify_cli/extensions/_commands.py:734
- This condition still emits the generic configuration warning for extensions with no
provides.config, because all three result lists are empty. That contradicts the comment immediately above and makes unaffected extensions appear to need setup. Restrict this warning to actual scaffolding failures.
if failed or not (deployed or skipped):
src/specify_cli/extensions/init.py:2560
nameis required by the manifest API, but defaulting it totemplatemakes a missing name look successful: the template already exists at that same path, so the entry is reported as “skipped/preserved” and no config is deployed. Treat a missing name as malformed instead.
target_name = config_entry.get("name", template_name)
src/specify_cli/extensions/_commands.py:715
- The new tests call
scaffold_config()directly, so the mainextension addlifecycle integration and its CLI behavior are not exercised. Add a CLI test that installs an extension with config and verifies deployment, plus a no-config case so warning regressions in this branch are caught.
# Scaffold config templates automatically
deployed, skipped, failed = manager.scaffold_config(manifest.id)
src/specify_cli/extensions/_commands.py:2244
- No added test re-enables a disabled extension through the CLI; all scaffolding tests invoke the manager directly. Add an enable-path test that removes a generated config, disables/re-enables the extension, and verifies the missing config is restored without altering existing config.
# Scaffold config templates on enable
try:
deployed, skipped, failed = manager.scaffold_config(extension_id)
tests/test_extensions.py:10473
- This unconditionally creates a symlink, but the suite runs on Windows and already uses
can_create_symlink()to skip when the process lacks symlink privileges (for example, tests/test_extensions.py:1437-1443 and 3573-3574). Guard this test the same way or it can fail before exercising scaffolding.
(ext_dir / "config-link.yml").symlink_to(real_template)
tests/test_extensions.py:10527
- Directory symlink creation is also unconditional here. The test matrix includes Windows, while this file’s established pattern is to call
can_create_symlink()and skip when privileges are unavailable (tests/test_extensions.py:52-63, 1437-1443). Add that guard before creating.specify.
(project / ".specify").symlink_to(outside, target_is_directory=True)
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Medium
| template_candidate = ext_dir / template_name | ||
| template_path = template_candidate.resolve() | ||
| target_path = (config_dir / target_name).resolve() |
|
Please address Copilot feedback |
Description
Rework of #1929 per @mnriem's feedback. Instead of a standalone
specify extension initcommand, config scaffolding now runs automatically as part of the extension lifecycle:extension add: after installing an extension, config templates fromprovides.configare deployed to.specify/extension enable: when re-enabling a disabled extension, missing config templates are deployedExisting user-customized config files are never overwritten. Extensions without a
provides.configsection are unaffected.Changes:
ExtensionManifest.configproperty readsprovides.configfrom the manifestExtensionManager.scaffold_config()copies config templates to the project, skipping existing filesextension_addreplaces the "Configuration may be required" warning with automatic deploymentextension_enabledeploys config on re-enableVideo Demo
The demo shows: config template exists in extension dir, no project config yet,
scaffold_configdeploys it, re-running preserves existing config (idempotent).Testing
uv run specify --helpuv sync --extra test && uv run python -m pytest- all 890 tests passAI Disclosure
This contribution was developed with AI assistance (Claude Code + Codex CLI).
Closes #1929