Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions scripts/package-npm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
# Usage:
# ./scripts/package-npm.sh # pack only
# ./scripts/package-npm.sh --publish # also publish to npmjs.com
#
# --publish checks both registries' credentials before doing any work, so it
# needs a terminal: an expired npm session is renewed with an interactive
# `npm login` (the account has 2FA). The MCP Registry is reached with the token
# from `gh auth token`, which must carry read:org; set CODEGRAPH_MCP_TOKEN to
# use a PAT limited to that scope instead. Packing alone needs no credentials.

set -euo pipefail

Expand All @@ -31,6 +37,140 @@ BIN_DIR="$PKG_DIR/bin"
echo "=== CodeGraph npm package builder ==="
echo ""

# ------------------------------------------------------------------ auth
#
# The credentials are checked here, before the tests, the asset probe and the
# pack - not at the point of use. Every one of those has to pass anyway, and
# discovering an unusable credential after them means doing them again. The last
# release failed exactly there: `npm publish` ran after several minutes of work
# and `mcp-publisher` after that, so a stale token surfaced at the end.
#
# What is checked here is deliberately not what is minted here. The npm session
# is long-lived, so logging in now is the whole fix for that half. The MCP
# Registry's token lives 300 seconds - less than the rest of this script takes -
# so it is minted at the publish step instead, and what is checked now is the
# GitHub token it will be minted from, which is the credential that goes stale.
#
# Only for --publish. Packing needs no credentials, and this script also runs as
# a plain build step, where prompting for a login would hang it.
MCP_TOKEN=""
if [ "${1:-}" = "--publish" ]; then
echo "Checking publish credentials..."

# npm's own session. Left interactive on purpose: the account has 2FA, so this
# needs a human and a TTY, and that is better spent now than after the pack.
if npm_user="$(npm whoami 2>/dev/null)"; then
echo " ✓ npm authenticated as $npm_user"
else
echo " npm: not logged in - starting login (2FA expected)"
npm login || { echo " ✗ npm login failed - not packaging" >&2; exit 1; }
npm_user="$(npm whoami 2>/dev/null || echo '<unknown>')"
echo " ✓ npm authenticated as $npm_user"
fi

# The MCP Registry decides which namespaces a token may publish to by calling
# GET /user/memberships/orgs and granting io.github.<org>/* for every org the
# account owns; GitHub gates that call behind read:org. A token without the
# scope does not fail to log in - GitHub answers 403, the registry reads that
# as "owns no organisations", and it issues a perfectly valid token scoped to
# io.github.<user>/* alone. The 403 then arrives at the publish, carrying a
# message about organisation membership that is not the actual cause.
#
# A successful login therefore cannot tell the two cases apart, so the
# precondition is checked against GitHub directly instead of inferred from one.
#
# `gh auth token` already carries read:org. It also carries repo and workflow,
# which is broader than the registry needs; a PAT limited to read:org can be
# substituted by setting CODEGRAPH_MCP_TOKEN.
if command -v mcp-publisher >/dev/null 2>&1; then
MCP_TOKEN="${CODEGRAPH_MCP_TOKEN:-}"
if [ -z "$MCP_TOKEN" ] && command -v gh >/dev/null 2>&1; then
MCP_TOKEN="$(gh auth token 2>/dev/null || true)"
fi
if [ -z "$MCP_TOKEN" ]; then
echo " ✗ no GitHub token for the MCP Registry - not packaging" >&2
echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2
exit 1
fi

gh_body="$(mktemp)"
trap 'rm -f "$gh_body"' EXIT
gh_api() {
curl -sS -o "$gh_body" -w '%{http_code}' \
-H "Authorization: Bearer $MCP_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/$1" 2>/dev/null || echo 000
}
# GitHub's error bodies carry no trailing newline, which would otherwise run
# the hint that follows onto the last line of the JSON.
gh_body_err() { printf '%s\n' "$(sed 's/^/ /' "$gh_body")" >&2; }

gh_status="$(gh_api user)"
if [ "$gh_status" != "200" ]; then
echo " ✗ GitHub rejected the token (HTTP $gh_status) - not packaging" >&2
gh_body_err
echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a live PAT with read:org." >&2
exit 1
fi
gh_login="$(node -e "
console.log(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).login);
" "$gh_body")"

# Read the namespace from server.json rather than naming it here: that file is
# what the publish is authorised against, so renaming the server must not be
# able to leave this check passing against the namespace it used to use.
MCP_NAMESPACE="$(node -e "console.log(require('$PKG_DIR/server.json').name.split('/')[0])")"
case "$MCP_NAMESPACE" in
io.github.*) mcp_owner="${MCP_NAMESPACE#io.github.}" ;;
*) mcp_owner="" ;;
esac

if [ -z "$mcp_owner" ]; then
echo " ⚠ $MCP_NAMESPACE is not an io.github.* namespace - ownership not checked"
elif [ "$(printf '%s' "$mcp_owner" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$gh_login" | tr '[:upper:]' '[:lower:]')" ]; then
echo " ✓ $MCP_NAMESPACE is the token's own user namespace ($gh_login)"
else
gh_status="$(gh_api 'user/memberships/orgs?per_page=100')"
if [ "$gh_status" = "403" ]; then
echo " ✗ the GitHub token cannot read organisation membership - not packaging" >&2
echo " GitHub answered 403 for GET /user/memberships/orgs, which needs read:org." >&2
echo " Without it the Registry sees no organisations and refuses $MCP_NAMESPACE." >&2
echo " Use 'gh auth token', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2
exit 1
fi
if [ "$gh_status" != "200" ]; then
echo " ✗ could not read organisation membership from GitHub (HTTP $gh_status)" >&2
gh_body_err
exit 1
fi
# The Registry grants io.github.<org>/* to owners only, which this endpoint
# reports as role "admin". An active plain membership is refused at the
# publish just as a missing one is, so both are refused here.
membership="$(node -e "
const want = process.argv[2].toLowerCase();
const orgs = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
const m = (Array.isArray(orgs) ? orgs : []).find(
(o) => ((o.organization || {}).login || '').toLowerCase() === want);
console.log(m ? m.role + '/' + m.state : 'none/none');
" "$gh_body" "$mcp_owner")"
if [ "$membership" != "admin/active" ]; then
echo " ✗ $gh_login does not own the $mcp_owner organisation - not packaging" >&2
echo " GET /user/memberships/orgs reports role/state: $membership" >&2
echo " The Registry grants $MCP_NAMESPACE to owners (role admin) only." >&2
exit 1
fi
echo " ✓ $gh_login owns $mcp_owner - $MCP_NAMESPACE is publishable"
fi

rm -f "$gh_body"
trap - EXIT
else
echo " ⚠ mcp-publisher not on PATH - the MCP Registry step will be skipped"
fi
echo ""
fi

echo "Removing any bundled binaries (the engine is fetched at install time)..."
for stale in "$BIN_DIR"/codegraph-server-* "$BIN_DIR/onnxruntime.dll"; do
if [ -e "$stale" ]; then
Expand Down Expand Up @@ -172,6 +312,19 @@ if [ "${1:-}" = "--publish" ]; then
echo ""
echo "Updating MCP Registry..."
if command -v mcp-publisher &>/dev/null; then
# The Registry's token is minted here, not in the preflight above: it lives
# 300 seconds, and the tests, the asset probe, the pack and an interactive
# npm 2FA prompt all happen in between. The preflight established that this
# GitHub token can reach the namespace, so this is expected to succeed - it
# is checked anyway, because the npm publish above is already irreversible.
if ! login_log="$(mcp-publisher login github -token "${MCP_TOKEN:-}" 2>&1)"; then
printf '%s\n' "$login_log" >&2
echo "✗ mcp-publisher login failed - npmjs.com has $PKG_VERSION but the MCP" >&2
echo " Registry does not. Nothing else is needed; re-run just that step:" >&2
echo " cd mcp-package && mcp-publisher login github -token \"\$(gh auth token)\" \\" >&2
echo " && mcp-publisher publish --server-json server.json" >&2
exit 1
fi
mcp-publisher publish --server-json server.json
echo "✓ MCP Registry updated"
else
Expand Down
Loading