diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afab89f..606a03d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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. @@ -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 }} diff --git a/.gitignore b/.gitignore index 1b2fabb..8f84932 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d41b9..36a7e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11e33ca --- /dev/null +++ b/Dockerfile @@ -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"] +CMD ["--help"] diff --git a/README.md b/README.md index 1c8fbe5..6bb54ae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/commands.md b/docs/commands.md index c03560e..7c802a8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the pdcli command-line interface. -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 @@ -18,6 +18,7 @@ pdcli api [flags] ``` - `--body ` — 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: diff --git a/package-lock.json b/package-lock.json index 34857b1..8347f57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wavyx/pdcli", - "version": "0.19.0", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wavyx/pdcli", - "version": "0.19.0", + "version": "0.20.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.0", diff --git a/package.json b/package.json index 46e6da5..40575ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/pdcli", - "version": "0.19.0", + "version": "0.20.0", "publishConfig": { "access": "public" }, @@ -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", diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..b1f4d2f --- /dev/null +++ b/packaging/README.md @@ -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 # 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. diff --git a/scripts/gen-dist.mjs b/scripts/gen-dist.mjs new file mode 100644 index 0000000..289848f --- /dev/null +++ b/scripts/gen-dist.mjs @@ -0,0 +1,109 @@ +// Regenerate the Homebrew formula + Scoop manifest for a published version. +// +// node scripts/gen-dist.mjs +// +// 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 + 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 ') + 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})`, + ) +} diff --git a/src/base-command.js b/src/base-command.js index 991ceb8..a24ed7b 100644 --- a/src/base-command.js +++ b/src/base-command.js @@ -5,10 +5,57 @@ import { loadConfig } from './lib/config.js' import { resolveCredentials, refreshAccessToken } from './lib/auth.js' import { setOAuthTokens } from './lib/keychain.js' import { createClient } from './lib/client.js' -import { handleError } from './lib/errors.js' +import { handleError, CliError } from './lib/errors.js' const debug = createDebug('pd:fields') +/** Default upper bound on a single jq invocation before we treat it as hung. */ +const JQ_TIMEOUT_MS = 15_000 + +/** + * Run a jq filter, guarding only against a HANG. npm >= 11 blocks node-jq's + * preinstall, so a globally installed pdcli can ship without the jq binary at + * node_modules/node-jq/bin/jq; node-jq's run() then does NOT reject on the spawn + * ENOENT — it hangs forever. Bound it: race the run against a timeout and, when + * the timeout wins, fail fast with EX_UNAVAILABLE (69). A genuine run() REJECTION + * is a real jq error (an invalid filter compiles to a syntax error) — surface it + * unchanged so the user sees jq's own diagnostic, not a misleading "reinstall + * jq". The timeout is overridable via PDCLI_JQ_TIMEOUT_MS (ms) for slow machines + * / tests; a non-positive/non-finite value falls back to the default. + * @param {string} filter jq expression + * @param {string} input JSON string + * @returns {Promise} + */ +async function runJq(filter, input) { + const override = Number(process.env.PDCLI_JQ_TIMEOUT_MS) + const timeoutMs = + Number.isFinite(override) && override > 0 ? override : JQ_TIMEOUT_MS + const { run } = await import('node-jq') + const TIMED_OUT = Symbol('jq-timeout') + let timer + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(TIMED_OUT), timeoutMs) + timer.unref?.() // never keep the event loop alive just for this guard + }) + try { + return await Promise.race([ + run(filter, input, { input: 'string', output: 'pretty' }), + timeout, + ]) + } catch (cause) { + if (cause === TIMED_OUT) { + throw new CliError( + `jq did not respond within ${timeoutMs}ms — the jq binary may be ` + + 'missing or broken. Try: npm rebuild node-jq, or install jq.', + { exitCode: 69 }, + ) + } + throw cause // a real jq error (e.g. an invalid filter) — surface it as-is + } finally { + clearTimeout(timer) + } +} + export default class BaseCommand extends Command { static baseFlags = { output: Flags.string({ @@ -57,6 +104,7 @@ export default class BaseCommand extends Command { limit: Flags.integer({ description: 'Maximum number of items to return (lists)', helpGroup: 'GLOBAL', + min: 1, }), } @@ -193,14 +241,9 @@ export default class BaseCommand extends Command { if (this.flags.jq) { // node-jq ships a native binary — load it only when actually used. // Single records pass UNWRAPPED: `--jq .id` works on a get without - // the historical `.[0]` indirection (changed in 0.9.0). - const { run } = await import('node-jq') - const input = JSON.stringify(data) - const result = await run(this.flags.jq, input, { - input: 'string', - output: 'pretty', - }) - this.log(result) + // the historical `.[0]` indirection (changed in 0.9.0). runJq bounds the + // call so a missing/broken jq binary fails fast instead of hanging. + this.log(await runJq(this.flags.jq, JSON.stringify(data))) return } diff --git a/src/commands/api.js b/src/commands/api.js index ac7f2f0..8f73867 100644 --- a/src/commands/api.js +++ b/src/commands/api.js @@ -2,6 +2,7 @@ import { Args, Flags } from '@oclif/core' import BaseCommand from '../base-command.js' import { resolveBody } from '../lib/body.js' import { CliError } from '../lib/errors.js' +import { collectPages } from '../lib/pagination.js' export default class ApiCommand extends BaseCommand { static description = @@ -19,6 +20,14 @@ export default class ApiCommand extends BaseCommand { body: Flags.string({ description: 'Request body (JSON string, @file, or pipe stdin)', }), + paginate: Flags.boolean({ + aliases: ['all'], + description: + '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[]`).', + }), } static args = { @@ -36,6 +45,10 @@ export default class ApiCommand extends BaseCommand { async run() { const { args, flags } = await this.parse(ApiCommand) + if (flags.paginate) { + return this.runPaginated(args.method, args.path) + } + const methodMap = { GET: 'get', POST: 'post', @@ -72,4 +85,70 @@ export default class ApiCommand extends BaseCommand { } this.log(JSON.stringify(data, null, 2)) } + + /** + * Follow pagination for a GET passthrough and print the collected array. + * The pager is inferred from the path (v2 cursor for /api/v2/, v1 offset for + * /api/v1/); any querystring already on the path seeds the first page. The + * global --limit caps the total items collected (all pages when unset). + * @param {string} method + * @param {string} path + */ + async runPaginated(method, path) { + if (method !== 'GET') { + throw new CliError('--paginate is only valid with GET', { exitCode: 64 }) + } + + // (--limit is validated to >= 1 at parse time via the shared flag's min.) + + // Peel any querystring off the path into a seed query object — the pagers + // append cursor/start themselves. A dummy base makes relative paths parse. + // Repeated keys (e.g. ?ids=1&ids=2) accumulate into an array so nothing is + // dropped; the client comma-joins array values back onto the request. + const url = new URL(path, 'http://pdcli.invalid') + const query = {} + for (const [k, v] of url.searchParams) { + if (k in query) { + query[k] = Array.isArray(query[k]) ? [...query[k], v] : [query[k], v] + } else { + query[k] = v + } + } + const cleanPath = url.pathname + + let pager + if (cleanPath.includes('/api/v2/')) pager = this.apiClient.pageV2 + else if (cleanPath.includes('/api/v1/')) pager = this.apiClient.pageV1 + else { + throw new CliError( + `Cannot infer the pager for ${path} — --paginate needs an ` + + `/api/v1/ or /api/v2/ path`, + { exitCode: 64 }, + ) + } + + // The pagers `yield*` the envelope's `data`, which throws a TypeError when + // a single-record endpoint (e.g. /api/v2/deals/5) returns `data` as an + // object instead of an array. Translate that into a clear usage error + // rather than letting it surface as an internal exit 70. + let items + try { + items = await collectPages(pager(cleanPath, query), this.flags.limit) + } catch (err) { + if (err instanceof TypeError) { + throw new CliError( + '--paginate expects a list endpoint; GET without --paginate for a ' + + 'single record', + { exitCode: 64 }, + ) + } + throw err + } + + if (this.flags.jq || this.flags.output) { + await this.outputResults(items, {}) + return + } + this.log(JSON.stringify(items, null, 2)) + } } diff --git a/test/base-command.test.js b/test/base-command.test.js index 65f0885..712a6e3 100644 --- a/test/base-command.test.js +++ b/test/base-command.test.js @@ -25,6 +25,15 @@ vi.mock('../src/lib/config.js', () => ({ getProfileConfig: vi.fn().mockReturnValue(undefined), })) +// node-jq's run() does NOT reject on a missing binary — it hangs. Mock it so a +// test can force a rejection or a never-resolving run; when `jq.impl` is unset +// it delegates to the real binary so the --jq success path is still exercised. +const jq = vi.hoisted(() => ({ impl: undefined })) +vi.mock('node-jq', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, run: (...args) => (jq.impl ?? actual.run)(...args) } +}) + const { default: BaseCommand } = await import('../src/base-command.js') const { AuthRequiredError } = await import('../src/lib/errors.js') const { clearFieldsCache } = await import('../src/lib/fields.js') @@ -61,6 +70,12 @@ function captureLogs(CmdClass, argv = []) { .finally(() => spy.mockRestore()) } +// Every test starts with the real jq binary; jq-guard tests opt into a +// forced rejection / hang and this restores the default afterwards. +afterEach(() => { + jq.impl = undefined +}) + describe('BaseCommand', () => { beforeEach(() => { nock.cleanAll() @@ -838,3 +853,107 @@ describe('resolveFormat with default_output', () => { expect(result).toBeUndefined() }) }) + +describe('--jq is guarded against a missing/broken jq binary', () => { + // npm >= 11 blocks node-jq's preinstall, so a globally installed pdcli can + // ship without the jq binary. node-jq's run() then hangs forever on the + // spawn ENOENT rather than rejecting — every `pdcli … --jq` would hang. + class JqCmd extends BaseCommand { + static skipAuth = true + async run() { + await this.outputResults({ id: 1, name: 'X' }, { id: { header: 'ID' } }) + } + } + + beforeEach(() => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + }) + + it('surfaces a real jq error (invalid filter) unchanged, not as "unavailable"', async () => { + // node-jq REJECTS on a filter compile error (unlike the missing-binary hang). + // That must reach the user as jq's own diagnostic, not a misleading + // reinstall-jq message, and keep the prior exit code (70, not 69). + jq.impl = () => + Promise.reject(new Error('jq: error: syntax error, unexpected end')) + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true // human path carries the message + try { + const err = await JqCmd.run(['--jq', '.[']).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(70) + expect(err.message).toMatch(/syntax error/i) + expect(err.message).not.toMatch(/unavailable|rebuild node-jq/i) + } finally { + process.stdout.isTTY = origIsTTY + } + }) + + it('ignores a non-positive PDCLI_JQ_TIMEOUT_MS (no immediate timeout)', async () => { + // A negative override is truthy; without a floor setTimeout(-1) fires almost + // immediately and kills every --jq. The guard must fall back to the default. + // Small real delay: an unfloored -1 timeout (clamped to ~1ms) would beat it + // and fail with 69; the default 15s must win instead. + jq.impl = () => new Promise((r) => setTimeout(() => r('"X"'), 20)) + process.env.PDCLI_JQ_TIMEOUT_MS = '-1' + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true + try { + const stdout = await captureLogs(JqCmd, ['--jq', '.name']) + expect(stdout.trim()).toBe('"X"') + } finally { + delete process.env.PDCLI_JQ_TIMEOUT_MS + process.stdout.isTTY = origIsTTY + } + }) + + it('does NOT hang when jq run never resolves — the timeout guard fires (69)', async () => { + // A never-resolving run mimics node-jq's spawn-ENOENT hang. With a tiny + // timeout override the guard must still fire fast instead of hanging. + jq.impl = () => new Promise(() => {}) + process.env.PDCLI_JQ_TIMEOUT_MS = '25' + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true + try { + const err = await JqCmd.run(['--jq', '.name']).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(69) + expect(err.message).toMatch(/did not respond within 25ms/i) + expect(err.message).toMatch(/rebuild node-jq|install jq/i) + } finally { + process.stdout.isTTY = origIsTTY + delete process.env.PDCLI_JQ_TIMEOUT_MS + } + }) + + it('still runs the real jq binary on the success path', async () => { + const stdout = await captureLogs(JqCmd, ['--jq', '.name']) + expect(stdout).toContain('X') + }) +}) + +describe('--limit rejects non-positive values (usage error, exit 64)', () => { + class LimitCmd extends BaseCommand { + static skipAuth = true + async run() { + this.log(`limit:${this.flags.limit}`) + } + } + + beforeEach(() => { + mockLoadConfig.mockReturnValue({ activeProfile: 'default' }) + }) + + it.each(['0', '-5'])('rejects --limit %s with exit 64', async (val) => { + const origIsTTY = process.stdout.isTTY + process.stdout.isTTY = true + try { + const err = await LimitCmd.run(['--limit', val]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + } finally { + process.stdout.isTTY = origIsTTY + } + }) + + it('accepts a positive --limit', async () => { + const stdout = await captureLogs(LimitCmd, ['--limit', '1']) + expect(stdout).toContain('limit:1') + }) +}) diff --git a/test/commands/api.test.js b/test/commands/api.test.js index d822733..34fedae 100644 --- a/test/commands/api.test.js +++ b/test/commands/api.test.js @@ -121,6 +121,256 @@ describe('api', () => { }) }) +describe('api --paginate', () => { + beforeEach(() => { + nock.cleanAll() + mockResolveCredentials.mockResolvedValue({ + companyDomain: 'acme', + token: 'tok', + source: 'profile', + }) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('follows v2 next_cursor across pages and concatenates', async () => { + mockApi() + .get('/api/v2/deals') + .reply(200, { + success: true, + data: [{ id: 1 }, { id: 2 }], + additional_data: { next_cursor: 'abc' }, + }) + mockApi() + .get('/api/v2/deals') + .query({ cursor: 'abc' }) + .reply(200, { + success: true, + data: [{ id: 3 }], + additional_data: { next_cursor: null }, + }) + + const stdout = await runCmd(ApiCommand, [ + 'GET', + '/api/v2/deals', + '--paginate', + ]) + + const items = JSON.parse(stdout) + expect(items.map((d) => d.id)).toEqual([1, 2, 3]) + }) + + it('follows v1 next_start until more_items_in_collection is false', async () => { + mockApi() + .get('/api/v1/activities') + .reply(200, { + success: true, + data: [{ id: 1 }], + additional_data: { + pagination: { more_items_in_collection: true, next_start: 1 }, + }, + }) + mockApi() + .get('/api/v1/activities') + .query({ start: '1' }) + .reply(200, { + success: true, + data: [{ id: 2 }], + additional_data: { + pagination: { more_items_in_collection: false }, + }, + }) + + const stdout = await runCmd(ApiCommand, [ + 'GET', + '/api/v1/activities', + '--paginate', + ]) + + expect(JSON.parse(stdout).map((d) => d.id)).toEqual([1, 2]) + }) + + it('accepts --all as an alias for --paginate', async () => { + mockApi() + .get('/api/v2/deals') + .reply(200, { + success: true, + data: [{ id: 1 }], + additional_data: { next_cursor: null }, + }) + + const stdout = await runCmd(ApiCommand, ['GET', '/api/v2/deals', '--all']) + + expect(JSON.parse(stdout).map((d) => d.id)).toEqual([1]) + }) + + it('caps total items at --limit and stops fetching early', async () => { + const page1 = mockApi() + .get('/api/v2/deals') + .reply(200, { + success: true, + data: [{ id: 1 }, { id: 2 }], + additional_data: { next_cursor: 'abc' }, + }) + const page2 = mockApi() + .get('/api/v2/deals') + .query({ cursor: 'abc' }) + .reply(200, { + success: true, + data: [{ id: 3 }], + additional_data: { next_cursor: null }, + }) + + const stdout = await runCmd(ApiCommand, [ + 'GET', + '/api/v2/deals', + '--paginate', + '--limit', + '2', + ]) + + expect(JSON.parse(stdout).map((d) => d.id)).toEqual([1, 2]) + expect(page1.isDone()).toBe(true) + expect(page2.isDone()).toBe(false) + }) + + it('preserves an existing querystring into the first page', async () => { + mockApi() + .get('/api/v2/deals') + .query({ status: 'open' }) + .reply(200, { + success: true, + data: [{ id: 5 }], + additional_data: { next_cursor: null }, + }) + + const stdout = await runCmd(ApiCommand, [ + 'GET', + '/api/v2/deals?status=open', + '--paginate', + ]) + + expect(JSON.parse(stdout).map((d) => d.id)).toEqual([5]) + }) + + it('routes the collected array through --jq', async () => { + mockApi() + .get('/api/v2/deals') + .reply(200, { + success: true, + data: [{ id: 1 }, { id: 2 }], + additional_data: { next_cursor: null }, + }) + + const stdout = await runCmd(ApiCommand, [ + 'GET', + '/api/v2/deals', + '--paginate', + '--jq', + 'length', + ]) + + expect(stdout.trim()).toBe('2') + }) + + it('rejects --paginate on POST with exit 64', async () => { + const err = await ApiCommand.run([ + 'POST', + '/api/v2/deals', + '--paginate', + ]).catch((e) => e) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('rejects a path with no /api/v1|v2/ marker with exit 64', async () => { + const err = await ApiCommand.run(['GET', '/foo/bar', '--paginate']).catch( + (e) => e, + ) + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('rejects a single-object endpoint with exit 64, not an internal 70', async () => { + mockApi() + .get('/api/v2/deals/5') + .reply(200, { success: true, data: { id: 5 } }) + + const err = await ApiCommand.run([ + 'GET', + '/api/v2/deals/5', + '--paginate', + ]).catch((e) => e) + + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + expect(err.message).toMatch(/list endpoint/i) + }) + + it('propagates a genuine API error during paging (not a single-record guard)', async () => { + // A 5xx while paging throws an ApiError (not a TypeError); it must surface + // as the API error (exit 69), not be masked as a list-endpoint usage error. + mockApi().get('/api/v2/deals').query(true).reply(500, { success: false }) + + const err = await ApiCommand.run([ + 'GET', + '/api/v2/deals', + '--paginate', + '--no-retry', + ]).catch((e) => e) + + expect(err.exitCode ?? err.oclif?.exit).toBe(69) + expect(err.message).not.toMatch(/list endpoint/i) + }) + + it('rejects --limit 0 with exit 64', async () => { + const err = await ApiCommand.run([ + 'GET', + '/api/v2/deals', + '--paginate', + '--limit', + '0', + ]).catch((e) => e) + + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + // The shared --limit flag enforces min:1, so oclif rejects a non-positive + // value at parse time ("greater than or equal to 1"). + expect(err.message).toMatch(/greater than or equal to 1/i) + }) + + it('rejects a negative --limit with exit 64', async () => { + const err = await ApiCommand.run([ + 'GET', + '/api/v2/deals', + '--paginate', + '--limit', + '-1', + ]).catch((e) => e) + + expect(err.exitCode ?? err.oclif?.exit).toBe(64) + }) + + it('preserves repeated querystring keys into the first page', async () => { + // Three repeats exercises both the first-duplicate (make an array) and the + // subsequent-duplicate (append) branches; the client comma-joins the array. + mockApi() + .get('/api/v2/deals') + .query({ ids: '1,2,3' }) + .reply(200, { + success: true, + data: [{ id: 1 }, { id: 2 }, { id: 3 }], + additional_data: { next_cursor: null }, + }) + + const stdout = await runCmd(ApiCommand, [ + 'GET', + '/api/v2/deals?ids=1&ids=2&ids=3', + '--paginate', + ]) + + expect(JSON.parse(stdout).map((d) => d.id)).toEqual([1, 2, 3]) + }) +}) + describe('api --jq', () => { it('filters the raw response through jq', async () => { mockApi() diff --git a/test/gen-dist.test.js b/test/gen-dist.test.js new file mode 100644 index 0000000..a8b2f23 --- /dev/null +++ b/test/gen-dist.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { renderScoopManifest } from '../scripts/gen-dist.mjs' + +describe('renderScoopManifest', () => { + it('pins the concrete version in the active installer.script', () => { + const manifest = JSON.parse(renderScoopManifest({ version: '0.20.0' })) + expect(manifest.version).toBe('0.20.0') + expect(manifest.installer.script).toEqual([ + 'npm install -g @wavyx/pdcli@0.20.0', + ]) + }) + + it('templates autoupdate.installer.script with the $version placeholder so autoupdate rewrites the pinned install version, not just url/hash', () => { + const manifest = JSON.parse(renderScoopManifest({ version: '0.20.0' })) + expect(manifest.autoupdate.installer.script).toEqual([ + 'npm install -g @wavyx/pdcli@$version', + ]) + }) +}) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index bc3f54d..68e8213 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -83,6 +83,7 @@ export default defineConfig({ label: 'Start here', items: [ { label: 'Installation', slug: 'start/installation' }, + { label: 'Distribution (all channels)', slug: 'start/distribution' }, { label: 'Quickstart', slug: 'start/quickstart' }, { label: 'Quickstart for AI agents', slug: 'start/agents' }, ], @@ -114,6 +115,7 @@ export default defineConfig({ { label: 'Output & filtering', slug: 'automation/output' }, { label: 'Exit codes', slug: 'automation/exit-codes' }, { label: 'CI recipes', slug: 'automation/ci' }, + { label: 'GitHub Actions', slug: 'automation/github-actions' }, { label: 'Cookbook', slug: 'automation/cookbook' }, ], }, diff --git a/website/src/content/docs/automation/github-actions.mdx b/website/src/content/docs/automation/github-actions.mdx new file mode 100644 index 0000000..5accd21 --- /dev/null +++ b/website/src/content/docs/automation/github-actions.mdx @@ -0,0 +1,139 @@ +--- +title: GitHub Actions +description: Run pdcli in GitHub Actions with pinned versions or the container image, secret-wired env-var auth, an offline preflight, and exit-code gating. +--- + +`pdcli` is built for non-interactive use, so it drops into GitHub Actions cleanly: +authenticate with secrets as environment variables, run a preflight, then let the +[deterministic exit codes](/pdcli/automation/exit-codes/) and `--output json` decide +whether the job passes. This page is the GitHub-specific recipe; see +[CI recipes](/pdcli/automation/ci/) for the flags and scheduled-job patterns that apply +to any CI. + +## Wire the secrets + +Store the token as an encrypted repository or environment secret (never in the +workflow file), and set the company domain and token as environment variables. Env +vars take precedence over the keychain, so the runner needs no keychain and pdcli never +prompts. + +```yaml +env: + PDCLI_COMPANY_DOMAIN: acme + PDCLI_API_TOKEN: ${{ secrets.PIPEDRIVE_API_TOKEN }} +``` + +Add the token under **Settings → Secrets and variables → Actions** as +`PIPEDRIVE_API_TOKEN`. The domain is not secret (it's your `{company}.pipedrive.com` +subdomain), so it can sit in plain text. + +## Option A: pinned npm install + +Install a pinned version so a background release can't change behaviour mid-pipeline. + +```yaml +name: pipedrive +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC + +jobs: + deals: + runs-on: ubuntu-latest + env: + PDCLI_COMPANY_DOMAIN: acme + PDCLI_API_TOKEN: ${{ secrets.PIPEDRIVE_API_TOKEN }} + steps: + - uses: actions/setup-node@v5 + with: + node-version: 20 + - run: npm install -g @wavyx/pdcli@0.20.0 + + # Preflight: config + keychain checks, zero network egress. Exits 78 on a + # config problem, failing the job before it spends any API budget. + - run: pdcli doctor --offline + + - name: Open deals to JSON + run: pdcli deal list --status open --output json --jq '[.[] | {id, title, value}]' > deals.json + + - uses: actions/upload-artifact@v4 + with: + name: deals + path: deals.json +``` + +## Option B: the container image + +Skip the Node setup entirely by running the job inside the published image. The +`container:` key runs every step inside it, and the token/domain still arrive as env +vars. + +```yaml +jobs: + deals: + runs-on: ubuntu-latest + container: ghcr.io/wavyx/pdcli:0.20.0 + env: + PDCLI_COMPANY_DOMAIN: acme + PDCLI_API_TOKEN: ${{ secrets.PIPEDRIVE_API_TOKEN }} + steps: + - run: pdcli doctor --offline + - run: pdcli deal list --status open --output json --jq '.[].id' +``` + +Pin the tag (`:0.20.0`) rather than `latest` for reproducible runs. See +[Distribution](/pdcli/start/distribution/) for the image details. + +## Gate on the exit code + +Every command returns a deterministic [sysexit](/pdcli/automation/exit-codes/) code, so +a step fails the job on the right condition without parsing text. A non-zero exit fails +the step by default, which is usually what you want. When you need to branch, read +`$?`: + +```yaml + - name: Fail only on real errors, tolerate rate limits + run: | + set +e + pdcli deal list --output json > deals.json + code=$? + case $code in + 0) echo "ok" ;; + 75|69) echo "::warning::transient ($code) — will retry next run"; exit 0 ;; + 77) echo "::error::auth failed — rotate PIPEDRIVE_API_TOKEN"; exit 1 ;; + *) echo "::error::pdcli exited $code"; exit 1 ;; + esac +``` + +Because the error output also mirrors the format (JSON on a piped or `--output json` +run, written to stderr), a failed step leaves a parseable object you can inspect or +upload. `--output json` on the happy path means downstream `jq` and other tools get +structured data every time. + +## Data-quality gate + +`pdcli audit --strict` exits `1` when any must-severity hygiene check has findings, so +it works as a required check on a schedule or before a deploy that depends on clean +data: + +```yaml + - run: pdcli audit --strict +``` + +Exit `1` fails the job and the findings print for the log. See +[Data-hygiene audit](/pdcli/guides/audit/). + +## Fail fast under load + +For CI you often want a clean failure over a long retry. `--no-retry` surfaces the +first `429`/`5xx` immediately (exit `75` or `69`) instead of sleeping through backoff, +and `--timeout ` caps each request so a hung network can't stall the job: + +```yaml + - run: pdcli deal list --no-retry --timeout 10000 --output json +``` + +Let the workflow's own retry or schedule re-run the job. See +[CI recipes](/pdcli/automation/ci/) for nightly backups, incremental pulls, and bulk +imports. diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 8210c8f..59093ef 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -5,7 +5,7 @@ description: Every pdcli command, flag, and example — generated from the CLI m {/* AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. */} -All 146 commands in `pdcli` v0.19.0. Every command also +All 146 commands in `pdcli` v0.20.0. Every command also accepts the [global flags](/pdcli/reference/config/) `--output`, `--jq`, `--fields`, `--profile`, `--limit`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. Run `pdcli --help` for the live version. @@ -23,6 +23,7 @@ pdcli api [flags] | Flag | Description | | --- | --- | | `--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[]`). | ```bash pdcli api GET /api/v2/deals diff --git a/website/src/content/docs/start/distribution.mdx b/website/src/content/docs/start/distribution.mdx new file mode 100644 index 0000000..a3ce2eb --- /dev/null +++ b/website/src/content/docs/start/distribution.mdx @@ -0,0 +1,177 @@ +--- +title: Distribution +description: Every way to install pdcli — npm, npx, Docker, Homebrew, Scoop, and prebuilt standalone tarballs — with the auth note for each. +--- + +`pdcli` ships through several channels so you can pick the one that fits your machine +or pipeline. They all run the same CLI; the difference is how the binary gets onto the +box and how credentials reach it. + +Prefer the [Installation](/pdcli/start/installation/) page if you just want the fast +path (npm) plus completions and the doctor. This page is the full menu. + +## Which channel? + +| Channel | Best for | Needs Node? | +| ------- | -------- | ----------- | +| **npm (global)** | Daily use on your own machine | Yes (20+) | +| **npx** | A one-off, or a CI step you don't want to install | Yes (20+) | +| **Docker** | CI runners and servers with no Node, reproducible pins | No | +| **Homebrew** | macOS / Linux users who live in `brew` | No | +| **Scoop** | Windows users who live in `scoop` | No | +| **Standalone tarball** | Air-gapped or Node-free hosts, manual pinning | No | + +## Authentication, in one line + +Two ways to get a token to pdcli, and the choice usually follows the channel: + +- **Keychain (default).** `pdcli auth login` stores the token in your OS keychain + (macOS Keychain, GNOME Keyring / libsecret, Windows Credential Manager). Nothing + lands in plaintext on disk. This is the right choice for the npm, Homebrew, Scoop, + and tarball installs on a workstation. +- **Environment variables.** Set `PDCLI_COMPANY_DOMAIN` and `PDCLI_API_TOKEN` instead. + Env vars take precedence over the keychain and require no keychain at all, which is + exactly what a container or CI runner needs. See + [Authentication](/pdcli/guides/authentication/) and the + [Security model](/pdcli/concepts/security/). + +On a headless host, verify the setup before you run real commands with the offline +preflight (added in v0.19), which runs five checks with zero network egress: + +```bash +PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=xxxxxxxx pdcli doctor --offline +``` + +It exits `78` if config or the keychain check fails, so it doubles as a CI gate. + +## npm (global) + +The primary channel. Puts a `pdcli` binary on your `PATH`. + +```bash +npm install -g @wavyx/pdcli # Node.js 20+ +pdcli version +``` + +Pin a version in CI so a background release can't change behaviour mid-pipeline: + +```bash +npm install -g @wavyx/pdcli@0.20.0 +``` + +Auth: keychain by default (`pdcli auth login`), or env vars for scripts. + +## npx (no install) + +Run the published package without a global install. Good for a single command or a CI +step where you'd rather not add an install line. + +```bash +npx @wavyx/pdcli deal list +npx @wavyx/pdcli@0.20.0 deal list # pinned +``` + +The global install is faster for daily use because npx resolves and caches the package +on each cold run. Auth is the same as npm. + +## Docker + +The image bundles Node and the published package, so a runner needs neither. A +container has no OS keychain, so pass the token and domain as environment variables: + +```bash +docker run --rm \ + -e PDCLI_API_TOKEN \ + -e PDCLI_COMPANY_DOMAIN \ + ghcr.io/wavyx/pdcli deal list --output json +``` + +Passing `-e NAME` (no `=value`) forwards the variable from your current shell, so the +token never appears in the command or in shell history. Pin the tag in CI: + +```bash +docker run --rm -e PDCLI_API_TOKEN -e PDCLI_COMPANY_DOMAIN \ + ghcr.io/wavyx/pdcli:0.20.0 deal list --output json +``` + +The entrypoint is `pdcli`, so everything after the image name is passed straight +through as arguments. Auth: env vars only (there's no keychain in a container). + +## Homebrew + +For macOS and Linux users on Homebrew. Installs a self-contained binary, no Node +required. + +```bash +brew install wavyx/tap/pdcli +pdcli version +``` + +`wavyx/tap` is the custom tap; the first install taps it automatically. Upgrade with +`brew upgrade wavyx/tap/pdcli`. Auth: keychain by default, or env vars. + +## Scoop + +For Windows users on Scoop. Also a self-contained binary, no Node required. + +```powershell +scoop bucket add wavyx https://github.com/wavyx/scoop-pdcli +scoop install pdcli +pdcli version +``` + +Add the bucket once, then `scoop update pdcli` to upgrade. Auth: Windows Credential +Manager by default, or env vars. + +## Standalone tarballs + +Every release attaches prebuilt, self-contained tarballs to its +[GitHub Release](https://github.com/wavyx/pdcli/releases). Each bundles Node and the +CLI, so the target host needs nothing preinstalled. Use these for air-gapped machines, +Node-free servers, or when you want to vendor an exact build. + +Five targets are published per release: + +| Target | Platform | +| ------ | -------- | +| `linux-x64` | Linux, Intel/AMD 64-bit | +| `linux-arm64` | Linux, ARM 64-bit | +| `darwin-x64` | macOS, Intel | +| `darwin-arm64` | macOS, Apple Silicon | +| `win32-x64` | Windows, 64-bit | + +Each tarball filename embeds the build's commit SHA (for example +`pdcli-v0.20.0-1a2b3c4-linux-x64.tar.gz`), so there is no fixed, predictable +download URL. Grab the exact asset from the +[Releases page](https://github.com/wavyx/pdcli/releases), or let the +[`gh` CLI](https://cli.github.com/) resolve it for you: + +```bash +# See the tarballs attached to a release: +gh release view v0.20.0 --json assets --jq '.assets[].name' + +# Download the one for your target by pattern (no need to know the SHA): +gh release download v0.20.0 --pattern '*linux-x64.tar.gz' +``` + +Then unpack it and put the `bin` directory on your `PATH`: + +```bash +tar -xzf pdcli-v0.20.0-*-linux-x64.tar.gz +./pdcli/bin/pdcli version +``` + +Auth: keychain by default where one is available, or env vars on a headless host. + +## Verify any install + +Whatever the channel, confirm it works the same way: + +```bash +pdcli version # version + Node + platform +pdcli doctor --offline # five config checks, no network +``` + +See [Installation](/pdcli/start/installation/) for shell completions and the full +doctor, and [CI recipes](/pdcli/automation/ci/) plus +[GitHub Actions](/pdcli/automation/github-actions/) for pipeline patterns. diff --git a/website/src/data/cli-stats.json b/website/src/data/cli-stats.json index 460bd0f..c0c8df7 100644 --- a/website/src/data/cli-stats.json +++ b/website/src/data/cli-stats.json @@ -1,5 +1,5 @@ { - "version": "0.19.0", + "version": "0.20.0", "commands": 146, "topics": 27, "formats": 4