diff --git a/README.md b/README.md index 2a21561..adfe89c 100644 --- a/README.md +++ b/README.md @@ -8,33 +8,41 @@ A node-based paperless-ngx cli * [ppls](#ppls) -* [Usage](#usage) +* [Quickstart](#quickstart) * [Configuration](#configuration) * [Commands](#commands) -# Usage - - -```sh-session -$ npm install -g ppls -$ ppls COMMAND -running command... -$ ppls (--version) -ppls/0.0.0 darwin-arm64 node-v24.13.0 -$ ppls --help [COMMAND] -USAGE - $ ppls COMMAND -... -``` - +# Quickstart +TODO # Configuration -Set the following environment variables before running the CLI (they are used as defaults for `--hostname` and `--token` flags): +Configuration options can be specified via config file (`$XDG_CONFIG_HOME/ppls/config.json`), environment variables, or command flags. + +| Config file | Env var | Flag | Notes | +| ------------ | ------------------ | --------------- | --------------------------------------------------------------------------------------- | +| `hostname` | `PPLS_HOSTNAME` | `--hostname` | **Required** | +| `token` | `PPLS_TOKEN` | `--token` | **Required** | +| `headers` | `PPLS_HEADERS` | `--header` | `--header` and `PPLS_HEADERS` use `Key=Value` (repeat `--header` for multiple headers). | +| `dateFormat` | `PPLS_DATE_FORMAT` | `--date-format` | uses [date-fns format tokens](https://date-fns.org/v3.6.0/docs/format) | + +Precedence: config file < environment variables < command flags. -- `PPLS_HOSTNAME`: Base URL for your paperless-ngx instance (for example, `https://paperless.example.com`) -- `PPLS_TOKEN`: API token for your paperless-ngx user +### Config file management + +See the `ppls config` commands (`init`, `set`, `get`, `list`, `remove`) to manage the config file. + +Examples: + +``` +$ ppls config init +$ ppls config set hostname https://paperless.example.com +$ ppls config set token your-api-token +$ ppls config set headers '{"X-Api-Key":"token"}' +$ ppls config list +$ ppls config get hostname +``` # Commands diff --git a/src/base-command.ts b/src/base-command.ts index 9e3cfff..58757e7 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -15,10 +15,8 @@ type ResolvedGlobalFlags = ApiFlags & { } type UserConfig = Partial & { - 'date-format'?: string dateFormat?: string - header?: string | string[] - headers?: Record | string | string[] + headers?: Record } type CommandMetadata = { @@ -219,20 +217,11 @@ export abstract class BaseCommand extends Command { continue } - const colonIndex = trimmed.indexOf(':') const equalsIndex = trimmed.indexOf('=') - let separatorIndex = -1 - - if (colonIndex === -1) { - separatorIndex = equalsIndex - } else if (equalsIndex === -1) { - separatorIndex = colonIndex - } else { - separatorIndex = Math.min(colonIndex, equalsIndex) - } + const separatorIndex = equalsIndex if (separatorIndex === -1) { - this.error(`Invalid header "${entry}" from ${source}. Use "Key: Value" or "Key=Value".`) + this.error(`Invalid header "${entry}" from ${source}. Use "Key=Value".`) } const key = trimmed.slice(0, separatorIndex).trim() @@ -248,6 +237,28 @@ export abstract class BaseCommand extends Command { return headers } + protected parseHeadersConfig(input: unknown, source: string): Record { + if (input === undefined || input === null) { + return {} + } + + if (typeof input !== 'object' || Array.isArray(input)) { + this.error(`Invalid headers from ${source}. Expected an object of header key/value pairs.`) + } + + const headers: Record = {} + + for (const [key, value] of Object.entries(input as Record)) { + if (value === undefined || value === null) { + continue + } + + headers[key] = String(value) + } + + return headers + } + protected parseHeadersInput(input: unknown, source: string): Record { if (input === undefined || input === null) { return {} @@ -260,16 +271,6 @@ export abstract class BaseCommand extends Command { return {} } - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - try { - const parsed = JSON.parse(trimmed) - return this.parseHeadersInput(parsed, `${source} JSON`) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.error(`Failed to parse headers from ${source}: ${message}`) - } - } - return this.parseHeaderEntries([trimmed], source) } @@ -283,21 +284,7 @@ export abstract class BaseCommand extends Command { return headers } - if (typeof input === 'object') { - const headers: Record = {} - - for (const [key, value] of Object.entries(input as Record)) { - if (value === undefined || value === null) { - continue - } - - headers[key] = String(value) - } - - return headers - } - - this.error(`Invalid headers from ${source}. Expected an object, array, or string.`) + this.error(`Invalid headers from ${source}. Expected a string or array of strings.`) } protected async patchApiJson(flags: ApiFlags, path: string, body: unknown): Promise { @@ -382,7 +369,7 @@ export abstract class BaseCommand extends Command { metadata: CommandMetadata | undefined, userConfig: UserConfig, ): string { - const configDateFormat = userConfig['date-format'] ?? userConfig.dateFormat + const configDateFormat = userConfig.dateFormat const flagValue = flags['date-format'] as string | undefined const usedDefault = metadata?.flags?.['date-format']?.setFromDefault @@ -416,7 +403,7 @@ export abstract class BaseCommand extends Command { } protected resolveHeaders(flags: Record, userConfig: UserConfig): Record { - const configHeaders = this.parseHeadersInput(userConfig.headers ?? userConfig.header, 'config.json headers') + const configHeaders = this.parseHeadersConfig(userConfig.headers, 'config.json headers') const envHeaders = this.parseHeadersInput(process.env.PPLS_HEADERS, 'PPLS_HEADERS') const flagHeaders = this.parseHeadersInput(flags.header, '--header') diff --git a/src/commands/config/init.ts b/src/commands/config/init.ts index 8ab202a..c19947c 100644 --- a/src/commands/config/init.ts +++ b/src/commands/config/init.ts @@ -41,7 +41,14 @@ export default class ConfigInit extends BaseCommand { this.error(`Config already exists at ${configFile}. Use --force to overwrite.`) } - await writeConfig(this.config.configDir, {}) + await writeConfig(this.config.configDir, { + dateFormat: 'YYYY-MM-DD', + headers: { + 'Custom-Header': 'value', + }, + hostname: 'http://example.com', + token: 'your-api-token-here', + }) const result: ConfigInitResult = {overwritten: exists, path: configFile} diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 0026d97..de3de94 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -58,6 +58,14 @@ export default class ConfigSet extends BaseCommand { this.error(message) } + if ( + typedArgs.key === 'headers' && + (!parsedValue || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) + ) { + this.warn('Config key "headers" must be a JSON object. Example: {"X-Api-Key":"token"}') + return config + } + config[typedArgs.key] = parsedValue await writeConfig(this.config.configDir, config) diff --git a/test/commands/config/init.test.ts b/test/commands/config/init.test.ts index 824d9e7..37e2084 100644 --- a/test/commands/config/init.test.ts +++ b/test/commands/config/init.test.ts @@ -34,8 +34,15 @@ describe('config:init', () => { expect(payload.path).to.equal(configPath) expect(payload.overwritten).to.equal(false) - const contents = await readFile(configPath, 'utf8') - expect(contents.trim()).to.equal('{}') + const contents = JSON.parse(await readFile(configPath, 'utf8')) as Record + expect(contents).to.deep.equal({ + dateFormat: 'YYYY-MM-DD', + headers: { + 'Custom-Header': 'value', + }, + hostname: 'http://example.com', + token: 'your-api-token-here', + }) }) it('refuses to overwrite without --force', async () => { diff --git a/test/commands/config/set.test.ts b/test/commands/config/set.test.ts index bb6bd89..13c8b3b 100644 --- a/test/commands/config/set.test.ts +++ b/test/commands/config/set.test.ts @@ -1,6 +1,6 @@ import {runCommand} from '@oclif/test' import {expect} from 'chai' -import {mkdtemp, readFile, rm} from 'node:fs/promises' +import {mkdtemp, readFile, rm, stat} from 'node:fs/promises' import {tmpdir} from 'node:os' import path from 'node:path' @@ -43,4 +43,23 @@ describe('config:set', () => { expect(payload.headers['X-Api-Key']).to.equal('token') }) + + it('warns and skips when headers is not an object', async () => { + const {stderr} = await runCommand('config:set headers not-json', loadOpts) + + expect(stderr).to.include('Config key "headers" must be a JSON object.') + + const configPath = path.join(tempDir, 'config.json') + let exists = true + + try { + await stat(configPath) + } catch (error) { + const typedError = error as NodeJS.ErrnoException + expect(typedError.code).to.equal('ENOENT') + exists = false + } + + expect(exists).to.equal(false) + }) })