Optique 1.3.0: Dependency-aware prompts, an OS keychain fallback, and a testing package #963
dahlia
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Optique is a type-safe combinatorial CLI parser for TypeScript built from composable parser functions. Parser definitions drive runtime parsing, help and completion output, and the inferred value types that handlers receive.
This release closes out a multi-part effort to make dependency sources, the mechanism behind
dependency(),bindEnv(),bindConfig(), and the prompt integrations, resolve consistently regardless of how a parser is declared, and it builds a real feature on top of that fix: prompts that adapt their options to an answer given earlier in the same run. Two new packages ship alongside it,@optique/keyringfor OS credential-store fallback and@optique/testingfor testing Optique-based CLIs without reparsing stderr. Here's what changed.Dependency sources, independent of declaration order
A “dependency source”, a parser wrapped in
dependency(), or a value bound throughbindEnv()/bindConfig()/bindKeyring(), publishes its resolved value so other parsers can read it, an area of work tracked from the umbrella issue #869. Before this release, whether that publication actually reached a consumer depended on where in the parser tree the two were declared relative to each other.object({ source, consumer })worked;object({ consumer, source })did not, because a source nested inside a construct such asobject(),tuple(),concat(), ormerge()registered only once its own construct finished, so a consumer positioned earlier validated against a stale default instead of the value actually on the command line, and a derived parser, one that is itself a consumer of another source, could not act as a source for anything declared after it. Fixes in #871, #915, and #958 give a nested source the declaration position of the construct that holds it and let derived parsers chain as sources in turn, so field order no longer changes which values a consumer accepts, including across multiple levels of dependency.The same declaration-order sensitivity showed up inside
conditional(): an unselected or speculative branch could still run its own prompts or register false dependency cycles against parsers outside the branch it was never part of. Completion, provider precedence, and cycle detection now consider only the selected branch, a fix that landed across #872 and a run of follow-up issues from #919 through #932, with a final confirmation in #957, so aconditional()no longer prompts for, or double-counts, a value that belongs to a branch the input never took. Prompt fallbacks around dependency sources got the same treatment in #870 and #912: a source's prompt now runs, and publishes its answer, before the parsers that depend on it, source prompts run serially in dependency order with declaration order breaking ties, and each prompt occurrence fires at most once per parse, whether the value came from the command line, a binding, or the prompt itself.derivePromptConfig(): prompts that adapt to an earlier answerWith source values now reaching consumers reliably,
@optique/prompt,@optique/clack, and@optique/inquireraddderivePromptConfig(), proposed alongside the dependency-source rework in #869 and implemented in #872. It builds a prompt's configuration from one or more dependency sources instead of a fixed object, and the resolver runs only when parsing actually reaches that prompt, synchronously or asynchronously:Answering “fresh” narrows the package-manager prompt to Deno; answering “hono” offers npm and pnpm.
derivePromptConfig()only changes which options the prompt shows, not what the parser accepts on the command line, so--package-manager denostill parses even alongside--framework hono; wrap a dependency-derived value parser withdependency()separately if CLI input should be constrained too. See the dependency sources guide and the Clack integration guide for the full contract.Runtime conditions and shared validation for prompts
Two smaller additions round out the prompt integrations.
when/otherwiseon a prompt configuration, proposed in #882, skip the prompt fallback under a runtime condition instead of forcing the caller to check eagerly, before the parser even runs, or to fall back todeferredValue():The
gh --versioncheck only runs when parsing actually reaches this prompt, not on every invocation. And every prompt type in@optique/clackand@optique/inquirer, including selection prompts that previously had no way to reject an answer, now accepts a sharedvalidatefunction and amaxAttemptsretry limit through a common third argument, replacing the ad hoc re-prompt loop each application previously had to write by hand, the problem described in #873:Both features are also available to any custom prompt library through
createPromptAdapter()in@optique/prompt, not just the two bundled integrations; the implementations across all three packages landed in #935 through #940.@optique/keyring: OS credential-store fallback@optique/keyring resolves a value from the operating system's credential store, Keychain on macOS, Credential Manager on Windows, and Secret Service on Linux, when nothing else supplies it, so a password or token no longer has to live in a configuration file to be reusable across runs:
A missing entry falls through to the next fallback, such as the interactive prompt above, while a locked, inaccessible, or ambiguous credential store rejects the parse with its own error instead of silently behaving like a missing entry. The store is only touched when a keyring fallback is actually reached, so
--help,--version, and completion never trigger a credential-store prompt or an OS permission dialog, and a keyring value takes precedence over inner fallbacks even when used as a dependency source. Stored-password validation failures hide their original details so a password never appears in error output. PutbindEnv()outsidebindKeyring()when an environment variable should win over the stored credential:See the keyring integration guide for the full option reference. Thanks to Minseok Youn (@black7375) for proposing OS-keychain support in #886 and implementing it in #956.
Installation
@optique/testing: testing Optique CLIs without reparsing stderrTesting an Optique-based CLI used to mean either parsing rendered stderr to check an error, or spawning a real process and asserting on raw stdout. Thanks to [Minseok Youn], who proposed a dedicated capture helper for
runProgram(), with a working sketch, in #887. @optique/testing, set up in #942, grew that proposal into four entry points that capture the level of behavior each kind of test actually needs, without a dependency on any particular test framework or assertion library.@optique/testing/parseris the narrowest: it parses a complete argument list and returns the inferred value or a structured failure, including the remaining arguments and matched command path, with no runner or process involved.@optique/testing/run, added in #944, captures whatrun()/runAsync()would print or return, including help, version, and completion output, without writing toprocess.stdout/stderror callingprocess.exit().@optique/testing/discover, added in #945, does the same forrunProgram(), exercising real command discovery, lifecycle hooks, and handler dispatch while capturing output routed through Optique's own callbacks (a handler's directconsole.log()calls are not captured, since those bypass Optique entirely). At the other end,@optique/testing/cli, added in #946, spawns the CLI as a real child process and captures everything, including direct writes and the actual exit code:createCliRunner()runs under the current runtime by default, or a specificcommand, and supports stdin, working directory and environment overrides, cancellation, and a 5-second default timeout; execution failures, timeouts, and cancellation reject with aCliInvocationErrorthat still carries whatever output was captured before the failure, and process-tree cleanup on both POSIX and Windows now runs within a bounded deadline, fixed in #954, rather than blocking indefinitely. The package'sCapturedOutputtype is shared between the/runand/discoverlayers. See the testing guide for the complete contract.Installation
Terminal themes and custom message formatting
Optique already colors and wraps its own help, usage, and error output.
TerminalTheme, added in #952 after being proposed in #907, now lets an application restyle specific pieces of that output, such as values, option names, metavariables, or the error label, without reimplementing the renderer:Roles left out of a theme keep their defaults. For full control over rendering, including output with no color support at all, a
messageFormattercan replace message rendering outright; boththemeandmessageFormatterare accepted by the core runner,run()/runSync()/runAsync(),runProgram(),formatDocPage(), and the standaloneprint()/printError()/createPrinter()functions, with an explicitmessageFormattertaking precedence over atheme. Themed output now also preserves explicit line breaks, spaces, styles, and hyperlinks when wrapping, and measures each line of a multiline label or term separately instead of treating it as one run of text. See the terminal themes and message formatter injection sections of the messages guide for the full role list.New value parsers:
origin()andregExp()origin(), proposed in #961 and implemented in #962, parses a web origin, a scheme, a host, and an optional port, and nothing else, returning aURLwhose pathname is/and which carries no credentials, query, or fragment. Input beyond an origin is canonicalized rather than rejected by default: the host is lowercased, internationalized names become punycode, a default port is elided, andHTTPS://Example.COM/andhttps://example.com:443/path?query#fragmentboth parse tohttps://example.com;extraComponents: "reject"fails instead on a path, query, or fragment. Credentials are always rejected, as are schemes whose origin is opaque (mailto:,data:,file:) or, likeblob:, borrowed from the URL it wraps:regExp(), proposed in #906 and implemented in #909, compiles a command-line value into aRegExp, treating the whole token as the pattern source rather than parsing/pattern/flagssyntax, so/foo/imatches that literal text. Flags are fixed by the parser's own options and validated at construction time, so an invalid, duplicate, or incompatible flag combination throws immediately instead of surfacing as a confusing runtime parse failure:regExp()documents a ReDoS caveat worth repeating here: compiling a pattern doesn't establish that it's safe to execute against untrusted input. See the value parsers guide fororigin()'s trailing-dot handling andregExp()'s flag validation in full.Other changes
parseDetailed()runs a parser against a complete argument list and returns its remaining arguments and matched command path alongside the usual value or error, for callers, including@optique/testing/parser, that need more thanparse()'s plain result. (Add layered CLI testing APIs in@optique/testing#890, Add parser test helpers to@optique/testing/parser#892, Share whole-argument parsing with test helpers #943)formatDocPage()acceptstermWidth: "auto", which measures the rendered width of visible terms, after runner-added help, version, and completion entries, using Optique's own display-width algorithm, and aligns descriptions to it instead of the fixed default column. (Support automatictermWidthcalculation informatDocPage()#904, Measure help term widths automatically #911)help.onShow(exitCode, page)now receives the finalDocPage, the one built after runner-added entries, appliedusageLine, and command selection, as its second argument, so a custom renderer no longer has to reconstruct it. Existing handlers that accept only the exit code, or no arguments, keep working; a wrapper that forwards the callback now needs to forward both arguments. (Pass finalDocPagevalues to help callbacks #899, Expose final help pages to custom renderers #950)usageLineon the core runner,run()/runSync()/runAsync(), andrunProgram()replaces the generated root synopsis on the top-level full help page, either with a literal usage or a callback that derives one from the default, without touching subcommand help or usage-only error output. (Allow overriding the top-level usage line #879)onError(exitCode, error)now receives the structuredMessagebehind the rendered error, covering parse failures, invalid command paths before help, and unsupported completion shells, so an application can act on it without reparsing stderr; pair it with astderrcallback to fully own error output. (Pass structured error messages to runner callbacks #897, Expose structured errors to runner callbacks #949)run()/runSync()/runAsync()now derive their color and width defaults more carefully: any nonemptyFORCE_COLOR, including"0", decides color support, falling back toNO_COLOR/NODE_DISABLE_COLORSand then TTY detection; an invalid or zero reported terminal width falls back to a validCOLUMNSvalue, or leaves output unwrapped when neither is usable. (Harden terminal capability detection in@optique/run#903, Honor color preferences and validate terminal width #955)Upgrading
There are no breaking changes in 1.3.0. If your own code wraps
onErrororhelp.onShowto forward them to another callback rather than calling them directly, add the second argument the old signature dropped; application code that just passes a handler torun()or the core runner needs no changes.See CHANGES.md for the complete changelog.
All reactions