Skip to content

Commit

Permalink
feat: Add @jahands/cli-tools package
Browse files Browse the repository at this point in the history
  • Loading branch information
jahands committed Jan 12, 2025
1 parent e57bd71 commit 8aca1fb
Show file tree
Hide file tree
Showing 31 changed files with 796 additions and 1 deletion.
6 changes: 6 additions & 0 deletions .changeset/wise-melons-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@repo/cli-example': patch
'@jahands/cli-tools': patch
---

feat: Add @jahands/cli-tools package
1 change: 1 addition & 0 deletions .syncpackrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const config = {
'workers-tagged-logger',
'@jahands/otel-cf-workers',
'http-codex',
'@jahands/cli-tools',
],
dependencyTypes: ['!local'], // Exclude the local package itself
pinVersion: 'workspace:*',
Expand Down
5 changes: 5 additions & 0 deletions examples/cli-example/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ['@repo/eslint-config/workers.cjs'],
}
3 changes: 3 additions & 0 deletions examples/cli-example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# cli-example

An example CLI to showcase the @jahands/cli-tools package
27 changes: 27 additions & 0 deletions examples/cli-example/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@repo/cli-example",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"scripts": {
"check:lint": "run-eslint-workers",
"check:types": "run-tsc",
"cli": "bun ./src/bin/cli.ts",
"test": "run-vitest"
},
"dependencies": {
"@commander-js/extra-typings": "12.1.0",
"@jahands/cli-tools": "workspace:*",
"commander": "12.1.0",
"zod": "3.23.8",
"zx": "8.1.9"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.5.14",
"@cloudflare/workers-types": "4.20241004.0",
"@repo/eslint-config": "workspace:*",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*",
"vitest": "2.1.1"
}
}
23 changes: 23 additions & 0 deletions examples/cli-example/src/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'zx/globals'

import { program } from '@commander-js/extra-typings'

import { exampleCmd } from '../cmd/example'

program
.name('factorio-icons')
.description('A CLI for scripting cropping Factorio icons')

// Commands
.addCommand(exampleCmd)

// Don't hang for unresolved promises
.hook('postAction', () => process.exit(0))
.parseAsync()
.catch((e) => {
if (e instanceof ProcessOutput) {
process.exit(1)
} else {
throw e
}
})
28 changes: 28 additions & 0 deletions examples/cli-example/src/cmd/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Command } from '@commander-js/extra-typings'
import { cliError, prefixOutput, validateArg } from '@jahands/cli-tools'
import { z } from 'zod'

export const exampleCmd = new Command('example').description('An example command with subcommands')

exampleCmd
.command('throw-cli-error')
.description('throws a cli error')
.action(async () => {
throw cliError('an error!')
})

exampleCmd
.command('validate-args')
.requiredOption(
'-e, --env <staging|production>',
'environment to use',
validateArg(z.enum(['staging', 'production']))
)
.action(async ({ env }) => {
echo(`env is: ${env}`)
})

exampleCmd.command('prefix-output').action(async () => {
const proc = $`jctl`
await prefixOutput(proc, 'OUTPUT:')
})
22 changes: 22 additions & 0 deletions examples/cli-example/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,

/* Linting */
"skipLibCheck": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
}
}
17 changes: 17 additions & 0 deletions examples/cli-example/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'

export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
main: `${__dirname}/src/index.ts`,
isolatedStorage: true,
singleWorker: true,
miniflare: {
compatibilityDate: '2024-09-02',
compatibilityFlags: ['nodejs_compat'],
},
},
},
},
})
4 changes: 4 additions & 0 deletions examples/cli-example/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name = "hono-app"
main = "src/index.ts"
compatibility_date = "2024-09-02"
compatibility_flags = ["nodejs_compat"]
5 changes: 5 additions & 0 deletions packages/cli-tools/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ['@repo/eslint-config/workers.cjs'],
}
3 changes: 3 additions & 0 deletions packages/cli-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @jahands/cli-tools

A collection of CLI tools to help with my commander CLIs
47 changes: 47 additions & 0 deletions packages/cli-tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@jahands/cli-tools",
"version": "0.1.0",
"private": false,
"description": "cli tools for commander",
"homepage": "https://github.com/jahands/workers-packages/tree/main/packages/cli-tools",
"repository": {
"type": "git",
"url": "https://github.com/jahands/workers-packages.git",
"directory": "packages/cli-tools"
},
"license": "MIT",
"author": {
"name": "Jacob Hands",
"url": "https://github.com/jahands"
},
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"scripts": {
"build": "bun ./scripts/build.ts",
"check:lint": "run-eslint-workers",
"check:types": "run-tsc",
"test": "run-vitest"
},
"devDependencies": {
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*",
"esbuild": "0.24.0",
"vitest": "2.1.1"
},
"peerDependencies": {
"@commander-js/extra-typings": "^12.1.0",
"commander": "^12.1.0",
"typescript": "^5.5.4",
"zod": "^3.23.8",
"zx": "^8.1.9"
}
}
24 changes: 24 additions & 0 deletions packages/cli-tools/scripts/build-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'zx/globals'

import { inspect } from 'util'
import ts from 'typescript'

import { entryPoints } from './entrypoints'

function buildDeclarationFiles(fileNames: string[], options: ts.CompilerOptions): void {
options = {
...options,
declaration: true,
emitDeclarationOnly: true,
outDir: './dist/',
}
const program = ts.createProgram(fileNames, options)
program.emit()
}

const tsconfig = ts.readConfigFile('./tsconfig.json', ts.sys.readFile)
if (tsconfig.error) {
throw new Error(`failed to read tsconfig: ${inspect(tsconfig)}`)
}

buildDeclarationFiles(entryPoints, tsconfig.config)
31 changes: 31 additions & 0 deletions packages/cli-tools/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'zx/globals'

import * as esbuild from 'esbuild'

import { entryPoints } from './entrypoints'

await fs.rm('./dist/', { force: true, recursive: true })

await Promise.all([
$`bun ./scripts/build-types.ts`,
esbuild.build({
entryPoints,
outdir: './dist/',
logLevel: 'info',
outExtension: {
'.js': '.mjs',
},
target: 'es2022',
format: 'esm',
bundle: true,
treeShaking: true,
external: [
// peerDependencies
'@commander-js/extra-typings',
'commander',
'typescript',
'zod',
'zx',
],
}),
])
4 changes: 4 additions & 0 deletions packages/cli-tools/scripts/entrypoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const entryPoints = [
// entrypoints
'./src/index.ts',
] as const satisfies string[]
58 changes: 58 additions & 0 deletions packages/cli-tools/src/args.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Command, program } from '@commander-js/extra-typings'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { z } from 'zod'

import { parseArg } from './args'

const exitErrors: Error[] = []
beforeAll(() => {
// Don't call process.exit(1) - instead, track the errors here
// for tests that throw program.error()
program.exitOverride((e) => {
exitErrors.push(e)
throw e
})
})

beforeEach(() => {
exitErrors.splice(0, exitErrors.length)
})

describe('parseArg()', () => {
it(`returns the output of the zod schema's parse()`, () => {
const cmd = new Command()
const schema = z.coerce.number()
expect(parseArg('5', schema, cmd)).toBe(5)
})

it(`throws cmd.error() with nicely formatted message`, () => {
let exited = false
const cli = new Command()
.exitOverride((e) => {
exited = true
throw e
})
.action(() => {
const schema = z.coerce.number({ description: 'a number' })
parseArg('a', schema, cli)
})
expect(() => cli.parse([])).toThrowErrorMatchingInlineSnapshot(
`[CommanderError: error: Expected number, received nan]`
)
expect(exited).toBe(true)
})

it(`throws program.error() if no command is passed in`, () => {
expect(exitErrors.length).toBe(0)
const schema = z.string().regex(/^foo$/, 'should have been foo!')
expect(() => parseArg('bar', schema)).toThrowErrorMatchingInlineSnapshot(
`[CommanderError: error: should have been foo!]`
)
expect(exitErrors.length).toBe(1)
expect(exitErrors).toMatchInlineSnapshot(`
[
[CommanderError: error: should have been foo!],
]
`)
})
})
46 changes: 46 additions & 0 deletions packages/cli-tools/src/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { program } from '@commander-js/extra-typings'
import { ZodError } from 'zod'

import type { Command } from '@commander-js/extra-typings'
import type { ZodTypeAny } from 'zod'

/**
* Parses an argument using a zod validator. If it fails,
* it throws a well-formatted commander error using the Zod messages
* @param validator Zod schema to validate with
* @param cmd Optional commander Command to use when throwing an error. Defaults to `program`
* @returns The zod type specified
*/
export function validateArg<T extends ZodTypeAny>(validator: T, cmd?: Command) {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
return (s: string) => parseArg(s, validator, cmd)
}

/**
* Parses an argument using a zod validator. If it fails,
* it throws a well-formatted commander error using the Zod messages
* @param s Arg/option passed into CLI
* @param validator Zod schema to validate with
* @param cmd Optional commander Command to use when throwing an error. Defaults to `program`
* @returns The zod type specified
*/
export function parseArg<T extends ZodTypeAny>(
s: string,
validator: T,
cmd?: Command
): ReturnType<T['parse']> {
try {
return validator.parse(s)
} catch (err) {
if (err instanceof ZodError && err.issues.length > 0) {
const messages = err.issues.map((e) => e.message)
let messagesFmt = messages[0]
for (const msg of messages.slice(1)) {
messagesFmt += `\n ${msg}`
}
throw (cmd ?? program).error(`${chalk.redBright('error')}${chalk.grey(':')} ${messagesFmt}`)
} else {
throw err
}
}
}
Loading

0 comments on commit 8aca1fb

Please sign in to comment.