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
7 changes: 7 additions & 0 deletions .changeset/ctx-raw-argv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kidd-cli/core": minor
---

feat(core): expose `ctx.raw.argv` — a normalized argv where `argv[0]` is always the CLI name (via yargs `$0`), regardless of invocation mode. Middleware can inspect the full invocation without guessing preamble offsets.

Fixes #146
7 changes: 7 additions & 0 deletions .changeset/dynamic-fullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kidd-cli/core": minor
---

feat(core): allow `screen({ fullscreen })` to accept a resolver function `(ctx: ScreenContext) => boolean | Promise<boolean>` for runtime fullscreen decisions based on args, config, or terminal capabilities.

Fixes #148
7 changes: 7 additions & 0 deletions .changeset/render-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kidd-cli/core": minor
---

feat(core): export `render()` and `renderToString()` helpers that wrap Ink's render methods with kidd's `KiddProvider` (screen context, output store, screen-backed log/spinner/report). Enables full lifecycle control from normal `command()` handlers.

Fixes #147
8 changes: 6 additions & 2 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { P, attemptAsync, err, isNil, isPlainObject, isString, match, ok } from
import type { Result } from '@kidd-cli/utils/fp'
import yargs from 'yargs'
import type { Argv } from 'yargs'
import { hideBin } from 'yargs/helpers'
import { z } from 'zod'

import type { DisplayConfig } from '@/context/types.js'
Expand All @@ -23,7 +24,6 @@ import { isCommandsConfig } from './command.js'
import { createRuntime, registerCommands } from './runtime/index.js'
import type { ErrorRef, ResolvedRef } from './runtime/index.js'

const ARGV_SLICE_START = 2

/**
* Bootstrap and run the CLI application.
Expand All @@ -45,7 +45,8 @@ export async function cli<TSchema extends z.ZodType = z.ZodType>(
return versionError
}

const program = yargs(process.argv.slice(ARGV_SLICE_START))
const rawTokens = hideBin(process.argv)
const program = yargs(rawTokens)
.scriptName(options.name)
.version(version)
.alias('version', 'v')
Expand Down Expand Up @@ -100,7 +101,10 @@ export async function cli<TSchema extends z.ZodType = z.ZodType>(

applyDisplayGlobals(options.display)

const normalizedArgv = [String(argv.$0), ...rawTokens]

const [runtimeError, runtime] = await createRuntime({
argv: normalizedArgv,
config: options.config,
dirs,
display: options.display,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/context/create-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isContextError } from './error.js'

function defaultOptions(): {
args: { name: string; verbose: boolean }
argv: readonly string[]
config: { debug: boolean }
meta: {
command: string[]
Expand All @@ -16,6 +17,7 @@ function defaultOptions(): {
} {
return {
args: { name: 'test', verbose: true },
argv: ['my-cli', 'deploy', 'preview', '--verbose'],
config: { debug: false },
meta: {
command: ['deploy', 'preview'],
Expand Down Expand Up @@ -67,6 +69,38 @@ describe('createContext()', () => {
})
})

// ---------------------------------------------------------------------------
// Raw
// ---------------------------------------------------------------------------

describe('raw', () => {
it('should contain the normalized argv', () => {
const ctx = createContext(defaultOptions())
expect(ctx.raw.argv).toEqual(['my-cli', 'deploy', 'preview', '--verbose'])
})

it('should have argv[0] as the CLI name', () => {
const ctx = createContext(defaultOptions())
expect(ctx.raw.argv[0]).toBe('my-cli')
})

it('should freeze the argv array', () => {
const ctx = createContext(defaultOptions())
expect(Object.isFrozen(ctx.raw.argv)).toBeTruthy()
})

it('should freeze the raw object', () => {
const ctx = createContext(defaultOptions())
expect(Object.isFrozen(ctx.raw)).toBeTruthy()
})

it('should not share references with the input array', () => {
const argv = ['my-cli', 'deploy']
const ctx = createContext({ ...defaultOptions(), argv })
expect(ctx.raw.argv).not.toBe(argv)
})
})

// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/context/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
*/
export interface CreateContextOptions<TArgs extends AnyRecord, TConfig extends AnyRecord> {
readonly args: TArgs
readonly argv: readonly string[]
readonly config: TConfig
readonly meta: {
readonly name: string
Expand Down Expand Up @@ -105,6 +106,7 @@ export function createContext<TArgs extends AnyRecord, TConfig extends AnyRecord
log: ctxLog,
meta: ctxMeta as CommandContext<TArgs, TConfig>['meta'],
prompts: ctxPrompts,
raw: Object.freeze({ argv: Object.freeze([...options.argv]) }),
status: ctxStatus,
store: ctxStore,
} as CommandContext<TArgs, TConfig>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/context/decorate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock(import('@clack/prompts'), async (importOriginal) => ({

function defaultOptions(): {
args: { name: string }
argv: readonly string[]
config: Record<string, never>
meta: {
command: string[]
Expand All @@ -36,6 +37,7 @@ function defaultOptions(): {
} {
return {
args: { name: 'test' },
argv: ['my-cli', 'test'],
config: {},
meta: {
command: ['test'],
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,4 +806,16 @@ export interface CommandContext<
* CLI metadata (name, version, resolved command path). Deeply immutable.
*/
readonly meta: DeepReadonly<Meta>

/**
* Raw invocation data not processed by the arg parser.
*
* `argv` is a normalized token array where `argv[0]` is always the CLI
* name regardless of invocation mode (`node script.js …` vs compiled
* binary). Middleware can inspect the full invocation without guessing
* the preamble offset.
*/
readonly raw: {
readonly argv: readonly string[]
}
}
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { autoload } from './autoload.js'
export { decorateContext } from './context/decorate.js'
export { middleware } from './middleware.js'
export { defineConfig } from '@kidd-cli/config'
export { screen, useScreenContext } from './screen/index.js'
export { render, renderToString, screen, useScreenContext } from './screen/index.js'
export type { ScreenDef, ScreenExit } from './screen/index.js'
export type {
CliConfig,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/middleware/auth/auth-http-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ vi.mock(import('@clack/prompts'), async (importOriginal) => ({
function createTestContext(cliName: string): ReturnType<typeof createContext> {
return createContext({
args: {},
argv: [cliName, 'test'],
config: {},
meta: {
command: ['test'],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/middleware/typed-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface Organization {
function createTestContext(): CommandContext {
return createContext({
args: {},
argv: ['test-app', 'test'],
config: {},
meta: {
command: ['test'],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function createRuntime<TSchema extends z.ZodType>(

const ctx = createContext({
args: validatedArgs,
argv: options.argv,
config,
display: options.display,
log: options.log,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
export interface RuntimeOptions<TSchema extends z.ZodType = z.ZodType> {
readonly name: string
readonly version: string
readonly argv: readonly string[]
readonly dirs: ResolvedDirs
readonly config?: CliConfigOptions<TSchema>
readonly middleware?: Middleware[]
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/screen/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { screen } from './screen.js'
export { render, renderToString } from './render.js'
export { screen, toScreenContext } from './screen.js'
export type { ScreenDef, ScreenExit } from './screen.js'

export { useScreenContext } from './provider.js'
135 changes: 135 additions & 0 deletions packages/core/src/screen/render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ReactElement } from 'react'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'

import type { CommandContext, ScreenContext, Store } from '../context/types.js'

vi.mock(import('ink'), () => ({
render: vi.fn<() => unknown>(() => ({
cleanup: vi.fn<() => void>(),
clear: vi.fn<() => void>(),
rerender: vi.fn<() => void>(),
unmount: vi.fn<() => void>(),
waitUntilExit: vi.fn<() => Promise<undefined>>().mockResolvedValue(undefined),
})),
renderToString: vi.fn<() => string>(() => 'rendered-output'),
}))

const ink = await import('ink')
const mockedInkRender = vi.mocked(ink.render)
const mockedInkRenderToString = vi.mocked(ink.renderToString)

function StubComponent(): null {
return null
}

function makeStore(): Store {
const map = new Map<string, unknown>()
return {
clear: () => map.clear(),
delete: (key: string) => map.delete(key),
get: (key: string) => map.get(key),
has: (key: string) => map.has(key),
set: (key: string, value: unknown) => map.set(key, value),
} as Store
}

const baseMeta = Object.freeze({
command: ['test'] as readonly string[],
dirs: Object.freeze({ global: '.cli', local: '.cli' }),
name: 'test-cli',
version: '1.0.0',
})

function makeContext(overrides?: Partial<CommandContext>): CommandContext {
return {
args: {},
colors: {} as CommandContext['colors'],
config: {},
fail: () => {
throw new Error('fail')
},
format: {} as CommandContext['format'],
log: {} as CommandContext['log'],
meta: baseMeta,
prompts: {} as CommandContext['prompts'],
raw: Object.freeze({ argv: Object.freeze(['test-cli', 'test']) }),
status: {} as CommandContext['status'],
store: makeStore(),
...overrides,
} as CommandContext
}

describe('render() helper', () => {
it('should call ink render and return an instance', async () => {
vi.clearAllMocks()
const { render } = await import('./render.js')
const instance = await render(<StubComponent />, makeContext())

expect(mockedInkRender).toHaveBeenCalledOnce()
expect(instance).toHaveProperty('waitUntilExit')
expect(instance).toHaveProperty('unmount')
})

it('should wrap node in KiddProvider with ScreenContext', async () => {
vi.clearAllMocks()
const { render } = await import('./render.js')
const ctx = makeContext({ config: { debug: true } })

await render(<StubComponent />, ctx)

const [firstCall] = mockedInkRender.mock.calls
const [rendered] = firstCall as [ReactElement]
const providerValue = rendered.props.value as ScreenContext
expect(providerValue.config).toStrictEqual({ debug: true })
expect(providerValue).toHaveProperty('log')
expect(providerValue).not.toHaveProperty('fail')
expect(providerValue).not.toHaveProperty('prompts')
})

it('should pass render options through to ink', async () => {
vi.clearAllMocks()
const { render } = await import('./render.js')
const options = { debug: true }

await render(<StubComponent />, makeContext(), options)

expect(mockedInkRender).toHaveBeenCalledWith(expect.anything(), options)
})
})

describe('renderToString() helper', () => {
it('should call ink renderToString and return a string', async () => {
vi.clearAllMocks()
const { renderToString } = await import('./render.js')
const result = await renderToString(<StubComponent />, makeContext())

expect(mockedInkRenderToString).toHaveBeenCalledOnce()
expect(result).toBe('rendered-output')
})

it('should wrap node in KiddProvider with ScreenContext', async () => {
vi.clearAllMocks()
const { renderToString } = await import('./render.js')
const ctx = makeContext({ config: { theme: 'dark' } })

await renderToString(<StubComponent />, ctx)

const [firstCall] = mockedInkRenderToString.mock.calls
const [rendered] = firstCall as [ReactElement]
const providerValue = rendered.props.value as ScreenContext
expect(providerValue.config).toStrictEqual({ theme: 'dark' })
expect(providerValue).not.toHaveProperty('colors')
expect(providerValue).not.toHaveProperty('format')
})

it('should pass options through to ink renderToString', async () => {
vi.clearAllMocks()
const { renderToString } = await import('./render.js')
const options = { columns: 120 }

await renderToString(<StubComponent />, makeContext(), options)

expect(mockedInkRenderToString).toHaveBeenCalledWith(expect.anything(), options)
})
})
Loading
Loading