Skip to content

v0.20.0 — distribution: api --paginate, Docker/GHCR, brew+Scoop, CI recipe#25

Merged
wavyx merged 7 commits into
mainfrom
feat-distribution
Jul 9, 2026
Merged

v0.20.0 — distribution: api --paginate, Docker/GHCR, brew+Scoop, CI recipe#25
wavyx merged 7 commits into
mainfrom
feat-distribution

Conversation

@wavyx

@wavyx wavyx commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Second pre-v1 release (distribution). Makes pdcli installable everywhere and lets the raw passthrough follow pagination.

Added

  • `pdcli api --paginate` (`--all`) — passthrough follows Pipedrive pagination; GET-only, pager inferred from /api/v1|v2/ path, querystring (incl. repeated keys) seeds page 1, `--limit` caps + stops early. `--jq` sees the bare array with `--paginate`.
  • Docker/GHCR (multi-arch amd64+arm64, built after npm publish), Homebrew tap, Scoop, documented tarballs, and a GitHub Actions recipe.

Changed

  • `--jq` never hangs: a missing jq binary (npm >= 11 skips node-jq's install) fails fast with exit 69; an invalid filter still shows jq's own error.
  • `--limit` must be >= 1 (exit 64), no longer silently "all pages".

Quality gate

  • TDD. 1685 tests, 100% coverage, lint clean.
  • Two adversarial review rounds (8 + 4 findings), all fixed. Notably: single-object `--paginate` no longer crashes (exit 64); the jq guard was corrected so it doesn't mislabel invalid filters as "reinstall jq"; prerelease tags won't poison the npm `latest` dist-tag or docker `:latest`.
  • Live-verified: `api --paginate` on sandbox (cursor paging, POST/non-list rejected 64); `--jq` invalid-filter passthrough.
  • Release infra hardened from the v0.19 recovery: node-jq binary installed explicitly, npm pinned to 11.5.1 (provenance), Windows-safe.

https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9

Greptile Summary

This release adds pdcli api --paginate (cursor and offset paging for v1/v2 paths), hardens --jq against a missing jq binary (timeout guard → exit 69), enforces --limit ≥ 1, and ships distribution infrastructure: Docker/GHCR multi-arch image, Homebrew formula and Scoop manifest generators, and a CI recipe doc.

  • Pagination (src/commands/api.js, src/lib/pagination.js): runPaginated correctly strips querystrings from the path, infers the pager from /api/v1/ or /api/v2/, catches TypeError from single-record endpoints and converts it to exit 64, and respects --limit for early exit.
  • jq guard (src/base-command.js): runJq races node-jq's run() against a configurable timeout; timeout → exit 69, real jq rejection → surfaces unchanged; timer.unref() prevents the timeout from keeping the event loop alive.
  • Distribution (Dockerfile, release.yml, scripts/gen-dist.mjs): The Docker image installs the published npm package and is built only after a successful npm publish, correctly skipping :latest for prerelease tags.

Confidence Score: 4/5

The core logic changes are sound and well-tested; the distribution additions are straightforward with minor hardening gaps.

The pagination implementation, jq guard, and --limit enforcement are all correct and comprehensively tested. The distribution layer works as written but has three non-blocking gaps: the Docker image runs as root, fetchDist gives an opaque error on a 404, and the Docker workflow's actions are pinned to mutable major version tags rather than SHAs. None affect runtime correctness.

Dockerfile (missing USER instruction) and .github/workflows/release.yml (action SHA pinning in the docker job).

Security Review

  • Dockerfile runs as root: No USER instruction; the node user from the base image should be set post-install.
  • GitHub Actions mutable tags: The docker workflow job uses major-version tags (@v4, @v7) on five actions in a workflow with packages: write and id-token: write permissions — SHA pinning would close the supply-chain window.

Important Files Changed

Filename Overview
src/commands/api.js Adds --paginate/--all flag with runPaginated method; correctly guards GET-only, parses URL, infers pager from path, and catches TypeError for single-record endpoints
src/base-command.js Adds runJq with timeout guard (15s default, env-overridable) to prevent hang on missing jq binary; adds min:1 to --limit flag
.github/workflows/release.yml Adds Docker multi-arch publish job gated on npm release; correctly handles prerelease tagging; action versions are major-tag pinned rather than SHA-pinned
Dockerfile Installs pdcli from npm into node:20-slim; no USER instruction so container runs as root
scripts/gen-dist.mjs Generates Homebrew formula and Scoop manifest; fetchDist accesses meta.dist.tarball without checking response status, giving an opaque TypeError if the version isn't on npm yet
src/lib/pagination.js Tiny collectPages helper; correctly uses falsy check for limit (undefined = no cap) and breaks early when limit is reached
test/commands/api.test.js Comprehensive tests for --paginate: v1/v2 paging, --all alias, --limit early stop, querystring passthrough, repeated keys, and error paths
test/base-command.test.js Tests for jq guard: timeout fires on hang (exit 69), invalid filter surfaces as jq error not unavailable (exit 70), negative PDCLI_JQ_TIMEOUT_MS ignored

Fix All in Claude Code

Reviews (1): Last reviewed commit: "chore: release v0.20.0" | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

wavyx added 7 commits July 9, 2026 09:27
- api --paginate (alias --all): the host-locked passthrough now follows
  Pipedrive pagination itself. GET-only (else exit 64); pager inferred
  from /api/v1/ vs /api/v2/; querystring on the path seeds page 1;
  --limit caps total items and short-circuits fetching further pages.
- Dockerfile (node:20-slim, env-var auth) + a docker job in release.yml
  that pushes multi-arch amd64+arm64 images to ghcr.io/wavyx/pdcli after
  the npm publish.
- scripts/gen-dist.mjs renders a Homebrew formula and Scoop manifest
  from the published tarball (run per release; outputs gitignored).
- Distribution + GitHub Actions docs pages; README install section.

Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
api --paginate:
- single-record GET no longer crashes as internal exit 70; a non-list
  endpoint yields a clean usage error (64).
- repeated querystring keys (?ids=1&ids=2) are preserved, not collapsed.
- --jq shape difference (bare array vs envelope) documented on the flag.

Global hardening:
- --jq can no longer hang: node-jq's run() is raced against a timeout and
  any failure (incl. a missing jq binary on npm>=11) becomes a clear exit
  69, not an indefinite hang.
- --limit is validated to >= 1 (exit 64) instead of silently treating 0 as
  'all pages'.

Release infra:
- Docker image version derives from package.json (single source), and
  :latest is skipped for prerelease tags so RCs cannot poison it.
- Scoop autoupdate rewrites the version inside installer.script.
- distribution docs point at the Releases page / gh release download,
  since oclif embeds the commit sha in tarball names (no clean URL).

Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
- --jq: only the HANG (missing binary) maps to 'unavailable' (exit 69);
  a genuine node-jq rejection is a real filter error and now surfaces
  jq's own diagnostic with the prior exit code, instead of a misleading
  'reinstall jq'. Distinguished via a timeout sentinel.
- PDCLI_JQ_TIMEOUT_MS: a non-positive/non-finite override falls back to
  the default (a negative value no longer immediately times out).
- release.yml: prerelease tags publish to the npm 'next' dist-tag (not
  'latest', which npm i and the Scoop checkver read) and are marked
  --prerelease on GitHub, matching the docker :latest gate.

Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread Dockerfile
Comment on lines +16 to +19
RUN npm install -g "@wavyx/pdcli@${PDCLI_VERSION}" \
&& npm cache clean --force

ENTRYPOINT ["pdcli"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security The container runs as root because no USER instruction is present. node:20-slim ships a node user (uid 1000) — switching to it after the global install is the standard Dockerfile hardening step for CLI images.

Suggested change
RUN npm install -g "@wavyx/pdcli@${PDCLI_VERSION}" \
&& npm cache clean --force
ENTRYPOINT ["pdcli"]
RUN npm install -g "@wavyx/pdcli@${PDCLI_VERSION}" \
&& npm cache clean --force
USER node
ENTRYPOINT ["pdcli"]

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment thread scripts/gen-dist.mjs
Comment on lines +75 to +79
export async function fetchDist(version, fetchFn = fetch) {
const meta = await fetchFn(
`https://registry.npmjs.org/${PKG}/${version}`,
).then((r) => r.json())
const url = meta.dist.tarball

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 fetchDist doesn't check the HTTP response status before accessing meta.dist.tarball. If the version doesn't yet exist on npm (or a typo is passed), the registry returns {"error":"version not found"}, meta.dist is undefined, and the crash is an opaque TypeError: Cannot read properties of undefined (reading 'tarball') — with no hint that the version is wrong.

Suggested change
export async function fetchDist(version, fetchFn = fetch) {
const meta = await fetchFn(
`https://registry.npmjs.org/${PKG}/${version}`,
).then((r) => r.json())
const url = meta.dist.tarball
export async function fetchDist(version, fetchFn = fetch) {
const res = await fetchFn(`https://registry.npmjs.org/${PKG}/${version}`)
if (!res.ok) {
throw new Error(`npm registry returned ${res.status} for ${PKG}@${version}`)
}
const meta = await res.json()
if (meta.error) throw new Error(`npm registry error: ${meta.error}`)
const url = meta.dist.tarball

Fix in Claude Code

Comment on lines 85 to +130
--notes "${NOTES:-Release $GITHUB_REF_NAME}" \
dist/*.tar.gz

docker:
name: Publish Docker image
needs: release # build from the just-published npm version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

# Single source of truth: read the version from the SAME package.json the
# npm publish step used, so the Docker image installs a version that exists
# on npm (deriving from the git tag can drift from package.json and 404).
# Only stable releases get :latest — a prerelease (0.20.0-rc1) must not
# poison the :latest tag that `docker pull` resolves by default.
- name: Resolve version
id: ver
run: |
V=$(node -p "require('./package.json').version")
echo "v=$V" >> "$GITHUB_OUTPUT"
case "$V" in
*-*) echo "latest=" >> "$GITHUB_OUTPUT" ;; # prerelease: no :latest
*) echo "latest=ghcr.io/${{ github.repository }}:latest" >> "$GITHUB_OUTPUT" ;;
esac

- uses: docker/setup-qemu-action@v4 # cross-build arm64

- uses: docker/setup-buildx-action@v4

- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: PDCLI_VERSION=${{ steps.ver.outputs.v }}
# The :latest line is empty for prereleases (see Resolve version);
# build-push-action ignores blank tag lines.
tags: |
ghcr.io/${{ github.repository }}:${{ steps.ver.outputs.v }}
${{ steps.ver.outputs.latest }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security GitHub Actions pinned to mutable major-version tags

All five actions in the docker job (actions/checkout@v5, docker/setup-qemu-action@v4, docker/setup-buildx-action@v4, docker/login-action@v4, docker/build-push-action@v7) use mutable major-version tags. A compromised tag can silently redirect to a malicious commit on the next run; SHA pinning (e.g. docker/login-action@65d2b36e4f3f) eliminates that class of supply-chain risk for a workflow that also holds packages: write and id-token: write permissions.

Fix in Claude Code

@wavyx wavyx merged commit 71f6bcf into main Jul 9, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants