From ed523c549400aab50a2452c61ea44d1ca6dfd1f1 Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Sun, 25 Jan 2026 19:39:57 -0600 Subject: [PATCH 1/6] Support multi-value id-in flags --- README.md | 10 +++++----- src/base-command.ts | 10 ++++++---- src/commands/documents/list.ts | 2 +- src/list-command.ts | 12 +++++++----- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d6c26bd..146416f 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ USAGE ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in= Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -461,7 +461,7 @@ USAGE ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in= Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -636,7 +636,7 @@ USAGE ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in= Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -856,7 +856,7 @@ USAGE ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in= Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -1092,7 +1092,7 @@ USAGE ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in= Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page diff --git a/src/base-command.ts b/src/base-command.ts index 53d47e5..f338e7f 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -10,6 +10,8 @@ export type ApiFlags = { token: string } +type QueryParams = Record + type ResolvedGlobalFlags = ApiFlags & { dateFormat: string } @@ -64,7 +66,7 @@ export abstract class BaseCommand extends Command { protected buildApiUrl( hostnameValue: string, path: string, - params: Record = {}, + params: QueryParams = {}, ): URL { let url: URL @@ -86,7 +88,7 @@ export abstract class BaseCommand extends Command { protected buildApiUrlFromFlags( flags: ApiFlags, path: string, - params: Record = {}, + params: QueryParams = {}, ): URL { return this.buildApiUrl(flags.hostname, path, params) } @@ -124,7 +126,7 @@ export abstract class BaseCommand extends Command { protected async fetchApiBinary( flags: ApiFlags, path: string, - params: Record = {}, + params: QueryParams = {}, ): Promise<{data: Uint8Array; headers: Headers}> { const url = this.buildApiUrlFromFlags(flags, path, params) const requestHeaders: Record = { @@ -149,7 +151,7 @@ export abstract class BaseCommand extends Command { protected async fetchApiJson( flags: ApiFlags, path: string, - params: Record = {}, + params: QueryParams = {}, ): Promise { const url = this.buildApiUrlFromFlags(flags, path, params) return this.fetchJson(url, flags.token, flags.headers) diff --git a/src/commands/documents/list.ts b/src/commands/documents/list.ts index 2966a77..020c32b 100644 --- a/src/commands/documents/list.ts +++ b/src/commands/documents/list.ts @@ -10,7 +10,7 @@ export default class DocumentsList extends ListCommand { protected override listParams( flags: Parameters[0], - ): Record { + ): Record { const params = super.listParams(flags) delete params.name__icontains diff --git a/src/list-command.ts b/src/list-command.ts index 1cc1267..4e3ef8d 100644 --- a/src/list-command.ts +++ b/src/list-command.ts @@ -6,7 +6,7 @@ import {BaseCommand} from './base-command.js' import {createValueFormatter, type TableColumn, type TableRow} from './helpers/table.js' type ListCommandFlags = ApiFlags & { - 'id-in'?: string + 'id-in'?: string[] 'name-contains'?: string page?: number 'page-size': number @@ -32,8 +32,10 @@ export abstract class ListCommand< static baseFlags = { ...BaseCommand.baseFlags, 'id-in': Flags.string({ - description: 'Filter by id list (comma-separated)', + delimiter: ',', + description: 'Filter by id list (repeatable or comma-separated)', exclusive: ['name-contains'], + multiple: true, }), 'name-contains': Flags.string({ description: 'Filter by name substring', @@ -57,7 +59,7 @@ export abstract class ListCommand< protected async fetchListResults(options: { flags: ListCommandFlags - params?: Record + params?: Record path: string }): Promise { const {flags, params = {}, path} = options @@ -79,7 +81,7 @@ export abstract class ListCommand< } } - protected listParams(flags: ListCommandFlags): Record { + protected listParams(flags: ListCommandFlags): Record { return { 'id__in': flags['id-in'], 'name__icontains': flags['name-contains'], @@ -161,7 +163,7 @@ export abstract class ListCommand< private buildListUrl(options: { flags: ListCommandFlags - params?: Record + params?: Record path: string }): URL { const {flags, params = {}, path} = options From 36fccea44dd8639f5bdaba36277e1f362e0901fd Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Tue, 27 Jan 2026 22:53:34 -0600 Subject: [PATCH 2/6] Harden document download id parsing Fixes #40 Co-authored-by: Codex --- src/commands/documents/download.ts | 56 ++++++++++++++---------- test/commands/documents/download.test.ts | 22 ++++++++++ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/commands/documents/download.ts b/src/commands/documents/download.ts index 663674d..6a8df45 100644 --- a/src/commands/documents/download.ts +++ b/src/commands/documents/download.ts @@ -5,7 +5,7 @@ import path from 'node:path' import {BaseCommand} from '../../base-command.js' type DocumentsDownloadArgs = { - id: string + id: number[] } type DocumentsDownloadFlags = { @@ -84,9 +84,40 @@ const resolveOutputPath = async (output: string | undefined, filename: string): return resolved } +const splitDelimitedValues = (input: string, delimiter: string): string[] => { + const splitRegex = new RegExp(`(? value.trim()) + .map((value) => + value + .replaceAll(new RegExp(`\\\\${delimiter}`, 'g'), delimiter) + .replace(/^"(.*)"$/, '$1') + .replace(/^'(.*)'$/, '$1'), + ) + .filter((value) => value.length > 0) +} + export default class DocumentsDownload extends BaseCommand { static override args = { - id: Args.string({description: 'Document id or comma-separated list of ids', required: true}), + id: Args.custom({ + async parse(input) { + const values = splitDelimitedValues(input, ',') + + if (values.length === 0) { + throw new Error('Provide at least one document id.') + } + + return values.map((value) => { + if (!/^-?\d+$/.test(value)) { + throw new Error(`Invalid document id: ${value}`) + } + + return Number.parseInt(value, 10) + }) + }, + })({description: 'Document id or comma-separated list of ids', required: true}), } static override description = 'Download one or more documents' static override examples = [ @@ -155,32 +186,13 @@ export default class DocumentsDownload extends BaseCommand { return results } - protected parseIds(raw: string): number[] { - const values = raw - .split(',') - .map((value) => value.trim()) - .filter((value) => value.length > 0) - - if (values.length === 0) { - this.error('Provide at least one document id.') - } - - return values.map((value) => { - if (!/^-?\d+$/.test(value)) { - this.error(`Invalid document id: ${value}`) - } - - return Number.parseInt(value, 10) - }) - } - public async run(): Promise { const {args, flags, metadata} = await this.parse() const {headers: apiHeaders, hostname, token} = await this.resolveGlobalFlags(flags, metadata) const apiFlags = {headers: apiHeaders, hostname, token} const typedArgs = args as DocumentsDownloadArgs const typedFlags = flags as DocumentsDownloadFlags - const ids = this.parseIds(typedArgs.id) + const ids = typedArgs.id let outputDir = typedFlags['output-dir'] if (ids.length > 1) { diff --git a/test/commands/documents/download.test.ts b/test/commands/documents/download.test.ts index 0f27bfd..475851d 100644 --- a/test/commands/documents/download.test.ts +++ b/test/commands/documents/download.test.ts @@ -113,4 +113,26 @@ describe('documents:download', () => { expect(contents12).to.equal('data-12') expect(contents34).to.equal('data-34') }) + + it('rejects escaped delimiters that yield non-numeric ids', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand(String.raw`documents:download 12\,34`) + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('Invalid document id: 12,34') + }) + + it('rejects empty comma-separated input', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:download ","') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('Provide at least one document id.') + }) }) From 0e6b085fbfabbfc3b8dc8384be3266391ed8d4c2 Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Tue, 27 Jan 2026 23:19:43 -0600 Subject: [PATCH 3/6] Support variadic document download ids Fixes #40 Co-authored-by: Codex --- README.md | 46 ++++++++++++------------ src/commands/documents/download.ts | 46 +++++++++++------------- test/commands/documents/download.test.ts | 26 ++++++++++++-- 3 files changed, 68 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 146416f..79dcf3b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ $ ppls config get hostname * [`ppls document-types update ID`](#ppls-document-types-update-id) * [`ppls documents add [PATH]`](#ppls-documents-add-path) * [`ppls documents delete ID`](#ppls-documents-delete-id) -* [`ppls documents download ID`](#ppls-documents-download-id) +* [`ppls documents download [ID]`](#ppls-documents-download-id) * [`ppls documents list`](#ppls-documents-list) * [`ppls documents show ID`](#ppls-documents-show-id) * [`ppls documents update ID`](#ppls-documents-update-id) @@ -280,11 +280,11 @@ List correspondents ``` USAGE $ ppls correspondents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (repeatable or comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -457,11 +457,11 @@ List custom fields ``` USAGE $ ppls custom-fields list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (repeatable or comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -632,11 +632,11 @@ List document types ``` USAGE $ ppls document-types list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (repeatable or comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -806,17 +806,17 @@ EXAMPLES _See code: [src/commands/documents/delete.ts](https://github.com/nickchristensen/ppls/blob/v1.1.0/src/commands/documents/delete.ts)_ -## `ppls documents download ID` +## `ppls documents download [ID]` Download one or more documents ``` USAGE - $ ppls documents download ID [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--original] [-o | --output-dir ] + $ ppls documents download [ID...] [--date-format ] [--header ...] [--hostname ] [--plain | --json + | --table] [--token ] [--original] [-o | --output-dir ] ARGUMENTS - ID Document id or comma-separated list of ids + [ID...] Document id (repeatable or comma-separated) FLAGS -o, --output= Output file path (single document) @@ -838,9 +838,11 @@ DESCRIPTION Download one or more documents EXAMPLES - $ ppls documents download 123 --output document.pdf + $ ppls documents download --output document.pdf 123 - $ ppls documents download 123,124 --output-dir ./downloads + $ ppls documents download --output-dir ./downloads 123 456 + + $ ppls documents download --output-dir ./downloads 123,456 ``` _See code: [src/commands/documents/download.ts](https://github.com/nickchristensen/ppls/blob/v1.1.0/src/commands/documents/download.ts)_ @@ -852,11 +854,11 @@ List documents ``` USAGE $ ppls documents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (repeatable or comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -1088,11 +1090,11 @@ List tags ``` USAGE $ ppls tags list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (repeatable or comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page diff --git a/src/commands/documents/download.ts b/src/commands/documents/download.ts index 6a8df45..79378c3 100644 --- a/src/commands/documents/download.ts +++ b/src/commands/documents/download.ts @@ -4,10 +4,6 @@ import path from 'node:path' import {BaseCommand} from '../../base-command.js' -type DocumentsDownloadArgs = { - id: number[] -} - type DocumentsDownloadFlags = { original?: boolean output?: string @@ -100,29 +96,15 @@ const splitDelimitedValues = (input: string, delimiter: string): string[] => { } export default class DocumentsDownload extends BaseCommand { + static override strict = false static override args = { - id: Args.custom({ - async parse(input) { - const values = splitDelimitedValues(input, ',') - - if (values.length === 0) { - throw new Error('Provide at least one document id.') - } - - return values.map((value) => { - if (!/^-?\d+$/.test(value)) { - throw new Error(`Invalid document id: ${value}`) - } - - return Number.parseInt(value, 10) - }) - }, - })({description: 'Document id or comma-separated list of ids', required: true}), + id: Args.string({description: 'Document id (repeatable or comma-separated)'}), } static override description = 'Download one or more documents' static override examples = [ - '<%= config.bin %> <%= command.id %> 123 --output document.pdf', - '<%= config.bin %> <%= command.id %> 123,124 --output-dir ./downloads', + '<%= config.bin %> <%= command.id %> --output document.pdf 123', + '<%= config.bin %> <%= command.id %> --output-dir ./downloads 123 456', + '<%= config.bin %> <%= command.id %> --output-dir ./downloads 123,456', ] static override flags = { original: Flags.boolean({description: 'Download original file'}), @@ -187,12 +169,24 @@ export default class DocumentsDownload extends BaseCommand { } public async run(): Promise { - const {args, flags, metadata} = await this.parse() + const {flags, metadata, raw} = await this.parse() const {headers: apiHeaders, hostname, token} = await this.resolveGlobalFlags(flags, metadata) const apiFlags = {headers: apiHeaders, hostname, token} - const typedArgs = args as DocumentsDownloadArgs const typedFlags = flags as DocumentsDownloadFlags - const ids = typedArgs.id + const rawArgs = raw.filter((token) => token.type === 'arg').map((token) => token.input) + const values = rawArgs.flatMap((value) => splitDelimitedValues(value, ',')) + + if (values.length === 0) { + this.error('Provide at least one document id.') + } + + const ids = values.map((value) => { + if (!/^-?\d+$/.test(value)) { + this.error(`Invalid document id: ${value}`) + } + + return Number.parseInt(value, 10) + }) let outputDir = typedFlags['output-dir'] if (ids.length > 1) { diff --git a/test/commands/documents/download.test.ts b/test/commands/documents/download.test.ts index 475851d..07bcfbb 100644 --- a/test/commands/documents/download.test.ts +++ b/test/commands/documents/download.test.ts @@ -86,7 +86,7 @@ describe('documents:download', () => { expect(contents).to.equal('data') }) - it('downloads multiple documents to an output directory', async () => { + it('downloads multiple documents to an output directory with repeated args', async () => { const outputDir = path.join(tempDir, 'downloads') await mkdir(outputDir) @@ -100,7 +100,7 @@ describe('documents:download', () => { }) } - const {stdout} = await runCommand(`documents:download 12,34 --output-dir ${outputDir} --json`) + const {stdout} = await runCommand(`documents:download 12 34 --output-dir ${outputDir} --json`) const payload = JSON.parse(stdout) as Array<{filename: string; output: string}> expect(payload).to.have.length(2) @@ -114,6 +114,28 @@ describe('documents:download', () => { expect(contents34).to.equal('data-34') }) + it('supports comma-separated ids', async () => { + const outputDir = path.join(tempDir, 'downloads-comma') + await mkdir(outputDir) + + globalThis.fetch = async (input) => { + requests.push(String(input)) + const idMatch = /\/documents\/(\d+)\/download/.exec(String(input)) + const id = idMatch ? idMatch[1] : 'unknown' + return new Response(new TextEncoder().encode(`data-${id}`), { + headers: {'Content-Disposition': `attachment; filename="doc-${id}.pdf"`}, + status: 200, + }) + } + + const {stdout} = await runCommand(`documents:download 12,34 --output-dir ${outputDir} --json`) + const payload = JSON.parse(stdout) as Array<{filename: string; output: string}> + + expect(payload).to.have.length(2) + expect(payload[0]?.output).to.equal(path.join(outputDir, 'doc-12.pdf')) + expect(payload[1]?.output).to.equal(path.join(outputDir, 'doc-34.pdf')) + }) + it('rejects escaped delimiters that yield non-numeric ids', async () => { globalThis.fetch = async () => { throw new Error('Unexpected fetch call') From 560c313a98719b3375529179b12f09c7e5e91e19 Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Tue, 27 Jan 2026 23:31:21 -0600 Subject: [PATCH 4/6] Fix lint error --- src/commands/documents/download.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/documents/download.ts b/src/commands/documents/download.ts index 79378c3..229ad57 100644 --- a/src/commands/documents/download.ts +++ b/src/commands/documents/download.ts @@ -96,7 +96,6 @@ const splitDelimitedValues = (input: string, delimiter: string): string[] => { } export default class DocumentsDownload extends BaseCommand { - static override strict = false static override args = { id: Args.string({description: 'Document id (repeatable or comma-separated)'}), } @@ -119,6 +118,7 @@ export default class DocumentsDownload extends BaseCommand { exists: true, }), } + static override strict = false protected async downloadDocument(options: DownloadDocumentOptions): Promise { const {apiFlags, id, original, output, outputDir} = options From 2d108112b646171d738c8dd638611d2b412c4097 Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Tue, 27 Jan 2026 23:37:09 -0600 Subject: [PATCH 5/6] Run tests on pre-push Co-authored-by: Codex --- .husky/.gitignore | 1 + .husky/pre-push | 4 ++++ package-lock.json | 21 +++++++++++++++++++-- package.json | 2 ++ 4 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-push diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..10df100 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_/ diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..449fcde --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm test diff --git a/package-lock.json b/package-lock.json index 10cf943..127b30c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ppls", + "name": "@nickchristensen/ppls", "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ppls", + "name": "@nickchristensen/ppls", "version": "1.1.0", "license": "MIT", "dependencies": { @@ -29,6 +29,7 @@ "eslint": "^9", "eslint-config-oclif": "^6", "eslint-config-prettier": "^10", + "husky": "^9.1.7", "mocha": "^10", "oclif": "^4", "shx": "^0.3.3", @@ -7280,6 +7281,22 @@ "node": ">=10.19.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/package.json b/package.json index 13a6355..cd60165 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "eslint": "^9", "eslint-config-oclif": "^6", "eslint-config-prettier": "^10", + "husky": "^9.1.7", "mocha": "^10", "oclif": "^4", "shx": "^0.3.3", @@ -80,6 +81,7 @@ "lint": "eslint", "postpack": "shx rm -f oclif.manifest.json", "posttest": "npm run lint", + "prepare": "husky install", "prepack": "npm run build", "postbuild": "oclif manifest && oclif readme --no-aliases", "test": "mocha --forbid-only \"test/**/*.test.ts\"", From 8f33329c850d1f07feda43779e425e33e8300e1b Mon Sep 17 00:00:00 2001 From: Nick Christensen Date: Tue, 27 Jan 2026 23:38:44 -0600 Subject: [PATCH 6/6] Update husky pre-push hook format Co-authored-by: Codex --- .husky/pre-push | 3 --- 1 file changed, 3 deletions(-) diff --git a/.husky/pre-push b/.husky/pre-push index 449fcde..72c4429 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - npm test