Skip to content
Merged
Show file tree
Hide file tree
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
58 changes: 56 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
permissions:
contents: write # create the GitHub Release
id-token: write # OIDC: npm trusted publishing + provenance (no NPM_TOKEN)
packages: write # push the Docker image to GHCR

jobs:
release:
Expand Down Expand Up @@ -46,10 +47,17 @@ jobs:
- name: Publish to npm (skip if already published)
run: |
VERSION=$(node -p "require('./package.json').version")
# A prerelease (0.20.0-rc1) must NOT take the `latest` dist-tag, or
# `npm i` and the Scoop checkver (which reads dist-tags.latest) serve
# the RC. npm does not auto-detect prereleases, so pass --tag.
case "$VERSION" in
*-*) NPM_TAG=next ;;
*) NPM_TAG=latest ;;
esac
if npm view "@wavyx/pdcli@$VERSION" version >/dev/null 2>&1; then
echo "@wavyx/pdcli@$VERSION already published — skipping"
else
npm publish --provenance
npm publish --provenance --tag "$NPM_TAG"
fi

# Native tarballs for non-npm installs, attached to the GitHub Release.
Expand All @@ -70,7 +78,53 @@ jobs:
/^## \[/ { flag=0 }
flag { print }
' CHANGELOG.md)
gh release create "$GITHUB_REF_NAME" \
# Mark prerelease tags so an RC does not become GitHub's "Latest".
case "$VERSION" in *-*) PRE=--prerelease ;; *) PRE= ;; esac
gh release create "$GITHUB_REF_NAME" $PRE \
--title "$GITHUB_REF_NAME" \
--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 }}
Comment on lines 85 to +130

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

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ website/.astro/
dist/
tmp/
.playwright-mcp/

# generated at release by scripts/gen-dist.mjs
packaging/homebrew/pdcli.rb
packaging/scoop/pdcli.json
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to `pdcli` are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[SemVer](https://semver.org/).

## [0.20.0] - 2026-07-09

Distribution: install pdcli however your pipeline prefers, and drive the
host-locked passthrough across every page.

### Added

- **`pdcli api --paginate` (alias `--all`)** — the raw passthrough now follows
Pipedrive pagination itself and prints one concatenated array. GET only; the
pager is inferred from the `/api/v1/` (offset) or `/api/v2/` (cursor) path; any
querystring on the path (including repeated keys) seeds the first page;
`--limit` caps total items and stops fetching early. With `--paginate`, `--jq`
sees the bare item array (`.[]`) rather than the response envelope (`.data[]`).
- **Docker images** on GHCR: `docker run --rm -e PDCLI_API_TOKEN -e PDCLI_COMPANY_DOMAIN ghcr.io/wavyx/pdcli deal list --output json` (multi-arch amd64+arm64, built after each npm publish).
- **Homebrew** (`brew install wavyx/tap/pdcli`) and **Scoop** packaging generated per release, plus documented standalone tarballs and a GitHub Actions recipe.

### Changed

- **`--jq` can no longer hang.** If the jq binary is missing (npm >= 11 skips
node-jq's install step), `--jq` now fails fast with a clear "jq unavailable"
error (exit 69) instead of hanging forever. A genuinely invalid jq filter still
surfaces jq's own syntax error.
- **`--limit` must be >= 1** (usage error 64) instead of silently treating `0`
as "all pages".

## [0.19.0] - 2026-07-09

Agent surface: pdcli becomes an MCP server, and `doctor` grows a
Expand Down
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Run pdcli without a local Node install (env-var auth — a container has no OS
# keychain, so pass the token/domain as environment variables):
# docker run --rm -e PDCLI_API_TOKEN -e PDCLI_COMPANY_DOMAIN \
# ghcr.io/wavyx/pdcli deal list --output json
#
# The image installs the published npm package, so build it after publishing
# (the release workflow passes the released version via PDCLI_VERSION).
FROM node:20-slim

ARG PDCLI_VERSION=latest

LABEL org.opencontainers.image.source="https://github.com/wavyx/pdcli"
LABEL org.opencontainers.image.description="Command-line interface for Pipedrive"
LABEL org.opencontainers.image.licenses="MIT"

RUN npm install -g "@wavyx/pdcli@${PDCLI_VERSION}" \
&& npm cache clean --force

ENTRYPOINT ["pdcli"]
Comment on lines +16 to +19

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

CMD ["--help"]
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,18 @@ and **AI agents** (Claude Code, Codex, and similar) — no SDK glue required.
npm install -g @wavyx/pdcli # Node.js 20+
```

The binary is `pdcli`.
The binary is `pdcli`. Other channels, no Node required:

```bash
npx @wavyx/pdcli deal list # one-off, no install
brew install wavyx/tap/pdcli # macOS / Linux
scoop bucket add wavyx https://github.com/wavyx/scoop-pdcli && scoop install pdcli # Windows
docker run --rm -e PDCLI_API_TOKEN -e PDCLI_COMPANY_DOMAIN ghcr.io/wavyx/pdcli deal list # containers / CI
```

Prebuilt standalone tarballs (5 targets) ship on each
[GitHub Release](https://github.com/wavyx/pdcli/releases). Full menu and the auth note
per channel: [Distribution](https://wavyx.github.io/pdcli/start/distribution/).

## Authenticate

Expand Down
3 changes: 2 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Full command reference for the pdcli command-line interface.

<!-- AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. -->

Reference for `pdcli` v0.19.0 (146 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`.
Reference for `pdcli` v0.20.0 (146 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`.

## Top-level

Expand All @@ -18,6 +18,7 @@ pdcli api <method> <path> [flags]
```

- `--body <value>` — Request body (JSON string, @file, or pipe stdin)
- `--paginate` — Follow pagination and collect every page into one array (GET only; pager inferred from the /api/v1/ or /api/v2/ path). Note: --jq then sees the bare concatenated item array (filter with `.[]`), whereas without --paginate it sees the full envelope (`.data[]`).

Examples:

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wavyx/pdcli",
"version": "0.19.0",
"version": "0.20.0",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -203,6 +203,7 @@
"build": "oclif manifest",
"docs:commands": "npm run build && node scripts/gen-commands.mjs",
"docs:demo": "node scripts/gen-demo.mjs",
"docs:dist": "node scripts/gen-dist.mjs",
"lint": "eslint . && prettier --check .",
"lint:fix": "eslint --fix . && prettier --write .",
"test": "vitest run",
Expand Down
50 changes: 50 additions & 0 deletions packaging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Packaging

pdcli ships through several channels. npm is the source of truth; the others
are generated from the published tarball.

| Channel | Install | Source |
| -------- | ------------------------------------------------------------------------------------ | ------------------------------ |
| npm | `npm install -g @wavyx/pdcli` | this repo (`npm publish`) |
| Docker | `docker run --rm ghcr.io/wavyx/pdcli --help` | `Dockerfile` (release.yml) |
| Homebrew | `brew tap wavyx/tap && brew install pdcli` | `wavyx/homebrew-tap` (formula) |
| Scoop | `scoop bucket add pdcli https://github.com/wavyx/scoop-pdcli && scoop install pdcli` | `wavyx/scoop-pdcli` (manifest) |

## Updating Homebrew + Scoop on release

After a version is published to npm:

```bash
node scripts/gen-dist.mjs <version> # e.g. 0.19.0
```

This downloads the npm tarball, computes its sha256, and writes:

- `packaging/homebrew/pdcli.rb` → commit to `wavyx/homebrew-tap` as `Formula/pdcli.rb`
- `packaging/scoop/pdcli.json` → commit to `wavyx/scoop-pdcli` as `bucket/pdcli.json`

Both generated files are git-ignored here — they live in their own repos.

> Auto-bumping these on every release would need a cross-repo token (PAT); for
> now it's a one-line manual step. The Docker image and npm publish are fully
> automated in `.github/workflows/release.yml`.

## Container security caveat (env-var tokens)

The container has no OS keychain, so `pdcli` in Docker authenticates from
environment variables — `PDCLI_API_TOKEN` and `PDCLI_COMPANY_DOMAIN`:

```bash
docker run --rm -e PDCLI_API_TOKEN -e PDCLI_COMPANY_DOMAIN \
ghcr.io/wavyx/pdcli deal list --output json
```

Env-var tokens are **plaintext** — that is fine for an ephemeral CI runner that
holds the secret only for the job, but on a desktop the OS keychain is the whole
point of pdcli's security posture, so prefer the npm/Homebrew/Scoop installs and
`pdcli auth login` there. Two rules regardless of host:

- **Never bake a token into an image layer** (no `ENV PDCLI_API_TOKEN=…`, no
`--build-arg` secrets). Layers are cached and shippable; the token would leak
to anyone who pulls the image. Pass it at runtime with `-e` / `--env-file`.
- Scope the token to what the job needs and rotate it like any other CI secret.
109 changes: 109 additions & 0 deletions scripts/gen-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Regenerate the Homebrew formula + Scoop manifest for a published version.
//
// node scripts/gen-dist.mjs <version>
//
// Writes packaging/homebrew/pdcli.rb and packaging/scoop/pdcli.json (both
// git-ignored — they live in the wavyx/homebrew-tap and wavyx/scoop-pdcli
// repos). See packaging/README.md. Run AFTER the version is on npm.
import { writeFileSync, mkdirSync } from 'node:fs'
import { createHash } from 'node:crypto'

const PKG = '@wavyx/pdcli'

/** Render the Homebrew formula (standard node-CLI pattern). */
export function renderHomebrewFormula({ url, sha256 }) {
return `class Pdcli < Formula
desc "Command-line interface for Pipedrive"
homepage "https://github.com/wavyx/pdcli"
url "${url}"
sha256 "${sha256}"
license "MIT"

depends_on "jq"
depends_on "node"

def install
system "npm", "install", *std_npm_args
# pdcli's --jq flag uses node-jq, which normally downloads its own jq binary
# via a postinstall script. std_npm_args passes --ignore-scripts and the
# Homebrew build sandbox blocks network, so point node-jq at the Homebrew jq
# instead (node-jq honors $JQ_PATH at runtime).
(bin/"pdcli").write_env_script libexec/"bin/pdcli",
JQ_PATH: Formula["jq"].opt_bin/"jq"
end

test do
assert_match "pdcli", shell_output("#{bin}/pdcli version")
# Exercise --jq so the node-jq / Homebrew-jq wiring can't silently regress.
system bin/"pdcli", "config", "list", "--output", "json", "--jq", "."
end
end
`
}

/** Render the Scoop manifest (installs via npm; needs Node). */
export function renderScoopManifest({ version }) {
return (
JSON.stringify(
{
version,
description: 'Command-line interface for Pipedrive',
homepage: 'https://github.com/wavyx/pdcli',
license: 'MIT',
depends: 'nodejs',
installer: { script: [`npm install -g ${PKG}@${version}`] },
uninstaller: { script: [`npm uninstall -g ${PKG}`] },
checkver: {
url: `https://registry.npmjs.org/${PKG}`,
jsonpath: "$.['dist-tags'].latest",
},
// Templated with Scoop's $version placeholder so autoupdate rewrites the
// pinned version inside installer.script too — not just top-level version.
// Without this, autoupdate bumps `version` while the install command stays
// pinned to the previous release.
autoupdate: {
installer: { script: [`npm install -g ${PKG}@$version`] },
},
},
null,
2,
) + '\n'
)
}

/** Fetch the npm tarball URL + its sha256 for a version. */
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
Comment on lines +75 to +79

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

const buf = Buffer.from(await fetchFn(url).then((r) => r.arrayBuffer()))
const sha256 = createHash('sha256').update(buf).digest('hex')
return { version, url, sha256 }
}

if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
const version = process.argv[2]
if (!version) {
console.error('usage: node scripts/gen-dist.mjs <version>')
process.exit(1)
}
const dist = await fetchDist(version)
mkdirSync(new URL('../packaging/homebrew/', import.meta.url), {
recursive: true,
})
mkdirSync(new URL('../packaging/scoop/', import.meta.url), {
recursive: true,
})
writeFileSync(
new URL('../packaging/homebrew/pdcli.rb', import.meta.url),
renderHomebrewFormula(dist),
)
writeFileSync(
new URL('../packaging/scoop/pdcli.json', import.meta.url),
renderScoopManifest(dist),
)
console.log(
`Wrote packaging/homebrew/pdcli.rb + packaging/scoop/pdcli.json for ${version} (sha256 ${dist.sha256})`,
)
}
Loading
Loading