diff --git a/.changeset/ctx-raw-argv.md b/.changeset/ctx-raw-argv.md new file mode 100644 index 00000000..9695d787 --- /dev/null +++ b/.changeset/ctx-raw-argv.md @@ -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 diff --git a/.changeset/dynamic-fullscreen.md b/.changeset/dynamic-fullscreen.md new file mode 100644 index 00000000..f395aed2 --- /dev/null +++ b/.changeset/dynamic-fullscreen.md @@ -0,0 +1,7 @@ +--- +"@kidd-cli/core": minor +--- + +feat(core): allow `screen({ fullscreen })` to accept a resolver function `(ctx: ScreenContext) => boolean | Promise` for runtime fullscreen decisions based on args, config, or terminal capabilities. + +Fixes #148 diff --git a/.changeset/render-helpers.md b/.changeset/render-helpers.md new file mode 100644 index 00000000..ac9601a0 --- /dev/null +++ b/.changeset/render-helpers.md @@ -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 diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 0577fba2..38f5eed7 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -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' @@ -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. @@ -45,7 +45,8 @@ export async function cli( 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') @@ -100,7 +101,10 @@ export async function cli( applyDisplayGlobals(options.display) + const normalizedArgv = [String(argv.$0), ...rawTokens] + const [runtimeError, runtime] = await createRuntime({ + argv: normalizedArgv, config: options.config, dirs, display: options.display, diff --git a/packages/core/src/context/create-context.test.ts b/packages/core/src/context/create-context.test.ts index ca3077a4..6c2ee846 100644 --- a/packages/core/src/context/create-context.test.ts +++ b/packages/core/src/context/create-context.test.ts @@ -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[] @@ -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'], @@ -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 // --------------------------------------------------------------------------- diff --git a/packages/core/src/context/create-context.ts b/packages/core/src/context/create-context.ts index 48be5c59..54a68fed 100644 --- a/packages/core/src/context/create-context.ts +++ b/packages/core/src/context/create-context.ts @@ -32,6 +32,7 @@ import type { */ export interface CreateContextOptions { readonly args: TArgs + readonly argv: readonly string[] readonly config: TConfig readonly meta: { readonly name: string @@ -105,6 +106,7 @@ export function createContext['meta'], prompts: ctxPrompts, + raw: Object.freeze({ argv: Object.freeze([...options.argv]) }), status: ctxStatus, store: ctxStore, } as CommandContext diff --git a/packages/core/src/context/decorate.test.ts b/packages/core/src/context/decorate.test.ts index 611a7f7c..ec984439 100644 --- a/packages/core/src/context/decorate.test.ts +++ b/packages/core/src/context/decorate.test.ts @@ -26,6 +26,7 @@ vi.mock(import('@clack/prompts'), async (importOriginal) => ({ function defaultOptions(): { args: { name: string } + argv: readonly string[] config: Record meta: { command: string[] @@ -36,6 +37,7 @@ function defaultOptions(): { } { return { args: { name: 'test' }, + argv: ['my-cli', 'test'], config: {}, meta: { command: ['test'], diff --git a/packages/core/src/context/types.ts b/packages/core/src/context/types.ts index fd488cff..404c7561 100644 --- a/packages/core/src/context/types.ts +++ b/packages/core/src/context/types.ts @@ -806,4 +806,16 @@ export interface CommandContext< * CLI metadata (name, version, resolved command path). Deeply immutable. */ readonly meta: DeepReadonly + + /** + * 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[] + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a280733e..6f103d6d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/middleware/auth/auth-http-chain.test.ts b/packages/core/src/middleware/auth/auth-http-chain.test.ts index 7746689a..98219602 100644 --- a/packages/core/src/middleware/auth/auth-http-chain.test.ts +++ b/packages/core/src/middleware/auth/auth-http-chain.test.ts @@ -38,6 +38,7 @@ vi.mock(import('@clack/prompts'), async (importOriginal) => ({ function createTestContext(cliName: string): ReturnType { return createContext({ args: {}, + argv: [cliName, 'test'], config: {}, meta: { command: ['test'], diff --git a/packages/core/src/middleware/typed-middleware.test.ts b/packages/core/src/middleware/typed-middleware.test.ts index 6d34c4c1..17e19b73 100644 --- a/packages/core/src/middleware/typed-middleware.test.ts +++ b/packages/core/src/middleware/typed-middleware.test.ts @@ -58,6 +58,7 @@ interface Organization { function createTestContext(): CommandContext { return createContext({ args: {}, + argv: ['test-app', 'test'], config: {}, meta: { command: ['test'], diff --git a/packages/core/src/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts index 2649b0a5..cc9f26cb 100644 --- a/packages/core/src/runtime/runtime.ts +++ b/packages/core/src/runtime/runtime.ts @@ -41,6 +41,7 @@ export async function createRuntime( const ctx = createContext({ args: validatedArgs, + argv: options.argv, config, display: options.display, log: options.log, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 8cb3289d..075dbe92 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -16,6 +16,7 @@ import type { export interface RuntimeOptions { readonly name: string readonly version: string + readonly argv: readonly string[] readonly dirs: ResolvedDirs readonly config?: CliConfigOptions readonly middleware?: Middleware[] diff --git a/packages/core/src/screen/index.ts b/packages/core/src/screen/index.ts index 7469f4ee..234014ab 100644 --- a/packages/core/src/screen/index.ts +++ b/packages/core/src/screen/index.ts @@ -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' diff --git a/packages/core/src/screen/render.test.tsx b/packages/core/src/screen/render.test.tsx new file mode 100644 index 00000000..851cb810 --- /dev/null +++ b/packages/core/src/screen/render.test.tsx @@ -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>().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() + 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 { + 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(, 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(, 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(, 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(, 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(, 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(, makeContext(), options) + + expect(mockedInkRenderToString).toHaveBeenCalledWith(expect.anything(), options) + }) +}) diff --git a/packages/core/src/screen/render.tsx b/packages/core/src/screen/render.tsx new file mode 100644 index 00000000..2dbf8831 --- /dev/null +++ b/packages/core/src/screen/render.tsx @@ -0,0 +1,55 @@ +import type { Instance, RenderOptions, RenderToStringOptions } from 'ink' +import type { ReactNode } from 'react' +import React from 'react' + +import type { CommandContext } from '../context/types.js' +import { KiddProvider } from './provider.js' +import { toScreenContext } from './screen.js' + +/** + * Render a React element as a live Ink terminal application with the kidd + * screen context injected. + * + * Wraps the provided node in a {@link KiddProvider} that supplies the + * screen context (React-backed `log`, `spinner`, `store`, and any + * middleware-decorated properties). Returns the raw Ink {@link Instance} + * so callers have full lifecycle control — `waitUntilExit()`, `unmount()`, + * `rerender()`, etc. + * + * @param node - The React element to render. + * @param ctx - The command context to convert and inject as screen context. + * @param options - Optional Ink render options (stdout, stdin, debug, etc.). + * @returns The Ink render instance. + */ +export async function render( + node: ReactNode, + ctx: CommandContext, + options?: RenderOptions +): Promise { + const { render: inkRender } = await import('ink') + const screenCtx = toScreenContext(ctx) + return inkRender({node}, options) +} + +/** + * Render a React element to a string with the kidd screen context injected. + * + * Wraps the provided node in a {@link KiddProvider} and delegates to Ink's + * `renderToString`. Useful for generating documentation, writing output to + * files, testing, or scenarios where rendered output is needed as a string + * without a persistent terminal session. + * + * @param node - The React element to render. + * @param ctx - The command context to convert and inject as screen context. + * @param options - Optional render-to-string options (columns width). + * @returns The rendered string output. + */ +export async function renderToString( + node: ReactNode, + ctx: CommandContext, + options?: RenderToStringOptions +): Promise { + const { renderToString: inkRenderToString } = await import('ink') + const screenCtx = toScreenContext(ctx) + return inkRenderToString({node}, options) +} diff --git a/packages/core/src/screen/screen.test.ts b/packages/core/src/screen/screen.test.ts index de8e7dbd..de9c1ed9 100644 --- a/packages/core/src/screen/screen.test.ts +++ b/packages/core/src/screen/screen.test.ts @@ -48,6 +48,7 @@ function makeContext(overrides?: Partial): CommandContext { 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, @@ -311,6 +312,90 @@ describe('screen() render function', () => { expect(providerValue).not.toHaveProperty('report') }) + it('should resolve fullscreen from a sync function', async () => { + mockedInkRender.mockReturnValue({ + unmount: vi.fn(), + waitUntilExit: vi.fn().mockResolvedValue(undefined), + } as never) + + const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + + const { screen } = await import('./screen.js') + const cmd = screen({ + fullscreen: () => true, + render: StubComponent, + }) + + await cmd.render!(makeContext()) + + expect(writeSpy).toHaveBeenCalledWith('\u001B[?1049l') + writeSpy.mockRestore() + }) + + it('should resolve fullscreen from an async function', async () => { + mockedInkRender.mockReturnValue({ + unmount: vi.fn(), + waitUntilExit: vi.fn().mockResolvedValue(undefined), + } as never) + + const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + + const { screen } = await import('./screen.js') + const cmd = screen({ + fullscreen: async () => true, + render: StubComponent, + }) + + await cmd.render!(makeContext()) + + expect(writeSpy).toHaveBeenCalledWith('\u001B[?1049l') + writeSpy.mockRestore() + }) + + it('should not enter fullscreen when resolver returns false', async () => { + mockedInkRender.mockReturnValue({ + unmount: vi.fn(), + waitUntilExit: vi.fn().mockResolvedValue(undefined), + } as never) + + const writeSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + + const { screen } = await import('./screen.js') + const cmd = screen({ + fullscreen: () => false, + render: StubComponent, + }) + + await cmd.render!(makeContext()) + + expect(writeSpy).not.toHaveBeenCalledWith('\u001B[?1049l') + writeSpy.mockRestore() + }) + + it('should pass ScreenContext to fullscreen resolver', async () => { + mockedInkRender.mockReturnValue({ + unmount: vi.fn(), + waitUntilExit: vi.fn().mockResolvedValue(undefined), + } as never) + + const resolver = vi.fn().mockReturnValue(false) + + const { screen } = await import('./screen.js') + const cmd = screen({ + fullscreen: resolver, + render: StubComponent, + }) + + const ctx = makeContext({ args: { compact: true } }) + await cmd.render!(ctx) + + expect(resolver).toHaveBeenCalledOnce() + const receivedCtx = resolver.mock.calls[0]![0] as ScreenContext + expect(receivedCtx.args).toMatchObject({ compact: true }) + expect(receivedCtx).not.toHaveProperty('fail') + expect(receivedCtx).not.toHaveProperty('prompts') + }) + it('should write LEAVE_ALT_SCREEN even when waitUntilExit rejects in fullscreen mode', async () => { mockedInkRender.mockReturnValue({ unmount: vi.fn(), diff --git a/packages/core/src/screen/screen.tsx b/packages/core/src/screen/screen.tsx index 8d6db032..d0c25b1a 100644 --- a/packages/core/src/screen/screen.tsx +++ b/packages/core/src/screen/screen.tsx @@ -1,5 +1,6 @@ import process from 'node:process' +import { isFunction } from '@kidd-cli/utils/fp' import { withTag } from '@kidd-cli/utils/tag' import type { ComponentType } from 'react' import React from 'react' @@ -83,8 +84,13 @@ export interface ScreenDef< * When `true`, the screen renders in the terminal's alternate screen * buffer (fullscreen mode). Preserves the user's scrollback history * and restores it on exit. + * + * Accepts a static boolean or a resolver function that receives the + * {@link ScreenContext} and returns a boolean (sync or async). The + * resolver runs after middleware, so `ctx.args` and `ctx.config` are + * available for conditional fullscreen decisions. */ - readonly fullscreen?: boolean + readonly fullscreen?: boolean | ((ctx: ScreenContext) => boolean | Promise) /** * When `true` (inherited default), yargs rejects unknown flags for this screen. @@ -123,12 +129,17 @@ export function screen< TPositionalsDef extends ArgsDef = ArgsDef, >(def: ScreenDef): Command { const exitMode = def.exit ?? 'manual' - const isFullscreen = def.fullscreen === true const ScreenComponent = def.render as ComponentType> const renderFn = async (ctx: CommandContext): Promise => { const { render: inkRender } = await import('ink') const screenCtx = toScreenContext(ctx) + const isFullscreen = await match(isFunction(def.fullscreen)) + .with(true, () => + (def.fullscreen as (ctx: ScreenContext) => boolean | Promise)(screenCtx) + ) + .with(false, () => def.fullscreen === true) + .exhaustive() const children = match(isFullscreen) .with(true, () => ( @@ -198,11 +209,10 @@ const STRIPPED_KEYS: ReadonlySet = new Set([ * entries to the store. The store is attached via {@link OUTPUT_STORE_KEY} * (a private symbol) so `` can subscribe to it. * - * @private * @param ctx - The full command context. * @returns A ScreenContext with React-backed I/O. */ -function toScreenContext(ctx: CommandContext): ScreenContext { +export function toScreenContext(ctx: CommandContext): ScreenContext { const store = createOutputStore() const screenLog = createScreenLog(store) const screenSpinner = createScreenSpinner(store) @@ -236,8 +246,8 @@ function toScreenContext(ctx: CommandContext): ScreenContext { * @private */ function resolveValue(value: Resolvable | undefined): T | undefined { - if (typeof value === 'function') { - return (value as () => T)() - } - return value + return match(isFunction(value)) + .with(true, () => (value as () => T)()) + .with(false, () => value as T | undefined) + .exhaustive() } diff --git a/packages/core/src/test/context.ts b/packages/core/src/test/context.ts index ca27bc82..0d912139 100644 --- a/packages/core/src/test/context.ts +++ b/packages/core/src/test/context.ts @@ -40,6 +40,7 @@ export function createTestContext< const ctx = createContext({ args: (opts.args ?? {}) as TArgs, + argv: [meta.name, ...meta.command], config: (opts.config ?? {}) as TConfig, log, meta, diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index 8642bc9a..e0454ddd 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -119,7 +119,7 @@ export type { OutputStore } from '../screen/output/index.js' // Screen // --------------------------------------------------------------------------- -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 { ScreenContext } from '../context/types.js'