@charlie-labs/oclif-plugin-helpers
v0.3.0
Published
Shared oclif helpers for errors, logging, JSON support, etc.
Keywords
Readme
@charlie-labs/oclif-plugin-helpers
Shared helpers to standardize error handling and output behavior across multiple oclif CLIs.
- Shared error classes with stable static exit codes and string codes
- Robust unknown → exit-code mapper (
errorToExitCode) - A
BaseCommandthat centralizes--jsongating, minimal error JSON shaping, stderr-only logging helpers, and a TSV printer - Optional
handle()forbin/runcatch chains to conform exit codes - Optional
finallyhook that conservatively setsprocess.exitCodewhen an error occurred
Installation
This package is publicly published on the npm registry as @charlie-labs/oclif-plugin-helpers and expects oclif v4.
Public npm: npmjs.com/package/@charlie-labs/oclif-plugin-helpers
Peer dependency:
@oclif/core@^4.5.2Node:
>=18(per this package’senginesfield)
# npm
npm install @charlie-labs/oclif-plugin-helpers @oclif/core@^4.5.2
# bun
bun add @charlie-labs/oclif-plugin-helpers @oclif/core@^4.5.2Overview / Mission
A reusable place to make multiple CLIs behave the same way when errors happen and when users request JSON:
- Error classes include a static
exitCodeand a stringcodefor stability across boundaries. errorToExitCode(err)maps any thrown value to a stable exit code using a resilient precedence order.BaseCommandis the primary integration point: it enables--jsonfor all commands, shapes error JSON, suppresses logs under--json, and provides stderr-only logging helpers plus a TSV printer.handle()can be used inbin/runto conform exit codes for errors that bypassCommand.catch().- An optional
finallyhook exists to setprocess.exitCodeonly when no other layer has set it.
Quick Start
Extend BaseCommand and implement execute(ctx) (do not override run()). Destructure only what you need from ctx — { parsed }, { deps }, or { parsed, deps }. If you don’t need the context at all, accept it as an unused parameter: execute(_ctx). When your command defines flags, build a manifest with defineFlags(...) and install it via static override flags = super.registerManifest(manifest);. Commands without flags can skip that line entirely; they fall back to an empty manifest. Use logInfo/logWarn for stderr-only logs and printRows for TSV content. Under --json, logs are suppressed and printRows is a no-op. The BaseCommand generic is kwargs‑style and order‑free: supply a union of tags — CfgFlags<typeof manifest> | Result<T> | Deps<D> — and specify only what you need.
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type { Result } from '@charlie-labs/oclif-plugin-helpers';
export default class Demo extends BaseCommand<Result<{ id: string }>> {
// `--json` is enabled for all subclasses via BaseCommand
protected async execute(_ctx) {
this.logInfo('Fetching record…'); // stderr-only; suppressed under --json
// pretend we looked something up
const record = { id: 'rec_123' } as const;
// TSV output (stdout). No-op under --json
this.printRows([
['id', 'name'],
['123', 'example'],
]);
return record;
}
}JSON mode and error JSON shape
All subclasses of BaseCommand automatically support oclif’s built-in --json flag. When --json is set:
- Non-content logs are suppressed (
logInfo/logWarndon’t write) printRows()is a no-op- Errors are rendered as minimal, stable JSON using
toErrorJson(err)
Exact error JSON shape:
{
error: {
type: string;
message: string;
exitCode: number;
meta?: {
code?: string;
status?: number;
retryable?: boolean;
};
};
}typeis derived fromerr.name(or constructor name)messageis concise and always presentexitCodeis computed byerrorToExitCodemetaincludes optional hints when available: string errorcode, HTTPstatus, andretryable(based on common transport failures viaisRetryableNetworkError)
Exit code mapping
errorToExitCode(err) resolves a stable process exit code using this order:
err.exitCode(instance)err.constructor.exitCode(static)err.code(string)err.nameinstanceofchecks- default
1
Provided error classes and their static exit codes:
ValidationError→ 2 (code:EVALIDATION)NotFoundError→ 3 (code:ERESOURCE_NOT_FOUND)ConflictError→ 4 (code:ECONFLICT)UnauthorizedError→ 5 (code:EUNAUTHORIZED)RateLimitedError→ 6 (code:ERATELIMIT)ServiceUnavailableError→ 7 (code:ESVCUNAVAILABLE)CanceledError→ 8 (code:ECANCELED)ApiRequestError→ 1 (code:EAPI)
Public API reference
All symbols below are exported from @charlie-labs/oclif-plugin-helpers (via src/index.ts).
Classes (errors)
ValidationErrorNotFoundErrorConflictErrorUnauthorizedErrorRateLimitedErrorServiceUnavailableErrorCanceledErrorApiRequestError
Functions
errorToExitCode(err: unknown): numberisRetryableNetworkError(err: unknown): boolean
FlagManifest (v1)
A tiny framework to define oclif flags alongside Zod schemas, compose them into a manifest, and parse the oclif flag bag into fully typed domain values with optional cross-flag validation.
Public surface (concise):
import { Flags, type Command } from '@oclif/core';
import { z } from 'zod';
import {
defineFlags,
type FlagSchema,
type FlagManifest,
zDateYYYYMMDD,
zStringList,
zPositiveInt,
zOrderDir,
zMultiEnum,
CommonFlags,
} from '@charlie-labs/oclif-plugin-helpers';
// Build a manifest from inline flag schemas (preferred pattern)
const statusValues = ['started', 'completed', 'error'] as const;
export const manifest = defineFlags({
status: {
oclif: Flags.option({
options: statusValues,
multiple: true,
delimiter: ',',
description: 'Multi-select; repeats and/or comma-separated',
})(),
schema: zMultiEnum(statusValues),
},
start: {
oclif: Flags.string({ description: 'Inclusive (YYYY-MM-DD, UTC)' }),
schema: zDateYYYYMMDD.optional(),
},
end: {
oclif: Flags.string({ description: 'Exclusive (YYYY-MM-DD, UTC)' }),
schema: zDateYYYYMMDD.optional(),
},
limit: {
oclif: Flags.integer({
description: 'Positive integer (10000 max)',
default: 100,
}),
schema: zPositiveInt({ default: 100, max: 10_000 }),
},
})
.withValidation((schema) =>
schema.superRefine(({ start, end }, ctx) => {
if (start && end && !(start < end))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'end must be after start',
path: ['end'],
});
})
)
.withPredicate(
'start < end',
({ start, end }) => !start || !end || start < end,
{
path: ['end'],
message: 'end must be after start',
}
);
// oclif command usage (preferred)
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type { CfgFlags, ExecCtxOf } from '@charlie-labs/oclif-plugin-helpers';
export class MyCmd extends BaseCommand<CfgFlags<typeof manifest>> {
static override flags = super.registerManifest(manifest);
protected override async execute({ parsed }: ExecCtxOf<this>) {
// use typed flags
}
}Included atoms:
zDateYYYYMMDD: parseYYYY-MM-DDintoDateat UTC midnightzStringList: parsestring | number | (string|number)[] | undefined(repeat/comma) → de-duplicatedstring[](undefined normalizes to[])zMultiEnum(values): normalize repeat/comma inputs and validate against provided enum valueszPositiveInt({ max?, default? })zOrderDir('asc'|'desc')zDateComparator: parse a single comparator string like ">=2025-01-01" or "> 2025-01-01" to{ op: 'gte'|'gt'|'lte'|'lt'|'eq', date: Date }(UTC midnight)zDateComparatorList: alias forzStringList.pipe(z.array(zDateComparator))— accepts repeat/comma inputs, returnsDateComparator[], and normalizesundefinedto[]
Notes
- Inline schemas are the primary path. For optional dates, use
zDateYYYYMMDD.optional(). - When using a default with
zPositiveInt({ default: N, ... }), define the same default on the oclif flag to keep behavior consistent between oclif and Zod parsing.
Convenience: CommonFlags exports ready-made start, end, limit, order, and a sample status multi-enum.
Date comparator examples
import { Flags } from '@oclif/core';
import {
defineFlags,
zDateComparator,
zDateComparatorList,
} from '@charlie-labs/oclif-plugin-helpers';
export const manifest = defineFlags({
created: {
oclif: Flags.string({
description: '>, >=, <, <=, = followed by YYYY-MM-DD (UTC)',
}),
schema: zDateComparator.optional(),
},
updated: {
oclif: Flags.string({
multiple: true,
delimiter: ',',
description:
'>, >=, <, <=, = followed by YYYY-MM-DD (UTC). Repeat or comma-separate',
}),
schema: zDateComparatorList,
},
});BaseCommand
class BaseCommand<Cfg>whereCfgis a union of order‑free tags you pick from:CfgFlags<typeof manifest>— enables typedparsedflags inexecute(...)Result<T>— declares the return type ofrun()/execute()(defaults tounknown)Deps<D>— declares the dependency object type used by DI helpers
static enableJsonFlag = truestatic get manifest(): FlagManifest<Defs, ZodTypeAny>— returns the manifest registered on the concrete subclass (defaults to an empty manifest). Callstatic override flags = super.registerManifest(manifest);to install your manifest and expose its oclif flags to help output and parsing.protected execute(ctx)— pick only what you need by destructuring:execute({ parsed })execute({ deps })execute({ parsed, deps })If you don’t need the context, accept it asexecute(_ctx). When noCfgFlags<>tag is present,ctx.parsedis typed asRecord<string, never>. When aDeps<>tag is present,ctx.depsis typed asD | undefined; without aDeps<>tag,ctx.depsisundefined.
- Dependency injection (resolution order in
run()): test override →static buildDeps(parsed)→protected get deps()static buildDeps(parsed): D | Promise<D | undefined> | undefined— override on your subclass to construct deps from flags. The override is fully typed to yourCfgvia a polymorphicthisparameter, soparsedisParsedOf<typeof manifest>and the return type is yourD. If you mark itasync, the return type becomesPromise<D | undefined>.protected get deps(): D | undefined— instance fallback whenbuildDepsisn’t used- Note: this getter is typed to your
Deps<D>tag. If you previously returned a broader type, narrow it toD.
- Note: this getter is typed to your
static setTestDeps(deps: D)— test-only override used first when present (one‑shot; cleared after use)static clearTestDeps()— clears any previously set test override (useful in custom harnesses)
async run(): Promise<T>— provided byBaseCommandand final in practice (whereTis the type you supplied viaResult<T>inCfg)- Helpers (no-op under
--json):protected logInfo(msg: string): void— stderr-onlyprotected logWarn(msg: string): void— stderr-onlyprotected printRows(rows: (string | string[])[], options?: { header?: string[] }): void— stdout TSV
- Error shaping:
protected toErrorJson(err: unknown)— returns the JSON shape shown above (used automatically by oclif in JSON mode)
Cfg combinations (type-only examples)
Below are short, type-only class declarations that show common ways to configure BaseCommand<Cfg> using the CfgFlags<>, Result<>, and Deps<> tags. These intentionally omit method bodies and focus on the type surface.
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type {
CfgFlags,
Result,
Deps,
FlagManifest,
Defs,
} from '@charlie-labs/oclif-plugin-helpers';
import type { ZodTypeAny } from 'zod';
// For these standalone examples, use a type-only stub for `manifest` with the correct generic shape.
declare const manifest: FlagManifest<Defs, ZodTypeAny>;
// 1) Deps-only (no flags; implicit output type = unknown)
export class DepsOnly extends BaseCommand<Deps<{ source: string }>> {}
// 2) Result + Deps (no flags)
export class ResultAndDeps extends BaseCommand<
| Deps<{ client: { request: (s: string) => Promise<unknown> } }>
| Result<number>
> {}
// 3) Flags + Result (no deps)
export class FlagsAndResult extends BaseCommand<
CfgFlags<typeof manifest> | Result<string>
> {
static override flags = super.registerManifest(manifest);
}
// 3a) Flags-only (no deps; implicit output type = unknown)
export class FlagsOnly extends BaseCommand<CfgFlags<typeof manifest>> {
static override flags = super.registerManifest(manifest);
}
// 4) Flags + Deps + Result (all three)
export class FlagsDepsResult extends BaseCommand<
CfgFlags<typeof manifest> | Deps<{ db: unknown }> | Result<void>
> {
static override flags = super.registerManifest(manifest);
}Notes
- When you omit
Result<T>, the command’s output type defaults tounknown. - When you omit
CfgFlags<>,ctx.parsedinexecute(ctx)isRecord<string, never>. - When you omit
Deps<>,ctx.depsisundefined.
Other
handle(err: unknown): Promise<void>— drop-in forbin/runcatch chains
Optional integration points
handle() in a bin script
Use handle() to ensure exit codes conform to the mapper even when errors bypass Command.catch():
// bin/run (snippet)
import { handle } from '@charlie-labs/oclif-plugin-helpers';
import { run } from '@oclif/core';
await run(void 0, import.meta.url).catch(handle);Finally hook
This package includes an optional finally hook at src/hooks/finally/report.ts that only sets process.exitCode if an error occurred and the exit code wasn’t set yet. It does not print anything. This is a conservative last resort layer.
Examples
1) Command with JSON gating, stderr logs, and TSV output
import { BaseCommand } from '@charlie-labs/oclif-plugin-helpers';
import type { Result } from '@charlie-labs/oclif-plugin-helpers';
export default class ListProjects extends BaseCommand<Result<void>> {
protected async execute(_ctx) {
this.logInfo('Listing projects');
this.printRows(
[
['p_123', 'Acme'],
['p_456', 'Beta'],
],
{ header: ['id', 'name'] }
);
}
}2) Throwing a provided error → mapped exit code
import {
BaseCommand,
ValidationError,
} from '@charlie-labs/oclif-plugin-helpers';
import type { CfgFlags, Result } from '@charlie-labs/oclif-plugin-helpers';
import { defineFlags } from '@charlie-labs/oclif-plugin-helpers/flags';
const noFlags = defineFlags({} as const);
export default class Create extends BaseCommand<
CfgFlags<typeof noFlags> | Result<void>
> {
protected async execute(_ctx) {
const name = '';
if (!name) throw new ValidationError('`name` is required'); // exit 2
}
}3) Mapping an unknown error yourself
import { errorToExitCode } from '@charlie-labs/oclif-plugin-helpers';
try {
// … your code …
} catch (err) {
process.exitCode = errorToExitCode(err);
}4) bin/run catch chain
import { handle } from '@charlie-labs/oclif-plugin-helpers';
import { run } from '@oclif/core';
await run(void 0, import.meta.url).catch(handle);Compatibility and requirements
- Peer:
@oclif/core@^4.5.2 - Node:
>=18
Repository meta
- Package:
@charlie-labs/oclif-plugin-helpers - Published: public on npm (
publishConfig.access: "public") - License:
UNLICENSED - Spec: Issue #1 — “Initial Spec”
