diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cf66e85..41ba4e72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,19 +78,25 @@ jobs: # Authenticated command execution against a real WorkOS staging # environment: list plus a create → get → delete round-trip. The secret - # is a dedicated staging-environment API key; fork PRs receive no - # secrets, so this skips there instead of failing. + # is a dedicated staging-environment API key. Only a successful direct + # read-only probe enables live checks; missing/rejected credentials are + # reported as NOT RUN, while other preflight failures fail the job. + - name: Preflight smoke authentication without the CLI + id: smoke-auth-preflight + env: + WORKOS_SMOKE_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} + WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} + run: sh scripts/smoke-auth-preflight.sh + - name: Smoke test authenticated commands + id: authenticated-smoke + if: steps.smoke-auth-preflight.outputs.usable == 'true' env: WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} # Optional: set when the smoke key belongs to a non-default API host # (e.g. an internal staging deployment). WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} run: | - if [ -z "$WORKOS_API_KEY" ]; then - echo "Skipped: WORKOS_SMOKE_API_KEY secret not available (fork PR or not yet configured)" - exit 0 - fi if [ -n "$WORKOS_SMOKE_API_URL" ]; then export WORKOS_API_URL="$WORKOS_SMOKE_API_URL" fi diff --git a/CLAUDE.md b/CLAUDE.md index 94f9957c..3888921b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or ## Architecture -- Three adapters (CLI, Dashboard, Headless) subscribe to `InstallerEventEmitter` state machine events, selected by TTY detection +- Three adapters subscribe to `InstallerEventEmitter` state machine events, chosen by `selectInstallerAdapter()`: Headless in JSON mode; the full-screen TUI (`src/tui/`, Ink) for a human at an interactive terminal of at least 80×24 unless `--no-tui`; CLI otherwise. The TUI adapter wraps the CLI adapter and redirects `ui` output/prompts via `setUiHost()`, so prompt logic lives in one place +- Full-screen installer copy (tips, announcements, walkthrough, task labels) is data in `src/tui/content/installer-content.json`, validated by tests - `OutputMode` (`human`/`json`) resolved once at startup in `bin.ts`, drives all formatting - `installerCanUseTool()` in `agent-interface.ts` restricts Bash to safe commands only - Config/credentials stored in system keyring with file fallback @@ -103,7 +104,7 @@ All commands automatically emit a `command` telemetry event with name, duration, **Exiting with errors:** Use `exitWithError()` or `exitWithCode()` from handlers — they throw `CliExit` which the lifecycle catches, classifies, and records. -**Skip list**: Commands in `SKIP_TELEMETRY_COMMANDS` (`command-telemetry.ts`) are excluded from command-level telemetry because they have their own session-based telemetry. Currently: `install`, `dashboard`, `root` (the default `$0` handler). +**Skip list**: Commands in `SKIP_TELEMETRY_COMMANDS` (`command-telemetry.ts`) are excluded from command-level telemetry because they have their own session-based telemetry. Currently: `install`, `root` (the default `$0` handler). **Aliases**: if you register a command with multiple names (e.g., `['organization', 'org']`), add the alias to `src/lib/command-aliases.ts` so metrics don't fragment. @@ -111,7 +112,7 @@ All commands automatically emit a `command` telemetry event with name, duration, **Do:** -- Follow the adapter pattern (`CLI`, `Dashboard`, `Headless`) in `src/integrations/` when adding framework installers +- Follow the adapter pattern (`CLI`, `TUI`, `Headless`) in `src/integrations/` when adding framework installers - Use `InstallerEventEmitter` for state machine events -- see existing adapters for examples - Add both human and JSON output modes -- check `OutputMode` usage in `src/bin.ts` - Follow existing command patterns in `src/commands/organization.ts` when adding resource commands diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 33ddd406..35377663 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -19,7 +19,7 @@ src/ │ ├── workos-api.ts # Generic WorkOS REST API client │ ├── credential-proxy.ts # Token refresh proxy for long sessions │ ├── ensure-auth.ts # Startup auth guard -│ └── adapters/ # CLI and dashboard adapters +│ └── adapters/ # CLI, full-screen (TUI), and headless adapters ├── commands/ │ ├── env.ts # workos env (add/remove/switch/list) │ ├── organization.ts # workos organization (create/update/get/list/delete) @@ -29,7 +29,9 @@ src/ │ ├── auth-status.ts # workos auth status │ ├── login.ts # workos auth login │ └── logout.ts # workos auth logout -├── dashboard/ # Ink/React TUI components +├── tui/ # Full-screen installer (Ink/React) +│ ├── content/ # Swappable tips, news, walkthrough copy (see README there) +│ └── model/ # Event-driven run model (no Ink) ├── nextjs/ # Next.js installer agent ├── react/ # React SPA installer agent ├── react-router/ # React Router installer agent @@ -61,7 +63,7 @@ bun run dev # Test installer in another project cd /path/to/test/nextjs-app -workos dashboard +workos install # Test management commands workos env add sandbox sk_test_xxx @@ -94,7 +96,6 @@ bun run test:watch - **Target:** ES2022 - **Module:** NodeNext (ESM) - **Strict mode** enabled -- **JSX:** react-jsx (for Ink/React dashboard) ## Output Mode vs Interaction Mode @@ -151,18 +152,15 @@ hook) guarantees. ### Why `react-devtools-core` is a devDependency Nothing in `src/` imports `react-devtools-core`, but it is required to -**build**, not to run. The dashboard TUI (`src/dashboard/`) uses `ink`, whose +**build**, not to run. The full-screen installer (`src/tui/`) uses `ink`, whose reconciler does a runtime-gated `await import('./devtools.js')` that only fires when `DEV=true`; `devtools.js` then _statically_ imports `react-devtools-core`. `bun build --compile` follows that static import at bundle time and cannot prove the `DEV` branch is dead, so removing the devDependency fails the compile with -`error: Could not resolve: "react-devtools-core"` (do not "clean it up"). As a -result it is also bundled into every shipped binary, costing ~742 KiB -(measured: 72,512,032 → 71,752,480 bytes when excluded). Do **not** try to trim -it with `--external react-devtools-core`: that compiles, but bun resolves the -external eagerly and the standalone binary then crashes on _every_ command -(even `--version`) with `Cannot find package 'react-devtools-core'`. The ~742 KiB -is the price of keeping the compile green and the dev-mode fallback graceful. +`error: Could not resolve: "react-devtools-core"` (do not "clean it up"). Do +**not** try to trim it with `--external react-devtools-core`: that compiles, but +bun resolves the external eagerly and the standalone binary then crashes on +_every_ command (even `--version`) with `Cannot find package 'react-devtools-core'`. ### Updating Integration Instructions @@ -190,10 +188,20 @@ export function redactCredentials(obj: any): any { **Manual testing:** -1. Run installer in a test app: `workos dashboard` +1. Run installer in a test app: `workos install` (full-screen), and `workos install --no-tui` (plain) 2. Check logs at `~/.workos/logs/workos-{timestamp}.log` 3. Verify integration works in test app +**Full-screen installer:** the view lives in `src/tui/`. Its text (tips, news, +walkthrough copy, task labels) is data in `src/tui/content/installer-content.json`; +edit that and run `bun run test src/tui/content`. The adapter +(`src/lib/adapters/tui-adapter.ts`) wraps the CLI adapter, so prompt behavior +never forks: it routes `ui` output and prompts into the view through +`setUiHost()`. Render tests draw into fake streams +(`src/tui/ink-streams.test-utils.ts`); check a real terminal at about 80×24 and +in a large window, and confirm the terminal is restored after success, failure, +and ctrl-c. + **What to test:** - Framework detection diff --git a/README.md b/README.md index a989799c..3c6ae363 100644 --- a/README.md +++ b/README.md @@ -550,9 +550,21 @@ workos install [options] --create-pr Auto-create pull request after installation --no-git-check Skip git dirty working tree check --force-install Force install packages even if peer dependency checks fail + --no-tui Use plain line-by-line output instead of the full-screen installer --debug Enable verbose logging ``` +**Full-screen installer:** In an interactive terminal of at least 80×24, +`workos install` opens a full-screen view: a plain-English walkthrough of what +it's doing, a task list beside it that follows the dashboard's AuthKit +checklist, and tips and WorkOS news, with every question asked inline. Below +100×30 the task list becomes a single progress line under the walkthrough. When +it closes, your answers, the settings it made, any warnings or errors, and the +same summary a plain run prints stay in your scrollback; the agent's +step-by-step output is left to the log in `~/.workos/logs/`. Pass `--no-tui` for +plain output. Smaller terminals, piped input/output, CI and agent modes, and +`--json` always use plain or JSON output. + **Empty directories:** Running `workos install` in an empty directory scaffolds a new Next.js app with `create-next-app` (App Router, TypeScript, Tailwind, `src/`) and then wires AuthKit into it. This only happens when the directory is empty or contains nothing but VCS/editor metadata (`.git`, `.gitignore`, `LICENSE`, `.idea`, and similar). Any project file — including a `README.md` or a `package.json` — opts out, and the installer treats the directory as an existing project. Interactive runs confirm first (default yes); non-interactive/headless runs (or `--scaffold`) scaffold automatically and report `"scaffolded": true`. The package manager is resolved from how you invoked the CLI (`npm_config_user_agent`) unless you pass `--pm`. ## Examples @@ -564,9 +576,6 @@ workos install # Greenfield: scaffold a new Next.js app + AuthKit in an empty directory mkdir my-app && cd my-app && workos install -# With visual dashboard (experimental) -workos dashboard - # JSON output (explicit; requires a prior `workos auth login`) workos org list --json @@ -715,7 +724,7 @@ OAuth credentials are stored in the system keychain (with `~/.workos/credentials The CLI collects anonymous usage telemetry to help improve the product: -- **Command events** -- command name, duration, success/failure, termination reason, and which flags were used (for telemetry-enabled commands; `install` and `dashboard` use session events instead) +- **Command events** -- command name, duration, success/failure, termination reason, and which flags were used (for telemetry-enabled commands; `install` uses session events instead) - **Session events** -- framework detected, step timing, token usage (installer only) - **Crash events** -- sanitized error type and stack trace (no secrets, truncated to 4KB) diff --git a/bun.lock b/bun.lock index ffc6b664..8396c481 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.124.0", + "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^10.13.0", @@ -22,6 +23,7 @@ "open": "^11.0.0", "react": "^19.2.4", "semver": "^7.7.4", + "string-width": "^8.2.2", "uuid": "^13.0.0", "xstate": "^5.28.0", "yaml": "^2.8.2", @@ -122,6 +124,8 @@ "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + "@inkjs/ui": ["@inkjs/ui@2.0.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-spinners": "^3.0.0", "deepmerge": "^4.3.1", "figures": "^6.1.0" }, "peerDependencies": { "ink": ">=5" } }, "sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], @@ -340,7 +344,7 @@ "@types/opn": ["@types/opn@5.5.0", "", { "dependencies": { "opn": "*" } }, "sha512-rfEmrSa/x0vArY1aFnVTBAmy6b2I0oNHONL59qR+vnsHfD5xacM40O4PSiVisERq/GmROjmS3Xo/ptp8fx823g=="], - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], @@ -430,6 +434,8 @@ "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -468,6 +474,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -502,7 +510,7 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -556,6 +564,8 @@ "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -636,6 +646,8 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], @@ -790,7 +802,7 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -896,7 +908,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], @@ -962,7 +974,7 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "react-devtools-core/ws": ["ws@7.5.12", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg=="], + "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], diff --git a/package.json b/package.json index 58b40a5b..3ee7876b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.124.0", + "@inkjs/ui": "^2.0.0", "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^10.13.0", @@ -39,6 +40,7 @@ "open": "^11.0.0", "react": "^19.2.4", "semver": "^7.7.4", + "string-width": "^8.2.2", "uuid": "^13.0.0", "xstate": "^5.28.0", "yaml": "^2.8.2", diff --git a/scripts/command-smoke.sh b/scripts/command-smoke.sh index 65fa2f2b..3e831c3e 100644 --- a/scripts/command-smoke.sh +++ b/scripts/command-smoke.sh @@ -116,7 +116,7 @@ pages_project="$SANDBOX/pages-project" mkdir -p "$pages_project/src/pages" printf '%s\n' 'export default function App() {}' >"$pages_project/src/pages/_app.tsx" printf '%s\n' '{"dependencies":{"next":"16.0.0"}}' >"$pages_project/package.json" -for command in install integrate dashboard; do +for command in install integrate; do err=$("$BIN" "$command" --install-dir "$pages_project" --force --router app --json --insecure-storage 2>&1 >/dev/null) code=$? case "$err" in diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh new file mode 100644 index 00000000..9adbeba0 --- /dev/null +++ b/scripts/smoke-auth-preflight.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Read-only credential eligibility check. Never log credentials or API payloads. +set +x +set -eu + +report() { + printf '%s\n' "$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf 'usable=%s\n' "$1" >> "$GITHUB_OUTPUT" + fi + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '%s\n' "$2" >> "$GITHUB_STEP_SUMMARY" + fi +} + +if [ -z "${WORKOS_SMOKE_API_KEY:-}" ]; then + report false '::warning::Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' + exit 0 +fi + +base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" +curl_exit=0 +# -q must be first: ignore curlrc (including redirects, retries and tracing). +# No --location, retries, response headers, body, or curl diagnostics. +status=$(curl -q --silent --connect-timeout 10 --max-time 20 \ + --proto '=http,https' --request GET \ + --header "Authorization: Bearer $WORKOS_SMOKE_API_KEY" \ + --output /dev/null --write-out '%{http_code}' \ + "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? + +if [ "$curl_exit" -ne 0 ]; then + report false '::error::Smoke credential preflight transport failure. Live authenticated checks NOT RUN; investigate credential/API connectivity.' + exit 1 +fi + +case "$status" in + 200) + report true 'Smoke credential preflight HTTP 200: eligible for live authenticated checks; this is not a live smoke pass.' + ;; + 401) + report false '::warning::Smoke credential preflight HTTP 401: live authenticated checks NOT RUN. Investigate the CI credential/API configuration; live checks have not passed.' + ;; + *) + report false "::error::Smoke credential preflight HTTP $status (expected 200 or 401). Live authenticated checks NOT RUN; investigate the API response." + exit 1 + ;; +esac diff --git a/scripts/smoke-auth-preflight.spec.ts b/scripts/smoke-auth-preflight.spec.ts new file mode 100644 index 00000000..8c443d63 --- /dev/null +++ b/scripts/smoke-auth-preflight.spec.ts @@ -0,0 +1,185 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +const script = resolve('scripts/smoke-auth-preflight.sh'); +const key = 'fake-localhost-only-secret'; +const body = 'private-response-body'; +const header = 'private-response-header'; +const servers: Server[] = []; +const dirs: string[] = []; + +async function fixture(status: number, disconnect = false) { + const requests: { method?: string; url?: string; authorization?: string }[] = []; + const server = createServer((req, res) => { + requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization }); + if (disconnect) { + req.socket.destroy(); + return; + } + res.writeHead(status, { Location: '/redirect-target', 'X-Private': header }); + res.end(`${body} ${key}`); + }); + servers.push(server); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing local address'); + return { url: `http://127.0.0.1:${address.port}`, requests }; +} + +async function run(url: string, credential = key, githubFiles = true) { + const dir = await mkdtemp(join(tmpdir(), 'smoke-preflight-')); + dirs.push(dir); + const output = join(dir, 'output'); + const summary = join(dir, 'summary'); + // A user curlrc must not enable redirects, retries, or verbose secret logging. + await writeFile(join(dir, '.curlrc'), 'location\nverbose\nretry = 2\n'); + await writeFile(output, ''); + await writeFile(summary, ''); + const result = await new Promise<{ code: number; stdout: string; stderr: string }>((done) => { + execFile( + 'sh', + [script], + { + timeout: 5000, + // Do not inherit credentials, proxy configuration or curl settings. + env: { + PATH: process.env.PATH, + HOME: dir, + CURL_HOME: dir, + NO_PROXY: '*', + WORKOS_SMOKE_API_KEY: credential, + WORKOS_SMOKE_API_URL: `${url}/`, + ...(githubFiles ? { GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary } : {}), + }, + }, + (error, stdout, stderr) => { + done({ code: error ? (typeof error.code === 'number' ? error.code : -1) : 0, stdout, stderr }); + }, + ); + }); + const outputs = await readFile(output, 'utf8'); + const notes = await readFile(summary, 'utf8'); + const logs = result.stdout + result.stderr + outputs + notes; + for (const sensitive of [key, body, header, 'Authorization:', 'X-Private:']) { + expect(logs).not.toContain(sensitive); + } + expect(result.stderr).toBe(''); + return { ...result, outputs, notes }; +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((done, reject) => { + server.close((error) => (error ? reject(error) : done())); + }), + ), + ); + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('smoke authentication preflight (localhost only)', () => { + it('enables live smoke only for HTTP 200 with a single authenticated GET', async () => { + const local = await fixture(200); + const result = await run(local.url); + expect(result.code).toBe(0); + expect(result.outputs).toBe('usable=true\n'); + expect(result.notes).toContain('not a live smoke pass'); + expect(local.requests).toEqual([ + { + method: 'GET', + url: '/connections?limit=1', + authorization: `Bearer ${key}`, + }, + ]); + }); + + it('transparently skips rejected credentials with a GitHub warning and summary', async () => { + const local = await fixture(401); + const result = await run(local.url); + expect(result.code).toBe(0); + expect(result.outputs).toBe('usable=false\n'); + expect(result.stdout).toContain('::warning::'); + expect(result.notes).toContain('HTTP 401'); + expect(result.notes).toContain('NOT RUN'); + expect(result.notes).toContain('Investigate the CI credential/API'); + expect(local.requests).toHaveLength(1); + }); + + it('skips missing credentials without a request', async () => { + const local = await fixture(200); + const result = await run(local.url, ''); + expect(result.code).toBe(0); + expect(result.outputs).toBe('usable=false\n'); + expect(result.notes).toContain('NOT RUN'); + expect(result.notes).toContain('Credential/API configuration needs investigation'); + expect(result.stdout).toContain('::warning::'); + expect(local.requests).toHaveLength(0); + }); + + it.each([204, 301, 302, 307, 308, 403, 429, 500, 503])( + 'fails closed for HTTP %s, without following or retrying', + async (status) => { + const local = await fixture(status); + const result = await run(local.url); + expect(result.code).toBe(1); + expect(result.outputs).toBe('usable=false\n'); + expect(result.stdout).toContain('::error::'); + expect(result.notes).toContain('NOT RUN'); + expect(local.requests).toHaveLength(1); + expect(local.requests[0].url).toBe('/connections?limit=1'); + }, + ); + + it('fails red on transport failure', async () => { + const local = await fixture(200, true); + const result = await run(local.url); + expect(result.code).toBe(1); + expect(result.outputs).toBe('usable=false\n'); + expect(result.notes).toContain('transport failure'); + expect(local.requests).toHaveLength(1); + }); + + it('also works without GitHub output files', async () => { + const local = await fixture(200); + const result = await run(local.url, key, false); + expect(result.code).toBe(0); + expect(result.stdout).toContain('eligible'); + }); + + it('gates the existing live CLI smoke without suppressing its failures', async () => { + const workflow = parse(await readFile('.github/workflows/test.yml', 'utf8')); + const steps = workflow.jobs.test.steps; + const preflight = steps.findIndex((step: { id?: string }) => step.id === 'smoke-auth-preflight'); + const live = steps.findIndex((step: { id?: string }) => step.id === 'authenticated-smoke'); + expect(preflight).toBeGreaterThan(-1); + expect(live).toBeGreaterThan(preflight); + expect(steps[preflight].run).toBe('sh scripts/smoke-auth-preflight.sh'); + expect(steps[live].if).toBe("steps.smoke-auth-preflight.outputs.usable == 'true'"); + expect(steps[live]['continue-on-error']).toBeUndefined(); + expect(steps[preflight]['continue-on-error']).toBeUndefined(); + expect(steps[live].env.WORKOS_API_KEY).toBe(steps[preflight].env.WORKOS_SMOKE_API_KEY); + expect(steps[live].env.WORKOS_SMOKE_API_URL).toBe(steps[preflight].env.WORKOS_SMOKE_API_URL); + expect(steps[live].run.trim().endsWith('sh scripts/command-smoke.sh ./dist/workos')).toBe(true); + // Execute the workflow wrapper with a failing local CLI-smoke stand-in. + // No CLI, credentials, or network are used; its actual exit must survive. + const exitCode = await new Promise((done) => { + execFile( + 'sh', + ['-e', '-c', `sh() { return 17; };\n${steps[live].run}`], + { env: { PATH: process.env.PATH, WORKOS_SMOKE_API_URL: 'http://127.0.0.1:1' } }, + (error) => done(error && typeof error.code === 'number' ? error.code : 0), + ); + }); + expect(exitCode).toBe(17); + expect(steps.some((step: { name?: string }) => step.name === 'Diagnose smoke authentication without the CLI')).toBe( + false, + ); + }); +}); diff --git a/src/bin.ts b/src/bin.ts index e1f10e2b..0f032e2c 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -167,6 +167,15 @@ const forceOption = { }, } as const; +/** Shared by `install` and the bare `workos` installer prompt. */ +const tuiOption = { + tui: { + default: true, + describe: 'Use the full-screen installer in an interactive terminal (use --no-tui for plain output)', + type: 'boolean' as const, + }, +} as const; + const installerOptions = { direct: { alias: 'D', @@ -232,12 +241,6 @@ const installerOptions = { describe: 'Force install packages even if peer dependency checks fail', type: 'boolean' as const, }, - dashboard: { - alias: 'd', - default: false, - describe: 'Run with visual dashboard mode', - type: 'boolean' as const, - }, branch: { default: true, describe: 'Create a new branch for changes (use --no-branch to skip)', @@ -274,6 +277,7 @@ const installerOptions = { type: 'string' as const, }, ...forceOption, + ...tuiOption, }; // Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean) @@ -353,7 +357,7 @@ async function runCli(): Promise { }) .middleware(async (argv) => { // Warn about unclaimed environments before management commands. - // Excluded: auth/claim/install/setup/dashboard handle their own credential + // Excluded: auth/claim/install/setup handle their own credential // or onboarding flows; skills/doctor/profile/debug are utility commands // where the warning is unnecessary. Both the canonical name and its // alias are listed — this matches argv._[0], the token as typed. @@ -372,7 +376,6 @@ async function runCli(): Promise { 'setup', 'debug', 'internal', - 'dashboard', 'emulate', 'dev', 'migrations', @@ -3402,30 +3405,12 @@ async function runCli(): Promise { await runMigrations(passthrough, resolveOptionalApiKey({ apiKey: argv.apiKey }), endpoint); }, ) - .command( - 'dashboard', - false, // hidden from help - (yargs) => yargs.options(installerOptions), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - // Guard first, before credential resolution — see the `install` handler above. - const preflight = await import('./lib/preflight-authkit.js'); - await preflight.assertInstallPreflight({ - installDir: argv.installDir ?? process.cwd(), - force: argv.force, - router: argv.router, - }); - await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated); - const { handleInstall } = await import('./commands/install.js'); - await handleInstall({ ...argv, dashboard: true }); - }, - ) .command( ['$0'], 'WorkOS AuthKit CLI', // `--force` must be registered here too: this parser is .strict(), so // `npx workos --force` would die as an unknown argument otherwise. - (yargs) => yargs.options({ ...insecureStorageOption, ...forceOption }), + (yargs) => yargs.options({ ...insecureStorageOption, ...forceOption, ...tuiOption }), async (argv) => { // Non-human modes: emit machine-readable command tree (JSON) or the // fully-configured parser help (human non-TTY edge) instead of prompting. @@ -3459,7 +3444,7 @@ async function runCli(): Promise { await resolveInstallCredentials(undefined, undefined, false, ensureAuthenticated); const { handleInstall } = await import('./commands/install.js'); - await handleInstall({ ...argv, dashboard: false }); + await handleInstall(argv); }, ) .strict() diff --git a/src/dashboard/components/AnimatedLogo.tsx b/src/dashboard/components/AnimatedLogo.tsx deleted file mode 100644 index 29b48f14..00000000 --- a/src/dashboard/components/AnimatedLogo.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useMemo } from 'react'; -import { Box, Text } from 'ink'; -import { useAnimation } from '../hooks/useAnimation.js'; -import { getFrames, FRAME_DELAYS, type AnimationMode } from '../lib/logo-frames.js'; - -interface AnimatedLogoProps { - mode?: AnimationMode; - paused?: boolean; -} - -export function AnimatedLogo({ mode = 'spin', paused = false }: AnimatedLogoProps): React.ReactElement { - const frames = useMemo(() => getFrames(mode), [mode]); - const frameIndex = useAnimation({ - frameCount: frames.length, - frameDelayMs: FRAME_DELAYS[mode], - paused, - }); - - const frame = frames[frameIndex]; - - return ( - - {frame.lines.map((line, i) => ( - {`\x1b[${frame.color}m${line}\x1b[0m`} - ))} - - WorkOS Installer - - - ); -} diff --git a/src/dashboard/components/CompletionView.tsx b/src/dashboard/components/CompletionView.tsx deleted file mode 100644 index 525f8794..00000000 --- a/src/dashboard/components/CompletionView.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Text, useInput, useApp } from 'ink'; -import { getLockArt, type LockExpression } from '../../utils/lock-art.js'; - -interface OutputLine { - text: string; - isError?: boolean; - isStatus?: boolean; -} - -interface CompletionViewProps { - success: boolean; - summary?: string; - outputLog: OutputLine[]; -} - -export function CompletionView({ success, summary, outputLog }: CompletionViewProps): React.ReactElement { - const { exit } = useApp(); - const [scrollOffset, setScrollOffset] = useState(Math.max(0, outputLog.length - 20)); - - useInput((input, key) => { - if (key.upArrow) { - setScrollOffset((prev) => Math.max(0, prev - 1)); - } else if (key.downArrow) { - setScrollOffset((prev) => Math.min(outputLog.length - 1, prev + 1)); - } else if (key.return || input === 'q') { - exit(); - } - }); - - const visibleLines = outputLog.slice(scrollOffset, scrollOffset + 20); - - const expression: LockExpression = success ? 'success' : 'error'; - const lockLines = getLockArt(expression, false); - const color = success ? 'green' : 'red'; - - return ( - - {/* Lock + Header */} - - - {lockLines.map((line, i) => ( - - {line} - - ))} - - - - {success ? 'WorkOS AuthKit Installed' : 'Installation Failed'} - - {summary && {summary}} - - - - {/* Scrollable Log */} - - - Output Log [{scrollOffset + 1}-{scrollOffset + visibleLines.length} of {outputLog.length}] - - {visibleLines.map((line, i) => ( - - {line.text} - - ))} - - - {/* Footer */} - - ↑/↓ to scroll, Enter or Q to exit - - - ); -} diff --git a/src/dashboard/components/ConfirmPrompt.tsx b/src/dashboard/components/ConfirmPrompt.tsx deleted file mode 100644 index a5147b82..00000000 --- a/src/dashboard/components/ConfirmPrompt.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; - -interface ConfirmPromptProps { - message: string; - warning?: string; - files?: string[]; - onConfirm: (confirmed: boolean) => void; -} - -export function ConfirmPrompt({ message, warning, files, onConfirm }: ConfirmPromptProps): React.ReactElement { - const [selected, setSelected] = useState<'yes' | 'no'>('yes'); - - useInput((input, key) => { - if (key.leftArrow || input === 'h') { - setSelected('yes'); - } else if (key.rightArrow || input === 'l') { - setSelected('no'); - } else if (input === 'y' || input === 'Y') { - onConfirm(true); - } else if (input === 'n' || input === 'N') { - onConfirm(false); - } else if (key.return) { - onConfirm(selected === 'yes'); - } - }); - - return ( - - {warning && ( - - - ⚠ {warning} - - - )} - - {files && files.length > 0 && ( - - {files.slice(0, 10).map((file, i) => ( - - {file} - - ))} - {files.length > 10 && ...and {files.length - 10} more} - - )} - - - {message} - - - - - {selected === 'yes' ? '● ' : '○ '}Yes - - - - {selected === 'no' ? '● ' : '○ '}No - - - - - ←/→ or y/n to select | Enter to confirm - - - ); -} diff --git a/src/dashboard/components/CredentialsForm.tsx b/src/dashboard/components/CredentialsForm.tsx deleted file mode 100644 index d477573e..00000000 --- a/src/dashboard/components/CredentialsForm.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { TextInput } from './TextInput.js'; - -interface CredentialsFormProps { - requiresApiKey: boolean; - onSubmit: (credentials: { apiKey: string; clientId: string }) => void; -} - -type Field = 'apiKey' | 'clientId'; - -export function CredentialsForm({ requiresApiKey, onSubmit }: CredentialsFormProps): React.ReactElement { - const [apiKey, setApiKey] = useState(''); - const [clientId, setClientId] = useState(''); - const [focusedField, setFocusedField] = useState(requiresApiKey ? 'apiKey' : 'clientId'); - const [errors, setErrors] = useState<{ apiKey?: string; clientId?: string }>({}); - - const validate = (): boolean => { - const newErrors: { apiKey?: string; clientId?: string } = {}; - - if (requiresApiKey && !apiKey) { - newErrors.apiKey = 'API Key is required'; - } else if (requiresApiKey && !apiKey.startsWith('sk_')) { - newErrors.apiKey = 'API Key should start with sk_'; - } - - if (!clientId) { - newErrors.clientId = 'Client ID is required'; - } else if (!clientId.startsWith('client_')) { - newErrors.clientId = 'Client ID should start with client_'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = () => { - if (validate()) { - onSubmit({ apiKey, clientId }); - } - }; - - // Tab/arrows to switch fields - useInput((input, key) => { - if (key.tab || key.downArrow) { - if (requiresApiKey) { - setFocusedField((prev) => (prev === 'apiKey' ? 'clientId' : 'apiKey')); - } - } else if (key.upArrow) { - if (requiresApiKey) { - setFocusedField((prev) => (prev === 'clientId' ? 'apiKey' : 'clientId')); - } - } - }); - - return ( - - - - Enter your WorkOS credentials - - - - Get them from https://dashboard.workos.com - - - - {requiresApiKey && ( - - setFocusedField('clientId')} - focused={focusedField === 'apiKey'} - error={errors.apiKey} - /> - - )} - - - - - - - - {requiresApiKey ? 'Tab: switch fields | ' : ''}Ctrl+U: clear | Enter: continue - - - ); -} diff --git a/src/dashboard/components/Dashboard.tsx b/src/dashboard/components/Dashboard.tsx deleted file mode 100644 index 58102ed9..00000000 --- a/src/dashboard/components/Dashboard.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useInput } from 'ink'; -import { DashboardLayout } from './DashboardLayout.js'; -import { CompletionView } from './CompletionView.js'; -import type { DashboardProps } from '../types.js'; - -type DashboardState = 'running' | 'complete'; -type FocusedPanel = 'changes' | 'output'; - -interface OutputLine { - text: string; - isError?: boolean; - isStatus?: boolean; -} - -interface ConfirmRequest { - id: string; - message: string; - warning?: string; - files?: string[]; -} - -export function Dashboard({ emitter }: DashboardProps): React.ReactElement { - const [state, setState] = useState('running'); - const [focusedPanel, setFocusedPanel] = useState('changes'); - const [completionData, setCompletionData] = useState<{ - success: boolean; - summary?: string; - } | null>(null); - const [outputLog, setOutputLog] = useState([]); - const [confirmRequest, setConfirmRequest] = useState(null); - const [credentialsRequest, setCredentialsRequest] = useState<{ - requiresApiKey: boolean; - } | null>(null); - - // Tab to switch focus between panels (only when not in a prompt) - useInput( - (input, key) => { - if (key.tab) { - setFocusedPanel((prev) => (prev === 'changes' ? 'output' : 'changes')); - } - }, - { isActive: state === 'running' && !credentialsRequest && !confirmRequest }, - ); - - useEffect(() => { - const handleOutput = ({ text, isError }: { text: string; isError?: boolean }) => { - const newLines = text.split('\n').map((line) => ({ - text: line, - isError, - isStatus: line.includes('[STATUS]'), - })); - setOutputLog((prev) => [...prev, ...newLines]); - }; - - const handleStatus = ({ message }: { message: string }) => { - setOutputLog((prev) => [...prev, { text: `[STATUS] ${message}`, isStatus: true }]); - }; - - const handleComplete = ({ success, summary }: { success: boolean; summary?: string }) => { - setCompletionData({ success, summary }); - setState('complete'); - }; - - const handleError = ({ message }: { message: string }) => { - setOutputLog((prev) => [...prev, { text: `ERROR: ${message}`, isError: true }]); - }; - - const handleCredentialsRequest = ({ requiresApiKey }: { requiresApiKey: boolean }) => { - setCredentialsRequest({ requiresApiKey }); - }; - - const handleConfirmRequest = ({ - id, - message, - warning, - files, - }: { - id: string; - message: string; - warning?: string; - files?: string[]; - }) => { - setConfirmRequest({ id, message, warning, files }); - }; - - const handleValidationStart = () => { - setOutputLog((prev) => [...prev, { text: '[STATUS] Validating installation...', isStatus: true }]); - }; - - const handleValidationIssues = ({ - issues, - }: { - issues: Array<{ severity: string; message: string; hint?: string }>; - }) => { - for (const issue of issues) { - const prefix = issue.severity === 'error' ? '!' : '?'; - setOutputLog((prev) => [...prev, { text: `${prefix} ${issue.message}`, isError: issue.severity === 'error' }]); - if (issue.hint) { - setOutputLog((prev) => [...prev, { text: ` Hint: ${issue.hint}` }]); - } - } - }; - - const handleValidationComplete = ({ passed, issueCount }: { passed: boolean; issueCount: number }) => { - if (passed) { - setOutputLog((prev) => [...prev, { text: '[STATUS] Validation passed', isStatus: true }]); - } else { - setOutputLog((prev) => [...prev, { text: `[STATUS] Validation found ${issueCount} issue(s)`, isStatus: true }]); - } - }; - - emitter.on('output', handleOutput); - emitter.on('status', handleStatus); - emitter.on('complete', handleComplete); - emitter.on('error', handleError); - emitter.on('credentials:request', handleCredentialsRequest); - emitter.on('confirm:request', handleConfirmRequest); - emitter.on('validation:start', handleValidationStart); - emitter.on('validation:issues', handleValidationIssues); - emitter.on('validation:complete', handleValidationComplete); - - return () => { - emitter.off('output', handleOutput); - emitter.off('status', handleStatus); - emitter.off('complete', handleComplete); - emitter.off('error', handleError); - emitter.off('credentials:request', handleCredentialsRequest); - emitter.off('confirm:request', handleConfirmRequest); - emitter.off('validation:start', handleValidationStart); - emitter.off('validation:issues', handleValidationIssues); - emitter.off('validation:complete', handleValidationComplete); - }; - }, [emitter]); - - const handleConfirm = (confirmed: boolean) => { - if (confirmRequest) { - emitter.emit('confirm:response', { id: confirmRequest.id, confirmed }); - setConfirmRequest(null); - } - }; - - const handleCredentialsSubmit = (credentials: { apiKey: string; clientId: string }) => { - emitter.emit('credentials:response', credentials); - setCredentialsRequest(null); - }; - - if (state === 'complete' && completionData) { - return ; - } - - return ( - - ); -} diff --git a/src/dashboard/components/DashboardLayout.tsx b/src/dashboard/components/DashboardLayout.tsx deleted file mode 100644 index b53d24d2..00000000 --- a/src/dashboard/components/DashboardLayout.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import { useTerminalSize, MIN_COLUMNS, MIN_ROWS } from '../hooks/useTerminalSize.js'; -import { Panel } from './Panel.js'; -import { OutputPanel } from './OutputPanel.js'; -import { DiffPanel } from './DiffPanel.js'; -import { AnimatedLogo } from './AnimatedLogo.js'; -import { CredentialsForm } from './CredentialsForm.js'; -import { ConfirmPrompt } from './ConfirmPrompt.js'; -import type { InstallerEventEmitter } from '../../lib/events.js'; - -type FocusedPanel = 'changes' | 'output'; - -interface ConfirmRequest { - id: string; - message: string; - warning?: string; - files?: string[]; -} - -interface DashboardLayoutProps { - emitter: InstallerEventEmitter; - focusedPanel?: FocusedPanel; - confirmRequest?: ConfirmRequest | null; - onConfirm?: (confirmed: boolean) => void; - credentialsRequest?: { requiresApiKey: boolean } | null; - onCredentialsSubmit?: (credentials: { apiKey: string; clientId: string }) => void; -} - -export function DashboardLayout({ - emitter, - focusedPanel = 'changes', - confirmRequest, - onConfirm, - credentialsRequest, - onCredentialsSubmit, -}: DashboardLayoutProps): React.ReactElement { - const { columns, rows } = useTerminalSize(); - - // Check minimum size - if (columns < MIN_COLUMNS || rows < MIN_ROWS) { - return ( - - Terminal too small - - Minimum size: {MIN_COLUMNS}x{MIN_ROWS} - - - Current size: {columns}x{rows} - - - ); - } - - // Calculate explicit heights - reserve 1 row for help hint - const availableRows = rows - 1; - const topHeight = Math.floor(availableRows * 0.6); - const bottomHeight = availableRows - topHeight; - - // Calculate widths for bottom panels - const outputWidth = Math.floor(columns * 0.75); - const statusWidth = columns - outputWidth; - - return ( - - {/* Top Panel - 60% height */} - - {confirmRequest && onConfirm ? ( - - ) : credentialsRequest && onCredentialsSubmit ? ( - - ) : ( - - )} - - - {/* Bottom Row */} - - {/* Bottom Left - 75% width */} - - - - - {/* Bottom Right - 25% width, no title */} - - - - - - - - {/* Help hint at bottom */} - - Tab: switch panel | j/k: scroll | gg/G: top/bottom - - - ); -} diff --git a/src/dashboard/components/DiffPanel.tsx b/src/dashboard/components/DiffPanel.tsx deleted file mode 100644 index e37c3ee6..00000000 --- a/src/dashboard/components/DiffPanel.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { Box, Text, useStdout, useInput } from 'ink'; -import type { InstallerEventEmitter } from '../../lib/events.js'; -import { WelcomeArt } from './WelcomeArt.js'; -import { - computeDiff, - filterWithContext, - formatDiffLine, - getLanguage, - tokenize, - type FileDiff, - type TokenType, -} from '../lib/diff-utils.js'; - -interface DiffPanelProps { - emitter: InstallerEventEmitter; - focused?: boolean; - height?: number; -} - -// Color mapping for syntax tokens -const TOKEN_COLORS: Record = { - keyword: 'magenta', - string: 'yellow', - comment: 'gray', - number: 'cyan', - function: 'blue', - type: 'green', - operator: 'white', - punctuation: 'gray', - plain: undefined, -}; - -// Render a line with syntax highlighting -function HighlightedLine({ - content, - language, - prefix, - dimmed, -}: { - content: string; - language: string; - prefix: string; - dimmed?: boolean; -}): React.ReactElement { - const tokens = tokenize(content, language); - - return ( - - {prefix} - {tokens.map((token, i) => ( - - {token.value} - - ))} - - ); -} - -export function DiffPanel({ emitter, focused = true, height }: DiffPanelProps): React.ReactElement { - const [diffs, setDiffs] = useState([]); - const [scrollOffset, setScrollOffset] = useState(0); - const lastKeyRef = useRef(''); - const { stdout } = useStdout(); - // Use provided height or fall back to calculated height - const visibleLines = height ?? Math.floor((stdout?.rows || 24) * 0.55) - 4; - - useEffect(() => { - const handleWrite = ({ path, content }: { path: string; content: string }) => { - const diff = computeDiff(path, null, content); - setDiffs((prev) => [diff, ...prev].slice(0, 10)); - setScrollOffset(0); - }; - - const handleEdit = ({ path, oldContent, newContent }: { path: string; oldContent: string; newContent: string }) => { - const diff = computeDiff(path, oldContent, newContent); - setDiffs((prev) => [diff, ...prev].slice(0, 10)); - setScrollOffset(0); - }; - - emitter.on('file:write', handleWrite); - emitter.on('file:edit', handleEdit); - - return () => { - emitter.off('file:write', handleWrite); - emitter.off('file:edit', handleEdit); - }; - }, [emitter]); - - // Flatten all diffs for display with language info - const allLines = useMemo(() => { - const lines: Array<{ - type: 'header' | 'add' | 'remove' | 'unchanged'; - content: string; - language: string; - }> = []; - - for (const diff of diffs) { - const language = getLanguage(diff.path); - lines.push({ - type: 'header', - content: `── ${diff.path} ${diff.isNew ? '(new file)' : '(modified)'} ──`, - language: 'plain', - }); - - const filteredChanges = filterWithContext(diff.changes, 3); - for (const change of filteredChanges) { - lines.push({ - type: change.type, - content: formatDiffLine(change), - language, - }); - } - - lines.push({ type: 'unchanged', content: '', language: 'plain' }); - } - - return lines; - }, [diffs]); - - // Vim-style keyboard navigation (only when focused) - useInput((input, key) => { - if (!focused || diffs.length === 0) return; - - const maxOffset = Math.max(0, allLines.length - visibleLines); - const halfPage = Math.floor(visibleLines / 2); - - // j/k or arrow keys for line-by-line - if (input === 'j' || key.downArrow) { - setScrollOffset((prev) => Math.min(maxOffset, prev + 1)); - } else if (input === 'k' || key.upArrow) { - setScrollOffset((prev) => Math.max(0, prev - 1)); - } - // Ctrl-d / Ctrl-u for half-page - else if (key.ctrl && input === 'd') { - setScrollOffset((prev) => Math.min(maxOffset, prev + halfPage)); - } else if (key.ctrl && input === 'u') { - setScrollOffset((prev) => Math.max(0, prev - halfPage)); - } - // G for bottom, gg for top - else if (input === 'G') { - setScrollOffset(maxOffset); - } else if (input === 'g') { - if (lastKeyRef.current === 'g') { - setScrollOffset(0); - lastKeyRef.current = ''; - } else { - lastKeyRef.current = 'g'; - setTimeout(() => { - lastKeyRef.current = ''; - }, 500); - } - } - }); - - // Show welcome art if no diffs yet - if (diffs.length === 0) { - return ; - } - - // Reserve 1 line for scroll indicator if needed - const hasScroll = allLines.length > visibleLines - 1; - const contentLines = hasScroll ? visibleLines - 1 : visibleLines; - const displayLines = allLines.slice(scrollOffset, scrollOffset + contentLines); - const maxOffset = Math.max(0, allLines.length - contentLines); - - // Calculate scrollbar position - const scrollbarHeight = Math.max(1, Math.floor((contentLines / allLines.length) * contentLines)); - const scrollbarPosition = - maxOffset > 0 ? Math.floor((scrollOffset / maxOffset) * (contentLines - scrollbarHeight)) : 0; - - return ( - - - {displayLines.map((line, i) => { - if (line.type === 'header') { - return ( - - {line.content} - - ); - } - - // Extract the prefix (+ - or space) and the actual content - const prefix = line.content.slice(0, 2); - const codeContent = line.content.slice(2); - - if (line.type === 'add') { - return ( - - - {prefix} - - - - ); - } - - if (line.type === 'remove') { - return ( - - - {prefix} - - - - ); - } - - // Unchanged lines - dimmed with syntax highlighting - return ( - - ); - })} - {hasScroll && ( - - [{scrollOffset + 1}-{Math.min(scrollOffset + contentLines, allLines.length)}/{allLines.length}] - - )} - - {hasScroll && ( - - {Array.from({ length: contentLines }).map((_, i) => ( - = scrollbarPosition && i < scrollbarPosition + scrollbarHeight ? 'cyan' : 'gray'}> - {i >= scrollbarPosition && i < scrollbarPosition + scrollbarHeight ? '█' : '░'} - - ))} - - )} - - ); -} diff --git a/src/dashboard/components/InlinePrompt.tsx b/src/dashboard/components/InlinePrompt.tsx deleted file mode 100644 index b50c8161..00000000 --- a/src/dashboard/components/InlinePrompt.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; - -interface InlinePromptProps { - message: string; - options: string[]; - onSelect: (value: string) => void; -} - -export function InlinePrompt({ message, options, onSelect }: InlinePromptProps): React.ReactElement { - const [selectedIndex, setSelectedIndex] = useState(0); - - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1)); - } else if (key.downArrow) { - setSelectedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0)); - } else if (key.return) { - onSelect(options[selectedIndex]); - } - }); - - return ( - - - ? {message} - - - {options.map((option, i) => ( - - {i === selectedIndex ? '› ' : ' '} - {option} - - ))} - - ↑/↓ to navigate, Enter to select - - ); -} diff --git a/src/dashboard/components/OutputPanel.tsx b/src/dashboard/components/OutputPanel.tsx deleted file mode 100644 index 94605864..00000000 --- a/src/dashboard/components/OutputPanel.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Box, Text, useStdout, useInput } from 'ink'; -import type { InstallerEventEmitter } from '../../lib/events.js'; -import { InlinePrompt } from './InlinePrompt.js'; - -interface OutputPanelProps { - emitter: InstallerEventEmitter; - maxLines?: number; - focused?: boolean; - height?: number; -} - -interface OutputLine { - text: string; - isError?: boolean; - isStatus?: boolean; -} - -interface ActivePrompt { - id: string; - message: string; - options: string[]; -} - -export function OutputPanel({ - emitter, - maxLines = 100, - focused = false, - height, -}: OutputPanelProps): React.ReactElement { - const [lines, setLines] = useState([]); - const [scrollOffset, setScrollOffset] = useState(null); // null = auto-scroll to bottom - const [activePrompt, setActivePrompt] = useState(null); - const lastKeyRef = useRef(''); - const { stdout } = useStdout(); - // Use provided height or fall back to calculated height - const visibleLines = height ?? Math.floor((stdout?.rows || 24) * 0.35) - 4; - - useEffect(() => { - const handleOutput = ({ text, isError }: { text: string; isError?: boolean }) => { - const newLines = text.split('\n').map((line) => ({ - text: line, - isError, - isStatus: line.includes('[STATUS]'), - })); - - setLines((prev) => [...prev, ...newLines].slice(-maxLines)); - // Auto-scroll to bottom when new content arrives (if not manually scrolled) - setScrollOffset(null); - }; - - const handleStatus = ({ message }: { message: string }) => { - setLines((prev) => [...prev, { text: `[STATUS] ${message}`, isStatus: true }].slice(-maxLines)); - setScrollOffset(null); - }; - - const handlePromptRequest = ({ id, message, options }: { id: string; message: string; options?: string[] }) => { - if (options && options.length > 0) { - setActivePrompt({ id, message, options }); - } - }; - - emitter.on('output', handleOutput); - emitter.on('status', handleStatus); - emitter.on('prompt:request', handlePromptRequest); - - return () => { - emitter.off('output', handleOutput); - emitter.off('status', handleStatus); - emitter.off('prompt:request', handlePromptRequest); - }; - }, [emitter, maxLines]); - - // Vim-style keyboard navigation (only when focused) - useInput((input, key) => { - if (!focused) return; - - const maxOffset = Math.max(0, lines.length - visibleLines); - const halfPage = Math.floor(visibleLines / 2); - const currentOffset = scrollOffset ?? maxOffset; - - // j/k or arrow keys for line-by-line - if (input === 'j' || key.downArrow) { - setScrollOffset(Math.min(maxOffset, currentOffset + 1)); - } else if (input === 'k' || key.upArrow) { - setScrollOffset(Math.max(0, currentOffset - 1)); - } - // Ctrl-d / Ctrl-u for half-page - else if (key.ctrl && input === 'd') { - setScrollOffset(Math.min(maxOffset, currentOffset + halfPage)); - } else if (key.ctrl && input === 'u') { - setScrollOffset(Math.max(0, currentOffset - halfPage)); - } - // G for bottom (and resume auto-scroll), gg for top - else if (input === 'G') { - setScrollOffset(null); // Resume auto-scroll - } else if (input === 'g') { - if (lastKeyRef.current === 'g') { - setScrollOffset(0); - lastKeyRef.current = ''; - } else { - lastKeyRef.current = 'g'; - setTimeout(() => { - lastKeyRef.current = ''; - }, 500); - } - } - }); - - const handlePromptSelect = (value: string) => { - if (activePrompt) { - emitter.emit('prompt:response', { id: activePrompt.id, value }); - setLines((prev) => - [...prev, { text: `? ${activePrompt.message}`, isStatus: true }, { text: ` → ${value}` }].slice(-maxLines), - ); - setActivePrompt(null); - } - }; - - // Calculate display range - const promptHeight = activePrompt ? 5 + activePrompt.options.length : 0; - // Reserve 1 line for scroll indicator if needed - const hasScroll = lines.length > visibleLines - promptHeight - 1; - const contentLines = hasScroll ? visibleLines - promptHeight - 1 : visibleLines - promptHeight; - const maxOffset = Math.max(0, lines.length - contentLines); - const effectiveOffset = scrollOffset ?? maxOffset; - const displayLines = lines.slice(Math.max(0, effectiveOffset), Math.max(0, effectiveOffset) + contentLines); - - // Calculate scrollbar position - const scrollbarHeight = lines.length > 0 ? Math.max(1, Math.floor((contentLines / lines.length) * contentLines)) : 1; - const scrollbarPosition = - maxOffset > 0 ? Math.floor((effectiveOffset / maxOffset) * (contentLines - scrollbarHeight)) : 0; - - return ( - - - {displayLines.length === 0 && !activePrompt ? ( - Waiting for output... - ) : ( - displayLines.map((line, i) => ( - - {line.text} - - )) - )} - - {activePrompt && ( - - )} - - {hasScroll && ( - - [{effectiveOffset + 1}-{Math.min(effectiveOffset + contentLines, lines.length)}/{lines.length}] - {scrollOffset === null ? ' (following)' : ''} - - )} - - {hasScroll && ( - - {Array.from({ length: contentLines }).map((_, i) => ( - = scrollbarPosition && i < scrollbarPosition + scrollbarHeight ? 'cyan' : 'gray'}> - {i >= scrollbarPosition && i < scrollbarPosition + scrollbarHeight ? '█' : '░'} - - ))} - - )} - - ); -} diff --git a/src/dashboard/components/Panel.tsx b/src/dashboard/components/Panel.tsx deleted file mode 100644 index 5dc06f4a..00000000 --- a/src/dashboard/components/Panel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; - -interface PanelProps { - title?: string; - children: React.ReactNode; - borderColor?: string; - flexGrow?: number; - width?: string | number; - height?: string | number; - contentHeight?: number; -} - -export function Panel({ - title, - children, - borderColor = 'gray', - flexGrow, - width, - height, -}: PanelProps): React.ReactElement { - return ( - - {title && ( - - - {title} - - - )} - - {children} - - - ); -} diff --git a/src/dashboard/components/TextInput.tsx b/src/dashboard/components/TextInput.tsx deleted file mode 100644 index 217ac0bb..00000000 --- a/src/dashboard/components/TextInput.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Box, Text, useInput } from 'ink'; - -interface TextInputProps { - label: string; - placeholder?: string; - mask?: boolean; - value: string; - onChange: (value: string) => void; - onSubmit: () => void; - focused?: boolean; - error?: string; -} - -export function TextInput({ - label, - placeholder = '', - mask = false, - value, - onChange, - onSubmit, - focused = true, - error, -}: TextInputProps): React.ReactElement { - const [cursorVisible, setCursorVisible] = useState(true); - // Use ref for synchronous value tracking (fixes snippet tool race conditions) - const valueRef = useRef(value); - valueRef.current = value; - - // Blink cursor - useEffect(() => { - if (!focused) return; - const interval = setInterval(() => { - setCursorVisible((v) => !v); - }, 500); - return () => clearInterval(interval); - }, [focused]); - - useInput( - (input, key) => { - if (!focused) return; - - if (key.return) { - onSubmit(); - } else if (key.ctrl && input === 'u') { - // Ctrl+U: clear entire line - valueRef.current = ''; - onChange(''); - } else if (key.backspace || key.delete) { - // Use ref for current value to avoid stale closure - const newValue = valueRef.current.slice(0, -1); - valueRef.current = newValue; - onChange(newValue); - } else if (!key.ctrl && !key.meta && input && !key.upArrow && !key.downArrow) { - // Handle snippet tool expansion: input may contain backspaces (\x7f or \x08) - // followed by the replacement text - let newValue = valueRef.current; - - for (const char of input) { - if (char === '\x7f' || char === '\x08') { - // Backspace - delete last character - newValue = newValue.slice(0, -1); - } else if (char.charCodeAt(0) >= 32) { - // Only add printable characters - newValue += char; - } - } - - valueRef.current = newValue; - onChange(newValue); - } - }, - { isActive: focused }, - ); - - const displayValue = mask ? '*'.repeat(value.length) : value; - const showPlaceholder = value.length === 0; - - return ( - - - - {label}:{' '} - - {showPlaceholder ? placeholder : displayValue} - {focused && cursorVisible && |} - - {error && ( - - {error} - - )} - - ); -} diff --git a/src/dashboard/components/WelcomeArt.tsx b/src/dashboard/components/WelcomeArt.tsx deleted file mode 100644 index 47cbe6ec..00000000 --- a/src/dashboard/components/WelcomeArt.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import { getWelcomeArt } from '../lib/welcome-art.js'; - -export function WelcomeArt(): React.ReactElement { - const art = getWelcomeArt(); - const lines = art.split('\n'); - - return ( - - - {lines.map((line, i) => ( - - {line} - - ))} - - - ); -} diff --git a/src/dashboard/hooks/useAnimation.ts b/src/dashboard/hooks/useAnimation.ts deleted file mode 100644 index ac17f3c0..00000000 --- a/src/dashboard/hooks/useAnimation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; - -interface UseAnimationOptions { - frameCount: number; - frameDelayMs: number; - paused?: boolean; -} - -export function useAnimation({ frameCount, frameDelayMs, paused = false }: UseAnimationOptions): number { - const [frameIndex, setFrameIndex] = useState(0); - const intervalRef = useRef(null); - - useEffect(() => { - if (paused) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - return; - } - - intervalRef.current = setInterval(() => { - setFrameIndex((prev) => (prev + 1) % frameCount); - }, frameDelayMs); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, [frameCount, frameDelayMs, paused]); - - return frameIndex; -} diff --git a/src/dashboard/hooks/useKeyboard.ts b/src/dashboard/hooks/useKeyboard.ts deleted file mode 100644 index 851bbe94..00000000 --- a/src/dashboard/hooks/useKeyboard.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useInput } from 'ink'; - -interface UseScrollOptions { - totalItems: number; - visibleItems: number; - scrollOffset: number; - setScrollOffset: (offset: number) => void; -} - -export function useScrollNavigation({ - totalItems, - visibleItems, - scrollOffset, - setScrollOffset, -}: UseScrollOptions): void { - useInput((input, key) => { - if (key.upArrow) { - setScrollOffset(Math.max(0, scrollOffset - 1)); - } else if (key.downArrow) { - setScrollOffset(Math.min(totalItems - visibleItems, scrollOffset + 1)); - } else if (key.pageUp) { - setScrollOffset(Math.max(0, scrollOffset - visibleItems)); - } else if (key.pageDown) { - setScrollOffset(Math.min(totalItems - visibleItems, scrollOffset + visibleItems)); - } - }); -} diff --git a/src/dashboard/hooks/useTerminalSize.ts b/src/dashboard/hooks/useTerminalSize.ts deleted file mode 100644 index a9acbf2b..00000000 --- a/src/dashboard/hooks/useTerminalSize.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useState, useEffect } from 'react'; - -interface TerminalSize { - columns: number; - rows: number; -} - -export function useTerminalSize(): TerminalSize { - const [size, setSize] = useState({ - columns: process.stdout.columns || 80, - rows: process.stdout.rows || 24, - }); - - useEffect(() => { - const handleResize = () => { - setSize({ - columns: process.stdout.columns || 80, - rows: process.stdout.rows || 24, - }); - }; - - process.stdout.on('resize', handleResize); - return () => { - process.stdout.off('resize', handleResize); - }; - }, []); - - return size; -} - -export const MIN_COLUMNS = 80; -export const MIN_ROWS = 24; diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts deleted file mode 100644 index f3d0fff7..00000000 --- a/src/dashboard/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { InstallerEventEmitter } from '../lib/events.js'; - -export interface DashboardOptions { - emitter: InstallerEventEmitter; -} - -let cleanup: (() => void) | null = null; -let isRunning = false; - -// Enter alternate screen buffer for fullscreen TUI -function enterFullscreen(): void { - process.stdout.write('\x1b[?1049h'); // Enter alternate screen - process.stdout.write('\x1b[2J'); // Clear entire screen - process.stdout.write('\x1b[H'); // Move cursor to home position - process.stdout.write('\x1b[?25l'); // Hide cursor -} - -// Exit alternate screen buffer -function exitFullscreen(): void { - process.stdout.write('\x1b[?25h'); // Show cursor - process.stdout.write('\x1b[?1049l'); // Exit alternate screen -} - -export async function startDashboard(options: DashboardOptions): Promise { - if (isRunning) return; // Prevent double initialization - isRunning = true; - - const { render } = await import('ink'); - const { createElement } = await import('react'); - const { Dashboard } = await import('./components/Dashboard.js'); - - enterFullscreen(); - - const instance = render(createElement(Dashboard, { emitter: options.emitter }), { - exitOnCtrlC: false, - }); - cleanup = () => { - instance.unmount(); - exitFullscreen(); - isRunning = false; - }; -} - -export async function stopDashboard(): Promise { - cleanup?.(); - cleanup = null; -} diff --git a/src/dashboard/lib/diff-utils.ts b/src/dashboard/lib/diff-utils.ts deleted file mode 100644 index 3c073502..00000000 --- a/src/dashboard/lib/diff-utils.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { diffLines } from 'diff'; - -export interface FileDiff { - path: string; - isNew: boolean; - changes: DiffLine[]; - timestamp: number; -} - -export interface DiffLine { - type: 'add' | 'remove' | 'unchanged'; - content: string; - lineNumber?: number; -} - -// Token types for syntax highlighting -export type TokenType = - | 'keyword' - | 'string' - | 'comment' - | 'number' - | 'function' - | 'type' - | 'operator' - | 'punctuation' - | 'plain'; - -export interface Token { - type: TokenType; - value: string; -} - -// Detect file language from path -export function getLanguage(path: string): string { - const ext = path.split('.').pop()?.toLowerCase() || ''; - const langMap: Record = { - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - json: 'json', - css: 'css', - scss: 'css', - html: 'html', - md: 'markdown', - env: 'env', - }; - return langMap[ext] || 'plain'; -} - -// Keywords for JS/TS -const JS_KEYWORDS = new Set([ - 'async', - 'await', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'debugger', - 'default', - 'delete', - 'do', - 'else', - 'enum', - 'export', - 'extends', - 'false', - 'finally', - 'for', - 'from', - 'function', - 'if', - 'import', - 'in', - 'instanceof', - 'interface', - 'let', - 'new', - 'null', - 'of', - 'private', - 'protected', - 'public', - 'readonly', - 'return', - 'static', - 'super', - 'switch', - 'this', - 'throw', - 'true', - 'try', - 'type', - 'typeof', - 'undefined', - 'var', - 'void', - 'while', - 'with', - 'yield', -]); - -// Tokenize a line of code for syntax highlighting -export function tokenize(line: string, language: string): Token[] { - if (language === 'plain' || language === 'env') { - // For .env files, highlight key=value - if (language === 'env') { - const match = line.match(/^(\w+)(=)(.*)$/); - if (match) { - return [ - { type: 'type', value: match[1] }, - { type: 'operator', value: match[2] }, - { type: 'string', value: match[3] }, - ]; - } - } - return [{ type: 'plain', value: line }]; - } - - const tokens: Token[] = []; - let remaining = line; - - while (remaining.length > 0) { - // Comments - const commentMatch = remaining.match(/^(\/\/.*|\/\*[\s\S]*?\*\/)/); - if (commentMatch) { - tokens.push({ type: 'comment', value: commentMatch[1] }); - remaining = remaining.slice(commentMatch[1].length); - continue; - } - - // Strings (single, double, template) - const stringMatch = remaining.match(/^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/); - if (stringMatch) { - tokens.push({ type: 'string', value: stringMatch[1] }); - remaining = remaining.slice(stringMatch[1].length); - continue; - } - - // JSX/HTML tags - const tagMatch = remaining.match(/^(<\/?[A-Za-z][A-Za-z0-9]*)/); - if (tagMatch) { - tokens.push({ type: 'keyword', value: tagMatch[1] }); - remaining = remaining.slice(tagMatch[1].length); - continue; - } - - // Numbers - const numberMatch = remaining.match(/^(\d+\.?\d*)/); - if (numberMatch) { - tokens.push({ type: 'number', value: numberMatch[1] }); - remaining = remaining.slice(numberMatch[1].length); - continue; - } - - // Identifiers and keywords - const identMatch = remaining.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/); - if (identMatch) { - const word = identMatch[1]; - if (JS_KEYWORDS.has(word)) { - tokens.push({ type: 'keyword', value: word }); - } else if (word[0] === word[0].toUpperCase() && word !== word.toUpperCase()) { - // PascalCase = likely a type or component - tokens.push({ type: 'type', value: word }); - } else if (remaining.slice(word.length).match(/^\s*[(<]/)) { - // Followed by ( or < = function call - tokens.push({ type: 'function', value: word }); - } else { - tokens.push({ type: 'plain', value: word }); - } - remaining = remaining.slice(word.length); - continue; - } - - // Operators - const opMatch = remaining.match(/^(=>|===|!==|==|!=|<=|>=|&&|\|\||[+\-*/%=<>!&|^~?:])/); - if (opMatch) { - tokens.push({ type: 'operator', value: opMatch[1] }); - remaining = remaining.slice(opMatch[1].length); - continue; - } - - // Punctuation - const punctMatch = remaining.match(/^([{}[\]();,.])/); - if (punctMatch) { - tokens.push({ type: 'punctuation', value: punctMatch[1] }); - remaining = remaining.slice(1); - continue; - } - - // Whitespace and other characters - const wsMatch = remaining.match(/^(\s+)/); - if (wsMatch) { - tokens.push({ type: 'plain', value: wsMatch[1] }); - remaining = remaining.slice(wsMatch[1].length); - continue; - } - - // Single character fallback - tokens.push({ type: 'plain', value: remaining[0] }); - remaining = remaining.slice(1); - } - - return tokens; -} - -export function computeDiff(path: string, oldContent: string | null, newContent: string): FileDiff { - const isNew = oldContent === null || oldContent === ''; - - if (isNew) { - // New file - all lines are additions - const lines = newContent.split('\n'); - return { - path, - isNew: true, - changes: lines.map((content, i) => ({ - type: 'add' as const, - content, - lineNumber: i + 1, - })), - timestamp: Date.now(), - }; - } - - // Compute unified diff - const changes = diffLines(oldContent, newContent); - const resultLines: DiffLine[] = []; - let lineNumber = 1; - - for (const change of changes) { - const lines = change.value.split('\n').filter( - (l, i, arr) => - // Filter out empty last line from split - i < arr.length - 1 || l !== '', - ); - - for (const line of lines) { - if (change.added) { - resultLines.push({ - type: 'add', - content: line, - lineNumber: lineNumber++, - }); - } else if (change.removed) { - resultLines.push({ type: 'remove', content: line }); - } else { - resultLines.push({ - type: 'unchanged', - content: line, - lineNumber: lineNumber++, - }); - } - } - } - - // Truncate very large diffs - const MAX_LINES = 500; - if (resultLines.length > MAX_LINES) { - return { - path, - isNew: false, - changes: [ - ...resultLines.slice(0, MAX_LINES), - { - type: 'unchanged' as const, - content: `... (${resultLines.length - MAX_LINES} more lines)`, - }, - ], - timestamp: Date.now(), - }; - } - - return { - path, - isNew: false, - changes: resultLines, - timestamp: Date.now(), - }; -} - -// Helper: filter diff lines to show only changes with context -export function filterWithContext(changes: DiffLine[], contextLines: number): DiffLine[] { - const result: DiffLine[] = []; - const indices = new Set(); - - // Mark indices of changed lines and their context - changes.forEach((change, i) => { - if (change.type === 'add' || change.type === 'remove') { - for (let j = Math.max(0, i - contextLines); j <= Math.min(changes.length - 1, i + contextLines); j++) { - indices.add(j); - } - } - }); - - // Build filtered result - let lastIncluded = -2; - for (let i = 0; i < changes.length; i++) { - if (indices.has(i)) { - if (lastIncluded < i - 1 && lastIncluded >= 0) { - result.push({ type: 'unchanged', content: '...' }); - } - result.push(changes[i]); - lastIncluded = i; - } - } - - return result; -} - -// Helper: format diff line with prefix -export function formatDiffLine(change: DiffLine): string { - switch (change.type) { - case 'add': - return `+ ${change.content}`; - case 'remove': - return `- ${change.content}`; - default: - return ` ${change.content}`; - } -} diff --git a/src/dashboard/lib/logo-frames.ts b/src/dashboard/lib/logo-frames.ts deleted file mode 100644 index 3a6a8aa6..00000000 --- a/src/dashboard/lib/logo-frames.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Scaled-down WorkOS logo frames for animation -// Original is 20 lines tall; this is ~10 lines for panel fit - -const LOGO_SMALL = [ - ' *+++++++*++++* ', - ' *++++++++*++++++ ', - ' *+++++* *+++++* ', - ' *+++++* *+++++* ', - ' *++++* *++++* ', - ' *+++++* *+++++* ', - ' *+++++* *+++++* ', - ' *++++++**++++++* ', - ' *++++*++*++++* ', -]; - -// Purple gradient colors (256 color mode) -export const COLORS = { - brightest: '38;5;225', // light lavender - bright: '38;5;183', // lavender - medium: '38;5;141', // medium purple - dark: '38;5;135', // purple - darker: '38;5;93', // deep purple - darkest: '38;5;54', // dark purple -}; - -export type AnimationMode = 'spin' | 'pulse'; - -export interface LogoFrame { - lines: string[]; - color: string; -} - -// Pre-generate frames for spin animation -export function generateSpinFrames(): LogoFrame[] { - const NUM_FRAMES = 32; - const frames: LogoFrame[] = []; - - for (let f = 0; f < NUM_FRAMES; f++) { - const angle = (f / NUM_FRAMES) * 2 * Math.PI; - const cosA = Math.cos(angle); - const absCos = Math.abs(cosA); - - // Color based on facing angle - let color: string; - if (absCos > 0.9) color = COLORS.brightest; - else if (absCos > 0.7) color = COLORS.bright; - else if (absCos > 0.5) color = COLORS.medium; - else if (absCos > 0.3) color = COLORS.dark; - else if (absCos > 0.15) color = COLORS.darker; - else color = COLORS.darkest; - - // Project characters - const projectedLines = LOGO_SMALL.map((line) => { - const centerX = line.length / 2; - const outChars = Array.from({ length: line.length }, () => ' '); - - for (let col = 0; col < line.length; col++) { - const ch = line[col]; - if (ch === ' ') continue; - - const x = col - centerX; - const newX = x * cosA; - const screenX = Math.round(centerX + newX); - - if (screenX >= 0 && screenX < line.length) { - outChars[screenX] = ch; - } - } - - return outChars.join(''); - }); - - frames.push({ lines: projectedLines, color }); - } - - return frames; -} - -// Pre-generate frames for pulse animation -export function generatePulseFrames(): LogoFrame[] { - const NUM_FRAMES = 24; - const frames: LogoFrame[] = []; - - for (let f = 0; f < NUM_FRAMES; f++) { - const angle = (f / NUM_FRAMES) * 2 * Math.PI; - const brightness = (Math.cos(angle) + 1) / 2; // 0 to 1 - - let color: string; - if (brightness > 0.85) color = COLORS.brightest; - else if (brightness > 0.7) color = COLORS.bright; - else if (brightness > 0.5) color = COLORS.medium; - else if (brightness > 0.35) color = COLORS.dark; - else if (brightness > 0.2) color = COLORS.darker; - else color = COLORS.darkest; - - frames.push({ lines: [...LOGO_SMALL], color }); - } - - return frames; -} - -// Cache frames on first use -let spinFramesCache: LogoFrame[] | null = null; -let pulseFramesCache: LogoFrame[] | null = null; - -export function getFrames(mode: AnimationMode): LogoFrame[] { - if (mode === 'spin') { - if (!spinFramesCache) spinFramesCache = generateSpinFrames(); - return spinFramesCache; - } else { - if (!pulseFramesCache) pulseFramesCache = generatePulseFrames(); - return pulseFramesCache; - } -} - -export const FRAME_DELAYS = { - spin: 55, - pulse: 80, -} as const; diff --git a/src/dashboard/lib/welcome-art.ts b/src/dashboard/lib/welcome-art.ts deleted file mode 100644 index 5e82eacc..00000000 --- a/src/dashboard/lib/welcome-art.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getConfig } from '../../lib/settings.js'; - -export function getWelcomeArt(): string { - return getConfig().branding.asciiArt; -} diff --git a/src/dashboard/types.ts b/src/dashboard/types.ts deleted file mode 100644 index ae2c1a3c..00000000 --- a/src/dashboard/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { InstallerEventEmitter, InstallerEvents } from '../lib/events.js'; - -export interface DashboardProps { - emitter: InstallerEventEmitter; -} - -// Re-export for convenience -export type { InstallerEvents, InstallerEventEmitter }; diff --git a/src/doctor/output.ts b/src/doctor/output.ts index de4e61e3..4478fb92 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -3,8 +3,7 @@ import { homedir } from 'os'; import { createAgents } from '../commands/install-skill.js'; import type { InteractionModeSource } from '../utils/interaction-mode.js'; import type { DoctorReport, Issue } from './types.js'; -import { renderSummaryBox, type SummaryBoxItem } from '../utils/summary-box.js'; -import type { LockExpression } from '../utils/lock-art.js'; +import { renderSummaryBox, type SummaryBoxItem, type SummaryTone } from '../utils/summary-box.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface FormatOptions { @@ -253,12 +252,8 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi console.log(''); - // Summary box with lock character - const expression: LockExpression = report.summary.healthy - ? 'success' - : report.summary.errors > 0 - ? 'error' - : 'warning'; + // Summary box: its title line carries the outcome + const tone: SummaryTone = report.summary.healthy ? 'success' : report.summary.errors > 0 ? 'error' : 'warning'; const title = report.summary.healthy ? 'WorkOS Integration Healthy' @@ -273,7 +268,7 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi console.log( renderSummaryBox({ - expression, + tone, title, items, footer: `${formatWorkOSCommand('doctor --copy')} | https://workos.com/docs`, diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 512f417d..2c10c615 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -105,16 +105,15 @@ export class CLIAdapter implements InstallerAdapter { ui.intro('WorkOS', 'AuthKit installer'); } - // Handle Ctrl+C gracefully + // Let the machine finish cancellation and runWithCore flush telemetry and + // stop the adapter. Exiting here would preempt its later SIGINT listener. const handleSigInt = () => { this.promptAbort?.abort(); if (this.spinner) { this.spinner.stop('Cancelled'); this.spinner = null; } - ui.log.warn('Installer cancelled'); - ui.outro('Your project was not modified'); - process.exit(0); + this.sendEvent({ type: 'CANCEL' }); }; process.on('SIGINT', handleSigInt); this.sigIntHandler = handleSigInt; diff --git a/src/lib/adapters/dashboard-adapter.ts b/src/lib/adapters/dashboard-adapter.ts deleted file mode 100644 index 90c832c2..00000000 --- a/src/lib/adapters/dashboard-adapter.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { InstallerAdapter, AdapterConfig } from './types.js'; -import type { InstallerEventEmitter, InstallerEvents, CompletionData } from '../events.js'; -import { renderCompletionSummary } from '../../utils/summary-box.js'; - -/** - * Dashboard adapter that renders wizard events via Ink/React TUI. - * - * Wraps the existing Dashboard component and passes the emitter to it. - * The Dashboard component already handles most event rendering internally. - */ -export class DashboardAdapter implements InstallerAdapter { - readonly emitter: InstallerEventEmitter; - private sendEvent: AdapterConfig['sendEvent']; - private cleanup: (() => void) | null = null; - private isStarted = false; - private completionData: { success: boolean; summary?: string; completion?: CompletionData } | null = null; - - constructor(config: AdapterConfig) { - this.emitter = config.emitter; - this.sendEvent = config.sendEvent; - } - - async start(): Promise { - if (this.isStarted) return; - this.isStarted = true; - - // Dynamic imports to avoid loading Ink when not needed - const { render } = await import('ink'); - const { createElement } = await import('react'); - const { Dashboard } = await import('../../dashboard/components/Dashboard.js'); - - // Enter fullscreen (alternate screen buffer) - process.stdout.write('\x1b[?1049h'); // Enter alternate screen - process.stdout.write('\x1b[2J'); // Clear entire screen - process.stdout.write('\x1b[H'); // Move cursor to home - process.stdout.write('\x1b[?25l'); // Hide cursor - - // Render the Dashboard component with emitter - const instance = render(createElement(Dashboard, { emitter: this.emitter })); - - // Setup cleanup function - this.cleanup = () => { - instance.unmount(); - process.stdout.write('\x1b[?25h'); // Show cursor - process.stdout.write('\x1b[?1049l'); // Exit alternate screen - }; - - // Wire up Dashboard responses back to the machine - // The Dashboard component emits these when user interacts - this.emitter.on('confirm:response', this.handleConfirmResponse); - this.emitter.on('credentials:response', this.handleCredentialsResponse); - - // Scaffold (empty-dir): the TUI has no dedicated scaffold prompt yet, so - // auto-proceed (the user ran the installer in an empty dir) and surface - // progress through the `status` event the Dashboard already renders. - this.emitter.on('scaffold:prompt', this.handleScaffoldPrompt); - this.emitter.on('scaffold:start', this.handleScaffoldStart); - this.emitter.on('scaffold:complete', this.handleScaffoldComplete); - this.emitter.on('scaffold:failed', this.handleScaffoldFailed); - - // Track completion for post-exit summary - this.emitter.on('complete', this.handleComplete); - } - - /** - * Capture completion data for display after exit. - */ - private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { - this.completionData = { success, summary, completion }; - }; - - async stop(): Promise { - if (!this.isStarted) return; - - // Unsubscribe from events - this.emitter.off('confirm:response', this.handleConfirmResponse); - this.emitter.off('credentials:response', this.handleCredentialsResponse); - this.emitter.off('scaffold:prompt', this.handleScaffoldPrompt); - this.emitter.off('scaffold:start', this.handleScaffoldStart); - this.emitter.off('scaffold:complete', this.handleScaffoldComplete); - this.emitter.off('scaffold:failed', this.handleScaffoldFailed); - this.emitter.off('complete', this.handleComplete); - - // Run cleanup (unmount Ink, exit fullscreen) - this.cleanup?.(); - this.cleanup = null; - - if (this.completionData) { - console.log(); - console.log( - renderCompletionSummary( - this.completionData.success, - this.completionData.summary, - this.completionData.completion, - ), - ); - console.log(); - } - - this.isStarted = false; - } - - /** - * Handle confirm dialog responses from Dashboard. - */ - private handleConfirmResponse = ({ id, confirmed }: { id: string; confirmed: boolean }): void => { - if (id === 'git-status') { - this.sendEvent({ type: confirmed ? 'GIT_CONFIRMED' : 'GIT_CANCELLED' }); - } else if (id === 'env-scan') { - this.sendEvent({ type: confirmed ? 'ENV_SCAN_APPROVED' : 'ENV_SCAN_DECLINED' }); - } else if (id === 'branch-check') { - // For dashboard, confirmed=true means create branch, false means continue on current - this.sendEvent({ type: confirmed ? 'BRANCH_CREATE' : 'BRANCH_CONTINUE' }); - } else if (id === 'commit') { - this.sendEvent({ type: confirmed ? 'COMMIT_APPROVED' : 'COMMIT_DECLINED' }); - } else if (id === 'pr') { - this.sendEvent({ type: confirmed ? 'PR_APPROVED' : 'PR_DECLINED' }); - } - }; - - /** - * Handle credentials form submission from Dashboard. - */ - private handleCredentialsResponse = ({ apiKey, clientId }: { apiKey: string; clientId: string }): void => { - this.sendEvent({ type: 'CREDENTIALS_SUBMITTED', apiKey, clientId }); - }; - - // ===== Scaffold (empty-dir) handlers ===== - - private handleScaffoldPrompt = (): void => { - this.sendEvent({ type: 'SCAFFOLD_CONFIRMED' }); - }; - - private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => { - this.emitter.emit('status', { message: `Scaffolding a new Next.js app with ${packageManager}...` }); - }; - - private handleScaffoldComplete = (): void => { - this.emitter.emit('status', { message: 'Next.js app created' }); - }; - - private handleScaffoldFailed = ({ error }: InstallerEvents['scaffold:failed']): void => { - this.emitter.emit('status', { message: `Scaffold failed: ${error}` }); - }; -} diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index e88217d5..3eaf9932 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -1,4 +1,3 @@ export { CLIAdapter } from './cli-adapter.js'; -export { DashboardAdapter } from './dashboard-adapter.js'; export { HeadlessAdapter } from './headless-adapter.js'; export type { InstallerAdapter, AdapterConfig } from './types.js'; diff --git a/src/lib/adapters/select-adapter.spec.ts b/src/lib/adapters/select-adapter.spec.ts new file mode 100644 index 00000000..b188fb8d --- /dev/null +++ b/src/lib/adapters/select-adapter.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { selectInstallerAdapter, type AdapterSelectionInput } from './select-adapter.js'; + +const interactive: AdapterSelectionInput = { + json: false, + interaction: 'human', + ci: false, + stdinTTY: true, + stdoutTTY: true, + stderrTTY: true, + columns: 120, + rows: 40, + noTui: false, + term: 'xterm-256color', +}; + +describe('selectInstallerAdapter', () => { + it('uses the full-screen installer for a person at a big-enough terminal', () => { + expect(selectInstallerAdapter(interactive)).toBe('tui'); + expect(selectInstallerAdapter({ ...interactive, columns: 80, rows: 24 })).toBe('tui'); + }); + + it('is headless for JSON output, whatever else is true', () => { + expect(selectInstallerAdapter({ ...interactive, json: true })).toBe('headless'); + expect(selectInstallerAdapter({ ...interactive, json: true, interaction: 'agent' })).toBe('headless'); + }); + + it.each([ + ['agent mode', { interaction: 'agent' as const }], + ['CI mode', { interaction: 'ci' as const }], + ['the --ci flag', { ci: true }], + ['piped stdin', { stdinTTY: false }], + ['piped stdout', { stdoutTTY: false }], + ['redirected stderr', { stderrTTY: false }], + ['--no-tui', { noTui: true }], + ['TERM=dumb', { term: 'dumb' }], + ['a narrow terminal', { columns: 79 }], + ['a short terminal', { rows: 23 }], + ['an unknown size', { columns: 0, rows: 0 }], + ])('keeps the plain CLI for %s', (_, change) => { + expect(selectInstallerAdapter({ ...interactive, ...change })).toBe('cli'); + }); +}); diff --git a/src/lib/adapters/select-adapter.ts b/src/lib/adapters/select-adapter.ts new file mode 100644 index 00000000..19662af2 --- /dev/null +++ b/src/lib/adapters/select-adapter.ts @@ -0,0 +1,39 @@ +import type { InteractionMode } from '../../utils/interaction-mode.js'; +import { MIN_COLUMNS, MIN_ROWS } from '../../tui/theme.js'; + +export type InstallerAdapterKind = 'headless' | 'cli' | 'tui'; + +export interface AdapterSelectionInput { + /** Machine (JSON/NDJSON) output. */ + json: boolean; + interaction: InteractionMode; + /** The hidden --ci flag. */ + ci: boolean; + stdinTTY: boolean; + stdoutTTY: boolean; + /** Redirected stderr means someone is capturing errors: keep them on it. */ + stderrTTY: boolean; + columns: number; + rows: number; + /** --no-tui */ + noTui: boolean; + /** $TERM */ + term?: string; +} + +/** + * Which adapter renders the installer. + * + * JSON output is always headless (a prompt can't render into a JSON stream). + * The full-screen installer is for a person at a real, big-enough terminal; + * every other human run keeps the plain CLI adapter, as before. + */ +export function selectInstallerAdapter(input: AdapterSelectionInput): InstallerAdapterKind { + if (input.json) return 'headless'; + if (input.interaction !== 'human' || input.ci) return 'cli'; + if (!input.stdinTTY || !input.stdoutTTY || !input.stderrTTY) return 'cli'; + if (input.noTui) return 'cli'; + if (input.term === 'dumb') return 'cli'; + if (input.columns < MIN_COLUMNS || input.rows < MIN_ROWS) return 'cli'; + return 'tui'; +} diff --git a/src/lib/adapters/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts new file mode 100644 index 00000000..f1a57341 --- /dev/null +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createInstallerEventEmitter, type InstallerEventEmitter } from '../events.js'; +import ui, { CANCEL, getUiHost, isCancel } from '../../utils/ui.js'; +import { ENTER_FULLSCREEN, LEAVE_FULLSCREEN } from '../../tui/terminal.js'; +import { FakeStdin, FakeStdout, KEY, stripAnsi, waitFor } from '../../tui/ink-streams.test-utils.js'; +import { TuiAdapter } from './tui-adapter.js'; +import { CLIAdapter } from './cli-adapter.js'; +import { createActor, fromPromise } from 'xstate'; +import { installerMachine } from '../installer-core.js'; + +let emitter: InstallerEventEmitter; +let sendEvent: ReturnType; +let stdout: FakeStdout; +let stdin: FakeStdin; +let adapter: TuiAdapter; +let stdinTty: PropertyDescriptor | undefined; +const originalLog = console.log; + +function create(overrides: Partial[0]> = {}) { + return new TuiAdapter({ + emitter, + sendEvent, + installDir: '/work/my-app', + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + tipIntervalMs: 60_000, + renderFrames: true, + ...overrides, + }); +} + +const frame = () => stripAnsi(stdout.lastFrame('WorkOS AuthKit installer')); +/** Everything written after the full screen closed. */ +const afterExit = () => { + const output = stdout.output(); + return stripAnsi(output.slice(output.lastIndexOf(LEAVE_FULLSCREEN) + LEAVE_FULLSCREEN.length)); +}; + +beforeEach(() => { + emitter = createInstallerEventEmitter(); + sendEvent = vi.fn(); + stdout = new FakeStdout(100, 30); + stdin = new FakeStdin(); + // ui's prompt guard checks the real stdin. + stdinTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + adapter = create(); +}); + +afterEach(async () => { + await adapter.stop(); + if (stdinTty) Object.defineProperty(process.stdin, 'isTTY', stdinTty); + else delete (process.stdin as { isTTY?: boolean }).isTTY; + vi.restoreAllMocks(); +}); + +describe('TuiAdapter', () => { + it('enters the alternate screen and draws the installer', async () => { + await adapter.start(); + expect(stdout.writes[0]).toBe(ENTER_FULLSCREEN); + await waitFor(() => expect(frame()).toContain('WorkOS AuthKit installer')); + expect(frame()).toContain('Tasks'); + expect(getUiHost()).not.toBeNull(); + }); + + it('keeps direct console output off the full screen', async () => { + await adapter.start(); + console.log('stray line'); + expect(stdout.output()).not.toContain('stray line'); + await adapter.stop(); + expect(afterExit()).toContain('stray line'); + }); + + it("answers the plain CLI's git-dirty question inline (No → GIT_CANCELLED)", async () => { + await adapter.start(); + emitter.emit('git:dirty', { files: ['a.ts', 'b.ts'] }); + + await waitFor(() => expect(frame()).toContain('? Continue anyway?')); + expect(frame()).toContain('You have uncommitted changes (files: 2)'); + // The question comes with the list the plain CLI prints above it. + const lines = frame().split('\n'); + const at = (text: string) => lines.findIndex((l) => l.includes(text)); + expect(at('! You have uncommitted or untracked files:')).toBeGreaterThan(-1); + expect(at('a.ts')).toBeGreaterThan(at('! You have uncommitted or untracked files:')); + expect(at('b.ts')).toBe(at('a.ts') + 1); + expect(at('? Continue anyway?')).toBe(at('b.ts') + 1); + stdin.press('n'); + await waitFor(() => expect(sendEvent).toHaveBeenCalledWith({ type: 'GIT_CANCELLED' })); + await waitFor(() => expect(frame()).not.toContain('? Continue anyway?')); + }); + + it("answers the plain CLI's branch question inline with the arrow keys", async () => { + await adapter.start(); + emitter.emit('branch:prompt', { branch: 'main' }); + + await waitFor(() => expect(frame()).toContain('› Create feat/add-workos-authkit')); + stdin.press(KEY.down); + await waitFor(() => expect(frame()).toContain('› Continue on current branch')); + stdin.press(KEY.enter); + await waitFor(() => expect(sendEvent).toHaveBeenCalledWith({ type: 'BRANCH_CONTINUE' })); + }); + + it('picks the option the arrow keys moved to, even when enter follows faster than a redraw', async () => { + await adapter.start(); + emitter.emit('branch:prompt', { branch: 'main' }); + await waitFor(() => expect(frame()).toContain('› Create feat/add-workos-authkit')); + // One chunk, like a fast typist or a paste: no render between the keys. + stdin.press(KEY.down); + stdin.press(KEY.enter); + await waitFor(() => expect(sendEvent).toHaveBeenCalled()); + expect(sendEvent).toHaveBeenCalledWith({ type: 'BRANCH_CONTINUE' }); + expect(sendEvent).not.toHaveBeenCalledWith({ type: 'BRANCH_CREATE' }); + }); + + it.each(['text', 'password'] as const)( + 'submits a %s answer pasted with its trailing newline, like the plain CLI', + async (kind) => { + await adapter.start(); + const answer = ui[kind]({ message: 'Paste your API key' }); + await waitFor(() => expect(frame()).toContain('Paste your API key')); + stdin.press('sk_test_abc123\r'); // one chunk: a paste + expect(await answer).toBe('sk_test_abc123'); + }, + ); + + it('keeps every key typed faster than a redraw', async () => { + await adapter.start(); + const answer = ui.text({ message: 'Name?' }); + await waitFor(() => expect(frame()).toContain('Name?')); + // Separate chunks with no render between them: typing ahead. + for (const key of ['a', 'c', KEY.left, 'b', 'x', '\x7f', KEY.right, KEY.enter]) stdin.press(key); + expect(await answer).toBe('abc'); + }); + + it('cancels an open question on esc, the same as cancelling it in the plain CLI', async () => { + await adapter.start(); + emitter.emit('postinstall:commit:prompt', {}); + await waitFor(() => expect(frame()).toContain('? Commit the changes?')); + stdin.press(KEY.escape); + await waitFor(() => expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_DECLINED' })); + }); + + it('lets ctrl-c finish machine cancellation before the adapter stops', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const aborted = vi.fn(); + const machine = installerMachine.provide({ + actors: { + checkAuthentication: fromPromise(({ signal }) => { + signal.addEventListener('abort', aborted, { once: true }); + return new Promise(() => {}); + }), + }, + }); + const actor = createActor(machine, { + input: { emitter, options: { installDir: '/work/my-app' } }, + }); + sendEvent.mockImplementation((event) => actor.send(event)); + const complete = vi.fn(); + actor.subscribe({ complete }); + // Match runWithCore: its listener is registered AFTER adapter.start(). + const runWithCoreSigint = vi.fn(() => actor.send({ type: 'CANCEL' })); + try { + await adapter.start(); + process.on('SIGINT', runWithCoreSigint); + actor.start(); + actor.send({ type: 'START' }); + expect(actor.getSnapshot().value).toBe('authenticating'); + await waitFor(() => expect(frame()).toContain('ctrl-c cancel')); + stdin.press(KEY.ctrlC); + await waitFor(() => expect(complete).toHaveBeenCalledOnce()); + expect(runWithCoreSigint).toHaveBeenCalledOnce(); + expect(actor.getSnapshot().value).toBe('cancelled'); + expect(aborted).toHaveBeenCalledOnce(); + expect(exit).not.toHaveBeenCalled(); + await adapter.stop(); + expect(afterExit()).toContain('cancelled'); + expect(getUiHost()).toBeNull(); + expect(stdin.rawMode).toBe(false); + } finally { + process.off('SIGINT', runWithCoreSigint); + actor.stop(); + } + }); + + it('shows the device-flow code and waiting status', async () => { + await adapter.start(); + emitter.emit('device:started', { + verificationUri: 'https://api.workos.com/device', + verificationUriComplete: 'https://api.workos.com/device?code=WXYZ-1234', + userCode: 'WXYZ-1234', + }); + await waitFor(() => expect(frame()).toContain('enter the code WXYZ-1234')); + expect(frame()).toContain('Waiting for authentication...'); + }); + + it('surfaces errors the installer prints in the walkthrough', async () => { + await adapter.start(); + emitter.emit('postinstall:commit:failed', { error: 'nothing to commit' }); + await waitFor(() => expect(frame()).toContain('✗ Commit failed: nothing to commit')); + }); + + it('leaves the alternate screen on stop and prints the plain completion summary', async () => { + await adapter.start(); + emitter.emit('git:dirty', { files: ['a.ts'] }); + await waitFor(() => expect(frame()).toContain('? Continue anyway?')); + stdin.press('y'); + await waitFor(() => expect(sendEvent).toHaveBeenCalledWith({ type: 'GIT_CONFIRMED' })); + emitter.emit('complete', { success: true, summary: 'AuthKit is set up.' }); + await adapter.stop(); + + const output = stdout.output(); + expect(output.lastIndexOf(LEAVE_FULLSCREEN)).toBeGreaterThan(output.indexOf(ENTER_FULLSCREEN)); + const scrollback = afterExit(); + expect(scrollback).toContain('✔ Continue anyway? Yes'); + expect(scrollback).toContain('WorkOS AuthKit Installed'); + // The view already showed the logo; the scrollback doesn't repeat the opener. + expect(scrollback).not.toContain('AuthKit installer'); + expect(scrollback).not.toContain('▄▄██'); + expect(scrollback.trimStart().startsWith('! You have uncommitted or untracked files:')).toBe(true); + expect(getUiHost()).toBeNull(); + expect(console.log).toBe(originalLog); + expect(stdin.rawMode).toBe(false); + }); + + it("leaves the agent's play-by-play out of the scrollback, keeping what matters", async () => { + await adapter.start(); + emitter.emit('agent:start', {}); + emitter.emit('agent:progress', { step: 'Phase 2: Installing SDK' }); + emitter.emit('agent:tool', { kind: 'command', detail: 'pnpm add @workos-inc/authkit-nextjs' }); + emitter.emit('file:write', { path: '/work/my-app/app/callback/route.ts' }); + ui.log.warn('The build printed a warning'); + emitter.emit('validation:start', { framework: 'nextjs' }); + emitter.emit('validation:issues', { + issues: [{ type: 'file', severity: 'error', message: 'Callback has no route', hint: 'Move the route' }], + }); + emitter.emit('validation:complete', { passed: false, issueCount: 1, durationMs: 1 }); + emitter.emit('complete', { success: true, summary: 'AuthKit is set up.' }); + await adapter.stop(); + + const scrollback = afterExit(); + expect(scrollback).not.toContain('pnpm add'); + expect(scrollback).not.toContain('app/callback/route.ts'); + expect(scrollback).not.toContain('Phase 2'); + // Warnings and errors from inside the agent's run stay. + expect(scrollback).toContain('The build printed a warning'); + expect(scrollback).toContain('Agent completed'); + expect(scrollback).toContain("The agent's step-by-step log is in the installer log"); + // Everything after the agent stays, in order. + const order = [ + 'Agent completed', + "The agent's step-by-step log is in the installer log", + 'Callback has no route', + 'Hint: Move the route', + 'Validation found 1 issue(s)', + ]; + const at = order.map((text) => scrollback.indexOf(text)); + expect(at.every((i, n) => i > -1 && (n === 0 || i > at[n - 1]))).toBe(true); + expect(scrollback).toContain('WorkOS AuthKit Installed'); + }); + + it('restores the terminal from the exit hook when the process exits without stop()', async () => { + await adapter.start(); + const hooks = process.listeners('exit'); + const hook = hooks.at(-1) as () => void; + hook(); + expect(stdout.output().endsWith(LEAVE_FULLSCREEN) || stdout.output().includes(LEAVE_FULLSCREEN)).toBe(true); + expect(getUiHost()).toBeNull(); + expect(process.listeners('exit')).not.toContain(hook); + await adapter.stop(); // no-op + expect(stdout.output().split(LEAVE_FULLSCREEN).length).toBe(2); + }); + + it.each(['SIGTERM', 'SIGHUP'] as const)( + 'restores the terminal on %s, which emits no exit event, then dies of it', + async (signal) => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + const listeners = process.listenerCount(signal); + await adapter.start(); + await waitFor(() => expect(frame()).toContain('WorkOS AuthKit installer')); + process.emit(signal, signal); + + expect(stdout.output().lastIndexOf(LEAVE_FULLSCREEN)).toBeGreaterThan(stdout.output().indexOf(ENTER_FULLSCREEN)); + expect(stdin.rawMode).toBe(false); + expect(getUiHost()).toBeNull(); + expect(kill).toHaveBeenCalledWith(process.pid, signal); + // Its handler is gone, so the re-raised signal gets the default: exit. + expect(process.listenerCount(signal)).toBe(listeners); + }, + ); + + it('undoes a start that fails partway, leaving nothing hijacked', async () => { + const exitHooks = process.listeners('exit').length; + vi.spyOn(CLIAdapter.prototype, 'start').mockRejectedValueOnce(new Error('boom')); + await expect(adapter.start()).rejects.toThrow('boom'); + expect(getUiHost()).toBeNull(); + expect(console.log).toBe(originalLog); + expect(process.listeners('exit').length).toBe(exitHooks); + expect(stdout.output()).toContain(LEAVE_FULLSCREEN); + }); + + it('resolves a question nobody answered with CANCEL when the run stops', async () => { + await adapter.start(); + const answer = ui.confirm({ message: 'Still there?' }); + await waitFor(() => expect(frame()).toContain('? Still there?')); + await adapter.stop(); + expect(isCancel(await answer)).toBe(true); + expect(afterExit()).toContain('✗ Still there? cancelled'); + }); + + it('answers CANCEL when the question is aborted by its signal', async () => { + await adapter.start(); + const controller = new AbortController(); + const answer = ui.select({ message: 'Pick one', options: [{ value: 'a' }], signal: controller.signal }); + await waitFor(() => expect(frame()).toContain('? Pick one')); + controller.abort(); + expect(await answer).toBe(CANCEL); + await waitFor(() => expect(frame()).not.toContain('? Pick one')); + }); +}); diff --git a/src/lib/adapters/tui-adapter.ts b/src/lib/adapters/tui-adapter.ts new file mode 100644 index 00000000..9de4c6c0 --- /dev/null +++ b/src/lib/adapters/tui-adapter.ts @@ -0,0 +1,296 @@ +/** + * Full-screen installer adapter. + * + * It wraps the CLIAdapter rather than re-implementing it: every installer + * event is still handled, and every question asked and answered, by the same + * code as the plain CLI. What changes is where that goes. A UI host + * (`setUiHost`) receives the lines and prompts instead of the terminal, the + * run model turns installer events into the task list and walkthrough, and + * Ink draws it all on the alternate screen. + * + * What the plain CLI prints is collected while the full screen is up and + * printed to the normal screen when it closes: the answers, the settings it + * made, every warning and error, and the same summary a plain run leaves. The + * one thing left out is the agent's play-by-play (each command and file while + * it works); the scrollback points to the log file that has it. + */ + +import { homedir } from 'node:os'; +import { basename } from 'node:path'; +import { format } from 'node:util'; +import { createElement } from 'react'; +import { render, type Instance } from 'ink'; +import chalk from 'chalk'; +import type { AdapterConfig, InstallerAdapter } from './types.js'; +import type { InstallerEventEmitter } from '../events.js'; +import { CLIAdapter } from './cli-adapter.js'; +import { CANCEL, isCancel, setUiHost, type UiHost, type UiLine, type UiPromptRequest } from '../../utils/ui.js'; +import { createRunModel, type RunModel } from '../../tui/model/run-model.js'; +import { loadInstallerContent } from '../../tui/content/index.js'; +import { InstallerApp } from '../../tui/App.js'; +import { ENTER_FULLSCREEN, LEAVE_FULLSCREEN, releaseStdin, writeNow } from '../../tui/terminal.js'; +import { getLogFilePath } from '../../utils/debug.js'; + +export interface TuiAdapterConfig extends AdapterConfig { + /** Project directory, for relative file paths and the header. */ + installDir: string; + stdout?: NodeJS.WriteStream; + stdin?: NodeJS.ReadStream; + tipIntervalMs?: number; + /** Tests: have Ink write every frame whole (it skips live redraws when $CI is set). */ + renderFrames?: boolean; +} + +const INDENT = ' '; +const TERMINATING_SIGNALS = ['SIGTERM', 'SIGHUP'] as const; +/** Events after which the agent is no longer working (validation follows it). */ +const AGENT_ENDS = ['validation:start', 'agent:success', 'agent:failure', 'complete'] as const; +const ANSI = /\x1b\[[0-9;]*m/g; +const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug'] as const; +type ConsoleMethod = (typeof CONSOLE_METHODS)[number]; + +interface PendingPrompt { + request: UiPromptRequest; + resolve: (value: unknown) => void; + onAbort: () => void; +} + +/** How an answered prompt reads in the scrollback, like inquirer leaves it. */ +function answeredLine(request: UiPromptRequest, value: unknown): string { + const message = request.message; + if (isCancel(value)) return `${INDENT}${chalk.red('✗')} ${message} ${chalk.dim('cancelled')}`; + let answer: string; + switch (request.kind) { + case 'confirm': + answer = value ? 'Yes' : 'No'; + break; + case 'select': { + const option = request.options.find((o) => o.value === value); + answer = option?.label ?? String(value); + break; + } + case 'password': + answer = '********'; + break; + default: + answer = String(value); + } + return `${INDENT}${chalk.green('✔')} ${message} ${chalk.cyan(answer)}`; +} + +export class TuiAdapter implements InstallerAdapter { + readonly emitter: InstallerEventEmitter; + private readonly cli: CLIAdapter; + private readonly config: TuiAdapterConfig; + private readonly stdout: NodeJS.WriteStream; + private readonly stdin: NodeJS.ReadStream; + private model: RunModel | null = null; + private ink: Instance | null = null; + private pending: PendingPrompt | null = null; + private transcript: string[] = []; + private savedConsole: Partial void>> = {}; + private active = false; + // While the agent works, its step-by-step lines stay out of the transcript. + private agentWorking = false; + private hiddenAgentLines = 0; + // Where the hidden lines went; printed after the agent's closing line. + private logPointer: string | null = null; + + constructor(config: TuiAdapterConfig) { + this.config = config; + this.emitter = config.emitter; + this.cli = new CLIAdapter(config); + this.stdout = config.stdout ?? process.stdout; + this.stdin = config.stdin ?? process.stdin; + } + + async start(): Promise { + if (this.active) return; + this.active = true; + try { + this.model = createRunModel({ + emitter: this.emitter, + content: loadInstallerContent(), + cwd: this.config.installDir, + }); + this.captureConsole(); + setUiHost(this.host); + // Before the CLI adapter subscribes, so the agent window opens before its + // spinner starts and closes before it prints the agent's final line. + this.emitter.on('agent:start', this.agentStarted); + for (const event of AGENT_ENDS) this.emitter.on(event, this.agentEnded); + // Covers every way out that skips stop(): process.exit() from a handler + // or an uncaught error. A signal that terminates by default (kill, a + // closed terminal) emits no 'exit', so those restore the terminal themselves. + process.on('exit', this.teardown); + for (const signal of TERMINATING_SIGNALS) process.on(signal, this.terminated); + + writeNow(this.stdout, ENTER_FULLSCREEN); + this.ink = render( + createElement(InstallerApp, { + model: this.model, + answer: this.answer, + // ctrl-c with no prompt open: the same SIGINT path as the plain CLI. + interrupt: () => process.emit('SIGINT'), + projectName: basename(this.config.installDir), + tipIntervalMs: this.config.tipIntervalMs, + }), + { + stdout: this.stdout, + stdin: this.stdin, + exitOnCtrlC: false, + patchConsole: false, + debug: this.config.renderFrames ?? false, + }, + ); + + // Last, so its handlers land inside the full screen. What it prints on + // start is its opener (the brand mark): the view already shows the logo, + // so the scrollback shouldn't repeat it. + const openerStart = this.transcript.length; + await this.cli.start(); + this.transcript.splice(openerStart); + } catch (error) { + // Don't leave the terminal, console, or ui hijacked by a half-started view. + await this.cli.stop(); + this.teardown(); + throw error; + } + } + + async stop(): Promise { + if (!this.active) return; + await this.cli.stop(); + this.teardown(); + } + + /** Restore the terminal and print the transcript. Safe to call more than once. */ + private teardown = (): void => { + if (!this.active) return; + this.active = false; + process.off('exit', this.teardown); + for (const signal of TERMINATING_SIGNALS) process.off(signal, this.terminated); + + // A prompt nobody will answer now must not leave its caller hanging. + this.settle(CANCEL); + try { + this.ink?.unmount(); + } catch { + // Already gone. + } + this.ink = null; + releaseStdin(this.stdin); + writeNow(this.stdout, LEAVE_FULLSCREEN); + + setUiHost(null); + this.restoreConsole(); + this.emitter.off('agent:start', this.agentStarted); + for (const event of AGENT_ENDS) this.emitter.off(event, this.agentEnded); + this.agentEnded(); + this.flushLogPointer(); + this.model?.dispose(); + this.model = null; + + const transcript = this.transcript.splice(0); + if (transcript.length) writeNow(this.stdout, `${transcript.join('\n')}\n`); + }; + + /** Restore the terminal, then die of the signal as if it had never been caught. */ + private readonly terminated = (signal: NodeJS.Signals): void => { + this.teardown(); + process.kill(process.pid, signal); + }; + + // ── UI host ───────────────────────────────────────────────────────────── + + private readonly host: UiHost = { + line: (line: UiLine) => { + this.record(line.rendered ? INDENT + line.rendered : '', line.kind === 'warn' || line.kind === 'error'); + // Warnings and errors also show in the walkthrough. A line ending in ':' + // only introduces a list that follows, so it would read as broken there. + const message = line.message.replace(ANSI, '').trim(); + if ((line.kind === 'warn' || line.kind === 'error') && message && !message.endsWith(':')) { + this.model?.addNotice(line.kind === 'warn' ? 'warning' : 'error', message); + } + }, + status: (message) => this.model?.setStatus(message ? message.replace(ANSI, '') : null), + prompt: (request) => + new Promise((resolve) => { + const onAbort = () => this.settle(CANCEL); + request.signal?.addEventListener('abort', onAbort, { once: true }); + this.pending = { request, resolve, onAbort }; + this.model?.setPrompt(request); + }), + }; + + private readonly answer = (value: unknown): void => this.settle(value); + + // ── Transcript ────────────────────────────────────────────────────────── + + /** Keep a line for the scrollback, unless it's the agent's play-by-play. */ + private record(text: string, important: boolean): void { + if (this.agentWorking && !important) { + this.hiddenAgentLines++; + return; + } + this.transcript.push(text); + this.flushLogPointer(); + } + + private flushLogPointer(): void { + if (this.logPointer) this.transcript.push(this.logPointer); + this.logPointer = null; + } + + private readonly agentStarted = (): void => { + this.agentWorking = true; + }; + + /** + * Close the agent window. This runs just before the CLI adapter prints the + * agent's closing line ("Agent completed"), so the note on where the hidden + * lines went waits for that line. + */ + private readonly agentEnded = (): void => { + if (!this.agentWorking) return; + this.agentWorking = false; + if (this.hiddenAgentLines > 0) { + const log = getLogFilePath(); + const where = log ? `: ${log.startsWith(homedir()) ? `~${log.slice(homedir().length)}` : log}` : ''; + this.logPointer = `${INDENT}${chalk.dim(`› The agent's step-by-step log is in the installer log${where}`)}`; + } + this.hiddenAgentLines = 0; + }; + + private settle(value: unknown): void { + const pending = this.pending; + if (!pending) return; + this.pending = null; + pending.request.signal?.removeEventListener('abort', pending.onAbort); + this.model?.setPrompt(null); + this.transcript.push(answeredLine(pending.request, value)); + pending.resolve(value); + } + + // ── Console capture ───────────────────────────────────────────────────── + // Direct console output (the brand mark, the device-code URL, the completion + // summary, debug lines) would scribble over the full screen, so it joins the + // transcript instead. + + private captureConsole(): void { + for (const method of CONSOLE_METHODS) { + this.savedConsole[method] = console[method]; + console[method] = (...args: unknown[]) => { + this.record(format(...args), method === 'warn' || method === 'error'); + }; + } + } + + private restoreConsole(): void { + for (const method of CONSOLE_METHODS) { + const original = this.savedConsole[method]; + if (original) console[method] = original; + } + this.savedConsole = {}; + } +} diff --git a/src/lib/events.ts b/src/lib/events.ts index 1dec90f2..75c0c845 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -29,6 +29,19 @@ export interface CompletionData { applicationSetup?: import('./authkit-application-setup.js').AuthkitApplicationSetup; } +/** + * A WorkOS setting, named for the dashboard's AuthKit checklist ("Add + * environment variables", "Set redirect URI", …). + */ +export type SetupItemId = 'env-vars' | 'redirect-uri' | 'initiate-login-uri' | 'sign-out-uri' | 'cors-origin'; +/** `skipped`: not set, or not verified; `detail` says why and what to do. */ +export type SetupItemStatus = 'started' | 'done' | 'already-set' | 'skipped' | 'failed'; +export interface SetupItemEvent { + step: SetupItemId; + status: SetupItemStatus; + detail?: string; +} + export interface InstallerEvents { status: { message: string }; output: { text: string; isError?: boolean }; @@ -85,6 +98,10 @@ export interface InstallerEvents { 'staging:error': { message: string; statusCode?: number }; 'config:start': Record; 'config:complete': Record; + /** A setting the configure step (before the agent) started or resolved. */ + 'config:step': SetupItemEvent; + /** An app URL set once the agent's routes exist (Next.js) started or resolved. */ + 'app-urls:step': SetupItemEvent; 'agent:start': Record; 'agent:progress': { step: string; detail?: string }; 'agent:success': { summary?: string }; @@ -137,6 +154,101 @@ export interface InstallerEvents { export type InstallerEventName = keyof InstallerEvents; +/** + * Runtime list of every installer event name, for validating data that refers + * to events by name (e.g. the full-screen installer's walkthrough copy). + * `satisfies` makes the compiler reject a missing or unknown key, so this can't + * drift from `InstallerEvents`. + */ +const INSTALLER_EVENT_REGISTRY = { + status: true, + output: true, + 'file:write': true, + 'file:edit': true, + 'prompt:request': true, + 'prompt:response': true, + 'confirm:request': true, + 'confirm:response': true, + 'credentials:request': true, + 'credentials:response': true, + complete: true, + error: true, + 'state:enter': true, + 'state:exit': true, + 'auth:checking': true, + 'auth:required': true, + 'auth:success': true, + 'auth:failure': true, + 'detection:start': true, + 'detection:complete': true, + 'detection:none': true, + 'git:checking': true, + 'git:clean': true, + 'git:dirty': true, + 'git:dirty:confirmed': true, + 'git:dirty:cancelled': true, + 'credentials:gathering': true, + 'credentials:found': true, + 'credentials:env:detected': true, + 'credentials:env:prompt': true, + 'credentials:env:scanning': true, + 'credentials:env:found': true, + 'credentials:env:notfound': true, + 'device:started': true, + 'device:polling': true, + 'device:success': true, + 'device:timeout': true, + 'device:error': true, + 'staging:fetching': true, + 'staging:success': true, + 'staging:error': true, + 'config:start': true, + 'config:complete': true, + 'config:step': true, + 'app-urls:step': true, + 'agent:start': true, + 'agent:progress': true, + 'agent:success': true, + 'agent:failure': true, + 'agent:retry': true, + 'agent:tool': true, + 'validation:retry:start': true, + 'validation:retry:complete': true, + 'validation:start': true, + 'validation:issues': true, + 'validation:complete': true, + 'scaffold:checking': true, + 'scaffold:prompt': true, + 'scaffold:start': true, + 'scaffold:progress': true, + 'scaffold:complete': true, + 'scaffold:failed': true, + 'scaffold:skipped': true, + 'branch:checking': true, + 'branch:protected': true, + 'branch:prompt': true, + 'branch:created': true, + 'branch:create:failed': true, + 'branch:skipped': true, + 'postinstall:changes': true, + 'postinstall:nochanges': true, + 'postinstall:commit:prompt': true, + 'postinstall:commit:generating': true, + 'postinstall:commit:committing': true, + 'postinstall:commit:success': true, + 'postinstall:commit:failed': true, + 'postinstall:pr:prompt': true, + 'postinstall:pr:generating': true, + 'postinstall:pr:pushing': true, + 'postinstall:pr:creating': true, + 'postinstall:pr:success': true, + 'postinstall:pr:failed': true, + 'postinstall:push:failed': true, + 'postinstall:manual': true, +} as const satisfies Record; + +export const INSTALLER_EVENT_NAMES = Object.keys(INSTALLER_EVENT_REGISTRY) as InstallerEventName[]; + export class InstallerEventEmitter extends EventEmitter { emit(event: K, payload: InstallerEvents[K]): boolean { return super.emit(event, payload); diff --git a/src/lib/installer-core.events.spec.ts b/src/lib/installer-core.events.spec.ts index 093eacc7..6d84d795 100644 --- a/src/lib/installer-core.events.spec.ts +++ b/src/lib/installer-core.events.spec.ts @@ -1,30 +1,20 @@ /** * Event Sequence Tests * - * These tests verify that the wizard state machine emits events correctly - * and that CLI/Dashboard modes receive identical event sequences. + * These tests verify that the installer state machine emits events correctly. * * IMPORTANT: These tests use mocked actors and do NOT test the full integration. - * Before releasing, manually test both modes against a real project: + * Before releasing, manually test against a real project: * * ```bash - * # Test CLI mode * cd /tmp && npx create-next-app@latest test-app --typescript --yes - * cd test-app && wizard --skip-auth - * - * # Test Dashboard mode - * cd /tmp/test-app && wizard dashboard --skip-auth - * - * # Verify both modes: - * # - Show same progress steps - * # - Create same files - * # - Emit exactly one 'complete' event (check logs) + * cd test-app && workos install --skip-auth * ``` */ import { describe, it, expect, beforeEach } from 'vitest'; import { createActor, fromPromise } from 'xstate'; import { installerMachine } from './installer-core.js'; -import { createEventCapture, compareEventSequences, filterDeterministicEvents } from './installer-core.test-utils.js'; +import { createEventCapture } from './installer-core.test-utils.js'; import type { InstallerOptions } from '../utils/types.js'; import type { DetectionOutput, @@ -77,7 +67,6 @@ function createTestOptions(overrides?: Partial): InstallerOpti local: true, ci: false, skipAuth: false, - dashboard: false, emitter: null!, // Will be set per test apiKey: 'sk_test_123', clientId: 'client_test_123', @@ -115,134 +104,13 @@ async function runMachineToCompletion( }); } -describe('Event Sequence Parity', () => { +describe('Installer event sequences', () => { let mockActors: ReturnType; beforeEach(() => { mockActors = createMockActors(); }); - describe('CLI vs Dashboard mode', () => { - it('emits identical event sequences for happy path', async () => { - // Run with CLI mode (dashboard: false) - const cliCapture = createEventCapture(); - const cliOptions = createTestOptions({ dashboard: false }); - await runMachineToCompletion(cliOptions, mockActors, cliCapture); - const cliEvents = filterDeterministicEvents(cliCapture.getEventTypes()); - - // Reset mocks to ensure identical behavior - mockActors = createMockActors(); - - // Run with Dashboard mode (dashboard: true) - const dashCapture = createEventCapture(); - const dashOptions = createTestOptions({ dashboard: true }); - await runMachineToCompletion(dashOptions, mockActors, dashCapture); - const dashEvents = filterDeterministicEvents(dashCapture.getEventTypes()); - - // Compare sequences - const result = compareEventSequences(cliEvents, dashEvents); - expect(result.match, result.diff).toBe(true); - }); - - it('emits identical events when skipping auth', async () => { - const cliCapture = createEventCapture(); - await runMachineToCompletion(createTestOptions({ dashboard: false, skipAuth: true }), mockActors, cliCapture); - const cliEvents = cliCapture.getEventTypes(); - - mockActors = createMockActors(); - - const dashCapture = createEventCapture(); - await runMachineToCompletion(createTestOptions({ dashboard: true, skipAuth: true }), mockActors, dashCapture); - const dashEvents = dashCapture.getEventTypes(); - - const result = compareEventSequences(cliEvents, dashEvents); - expect(result.match, result.diff).toBe(true); - }); - - it('emits identical events with dirty git (confirmed)', async () => { - // Mock dirty git status - override checkGitStatus with dirty result - const dirtyMockActors = { - ...createMockActors(), - checkGitStatus: fromPromise(async () => ({ - isClean: false, - files: ['file1.ts', 'file2.ts'], - })), - }; - - const cliCapture = createEventCapture(); - const cliOptions = createTestOptions({ dashboard: false, skipAuth: true }); - - const machineWithActors = installerMachine.provide({ - actors: dirtyMockActors, - }); - - const cliActor = createActor(machineWithActors, { - input: { - emitter: cliCapture.emitter, - options: { ...cliOptions, emitter: cliCapture.emitter }, - }, - }); - - // Start machine and handle git confirmation - await new Promise((resolve) => { - cliActor.subscribe({ - complete: () => resolve(), - }); - - cliActor.start(); - cliActor.send({ type: 'START' }); - - // Simulate user confirming git status after a tick - setTimeout(() => { - cliActor.send({ type: 'GIT_CONFIRMED' }); - }, 50); - }); - - const cliEvents = cliCapture.getEventTypes(); - - // Reset and run dashboard mode - const dashMockActors = { - ...createMockActors(), - checkGitStatus: fromPromise(async () => ({ - isClean: false, - files: ['file1.ts', 'file2.ts'], - })), - }; - - const dashCapture = createEventCapture(); - const dashOptions = createTestOptions({ dashboard: true, skipAuth: true }); - - const dashMachine = installerMachine.provide({ - actors: dashMockActors, - }); - - const dashActor = createActor(dashMachine, { - input: { - emitter: dashCapture.emitter, - options: { ...dashOptions, emitter: dashCapture.emitter }, - }, - }); - - await new Promise((resolve) => { - dashActor.subscribe({ - complete: () => resolve(), - }); - - dashActor.start(); - dashActor.send({ type: 'START' }); - - setTimeout(() => { - dashActor.send({ type: 'GIT_CONFIRMED' }); - }, 50); - }); - - const dashEvents = dashCapture.getEventTypes(); - - const result = compareEventSequences(cliEvents, dashEvents); - expect(result.match, result.diff).toBe(true); - }); - }); - describe('event correctness', () => { it('emits state:enter for each state transition', async () => { const capture = createEventCapture(); diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index dfb71214..2cdc2465 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -75,7 +75,6 @@ function createTestActor(overrides?: Partial, actorOverrides?: local: true, ci: false, skipAuth: false, - dashboard: false, emitter, ...overrides, }; @@ -180,7 +179,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: false, - dashboard: false, emitter, }; @@ -225,7 +223,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, }; @@ -278,7 +275,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, }; @@ -345,7 +341,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, apiKey: 'sk_test_123', clientId: 'client_123', @@ -398,7 +393,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, // No credentials provided - should wait for CREDENTIALS_SUBMITTED }; @@ -454,7 +448,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, apiKey: 'sk_test_provisioned', clientId: 'client_provisioned', @@ -486,7 +479,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, apiKey: 'sk_test_flag', clientId: 'client_flag', @@ -517,7 +509,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, // No CLI credentials — forces credential gathering flow }; @@ -569,7 +560,6 @@ describe('InstallerCore State Machine', () => { local: true, ci: false, skipAuth: true, - dashboard: false, emitter, }; diff --git a/src/lib/installer-core.test-utils.ts b/src/lib/installer-core.test-utils.ts index 2cd0b27e..446f2743 100644 --- a/src/lib/installer-core.test-utils.ts +++ b/src/lib/installer-core.test-utils.ts @@ -90,47 +90,3 @@ export function createEventCapture() { count: () => events.length, }; } - -/** - * Compare two event type sequences for equality. - * Returns match result with diff info if different. - */ -export function compareEventSequences( - seq1: InstallerEventName[], - seq2: InstallerEventName[], -): { match: boolean; diff?: string } { - // Check lengths - if (seq1.length !== seq2.length) { - return { - match: false, - diff: - `Length mismatch: ${seq1.length} vs ${seq2.length}\n` + - `Seq1: ${seq1.join(' → ')}\n` + - `Seq2: ${seq2.join(' → ')}`, - }; - } - - // Check each position - for (let i = 0; i < seq1.length; i++) { - if (seq1[i] !== seq2[i]) { - return { - match: false, - diff: - `Event ${i} differs: "${seq1[i]}" vs "${seq2[i]}"\n` + - `Context: ...${seq1.slice(Math.max(0, i - 2), i + 3).join(' → ')}...`, - }; - } - } - - return { match: true }; -} - -/** - * Filter out non-deterministic events for comparison. - * Some events (like timestamps) shouldn't affect sequence equality. - */ -export function filterDeterministicEvents(events: InstallerEventName[]): InstallerEventName[] { - // Currently all events are deterministic, but this could filter - // things like progress events with variable timing - return events; -} diff --git a/src/lib/run-with-core.setup.spec.ts b/src/lib/run-with-core.setup.spec.ts index e4c6d678..3be91fc8 100644 --- a/src/lib/run-with-core.setup.spec.ts +++ b/src/lib/run-with-core.setup.spec.ts @@ -3,7 +3,8 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import type { InstallerOptions } from '../utils/types.js'; -import { configureInstallEnvironment } from './run-with-core.js'; +import { configureInstallEnvironment, reportAppUrlSetup } from './run-with-core.js'; +import { createInstallerEventEmitter } from './events.js'; import { readProjectEnvCredentials } from './project-env.js'; let directory: string; @@ -91,3 +92,100 @@ describe('Next.js environment preparation', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe('dashboard checklist reporting', () => { + const record = () => { + const emitter = createInstallerEventEmitter(); + const events: string[] = []; + for (const name of ['config:step', 'app-urls:step'] as const) { + emitter.on(name, ({ step, status, detail }) => + events.push(`${name} ${step} ${status}${detail ? ` (${detail})` : ''}`), + ); + } + return { emitter, events }; + }; + + it('reports only the environment variables for Next.js, whose URLs are set after the agent', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'nextjs', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + emitter, + }); + expect(events).toEqual(['config:step env-vars started', 'config:step env-vars done']); + }); + + it('reports the redirect URI and CORS origin as they land for React Router', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'react-router', + credentials: { apiKey: 'sk_test_a', clientId: 'client_a' }, + emitter, + }); + expect(events).toEqual([ + 'config:step env-vars started', + 'config:step redirect-uri started', + 'config:step cors-origin started', + 'config:step redirect-uri done', + 'config:step cors-origin done', + 'config:step env-vars done', + ]); + }); + + it('says why the redirect URI and CORS origin were not set without an API key', async () => { + const { emitter, events } = record(); + await configureInstallEnvironment({ + options, + integration: 'react-router', + credentials: { clientId: 'client_a' }, + emitter, + }); + expect(events).toContain('config:step redirect-uri skipped (No API key was available for this install.)'); + expect(events).toContain('config:step cors-origin skipped (No API key was available for this install.)'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + const setup = { clientId: 'client_a', redirectUri: 'x', signOutUri: 'y', initiateLoginUri: 'z' }; + + it('ticks all three app URLs when the setup is verified', async () => { + const { emitter, events } = record(); + await reportAppUrlSetup(emitter, async () => ({ ...setup, verified: true, callbackRegistered: true })); + expect(events.slice(3)).toEqual([ + 'app-urls:step redirect-uri done', + 'app-urls:step initiate-login-uri done', + 'app-urls:step sign-out-uri done', + ]); + }); + + it('flags the unverified URLs with the setup reason', async () => { + const { emitter, events } = record(); + const result = await reportAppUrlSetup(emitter, async () => ({ + ...setup, + verified: false, + callbackRegistered: true, + reason: 'Sign in to manage those settings.', + })); + expect(result.verified).toBe(false); + expect(events.slice(3)).toEqual([ + 'app-urls:step redirect-uri done', + 'app-urls:step initiate-login-uri skipped (Sign in to manage those settings.)', + 'app-urls:step sign-out-uri skipped (Sign in to manage those settings.)', + ]); + }); + + it('leaves the items running when setup throws, so the failed install fails them', async () => { + const { emitter, events } = record(); + await expect( + reportAppUrlSetup(emitter, async () => { + throw new Error('Callback URL is not registered or verified.'); + }), + ).rejects.toThrow('not registered'); + expect(events).toEqual([ + 'app-urls:step redirect-uri started', + 'app-urls:step initiate-login-uri started', + 'app-urls:step sign-out-uri started', + ]); + }); +}); diff --git a/src/lib/run-with-core.spec.ts b/src/lib/run-with-core.spec.ts index ceaffa51..4dfd95e7 100644 --- a/src/lib/run-with-core.spec.ts +++ b/src/lib/run-with-core.spec.ts @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { detectSingleIntegration, resolveCredentialSource } from './run-with-core.js'; +import { detectSingleIntegration, resolveAdapterKind, resolveCredentialSource } from './run-with-core.js'; +import { setOutputMode } from '../utils/output.js'; +import { resetInteractionModeForTests, setInteractionMode } from '../utils/interaction-mode.js'; describe('detectSingleIntegration', () => { let dir: string; @@ -113,3 +115,65 @@ describe('resolveCredentialSource', () => { expect(resolveCredentialSource({}, {})).toBeUndefined(); }); }); + +describe('resolveAdapterKind', () => { + let saved: Array<[NodeJS.ReadStream | NodeJS.WriteStream, string, PropertyDescriptor | undefined]> = []; + const originalTerm = process.env.TERM; + + function terminal(opts: { + stdinTTY: boolean; + stdoutTTY: boolean; + stderrTTY?: boolean; + columns: number; + rows: number; + }) { + const set = (stream: NodeJS.ReadStream | NodeJS.WriteStream, key: string, value: unknown) => { + saved.push([stream, key, Object.getOwnPropertyDescriptor(stream, key)]); + Object.defineProperty(stream, key, { value, configurable: true }); + }; + set(process.stdin, 'isTTY', opts.stdinTTY); + set(process.stdout, 'isTTY', opts.stdoutTTY); + set(process.stderr, 'isTTY', opts.stderrTTY ?? true); + set(process.stdout, 'columns', opts.columns); + set(process.stdout, 'rows', opts.rows); + } + + beforeEach(() => { + saved = []; + process.env.TERM = 'xterm-256color'; + resetInteractionModeForTests(); + setOutputMode('human'); + }); + + afterEach(() => { + for (const [stream, key, desc] of saved.reverse()) { + if (desc) Object.defineProperty(stream, key, desc); + else delete (stream as unknown as Record)[key]; + } + if (originalTerm === undefined) delete process.env.TERM; + else process.env.TERM = originalTerm; + resetInteractionModeForTests(); + setOutputMode('human'); + }); + + // The full decision matrix is covered by select-adapter.spec.ts; these prove + // runWithCore feeds it the real process state and flags. + it('reads the terminal and flags from the running process', () => { + terminal({ stdinTTY: true, stdoutTTY: true, columns: 80, rows: 24 }); + expect(resolveAdapterKind({})).toBe('tui'); + expect(resolveAdapterKind({ noTui: true })).toBe('cli'); + terminal({ stdinTTY: true, stdoutTTY: true, columns: 79, rows: 24 }); + expect(resolveAdapterKind({})).toBe('cli'); + // `2> errors.log`: errors stay on stderr, so plain output. + terminal({ stdinTTY: true, stdoutTTY: true, stderrTTY: false, columns: 120, rows: 40 }); + expect(resolveAdapterKind({})).toBe('cli'); + }); + + it('reads the output and interaction modes', () => { + terminal({ stdinTTY: true, stdoutTTY: true, columns: 120, rows: 40 }); + setInteractionMode({ mode: 'agent', source: 'flag' }); + expect(resolveAdapterKind({})).toBe('cli'); + setOutputMode('json'); + expect(resolveAdapterKind({})).toBe('headless'); + }); +}); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 94869303..d7dafdb4 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -2,14 +2,18 @@ import { createActor, fromPromise } from 'xstate'; import open from 'open'; import { installerMachine } from './installer-core.js'; import { createInstallerEventEmitter } from './events.js'; -import type { CompletionData } from './events.js'; +import type { CompletionData, InstallerEventEmitter, SetupItemId, SetupItemStatus } from './events.js'; import { buildCompletionData, applicationSetupNextSteps } from './completion-data.js'; -import { readNextjsApplicationSetup, configureAuthkitApplication } from './authkit-application-setup.js'; +import { + readNextjsApplicationSetup, + configureAuthkitApplication, + type AuthkitApplicationSetup, +} from './authkit-application-setup.js'; import { validateInstallation } from './validation/index.js'; import { resolveDevCommand } from './dev-command.js'; import { getConfig as getInstallerSettings } from './settings.js'; import { CLIAdapter } from './adapters/cli-adapter.js'; -import { DashboardAdapter } from './adapters/dashboard-adapter.js'; +import { selectInstallerAdapter, type InstallerAdapterKind } from './adapters/select-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; import type { InstallerOptions } from '../utils/types.js'; import { getInteractionMode, isAgentMode, isCiMode } from '../utils/interaction-mode.js'; @@ -184,10 +188,15 @@ export function resolveCredentialSource( } export async function configureInstallEnvironment( - context: Pick, + context: Pick & + Partial>, ): Promise { const { options: installerOptions, integration, credentials } = context; if (!integration || !credentials) throw new Error('Missing integration or credentials'); + // Each item of the dashboard's AuthKit checklist reports as it goes, so the + // full-screen installer can tick it off. + const step = (id: SetupItemId, status: SetupItemStatus, detail?: string) => + context.emitter?.emit('config:step', { step: id, status, ...(detail ? { detail } : {}) }); const registry = await getRegistry(); const mod = registry.get(integration); @@ -203,18 +212,71 @@ export async function configureInstallEnvironment( // Next.js URL writes happen after code validation. That step chooses ONE // target: the dashboard application, or an API-key-only callback without a session. const requiresApiKey = ['tanstack-start', 'react-router'].includes(integration); + step('env-vars', 'started'); if (credentials.apiKey && requiresApiKey) { await autoConfigureWorkOSEnvironment(credentials.apiKey, integration, port, { homepageUrl: installerOptions.homepageUrl, redirectUri: installerOptions.redirectUri, + onStep: step, }); + } else if (requiresApiKey) { + for (const id of ['redirect-uri', 'cors-origin'] as const) { + step(id, 'skipped', 'No API key was available for this install.'); + } } const redirectUriKey = integration === 'nextjs' ? 'NEXT_PUBLIC_WORKOS_REDIRECT_URI' : 'WORKOS_REDIRECT_URI'; - writeEnvLocal(installerOptions.installDir, { - ...(credentials.apiKey ? { WORKOS_API_KEY: credentials.apiKey } : {}), - WORKOS_CLIENT_ID: credentials.clientId, - [redirectUriKey]: redirectUri, + try { + writeEnvLocal(installerOptions.installDir, { + ...(credentials.apiKey ? { WORKOS_API_KEY: credentials.apiKey } : {}), + WORKOS_CLIENT_ID: credentials.clientId, + [redirectUriKey]: redirectUri, + }); + } catch (error) { + step('env-vars', 'failed', error instanceof Error ? error.message : String(error)); + throw error; + } + step('env-vars', 'done'); +} + +/** + * Report the app URLs `configure` sets (the dashboard checklist's redirect, + * initiate login, and sign-out URIs) as `app-urls:step` events. + * + * `configureAuthkitApplication` throws when the callback isn't registered, + * which fails the install (and the items still running with it). When it + * returns, the callback is registered; the other two are verified together or + * not at all, with one reason covering both. + */ +export async function reportAppUrlSetup( + emitter: Pick, + configure: () => Promise, +): Promise { + const step = (id: SetupItemId, status: SetupItemStatus, detail?: string) => + emitter.emit('app-urls:step', { step: id, status, ...(detail ? { detail } : {}) }); + for (const id of ['redirect-uri', 'initiate-login-uri', 'sign-out-uri'] as const) step(id, 'started'); + const setup = await configure(); + step('redirect-uri', 'done'); + for (const id of ['initiate-login-uri', 'sign-out-uri'] as const) { + if (setup.verified) step(id, 'done'); + else step(id, 'skipped', setup.reason); + } + return setup; +} + +/** Pick the installer adapter for this process's output mode and terminal. */ +export function resolveAdapterKind(options: Pick): InstallerAdapterKind { + return selectInstallerAdapter({ + json: isJsonMode(), + interaction: getInteractionMode().mode, + ci: Boolean(options.ci), + stdinTTY: Boolean(process.stdin.isTTY), + stdoutTTY: Boolean(process.stdout.isTTY), + stderrTTY: Boolean(process.stderr.isTTY), + columns: process.stdout.columns ?? 0, + rows: process.stdout.rows ?? 0, + noTui: Boolean(options.noTui), + term: process.env.TERM, }); } @@ -226,7 +288,6 @@ export async function runWithCore(options: InstallerOptions): Promise { } logInfo('Wizard starting with options:', { debug: options.debug, - dashboard: options.dashboard, local: options.local, ci: options.ci, skipAuth: options.skipAuth, @@ -266,8 +327,10 @@ export async function runWithCore(options: InstallerOptions): Promise { // until you confirm" contract. Those sessions keep the CLIAdapter, which now // fails fast with a clear `prompt_unavailable` error on the first prompt // (see CLIAdapter's handler-error catch) instead of hanging or auto-writing. - // --dashboard keeps its own adapter even under --json. - const headlessMode = isJsonMode() && !options.dashboard; + // A person at a real terminal of at least 80x24 gets the full-screen + // installer unless they pass --no-tui. + const adapterKind = resolveAdapterKind(augmentedOptions); + const headlessMode = adapterKind === 'headless'; let adapter: InstallerAdapter; if (headlessMode) { @@ -286,8 +349,15 @@ export async function runWithCore(options: InstallerOptions): Promise { ci: augmentedOptions.ci, }, }); - } else if (options.dashboard) { - adapter = new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); + } else if (adapterKind === 'tui') { + // Loaded on demand so Ink and React stay off every other path. + const { TuiAdapter } = await import('./adapters/tui-adapter.js'); + adapter = new TuiAdapter({ + emitter, + sendEvent, + debug: augmentedOptions.debug, + installDir: augmentedOptions.installDir, + }); } else { adapter = new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); } @@ -392,10 +462,9 @@ export async function runWithCore(options: InstallerOptions): Promise { .join('\n')}`, ); } - applicationSetup = await configureAuthkitApplication( - applicationSetup, - credentials?.clientId ?? '', - credentials?.apiKey, + const setup = applicationSetup; + applicationSetup = await reportAppUrlSetup(context.emitter, () => + configureAuthkitApplication(setup, credentials?.clientId ?? '', credentials?.apiKey), ); } return { @@ -628,11 +697,18 @@ export async function runWithCore(options: InstallerOptions): Promise { // guards JSON mode, project-owned keys, and single-profile configs. if (!headlessMode && !augmentedOptions.apiKey && !process.env.WORKOS_API_KEY) { const { maybePickInstallEnvironment } = await import('./resolve-install-credentials.js'); - await maybePickInstallEnvironment(getActiveEnvironment(), augmentedOptions.installDir); + try { + await maybePickInstallEnvironment(getActiveEnvironment(), augmentedOptions.installDir); + } catch (error) { + // Cancelling the picker ends the run before the try/finally below, so + // release the adapter here (the full-screen one owns the terminal). + await adapter.stop(); + throw error; + } } analytics.configureAuthFromAvailableSources(); - const mode = headlessMode ? 'headless' : augmentedOptions.dashboard ? 'tui' : 'cli'; + const mode = adapterKind; analytics.sessionStart(mode, getVersion()); let installerStatus: 'success' | 'error' | 'cancelled' = 'success'; diff --git a/src/lib/workos-management.spec.ts b/src/lib/workos-management.spec.ts index dd371342..1bf619e2 100644 --- a/src/lib/workos-management.spec.ts +++ b/src/lib/workos-management.spec.ts @@ -323,4 +323,29 @@ describe('workos-management', () => { expect(rowFor('Environment').value).toBe('the API key supplied to this run'); }); }); + + describe('checklist reporting', () => { + it('reports each item as it resolves', async () => { + stubFetch(() => jsonResponse(200, { url: 'http://elsewhere' })); + const steps: string[] = []; + await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT, { + onStep: (step, status) => steps.push(`${step}:${status}`), + }); + expect(steps).toEqual(['redirect-uri:started', 'cors-origin:started', 'redirect-uri:done', 'cors-origin:done']); + }); + + it('reports a failed write with its error and keeps the existing failure handling', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(500, { message: 'boom' })), + ); + const steps: string[] = []; + const result = await autoConfigureWorkOSEnvironment(API_KEY, INTEGRATION, PORT, { + onStep: (step, status) => steps.push(`${step}:${status}`), + }); + expect(result).toBeNull(); + expect(steps).toContain('redirect-uri:failed'); + expect(steps).toContain('cors-origin:failed'); + }); + }); }); diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index 78be6048..7346c209 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -1,4 +1,5 @@ import type { Integration } from './constants.js'; +import type { SetupItemId, SetupItemStatus } from './events.js'; import type { EnvironmentConfig } from './config-store.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { analytics } from '../utils/analytics.js'; @@ -179,6 +180,8 @@ export interface AutoConfigOptions { homepageUrl?: string; /** Custom redirect URI (defaults to framework convention) */ redirectUri?: string; + /** Told as each checklist item starts and resolves. */ + onStep?: (step: SetupItemId, status: SetupItemStatus, detail?: string) => void; } /** @@ -205,10 +208,26 @@ export async function autoConfigureWorkOSEnvironment( ui.log.step('Configuring WorkOS dashboard settings...'); + const onStep = options.onStep ?? (() => {}); + // Report each item as it resolves, not when the whole batch does. + const track = (step: SetupItemId, write: Promise): Promise => { + onStep(step, 'started'); + return write.then( + (result) => { + onStep(step, result.alreadyExists ? 'already-set' : 'done'); + return result; + }, + (error: unknown) => { + onStep(step, 'failed', error instanceof Error ? error.message : String(error)); + throw error; + }, + ); + }; + try { const [redirectUri, corsOrigin, homepageUrl] = await Promise.all([ - createRedirectUri(apiKey, callbackUrl), - createCorsOrigin(apiKey, baseUrl), + track('redirect-uri', createRedirectUri(apiKey, callbackUrl)), + track('cors-origin', createCorsOrigin(apiKey, baseUrl)), setHomepageUrl(apiKey, homepageUrlValue), ]); diff --git a/src/run.ts b/src/run.ts index 5d051a81..e2729626 100644 --- a/src/run.ts +++ b/src/run.ts @@ -20,7 +20,6 @@ export type InstallerArgs = { clientId?: string; homepageUrl?: string; redirectUri?: string; - dashboard?: boolean; inspect?: boolean; noValidate?: boolean; validate?: boolean; @@ -32,6 +31,8 @@ export type InstallerArgs = { noGitCheck?: boolean; gitCheck?: boolean; direct?: boolean; + tui?: boolean; + noTui?: boolean; scaffold?: boolean; pm?: string; router?: 'app'; @@ -68,7 +69,6 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { clientId: merged.clientId, homepageUrl: merged.homepageUrl, redirectUri: merged.redirectUri, - dashboard: merged.dashboard ?? false, inspect: merged.inspect ?? false, noValidate: merged.noValidate ?? merged.validate === false, noCommit: merged.noCommit ?? merged.commit === false, @@ -76,6 +76,7 @@ function buildOptions(argv: InstallerArgs): InstallerOptions { createPr: merged.createPr ?? false, noGitCheck: merged.noGitCheck ?? merged.gitCheck === false, direct: merged.direct ?? false, + noTui: merged.noTui ?? merged.tui === false, scaffold: merged.scaffold ?? false, pm: merged.pm, router: merged.router, diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 00000000..1573ca5c --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,136 @@ +/** + * The full-screen installer: WorkOS logo and a rotating tips & news card up + * top, the task list beside a plain-English walkthrough, any open question + * below them, and a status line at the bottom. + */ + +import { useEffect, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import type { RunModel } from './model/run-model.js'; +import { useRunSnapshot, useTerminalSize } from './hooks.js'; +import { colors, MIN_COLUMNS, MIN_ROWS, SIDE_BY_SIDE_MIN_COLUMNS, SIDE_BY_SIDE_MIN_ROWS } from './theme.js'; +import { COMPACT_HEADER_ROWS, FULL_HEADER_ROWS, Header } from './components/Header.js'; +import { ProgressLine, TaskList } from './components/TaskList.js'; +import { Walkthrough } from './components/Walkthrough.js'; +import { PromptPanel, promptHeight } from './components/PromptPanel.js'; +import { StatusBar } from './components/StatusBar.js'; + +export interface InstallerAppProps { + model: RunModel; + /** Answer the open prompt (a value or CANCEL). */ + answer: (value: unknown) => void; + /** ctrl-c with no prompt open. */ + interrupt: () => void; + projectName: string; + tipIntervalMs?: number; +} + +/** Rows at which the full-size logo fits alongside everything else. */ +const FULL_LOGO_MIN_ROWS = 36; +const MIN_BODY_ROWS = 8; +/** An open question may squeeze the body further; the task list windows itself. */ +const MIN_BODY_ROWS_WITH_PROMPT = 6; +const TOP_MARGIN = 1; + +// A fresh key per prompt so each question starts with empty input state. +const promptKeys = new WeakMap(); +let nextPromptKey = 0; +function keyFor(prompt: object): number { + let key = promptKeys.get(prompt); + if (key === undefined) promptKeys.set(prompt, (key = nextPromptKey++)); + return key; +} + +export function InstallerApp({ model, answer, interrupt, projectName, tipIntervalMs = 12_000 }: InstallerAppProps) { + const snapshot = useRunSnapshot(model); + const [columns, rows] = useTerminalSize(); + const [tipIndex, setTipIndex] = useState(0); + + useEffect(() => { + const timer = setInterval(() => setTipIndex((i) => i + 1), tipIntervalMs); + return () => clearInterval(timer); + }, [tipIntervalMs]); + + // The open prompt owns the keyboard; otherwise ctrl-c cancels and ←/→ page tips. + useInput( + (input, key) => { + if (key.ctrl && input === 'c') interrupt(); + else if (key.rightArrow) setTipIndex((i) => i + 1); + else if (key.leftArrow) setTipIndex((i) => i - 1); + }, + { isActive: !snapshot.prompt }, + ); + + // Margins: one column each side, one row on top, and the bottom row left + // empty (drawing one short of the screen keeps Ink's redraw incremental). + const width = Math.max(20, columns - 2); + const height = Math.max(1, rows - 1); + const inner = Math.max(1, height - TOP_MARGIN); + const compact = rows < FULL_LOGO_MIN_ROWS; + const headerRows = compact ? COMPACT_HEADER_ROWS : FULL_HEADER_ROWS; + const minBody = snapshot.prompt ? MIN_BODY_ROWS_WITH_PROMPT : MIN_BODY_ROWS; + // Room for a select's options: what's left after the header, a minimal + // body, the gaps, the rule, the question, and the status line. + const maxOptions = Math.max(3, inner - headerRows - minBody - 6); + // The question always fits; the context above it takes what's left. + const promptRoom = inner - headerRows - 1 - minBody - 1 - 1; + const maxContext = snapshot.prompt + ? Math.max(1, promptRoom - promptHeight(snapshot.prompt, width, maxOptions, 0)) + : 0; + const prompt = snapshot.prompt ? ( + + ) : null; + + if (columns < MIN_COLUMNS || rows < MIN_ROWS) { + return ( + + + WorkOS AuthKit installer + + {`Make this window at least ${MIN_COLUMNS}×${MIN_ROWS} to see the installer (it's ${columns}×${rows}).`} + + {prompt} + + + ); + } + + const visibleTasks = snapshot.tasks.filter((t) => t.status !== 'skipped'); + const promptRows = snapshot.prompt ? promptHeight(snapshot.prompt, width, maxOptions, maxContext) + 1 : 0; + const bodyRows = Math.max(minBody, inner - headerRows - 1 - promptRows - 1); + // Walkthrough on the left, checklist on the right, when both fit. + const sideBySide = columns >= SIDE_BY_SIDE_MIN_COLUMNS && rows >= SIDE_BY_SIDE_MIN_ROWS; + const taskWidth = columns >= 110 ? 34 : 30; + const walkthroughWidth = sideBySide ? width - taskWidth - 2 : width; + + return ( + +
+ + {sideBySide ? ( + + + + + + ) : ( + + + + + + )} + {prompt ? : null} + {prompt} + + + + ); +} diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts new file mode 100644 index 00000000..e4f167cc --- /dev/null +++ b/src/tui/app.spec.ts @@ -0,0 +1,607 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { createElement } from 'react'; +import { render, type Instance } from 'ink'; +import chalk from 'chalk'; +import { createInstallerEventEmitter } from '../lib/events.js'; +import { CANCEL, type UiPromptRequest } from '../utils/ui.js'; +import { InstallerApp } from './App.js'; +import { loadInstallerContent } from './content/index.js'; +import { createRunModel } from './model/run-model.js'; +import { compactLogoRows, LOGO_MASK } from '../utils/logomark.js'; +import { glyphs } from './theme.js'; +import stringWidth from 'string-width'; +import { FakeStdin, FakeStdout, KEY, settle, stripAnsi, waitFor } from './ink-streams.test-utils.js'; + +const content = loadInstallerContent(); +const BLURPLE_BG = '\x1b[48;2;99;99;241m'; + +let savedLevel: typeof chalk.level; +beforeAll(() => { + savedLevel = chalk.level; + chalk.level = 3; // truecolor, so the logo's background cells are visible in frames +}); +afterAll(() => { + chalk.level = savedLevel; +}); + +let instance: Instance | undefined; +afterEach(() => { + instance?.unmount(); + instance = undefined; +}); + +function mount(columns: number, rows: number, extra: { answer?: (v: unknown) => void; interrupt?: () => void } = {}) { + const emitter = createInstallerEventEmitter(); + const model = createRunModel({ emitter, content, cwd: '/work/my-app', now: new Date('2026-09-24T12:00:00Z') }); + const stdout = new FakeStdout(columns, rows); + const stdin = new FakeStdin(); + const answer = extra.answer ?? vi.fn(); + const interrupt = extra.interrupt ?? vi.fn(); + instance = render( + createElement(InstallerApp, { model, answer, interrupt, projectName: 'my-app', tipIntervalMs: 60_000 }), + { + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + debug: true, + exitOnCtrlC: false, + patchConsole: false, + }, + ); + return { emitter, model, stdout, stdin, answer, interrupt }; +} + +/** Drive a realistic slice of a run through the model. */ +function progress(emitter: ReturnType) { + emitter.emit('state:enter', { state: 'authenticating' }); + emitter.emit('auth:checking', {}); + emitter.emit('auth:success', {}); + emitter.emit('state:exit', { state: 'authenticating' }); + emitter.emit('state:enter', { state: 'scaffold' }); + emitter.emit('state:exit', { state: 'scaffold' }); + emitter.emit('state:enter', { state: 'preparing' }); + emitter.emit('detection:complete', { integration: 'nextjs' }); + emitter.emit('state:exit', { state: 'preparing' }); + emitter.emit('state:enter', { state: 'gatheringCredentials' }); + emitter.emit('state:exit', { state: 'gatheringCredentials' }); + emitter.emit('state:enter', { state: 'configuring' }); + emitter.emit('state:exit', { state: 'configuring' }); + emitter.emit('state:enter', { state: 'runningAgent' }); + emitter.emit('agent:start', {}); + emitter.emit('file:write', { path: '/work/my-app/app/callback/route.ts', content: '' }); +} + +function frameAt(stdout: FakeStdout, match?: string): string { + return stripAnsi(stdout.lastFrame(match)); +} + +describe.each([ + [80, 24], + [120, 40], +])('full-screen installer at %i×%i', (columns, rows) => { + it('shows the logo, task list, walkthrough, and a tip or announcement', async () => { + const { emitter, stdout } = mount(columns, rows); + progress(emitter); + + await waitFor(() => expect(frameAt(stdout)).toContain('Created app/callback/route.ts')); + const raw = stdout.lastFrame(); + const frame = stripAnsi(raw); + const lines = frame.split('\n'); + + // Header: title, detected framework and project. + expect(frame).toContain('WorkOS AuthKit installer'); + expect(frame).toContain('Next.js · my-app'); + + // Logo: half-block glyphs when short, blurple cells when there's room. + if (rows < 36) { + for (const row of compactLogoRows()) expect(frame).toContain(row); + } else { + // Every logo row paints blurple cells. + const painted = raw.split('\n').filter((line) => line.includes(BLURPLE_BG)); + expect(painted.length).toBe(LOGO_MASK.length); + } + + if (columns >= 100 && rows >= 30) { + // Room for both: walkthrough on the left, checklist on the right. + expect(frame).toMatch(/✔ Sign in to WorkOS/); + expect(frame).toMatch(/✔ Look over your project/); + expect(frame).toContain(content.tasks.install.activeLabel!); + expect(frame).toMatch(/○ Wrap up/); + expect(frame).toMatch(/○ Complete your first sign-up/); + expect(frame).toContain('4 of 6 done'); + const heads = lines.find((l) => l.includes('Tasks'))!; + expect(heads.indexOf("What's happening")).toBeGreaterThan(-1); + expect(heads.indexOf('Tasks')).toBeGreaterThan(heads.indexOf("What's happening")); + } else { + // Too small for both: the walkthrough keeps the room, the checklist is one line. + expect(frame).not.toContain('Tasks'); + expect(frame).toContain(`${content.tasks.install.activeLabel} · 4 of 6 done`); + } + + // Walkthrough, in plain English. + expect(frame).toContain("What's happening"); + expect(frame).toContain('This is a Next.js app.'); + + // Tips & news: the first card is an announcement. + expect(frame).toContain(content.announcements[0].title); + + // Fits the screen: one row short, nothing wider than the terminal. + expect(lines.length).toBeLessThanOrEqual(rows); + for (const line of lines) expect([...line].length).toBeLessThanOrEqual(columns); + + // Margins: a blank row on top, and one clear column on each side. + expect(lines[0].trim()).toBe(''); + expect(lines[1].trim()).not.toBe(''); + for (const line of lines.filter((l) => l.trim())) { + expect(line.startsWith(' '), line).toBe(true); + expect([...line.trimEnd()].length, line).toBeLessThanOrEqual(columns - 1); + } + }); +}); + +describe.each([ + [80, 24], + [120, 40], +])('with a question open at %i×%i', (columns, rows) => { + const questions: UiPromptRequest[] = [ + { kind: 'confirm', message: 'This directory is empty. Scaffold a new Next.js app with AuthKit here?' }, + { + kind: 'select', + message: 'Which WorkOS environment should this install use?', + options: Array.from({ length: 12 }, (_, i) => ({ value: i, label: `Environment ${i + 1}`, hint: 'staging' })), + }, + { kind: 'text', message: 'Enter your WorkOS Client ID:', placeholder: 'client_...' }, + { + kind: 'confirm', + message: 'Continue anyway?', + initialValue: false, + context: [ + { + kind: 'warn', + message: 'You have uncommitted or untracked files:', + rendered: '! You have uncommitted or untracked files:', + stream: 'stdout', + }, + ...['README.md', 'next.config.ts', 'package.json', 'src/', 'tsconfig.json'].map((f) => ({ + kind: 'info' as const, + message: ` - ${f}`, + rendered: ` - ${f}`, + stream: 'stdout' as const, + })), + { kind: 'info', message: ' ... and 2 more', rendered: ' ... and 2 more', stream: 'stdout' }, + ], + }, + ]; + + it.each(questions.map((q) => [q.kind, q] as const))( + 'keeps a %s prompt and the status line on screen', + async (_, question) => { + const { emitter, model, stdout } = mount(columns, rows); + progress(emitter); + model.setPrompt(question); + await waitFor(() => expect(frameAt(stdout)).toContain(question.message)); + const lines = frameAt(stdout).split('\n'); + expect(lines.length).toBeLessThanOrEqual(rows - 1); + expect(lines[0].trim()).toBe(''); + expect(lines.at(-1)).toMatch(/esc cancel/); + if (question.context) { + // The question and its answer line always fit; context above it may collapse. + const ask = lines.findIndex((l) => l.includes('? Continue anyway?')); + expect(ask).toBeGreaterThan(-1); + expect(lines[ask + 1]).toContain('y/N'); + expect(lines[ask - 1]).toMatch(/… \d+ more|\.\.\. and 2 more/); + expect(frameAt(stdout)).toContain('! You have uncommitted or untracked files:'); + } + for (const line of lines) expect([...line].length).toBeLessThanOrEqual(columns); + // However tight, the task in progress stays in view. + expect(frameAt(stdout)).toContain(content.tasks.install.activeLabel!); + }, + ); +}); + +describe('checklist', () => { + function configuring(emitter: ReturnType) { + emitter.emit('state:enter', { state: 'authenticating' }); + emitter.emit('state:exit', { state: 'authenticating' }); + emitter.emit('state:enter', { state: 'preparing' }); + emitter.emit('state:exit', { state: 'preparing' }); + emitter.emit('state:enter', { state: 'gatheringCredentials' }); + emitter.emit('state:exit', { state: 'gatheringCredentials' }); + emitter.emit('state:enter', { state: 'configuring' }); + for (const step of ['env-vars', 'redirect-uri', 'cors-origin'] as const) { + emitter.emit('config:step', { step, status: 'started' }); + } + emitter.emit('config:step', { step: 'env-vars', status: 'done' }); + emitter.emit('config:step', { step: 'redirect-uri', status: 'done' }); + } + + it("expands Configure WorkOS into the dashboard's items, indented under it", async () => { + const { emitter, stdout } = mount(120, 40); + configuring(emitter); + await waitFor(() => expect(frameAt(stdout)).toContain('Set CORS origin')); + const lines = frameAt(stdout).split('\n'); + const at = (text: string) => lines.findIndex((l) => l.includes(text)); + const col = (text: string) => lines[at(text)].indexOf(text); + + expect(at('Configuring WorkOS')).toBeGreaterThan(-1); + const items = ['Add environment variables', 'Set redirect URI', 'Set CORS origin']; + items.forEach((item, i) => expect(at(item), item).toBe(at('Configuring WorkOS') + 1 + i)); + // Indented one level under the parent. + expect(col('Add environment variables')).toBe(col('Configuring WorkOS') + 2); + expect(lines[at('Add environment variables')]).toMatch(/✔ Add environment variables/); + expect(lines[at('Set CORS origin')]).not.toMatch(/✔ Set CORS origin/); + }); + + it('names the running sub-step on the progress line when the terminal is small', async () => { + const { emitter, stdout } = mount(80, 24); + configuring(emitter); + await waitFor(() => + expect(frameAt(stdout)).toContain(`${content.tasks.configure.activeLabel} · Set CORS origin · 3 of 6 done`), + ); + }); + + it('connects the app URLs after the agent and flags the ones to check in the dashboard', async () => { + const { emitter, stdout } = mount(120, 40); + emitter.emit('state:enter', { state: 'runningAgent' }); + for (const step of ['redirect-uri', 'initiate-login-uri', 'sign-out-uri'] as const) { + emitter.emit('app-urls:step', { step, status: 'started' }); + } + emitter.emit('app-urls:step', { step: 'redirect-uri', status: 'done' }); + emitter.emit('app-urls:step', { step: 'sign-out-uri', status: 'skipped', detail: 'Not verified.' }); + await waitFor(() => expect(frameAt(stdout)).toContain('! Set sign-out URI')); + const frame = frameAt(stdout); + expect(frame).toContain(content.tasks['app-urls'].activeLabel!); + expect(frame).toMatch(/✔ Set redirect URI/); + expect(frame).toContain('Check in the WorkOS dashboard: set sign-out URI. Not verified.'); + }); + + it("doesn't say all done on the small-terminal line while a setting needs a look", async () => { + const { emitter, stdout } = mount(80, 24); + emitter.emit('state:enter', { state: 'runningAgent' }); + emitter.emit('app-urls:step', { step: 'sign-out-uri', status: 'started' }); + emitter.emit('app-urls:step', { step: 'sign-out-uri', status: 'skipped', detail: 'Not verified.' }); + emitter.emit('state:exit', { state: 'runningAgent' }); + emitter.emit('state:enter', { state: 'postInstall' }); + emitter.emit('state:enter', { state: 'complete' }); + await waitFor(() => + expect(frameAt(stdout)).toContain('! Check your WorkOS settings · → Complete your first sign-up'), + ); + expect(frameAt(stdout)).not.toContain('All done'); + }); + + it('ends on the first sign-up as the next step', async () => { + for (const [columns, rows, expected] of [ + [120, 40, '→ Complete your first sign-up'], + [80, 24, '✔ All done · → Complete your first sign-up'], + ] as const) { + const { emitter, stdout } = mount(columns, rows); + emitter.emit('state:enter', { state: 'runningAgent' }); + emitter.emit('state:exit', { state: 'runningAgent' }); + emitter.emit('state:enter', { state: 'postInstall' }); + emitter.emit('state:enter', { state: 'complete' }); + await waitFor(() => expect(frameAt(stdout)).toContain(expected)); + instance?.unmount(); + } + }); + + it.each([ + [100, 30, true], + [99, 40, false], + [120, 29, false], + ])('at %i×%i shows the checklist beside the walkthrough: %s', async (columns, rows, sideBySide) => { + const { emitter, stdout } = mount(columns, rows); + progress(emitter); + await waitFor(() => expect(frameAt(stdout)).toContain("What's happening")); + expect(frameAt(stdout).includes('Tasks')).toBe(sideBySide); + }); +}); + +describe('full-screen installer interaction', () => { + it('asks a confirm inline and answers with y', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ kind: 'confirm', message: 'Commit the changes?', initialValue: true }); + + await waitFor(() => expect(frameAt(stdout)).toContain('? Commit the changes?')); + expect(frameAt(stdout)).toContain('Y/n'); + stdin.press('y'); + await waitFor(() => expect(answer).toHaveBeenCalledWith(true)); + }); + + it('shows what the question is about right above it', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ + kind: 'confirm', + message: 'Continue anyway?', + initialValue: false, + context: [ + { + kind: 'warn', + message: 'You have uncommitted or untracked files:', + rendered: '! You have uncommitted or untracked files:', + stream: 'stdout', + }, + { kind: 'info', message: ' src/app/page.tsx', rendered: ' src/app/page.tsx', stream: 'stdout' }, + { kind: 'info', message: ' ... and 5 more', rendered: ' ... and 5 more', stream: 'stdout' }, + ], + }); + + await waitFor(() => expect(frameAt(stdout)).toContain('? Continue anyway?')); + const lines = frameAt(stdout).split('\n'); + const at = (text: string) => lines.findIndex((l) => l.includes(text)); + expect(at('! You have uncommitted or untracked files:')).toBeGreaterThan(-1); + expect(at('src/app/page.tsx')).toBe(at('! You have uncommitted') + 1); + expect(at('... and 5 more')).toBe(at('src/app/page.tsx') + 1); + expect(at('? Continue anyway?')).toBe(at('... and 5 more') + 1); + expect(lines.at(-1)).toMatch(/esc cancel/); + stdin.press('n'); + await waitFor(() => expect(answer).toHaveBeenCalledWith(false)); + }); + + it('confirms the default on enter and declines with n', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ kind: 'confirm', message: 'Continue anyway?', initialValue: false }); + await waitFor(() => expect(frameAt(stdout)).toContain('y/N')); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenLastCalledWith(false)); + stdin.press('n'); + await waitFor(() => expect(answer).toHaveBeenCalledTimes(2)); + }); + + it('asks a select inline, starting on the initial value and skipping disabled options', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + const request: UiPromptRequest = { + kind: 'select', + message: 'You are on main. Create a feature branch?', + options: [ + { value: 'create', label: 'Create feat/add-workos-authkit' }, + { value: 'locked', label: 'Use a locked branch', disabled: 'not allowed' }, + { value: 'continue', label: 'Continue on current branch' }, + { value: 'cancel', label: 'Cancel' }, + ], + initialValue: 'continue', + }; + model.setPrompt(request); + + await waitFor(() => expect(frameAt(stdout)).toContain('› Continue on current branch')); + expect(frameAt(stdout)).toContain('Use a locked branch not allowed'); + stdin.press(KEY.up); // skips the disabled option + await waitFor(() => expect(frameAt(stdout)).toContain('› Create feat/add-workos-authkit')); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith('create')); + }); + + it('starts a select on the first selectable option when the initial value is missing or disabled', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ + kind: 'select', + message: 'Pick an environment', + options: [ + { value: 'locked', label: 'Locked', disabled: true }, + { value: 'staging', label: 'Staging' }, + ], + initialValue: 'locked', + }); + await waitFor(() => expect(frameAt(stdout)).toContain('› Staging')); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith('staging')); + }); + + it('validates text input before answering', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ + kind: 'text', + message: 'Enter your WorkOS Client ID:', + placeholder: 'client_...', + validate: (v) => (v.startsWith('client_') ? undefined : 'Client ID should start with "client_"'), + }); + + await waitFor(() => expect(frameAt(stdout)).toContain('Enter your WorkOS Client ID:')); + // Wait for each keystroke to land before the next, as a person typing would. + stdin.press('oops'); + await waitFor(() => expect(frameAt(stdout)).toContain('› oops')); + await settle(); + stdin.press(KEY.enter); + await waitFor(() => expect(frameAt(stdout)).toContain('✗ Client ID should start with "client_"')); + expect(answer).not.toHaveBeenCalled(); + + for (let i = 4; i > 0; i--) { + stdin.press('\x7f'); // backspace + await waitFor(() => expect(frameAt(stdout)).toContain(`› ${'oops'.slice(0, i - 1)}`)); + await settle(); + } + stdin.press('client_123'); + await waitFor(() => expect(frameAt(stdout)).toContain('› client_123')); + await settle(); + // Typing clears the error. + expect(frameAt(stdout)).not.toContain('should start with'); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith('client_123')); + }); + + it('edits by character, so an emoji is one step', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ kind: 'text', message: 'Team name?' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Team name?')); + for (const key of ['a', '😀', 'b', KEY.left, '\x7f', KEY.enter]) stdin.press(key); + await waitFor(() => expect(answer).toHaveBeenCalledWith('ab')); + }); + + it("ignores keys that arrive as escape sequences Ink doesn't name", async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ kind: 'text', message: 'Team name?' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Team name?')); + for (const key of ['a', '\x1b[H', '\x1b[F', 'b', KEY.enter]) stdin.press(key); + await waitFor(() => expect(answer).toHaveBeenCalledWith('ab')); + }); + + it('shows a validator that throws as the error, instead of crashing', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ + kind: 'text', + message: 'Client ID?', + validate: async () => { + throw new Error('Could not check that client ID'); + }, + }); + await waitFor(() => expect(frameAt(stdout)).toContain('Client ID?')); + stdin.press('client_1\r'); + await waitFor(() => expect(frameAt(stdout)).toContain('✗ Could not check that client ID')); + expect(answer).not.toHaveBeenCalled(); + }); + + it('masks password input', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + model.setPrompt({ kind: 'password', message: 'Enter your WorkOS API Key:' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Enter your WorkOS API Key:')); + stdin.press('sk_secret'); + await waitFor(() => expect(frameAt(stdout)).toContain('*********')); + expect(frameAt(stdout)).not.toContain('sk_secret'); + await settle(); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith('sk_secret')); + }); + + // Helpers for the horizontal-viewport regression tests below. + const inputRow = (stdout: FakeStdout) => + frameAt(stdout) + .split('\n') + .find((l) => l.includes(glyphs.pointer)) ?? ''; + const bodyRows = (stdout: FakeStdout) => + frameAt(stdout) + .split('\n') + .filter((l) => !l.includes(glyphs.pointer)); + + it.each(['text', 'password'] as const)('keeps a long %s value on one row', async (kind) => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(80, 24, { answer }); + model.setPrompt({ kind, message: 'Paste a token:' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Paste a token:')); + + const long = `START-${'abcdefghij'.repeat(20)}-END`; + const visibleTail = kind === 'password' ? '*'.repeat(10) : long.slice(-10); + stdin.press(long); + // The freshly typed tail stays visible next to the cursor… + await waitFor(() => expect(inputRow(stdout)).toContain(visibleTail)); + await settle(); + // …the input never wraps past the 80-column budget… + expect(inputRow(stdout).length).toBeLessThanOrEqual(80); + // …and the value never spills onto another line. + expect(bodyRows(stdout).some((l) => l.includes(kind === 'password' ? '**********' : 'abcdefghij'))).toBe(false); + if (kind === 'password') expect(frameAt(stdout)).not.toContain('abcdefghij'); + + // The full value is submitted intact despite only a window being shown. + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith(long)); + }); + + it('scrolls the viewport back to the start when the cursor moves home', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(80, 24, { answer }); + model.setPrompt({ kind: 'text', message: 'Token?' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Token?')); + const long = `START-${'0123456789'.repeat(12)}-END`; + stdin.press(long); + await waitFor(() => expect(inputRow(stdout)).toContain('-END')); + expect(inputRow(stdout)).not.toContain('START-'); + for (let i = 0; i < long.length; i++) stdin.press(KEY.left); + await waitFor(() => expect(inputRow(stdout)).toContain('START-')); + expect(inputRow(stdout)).not.toContain('-END'); + expect(inputRow(stdout).length).toBeLessThanOrEqual(80); + stdin.press('X'); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith(`X${long}`)); + }); + + it('clips a long placeholder to one row', async () => { + const { model, stdout } = mount(80, 24); + model.setPrompt({ kind: 'text', message: 'Path?', placeholder: 'x'.repeat(200) }); + await waitFor(() => expect(frameAt(stdout)).toContain('Path?')); + await settle(); + expect(inputRow(stdout).length).toBeLessThanOrEqual(80); + expect(bodyRows(stdout).some((l) => l.includes('xxxx'))).toBe(false); + }); + + it('measures wide Unicode by display width so it fits one row', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(80, 24, { answer }); + model.setPrompt({ kind: 'text', message: 'Name?' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Name?')); + const wide = '中'.repeat(100); // each renders two columns → 200 cells + stdin.press(wide); + await waitFor(() => expect(inputRow(stdout)).toContain('中')); + await settle(); + expect(stringWidth(inputRow(stdout))).toBeLessThanOrEqual(80); + expect(bodyRows(stdout).some((l) => l.includes('中'))).toBe(false); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith(wide)); + }); + + it('cancels an open prompt with esc or ctrl-c, without interrupting the run', async () => { + const answer = vi.fn(); + const interrupt = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer, interrupt }); + model.setPrompt({ kind: 'confirm', message: 'Create a pull request?' }); + await waitFor(() => expect(frameAt(stdout)).toContain('Create a pull request?')); + + stdin.press(KEY.escape); + await waitFor(() => expect(answer).toHaveBeenCalledWith(CANCEL)); + stdin.press(KEY.ctrlC); + await waitFor(() => expect(answer).toHaveBeenCalledTimes(2)); + expect(answer).toHaveBeenLastCalledWith(CANCEL); + expect(interrupt).not.toHaveBeenCalled(); + }); + + it('interrupts on ctrl-c when no prompt is open', async () => { + const interrupt = vi.fn(); + const { stdout, stdin } = mount(100, 30, { interrupt }); + await waitFor(() => expect(frameAt(stdout)).toContain('ctrl-c cancel')); + stdin.press(KEY.ctrlC); + await waitFor(() => expect(interrupt).toHaveBeenCalledTimes(1)); + }); + + it('pages through tips and news with the arrow keys', async () => { + const { stdout, stdin } = mount(120, 40); + const total = content.announcements.length + content.tips.filter((t) => !t.frameworks).length; + await waitFor(() => expect(frameAt(stdout)).toContain(`Tips & news 1/${total}`)); + stdin.press(KEY.right); + await waitFor(() => expect(frameAt(stdout)).toContain(`Tips & news 2/${total}`)); + expect(frameAt(stdout)).toContain(content.announcements[1].title); + stdin.press(KEY.left); + stdin.press(KEY.left); + await waitFor(() => expect(frameAt(stdout)).toContain(`Tips & news ${total}/${total}`)); + }); + + it('asks the user to enlarge a too-small window but keeps the prompt answerable', async () => { + const answer = vi.fn(); + const { model, stdout, stdin } = mount(100, 30, { answer }); + stdout.resize(60, 20); + model.setPrompt({ kind: 'confirm', message: 'Commit the changes?' }); + await waitFor(() => expect(frameAt(stdout, 'Make this window')).toContain('Make this window at least 80×24')); + expect(frameAt(stdout, 'Make this window')).toContain('Commit the changes?'); + stdin.press('y'); + await waitFor(() => expect(answer).toHaveBeenCalledWith(true)); + }); + + it('shows the running status in the status bar', async () => { + const { model, stdout } = mount(100, 30); + model.setStatus('Waiting for authentication...'); + await waitFor(() => expect(frameAt(stdout)).toContain('Waiting for authentication...')); + }); +}); + +describe('run model wiring', () => { + it('re-renders when the model changes', async () => { + const { emitter, stdout } = mount(100, 30); + emitter.emit('branch:created', { branch: 'feat/add-workos-authkit' }); + await waitFor(() => expect(frameAt(stdout)).toContain('I made a new branch, feat/add-workos-authkit')); + }); +}); diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx new file mode 100644 index 00000000..cc1efd4e --- /dev/null +++ b/src/tui/components/Header.tsx @@ -0,0 +1,88 @@ +import { Box, Text } from 'ink'; +import { compactLogoRows, logoRows, LOGO_WIDTH } from '../../utils/logomark.js'; +import { colors } from '../theme.js'; +import type { RunSnapshot } from '../model/run-model.js'; +import { TipsCard } from './TipsCard.js'; + +export const FULL_HEADER_ROWS = 10; +export const COMPACT_HEADER_ROWS = 5; +const GAP = 3; + +interface HeaderProps { + snapshot: RunSnapshot; + columns: number; + compact: boolean; + projectName: string; + tipIndex: number; +} + +function Logo({ compact }: { compact: boolean }) { + if (compact) { + return ( + + {compactLogoRows().map((row, i) => ( + + {row} + + ))} + + ); + } + return ( + + {logoRows().map((runs, i) => ( + + {runs.map((run, j) => + run.painted ? ( + + {run.text} + + ) : ( + {run.text} + ), + )} + + ))} + + ); +} + +export function Header({ snapshot, columns, compact, projectName, tipIndex }: HeaderProps) { + const height = compact ? COMPACT_HEADER_ROWS : FULL_HEADER_ROWS; + const width = Math.max(20, columns - LOGO_WIDTH - GAP); + const where = [snapshot.framework, projectName].filter(Boolean).join(' · '); + const title = ( + + + WorkOS + + AuthKit installer + {compact && where ? {` ${where}`} : null} + + ); + + return ( + + + + + {title} + {compact ? null : ( + <> + + {where || ' '} + + + + )} + + + + ); +} diff --git a/src/tui/components/PromptPanel.tsx b/src/tui/components/PromptPanel.tsx new file mode 100644 index 00000000..225521c1 --- /dev/null +++ b/src/tui/components/PromptPanel.tsx @@ -0,0 +1,365 @@ +import { useCallback, useRef, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { ConfirmInput } from '@inkjs/ui'; +import stringWidth from 'string-width'; +import { CANCEL, type SelectOption, type UiLine, type UiPromptRequest, type ValidateFn } from '../../utils/ui.js'; +import { colors, glyphs } from '../theme.js'; +import { wrapText } from '../wrap.js'; + +const ANSI = /\x1b\[[0-9;]*m/g; +const DEFAULT_VISIBLE_OPTIONS = 7; + +type Answer = (value: unknown) => void; + +/** Rows the panel draws: a rule, what the question is about, the question, then its input. */ +export function promptHeight( + request: UiPromptRequest, + width: number, + maxOptions = DEFAULT_VISIBLE_OPTIONS, + maxContext = Number.POSITIVE_INFINITY, +): number { + const rule = 1 + Math.min(request.context?.length ?? 0, maxContext); + const question = wrapText(`? ${request.message.replace(ANSI, '')}`, Math.max(10, width)).length; + switch (request.kind) { + case 'confirm': + return rule + question + 1; + case 'select': + return rule + question + visibleOptions(request, maxOptions); + default: + // the input, plus a line kept for a validation error + return rule + question + 2; + } +} + +function visibleOptions(request: UiPromptRequest & { kind: 'select' }, maxOptions: number): number { + return Math.max(1, Math.min(request.options.length, request.maxItems ?? DEFAULT_VISIBLE_OPTIONS, maxOptions)); +} + +function Question({ message, width, suffix }: { message: string; width: number; suffix?: string }) { + return ( + + + + ?{' '} + + {message} + {suffix ? {` ${suffix}`} : null} + + + ); +} + +function ConfirmPrompt({ + request, + answer, + width, +}: { + request: UiPromptRequest & { kind: 'confirm' }; + answer: Answer; + width: number; +}) { + return ( + + + + {' '} + answer(true)} + onCancel={() => answer(false)} + /> + + + ); +} + +function selectable(option: SelectOption): boolean { + return !option.disabled; +} + +function SelectPrompt({ + request, + answer, + width, + maxOptions, +}: { + request: UiPromptRequest & { kind: 'select' }; + answer: Answer; + width: number; + maxOptions: number; +}) { + const { options } = request; + // Start on the initial value if it's selectable, else the first option that is. + const preferred = options.findIndex((o) => selectable(o) && o.value === request.initialValue); + const initial = preferred >= 0 ? preferred : Math.max(0, options.findIndex(selectable)); + const [focus, setFocus] = useState(initial); + // Keys can arrive faster than Ink re-subscribes this handler after a render + // (a fast ↓ then enter, or a paste), and a stale handler would pick the + // option from before the move. The ref is always current. + const focusRef = useRef(initial); + const visibleCount = visibleOptions(request, maxOptions); + const start = Math.min(Math.max(0, focus - visibleCount + 1), Math.max(0, options.length - visibleCount)); + + const move = (step: number) => { + for (let i = 1; i <= options.length; i++) { + const next = (focusRef.current + step * i + options.length) % options.length; + if (selectable(options[next])) { + focusRef.current = next; + setFocus(next); + return; + } + } + }; + + useInput((input, key) => { + const current = focusRef.current; + if (key.upArrow || input === 'k') move(-1); + else if (key.downArrow || input === 'j') move(1); + else if (key.return && selectable(options[current])) answer(options[current].value); + }); + + return ( + + + {options.slice(start, start + visibleCount).map((option, i) => { + const index = start + i; + const focused = index === focus; + const label = option.label ?? String(option.value); + const note = typeof option.disabled === 'string' ? option.disabled : option.hint; + return ( + + {focused ? `${glyphs.pointer} ` : ' '} + + {label} + + {note ? {` ${note}`} : null} + + ); + })} + + ); +} + +/** Display width in terminal cells; a wide glyph (CJK, emoji) counts as two. */ +function cellWidth(char: string): number { + return Math.max(1, stringWidth(char)); +} + +/** + * The widest run of `cells` that fits in `width` columns while keeping the cell + * at `cursor` visible. Grows left from the cursor first (so the tail stays in + * view while typing), then fills any remaining room to the right. + */ +function viewport(cells: string[], cursor: number, width: number): { start: number; end: number } { + if (cells.length === 0) return { start: 0, end: 0 }; + const pos = Math.min(Math.max(0, cursor), cells.length - 1); + let used = cellWidth(cells[pos]); + let start = pos; + while (start > 0 && used + cellWidth(cells[start - 1]) <= width) { + start--; + used += cellWidth(cells[start]); + } + let end = pos + 1; + while (end < cells.length && used + cellWidth(cells[end]) <= width) { + used += cellWidth(cells[end]); + end++; + } + return { start, end }; +} + +/** + * A one-line text input that can't drop keys. + * + * @inkjs/ui's TextInput keeps its value in state that only catches up after a + * render, so keys arriving faster than that (typing ahead, a paste) read a + * stale value, and a paste ending in a newline was inserted as text instead of + * submitting. Here the value lives in a ref that every key reads and writes + * immediately, and a newline anywhere in a chunk submits what precedes it. + */ +function LineInput({ + mask, + placeholder, + width, + onChange, + onSubmit, +}: { + mask?: string; + placeholder?: string; + /** Columns available to the value, so a long entry scrolls instead of wrapping. */ + width: number; + onChange: () => void; + onSubmit: (value: string) => void; +}) { + const [shown, setShown] = useState({ value: '', cursor: 0 }); + const state = useRef(shown); + const set = (next: { value: string; cursor: number }) => { + state.current = next; + setShown(next); + onChange(); + }; + + // The cursor counts characters (code points), so an emoji is one step. + useInput((input, key) => { + const { value, cursor } = state.current; + const chars = Array.from(value); + if (key.return) return onSubmit(value); + if (key.leftArrow) return set({ value, cursor: Math.max(0, cursor - 1) }); + if (key.rightArrow) return set({ value, cursor: Math.min(chars.length, cursor + 1) }); + if (key.backspace || key.delete) { + if (cursor > 0) { + chars.splice(cursor - 1, 1); + set({ value: chars.join(''), cursor: cursor - 1 }); + } + return; + } + if (!input || key.ctrl || key.meta || key.escape || key.tab || key.upArrow || key.downArrow) return; + // A key Ink doesn't name (Home, End, F-keys) arrives as its escape sequence. + if (input.startsWith('\x1b')) return; + const newline = input.search(/[\r\n]/); + const typed = Array.from(newline >= 0 ? input.slice(0, newline) : input); + chars.splice(cursor, 0, ...typed); + const next = { value: chars.join(''), cursor: cursor + typed.length }; + set(next); + if (newline >= 0) onSubmit(next.value); + }); + + const { value, cursor } = shown; + const view = Math.max(1, width); + if (!value && placeholder) { + const cells = Array.from(placeholder); + const { end } = viewport(cells, 0, view); + const [head, ...rest] = cells.slice(0, end); + return ( + + {head} + {rest.join('')} + + ); + } + // A trailing cell carries the cursor when it sits past the last character. + const cells = Array.from(value).map((c) => mask ?? c); + if (cursor >= cells.length) cells.push(' '); + const pos = Math.min(cursor, cells.length - 1); + const { start, end } = viewport(cells, pos, view); + return ( + + {cells.slice(start, pos).join('')} + {cells[pos] ?? ' '} + {cells.slice(pos + 1, end).join('')} + + ); +} + +function TextPrompt({ + request, + answer, + width, +}: { + request: UiPromptRequest & { kind: 'text' | 'password' }; + answer: Answer; + width: number; +}) { + const [error, setError] = useState(null); + const clearError = useCallback(() => setError(null), []); + const fallback = request.kind === 'text' ? (request.defaultValue ?? request.initialValue) : undefined; + const placeholder = request.kind === 'text' ? (request.placeholder ?? fallback) : undefined; + + const submit = async (typed: string) => { + const value = typed === '' && fallback !== undefined ? fallback : typed; + let verdict: unknown; + try { + verdict = await (request.validate as ValidateFn | undefined)?.(value); + } catch (error) { + // A validator that throws is saying no; it mustn't crash the installer. + verdict = error; + } + if (verdict != null) { + setError(verdict instanceof Error ? verdict.message : String(verdict)); + return; + } + answer(value); + }; + + return ( + + + + {`${glyphs.pointer} `} + void submit(value)} + /> + + + {error ? `${glyphs.failed} ${error}` : ' '} + + + ); +} + +/** Context lines, trimmed to `max` rows with the overflow counted on the last one. */ +function PromptContext({ context, max }: { context: readonly UiLine[]; max: number }) { + if (context.length === 0 || max < 1) return null; + const fits = context.length <= max; + const shown = fits ? context : context.slice(0, max - 1); + const hidden = context.length - shown.length; + return ( + <> + {shown.map((line, i) => ( + + {line.rendered} + + ))} + {hidden > 0 ? {` … ${hidden} more`} : null} + + ); +} + +interface PromptPanelProps { + request: UiPromptRequest; + answer: Answer; + width: number; + /** Most option rows a select may show in the space available. */ + maxOptions?: number; + /** Most rows of context above the question; the rest collapse into "… N more". */ + maxContext?: number; +} + +export function PromptPanel({ + request, + answer, + width, + maxOptions = DEFAULT_VISIBLE_OPTIONS, + maxContext = Number.POSITIVE_INFINITY, +}: PromptPanelProps) { + // Esc cancels, and so does ctrl-c while a question is open, the same as the + // plain CLI's prompts. + useInput((input, key) => { + if (key.escape || (key.ctrl && input === 'c')) answer(CANCEL); + }); + + let body; + switch (request.kind) { + case 'confirm': + body = ; + break; + case 'select': + body = ; + break; + default: + body = ; + } + + return ( + + {'─'.repeat(Math.max(1, width))} + {/* What the caller printed before asking, e.g. the files behind "Continue anyway?" */} + + {body} + + ); +} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx new file mode 100644 index 00000000..7097ab6c --- /dev/null +++ b/src/tui/components/StatusBar.tsx @@ -0,0 +1,37 @@ +import { Box, Text } from 'ink'; +import type { RunSnapshot } from '../model/run-model.js'; +import { useSpinner } from '../hooks.js'; +import { colors } from '../theme.js'; + +function hints(snapshot: RunSnapshot): string { + if (snapshot.prompt?.kind === 'select') return '↑/↓ choose · enter select · esc cancel'; + if (snapshot.prompt?.kind === 'confirm') return 'y/n · enter default · esc cancel'; + if (snapshot.prompt) return 'enter submit · esc cancel'; + if (snapshot.outcome) return 'finishing up…'; + return '←/→ tips · ctrl-c cancel'; +} + +export function StatusBar({ snapshot, width }: { snapshot: RunSnapshot; width: number }) { + const running = Boolean(snapshot.status) && !snapshot.prompt; + const spinner = useSpinner(running); + const help = hints(snapshot); + const statusWidth = Math.max(0, width - help.length - 2); + + return ( + + + {running ? ( + + {spinner} + {` ${snapshot.status}`} + + ) : null} + + + + {help} + + + + ); +} diff --git a/src/tui/components/TaskList.tsx b/src/tui/components/TaskList.tsx new file mode 100644 index 00000000..76c97f7c --- /dev/null +++ b/src/tui/components/TaskList.tsx @@ -0,0 +1,183 @@ +import { Box, Text } from 'ink'; +import { countedTasks, type SubtaskView, type TaskStatus, type TaskView } from '../model/run-model.js'; +import { colors, glyphs } from '../theme.js'; +import { useSpinner } from '../hooks.js'; + +/** One line of the list: a task or, indented under it, one of its sub-steps. */ +interface Row { + key: string; + label: string; + status: TaskStatus; + indent: boolean; +} + +function rowsFor(tasks: TaskView[]): Row[] { + return tasks.flatMap((task) => [ + { + key: task.id, + label: task.status === 'in_progress' && task.activeLabel ? task.activeLabel : task.label, + status: task.status, + indent: false, + }, + ...(task.subtasks ?? []).map((sub: SubtaskView) => ({ + key: `${task.id}/${sub.id}`, + label: sub.label, + status: sub.status, + indent: true, + })), + ]); +} + +function TaskRow({ row, spinner }: { row: Row; spinner: string }) { + const pad = row.indent ? ' ' : ''; + switch (row.status) { + case 'completed': + return ( + + {pad} + {glyphs.done} {row.label} + + ); + case 'in_progress': + return ( + + {pad} + {spinner} {row.label} + + ); + case 'next': + return ( + + {pad} + {glyphs.next} {row.label} + + ); + case 'failed': + return ( + + {pad} + {glyphs.failed} {row.label} + + ); + case 'attention': + return ( + + {pad} + {glyphs.warning} {row.label} + + ); + case 'cancelled': + return ( + + {pad} + {glyphs.cancelled} {row.label} + + ); + case 'skipped': + return ( + + {pad} + {glyphs.skipped} {row.label} + + ); + default: + return ( + + {pad} + {glyphs.pending} {row.label} + + ); + } +} + +/** + * The task list, fitted to `height`: the heading and counter go first when + * space is short, then it shows a window of rows around the current one. + */ +export function TaskList({ tasks, width, height }: { tasks: TaskView[]; width: number; height: number }) { + const rows = rowsFor(tasks); + const running = rows.some((r) => r.status === 'in_progress'); + const spinner = useSpinner(running); + const counted = countedTasks(tasks); + const done = counted.filter((t) => t.status === 'completed').length; + + const showTitle = height >= rows.length + 2; + const showCount = height >= rows.length + (showTitle ? 1 : 0) + 1; + const room = Math.max(1, height - (showTitle ? 1 : 0) - (showCount ? 1 : 0)); + // Keep the deepest running row in view (a sub-step over its parent). + const lastRunning = rows.map((r) => r.status).lastIndexOf('in_progress'); + const current = + lastRunning >= 0 + ? lastRunning + : Math.max( + 0, + rows.findIndex((r) => r.status === 'pending'), + ); + const start = Math.min(Math.max(0, current - 1), Math.max(0, rows.length - room)); + + return ( + + {showTitle ? Tasks : null} + {rows.slice(start, start + room).map((row) => ( + + ))} + {showCount ? {`${done} of ${counted.length} done`} : null} + + ); +} + +/** + * The task list as one line, for terminals too small to show it beside the + * walkthrough: where the run is and how much is done. + */ +export function ProgressLine({ tasks, width }: { tasks: TaskView[]; width: number }) { + const counted = countedTasks(tasks); + const done = counted.filter((t) => t.status === 'completed').length; + const count = `${done} of ${counted.length} done`; + const active = tasks.find((t) => t.status === 'in_progress'); + const failed = tasks.find((t) => t.status === 'failed' || t.status === 'cancelled'); + const next = tasks.find((t) => t.status === 'next'); + const spinner = useSpinner(Boolean(active)); + + let line; + if (active) { + const sub = active.subtasks?.filter((s) => s.status === 'in_progress').at(-1); + const label = `${active.activeLabel ?? active.label}${sub ? ` · ${sub.label}` : ''}`; + line = ( + + {spinner} + {` ${label}`} + {` · ${count}`} + + ); + } else if (failed) { + const cancelled = failed.status === 'cancelled'; + line = ( + + {`${cancelled ? glyphs.cancelled : glyphs.failed} ${failed.label} ${cancelled ? 'cancelled' : 'failed'}`} + {` · ${count}`} + + ); + } else if (next) { + // Done, unless a WorkOS setting still needs a look in the dashboard. + const check = tasks.some((t) => t.subtasks?.some((s) => s.status === 'attention' || s.status === 'failed')); + line = ( + + {check ? ( + {`${glyphs.warning} Check your WorkOS settings`} + ) : ( + {`${glyphs.done} All done`} + )} + {` · ${glyphs.next} ${next.label}`} + + ); + } else { + line = {`${glyphs.pending} ${count}`}; + } + + return ( + + {line} + + ); +} diff --git a/src/tui/components/TipsCard.tsx b/src/tui/components/TipsCard.tsx new file mode 100644 index 00000000..9cfb1b93 --- /dev/null +++ b/src/tui/components/TipsCard.tsx @@ -0,0 +1,76 @@ +import { Box, Text } from 'ink'; +import type { Announcement, Tip } from '../content/index.js'; +import type { RunSnapshot } from '../model/run-model.js'; +import { colors, glyphs } from '../theme.js'; +import { wrapText } from '../wrap.js'; + +export type Card = (Tip | Announcement) & { kind: 'news' | 'tip' }; + +/** Announcements first, then tips. */ +export function cardsFor(snapshot: RunSnapshot): Card[] { + return [ + ...snapshot.announcements.map((a) => ({ ...a, kind: 'news' as const })), + ...snapshot.tips.map((t) => ({ ...t, kind: 'tip' as const })), + ]; +} + +interface TipsCardProps { + snapshot: RunSnapshot; + /** Rotation position; wraps around the card count. */ + index: number; + width: number; + height: number; + showLabel: boolean; +} + +export function TipsCard({ snapshot, index, width, height, showLabel }: TipsCardProps) { + const cards = cardsFor(snapshot); + if (cards.length === 0 || height < 1) return null; + const position = ((index % cards.length) + cards.length) % cards.length; + const card = cards[position]; + + // Budget: optional label, title, then body, command, and link as room allows. + let room = height - (showLabel ? 1 : 0) - 1; + const extras = [card.command, card.url].filter(Boolean).length; + const bodyRoom = Math.max(room > extras ? 1 : 0, room - extras); + const body = wrapText(card.body, width).slice(0, bodyRoom); + room -= body.length; + const command = card.command && room > 0 ? card.command : undefined; + if (command) room--; + const url = card.url && room > 0 ? card.url : undefined; + + return ( + + {showLabel ? ( + + {`Tips & news ${position + 1}/${cards.length}`} + + ) : null} + + {card.kind === 'news' ? ( + + {' NEW '} + + ) : ( + {glyphs.tip} + )} + {` ${card.title}`} + + {body.map((line, i) => ( + + {line} + + ))} + {command ? ( + + {`$ ${command}`} + + ) : null} + {url ? ( + + {url} + + ) : null} + + ); +} diff --git a/src/tui/components/Walkthrough.tsx b/src/tui/components/Walkthrough.tsx new file mode 100644 index 00000000..a10538b5 --- /dev/null +++ b/src/tui/components/Walkthrough.tsx @@ -0,0 +1,51 @@ +import { Box, Text } from 'ink'; +import type { WalkthroughEntry } from '../model/run-model.js'; +import { colors, glyphs } from '../theme.js'; +import { fitNewest, wrapText } from '../wrap.js'; + +const PREFIX_WIDTH = 2; + +function prefix(entry: WalkthroughEntry): { glyph: string; color?: string } { + if (entry.kind === 'narration' || entry.kind === 'notice') { + if (entry.tone === 'success') return { glyph: glyphs.done, color: colors.success }; + if (entry.tone === 'warning') return { glyph: glyphs.warning, color: colors.warning }; + if (entry.tone === 'error') return { glyph: glyphs.failed, color: colors.error }; + return { glyph: glyphs.pointer, color: colors.brand }; + } + return { glyph: glyphs.bullet, color: colors.muted }; +} + +function textColor(entry: WalkthroughEntry): string | undefined { + if (entry.kind === 'notice') return entry.tone === 'error' ? colors.error : colors.warning; + if (entry.kind === 'status') return colors.link; + if (entry.kind === 'file' || entry.kind === 'command') return colors.muted; + return undefined; +} + +interface WalkthroughProps { + entries: WalkthroughEntry[]; + width: number; + height: number; +} + +/** The newest entries that fit, oldest at the top, wrapped to the pane. */ +export function Walkthrough({ entries, width, height }: WalkthroughProps) { + const textWidth = Math.max(10, width - PREFIX_WIDTH); + const visible = fitNewest(entries, (e) => wrapText(e.text, textWidth), Math.max(1, height - 1)); + + return ( + + What's happening + {visible.length === 0 ? Getting started… : null} + {visible.flatMap(({ item, lines }) => { + const { glyph, color } = prefix(item); + return lines.map((line, i) => ( + + {i === 0 ? glyph : ' '} + {` ${line}`} + + )); + })} + + ); +} diff --git a/src/tui/content/README.md b/src/tui/content/README.md new file mode 100644 index 00000000..ed2ec43c --- /dev/null +++ b/src/tui/content/README.md @@ -0,0 +1,55 @@ +# Installer content + +`installer-content.json` holds everything the full-screen installer says that +isn't code: task labels, the walkthrough, tips, and announcements. Edit it, +run `bun run test src/tui/content`, and ship. It's bundled into the binary, so +changes go out with the next release. + +## Tips and announcements + +Both render in the "Tips & news" panel, which rotates through announcements +first and then tips. + +```json +{ + "id": "branding", + "title": "Make AuthKit look like your app", + "body": "Set your logo and colors so the sign-in page feels like home.", + "command": "workos branding set", + "url": "https://workos.com/docs/authkit/branding", + "frameworks": ["nextjs"] +} +``` + +- `id`: lowercase-kebab and unique across tips and announcements. +- `command` and `url` are optional. URLs must be `https`. +- `frameworks` is optional. Omit it to show the card for every framework. + Otherwise it lists integration ids (keys of `frameworks` at the top of the + file), and the card appears once that framework is detected. +- Announcements also take `startsAt` / `endsAt` (`2026-10-01` or a full ISO + timestamp). Both are inclusive, so `"endsAt": "2026-10-31"` shows through + the whole of October 31 (UTC). + +## Walkthrough + +`walkthrough` maps an installer event (see `src/lib/events.ts`) to the sentence +shown when it happens. Events that aren't listed aren't narrated. + +Some events fill `{placeholders}`, for example `"branch:created": "I made a new +branch, {branch}."`. The allowed placeholders per event are in +`WALKTHROUGH_PARAMS` in `schema.ts`. Events with more than one outcome +(`complete`, `validation:complete`) take an object with one sentence per outcome +(see `WALKTHROUGH_VARIANTS`). + +## Tasks + +`tasks` sets the label for each step in the task list, plus an optional +`activeLabel` shown while that step runs. Which events move a task is code +(`src/tui/model/`), so the set of task ids is fixed. + +`subtasks` labels the dashboard checklist items shown under "Configure WorkOS" +and "Connect your app's URLs" while they run. Keep them in the dashboard's +words ("Set redirect URI"), so the two checklists read the same. + +The tests reject unknown events, placeholders, frameworks, duplicate ids, and +bad dates, with a message naming the exact field. diff --git a/src/tui/content/content.spec.ts b/src/tui/content/content.spec.ts new file mode 100644 index 00000000..870ba300 --- /dev/null +++ b/src/tui/content/content.spec.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import bundled from './installer-content.json' with { type: 'json' }; +import { INSTALLER_EVENT_NAMES, type InstallerEventName } from '../../lib/events.js'; +import { + TASK_IDS, + WALKTHROUGH_PARAMS, + WALKTHROUGH_VARIANTS, + frameworkName, + interpolate, + isAnnouncementActive, + loadInstallerContent, + parseInstallerContent, + placeholdersOf, + selectAnnouncements, + selectTips, + walkthroughText, + type InstallerContent, +} from './index.js'; + +/** A deep copy of the bundled content to mutate per test. */ +function raw(): Record { + return structuredClone(bundled) as Record; +} + +function problems(content: unknown): string { + try { + parseInstallerContent(content); + return ''; + } catch (error) { + return (error as Error).message; + } +} + +describe('bundled installer content', () => { + const content = loadInstallerContent(); + + it('parses against the schema', () => { + expect(problems(bundled)).toBe(''); + }); + + it('labels every task', () => { + for (const id of TASK_IDS) expect(content.tasks[id].label.length).toBeGreaterThan(0); + }); + + it('names a display name for every integration and nothing else', () => { + const integrationsDir = join(import.meta.dirname, '../../integrations'); + const integrations = readdirSync(integrationsDir) + .filter((name) => statSync(join(integrationsDir, name)).isDirectory()) + .sort(); + expect(Object.keys(content.frameworks).sort()).toEqual(integrations); + }); + + it('keys the walkthrough by real installer events', () => { + const known = new Set(INSTALLER_EVENT_NAMES); + for (const event of Object.keys(content.walkthrough)) expect(known.has(event), event).toBe(true); + }); + + it('resolves every placeholder when the model supplies its declared params', () => { + for (const [event, copy] of Object.entries(content.walkthrough)) { + const params = Object.fromEntries( + (WALKTHROUGH_PARAMS[event as InstallerEventName] ?? []).map((name) => [name, 'X']), + ); + const variants = WALKTHROUGH_VARIANTS[event as InstallerEventName] ?? [undefined]; + for (const variant of variants) { + const text = walkthroughText(content, event as InstallerEventName, params, variant); + expect(text, `${event}${variant ? `.${variant}` : ''}`).toBeDefined(); + expect(placeholdersOf(text!), `${event}: ${text}`).toEqual([]); + } + expect(copy).toBeTruthy(); + } + }); + + it('uses unique ids across tips and announcements', () => { + const ids = [...content.tips, ...content.announcements].map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('has at least one tip for every framework and one untargeted announcement', () => { + for (const framework of Object.keys(content.frameworks)) { + expect(selectTips(content, { framework, now: new Date() }).length, framework).toBeGreaterThan(0); + } + expect(content.announcements.some((a) => !a.frameworks && !a.startsAt && !a.endsAt)).toBe(true); + }); +}); + +describe('content validation', () => { + it('rejects a walkthrough key that is not an installer event', () => { + const c = raw(); + c.walkthrough['agent:thinking'] = 'Hmm.'; + expect(problems(c)).toContain('walkthrough.agent:thinking: "agent:thinking" is not an installer event'); + }); + + it('rejects a placeholder the event does not supply, naming the ones it does', () => { + const c = raw(); + c.walkthrough['branch:created'] = 'Made {branchName}.'; + expect(problems(c)).toContain('unknown placeholder {branchName} (available: {branch})'); + }); + + it('rejects a placeholder on an event that supplies none', () => { + const c = raw(); + c.walkthrough['auth:success'] = 'Hi {name}!'; + expect(problems(c)).toContain('unknown placeholder {name} (none available)'); + }); + + it('requires exactly the declared outcomes for variant events', () => { + const missing = raw(); + delete missing.walkthrough.complete.cancelled; + expect(problems(missing)).toContain('outcomes must be exactly success, setup-required, failure, cancelled'); + + const flat = raw(); + flat.walkthrough.complete = 'Done.'; + expect(problems(flat)).toContain('needs one entry per outcome'); + + const nested = raw(); + nested.walkthrough['auth:success'] = { yes: 'ok' }; + expect(problems(nested)).toContain('must be a single string'); + }); + + it('rejects duplicate ids across tips and announcements', () => { + const c = raw(); + c.announcements.push({ ...c.tips[0] }); + expect(problems(c)).toContain(`duplicate id "${c.tips[0].id}"`); + }); + + it('rejects a card targeting an unknown framework', () => { + const c = raw(); + c.tips[0].frameworks = ['nuxt']; + expect(problems(c)).toContain('unknown framework "nuxt"'); + }); + + it('rejects missing tasks, unknown fields, non-https URLs and bad dates', () => { + const noTask = raw(); + delete noTask.tasks.install; + expect(problems(noTask)).toContain('tasks.install'); + + const extra = raw(); + extra.tips[0].emoji = '🎉'; + expect(problems(extra)).toMatch(/tips\.0: .*emoji/); + + const http = raw(); + http.tips[0].url = 'http://workos.com/docs/sso'; + expect(problems(http)).toContain('tips.0.url'); + + const date = raw(); + date.announcements[0].startsAt = 'next tuesday'; + expect(problems(date)).toContain('expected an ISO date'); + + const backwards = raw(); + backwards.announcements[0].startsAt = '2026-10-02'; + backwards.announcements[0].endsAt = '2026-10-01'; + expect(problems(backwards)).toContain('ends before it starts'); + }); + + it('lists every problem at once', () => { + const c = raw(); + c.walkthrough['not:real'] = 'x'; + c.tips[0].frameworks = ['nuxt']; + const message = problems(c); + expect(message).toContain('not:real'); + expect(message).toContain('nuxt'); + }); +}); + +describe('selectors', () => { + const content: InstallerContent = parseInstallerContent({ + ...raw(), + tips: [ + { id: 'everywhere', title: 'All', body: 'Shown for every framework.' }, + { id: 'next-only', title: 'Next', body: 'Next.js only.', frameworks: ['nextjs'] }, + ], + announcements: [ + { id: 'always', title: 'Always', body: 'No window.' }, + { id: 'october', title: 'October', body: 'October only.', startsAt: '2026-10-01', endsAt: '2026-10-31' }, + { id: 'react-launch', title: 'React', body: 'React only.', frameworks: ['react'] }, + { + id: 'exact', + title: 'Exact', + body: 'Timestamps.', + startsAt: '2026-10-01T12:00:00Z', + endsAt: '2026-10-01T13:00:00Z', + }, + ], + }); + const at = (iso: string) => new Date(iso); + const ids = (cards: Array<{ id: string }>) => cards.map((c) => c.id); + + it('shows untargeted tips before a framework is known, targeted ones only for their framework', () => { + expect(ids(selectTips(content, { now: at('2026-09-01') }))).toEqual(['everywhere']); + expect(ids(selectTips(content, { framework: 'nextjs', now: at('2026-09-01') }))).toEqual([ + 'everywhere', + 'next-only', + ]); + expect(ids(selectTips(content, { framework: 'react', now: at('2026-09-01') }))).toEqual(['everywhere']); + }); + + it('filters announcements to their date window, inclusive of the whole end day', () => { + const on = (iso: string) => ids(selectAnnouncements(content, { now: at(iso) })); + expect(on('2026-09-30T23:59:59Z')).toEqual(['always']); + expect(on('2026-10-01T00:00:00Z')).toEqual(['always', 'october']); + expect(on('2026-10-31T23:59:59Z')).toEqual(['always', 'october']); + expect(on('2026-11-01T00:00:00Z')).toEqual(['always']); + }); + + it('treats timestamp windows as exact instants', () => { + const exact = content.announcements.find((a) => a.id === 'exact')!; + expect(isAnnouncementActive(exact, at('2026-10-01T11:59:59Z'))).toBe(false); + expect(isAnnouncementActive(exact, at('2026-10-01T12:30:00Z'))).toBe(true); + expect(isAnnouncementActive(exact, at('2026-10-01T13:00:00Z'))).toBe(false); + }); + + it('filters announcements by framework too', () => { + expect(ids(selectAnnouncements(content, { framework: 'react', now: at('2026-09-01') }))).toEqual([ + 'always', + 'react-launch', + ]); + }); + + it('interpolates known params and leaves unknown ones visible', () => { + expect(interpolate('On {branch} with {count} files', { branch: 'main', count: 3 })).toBe('On main with 3 files'); + expect(interpolate('Hi {name}', {})).toBe('Hi {name}'); + }); + + it('picks the outcome variant and returns undefined for events it does not narrate', () => { + expect(walkthroughText(content, 'complete', {}, 'success')).toBe(content.walkthrough.complete.success); + expect(walkthroughText(content, 'complete')).toBeUndefined(); + expect(walkthroughText(content, 'git:checking')).toBeUndefined(); + }); + + it('falls back to the integration id for an unknown framework name', () => { + expect(frameworkName(content, 'nextjs')).toBe('Next.js'); + expect(frameworkName(content, 'cobol')).toBe('cobol'); + }); +}); diff --git a/src/tui/content/index.ts b/src/tui/content/index.ts new file mode 100644 index 00000000..57f128a9 --- /dev/null +++ b/src/tui/content/index.ts @@ -0,0 +1,19 @@ +/** + * The full-screen installer's content: task labels, walkthrough copy, tips, + * and announcements. Edit `installer-content.json` to change any of it; see + * README.md in this directory. + */ + +import bundled from './installer-content.json' with { type: 'json' }; +import { parseInstallerContent, type InstallerContent } from './schema.js'; + +let cached: InstallerContent | undefined; + +/** The bundled content, validated on first use. */ +export function loadInstallerContent(): InstallerContent { + cached ??= parseInstallerContent(bundled); + return cached; +} + +export * from './schema.js'; +export * from './select.js'; diff --git a/src/tui/content/installer-content.json b/src/tui/content/installer-content.json new file mode 100644 index 00000000..ee32f860 --- /dev/null +++ b/src/tui/content/installer-content.json @@ -0,0 +1,210 @@ +{ + "version": 1, + "frameworks": { + "dotnet": ".NET", + "elixir": "Phoenix", + "go": "Go", + "kotlin": "Spring Boot", + "nextjs": "Next.js", + "node": "Express", + "php": "PHP", + "php-laravel": "Laravel", + "python": "Django", + "react": "React", + "react-router": "React Router", + "ruby": "Rails", + "sveltekit": "SvelteKit", + "tanstack-start": "TanStack Start", + "vanilla-js": "JavaScript" + }, + "tasks": { + "sign-in": { + "label": "Sign in to WorkOS", + "activeLabel": "Signing in to WorkOS" + }, + "scaffold": { + "label": "Create a new app", + "activeLabel": "Creating a new app" + }, + "inspect": { + "label": "Look over your project", + "activeLabel": "Looking over your project" + }, + "credentials": { + "label": "Connect a WorkOS environment", + "activeLabel": "Connecting a WorkOS environment" + }, + "configure": { + "label": "Configure WorkOS", + "activeLabel": "Configuring WorkOS" + }, + "install": { + "label": "Add AuthKit to your code", + "activeLabel": "Adding AuthKit to your code" + }, + "verify": { + "label": "Check the changes", + "activeLabel": "Checking the changes" + }, + "app-urls": { + "label": "Connect your app's URLs", + "activeLabel": "Connecting your app's URLs" + }, + "finish": { + "label": "Wrap up", + "activeLabel": "Wrapping up" + }, + "first-sign-up": { + "label": "Complete your first sign-up" + } + }, + "subtasks": { + "env-vars": "Add environment variables", + "redirect-uri": "Set redirect URI", + "initiate-login-uri": "Set initiate login URI", + "sign-out-uri": "Set sign-out URI", + "cors-origin": "Set CORS origin" + }, + "walkthrough": { + "auth:checking": "First, I'm making sure you're signed in to WorkOS.", + "auth:success": "You're signed in.", + "auth:failure": "I couldn't confirm your WorkOS sign-in.", + "scaffold:prompt": "This folder is empty, so I can create a new Next.js app here for you.", + "scaffold:start": "Creating a new Next.js app with {packageManager}. This can take a minute.", + "scaffold:complete": "Your new app is ready.", + "scaffold:failed": "Creating the app didn't work.", + "detection:start": "Now I'm looking around your project to see what it's built with.", + "detection:complete": "This is a {framework} app. I know how to set that up.", + "detection:none": "I couldn't tell which framework this project uses.", + "git:dirty": "You have uncommitted changes (files: {count}), so I'll check with you before going further.", + "git:dirty:confirmed": "Okay, carrying on. I'll leave your existing changes alone.", + "branch:protected": "You're on {branch}. I'll ask whether to put these changes on their own branch.", + "branch:created": "I made a new branch, {branch}, so these changes stay separate.", + "credentials:gathering": "Next, I need the WorkOS keys for your app.", + "credentials:found": "I'll use the WorkOS keys you already have.", + "credentials:env:found": "Found your WorkOS keys in {file}.", + "device:started": "Open {url} in your browser and enter the code {code} to connect this computer to WorkOS.", + "device:success": "Connected. Thanks!", + "staging:fetching": "Getting your WorkOS environment ready.", + "staging:success": "Your WorkOS environment is ready.", + "credentials:request": "Paste your WorkOS keys below. You'll find them at dashboard.workos.com.", + "config:start": "Setting up WorkOS for this app.", + "config:complete": "WorkOS is configured.", + "agent:start": "Now the AuthKit agent is adding sign-in to your code. This usually takes a few minutes, so it's a good time for a coffee.", + "agent:retry": "Trying that again (attempt {attempt} of {maxRetries}).", + "agent:success": "The code changes are done.", + "agent:failure": "The agent ran into a problem.", + "file:write": "Created {path}", + "file:edit": "Updated {path}", + "agent:tool": "Ran {command}", + "validation:start": "Double-checking the changes.", + "validation:complete": { + "passed": "Everything checks out.", + "failed": "A few things are worth a look (issues: {count}). They'll be listed when this screen closes." + }, + "app-urls:step": "Your app's routes are in place, so now I'm pointing WorkOS at them.", + "postinstall:changes": "Files changed: {count}.", + "postinstall:commit:success": "Committed: {message}", + "postinstall:pr:success": "Pull request opened: {url}", + "postinstall:manual": "The GitHub CLI isn't installed, so here's how to open the pull request yourself.", + "complete": { + "success": "All done. AuthKit is installed. Start your app and complete your first sign-up.", + "setup-required": "The code is in. Some WorkOS settings need a look in the dashboard (listed above). Then start your app and complete your first sign-up.", + "failure": "The install didn't finish. The details will be shown when this screen closes.", + "cancelled": "Stopped. The installer was cancelled." + } + }, + "tips": [ + { + "id": "sso", + "title": "Add Enterprise SSO", + "body": "Let your customers sign in with Okta, Entra ID, Google Workspace, and more.", + "url": "https://workos.com/docs/sso" + }, + { + "id": "admin-portal", + "title": "Let IT admins set up SSO themselves", + "body": "Send a customer's IT team a link to the Admin Portal instead of a setup call.", + "command": "workos portal generate-link --intent sso --org ", + "url": "https://workos.com/docs/admin-portal" + }, + { + "id": "directory-sync", + "title": "Sync users from your customers' directories", + "body": "Directory Sync keeps users and groups up to date from Okta, Entra ID, and Google.", + "url": "https://workos.com/docs/directory-sync" + }, + { + "id": "organizations", + "title": "Group users into organizations", + "body": "Organizations are how WorkOS models your customers. Create one right from your terminal.", + "command": "workos organization create" + }, + { + "id": "roles", + "title": "Add roles and permissions", + "body": "Define roles once and check them in your app with role-based access control.", + "command": "workos role create", + "url": "https://workos.com/docs/rbac" + }, + { + "id": "branding", + "title": "Make AuthKit look like your app", + "body": "Set your logo and colors so the sign-in page feels like home.", + "command": "workos branding set", + "url": "https://workos.com/docs/authkit/branding" + }, + { + "id": "passkeys", + "title": "Turn on passkeys", + "body": "Let users sign in with Face ID, Touch ID, or a security key.", + "url": "https://workos.com/docs/authkit/passkeys" + }, + { + "id": "audit-logs", + "title": "Keep an audit trail", + "body": "Record who did what, and let your customers export it for their compliance teams.", + "url": "https://workos.com/docs/audit-logs" + }, + { + "id": "skills", + "title": "Teach your coding agent WorkOS", + "body": "Install WorkOS skills so your coding agent knows the APIs and best practices.", + "command": "workos skills install" + }, + { + "id": "doctor", + "title": "Something not working?", + "body": "The doctor checks your AuthKit setup and explains what to fix.", + "command": "workos doctor" + }, + { + "id": "nextjs-guide", + "title": "AuthKit for Next.js", + "body": "See how to protect routes and read the signed-in user in your Next.js app.", + "url": "https://workos.com/docs/authkit/nextjs", + "frameworks": ["nextjs"] + }, + { + "id": "react-guide", + "title": "AuthKit for React", + "body": "See how to read the signed-in user and protect parts of your React app.", + "url": "https://workos.com/docs/authkit/react", + "frameworks": ["react"] + } + ], + "announcements": [ + { + "id": "changelog", + "title": "See what's new at WorkOS", + "body": "New features and improvements ship every week.", + "url": "https://workos.com/changelog" + }, + { + "id": "cli-resources", + "title": "Run your whole WorkOS account from the CLI", + "body": "Manage organizations, users, roles, webhooks, and more without leaving your terminal.", + "command": "workos --help" + } + ] +} diff --git a/src/tui/content/schema.ts b/src/tui/content/schema.ts new file mode 100644 index 00000000..79347790 --- /dev/null +++ b/src/tui/content/schema.ts @@ -0,0 +1,204 @@ +/** + * Schema for the full-screen installer's swappable content: task labels, + * walkthrough copy, tips, and announcements. + * + * The content itself is data (installer-content.json). This file is the + * contract between that data and the run model: which tasks exist, which + * installer events the walkthrough can narrate, and which `{placeholders}` each + * event supplies. Validation runs once when the content loads, so a bad edit + * fails the content tests instead of rendering broken copy mid-install. + */ + +import { z } from 'zod'; +import { INSTALLER_EVENT_NAMES, type InstallerEventName } from '../../lib/events.js'; + +/** Task ids in display order. The run model owns what moves each one. */ +export const TASK_IDS = [ + 'sign-in', + 'scaffold', + 'inspect', + 'credentials', + 'configure', + 'install', + 'verify', + 'app-urls', + 'finish', + 'first-sign-up', +] as const; +export type TaskId = (typeof TASK_IDS)[number]; + +/** + * Dashboard checklist items, in the dashboard's AuthKit checklist order. Ids + * match the installer's `config:step` and `app-urls:step` events. + */ +export const SUBTASK_IDS = ['env-vars', 'redirect-uri', 'initiate-login-uri', 'sign-out-uri', 'cors-origin'] as const; +export type SubtaskId = (typeof SUBTASK_IDS)[number]; + +/** + * Placeholders each narrated event can fill, e.g. `{branch}` for + * `branch:created`. An event that isn't listed supplies none. The run model + * builds exactly these params; the content validator rejects any other + * placeholder. + */ +export const WALKTHROUGH_PARAMS: Partial> = { + 'scaffold:start': ['packageManager'], + 'detection:complete': ['framework'], + 'git:dirty': ['count'], + 'branch:protected': ['branch'], + 'branch:created': ['branch'], + 'credentials:env:found': ['file'], + 'device:started': ['url', 'code'], + 'agent:retry': ['attempt', 'maxRetries'], + 'file:write': ['path'], + 'file:edit': ['path'], + 'agent:tool': ['command'], + 'validation:complete': ['count'], + 'postinstall:changes': ['count'], + 'postinstall:commit:success': ['message'], + 'postinstall:pr:success': ['url'], +}; + +/** + * Events whose copy depends on the outcome. Their walkthrough entry must be an + * object with exactly these keys instead of a single string. + */ +export const WALKTHROUGH_VARIANTS: Partial> = { + 'validation:complete': ['passed', 'failed'], + complete: ['success', 'setup-required', 'failure', 'cancelled'], +}; + +const PLACEHOLDER = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g; + +/** Placeholder names used in a template, e.g. `"On {branch}"` → `['branch']`. */ +export function placeholdersOf(template: string): string[] { + return [...template.matchAll(PLACEHOLDER)].map((m) => m[1]); +} + +/** Date-only (`2026-10-01`) or full ISO timestamp. */ +const isoDate = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2}))?$/, 'expected an ISO date like 2026-10-01') + .refine((s) => !Number.isNaN(Date.parse(s)), 'not a real date'); + +const id = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'expected a lowercase-kebab id'); +const text = z.string().trim().min(1); + +const taskSchema = z.strictObject({ + label: text, + /** Shown while the task is in progress, e.g. "Signing in to WorkOS". */ + activeLabel: text.optional(), +}); + +const cardFields = { + id, + title: text, + body: text, + /** A command worth trying, rendered as code. */ + command: text.optional(), + url: z.url({ protocol: /^https$/ }).optional(), + /** Integration ids (e.g. `nextjs`). Omit to show for every framework. */ + frameworks: z.array(text).min(1).optional(), +}; + +const tipSchema = z.strictObject(cardFields); + +const announcementSchema = z.strictObject({ + ...cardFields, + /** First day shown (inclusive). */ + startsAt: isoDate.optional(), + /** Last day shown (inclusive for a date-only value). */ + endsAt: isoDate.optional(), +}); + +const copySchema = z.union([text, z.record(z.string(), text)]); + +const knownEvents = new Set(INSTALLER_EVENT_NAMES); + +export const installerContentSchema = z + .strictObject({ + version: z.literal(1), + /** Display names keyed by integration id, used for `{framework}`. */ + frameworks: z.record(z.string(), text), + tasks: z.strictObject( + Object.fromEntries(TASK_IDS.map((t) => [t, taskSchema])) as Record, + ), + /** Labels for the "Configure WorkOS" sub-steps (the dashboard's checklist wording). */ + subtasks: z.strictObject(Object.fromEntries(SUBTASK_IDS.map((t) => [t, text])) as Record), + walkthrough: z.record(z.string(), copySchema), + tips: z.array(tipSchema), + announcements: z.array(announcementSchema), + }) + .superRefine((content, ctx) => { + const issue = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message }); + + for (const [event, copy] of Object.entries(content.walkthrough)) { + const path = ['walkthrough', event]; + if (!knownEvents.has(event)) { + issue(path, `"${event}" is not an installer event`); + continue; + } + const allowed = new Set(WALKTHROUGH_PARAMS[event as InstallerEventName] ?? []); + const variants = WALKTHROUGH_VARIANTS[event as InstallerEventName]; + const templates: Array<[string[], string]> = []; + if (variants) { + if (typeof copy === 'string') { + issue(path, `needs one entry per outcome: ${variants.join(', ')}`); + continue; + } + const keys = Object.keys(copy).sort(); + if (keys.join() !== [...variants].sort().join()) { + issue(path, `outcomes must be exactly ${variants.join(', ')} (got ${keys.join(', ') || 'none'})`); + } + for (const [variant, template] of Object.entries(copy)) templates.push([[...path, variant], template]); + } else if (typeof copy !== 'string') { + issue(path, 'must be a single string (this event has no outcome variants)'); + continue; + } else { + templates.push([path, copy]); + } + for (const [at, template] of templates) { + for (const name of placeholdersOf(template)) { + if (!allowed.has(name)) { + const offer = allowed.size + ? `available: ${[...allowed].map((p) => `{${p}}`).join(', ')}` + : 'none available'; + issue(at, `unknown placeholder {${name}} (${offer})`); + } + } + } + } + + const seen = new Set(); + const cards = [ + ...content.tips.map((c, i) => ['tips', i, c] as const), + ...content.announcements.map((c, i) => ['announcements', i, c] as const), + ]; + for (const [list, index, card] of cards) { + if (seen.has(card.id)) issue([list, index, 'id'], `duplicate id "${card.id}"`); + seen.add(card.id); + for (const framework of card.frameworks ?? []) { + if (!(framework in content.frameworks)) { + issue([list, index, 'frameworks'], `unknown framework "${framework}" (add it to "frameworks" first)`); + } + } + } + + content.announcements.forEach((a, i) => { + if (a.startsAt && a.endsAt && Date.parse(a.startsAt) > Date.parse(a.endsAt)) { + issue(['announcements', i, 'endsAt'], 'ends before it starts'); + } + }); + }); + +export type InstallerContent = z.infer; +export type TaskCopy = z.infer; +export type Tip = z.infer; +export type Announcement = z.infer; + +/** Parse raw content, throwing one readable error that lists every problem. */ +export function parseInstallerContent(raw: unknown): InstallerContent { + const result = installerContentSchema.safeParse(raw); + if (result.success) return result.data; + const problems = result.error.issues.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`); + throw new Error(`Invalid installer content:\n${problems.join('\n')}`); +} diff --git a/src/tui/content/select.ts b/src/tui/content/select.ts new file mode 100644 index 00000000..4cb57e4b --- /dev/null +++ b/src/tui/content/select.ts @@ -0,0 +1,75 @@ +/** + * Pure selectors over installer content: which tips and announcements to show + * for a framework and date, and how to turn walkthrough templates into text. + */ + +import type { InstallerEventName } from '../../lib/events.js'; +import { placeholdersOf, type Announcement, type InstallerContent, type Tip } from './schema.js'; + +export interface ContentQuery { + /** Detected integration id (e.g. `nextjs`), once known. */ + framework?: string; + now: Date; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +/** + * A card with a framework list shows only once that framework is detected; + * a card without one shows everywhere. + */ +function matchesFramework(card: Tip, framework: string | undefined): boolean { + return !card.frameworks || (framework !== undefined && card.frameworks.includes(framework)); +} + +/** `startsAt` is inclusive; a date-only `endsAt` includes that whole day (UTC). */ +export function isAnnouncementActive(announcement: Announcement, now: Date): boolean { + const t = now.getTime(); + if (announcement.startsAt && t < Date.parse(announcement.startsAt)) return false; + if (announcement.endsAt) { + const end = Date.parse(announcement.endsAt) + (DATE_ONLY.test(announcement.endsAt) ? DAY_MS : 0); + if (t >= end) return false; + } + return true; +} + +export function selectTips(content: InstallerContent, query: ContentQuery): Tip[] { + return content.tips.filter((tip) => matchesFramework(tip, query.framework)); +} + +export function selectAnnouncements(content: InstallerContent, query: ContentQuery): Announcement[] { + return content.announcements.filter( + (a) => matchesFramework(a, query.framework) && isAnnouncementActive(a, query.now), + ); +} + +/** Replace `{name}` with `params.name`. Unknown placeholders are left visible. */ +export function interpolate(template: string, params: Record = {}): string { + let out = template; + for (const name of placeholdersOf(template)) { + if (name in params) out = out.replaceAll(`{${name}}`, String(params[name])); + } + return out; +} + +/** + * Walkthrough text for an event, or undefined when the content doesn't narrate + * it. `variant` picks the outcome for events like `complete`. + */ +export function walkthroughText( + content: InstallerContent, + event: InstallerEventName, + params?: Record, + variant?: string, +): string | undefined { + const copy = content.walkthrough[event]; + if (copy === undefined) return undefined; + const template = typeof copy === 'string' ? copy : variant !== undefined ? copy[variant] : undefined; + return template === undefined ? undefined : interpolate(template, params); +} + +/** Friendly framework name, falling back to the integration id. */ +export function frameworkName(content: InstallerContent, integration: string): string { + return content.frameworks[integration] ?? integration; +} diff --git a/src/tui/hooks.ts b/src/tui/hooks.ts new file mode 100644 index 00000000..5065e18b --- /dev/null +++ b/src/tui/hooks.ts @@ -0,0 +1,33 @@ +import { useEffect, useState, useSyncExternalStore } from 'react'; +import { useStdout } from 'ink'; +import type { RunModel, RunSnapshot } from './model/run-model.js'; +import { SPINNER_FRAMES } from './theme.js'; + +/** [columns, rows], updated on resize (Ink's useStdout doesn't re-render on resize). */ +export function useTerminalSize(): [number, number] { + const { stdout } = useStdout(); + const [size, setSize] = useState<[number, number]>(() => [stdout.columns || 80, stdout.rows || 24]); + useEffect(() => { + const onResize = () => setSize([stdout.columns || 80, stdout.rows || 24]); + stdout.on('resize', onResize); + return () => { + stdout.off('resize', onResize); + }; + }, [stdout]); + return size; +} + +export function useRunSnapshot(model: RunModel): RunSnapshot { + return useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); +} + +/** Current spinner glyph while `active`. Purely visual: it never moves progress. */ +export function useSpinner(active: boolean, intervalMs = 80): string { + const [frame, setFrame] = useState(0); + useEffect(() => { + if (!active) return; + const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), intervalMs); + return () => clearInterval(timer); + }, [active, intervalMs]); + return SPINNER_FRAMES[frame]; +} diff --git a/src/tui/ink-streams.test-utils.ts b/src/tui/ink-streams.test-utils.ts new file mode 100644 index 00000000..f7d0aefb --- /dev/null +++ b/src/tui/ink-streams.test-utils.ts @@ -0,0 +1,118 @@ +/** + * Fake terminal streams for rendering Ink in tests: a stdout that records + * frames at a fixed size, and a stdin that feeds keypresses. + */ + +import { EventEmitter } from 'node:events'; + +const ANSI = /\x1b\[[0-9;?]*[a-zA-Z]/g; + +export const stripAnsi = (s: string): string => s.replace(ANSI, ''); + +export class FakeStdout extends EventEmitter { + readonly writes: string[] = []; + isTTY = true; + + constructor( + public columns = 80, + public rows = 24, + ) { + super(); + } + + write = (chunk: string | Uint8Array): boolean => { + this.writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }; + + /** + * The newest frame Ink drew (debug mode writes whole frames). Pass `match` + * when other writes (escape codes, a transcript) share the stream. + */ + lastFrame(match?: string): string { + for (let i = this.writes.length - 1; i >= 0; i--) { + if (match === undefined || stripAnsi(this.writes[i]).includes(match)) return this.writes[i]; + } + return ''; + } + + resize(columns: number, rows: number): void { + this.columns = columns; + this.rows = rows; + this.emit('resize'); + } + + output(): string { + return this.writes.join(''); + } +} + +export class FakeStdin extends EventEmitter { + isTTY = true; + rawMode = false; + private queue: string[] = []; + + setRawMode(mode: boolean): this { + this.rawMode = mode; + return this; + } + setEncoding(): this { + return this; + } + ref(): this { + return this; + } + unref(): this { + return this; + } + resume(): this { + return this; + } + pause(): this { + return this; + } + read(): string | null { + return this.queue.shift() ?? null; + } + + /** Type keys: '\r' enter, '\x1b' escape, '\x03' ctrl-c, '\x1b[B' down. */ + press(data: string): void { + this.queue.push(data); + this.emit('readable'); + } +} + +export const KEY = { + enter: '\r', + escape: '\x1b', + ctrlC: '\x03', + up: '\x1b[A', + down: '\x1b[B', + left: '\x1b[D', + right: '\x1b[C', +} as const; + +/** + * Let React flush passive effects. Ink re-subscribes input handlers in an + * effect after each render, so a keypress sent the instant a frame appears can + * reach the previous handler. A person never types that fast; tests wait this out. + */ +export function settle(ms = 30): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Poll until `check` passes (Ink renders asynchronously). */ +export async function waitFor(check: () => boolean | void, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (check() !== false) return; + } catch (error) { + lastError = error; + } + await new Promise((r) => setTimeout(r, 10)); + } + if (lastError) throw lastError; + throw new Error('waitFor timed out'); +} diff --git a/src/tui/model/run-model.spec.ts b/src/tui/model/run-model.spec.ts new file mode 100644 index 00000000..2aba13a1 --- /dev/null +++ b/src/tui/model/run-model.spec.ts @@ -0,0 +1,677 @@ +import { describe, it, expect } from 'vitest'; +import { createActor, fromPromise } from 'xstate'; +import { installerMachine } from '../../lib/installer-core.js'; +import { createInstallerEventEmitter, type InstallerEventEmitter, type InstallerEventName } from '../../lib/events.js'; +import type { InstallerOptions } from '../../utils/types.js'; +import type { + AgentOutput, + BranchCheckOutput, + DetectionOutput, + GitCheckOutput, + InstallerMachineContext, + WorkspaceCheckOutput, +} from '../../lib/installer-core.types.js'; +import { WALKTHROUGH_PARAMS, loadInstallerContent, parseInstallerContent, placeholdersOf } from '../content/index.js'; +import { countedTasks, createRunModel, type RunModel, type RunSnapshot, type TaskStatus } from './run-model.js'; + +const content = loadInstallerContent(); +const INSTALL_DIR = '/work/my-app'; + +function actors(overrides: Record = {}) { + return { + checkAuthentication: fromPromise(async () => true), + checkWorkspace: fromPromise(async () => ({ + scaffoldable: false, + packageManager: 'npm', + autoScaffold: false, + })), + runScaffold: fromPromise(async () => {}), + detectIntegration: fromPromise(async () => ({ + integration: 'nextjs', + })), + checkGitStatus: fromPromise(async () => ({ isClean: true, files: [] })), + configureEnvironment: fromPromise(async () => {}), + runAgent: fromPromise(async () => ({ + success: true, + summary: 'Done!', + })), + ...overrides, + }; +} + +function options(overrides: Partial = {}): InstallerOptions { + return { + debug: false, + forceInstall: false, + installDir: INSTALL_DIR, + default: false, + local: true, + ci: false, + skipAuth: false, + noCommit: true, + emitter: null!, + apiKey: 'sk_test_123', + clientId: 'client_test_123', + ...overrides, + } as InstallerOptions; +} + +interface Run { + model: RunModel; + emitter: InstallerEventEmitter; + /** Every distinct snapshot, in order. */ + history: RunSnapshot[]; + actor: ReturnType>; + done: Promise; +} + +function start(opts: InstallerOptions, mocks: ReturnType, onEvent?: (run: Run) => void): Run { + const emitter = createInstallerEventEmitter(); + // Node's EventEmitter throws on an 'error' event nobody listens to. In a real + // run the adapter always listens; here only the model is attached. + emitter.on('error', () => {}); + const model = createRunModel({ emitter, content, cwd: INSTALL_DIR, now: new Date('2026-09-24T12:00:00Z') }); + const actor = createActor(installerMachine.provide({ actors: mocks }), { + input: { emitter, options: { ...opts, emitter } }, + }); + const history: RunSnapshot[] = [model.getSnapshot()]; + const run = { model, emitter, history, actor } as Run; + model.subscribe(() => { + history.push(model.getSnapshot()); + onEvent?.(run); + }); + run.done = new Promise((resolve, reject) => { + actor.subscribe({ complete: () => resolve(), error: reject }); + }); + actor.start(); + actor.send({ type: 'START' }); + return run; +} + +const statuses = (s: RunSnapshot) => Object.fromEntries(s.tasks.map((t) => [t.id, t.status])); +const texts = (s: RunSnapshot, kind?: string) => + s.walkthrough.filter((e) => !kind || e.kind === kind).map((e) => e.text); +const RANK: Record = { + pending: 0, + in_progress: 1, + completed: 2, + skipped: 2, + failed: 2, + cancelled: 2, + next: 2, + attention: 2, +}; + +/** A task only ever moves forward: pending → in_progress → a final state. */ +function expectMonotonic(history: RunSnapshot[]): void { + const last = new Map(); + for (const snapshot of history) { + for (const task of snapshot.tasks) { + const prev = last.get(task.id); + if (prev) { + expect(RANK[task.status], `${task.id}: ${prev} → ${task.status}`).toBeGreaterThanOrEqual(RANK[prev]); + if (RANK[prev] === 2) expect(task.status, `${task.id} changed after finishing`).toBe(prev); + } + last.set(task.id, task.status); + } + } +} + +describe('run model: tasks follow real installer events', () => { + it('starts with every required task pending and nothing optional shown', () => { + const emitter = createInstallerEventEmitter(); + const snapshot = createRunModel({ emitter, content }).getSnapshot(); + expect(statuses(snapshot)).toEqual({ + 'sign-in': 'pending', + inspect: 'pending', + credentials: 'pending', + configure: 'pending', + install: 'pending', + finish: 'pending', + 'first-sign-up': 'pending', + }); + expect(snapshot.outcome).toBeNull(); + }); + + it('completes every task on a successful run, in order', async () => { + const run = start(options(), actors()); + await run.done; + const final = run.model.getSnapshot(); + + expect(statuses(final)).toEqual({ + 'sign-in': 'completed', + inspect: 'completed', + credentials: 'completed', + configure: 'completed', + install: 'completed', + finish: 'completed', + // The installer's done; the first sign-up is the user's next step. + 'first-sign-up': 'next', + }); + expect(final.outcome).toBe('success'); + expect(final.integration).toBe('nextjs'); + expect(final.framework).toBe('Next.js'); + expectMonotonic(run.history); + + // Each task was seen in progress before it completed. + for (const id of ['sign-in', 'inspect', 'configure', 'install']) { + expect( + run.history.some((s) => statuses(s)[id] === 'in_progress'), + id, + ).toBe(true); + } + // Only one task is in progress at a time. + for (const s of run.history) { + expect(s.tasks.filter((t) => t.status === 'in_progress').length).toBeLessThanOrEqual(1); + } + }); + + it('narrates the run using the content copy', async () => { + const run = start(options(), actors()); + await run.done; + const narration = texts(run.model.getSnapshot(), 'narration'); + + expect(narration).toContain(content.walkthrough['auth:checking']); + expect(narration).toContain(content.walkthrough['auth:success']); + expect(narration).toContain('This is a Next.js app. I know how to set that up.'); + expect(narration).toContain(content.walkthrough['config:start']); + expect(narration).toContain(content.walkthrough['agent:start']); + expect(narration.at(-1)).toBe((content.walkthrough.complete as Record).success); + expect(run.model.getSnapshot().walkthrough.at(-1)!.tone).toBe('success'); + }); + + it('marks sign-in skipped with --skip-auth', async () => { + const run = start(options({ skipAuth: true }), actors()); + await run.done; + expect(statuses(run.model.getSnapshot())['sign-in']).toBe('skipped'); + expect(run.history.every((s) => statuses(s)['sign-in'] !== 'in_progress')).toBe(true); + }); + + it('shows agent status lines, file changes, commands, and a validation step', async () => { + const runAgent = fromPromise(async ({ input }) => { + const e = input.context.emitter; + e.emit('agent:progress', { step: 'Reading your project' }); + e.emit('agent:progress', { step: 'Reading your project' }); // duplicate + e.emit('file:write', { path: `${INSTALL_DIR}/app/callback/route.ts`, content: '' }); + e.emit('file:edit', { path: `${INSTALL_DIR}/app/layout.tsx`, oldContent: '', newContent: '' }); + e.emit('file:edit', { path: `${INSTALL_DIR}/app/layout.tsx`, oldContent: '', newContent: '' }); // same path + e.emit('agent:tool', { kind: 'command', detail: `npm install @workos-inc/authkit-nextjs ${'x'.repeat(100)}` }); + e.emit('validation:start', { framework: 'nextjs' }); + e.emit('validation:complete', { passed: false, issueCount: 2, durationMs: 5 }); + return { success: true, summary: 'Done!' }; + }); + const run = start(options(), actors({ runAgent })); + await run.done; + const final = run.model.getSnapshot(); + + expect(texts(final, 'status')).toEqual(['Reading your project']); + expect(texts(final, 'file')).toEqual(['Created app/callback/route.ts', 'Updated app/layout.tsx']); + const [command] = texts(final, 'command'); + expect(command.startsWith('Ran npm install @workos-inc/authkit-nextjs')).toBe(true); + expect(command.length).toBeLessThanOrEqual('Ran '.length + 80); + expect(command.endsWith('…')).toBe(true); + + // validation:complete reported passed: false, so verify must settle as failed. + expect(statuses(final)).toMatchObject({ install: 'completed', verify: 'failed' }); + expect(final.tasks.map((t) => t.id)).toEqual([ + 'sign-in', + 'inspect', + 'credentials', + 'configure', + 'install', + 'verify', + 'finish', + 'first-sign-up', + ]); + const issue = final.walkthrough.find((e) => e.text.includes('issues: 2')); + expect(issue?.tone).toBe('warning'); + expectMonotonic(run.history); + }); + + it('does not mark verify completed when blocking validation fails (passed: false)', async () => { + // A blocking validation/security finding surfaces as validation:complete + // with passed: false; the agent then fails so the run ends unsuccessfully. + const runAgent = fromPromise(async ({ input }) => { + const e = input.context.emitter; + e.emit('validation:start', { framework: 'nextjs' }); + e.emit('validation:complete', { passed: false, issueCount: 2, durationMs: 5 }); + return { success: false, error: new Error('blocked by security gate') }; + }); + const run = start(options(), actors({ runAgent })); + await run.done; + const final = run.model.getSnapshot(); + + // The verify step must reflect the failed validation, not report success. + expect(statuses(final)).toMatchObject({ install: 'completed', verify: 'failed' }); + const issue = final.walkthrough.find((e) => e.text.includes('issues: 2')); + expect(issue?.tone).toBe('warning'); + expectMonotonic(run.history); + }); + + it('marks verify completed when validation ultimately passes after retries', async () => { + // Non-blocking validation self-corrects across retries and the final + // validation:complete reports passed: true, so verify settles as completed. + const runAgent = fromPromise(async ({ input }) => { + const e = input.context.emitter; + e.emit('validation:start', { framework: 'nextjs' }); + e.emit('validation:retry:start', { attempt: 1 }); + e.emit('validation:retry:complete', { attempt: 1, passed: false }); + e.emit('validation:retry:start', { attempt: 2 }); + e.emit('validation:retry:complete', { attempt: 2, passed: true }); + e.emit('validation:complete', { passed: true, issueCount: 0, durationMs: 5 }); + return { success: true, summary: 'Done!' }; + }); + const run = start(options(), actors({ runAgent })); + await run.done; + expect(statuses(run.model.getSnapshot())).toMatchObject({ install: 'completed', verify: 'completed' }); + }); + + it('fails the install task when the agent fails', async () => { + const runAgent = fromPromise(async () => ({ + success: false, + error: new Error('boom'), + })); + const run = start(options(), actors({ runAgent })); + await run.done; + const final = run.model.getSnapshot(); + + expect(statuses(final)).toMatchObject({ + configure: 'completed', + install: 'failed', + finish: 'pending', + // Only a finished install makes the first sign-up the next step. + 'first-sign-up': 'pending', + }); + expect(final.outcome).toBe('failure'); + expect(texts(final, 'narration')).toContain(content.walkthrough['agent:failure']); + expect(final.walkthrough.at(-1)).toMatchObject({ + text: (content.walkthrough.complete as Record).failure, + tone: 'error', + }); + expectMonotonic(run.history); + }); + + it('fails the validation task, not the install task, when the run fails after validating', async () => { + const runAgent = fromPromise(async ({ input }) => { + input.context.emitter.emit('validation:start', { framework: 'nextjs' }); + return { success: false, error: new Error('validation crashed') }; + }); + const run = start(options(), actors({ runAgent })); + await run.done; + expect(statuses(run.model.getSnapshot())).toMatchObject({ install: 'completed', verify: 'failed' }); + }); + + it('fails the inspect task when no framework is detected', async () => { + const detectIntegration = fromPromise(async () => ({ + integration: undefined, + })); + const run = start(options(), actors({ detectIntegration })); + await run.done; + const final = run.model.getSnapshot(); + expect(statuses(final)).toMatchObject({ 'sign-in': 'completed', inspect: 'failed', credentials: 'pending' }); + expect(texts(final, 'narration')).toContain(content.walkthrough['detection:none']); + expect(final.outcome).toBe('failure'); + }); + + it('cancels the current task when the user declines a dirty working tree', async () => { + const checkGitStatus = fromPromise(async () => ({ + isClean: false, + files: ['a.ts', 'b.ts', 'c.ts'], + })); + let answered = false; + const run = start(options(), actors({ checkGitStatus }), (r) => { + if (!answered && texts(r.model.getSnapshot()).some((t) => t.includes('uncommitted'))) { + answered = true; + queueMicrotask(() => r.actor.send({ type: 'GIT_CANCELLED' })); + } + }); + await run.done; + const final = run.model.getSnapshot(); + + expect(texts(final, 'narration')).toContain( + "You have uncommitted changes (files: 3), so I'll check with you before going further.", + ); + expect(statuses(final)).toMatchObject({ inspect: 'cancelled', credentials: 'pending' }); + expect(final.outcome).toBe('cancelled'); + expect(final.walkthrough.at(-1)!.text).toBe((content.walkthrough.complete as Record).cancelled); + }); + + it('shows the scaffold task only when this run scaffolds', async () => { + const checkWorkspace = fromPromise(async () => ({ + scaffoldable: true, + packageManager: 'pnpm', + autoScaffold: false, + })); + let confirmed = false; + const run = start(options(), actors({ checkWorkspace }), (r) => { + if (!confirmed && statuses(r.model.getSnapshot()).scaffold === 'in_progress') { + confirmed = true; + queueMicrotask(() => r.actor.send({ type: 'SCAFFOLD_CONFIRMED' })); + } + }); + await run.done; + const final = run.model.getSnapshot(); + + expect(final.tasks.map((t) => t.id).slice(0, 3)).toEqual(['sign-in', 'scaffold', 'inspect']); + expect(statuses(final).scaffold).toBe('completed'); + expect(texts(final, 'narration')).toContain('Creating a new Next.js app with pnpm. This can take a minute.'); + expectMonotonic(run.history); + }); + + it('narrates a protected branch and the branch it creates', async () => { + const checkBranch = fromPromise(async () => ({ branch: 'main', isProtected: true })); + const createBranch = fromPromise<{ branch: string }, { name: string; fallbackName: string }>(async ({ input }) => ({ + branch: input.name, + })); + let answered = false; + const run = start(options(), actors({ checkBranch, createBranch }), (r) => { + if (!answered && texts(r.model.getSnapshot()).some((t) => t.startsWith("You're on main"))) { + answered = true; + queueMicrotask(() => r.actor.send({ type: 'BRANCH_CREATE' })); + } + }); + await run.done; + expect(texts(run.model.getSnapshot(), 'narration')).toContain( + 'I made a new branch, feat/add-workos-authkit, so these changes stay separate.', + ); + }); +}); + +describe('run model: tips, prompt, status, notices', () => { + it('adds framework-targeted tips once the framework is detected', async () => { + const run = start(options(), actors()); + expect(run.history[0].tips.some((t) => t.frameworks)).toBe(false); + await run.done; + const tips = run.model.getSnapshot().tips; + expect(tips.some((t) => t.id === 'nextjs-guide')).toBe(true); + expect(tips.some((t) => t.id === 'react-guide')).toBe(false); + }); + + it('filters announcements by the model clock', () => { + const dated = parseInstallerContent({ + ...structuredClone(content), + announcements: [ + { id: 'october', title: 'Oct', body: 'Only in October.', startsAt: '2026-10-01', endsAt: '2026-10-31' }, + ], + }); + const emitter = createInstallerEventEmitter(); + const before = createRunModel({ emitter, content: dated, now: new Date('2026-09-30T00:00:00Z') }); + const during = createRunModel({ emitter, content: dated, now: new Date('2026-10-15T00:00:00Z') }); + expect(before.getSnapshot().announcements).toEqual([]); + expect(during.getSnapshot().announcements.map((a) => a.id)).toEqual(['october']); + }); + + it('holds the pending prompt, the running status, and notices', () => { + const model = createRunModel({ emitter: createInstallerEventEmitter(), content }); + let notified = 0; + const unsubscribe = model.subscribe(() => notified++); + + model.setPrompt({ kind: 'confirm', message: 'Commit the changes?', initialValue: true }); + expect(model.getSnapshot().prompt).toEqual({ kind: 'confirm', message: 'Commit the changes?', initialValue: true }); + model.setStatus('Generating commit message...'); + const withStatus = model.getSnapshot(); + model.setStatus('Generating commit message...'); // unchanged: same snapshot + expect(model.getSnapshot()).toBe(withStatus); + model.addNotice('error', 'Commit failed: nothing to commit'); + model.setPrompt(null); + + const snapshot = model.getSnapshot(); + expect(snapshot.prompt).toBeNull(); + expect(snapshot.status).toBe('Generating commit message...'); + expect(snapshot.walkthrough.at(-1)).toMatchObject({ + kind: 'notice', + tone: 'error', + text: 'Commit failed: nothing to commit', + }); + expect(notified).toBeGreaterThan(0); + + unsubscribe(); + const before = notified; + model.setStatus(null); + expect(notified).toBe(before); + }); + + it('returns the same snapshot object until something changes', () => { + const emitter = createInstallerEventEmitter(); + const model = createRunModel({ emitter, content }); + const a = model.getSnapshot(); + expect(model.getSnapshot()).toBe(a); + emitter.emit('auth:checking', {}); + expect(model.getSnapshot()).not.toBe(a); + }); + + it('stops listening after dispose', () => { + const emitter = createInstallerEventEmitter(); + const model = createRunModel({ emitter, content }); + model.dispose(); + emitter.emit('auth:checking', {}); + expect(model.getSnapshot().walkthrough).toEqual([]); + expect(emitter.listenerCount('auth:checking')).toBe(0); + }); + + it('keeps only the newest walkthrough entries', () => { + const emitter = createInstallerEventEmitter(); + const model = createRunModel({ emitter, content, maxEntries: 3 }); + for (let i = 0; i < 5; i++) emitter.emit('agent:progress', { step: `step ${i}` }); + expect(texts(model.getSnapshot())).toEqual(['step 2', 'step 3', 'step 4']); + }); + + it('fills every placeholder for each event that supplies params', () => { + const payloads: Partial> = { + 'scaffold:start': { packageManager: 'pnpm' }, + 'detection:complete': { integration: 'react' }, + 'git:dirty': { files: ['a'] }, + 'branch:protected': { branch: 'main' }, + 'branch:created': { branch: 'feat/x' }, + 'credentials:env:found': { sourcePath: `${INSTALL_DIR}/.env.local` }, + 'device:started': { + verificationUri: 'https://w.os/d', + verificationUriComplete: 'https://w.os/d?c=1', + userCode: 'ABCD', + }, + 'agent:retry': { attempt: 2, maxRetries: 3 }, + 'file:write': { path: `${INSTALL_DIR}/a.ts`, content: '' }, + 'file:edit': { path: `${INSTALL_DIR}/b.ts`, oldContent: '', newContent: '' }, + 'agent:tool': { kind: 'command', detail: 'ls' }, + 'validation:complete': { passed: false, issueCount: 1, durationMs: 1 }, + 'postinstall:changes': { files: ['a'] }, + 'postinstall:commit:success': { message: 'feat: add AuthKit' }, + 'postinstall:pr:success': { url: 'https://github.com/o/r/pull/1' }, + }; + expect(Object.keys(payloads).sort()).toEqual(Object.keys(WALKTHROUGH_PARAMS).sort()); + + for (const [event, payload] of Object.entries(payloads)) { + const emitter = createInstallerEventEmitter(); + const model = createRunModel({ emitter, content, cwd: INSTALL_DIR }); + emitter.emit(event as InstallerEventName, payload as never); + const entries = model.getSnapshot().walkthrough; + expect(entries.length, event).toBe(1); + expect(placeholdersOf(entries[0].text), `${event}: ${entries[0].text}`).toEqual([]); + } + }); + + it('prefers the one-click device URL and shows env files relative to the project', () => { + const emitter = createInstallerEventEmitter(); + const model = createRunModel({ emitter, content, cwd: INSTALL_DIR }); + emitter.emit('device:started', { + verificationUri: 'https://w.os/d', + verificationUriComplete: 'https://w.os/d?c=1', + userCode: 'ABCD', + }); + emitter.emit('credentials:env:found', { sourcePath: `${INSTALL_DIR}/.env.local` }); + expect(texts(model.getSnapshot())).toEqual([ + 'Open https://w.os/d?c=1 in your browser and enter the code ABCD to connect this computer to WorkOS.', + 'Found your WorkOS keys in .env.local.', + ]); + }); +}); + +describe('run model: the dashboard checklist', () => { + function configuring() { + const emitter = createInstallerEventEmitter(); + emitter.on('error', () => {}); + const model = createRunModel({ emitter, content }); + emitter.emit('state:enter', { state: 'configuring' }); + emitter.emit('config:start', {}); + return { emitter, model }; + } + /** Through the configure step and into the agent's validation, like a Next.js run. */ + function validating() { + const { emitter, model } = configuring(); + emitter.emit('config:step', { step: 'env-vars', status: 'started' }); + emitter.emit('config:step', { step: 'env-vars', status: 'done' }); + emitter.emit('state:exit', { state: 'configuring' }); + emitter.emit('state:enter', { state: 'runningAgent' }); + emitter.emit('validation:start', { framework: 'nextjs' }); + emitter.emit('validation:complete', { passed: true, issueCount: 0, durationMs: 1 }); + return { emitter, model }; + } + const APP_URLS = ['redirect-uri', 'initiate-login-uri', 'sign-out-uri'] as const; + const task = (model: RunModel, id: string) => model.getSnapshot().tasks.find((t) => t.id === id); + const subStatuses = (model: RunModel, id: string) => + Object.fromEntries((task(model, id)?.subtasks ?? []).map((s) => [s.id, s.status])); + + it("lists the reported items under Configure WorkOS, in the dashboard's order and words", () => { + const { emitter, model } = configuring(); + expect(task(model, 'configure')?.subtasks).toBeUndefined(); + + // Reported out of order; shown in the dashboard's. + for (const step of ['cors-origin', 'env-vars', 'redirect-uri'] as const) { + emitter.emit('config:step', { step, status: 'started' }); + } + expect(task(model, 'configure')?.subtasks?.map((s) => s.label)).toEqual([ + 'Add environment variables', + 'Set redirect URI', + 'Set CORS origin', + ]); + + emitter.emit('config:step', { step: 'redirect-uri', status: 'done' }); + emitter.emit('config:step', { step: 'cors-origin', status: 'already-set' }); + expect(subStatuses(model, 'configure')).toEqual({ + 'env-vars': 'in_progress', + 'redirect-uri': 'completed', + 'cors-origin': 'completed', + }); + }); + + it('collapses back to one row once every item landed', () => { + const { emitter, model } = configuring(); + for (const step of ['env-vars', 'redirect-uri', 'cors-origin'] as const) { + emitter.emit('config:step', { step, status: 'started' }); + emitter.emit('config:step', { step, status: 'done' }); + } + expect(task(model, 'configure')?.subtasks).toHaveLength(3); // still configuring + + emitter.emit('state:exit', { state: 'configuring' }); + emitter.emit('state:enter', { state: 'runningAgent' }); + expect(task(model, 'configure')?.status).toBe('completed'); + expect(task(model, 'configure')?.subtasks).toBeUndefined(); + }); + + it('connects the app URLs after the agent, as their own task', () => { + const { emitter, model } = validating(); + expect(task(model, 'app-urls')).toBeUndefined(); + + for (const step of APP_URLS) emitter.emit('app-urls:step', { step, status: 'started' }); + expect(task(model, 'verify')?.status).toBe('completed'); + expect(task(model, 'app-urls')?.status).toBe('in_progress'); + expect(task(model, 'app-urls')?.subtasks?.map((s) => s.label)).toEqual([ + 'Set redirect URI', + 'Set initiate login URI', + 'Set sign-out URI', + ]); + expect(model.getSnapshot().walkthrough.at(-1)?.text).toBe(content.walkthrough['app-urls:step']); + + for (const step of APP_URLS) emitter.emit('app-urls:step', { step, status: 'done' }); + emitter.emit('state:exit', { state: 'runningAgent' }); + emitter.emit('state:enter', { state: 'postInstall' }); + expect(task(model, 'app-urls')?.status).toBe('completed'); + expect(task(model, 'app-urls')?.subtasks).toBeUndefined(); + expect( + model + .getSnapshot() + .tasks.map((t) => t.id) + .slice(-3), + ).toEqual(['app-urls', 'finish', 'first-sign-up']); + }); + + it('keeps unverified items open with one line for their shared reason, and ends on a warning', () => { + const { emitter, model } = validating(); + const reason = 'Callback registered using the API key. Sign in to the correct team to manage those settings.'; + for (const step of APP_URLS) emitter.emit('app-urls:step', { step, status: 'started' }); + emitter.emit('app-urls:step', { step: 'redirect-uri', status: 'done' }); + emitter.emit('app-urls:step', { step: 'initiate-login-uri', status: 'skipped', detail: reason }); + emitter.emit('app-urls:step', { step: 'sign-out-uri', status: 'skipped', detail: reason }); + emitter.emit('state:exit', { state: 'runningAgent' }); + emitter.emit('state:enter', { state: 'postInstall' }); + emitter.emit('state:exit', { state: 'postInstall' }); + emitter.emit('state:enter', { state: 'complete' }); + emitter.emit('complete', { success: true }); + + expect(subStatuses(model, 'app-urls')).toEqual({ + 'redirect-uri': 'completed', + 'initiate-login-uri': 'attention', + 'sign-out-uri': 'attention', + }); + // The task shares its settings' `!` and doesn't count as done. + expect(statuses(model.getSnapshot())['app-urls']).toBe('attention'); + const counted = countedTasks(model.getSnapshot().tasks); + expect(counted.filter((t) => t.status === 'completed')).toHaveLength(counted.length - 1); + const notices = model.getSnapshot().walkthrough.filter((e) => e.kind === 'notice'); + expect(notices).toEqual([ + expect.objectContaining({ + tone: 'warning', + text: `Check in the WorkOS dashboard: set initiate login URI and set sign-out URI. ${reason}`, + }), + ]); + expect(model.getSnapshot().walkthrough.at(-1)).toMatchObject({ + tone: 'warning', + text: content.walkthrough.complete['setup-required'], + }); + }); + + it("doesn't end on all done when a setting failed but the install carried on", () => { + const { emitter, model } = configuring(); + emitter.emit('config:step', { step: 'redirect-uri', status: 'started' }); + emitter.emit('config:step', { step: 'redirect-uri', status: 'failed', detail: 'Request failed (403)' }); + emitter.emit('state:exit', { state: 'configuring' }); + emitter.emit('state:enter', { state: 'complete' }); + emitter.emit('complete', { success: true }); + expect(statuses(model.getSnapshot()).configure).toBe('attention'); + expect(model.getSnapshot().walkthrough.at(-1)).toMatchObject({ + tone: 'warning', + text: content.walkthrough.complete['setup-required'], + }); + }); + + it('explains a failed item and fails the ones still running when the install errors', () => { + const { emitter, model } = configuring(); + for (const step of ['env-vars', 'redirect-uri', 'cors-origin'] as const) { + emitter.emit('config:step', { step, status: 'started' }); + } + emitter.emit('config:step', { step: 'redirect-uri', status: 'failed', detail: 'Request failed (500)' }); + expect(model.getSnapshot().walkthrough.at(-1)).toMatchObject({ + tone: 'error', + text: "Couldn't set redirect URI. Request failed (500). Set it in the WorkOS dashboard.", + }); + + emitter.emit('state:exit', { state: 'configuring' }); + emitter.emit('state:enter', { state: 'error' }); + expect(task(model, 'configure')?.status).toBe('failed'); + expect(subStatuses(model, 'configure')).toMatchObject({ 'env-vars': 'failed', 'cors-origin': 'failed' }); + }); + + it('shows no items when the installer reports none (non-JS integrations)', () => { + const { emitter, model } = configuring(); + emitter.emit('config:complete', {}); + emitter.emit('state:exit', { state: 'configuring' }); + emitter.emit('state:enter', { state: 'runningAgent' }); + expect(task(model, 'configure')?.subtasks).toBeUndefined(); + }); + + it("doesn't count the first sign-up toward done", () => { + const tasks = createRunModel({ emitter: createInstallerEventEmitter(), content }).getSnapshot().tasks; + expect(tasks.map((t) => t.id)).toContain('first-sign-up'); + expect(countedTasks(tasks).map((t) => t.id)).not.toContain('first-sign-up'); + }); +}); diff --git a/src/tui/model/run-model.ts b/src/tui/model/run-model.ts new file mode 100644 index 00000000..5c1824b5 --- /dev/null +++ b/src/tui/model/run-model.ts @@ -0,0 +1,498 @@ +/** + * Run model for the full-screen installer. + * + * A plain store over InstallerEventEmitter: it turns installer events into a + * task list, a plain-English walkthrough, and the tips to show, and holds the + * prompt the view should render. It has no Ink or React dependency, so it can + * be driven by the real state machine in tests; the view reads it through + * `useSyncExternalStore` (`subscribe` + `getSnapshot`). + * + * Progress comes only from real events. Nothing here advances on a timer. + */ + +import type { EventEmitter } from 'node:events'; +import { isAbsolute, relative } from 'node:path'; +import type { + InstallerEventEmitter, + InstallerEventName, + InstallerEvents, + SetupItemEvent, + SetupItemStatus, +} from '../../lib/events.js'; +import type { UiPromptRequest } from '../../utils/ui.js'; +import { + SUBTASK_IDS, + TASK_IDS, + frameworkName, + selectAnnouncements, + selectTips, + walkthroughText, + type Announcement, + type InstallerContent, + type SubtaskId, + type TaskId, + type Tip, +} from '../content/index.js'; + +/** + * `next` is the user's own step once the installer is done (first sign-up). + * `attention` is a WorkOS setting to check in the dashboard: not set, or not + * verified. + */ +export type TaskStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'skipped' + | 'failed' + | 'cancelled' + | 'next' + | 'attention'; + +export interface SubtaskView { + id: SubtaskId; + label: string; + status: TaskStatus; +} + +export interface TaskView { + id: TaskId; + label: string; + activeLabel?: string; + status: TaskStatus; + /** + * The dashboard checklist items this task sets, while they're worth seeing: + * during the task, or after it when one needs a look. + */ + subtasks?: SubtaskView[]; +} + +/** Tasks that count toward "N of M done": not the user's own next step. */ +export function countedTasks(tasks: readonly TaskView[]): TaskView[] { + return tasks.filter((t) => t.status !== 'skipped' && t.id !== 'first-sign-up'); +} + +export type EntryKind = 'narration' | 'status' | 'file' | 'command' | 'notice'; +export type EntryTone = 'neutral' | 'success' | 'warning' | 'error'; + +export interface WalkthroughEntry { + /** Stable, increasing key for rendering. */ + id: number; + kind: EntryKind; + tone: EntryTone; + text: string; +} + +export type RunOutcome = 'success' | 'failure' | 'cancelled'; + +export interface RunSnapshot { + /** Tasks to display, in order. Optional tasks appear once they start. */ + tasks: TaskView[]; + walkthrough: WalkthroughEntry[]; + /** What's running right now (spinner text), if anything. */ + status: string | null; + /** The prompt waiting on the user, if any. */ + prompt: UiPromptRequest | null; + /** Detected integration id and its display name. */ + integration?: string; + framework?: string; + outcome: RunOutcome | null; + tips: Tip[]; + announcements: Announcement[]; +} + +export interface RunModelOptions { + emitter: InstallerEventEmitter; + content: InstallerContent; + /** Clock for announcement windows. */ + now?: Date; + /** File paths are shown relative to this directory. */ + cwd?: string; + /** Oldest walkthrough entries are dropped past this many. */ + maxEntries?: number; +} + +export interface RunModel { + subscribe(listener: () => void): () => void; + getSnapshot(): RunSnapshot; + setPrompt(prompt: UiPromptRequest | null): void; + setStatus(message: string | null): void; + /** Surface a warning or error the installer printed (e.g. a validation issue). */ + addNotice(tone: 'warning' | 'error', text: string): void; + dispose(): void; +} + +/** Machine phases (state:enter/exit ids) that own a task. */ +const PHASE_TASKS: Record = { + authenticating: 'sign-in', + preparing: 'inspect', + gatheringCredentials: 'credentials', + configuring: 'configure', + runningAgent: 'install', + postInstall: 'finish', +}; + +/** Shown only once something starts them. */ +const OPTIONAL_TASKS = new Set(['scaffold', 'verify', 'app-urls']); + +/** Tasks that set dashboard checklist items, and the event that reports them. */ +type SetupTask = 'configure' | 'app-urls'; + +const SETUP_STATUS: Record = { + started: 'in_progress', + done: 'completed', + 'already-set': 'completed', + skipped: 'attention', + failed: 'failed', +}; + +/** "a", "a and b", "a, b and c" */ +function listOf(items: string[]): string { + return items.length < 2 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`; +} + +const lowerFirst = (text: string) => text.charAt(0).toLowerCase() + text.slice(1); + +const TONES: Partial> = { + 'auth:success': 'success', + 'auth:failure': 'error', + 'scaffold:complete': 'success', + 'scaffold:failed': 'error', + 'detection:complete': 'success', + 'detection:none': 'warning', + 'git:dirty': 'warning', + 'branch:created': 'success', + 'credentials:env:found': 'success', + 'device:success': 'success', + 'staging:success': 'success', + 'config:complete': 'success', + 'agent:retry': 'warning', + 'agent:success': 'success', + 'agent:failure': 'error', + 'postinstall:commit:success': 'success', + 'postinstall:pr:success': 'success', +}; + +const MAX_COMMAND = 80; + +export function createRunModel(options: RunModelOptions): RunModel { + const { emitter, content } = options; + const now = options.now ?? new Date(); + const cwd = options.cwd ?? process.cwd(); + const maxEntries = options.maxEntries ?? 300; + + const status = new Map(TASK_IDS.map((id) => [id, 'pending'])); + const shown = new Set(TASK_IDS.filter((id) => !OPTIONAL_TASKS.has(id))); + let walkthrough: WalkthroughEntry[] = []; + let nextId = 1; + let spinnerStatus: string | null = null; + let prompt: UiPromptRequest | null = null; + let integration: string | undefined; + let outcome: RunOutcome | null = null; + let cancelled = false; + // The task whose phase just exited. Every exit is immediately followed by the + // next state's enter, which settles it: completed, or failed/cancelled when + // the machine went to error/cancelled instead. Settling there (not on exit) + // keeps a failing task from flashing "completed" first. + let exiting: TaskId | null = null; + let lastFile: string | null = null; + let lastProgress: string | null = null; + // Dashboard checklist items per task: shown once the installer reports any. + const substatus = new Map>(); + // One walkthrough line per shared reason, so four skips read as one sentence. + const subNotices = new Map(); + + const listeners = new Set<() => void>(); + let snapshot = build(); + + function build(): RunSnapshot { + const query = { framework: integration, now }; + return { + tasks: TASK_IDS.filter((id) => shown.has(id)).map((id) => { + const subtasks = id === 'configure' || id === 'app-urls' ? subtaskViews(id) : undefined; + const taskStatus = status.get(id)!; + // A finished task with a setting left to check isn't done: it gets the + // same `!` as that setting and stays out of "N of M done". + const flagged = + taskStatus === 'completed' && subtasks?.some((s) => s.status === 'attention' || s.status === 'failed'); + return { + id, + label: content.tasks[id].label, + activeLabel: content.tasks[id].activeLabel, + status: flagged ? 'attention' : taskStatus, + ...(subtasks ? { subtasks } : {}), + }; + }), + walkthrough, + status: spinnerStatus, + prompt, + integration, + framework: integration ? frameworkName(content, integration) : undefined, + outcome, + tips: selectTips(content, query), + announcements: selectAnnouncements(content, query), + }; + } + + /** Visible while the task runs; collapses once every item landed, stays open otherwise. */ + function subtaskViews(task: SetupTask): SubtaskView[] | undefined { + const items = substatus.get(task); + if (!items?.size) return undefined; + const views = SUBTASK_IDS.filter((id) => items.has(id)).map((id) => ({ + id, + label: content.subtasks[id], + status: items.get(id)!, + })); + const settled = status.get(task) === 'completed'; + if (settled && views.every((v) => v.status === 'completed')) return undefined; + return views; + } + + function onSetupStep(task: SetupTask, { step, status: stepStatus, detail }: SetupItemEvent): void { + const items = substatus.get(task) ?? new Map(); + substatus.set(task, items); + items.set(step, SETUP_STATUS[stepStatus]); + if (stepStatus !== 'skipped' && stepStatus !== 'failed') return; + + // Items sharing a reason share one line, so two skips read as one sentence. + const key = `${stepStatus}:${detail ?? ''}`; + const notice = subNotices.get(key); + const labels = [...(notice?.labels ?? []), lowerFirst(content.subtasks[step])]; + const reason = detail ? ` ${/[.!?]$/.test(detail) ? detail : `${detail}.`}` : ''; + const text = + stepStatus === 'skipped' + ? `Check in the WorkOS dashboard: ${listOf(labels)}.${reason}` + : `Couldn't ${listOf(labels)}.${reason} Set ${labels.length > 1 ? 'these' : 'it'} in the WorkOS dashboard.`; + if (notice) { + notice.labels = labels; + walkthrough = walkthrough.map((e) => (e.id === notice.id ? { ...e, text } : e)); + } else { + push('notice', stepStatus === 'failed' ? 'error' : 'warning', text); + subNotices.set(key, { id: nextId - 1, labels }); + } + } + + /** Any dashboard setting left for the user to check: not set, not verified, or failed. */ + function needsAttention(): boolean { + return [...substatus.values()].some((items) => + [...items.values()].some((s) => s === 'attention' || s === 'failed'), + ); + } + + function changed(): void { + snapshot = build(); + for (const listener of listeners) listener(); + } + + function push(kind: EntryKind, tone: EntryTone, text: string): void { + walkthrough = [...walkthrough, { id: nextId++, kind, tone, text }].slice(-maxEntries); + } + + function narrate( + event: InstallerEventName, + params?: Record, + variant?: string, + tone: EntryTone = TONES[event] ?? 'neutral', + ): void { + const text = walkthroughText(content, event, params, variant); + if (text) push('narration', tone, text); + } + + function setTask(id: TaskId, next: TaskStatus): void { + shown.add(id); + status.set(id, next); + } + + function current(): TaskId | undefined { + return TASK_IDS.find((id) => status.get(id) === 'in_progress'); + } + + function displayPath(path: string): string { + if (!isAbsolute(path)) return path; + const rel = relative(cwd, path); + return rel && !rel.startsWith('..') ? rel : path; + } + + // ── Phases → tasks ──────────────────────────────────────────────────────── + + function onEnter({ state }: InstallerEvents['state:enter']): void { + const ended = state === 'error' || state === 'cancelled'; + const settle: TaskStatus = state === 'error' ? 'failed' : state === 'cancelled' ? 'cancelled' : 'completed'; + const settling = exiting ?? (ended ? current() : undefined); + exiting = null; + if (settling) setTask(settling, settle); + + if (ended) { + cancelled ||= state === 'cancelled'; + for (const items of substatus.values()) { + for (const [id, s] of items) if (s === 'in_progress') items.set(id, settle); + } + return; + } + if (state === 'complete') { + for (const id of TASK_IDS) { + const s = status.get(id); + if (id === 'first-sign-up') status.set(id, 'next'); + else if (s === 'in_progress') setTask(id, 'completed'); + else if (s === 'pending') status.set(id, 'skipped'); + } + return; + } + if (state === 'scaffold') { + // --skip-auth goes straight here from idle. + if (status.get('sign-in') === 'pending') status.set('sign-in', 'skipped'); + return; + } + const task = PHASE_TASKS[state]; + if (!task) return; + // Anything still pending before this phase didn't run (e.g. sign-in with + // --skip-auth). + for (const id of TASK_IDS.slice(0, TASK_IDS.indexOf(task))) { + if (status.get(id) === 'pending') status.set(id, 'skipped'); + } + setTask(task, 'in_progress'); + } + + function onExit({ state }: InstallerEvents['state:exit']): void { + const task: TaskId | undefined = state === 'scaffold' ? 'scaffold' : PHASE_TASKS[state]; + if (!task) return; + // Validation and the app URLs run inside the agent phase, so they end when it does. + const inside = + task === 'install' ? (['app-urls', 'verify'] as const).find((id) => status.get(id) === 'in_progress') : undefined; + const active = inside ?? task; + if (status.get(active) === 'in_progress') exiting = active; + } + + // ── Event handlers ──────────────────────────────────────────────────────── + + type Handlers = { [K in InstallerEventName]?: (payload: InstallerEvents[K]) => void }; + const handlers: Handlers = { + 'state:enter': onEnter, + 'state:exit': onExit, + 'config:step': (payload) => onSetupStep('configure', payload), + 'app-urls:step': (payload) => { + if (status.get('app-urls') === 'pending') { + // The agent (and its checks) finished before the URLs are set. + for (const id of ['install', 'verify'] as const) if (status.get(id) === 'in_progress') setTask(id, 'completed'); + setTask('app-urls', 'in_progress'); + narrate('app-urls:step'); + } + onSetupStep('app-urls', payload); + }, + + 'scaffold:prompt': () => { + setTask('scaffold', 'in_progress'); + narrate('scaffold:prompt'); + }, + 'scaffold:start': ({ packageManager }) => { + setTask('scaffold', 'in_progress'); + narrate('scaffold:start', { packageManager }); + }, + + 'detection:complete': (payload) => { + integration = payload.integration; + narrate('detection:complete', { framework: frameworkName(content, payload.integration) }); + }, + 'git:dirty': ({ files }) => narrate('git:dirty', { count: files.length }), + 'branch:protected': ({ branch }) => narrate('branch:protected', { branch }), + 'branch:created': ({ branch }) => narrate('branch:created', { branch }), + 'credentials:env:found': ({ sourcePath }) => narrate('credentials:env:found', { file: displayPath(sourcePath) }), + 'device:started': ({ verificationUriComplete, verificationUri, userCode }) => + narrate('device:started', { url: verificationUriComplete || verificationUri, code: userCode }), + 'agent:retry': ({ attempt, maxRetries }) => narrate('agent:retry', { attempt, maxRetries }), + + 'agent:progress': ({ step, detail }) => { + const text = detail ? `${step}: ${detail}` : step; + if (text === lastProgress) return; + lastProgress = text; + push('status', 'neutral', text); + }, + 'file:write': ({ path }) => fileOp('file:write', path), + 'file:edit': ({ path }) => fileOp('file:edit', path), + 'agent:tool': ({ detail }) => { + const command = detail.length > MAX_COMMAND ? `${detail.slice(0, MAX_COMMAND - 1)}…` : detail; + const text = walkthroughText(content, 'agent:tool', { command }); + if (text) push('command', 'neutral', text); + }, + + 'validation:start': () => { + if (status.get('install') === 'in_progress') setTask('install', 'completed'); + setTask('verify', 'in_progress'); + narrate('validation:start'); + }, + 'validation:complete': ({ passed, issueCount }) => { + setTask('verify', passed ? 'completed' : 'failed'); + narrate( + 'validation:complete', + { count: issueCount }, + passed ? 'passed' : 'failed', + passed ? 'success' : 'warning', + ); + }, + + 'postinstall:changes': ({ files }) => narrate('postinstall:changes', { count: files.length }), + 'postinstall:commit:success': ({ message }) => narrate('postinstall:commit:success', { message }), + 'postinstall:pr:success': ({ url }) => narrate('postinstall:pr:success', { url }), + + complete: ({ success }) => { + outcome = success ? 'success' : cancelled ? 'cancelled' : 'failure'; + const pending = outcome === 'success' && needsAttention(); + narrate( + 'complete', + undefined, + pending ? 'setup-required' : outcome, + outcome === 'success' && !pending ? 'success' : outcome === 'failure' ? 'error' : 'warning', + ); + }, + }; + + function fileOp(event: 'file:write' | 'file:edit', path: string): void { + if (path === lastFile) return; + lastFile = path; + const text = walkthroughText(content, event, { path: displayPath(path) }); + if (text) push('file', 'neutral', text); + } + + // Every other event the content narrates takes no params. + for (const event of Object.keys(content.walkthrough) as InstallerEventName[]) { + if (!(event in handlers)) (handlers as Record void>)[event] = () => narrate(event); + } + // Handlers are keyed dynamically, so subscribe through the untyped base emitter. + type Listener = (payload: unknown) => void; + const bus: EventEmitter = emitter; + const subscribed: Array<[string, Listener]> = []; + for (const [event, handler] of Object.entries(handlers) as Array<[string, Listener]>) { + const listener: Listener = (payload) => { + handler(payload); + changed(); + }; + bus.on(event, listener); + subscribed.push([event, listener]); + } + + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot: () => snapshot, + setPrompt(next) { + prompt = next; + changed(); + }, + setStatus(message) { + if (message === spinnerStatus) return; + spinnerStatus = message; + changed(); + }, + addNotice(tone, text) { + push('notice', tone, text); + changed(); + }, + dispose() { + for (const [event, listener] of subscribed) bus.off(event, listener); + subscribed.length = 0; + listeners.clear(); + }, + }; +} diff --git a/src/tui/terminal.ts b/src/tui/terminal.ts new file mode 100644 index 00000000..c3bed491 --- /dev/null +++ b/src/tui/terminal.ts @@ -0,0 +1,42 @@ +/** Terminal control for the full-screen installer. */ + +import { writeSync } from 'node:fs'; + +const ENTER_ALT_SCREEN = '\x1b[?1049h'; +const LEAVE_ALT_SCREEN = '\x1b[?1049l'; +const CLEAR_SCREEN = '\x1b[2J'; +const CURSOR_HOME = '\x1b[H'; +const HIDE_CURSOR = '\x1b[?25l'; +const SHOW_CURSOR = '\x1b[?25h'; +const RESET_ATTRS = '\x1b[0m'; + +export const ENTER_FULLSCREEN = ENTER_ALT_SCREEN + CLEAR_SCREEN + CURSOR_HOME + HIDE_CURSOR; +export const LEAVE_FULLSCREEN = RESET_ATTRS + SHOW_CURSOR + LEAVE_ALT_SCREEN; + +/** + * Write synchronously. Restoring the terminal happens in `process.on('exit')`, + * where queued async writes never flush, so the real stdout gets a direct + * `writeSync`. Other streams (tests) fall back to `write`. + */ +export function writeNow(stream: NodeJS.WriteStream, text: string): void { + if (!text) return; + if (stream === process.stdout || stream === process.stderr) { + try { + writeSync(stream === process.stdout ? 1 : 2, text); + return; + } catch { + // EAGAIN on a non-blocking pipe: fall through to the async write. + } + } + stream.write(text); +} + +/** Put stdin back to cooked mode so the shell works normally after we exit. */ +export function releaseStdin(stdin: NodeJS.ReadStream): void { + try { + if (stdin.isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false); + stdin.pause?.(); + } catch { + // Already closed. + } +} diff --git a/src/tui/theme.ts b/src/tui/theme.ts new file mode 100644 index 00000000..62fff77e --- /dev/null +++ b/src/tui/theme.ts @@ -0,0 +1,39 @@ +/** Colors and glyphs for the full-screen installer. */ + +/** WorkOS blurple, the same brand color Arc paints its logomark with. */ +export const BLURPLE = '#6363F1'; + +export const colors = { + brand: BLURPLE, + success: 'green', + warning: 'yellow', + error: 'red', + link: 'cyan', + muted: 'gray', +} as const; + +export const glyphs = { + done: '✔', + failed: '✗', + pending: '○', + skipped: '–', + cancelled: '■', + pointer: '›', + bullet: '·', + warning: '!', + tip: '◆', + next: '→', +} as const; + +export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/** Smallest terminal the full-screen installer lays out in. */ +export const MIN_COLUMNS = 80; +export const MIN_ROWS = 24; + +/** + * Below this the checklist doesn't fit beside the walkthrough; the walkthrough + * keeps the room and the checklist becomes one progress line. + */ +export const SIDE_BY_SIDE_MIN_COLUMNS = 100; +export const SIDE_BY_SIDE_MIN_ROWS = 30; diff --git a/src/tui/wrap.ts b/src/tui/wrap.ts new file mode 100644 index 00000000..1d74708b --- /dev/null +++ b/src/tui/wrap.ts @@ -0,0 +1,62 @@ +/** + * Word wrapping for walkthrough entries. The walkthrough shows the newest + * entries that fit its height, so it needs exact line counts rather than + * letting the terminal wrap. + */ + +/** Display width in cells. Content is plain text, so code points are enough. */ +const stringWidth = (s: string): number => [...s].length; + +/** Break `text` into lines no wider than `width`, splitting long words. */ +export function wrapText(text: string, width: number): string[] { + const max = Math.max(1, width); + const lines: string[] = []; + for (const paragraph of text.split('\n')) { + let line = ''; + for (const word of paragraph.split(/\s+/).filter(Boolean)) { + let rest = word; + while (stringWidth(rest) > max) { + if (line) { + lines.push(line); + line = ''; + } + const chars = [...rest]; + lines.push(chars.slice(0, max).join('')); + rest = chars.slice(max).join(''); + } + if (!rest) continue; + const candidate = line ? `${line} ${rest}` : rest; + if (stringWidth(candidate) <= max) line = candidate; + else { + lines.push(line); + line = rest; + } + } + lines.push(line); + } + return lines; +} + +/** + * The newest items whose wrapped lines fit in `height`, oldest first. The + * newest item is always included (clipped to `height` lines) so the latest + * news is never hidden. + */ +export function fitNewest( + items: readonly T[], + lines: (item: T) => string[], + height: number, +): Array<{ item: T; lines: string[] }> { + const out: Array<{ item: T; lines: string[] }> = []; + let used = 0; + for (let i = items.length - 1; i >= 0; i--) { + const wrapped = lines(items[i]); + if (used + wrapped.length > height) { + if (out.length === 0) out.push({ item: items[i], lines: wrapped.slice(0, Math.max(1, height)) }); + break; + } + out.push({ item: items[i], lines: wrapped }); + used += wrapped.length; + } + return out.reverse(); +} diff --git a/src/utils/analytics.spec.ts b/src/utils/analytics.spec.ts index 039a638a..ac659500 100644 --- a/src/utils/analytics.spec.ts +++ b/src/utils/analytics.spec.ts @@ -289,7 +289,7 @@ describe('Analytics', () => { describe('sessionStart', () => { it('queues session.start event with version and mode', () => { - analytics.sessionStart('tui', '2.0.0'); + analytics.sessionStart('cli', '2.0.0'); expect(mockQueueEvent).toHaveBeenCalledWith( expect.objectContaining({ @@ -297,7 +297,7 @@ describe('Analytics', () => { sessionId: 'test-session-id-123', attributes: expect.objectContaining({ 'installer.version': '2.0.0', - 'installer.mode': 'tui', + 'installer.mode': 'cli', }), }), ); @@ -382,7 +382,7 @@ describe('Analytics', () => { }); it('includes env fingerprint and installer.mode', async () => { - analytics.sessionStart('tui', '1.0.0'); + analytics.sessionStart('cli', '1.0.0'); mockQueueEvent.mockClear(); await analytics.shutdown('success'); @@ -393,7 +393,7 @@ describe('Analytics', () => { expect(event.attributes).toHaveProperty('env.runtime_version'); expect(event.attributes).toHaveProperty('env.shell'); expect(typeof event.attributes['env.ci']).toBe('boolean'); - expect(event.attributes['installer.mode']).toBe('tui'); + expect(event.attributes['installer.mode']).toBe('cli'); }); }); diff --git a/src/utils/command-telemetry.ts b/src/utils/command-telemetry.ts index 94610aa1..b1f6c8fd 100644 --- a/src/utils/command-telemetry.ts +++ b/src/utils/command-telemetry.ts @@ -16,7 +16,7 @@ function topLevelCommands(): Set { return knownTopLevelCommands; } -export const SKIP_TELEMETRY_COMMANDS = new Set(['install', 'dashboard', 'root', 'telemetry', 'internal']); +export const SKIP_TELEMETRY_COMMANDS = new Set(['install', 'root', 'telemetry', 'internal']); export function resolveCanonicalName(parts: string[]): string { if (parts.length === 0) return 'root'; diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index a3d30269..1322d8e5 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -76,12 +76,6 @@ describe('help-json', () => { expect(router?.description).toContain('Pages-only projects are refused'); }); - it('does not include hidden dashboard command', () => { - const tree = buildCommandTree(); - const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name); - expect(names).not.toContain('dashboard'); - }); - it('includes global options with types and defaults', () => { const tree = buildCommandTree(); const opts = (tree as { options: { name: string; type: string; default?: unknown }[] }).options; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 43b52362..39ca3400 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -2109,15 +2109,6 @@ const commands: CommandSchema[] = [ default: false, hidden: false, }, - { - name: 'dashboard', - type: 'boolean', - description: 'Run with visual dashboard mode', - required: false, - default: false, - alias: 'd', - hidden: false, - }, { name: 'branch', type: 'boolean', @@ -2150,6 +2141,14 @@ const commands: CommandSchema[] = [ default: true, hidden: false, }, + { + name: 'tui', + type: 'boolean', + description: 'Use the full-screen installer in an interactive terminal (use --no-tui for plain output)', + required: false, + default: true, + hidden: false, + }, { name: 'router', type: 'string', diff --git a/src/utils/lock-art.spec.ts b/src/utils/lock-art.spec.ts deleted file mode 100644 index 6790228e..00000000 --- a/src/utils/lock-art.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getLockArt, LOCK_WIDTH, type LockExpression } from './lock-art.js'; - -describe('lock-art', () => { - const expressions: LockExpression[] = ['success', 'warning', 'error']; - - describe('getLockArt', () => { - it.each(expressions)('returns 6 lines for %s expression', (expression) => { - const lines = getLockArt(expression, false); - expect(lines).toHaveLength(6); - }); - - it.each(expressions)('all lines have consistent width for %s', (expression) => { - const lines = getLockArt(expression, false); - for (const line of lines) { - expect(line.length).toBe(LOCK_WIDTH); - } - }); - - it('each expression has distinct face characters', () => { - const [successLines, warningLines, errorLines] = expressions.map((e) => getLockArt(e, false)); - // Face row is index 3 (eyes) and 4 (mouth) - expect(successLines[3]).not.toBe(warningLines[3]); - expect(successLines[3]).not.toBe(errorLines[3]); - expect(warningLines[3]).not.toBe(errorLines[3]); - }); - - it('success and warning share a closed shackle', () => { - const success = getLockArt('success', false); - const warning = getLockArt('warning', false); - expect(success[0]).toBe(warning[0]); - expect(success[1]).toBe(warning[1]); - }); - - it('error has an open shackle (right side not connected to body)', () => { - const success = getLockArt('success', false); - const error = getLockArt('error', false); - expect(error[0]).toBe(success[0]); // top curve same - expect(error[1]).toBe(success[1]); // shackle bars same - expect(error[2]).not.toBe(success[2]); // body top differs — no right connector - }); - - it('colored and uncolored return same number of lines', () => { - const colored = getLockArt('success', true); - const raw = getLockArt('success', false); - expect(colored).toHaveLength(raw.length); - }); - - it('returns uncolored lines when color is false', () => { - const lines = getLockArt('success', false); - for (const line of lines) { - // No ANSI escape codes (they start with \x1B) - expect(line).not.toMatch(/\x1B/); - } - }); - }); -}); diff --git a/src/utils/lock-art.ts b/src/utils/lock-art.ts deleted file mode 100644 index 3e10406b..00000000 --- a/src/utils/lock-art.ts +++ /dev/null @@ -1,79 +0,0 @@ -import chalk from 'chalk'; -import { isUnicodeSupported } from './vendor/is-unicorn-supported.js'; - -export type LockExpression = 'success' | 'warning' | 'error'; - -const unicode = isUnicodeSupported(); - -// prettier-ignore -const UNICODE_LOCKS: Record = { - success: [ - ' ╭───╮ ', - ' │ │ ', - '╔═╧═══╧═╗', - '║ ◠ ◠ ║', - '║ ▽ ║', - '╚═══════╝', - ], - warning: [ - ' ╭───╮ ', - ' │ │ ', - '╔═╧═══╧═╗', - '║ • • ║', - '║ ─ ║', - '╚═══════╝', - ], - error: [ - ' ╭───╮ ', - ' │ │ ', - '╔═╧═════╗', - '║ × × ║', - '║ △ ║', - '╚═══════╝', - ], -}; - -// prettier-ignore -const ASCII_LOCKS: Record = { - success: [ - ' .---. ', - ' | | ', - '+-------+', - '| ^ ^ |', - '| v |', - '+-------+', - ], - warning: [ - ' .---. ', - ' | | ', - '+-------+', - '| . . |', - '| - |', - '+-------+', - ], - error: [ - ' .---. ', - ' | ', - '+-------+', - '| x x |', - '| ^ |', - '+-------+', - ], -}; - -const COLORS: Record string> = { - success: chalk.green, - warning: chalk.yellow, - error: chalk.red, -}; - -/** Width of each lock art line (all lines are the same width). */ -export const LOCK_WIDTH = 9; - -export function getLockArt(expression: LockExpression, color = true): string[] { - const variants = unicode ? UNICODE_LOCKS : ASCII_LOCKS; - const lines = variants[expression]; - if (!color) return lines; - const colorFn = COLORS[expression]; - return lines.map((line) => colorFn(line)); -} diff --git a/src/utils/logomark.ts b/src/utils/logomark.ts new file mode 100644 index 00000000..ebe97589 --- /dev/null +++ b/src/utils/logomark.ts @@ -0,0 +1,65 @@ +/** + * The WorkOS logomark as terminal cells, matching Arc's startup header. The + * one brand mark in the CLI: the full-screen installer's header and the plain + * CLI's opener both draw it. + * + * Arc rasterizes workos.svg at 22 cells wide and paints the covered cells with + * a blurple background, because block glyphs can leave seams in some + * terminals while cell backgrounds always fill the whole cell. This is that + * raster, precomputed, so the binary ships no SVG renderer. + */ + +export const LOGO_MASK = [ + '.....#######.####.....', + '...########.#######...', + '..########..########..', + '.########....########.', + '#######........#######', + '#######........#######', + '.########....########.', + '..########..########..', + '....######.#######....', + '.....####.#######.....', +] as const; + +export const LOGO_WIDTH = LOGO_MASK[0].length; + +/** A horizontal run of cells that are all painted or all blank. */ +export interface LogoRun { + painted: boolean; + text: string; +} + +/** Full size: one terminal row per mask row, painted cells as spaces on blurple. */ +export function logoRows(): LogoRun[][] { + return LOGO_MASK.map((row) => { + const runs: LogoRun[] = []; + for (const cell of row) { + const painted = cell === '#'; + const last = runs.at(-1); + if (last && last.painted === painted) last.text += ' '; + else runs.push({ painted, text: ' ' }); + } + return runs; + }); +} + +/** + * Half height for short terminals: two mask rows per terminal row, drawn with + * half-block glyphs in the brand color. + */ +export function compactLogoRows(): string[] { + const out: string[] = []; + for (let y = 0; y < LOGO_MASK.length; y += 2) { + const top = LOGO_MASK[y]; + const bottom = LOGO_MASK[y + 1] ?? '.'.repeat(LOGO_WIDTH); + let line = ''; + for (let x = 0; x < LOGO_WIDTH; x++) { + const t = top[x] === '#'; + const b = bottom[x] === '#'; + line += t && b ? '█' : t ? '▀' : b ? '▄' : ' '; + } + out.push(line); + } + return out; +} diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index 81512e54..450d81b1 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest'; +import { palette } from './cli-symbols.js'; +import { compactLogoRows } from './logomark.js'; import { renderSummaryBox, renderCompletionSummary, renderBrandMark, type SummaryBoxItem } from './summary-box.js'; import type { CompletionData } from '../lib/events.js'; @@ -13,7 +15,7 @@ describe('summary-box', () => { it('renders a box with title and no items', () => { const result = strip( renderSummaryBox({ - expression: 'success', + tone: 'success', title: 'All Good', }), ); @@ -31,7 +33,7 @@ describe('summary-box', () => { ]; const result = strip( renderSummaryBox({ - expression: 'warning', + tone: 'warning', title: 'Issues Found', items, }), @@ -45,7 +47,7 @@ describe('summary-box', () => { it('renders footer with divider', () => { const result = strip( renderSummaryBox({ - expression: 'success', + tone: 'success', title: 'Done', footer: 'pnpm dev', }), @@ -58,7 +60,7 @@ describe('summary-box', () => { it('omits mid-border when no footer provided', () => { const result = strip( renderSummaryBox({ - expression: 'success', + tone: 'success', title: 'Done', }), ); @@ -67,27 +69,24 @@ describe('summary-box', () => { expect(hasMidBorder).toBe(false); }); - it('renders lock character in the box', () => { - const result = strip( - renderSummaryBox({ - expression: 'success', - title: 'Test', - }), - ); - // Should contain parts of the lock body - expect(result).toMatch(/[╔+]/); - expect(result).toMatch(/[╚+]/); + it('leads with a title line that carries the outcome', () => { + const line = (tone: 'success' | 'warning' | 'error') => + strip(renderSummaryBox({ tone, title: 'Result' })) + .split('\n') + .find((l) => l.includes('Result')); + expect(line('success')).toMatch(/[✔+] Result/); + expect(line('warning')).toMatch(/! Result/); + expect(line('error')).toMatch(/[✗x] Result/); }); - it('renders all three expressions without error', () => { - for (const expression of ['success', 'warning', 'error'] as const) { - expect(() => renderSummaryBox({ expression, title: `Test ${expression}` })).not.toThrow(); - } + it('no longer draws the lock mascot', () => { + const result = strip(renderSummaryBox({ tone: 'success', title: 'Test' })); + for (const piece of ['╭───╮', '╔═', '◠', '▽']) expect(result).not.toContain(piece); }); it('handles empty items array', () => { const result = renderSummaryBox({ - expression: 'success', + tone: 'success', title: 'Clean', items: [], }); @@ -102,7 +101,7 @@ describe('summary-box', () => { })); const result = strip( renderSummaryBox({ - expression: 'success', + tone: 'success', title: 'Many Items', items, }), @@ -114,7 +113,7 @@ describe('summary-box', () => { it('box lines are consistent width', () => { const result = strip( renderSummaryBox({ - expression: 'success', + tone: 'success', title: 'Width Test', items: [ { type: 'done', text: 'Short' }, @@ -190,7 +189,8 @@ describe('summary-box', () => { }), ), ); - expect(result).toContain('App code installed; WorkOS setup required'); + expect(result).toContain('! App code installed; WorkOS setup required'); + expect(result).not.toContain('✔'); expect(result).toContain('http://localhost:8080/sign-in'); }); @@ -206,6 +206,20 @@ describe('summary-box', () => { expect(result).toContain('Something went wrong'); }); + it('puts the outcome on the title line, not on a lock', () => { + const success = strip(renderCompletionSummary(true)).split('\n'); + const failure = strip(renderCompletionSummary(false, 'Something went wrong')).split('\n'); + + expect(success[0]).toMatch(/^ {2}[✔+] WorkOS AuthKit Installed$/); + expect(failure[0]).toMatch(/^ {2}[✗x] Installation Failed$/); + // One ✗ for the outcome; the detail under it doesn't repeat it. + expect(failure[1]).toBe(' › Something went wrong'); + for (const piece of ['╭───╮', '╔═', '◠', '×']) { + expect(success.join('\n')).not.toContain(piece); + expect(failure.join('\n')).not.toContain(piece); + } + }); + it('renders de-boxed (variable-width lines, not a fixed-width box)', () => { const result = strip(renderCompletionSummary(true, undefined, makeCompletion())); // A bordered box forces every line to the same width; the flat summary does not. @@ -220,20 +234,30 @@ describe('summary-box', () => { }); describe('renderBrandMark', () => { - it('places the wordmark beside the lock and stays 6 lines tall', () => { - const result = strip(renderBrandMark('AuthKit installer')); + it('draws the compact logomark with the wordmark and subtitle beside it', () => { + const lines = strip(renderBrandMark('AuthKit installer', { unicode: true })).split('\n'); - expect(result).toContain('WorkOS'); - expect(result).toContain('AuthKit installer'); - // The compact brand mark is exactly the lock art height — no block banner. - expect(result.split('\n')).toHaveLength(6); + expect(lines).toHaveLength(compactLogoRows().length); + compactLogoRows().forEach((row, i) => expect(lines[i].startsWith(` ${row.trimEnd()}`)).toBe(true)); + expect(lines[1]).toMatch(/WorkOS$/); + expect(lines[2]).toMatch(/AuthKit installer$/); + for (const line of lines) expect(line).toBe(line.trimEnd()); + }); + + it('paints the mark in the WorkOS brand accent', () => { + const lines = renderBrandMark('AuthKit installer', { unicode: true }).split('\n'); + expect(lines[0]).toBe(` ${palette.accent(compactLogoRows()[0].trimEnd())}`); }); it('renders without a subtitle', () => { - const result = strip(renderBrandMark()); + const result = strip(renderBrandMark(undefined, { unicode: true })); expect(result).toContain('WorkOS'); expect(result).not.toContain('AuthKit installer'); }); + + it('falls back to one plain line without unicode', () => { + expect(strip(renderBrandMark('AuthKit installer', { unicode: false }))).toBe(' WorkOS AuthKit installer'); + }); }); }); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index 2cfd6dad..0296f4cb 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -1,13 +1,13 @@ import chalk from 'chalk'; import { isUnicodeSupported } from './vendor/is-unicorn-supported.js'; -import { type LockExpression, getLockArt, LOCK_WIDTH } from './lock-art.js'; import { symbols, palette } from './cli-symbols.js'; +import { compactLogoRows } from './logomark.js'; import type { CompletionData } from '../lib/events.js'; /** Max number of changed files listed in the success box before collapsing. */ const MAX_SUMMARY_FILES = 5; -/** Pre-built completion summaries shared by CLI and Dashboard adapters. */ +/** Pre-built completion summaries, printed by the CLI adapter (and replayed after the full-screen view). */ export function renderCompletionSummary(success: boolean, summary?: string, completion?: CompletionData): string { if (success) { if (completion) { @@ -17,19 +17,18 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp shown.push({ type: 'done', text: `…and ${files.length - MAX_SUMMARY_FILES} more` }); } const steps: SummaryBoxItem[] = completion.nextSteps.map((s) => ({ type: 'pending', text: s })); + const setupPending = Boolean(completion.applicationSetup && !completion.applicationSetup.verified); return renderFlatSummary({ - expression: 'success', - title: - completion.applicationSetup && !completion.applicationSetup.verified - ? 'App code installed; WorkOS setup required' - : 'WorkOS AuthKit Installed', + // Code in, dashboard not yet: a warning, not a success. + tone: setupPending ? 'warning' : 'success', + title: setupPending ? 'App code installed; WorkOS setup required' : 'WorkOS AuthKit Installed', items: [...shown, ...steps], footer: completion.docsUrl, }); } // Fallback: preserve the original static next-steps when no structured data is present. return renderFlatSummary({ - expression: 'success', + tone: 'success', title: 'WorkOS AuthKit Installed', items: [ ...(summary ? [{ type: 'pending' as const, text: summary }] : []), @@ -40,7 +39,7 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp }); } return renderFlatSummary({ - expression: 'error', + tone: 'error', title: 'Installation Failed', items: summary ? [{ type: 'error', text: summary }] : [], footer: 'https://github.com/workos/cli/issues', @@ -52,8 +51,11 @@ export interface SummaryBoxItem { text: string; } +/** The outcome a summary reports. It sets the title's glyph and color. */ +export type SummaryTone = 'success' | 'warning' | 'error'; + export interface SummaryBoxOptions { - expression: LockExpression; + tone: SummaryTone; title: string; items?: SummaryBoxItem[]; footer?: string; @@ -71,45 +73,61 @@ const ITEM_ICONS: Record = { error: chalk.red(symbols.error), }; -// ── Flat (de-boxed) rendering — the install opener + closer ─────────────────── +/** Outcome glyph and color for a summary's title line. */ +const TONE: Record string }> = { + success: { glyph: unicode ? '✔' : symbols.success, color: palette.green }, + warning: { glyph: symbols.warning, color: palette.yellow }, + error: { glyph: symbols.error, color: palette.red }, +}; + +/** The title line: outcome glyph and title in the outcome's color, bold. */ +function toneTitle(tone: SummaryTone, title: string): { text: string; width: number } { + const { glyph, color } = TONE[tone]; + return { text: chalk.bold(color(`${glyph} ${title}`)), width: glyph.length + 1 + title.length }; +} + +// ── Flat (de-boxed) rendering: the install opener + closer ────────────────── const { accent, cyan: flatCyan } = palette; -/** Flat glyphs matching the ui facade (green ✓ / accent › / red ✗). */ +/** + * Flat glyphs matching the ui facade (green ✓ / accent ›). The title line + * already carries the ✗, so an error detail under it is a red › rather than a + * second ✗. + */ const FLAT_ICONS: Record = { done: chalk.green('✓'), pending: accent('›'), - error: chalk.red('✗'), + error: chalk.red('›'), }; /** - * The install opener: the WorkOS lock (in brand indigo) beside the wordmark. - * A compact, de-boxed replacement for the full block-letter banner — the same - * lock that closes the install, so the two ends bookend each other. + * The install opener: the WorkOS logomark (the compact half-height raster the + * full-screen installer uses on short terminals) beside the wordmark. Without + * Unicode it collapses to one plain line. */ -export function renderBrandMark(subtitle?: string): string { - const lock = getLockArt('success', false); // raw lines; recolor to brand indigo - const titleLine = 2; // "WorkOS" sits beside the top of the lock body - const subtitleLine = 3; - return lock - .map((l, i) => { - const left = ` ${accent(l)}`; - if (i === titleLine) return `${left} ${accent.bold('WorkOS')}`; - if (i === subtitleLine && subtitle) return `${left} ${chalk.dim(subtitle)}`; - return left; +export function renderBrandMark(subtitle?: string, options: { unicode?: boolean } = {}): string { + if (!(options.unicode ?? unicode)) { + return ` ${chalk.bold(accent('WorkOS'))}${subtitle ? ` ${chalk.dim(subtitle)}` : ''}`; + } + const titleRow = 1; + const subtitleRow = 2; + return compactLogoRows() + .map((row, i) => { + if (i === titleRow) return ` ${accent(row)} ${chalk.bold(accent('WorkOS'))}`; + if (i === subtitleRow && subtitle) return ` ${accent(row)} ${chalk.dim(subtitle)}`; + return ` ${accent(row.trimEnd())}`; }) .join('\n'); } /** - * The install closer: the WorkOS lock (colored by outcome) above a flat title, - * checklist, and footer — no border. Shared by the CLI and Dashboard adapters. - * The lock is the same mark that opens the install (see renderBrandMark). + * The install closer: a title line that carries the outcome (✔ green, + * ! yellow, ✗ red), then the checklist and footer, with no border. */ function renderFlatSummary(options: SummaryBoxOptions): string { - const { expression, title, items = [], footer } = options; - const out: string[] = getLockArt(expression, true).map((l) => ` ${l}`); - out.push('', ` ${chalk.bold(title)}`); + const { tone, title, items = [], footer } = options; + const out: string[] = [` ${toneTitle(tone, title).text}`]; for (const item of items) { // File paths (done) read better in cyan; next-step prose stays default weight. const text = item.type === 'done' ? flatCyan(item.text) : item.text; @@ -124,6 +142,8 @@ const MIN_WIDTH = 42; const ITEM_PREFIX_LEN = 4; // Footer prefix " " = 2 visible chars before text const FOOTER_PREFIX_LEN = 2; +// Title prefix " " = 2 visible chars before the outcome glyph +const TITLE_PREFIX_LEN = 2; function hLine(left: string, right: string, width: number): string { return `${left}${BOX.h.repeat(width - 2)}${right}`; @@ -158,26 +178,19 @@ function getTerminalWidth(): number { } /** - * Render a branded summary box with the WorkOS lock character, - * a title, optional checklist items, and an optional footer. + * Render a summary box: a title line carrying the outcome, optional checklist + * items, and an optional footer. */ export function renderSummaryBox(options: SummaryBoxOptions): string { - const { expression, title, items = [], footer } = options; - - const lockColored = getLockArt(expression, true); - const lockRaw = getLockArt(expression, false); - const lockLines = lockColored.length; - - // " " + lock + " " = lock column width - const lockColWidth = LOCK_WIDTH + 3; + const { tone, title, items = [], footer } = options; + const heading = toneTitle(tone, title); // Cap box width to terminal width const termWidth = getTerminalWidth(); - // innerWidth must fit: lock+title, items, footer — but capped to terminal const maxInner = Math.max(MIN_WIDTH - 2, termWidth - 2); // Compute ideal inner width from content - const titleRowWidth = lockColWidth + title.length; + const titleRowWidth = TITLE_PREFIX_LEN + heading.width; const itemWidths = items.map((item) => ITEM_PREFIX_LEN + item.text.length); const footerWidth = footer ? FOOTER_PREFIX_LEN + footer.length : 0; const idealInner = Math.max(titleRowWidth, ...itemWidths, footerWidth) + 1; @@ -190,31 +203,16 @@ export function renderSummaryBox(options: SummaryBoxOptions): string { const footerTextMax = innerWidth - FOOTER_PREFIX_LEN - 1; const lines: string[] = []; + const blank = `${BOX.v}${' '.repeat(innerWidth)}${BOX.v}`; - // Top border + // Top border, then the title line with breathing room lines.push(hLine(BOX.tl, BOX.tr, boxWidth)); - - // Lock + title rows - const titleLineIndex = 3; - for (let i = 0; i < lockLines; i++) { - const lockPart = ` ${lockColored[i]} `; - const lockPartRaw = ` ${lockRaw[i]} `; - let rightPart: string; - let rightPartLen: number; - if (i === titleLineIndex) { - rightPart = chalk.bold(title); - rightPartLen = title.length; - } else { - rightPart = ''; - rightPartLen = 0; - } - const row = BOX.v + padRight(lockPart + rightPart, lockPartRaw.length + rightPartLen, innerWidth) + BOX.v; - lines.push(row); - } + lines.push(blank); + lines.push(`${BOX.v}${padRight(` ${heading.text}`, titleRowWidth, innerWidth)}${BOX.v}`); // Items if (items.length > 0) { - lines.push(`${BOX.v}${' '.repeat(innerWidth)}${BOX.v}`); + lines.push(blank); for (const item of items) { const icon = ITEM_ICONS[item.type]; @@ -231,6 +229,7 @@ export function renderSummaryBox(options: SummaryBoxOptions): string { } } } + lines.push(blank); // Footer if (footer) { diff --git a/src/utils/types.ts b/src/utils/types.ts index 28146cbd..be36754f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -67,12 +67,12 @@ export type InstallerOptions = { redirectUri?: string; /** - * [Experimental] Enable visual dashboard mode + * Use the plain CLI output instead of the full-screen installer (--no-tui). */ - dashboard?: boolean; + noTui?: boolean; /** - * Event emitter for dashboard mode + * Event emitter for installer state machine events */ emitter?: import('../lib/events.js').InstallerEventEmitter; diff --git a/src/utils/ui-utils.spec.ts b/src/utils/ui-utils.spec.ts index e934d7bd..24e6a84c 100644 --- a/src/utils/ui-utils.spec.ts +++ b/src/utils/ui-utils.spec.ts @@ -121,11 +121,6 @@ describe('getOrAskForWorkOSCredentials — credential-source-aware copy', () => } }); - it('stays silent in dashboard mode', async () => { - await getOrAskForWorkOSCredentials({ ...base, dashboard: true, credentialSource: 'cli' }); - expect(ui.log.info).not.toHaveBeenCalled(); - }); - it('stays silent in JSON output mode (no human copy into JSON)', async () => { setOutputMode('json'); await getOrAskForWorkOSCredentials({ ...base, credentialSource: 'cli' }); diff --git a/src/utils/ui-utils.ts b/src/utils/ui-utils.ts index f33fa823..e29efbd2 100644 --- a/src/utils/ui-utils.ts +++ b/src/utils/ui-utils.ts @@ -420,7 +420,6 @@ export async function ensurePackageIsInstalled( packageJson: PackageDotJson, packageId: string, packageName: string, - options?: Pick, ): Promise { return traceStep('ensure-package-installed', async () => { const installed = hasPackageInstalled(packageId, packageJson); @@ -428,11 +427,6 @@ export async function ensurePackageIsInstalled( analytics.setTag(`${packageName.toLowerCase()}-installed`, installed); if (!installed) { - // In dashboard mode, auto-continue (integration was already detected) - if (options?.dashboard) { - return; - } - const continueWithoutPackage = await abortIfCancelled( ui.confirm({ message: `${packageName} does not seem to be installed. Do you still want to continue?`, @@ -545,7 +539,7 @@ export function isUsingTypeScript({ installDir }: Pick, + _options: Pick, requireApiKey: boolean = true, ): Promise<{ apiKey: string; @@ -554,7 +548,7 @@ export async function getOrAskForWorkOSCredentials( let apiKey = _options.apiKey; let clientId = _options.clientId; - // If credentials provided via CLI (e.g., CI mode or dashboard mode), use them + // If credentials provided via CLI (e.g., CI mode), use them if ((!requireApiKey || apiKey) && clientId) { // Say "you provided" only when the user actually supplied credentials // (cli/manual, or an unknown source). For device/stored/env the state @@ -563,7 +557,7 @@ export async function getOrAskForWorkOSCredentials( // this never pollutes JSON/NDJSON. const source = _options.credentialSource; const userProvided = source === 'cli' || source === 'manual' || source === undefined; - if (!_options.dashboard && !isJsonMode() && userProvided) { + if (!isJsonMode() && userProvided) { ui.log.info('Using the WorkOS credentials you provided'); } return { apiKey: apiKey || '', clientId }; @@ -581,9 +575,7 @@ export async function getOrAskForWorkOSCredentials( // Use existing credentials if both are present (or API key not required) if (existingClientId && (!requireApiKey || existingApiKey)) { - if (!_options.dashboard) { - ui.log.success(`Found existing WorkOS credentials in .env.local`); - } + ui.log.success(`Found existing WorkOS credentials in .env.local`); return { apiKey: existingApiKey || '', clientId: existingClientId, diff --git a/src/utils/ui.spec.ts b/src/utils/ui.spec.ts index 55575d2e..cbd2bbe1 100644 --- a/src/utils/ui.spec.ts +++ b/src/utils/ui.spec.ts @@ -9,7 +9,10 @@ vi.mock('@inquirer/prompts', () => ({ const inquirer = await import('@inquirer/prompts'); const ui = (await import('./ui.js')).default; -const { isCancel, CANCEL, setDashboardMode } = await import('./ui.js'); +const uiModule = await import('./ui.js'); +const { isCancel, CANCEL, setUiHost } = uiModule; +type UiHost = import('./ui.js').UiHost; +type UiPromptRequest = import('./ui.js').UiPromptRequest; function namedError(name: string): Error { const e = new Error(name); @@ -20,7 +23,7 @@ function namedError(name: string): Error { let stdinTtyDesc: PropertyDescriptor | undefined; beforeEach(() => { vi.clearAllMocks(); - setDashboardMode(false); + setUiHost(null); // Prompts route through withPrompt, which refuses to open on a non-TTY stdin. // Simulate an interactive terminal so the adapter/cancellation tests exercise // the real prompt path (individual tests override this to test the guard). @@ -196,25 +199,267 @@ describe('prompt coordination (withPrompt)', () => { }); }); -describe('dashboard mode suppresses output', () => { +describe('UI host', () => { let logSpy: ReturnType; + let errorSpy: ReturnType; + let writeSpy: ReturnType; + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); + + function fakeHost(answer: (request: UiPromptRequest) => unknown = () => true) { + const lines: Array<{ kind: string; message: string; rendered: string; stream: string }> = []; + const statuses: Array = []; + const requests: UiPromptRequest[] = []; + const host: UiHost = { + line: (l) => lines.push({ ...l, rendered: strip(l.rendered) }), + status: (m) => statuses.push(m), + prompt: async (request) => { + requests.push(request); + return answer(request); + }, + }; + return { host, lines, statuses, requests }; + } + beforeEach(() => { logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + afterEach(() => { + setUiHost(null); + logSpy.mockRestore(); + errorSpy.mockRestore(); + writeSpy.mockRestore(); }); - afterEach(() => logSpy.mockRestore()); - it('no-ops log/intro/note when dashboard mode is on', () => { - setDashboardMode(true); - ui.log.info('hi'); - ui.intro('title'); - ui.note('body'); - expect(logSpy).not.toHaveBeenCalled(); + it('no longer exposes the removed dashboard-mode flag', () => { + expect('setDashboardMode' in uiModule).toBe(false); + expect('isDashboardMode' in uiModule).toBe(false); }); - it('writes when dashboard mode is off', () => { - setDashboardMode(false); + it('prints to the terminal when no host is registered', () => { ui.log.success('done'); - expect(logSpy).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(strip(String(logSpy.mock.calls[0][0]))).toBe(' ✓ done'); + }); + + it('routes every output helper to the host with its level, and prints nothing', () => { + const { host, lines } = fakeHost(); + setUiHost(host); + + ui.log.info('plain info'); + ui.log.step('a step'); + ui.log.success('it worked'); + ui.log.warn('careful'); + ui.log.warning('also careful'); + ui.log.error('it broke'); + ui.log.hint('psst'); + ui.log.detail('nested'); + ui.intro('WorkOS', 'installer'); + ui.rows([{ key: 'Client ID', value: 'client_123', status: 'created' }]); + ui.cancel('Stopped'); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(lines.slice(0, 8)).toEqual([ + { kind: 'info', message: 'plain info', rendered: 'plain info', stream: 'stdout' }, + { kind: 'step', message: 'a step', rendered: '› a step', stream: 'stdout' }, + { kind: 'success', message: 'it worked', rendered: '✓ it worked', stream: 'stdout' }, + { kind: 'warn', message: 'careful', rendered: '! careful', stream: 'stdout' }, + { kind: 'warn', message: 'also careful', rendered: '! also careful', stream: 'stdout' }, + { kind: 'error', message: 'it broke', rendered: '✗ it broke', stream: 'stdout' }, + { kind: 'hint', message: 'psst', rendered: 'psst', stream: 'stdout' }, + { kind: 'detail', message: 'nested', rendered: ' › nested', stream: 'stdout' }, + ]); + // intro: blank, title line, blank + expect(lines.slice(8, 11).map((l) => l.rendered)).toEqual(['', 'WorkOS · installer', '']); + expect(lines[11].rendered).toMatch(/^✓ Client ID {2}client_123 {2}created$/); + expect(lines[12]).toEqual({ kind: 'plain', message: 'Stopped', rendered: 'Stopped', stream: 'stderr' }); + }); + + it('reports spinner progress as status and records the final line', () => { + const { host, lines, statuses } = fakeHost(); + setUiHost(host); + + const s = ui.spinner(); + s.start('Working'); + s.message('Still working'); + s.stop('Done'); + const failed = ui.spinner(); + failed.start('Pushing'); + failed.stop('Push failed', 1); + const cleared = ui.spinner(); + cleared.start('Waiting'); + cleared.clear(); + + expect(statuses).toEqual(['Working', 'Still working', null, 'Pushing', null, 'Waiting', null]); + expect(lines).toEqual([ + { kind: 'success', message: 'Done', rendered: '✓ Done', stream: 'stdout' }, + { kind: 'error', message: 'Push failed', rendered: '✗ Push failed', stream: 'stdout' }, + ]); + // No animation frames were written to the terminal. + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('delegates each prompt kind to the host with the same call shape, not to inquirer', async () => { + const validate = (v: string) => (v ? undefined : 'required'); + const { host, requests } = fakeHost((r) => (r.kind === 'confirm' ? false : r.kind === 'select' ? 'b' : 'typed')); + setUiHost(host); + + await expect(ui.confirm({ message: 'Continue?', initialValue: true })).resolves.toBe(false); + await expect( + ui.select({ + message: 'Pick', + options: [{ value: 'a' }, { value: 'b', label: 'B', hint: 'second' }], + initialValue: 'b', + }), + ).resolves.toBe('b'); + await expect(ui.text({ message: 'Name', placeholder: 'client_...', validate })).resolves.toBe('typed'); + await expect(ui.password({ message: 'Key', validate })).resolves.toBe('typed'); + + expect(requests).toEqual([ + { kind: 'confirm', message: 'Continue?', initialValue: true }, + { + kind: 'select', + message: 'Pick', + options: [{ value: 'a' }, { value: 'b', label: 'B', hint: 'second' }], + initialValue: 'b', + }, + { kind: 'text', message: 'Name', placeholder: 'client_...', validate }, + { kind: 'password', message: 'Key', validate }, + ]); + expect(inquirer.confirm).not.toHaveBeenCalled(); + expect(inquirer.select).not.toHaveBeenCalled(); + expect(inquirer.input).not.toHaveBeenCalled(); + expect(inquirer.password).not.toHaveBeenCalled(); + }); + + it('passes CANCEL from the host through unchanged', async () => { + const { host } = fakeHost(() => CANCEL); + setUiHost(host); + const answer = await ui.confirm({ message: 'q' }); + expect(isCancel(answer)).toBe(true); + }); + + it('returns CANCEL for an already-aborted signal without opening the host prompt', async () => { + const { host, requests } = fakeHost(); + setUiHost(host); + const controller = new AbortController(); + controller.abort(); + const answer = await ui.select({ message: 'q', options: [{ value: 'x' }], signal: controller.signal }); + expect(isCancel(answer)).toBe(true); + expect(requests).toEqual([]); + }); + + it('keeps the --json and non-TTY guards in front of the host', async () => { + const { host, requests } = fakeHost(); + setUiHost(host); + + const { setOutputMode } = await import('./output.js'); + setOutputMode('json'); + try { + await expect(ui.confirm({ message: 'q' })).rejects.toMatchObject({ + name: 'PromptUnavailableError', + reason: 'json', + }); + } finally { + setOutputMode('human'); + } + + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + await expect(ui.text({ message: 'q' })).rejects.toMatchObject({ name: 'PromptUnavailableError', reason: 'no-tty' }); + expect(requests).toEqual([]); + }); + + it('opens host prompts one at a time', async () => { + const order: string[] = []; + let resolveFirst!: (v: boolean) => void; + setUiHost({ + line: () => {}, + status: () => {}, + prompt: (request) => { + order.push(`open:${request.message}`); + if (request.message === 'first') return new Promise((resolve) => (resolveFirst = resolve)); + return Promise.resolve(true); + }, + }); + + const p1 = ui.confirm({ message: 'first' }); + const p2 = ui.confirm({ message: 'second' }); + await new Promise((r) => setTimeout(r, 0)); + expect(order).toEqual(['open:first']); + + resolveFirst(true); + await Promise.all([p1, p2]); + expect(order).toEqual(['open:first', 'open:second']); + }); + + it('attaches what was printed just before a prompt as its context', async () => { + const { host, requests } = fakeHost(() => false); + setUiHost(host); + + ui.log.warn('You have uncommitted or untracked files:'); + ui.log.info(' src/a.ts'); + ui.log.info(''); + ui.log.info(' src/b.ts'); + await ui.confirm({ message: 'Continue anyway?', initialValue: false }); + + expect(requests[0].context?.map((l) => [l.kind, l.message])).toEqual([ + ['warn', 'You have uncommitted or untracked files:'], + ['info', ' src/a.ts'], + ['info', ' src/b.ts'], + ]); + }); + + it('does not attach lines printed in an earlier run', async () => { + const { host, requests } = fakeHost(); + setUiHost(host); + + ui.log.success('Authenticated'); + await new Promise((r) => setTimeout(r, 0)); + await ui.confirm({ message: 'Commit the changes?' }); + + expect(requests[0]).not.toHaveProperty('context'); + }); + + it('keeps each queued prompt paired with the lines printed before it was called', async () => { + const opened: UiPromptRequest[] = []; + let resolveFirst!: (v: unknown) => void; + setUiHost({ + line: () => {}, + status: () => {}, + prompt: (request) => { + opened.push(request); + return opened.length === 1 ? new Promise((r) => (resolveFirst = r)) : Promise.resolve(true); + }, + }); + + ui.log.info('about the first'); + const first = ui.select({ message: 'First?', options: [{ value: 'a' }] }); + ui.log.warn('about the second'); + const second = ui.confirm({ message: 'Second?' }); + await new Promise((r) => setTimeout(r, 0)); + resolveFirst('a'); + await Promise.all([first, second]); + + expect(opened.map((r) => r.context?.map((l) => l.message))).toEqual([['about the first'], ['about the second']]); + }); + + it('never collects context without a host', async () => { + ui.log.warn('printed to the terminal'); + vi.mocked(inquirer.confirm).mockResolvedValue(true); + await ui.confirm({ message: 'q' }); + expect(vi.mocked(inquirer.confirm).mock.calls[0][0]).not.toHaveProperty('context'); + }); + + it('goes back to the terminal after the host is removed', () => { + const { host, lines } = fakeHost(); + setUiHost(host); + ui.log.info('to host'); + setUiHost(null); + ui.log.info('to terminal'); + expect(lines.map((l) => l.message)).toEqual(['to host']); + expect(strip(String(logSpy.mock.calls[0][0]))).toBe(' to terminal'); }); }); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index ff26fcf7..0b520c55 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -24,14 +24,59 @@ import { palette } from './cli-symbols.js'; // resource commands), which is most invocations. import() is module-cached, so // only the first prompt pays. -// ── Dashboard mode ────────────────────────────────────────────────────────── -// When true, suppress all human output (the Dashboard adapter drives its own UI). -let dashboardMode = false; -export function setDashboardMode(enabled: boolean): void { - dashboardMode = enabled; +// ── UI host ───────────────────────────────────────────────────────────────── +// A host (the full-screen installer) takes over where output and prompts go. +// With no host, everything below prints to the terminal exactly as before. + +/** The log level a line was printed at, so a host can style or surface it. */ +export type UiLineKind = 'plain' | 'info' | 'step' | 'success' | 'warn' | 'error' | 'hint' | 'detail'; + +export interface UiLine { + kind: UiLineKind; + /** The caller's text, unstyled. Empty for a blank line. */ + message: string; + /** The styled line ui would have printed, without the leading indent. */ + rendered: string; + /** Where the line would have gone. */ + stream: 'stdout' | 'stderr'; } -export function isDashboardMode(): boolean { - return dashboardMode; + +export type UiPromptRequest = ( + | ({ kind: 'confirm' } & ConfirmOptions) + | ({ kind: 'select' } & SelectOptions) + | ({ kind: 'text' } & TextOptions) + | ({ kind: 'password' } & PasswordOptions) +) & { + /** + * What the caller printed right before asking (same synchronous run), e.g. + * the file list behind "Continue anyway?". On a terminal those lines sit + * above the question; a host has to show them with it. Absent when empty. + */ + context?: readonly UiLine[]; +}; + +export interface UiHost { + /** A line ui would have printed. */ + line(line: UiLine): void; + /** The running spinner's message, or null when no spinner is running. */ + status(message: string | null): void; + /** + * Answer a prompt: a boolean for confirm, the chosen option's value for + * select, a string for text/password, or CANCEL. Must resolve CANCEL when + * `request.signal` aborts. Only one prompt is open at a time. + */ + prompt(request: UiPromptRequest): Promise; +} + +let uiHost: UiHost | null = null; + +/** Route all ui output and prompts to `host`, or back to the terminal with null. */ +export function setUiHost(host: UiHost | null): void { + uiHost = host; +} + +export function getUiHost(): UiHost | null { + return uiHost; } // Brand palette (shared with summary-box via cli-symbols). chalk auto-disables @@ -51,18 +96,54 @@ export function pill(label: string, kind: 'info' | 'warn' = 'info'): string { const INDENT = ' '; -// Every stdout write routes through line()/blank(), which are the single -// dashboard-mode guard — so no output surface below has to re-check the flag. -/** Print one indented line to stdout (suppressed in dashboard mode). */ +// Every stdout write routes through emit(), the single point where a UI host +// takes over — so no output surface below has to check for one. +function emit(kind: UiLineKind, message: string, rendered: string, terminal = INDENT + rendered): void { + if (uiHost) { + const line: UiLine = { kind, message, rendered, stream: 'stdout' }; + rememberForPrompt(line); + uiHost.line(line); + return; + } + console.log(terminal); +} + +// Callers print what a question is about, then ask, in one synchronous run +// ("You have uncommitted files:" + the list, then "Continue anyway?"). With a +// host, keep this run's lines so a prompt called in it can carry them as +// context. The next microtask clears them, so earlier output never leaks in. +const MAX_PROMPT_CONTEXT = 8; +let recentLines: UiLine[] = []; +let recentClearScheduled = false; + +function rememberForPrompt(line: UiLine): void { + if (!line.message.trim()) return; + recentLines.push(line); + if (!recentClearScheduled) { + recentClearScheduled = true; + queueMicrotask(() => { + recentLines = []; + recentClearScheduled = false; + }); + } +} + +/** Claim this run's lines for the prompt being called now. */ +function takePromptContext(): { context?: readonly UiLine[] } { + if (!uiHost || recentLines.length === 0) return {}; + const context = recentLines.slice(-MAX_PROMPT_CONTEXT); + recentLines = []; + return { context }; +} + +/** Print one indented line to stdout. */ function line(text = ''): void { - if (dashboardMode) return; - console.log(INDENT + text); + emit('plain', text, text); } -/** Print a vertical blank line (suppressed in dashboard mode). */ +/** Print a vertical blank line. */ function blank(): void { - if (dashboardMode) return; - console.log(''); + emit('plain', '', '', ''); } // ── Output surface ─────────────────────────────────────────────────────────── @@ -98,16 +179,16 @@ function note(message: string): void { } const log = { - info: (m: string) => line(m), - step: (m: string) => line(`${accent('›')} ${m}`), - success: (m: string) => line(`${green('✓')} ${m}`), - warn: (m: string) => line(`${yellow('!')} ${m}`), - warning: (m: string) => line(`${yellow('!')} ${m}`), - error: (m: string) => line(`${red('✗')} ${m}`), + info: (m: string) => emit('info', m, m), + step: (m: string) => emit('step', m, `${accent('›')} ${m}`), + success: (m: string) => emit('success', m, `${green('✓')} ${m}`), + warn: (m: string) => emit('warn', m, `${yellow('!')} ${m}`), + warning: (m: string) => emit('warn', m, `${yellow('!')} ${m}`), + error: (m: string) => emit('error', m, `${red('✗')} ${m}`), /** A muted, low-priority aside (opt-out hints, "run X later"). One dim line. */ - hint: (m: string) => line(dim(m)), + hint: (m: string) => emit('hint', m, dim(m)), /** A nested sub-step, indented one level under its parent line. */ - detail: (m: string) => line(` ${dim('›')} ${dim(m)}`), + detail: (m: string) => emit('detail', m, ` ${dim('›')} ${dim(m)}`), }; // ── Aligned key/value rows ──────────────────────────────────────────────────── @@ -128,7 +209,7 @@ export interface Row { * property of the whole set, so callers pass every row at once. */ function rows(items: Row[]): void { - if (dashboardMode || items.length === 0) return; + if (items.length === 0) return; const width = Math.max(...items.map((i) => i.key.length)); const paint: Record string> = { ok: green, muted: dim, warn: yellow }; for (const it of items) { @@ -158,12 +239,18 @@ interface PausableSpinner { } let activeSpinner: PausableSpinner | null = null; -/** spinner: start(msg) / message(msg) / stop(msg, code). */ +/** + * spinner: start(msg) / message(msg) / stop(msg, code). + * + * With a UI host, the spinner reports its message through `host.status()` and + * never animates; `stop()` still records its final line. + */ function spinner(): Spinner { let timer: ReturnType | undefined; let frame = 0; let text = ''; - const isTty = Boolean(process.stdout.isTTY) && !dashboardMode; + let hosted = false; + const isTty = Boolean(process.stdout.isTTY); const render = () => { process.stdout.write(`\r${INDENT}${dim(SPINNER_FRAMES[(frame = (frame + 1) % SPINNER_FRAMES.length)])} ${text}`); }; @@ -179,7 +266,11 @@ function spinner(): Spinner { const handle: Spinner & PausableSpinner = { start(message = '') { text = message; - if (dashboardMode) return; + if (uiHost) { + hosted = true; + uiHost.status(text); + return; + } if (isTty) { tick(); activeSpinner = handle; @@ -189,6 +280,7 @@ function spinner(): Spinner { }, message(message: string) { text = message; + if (hosted) uiHost?.status(text); }, stop(message?: string, code = 0) { if (timer) { @@ -196,10 +288,14 @@ function spinner(): Spinner { timer = undefined; } if (activeSpinner === handle) activeSpinner = null; - if (dashboardMode) return; - clearLine(); - const glyph = code === 0 ? green('✓') : red('✗'); - line(`${glyph} ${message ?? text}`); + if (hosted) { + hosted = false; + uiHost?.status(null); + } else { + clearLine(); + } + const final = message ?? text; + emit(code === 0 ? 'success' : 'error', final, `${code === 0 ? green('✓') : red('✗')} ${final}`); }, // Halt + erase without printing a final line (e.g. an orphaned spinner from // a failed step being cleared before a prompt), and deregister so a prompt @@ -210,7 +306,11 @@ function spinner(): Spinner { timer = undefined; } if (activeSpinner === handle) activeSpinner = null; - if (dashboardMode) return; + if (hosted) { + hosted = false; + uiHost?.status(null); + return; + } clearLine(); }, // Pause/resume let a prompt borrow the terminal: pause clears the spinner @@ -226,7 +326,7 @@ function spinner(): Spinner { resume() { // Only resume if this handle is still the active spinner — never resurrect // a spinner that was stopped or cleared while the prompt was open. - if (activeSpinner === handle && !dashboardMode) tick(); + if (activeSpinner === handle) tick(); }, }; return handle; @@ -248,7 +348,10 @@ export function isCancel(value: unknown): value is symbol { /** Print a cancellation line. */ export function cancel(message = 'Cancelled'): void { - if (dashboardMode) return; + if (uiHost) { + uiHost.line({ kind: 'plain', message, rendered: dim(message), stream: 'stderr' }); + return; + } console.error(INDENT + dim(message)); } @@ -288,6 +391,12 @@ export class PromptUnavailableError extends Error { */ let promptChain: Promise = Promise.resolve(); +/** Hand a prompt to the UI host, short-circuiting one whose signal already aborted. */ +async function hostPrompt(host: UiHost, request: UiPromptRequest): Promise { + if (request.signal?.aborted) return CANCEL; + return (await host.prompt(request)) as T | symbol; +} + async function withPrompt(run: () => Promise): Promise { if (isJsonMode()) { throw new PromptUnavailableError( @@ -321,7 +430,9 @@ async function withPrompt(run: () => Promise): Promise { * Adapt the validate contract (return error string / Error when invalid, * undefined when valid) to @inquirer's (return true when valid, string when not). */ -type ValidateFn = (value: string) => string | Error | undefined | void | Promise; +export type ValidateFn = ( + value: string, +) => string | Error | undefined | void | Promise; function adaptValidate(validate?: ValidateFn) { if (!validate) return undefined; return async (value: string): Promise => { @@ -333,13 +444,15 @@ function adaptValidate(validate?: ValidateFn) { // ── Input surface (@inquirer under the hood) ───────────────────── -interface ConfirmOptions { +export interface ConfirmOptions { message: string; initialValue?: boolean; signal?: AbortSignal; } async function confirm(options: ConfirmOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'confirm', ...options, ...context }); const { confirm: inquirerConfirm } = await import('@inquirer/prompts'); try { return await inquirerConfirm( @@ -353,14 +466,14 @@ async function confirm(options: ConfirmOptions): Promise { }); } -interface SelectOption { +export interface SelectOption { value: T; label?: string; hint?: string; /** Not selectable; a string renders as the reason next to the row. */ disabled?: boolean | string; } -interface SelectOptions { +export interface SelectOptions { message: string; options: ReadonlyArray>; initialValue?: T; @@ -368,7 +481,9 @@ interface SelectOptions { signal?: AbortSignal; } async function select(options: SelectOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'select', ...(options as SelectOptions), ...context }); const { select: inquirerSelect } = await import('@inquirer/prompts'); try { return await inquirerSelect( @@ -392,7 +507,7 @@ async function select(options: SelectOptions): Promise { }); } -interface TextOptions { +export interface TextOptions { message: string; placeholder?: string; defaultValue?: string; @@ -401,7 +516,9 @@ interface TextOptions { signal?: AbortSignal; } async function text(options: TextOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'text', ...options, ...context }); // @inquirer/input has no placeholder concept, and mapping it to `default` // would auto-submit the hint as the real value on an empty enter. Fold it // into the message so the hint survives (rendered as ghost text previously). @@ -423,13 +540,15 @@ async function text(options: TextOptions): Promise { }); } -interface PasswordOptions { +export interface PasswordOptions { message: string; validate?: ValidateFn; signal?: AbortSignal; } async function password(options: PasswordOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'password', ...options, ...context }); const { password: inquirerPassword } = await import('@inquirer/prompts'); try { return await inquirerPassword( diff --git a/tests/evals/README.md b/tests/evals/README.md index b8593546..0f7dcb3e 100644 --- a/tests/evals/README.md +++ b/tests/evals/README.md @@ -60,7 +60,6 @@ Use `--no-fail` to run without exit code validation. --no-retry Disable retries --no-fail Don't exit 1 on threshold failure --sequential Run scenarios sequentially (disable parallelism) ---no-dashboard Disable live dashboard, use sequential logging --json Output as JSON --help, -h Show help ``` diff --git a/tests/evals/__tests__/parallel-runner.spec.ts b/tests/evals/__tests__/parallel-runner.spec.ts new file mode 100644 index 00000000..6e514fa3 --- /dev/null +++ b/tests/evals/__tests__/parallel-runner.spec.ts @@ -0,0 +1,21 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { ParallelRunner } from '../parallel-runner.js'; +import { evalEvents } from '../events.js'; + +vi.mock('../agent-executor.js', () => ({ AgentExecutor: vi.fn() })); + +afterEach(() => vi.restoreAllMocks()); + +it('completes a run without scheduling unused dashboard progress updates', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + // Keep the runner's process-wide shutdown handlers out of the test process. + vi.spyOn(process, 'on').mockReturnValue(process); + const interval = vi.spyOn(globalThis, 'setInterval'); + const completed = vi.spyOn(evalEvents, 'emitRunComplete'); + const runner = new ParallelRunner([], { maxAttempts: 1, concurrency: 1 }); + + await expect(runner.run()).resolves.toEqual([]); + + expect(interval).not.toHaveBeenCalled(); + expect(completed).toHaveBeenCalledOnce(); +}); diff --git a/tests/evals/cli.ts b/tests/evals/cli.ts index 122a6f00..5a71af8f 100644 --- a/tests/evals/cli.ts +++ b/tests/evals/cli.ts @@ -10,7 +10,6 @@ export interface CliOptions { retry: number; noRetry: boolean; sequential: boolean; - noDashboard: boolean; noFail: boolean; noCorrection: boolean; quality: boolean; @@ -60,7 +59,6 @@ export function parseArgs(args: string[]): CliOptions { retry: 2, noRetry: false, sequential: false, - noDashboard: false, noFail: false, noCorrection: false, quality: false, @@ -147,8 +145,6 @@ export function parseArgs(args: string[]): CliOptions { options.state = state; } else if (arg === '--sequential') { options.sequential = true; - } else if (arg === '--no-dashboard') { - options.noDashboard = true; } else if (arg === '--no-fail') { options.noFail = true; } else if (arg === '--no-correction') { @@ -200,8 +196,6 @@ Options: --sequential Run scenarios sequentially (disable parallelism) - --no-dashboard Disable live dashboard, use sequential logging - --no-fail Exit 0 even if success criteria thresholds not met --no-correction Disable within-session agent self-correction retries diff --git a/tests/evals/dashboard/EvalDashboard.tsx b/tests/evals/dashboard/EvalDashboard.tsx deleted file mode 100644 index 1847ae45..00000000 --- a/tests/evals/dashboard/EvalDashboard.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Box, Text, useApp } from 'ink'; -import { evalEvents, type ScenarioStartEvent, type ScenarioCompleteEvent, type RunProgressEvent } from '../events.js'; -import { Header } from './Header.js'; -import { ScenarioRow } from './ScenarioRow.js'; - -interface ScenarioStatus { - scenario: string; - framework: string; - state: string; - status: 'pending' | 'running' | 'retrying' | 'passed' | 'failed'; - attempt: number; - duration?: number; - error?: string; - startTime?: number; -} - -interface DashboardProps { - scenarios: Array<{ framework: string; state: string }>; - concurrency: number; -} - -export function EvalDashboard({ scenarios, concurrency }: DashboardProps) { - const { exit } = useApp(); - - const [statuses, setStatuses] = useState>(() => { - const map = new Map(); - for (const s of scenarios) { - const name = `${s.framework}/${s.state}`; - map.set(name, { - scenario: name, - framework: s.framework, - state: s.state, - status: 'pending', - attempt: 0, - }); - } - return map; - }); - - const [progress, setProgress] = useState({ - completed: 0, - total: scenarios.length, - running: 0, - elapsed: 0, - }); - - useEffect(() => { - const onStart = (e: ScenarioStartEvent) => { - setStatuses((prev) => { - const next = new Map(prev); - next.set(e.scenario, { - ...next.get(e.scenario)!, - status: 'running', - attempt: e.attempt, - startTime: Date.now(), - }); - return next; - }); - }; - - const onRetry = (e: ScenarioStartEvent) => { - setStatuses((prev) => { - const next = new Map(prev); - next.set(e.scenario, { - ...next.get(e.scenario)!, - status: 'retrying', - attempt: e.attempt, - startTime: Date.now(), - }); - return next; - }); - }; - - const onComplete = (e: ScenarioCompleteEvent) => { - setStatuses((prev) => { - const next = new Map(prev); - next.set(e.scenario, { - ...next.get(e.scenario)!, - status: e.passed ? 'passed' : 'failed', - duration: e.duration, - error: e.error, - }); - return next; - }); - }; - - const onProgress = (e: RunProgressEvent) => setProgress(e); - const onRunComplete = () => exit(); - - evalEvents.on('scenario:start', onStart); - evalEvents.on('scenario:retry', onRetry); - evalEvents.on('scenario:complete', onComplete); - evalEvents.on('run:progress', onProgress); - evalEvents.on('run:complete', onRunComplete); - - return () => { - evalEvents.off('scenario:start', onStart); - evalEvents.off('scenario:retry', onRetry); - evalEvents.off('scenario:complete', onComplete); - evalEvents.off('run:progress', onProgress); - evalEvents.off('run:complete', onRunComplete); - }; - }, [exit]); - - // Sort: running → retrying → failed → passed → pending - const sorted = Array.from(statuses.values()).sort((a, b) => { - const order = { running: 0, retrying: 1, failed: 2, passed: 3, pending: 4 }; - return order[a.status] - order[b.status]; - }); - - return ( - -
- - {sorted.map((s) => ( - - ))} - - - Press Ctrl+C to cancel - - - ); -} diff --git a/tests/evals/dashboard/Header.tsx b/tests/evals/dashboard/Header.tsx deleted file mode 100644 index f42926d0..00000000 --- a/tests/evals/dashboard/Header.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import type { RunProgressEvent } from '../events.js'; - -interface HeaderProps { - progress: RunProgressEvent; - concurrency: number; -} - -export function Header({ progress, concurrency }: HeaderProps) { - const elapsed = Math.round(progress.elapsed / 1000); - const mins = Math.floor(elapsed / 60); - const secs = elapsed % 60; - const timeStr = `${mins}:${secs.toString().padStart(2, '0')}`; - - return ( - - Evals - - {progress.completed}/{progress.total} - - complete - | - - {progress.running} - running - | - Concurrency: - {concurrency} - | - {timeStr} - - ); -} diff --git a/tests/evals/dashboard/ScenarioRow.tsx b/tests/evals/dashboard/ScenarioRow.tsx deleted file mode 100644 index 25ac774c..00000000 --- a/tests/evals/dashboard/ScenarioRow.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Box, Text } from 'ink'; - -interface ScenarioRowProps { - scenario: string; - status: 'pending' | 'running' | 'retrying' | 'passed' | 'failed'; - attempt: number; - duration?: number; - error?: string; - startTime?: number; -} - -const STATUS_ICONS: Record = { - pending: { icon: '-', color: 'gray' }, - running: { icon: '*', color: 'cyan' }, - retrying: { icon: '~', color: 'yellow' }, - passed: { icon: '+', color: 'green' }, - failed: { icon: 'x', color: 'red' }, -}; - -export function ScenarioRow({ scenario, status, attempt, duration, error, startTime }: ScenarioRowProps) { - const [liveElapsed, setLiveElapsed] = useState(0); - - useEffect(() => { - if ((status === 'running' || status === 'retrying') && startTime) { - const interval = setInterval(() => { - setLiveElapsed(Math.round((Date.now() - startTime) / 1000)); - }, 500); - return () => clearInterval(interval); - } - }, [status, startTime]); - - const { icon, color } = STATUS_ICONS[status]; - const durationStr = duration - ? `${Math.round(duration / 1000)}s` - : status === 'running' || status === 'retrying' - ? `${liveElapsed}s` - : ''; - - return ( - - - [0]['color']}>{icon} - - - [0]['color']}>{scenario} - - - {durationStr} - - {attempt > 1 && (attempt {attempt})} - {error && {error.slice(0, 50)}} - - ); -} diff --git a/tests/evals/dashboard/index.ts b/tests/evals/dashboard/index.ts deleted file mode 100644 index 841e61f6..00000000 --- a/tests/evals/dashboard/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { render } from 'ink'; -import { EvalDashboard } from './EvalDashboard.js'; - -interface RenderOptions { - scenarios: Array<{ framework: string; state: string }>; - concurrency: number; -} - -export function renderDashboard(options: RenderOptions): { unmount: () => void } { - const { unmount } = render(React.createElement(EvalDashboard, options)); - return { unmount }; -} - -export { EvalDashboard }; diff --git a/tests/evals/events.ts b/tests/evals/events.ts index e6dc8937..74d5a450 100644 --- a/tests/evals/events.ts +++ b/tests/evals/events.ts @@ -21,20 +21,12 @@ export interface ScenarioCompleteEvent extends ScenarioEvent { agentOutput?: string; } -export interface RunProgressEvent { - completed: number; - total: number; - running: number; - elapsed: number; -} - export type EvalEventType = | 'scenario:start' | 'scenario:retry' | 'scenario:pass' | 'scenario:fail' | 'scenario:complete' - | 'run:progress' | 'run:complete'; export class EvalEventEmitter extends EventEmitter { @@ -56,10 +48,6 @@ export class EvalEventEmitter extends EventEmitter { this.emit('scenario:complete', event); } - emitProgress(event: RunProgressEvent): void { - this.emit('run:progress', event); - } - emitRunComplete(): void { this.emit('run:complete'); } diff --git a/tests/evals/index.ts b/tests/evals/index.ts index 118f3a2c..56f6d84a 100644 --- a/tests/evals/index.ts +++ b/tests/evals/index.ts @@ -57,7 +57,6 @@ async function main() { keepOnFail: options.keepOnFail, retry: options.retry, sequential: options.sequential, - noDashboard: options.noDashboard, debug: options.debug, noFail: options.noFail, noCorrection: options.noCorrection, diff --git a/tests/evals/parallel-runner.ts b/tests/evals/parallel-runner.ts index 0af00745..2da38472 100644 --- a/tests/evals/parallel-runner.ts +++ b/tests/evals/parallel-runner.ts @@ -45,31 +45,10 @@ export class ParallelRunner { } async run(): Promise { - const startTime = Date.now(); - let completed = 0; const limit = pLimit(this.concurrency); - - // Emit progress updates every 500ms - const progressInterval = setInterval(() => { - evalEvents.emitProgress({ - completed, - total: this.scenarios.length, - running: this.activeFixtures.size, - elapsed: Date.now() - startTime, - }); - }, 500); - - const tasks = this.scenarios.map((scenario) => - limit(async () => { - const result = await this.runScenario(scenario); - completed++; - return result; - }), - ); - + const tasks = this.scenarios.map((scenario) => limit(() => this.runScenario(scenario))); const results = await Promise.allSettled(tasks); - clearInterval(progressInterval); evalEvents.emitRunComplete(); return results.map((result, i) => { diff --git a/tests/evals/runner.ts b/tests/evals/runner.ts index 77b27b83..98bd6243 100644 --- a/tests/evals/runner.ts +++ b/tests/evals/runner.ts @@ -15,7 +15,6 @@ import { KotlinGrader } from './graders/kotlin.grader.js'; import { ElixirGrader } from './graders/elixir.grader.js'; import { saveResults } from './history.js'; import { ParallelRunner } from './parallel-runner.js'; -import { renderDashboard } from './dashboard/index.js'; import { LogWriter } from './log-writer.js'; import { validateResults, type ValidationResult } from './success-criteria.js'; import { captureVersionMetadata } from './versioning.js'; @@ -114,7 +113,6 @@ export interface ExtendedEvalOptions extends EvalOptions { keep?: boolean; keepOnFail?: boolean; retry?: number; - noDashboard?: boolean; debug?: boolean; noFail?: boolean; noCorrection?: boolean; @@ -158,23 +156,8 @@ export async function runEvals(options: ExtendedEvalOptions): Promise void } | null = null; - if (useDashboard) { - dashboard = renderDashboard({ - scenarios: scenarios.map((s) => ({ framework: s.framework, state: s.state })), - concurrency: runner.getConcurrency(), - }); - } - const results = await runner.run(); - if (dashboard) { - dashboard.unmount(); - } - // Quality grading (optional, only for passing scenarios with key files) if (options.quality) { const credentials = loadCredentials(); diff --git a/tsconfig.json b/tsconfig.json index c679a912..6b893e26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,7 @@ "module": "nodenext", "moduleResolution": "nodenext", - // JSX for React/Ink dashboard + // JSX for the full-screen installer (Ink/React) "jsx": "react-jsx", // Output