From 378807bd600cbc2bbf78edcc8078946374bf4189 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Thu, 16 Apr 2026 21:54:13 +0000 Subject: [PATCH 01/18] chore(@hyperfrontend/workspace): update roadmap docs --- roadmap/docs-site-action-plan.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/roadmap/docs-site-action-plan.md b/roadmap/docs-site-action-plan.md index 12d63b96..f1fd80a7 100644 --- a/roadmap/docs-site-action-plan.md +++ b/roadmap/docs-site-action-plan.md @@ -4,23 +4,6 @@ Improve docs-site SEO, branding, and engagement using Next.js built-in features. --- -## Phase 1: SEO (Foundation) ✅ - -- Root layout metadata (metadataBase, twitter, alternates) -- sitemap.ts and robots.ts -- Per-page metadata for 20+ pages -- Library metadata utility using manifest - -### Verification - -```bash -npx nx build docs-site -npx nx lint docs-site --fix -npx nx typecheck docs-site -``` - ---- - ## Phase 2: Branding (Isolated) Favicon and social card assets. From d02e74008a5f48ed45e6b72b7c76c791db85dfc8 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Fri, 17 Apr 2026 00:00:36 +0000 Subject: [PATCH 02/18] docs(@hyperfrontend/workspace): add final stretch roadmap --- roadmap/features-implementation-plan.md | 1099 +++++++++++++++++++++++ 1 file changed, 1099 insertions(+) create mode 100644 roadmap/features-implementation-plan.md diff --git a/roadmap/features-implementation-plan.md b/roadmap/features-implementation-plan.md new file mode 100644 index 00000000..f9d8c7e4 --- /dev/null +++ b/roadmap/features-implementation-plan.md @@ -0,0 +1,1099 @@ +# @hyperfrontend/features Implementation Plan + +Comprehensive implementation plan for the `@hyperfrontend/features` package — the SDK, CLI, dev server, and shell generation system that enables the hyperfrontend microfrontend framework. + +**Date**: April 16, 2026 +**Status**: Approved (Grill Session Complete) + +--- + +## Table of Contents + +1. [Context](#context) +2. [Architecture Overview](#architecture-overview) +3. [Decisions Summary](#decisions-summary) +4. [Package Structure](#package-structure) +5. [Dependencies](#dependencies) +6. [Implementation Phases](#implementation-phases) +7. [Demo Implementation](#demo-implementation) +8. [Deployment Architecture](#deployment-architecture) +9. [Deferred Items](#deferred-items) + +--- + +## Context + +The hyperfrontend project provides a microfrontend framework centered on `@hyperfrontend/nexus` (cross-window messaging protocol). However, nexus handles only the communication protocol — there is significant "frontend glue code" (iframe management, display modes, lifecycle orchestration) that exists in legacy references but is not yet formalized. + +`@hyperfrontend/features` will be the batteries-included solution that: + +- Provides a host-side SDK for embedding features +- Provides a hostee-side SDK for feature apps +- Generates shell packages that hosts import +- Includes a CLI for initialization, building, and development +- Ships a dev server with debug UI for testing host/hostee interactions +- Bundles all dependencies (nexus, network-protocol, etc.) for zero-config usage + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ FEATURE APP (e.g., Clock) │ +├─────────────────────────────────────────────────────────────────────┤ +│ • Normal app (React, Vue, Angular, Vanilla JS) │ +│ • Knows nothing about any host │ +│ • Declares: "I am a feature, here's my contract" │ +│ • Uses @hyperfrontend/features/hostee SDK │ +│ │ +│ BUILD OUTPUT: @mycompany/clock-shell (npm-style package) │ +│ • Self-contained (zero runtime deps) │ +│ • Contract inlined in bundle │ +│ • metadata.json for registry/humans │ +└──────────────────────────────────────────────────────────────────────┘ + │ + │ Host installs shell package + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ HOST APP │ +├─────────────────────────────────────────────────────────────────────┤ +│ • Any app that wants to embed features │ +│ • Imports shell packages at build time │ +│ • Uses shell API: shell.open(), shell.send(), shell.on() │ +│ • Controls display mode, container, lifecycle │ +│ • Does NOT need @hyperfrontend/features as direct dep │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Shell Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ SHELL PACKAGE (generated per feature) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ @mycompany/clock-shell/ │ +│ ├── package.json # No dependencies (self-contained) │ +│ ├── dist/ │ +│ │ ├── index.js # Shell SDK (bundled) │ +│ │ ├── index.d.ts # TypeScript declarations │ +│ │ └── index.js.map # Source maps │ +│ ├── metadata.json # Contract + feature info (humans/registry)│ +│ └── README.md # Generated docs │ +│ │ +│ BUNDLED INSIDE index.js: │ +│ • Contract (inlined) │ +│ • Comms layer (nexus subset) │ +│ • Init/handshake protocol │ +│ • Display mode logic (embedded, dialog, popup, standalone) │ +│ • Lifecycle management │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Decisions Summary + +### Core Decisions + +| # | Topic | Decision | +| --- | ------------------ | --------------------------------------------------------- | +| 1 | Location | `libs/features/` (publishable library, not plugin) | +| 2 | Entry points | Sub-path exports: `/host`, `/hostee`, `/cli`, `/server` | +| 3 | CLI invocation | `npx @hyperfrontend/features ` | +| 4 | Bundling | Direct deps (nexus, network-protocol, versioning) bundled | +| 5 | Framework adapters | None for now (leave room for future) | +| 6 | Nx exclusivity | NOT Nx-exclusive; use `@hyperfrontend/scope` for I/O | + +### SDK Decisions + +| # | Topic | Decision | +| --- | -------------------- | ----------------------------------------------------- | +| 7 | Shell pattern | Singleton (nexus caches broker instances) | +| 8 | API typing | Typed overloads generated from contract | +| 9 | Display modes | All 4 baked in (embedded, dialog, popup, standalone) | +| 10 | Display mode plugins | Reserved for future (loading animations, etc.) | +| 11 | Dialog defaults | SDK provides overlay + close button, customizable | +| 12 | Escape key | Configurable (`closeOnEscape: boolean`, default true) | + +### Configuration Decisions + +| # | Topic | Decision | +| --- | ----------------- | ------------------------------------- | +| 13 | Config format | JSON (`feature.config.json`) | +| 14 | Feature config | Minimal: name, version, contract path | +| 15 | Host config | None — all programmatic in code | +| 16 | Dev server config | JSON (`hf-dev.config.json`) | + +### Contract Decisions + +| # | Topic | Decision | +| --- | ------------------- | ------------------------------------------------ | +| 17 | Contract location | Separate JSON file (`contracts/*.contract.json`) | +| 18 | Contract loading | Bundled at build time (inlined in shell) | +| 19 | Contract validation | Both build-time and runtime | +| 20 | Schema requirement | Optional (type-only is fine) | + +### Shell Generation Decisions + +| # | Topic | Decision | +| --- | ------------------- | ------------------------------------------------------- | +| 21 | Shell source | Generated TypeScript (user can see/edit) | +| 22 | Editable boundaries | Separate files (Vercel-style exports) | +| 23 | Protected core | Handshake, channel creation, contract shape | +| 24 | Customizable | Display defaults, lifecycle hooks, message transformers | +| 25 | Generation timing | Build-time (regenerated if contract changes) | + +### Security Decisions + +| # | Topic | Decision | +| --- | ---------------- | -------------------------------------- | +| 26 | network-protocol | Bundled as direct dep in features | +| 27 | Local default | `protocol: 'none'` (opt-in security) | +| 28 | Production | Must pick v1 or v2 (enforced at build) | + +### Dev Server Decisions + +| # | Topic | Decision | +| --- | -------------- | --------------------------------------------------------- | +| 29 | Purpose | Serve host + hostee together for integration testing | +| 30 | Debug UI | At root (`/`) of dev server | +| 31 | Debug features | Resize, display mode toggle, message log, encryption view | +| 32 | Multi-app | Config-based orchestration + individual commands | +| 33 | Asset serving | Compiled output only (not framework-specific) | + +### Error Handling Decisions + +| # | Topic | Decision | +| --- | ---------------- | -------------------------------------------- | +| 34 | Default behavior | Follow legacy pattern (show error UI) | +| 35 | Customization | Allow configuration (override error display) | +| 36 | Auto-retry | No (emit error, let host decide) | + +### Demo Decisions + +| # | Topic | Decision | +| --- | ------------------ | ------------------------------------------------- | +| 37 | Demo order | Clock (Vue) → Heartbeat (React) → Views (Vanilla) | +| 38 | Demo host | Docs site (`apps/docs-site/`) | +| 39 | Demo routes | Next.js pages that embed features via shell | +| 40 | Landing page | Carousel with live embedded previews | +| 41 | Shell packages | Local workspace builds (not npm-published) | +| 42 | Feature deployment | Railway (separate apps) | +| 43 | Docs deployment | Vercel | + +--- + +## Package Structure + +``` +libs/features/ +├── package.json +│ { +│ "name": "@hyperfrontend/features", +│ "version": "0.1.0", +│ "exports": { +│ ".": "./dist/index.js", +│ "./host": "./dist/sdk/host/index.js", +│ "./hostee": "./dist/sdk/hostee/index.js", +│ "./cli": "./dist/cli/index.js", +│ "./server": "./dist/server/index.js" +│ }, +│ "bin": { +│ "hf": "./dist/cli/bin.js" +│ }, +│ "dependencies": { +│ "@hyperfrontend/nexus": "...", +│ "@hyperfrontend/network-protocol": "...", +│ "@hyperfrontend/versioning": "...", +│ "@hyperfrontend/scope": "...", +│ "@hyperfrontend/json-utils": "..." +│ } +│ } +│ +├── src/ +│ ├── index.ts # Main entry (re-exports) +│ │ +│ ├── sdk/ +│ │ ├── host/ # Host-side SDK +│ │ │ ├── create-shell.ts # Factory for shell instances +│ │ │ ├── display-modes/ +│ │ │ │ ├── embedded.ts # Inline in container +│ │ │ │ ├── dialog.ts # Modal with overlay +│ │ │ │ ├── popup.ts # New browser window +│ │ │ │ ├── standalone.ts # Full page +│ │ │ │ └── index.ts +│ │ │ ├── lifecycle.ts # Open/close/destroy state machine +│ │ │ ├── iframe.ts # Iframe creation utilities +│ │ │ ├── types.ts +│ │ │ └── index.ts +│ │ │ +│ │ ├── hostee/ # Hostee-side SDK +│ │ │ ├── create-feature.ts # Feature initialization +│ │ │ ├── lifecycle.ts # Feature lifecycle +│ │ │ ├── types.ts +│ │ │ └── index.ts +│ │ │ +│ │ └── shared/ # Shared types, utilities +│ │ ├── types.ts # Common interfaces +│ │ ├── contract.ts # Contract utilities +│ │ └── index.ts +│ │ +│ ├── cli/ +│ │ ├── bin.ts # CLI entry point +│ │ ├── commands/ +│ │ │ ├── init.ts # Initialize feature or shell +│ │ │ ├── build.ts # Build shell package +│ │ │ ├── dev.ts # Start dev server +│ │ │ └── index.ts +│ │ ├── prompts.ts # Interactive prompts (uses @hyperfrontend/questions) +│ │ └── index.ts +│ │ +│ ├── server/ +│ │ ├── dev-server.ts # Static file server +│ │ ├── debug-ui/ # Debug interface +│ │ │ ├── index.html # Debug page template +│ │ │ ├── controls.ts # Display mode, resize controls +│ │ │ ├── message-log.ts # Message traffic viewer +│ │ │ └── styles.ts # Inline styles (no CSS files) +│ │ ├── config.ts # Config file parsing +│ │ └── index.ts +│ │ +│ ├── generators/ # Code generation +│ │ ├── shell/ +│ │ │ ├── templates/ # Shell code templates +│ │ │ │ ├── shell.core.ts.template +│ │ │ │ ├── shell.config.ts.template +│ │ │ │ └── shell.exports.ts.template +│ │ │ └── generate-shell.ts +│ │ ├── contract/ +│ │ │ └── generate-types.ts # Generate TS types from contract +│ │ ├── metadata/ +│ │ │ └── generate-metadata.ts # Generate metadata.json +│ │ └── index.ts +│ │ +│ └── nx/ # Nx-specific adapters (optional) +│ ├── generators/ +│ │ ├── init/ +│ │ └── add/ +│ ├── executors/ +│ │ ├── build/ +│ │ └── dev/ +│ └── index.ts +│ +├── generators.json # Nx generator manifest +├── executors.json # Nx executor manifest +├── README.md +├── ARCHITECTURE.md +└── CHANGELOG.md +``` + +--- + +## Dependencies + +### Build Order (Blocking Dependencies) + +``` +Phase 0 (Existing - may need enhancements): +├── @hyperfrontend/scope # Project I/O abstraction +├── @hyperfrontend/nexus # Messaging protocol +├── @hyperfrontend/network-protocol # Security layer +├── @hyperfrontend/versioning # Version management +└── @hyperfrontend/json-utils # JSON utilities + +Phase 1 (New - blocks features): +├── @hyperfrontend/questions # Terminal prompts (blocks CLI) +└── @hyperfrontend/builder # Build tooling (blocks CLI binary) + +Phase 2 (Core): +└── @hyperfrontend/features # This package +``` + +### Internal Dependencies of @hyperfrontend/features + +| Dependency | Used By | Purpose | +| --------------------------------- | ---------------- | ------------------- | +| `@hyperfrontend/nexus` | SDK | Messaging layer | +| `@hyperfrontend/network-protocol` | SDK | Security/encryption | +| `@hyperfrontend/versioning` | Shell generation | Version management | +| `@hyperfrontend/scope` | CLI/generators | Project file I/O | +| `@hyperfrontend/json-utils` | Config parsing | JSON utilities | +| `@hyperfrontend/questions` | CLI | Interactive prompts | + +--- + +## Implementation Phases + +### Phase 0: Prerequisites + +Ensure existing packages are ready: + +**Files to verify:** + +- [libs/nexus/src/index.ts](libs/nexus/src/index.ts) — Messaging API stable +- [libs/network-protocol/src/index.ts](libs/network-protocol/src/index.ts) — Security API stable +- [libs/versioning/src/index.ts](libs/versioning/src/index.ts) — Version utilities available +- [libs/project-scope/src/index.ts](libs/project-scope/src/index.ts) — File I/O abstraction + +**Verification:** + +```bash +npx nx test nexus +npx nx test network-protocol +npx nx test versioning +npx nx test project-scope +``` + +--- + +### Phase 1: Foundation — New Prerequisite Packages + +#### 1.1 Create @hyperfrontend/questions + +Terminal prompting library (enquirer-inspired). + +**Files to create:** + +- `libs/questions/package.json` +- `libs/questions/project.json` +- `libs/questions/src/index.ts` +- `libs/questions/src/prompts/text.ts` +- `libs/questions/src/prompts/select.ts` +- `libs/questions/src/prompts/confirm.ts` +- `libs/questions/src/prompts/multiselect.ts` +- `libs/questions/README.md` + +**Verification:** + +```bash +npx nx test questions +npx nx lint questions --fix +npx nx typecheck questions +``` + +#### 1.2 Create @hyperfrontend/builder + +Build tooling with Node SEA compilation. + +**Files to create:** + +- `libs/builder/package.json` +- `libs/builder/project.json` +- `libs/builder/src/index.ts` +- `libs/builder/src/bundle/rollup.ts` +- `libs/builder/src/cli/node-sea.ts` +- `libs/builder/README.md` + +**Verification:** + +```bash +npx nx test builder +npx nx lint builder --fix +npx nx typecheck builder +``` + +--- + +### Phase 2: Core — @hyperfrontend/features SDK + +#### 2.1 Create Package Scaffold + +**Files to create:** + +- `libs/features/package.json` +- `libs/features/project.json` +- `libs/features/tsconfig.json` +- `libs/features/tsconfig.lib.json` +- `libs/features/tsconfig.spec.json` +- `libs/features/eslint.config.cjs` +- `libs/features/jest.config.ts` +- `libs/features/README.md` + +#### 2.2 Shared Types & Utilities + +**Files to create:** + +- `libs/features/src/index.ts` +- `libs/features/src/sdk/shared/types.ts` +- `libs/features/src/sdk/shared/contract.ts` +- `libs/features/src/sdk/shared/index.ts` + +**Types to define:** + +```typescript +// DisplayMode enum +export enum DisplayMode { + Embedded = 'embedded', + Dialog = 'dialog', + Popup = 'popup', + Standalone = 'standalone', +} + +// Shell options interface +export interface ShellOptions { + container: string | HTMLElement + displayMode?: DisplayMode + url?: string + closeOnEscape?: boolean + // Dialog-specific + dialogWidth?: number + dialogHeight?: number + dialogOverlay?: boolean + // Security + protocol?: 'none' | 'v1' | 'v2' + sharedKey?: string +} + +// Feature options interface +export interface FeatureOptions { + name: string + contract: FeatureContract +} + +// Contract interface +export interface FeatureContract { + emitted: ActionDescription[] + accepted: ActionDescription[] +} +``` + +**Verification:** + +```bash +npx nx lint features --fix +npx nx typecheck features +``` + +#### 2.3 Host SDK — Display Modes + +**Files to create:** + +- `libs/features/src/sdk/host/index.ts` +- `libs/features/src/sdk/host/types.ts` +- `libs/features/src/sdk/host/create-shell.ts` +- `libs/features/src/sdk/host/iframe.ts` +- `libs/features/src/sdk/host/lifecycle.ts` +- `libs/features/src/sdk/host/display-modes/index.ts` +- `libs/features/src/sdk/host/display-modes/embedded.ts` +- `libs/features/src/sdk/host/display-modes/dialog.ts` +- `libs/features/src/sdk/host/display-modes/popup.ts` +- `libs/features/src/sdk/host/display-modes/standalone.ts` + +**Core API:** + +```typescript +// libs/features/src/sdk/host/create-shell.ts +export function createShell(options: ShellOptions): ShellHandle { + // Creates broker via nexus + // Returns handle with: open, close, destroy, send, on, isOpen +} + +export interface ShellHandle { + open(options: ShellOptions): void + close(): void + destroy(): void + send(type: string, data?: unknown): void + on(event: string, handler: (data: unknown) => void): () => void + isOpen(): boolean +} +``` + +**Verification:** + +```bash +npx nx test features +npx nx lint features --fix +npx nx typecheck features +``` + +#### 2.4 Hostee SDK + +**Files to create:** + +- `libs/features/src/sdk/hostee/index.ts` +- `libs/features/src/sdk/hostee/types.ts` +- `libs/features/src/sdk/hostee/create-feature.ts` +- `libs/features/src/sdk/hostee/lifecycle.ts` + +**Core API:** + +```typescript +// libs/features/src/sdk/hostee/create-feature.ts +export function createFeature(options: FeatureOptions): FeatureHandle { + // Creates broker via nexus (hostee side) + // Waits for connection from host + // Returns handle with: send, on, ready, close +} + +export interface FeatureHandle { + send(type: string, data?: unknown): void + on(event: string, handler: (data: unknown) => void): () => void + ready(): Promise + close(): void +} +``` + +**Verification:** + +```bash +npx nx test features +npx nx lint features --fix +npx nx typecheck features +``` + +--- + +### Phase 3: Shell Generation + +#### 3.1 Shell Templates + +**Files to create:** + +- `libs/features/src/generators/shell/templates/shell.core.ts.template` +- `libs/features/src/generators/shell/templates/shell.config.ts.template` +- `libs/features/src/generators/shell/templates/shell.exports.ts.template` +- `libs/features/src/generators/shell/templates/metadata.json.template` + +**Template structure:** + +```typescript +// shell.core.ts.template (PROTECTED - regenerated on build) +// ═══════════════════════════════════════════════════════════════ +// MAINFRAME — DO NOT REMOVE OR MODIFY THIS SECTION +// ═══════════════════════════════════════════════════════════════ +import { createShell } from '@hyperfrontend/features/host'; + +const contract = <%= JSON.stringify(contract) %>; + +export function __shellInit() { + return createShell({ contract, url: '<%= featureUrl %>' }); +} +// ═══════════════════════════════════════════════════════════════ + +// shell.config.ts.template (USER EDITABLE) +export const dialogOptions = { + width: 530, + height: 550, + overlay: true, + closeOnEscape: true +}; + +export function onConnected(shell) { + // Custom logic after connection +} + +export function onError(error) { + // Custom error handling +} +``` + +#### 3.2 Shell Generator + +**Files to create:** + +- `libs/features/src/generators/shell/generate-shell.ts` +- `libs/features/src/generators/shell/index.ts` +- `libs/features/src/generators/contract/generate-types.ts` +- `libs/features/src/generators/metadata/generate-metadata.ts` +- `libs/features/src/generators/index.ts` + +**Verification:** + +```bash +npx nx test features +npx nx lint features --fix +npx nx typecheck features +``` + +--- + +### Phase 4: CLI + +#### 4.1 CLI Commands + +**Files to create:** + +- `libs/features/src/cli/bin.ts` +- `libs/features/src/cli/index.ts` +- `libs/features/src/cli/commands/index.ts` +- `libs/features/src/cli/commands/init.ts` +- `libs/features/src/cli/commands/build.ts` +- `libs/features/src/cli/commands/dev.ts` +- `libs/features/src/cli/prompts.ts` + +**CLI Commands:** + +```bash +# Initialize a feature app +npx @hyperfrontend/features init +# Interactive prompts: name, contract path + +# Build shell package +npx @hyperfrontend/features build +# Reads feature.config.json, generates shell, bundles + +# Start dev server +npx @hyperfrontend/features dev +# Serves feature + debug UI +``` + +**Verification:** + +```bash +npx nx test features +npx nx lint features --fix +npx nx typecheck features +``` + +--- + +### Phase 5: Dev Server + +#### 5.1 Static Server + +**Files to create:** + +- `libs/features/src/server/index.ts` +- `libs/features/src/server/dev-server.ts` +- `libs/features/src/server/config.ts` + +#### 5.2 Debug UI + +**Files to create:** + +- `libs/features/src/server/debug-ui/index.html` +- `libs/features/src/server/debug-ui/index.ts` +- `libs/features/src/server/debug-ui/controls.ts` +- `libs/features/src/server/debug-ui/message-log.ts` +- `libs/features/src/server/debug-ui/styles.ts` + +**Debug UI Features:** + +- Display mode switcher (embedded/dialog/popup/standalone) +- Resize controls (width/height inputs + drag handles) +- Message log (incoming/outgoing, raw/decrypted/pretty views) +- Security protocol selector (none/v1/v2) +- Connection status indicator + +**Verification:** + +```bash +npx nx test features +npx nx lint features --fix +npx nx typecheck features +``` + +--- + +### Phase 6: Nx Adapters (Optional) + +#### 6.1 Nx Generators + +**Files to create:** + +- `libs/features/src/nx/generators/init/generator.ts` +- `libs/features/src/nx/generators/init/schema.json` +- `libs/features/src/nx/generators/init/schema.d.ts` +- `libs/features/src/nx/executors/build/executor.ts` +- `libs/features/src/nx/executors/build/schema.json` +- `libs/features/src/nx/executors/dev/executor.ts` +- `libs/features/src/nx/executors/dev/schema.json` +- `libs/features/generators.json` +- `libs/features/executors.json` + +**Verification:** + +```bash +npx nx test features +npx nx lint features --fix +npx nx typecheck features +``` + +--- + +## Demo Implementation + +### Phase 7: Clock Demo (Vue) + +#### 7.1 Create Clock Feature App + +**Files to create:** + +- `apps/demos/clock/package.json` +- `apps/demos/clock/project.json` +- `apps/demos/clock/feature.config.json` +- `apps/demos/clock/contracts/clock.contract.json` +- `apps/demos/clock/src/main.ts` +- `apps/demos/clock/src/App.vue` +- `apps/demos/clock/src/clock.ts` (feature initialization) +- `apps/demos/clock/index.html` +- `apps/demos/clock/vite.config.ts` + +**Contract:** + +```json +{ + "name": "clock", + "version": "1.0.0", + "emitted": [ + { "type": "timeUpdated", "description": "Emitted every second with current time" }, + { "type": "timezoneChanged", "description": "Emitted when timezone changes" } + ], + "accepted": [ + { "type": "setTimezone", "description": "Set the clock timezone" }, + { "type": "setFormat", "description": "Set 12h or 24h format" } + ] +} +``` + +#### 7.2 Generate Clock Shell + +**Files generated (by features build):** + +- `apps/demos/clock/shell/shell.core.ts` +- `apps/demos/clock/shell/shell.config.ts` +- `apps/demos/clock/shell/shell.exports.ts` +- `apps/demos/clock/shell/dist/index.js` +- `apps/demos/clock/shell/dist/index.d.ts` +- `apps/demos/clock/shell/metadata.json` +- `apps/demos/clock/shell/package.json` + +**Verification:** + +```bash +npx nx build clock +npx nx test clock +npx nx lint clock --fix +``` + +--- + +### Phase 8: Heartbeat Demo (React) + +#### 8.1 Create Heartbeat Feature App + +**Files to create:** + +- `apps/demos/heartbeat/package.json` +- `apps/demos/heartbeat/project.json` +- `apps/demos/heartbeat/feature.config.json` +- `apps/demos/heartbeat/contracts/heartbeat.contract.json` +- `apps/demos/heartbeat/src/main.tsx` +- `apps/demos/heartbeat/src/App.tsx` +- `apps/demos/heartbeat/src/heartbeat.ts` +- `apps/demos/heartbeat/index.html` +- `apps/demos/heartbeat/vite.config.ts` + +**Contract:** + +```json +{ + "name": "heartbeat", + "version": "1.0.0", + "emitted": [ + { "type": "pong", "description": "Response to ping" }, + { "type": "status", "description": "Health status update" } + ], + "accepted": [{ "type": "ping", "description": "Health check request" }] +} +``` + +**Verification:** + +```bash +npx nx build heartbeat +npx nx test heartbeat +npx nx lint heartbeat --fix +``` + +--- + +### Phase 9: Views Demo (Vanilla JS) + +#### 9.1 Create Views Feature App + +**Files to create:** + +- `apps/demos/views/package.json` +- `apps/demos/views/project.json` +- `apps/demos/views/feature.config.json` +- `apps/demos/views/contracts/views.contract.json` +- `apps/demos/views/src/main.ts` +- `apps/demos/views/src/views.ts` +- `apps/demos/views/index.html` +- `apps/demos/views/vite.config.ts` + +**Verification:** + +```bash +npx nx build views +npx nx test views +npx nx lint views --fix +``` + +--- + +### Phase 10: Docs Site Integration + +#### 10.1 Add Demo Shell Dependencies + +**Files to edit:** + +- `apps/docs-site/package.json` — Add workspace deps for demo shells + +```json +{ + "dependencies": { + "@hyperfrontend/demo-clock-shell": "workspace:*", + "@hyperfrontend/demo-heartbeat-shell": "workspace:*", + "@hyperfrontend/demo-views-shell": "workspace:*" + } +} +``` + +#### 10.2 Create Demo Pages + +**Files to create:** + +- `apps/docs-site/src/app/demo/clock/page.tsx` +- `apps/docs-site/src/app/demo/heartbeat/page.tsx` +- `apps/docs-site/src/app/demo/views/page.tsx` + +#### 10.3 Update Landing Page + +**Files to edit:** + +- `apps/docs-site/src/app/page.tsx` — Add demo carousel with live embeds + +**Verification:** + +```bash +npx nx build docs-site +npx nx lint docs-site --fix +npx nx typecheck docs-site +``` + +--- + +## Deployment Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ VERCEL │ +├─────────────────────────────────────────────────────────────────────┤ +│ hyperfrontend.dev │ +│ ├── / Landing (carousel embeds Railway features)│ +│ ├── /docs/* Documentation pages │ +│ ├── /demo/clock Shell page → embeds clock from Railway │ +│ ├── /demo/heartbeat Shell page → embeds heartbeat from Railway│ +│ └── /demo/views Shell page → embeds views from Railway │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ iframe src (feature URLs) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ RAILWAY │ +├─────────────────────────────────────────────────────────────────────┤ +│ clock-demo.up.railway.app ← Clock feature (Vue) │ +│ heartbeat-demo.up.railway.app ← Heartbeat feature (React) │ +│ views-demo.up.railway.app ← Views feature (Vanilla JS) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### CI/CD Configuration + +**Files to create:** + +- `.github/workflows/ci-lib-features.yml` +- `.github/workflows/deploy-demo-clock.yml` +- `.github/workflows/deploy-demo-heartbeat.yml` +- `.github/workflows/deploy-demo-views.yml` + +--- + +## Deferred Items + +The following items are explicitly deferred to future work: + +| Item | Reason | Priority | +| ------------------------------------ | ----------------------------- | -------- | +| Version negotiation at runtime | Need more design | Medium | +| Auto-retry on errors | Keep v1 simple | Low | +| `@hyperfrontend/builder` full scope | Start with narrow MVP | High | +| `@hyperfrontend/questions` | Blocking, but well-scoped | High | +| Experience plugins | Future extensibility | Low | +| `plugins/` directory rename | Cosmetic | Low | +| Framework-specific adapters | Leave room, don't build | Low | +| Web Component alternative to iframes | Experimental (55% confidence) | Low | + +--- + +## Config File Schemas + +### feature.config.json + +```json +{ + "$schema": "https://hyperfrontend.dev/schemas/feature.config.json", + "name": "clock", + "version": "1.0.0", + "contract": "./contracts/clock.contract.json" +} +``` + +### hf-dev.config.json + +```json +{ + "$schema": "https://hyperfrontend.dev/schemas/hf-dev.config.json", + "apps": [ + { + "name": "clock", + "outputDir": "./dist", + "port": 3000 + } + ], + "debug": { + "enabled": true, + "messageLog": true, + "securityView": true + } +} +``` + +### \*.contract.json + +```json +{ + "name": "clock", + "version": "1.0.0", + "emitted": [ + { + "type": "timeUpdated", + "description": "Emitted every second with current time", + "schema": { + "type": "object", + "properties": { + "time": { "type": "number" }, + "timezone": { "type": "string" } + }, + "required": ["time"] + } + } + ], + "accepted": [ + { + "type": "setTimezone", + "description": "Set the clock timezone", + "schema": { + "type": "object", + "properties": { + "tz": { "type": "string" } + }, + "required": ["tz"] + } + } + ] +} +``` + +--- + +## SDK API Reference + +### Host Side (`@hyperfrontend/features/host`) + +```typescript +import { createShell, DisplayMode } from '@hyperfrontend/features/host' +import clockContract from '@mycompany/clock-shell/contract' + +// Create shell instance (singleton per feature) +const clock = createShell({ + name: 'clock', + contract: clockContract, + url: process.env.CLOCK_FEATURE_URL, +}) + +// Open in embedded mode +clock.open({ + container: '#clock-container', + displayMode: DisplayMode.Embedded, +}) + +// Open as dialog +clock.open({ + displayMode: DisplayMode.Dialog, + dialogWidth: 530, + dialogHeight: 550, + closeOnEscape: true, +}) + +// Send messages (typed from contract) +clock.send('setTimezone', { tz: 'America/New_York' }) + +// Listen for messages (typed from contract) +clock.on('timeUpdated', (data) => { + console.log('Time:', data.time) +}) + +// Lifecycle +clock.on('open', () => console.log('Connected')) +clock.on('close', () => console.log('Disconnected')) +clock.on('error', (err) => console.error('Error:', err)) + +// Close and cleanup +clock.close() +clock.destroy() +``` + +### Hostee Side (`@hyperfrontend/features/hostee`) + +```typescript +import { createFeature } from '@hyperfrontend/features/hostee' +import contract from './contracts/clock.contract.json' + +// Initialize feature +const feature = createFeature({ + name: 'clock', + contract, +}) + +// Wait for host connection +feature.ready().then(() => { + console.log('Connected to host') + + // Start sending time updates + setInterval(() => { + feature.send('timeUpdated', { + time: Date.now(), + timezone: currentTimezone, + }) + }, 1000) +}) + +// Listen for host messages +feature.on('setTimezone', (data) => { + currentTimezone = data.tz + feature.send('timezoneChanged', { tz: data.tz }) +}) + +feature.on('setFormat', (data) => { + timeFormat = data.format +}) +``` + +--- + +## Summary + +This implementation plan captures all decisions from the grill session and provides a clear path forward: + +1. **Phase 0-1**: Ensure prerequisites, create blocking packages (questions, builder) +2. **Phase 2-5**: Build core `@hyperfrontend/features` (SDK, generators, CLI, server) +3. **Phase 6**: Optional Nx adapters +4. **Phase 7-9**: Demo apps (Clock, Heartbeat, Views) +5. **Phase 10**: Docs site integration + +The first demo (Clock) will prove the entire architecture end-to-end. Subsequent demos validate framework-agnostic claims. From f3f6d637e8c897ae6be66dd81d0b1563bc7869d0 Mon Sep 17 00:00:00 2001 From: Andrew Redican Date: Fri, 17 Apr 2026 19:49:00 +0000 Subject: [PATCH 03/18] feat(lib-questions): new package for terminal questions --- .github/workflows/ci-lib-questions.yml | 17 + apps/docs-site/scripts/generate-docs.ts | 1 + apps/docs-site/src/lib/content.ts | 8 + .../questions/jest.config.browser.ts | 13 + apps/package-e2e/questions/jest.config.cjs.ts | 13 + apps/package-e2e/questions/jest.config.esm.ts | 23 + apps/package-e2e/questions/jest.setup.ts | 27 + apps/package-e2e/questions/package.json | 23 + apps/package-e2e/questions/project.json | 18 + apps/package-e2e/questions/src/cjs.spec.ts | 29 + apps/package-e2e/questions/src/esm.spec.ts | 26 + apps/package-e2e/questions/src/iife.spec.ts | 34 + apps/package-e2e/questions/src/umd.spec.ts | 34 + apps/package-e2e/questions/tsconfig.json | 17 + libs/questions/README.md | 147 ++++ libs/questions/eslint.config.cjs | 20 + libs/questions/jest.config.ts | 27 + libs/questions/jest.setup.ts | 5 + libs/questions/package.json | 38 + libs/questions/project.json | 25 + libs/questions/src/index.ts | 22 + libs/questions/src/prompts/confirm.spec.ts | 295 ++++++++ libs/questions/src/prompts/confirm.ts | 98 +++ .../prompts/multiselect-searchable.spec.ts | 269 ++++++++ .../questions/src/prompts/multiselect.spec.ts | 650 ++++++++++++++++++ libs/questions/src/prompts/multiselect.ts | 453 ++++++++++++ libs/questions/src/prompts/select.spec.ts | 510 ++++++++++++++ libs/questions/src/prompts/select.ts | 293 ++++++++ libs/questions/src/prompts/text.spec.ts | 363 ++++++++++ libs/questions/src/prompts/text.ts | 210 ++++++ libs/questions/src/render.spec.ts | 200 ++++++ libs/questions/src/render.ts | 133 ++++ libs/questions/src/terminal.spec.ts | 384 +++++++++++ libs/questions/src/terminal.ts | 203 ++++++ libs/questions/src/types.spec.ts | 23 + libs/questions/src/types.ts | 125 ++++ libs/questions/tsconfig.json | 12 + libs/questions/tsconfig.lib.json | 8 + libs/questions/tsconfig.spec.json | 8 + tsconfig.base.json | 5 +- 40 files changed, 4807 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci-lib-questions.yml create mode 100644 apps/package-e2e/questions/jest.config.browser.ts create mode 100644 apps/package-e2e/questions/jest.config.cjs.ts create mode 100644 apps/package-e2e/questions/jest.config.esm.ts create mode 100644 apps/package-e2e/questions/jest.setup.ts create mode 100644 apps/package-e2e/questions/package.json create mode 100644 apps/package-e2e/questions/project.json create mode 100644 apps/package-e2e/questions/src/cjs.spec.ts create mode 100644 apps/package-e2e/questions/src/esm.spec.ts create mode 100644 apps/package-e2e/questions/src/iife.spec.ts create mode 100644 apps/package-e2e/questions/src/umd.spec.ts create mode 100644 apps/package-e2e/questions/tsconfig.json create mode 100644 libs/questions/README.md create mode 100644 libs/questions/eslint.config.cjs create mode 100644 libs/questions/jest.config.ts create mode 100644 libs/questions/jest.setup.ts create mode 100644 libs/questions/package.json create mode 100644 libs/questions/project.json create mode 100644 libs/questions/src/index.ts create mode 100644 libs/questions/src/prompts/confirm.spec.ts create mode 100644 libs/questions/src/prompts/confirm.ts create mode 100644 libs/questions/src/prompts/multiselect-searchable.spec.ts create mode 100644 libs/questions/src/prompts/multiselect.spec.ts create mode 100644 libs/questions/src/prompts/multiselect.ts create mode 100644 libs/questions/src/prompts/select.spec.ts create mode 100644 libs/questions/src/prompts/select.ts create mode 100644 libs/questions/src/prompts/text.spec.ts create mode 100644 libs/questions/src/prompts/text.ts create mode 100644 libs/questions/src/render.spec.ts create mode 100644 libs/questions/src/render.ts create mode 100644 libs/questions/src/terminal.spec.ts create mode 100644 libs/questions/src/terminal.ts create mode 100644 libs/questions/src/types.spec.ts create mode 100644 libs/questions/src/types.ts create mode 100644 libs/questions/tsconfig.json create mode 100644 libs/questions/tsconfig.lib.json create mode 100644 libs/questions/tsconfig.spec.json diff --git a/.github/workflows/ci-lib-questions.yml b/.github/workflows/ci-lib-questions.yml new file mode 100644 index 00000000..283ccdff --- /dev/null +++ b/.github/workflows/ci-lib-questions.yml @@ -0,0 +1,17 @@ +name: lib-questions + +on: + workflow_run: + workflows: [libraries] + types: [completed] + branches: [main] + +permissions: + actions: read + +jobs: + status: + uses: ./.github/workflows/_lib-status.yml + with: + project-name: lib-questions + library-path: libs/questions diff --git a/apps/docs-site/scripts/generate-docs.ts b/apps/docs-site/scripts/generate-docs.ts index e1858d88..3538a4b2 100644 --- a/apps/docs-site/scripts/generate-docs.ts +++ b/apps/docs-site/scripts/generate-docs.ts @@ -211,6 +211,7 @@ const LIBRARIES: LibraryConfig[] = [ }, { name: 'Time Utils', packageName: '@hyperfrontend/time-utils', slug: 'time-utils', srcPath: 'libs/utils/time', category: 'utils' }, { name: 'UI Utils', packageName: '@hyperfrontend/ui-utils', slug: 'ui-utils', srcPath: 'libs/utils/ui', category: 'utils' }, + { name: 'Questions', packageName: '@hyperfrontend/questions', slug: 'questions', srcPath: 'libs/questions', category: 'utils' }, { name: 'Features Plugin', packageName: '@hyperfrontend/features', slug: 'features', srcPath: 'plugins/features', category: 'plugin' }, ] diff --git a/apps/docs-site/src/lib/content.ts b/apps/docs-site/src/lib/content.ts index ab7d367c..99b59ed7 100644 --- a/apps/docs-site/src/lib/content.ts +++ b/apps/docs-site/src/lib/content.ts @@ -164,6 +164,14 @@ export const LIBRARIES: LibraryInfo[] = [ entryPoints: ['libs/utils/ui/src/index.ts'], category: 'utils', }, + { + name: 'Questions', + packageName: '@hyperfrontend/questions', + slug: 'questions', + readmePath: 'libs/questions/README.md', + entryPoints: ['libs/questions/src/index.ts'], + category: 'utils', + }, { name: 'Features Plugin', packageName: '@hyperfrontend/features', diff --git a/apps/package-e2e/questions/jest.config.browser.ts b/apps/package-e2e/questions/jest.config.browser.ts new file mode 100644 index 00000000..d5338fa5 --- /dev/null +++ b/apps/package-e2e/questions/jest.config.browser.ts @@ -0,0 +1,13 @@ +import type { Config } from 'jest' + +const config: Config = { + displayName: 'e2e-lib-questions-browser', + testEnvironment: 'jsdom', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json' }], + }, + testMatch: ['/src/iife.spec.ts', '/src/umd.spec.ts'], + setupFilesAfterEnv: ['/jest.setup.ts'], +} + +export default config diff --git a/apps/package-e2e/questions/jest.config.cjs.ts b/apps/package-e2e/questions/jest.config.cjs.ts new file mode 100644 index 00000000..e0872e32 --- /dev/null +++ b/apps/package-e2e/questions/jest.config.cjs.ts @@ -0,0 +1,13 @@ +import type { Config } from 'jest' + +const config: Config = { + displayName: 'e2e-lib-questions-cjs', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json' }], + }, + testMatch: ['/src/cjs.spec.ts'], + moduleNameMapper: {}, +} + +export default config diff --git a/apps/package-e2e/questions/jest.config.esm.ts b/apps/package-e2e/questions/jest.config.esm.ts new file mode 100644 index 00000000..4b8fca9b --- /dev/null +++ b/apps/package-e2e/questions/jest.config.esm.ts @@ -0,0 +1,23 @@ +import type { Config } from 'jest' + +const config: Config = { + displayName: 'e2e-lib-questions-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.(ts|tsx|js|mjs)$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.json', + useESM: true, + }, + ], + }, + transformIgnorePatterns: [ + // Transform @hyperfrontend packages since they use ESM + 'node_modules/(?!@hyperfrontend/)', + ], + testMatch: ['/src/esm.spec.ts'], +} + +export default config diff --git a/apps/package-e2e/questions/jest.setup.ts b/apps/package-e2e/questions/jest.setup.ts new file mode 100644 index 00000000..6768faba --- /dev/null +++ b/apps/package-e2e/questions/jest.setup.ts @@ -0,0 +1,27 @@ +/** + * Jest setup for browser bundle tests. + * Cleans up window globals between tests. + */ + +// jsdom doesn't provide TextEncoder/TextDecoder by default +import { TextEncoder, TextDecoder } from 'util' + +Object.assign(global, { TextEncoder, TextDecoder }) + +beforeEach(() => { + // Clear any globals that may have been set by previous tests + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((window as any).HyperfrontendQuestions !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).HyperfrontendQuestions = undefined + } + } catch { + // Property may not be deletable, that's ok + } +}) + +afterEach(() => { + // Remove any added script elements + document.head.querySelectorAll('script').forEach((script) => script.remove()) +}) diff --git a/apps/package-e2e/questions/package.json b/apps/package-e2e/questions/package.json new file mode 100644 index 00000000..48511b65 --- /dev/null +++ b/apps/package-e2e/questions/package.json @@ -0,0 +1,23 @@ +{ + "name": "@hyperfrontend/e2e-questions", + "version": "0.0.0", + "private": true, + "description": "E2E tests for @hyperfrontend/questions package outputs", + "scripts": { + "pack-install": "cd ../../../dist/libs/questions && npm pack && cd - && npm install ../../../dist/libs/questions/*.tgz" + }, + "dependencies": { + "@hyperfrontend/questions": "file:../../../tmp/e2e-packs/hyperfrontend-questions-0.0.0.tgz" + }, + "devDependencies": { + "@types/jest": "29.5.14", + "@types/node": "25.3.3", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "ts-jest": "29.2.6", + "typescript": "5.7.3" + }, + "overrides": { + "picomatch": "4.0.4" + } +} diff --git a/apps/package-e2e/questions/project.json b/apps/package-e2e/questions/project.json new file mode 100644 index 00000000..1edd037f --- /dev/null +++ b/apps/package-e2e/questions/project.json @@ -0,0 +1,18 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "e2e-lib-questions", + "description": "E2E tests for @hyperfrontend/questions package outputs", + "sourceRoot": "{projectRoot}/src", + "projectType": "application", + "tags": ["type:e2e", "scope:internal"], + "targets": { + "e2e": { + "executor": "@hyperfrontend/package:e2e", + "dependsOn": ["^build"], + "options": { + "formats": ["cjs", "esm", "browser"] + } + } + }, + "implicitDependencies": ["lib-questions"] +} diff --git a/apps/package-e2e/questions/src/cjs.spec.ts b/apps/package-e2e/questions/src/cjs.spec.ts new file mode 100644 index 00000000..757e0eb9 --- /dev/null +++ b/apps/package-e2e/questions/src/cjs.spec.ts @@ -0,0 +1,29 @@ +/** + * CJS (CommonJS) E2E tests for @hyperfrontend/questions + * Tests that the package is requireable and exports work correctly. + */ + +describe('@hyperfrontend/questions CJS', () => { + it('should be requireable', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('@hyperfrontend/questions') + expect(pkg).toBeDefined() + }) + + it('should have exports', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('@hyperfrontend/questions') + const exportedKeys = Object.keys(pkg) + expect(exportedKeys.length).toBeGreaterThan(0) + }) + + it('should export functions or objects', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('@hyperfrontend/questions') + + // At least one export should be a function, object, or class + const exportTypes = Object.values(pkg).map((v) => typeof v) + const hasValidExport = exportTypes.some((t) => ['function', 'object'].includes(t)) + expect(hasValidExport).toBe(true) + }) +}) diff --git a/apps/package-e2e/questions/src/esm.spec.ts b/apps/package-e2e/questions/src/esm.spec.ts new file mode 100644 index 00000000..6fe3cc16 --- /dev/null +++ b/apps/package-e2e/questions/src/esm.spec.ts @@ -0,0 +1,26 @@ +/** + * ESM (ES Modules) E2E tests for @hyperfrontend/questions + * Tests that the package is importable and exports work correctly. + */ + +describe('@hyperfrontend/questions ESM', () => { + it('should be importable', async () => { + const pkg = await import('@hyperfrontend/questions') + expect(pkg).toBeDefined() + }) + + it('should have exports', async () => { + const pkg = await import('@hyperfrontend/questions') + const exportedKeys = Object.keys(pkg) + expect(exportedKeys.length).toBeGreaterThan(0) + }) + + it('should export functions or objects', async () => { + const pkg = await import('@hyperfrontend/questions') + + // At least one export should be a function, object, or class + const exportTypes = Object.values(pkg).map((v) => typeof v) + const hasValidExport = exportTypes.some((t) => ['function', 'object'].includes(t)) + expect(hasValidExport).toBe(true) + }) +}) diff --git a/apps/package-e2e/questions/src/iife.spec.ts b/apps/package-e2e/questions/src/iife.spec.ts new file mode 100644 index 00000000..6be069fa --- /dev/null +++ b/apps/package-e2e/questions/src/iife.spec.ts @@ -0,0 +1,34 @@ +/** + * IIFE bundle E2E tests for @hyperfrontend/questions + * Tests that the browser bundle loads correctly and attaches to window. + */ + +import { getBundlePath, loadBundleCode, executeBundleInWindow } from '../../shared/helpers' + +describe('@hyperfrontend/questions IIFE bundle', () => { + const bundlePath = getBundlePath('questions', 'iife') + const minBundlePath = getBundlePath('questions', 'iife', true) + + it('bundle file should exist', () => { + expect(() => loadBundleCode(bundlePath)).not.toThrow() + }) + + it('minified bundle file should exist', () => { + expect(() => loadBundleCode(minBundlePath)).not.toThrow() + }) + + it('should attach HyperfrontendQuestions to window global', () => { + const bundleCode = loadBundleCode(bundlePath) + const global = executeBundleInWindow(bundleCode, 'HyperfrontendQuestions') + + expect(global).toBeDefined() + }) + + it('should have exports on the global', () => { + const bundleCode = loadBundleCode(bundlePath) + const global = executeBundleInWindow(bundleCode, 'HyperfrontendQuestions') as Record + + const exportedKeys = Object.keys(global) + expect(exportedKeys.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/package-e2e/questions/src/umd.spec.ts b/apps/package-e2e/questions/src/umd.spec.ts new file mode 100644 index 00000000..aab18451 --- /dev/null +++ b/apps/package-e2e/questions/src/umd.spec.ts @@ -0,0 +1,34 @@ +/** + * UMD bundle E2E tests for @hyperfrontend/questions + * Tests that the UMD bundle loads correctly in browser context. + */ + +import { getBundlePath, loadBundleCode, executeBundleInWindow } from '../../shared/helpers' + +describe('@hyperfrontend/questions UMD bundle', () => { + const bundlePath = getBundlePath('questions', 'umd') + const minBundlePath = getBundlePath('questions', 'umd', true) + + it('bundle file should exist', () => { + expect(() => loadBundleCode(bundlePath)).not.toThrow() + }) + + it('minified bundle file should exist', () => { + expect(() => loadBundleCode(minBundlePath)).not.toThrow() + }) + + it('should attach HyperfrontendQuestions to window global', () => { + const bundleCode = loadBundleCode(bundlePath) + const global = executeBundleInWindow(bundleCode, 'HyperfrontendQuestions') + + expect(global).toBeDefined() + }) + + it('should have exports on the global', () => { + const bundleCode = loadBundleCode(bundlePath) + const global = executeBundleInWindow(bundleCode, 'HyperfrontendQuestions') as Record + + const exportedKeys = Object.keys(global) + expect(exportedKeys.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/package-e2e/questions/tsconfig.json b/apps/package-e2e/questions/tsconfig.json new file mode 100644 index 00000000..32cdb059 --- /dev/null +++ b/apps/package-e2e/questions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "noEmit": true, + "types": ["node", "jest"], + "noImplicitAny": false, + "allowJs": true + }, + "include": ["src/**/*.ts"] +} diff --git a/libs/questions/README.md b/libs/questions/README.md new file mode 100644 index 00000000..17a4c16e --- /dev/null +++ b/libs/questions/README.md @@ -0,0 +1,147 @@ +# @hyperfrontend/questions + +

+ + Build + + + Coverage + + + npm version + + + npm bundle size + +

+

+ + + All Contributors + + + + License + + + npm downloads + + + GitHub stars + + Node Version + Tree Shakeable +

+ +Terminal prompting library with composable, functional API for text, select, confirm, and multiselect prompts + +• 👉 See [**documentation**](https://www.hyperfrontend.dev/docs/libraries/questions/) + +## What is @hyperfrontend/questions? + +A terminal prompting library built on functional programming principles. Create interactive CLI experiences with composable, type-safe prompts that return structured outcomes. + +### Key Features + +- **Pure Functions** — Every prompt is a pure function returning `Promise>`, making results predictable and easily testable +- **Composable API** — Build complex interactive flows by combining simple prompt functions +- **Type-Safe** — Full TypeScript support with discriminated unions for prompt outcomes +- **Zero External Dependencies** — Uses only Node.js built-ins and `@hyperfrontend` utilities +- **Searchable Multiselect** — Type-to-filter functionality for large option lists + +### Architecture Highlights + +Each prompt follows a functional state machine pattern: + +- **Immutable State** — All prompt state is frozen; updates create new state objects +- **Explicit Outcomes** — Prompts return either `{ result: 'submitted', value: T }` or `{ result: 'cancelled', value: undefined }` +- **Terminal Abstraction** — Low-level I/O is encapsulated in a `Terminal` interface for testability + +## Why Use @hyperfrontend/questions? + +When building CLI tools, you need interactive prompts that are: + +1. **Predictable** — Know exactly what a prompt returns, always +2. **Composable** — Chain prompts without callback hell +3. **Cancellable** — Handle Ctrl+C gracefully with structured cancellation +4. **Lightweight** — No large dependency trees for simple prompts + +This library provides all four while staying true to functional programming principles. + +## Installation + +```bash +npm install @hyperfrontend/questions +``` + +## Quick Start + +```typescript +import { text, confirm, select, multiselect, PromptResult } from '@hyperfrontend/questions' + +// Text input +const nameResult = await text({ + message: 'What is your name?', + validate: (value) => (value.length < 2 ? 'Name too short' : undefined), +}) + +if (nameResult.result === PromptResult.Submitted) { + console.log(`Hello, ${nameResult.value}!`) +} + +// Confirmation +const continueResult = await confirm({ + message: 'Continue?', + initial: true, +}) + +// Single select +const colorResult = await select({ + message: 'Pick a color:', + choices: [ + { label: 'Red', value: 'red' }, + { label: 'Green', value: 'green', hint: 'recommended' }, + { label: 'Blue', value: 'blue' }, + ], +}) + +// Multiselect with search +const featuresResult = await multiselect({ + message: 'Select features:', + choices: [ + { label: 'TypeScript', value: 'ts' }, + { label: 'ESLint', value: 'eslint' }, + { label: 'Prettier', value: 'prettier' }, + ], + searchable: true, + min: 1, +}) +``` + +## API Overview + +| Function | Description | +| -------------- | ------------------------------------------------- | +| `text` | Free-form text input with optional validation | +| `confirm` | Yes/no confirmation prompt | +| `select` | Single selection from a list of choices | +| `multiselect` | Multiple selections with optional search | +| `PromptResult` | Discriminated union: `'submitted' \| 'cancelled'` | + +All prompts return `Promise>` where: + +```typescript +type PromptOutcome = { result: 'submitted'; value: T } | { result: 'cancelled'; value: undefined } +``` + +## Compatibility + +| Environment | Supported | +| -------------- | --------- | +| Node.js >= 18 | ✅ | +| TTY Terminal | ✅ | +| Tree Shakeable | ✅ | + +## License + +[MIT](https://github.com/AndrewRedican/hyperfrontend/blob/main/LICENSE.md) diff --git a/libs/questions/eslint.config.cjs b/libs/questions/eslint.config.cjs new file mode 100644 index 00000000..68a6e6a8 --- /dev/null +++ b/libs/questions/eslint.config.cjs @@ -0,0 +1,20 @@ +const baseConfig = require('../../eslint.base.config.cjs') + +module.exports = [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredDependencies: ['jest'], + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'], + }, + ], + }, + languageOptions: { + parser: require('jsonc-eslint-parser'), + }, + }, +] diff --git a/libs/questions/jest.config.ts b/libs/questions/jest.config.ts new file mode 100644 index 00000000..9e02b8da --- /dev/null +++ b/libs/questions/jest.config.ts @@ -0,0 +1,27 @@ +import type { Config } from 'jest' + +export default { + preset: '../../jest.preset.cjs', + collectCoverageFrom: [ + '**/*.{ts,tsx}', + '!jest.config.{ts,tsx}', + '!**/index.{ts,tsx}', + '!**/*.d.{ts,tsx}', + '!**/*.spec.ts', + '!**/jest.setup*.ts', + ], + coverageDirectory: '../../coverage/libs/questions', + coverageThreshold: { + global: { + branches: 93, + functions: 100, + lines: 98, + statements: 98, + }, + }, + displayName: 'questions', + moduleFileExtensions: ['ts', 'js', 'html'], + testEnvironment: 'node', + testMatch: ['**/*.spec.ts'], + setupFilesAfterEnv: ['/jest.setup.ts'], +} diff --git a/libs/questions/jest.setup.ts b/libs/questions/jest.setup.ts new file mode 100644 index 00000000..04dae87a --- /dev/null +++ b/libs/questions/jest.setup.ts @@ -0,0 +1,5 @@ +/** + * Jest setup for `@hyperfrontend/questions` tests. + */ + +// Add any test setup here diff --git a/libs/questions/package.json b/libs/questions/package.json new file mode 100644 index 00000000..d02e7a50 --- /dev/null +++ b/libs/questions/package.json @@ -0,0 +1,38 @@ +{ + "name": "@hyperfrontend/questions", + "version": "0.0.1", + "description": "Terminal prompting library with composable, functional API for text, select, confirm, and multiselect prompts", + "license": "MIT", + "exports": { + ".": "./src/index.js", + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "keywords": [ + "prompts", + "terminal", + "cli", + "interactive", + "questions", + "input", + "select", + "multiselect", + "confirm", + "functional", + "composable", + "zero-dependencies", + "typescript", + "type-safe" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/AndrewRedican" + }, + "dependencies": { + "@hyperfrontend/immutable-api-utils": "0.1.2" + } +} diff --git a/libs/questions/project.json b/libs/questions/project.json new file mode 100644 index 00000000..8430f5c7 --- /dev/null +++ b/libs/questions/project.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "lib-questions", + "description": "Terminal prompting library with composable, functional API for text, select, confirm, and multiselect prompts", + "sourceRoot": "{projectRoot}/src", + "projectType": "library", + "tags": ["type:util", "scope:public"], + "targets": { + "version": {}, + "version-check": {}, + "build": { + "executor": "@hyperfrontend/package:build", + "options": { + "esm": { + "bundleWorkspaceDeps": true + }, + "cjs": { + "bundleWorkspaceDeps": true + } + } + }, + "publish": {}, + "typecheck": {} + } +} diff --git a/libs/questions/src/index.ts b/libs/questions/src/index.ts new file mode 100644 index 00000000..e2a3eac8 --- /dev/null +++ b/libs/questions/src/index.ts @@ -0,0 +1,22 @@ +/** + * Terminal prompting library with composable, functional API for text, select, confirm, and multiselect prompts. + * + * @module @hyperfrontend/questions + */ +export type { + Choice, + ConfirmConfig, + MultiselectConfig, + PromptCancelledOutcome, + PromptConfig, + PromptFunction, + PromptOutcome, + PromptSubmittedOutcome, + SelectConfig, + TextConfig, +} from './types' +export { confirm } from './prompts/confirm' +export { multiselect } from './prompts/multiselect' +export { select } from './prompts/select' +export { text } from './prompts/text' +export { PromptResult } from './types' diff --git a/libs/questions/src/prompts/confirm.spec.ts b/libs/questions/src/prompts/confirm.spec.ts new file mode 100644 index 00000000..7d7bf35f --- /dev/null +++ b/libs/questions/src/prompts/confirm.spec.ts @@ -0,0 +1,295 @@ +import type { ConfirmConfig } from '../types' +import { PassThrough } from 'node:stream' +import { Key } from '../terminal' +import { PromptResult } from '../types' +import { confirm } from './confirm' + +/** + * Creates a mock input stream for testing. + * + * @returns Mock input stream with raw mode support and enqueue methods + */ +function createMockInput(): PassThrough & { + isRaw: boolean + setRawMode: (mode: boolean) => void + enqueueKeys: (keys: string[]) => void +} { + const input = new PassThrough() as PassThrough & { + isRaw: boolean + setRawMode: (mode: boolean) => void + enqueueKeys: (keys: string[]) => void + } + input.isRaw = false + input.setRawMode = (mode: boolean): void => { + input.isRaw = mode + } + + const keyQueue: string[] = [] + let emitting = false + + const emitNext = (): void => { + if (keyQueue.length > 0 && !emitting) { + emitting = true + const key = keyQueue.shift() + if (key !== undefined) { + setImmediate(() => { + input.emit('data', Buffer.from(key)) + emitting = false + emitNext() + }) + } + } + } + + input.enqueueKeys = (keys: string[]): void => { + keyQueue.push(...keys) + emitNext() + } + + return input +} + +/** + * Creates a mock output stream for testing. + * + * @returns Mock output stream that collects written data + */ +function createMockOutput(): PassThrough & { getWrittenData: () => string } { + const output = new PassThrough() as PassThrough & { getWrittenData: () => string } + let writtenData = '' + + const originalWrite = output.write.bind(output) + output.write = ((chunk: string | Buffer): boolean => { + writtenData += chunk.toString() + return originalWrite(chunk) + }) as typeof output.write + + output.getWrittenData = (): string => writtenData + + return output +} + +describe('confirm', () => { + let input: ReturnType + let output: ReturnType + + beforeEach(() => { + input = createMockInput() + output = createMockOutput() + }) + + afterEach(() => { + input.destroy() + output.destroy() + }) + + const createConfig = (overrides: Partial = {}): ConfirmConfig => ({ + message: 'Continue?', + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + ...overrides, + }) + + describe('yes response', () => { + it('returns true for lowercase y', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['y']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(true) + }) + + it('returns true for uppercase Y', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['Y']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(true) + }) + }) + + describe('no response', () => { + it('returns false for lowercase n', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['n']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(false) + }) + + it('returns false for uppercase N', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['N']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(false) + }) + }) + + describe('cancellation', () => { + it('returns cancelled result on Ctrl+C', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys([Key.CtrlC]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Cancelled) + expect(result.value).toBeUndefined() + }) + }) + + describe('default value', () => { + it('returns initial true on Enter', async () => { + const config = createConfig({ initial: true }) + const promise = confirm(config) + + input.enqueueKeys([Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(true) + }) + + it('returns initial false on Enter', async () => { + const config = createConfig({ initial: false }) + const promise = confirm(config) + + input.enqueueKeys([Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(false) + }) + + it('ignores Enter when no initial provided', async () => { + const config = createConfig({ initial: undefined }) + const promise = confirm(config) + + // why: Enter is ignored, then y is pressed + input.enqueueKeys([Key.Enter, 'y']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(true) + }) + }) + + describe('invalid keys', () => { + it('ignores invalid keys and waits for valid input', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['x', 'z', '1', 'y']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(true) + }) + + it('ignores arrow keys', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys([Key.Up, Key.Down, Key.Left, Key.Right, 'n']) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toBe(false) + }) + }) + + describe('output', () => { + it('writes message to output', async () => { + const config = createConfig({ message: 'Proceed?' }) + const promise = confirm(config) + + input.enqueueKeys(['y']) + await promise + + expect(output.getWrittenData()).toContain('Proceed?') + }) + + it('shows Y/n when initial is true', async () => { + const config = createConfig({ initial: true }) + const promise = confirm(config) + + input.enqueueKeys(['y']) + await promise + + expect(output.getWrittenData()).toContain('Y/n') + }) + + it('shows y/N when initial is false', async () => { + const config = createConfig({ initial: false }) + const promise = confirm(config) + + input.enqueueKeys(['n']) + await promise + + expect(output.getWrittenData()).toContain('y/N') + }) + + it('shows y/n when no initial provided', async () => { + const config = createConfig({ initial: undefined }) + const promise = confirm(config) + + input.enqueueKeys(['y']) + await promise + + expect(output.getWrittenData()).toContain('y/n') + }) + + it('shows Yes when confirmed', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['y']) + await promise + + expect(output.getWrittenData()).toContain('Yes') + }) + + it('shows No when declined', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys(['n']) + await promise + + expect(output.getWrittenData()).toContain('No') + }) + + it('shows cancelled when cancelled', async () => { + const config = createConfig() + const promise = confirm(config) + + input.enqueueKeys([Key.CtrlC]) + await promise + + expect(output.getWrittenData()).toContain('cancelled') + }) + }) +}) diff --git a/libs/questions/src/prompts/confirm.ts b/libs/questions/src/prompts/confirm.ts new file mode 100644 index 00000000..600b24d1 --- /dev/null +++ b/libs/questions/src/prompts/confirm.ts @@ -0,0 +1,98 @@ +/** + * Confirmation prompt. + * + * @module @hyperfrontend/questions/prompts/confirm + */ +import type { PromptOutcome, ConfirmConfig } from '../types' +import { freeze } from '@hyperfrontend/immutable-api-utils/built-in-copy/object' +import { renderMessage, renderSubmitted, renderCancelled, style } from '../render' +import { createTerminal, Ansi, Key } from '../terminal' +import { PromptResult } from '../types' + +/** + * Renders the confirm prompt hint based on default value. + * + * @internal + * @param initial - The default value for the confirmation + * @returns Styled hint string showing Y/n options + */ +function renderOptions(initial: boolean | undefined): string { + if (initial === true) { + return style.dim('(Y/n)') + } + if (initial === false) { + return style.dim('(y/N)') + } + return style.dim('(y/n)') +} + +/** + * Prompts for yes/no confirmation. + * + * Pure functional prompt that asks a yes/no question and returns a boolean. + * Supports default values and responds to y/Y/n/N keys. + * + * @param config - Confirm prompt configuration + * @returns Promise resolving to boolean value or cancellation + * + * @example Basic confirmation + * ```typescript + * const outcome = await confirm({ message: 'Continue?' }) + * if (outcome.result === 'submitted' && outcome.value) { + * console.log('Proceeding...') + * } + * ``` + * + * @example With default value + * ```typescript + * const outcome = await confirm({ + * message: 'Enable feature?', + * initial: true, // Default to yes + * }) + * ``` + */ +export async function confirm(config: ConfirmConfig): Promise> { + const term = createTerminal({ input: config.input, output: config.output }) + + const drawPrompt = (): void => { + term.write(Ansi.CursorStart + Ansi.ClearLine) + term.write(renderMessage(config.message) + renderOptions(config.initial) + ' ') + } + + const drawResult = (value: boolean): void => { + term.write(Ansi.CursorStart + Ansi.ClearLine) + term.write(renderMessage(config.message) + renderSubmitted(value ? 'Yes' : 'No') + '\n') + } + + drawPrompt() + + while (true) { + const key = await term.readKey() + const lowerKey = key.toLowerCase() + + if (term.isCancelled()) { + term.write(Ansi.CursorStart + Ansi.ClearLine) + term.write(renderMessage(config.message) + renderCancelled() + '\n') + term.close() + return freeze({ result: PromptResult.Cancelled, value: undefined }) + } + + if (lowerKey === 'y') { + drawResult(true) + term.close() + return freeze({ result: PromptResult.Submitted, value: true }) + } + + if (lowerKey === 'n') { + drawResult(false) + term.close() + return freeze({ result: PromptResult.Submitted, value: false }) + } + + if (key === Key.Enter && config.initial !== undefined) { + drawResult(config.initial) + term.close() + return freeze({ result: PromptResult.Submitted, value: config.initial }) + } + } +} diff --git a/libs/questions/src/prompts/multiselect-searchable.spec.ts b/libs/questions/src/prompts/multiselect-searchable.spec.ts new file mode 100644 index 00000000..9ccc00b2 --- /dev/null +++ b/libs/questions/src/prompts/multiselect-searchable.spec.ts @@ -0,0 +1,269 @@ +import type { MultiselectConfig, Choice } from '../types' +import { PassThrough } from 'node:stream' +import { Key } from '../terminal' +import { PromptResult } from '../types' +import { multiselect } from './multiselect' + +/** + * Creates a mock input stream for testing. + * + * @returns Mock input stream with raw mode support and enqueue methods + */ +function createMockInput(): PassThrough & { + isRaw: boolean + setRawMode: (mode: boolean) => void + enqueueKeys: (keys: string[]) => void +} { + const input = new PassThrough() as PassThrough & { + isRaw: boolean + setRawMode: (mode: boolean) => void + enqueueKeys: (keys: string[]) => void + } + input.isRaw = false + input.setRawMode = (mode: boolean): void => { + input.isRaw = mode + } + + const keyQueue: string[] = [] + let emitting = false + + const emitNext = (): void => { + if (keyQueue.length > 0 && !emitting) { + emitting = true + const key = keyQueue.shift() + if (key !== undefined) { + setImmediate(() => { + input.emit('data', Buffer.from(key)) + emitting = false + emitNext() + }) + } + } + } + + input.enqueueKeys = (keys: string[]): void => { + keyQueue.push(...keys) + emitNext() + } + + return input +} + +/** + * Creates a mock output stream for testing. + * + * @returns Mock output stream that collects written data + */ +function createMockOutput(): PassThrough & { getWrittenData: () => string } { + const output = new PassThrough() as PassThrough & { getWrittenData: () => string } + let writtenData = '' + + const originalWrite = output.write.bind(output) + output.write = ((chunk: string | Buffer): boolean => { + writtenData += chunk.toString() + return originalWrite(chunk) + }) as typeof output.write + + output.getWrittenData = (): string => writtenData + + return output +} + +const basicChoices: ReadonlyArray> = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, +] + +describe('multiselect searchable mode', () => { + let input: ReturnType + let output: ReturnType + + beforeEach(() => { + input = createMockInput() + output = createMockOutput() + }) + + afterEach(() => { + input.destroy() + output.destroy() + }) + + const createConfig = (overrides: Partial> = {}): MultiselectConfig => + ({ + message: 'Select fruits:', + choices: basicChoices, + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + ...overrides, + }) as MultiselectConfig + + describe('filtering', () => { + it('filters choices by typed text', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: type 'ban' to filter to Banana + input.enqueueKeys(['b', 'a', 'n', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['banana']) + }) + + it('shows no matches message', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + input.enqueueKeys(['x', 'y', 'z', Key.CtrlC]) + await promise + + expect(output.getWrittenData()).toContain('No matches') + }) + + it('clears filter with backspace', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: type 'xy' (no matches), backspace twice, type 'a' to filter + input.enqueueKeys(['x', 'y', Key.Backspace, Key.Backspace, 'a', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + // why: first match for 'a' is Apple + expect(result.value).toEqual(['apple']) + }) + + it('shows filter hint when searchable', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Enter]) + await promise + + expect(output.getWrittenData()).toContain('type to filter') + }) + + it('handles space in search correctly', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: space toggles selection, not adds to filter + input.enqueueKeys(['a', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + + it('backspace does nothing on empty filter', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Backspace, Key.Backspace, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + + it('handles alternate backspace character', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + input.enqueueKeys(['x', '\b', 'a', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + + it('handles non-searchable mode ignoring typed keys', async () => { + const config = createConfig({ searchable: false }) + const promise = multiselect(config) + + // why: typing doesn't filter when not searchable + input.enqueueKeys(['a', 'b', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + + it('resets cursor on filter change', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: navigate down, then type to filter, cursor resets to 0 + input.enqueueKeys([Key.Down, Key.Down, 'c', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + // why: 'c' matches Cherry + expect(result.value).toEqual(['cherry']) + }) + }) + + describe('filtered navigation', () => { + it('handles navigation with empty filtered choices', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: filter to no results, try navigation + input.enqueueKeys(['z', 'z', 'z', Key.Down, Key.Up, Key.Space, Key.CtrlC]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Cancelled) + }) + + it('handles cursor beyond filtered indices on space press', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: Filter to very few results then press space when filtered list is small + input.enqueueKeys(['z', 'z', Key.Space, Key.CtrlC]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Cancelled) + }) + }) + + describe('display', () => { + it('displays search query text in searchable mode', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: Type some characters to show the search query display + input.enqueueKeys(['a', 'p', Key.Space, Key.Enter]) + await promise + + const data = output.getWrittenData() + + // why: Should show both the query and the filter hint + expect(data).toContain('ap') + expect(data).toContain('type to filter') + }) + + it('handles filter clearing empty query via alternate backspace', async () => { + const config = createConfig({ searchable: true }) + const promise = multiselect(config) + + // why: Test alternate backspace on empty query does nothing + input.enqueueKeys(['\b', '\b', Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + }) +}) diff --git a/libs/questions/src/prompts/multiselect.spec.ts b/libs/questions/src/prompts/multiselect.spec.ts new file mode 100644 index 00000000..4d72df8b --- /dev/null +++ b/libs/questions/src/prompts/multiselect.spec.ts @@ -0,0 +1,650 @@ +import type { MultiselectConfig, Choice } from '../types' +import { PassThrough } from 'node:stream' +import { Key } from '../terminal' +import { PromptResult } from '../types' +import { multiselect } from './multiselect' + +/** + * Creates a mock input stream for testing. + * + * @returns Mock input stream with raw mode support and enqueue methods + */ +function createMockInput(): PassThrough & { + isRaw: boolean + setRawMode: (mode: boolean) => void + enqueueKeys: (keys: string[]) => void +} { + const input = new PassThrough() as PassThrough & { + isRaw: boolean + setRawMode: (mode: boolean) => void + enqueueKeys: (keys: string[]) => void + } + input.isRaw = false + input.setRawMode = (mode: boolean): void => { + input.isRaw = mode + } + + const keyQueue: string[] = [] + let emitting = false + + const emitNext = (): void => { + if (keyQueue.length > 0 && !emitting) { + emitting = true + const key = keyQueue.shift() + if (key !== undefined) { + setImmediate(() => { + input.emit('data', Buffer.from(key)) + emitting = false + emitNext() + }) + } + } + } + + input.enqueueKeys = (keys: string[]): void => { + keyQueue.push(...keys) + emitNext() + } + + return input +} + +/** + * Creates a mock output stream for testing. + * + * @returns Mock output stream that collects written data + */ +function createMockOutput(): PassThrough & { getWrittenData: () => string } { + const output = new PassThrough() as PassThrough & { getWrittenData: () => string } + let writtenData = '' + + const originalWrite = output.write.bind(output) + output.write = ((chunk: string | Buffer): boolean => { + writtenData += chunk.toString() + return originalWrite(chunk) + }) as typeof output.write + + output.getWrittenData = (): string => writtenData + + return output +} + +const basicChoices: ReadonlyArray> = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, +] + +describe('multiselect', () => { + let input: ReturnType + let output: ReturnType + + beforeEach(() => { + input = createMockInput() + output = createMockOutput() + }) + + afterEach(() => { + input.destroy() + output.destroy() + }) + + const createConfig = (overrides: Partial> = {}): MultiselectConfig => + ({ + message: 'Select fruits:', + choices: basicChoices, + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + ...overrides, + }) as MultiselectConfig + + describe('selection', () => { + it('submits empty array when nothing selected', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual([]) + }) + + it('toggles selection with space', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + + it('selects multiple items', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Down, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple', 'cherry']) + }) + + it('deselects with second space', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual([]) + }) + + it('uses initial selection', async () => { + const config = createConfig({ initial: [1, 2] }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['banana', 'cherry']) + }) + + it('can modify initial selection', async () => { + const config = createConfig({ initial: [1] }) + const promise = multiselect(config) + + // why: toggle first, desleect second, submit + input.enqueueKeys([Key.Space, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + }) + + describe('navigation', () => { + it('moves down with Down key', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['banana']) + }) + + it('moves up with Up key', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Down, Key.Down, Key.Up, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['banana']) + }) + + it('stops at top when pressing Up', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Up, Key.Up, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple']) + }) + + it('stops at bottom when pressing Down', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Down, Key.Down, Key.Down, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['cherry']) + }) + }) + + describe('cancellation', () => { + it('returns cancelled result on Ctrl+C', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.CtrlC]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Cancelled) + expect(result.value).toBeUndefined() + }) + + it('returns cancelled after partial selection', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Down, Key.Space, Key.CtrlC]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Cancelled) + }) + }) + + describe('disabled choices', () => { + it('ignores space on disabled choice', async () => { + const choices: ReadonlyArray> = [{ label: 'Disabled', value: 'x', disabled: true }] + const config = createConfig({ choices }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual([]) + }) + + it('displays disabled hint in output', async () => { + const choices: ReadonlyArray> = [ + { label: 'Available', value: 'a' }, + { label: 'NotAvailable', value: 'na', disabled: true }, + ] + const config = createConfig({ choices }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + await promise + + expect(output.getWrittenData()).toContain('(disabled)') + }) + + it('handles all disabled choices', async () => { + const choices: ReadonlyArray> = [ + { label: 'A', value: 'a', disabled: true }, + { label: 'B', value: 'b', disabled: true }, + ] + const config = createConfig({ choices }) + const promise = multiselect(config) + + // why: try to navigate and select, should be ignored + input.enqueueKeys([Key.Space, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual([]) + }) + }) + + describe('min/max constraints', () => { + it('enforces minimum selections', async () => { + const config = createConfig({ min: 2 }) + const promise = multiselect(config) + + // why: try submit with 1 selection, shows error, add another + input.enqueueKeys([Key.Space, Key.Enter, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple', 'banana']) + + const data = output.getWrittenData() + + expect(data).toContain('at least 2') + }) + + it('enforces singular minimum message', async () => { + const config = createConfig({ min: 1 }) + const promise = multiselect(config) + + // why: try submit with 0 selections + input.enqueueKeys([Key.Enter, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + + const data = output.getWrittenData() + + expect(data).toContain('at least 1 option') + expect(data).not.toContain('options') + }) + + it('enforces maximum selections', async () => { + const config = createConfig({ max: 2 }) + const promise = multiselect(config) + + // why: select 3, but max is 2 so third is ignored + input.enqueueKeys([Key.Space, Key.Down, Key.Space, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['apple', 'banana']) + }) + + it('shows min/max hints', async () => { + const config = createConfig({ min: 1, max: 3 }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Enter]) + await promise + + const data = output.getWrittenData() + + expect(data).toContain('min: 1') + expect(data).toContain('max: 3') + }) + }) + + describe('scrolling', () => { + const manyChoices: ReadonlyArray> = Array.from({ length: 15 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item${i + 1}`, + })) + + it('scrolls down when navigating beyond visible', async () => { + const config = createConfig({ choices: manyChoices, maxVisible: 5 }) + const promise = multiselect(config) + + const downKeys = Array.from({ length: 7 }, () => Key.Down) + input.enqueueKeys([...downKeys, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual(['item8']) + }) + + it('shows scroll indicators', async () => { + const config = createConfig({ choices: manyChoices, maxVisible: 5 }) + const promise = multiselect(config) + + // why: navigate to middle to show both indicators + input.enqueueKeys([Key.Down, Key.Down, Key.Down, Key.Down, Key.Down, Key.Down, Key.Down, Key.Enter]) + await promise + + const data = output.getWrittenData() + + expect(data).toContain('more above') + expect(data).toContain('more below') + }) + }) + + describe('output', () => { + it('writes message to output', async () => { + const config = createConfig({ message: 'Pick items:' }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + await promise + + expect(output.getWrittenData()).toContain('Pick items:') + }) + + it('shows selected count', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Down, Key.Space, Key.Enter]) + await promise + + expect(output.getWrittenData()).toContain('2 selected') + }) + + it('shows selected labels on submission', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Down, Key.Space, Key.Enter]) + await promise + + const data = output.getWrittenData() + + expect(data).toContain('Apple') + expect(data).toContain('Banana') + }) + + it('shows none when nothing selected', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + await promise + + expect(output.getWrittenData()).toContain('none') + }) + + it('shows cancelled text on cancellation', async () => { + const config = createConfig() + const promise = multiselect(config) + + input.enqueueKeys([Key.CtrlC]) + await promise + + expect(output.getWrittenData()).toContain('cancelled') + }) + }) + + describe('hints', () => { + it('displays hint text', async () => { + const choices: ReadonlyArray> = [ + { label: 'Small', value: 'sm', hint: '1-10 items' }, + { label: 'Large', value: 'lg', hint: '10+ items' }, + ] + const config = createConfig({ choices }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + await promise + + const data = output.getWrittenData() + + expect(data).toContain('1-10 items') + }) + }) + + describe('empty state', () => { + it('handles empty choices array', async () => { + const config = createConfig({ choices: [] }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual([]) + }) + }) + + describe('typed values', () => { + it('works with number values', async () => { + const choices: ReadonlyArray> = [ + { label: 'One', value: 1 }, + { label: 'Two', value: 2 }, + { label: 'Three', value: 3 }, + ] + const config = createConfig({ choices }) + const promise = multiselect(config) + + input.enqueueKeys([Key.Space, Key.Down, Key.Down, Key.Space, Key.Enter]) + + const result = await promise + + expect(result.result).toBe(PromptResult.Submitted) + expect(result.value).toEqual([1, 3]) + }) + + it('works with object values', async () => { + interface Option { + readonly id: number + readonly name: string + } + const choices: ReadonlyArray> = [ + { label: 'Option A', value: { id: 1, name: 'a' } }, + { label: 'Option B', value: { id: 2, name: 'b' } }, + ] + const config = createConfig