mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56515ff610 | ||
|
|
903d707d21 | ||
|
|
f6f3540409 | ||
|
|
9d96c62901 | ||
|
|
a3bcd67925 | ||
|
|
5c90a0547e | ||
|
|
c2af5c5a52 | ||
|
|
ba1f13a8b1 | ||
|
|
e59da78677 | ||
|
|
a10fd2f355 | ||
|
|
1be42992e6 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.12] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
|
||||
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
|
||||
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
|
||||
- docs(bundles): document --integration on 'bundle update' (#3271)
|
||||
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
|
||||
- Add Verify Review Ship extension to community catalog (#3450)
|
||||
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
|
||||
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
|
||||
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
|
||||
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
|
||||
|
||||
## [0.12.11] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -148,6 +148,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
|
||||
@@ -51,10 +51,11 @@ If the current directory is not yet a Spec Kit project, `install` initializes on
|
||||
specify bundle update [<bundle_id>]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------ | ------------------------------------ |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--offline` | Do not access the network |
|
||||
| Option | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
|
||||
| `--offline` | Do not access the network |
|
||||
|
||||
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
@@ -219,6 +219,7 @@ Some integrations accept additional options via `--integration-options`:
|
||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
||||
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -4356,6 +4356,40 @@
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"verify-review-ship": {
|
||||
"name": "Verify Review Ship",
|
||||
"id": "verify-review-ship",
|
||||
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
|
||||
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"review",
|
||||
"shipping",
|
||||
"workflow",
|
||||
"testing"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
"id": "verify-tasks",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.11"
|
||||
version = "0.12.12"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -746,11 +746,16 @@ def _resolve_manifest_path(path: Path | None) -> Path:
|
||||
def _download_manifest(resolved, *, offline: bool):
|
||||
"""Resolve a bundle's manifest from its catalog ``download_url``.
|
||||
|
||||
Local/``file://`` URLs always work offline and may point at a ``.zip``
|
||||
artifact, a bundle directory, or a ``bundle.yml`` (handled by
|
||||
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
|
||||
the shared authenticated, redirect-validated HTTP client, and only when not
|
||||
``--offline``.
|
||||
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost),
|
||||
matching the extensions/presets/workflows catalog systems. Remote URLs are
|
||||
fetched with the shared authenticated, redirect-validated HTTP client, and
|
||||
only when not ``--offline``.
|
||||
|
||||
Local and ``file://`` sources are intentionally not resolved here: to
|
||||
install a bundle from disk, pass the path positionally
|
||||
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
|
||||
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
|
||||
before catalog resolution and which never touches ``download_url``.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -763,26 +768,35 @@ def _download_manifest(resolved, *, offline: bool):
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
# On Windows an absolute path like ``C:\bundle.yml`` parses with a
|
||||
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
|
||||
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
|
||||
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme)
|
||||
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
|
||||
# every catalog system; installing from disk is done by passing the path
|
||||
# positionally, which never reaches URL resolution. Give an actionable
|
||||
# error rather than accepting a scheme the rest of the codebase rejects.
|
||||
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
|
||||
local = Path(parsed.path if scheme == "file" else url)
|
||||
manifest = _local_manifest_source(str(local))
|
||||
if manifest is None:
|
||||
raise BundlerError(f"Bundle manifest not found: {local}")
|
||||
return manifest
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url "
|
||||
f"({url}); catalog download URLs must be HTTPS (http for localhost) — "
|
||||
"a file:// URL, a local filesystem path, or a scheme-less value "
|
||||
"(e.g. 'example.com/bundle.zip') is not accepted. "
|
||||
"To install a bundle from disk, pass the path directly: "
|
||||
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
|
||||
)
|
||||
|
||||
if scheme in ("http", "https"):
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
# Validate the scheme/host *before* the offline gate so an invalid or
|
||||
# non-HTTPS download_url reports the real problem in every mode, rather
|
||||
# than a misleading "Network access disabled" under --offline.
|
||||
# (_download_remote_manifest re-checks this, but only once network access
|
||||
# is permitted.) HTTPS-only, http allowed for localhost.
|
||||
_require_https(f"bundle '{resolved.entry.id}'", url)
|
||||
|
||||
raise BundlerError(
|
||||
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
|
||||
)
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
|
||||
|
||||
def _require_https(label: str, url: str) -> None:
|
||||
|
||||
@@ -2755,18 +2755,32 @@ class ConfigManager:
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
# Remove prefix and split into parts
|
||||
config_path = key[len(prefix) :].lower().split("_")
|
||||
# Remove prefix and split into parts. Drop empty components from a
|
||||
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
|
||||
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
|
||||
# entry under an empty key.
|
||||
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
|
||||
if not config_path:
|
||||
continue
|
||||
|
||||
# Build nested dict
|
||||
# Build nested dict. Two env vars can collide on a prefix, e.g.
|
||||
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
|
||||
# walk so a colliding scalar is replaced by a dict (deeper/more
|
||||
# specific vars win) instead of being indexed into — which raised
|
||||
# TypeError ('str' object does not support item assignment) — and
|
||||
# guard the leaf so a scalar processed after the nested var does
|
||||
# not clobber the nested dict. Order-independent: both insertion
|
||||
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
|
||||
# _merge_configs' dict-preserving semantics.
|
||||
current = env_config
|
||||
for part in config_path[:-1]:
|
||||
if part not in current:
|
||||
if not isinstance(current.get(part), dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Set the final value
|
||||
current[config_path[-1]] = value
|
||||
# Set the final value, unless a nested dict already occupies it.
|
||||
if not isinstance(current.get(config_path[-1]), dict):
|
||||
current[config_path[-1]] = value
|
||||
|
||||
return env_config
|
||||
|
||||
|
||||
@@ -1566,7 +1566,14 @@ def extension_set_priority(
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
|
||||
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
|
||||
# equals 1/0 here and is never repaired.
|
||||
if (
|
||||
isinstance(raw_priority, int)
|
||||
and not isinstance(raw_priority, bool)
|
||||
and raw_priority == priority
|
||||
):
|
||||
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@@ -469,7 +469,14 @@ def preset_set_priority(
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
|
||||
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
|
||||
# equals 1/0 here and is never repaired.
|
||||
if (
|
||||
isinstance(raw_priority, int)
|
||||
and not isinstance(raw_priority, bool)
|
||||
and raw_priority == priority
|
||||
):
|
||||
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@@ -76,8 +76,16 @@ class WorkflowRegistry:
|
||||
if self.registry_path.exists():
|
||||
try:
|
||||
with open(self.registry_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
data = json.load(f)
|
||||
# Validate shape: must be a dict with a dict "workflows" field,
|
||||
# otherwise every method that indexes data["workflows"] crashes.
|
||||
# Mirrors StepRegistry._load.
|
||||
if not isinstance(data, dict):
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
if not isinstance(data.get("workflows"), dict):
|
||||
data["workflows"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
|
||||
# Corrupted registry file — reset to default
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
@@ -438,9 +446,9 @@ class WorkflowCatalog:
|
||||
q = query.lower()
|
||||
searchable = " ".join(
|
||||
[
|
||||
wf_data.get("name", ""),
|
||||
wf_data.get("description", ""),
|
||||
wf_data.get("id", ""),
|
||||
str(wf_data.get("name") or ""),
|
||||
str(wf_data.get("description") or ""),
|
||||
str(wf_data.get("id") or ""),
|
||||
]
|
||||
).lower()
|
||||
if q not in searchable:
|
||||
|
||||
@@ -982,7 +982,16 @@ class WorkflowEngine:
|
||||
from .expressions import evaluate_condition
|
||||
|
||||
max_iters = step_config.get("max_iterations")
|
||||
if not isinstance(max_iters, int) or max_iters < 1:
|
||||
# A bool is an int in Python (isinstance(True, int) is True
|
||||
# and True == 1), so a bool max_iterations would slip past
|
||||
# the int check and cap the loop at range(0)==1 iteration
|
||||
# instead of the default. Exclude bools, mirroring the
|
||||
# while/do-while validators and the continue_on_error guard.
|
||||
if (
|
||||
isinstance(max_iters, bool)
|
||||
or not isinstance(max_iters, int)
|
||||
or max_iters < 1
|
||||
):
|
||||
max_iters = 10
|
||||
condition = step_config.get("condition", False)
|
||||
for _loop_iter in range(max_iters - 1):
|
||||
|
||||
@@ -175,7 +175,23 @@ def test_build_produces_artifact(project: Path):
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path):
|
||||
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
|
||||
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
|
||||
|
||||
Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
|
||||
point one at a local file. Patch ``_download_manifest`` to return the
|
||||
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
|
||||
exercising ``info``'s expansion without a network call.
|
||||
"""
|
||||
from specify_cli.commands.bundle import _local_manifest_source
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.commands.bundle._download_manifest",
|
||||
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
|
||||
)
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path, monkeypatch):
|
||||
bundle_dir = project / "src-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
@@ -183,13 +199,14 @@ def test_info_expands_full_component_set(project: Path):
|
||||
)
|
||||
catalog = project / "local-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -207,7 +224,7 @@ def test_info_expands_full_component_set(project: Path):
|
||||
assert "Trust" in text.output
|
||||
|
||||
|
||||
def test_info_expands_discovery_only_bundle(project: Path):
|
||||
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
|
||||
# Discovery-only bundles must still be fully inspectable via `info`;
|
||||
# only `install` is refused for them.
|
||||
bundle_dir = project / "disc-bundle"
|
||||
@@ -217,7 +234,7 @@ def test_info_expands_discovery_only_bundle(project: Path):
|
||||
)
|
||||
catalog = project / "disc-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
config = {
|
||||
@@ -230,6 +247,7 @@ def test_info_expands_discovery_only_bundle(project: Path):
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
@@ -237,8 +255,9 @@ def test_info_expands_discovery_only_bundle(project: Path):
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_info_resolves_local_zip_download_url(project: Path):
|
||||
# A local .zip artifact as download_url is extracted to read bundle.yml.
|
||||
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
|
||||
# A .zip artifact is extracted to read bundle.yml; info expands it. (The
|
||||
# download itself is HTTPS-only now and mocked here — see contract note.)
|
||||
bundle_dir = project / "zip-src"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
@@ -249,12 +268,15 @@ def test_info_resolves_local_zip_download_url(project: Path):
|
||||
catalog = project / "zip-catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
|
||||
{"demo-bundle": catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)},
|
||||
)
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
_mock_manifest_download(monkeypatch, artifact)
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
|
||||
@@ -112,3 +112,62 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
assert not ExtensionManager(project).registry.is_installed("agent-context")
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
|
||||
def test_download_manifest_rejects_file_url(tmp_path: Path):
|
||||
"""A catalog ``file://`` download_url is rejected — catalog URLs are
|
||||
HTTPS-only, matching extensions/presets/workflows. Disk installs go through
|
||||
the positional path (see the local-source tests above), not download_url.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
manifest_path = write_manifest(tmp_path / "my bundles")
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri())
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="bundle install"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_download_manifest_rejects_bare_path(tmp_path: Path):
|
||||
"""A bare filesystem path download_url is likewise rejected."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
manifest_path = write_manifest(tmp_path / "plain")
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path))
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="bundle install"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_local_install_still_resolves_via_positional_path(tmp_path: Path):
|
||||
"""The supported local route — a positional path, not a download_url —
|
||||
still resolves the manifest via _local_manifest_source."""
|
||||
manifest_path = write_manifest(tmp_path / "my bundles")
|
||||
manifest = _local_manifest_source(str(manifest_path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
|
||||
"""A non-HTTPS download_url must report the HTTPS problem, not a misleading
|
||||
'Network access disabled', even under --offline (scheme is validated before
|
||||
the offline gate)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from specify_cli.commands.bundle import _download_manifest
|
||||
|
||||
resolved = SimpleNamespace(
|
||||
entry=SimpleNamespace(
|
||||
id="demo-bundle", download_url="http://example.com/bundle.zip"
|
||||
)
|
||||
)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
@@ -6424,6 +6424,42 @@ class TestExtensionPriorityCLI:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_repairs_corrupted_bool(self, extension_dir, project_dir):
|
||||
"""A corrupted boolean priority must be repaired, not skipped.
|
||||
|
||||
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
|
||||
stored ``True`` priority would short-circuit the ``already has
|
||||
priority 1`` skip path and never get rewritten to a real int —
|
||||
contradicting the comment that promises corrupted values are
|
||||
repaired. The guard must exclude bools (like normalize_priority).
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False, priority=5
|
||||
)
|
||||
# Inject a corrupted boolean priority (True == 1).
|
||||
manager.registry.update("test-ext", {"priority": True})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "1"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
# The corrupted bool must be repaired, not reported as already-set.
|
||||
assert "already has priority" not in plain
|
||||
assert "priority changed" in plain
|
||||
|
||||
# The stored value is now a real int, not a bool.
|
||||
reloaded = ExtensionManager(project_dir).registry.get("test-ext")
|
||||
assert reloaded["priority"] == 1
|
||||
assert not isinstance(reloaded["priority"], bool)
|
||||
|
||||
def test_set_priority_invalid_value(self, extension_dir, project_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -7628,3 +7664,56 @@ class TestConfigManagerNonMappingYaml:
|
||||
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
|
||||
executor = HookExecutor(tmp_path)
|
||||
assert executor._evaluate_condition("config.x is set", "jira") is False
|
||||
|
||||
|
||||
class TestConfigManagerEnvPrefixCollision:
|
||||
"""Prefix-colliding env vars must not crash or clobber nested config."""
|
||||
|
||||
def test_scalar_then_nested_yields_nested(self, tmp_path, monkeypatch):
|
||||
"""SPECKIT_X_CONNECTION=x then SPECKIT_X_CONNECTION_URL=y.
|
||||
|
||||
The scalar-first order previously raised TypeError ('str' object
|
||||
does not support item assignment) when the walk indexed into 'x'.
|
||||
"""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
assert cm._get_env_config() == {"connection": {"url": "y"}}
|
||||
|
||||
def test_nested_then_scalar_does_not_clobber(self, tmp_path, monkeypatch):
|
||||
"""Reverse order previously returned {'connection': 'x'}, losing url."""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
assert cm._get_env_config() == {"connection": {"url": "y"}}
|
||||
|
||||
def test_colliding_env_does_not_disable_hook_condition(self, tmp_path, monkeypatch):
|
||||
"""`config.connection.url is set` must stay True under colliding env.
|
||||
|
||||
Before the fix the TypeError propagated into should_execute_hook's
|
||||
blanket `except Exception: return False`, silently disabling the hook.
|
||||
"""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "testext"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "testext-config.yml").write_text(
|
||||
"connection:\n url: https://example.com\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
|
||||
executor = HookExecutor(tmp_path)
|
||||
# Exercise the public API: before the fix the TypeError was swallowed
|
||||
# by should_execute_hook's `except Exception: return False`, so the
|
||||
# hook was silently disabled (False); after the fix it returns True.
|
||||
assert executor.should_execute_hook(
|
||||
{"condition": "config.connection.url is set", "extension": "testext"}
|
||||
) is True
|
||||
|
||||
def test_malformed_env_names_ignored(self, tmp_path, monkeypatch):
|
||||
"""A name with no key (SPECKIT_X_) or empty parts (consecutive
|
||||
underscores) must not create an entry under an empty key."""
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_", "orphan") # no key at all
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_A__B", "z") # empty middle part
|
||||
cm = ConfigManager(tmp_path, "testext")
|
||||
cfg = cm._get_env_config()
|
||||
assert "" not in cfg
|
||||
assert cfg == {"a": {"b": "z"}}
|
||||
|
||||
@@ -4060,6 +4060,40 @@ class TestPresetSetPriority:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir):
|
||||
"""A corrupted boolean priority must be repaired, not skipped.
|
||||
|
||||
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
|
||||
stored ``True`` priority would short-circuit the ``already has
|
||||
priority 1`` skip path and never get rewritten to a real int —
|
||||
contradicting the comment that promises corrupted values are
|
||||
repaired. The guard must exclude bools (like normalize_priority).
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
||||
# Inject a corrupted boolean priority (True == 1).
|
||||
manager.registry.update("test-pack", {"priority": True})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
# The corrupted bool must be repaired, not reported as already-set.
|
||||
assert "already has priority" not in plain
|
||||
assert "priority changed" in plain
|
||||
|
||||
# The stored value is now a real int, not a bool.
|
||||
reloaded = PresetManager(project_dir).registry.get("test-pack")
|
||||
assert reloaded["priority"] == 1
|
||||
assert not isinstance(reloaded["priority"], bool)
|
||||
|
||||
def test_set_priority_invalid_value(self, project_dir, pack_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -3872,6 +3872,56 @@ steps:
|
||||
assert "retry-loop:tick:1" in state.step_results
|
||||
assert "retry-loop:tick:2" in state.step_results
|
||||
|
||||
def test_loop_with_bool_max_iterations_uses_default_cap(self, project_dir):
|
||||
"""A boolean max_iterations must fall back to the default cap of 10,
|
||||
not be treated as the int 1 (bool-is-int trap).
|
||||
|
||||
``max_iterations: true`` would otherwise slip past the int check
|
||||
(``isinstance(True, int)`` is True and ``True < 1`` is False) and
|
||||
cap the loop at ``range(True - 1) == range(0)`` — a single
|
||||
iteration. ``execute()`` does not auto-validate, so the engine's own
|
||||
guard is the only line of defence here.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
script_file = project_dir / "_tick.py"
|
||||
script_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print('pending', end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "while-bool-max-iterations"
|
||||
name: "While Bool Max Iterations"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: while
|
||||
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
|
||||
max_iterations: true
|
||||
steps:
|
||||
- id: tick
|
||||
type: shell
|
||||
run: '"{py}" "{script_file}"'
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
# Falls back to the default cap of 10, not range(True - 1) == 1 run.
|
||||
assert counter_file.read_text(encoding="utf-8").strip() == "10"
|
||||
|
||||
def test_do_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
|
||||
"""Do-while loop must still run to max_iterations when the condition
|
||||
never becomes false.
|
||||
@@ -4746,12 +4796,59 @@ class TestWorkflowRegistry:
|
||||
registry2 = WorkflowRegistry(project_dir)
|
||||
assert registry2.is_installed("test-wf")
|
||||
|
||||
@pytest.mark.parametrize("bad_content", ["[]", '{"schema_version": "1.0"}'])
|
||||
def test_load_tolerates_misshaped_registry(self, project_dir, bad_content):
|
||||
"""A JSON-valid but mis-shaped registry file must not crash every method.
|
||||
|
||||
A list root, or a dict lacking a 'workflows' mapping, previously made
|
||||
is_installed/get/list/remove/add raise TypeError/KeyError. Mirrors the
|
||||
shape guard StepRegistry._load already has.
|
||||
"""
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
reg_path = project_dir / ".specify" / "workflows" / "workflow-registry.json"
|
||||
reg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
reg_path.write_text(bad_content, encoding="utf-8")
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
assert registry.data == {
|
||||
"schema_version": WorkflowRegistry.SCHEMA_VERSION,
|
||||
"workflows": {},
|
||||
}
|
||||
# None of these should raise on the recovered-default shape.
|
||||
assert registry.is_installed("x") is False
|
||||
assert registry.get("x") is None
|
||||
assert registry.list() == {} # list() always returns a dict
|
||||
registry.remove("x")
|
||||
registry.add("x", {"name": "X"})
|
||||
assert registry.is_installed("x")
|
||||
|
||||
|
||||
# ===== Workflow Catalog Tests =====
|
||||
|
||||
class TestWorkflowCatalog:
|
||||
"""Test WorkflowCatalog catalog resolution."""
|
||||
|
||||
def test_search_with_non_string_fields(self, project_dir, monkeypatch):
|
||||
"""Non-string workflow fields (null/int name/description) must not
|
||||
raise TypeError in search — StepCatalog.search already coerces these."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
monkeypatch.setattr(catalog, "_get_merged_workflows", lambda **kw: {
|
||||
"42": {
|
||||
"id": 42,
|
||||
"name": None,
|
||||
"description": 99,
|
||||
"_catalog_name": "test",
|
||||
"_install_allowed": True,
|
||||
},
|
||||
})
|
||||
|
||||
assert len(catalog.search()) == 1
|
||||
assert len(catalog.search(query="42")) == 1
|
||||
assert len(catalog.search(query="missing")) == 0
|
||||
|
||||
def test_default_catalogs(self, project_dir, monkeypatch):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
|
||||
Reference in New Issue
Block a user