diff --git a/CHANGELOG.md b/CHANGELOG.md index fb84e560c..d93c862b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Custom-port credential errors now include a ready-to-run `git credential fill` verification command and a link to the auth troubleshooting docs, so users can diagnose miskeyed helpers without guessing. (closes #799) - `apm install` now shows a recovery hint (`apm install --no-policy`) when the `required-packages-deployed` policy check fails, so users know how to unblock without hunting for the flag. (closes #1314) - `apm install -g` now deploys `instructions` primitives for the Copilot target by concatenating all `*.instructions.md` files from each installed package into `~/.copilot/copilot-instructions.md`, the single file Copilot CLI reads at user scope. Previously this primitive type was silently skipped for global installs. Each package's contribution is wrapped in an HTML provenance comment so the file is auditable and multi-package installs accumulate correctly. (closes #650) - `apm compile --target copilot` (and `agents`) no longer writes instructions into `AGENTS.md` when `apm install` has already deployed them to `.github/instructions/`, eliminating duplicate context that Copilot would read from both locations. Mirrors the equivalent dedup behaviour that was already in place for the Claude path (`.claude/rules/`). (closes #1550, refs #1445) diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index c0dd5887c..9b0751c93 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -457,7 +457,13 @@ For self-hosted Git instances on non-standard ports (e.g. Bitbucket Datacenter o | `libsecret` (Linux) | Yes (port in URI) | | `gh auth git-credential` | No -- but only used for GitHub hosts, which do not use custom ports | -If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port`; otherwise either switch helpers or store credentials under fully qualified `https://:/` URLs. +If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port` using the helper-agnostic verification command: + +```sh +printf 'protocol=https\nhost=:\n\n' | git credential fill +``` + +This reproduces exactly what APM sends to the credential helper. If the returned `username`/`password` are wrong or empty, either switch helpers or store credentials under a fully qualified `https://:/` URL. ### SSH connection hangs on corporate/VPN networks diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 2dcdc17a7..ca05edb10 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -234,6 +234,13 @@ backend: | `libsecret` (Linux) | Yes (port in URI) | | `gh auth git-credential` | No -- but only used for GitHub hosts, which do not use custom ports | +To verify what your helper returns for a custom-port host, use the +helper-agnostic command APM itself calls: + +```sh +printf 'protocol=https\nhost=:\n\n' | git credential fill +``` + If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port`; otherwise either switch helpers or store the credential under a fully qualified `https://:/` URL. diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 1fc6a44fd..c921e1ebd 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -48,6 +48,11 @@ T = TypeVar("T") +_PORT_CREDENTIAL_DOCS_URL = ( + "https://microsoft.github.io/apm/getting-started/authentication/" + "#custom-port-hosts-and-per-port-credentials" +) + # --------------------------------------------------------------------------- # Data classes @@ -709,8 +714,10 @@ def build_error_context( # return the wrong credential. Point the user at the concrete fix. if host_info.port is not None: lines.append( - f"[i] Host '{display}' -- verify your credential helper stores per-port entries " - f"(some helpers key by host only)." + f"[i] Host '{display}' -- this helper may key by host only.\n" + f" Verify with: printf 'protocol=https\\nhost={display}\\n\\n'" + f" | git credential fill\n" + f" Docs: {_PORT_CREDENTIAL_DOCS_URL}" ) lines.append("Run with --verbose for detailed auth diagnostics.") diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index f3bf4a4df..c40811c3e 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -4,6 +4,7 @@ import time from concurrent.futures import ThreadPoolExecutor from unittest.mock import patch +from urllib.parse import urlparse import pytest @@ -1227,6 +1228,33 @@ def test_port_hint_appears_when_port_set(self): msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) assert "per-port" in msg, f"Expected per-port hint when port is set, got:\n{msg}" + def test_port_hint_includes_credential_fill_command(self): + with patch.dict(os.environ, {}, clear=True): + with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None): + resolver = AuthResolver() + msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) + assert "git credential fill" in msg, ( + f"Expected 'git credential fill' verification command in hint, got:\n{msg}" + ) + + def test_port_hint_includes_docs_url(self): + with patch.dict(os.environ, {}, clear=True): + with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None): + resolver = AuthResolver() + msg = resolver.build_error_context("bitbucket.corp.com", "clone", port=7999) + # Extract the docs URL from the hint and validate its components with urlparse + # (substring URL assertions are prohibited; see tests.instructions.md) + url_line = next( + (line for line in msg.splitlines() if "microsoft.github.io/apm" in line), None + ) + assert url_line is not None, f"Expected docs URL line in hint, got:\n{msg}" + url = url_line.split()[-1] + parsed = urlparse(url) + assert parsed.hostname == "microsoft.github.io", f"Unexpected hostname: {parsed.hostname}" + assert parsed.fragment == "custom-port-hosts-and-per-port-credentials", ( + f"Unexpected fragment: {parsed.fragment}" + ) + def test_no_port_hint_when_port_missing(self): with patch.dict(os.environ, {}, clear=True): with patch.object(GitHubTokenManager, "resolve_credential_from_git", return_value=None):