Skip to content

Commit

Permalink
feat: history file (#191)
Browse files Browse the repository at this point in the history
* chore: update dependencies

* feat: history file

* test: fix

* ci: update ci workflow
  • Loading branch information
hongaar authored Jan 30, 2021
1 parent 8200988 commit 14b2b0d
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 27 deletions.
4 changes: 1 addition & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: ci

on:
push:
pull_request:
types: [opened, synchronize]

jobs:
test:
Expand Down Expand Up @@ -31,7 +29,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: 12
node-version: 14
- run: yarn install
- uses: paambaati/[email protected]
env:
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- ➰ Built-in [REPL](https://en.wikipedia.org/wiki/Read–eval–print_loop)
- 💬 Prompts for missing arguments
- 🔜 Autocompletes arguments
- 🔙 Command history
- 🤯 Fully typed
- ⚡ Uses the power of `yargs` and `enquirer`

Expand Down Expand Up @@ -317,11 +318,13 @@ All methods are chainable unless the docs mention otherwise.
Creates a new program. Options (object, optional) can contain these keys:
- `description` (string, optional) is used in help output.
- `prompt` (string, default: `>`) use this prompt prefix when in REPL mode.
- `prompt` (string, default: `> `) use this prompt prefix when in REPL mode.
- `help` (boolean, default: true) adds `help` and `--help` to the program which
displays program usage information.
- `version` (boolean, default: true) adds `version` and `--version` to the
program which displays program version from package.json.
- `historyFile` (string, defaults: {homedir}/.bandersnatch_history) is a path to
the app history file.
#### `program.description(description)`
Expand Down Expand Up @@ -360,7 +363,7 @@ program()
#### `program.repl()`
Start a read-eval-print loop. Returns promise-like repl instance.
Start a read-eval-print loop. Returns promise-like REPL instance.
```ts
program()
Expand All @@ -371,7 +374,7 @@ program()
#### `program.runOrRepl()`
Invokes `run()` if process.argv is set, `repl()` otherwise. Returns promise or
promise-like repl instance.
promise-like REPL instance.
```ts
program()
Expand All @@ -381,7 +384,7 @@ program()
#### `program.isRepl()`
Returns `true` if program is running a repl loop, `false` otherwise.
Returns `true` if program is running a REPL loop, `false` otherwise.
#### `program.on(event, listener)`
Expand Down Expand Up @@ -677,6 +680,7 @@ Optionally deploy to GitHub, S3, etc. using your preferred CD method if needed.
## Todo

- [ ] Better code coverage
- [ ] History file cleanup (retain first x lines only)
- [ ] Consider resolving ambiguity in _prompt_ param/method
- [ ] Async autocomplete method
- [ ] Choices autocompletion in REPL mode (open upstream PR in yargs)
Expand Down
2 changes: 1 addition & 1 deletion examples/pretty.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { program, command } from '../src'
import { command, program } from '../src'

const app = program()
.description('JSON pretty printer')
Expand Down
41 changes: 41 additions & 0 deletions examples/repl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { command, program } from '../src'

let url: string | null = null

const app = program()
.add(
command('connect')
.description('Connect to a server')
.argument('host', {
default: 'example.com',
prompt: true,
})
.argument('port', {
default: '443',
type: 'number',
prompt: true,
})
.argument('tls', {
default: true,
type: 'boolean',
prompt: true,
})
.action(async ({ host, port, tls }) => {
url = `${tls ? 'https' : 'http'}://${host}:${port}`
console.log(`Connecting to ${url}...`)
})
)
.add(
command('disconnect')
.description('Disconnect from a server')
.action(async () => {
if (!url) {
throw new Error('Not connected')
}

console.log(`Disconnecting from ${url}...`)
url = null
})
)

app.runOrRepl()
53 changes: 53 additions & 0 deletions src/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fs from 'fs'
import os from 'os'
import { REPLServer } from 'repl'
import { Program } from './program'

/**
* Create new history instance.
*/
export function history(program: Program) {
return new History(program)
}

export class History {
private path: string

constructor(private program: Program) {
this.path = this.program.options.historyFile!

this.program.on('run', (command) => this.push(command))
}

/**
* Add a new entry to the history file.
*/
public push(entry: string | readonly string[]) {
if (Array.isArray(entry)) {
entry = entry.join(' ')
}

fs.appendFileSync(this.path, entry + os.EOL)
}

/**
* Read the history file and hydrate the REPL server history.
*/
public hydrateReplServer(server: REPLServer) {
// @ts-ignore
if (typeof server.history !== 'object') {
return
}

try {
fs.readFileSync(this.path, 'utf-8')
.split(os.EOL)
.reverse()
.filter((line) => line.trim())
// @ts-ignore
.map((line) => server.history.push(line))
} catch (err) {
// Ignore history file read errors
}
}
}
37 changes: 31 additions & 6 deletions src/program.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { EventEmitter } from 'events'
import os from 'os'
import path from 'path'
import TypedEventEmitter from 'typed-emitter'
import { Argv } from 'yargs'
import createYargs from 'yargs/yargs'
import { Arguments, Command, command } from './command'
import { history, History } from './history'
import { Repl, repl } from './repl'
import { isPromise } from './utils'

const DEFAULT_PROMPT = '> '
const DEFAULT_HISTORY_FILE = '.bandersnatch_history'

interface Events {
run: (command: string | readonly string[]) => void
}
Expand Down Expand Up @@ -42,6 +48,13 @@ type ProgramOptions = {
* Defaults to `true`.
*/
version?: boolean

/**
* Use this history file in REPL mode.
*
* Defaults to `{homedir}/.bandersnatch_history`.
*/
historyFile?: string
}

/**
Expand All @@ -55,14 +68,25 @@ function extractCommandFromProcess() {
return process.argv.slice(2)
}

export class Program extends (EventEmitter as new () => TypedEventEmitter<
Events
>) {
export class Program extends (EventEmitter as new () => TypedEventEmitter<Events>) {
private commands: Command<any>[] = []
private history: History
private replInstance?: Repl

constructor(private options: ProgramOptions = {}) {
constructor(public options: ProgramOptions = {}) {
super()

// Set default prompt
if (!this.options.prompt) {
this.options.prompt = DEFAULT_PROMPT
}

// Set default historyFile
if (!this.options.historyFile) {
this.options.historyFile = path.join(os.homedir(), DEFAULT_HISTORY_FILE)
}

this.history = history(this)
}

/**
Expand Down Expand Up @@ -110,7 +134,7 @@ export class Program extends (EventEmitter as new () => TypedEventEmitter<
yargs.fail(this.failHandler.bind(this))

// In case we're in a REPL session, do not exit on errors.
yargs.exitProcess(!this.replInstance)
yargs.exitProcess(!this.isRepl())

// Add commands
this.commands.forEach((command) => {
Expand Down Expand Up @@ -194,7 +218,7 @@ export class Program extends (EventEmitter as new () => TypedEventEmitter<
* Run event loop which reads command from stdin.
*/
public repl() {
this.replInstance = repl(this, this.options.prompt)
this.replInstance = repl(this)

// Add exit command
this.add(
Expand All @@ -205,6 +229,7 @@ export class Program extends (EventEmitter as new () => TypedEventEmitter<
})
)

this.replInstance.attachHistory(this.history)
this.replInstance.start()

return this.replInstance
Expand Down
23 changes: 14 additions & 9 deletions src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,26 @@ import nodeRepl, { REPLServer } from 'repl'
import { parseArgsStringToArgv } from 'string-argv'
import { Context } from 'vm'
import { autocompleter, Autocompleter } from './autocompleter'
import { History } from './history'
import { Program } from './program'

const DEFAULT_PROMPT = '> '

/**
* Create new REPL instance.
*/
export function repl(program: Program, prefix: string = DEFAULT_PROMPT) {
return new Repl(program, prefix)
export function repl(program: Program) {
return new Repl(program)
}

export class Repl {
private server?: REPLServer
private history?: History
private autocompleter: Autocompleter

private successHandler: (value?: unknown) => void = () => {}
private errorHandler: (reason?: any) => void = (reason) =>
console.error(reason)

constructor(
private program: Program,
private prompt: string = DEFAULT_PROMPT
) {
constructor(private program: Program) {
this.autocompleter = autocompleter(program)

// Stop the server to avoid eval'ing stdin from prompts
Expand All @@ -35,6 +32,10 @@ export class Repl {
})
}

attachHistory(history: History) {
this.history = history
}

/**
* Start the REPL server. This method may change at any time, not
* intended for public use.
Expand All @@ -43,12 +44,16 @@ export class Repl {
*/
public async start() {
this.server = nodeRepl.start({
prompt: this.prompt,
prompt: this.program.options.prompt,
eval: this.eval.bind(this),
completer: this.completer.bind(this),
ignoreUndefined: true,
})

// Setup history
this.history?.hydrateReplServer(this.server)


// Fixes bug with hidden cursor after enquirer prompt
// @ts-ignore
new Prompt().cursorShow()
Expand Down
5 changes: 1 addition & 4 deletions tests/program.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@ jest.mock('../src/repl', () => {

// Repl mock
const replStartFn = jest.fn()
const replPauseFn = jest.fn()
const replResumeFn = jest.fn()
class MockedRepl {
start = replStartFn
pause = replPauseFn
resume = replResumeFn
attachHistory = jest.fn()
}

beforeEach(() => {
Expand Down

0 comments on commit 14b2b0d

Please sign in to comment.