Extract GitHub API client into @exodus/stasis-api package - #149
Open
exo-nikita wants to merge 5 commits into
Open
Extract GitHub API client into @exodus/stasis-api package#149exo-nikita wants to merge 5 commits into
exo-nikita wants to merge 5 commits into
Conversation
…client Registry access lived inside `stasis` as `src/apis/npm/`, which meant the npm advisories client and the lazily-bound `semver` shim were only reachable by importing the whole CLI package. Move them into a separate zero-dependency workspace package so API clients are versioned and consumed on their own. - `@exodus/stasis-api/npm` — `advisories()`, moved verbatim - `@exodus/stasis-api/npm/semver` — the npm-CLI-backed semver shim, moved verbatim - `@exodus/stasis-api/github` — new: `releases()`, `release()`, `latestRelease()` and `asset()` for listing releases and fetching their attachments `stasis` now depends on `@exodus/stasis-api`; `audit.js`, `audit-corrections.js` and `sbom.js` import through the package specifier instead of relative paths. The GitHub client is transport-only, matching the npm one: no disk, no caching, and no credential discovery — a `token` is always passed explicitly, so one is never picked up from the environment. Owner/repo slugs are validated (not just escaped) and tags are percent-encoded so a caller-supplied value can't reshape the endpoint it lands in; `Link: rel="next"` pagination refuses to follow a link off the API host, which would carry the token elsewhere. `asset()` verifies the listing's `digest` before returning bytes, so a truncated or swapped download fails at the boundary rather than downstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
Cleanup pass over the new package; no intended behavior change beyond the slug fix below. - Lift the fetch + error-wrap shell into `src/request.js` (`requestOk`). The `—` separator, the 200-char body cap, the swallowed body-read failure and the cause chain were encoded once per client, so a third client would have copied them again and anything GitHub needs later (retry, Retry-After) would land in N places. Both clients' error strings are unchanged. - Validate repo slugs as path segments instead of re-encoding GitHub's account naming policy. The old rule required a leading word character, which refused real repos — `ExodusOSS/.github` among them — while `.`/`..` and non-segment characters are what actually need rejecting. - `repoSlug()` rebuilt a string identical to its input; it is now `assertRepo()` and reads as the validator it always was. - Name the two request timeouts rather than repeating `30_000` in three signatures, and drop `request()`'s unreachable `token = null` default. - Trim comments that restated the code or repeated a nearby comment, and correct two that overclaimed: the normalizers narrow to a curated view rather than to "what is needed to fetch and verify", and the package is not disk-free — the semver shim reads npm's bundled copy. - README: fix the same disk claim, and stop showing a serialized download loop as the canonical example. - Tests: rejection-path tests now install a fetch that throws, so "no request was made" holds by construction instead of by a trailing assertion; add coverage for the repo names GitHub actually allows. `stasis/README.md` said `audit` builds on the npm and GitHub clients; it builds on the npm one only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
`subtree(repo, ref, { path, format })` returns a repo subtree at an exact
commit, tag or branch, as a Map of repo-relative posix path -> bytes. No git
client is involved and nothing is written to disk: one request to GitHub's
archive endpoint, decompressed and parsed in memory.
- `src/archive.js` — in-memory readers for both containers GitHub serves a tree
as: gzipped tar (`/tarball`, default) and zip (`/zipball`). Node's zlib does
the decompression; the container framing is parsed here, so the package stays
dependency-free.
- tar: ustar with `prefix` long names, GNU 'L' long names, pax 'x' path
overrides, and base-256 sizes.
- zip: central-directory driven, so sizes are read from the authoritative
record rather than a local header that may defer them to a data descriptor.
Stored and deflated entries; zip64 is rejected rather than misread.
- Only regular files are returned. Directories and symlinks carry no usable
content, and a symlink target is precisely what could point out of the tree.
- Entries that escape the tree (absolute, `..`, backslash, NUL) are rejected
rather than sanitized, as are archives with more than one top-level directory.
- The archive's top-level directory is returned as `root` — it records the
commit the ref resolved to — and is taken from the entries rather than
reconstructed from a name pattern and assumed to match.
- `maxBytes` (256 MiB default) bounds the download from both `content-length`
and the streamed byte count, since the archive is held in memory to parse.
- A `path` matching nothing throws: that is a typo far more often than an
intentionally empty subtree, and an empty Map would look like success.
Verified against real GitHub archives, not only the hand-built fixtures: the tar
and zip readers are independent code paths and decode the same commit to
identical bytes across all 476 files of a real repo. That check is also what
caught the archive endpoints answering 415 to `Accept: application/octet-stream`
— the header the asset endpoint requires — so the archive request keeps the
default JSON Accept and lets the codeload redirect serve the bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
- Drop the zip container. GitHub serves /tarball for every repo, so supporting both was a second way to do one job: `subtree()` loses its `format` option, `readZip` and its central-directory/EOCD/zip64 handling are gone, and `zipball_url` is no longer carried on a normalized release beside the tarball. - Split github/index.js by altitude. core.js holds the plumbing that knows how to talk to GitHub but not what to ask it — host, headers, token policy, identifier validation, Link pagination, capped body reads — and each endpoint family is its own module on top: releases.js (releases + assets) and subtree.js. index.js re-exports just the five public functions, so core.js stays internal and can change without breaking a consumer. - Assert an archive never repeats a path. tar can legally carry the same path twice, and reading into a Map would keep whichever came last, so a second entry could shadow the first and the shadowed bytes would never be seen by any caller. Refuse the archive instead of silently picking a winner. Re-verified against real GitHub archives after the restructure: the whole tree of a real repo still reads (476 files, no duplicate-path trip) and a subtree filter still resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
Cleanups from a review pass over the new package. No public API change --
`asset, latestRelease, release, releases, subtree` and their option shapes are
untouched.
Structure:
- Add `repoUrl(repo, ...segments)` as the single route by which a slug becomes a
URL. `assertRepo` was a separate step every endpoint had to remember before
hand-interpolating `${API}/repos/${repo}/...` five times over; now the check
cannot be forgotten, and `API`/`assertRepo` stop being exported for string
concatenation at the leaves.
- Hold both halves of the timeout policy in core.js: `ASSET_TIMEOUT` and
`ARCHIVE_TIMEOUT` were the same 300s under two names in two files, while the
comment describing the policy lived in a third. Now one `TRANSFER_TIMEOUT`.
- Let `request()` own the token policy. The `token = null` sentinel was restated
in all five signatures and interpreted with a strict `!== null`, so an
endpoint that omitted the default would report "Expected a non-empty token"
for a caller who supplied none.
- Move `readCapped` to subtree.js. Its remediation hint names `maxBytes`, one
endpoint's option, so it was never core policy -- asset() reads its body
whole, whose size the caller already knows from the release listing.
- Fold `releasePage` into the pagination loop, and `release`/`latestRelease`
into one `oneRelease` -- they differed only in how the release is addressed.
Efficiency:
- Filter the archive while reading it. `subtree({ path })` copied every regular
file out of the tarball and then discarded most of them; `readTarGz` now takes
a `select` hook that re-keys and narrows during the walk, so a skipped entry's
bytes are never copied. On a 2000-file 9.2 MB archive keeping 20 files: 9.4 ms
and 0.08 MB copied, against 15.3 ms and 8.19 MB. The safety and duplicate-path
checks still run on every entry, so an archive that is unsafe or
self-shadowing outside the requested subtree is still refused -- covered by a
new test.
- Collapse three full Maps into one. readTar built one, stripRoot rebuilt it
re-keyed, subtree rebuilt it again to apply the prefix; the root is now taken
during the same walk.
- Read tar text fields by range instead of slicing two Buffer views per call
(three calls per entry), and test `..` with one regex instead of splitting
every path into segments.
- Drop `view()`: correct, but unreachable -- both call sites already pass a
Buffer, so it never converted anything.
Tests and packaging:
- One `tests/fetch.helper.js` replaces three copies of the same global-fetch
swap (audit.test.js had already drifted from the other two).
- Ship `src` instead of enumerating every module in `files`, which is one added
module away from publishing a tarball that omits it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR extracts the GitHub releases API client into a new standalone
@exodus/stasis-apipackage, making it reusable across projects. The package also includes the existing npm advisories client that was previously embedded in@exodus/stasis.Summary
A new
@exodus/stasis-apipackage has been created as a zero-dependency registry API client library. It provides transport-only clients for npm and GitHub APIs with no disk access, credential discovery, or caching. The GitHub client is newly extracted from internal usage, while the npm client is moved from@exodus/stasisto the new package.Key Changes
New
@exodus/stasis-apipackage with three export paths:@exodus/stasis-api/npm— npm security advisories client@exodus/stasis-api/npm/semver— lazily-bound semver from bundled npm CLI@exodus/stasis-api/github— GitHub releases and asset download clientGitHub API client (
stasis-api/src/github/index.js):releases()— list a repo's releases with pagination support and optional limitrelease()— fetch a single release by taglatestRelease()— fetch the latest non-draft, non-prerelease releaseasset()— download release assets with optional digest verificationComprehensive test suite (
tests/github-api.test.js):Updated
@exodus/stasisto import from the new package:stasis/src/audit.jsandstasis/src/audit-corrections.jsnow import from@exodus/stasis-api/npmstasis/src/sbom.jsimports semver from@exodus/stasis-api/npm/semverWorkspace updates:
stasis-apito pnpm workspaceNotable Implementation Details
signalparameter, defaulting to 30 seconds for metadata and 300 seconds for asset downloads.https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN