@thegetty/quire-cli 1.0.0-rc.38 → 1.0.0-rc.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thegetty/quire-cli",
3
3
  "description": "Quire command-line interface",
4
- "version": "1.0.0-rc.38",
4
+ "version": "1.0.0-rc.39",
5
5
  "author": "Getty Digital",
6
6
  "license": "SEE LICENSE IN https://github.com/thegetty/quire/blob/main/LICENSE",
7
7
  "bugs": {
@@ -70,6 +70,8 @@
70
70
  "inquirer": "^9.1.4",
71
71
  "js-yaml": "^4.1.0",
72
72
  "loglevel": "^1.8.1",
73
+ "marked": "^15.0.12",
74
+ "marked-terminal": "^7.3.0",
73
75
  "node-fetch": "^3.3.2",
74
76
  "open": "^8.4.0",
75
77
  "ora": "^6.1.2",
@@ -0,0 +1,60 @@
1
+ import Command from '#src/Command.js'
2
+ import { outputWithPaging } from '#helpers/pager.js'
3
+ import { getTopicContent, getTopicList } from '#lib/help/index.js'
4
+
5
+ /**
6
+ * Quire CLI `help` Command
7
+ *
8
+ * Display help for a specific topic or list available topics.
9
+ * Complements Commander's built-in help with extended documentation.
10
+ *
11
+ * @example
12
+ * quire help # List available topics
13
+ * quire help workflows # Show workflows topic
14
+ * quire help --list # List available topics
15
+ *
16
+ * @class HelpCommand
17
+ * @extends {Command}
18
+ */
19
+ export default class HelpCommand extends Command {
20
+ static definition = {
21
+ name: 'help',
22
+ aliases: ['h'],
23
+ description: 'Display help for a topic or list available topics',
24
+ summary: 'show help for a topic',
25
+ docsLink: 'quire-commands/',
26
+ helpText: `
27
+ Examples:
28
+ quire help List available help topics
29
+ quire help workflows Show common workflow examples
30
+ quire help pdf Show PDF generation guide
31
+ quire help --list List all available topics
32
+ `,
33
+ version: '1.0.0',
34
+ args: [
35
+ ['[topic]', 'help topic to display']
36
+ ],
37
+ options: [
38
+ ['--list', 'list all available topics'],
39
+ ],
40
+ }
41
+
42
+ constructor() {
43
+ super(HelpCommand.definition)
44
+ }
45
+
46
+ async action(topic, options, command) {
47
+ this.debug('called with topic=%s options=%O', topic, options)
48
+
49
+ // List topics if no topic specified or --list flag
50
+ if (!topic || options.list) {
51
+ const list = await getTopicList()
52
+ process.stdout.write(list + '\n')
53
+ return
54
+ }
55
+
56
+ // Display requested topic with paging (throws HelpTopicNotFoundError if missing)
57
+ const content = await getTopicContent(topic)
58
+ await outputWithPaging(content)
59
+ }
60
+ }
@@ -0,0 +1,132 @@
1
+ import esmock from 'esmock'
2
+ import sinon from 'sinon'
3
+ import test from 'ava'
4
+
5
+ /**
6
+ * Help Command Integration Tests
7
+ *
8
+ * Tests the help command behavior with mocked dependencies.
9
+ */
10
+
11
+ test.serial('help command outputs topic list when called without arguments', async (t) => {
12
+ const sandbox = sinon.createSandbox()
13
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
14
+
15
+ try {
16
+ const mockList = 'Available help topics:\n debugging Troubleshooting help'
17
+
18
+ const HelpCommand = await esmock('./help.js', {
19
+ '#lib/help/index.js': {
20
+ getTopicList: sandbox.stub().resolves(mockList),
21
+ getTopicContent: sandbox.stub()
22
+ },
23
+ '#helpers/pager.js': {
24
+ outputWithPaging: sandbox.stub()
25
+ }
26
+ })
27
+
28
+ const command = new HelpCommand.default()
29
+ await command.action(undefined, {}, {})
30
+
31
+ t.true(stdoutWrite.calledOnce)
32
+ t.true(stdoutWrite.calledWith(mockList + '\n'))
33
+ } finally {
34
+ sandbox.restore()
35
+ }
36
+ })
37
+
38
+ test.serial('help command outputs topic list when --list flag is provided', async (t) => {
39
+ const sandbox = sinon.createSandbox()
40
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
41
+
42
+ try {
43
+ const mockList = 'Available help topics:\n pdf PDF generation'
44
+
45
+ const HelpCommand = await esmock('./help.js', {
46
+ '#lib/help/index.js': {
47
+ getTopicList: sandbox.stub().resolves(mockList),
48
+ getTopicContent: sandbox.stub()
49
+ },
50
+ '#helpers/pager.js': {
51
+ outputWithPaging: sandbox.stub()
52
+ }
53
+ })
54
+
55
+ const command = new HelpCommand.default()
56
+ await command.action('ignored', { list: true }, {})
57
+
58
+ t.true(stdoutWrite.calledOnce)
59
+ t.true(stdoutWrite.calledWith(mockList + '\n'))
60
+ } finally {
61
+ sandbox.restore()
62
+ }
63
+ })
64
+
65
+ test('help command outputs topic content with paging when topic is specified', async (t) => {
66
+ const sandbox = sinon.createSandbox()
67
+
68
+ try {
69
+ const mockRendered = 'Rendered workflows content'
70
+ const mockOutputWithPaging = sandbox.stub().resolves()
71
+
72
+ const HelpCommand = await esmock('./help.js', {
73
+ '#lib/help/index.js': {
74
+ getTopicList: sandbox.stub(),
75
+ getTopicContent: sandbox.stub().resolves(mockRendered)
76
+ },
77
+ '#helpers/pager.js': {
78
+ outputWithPaging: mockOutputWithPaging
79
+ }
80
+ })
81
+
82
+ const command = new HelpCommand.default()
83
+ await command.action('workflows', {}, {})
84
+
85
+ t.true(mockOutputWithPaging.calledOnce)
86
+ t.true(mockOutputWithPaging.calledWith(mockRendered))
87
+ } finally {
88
+ sandbox.restore()
89
+ }
90
+ })
91
+
92
+ test('help command propagates HelpTopicNotFoundError from getTopicContent', async (t) => {
93
+ const sandbox = sinon.createSandbox()
94
+
95
+ try {
96
+ const { default: HelpTopicNotFoundError } = await import('#src/errors/help/help-topic-not-found-error.js')
97
+
98
+ const HelpCommand = await esmock('./help.js', {
99
+ '#lib/help/index.js': {
100
+ getTopicList: sandbox.stub(),
101
+ getTopicContent: sandbox.stub().rejects(new HelpTopicNotFoundError('nonexistent'))
102
+ },
103
+ '#helpers/pager.js': {
104
+ outputWithPaging: sandbox.stub()
105
+ }
106
+ })
107
+
108
+ const command = new HelpCommand.default()
109
+
110
+ const error = await t.throwsAsync(
111
+ () => command.action('nonexistent', {}, {}),
112
+ { name: 'HelpTopicNotFoundError' }
113
+ )
114
+
115
+ t.is(error.code, 'HELP_TOPIC_NOT_FOUND')
116
+ t.is(error.exitCode, 2)
117
+ t.is(error.topic, 'nonexistent')
118
+ } finally {
119
+ sandbox.restore()
120
+ }
121
+ })
122
+
123
+ test('help command has correct definition', async (t) => {
124
+ const HelpCommand = await import('./help.js')
125
+ const command = new HelpCommand.default()
126
+
127
+ t.is(command.name, 'help')
128
+ t.deepEqual(command.aliases, ['h'])
129
+ t.truthy(command.description)
130
+ t.truthy(command.args)
131
+ t.truthy(command.options)
132
+ })
@@ -0,0 +1,22 @@
1
+ import InvalidInputError from '../input/invalid-input-error.js'
2
+ import QuireError from '../quire-error.js'
3
+
4
+ /**
5
+ * Error thrown when a requested help topic does not exist
6
+ */
7
+ export default class HelpTopicNotFoundError extends InvalidInputError {
8
+ constructor(topic) {
9
+ super(
10
+ `Unknown help topic: ${topic}`,
11
+ {
12
+ code: 'HELP_TOPIC_NOT_FOUND',
13
+ exitCode: 2,
14
+ inputValue: topic,
15
+ inputName: 'topic',
16
+ suggestion: 'Run "quire help --list" to see available topics',
17
+ docsUrl: `${QuireError.DOCS_BASE}/quire-commands/`
18
+ }
19
+ )
20
+ this.topic = topic
21
+ }
22
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Help errors (exit code: 2)
3
+ *
4
+ * Errors related to the help system and topic lookup.
5
+ *
6
+ * @module errors/help
7
+ */
8
+ export { default as HelpTopicNotFoundError } from './help-topic-not-found-error.js'
@@ -46,3 +46,9 @@ export {
46
46
 
47
47
  // Validation errors (exit code: 4)
48
48
  export { default as ValidationError } from './validation/validation-error.js'
49
+
50
+ // Input errors (exit code: 2)
51
+ export { InvalidInputError } from './input/index.js'
52
+
53
+ // Help errors (exit code: 2)
54
+ export { HelpTopicNotFoundError } from './help/index.js'
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Input errors (exit code: 2)
3
+ *
4
+ * Errors related to invalid user input (arguments, options, values).
5
+ *
6
+ * @module errors/input
7
+ */
8
+ export { default as InvalidInputError } from './invalid-input-error.js'
@@ -0,0 +1,21 @@
1
+ import QuireError from '../quire-error.js'
2
+
3
+ /**
4
+ * Error thrown when user input is invalid
5
+ *
6
+ * Generic error for invalid arguments, options, or other user-provided values.
7
+ * Can be extended for specific input validation scenarios.
8
+ */
9
+ export default class InvalidInputError extends QuireError {
10
+ constructor(message, options = {}) {
11
+ super(message, {
12
+ code: options.code || 'INVALID_INPUT',
13
+ exitCode: options.exitCode ?? 2,
14
+ suggestion: options.suggestion,
15
+ docsUrl: options.docsUrl,
16
+ ...options
17
+ })
18
+ this.inputValue = options.inputValue
19
+ this.inputName = options.inputName
20
+ }
21
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Terminal pager utility
3
+ *
4
+ * Provides paged output for long content in interactive terminals.
5
+ * Paging can be disabled with:
6
+ * - `--no-pager` CLI flag (sets NO_PAGER env var)
7
+ * - `NO_PAGER=1` environment variable
8
+ * - `PAGER=cat` traditional Unix convention (content passes through without paging)
9
+ *
10
+ * @module helpers/pager
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+
14
+ /**
15
+ * Output content with optional paging for long content
16
+ *
17
+ * Uses system pager (less/more) when:
18
+ * - Output is to an interactive terminal (TTY)
19
+ * - Content exceeds terminal height
20
+ * - Paging is not disabled via NO_PAGER env var
21
+ *
22
+ * The PAGER env var selects the pager program (default: less on Unix, more on
23
+ * Windows). Setting PAGER=cat effectively disables paging as a traditional
24
+ * Unix convention.
25
+ *
26
+ * @param {string} content - Content to output
27
+ * @returns {Promise<void>}
28
+ */
29
+ export async function outputWithPaging(content) {
30
+ const noPager = process.env.NO_PAGER
31
+ const lines = content.split('\n').length
32
+ const isTTY = process.stdout.isTTY
33
+ const terminalRows = process.stdout.rows || 24
34
+
35
+ // Use pager if content exceeds terminal height and we're in a TTY
36
+ if (!noPager && isTTY && lines > terminalRows) {
37
+ const pager = process.env.PAGER || (process.platform === 'win32' ? 'more' : 'less')
38
+ const pagerArgs = pager === 'less' ? ['-R'] : [] // -R preserves ANSI colors
39
+
40
+ return new Promise((resolve) => {
41
+ const child = spawn(pager, pagerArgs, {
42
+ stdio: ['pipe', 'inherit', 'inherit']
43
+ })
44
+
45
+ child.stdin.write(content + '\n')
46
+ child.stdin.end()
47
+
48
+ child.on('close', resolve)
49
+ child.on('error', () => {
50
+ // Fallback to direct output if pager fails
51
+ process.stdout.write(content + '\n')
52
+ resolve()
53
+ })
54
+ })
55
+ }
56
+
57
+ // Direct output for non-TTY or short content
58
+ process.stdout.write(content + '\n')
59
+ }
@@ -0,0 +1,85 @@
1
+ import sinon from 'sinon'
2
+ import test from 'ava'
3
+
4
+ /**
5
+ * Pager Helper Tests
6
+ */
7
+
8
+ test.beforeEach((t) => {
9
+ t.context.sandbox = sinon.createSandbox()
10
+ })
11
+
12
+ test.afterEach.always((t) => {
13
+ t.context.sandbox.restore()
14
+ })
15
+
16
+ test.serial('outputWithPaging() writes directly when not a TTY', async (t) => {
17
+ const { sandbox } = t.context
18
+ const originalIsTTY = process.stdout.isTTY
19
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
20
+
21
+ try {
22
+ // Simulate non-TTY (piped output)
23
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true })
24
+
25
+ const { outputWithPaging } = await import('./pager.js')
26
+ await outputWithPaging('Short content')
27
+
28
+ t.true(stdoutWrite.calledWith('Short content\n'))
29
+ } finally {
30
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
31
+ }
32
+ })
33
+
34
+ test.serial('outputWithPaging() writes directly when content is short', async (t) => {
35
+ const { sandbox } = t.context
36
+ const originalIsTTY = process.stdout.isTTY
37
+ const originalRows = process.stdout.rows
38
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
39
+
40
+ try {
41
+ // Simulate TTY with 24 rows
42
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
43
+ Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true })
44
+
45
+ const { outputWithPaging } = await import('./pager.js')
46
+ await outputWithPaging('Line 1\nLine 2\nLine 3')
47
+
48
+ t.true(stdoutWrite.calledWith('Line 1\nLine 2\nLine 3\n'))
49
+ } finally {
50
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
51
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true })
52
+ }
53
+ })
54
+
55
+ test.serial('outputWithPaging() writes directly when NO_PAGER is set', async (t) => {
56
+ const { sandbox } = t.context
57
+ const originalIsTTY = process.stdout.isTTY
58
+ const originalRows = process.stdout.rows
59
+ const originalNoPager = process.env.NO_PAGER
60
+ const stdoutWrite = sandbox.stub(process.stdout, 'write')
61
+
62
+ try {
63
+ // Simulate TTY with small terminal (would normally trigger pager)
64
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
65
+ Object.defineProperty(process.stdout, 'rows', { value: 5, configurable: true })
66
+ process.env.NO_PAGER = '1'
67
+
68
+ const { outputWithPaging } = await import('./pager.js')
69
+
70
+ // Content longer than terminal height
71
+ const longContent = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`).join('\n')
72
+ await outputWithPaging(longContent)
73
+
74
+ // Should write directly despite long content, because pager is disabled
75
+ t.true(stdoutWrite.calledWith(longContent + '\n'))
76
+ } finally {
77
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
78
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true })
79
+ if (originalNoPager === undefined) {
80
+ delete process.env.NO_PAGER
81
+ } else {
82
+ process.env.NO_PAGER = originalNoPager
83
+ }
84
+ }
85
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * YAML frontmatter parsing utilities
3
+ *
4
+ * Extracts and strips YAML frontmatter from markdown content.
5
+ * Frontmatter is delimited by `---` on its own line.
6
+ *
7
+ * @module lib/help/frontmatter
8
+ */
9
+ import yaml from 'js-yaml'
10
+
11
+ /** Regex to match YAML frontmatter block */
12
+ const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---/
13
+
14
+ /**
15
+ * Extract YAML frontmatter from markdown content
16
+ *
17
+ * @param {string} content - Markdown content with optional frontmatter
18
+ * @returns {Object} Parsed frontmatter data or empty object if none/invalid
19
+ *
20
+ * @example
21
+ * const meta = extractFrontmatter(`---
22
+ * title: My Topic
23
+ * description: A description
24
+ * ---
25
+ * # Content`)
26
+ * // => { title: 'My Topic', description: 'A description' }
27
+ */
28
+ export function extractFrontmatter(content) {
29
+ const match = content.match(FRONTMATTER_REGEX)
30
+ if (!match) return {}
31
+
32
+ try {
33
+ return yaml.load(match[1]) || {}
34
+ } catch {
35
+ return {}
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Strip YAML frontmatter from markdown content
41
+ *
42
+ * @param {string} content - Markdown content with optional frontmatter
43
+ * @returns {string} Content without frontmatter block
44
+ *
45
+ * @example
46
+ * const body = stripFrontmatter(`---
47
+ * title: My Topic
48
+ * ---
49
+ * # Content`)
50
+ * // => '# Content'
51
+ */
52
+ export function stripFrontmatter(content) {
53
+ return content.replace(/^---\n[\s\S]*?\n---\n?/, '')
54
+ }
55
+
56
+ /**
57
+ * Parse markdown content with frontmatter
58
+ *
59
+ * Convenience function that returns both metadata and content body.
60
+ *
61
+ * @param {string} content - Markdown content with optional frontmatter
62
+ * @returns {{ data: Object, content: string }} Parsed frontmatter and body
63
+ *
64
+ * @example
65
+ * const { data, content } = parseFrontmatter(`---
66
+ * title: My Topic
67
+ * ---
68
+ * # Content`)
69
+ * // data => { title: 'My Topic' }
70
+ * // content => '# Content'
71
+ */
72
+ export function parseFrontmatter(content) {
73
+ return {
74
+ data: extractFrontmatter(content),
75
+ content: stripFrontmatter(content)
76
+ }
77
+ }
@@ -0,0 +1,139 @@
1
+ import test from 'ava'
2
+
3
+ import {
4
+ extractFrontmatter,
5
+ stripFrontmatter,
6
+ parseFrontmatter
7
+ } from './frontmatter.js'
8
+
9
+ /**
10
+ * Frontmatter Parsing Tests
11
+ */
12
+
13
+ // extractFrontmatter tests
14
+
15
+ test('extractFrontmatter() returns parsed YAML data', (t) => {
16
+ const content = `---
17
+ title: Test Topic
18
+ description: A test description
19
+ ---
20
+ # Content`
21
+
22
+ const result = extractFrontmatter(content)
23
+
24
+ t.deepEqual(result, {
25
+ title: 'Test Topic',
26
+ description: 'A test description'
27
+ })
28
+ })
29
+
30
+ test('extractFrontmatter() returns empty object when no frontmatter', (t) => {
31
+ const content = '# Just Content\n\nNo frontmatter here.'
32
+
33
+ const result = extractFrontmatter(content)
34
+
35
+ t.deepEqual(result, {})
36
+ })
37
+
38
+ test('extractFrontmatter() returns empty object for invalid YAML', (t) => {
39
+ const content = `---
40
+ title: [invalid yaml
41
+ ---
42
+ # Content`
43
+
44
+ const result = extractFrontmatter(content)
45
+
46
+ t.deepEqual(result, {})
47
+ })
48
+
49
+ test('extractFrontmatter() handles values containing colons', (t) => {
50
+ const content = `---
51
+ url: https://example.com/path
52
+ title: "Part 1: Introduction"
53
+ ---
54
+ # Content`
55
+
56
+ const result = extractFrontmatter(content)
57
+
58
+ t.is(result.url, 'https://example.com/path')
59
+ t.is(result.title, 'Part 1: Introduction')
60
+ })
61
+
62
+ test('extractFrontmatter() handles arrays and nested objects', (t) => {
63
+ const content = `---
64
+ tags:
65
+ - one
66
+ - two
67
+ author:
68
+ name: Test
69
+ email: test@example.com
70
+ ---
71
+ # Content`
72
+
73
+ const result = extractFrontmatter(content)
74
+
75
+ t.deepEqual(result.tags, ['one', 'two'])
76
+ t.deepEqual(result.author, { name: 'Test', email: 'test@example.com' })
77
+ })
78
+
79
+ // stripFrontmatter tests
80
+
81
+ test('stripFrontmatter() removes frontmatter block', (t) => {
82
+ const content = `---
83
+ title: Test
84
+ ---
85
+ # Content
86
+
87
+ Body text.`
88
+
89
+ const result = stripFrontmatter(content)
90
+
91
+ t.is(result, '# Content\n\nBody text.')
92
+ })
93
+
94
+ test('stripFrontmatter() returns content unchanged when no frontmatter', (t) => {
95
+ const content = '# Just Content\n\nNo frontmatter.'
96
+
97
+ const result = stripFrontmatter(content)
98
+
99
+ t.is(result, content)
100
+ })
101
+
102
+ test('stripFrontmatter() handles frontmatter without trailing newline', (t) => {
103
+ const content = `---
104
+ title: Test
105
+ ---# Content`
106
+
107
+ const result = stripFrontmatter(content)
108
+
109
+ t.is(result, '# Content')
110
+ })
111
+
112
+ // parseFrontmatter tests
113
+
114
+ test('parseFrontmatter() returns both data and content', (t) => {
115
+ const content = `---
116
+ title: Test Topic
117
+ description: A description
118
+ ---
119
+ # Heading
120
+
121
+ Body content.`
122
+
123
+ const result = parseFrontmatter(content)
124
+
125
+ t.deepEqual(result.data, {
126
+ title: 'Test Topic',
127
+ description: 'A description'
128
+ })
129
+ t.is(result.content, '# Heading\n\nBody content.')
130
+ })
131
+
132
+ test('parseFrontmatter() returns empty data when no frontmatter', (t) => {
133
+ const content = '# Just Content'
134
+
135
+ const result = parseFrontmatter(content)
136
+
137
+ t.deepEqual(result.data, {})
138
+ t.is(result.content, '# Just Content')
139
+ })