From 7080631e36da9ec6d2afd86d1a523e3bc503a2b6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 23 Sep 2026 17:21:43 -0500 Subject: [PATCH 01/20] refactor!: remove the Ink dashboard TUI The `--dashboard` installer mode and hidden `workos dashboard` command were unmaintained. Remove them along with the Ink/React components, the DashboardAdapter, the eval harness's Ink live view, and the ink, react, @types/react, and react-devtools-core dependencies. The installer now uses HeadlessAdapter in JSON mode and CLIAdapter otherwise. Evals always use sequential logging (`--no-dashboard` is gone). BREAKING CHANGE: `workos install --dashboard` / `-d` and `workos dashboard` are removed. Use `workos install`. --- CLAUDE.md | 6 +- DEVELOPMENT.md | 24 +- README.md | 5 +- bun.lock | 86 +---- package.json | 4 - scripts/command-smoke.sh | 2 +- src/bin.ts | 29 +- src/dashboard/components/AnimatedLogo.tsx | 31 -- src/dashboard/components/CompletionView.tsx | 74 ----- src/dashboard/components/ConfirmPrompt.tsx | 68 ---- src/dashboard/components/CredentialsForm.tsx | 101 ------ src/dashboard/components/Dashboard.tsx | 162 ---------- src/dashboard/components/DashboardLayout.tsx | 113 ------- src/dashboard/components/DiffPanel.tsx | 232 ------------- src/dashboard/components/InlinePrompt.tsx | 39 --- src/dashboard/components/OutputPanel.tsx | 169 ---------- src/dashboard/components/Panel.tsx | 43 --- src/dashboard/components/TextInput.tsx | 95 ------ src/dashboard/components/WelcomeArt.tsx | 20 -- src/dashboard/hooks/useAnimation.ts | 34 -- src/dashboard/hooks/useKeyboard.ts | 27 -- src/dashboard/hooks/useTerminalSize.ts | 32 -- src/dashboard/index.ts | 47 --- src/dashboard/lib/diff-utils.ts | 323 ------------------- src/dashboard/lib/logo-frames.ts | 119 ------- src/dashboard/lib/welcome-art.ts | 5 - src/dashboard/types.ts | 8 - src/lib/adapters/dashboard-adapter.ts | 145 --------- src/lib/adapters/index.ts | 1 - src/lib/installer-core.events.spec.ts | 142 +------- src/lib/installer-core.spec.ts | 10 - src/lib/installer-core.test-utils.ts | 44 --- src/lib/run-with-core.ts | 9 +- src/run.ts | 1 - src/utils/analytics.spec.ts | 8 +- src/utils/analytics.ts | 4 +- src/utils/command-telemetry.ts | 2 +- src/utils/help-json.spec.ts | 6 - src/utils/help-json.ts | 9 - src/utils/telemetry-types.ts | 2 +- src/utils/types.ts | 7 +- src/utils/ui-utils.spec.ts | 5 - src/utils/ui-utils.ts | 16 +- tests/evals/README.md | 1 - tests/evals/cli.ts | 6 - tests/evals/dashboard/EvalDashboard.tsx | 125 ------- tests/evals/dashboard/Header.tsx | 34 -- tests/evals/dashboard/ScenarioRow.tsx | 55 ---- tests/evals/dashboard/index.ts | 15 - tests/evals/index.ts | 1 - tests/evals/runner.ts | 17 - tsconfig.json | 3 - 52 files changed, 33 insertions(+), 2533 deletions(-) delete mode 100644 src/dashboard/components/AnimatedLogo.tsx delete mode 100644 src/dashboard/components/CompletionView.tsx delete mode 100644 src/dashboard/components/ConfirmPrompt.tsx delete mode 100644 src/dashboard/components/CredentialsForm.tsx delete mode 100644 src/dashboard/components/Dashboard.tsx delete mode 100644 src/dashboard/components/DashboardLayout.tsx delete mode 100644 src/dashboard/components/DiffPanel.tsx delete mode 100644 src/dashboard/components/InlinePrompt.tsx delete mode 100644 src/dashboard/components/OutputPanel.tsx delete mode 100644 src/dashboard/components/Panel.tsx delete mode 100644 src/dashboard/components/TextInput.tsx delete mode 100644 src/dashboard/components/WelcomeArt.tsx delete mode 100644 src/dashboard/hooks/useAnimation.ts delete mode 100644 src/dashboard/hooks/useKeyboard.ts delete mode 100644 src/dashboard/hooks/useTerminalSize.ts delete mode 100644 src/dashboard/index.ts delete mode 100644 src/dashboard/lib/diff-utils.ts delete mode 100644 src/dashboard/lib/logo-frames.ts delete mode 100644 src/dashboard/lib/welcome-art.ts delete mode 100644 src/dashboard/types.ts delete mode 100644 src/lib/adapters/dashboard-adapter.ts delete mode 100644 tests/evals/dashboard/EvalDashboard.tsx delete mode 100644 tests/evals/dashboard/Header.tsx delete mode 100644 tests/evals/dashboard/ScenarioRow.tsx delete mode 100644 tests/evals/dashboard/index.ts diff --git a/CLAUDE.md b/CLAUDE.md index 94f9957c..18d616e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ 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 +- Two adapters (CLI, Headless) subscribe to `InstallerEventEmitter` state machine events; Headless is used in JSON mode, CLI otherwise - `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 +103,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 +111,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`, `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..f1e73699 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 and headless adapters ├── commands/ │ ├── env.ts # workos env (add/remove/switch/list) │ ├── organization.ts # workos organization (create/update/get/list/delete) @@ -29,7 +29,6 @@ src/ │ ├── auth-status.ts # workos auth status │ ├── login.ts # workos auth login │ └── logout.ts # workos auth logout -├── dashboard/ # Ink/React TUI components ├── nextjs/ # Next.js installer agent ├── react/ # React SPA installer agent ├── react-router/ # React Router installer agent @@ -61,7 +60,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 +93,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 @@ -148,22 +146,6 @@ for a non-host platform; the same value must be used for both `generate` and the compile, which `bun run build` (via `scripts/build.ts` + the `prebuild` 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 -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. - ### Updating Integration Instructions The installer prompt in `agent-runner.ts` tells Claude to: @@ -190,7 +172,7 @@ 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` 2. Check logs at `~/.workos/logs/workos-{timestamp}.log` 3. Verify integration works in test app diff --git a/README.md b/README.md index a989799c..f6c939d8 100644 --- a/README.md +++ b/README.md @@ -564,9 +564,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 +712,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..aa963c57 100644 --- a/bun.lock +++ b/bun.lock @@ -17,10 +17,8 @@ "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", - "ink": "^6.8.0", "jsonc-parser": "^3.3.1", "open": "^11.0.0", - "react": "^19.2.4", "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", @@ -32,7 +30,6 @@ "@statelyai/inspect": "^0.7.0", "@types/node": "~22.20.0", "@types/opn": "5.5.0", - "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "^4.0.18", @@ -41,15 +38,12 @@ "oxfmt": "^0.67.0", "oxlint": "^1.50.0", "p-limit": "^7.3.0", - "react-devtools-core": "^7.0.1", "typescript": "^5.9.3", "vitest": "^4.0.18", }, }, }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.211", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.211", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.211" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-JhbLu6o1v2g9fjqkO+LDNPWrE0bgd9UeRQQ41JBGouAgows3KyPPYgU2WU0q7M2onuwQxR5plGDpas01F+oaUA=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.211", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Iwhm4kfcs20LdXffZ2RGRjj+BFdUOrT/JjhGtICjlGlBPlrLkkkAiHtGzqO9K36v5B/kSIHwOw9CM836kYYPHQ=="], @@ -340,8 +334,6 @@ "@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/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -384,8 +376,6 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -396,8 +386,6 @@ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -424,20 +412,12 @@ "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], - - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], - "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=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], - "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -446,8 +426,6 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -458,8 +436,6 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "csv-parse": ["csv-parse@6.2.1", "", {}, "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ=="], "csv-stringify": ["csv-stringify@6.8.1", "", {}, "sha512-tZ6X6TKQyQgCo5OptXcyAbfN1pwmoxEqELPQ7KFazNErx7kiVsDK8o+VYRXhfMl4N9vvOOLXuioquR2MeP847A=="], @@ -492,8 +468,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -502,14 +476,10 @@ "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=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -608,12 +578,8 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -622,12 +588,10 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -716,8 +680,6 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -742,8 +704,6 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "opn": ["opn@6.0.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-I9PKfIZC+e4RXZ/qr1RhgyCnGgYX0UEIlXgWnCOVACIvFgaC9rz6Won7xbdhoHrd8IIhV7YEpHjreNUNkqCGkQ=="], @@ -758,8 +718,6 @@ "partysocket": ["partysocket@0.0.25", "", { "dependencies": { "event-target-shim": "^6.0.2" } }, "sha512-1oCGA65fydX/FgdnsiBh68buOvfxuteoZVSb3Paci2kRp/7lhF0HyA8EDb5X/O6FxId1e+usPTQNRuzFEvkJbQ=="], - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], - "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -790,16 +748,8 @@ "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-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], - - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], @@ -816,8 +766,6 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -830,8 +778,6 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], - "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -842,18 +788,14 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], @@ -862,7 +804,7 @@ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], - "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -872,10 +814,6 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - - "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -896,8 +834,6 @@ "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-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=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -920,8 +856,6 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -944,38 +878,24 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "cli-progress/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "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=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "cli-progress/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "cli-progress/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "cli-progress/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cli-progress/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index 58b40a5b..158abbbd 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,8 @@ "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", - "ink": "^6.8.0", "jsonc-parser": "^3.3.1", "open": "^11.0.0", - "react": "^19.2.4", "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", @@ -49,7 +47,6 @@ "@statelyai/inspect": "^0.7.0", "@types/node": "~22.20.0", "@types/opn": "5.5.0", - "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "^4.0.18", @@ -58,7 +55,6 @@ "oxfmt": "^0.67.0", "oxlint": "^1.50.0", "p-limit": "^7.3.0", - "react-devtools-core": "^7.0.1", "typescript": "^5.9.3", "vitest": "^4.0.18" }, 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/src/bin.ts b/src/bin.ts index e1f10e2b..ffb73f42 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -232,12 +232,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)', @@ -353,7 +347,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 +366,6 @@ async function runCli(): Promise { 'setup', 'debug', 'internal', - 'dashboard', 'emulate', 'dev', 'migrations', @@ -3402,24 +3395,6 @@ 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', @@ -3459,7 +3434,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/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/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.ts b/src/lib/run-with-core.ts index 94869303..42d65188 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -9,7 +9,6 @@ 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 type { InstallerAdapter } from './adapters/types.js'; import type { InstallerOptions } from '../utils/types.js'; import { getInteractionMode, isAgentMode, isCiMode } from '../utils/interaction-mode.js'; @@ -226,7 +225,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 +264,7 @@ 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; + const headlessMode = isJsonMode(); let adapter: InstallerAdapter; if (headlessMode) { @@ -286,8 +283,6 @@ export async function runWithCore(options: InstallerOptions): Promise { ci: augmentedOptions.ci, }, }); - } else if (options.dashboard) { - adapter = new DashboardAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); } else { adapter = new CLIAdapter({ emitter, sendEvent, debug: augmentedOptions.debug }); } @@ -632,7 +627,7 @@ export async function runWithCore(options: InstallerOptions): Promise { } analytics.configureAuthFromAvailableSources(); - const mode = headlessMode ? 'headless' : augmentedOptions.dashboard ? 'tui' : 'cli'; + const mode = headlessMode ? 'headless' : 'cli'; analytics.sessionStart(mode, getVersion()); let installerStatus: 'success' | 'error' | 'cancelled' = 'success'; diff --git a/src/run.ts b/src/run.ts index 5d051a81..ee3c3013 100644 --- a/src/run.ts +++ b/src/run.ts @@ -68,7 +68,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, 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/analytics.ts b/src/utils/analytics.ts index 15a7bb5e..8e14cf42 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -28,7 +28,7 @@ export class Analytics { private sessionId: string; private sessionStartTime: Date; private distinctId?: string; - private mode?: 'cli' | 'tui' | 'headless'; + private mode?: 'cli' | 'headless'; private authMode: AuthMode = 'none'; // Agent metrics tracking @@ -210,7 +210,7 @@ export class Analytics { }; } - sessionStart(mode: 'cli' | 'tui' | 'headless', version: string) { + sessionStart(mode: 'cli' | 'headless', version: string) { if (!this.isEnabled()) return; this.mode = mode; 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..b97952d9 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', diff --git a/src/utils/telemetry-types.ts b/src/utils/telemetry-types.ts index c38e3296..b2bf6555 100644 --- a/src/utils/telemetry-types.ts +++ b/src/utils/telemetry-types.ts @@ -39,7 +39,7 @@ export interface SessionStartEvent extends BaseTelemetryEvent { type: 'session.start'; attributes: { 'installer.version': string; - 'installer.mode': 'cli' | 'tui' | 'headless'; + 'installer.mode': 'cli' | 'headless'; 'workos.user_id'?: string; 'workos.org_id'?: string; } & EnvFingerprint; diff --git a/src/utils/types.ts b/src/utils/types.ts index 28146cbd..2775078b 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -67,12 +67,7 @@ export type InstallerOptions = { redirectUri?: string; /** - * [Experimental] Enable visual dashboard mode - */ - dashboard?: 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/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/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/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/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..291f6e8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,9 +25,6 @@ "module": "nodenext", "moduleResolution": "nodenext", - // JSX for React/Ink dashboard - "jsx": "react-jsx", - // Output "rootDir": "./src", "outDir": "./dist", From e6efffdb63331c85ff6006ef70993d56dbfe57b4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 17:40:43 -0500 Subject: [PATCH 02/20] fix: remove stale dashboard option and eval progress timer --- src/run.ts | 1 - tests/evals/__tests__/parallel-runner.spec.ts | 21 +++++++++++++++++ tests/evals/events.ts | 12 ---------- tests/evals/parallel-runner.ts | 23 +------------------ 4 files changed, 22 insertions(+), 35 deletions(-) create mode 100644 tests/evals/__tests__/parallel-runner.spec.ts diff --git a/src/run.ts b/src/run.ts index ee3c3013..f438b7da 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; 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/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/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) => { From 2727be37bf1fa4dd45d02dd26f5d381c8e856a8b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 07:43:13 -0500 Subject: [PATCH 03/20] chore(deps): add Ink, React, and @inkjs/ui for the full-screen installer Groundwork for the full-screen installer. Nothing imports these yet. react-devtools-core is a build-only devDependency: Ink's reconciler statically imports it behind a DEV gate, and `bun build --compile` must resolve it. --- bun.lock | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 5 +++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index aa963c57..3a1988a2 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", @@ -17,8 +18,10 @@ "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", + "ink": "^6.8.0", "jsonc-parser": "^3.3.1", "open": "^11.0.0", + "react": "^19.2.4", "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", @@ -30,6 +33,7 @@ "@statelyai/inspect": "^0.7.0", "@types/node": "~22.20.0", "@types/opn": "5.5.0", + "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "^4.0.18", @@ -38,12 +42,15 @@ "oxfmt": "^0.67.0", "oxlint": "^1.50.0", "p-limit": "^7.3.0", + "react-devtools-core": "^7.0.1", "typescript": "^5.9.3", "vitest": "^4.0.18", }, }, }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.211", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.211", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.211" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-JhbLu6o1v2g9fjqkO+LDNPWrE0bgd9UeRQQ41JBGouAgows3KyPPYgU2WU0q7M2onuwQxR5plGDpas01F+oaUA=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.211", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Iwhm4kfcs20LdXffZ2RGRjj+BFdUOrT/JjhGtICjlGlBPlrLkkkAiHtGzqO9K36v5B/kSIHwOw9CM836kYYPHQ=="], @@ -116,6 +123,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=="], @@ -334,6 +343,8 @@ "@types/opn": ["@types/opn@5.5.0", "", { "dependencies": { "opn": "*" } }, "sha512-rfEmrSa/x0vArY1aFnVTBAmy6b2I0oNHONL59qR+vnsHfD5xacM40O4PSiVisERq/GmROjmS3Xo/ptp8fx823g=="], + "@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=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -376,6 +387,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -386,6 +399,8 @@ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -412,12 +427,22 @@ "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "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=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -426,6 +451,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -436,6 +463,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "csv-parse": ["csv-parse@6.2.1", "", {}, "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ=="], "csv-stringify": ["csv-stringify@6.8.1", "", {}, "sha512-tZ6X6TKQyQgCo5OptXcyAbfN1pwmoxEqELPQ7KFazNErx7kiVsDK8o+VYRXhfMl4N9vvOOLXuioquR2MeP847A=="], @@ -444,6 +473,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=="], @@ -468,6 +499,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -476,10 +509,14 @@ "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.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -526,6 +563,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=="], @@ -578,8 +617,12 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -588,10 +631,12 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -600,6 +645,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=="], @@ -680,6 +727,8 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -704,6 +753,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "opn": ["opn@6.0.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-I9PKfIZC+e4RXZ/qr1RhgyCnGgYX0UEIlXgWnCOVACIvFgaC9rz6Won7xbdhoHrd8IIhV7YEpHjreNUNkqCGkQ=="], @@ -718,6 +769,8 @@ "partysocket": ["partysocket@0.0.25", "", { "dependencies": { "event-target-shim": "^6.0.2" } }, "sha512-1oCGA65fydX/FgdnsiBh68buOvfxuteoZVSb3Paci2kRp/7lhF0HyA8EDb5X/O6FxId1e+usPTQNRuzFEvkJbQ=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -748,8 +801,16 @@ "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.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=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], @@ -766,6 +827,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -778,6 +841,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -788,14 +853,18 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], @@ -804,7 +873,7 @@ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -814,6 +883,10 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -834,6 +907,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "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=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -856,6 +931,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -878,24 +955,38 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "cli-progress/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "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=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "cli-progress/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "cli-progress/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "cli-progress/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cli-progress/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index 158abbbd..a169d900 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", @@ -34,8 +35,10 @@ "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", + "ink": "^6.8.0", "jsonc-parser": "^3.3.1", "open": "^11.0.0", + "react": "^19.2.4", "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", @@ -47,6 +50,7 @@ "@statelyai/inspect": "^0.7.0", "@types/node": "~22.20.0", "@types/opn": "5.5.0", + "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "^4.0.18", @@ -55,6 +59,7 @@ "oxfmt": "^0.67.0", "oxlint": "^1.50.0", "p-limit": "^7.3.0", + "react-devtools-core": "^7.0.1", "typescript": "^5.9.3", "vitest": "^4.0.18" }, From 8497cc77ee6b131979a46ca0848feef44db15d60 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 08:51:03 -0500 Subject: [PATCH 04/20] feat(tui): add swappable content for the full-screen installer Task labels, walkthrough copy, tips, and announcements live in one bundled, data-only JSON file validated by a zod schema. Walkthrough keys must be real installer events with only the placeholders each event supplies; tips and announcements can target frameworks, and announcements take a date window. Adds a runtime INSTALLER_EVENT_NAMES list that the compiler keeps in sync with InstallerEvents. --- src/lib/events.ts | 93 ++++++++++ src/tui/content/README.md | 51 ++++++ src/tui/content/content.spec.ts | 235 +++++++++++++++++++++++++ src/tui/content/index.ts | 19 ++ src/tui/content/installer-content.json | 170 ++++++++++++++++++ src/tui/content/schema.ts | 194 ++++++++++++++++++++ src/tui/content/select.ts | 75 ++++++++ 7 files changed, 837 insertions(+) create mode 100644 src/tui/content/README.md create mode 100644 src/tui/content/content.spec.ts create mode 100644 src/tui/content/index.ts create mode 100644 src/tui/content/installer-content.json create mode 100644 src/tui/content/schema.ts create mode 100644 src/tui/content/select.ts diff --git a/src/lib/events.ts b/src/lib/events.ts index 1dec90f2..6fe784a4 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -137,6 +137,99 @@ 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, + '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/tui/content/README.md b/src/tui/content/README.md new file mode 100644 index 00000000..4c9cffe1 --- /dev/null +++ b/src/tui/content/README.md @@ -0,0 +1,51 @@ +# 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. + +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..f427ecd6 --- /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, 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..f0b2f640 --- /dev/null +++ b/src/tui/content/installer-content.json @@ -0,0 +1,170 @@ +{ + "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" }, + "finish": { "label": "Wrap up", "activeLabel": "Wrapping up" } + }, + "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: redirect URIs, sign-in settings, and environment variables.", + "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." + }, + "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.", + "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..303e4135 --- /dev/null +++ b/src/tui/content/schema.ts @@ -0,0 +1,194 @@ +/** + * 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', + 'finish', +] as const; +export type TaskId = (typeof TASK_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> = { + 'auth:failure': [], + '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', '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, + ), + 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; +} From d340fcad1fd5cc57ea3fc9a90a6ed7d62f0afd6d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 08:53:18 -0500 Subject: [PATCH 05/20] refactor(ui): replace the dead dashboard flag with a UI host seam setUiHost() routes every ui line (with its log level), spinner status, and confirm/select/text/password prompt to a host such as the full-screen installer. The --json and non-TTY guards and the one-prompt-at-a-time chain still apply in front of the host. With no host registered, output is byte-for-byte unchanged. --- src/utils/ui.spec.ts | 213 ++++++++++++++++++++++++++++++++++++++++--- src/utils/ui.ts | 157 +++++++++++++++++++++++-------- 2 files changed, 317 insertions(+), 53 deletions(-) diff --git a/src/utils/ui.spec.ts b/src/utils/ui.spec.ts index 55575d2e..67120c9e 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,209 @@ 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('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..d75aebd5 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -24,14 +24,51 @@ 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 type UiPromptRequest = + | ({ kind: 'confirm' } & ConfirmOptions) + | ({ kind: 'select' } & SelectOptions) + | ({ kind: 'text' } & TextOptions) + | ({ kind: 'password' } & PasswordOptions); + +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 isDashboardMode(): boolean { - return dashboardMode; + +export function getUiHost(): UiHost | null { + return uiHost; } // Brand palette (shared with summary-box via cli-symbols). chalk auto-disables @@ -51,18 +88,24 @@ 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) { + uiHost.line({ kind, message, rendered, stream: 'stdout' }); + return; + } + console.log(terminal); +} + +/** 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 +141,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 +171,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 +201,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 +228,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 +242,7 @@ function spinner(): Spinner { }, message(message: string) { text = message; + if (hosted) uiHost?.status(text); }, stop(message?: string, code = 0) { if (timer) { @@ -196,10 +250,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 +268,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 +288,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 +310,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 +353,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 +392,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 +406,14 @@ 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 { return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'confirm', ...options }); const { confirm: inquirerConfirm } = await import('@inquirer/prompts'); try { return await inquirerConfirm( @@ -353,14 +427,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; @@ -369,6 +443,7 @@ interface SelectOptions { } async function select(options: SelectOptions): Promise { return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'select', ...(options as SelectOptions) }); const { select: inquirerSelect } = await import('@inquirer/prompts'); try { return await inquirerSelect( @@ -392,7 +467,7 @@ async function select(options: SelectOptions): Promise { }); } -interface TextOptions { +export interface TextOptions { message: string; placeholder?: string; defaultValue?: string; @@ -402,6 +477,7 @@ interface TextOptions { } async function text(options: TextOptions): Promise { return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'text', ...options }); // @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 +499,14 @@ async function text(options: TextOptions): Promise { }); } -interface PasswordOptions { +export interface PasswordOptions { message: string; validate?: ValidateFn; signal?: AbortSignal; } async function password(options: PasswordOptions): Promise { return withPrompt(async () => { + if (uiHost) return hostPrompt(uiHost, { kind: 'password', ...options }); const { password: inquirerPassword } = await import('@inquirer/prompts'); try { return await inquirerPassword( From ca39d1269ac2ada89315d4d678283ba9cf071bbb Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 08:57:16 -0500 Subject: [PATCH 06/20] feat(tui): add the event-driven run model for the full-screen installer A plain subscribe/getSnapshot store over InstallerEventEmitter. It maps machine phases to the task list (settling a task on the next phase's enter, so a failure never flashes "completed"), narrates events with the content copy, and records agent status lines, file changes, and commands. It also holds the pending prompt, spinner status, notices, the outcome, and the tips and announcements for the detected framework. No Ink dependency; tests drive the real installer machine. --- src/tui/content/schema.ts | 1 - src/tui/model/run-model.spec.ts | 456 ++++++++++++++++++++++++++++++++ src/tui/model/run-model.ts | 368 ++++++++++++++++++++++++++ 3 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 src/tui/model/run-model.spec.ts create mode 100644 src/tui/model/run-model.ts diff --git a/src/tui/content/schema.ts b/src/tui/content/schema.ts index 303e4135..ce5ac445 100644 --- a/src/tui/content/schema.ts +++ b/src/tui/content/schema.ts @@ -32,7 +32,6 @@ export type TaskId = (typeof TASK_IDS)[number]; * placeholder. */ export const WALKTHROUGH_PARAMS: Partial> = { - 'auth:failure': [], 'scaffold:start': ['packageManager'], 'detection:complete': ['framework'], 'git:dirty': ['count'], diff --git a/src/tui/model/run-model.spec.ts b/src/tui/model/run-model.spec.ts new file mode 100644 index 00000000..ac4d7ff0 --- /dev/null +++ b/src/tui/model/run-model.spec.ts @@ -0,0 +1,456 @@ +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 { 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, +}; + +/** 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', + }); + 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', + }); + 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); + + expect(statuses(final)).toMatchObject({ install: 'completed', verify: 'completed' }); + expect(final.tasks.map((t) => t.id)).toEqual([ + 'sign-in', + 'inspect', + 'credentials', + 'configure', + 'install', + 'verify', + 'finish', + ]); + const issue = final.walkthrough.find((e) => e.text.includes('issues: 2')); + expect(issue?.tone).toBe('warning'); + expectMonotonic(run.history); + }); + + 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' }); + 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.', + ]); + }); +}); diff --git a/src/tui/model/run-model.ts b/src/tui/model/run-model.ts new file mode 100644 index 00000000..d957b428 --- /dev/null +++ b/src/tui/model/run-model.ts @@ -0,0 +1,368 @@ +/** + * 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 } from '../../lib/events.js'; +import type { UiPromptRequest } from '../../utils/ui.js'; +import { + TASK_IDS, + frameworkName, + selectAnnouncements, + selectTips, + walkthroughText, + type Announcement, + type InstallerContent, + type TaskId, + type Tip, +} from '../content/index.js'; + +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'skipped' | 'failed' | 'cancelled'; + +export interface TaskView { + id: TaskId; + label: string; + activeLabel?: string; + status: TaskStatus; +} + +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']); + +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; + + 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) => ({ + id, + label: content.tasks[id].label, + activeLabel: content.tasks[id].activeLabel, + status: status.get(id)!, + })), + walkthrough, + status: spinnerStatus, + prompt, + integration, + framework: integration ? frameworkName(content, integration) : undefined, + outcome, + tips: selectTips(content, query), + announcements: selectAnnouncements(content, query), + }; + } + + 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'; + return; + } + if (state === 'complete') { + for (const id of TASK_IDS) { + const s = status.get(id); + 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 runs inside the agent phase, so it ends when that phase does. + const active = task === 'install' && status.get('verify') === 'in_progress' ? 'verify' : 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, + + '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', 'completed'); + 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'; + narrate( + 'complete', + undefined, + outcome, + outcome === 'success' ? 'success' : outcome === 'cancelled' ? 'warning' : 'error', + ); + }, + }; + + 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(); + }, + }; +} From e4fed49b0bcc80c7fcc3d718e17d6a398252dbc8 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 09:21:23 -0500 Subject: [PATCH 07/20] feat(install): add the full-screen installer In an interactive terminal of at least 80x24, `workos install` now opens a full-screen Ink view: the WorkOS logomark (Arc's raster, precomputed), a task list driven by installer events, a plain-English walkthrough, a rotating tips and news card, and every question asked inline. When it closes, the terminal is restored and the same output a plain run prints is replayed into the scrollback, ending with the completion summary. TuiAdapter wraps CLIAdapter, so every event is still handled and every prompt answered by the same code; it only redirects ui output and prompts through setUiHost() and captures stray console output. ctrl-c goes down the existing SIGINT path, esc/ctrl-c in a prompt cancel it as in the plain CLI, and an exit hook restores the terminal on any process.exit. Adapter selection is a pure, tested function. JSON stays headless; CI/agent modes, piped stdio, TERM=dumb, small terminals, and --no-tui keep the plain CLI adapter. Telemetry reports installer.mode 'tui'. Also drops a leftover `dashboard` arg type and stops the adapter if the environment picker throws. --- CLAUDE.md | 5 +- DEVELOPMENT.md | 30 ++- README.md | 8 + src/bin.ts | 12 +- src/lib/adapters/select-adapter.spec.ts | 41 +++ src/lib/adapters/select-adapter.ts | 37 +++ src/lib/adapters/tui-adapter.spec.ts | 197 +++++++++++++++ src/lib/adapters/tui-adapter.ts | 226 +++++++++++++++++ src/lib/run-with-core.spec.ts | 56 ++++- src/lib/run-with-core.ts | 41 ++- src/run.ts | 3 + src/tui/App.tsx | 112 +++++++++ src/tui/app.spec.ts | 317 ++++++++++++++++++++++++ src/tui/components/Header.tsx | 88 +++++++ src/tui/components/PromptPanel.tsx | 208 ++++++++++++++++ src/tui/components/StatusBar.tsx | 37 +++ src/tui/components/TaskList.tsx | 76 ++++++ src/tui/components/TipsCard.tsx | 76 ++++++ src/tui/components/Walkthrough.tsx | 51 ++++ src/tui/hooks.ts | 33 +++ src/tui/ink-streams.test-utils.ts | 109 ++++++++ src/tui/logo.ts | 63 +++++ src/tui/terminal.ts | 42 ++++ src/tui/theme.ts | 31 +++ src/tui/wrap.ts | 62 +++++ src/utils/analytics.ts | 4 +- src/utils/help-json.ts | 8 + src/utils/telemetry-types.ts | 2 +- src/utils/types.ts | 5 + tsconfig.json | 3 + 30 files changed, 1971 insertions(+), 12 deletions(-) create mode 100644 src/lib/adapters/select-adapter.spec.ts create mode 100644 src/lib/adapters/select-adapter.ts create mode 100644 src/lib/adapters/tui-adapter.spec.ts create mode 100644 src/lib/adapters/tui-adapter.ts create mode 100644 src/tui/App.tsx create mode 100644 src/tui/app.spec.ts create mode 100644 src/tui/components/Header.tsx create mode 100644 src/tui/components/PromptPanel.tsx create mode 100644 src/tui/components/StatusBar.tsx create mode 100644 src/tui/components/TaskList.tsx create mode 100644 src/tui/components/TipsCard.tsx create mode 100644 src/tui/components/Walkthrough.tsx create mode 100644 src/tui/hooks.ts create mode 100644 src/tui/ink-streams.test-utils.ts create mode 100644 src/tui/logo.ts create mode 100644 src/tui/terminal.ts create mode 100644 src/tui/theme.ts create mode 100644 src/tui/wrap.ts diff --git a/CLAUDE.md b/CLAUDE.md index 18d616e9..3888921b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or ## Architecture -- Two adapters (CLI, Headless) subscribe to `InstallerEventEmitter` state machine events; Headless is used in JSON mode, CLI otherwise +- 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 @@ -111,7 +112,7 @@ All commands automatically emit a `command` telemetry event with name, duration, **Do:** -- Follow the adapter pattern (`CLI`, `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 f1e73699..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 headless 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,6 +29,9 @@ src/ │ ├── auth-status.ts # workos auth status │ ├── login.ts # workos auth login │ └── logout.ts # workos auth logout +├── 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 @@ -146,6 +149,19 @@ for a non-host platform; the same value must be used for both `generate` and the compile, which `bun run build` (via `scripts/build.ts` + the `prebuild` 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 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"). 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 The installer prompt in `agent-runner.ts` tells Claude to: @@ -172,10 +188,20 @@ export function redactCredentials(obj: any): any { **Manual testing:** -1. Run installer in a test app: `workos install` +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 f6c939d8..9b1ad1ca 100644 --- a/README.md +++ b/README.md @@ -550,9 +550,17 @@ 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 task list, a plain-English +walkthrough of what it's doing, and tips and WorkOS news, with every question +asked inline. When it closes, the same summary a plain run prints stays in your +scrollback. 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 diff --git a/src/bin.ts b/src/bin.ts index ffb73f42..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', @@ -268,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) @@ -3400,7 +3410,7 @@ async function runCli(): Promise { '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. diff --git a/src/lib/adapters/select-adapter.spec.ts b/src/lib/adapters/select-adapter.spec.ts new file mode 100644 index 00000000..835fe063 --- /dev/null +++ b/src/lib/adapters/select-adapter.spec.ts @@ -0,0 +1,41 @@ +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, + 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 }], + ['--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..986f9764 --- /dev/null +++ b/src/lib/adapters/select-adapter.ts @@ -0,0 +1,37 @@ +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; + 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) 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..089379a3 --- /dev/null +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -0,0 +1,197 @@ +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'; + +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)'); + 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('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('sends ctrl-c down the existing SIGINT → CANCEL path', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + // Stands in for runWithCore's SIGINT handler, which sends CANCEL to the machine. + const runWithCoreSigint = () => sendEvent({ type: 'CANCEL' }); + process.on('SIGINT', runWithCoreSigint); + try { + await adapter.start(); + await waitFor(() => expect(frame()).toContain('ctrl-c cancel')); + stdin.press(KEY.ctrlC); + await waitFor(() => expect(exit).toHaveBeenCalledWith(0)); + expect(sendEvent).toHaveBeenCalledWith({ type: 'CANCEL' }); + } finally { + process.off('SIGINT', runWithCoreSigint); + } + await adapter.stop(); + // The plain CLI's cancel message survives into the scrollback. + expect(afterExit()).toContain('Installer cancelled'); + }); + + 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'); + expect(getUiHost()).toBeNull(); + expect(console.log).toBe(originalLog); + expect(stdin.rawMode).toBe(false); + }); + + 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('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..6cb2c833 --- /dev/null +++ b/src/lib/adapters/tui-adapter.ts @@ -0,0 +1,226 @@ +/** + * 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. + * + * Nothing the plain CLI would have printed is lost: it's collected while the + * full screen is up and printed to the normal screen when it closes, so the + * scrollback ends with the same summary a plain run leaves. + */ + +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'; + +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 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; + + 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); + // Covers every way out that skips stop(): process.exit() from a handler, + // the plain CLI's SIGINT handler, an uncaught error. + process.on('exit', this.teardown); + + 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 brand mark and handlers land inside the full screen. + await this.cli.start(); + } 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); + + // 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.model?.dispose(); + this.model = null; + + const transcript = this.transcript.splice(0); + if (transcript.length) writeNow(this.stdout, `${transcript.join('\n')}\n`); + }; + + // ── UI host ───────────────────────────────────────────────────────────── + + private readonly host: UiHost = { + line: (line: UiLine) => { + this.transcript.push(line.rendered ? INDENT + line.rendered : ''); + // 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); + + 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.transcript.push(format(...args)); + }; + } + } + + 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/run-with-core.spec.ts b/src/lib/run-with-core.spec.ts index ceaffa51..3057038e 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,55 @@ 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; 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.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'); + }); + + 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 42d65188..b349aaf2 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -9,6 +9,7 @@ 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 { 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'; @@ -217,6 +218,21 @@ export async function configureInstallEnvironment( }); } +/** 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), + columns: process.stdout.columns ?? 0, + rows: process.stdout.rows ?? 0, + noTui: Boolean(options.noTui), + term: process.env.TERM, + }); +} + export async function runWithCore(options: InstallerOptions): Promise { // Initialize debug/logging early so we capture all failures initLogFile(); @@ -264,7 +280,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. - const headlessMode = isJsonMode(); + // 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) { @@ -283,6 +302,15 @@ export async function runWithCore(options: InstallerOptions): Promise { ci: augmentedOptions.ci, }, }); + } 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 }); } @@ -623,11 +651,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' : 'cli'; + const mode = adapterKind; analytics.sessionStart(mode, getVersion()); let installerStatus: 'success' | 'error' | 'cancelled' = 'success'; diff --git a/src/run.ts b/src/run.ts index f438b7da..e2729626 100644 --- a/src/run.ts +++ b/src/run.ts @@ -31,6 +31,8 @@ export type InstallerArgs = { noGitCheck?: boolean; gitCheck?: boolean; direct?: boolean; + tui?: boolean; + noTui?: boolean; scaffold?: boolean; pm?: string; router?: 'app'; @@ -74,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..337db944 --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,112 @@ +/** + * 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 } from './theme.js'; +import { COMPACT_HEADER_ROWS, FULL_HEADER_ROWS, Header } from './components/Header.js'; +import { 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; + +// 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 }, + ); + + const width = Math.max(20, columns - 2); + const height = Math.max(1, rows - 1); // one short of the screen keeps Ink's redraw incremental + const compact = rows < FULL_LOGO_MIN_ROWS; + const headerRows = compact ? COMPACT_HEADER_ROWS : FULL_HEADER_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, height - headerRows - MIN_BODY_ROWS - 6); + 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 promptRows = snapshot.prompt ? promptHeight(snapshot.prompt, width, maxOptions) + 1 : 0; + const bodyRows = Math.max(MIN_BODY_ROWS, height - headerRows - 1 - promptRows - 1); + const taskWidth = columns >= 110 ? 34 : 30; + const walkthroughWidth = width - taskWidth - 2; + + return ( + +
+ + + t.status !== 'skipped')} width={taskWidth} height={bodyRows} /> + + + + {prompt ? : null} + {prompt} + + + + ); +} diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts new file mode 100644 index 00000000..e25ff768 --- /dev/null +++ b/src/tui/app.spec.ts @@ -0,0 +1,317 @@ +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 './logo.js'; +import { FakeStdin, FakeStdout, KEY, 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); + } + + // Tasks: done, in progress (active label), and pending. + 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).toContain('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); + }); +}); + +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_...' }, + ]; + + 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.at(-1)).toMatch(/esc cancel/); + 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('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('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')); + 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)}`)); + } + stdin.press('client_123'); + await waitFor(() => expect(frameAt(stdout)).toContain('› client_123')); + // Typing clears the error. + expect(frameAt(stdout)).not.toContain('should start with'); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith('client_123')); + }); + + 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'); + stdin.press(KEY.enter); + await waitFor(() => expect(answer).toHaveBeenCalledWith('sk_secret')); + }); + + 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..b044c3da --- /dev/null +++ b/src/tui/components/Header.tsx @@ -0,0 +1,88 @@ +import { Box, Text } from 'ink'; +import { compactLogoRows, logoRows, LOGO_WIDTH } from '../logo.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..2d0edbcd --- /dev/null +++ b/src/tui/components/PromptPanel.tsx @@ -0,0 +1,208 @@ +import { useCallback, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { ConfirmInput, PasswordInput, TextInput } from '@inkjs/ui'; +import { CANCEL, type SelectOption, 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, the wrapped question, then its input. */ +export function promptHeight(request: UiPromptRequest, width: number, maxOptions = DEFAULT_VISIBLE_OPTIONS): number { + const rule = 1; + 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); + 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 = (focus + step * i + options.length) % options.length; + if (selectable(options[next])) { + setFocus(next); + return; + } + } + }; + + useInput((input, key) => { + if (key.upArrow || input === 'k') move(-1); + else if (key.downArrow || input === 'j') move(1); + else if (key.return && selectable(options[focus])) answer(options[focus].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} + + ); + })} + + ); +} + +function TextPrompt({ + request, + answer, + width, +}: { + request: UiPromptRequest & { kind: 'text' | 'password' }; + answer: Answer; + width: number; +}) { + const [error, setError] = useState(null); + // Must be stable: @inkjs/ui calls onChange from an effect keyed on its + // identity, so a fresh function each render would clear the error it just set. + 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; + const verdict = await (request.validate as ValidateFn | undefined)?.(value); + if (verdict != null) { + setError(verdict instanceof Error ? verdict.message : String(verdict)); + return; + } + answer(value); + }; + + return ( + + + + {`${glyphs.pointer} `} + {request.kind === 'password' ? ( + + ) : ( + + )} + + + {error ? `${glyphs.failed} ${error}` : ' '} + + + ); +} + +interface PromptPanelProps { + request: UiPromptRequest; + answer: Answer; + width: number; + /** Most option rows a select may show in the space available. */ + maxOptions?: number; +} + +export function PromptPanel({ request, answer, width, maxOptions = DEFAULT_VISIBLE_OPTIONS }: 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))} + {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..8d4835ca --- /dev/null +++ b/src/tui/components/TaskList.tsx @@ -0,0 +1,76 @@ +import { Box, Text } from 'ink'; +import type { TaskView } from '../model/run-model.js'; +import { colors, glyphs } from '../theme.js'; +import { useSpinner } from '../hooks.js'; + +function TaskRow({ task, spinner }: { task: TaskView; spinner: string }) { + const label = task.status === 'in_progress' && task.activeLabel ? task.activeLabel : task.label; + switch (task.status) { + case 'completed': + return ( + + {glyphs.done} {label} + + ); + case 'in_progress': + return ( + + {spinner} {label} + + ); + case 'failed': + return ( + + {glyphs.failed} {label} + + ); + case 'cancelled': + return ( + + {glyphs.cancelled} {label} + + ); + case 'skipped': + return ( + + {glyphs.skipped} {label} + + ); + default: + return ( + + {glyphs.pending} {label} + + ); + } +} + +/** + * The task list, fitted to `height`: the heading and counter go first when + * space is short, then it shows a window of tasks around the current one. + */ +export function TaskList({ tasks, width, height }: { tasks: TaskView[]; width: number; height: number }) { + const running = tasks.some((t) => t.status === 'in_progress'); + const spinner = useSpinner(running); + const counted = tasks.filter((t) => t.status !== 'skipped'); + const done = counted.filter((t) => t.status === 'completed').length; + + const showTitle = height >= tasks.length + 2; + const showCount = height >= tasks.length + (showTitle ? 1 : 0) + 1; + const rows = Math.max(1, height - (showTitle ? 1 : 0) - (showCount ? 1 : 0)); + const current = Math.max( + 0, + tasks.findIndex((t) => t.status === 'in_progress' || t.status === 'pending'), + ); + const start = Math.min(Math.max(0, current - 1), Math.max(0, tasks.length - rows)); + + return ( + + {showTitle ? Tasks : null} + {tasks.slice(start, start + rows).map((task) => ( + + ))} + {showCount ? {`${done} of ${counted.length} done`} : null} + + ); +} 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/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..8dd01eb4 --- /dev/null +++ b/src/tui/ink-streams.test-utils.ts @@ -0,0 +1,109 @@ +/** + * 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; + +/** 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/logo.ts b/src/tui/logo.ts new file mode 100644 index 00000000..8d8193c1 --- /dev/null +++ b/src/tui/logo.ts @@ -0,0 +1,63 @@ +/** + * The WorkOS logomark as terminal cells, matching Arc's startup header. + * + * 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/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..1b05b365 --- /dev/null +++ b/src/tui/theme.ts @@ -0,0 +1,31 @@ +/** 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: '◆', +} 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; 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.ts b/src/utils/analytics.ts index 8e14cf42..15a7bb5e 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -28,7 +28,7 @@ export class Analytics { private sessionId: string; private sessionStartTime: Date; private distinctId?: string; - private mode?: 'cli' | 'headless'; + private mode?: 'cli' | 'tui' | 'headless'; private authMode: AuthMode = 'none'; // Agent metrics tracking @@ -210,7 +210,7 @@ export class Analytics { }; } - sessionStart(mode: 'cli' | 'headless', version: string) { + sessionStart(mode: 'cli' | 'tui' | 'headless', version: string) { if (!this.isEnabled()) return; this.mode = mode; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b97952d9..39ca3400 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -2141,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/telemetry-types.ts b/src/utils/telemetry-types.ts index b2bf6555..c38e3296 100644 --- a/src/utils/telemetry-types.ts +++ b/src/utils/telemetry-types.ts @@ -39,7 +39,7 @@ export interface SessionStartEvent extends BaseTelemetryEvent { type: 'session.start'; attributes: { 'installer.version': string; - 'installer.mode': 'cli' | 'headless'; + 'installer.mode': 'cli' | 'tui' | 'headless'; 'workos.user_id'?: string; 'workos.org_id'?: string; } & EnvFingerprint; diff --git a/src/utils/types.ts b/src/utils/types.ts index 2775078b..be36754f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -66,6 +66,11 @@ export type InstallerOptions = { */ redirectUri?: string; + /** + * Use the plain CLI output instead of the full-screen installer (--no-tui). + */ + noTui?: boolean; + /** * Event emitter for installer state machine events */ diff --git a/tsconfig.json b/tsconfig.json index 291f6e8d..6b893e26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,9 @@ "module": "nodenext", "moduleResolution": "nodenext", + // JSX for the full-screen installer (Ink/React) + "jsx": "react-jsx", + // Output "rootDir": "./src", "outDir": "./dist", From f218e6355d14eaa328f459c6873a4db96aa1d89b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 10:41:24 -0500 Subject: [PATCH 08/20] fix(install): show a question's context in the full-screen installer and add a top margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers print what a question is about and then ask ("You have uncommitted or untracked files:" + the list, then "Continue anyway?"). With a UI host, ui.ts now attaches the lines printed in the same synchronous run to the prompt as `context`, and the full-screen view shows them right above the question. The list collapses to "… N more" when the terminal is short, so the question and its answer line always stay on screen. The view also leaves a blank row at the top, matching the blank row at the bottom and the one-column side margins. --- src/lib/adapters/tui-adapter.spec.ts | 7 +++ src/tui/App.tsx | 25 +++++++--- src/tui/app.spec.ts | 74 +++++++++++++++++++++++++++- src/tui/components/PromptPanel.tsx | 43 ++++++++++++++-- src/tui/ink-streams.test-utils.ts | 9 ++++ src/utils/ui.spec.ts | 58 ++++++++++++++++++++++ src/utils/ui.ts | 56 ++++++++++++++++++--- 7 files changed, 253 insertions(+), 19 deletions(-) diff --git a/src/lib/adapters/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index 089379a3..1110b0e2 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -75,6 +75,13 @@ describe('TuiAdapter', () => { 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?')); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 337db944..07c8eb72 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -28,6 +28,9 @@ export interface InstallerAppProps { /** 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(); @@ -58,13 +61,22 @@ export function InstallerApp({ model, answer, interrupt, projectName, tipInterva { 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); // one short of the screen keeps Ink's redraw incremental + 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, height - headerRows - MIN_BODY_ROWS - 6); + 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 @@ -89,13 +102,13 @@ export function InstallerApp({ model, answer, interrupt, projectName, tipInterva ); } - const promptRows = snapshot.prompt ? promptHeight(snapshot.prompt, width, maxOptions) + 1 : 0; - const bodyRows = Math.max(MIN_BODY_ROWS, height - headerRows - 1 - promptRows - 1); + const promptRows = snapshot.prompt ? promptHeight(snapshot.prompt, width, maxOptions, maxContext) + 1 : 0; + const bodyRows = Math.max(minBody, inner - headerRows - 1 - promptRows - 1); const taskWidth = columns >= 110 ? 34 : 30; const walkthroughWidth = width - taskWidth - 2; return ( - +
diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts index e25ff768..6a017125 100644 --- a/src/tui/app.spec.ts +++ b/src/tui/app.spec.ts @@ -8,7 +8,7 @@ import { InstallerApp } from './App.js'; import { loadInstallerContent } from './content/index.js'; import { createRunModel } from './model/run-model.js'; import { compactLogoRows, LOGO_MASK } from './logo.js'; -import { FakeStdin, FakeStdout, KEY, stripAnsi, waitFor } from './ink-streams.test-utils.js'; +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'; @@ -115,6 +115,14 @@ describe.each([ // 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); + } }); }); @@ -130,6 +138,26 @@ describe.each([ 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))( @@ -141,7 +169,16 @@ describe.each([ 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!); @@ -161,6 +198,37 @@ describe('full-screen installer interaction', () => { 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 }); @@ -227,6 +295,7 @@ describe('full-screen installer interaction', () => { // 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(); @@ -234,9 +303,11 @@ describe('full-screen installer interaction', () => { 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); @@ -251,6 +322,7 @@ describe('full-screen installer interaction', () => { 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')); }); diff --git a/src/tui/components/PromptPanel.tsx b/src/tui/components/PromptPanel.tsx index 2d0edbcd..05be9590 100644 --- a/src/tui/components/PromptPanel.tsx +++ b/src/tui/components/PromptPanel.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { ConfirmInput, PasswordInput, TextInput } from '@inkjs/ui'; -import { CANCEL, type SelectOption, type UiPromptRequest, type ValidateFn } from '../../utils/ui.js'; +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'; @@ -10,9 +10,14 @@ const DEFAULT_VISIBLE_OPTIONS = 7; type Answer = (value: unknown) => void; -/** Rows the panel draws: a rule, the wrapped question, then its input. */ -export function promptHeight(request: UiPromptRequest, width: number, maxOptions = DEFAULT_VISIBLE_OPTIONS): number { - const rule = 1; +/** 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': @@ -172,15 +177,41 @@ function TextPrompt({ ); } +/** 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 }: PromptPanelProps) { +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) => { @@ -202,6 +233,8 @@ export function PromptPanel({ request, answer, width, maxOptions = DEFAULT_VISIB 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/ink-streams.test-utils.ts b/src/tui/ink-streams.test-utils.ts index 8dd01eb4..f7d0aefb 100644 --- a/src/tui/ink-streams.test-utils.ts +++ b/src/tui/ink-streams.test-utils.ts @@ -92,6 +92,15 @@ export const KEY = { 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; diff --git a/src/utils/ui.spec.ts b/src/utils/ui.spec.ts index 67120c9e..cbd2bbe1 100644 --- a/src/utils/ui.spec.ts +++ b/src/utils/ui.spec.ts @@ -394,6 +394,64 @@ describe('UI host', () => { 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); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index d75aebd5..0b520c55 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -41,11 +41,19 @@ export interface UiLine { stream: 'stdout' | 'stderr'; } -export type UiPromptRequest = +export type UiPromptRequest = ( | ({ kind: 'confirm' } & ConfirmOptions) | ({ kind: 'select' } & SelectOptions) | ({ kind: 'text' } & TextOptions) - | ({ kind: 'password' } & PasswordOptions); + | ({ 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. */ @@ -92,12 +100,42 @@ const INDENT = ' '; // 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) { - uiHost.line({ kind, message, rendered, stream: 'stdout' }); + 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 { emit('plain', text, text); @@ -412,8 +450,9 @@ export interface ConfirmOptions { signal?: AbortSignal; } async function confirm(options: ConfirmOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { - if (uiHost) return hostPrompt(uiHost, { kind: 'confirm', ...options }); + if (uiHost) return hostPrompt(uiHost, { kind: 'confirm', ...options, ...context }); const { confirm: inquirerConfirm } = await import('@inquirer/prompts'); try { return await inquirerConfirm( @@ -442,8 +481,9 @@ export 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) }); + if (uiHost) return hostPrompt(uiHost, { kind: 'select', ...(options as SelectOptions), ...context }); const { select: inquirerSelect } = await import('@inquirer/prompts'); try { return await inquirerSelect( @@ -476,8 +516,9 @@ export interface TextOptions { signal?: AbortSignal; } async function text(options: TextOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { - if (uiHost) return hostPrompt(uiHost, { kind: 'text', ...options }); + 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). @@ -505,8 +546,9 @@ export interface PasswordOptions { signal?: AbortSignal; } async function password(options: PasswordOptions): Promise { + const context = takePromptContext(); return withPrompt(async () => { - if (uiHost) return hostPrompt(uiHost, { kind: 'password', ...options }); + if (uiHost) return hostPrompt(uiHost, { kind: 'password', ...options, ...context }); const { password: inquirerPassword } = await import('@inquirer/prompts'); try { return await inquirerPassword( From e60d6fac49fa54ff782eb52e475f28d8a89a9266 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 10:51:30 -0500 Subject: [PATCH 09/20] feat(cli): replace the lock mascot with the WorkOS logomark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI drew two brand marks: the full-screen installer uses the WorkOS logomark, while the plain CLI (and the scrollback replayed after the full screen) used a lock mascot whose face showed the outcome. - The install opener draws the compact logomark in WorkOS indigo beside the wordmark, or one plain line without Unicode. - The install summary and `workos doctor`'s summary box lead with a title line that carries the outcome (✔ green, ! yellow, ✗ red). An error detail under a failed install's title reads "›" instead of repeating the ✗. - After the full-screen view closes, the replay skips the opener: you just saw the logo, so the scrollback starts with what happened. The logomark raster moves to src/utils/logomark.ts so both surfaces share it; lock-art.ts is gone. --- src/doctor/output.ts | 13 +-- src/lib/adapters/tui-adapter.spec.ts | 4 + src/lib/adapters/tui-adapter.ts | 6 +- src/tui/app.spec.ts | 2 +- src/tui/components/Header.tsx | 2 +- src/utils/lock-art.spec.ts | 57 ----------- src/utils/lock-art.ts | 79 --------------- src/{tui/logo.ts => utils/logomark.ts} | 4 +- src/utils/summary-box.spec.ts | 82 ++++++++++------ src/utils/summary-box.ts | 127 ++++++++++++------------- 10 files changed, 134 insertions(+), 242 deletions(-) delete mode 100644 src/utils/lock-art.spec.ts delete mode 100644 src/utils/lock-art.ts rename src/{tui/logo.ts => utils/logomark.ts} (93%) 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/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index 1110b0e2..0ab94f7f 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -156,6 +156,10 @@ describe('TuiAdapter', () => { 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); diff --git a/src/lib/adapters/tui-adapter.ts b/src/lib/adapters/tui-adapter.ts index 6cb2c833..1496bf45 100644 --- a/src/lib/adapters/tui-adapter.ts +++ b/src/lib/adapters/tui-adapter.ts @@ -126,8 +126,12 @@ export class TuiAdapter implements InstallerAdapter { }, ); - // Last, so its brand mark and handlers land inside the full screen. + // 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(); diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts index 6a017125..d795b9ab 100644 --- a/src/tui/app.spec.ts +++ b/src/tui/app.spec.ts @@ -7,7 +7,7 @@ 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 './logo.js'; +import { compactLogoRows, LOGO_MASK } from '../utils/logomark.js'; import { FakeStdin, FakeStdout, KEY, settle, stripAnsi, waitFor } from './ink-streams.test-utils.js'; const content = loadInstallerContent(); diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx index b044c3da..cc1efd4e 100644 --- a/src/tui/components/Header.tsx +++ b/src/tui/components/Header.tsx @@ -1,5 +1,5 @@ import { Box, Text } from 'ink'; -import { compactLogoRows, logoRows, LOGO_WIDTH } from '../logo.js'; +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'; 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/tui/logo.ts b/src/utils/logomark.ts similarity index 93% rename from src/tui/logo.ts rename to src/utils/logomark.ts index 8d8193c1..ebe97589 100644 --- a/src/tui/logo.ts +++ b/src/utils/logomark.ts @@ -1,5 +1,7 @@ /** - * The WorkOS logomark as terminal cells, matching Arc's startup header. + * 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 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) { From 06ac98102c0c04e6d8bbbd8a54da3d60dbf1d3f9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 13:59:18 -0500 Subject: [PATCH 10/20] feat(install): report each dashboard checklist item as it's set The full-screen installer can now tick off the dashboard's AuthKit checklist as the installer works through it, instead of one "Configure WorkOS" row. - `config:step`: the configure step (before the agent) reports "Add environment variables", and for React Router and TanStack Start the redirect URI and CORS origin as each REST write settles; without an API key those two are reported as not set, with the reason. - `app-urls:step`: for Next.js, the redirect, initiate login, and sign-out URIs that #244 sets on the AuthKit application once the agent's routes exist. `reportAppUrlSetup` wraps `configureAuthkitApplication` without changing it: a throw still fails the install; an unverified result flags both URLs with its reason. Plain and JSON output are unchanged. --- src/lib/events.ts | 19 ++++++ src/lib/run-with-core.setup.spec.ts | 100 +++++++++++++++++++++++++++- src/lib/run-with-core.ts | 69 +++++++++++++++---- src/lib/workos-management.spec.ts | 25 +++++++ src/lib/workos-management.ts | 23 ++++++- 5 files changed, 221 insertions(+), 15 deletions(-) diff --git a/src/lib/events.ts b/src/lib/events.ts index 6fe784a4..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 }; @@ -187,6 +204,8 @@ const INSTALLER_EVENT_REGISTRY = { '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, 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.ts b/src/lib/run-with-core.ts index b349aaf2..00df9d2d 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -2,9 +2,13 @@ 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'; @@ -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,19 +212,56 @@ 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. */ @@ -415,10 +461,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 { 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), ]); From f77ee98d95c0d8a113d7d1f2c76742ebaea52cf6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 13:59:18 -0500 Subject: [PATCH 11/20] feat(tui): align the checklist with the dashboard and put it on the right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Configure WorkOS" expands into the dashboard's items it sets while it runs, in the dashboard's order and words, and collapses once they land. - New "Connect your app's URLs" step (Next.js) after the agent, where the redirect, initiate login, and sign-out URIs are actually set. Items that weren't set or verified stay open with a `!`, and items sharing a reason share one walkthrough line. - "Complete your first sign-up" ends the list as the next step (→). The run's closing line says so when a setting still needs a look. - The walkthrough is on the left and the checklist on the right. Below 100x30 the walkthrough keeps the room and the checklist becomes one progress line. --- src/tui/App.tsx | 27 ++-- src/tui/app.spec.ts | 119 +++++++++++++++++- src/tui/components/TaskList.tsx | 155 +++++++++++++++++++---- src/tui/content/README.md | 4 + src/tui/content/content.spec.ts | 2 +- src/tui/content/installer-content.json | 60 +++++++-- src/tui/content/schema.ts | 13 +- src/tui/model/run-model.spec.ts | 168 ++++++++++++++++++++++++- src/tui/model/run-model.ts | 151 +++++++++++++++++++--- src/tui/theme.ts | 8 ++ 10 files changed, 641 insertions(+), 66 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 07c8eb72..1573ca5c 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -8,9 +8,9 @@ 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 } from './theme.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 { TaskList } from './components/TaskList.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'; @@ -102,20 +102,31 @@ export function InstallerApp({ model, answer, interrupt, projectName, tipInterva ); } + 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 = width - taskWidth - 2; + const walkthroughWidth = sideBySide ? width - taskWidth - 2 : width; return (
- - t.status !== 'skipped')} width={taskWidth} height={bodyRows} /> - - - + {sideBySide ? ( + + + + + + ) : ( + + + + + + )} {prompt ? : null} {prompt} diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts index d795b9ab..e5a414c6 100644 --- a/src/tui/app.spec.ts +++ b/src/tui/app.spec.ts @@ -98,12 +98,22 @@ describe.each([ expect(painted.length).toBe(LOGO_MASK.length); } - // Tasks: done, in progress (active label), and pending. - 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).toContain('4 of 6 done'); + 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"); @@ -186,6 +196,103 @@ describe.each([ ); }); +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(); diff --git a/src/tui/components/TaskList.tsx b/src/tui/components/TaskList.tsx index 8d4835ca..797642fa 100644 --- a/src/tui/components/TaskList.tsx +++ b/src/tui/components/TaskList.tsx @@ -1,45 +1,90 @@ import { Box, Text } from 'ink'; -import type { TaskView } from '../model/run-model.js'; +import { countedTasks, type SubtaskView, type TaskStatus, type TaskView } from '../model/run-model.js'; import { colors, glyphs } from '../theme.js'; import { useSpinner } from '../hooks.js'; -function TaskRow({ task, spinner }: { task: TaskView; spinner: string }) { - const label = task.status === 'in_progress' && task.activeLabel ? task.activeLabel : task.label; - switch (task.status) { +/** 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 ( - {glyphs.done} {label} + {pad} + {glyphs.done} {row.label} ); case 'in_progress': return ( - - {spinner} {label} + + {pad} + {spinner} {row.label} + + ); + case 'next': + return ( + + {pad} + {glyphs.next} {row.label} ); case 'failed': return ( - {glyphs.failed} {label} + {pad} + {glyphs.failed} {row.label} + + ); + case 'attention': + return ( + + {pad} + {glyphs.warning} {row.label} ); case 'cancelled': return ( - {glyphs.cancelled} {label} + {pad} + {glyphs.cancelled} {row.label} ); case 'skipped': return ( - {glyphs.skipped} {label} + {pad} + {glyphs.skipped} {row.label} ); default: return ( - {glyphs.pending} {label} + {pad} + {glyphs.pending} {row.label} ); } @@ -47,30 +92,92 @@ function TaskRow({ task, spinner }: { task: TaskView; spinner: string }) { /** * The task list, fitted to `height`: the heading and counter go first when - * space is short, then it shows a window of tasks around the current one. + * 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 running = tasks.some((t) => t.status === 'in_progress'); + const rows = rowsFor(tasks); + const running = rows.some((r) => r.status === 'in_progress'); const spinner = useSpinner(running); - const counted = tasks.filter((t) => t.status !== 'skipped'); + const counted = countedTasks(tasks); const done = counted.filter((t) => t.status === 'completed').length; - const showTitle = height >= tasks.length + 2; - const showCount = height >= tasks.length + (showTitle ? 1 : 0) + 1; - const rows = Math.max(1, height - (showTitle ? 1 : 0) - (showCount ? 1 : 0)); - const current = Math.max( - 0, - tasks.findIndex((t) => t.status === 'in_progress' || t.status === 'pending'), - ); - const start = Math.min(Math.max(0, current - 1), Math.max(0, tasks.length - rows)); + 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} - {tasks.slice(start, start + rows).map((task) => ( - + {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')); + 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/content/README.md b/src/tui/content/README.md index 4c9cffe1..ed2ec43c 100644 --- a/src/tui/content/README.md +++ b/src/tui/content/README.md @@ -47,5 +47,9 @@ branch, {branch}."`. The allowed placeholders per event are in `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 index f427ecd6..870ba300 100644 --- a/src/tui/content/content.spec.ts +++ b/src/tui/content/content.spec.ts @@ -107,7 +107,7 @@ describe('content validation', () => { 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, failure, cancelled'); + expect(problems(missing)).toContain('outcomes must be exactly success, setup-required, failure, cancelled'); const flat = raw(); flat.walkthrough.complete = 'Done.'; diff --git a/src/tui/content/installer-content.json b/src/tui/content/installer-content.json index f0b2f640..ee32f860 100644 --- a/src/tui/content/installer-content.json +++ b/src/tui/content/installer-content.json @@ -18,14 +18,52 @@ "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" }, - "finish": { "label": "Wrap up", "activeLabel": "Wrapping up" } + "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.", @@ -50,7 +88,7 @@ "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: redirect URIs, sign-in settings, and environment variables.", + "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}).", @@ -64,12 +102,14 @@ "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.", + "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." } diff --git a/src/tui/content/schema.ts b/src/tui/content/schema.ts index ce5ac445..79347790 100644 --- a/src/tui/content/schema.ts +++ b/src/tui/content/schema.ts @@ -21,10 +21,19 @@ export const TASK_IDS = [ '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 @@ -55,7 +64,7 @@ export const WALKTHROUGH_PARAMS: Partial> = { 'validation:complete': ['passed', 'failed'], - complete: ['success', 'failure', 'cancelled'], + complete: ['success', 'setup-required', 'failure', 'cancelled'], }; const PLACEHOLDER = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g; @@ -113,6 +122,8 @@ export const installerContentSchema = z 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), diff --git a/src/tui/model/run-model.spec.ts b/src/tui/model/run-model.spec.ts index ac4d7ff0..971d56c7 100644 --- a/src/tui/model/run-model.spec.ts +++ b/src/tui/model/run-model.spec.ts @@ -12,7 +12,7 @@ import type { WorkspaceCheckOutput, } from '../../lib/installer-core.types.js'; import { WALKTHROUGH_PARAMS, loadInstallerContent, parseInstallerContent, placeholdersOf } from '../content/index.js'; -import { createRunModel, type RunModel, type RunSnapshot, type TaskStatus } from './run-model.js'; +import { countedTasks, createRunModel, type RunModel, type RunSnapshot, type TaskStatus } from './run-model.js'; const content = loadInstallerContent(); const INSTALL_DIR = '/work/my-app'; @@ -98,6 +98,8 @@ const RANK: Record = { skipped: 2, failed: 2, cancelled: 2, + next: 2, + attention: 2, }; /** A task only ever moves forward: pending → in_progress → a final state. */ @@ -126,6 +128,7 @@ describe('run model: tasks follow real installer events', () => { configure: 'pending', install: 'pending', finish: 'pending', + 'first-sign-up': 'pending', }); expect(snapshot.outcome).toBeNull(); }); @@ -142,6 +145,8 @@ describe('run model: tasks follow real installer events', () => { 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'); @@ -215,6 +220,7 @@ describe('run model: tasks follow real installer events', () => { 'install', 'verify', 'finish', + 'first-sign-up', ]); const issue = final.walkthrough.find((e) => e.text.includes('issues: 2')); expect(issue?.tone).toBe('warning'); @@ -230,7 +236,13 @@ describe('run model: tasks follow real installer events', () => { await run.done; const final = run.model.getSnapshot(); - expect(statuses(final)).toMatchObject({ configure: 'completed', install: 'failed', finish: 'pending' }); + 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({ @@ -454,3 +466,155 @@ describe('run model: tips, prompt, status, notices', () => { ]); }); }); + +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', + }); + 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('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 index d957b428..b16049c0 100644 --- a/src/tui/model/run-model.ts +++ b/src/tui/model/run-model.ts @@ -12,9 +12,16 @@ import type { EventEmitter } from 'node:events'; import { isAbsolute, relative } from 'node:path'; -import type { InstallerEventEmitter, InstallerEventName, InstallerEvents } from '../../lib/events.js'; +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, @@ -22,17 +29,47 @@ import { walkthroughText, type Announcement, type InstallerContent, + type SubtaskId, type TaskId, type Tip, } from '../content/index.js'; -export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'skipped' | 'failed' | 'cancelled'; +/** + * `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'; @@ -96,7 +133,25 @@ const PHASE_TASKS: Record = { }; /** Shown only once something starts them. */ -const OPTIONAL_TASKS = new Set(['scaffold', 'verify']); +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', @@ -142,6 +197,10 @@ export function createRunModel(options: RunModelOptions): RunModel { 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(); @@ -149,12 +208,16 @@ export function createRunModel(options: RunModelOptions): RunModel { function build(): RunSnapshot { const query = { framework: integration, now }; return { - tasks: TASK_IDS.filter((id) => shown.has(id)).map((id) => ({ - id, - label: content.tasks[id].label, - activeLabel: content.tasks[id].activeLabel, - status: status.get(id)!, - })), + tasks: TASK_IDS.filter((id) => shown.has(id)).map((id) => { + const subtasks = id === 'configure' || id === 'app-urls' ? subtaskViews(id) : undefined; + return { + id, + label: content.tasks[id].label, + activeLabel: content.tasks[id].activeLabel, + status: status.get(id)!, + ...(subtasks ? { subtasks } : {}), + }; + }), walkthrough, status: spinnerStatus, prompt, @@ -166,6 +229,49 @@ export function createRunModel(options: RunModelOptions): RunModel { }; } + /** 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. */ + function needsAttention(): boolean { + return [...substatus.values()].some((items) => [...items.values()].includes('attention')); + } + function changed(): void { snapshot = build(); for (const listener of listeners) listener(); @@ -211,12 +317,16 @@ export function createRunModel(options: RunModelOptions): RunModel { 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 (s === 'in_progress') setTask(id, 'completed'); + 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; @@ -239,8 +349,10 @@ export function createRunModel(options: RunModelOptions): RunModel { function onExit({ state }: InstallerEvents['state:exit']): void { const task: TaskId | undefined = state === 'scaffold' ? 'scaffold' : PHASE_TASKS[state]; if (!task) return; - // Validation runs inside the agent phase, so it ends when that phase does. - const active = task === 'install' && status.get('verify') === 'in_progress' ? 'verify' : task; + // 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; } @@ -250,6 +362,16 @@ export function createRunModel(options: RunModelOptions): RunModel { 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'); @@ -307,11 +429,12 @@ export function createRunModel(options: RunModelOptions): RunModel { complete: ({ success }) => { outcome = success ? 'success' : cancelled ? 'cancelled' : 'failure'; + const pending = outcome === 'success' && needsAttention(); narrate( 'complete', undefined, - outcome, - outcome === 'success' ? 'success' : outcome === 'cancelled' ? 'warning' : 'error', + pending ? 'setup-required' : outcome, + outcome === 'success' && !pending ? 'success' : outcome === 'failure' ? 'error' : 'warning', ); }, }; diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 1b05b365..62fff77e 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -22,6 +22,7 @@ export const glyphs = { bullet: '·', warning: '!', tip: '◆', + next: '→', } as const; export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -29,3 +30,10 @@ 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; From 16b05faf7e7f7f495d88cf37cce9170aeb958b2d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 13:59:18 -0500 Subject: [PATCH 12/20] fix(install): leave the agent's play-by-play out of the full-screen scrollback On exit the full-screen view replayed everything the plain CLI would have printed, so the scrollback filled with every command and file the agent touched. It now keeps the answers, the settings rows, warnings and errors (including any from the agent's run), validation, and the summary, and points to the installer log for the step-by-step. --- README.md | 14 +++++--- src/lib/adapters/tui-adapter.spec.ts | 30 ++++++++++++++++ src/lib/adapters/tui-adapter.ts | 53 +++++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9b1ad1ca..3c6ae363 100644 --- a/README.md +++ b/README.md @@ -555,11 +555,15 @@ workos install [options] ``` **Full-screen installer:** In an interactive terminal of at least 80×24, -`workos install` opens a full-screen view: a task list, a plain-English -walkthrough of what it's doing, and tips and WorkOS news, with every question -asked inline. When it closes, the same summary a plain run prints stays in your -scrollback. Pass `--no-tui` for plain output. Smaller terminals, piped -input/output, CI and agent modes, and `--json` always use plain or JSON output. +`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`. diff --git a/src/lib/adapters/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index 0ab94f7f..47f93c6b 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -165,6 +165,36 @@ describe('TuiAdapter', () => { 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', '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'); diff --git a/src/lib/adapters/tui-adapter.ts b/src/lib/adapters/tui-adapter.ts index 1496bf45..f90a1b00 100644 --- a/src/lib/adapters/tui-adapter.ts +++ b/src/lib/adapters/tui-adapter.ts @@ -8,11 +8,14 @@ * run model turns installer events into the task list and walkthrough, and * Ink draws it all on the alternate screen. * - * Nothing the plain CLI would have printed is lost: it's collected while the - * full screen is up and printed to the normal screen when it closes, so the - * scrollback ends with the same summary a plain run leaves. + * 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'; @@ -26,6 +29,7 @@ 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. */ @@ -38,6 +42,8 @@ export interface TuiAdapterConfig extends AdapterConfig { } const INDENT = ' '; +/** 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]; @@ -83,6 +89,9 @@ export class TuiAdapter implements InstallerAdapter { 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; constructor(config: TuiAdapterConfig) { this.config = config; @@ -103,6 +112,10 @@ export class TuiAdapter implements InstallerAdapter { }); 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, // the plain CLI's SIGINT handler, an uncaught error. process.on('exit', this.teardown); @@ -165,6 +178,9 @@ export class TuiAdapter implements InstallerAdapter { 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.model?.dispose(); this.model = null; @@ -176,7 +192,7 @@ export class TuiAdapter implements InstallerAdapter { private readonly host: UiHost = { line: (line: UiLine) => { - this.transcript.push(line.rendered ? INDENT + line.rendered : ''); + 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(); @@ -196,6 +212,33 @@ export class TuiAdapter implements InstallerAdapter { 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); + } + + private readonly agentStarted = (): void => { + this.agentWorking = true; + }; + + /** Close the agent window; say where its hidden lines went, if any. */ + 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.transcript.push(`${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; @@ -215,7 +258,7 @@ export class TuiAdapter implements InstallerAdapter { for (const method of CONSOLE_METHODS) { this.savedConsole[method] = console[method]; console[method] = (...args: unknown[]) => { - this.transcript.push(format(...args)); + this.record(format(...args), method === 'warn' || method === 'error'); }; } } From f7cd5288c7b44398779c3164921ddcdb992d0973 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 14:24:03 -0500 Subject: [PATCH 13/20] fix(tui): don't drop or garble keys in the full-screen prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ink re-subscribes input handlers after each render, so a key arriving before that reached a handler holding the previous state: - a fast ↓ then enter in a select picked the option from before the move (continue on the current branch became "create a branch") - a text or password answer pasted with its trailing newline was inserted as text and never submitted, and typing ahead lost characters The select keeps its focus in a ref, and text/password prompts use a small line input whose value and cursor live in a ref; a newline anywhere in a chunk submits what precedes it, as the plain CLI's prompts do. It edits by character (an emoji is one step), ignores keys Ink delivers as raw escape sequences (Home, End), and shows a validator that throws as the prompt's error instead of crashing the installer. --- src/lib/adapters/tui-adapter.spec.ts | 32 ++++++++ src/tui/app.spec.ts | 34 +++++++++ src/tui/components/PromptPanel.tsx | 107 ++++++++++++++++++++++++--- 3 files changed, 161 insertions(+), 12 deletions(-) diff --git a/src/lib/adapters/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index 47f93c6b..f57c4933 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -98,6 +98,38 @@ describe('TuiAdapter', () => { 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', {}); diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts index e5a414c6..1edcaa2d 100644 --- a/src/tui/app.spec.ts +++ b/src/tui/app.spec.ts @@ -421,6 +421,40 @@ describe('full-screen installer interaction', () => { 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 }); diff --git a/src/tui/components/PromptPanel.tsx b/src/tui/components/PromptPanel.tsx index 05be9590..d39b373b 100644 --- a/src/tui/components/PromptPanel.tsx +++ b/src/tui/components/PromptPanel.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Box, Text, useInput } from 'ink'; -import { ConfirmInput, PasswordInput, TextInput } from '@inkjs/ui'; +import { ConfirmInput } from '@inkjs/ui'; 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'; @@ -92,13 +92,18 @@ function SelectPrompt({ 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 = (focus + step * i + options.length) % options.length; + const next = (focusRef.current + step * i + options.length) % options.length; if (selectable(options[next])) { + focusRef.current = next; setFocus(next); return; } @@ -106,9 +111,10 @@ function SelectPrompt({ }; 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[focus])) answer(options[focus].value); + else if (key.return && selectable(options[current])) answer(options[current].value); }); return ( @@ -133,6 +139,78 @@ function SelectPrompt({ ); } +/** + * 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, + onChange, + onSubmit, +}: { + mask?: string; + placeholder?: string; + 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; + if (!value && placeholder) { + return ( + + {placeholder[0]} + {placeholder.slice(1)} + + ); + } + const chars = Array.from(value).map((c) => mask ?? c); + return ( + + {chars.slice(0, cursor).join('')} + {chars[cursor] ?? ' '} + {chars.slice(cursor + 1).join('')} + + ); +} + function TextPrompt({ request, answer, @@ -143,15 +221,19 @@ function TextPrompt({ width: number; }) { const [error, setError] = useState(null); - // Must be stable: @inkjs/ui calls onChange from an effect keyed on its - // identity, so a fresh function each render would clear the error it just set. 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; - const verdict = await (request.validate as ValidateFn | undefined)?.(value); + 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; @@ -164,11 +246,12 @@ function TextPrompt({ {`${glyphs.pointer} `} - {request.kind === 'password' ? ( - - ) : ( - - )} + void submit(value)} + /> {error ? `${glyphs.failed} ${error}` : ' '} From 881f552b57381a1cd3be89dca8c42baae4b761ab Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 14:24:03 -0500 Subject: [PATCH 14/20] fix(install): restore the terminal on SIGTERM and SIGHUP in the full-screen view From review of the stack: - A terminating signal emits no 'exit' event, so `kill` or a closed terminal left the alternate screen up and the tty raw. The view now restores the terminal on SIGTERM/SIGHUP and then dies of the signal. - Redirected stderr (`2> errors.log`) now keeps the plain CLI, so errors stay on stderr; previously only the interaction mode implied this. - A setting whose write failed while the install carried on no longer ends the run on "All done": it counts as needing a look, like one not set. --- src/lib/adapters/select-adapter.spec.ts | 2 ++ src/lib/adapters/select-adapter.ts | 4 +++- src/lib/adapters/tui-adapter.spec.ts | 18 ++++++++++++++++++ src/lib/adapters/tui-adapter.ts | 13 ++++++++++++- src/lib/run-with-core.spec.ts | 12 +++++++++++- src/lib/run-with-core.ts | 1 + src/tui/components/TaskList.tsx | 2 +- src/tui/model/run-model.spec.ts | 13 +++++++++++++ src/tui/model/run-model.ts | 6 ++++-- 9 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/lib/adapters/select-adapter.spec.ts b/src/lib/adapters/select-adapter.spec.ts index 835fe063..b188fb8d 100644 --- a/src/lib/adapters/select-adapter.spec.ts +++ b/src/lib/adapters/select-adapter.spec.ts @@ -7,6 +7,7 @@ const interactive: AdapterSelectionInput = { ci: false, stdinTTY: true, stdoutTTY: true, + stderrTTY: true, columns: 120, rows: 40, noTui: false, @@ -30,6 +31,7 @@ describe('selectInstallerAdapter', () => { ['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 }], diff --git a/src/lib/adapters/select-adapter.ts b/src/lib/adapters/select-adapter.ts index 986f9764..19662af2 100644 --- a/src/lib/adapters/select-adapter.ts +++ b/src/lib/adapters/select-adapter.ts @@ -11,6 +11,8 @@ export interface AdapterSelectionInput { 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 */ @@ -29,7 +31,7 @@ export interface AdapterSelectionInput { export function selectInstallerAdapter(input: AdapterSelectionInput): InstallerAdapterKind { if (input.json) return 'headless'; if (input.interaction !== 'human' || input.ci) return 'cli'; - if (!input.stdinTTY || !input.stdoutTTY) 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'; diff --git a/src/lib/adapters/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index f57c4933..ec535b02 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -239,6 +239,24 @@ describe('TuiAdapter', () => { 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')); diff --git a/src/lib/adapters/tui-adapter.ts b/src/lib/adapters/tui-adapter.ts index f90a1b00..de8244c3 100644 --- a/src/lib/adapters/tui-adapter.ts +++ b/src/lib/adapters/tui-adapter.ts @@ -42,6 +42,7 @@ export interface TuiAdapterConfig extends AdapterConfig { } 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; @@ -117,8 +118,11 @@ export class TuiAdapter implements InstallerAdapter { 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, - // the plain CLI's SIGINT handler, an uncaught error. + // the plain CLI's SIGINT handler, 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( @@ -164,6 +168,7 @@ export class TuiAdapter implements InstallerAdapter { 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); @@ -188,6 +193,12 @@ export class TuiAdapter implements InstallerAdapter { 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 = { diff --git a/src/lib/run-with-core.spec.ts b/src/lib/run-with-core.spec.ts index 3057038e..4dfd95e7 100644 --- a/src/lib/run-with-core.spec.ts +++ b/src/lib/run-with-core.spec.ts @@ -120,13 +120,20 @@ describe('resolveAdapterKind', () => { let saved: Array<[NodeJS.ReadStream | NodeJS.WriteStream, string, PropertyDescriptor | undefined]> = []; const originalTerm = process.env.TERM; - function terminal(opts: { stdinTTY: boolean; stdoutTTY: boolean; columns: number; rows: number }) { + 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); } @@ -157,6 +164,9 @@ describe('resolveAdapterKind', () => { 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', () => { diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 00df9d2d..d7dafdb4 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -272,6 +272,7 @@ export function resolveAdapterKind(options: Pick t.subtasks?.some((s) => s.status === 'attention')); + const check = tasks.some((t) => t.subtasks?.some((s) => s.status === 'attention' || s.status === 'failed')); line = ( {check ? ( diff --git a/src/tui/model/run-model.spec.ts b/src/tui/model/run-model.spec.ts index 971d56c7..72d47fdd 100644 --- a/src/tui/model/run-model.spec.ts +++ b/src/tui/model/run-model.spec.ts @@ -587,6 +587,19 @@ describe('run model: the dashboard checklist', () => { }); }); + 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(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) { diff --git a/src/tui/model/run-model.ts b/src/tui/model/run-model.ts index b16049c0..729409b6 100644 --- a/src/tui/model/run-model.ts +++ b/src/tui/model/run-model.ts @@ -267,9 +267,11 @@ export function createRunModel(options: RunModelOptions): RunModel { } } - /** Any dashboard setting left for the user to check. */ + /** 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()].includes('attention')); + return [...substatus.values()].some((items) => + [...items.values()].some((s) => s === 'attention' || s === 'failed'), + ); } function changed(): void { From 8e58cc08774dd09de318c6a7b6a849cb26d2d8d3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 14:53:43 -0500 Subject: [PATCH 15/20] fix(install): print the agent-log pointer after the agent's closing line The note on where the hidden agent lines went printed just before "Agent completed", so the scrollback read backwards. It now follows the next line. --- src/lib/adapters/tui-adapter.spec.ts | 8 +++++++- src/lib/adapters/tui-adapter.ts | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/lib/adapters/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index ec535b02..c74f84e3 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -221,7 +221,13 @@ describe('TuiAdapter', () => { 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', 'Callback has no route', 'Hint: Move the route', 'Validation found 1 issue(s)']; + 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'); diff --git a/src/lib/adapters/tui-adapter.ts b/src/lib/adapters/tui-adapter.ts index de8244c3..fb4ea41f 100644 --- a/src/lib/adapters/tui-adapter.ts +++ b/src/lib/adapters/tui-adapter.ts @@ -93,6 +93,8 @@ export class TuiAdapter implements InstallerAdapter { // 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; @@ -186,6 +188,7 @@ export class TuiAdapter implements InstallerAdapter { 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; @@ -232,20 +235,30 @@ export class TuiAdapter implements InstallerAdapter { 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; say where its hidden lines went, if any. */ + /** + * 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.transcript.push(`${INDENT}${chalk.dim(`› The agent's step-by-step log is in the installer log${where}`)}`); + this.logPointer = `${INDENT}${chalk.dim(`› The agent's step-by-step log is in the installer log${where}`)}`; } this.hiddenAgentLines = 0; }; From b5b4888018aee78bd054de2285b555f3005021d6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 16:06:14 -0500 Subject: [PATCH 16/20] fix(tui): don't tick a task whose WorkOS setting still needs a look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished task with a setting left to check showed a green ✔ above its ! items and counted toward "N of M done", so a run that ends on "setup required" read 8 of 8 done. The task now shares the ! and stays out of the count. --- src/tui/model/run-model.spec.ts | 5 +++++ src/tui/model/run-model.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tui/model/run-model.spec.ts b/src/tui/model/run-model.spec.ts index 72d47fdd..e087b6c7 100644 --- a/src/tui/model/run-model.spec.ts +++ b/src/tui/model/run-model.spec.ts @@ -574,6 +574,10 @@ describe('run model: the dashboard checklist', () => { '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({ @@ -594,6 +598,7 @@ describe('run model: the dashboard checklist', () => { 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'], diff --git a/src/tui/model/run-model.ts b/src/tui/model/run-model.ts index 729409b6..8ff7119d 100644 --- a/src/tui/model/run-model.ts +++ b/src/tui/model/run-model.ts @@ -210,11 +210,16 @@ export function createRunModel(options: RunModelOptions): RunModel { 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: status.get(id)!, + status: flagged ? 'attention' : taskStatus, ...(subtasks ? { subtasks } : {}), }; }), From d201fd53d2e29db321ebca517d665b61cd1cb47e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 17:37:13 -0500 Subject: [PATCH 17/20] fix(install): address TUI validation, cancellation, and input review --- bun.lock | 1 + package.json | 1 + src/lib/adapters/cli-adapter.ts | 7 ++- src/lib/adapters/tui-adapter.spec.ts | 43 ++++++++++++---- src/lib/adapters/tui-adapter.ts | 7 ++- src/tui/app.spec.ts | 77 ++++++++++++++++++++++++++++ src/tui/components/PromptPanel.tsx | 53 ++++++++++++++++--- src/tui/model/run-model.spec.ts | 41 ++++++++++++++- src/tui/model/run-model.ts | 2 +- 9 files changed, 207 insertions(+), 25 deletions(-) diff --git a/bun.lock b/bun.lock index 3a1988a2..8396c481 100644 --- a/bun.lock +++ b/bun.lock @@ -23,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", diff --git a/package.json b/package.json index a169d900..3ee7876b 100644 --- a/package.json +++ b/package.json @@ -40,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/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/tui-adapter.spec.ts b/src/lib/adapters/tui-adapter.spec.ts index c74f84e3..f1a57341 100644 --- a/src/lib/adapters/tui-adapter.spec.ts +++ b/src/lib/adapters/tui-adapter.spec.ts @@ -5,6 +5,8 @@ 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; @@ -138,23 +140,46 @@ describe('TuiAdapter', () => { await waitFor(() => expect(sendEvent).toHaveBeenCalledWith({ type: 'COMMIT_DECLINED' })); }); - it('sends ctrl-c down the existing SIGINT → CANCEL path', async () => { + it('lets ctrl-c finish machine cancellation before the adapter stops', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); - // Stands in for runWithCore's SIGINT handler, which sends CANCEL to the machine. - const runWithCoreSigint = () => sendEvent({ type: 'CANCEL' }); - process.on('SIGINT', runWithCoreSigint); + 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(exit).toHaveBeenCalledWith(0)); - expect(sendEvent).toHaveBeenCalledWith({ type: 'CANCEL' }); + 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(); } - await adapter.stop(); - // The plain CLI's cancel message survives into the scrollback. - expect(afterExit()).toContain('Installer cancelled'); }); it('shows the device-flow code and waiting status', async () => { diff --git a/src/lib/adapters/tui-adapter.ts b/src/lib/adapters/tui-adapter.ts index fb4ea41f..9de4c6c0 100644 --- a/src/lib/adapters/tui-adapter.ts +++ b/src/lib/adapters/tui-adapter.ts @@ -119,10 +119,9 @@ export class TuiAdapter implements InstallerAdapter { // 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, - // the plain CLI's SIGINT handler, an uncaught error. A signal that - // terminates by default (kill, a closed terminal) emits no 'exit', so - // those restore the terminal themselves. + // 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); diff --git a/src/tui/app.spec.ts b/src/tui/app.spec.ts index 1edcaa2d..e4f167cc 100644 --- a/src/tui/app.spec.ts +++ b/src/tui/app.spec.ts @@ -8,6 +8,8 @@ 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(); @@ -468,6 +470,81 @@ describe('full-screen installer interaction', () => { 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(); diff --git a/src/tui/components/PromptPanel.tsx b/src/tui/components/PromptPanel.tsx index d39b373b..225521c1 100644 --- a/src/tui/components/PromptPanel.tsx +++ b/src/tui/components/PromptPanel.tsx @@ -1,6 +1,7 @@ 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'; @@ -139,6 +140,33 @@ function SelectPrompt({ ); } +/** 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. * @@ -151,11 +179,14 @@ function SelectPrompt({ 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; }) { @@ -193,20 +224,28 @@ function LineInput({ }); 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 ( - {placeholder[0]} - {placeholder.slice(1)} + {head} + {rest.join('')} ); } - const chars = Array.from(value).map((c) => mask ?? c); + // 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 ( - {chars.slice(0, cursor).join('')} - {chars[cursor] ?? ' '} - {chars.slice(cursor + 1).join('')} + {cells.slice(start, pos).join('')} + {cells[pos] ?? ' '} + {cells.slice(pos + 1, end).join('')} ); } @@ -249,6 +288,8 @@ function TextPrompt({ void submit(value)} /> diff --git a/src/tui/model/run-model.spec.ts b/src/tui/model/run-model.spec.ts index e087b6c7..2aba13a1 100644 --- a/src/tui/model/run-model.spec.ts +++ b/src/tui/model/run-model.spec.ts @@ -211,7 +211,8 @@ describe('run model: tasks follow real installer events', () => { expect(command.length).toBeLessThanOrEqual('Ran '.length + 80); expect(command.endsWith('…')).toBe(true); - expect(statuses(final)).toMatchObject({ install: 'completed', verify: 'completed' }); + // 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', @@ -227,6 +228,44 @@ describe('run model: tasks follow real installer events', () => { 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, diff --git a/src/tui/model/run-model.ts b/src/tui/model/run-model.ts index 8ff7119d..5c1824b5 100644 --- a/src/tui/model/run-model.ts +++ b/src/tui/model/run-model.ts @@ -421,7 +421,7 @@ export function createRunModel(options: RunModelOptions): RunModel { narrate('validation:start'); }, 'validation:complete': ({ passed, issueCount }) => { - setTask('verify', 'completed'); + setTask('verify', passed ? 'completed' : 'failed'); narrate( 'validation:complete', { count: issueCount }, From 092b3bd62aac16d98647b1b31bac77b16cb503ed Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:00:20 -0500 Subject: [PATCH 18/20] ci: diagnose smoke authentication with a direct read-only request When authenticated command smoke fails, issue a single GET /connections using the same smoke credential and configured API host through curl instead of the compiled CLI. Discard the body, do not follow redirects, and log only HTTP status, curl exit status, and the request ID. Keep the original failed check red. --- .github/workflows/test.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cf66e85..53d523fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,7 @@ jobs: # is a dedicated staging-environment API key; fork PRs receive no # secrets, so this skips there instead of failing. - name: Smoke test authenticated commands + id: authenticated-smoke env: WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} # Optional: set when the smoke key belongs to a non-default API host @@ -96,6 +97,34 @@ jobs: fi sh scripts/command-smoke.sh ./dist/workos + # Independent, read-only control when the CLI smoke fails: distinguish + # CLI credential handling from the API rejecting the CI credential. + # Never log the credential, response body, or full response headers. + - name: Diagnose smoke authentication without the CLI + if: ${{ failure() && steps.authenticated-smoke.outcome == 'failure' }} + env: + WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} + WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} + run: | + set +x + if [ -z "$WORKOS_API_KEY" ]; then + echo "Direct API probe skipped: smoke credential unavailable" + exit 0 + fi + headers=$(mktemp) + trap 'rm -f "$headers"' EXIT + base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" + curl_exit=0 + status=$(curl --silent --connect-timeout 10 --max-time 20 \ + --proto '=http,https' \ + --header "Authorization: Bearer $WORKOS_API_KEY" \ + --dump-header "$headers" --output /dev/null \ + --write-out '%{http_code}' \ + "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? + printf 'Direct GET /connections: HTTP %s; curl exit %s\n' "$status" "$curl_exit" + # No --location: do not follow redirects with credentials. + awk 'tolower($1) == "x-request-id:" { gsub(/\r/, "", $2); if ($2 ~ /^[A-Za-z0-9_-]+$/) print "API request ID: " $2 }' "$headers" + # Publish the generated packages to a throwaway local registry and run # the real user flows — `npx workos`, `npm install -g workos`, platform # selection, and the launcher's no-binary error path — from a hermetic From 8153045e03cc7b44fa726fbdba45c147d572cfc0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 21:38:39 -0500 Subject: [PATCH 19/20] ci: distinguish unusable smoke credentials from CLI regressions Preflight live smoke with one direct read-only API request. Missing credentials or direct HTTP401 report live checks NOT RUN with an explicit warning/summary instead of misclassifying a CLI regression. HTTP200 enables the unchanged authenticated CLI checks, whose failures still fail CI. All other HTTP or transport failures remain fatal. Localhost-only tests cover status handling, non-disclosure, no redirects, and propagation of CLI failures. --- .github/workflows/test.yml | 45 ++----- scripts/smoke-auth-preflight.sh | 47 +++++++ scripts/smoke-auth-preflight.spec.ts | 184 +++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 34 deletions(-) create mode 100644 scripts/smoke-auth-preflight.sh create mode 100644 scripts/smoke-auth-preflight.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53d523fd..41ba4e72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,53 +78,30 @@ 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 sh scripts/command-smoke.sh ./dist/workos - # Independent, read-only control when the CLI smoke fails: distinguish - # CLI credential handling from the API rejecting the CI credential. - # Never log the credential, response body, or full response headers. - - name: Diagnose smoke authentication without the CLI - if: ${{ failure() && steps.authenticated-smoke.outcome == 'failure' }} - env: - WORKOS_API_KEY: ${{ secrets.WORKOS_SMOKE_API_KEY }} - WORKOS_SMOKE_API_URL: ${{ secrets.WORKOS_SMOKE_API_URL }} - run: | - set +x - if [ -z "$WORKOS_API_KEY" ]; then - echo "Direct API probe skipped: smoke credential unavailable" - exit 0 - fi - headers=$(mktemp) - trap 'rm -f "$headers"' EXIT - base="${WORKOS_SMOKE_API_URL:-https://api.workos.com}" - curl_exit=0 - status=$(curl --silent --connect-timeout 10 --max-time 20 \ - --proto '=http,https' \ - --header "Authorization: Bearer $WORKOS_API_KEY" \ - --dump-header "$headers" --output /dev/null \ - --write-out '%{http_code}' \ - "${base%/}/connections?limit=1" 2>/dev/null) || curl_exit=$? - printf 'Direct GET /connections: HTTP %s; curl exit %s\n' "$status" "$curl_exit" - # No --location: do not follow redirects with credentials. - awk 'tolower($1) == "x-request-id:" { gsub(/\r/, "", $2); if ($2 ~ /^[A-Za-z0-9_-]+$/) print "API request ID: " $2 }' "$headers" - # Publish the generated packages to a throwaway local registry and run # the real user flows — `npx workos`, `npm install -g workos`, platform # selection, and the launcher's no-binary error path — from a hermetic diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh new file mode 100644 index 00000000..17eed0ba --- /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 '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..d05a7553 --- /dev/null +++ b/scripts/smoke-auth-preflight.spec.ts @@ -0,0 +1,184 @@ +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(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, + ); + }); +}); From 72299d7263a89307c7577a5efb7848f0bca79927 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 24 Sep 2026 22:26:00 -0500 Subject: [PATCH 20/20] ci: warn explicitly when smoke credentials are unavailable --- scripts/smoke-auth-preflight.sh | 2 +- scripts/smoke-auth-preflight.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-auth-preflight.sh b/scripts/smoke-auth-preflight.sh index 17eed0ba..9adbeba0 100644 --- a/scripts/smoke-auth-preflight.sh +++ b/scripts/smoke-auth-preflight.sh @@ -14,7 +14,7 @@ report() { } if [ -z "${WORKOS_SMOKE_API_KEY:-}" ]; then - report false 'Live authenticated checks NOT RUN: WORKOS_SMOKE_API_KEY is missing. Credential/API configuration needs investigation (fork PRs may have no secrets).' + 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 diff --git a/scripts/smoke-auth-preflight.spec.ts b/scripts/smoke-auth-preflight.spec.ts index d05a7553..8c443d63 100644 --- a/scripts/smoke-auth-preflight.spec.ts +++ b/scripts/smoke-auth-preflight.spec.ts @@ -119,6 +119,7 @@ describe('smoke authentication preflight (localhost only)', () => { 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); });