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
46 changes: 27 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,41 @@ A node-based paperless-ngx cli

<!-- toc -->
* [ppls](#ppls)
* [Usage](#usage)
* [Quickstart](#quickstart)
* [Configuration](#configuration)
* [Commands](#commands)
<!-- tocstop -->

# Usage

<!-- 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
...
```
<!-- usagestop -->
# 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

Expand Down
69 changes: 28 additions & 41 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ type ResolvedGlobalFlags = ApiFlags & {
}

type UserConfig = Partial<ApiFlags> & {
'date-format'?: string
dateFormat?: string
header?: string | string[]
headers?: Record<string, unknown> | string | string[]
headers?: Record<string, unknown>
}

type CommandMetadata = {
Expand Down Expand Up @@ -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()
Expand All @@ -248,6 +237,28 @@ export abstract class BaseCommand extends Command {
return headers
}

protected parseHeadersConfig(input: unknown, source: string): Record<string, string> {
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<string, string> = {}

for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (value === undefined || value === null) {
continue
}

headers[key] = String(value)
}

return headers
}

protected parseHeadersInput(input: unknown, source: string): Record<string, string> {
if (input === undefined || input === null) {
return {}
Expand All @@ -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)
}

Expand All @@ -283,21 +284,7 @@ export abstract class BaseCommand extends Command {
return headers
}

if (typeof input === 'object') {
const headers: Record<string, string> = {}

for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
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<T>(flags: ApiFlags, path: string, body: unknown): Promise<T> {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -416,7 +403,7 @@ export abstract class BaseCommand extends Command {
}

protected resolveHeaders(flags: Record<string, unknown>, userConfig: UserConfig): Record<string, string> {
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')

Expand Down
9 changes: 8 additions & 1 deletion src/commands/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
8 changes: 8 additions & 0 deletions src/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 9 additions & 2 deletions test/commands/config/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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 () => {
Expand Down
21 changes: 20 additions & 1 deletion test/commands/config/set.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
})
})