Compare commits

...

20 Commits

Author SHA1 Message Date
github-actions[bot]
5b5f812b44 chore: bump version to 0.8.8 2026-05-11 17:05:34 +00:00
dependabot[bot]
e1b531c648 chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#2486)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.3.1...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:03:32 -05:00
Julio Cesar Franco
b5db159394 feat(catalog): add Spec Kit Schedule (schedule) community extension (#2473)
* feat(catalog): add Spec Kit Schedule (schedule) community extension

CP-SAT scheduler for spec-kit projects with multi-agent task
optimization. Adds catalog entry for v0.5.2 release.

Pre-flight verification:
- archive/refs/tags/v0.5.2.zip resolves (HTTP 200, 718322 bytes,
  SHA-256 00d4dab1df680e5888e0d0e861eb4696ace00661d40669bf719a75dc379b40b5)
- extension.yml schema_version 1.0, id 'schedule', 3 commands
  (speckit.schedule.run, speckit.schedule.portfolio, speckit.schedule.visualize)
- 566 tests passing on Ubuntu 3.10/3.11/3.12 + macOS 3.12 (all blocking)
- 92.51% line coverage, mypy --strict on 28 modules
- Sigstore attestations via attest-build-provenance@v2 (gh attestation
  verify exit 0 confirmed)
- 4 worked examples + replan demo runnable via bash bin/run-examples.sh

License: MIT
speckit_version: >=0.4.0

* fix(catalog): update Spec Kit Schedule entry to v0.5.3

v0.5.2 had two real-world install bugs caught when a user tried the
documented commands:

1. README/INSTALL showed 'specify extension add --from URL' (missing
   the EXTENSION positional arg). The canonical form is
   'specify extension add schedule --from URL'. Fixed in v0.5.3.

2. Release zip was ~5x bigger than peer extensions due to dev cruft
   (.github/, tests/, benchmarks/, build metadata). Added .gitattributes
   export-ignore in v0.5.3, dropping the zip from 718 KB to 590 KB.

v0.5.3 archive verified HTTP 200, sigstore attestations active.

* fix(catalog): bump Spec Kit Schedule entry to v0.5.4

Adds an opt-in after_tasks hook so users get prompted to run the
scheduler immediately after /speckit.tasks, without forcing it.
Mirrors the canonical pattern used by the bundled 'git' extension.

* fix(catalog): bump Spec Kit Schedule entry to v0.5.5

Documents the after_tasks hook in README and rewrites the
/speckit.schedule.portfolio command to autodetect the project's
tech stack via solver.autodetect, then refine interactively
against the matching recipe in docs/portfolio-design.md, instead
of starting from a blank slate.

* fix(catalog): bump Spec Kit Schedule entry to v0.6.0

State now encapsulated under .specify/, /speckit.schedule.run is
idempotent with auto-bootstrap, and portfolio detection is
AI-aware (reads .specify/integration.json and discovers the user's
fleet from the canonical location for whichever spec-kit AI
assistant they chose: claude, copilot, cursor-agent, gemini, or any
of the other 26 supported integrations).

* fix(catalog): bump Spec Kit Schedule entry to v0.6.1

Per-AI portfolio templates with verified May 2026 GA models
(gpt-5.5 flagship, claude-opus-4-7, gemini-2.5-flash). Critical
price unit fix (cost_aware reported $ figures 1000× inflated
in v0.6.0). Plus calibration feedback loop and inline summary.

* fix(readme): add Spec Kit Schedule row to Community Extensions table

Per Copilot review on PR #2473: the publishing guide requires an
accepted submission to update both extensions/catalog.community.json
AND the root README's Community Extensions table. Without the README
row the extension wouldn't appear in the primary browsable list.

Inserted alphabetically between 'Spec Diagram' and 'Spec Orchestrator'.
Category: process. Effect: Read+Write.

* fix(catalog): provides.commands 3→4 (schedule only) + bump top-level updated_at

Surgical edit responding to two Copilot review nits on PR #2473.
Previous attempt used str.replace too broadly and was reverted —
this version uses unique anchors to mutate only the schedule
entry and the top-level updated_at field.

1. extensions/catalog.community.json schedule entry had
   provides.commands: 3, but the extension exposes 4 commands
   (run, portfolio, visualize, calibrate — calibrate was added
   in v0.6.0 Build 2 / calibration feedback loop).

2. Top-level catalog updated_at was 2026-05-06T22:28:55Z but
   per-entry updated_at for our schedule entry is 2026-05-07.
   Since this PR modifies the catalog, the top-level timestamp
   advances too.

* fix(catalog): bump Spec Kit Schedule entry to v0.6.2

Adds /speckit.schedule.status (5th command) — self-diagnose
installation state, distinguishes 'expected-missing' (will
bootstrap automatically) from 'missing' (real problem). Closes
the audit-tool false-alarm gap where schedule-config.yml absence
post-install was misread as broken state.

---------

Co-authored-by: Julio César Franco Ardila <noreply@anthropic.com>
2026-05-11 11:00:23 -05:00
Quratulain-bilal
947b4398c7 fix(integration): refresh shared infra on integration switch (#2375)
* fix(integration): refresh shared infra on integration switch

* fix(integration): address Copilot review on switch shared-infra refresh

- Clarify install_shared_infra docstring: force overwrites regular files
  but always preserves symlinks (safe-destination check refuses to follow).
- Print refresh_hint only for preserved_user_files; skipped_files keeps
  the generic remediation. Avoids misleading guidance when files were
  merely skipped (not detected as customized).
- Catch ValueError from the safe-destination check and bucket the path
  under a new symlinked_files warning instead of aborting the switch.
- Restore templates/constitution-template.md to upstream (drop accidental
  leading blank lines).

* fix(integration): narrow symlink bucketing to dedicated exception

Address Copilot feedback on shared_infra.py:305 — _safe_dest_or_bucket
caught any ValueError as 'symlinked', which masked genuine safety errors
(path escape, parent-not-a-directory).

- Introduce SymlinkedSharedPathError(ValueError) raised only by the
  symlink-specific branches in _ensure_safe_shared_*().
- _safe_dest_or_bucket() now catches only SymlinkedSharedPathError;
  other ValueErrors propagate so the operation aborts with the real
  cause instead of being silently bucketed.
- Wrap top-level dest_scripts/dest_variant/dest_templates mkdir calls
  in the same bucket helper so a symlinked .specify/scripts or
  .specify/templates is preserved with a warning rather than aborting
  the switch (matches the documented 'preserve customizations' behavior).
- Update tests to expect the new bucket+warn behavior for leaf-level
  symlinked destinations.

* fix(integration): tailor shared-infra warnings and rename preflight test

Address Copilot review on PR #2375:

- skipped_files hint now uses refresh_hint when refresh_managed=True
  so integration switch suggests --refresh-shared-infra instead of the
  generic init/upgrade flags.
- symlinked-files warning header says "path(s)" rather than "file(s)"
  since symlinked directories (e.g. .specify/scripts/bash) are also
  bucketed there.
- Rename test_shared_infra_install_preflights_before_writing to
  test_shared_infra_install_buckets_unsafe_destinations_and_continues
  to match the new bucket-and-continue semantics.

* test: rename symlink bucketing tests to reflect bucket-and-continue behavior

The two file-bucketing tests at line 300/320 were named *_refuses_*, but
the new behavior buckets symlinked file destinations with a warning while
safe destinations in the same install still complete. Rename to
*_buckets_* and update docstrings to match.

The remaining *_refuses_* tests (line 342/362/381) genuinely raise on
symlinked dirs/manifests and keep their names.

---------

Co-authored-by: Quratulain-bilal <quratulain.bilal@users.noreply.github.com>
2026-05-11 10:49:48 -05:00
Manfred Riem
28145b9a3a Add MDE preset to community catalog (#2513)
Add Model Driven Engineering preset by @ralphhanna to the community
catalog and docs website.

Closes #2493
2026-05-11 07:39:28 -05:00
Manfred Riem
cec0d2db5e Add MDE extension to community catalog (#2512)
Add the MDE (Model Driven Engineering) extension to the community
catalog and README extensions table.

Closes #2492

Co-authored-by: ralphhanna <11893416+ralphhanna@users.noreply.github.com>
2026-05-11 07:25:13 -05:00
Dyan Galih
688ca1b3c5 chore: update community catalog with latest extension versions (#2490)
- Update memory-md from 0.7.9 to 0.8.0
- Update architecture-guard from 1.6.7 to 1.8.0
2026-05-08 16:27:31 -05:00
dependabot[bot]
2b4a33e1fd chore(deps): bump actions/setup-dotnet from 4.3.1 to 5.2.0 (#2489)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4.3.1 to 5.2.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](67a3573c9a...c2fa09f4bd)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 16:12:40 -05:00
dependabot[bot]
2be4ef713d chore(deps): bump actions/github-script from 7 to 9 (#2488)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 16:11:29 -05:00
dependabot[bot]
282a1f7d1b chore(deps): bump DavidAnson/markdownlint-cli2-action (#2487)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.1.0 to 23.2.0.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](6b51ade7a9...ded1f9488f)

---
updated-dependencies:
- dependency-name: DavidAnson/markdownlint-cli2-action
  dependency-version: 23.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 15:48:49 -05:00
dependabot[bot]
b0674243d2 chore(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#2485)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e46ed2cbd0...68bde559de)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 15:43:51 -05:00
Quratulain-bilal
abb5fe7090 feat(catalog): add API Evolve (api-evolve) community extension (#2479)
* feat(catalog): add API Evolve (api-evolve) community extension

* chore(catalog): refresh top-level updated_at
2026-05-07 14:40:40 -05:00
Copilot
f0998348be feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
* Initial plan

* feat: add authentication provider registry (GitHub + Azure DevOps)

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry

- Add authentication/http.py with open_url() that tries each configured
  provider in registry order, falling through on 401/403 to the next,
  and finally to unauthenticated
- Add build_request() for one-shot request construction
- Add configured_providers() to registry __init__
- Remove api_base_url() from AuthProvider ABC (unused)
- Remove hosts attribute from providers (no host matching)
- Replace _github_http.py usage in ExtensionCatalog and PresetCatalog
- Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated)
- Wire _fetch_latest_release_tag() through open_url
- Wire all inline --from-url downloads through open_url
- Fix unused stub variable flagged by code-quality bot
- 49 auth tests (positive + negative), 1805 total tests passing

* fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url

- Fix _open_url() docstrings in extensions.py and presets.py that
  incorrectly claimed redirect stripping behavior
- Add extra_headers parameter to open_url() so callers can pass
  additional headers (e.g. Accept) that persist across retries
- Restore Accept: application/vnd.github+json header in
  _fetch_latest_release_tag() via extra_headers

* feat: config-driven opt-in auth via ~/.specify/auth.json

Security-first redesign: no credentials are sent unless the user
explicitly creates ~/.specify/auth.json mapping hosts to providers.

- Add authentication/config.py: loads and validates auth.json with
  host-to-provider mappings, supports token/token_env/azure-ad/azure-cli
- Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry)
- Refactor GitHubAuth: bearer scheme only, token from config entry
- Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad)
  with dynamic token acquisition for azure-cli and azure-ad
- Rewrite authentication/http.py: host matching, redirect stripping,
  provider fallthrough on 401/403, unauthenticated fallback
- Add docs/reference/authentication.md with full reference and template
- 1823 tests passing (67 auth-specific)

* fix: address review — unused imports, host normalization, provider+scheme validation, security hardening

- Remove unused imports (os, field, Any) in config.py
- Normalize hosts during load (strip + lowercase)
- Validate token/token_env are non-empty strings during load
- Validate provider+scheme compatibility during load
- Fix extra_headers order: auth headers applied last, cannot be overridden
- Remove unused 'tried' variable in http.py
- Warn (once) on malformed auth.json instead of silent fallback
- URL-encode OAuth2 client credentials body in azure_devops.py
- Update 403 message to mention auth.json configuration
- Fix registry leak in test_register_duplicate (try/finally)
- Fix import style consistency in test_authentication.py
- Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen)
- Add autouse fixture to isolate upgrade tests from real auth.json
- 1829 tests passing

* fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers

- Reject unknown provider keys during auth.json load with clear error message
- Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings
- Strip Authorization from extra_headers in both build_request and open_url
  to prevent accidental or intentional bypass of provider-configured auth
- Add tests for unknown provider and incompatible scheme validation
- 1831 tests passing

* fix: extract shared auth test helpers, global config isolation, align docstring

- Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py
  to eliminate duplication across test_extensions, test_presets, test_upgrade
- Move auth config isolation fixture to global conftest.py (autouse) so ALL
  tests are isolated from ~/.specify/auth.json, not just test_upgrade
- Align load_auth_config docstring with actual behavior: ValueError may be
  caught by higher-level HTTP helpers that warn and continue unauthenticated
- 1831 tests passing

* fix: preserve auth header across multi-hop redirect chains

- Read Authorization from both headers and unredirected_hdrs in
  _StripAuthOnRedirect to survive multi-hop chains within allowed hosts
- Add test_multi_hop_redirect_within_hosts_preserves_auth
- 1832 tests passing

* fix: use resolved config path in warning/error messages and patch build_opener in no-network test

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: assert full resolved config path in rate-limit output test

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: validate host patterns, cache auth config per-process

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: improve _is_valid_host_pattern docstring and test observability

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 12:51:20 -05:00
Manfred Riem
5563269831 chore: release 0.8.7, begin 0.8.8.dev0 development (#2480)
* chore: bump version to 0.8.7

* chore: begin 0.8.8.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 10:46:05 -05:00
Pragya Chaurasia
5b9f0040e7 feat: add agent-orchestrator to community extension catalog (#2236)
Register the Intelligent Agent Orchestrator as a community extension.
Extension code is hosted externally at:
https://github.com/pragya247/spec-kit-orchestrator

Changes:
- Add agent-orchestrator entry to extensions/catalog.community.json
- Add Agent Orchestrator row to README.md community extensions table

Co-authored-by: pragya247 <pragya@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 10:39:02 -05:00
Dyan Galih
11f49ebfb2 chore: update extension versions in community catalog (#2468)
* chore: update extension versions in community catalog

- Update architecture-guard from v1.4.0 to v1.6.7
- Update memory-md from v0.7.5 to v0.7.9
- Update security-review from v1.4.2 to v1.4.5

All extensions now point to latest release downloads.

* chore: update timestamps in community catalog

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 17:47:33 -05:00
natelastname
cd44dc2147 fix(goose): Declare args parameter in generated recipes (#2402) 2026-05-06 17:21:48 -05:00
qiyang.yuan
f5b675e9ee feat: Add lingma support (#2348)
* add lingma support

* fix

* fix context file

* Update CONTEXT_FILE path in test integration

* fix IntegrationOption.default

* fix IntegrationOption.defaultfix

* fix: address Copilot review feedback

- Add blank line after __future__ import (PEP 8)
- Remove trailing whitespace at end of lingma/__init__.py
- Bump integrations/catalog.json updated_at timestamp
- Add Lingma to supported agent list in README.md

* fix: address Copilot review feedback (round 4)

- Reword module docstring: Lingma is a brand-new skills-only integration
  with no prior command-mode history, so 'deprecated since v0.5.1'
  wording (copied from Trae) was misleading
- Remove Lingma from README CLI-tool check list: Lingma is IDE-based
  (requires_cli=False) and is explicitly skipped by specify init /
  specify check tool detection
2026-05-06 16:12:13 -05:00
Copilot
38bb88bde1 docs: Add uv installation guide and inline callouts (#2465)
* Initial plan

* docs: Add uv installation guide and inline callouts

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/027c81a0-57f2-4f67-ab54-4c72f93eb254

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: improve uv install guide PATH and Windows instructions

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/f56bcfb8-2cf5-44a5-b5e5-0fd6c3caa46f

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: clarify uv note in README applies only to uv commands not pipx

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a6ada1f7-522d-4a31-ac5b-880e763f9c97

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: clarify uv note in installation.md applies only to uvx commands not pipx

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4ec791dd-b048-4606-8db3-671bc8956b05

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-05-06 15:05:14 -05:00
Manfred Riem
0facb1bdc2 Add fx-to-dotnet to community extension catalog (#2471)
* Add fx-to-dotnet to community extension catalog

- Extension ID: fx-to-dotnet
- Version: 0.8.0
- Author: RogerBestMsft
- .NET Framework to Modern .NET Migration

Closes #2469

* Address review: remove tool version, fix table ordering

- Remove meaningless >=0.0.0 version from required tool entry
- Move .NET Framework row to correct alphabetical position (after Multi-Model Review)
- Lowercase link label to match table conventions
2026-05-06 13:23:23 -05:00
43 changed files with 2687 additions and 280 deletions

View File

@@ -19,7 +19,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;

View File

@@ -19,14 +19,14 @@ jobs:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -35,7 +35,7 @@ jobs:
fetch-depth: 0 # Fetch all history for git info
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
dotnet-version: '8.x'

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
with:
globs: |
'**/*.md'

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
@@ -34,7 +34,7 @@ jobs:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

View File

@@ -2,6 +2,39 @@
<!-- insert new changelog below this comment -->
## [0.8.8] - 2026-05-11
### Changed
- chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#2486)
- feat(catalog): add Spec Kit Schedule (schedule) community extension (#2473)
- fix(integration): refresh shared infra on `integration switch` (#2375)
- Add MDE preset to community catalog (#2513)
- Add MDE extension to community catalog (#2512)
- chore: update community catalog with latest extension versions (#2490)
- chore(deps): bump actions/setup-dotnet from 4.3.1 to 5.2.0 (#2489)
- chore(deps): bump actions/github-script from 7 to 9 (#2488)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2487)
- chore(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#2485)
- feat(catalog): add API Evolve (api-evolve) community extension (#2479)
- feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
- chore: release 0.8.7, begin 0.8.8.dev0 development (#2480)
## [0.8.7] - 2026-05-07
### Changed
- feat: add agent-orchestrator to community extension catalog (#2236)
- chore: update extension versions in community catalog (#2468)
- fix(goose): Declare args parameter in generated recipes (#2402)
- feat: Add lingma support (#2348)
- docs: Add uv installation guide and inline callouts (#2465)
- Add fx-to-dotnet to community extension catalog (#2471)
- fix: default non-interactive init to copilot integration (#2414)
- fix(forge): use hyphen notation for command refs in Forge integration (#2462)
- feat(catalog): add Cost Tracker (cost) community extension (#2448)
- chore: release 0.8.6, begin 0.8.7.dev0 development (#2463)
## [0.8.6] - 2026-05-06
### Changed

View File

@@ -56,6 +56,9 @@ Choose your preferred installation method:
Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
> [!NOTE]
> The `uv tool install` commands below require **[uv](https://docs.astral.sh/uv/)** — a fast Python package manager. If you see `command not found: uv`, [install uv first](./docs/install/uv.md). The `pipx` alternative does not require uv.
```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
@@ -197,6 +200,7 @@ The following community-contributed extensions are available in [`catalog.commun
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
@@ -220,6 +224,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
@@ -231,11 +236,13 @@ The following community-contributed extensions are available in [`catalog.commun
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
@@ -261,6 +268,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |

View File

@@ -18,6 +18,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |

60
docs/install/uv.md Normal file
View File

@@ -0,0 +1,60 @@
# Installing uv
[uv](https://docs.astral.sh/uv/) is a fast Python package manager by [Astral](https://astral.sh/). Spec Kit uses `uv` (via `uvx` or `uv tool install`) to run the `specify` CLI without polluting your global Python environment.
> [!NOTE]
> **Already have uv?** Run `uv --version` to confirm it is installed, then head back to the [Installation Guide](../installation.md).
## Installation
### macOS and Linux — Standalone Installer
The quickest way to install uv on macOS or Linux is the official shell script:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
After the script finishes, follow any instructions printed by the installer to add uv to your `PATH`, then open a new terminal.
### Windows — Standalone Installer
Run the following in **Command Prompt or PowerShell**:
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
After the script finishes, open a new terminal so the `uv` binary is on your `PATH`.
### macOS — Homebrew
```bash
brew install uv
```
### Windows — WinGet
```powershell
winget install --id=astral-sh.uv -e
```
### Windows — Scoop
```powershell
scoop install uv
```
## Verification
Confirm that uv is installed and on your `PATH`:
```bash
uv --version
```
You should see output similar to `uv 0.x.y (...)`.
## Further Reading
For advanced options (self-update, proxy settings, uninstall, etc.) see the official [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/).

View File

@@ -16,6 +16,9 @@
The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
> [!NOTE]
> The `uvx` commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](./install/uv.md). The `pipx` alternative does not require uv.
```bash
# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>

View File

@@ -0,0 +1,181 @@
# Authentication
Specify CLI uses **opt-in authentication** for HTTP requests to catalog
sources, extension downloads, and release checks. No credentials are
sent unless you explicitly configure them.
## Configuration
Create `~/.specify/auth.json` to enable authentication:
```json
{
"providers": [
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
]
}
```
> **Security:** Restrict the file to owner-only access:
> ```bash
> chmod 600 ~/.specify/auth.json
> ```
Without this file, all HTTP requests are unauthenticated.
## Fields
Each entry in the `providers` array has the following fields:
| Field | Required | Description |
|---|---|---|
| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. |
| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. |
| `auth` | Yes | Auth scheme (see below). |
| `token` | No | Token value (inline). Use `token_env` instead when possible. |
| `token_env` | No | Environment variable name to read the token from. |
For `azure-ad` auth, additional fields are required:
| Field | Required | Description |
|---|---|---|
| `tenant_id` | Yes | Azure AD tenant ID. |
| `client_id` | Yes | Service principal client ID. |
| `client_secret_env` | Yes | Environment variable containing the client secret. |
Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
## Providers and auth schemes
### GitHub (`github`)
| Scheme | Header | Use for |
|---|---|---|
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |
**Example — PAT via environment variable:**
```json
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
```
### Azure DevOps (`azure-devops`)
| Scheme | Header | Use for |
|---|---|---|
| `basic-pat` | `Authorization: Basic base64(:<PAT>)` | Personal Access Tokens |
| `bearer` | `Authorization: Bearer <token>` | Pre-acquired OAuth / Azure AD tokens |
| `azure-cli` | `Authorization: Bearer <token>` | Token acquired via `az account get-access-token` |
| `azure-ad` | `Authorization: Bearer <token>` | Token acquired via OAuth2 client credentials flow |
**Example — PAT via environment variable:**
```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT"
}
```
**Example — Azure CLI (interactive login):**
```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-cli"
}
```
Requires `az login` to have been run beforehand.
**Example — Azure AD service principal (CI/automation):**
```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret_env": "AZURE_CLIENT_SECRET"
}
```
## Multiple entries
You can configure multiple entries for different hosts or organizations:
```json
{
"providers": [
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
},
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT"
}
]
}
```
## How it works
1. For each outbound HTTP request, the URL hostname is matched against
the `hosts` patterns in `auth.json`.
2. If a match is found, the corresponding provider resolves the token
and attaches the appropriate `Authorization` header.
3. If the request receives a 401 or 403, the next matching entry is tried.
4. After all matching entries are exhausted, an unauthenticated request
is attempted as a final fallback.
5. On redirects, the `Authorization` header is stripped if the redirect
target leaves the entry's declared hosts — preventing credential
leakage to CDNs or third-party services.
## Template
A reference `auth.json` with GitHub pre-configured:
```json
{
"providers": [
{
"hosts": [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com"
],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
]
}
```
To use it:
```bash
mkdir -p ~/.specify
# Copy the JSON above into ~/.specify/auth.json
chmod 600 ~/.specify/auth.json
```

View File

@@ -24,6 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |

View File

@@ -11,6 +11,8 @@
href: quickstart.md
- name: Upgrade
href: upgrade.md
- name: Install uv
href: install/uv.md
# Reference
- name: Reference

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-06T00:00:00Z",
"updated_at": "2026-05-07T20:05:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -68,6 +68,75 @@
"created_at": "2026-03-31T00:00:00Z",
"updated_at": "2026-03-31T00:00:00Z"
},
"agent-orchestrator": {
"name": "Intelligent Agent Orchestrator",
"id": "agent-orchestrator",
"description": "Cross-catalog agent discovery and intelligent prompt-to-command routing",
"author": "pragya247",
"version": "0.1.0",
"download_url": "https://github.com/pragya247/spec-kit-orchestrator/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/pragya247/spec-kit-orchestrator",
"homepage": "https://github.com/pragya247/spec-kit-orchestrator",
"documentation": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/README.md",
"changelog": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.1"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"orchestrator",
"routing",
"discovery",
"agent",
"ai"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-04T00:00:00Z",
"updated_at": "2026-05-04T00:00:00Z"
},
"api-evolve": {
"name": "API Evolve",
"id": "api-evolve",
"description": "Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 12,
"hooks": 5
},
"tags": [
"api",
"contracts",
"versioning",
"openapi",
"graphql",
"grpc",
"deprecation",
"breaking-changes",
"semver",
"governance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-07T00:00:00Z",
"updated_at": "2026-05-07T00:00:00Z"
},
"architect-preview": {
"name": "Architect Impact Previewer",
"id": "architect-preview",
@@ -105,8 +174,8 @@
"id": "architecture-guard",
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
"author": "DyanGalih",
"version": "1.4.0",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.4.0.zip",
"version": "1.8.0",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.0.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
@@ -131,7 +200,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-05-05T07:26:00Z"
"updated_at": "2026-05-07T15:37:14Z"
},
"archive": {
"name": "Archive Extension",
@@ -842,6 +911,44 @@
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-31T00:00:00Z"
},
"fx-to-dotnet": {
"name": ".NET Framework to Modern .NET Migration",
"id": "fx-to-dotnet",
"description": "Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration.",
"author": "RogerBestMsft",
"version": "0.8.0",
"download_url": "https://github.com/RogerBestMsft/spec-kit-FxToNet/releases/download/v0.8.0/fx-to-dotnet.zip",
"repository": "https://github.com/RogerBestMsft/spec-kit-FxToNet",
"homepage": "https://github.com/RogerBestMsft/spec-kit-FxToNet",
"documentation": "https://github.com/RogerBestMsft/spec-kit-FxToNet/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "Microsoft.GitHubCopilot.Modernization.Mcp",
"required": true
}
]
},
"provides": {
"commands": 12,
"hooks": 5
},
"tags": [
"dotnet",
"migration",
"modernization",
"framework",
"aspnet",
"shared-artifact"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-06T00:00:00Z",
"updated_at": "2026-05-06T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",
"id": "github-issues",
@@ -1309,6 +1416,35 @@
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z"
},
"mde": {
"name": "MDE",
"id": "mde",
"description": "A Spec Kit extension that exposes a minimal model-driven engineering workflow with setup, next, and status commands.",
"author": "AI-MDE",
"version": "0.5.1",
"download_url": "https://github.com/AI-MDE/spec-kit-mde/archive/refs/tags/v0.5.1.zip",
"repository": "https://github.com/AI-MDE/spec-kit-mde",
"homepage": "https://github.com/AI-MDE/spec-kit-mde",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"mde",
"model-driven-engineering",
"workflow",
"process"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"memory-loader": {
"name": "Memory Loader",
"id": "memory-loader",
@@ -1345,8 +1481,8 @@
"id": "memory-md",
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
"author": "DyanGalih",
"version": "0.7.5",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip",
"version": "0.8.0",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.0.zip",
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
@@ -1371,7 +1507,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-23T00:00:00Z",
"updated_at": "2026-05-03T00:00:00Z"
"updated_at": "2026-05-07T15:37:14Z"
},
"memorylint": {
"name": "MemoryLint",
@@ -2009,6 +2145,38 @@
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z"
},
"schedule": {
"name": "Spec Kit Schedule — CP-SAT Agent Orchestrator",
"id": "schedule",
"description": "Optimal multi-agent task scheduling via CP-SAT solver with DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output",
"author": "Julio César Franco Ardila",
"version": "0.6.2",
"download_url": "https://github.com/jfranc38/spec-kit-schedule/archive/refs/tags/v0.6.2.zip",
"repository": "https://github.com/jfranc38/spec-kit-schedule",
"homepage": "https://github.com/jfranc38/spec-kit-schedule",
"documentation": "https://github.com/jfranc38/spec-kit-schedule/blob/main/README.md",
"changelog": "https://github.com/jfranc38/spec-kit-schedule/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"scheduling",
"optimization",
"multi-agent",
"cp-sat",
"operations-research"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-06T22:35:00Z",
"updated_at": "2026-05-07T17:25:00Z"
},
"scope": {
"name": "Spec Scope",
"id": "scope",
@@ -2047,8 +2215,8 @@
"id": "security-review",
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
"author": "DyanGalih",
"version": "1.4.2",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip",
"version": "1.4.5",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.5.zip",
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
@@ -2072,7 +2240,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-03T03:24:03Z",
"updated_at": "2026-05-03T00:00:00Z"
"updated_at": "2026-05-06T22:28:55Z"
},
"sf": {
"name": "SFSpeckit — Salesforce Spec-Driven Development",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -210,6 +210,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"lingma": {
"id": "lingma",
"name": "Lingma",
"version": "1.0.0",
"description": "Lingma IDE skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide", "skills"]
},
"pi": {
"id": "pi",
"name": "Pi Coding Agent",

View File

@@ -311,6 +311,37 @@
"created_at": "2026-04-15T00:00:00Z",
"updated_at": "2026-04-15T00:00:00Z"
},
"mde": {
"name": "Model Driven Engineering",
"id": "mde",
"version": "0.5.1",
"description": "Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows.",
"author": "Ralph Hanna",
"repository": "https://github.com/AI-MDE/spec-kit-preset-mde",
"download_url": "https://github.com/AI-MDE/spec-kit-preset-mde/archive/refs/tags/v0.5.1.zip",
"homepage": "https://github.com/AI-MDE/spec-kit-preset-mde",
"documentation": "https://github.com/AI-MDE/spec-kit-preset-mde/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"extensions": [
"mde"
]
},
"provides": {
"templates": 6,
"commands": 11
},
"tags": [
"model-driven-engineering",
"software-lifecycle",
"business-analysis",
"business-application",
"multi-layered-architecture"
],
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"multi-repo-branching": {
"name": "Multi-Repo Branching",
"id": "multi-repo-branching",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.8.7.dev0"
version = "0.8.8"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -769,6 +769,8 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared infrastructure files into *project_path*.
@@ -780,9 +782,23 @@ def _install_shared_infra(
placeholders using *invoke_separator* (``"."`` for markdown agents,
``"-"`` for skills agents).
When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped.
Overwrite policy:
* ``force=True`` — overwrite every existing file (still skips symlinks
to avoid following links outside the project root).
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
still matches the previously recorded manifest hash (i.e. unmodified
files installed by spec-kit). Files with diverging hashes are
treated as user customizations and preserved with a warning.
* Default — only add missing files; existing ones are skipped.
*refresh_hint* — caller-supplied rich-text fragment shown after the
"Preserved customized files" warning to tell the user which flag/command
they should re-run with to overwrite their customizations. Each caller
passes the flag that's actually valid in its CLI surface (e.g.
``--refresh-shared-infra`` for ``integration switch``,
``--force`` for ``init``/``integration upgrade``). When ``None``, no
remediation hint is printed for customizations.
Returns ``True`` on success.
"""
@@ -795,6 +811,8 @@ def _install_shared_infra(
console=console,
force=force,
invoke_separator=invoke_separator,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
@@ -804,6 +822,8 @@ def _install_shared_infra_or_exit(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
try:
return _install_shared_infra(
@@ -812,6 +832,8 @@ def _install_shared_infra_or_exit(
tracker=tracker,
force=force,
invoke_separator=invoke_separator,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
except (ValueError, OSError) as exc:
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
@@ -1762,22 +1784,14 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
On anything else — including a malformed response body — the exception
propagates; there is no catch-all (research D-006).
"""
req = urllib.request.Request(
GITHUB_API_LATEST,
headers={"Accept": "application/vnd.github+json"},
)
token = None
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
candidate = os.environ.get(env_var)
if candidate is not None:
candidate = candidate.strip()
if candidate:
token = candidate
break
if token:
req.add_header("Authorization", f"Bearer {token}")
from .authentication.http import open_url
try:
with urllib.request.urlopen(req, timeout=5) as resp:
with open_url(
GITHUB_API_LATEST,
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
payload = json.loads(resp.read().decode("utf-8"))
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
@@ -1786,7 +1800,9 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
except urllib.error.HTTPError as e:
# Order matters: HTTPError is a subclass of URLError.
if e.code == 403:
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
return None, (
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
)
return None, f"HTTP {e.code}"
except (urllib.error.URLError, OSError):
return None, "offline or timeout"
@@ -2589,7 +2605,8 @@ def integration_uninstall(
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
):
"""Switch from the current integration to a different one."""
@@ -2760,14 +2777,27 @@ def integration_switch(
target_integration, current, target, integration_options
)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
# supported-agent list) would silently break the new integration.
#
# Use refresh_managed=True so only files that match their previously
# recorded hash are overwritten — user customizations are detected via
# hash divergence and preserved with a warning. Pass
# --refresh-shared-infra to overwrite customizations as well. See #2293.
_install_shared_infra_or_exit(
project_root,
selected_script,
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options
),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
),
)
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -3381,7 +3411,9 @@ def preset_add(
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
from specify_cli.authentication.http import open_url as _open_url
with _open_url(from_url, timeout=60) as response:
zip_path.write_bytes(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}")
@@ -4285,7 +4317,9 @@ def extension_add(
zip_path = download_dir / f"{extension}-url-download.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
from specify_cli.authentication.http import open_url as _open_url
with _open_url(from_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)
@@ -5500,7 +5534,7 @@ def workflow_add(
if source.startswith("http://") or source.startswith("https://"):
from ipaddress import ip_address
from urllib.parse import urlparse
from urllib.request import urlopen # noqa: S310
from specify_cli.authentication.http import open_url as _open_url
parsed_src = urlparse(source)
src_host = parsed_src.hostname or ""
@@ -5517,7 +5551,7 @@ def workflow_add(
import tempfile
try:
with urlopen(source, timeout=30) as resp: # noqa: S310
with _open_url(source, timeout=30) as resp:
final_url = resp.geturl()
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
@@ -5613,10 +5647,10 @@ def workflow_add(
workflow_file = workflow_dir / "workflow.yml"
try:
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
from specify_cli.authentication.http import open_url as _open_url
workflow_dir.mkdir(parents=True, exist_ok=True)
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
with _open_url(workflow_url, timeout=30) as response:
# Validate final URL after redirects
final_url = response.geturl()
final_parsed = urlparse(final_url)

View File

@@ -0,0 +1,50 @@
"""Authentication provider registry for multi-platform support.
Credentials are **opt-in only**. No authentication headers are sent unless
the user creates ``~/.specify/auth.json`` mapping hosts to providers.
Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.)
while the config file defines *where* and *with what credentials*.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import AuthProvider
# Maps provider key → AuthProvider class instance.
AUTH_REGISTRY: dict[str, AuthProvider] = {}
def _register(provider: AuthProvider) -> None:
"""Register a provider instance in the global registry.
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = provider.key
if not key:
raise ValueError("Cannot register provider with an empty key.")
if key in AUTH_REGISTRY:
raise KeyError(f"Provider with key {key!r} is already registered.")
AUTH_REGISTRY[key] = provider
def get_provider(key: str) -> AuthProvider | None:
"""Return the provider for *key*, or ``None`` if not registered."""
return AUTH_REGISTRY.get(key)
# -- Register built-in providers -----------------------------------------
def _register_builtins() -> None:
"""Register all built-in authentication providers (alphabetical)."""
from .azure_devops import AzureDevOpsAuth
from .github import GitHubAuth
_register(AzureDevOpsAuth())
_register(GitHubAuth())
_register_builtins()

View File

@@ -0,0 +1,117 @@
"""Azure DevOps authentication provider."""
from __future__ import annotations
import base64
import json as _json
import os
import subprocess
from typing import TYPE_CHECKING
from .base import AuthProvider
if TYPE_CHECKING:
from .config import AuthConfigEntry
# Azure DevOps resource ID for OAuth / Azure AD token acquisition.
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.
Supports four auth schemes:
* ``basic-pat`` — PAT with empty username, Base64-encoded as ``:<PAT>``
* ``bearer`` — pre-acquired OAuth / Azure AD token
* ``azure-cli`` — acquires a token via ``az account get-access-token``
* ``azure-ad`` — acquires a token via OAuth2 client credentials flow
"""
key = "azure-devops"
supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad")
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Build the ``Authorization`` header for the given scheme."""
if auth_scheme == "basic-pat":
encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii")
return {"Authorization": f"Basic {encoded}"}
if auth_scheme in ("bearer", "azure-cli", "azure-ad"):
return {"Authorization": f"Bearer {token}"}
raise ValueError(
f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}"
)
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
"""Resolve token, with special handling for azure-cli and azure-ad."""
if entry.auth == "azure-cli":
return self._acquire_via_az_cli()
if entry.auth == "azure-ad":
return self._acquire_via_client_credentials(entry)
return super().resolve_token(entry)
# -- Token acquisition ------------------------------------------------
@staticmethod
def _acquire_via_az_cli() -> str | None:
"""Run ``az account get-access-token`` and return the access token."""
try:
result = subprocess.run( # noqa: S603, S607
[
"az",
"account",
"get-access-token",
"--resource",
_ADO_RESOURCE_ID,
"--output",
"json",
],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
return None
payload = _json.loads(result.stdout)
token = payload.get("accessToken", "").strip()
return token or None
except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError):
return None
@staticmethod
def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
"""Acquire a token via OAuth2 client credentials flow."""
import urllib.error
import urllib.request
if not entry.tenant_id or not entry.client_id or not entry.client_secret_env:
return None
client_secret = os.environ.get(entry.client_secret_env, "").strip()
if not client_secret:
return None
url = (
f"https://login.microsoftonline.com/{entry.tenant_id}"
"/oauth2/v2.0/token"
)
from urllib.parse import urlencode
body = urlencode({
"grant_type": "client_credentials",
"client_id": entry.client_id,
"client_secret": client_secret,
"scope": f"{_ADO_RESOURCE_ID}/.default",
}).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(resp.read().decode("utf-8"))
token = payload.get("access_token", "").strip()
return token or None
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
return None

View File

@@ -0,0 +1,57 @@
"""Abstract base class for authentication providers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .config import AuthConfigEntry
class AuthProvider(ABC):
"""Abstract base class every authentication provider must implement.
Subclasses must set:
* ``key`` — unique provider identifier (e.g. ``"github"``, ``"azure-devops"``)
* ``supported_auth_schemes`` — tuple of auth scheme strings this provider handles
And implement:
* ``auth_headers(token, auth_scheme)`` — build headers from a resolved token
* ``resolve_token(entry)`` — obtain the token for a config entry
"""
key: str = ""
"""Unique provider identifier."""
supported_auth_schemes: tuple[str, ...] = ()
"""Auth schemes this provider supports (e.g. ``("bearer",)``)."""
@abstractmethod
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Build authentication headers for *token* using *auth_scheme*.
Must return a dict with at least an ``Authorization`` key.
"""
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
"""Resolve the token for *entry*.
Default implementation reads from ``entry.token`` directly
or from the environment variable named by ``entry.token_env``.
Override for schemes that acquire tokens dynamically
(e.g. ``azure-cli``, ``azure-ad``).
"""
import os
if entry.token:
return entry.token.strip() or None
if entry.token_env:
val = os.environ.get(entry.token_env)
if val is not None:
val = val.strip()
if val:
return val
return None

View File

@@ -0,0 +1,209 @@
"""Authentication configuration loader.
Reads ``~/.specify/auth.json`` to determine which hosts receive credentials
and which provider/auth-scheme to use. No credentials are sent without
an explicit opt-in via this file.
"""
from __future__ import annotations
import json
import os
import stat
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from urllib.parse import urlparse
@dataclass(frozen=True)
class AuthConfigEntry:
"""A single provider entry from ``auth.json``."""
hosts: tuple[str, ...]
provider: str
auth: str
token: str | None = None
token_env: str | None = None
# Azure AD service-principal fields
tenant_id: str | None = None
client_id: str | None = None
client_secret_env: str | None = None
def _default_config_path() -> Path:
"""Return ``~/.specify/auth.json``."""
return Path.home() / ".specify" / "auth.json"
def _is_valid_host_pattern(pattern: str) -> bool:
"""Return True for safe host patterns: exact hostnames or ``*.suffix`` only.
Rejects patterns like ``*github.com`` (which would match
``github.com.evil.com``) or multi-wildcard forms. Only these two
forms are accepted:
* ``example.com`` — exact hostname
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
such as ``myorg.example.com`` but not ``example.com`` itself
"""
if "*" not in pattern:
return True # exact hostname — already validated as non-empty
# Only *.suffix is allowed; no other wildcard positions
return pattern.startswith("*.") and "*" not in pattern[2:]
def load_auth_config(
path: Path | None = None,
) -> list[AuthConfigEntry]:
"""Load and validate ``auth.json``, returning configured entries.
Returns an empty list when the file does not exist — this means
all HTTP requests will be unauthenticated (opt-in model).
Raises ``ValueError`` on schema violations. Callers that want
misconfigurations to fail fast can allow this exception to
propagate; higher-level HTTP helpers may instead catch it,
warn, and continue with unauthenticated requests.
"""
config_path = path or _default_config_path()
if not config_path.is_file():
return []
# Warn (but don't fail) if the file is world-readable (POSIX only).
if os.name != "nt":
try:
mode = config_path.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IROTH):
import warnings
warnings.warn(
f"{config_path} is readable by group/others. "
"Consider restricting with: chmod 600 "
f"{config_path}",
UserWarning,
stacklevel=2,
)
except OSError:
pass # stat failed — skip permission check
raw = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}")
providers_raw = raw.get("providers")
if not isinstance(providers_raw, list):
raise ValueError("auth.json must contain a 'providers' array")
entries: list[AuthConfigEntry] = []
for i, entry_raw in enumerate(providers_raw):
if not isinstance(entry_raw, dict):
raise ValueError(f"providers[{i}]: must be a JSON object")
hosts = entry_raw.get("hosts")
if not isinstance(hosts, list) or not hosts:
raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array")
if not all(isinstance(h, str) and h.strip() for h in hosts):
raise ValueError(f"providers[{i}]: each host must be a non-empty string")
# Normalize hosts: strip whitespace and lowercase
hosts = [h.strip().lower() for h in hosts]
# Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com)
for h in hosts:
if not _is_valid_host_pattern(h):
raise ValueError(
f"providers[{i}]: invalid host pattern {h!r}. "
"Only exact hostnames or '*.suffix' forms are allowed "
"(e.g. 'github.com' or '*.visualstudio.com')."
)
provider = entry_raw.get("provider", "")
if not isinstance(provider, str) or not provider:
raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string")
auth = entry_raw.get("auth", "")
if not isinstance(auth, str) or not auth:
raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string")
token = entry_raw.get("token")
token_env = entry_raw.get("token_env")
# Validate token/token_env types
if token is not None and (not isinstance(token, str) or not token.strip()):
raise ValueError(f"providers[{i}]: 'token' must be a non-empty string")
if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()):
raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string")
# Validate provider+scheme compatibility
from . import get_provider as _get_provider
_prov = _get_provider(provider)
if _prov is None:
from . import AUTH_REGISTRY
raise ValueError(
f"providers[{i}]: unknown provider {provider!r}; "
f"registered: {sorted(AUTH_REGISTRY.keys())}"
)
if auth not in _prov.supported_auth_schemes:
raise ValueError(
f"providers[{i}]: provider {provider!r} does not support "
f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}"
)
# Validate token source based on auth scheme
if auth in ("bearer", "basic-pat"):
if not token and not token_env:
raise ValueError(
f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'"
)
elif auth == "azure-ad":
tenant_id = entry_raw.get("tenant_id")
client_id = entry_raw.get("client_id")
client_secret_env = entry_raw.get("client_secret_env")
if not all([tenant_id, client_id, client_secret_env]):
raise ValueError(
f"providers[{i}]: auth='azure-ad' requires "
"'tenant_id', 'client_id', and 'client_secret_env'"
)
for field_name, field_val in [
("tenant_id", tenant_id),
("client_id", client_id),
("client_secret_env", client_secret_env),
]:
if not isinstance(field_val, str) or not field_val.strip():
raise ValueError(
f"providers[{i}]: '{field_name}' must be a non-empty string"
)
# azure-cli needs no extra fields
entries.append(
AuthConfigEntry(
hosts=tuple(hosts),
provider=provider,
auth=auth,
token=token,
token_env=token_env,
tenant_id=entry_raw.get("tenant_id"),
client_id=entry_raw.get("client_id"),
client_secret_env=entry_raw.get("client_secret_env"),
)
)
return entries
def find_entries_for_url(
url: str, entries: list[AuthConfigEntry]
) -> list[AuthConfigEntry]:
"""Return entries whose ``hosts`` match the hostname of *url*."""
hostname = (urlparse(url).hostname or "").lower()
if not hostname:
return []
return [
e
for e in entries
if any(
pattern == hostname or fnmatch(hostname, pattern)
for pattern in e.hosts
)
]

View File

@@ -0,0 +1,24 @@
"""GitHub authentication provider."""
from __future__ import annotations
from .base import AuthProvider
class GitHubAuth(AuthProvider):
"""GitHub authentication provider.
Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
OAuth tokens, and GitHub App installation tokens.
"""
key = "github"
supported_auth_schemes = ("bearer",)
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Return ``Authorization: Bearer <token>``."""
if auth_scheme != "bearer":
raise ValueError(
f"GitHubAuth does not support auth scheme {auth_scheme!r}"
)
return {"Authorization": f"Bearer {token}"}

View File

@@ -0,0 +1,149 @@
"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``.
No credentials are sent unless the user has created ``auth.json``.
For each outbound URL the helper matches the hostname against
configured entries, resolves the token via the appropriate provider
class, and attaches auth headers. Redirect safety is enforced:
the ``Authorization`` header is stripped when a redirect leaves the
entry's declared hosts. On 401/403 the next matching entry is tried,
then unauthenticated.
"""
from __future__ import annotations
import urllib.error
import urllib.request
from fnmatch import fnmatch
from urllib.parse import urlparse
from . import get_provider
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
_config_override: list[AuthConfigEntry] | None = None
_config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded
def _load_config() -> list[AuthConfigEntry]:
"""Load auth config, using override if set (for testing).
The result is cached per-process so ``auth.json`` is read at most once,
and any warning about a malformed file fires only once.
"""
global _config_cache
if _config_override is not None:
return _config_override
if _config_cache is not None:
return _config_cache
try:
_config_cache = load_auth_config()
except (ValueError, OSError) as exc:
import warnings
config_path = _default_config_path()
warnings.warn(
f"Failed to load {config_path}: {exc}. "
"All requests will be unauthenticated.",
UserWarning,
stacklevel=2,
)
_config_cache = []
return _config_cache
def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
"""Return True if *hostname* matches any pattern in *hosts*."""
hostname = hostname.lower()
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
def __init__(self, hosts: tuple[str, ...]) -> None:
super().__init__()
self._hosts = hosts
def redirect_request(self, req, fp, code, msg, headers, newurl):
original_auth = (
req.get_header("Authorization")
or req.unredirected_hdrs.get("Authorization")
)
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
hostname = (urlparse(newurl).hostname or "").lower()
if _hostname_in_hosts(hostname, self._hosts):
if original_auth:
new_req.add_unredirected_header("Authorization", original_auth)
else:
new_req.headers.pop("Authorization", None)
new_req.unredirected_hdrs.pop("Authorization", None)
return new_req
def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request:
"""Build a :class:`~urllib.request.Request`, attaching auth when config matches.
Uses the first matching entry from ``auth.json`` whose token resolves.
Returns a plain request when no entry matches or the file doesn't exist.
"""
headers: dict[str, str] = {}
if extra_headers:
# Strip Authorization from extra_headers to prevent bypass
headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
# Auth headers applied last — cannot be overridden by extra_headers
entries = find_entries_for_url(url, _load_config())
for entry in entries:
provider = get_provider(entry.provider)
if provider is None:
continue
token = provider.resolve_token(entry)
if token:
headers.update(provider.auth_headers(token, entry.auth))
break
return urllib.request.Request(url, headers=headers)
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
1. Find ``auth.json`` entries whose hosts match the URL.
2. For each entry, resolve the token and try the request.
3. On 401/403 move to the next matching entry.
4. After all entries exhausted (or none matched), try unauthenticated.
5. Non-auth errors (404, 500, network) raise immediately.
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
"""
entries = find_entries_for_url(url, _load_config())
def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
merged = {}
if extra_headers:
# Strip Authorization from extra_headers to prevent bypass
merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
# Auth headers applied last — cannot be overridden by extra_headers
merged.update(auth_headers)
return urllib.request.Request(url, headers=merged)
# Try each matching entry
for entry in entries:
provider = get_provider(entry.provider)
if provider is None:
continue
token = provider.resolve_token(entry)
if not token:
continue
req = _make_req(provider.auth_headers(token, entry.auth))
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
try:
return opener.open(req, timeout=timeout)
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
exc.close()
continue # try next entry
raise
# No entry worked (or none matched) — unauthenticated fallback
req = _make_req({})
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310

View File

@@ -1707,20 +1707,20 @@ class ExtensionCatalog:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
"""Build a urllib Request, adding auth headers when a provider matches.
Delegates to :func:`specify_cli._github_http.build_github_request`.
Delegates to :func:`specify_cli.authentication.http.build_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli._github_http.open_github_url`.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.

View File

@@ -66,6 +66,7 @@ def _register_builtins() -> None:
from .kilocode import KilocodeIntegration
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .lingma import LingmaIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
from .qodercli import QodercliIntegration
@@ -97,6 +98,7 @@ def _register_builtins() -> None:
_register(KilocodeIntegration())
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(LingmaIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
_register(QodercliIntegration())

View File

@@ -20,6 +20,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from .manifest import IntegrationManifest
@@ -606,6 +608,7 @@ class IntegrationBase(ABC):
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
@@ -953,7 +956,6 @@ class TomlIntegration(IntegrationBase):
and ``>``) keep their YAML semantics instead of being treated as
raw text.
"""
import yaml
frontmatter_text, _ = TomlIntegration._split_frontmatter(content)
if not frontmatter_text:
@@ -1140,7 +1142,6 @@ class YamlIntegration(IntegrationBase):
@staticmethod
def _extract_frontmatter(content: str) -> dict[str, Any]:
"""Extract frontmatter as a dict from YAML frontmatter block."""
import yaml
if not content.startswith("---"):
return {}
@@ -1201,24 +1202,38 @@ class YamlIntegration(IntegrationBase):
text = text[len("speckit.") :]
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
@staticmethod
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
@classmethod
def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]:
"""Build the base YAML header."""
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"parameters": [
{
"key": "args",
"input_type": "string",
"requirement": "optional",
"default": "",
"description": "User input passed to the command.",
}
],
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
return header
@classmethod
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
import yaml
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
header = cls._build_yaml_header(title, description)
header_yaml = yaml.safe_dump(
header,
@@ -1227,12 +1242,20 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# Indent each line for YAML block scalar
# Indent the body for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
lines = [
header_yaml,
"prompt: |",
indented,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"
def setup(
self,
project_root: Path,
@@ -1391,7 +1414,6 @@ class SkillsIntegration(IntegrationBase):
template. Each SKILL.md has normalised frontmatter containing
``name``, ``description``, ``compatibility``, and ``metadata``.
"""
import yaml
templates = self.list_command_templates()
if not templates:

View File

@@ -265,7 +265,6 @@ class IntegrationCatalog:
) -> Dict[str, Any]:
"""Fetch one catalog, with per-URL caching."""
import urllib.error
import urllib.request
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
@@ -289,7 +288,9 @@ class IntegrationCatalog:
pass # Cache cleanup is best-effort; ignore deletion failures.
try:
with urllib.request.urlopen(entry.url, timeout=10) as resp:
from specify_cli.authentication.http import open_url
with open_url(entry.url, timeout=10) as resp:
# Validate final URL after redirects
final_url = resp.geturl()
if final_url != entry.url:

View File

@@ -0,0 +1,41 @@
"""Lingma IDE integration. — skills-based agent.
Lingma IDE uses ``.lingma/skills/speckit-<name>/SKILL.md`` layout.
In Specify CLI, the Lingma integration is skills-only, and ``--skills``
defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class LingmaIntegration(SkillsIntegration):
"""Integration for Lingma IDE."""
key = "lingma"
config = {
"name": "Lingma",
"folder": ".lingma/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".lingma/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".lingma/rules/specify-rules.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills",
),
]

View File

@@ -1845,20 +1845,20 @@ class PresetCatalog:
)
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
"""Build a urllib Request, adding auth headers when a provider matches.
Delegates to :func:`specify_cli._github_http.build_github_request`.
Delegates to :func:`specify_cli.authentication.http.build_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli._github_http.open_github_url`.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
"""Load catalog stack configuration from a YAML file.

View File

@@ -11,6 +11,15 @@ from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
class SymlinkedSharedPathError(ValueError):
"""Raised when a shared infrastructure path or ancestor is a symlink.
Distinct from other unsafe-path errors so callers can preserve symlinked
destinations as customizations while still letting genuine safety errors
(e.g. path escape, not-a-directory) propagate and abort the operation.
"""
def load_speckit_manifest(
project_path: Path,
*,
@@ -89,7 +98,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if current.exists():
if not current.is_dir():
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
@@ -102,7 +111,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
current.mkdir()
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
@@ -119,7 +128,7 @@ def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if not current.exists():
continue
if not current.is_dir():
@@ -145,7 +154,7 @@ def _ensure_safe_shared_destination(
_validate_safe_shared_directory(project_path, dest.parent)
label = _shared_destination_label(project_path, dest)
if dest.is_symlink():
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
raise SymlinkedSharedPathError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
if dest.exists():
try:
@@ -242,58 +251,147 @@ def install_shared_infra(
console: Any,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared scripts and templates into *project_path*."""
"""Install shared scripts and templates into *project_path*.
When ``refresh_managed`` is True, files whose on-disk hash still matches
the previously recorded manifest hash are overwritten with the bundled
version. Files whose hash diverges are treated as user customizations and
preserved with a warning. ``force=True`` overwrites every regular file
(symlinks and symlinked-parent destinations are always preserved with a
warning — the safe-destination check refuses to follow them so writes
cannot escape the project root). ``refresh_hint`` is shown after the
customization warning to tell the user which flag would overwrite their
customizations.
"""
from .integrations.manifest import _sha256
manifest = load_speckit_manifest(project_path, version=version, console=console)
prior_hashes = dict(manifest.files)
def _is_managed(rel: str, dst: Path) -> bool:
expected = prior_hashes.get(rel)
if not expected or not dst.is_file() or dst.is_symlink():
return False
try:
return _sha256(dst) == expected
except OSError:
return False
skipped_files: list[str] = []
preserved_user_files: list[str] = []
symlinked_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
if not dst.exists():
return True, None
if force:
return True, None
if refresh_managed:
if _is_managed(rel, dst):
return True, None
if rel in prior_hashes:
return False, "preserved"
return False, "skip"
return False, "skip"
def _safe_dest_or_bucket(dst: Path, rel: str, *, parent_must_exist: bool = True) -> bool:
"""Run the safe-destination check and bucket symlinked paths.
Returns True when the destination is safe to consider (write or skip).
Returns False (and records *rel* under ``symlinked_files``) when the
destination or any of its ancestors is a symlink — those paths can't
be written to safely, but they shouldn't abort the whole switch
either. They're surfaced as a separate "symlinked" warning bucket.
Other unsafe-path errors (e.g. path escape, parent-not-a-directory)
are NOT caught here: they re-raise so the operation aborts, since
treating them as "symlinked" would mask security-relevant failures.
"""
try:
_ensure_safe_shared_destination(project_path, dst, parent_must_exist=parent_must_exist)
except SymlinkedSharedPathError:
symlinked_files.append(rel)
return False
return True
def _ensure_or_bucket_dir(directory: Path) -> bool:
"""Create *directory* unless an ancestor is symlinked.
Returns True when the directory is safe to use. Returns False (and
records the path under ``symlinked_files``) when a symlink ancestor
forces us to skip the whole subtree. Other unsafe-path errors
(escape, not-a-directory) re-raise so the operation aborts.
"""
try:
_ensure_safe_shared_directory(project_path, directory)
except SymlinkedSharedPathError:
symlinked_files.append(directory.relative_to(project_path).as_posix())
return False
return True
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
_ensure_safe_shared_directory(project_path, dest_scripts)
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
_ensure_safe_shared_directory(project_path, dest_variant)
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
if _ensure_or_bucket_dir(dest_scripts):
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
if _ensure_or_bucket_dir(dest_variant):
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
if dst_path.exists() and not force:
skipped_files.append(dst_path.relative_to(project_path).as_posix())
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
continue
_ensure_safe_shared_directory(project_path, dst_path.parent)
rel = dst_path.relative_to(project_path).as_posix()
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
if not _ensure_or_bucket_dir(dst_path.parent):
continue
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():
dest_templates = project_path / ".specify" / "templates"
_ensure_safe_shared_directory(project_path, dest_templates)
for src in templates_src.iterdir():
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
continue
if _ensure_or_bucket_dir(dest_templates):
for src in templates_src.iterdir():
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
continue
dst = dest_templates / src.name
_ensure_safe_shared_destination(project_path, dst)
if dst.exists() and not force:
skipped_files.append(dst.relative_to(project_path).as_posix())
continue
dst = dest_templates / src.name
rel = dst.relative_to(project_path).as_posix()
if not _safe_dest_or_bucket(dst, rel):
continue
write, bucket = _decide_overwrite(rel, dst)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
rel = dst.relative_to(project_path).as_posix()
planned_templates.append((dst, rel, content))
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
planned_templates.append((dst, rel, content))
for dst_path, rel, content, mode in planned_copies:
_ensure_safe_shared_directory(project_path, dst_path.parent)
if not _ensure_or_bucket_dir(dst_path.parent):
continue
_write_shared_bytes(project_path, dst_path, content, mode=mode)
manifest.record_existing(rel)
@@ -307,11 +405,37 @@ def install_shared_infra(
)
for path in skipped_files:
console.print(f" {path}")
if refresh_managed and refresh_hint:
console.print(refresh_hint)
else:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
)
if symlinked_files:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
f"[yellow]⚠[/yellow] Skipped {len(symlinked_files)} symlinked shared "
"infrastructure path(s) — symlinks are never overwritten because they "
"may resolve outside the project root:"
)
for path in symlinked_files:
console.print(f" {path}")
console.print(
"To restore the bundled version, remove or replace the symlink manually, "
"then re-run the command."
)
if preserved_user_files:
console.print(
f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared "
"infrastructure file(s) (hash differs from previous install):"
)
for path in preserved_user_files:
console.print(f" {path}")
if refresh_hint:
console.print(refresh_hint)
manifest.save()
return True

View File

@@ -322,7 +322,7 @@ class WorkflowCatalog:
# Fetch from URL — validate scheme before opening and after redirects
from urllib.parse import urlparse
from urllib.request import urlopen
from specify_cli.authentication.http import open_url as _open_url
def _validate_catalog_url(url: str) -> None:
parsed = urlparse(url)
@@ -337,7 +337,7 @@ class WorkflowCatalog:
_validate_catalog_url(entry.url)
try:
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
with _open_url(entry.url, timeout=30) as resp:
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:

21
tests/auth_helpers.py Normal file
View File

@@ -0,0 +1,21 @@
"""Shared test helpers for authentication config injection."""
from __future__ import annotations
from specify_cli.authentication.config import AuthConfigEntry
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
"""Build a GitHub ``AuthConfigEntry`` for testing."""
return AuthConfigEntry(
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
provider="github",
auth="bearer",
token_env=token_env,
)
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])

View File

@@ -66,3 +66,18 @@ requires_bash = pytest.mark.skipif(
def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from Rich-formatted CLI output."""
return _ANSI_ESCAPE_RE.sub("", text)
# ---------------------------------------------------------------------------
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_auth_config(monkeypatch):
"""Ensure no test reads the real ~/.specify/auth.json."""
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", [])
# Also clear the per-process cache so tests that unset _config_override
# won't see a previously cached real-file result.
monkeypatch.setattr(_auth_http, "_config_cache", None)

View File

@@ -320,8 +320,8 @@ class TestInitIntegrationFlag:
assert "A new shared manifest will be created" in captured.out
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
"""Shared script refreshes must not follow destination symlinks."""
def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-script-test"
@@ -334,14 +334,15 @@ class TestInitIntegrationFlag:
scripts_dir.mkdir(parents=True)
os.symlink(outside, scripts_dir / "common.sh")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_install_shared_infra(project, "sh", force=True)
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "symlinked shared infrastructure" in captured.out
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
"""Shared template installs must not follow destination symlinks."""
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-template-test"
@@ -354,9 +355,10 @@ class TestInitIntegrationFlag:
templates_dir.mkdir(parents=True)
os.symlink(outside, templates_dir / "plan-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_install_shared_infra(project, "sh", force=True)
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "symlinked shared infrastructure" in captured.out
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
@@ -381,7 +383,7 @@ class TestInitIntegrationFlag:
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
"""Shared infra directory creation must not follow a symlinked .specify."""
"""Shared infra installs must not follow a symlinked .specify directory."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-dir-test"
@@ -390,8 +392,10 @@ class TestInitIntegrationFlag:
outside.mkdir()
os.symlink(outside, project / ".specify")
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
with pytest.raises(ValueError, match="symlinked"):
_install_shared_infra(project, "sh", force=True)
# Nothing should have been written under the symlinked .specify target.
assert list(outside.iterdir()) == []
assert not (outside / "scripts").exists()
assert not (outside / "templates").exists()
@@ -465,8 +469,8 @@ class TestInitIntegrationFlag:
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
"""Full shared infra installs validate destinations before writing any file."""
def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "preflight-install-test"
@@ -486,19 +490,19 @@ class TestInitIntegrationFlag:
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, scripts_dir / "z.sh")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
assert existing.read_text(encoding="utf-8") == "# old a\n"
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
assert outside.read_text(encoding="utf-8") == "# outside\n"
assert existing.read_text(encoding="utf-8") == "# new a\n"
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
"""Nested script source files create safe destination parents at write time."""

View File

@@ -166,12 +166,12 @@ class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
def _patch_urlopen(self, monkeypatch, catalog_data):
"""Patch urllib.request.urlopen to return *catalog_data*."""
"""Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
self._url = url if isinstance(url, str) else url.full_url
def read(self):
return self._data
@@ -185,11 +185,12 @@ class TestCatalogFetch:
def __exit__(self, *a):
pass
def fake_urlopen(url, timeout=10):
def fake_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
return FakeResponse(catalog_data, url)
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
import specify_cli.authentication.http as _auth_http
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
@@ -486,12 +487,12 @@ class TestIntegrationListCatalog:
},
}
import urllib.request
import specify_cli.authentication.http as _auth_http
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
self._url = url if isinstance(url, str) else url.full_url
def read(self):
return self._data
def geturl(self):
@@ -501,7 +502,8 @@ class TestIntegrationListCatalog:
def __exit__(self, *a):
pass
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))
old = os.getcwd()
try:

View File

@@ -1,5 +1,9 @@
"""Tests for GooseIntegration."""
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_yaml import YamlIntegrationTests
@@ -9,3 +13,27 @@ class TestGooseIntegration(YamlIntegrationTests):
COMMANDS_SUBDIR = "recipes"
REGISTRAR_DIR = ".goose/recipes"
CONTEXT_FILE = "AGENTS.md"
def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path):
# “If a generated Goose recipe uses {{args}} in its prompt, it
# must declare a corresponding args parameter.”
integration = get_integration("goose")
assert integration is not None
manifest = IntegrationManifest("goose", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
recipe_files = [path for path in created if path.suffix == ".yaml"]
assert recipe_files
for recipe_file in recipe_files:
data = yaml.safe_load(recipe_file.read_text(encoding="utf-8"))
if "{{args}}" not in data["prompt"]:
continue
assert any(
param.get("key") == "args"
for param in data.get("parameters", [])
), f"{recipe_file} uses {{{{args}}}} but does not declare args"

View File

@@ -0,0 +1,11 @@
"""Tests for LingmaIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestLingmaIntegration(SkillsIntegrationTests):
KEY = "lingma"
FOLDER = ".lingma/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".lingma/skills"
CONTEXT_FILE = ".lingma/rules/specify-rules.md"

View File

@@ -901,6 +901,152 @@ class TestIntegrationSwitch:
assert shared_script.exists()
assert shared_script.read_text(encoding="utf-8") == shared_content
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
# Simulate a stale vendored script: write truncated content as bytes
# (write_text would translate \n→\r\n on Windows and break the hash)
# and update the speckit manifest hash so the stale copy is treated
# as "managed" (installed by spec-kit, not a user customization).
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
shared_script.write_bytes(stale_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
hashlib.sha256(stale_bytes).hexdigest()
)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Stale managed file should be replaced by the bundled version
assert shared_script.read_bytes() == bundled_bytes
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
# User customization: append bytes but do NOT update manifest hash,
# so on-disk hash diverges from the recorded one.
original = shared_script.read_bytes()
custom_bytes = original + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert shared_script.read_bytes() == custom_bytes
assert "Preserved" in result.output
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
# User customization (hash diverges from manifest)
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--refresh-shared-infra",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Customization is overwritten with the bundled version
assert shared_script.read_bytes() == bundled_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
Copilot follow-up on #2375: leaf-only symlink check let writes escape
when an *ancestor* directory was symlinked outside the project root.
"""
import sys
if sys.platform.startswith("win"):
import pytest as _pytest
_pytest.skip("Symlink creation typically requires admin on Windows")
project = _init_project(tmp_path, "claude")
bash_dir = project / ".specify" / "scripts" / "bash"
outside = tmp_path / "outside"
outside.mkdir()
for child in bash_dir.iterdir():
child.rename(outside / child.name)
bash_dir.rmdir()
bash_dir.symlink_to(outside, target_is_directory=True)
sentinel = (outside / "common.sh").read_bytes()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Symlinked tree reported, not written through.
assert "symlink" in result.output.lower()
# Outside dir contents unchanged.
assert (outside / "common.sh").read_bytes() == sentinel
def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.
Regression: ensures the decoupling of --force and --refresh-shared-infra.
"""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force alone preserves the customization
assert shared_script.read_bytes() == custom_bytes
def test_switch_from_nothing(self, tmp_path):
"""Switch when no integration is installed should just install the target."""
project = tmp_path / "bare"

View File

@@ -0,0 +1,860 @@
"""Tests for the authentication provider registry and config-driven HTTP helpers.
Covers:
- Config loading (auth.json parsing, validation, permission warning)
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
- GitHubAuth — bearer headers
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
- Host matching (find_entries_for_url)
- open_url — config-driven auth with fallthrough and redirect stripping
- build_request — single-shot request construction
- _fetch_latest_release_tag() delegation
"""
from __future__ import annotations
import base64
import json
import os
import pytest
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
from specify_cli.authentication.base import AuthProvider
from specify_cli.authentication.config import (
AuthConfigEntry,
find_entries_for_url,
load_auth_config,
)
from specify_cli.authentication.github import GitHubAuth
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
"""Build a standard GitHub config entry."""
return AuthConfigEntry(
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
provider="github",
auth="bearer",
token=token,
token_env=token_env if token is None else None,
)
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
"""Build an ADO basic-pat config entry."""
return AuthConfigEntry(
hosts=("dev.azure.com",),
provider="azure-devops",
auth="basic-pat",
token_env=token_env,
)
class _StubProvider(AuthProvider):
"""Minimal concrete provider for registry mechanics tests."""
key = "stub-provider"
supported_auth_schemes = ("bearer",)
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
class TestLoadAuthConfig:
def test_missing_file_returns_empty(self, tmp_path):
assert load_auth_config(tmp_path / "nonexistent.json") == []
def test_valid_github_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
assert entries[0].provider == "github"
assert entries[0].auth == "bearer"
assert entries[0].token_env == "GH_TOKEN"
def test_valid_ado_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
assert entries[0].provider == "azure-devops"
assert entries[0].auth == "basic-pat"
def test_inline_token(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token": "ghp_inline_token",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].token == "ghp_inline_token"
def test_azure_ad_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "tid",
"client_id": "cid",
"client_secret_env": "SECRET",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].auth == "azure-ad"
assert entries[0].tenant_id == "tid"
def test_azure_cli_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-cli",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].auth == "azure-cli"
def test_multiple_entries(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
]
}))
entries = load_auth_config(cfg)
assert len(entries) == 2
# -- Negative: validation errors --
def test_invalid_json_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("not json")
with pytest.raises(json.JSONDecodeError):
load_auth_config(cfg)
def test_not_object_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("[]")
with pytest.raises(ValueError, match="JSON object"):
load_auth_config(cfg)
def test_missing_providers_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({"foo": "bar"}))
with pytest.raises(ValueError, match="providers"):
load_auth_config(cfg)
def test_empty_hosts_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="non-empty"):
load_auth_config(cfg)
def test_missing_provider_key_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="provider"):
load_auth_config(cfg)
def test_unsupported_auth_scheme_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="does not support"):
load_auth_config(cfg)
def test_bearer_without_token_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
}))
with pytest.raises(ValueError, match="token"):
load_auth_config(cfg)
def test_azure_ad_missing_fields_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "tid",
}]
}))
with pytest.raises(ValueError, match="azure-ad"):
load_auth_config(cfg)
def test_unknown_provider_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="unknown provider"):
load_auth_config(cfg)
def test_incompatible_provider_scheme_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "basic-pat",
"token_env": "X",
}]
}))
with pytest.raises(ValueError, match="does not support"):
load_auth_config(cfg)
def test_dangerous_wildcard_host_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)
def test_multi_wildcard_host_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)
def test_valid_star_dot_host_accepted(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
}))
entries = load_auth_config(cfg)
assert entries[0].hosts == ("*.visualstudio.com",)
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
def test_world_readable_warns(self, tmp_path):
import stat
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
}))
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
with pytest.warns(UserWarning, match="readable by group"):
load_auth_config(cfg)
# ---------------------------------------------------------------------------
# Host matching
# ---------------------------------------------------------------------------
class TestFindEntriesForUrl:
def test_exact_match(self):
entry = _github_entry()
result = find_entries_for_url("https://github.com/org/repo", [entry])
assert result == [entry]
def test_wildcard_match(self):
entry = AuthConfigEntry(
hosts=("*.visualstudio.com",),
provider="azure-devops",
auth="basic-pat",
token_env="ADO_PAT",
)
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
assert result == [entry]
def test_no_match_returns_empty(self):
entry = _github_entry()
result = find_entries_for_url("https://evil.example.com/file", [entry])
assert result == []
def test_no_match_for_lookalike_host(self):
entry = _github_entry()
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
assert result == []
def test_empty_url_returns_empty(self):
assert find_entries_for_url("", [_github_entry()]) == []
def test_empty_entries_returns_empty(self):
assert find_entries_for_url("https://github.com/org/repo", []) == []
def test_multiple_matches_returned(self):
e1 = _github_entry(token_env="GH_TOKEN")
e2 = _github_entry(token_env="GITHUB_TOKEN")
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
assert len(result) == 2
# ---------------------------------------------------------------------------
# Registry mechanics
# ---------------------------------------------------------------------------
class TestAuthRegistry:
def test_github_registered(self):
assert "github" in AUTH_REGISTRY
def test_azure_devops_registered(self):
assert "azure-devops" in AUTH_REGISTRY
def test_get_provider_returns_github(self):
assert isinstance(get_provider("github"), GitHubAuth)
def test_get_provider_returns_azure_devops(self):
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
def test_get_provider_unknown_returns_none(self):
assert get_provider("does-not-exist") is None
def test_register_duplicate_raises_key_error(self):
class _UniqueStub(_StubProvider):
key = "__test_duplicate__"
try:
_register(_UniqueStub())
with pytest.raises(KeyError, match="already registered"):
_register(_UniqueStub())
finally:
AUTH_REGISTRY.pop("__test_duplicate__", None)
def test_register_empty_key_raises_value_error(self):
class _EmptyKey(_StubProvider):
key = ""
with pytest.raises(ValueError, match="empty key"):
_register(_EmptyKey())
# ---------------------------------------------------------------------------
# GitHubAuth
# ---------------------------------------------------------------------------
class TestGitHubAuth:
def test_bearer_headers(self):
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
def test_unsupported_scheme_raises(self):
with pytest.raises(ValueError, match="basic-pat"):
GitHubAuth().auth_headers("tok", "basic-pat")
def test_resolve_token_from_env(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", "env-token")
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
def test_resolve_token_inline(self):
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
def test_resolve_token_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " my-token ")
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
assert GitHubAuth().resolve_token(_github_entry()) is None
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
monkeypatch.delenv("GH_TOKEN", raising=False)
assert GitHubAuth().resolve_token(_github_entry()) is None
def test_key(self):
assert GitHubAuth.key == "github"
def test_supported_schemes(self):
assert GitHubAuth.supported_auth_schemes == ("bearer",)
# ---------------------------------------------------------------------------
# AzureDevOpsAuth
# ---------------------------------------------------------------------------
class TestAzureDevOpsAuth:
def test_basic_pat_headers(self):
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
encoded = base64.b64encode(b":my-pat").decode("ascii")
assert headers == {"Authorization": f"Basic {encoded}"}
def test_basic_pat_format(self):
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
assert raw == ":test-pat"
def test_bearer_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
def test_azure_cli_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
def test_azure_ad_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
def test_unsupported_scheme_raises(self):
with pytest.raises(ValueError):
AzureDevOpsAuth().auth_headers("tok", "ntlm")
def test_resolve_token_basic_pat(self, monkeypatch):
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
def test_resolve_token_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
def test_resolve_token_missing_returns_none(self, monkeypatch):
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
def test_key(self):
assert AzureDevOpsAuth.key == "azure-devops"
def test_supported_schemes(self):
schemes = AzureDevOpsAuth.supported_auth_schemes
assert "basic-pat" in schemes
assert "bearer" in schemes
assert "azure-cli" in schemes
assert "azure-ad" in schemes
def test_resolve_token_azure_cli_success(self):
"""azure-cli acquires token via az CLI."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 0
result.stdout = '{"accessToken": "cli-acquired-token"}'
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
def test_resolve_token_azure_cli_failure_returns_none(self):
"""azure-cli returns None when az CLI fails."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 1
result.stdout = ""
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_cli_not_installed_returns_none(self):
"""azure-cli returns None when az is not installed."""
from unittest.mock import patch
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_success(self, monkeypatch):
"""azure-ad acquires token via OAuth2 client credentials."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
"""azure-ad returns None when client secret env var is missing."""
monkeypatch.delenv("MY_SECRET", raising=False)
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
"""azure-ad returns None on network errors."""
import urllib.error
from unittest.mock import patch
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
with patch("urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused")):
assert AzureDevOpsAuth().resolve_token(entry) is None
# ---------------------------------------------------------------------------
# open_url / build_request — positive tests
# ---------------------------------------------------------------------------
class TestAuthenticatedHttp:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://github.com/org/repo")
assert req.get_header("Authorization") == "Bearer my-token"
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://evil.example.com/file")
assert "Authorization" not in req.headers
def test_build_request_no_auth_when_no_config(self, monkeypatch):
from specify_cli.authentication.http import build_request
self._set_config(monkeypatch, [])
req = build_request("https://github.com/org/repo")
assert "Authorization" not in req.headers
def test_build_request_extra_headers(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
assert req.get_header("Accept") == "application/json"
assert req.get_header("Authorization") == "Bearer my-token"
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener.open.side_effect = fake_open
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
open_url("https://github.com/org/repo/catalog.json")
assert captured["req"].get_header("Authorization") == "Bearer my-token"
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://example.com/file.json")
assert captured["req"].get_header("Authorization") is None
def test_open_url_no_auth_when_no_config(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://github.com/org/repo")
assert captured["req"].get_header("Authorization") is None
def test_open_url_falls_through_on_401(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "bad-token")
self._set_config(monkeypatch, [_github_entry()])
call_count = 0
def fake_side_effect(req, timeout=None):
nonlocal call_count; call_count += 1
if call_count == 1:
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
open_url("https://github.com/org/repo")
assert call_count == 2
# ---------------------------------------------------------------------------
# open_url — negative tests
# ---------------------------------------------------------------------------
class TestAuthenticatedHttpNegative:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def test_500_raises_immediately(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "tok")
self._set_config(monkeypatch, [_github_entry()])
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.HTTPError, match="500"):
open_url("https://github.com/org/repo")
def test_404_raises_immediately(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "tok")
self._set_config(monkeypatch, [_github_entry()])
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.HTTPError, match="404"):
open_url("https://github.com/org/repo")
def test_urlerror_propagates(self, monkeypatch):
import urllib.error
from unittest.mock import patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("refused")):
with pytest.raises(urllib.error.URLError):
open_url("https://example.com/file")
def test_timeout_propagates(self, monkeypatch):
import socket
from unittest.mock import patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=socket.timeout("timed out")):
with pytest.raises(socket.timeout):
open_url("https://example.com/file")
# ---------------------------------------------------------------------------
# _load_config caching
# ---------------------------------------------------------------------------
class TestLoadConfigCaching:
def test_config_cached_after_first_load(self, monkeypatch):
"""_load_config() should call load_auth_config only once per process."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
from specify_cli.authentication.config import AuthConfigEntry
# Allow the real load path (no override)
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
entry = _github_entry()
call_count = 0
def fake_load(path=None):
nonlocal call_count
call_count += 1
return [entry]
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
_mod._load_config()
_mod._load_config()
_mod._load_config()
assert call_count == 1
def test_cache_bypassed_by_override(self, monkeypatch):
"""When _config_override is set, the cache is ignored entirely."""
from specify_cli.authentication import http as _mod
sentinel = [_github_entry()]
monkeypatch.setattr(_mod, "_config_override", sentinel)
monkeypatch.setattr(_mod, "_config_cache", None)
result = _mod._load_config()
assert result is sentinel
# Cache must not have been populated when override is active
assert _mod._config_cache is None
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
import warnings as _warnings
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
call_count = 0
def fail_load(path=None):
nonlocal call_count
call_count += 1
raise ValueError("bad config")
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
with _warnings.catch_warnings(record=True) as w:
_warnings.simplefilter("always")
result1 = _mod._load_config()
result2 = _mod._load_config()
result3 = _mod._load_config()
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
assert len(user_warnings) == 1, "Expected exactly one warning"
# Loader called only once — subsequent calls used cache
assert call_count == 1
# All calls returned the cached empty list
assert result1 == result2 == result3 == []
# ---------------------------------------------------------------------------
# Redirect stripping
# ---------------------------------------------------------------------------
class TestRedirectStripping:
def test_redirect_within_hosts_preserves_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://codeload.github.com/org/repo/zip")
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer tok"
def test_redirect_outside_hosts_strips_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://objects.githubusercontent.com/asset")
assert new_req is not None
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
"""Auth survives a multi-hop redirect chain within allowed hosts."""
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
handler = _StripAuthOnRedirect(hosts)
# First hop: github.com → codeload.github.com
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
"https://codeload.github.com/org/repo/zip")
assert req2 is not None
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
assert auth2 == "Bearer tok"
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
"https://objects-origin.githubusercontent.com/asset")
assert req3 is not None
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
assert auth3 == "Bearer tok"
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation
# ---------------------------------------------------------------------------
class TestFetchLatestReleaseTagDelegation:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def _capture_request(self):
import json as _json
from unittest.mock import MagicMock
captured: dict = {}
def side_effect(req, timeout=None):
captured["request"] = req
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
resp = MagicMock(); resp.read.return_value = body
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
return cm
return captured, side_effect
def test_gh_token_forwarded_when_configured(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli import _fetch_latest_release_tag
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
self._set_config(monkeypatch, [_github_entry()])
captured, side_effect = self._capture_request()
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
def test_no_config_means_no_auth(self, monkeypatch):
from unittest.mock import patch
from specify_cli import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") is None
def test_accept_header_present(self, monkeypatch):
from unittest.mock import patch
from specify_cli import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Accept") == "application/vnd.github+json"

View File

@@ -2453,6 +2453,10 @@ class TestExtensionCatalog:
(project_dir / ".specify").mkdir()
return ExtensionCatalog(project_dir)
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
@@ -2473,6 +2477,7 @@ class TestExtensionCatalog:
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
@@ -2481,6 +2486,7 @@ class TestExtensionCatalog:
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
@@ -2489,49 +2495,40 @@ class TestExtensionCatalog:
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
"""Auth is NOT attached to hosts not listed in auth.json."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
"""No auth header when no auth.json config exists."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for api.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
@@ -2539,49 +2536,17 @@ class TestExtensionCatalog:
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_redirect_preserves_auth_for_github_to_codeload(self):
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer ghp_test"
def test_redirect_strips_auth_for_github_to_external(self):
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth_header = new_req.headers.get("Authorization")
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
assert auth_header is None
assert auth_unredirected is None
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
catalog_data = {"schema_version": "1.0", "extensions": {}}
@@ -2589,6 +2554,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
captured = {}
mock_opener = MagicMock()
@@ -2606,17 +2572,18 @@ class TestExtensionCatalog:
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header via opener for GitHub URLs."""
"""download_extension passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock
import zipfile, io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
# Build a minimal valid ZIP in memory
@@ -2631,7 +2598,6 @@ class TestExtensionCatalog:
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
@@ -2648,7 +2614,7 @@ class TestExtensionCatalog:
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"

View File

@@ -1224,6 +1224,10 @@ class TestExtensionPriorityResolution:
class TestPresetCatalog:
"""Test template catalog functionality."""
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
def test_default_catalog_url(self, project_dir):
"""Test default catalog URL."""
catalog = PresetCatalog(project_dir)
@@ -1418,6 +1422,7 @@ class TestPresetCatalog:
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
@@ -1426,6 +1431,7 @@ class TestPresetCatalog:
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
@@ -1434,58 +1440,50 @@ class TestPresetCatalog:
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
"""Auth is NOT attached to hosts not listed in auth.json."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
"""No auth header when no auth.json config exists."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
"""_fetch_single_catalog passes Authorization header when configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
catalog_data = {"schema_version": "1.0", "presets": {}}
@@ -1493,6 +1491,7 @@ class TestPresetCatalog:
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
captured = {}
mock_opener = MagicMock()
@@ -1510,16 +1509,17 @@ class TestPresetCatalog:
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
"""download_pack passes Authorization header via opener for GitHub URLs."""
"""download_pack passes Authorization header when configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
import io
@@ -1551,7 +1551,7 @@ class TestPresetCatalog:
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_pack("test-pack", target_dir=project_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"

View File

@@ -23,7 +23,6 @@ from specify_cli import (
_normalize_tag,
app,
)
from tests.conftest import strip_ansi
runner = CliRunner()
@@ -31,6 +30,10 @@ runner = CliRunner()
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
_RATE_LIMITED_REASON = (
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
)
def _mock_urlopen_response(payload: dict) -> MagicMock:
body = json.dumps(payload).encode("utf-8")
@@ -66,11 +69,20 @@ class TestSelfUpgradeStub:
]
def test_stub_makes_no_network_call(self):
# If the stub ever starts calling urllib, this patch's side_effect
# would fire and the assertion below would fail.
with patch(
"specify_cli.urllib.request.urlopen",
side_effect=AssertionError("stub must not hit the network"),
# The stub must not hit the network via either urllib path:
# unauthenticated requests use urlopen() directly; authenticated ones
# go through build_opener(...).open(). Both are patched so that any
# accidental network call raises immediately.
network_error = AssertionError("stub must not hit the network")
with (
patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=network_error,
),
patch(
"specify_cli.authentication.http.urllib.request.build_opener",
side_effect=network_error,
),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
@@ -138,7 +150,7 @@ class TestNormalizeTag:
class TestUserStory1:
def test_newer_available_prints_update_and_install_command(self):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -151,7 +163,7 @@ class TestUserStory1:
def test_up_to_date_prints_current_only(self):
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -163,7 +175,7 @@ class TestUserStory1:
def test_dev_build_ahead_of_release_is_up_to_date(self):
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -174,7 +186,7 @@ class TestUserStory1:
def test_unknown_installed_still_prints_latest_and_reinstall(self):
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -186,7 +198,7 @@ class TestUserStory1:
def test_unparseable_tag_routes_to_indeterminate(self):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -200,7 +212,7 @@ class TestUserStory1:
class TestFailureCategorization:
def test_urlerror_maps_to_offline(self):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("no route to host"),
):
tag, reason = _fetch_latest_release_tag()
@@ -209,7 +221,7 @@ class TestFailureCategorization:
def test_timeout_maps_to_offline(self):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=TimeoutError(),
):
tag, reason = _fetch_latest_release_tag()
@@ -218,17 +230,17 @@ class TestFailureCategorization:
def test_403_maps_to_rate_limited(self):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=_http_error(403, "rate limited"),
):
tag, reason = _fetch_latest_release_tag()
assert tag is None
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
assert reason == _RATE_LIMITED_REASON
@pytest.mark.parametrize("code", [404, 500, 502])
def test_other_http_uses_code_string(self, code):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=_http_error(code, "oops"),
):
tag, reason = _fetch_latest_release_tag()
@@ -238,7 +250,7 @@ class TestFailureCategorization:
def test_generic_exception_propagates(self):
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=RuntimeError("boom"),
):
with pytest.raises(RuntimeError):
@@ -247,7 +259,7 @@ class TestFailureCategorization:
_FAILURE_CASES = [
("offline or timeout", urllib.error.URLError("down")),
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
(_RATE_LIMITED_REASON, _http_error(403)),
("HTTP 500", _http_error(500)),
]
@@ -258,22 +270,21 @@ class TestUserStory2:
self, expected_reason, side_effect
):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert "Installed: 0.7.4" in output
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
if expected_reason == _RATE_LIMITED_REASON:
assert "Could not check latest release: rate limited" in output
assert "GH_TOKEN" in output
assert "GITHUB_TOKEN" in output
assert "~/.specify/auth.json" in output
else:
assert f"Could not check latest release: {expected_reason}" in output
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
def test_failure_exits_zero(self, _expected_reason, side_effect):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
assert result.exit_code == 0
@@ -283,7 +294,7 @@ class TestUserStory2:
self, _expected_reason, side_effect
):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
combined = (result.output or "") + (result.stderr or "")
@@ -302,12 +313,20 @@ def _capture_request_via_urlopen():
return captured, _side_effect
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
class TestUserStory3:
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
@@ -315,8 +334,11 @@ class TestUserStory3:
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
@@ -325,7 +347,7 @@ class TestUserStory3:
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
@@ -333,8 +355,9 @@ class TestUserStory3:
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", "")
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
@@ -342,8 +365,9 @@ class TestUserStory3:
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
@@ -351,8 +375,11 @@ class TestUserStory3:
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
@@ -364,7 +391,7 @@ class TestUserStory3:
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
combined = strip_ansi((result.output or "") + (result.stderr or ""))
@@ -377,7 +404,7 @@ class TestUserStory3:
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
combined = strip_ansi((result.output or "") + (result.stderr or ""))