From 4c577c245fde694b3df7fa61120a65d16352c5e6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 10:42:48 -0500 Subject: [PATCH 1/6] feat!: consent-gated agent setup and flat CLI output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, one change: 1. Consent — a customer called the CLI's skill auto-install "prompt injection malware": `workos auth login` / `workos install` silently and unconditionally installed all bundled skills to every detected coding agent. 2. Aesthetic — clack's gutter/box styling buried the important asks and the post-login sequence felt spammy. What changed: - One consented setup moment: a new `workos setup` owns skills + MCP together. Automatic offers after login/install are gated (silent in agent/CI/non-TTY/ JSON, never re-ask after a decline/completion) and nothing is written until the user confirms (or passes --yes). Replaces the silent auto-install. - Flat, gutterless output: swapped the clack engine for @inquirer/prompts behind the single UI facade (src/utils/ui.ts). Hand-styled: branded intro, aligned key/value rows, nested sub-steps, one accent color. De-boxed notices (telemetry = flat line, unclaimed-env = WARN pill). - Security fix: installer tool calls route through canUseTool (allowedTools: []) so installerCanUseTool's Bash allowlist actually runs — bare tool names had been auto-approving Bash and bypassing the gate (also silenced the SDK CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning). BREAKING CHANGE: `workos auth login` and `workos install` no longer auto-install skills. Skills + MCP are installed only through the consented setup flow (`workos setup`, or the post-login/install offer on a human TTY). Agent / CI / non-TTY / JSON runs write nothing. --- package.json | 3 +- pnpm-lock.yaml | 308 +++++++++++-- src/bin.ts | 74 +-- src/commands/api/index.spec.ts | 2 +- src/commands/api/index.ts | 8 +- src/commands/api/interactive.spec.ts | 2 +- src/commands/api/interactive.ts | 22 +- src/commands/claim.spec.ts | 26 +- src/commands/claim.ts | 30 +- src/commands/connection.spec.ts | 6 +- src/commands/connection.ts | 6 +- src/commands/debug.spec.ts | 4 +- src/commands/debug.ts | 12 +- src/commands/directory.spec.ts | 6 +- src/commands/directory.ts | 6 +- src/commands/doctor.ts | 4 +- src/commands/env.spec.ts | 24 +- src/commands/env.ts | 34 +- src/commands/install.spec.ts | 100 +--- src/commands/install.ts | 18 +- src/commands/login.spec.ts | 114 +---- src/commands/login.ts | 74 +-- src/commands/logout.ts | 8 +- src/commands/mcp.spec.ts | 16 +- src/commands/mcp.ts | 23 +- src/commands/setup.spec.ts | 338 ++++++++++++++ src/commands/setup.ts | 274 +++++++++++ src/integrations/dotnet/index.ts | 2 +- src/integrations/elixir/index.ts | 2 +- src/integrations/go/index.ts | 2 +- src/integrations/nextjs/index.ts | 10 +- src/integrations/nextjs/utils.spec.ts | 34 +- src/integrations/nextjs/utils.ts | 14 +- src/integrations/no-skill-tool.spec.ts | 4 +- src/integrations/react-router/index.ts | 10 +- src/integrations/react-router/utils.spec.ts | 20 +- src/integrations/react-router/utils.ts | 26 +- src/integrations/ruby/index.ts | 2 +- src/lib/adapters/cli-adapter.spec.ts | 88 ++-- src/lib/adapters/cli-adapter.ts | 154 +++---- src/lib/agent-interface.ts | 8 +- src/lib/agent-runner.ts | 2 +- src/lib/config.ts | 2 +- src/lib/mcp-clients.ts | 14 + src/lib/mcp-notice.spec.ts | 428 ------------------ src/lib/mcp-notice.ts | 276 ----------- src/lib/preferences.ts | 65 ++- src/lib/run-with-core.ts | 4 +- src/lib/telemetry-notice.spec.ts | 28 +- src/lib/telemetry-notice.ts | 7 +- src/lib/unclaimed-env-provision.spec.ts | 10 +- src/lib/unclaimed-env-provision.ts | 8 +- src/lib/unclaimed-warning.spec.ts | 34 +- src/lib/unclaimed-warning.ts | 8 +- src/lib/workos-management.ts | 50 +- .../add-or-update-environment-variables.ts | 20 +- src/steps/run-prettier.ts | 6 +- .../index.spec.ts | 12 +- .../upload-environment-variables/index.ts | 6 +- .../providers/vercel.ts | 4 +- src/utils/box.ts | 12 + src/utils/clack.ts | 41 -- src/utils/debug.spec.ts | 12 +- src/utils/debug.ts | 6 +- src/utils/environment.ts | 2 +- src/utils/help-json.ts | 12 + src/utils/package-manager.ts | 2 +- .../{clack-utils.spec.ts => ui-utils.spec.ts} | 20 +- src/utils/{clack-utils.ts => ui-utils.ts} | 65 ++- src/utils/ui.spec.ts | 212 +++++++++ src/utils/ui.ts | 327 +++++++++++++ 71 files changed, 2086 insertions(+), 1527 deletions(-) create mode 100644 src/commands/setup.spec.ts create mode 100644 src/commands/setup.ts delete mode 100644 src/lib/mcp-notice.spec.ts delete mode 100644 src/lib/mcp-notice.ts delete mode 100644 src/utils/clack.ts rename src/utils/{clack-utils.spec.ts => ui-utils.spec.ts} (88%) rename src/utils/{clack-utils.ts => ui-utils.ts} (91%) create mode 100644 src/utils/ui.spec.ts create mode 100644 src/utils/ui.ts diff --git a/package.json b/package.json index 1a904b58..d67a4039 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.3.0", "@anthropic-ai/sdk": "^0.106.0", - "@clack/core": "^1.0.1", - "@clack/prompts": "1.6.0", + "@inquirer/prompts": "^8.5.2", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^8.7.0", "@workos/emulate": "^0.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f6f0bfc..01e26b4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.106.0 version: 0.106.0(zod@4.4.3) - '@clack/core': - specifier: ^1.0.1 - version: 1.4.3 - '@clack/prompts': - specifier: 1.6.0 - version: 1.6.0 + '@inquirer/prompts': + specifier: ^8.5.2 + version: 8.5.2(@types/node@22.20.0) '@napi-rs/keyring': specifier: ^1.2.0 version: 1.3.0 @@ -289,18 +286,6 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} - '@clack/core@1.4.2': - resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} - engines: {node: '>= 20.12.0'} - - '@clack/core@1.4.3': - resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} - engines: {node: '>= 20.12.0'} - - '@clack/prompts@1.6.0': - resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} - engines: {node: '>= 20.12.0'} - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -476,6 +461,19 @@ packages: resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/confirm@5.1.21': resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} @@ -485,6 +483,15 @@ packages: '@types/node': optional: true + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@10.3.2': resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} engines: {node: '>=18'} @@ -494,10 +501,113 @@ packages: '@types/node': optional: true + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@1.0.15': resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/type@3.0.10': resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} @@ -507,6 +617,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1261,6 +1380,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -1958,6 +2080,10 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2949,23 +3075,6 @@ snapshots: tough-cookie: 4.1.4 optional: true - '@clack/core@1.4.2': - dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - - '@clack/core@1.4.3': - dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - - '@clack/prompts@1.6.0': - dependencies: - '@clack/core': 1.4.2 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -3067,6 +3176,17 @@ snapshots: '@inquirer/ansi@1.0.2': optional: true + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@22.20.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + '@inquirer/confirm@5.1.21(@types/node@22.20.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@22.20.0) @@ -3075,6 +3195,13 @@ snapshots: '@types/node': 22.20.0 optional: true + '@inquirer/confirm@6.1.1(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + '@inquirer/core@10.3.2(@types/node@22.20.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -3089,14 +3216,115 @@ snapshots: '@types/node': 22.20.0 optional: true + '@inquirer/core@11.2.1(@types/node@22.20.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/editor@5.2.2(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/external-editor': 3.0.3(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/expand@5.1.1(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/external-editor@3.0.3(@types/node@22.20.0)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.20.0 + '@inquirer/figures@1.0.15': optional: true + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/number@4.1.1(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/password@5.1.1(@types/node@22.20.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/prompts@8.5.2(@types/node@22.20.0)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@22.20.0) + '@inquirer/confirm': 6.1.1(@types/node@22.20.0) + '@inquirer/editor': 5.2.2(@types/node@22.20.0) + '@inquirer/expand': 5.1.1(@types/node@22.20.0) + '@inquirer/input': 5.1.2(@types/node@22.20.0) + '@inquirer/number': 4.1.1(@types/node@22.20.0) + '@inquirer/password': 5.1.1(@types/node@22.20.0) + '@inquirer/rawlist': 5.3.1(@types/node@22.20.0) + '@inquirer/search': 4.2.1(@types/node@22.20.0) + '@inquirer/select': 5.2.1(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/rawlist@5.3.1(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/search@4.2.1(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + + '@inquirer/select@5.2.1(@types/node@22.20.0)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.0) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + '@inquirer/type@3.0.10(@types/node@22.20.0)': optionalDependencies: '@types/node': 22.20.0 optional: true + '@inquirer/type@4.0.7(@types/node@22.20.0)': + optionalDependencies: + '@types/node': 22.20.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3746,6 +3974,8 @@ snapshots: chalk@5.6.2: {} + chardet@2.2.0: {} + cli-boxes@3.0.0: {} cli-cursor@4.0.0: @@ -3761,8 +3991,7 @@ snapshots: slice-ansi: 7.1.2 string-width: 8.2.1 - cli-width@4.1.0: - optional: true + cli-width@4.1.0: {} cliui@8.0.1: dependencies: @@ -4425,6 +4654,8 @@ snapshots: mute-stream@2.0.0: optional: true + mute-stream@3.0.0: {} + nanoid@3.3.15: {} negotiator@1.0.0: {} @@ -4739,8 +4970,7 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.1.0: - optional: true + signal-exit@4.1.0: {} sirv@3.0.2: dependencies: diff --git a/src/bin.ts b/src/bin.ts index b159927c..691ae129 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -19,7 +19,7 @@ import { checkForUpdates } from './lib/version-check.js'; const NODE_VERSION_RANGE = getConfig().nodeVersion; -// Have to run this above the other imports because they are importing clack that +// Have to run this above the other imports because they are importing the UI facade that // has the problematic imports. if (!satisfies(process.version, NODE_VERSION_RANGE)) { red( @@ -43,7 +43,7 @@ import { outputError, exitWithError, } from './utils/output.js'; -import clack from './utils/clack.js'; +import ui from './utils/ui.js'; import { registerSubcommand } from './utils/register-subcommand.js'; import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js'; import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js'; @@ -310,8 +310,9 @@ async function runCli(): Promise { }) .middleware(async (argv) => { // Warn about unclaimed environments before management commands. - // Excluded: auth/claim/install/dashboard handle their own credential flows; - // skills/doctor/env/debug are utility commands where the warning is unnecessary. + // Excluded: auth/claim/install/setup/dashboard handle their own credential + // or onboarding flows; skills/doctor/env/debug are utility commands where + // the warning is unnecessary. const command = String(argv._?.[0] ?? ''); if ( [ @@ -321,6 +322,7 @@ async function runCli(): Promise { 'env', 'claim', 'install', + 'setup', 'debug', 'dashboard', 'emulate', @@ -333,36 +335,6 @@ async function runCli(): Promise { await applyInsecureStorage(argv.insecureStorage as boolean | undefined); await maybeWarnUnclaimed(); }) - .middleware(async (argv) => { - // One-time MCP banner (lowest-priority startup notice — runs after the - // telemetry notice + unclaimed warning so they win the one-per-run slot). - // Skip commands that manage MCP/agents directly or where the nudge is - // noise, mirroring + extending maybeWarnUnclaimed's list. Self-guarded and - // never throws. - const command = String(argv._?.[0] ?? ''); - if ( - [ - 'mcp', - 'install', - 'doctor', - 'skills', - 'auth', - 'env', - 'claim', - 'debug', - 'dashboard', - 'emulate', - 'dev', - 'migrations', - 'telemetry', - 'completion', - '', - ].includes(command) - ) - return; - const { maybeShowMcpNotice } = await import('./lib/mcp-notice.js'); - await maybeShowMcpNotice(); - }) .command('auth', 'Manage authentication (login, logout, status)', (yargs) => { yargs.options(insecureStorageOption); registerSubcommand( @@ -2456,6 +2428,36 @@ async function runCli(): Promise { ); }, ) + .command( + 'setup', + 'Set up your coding agent (install WorkOS skills + MCP server)', + (yargs) => + yargs.options({ + ...insecureStorageOption, + agents: { type: 'string', describe: 'Comma-separated agent keys (claude-code, codex, cursor, goose)' }, + 'skills-only': { type: 'boolean', describe: 'Install skills only (skip the MCP server)' }, + 'mcp-only': { type: 'boolean', describe: 'Install the MCP server only (skip skills)' }, + yes: { type: 'boolean', alias: 'y', describe: 'Install without prompting' }, + reset: { type: 'boolean', describe: 'Re-enable automatic setup offers after a decline' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage as boolean | undefined); + const { runSetup } = await import('./commands/setup.js'); + await runSetup({ + trigger: 'command', + agents: argv.agents + ? String(argv.agents) + .split(',') + .map((a) => a.trim()) + .filter(Boolean) + : undefined, + skillsOnly: argv.skillsOnly as boolean | undefined, + mcpOnly: argv.mcpOnly as boolean | undefined, + assumeYes: argv.yes as boolean | undefined, + reset: argv.reset as boolean | undefined, + }); + }, + ) .command( 'setup-org ', 'One-shot organization onboarding (create org, domain, roles, portal link)', @@ -2771,11 +2773,11 @@ async function runCli(): Promise { } // TTY: ask if user wants to run installer - const shouldInstall = await clack.confirm({ + const shouldInstall = await ui.confirm({ message: 'Run the AuthKit installer?', }); - if (clack.isCancel(shouldInstall) || !shouldInstall) { + if (ui.isCancel(shouldInstall) || !shouldInstall) { return; } diff --git a/src/commands/api/index.spec.ts b/src/commands/api/index.spec.ts index 3b52830f..17e44054 100644 --- a/src/commands/api/index.spec.ts +++ b/src/commands/api/index.spec.ts @@ -65,7 +65,7 @@ vi.mock('../../lib/api-key.js', () => ({ const mockConfirm = vi.fn(); const mockIsCancel = vi.fn(() => false); -vi.mock('../../utils/clack.js', () => ({ +vi.mock('../../utils/ui.js', () => ({ default: { confirm: (...args: unknown[]) => mockConfirm(...args), isCancel: (...args: unknown[]) => mockIsCancel(...args), diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index 2f133f11..fc2e4a21 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -25,7 +25,7 @@ export interface ApiCommandOptions { const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); export async function runApiInteractive(options?: { apiKey?: string }): Promise { - // Interactive mode is inherently human-oriented (clack prompts, preview text, + // Interactive mode is inherently human-oriented (interactive prompts, preview text, // etc.). Refuse to enter it whenever JSON output was requested, regardless of // TTY status, so stdout stays machine-readable. if (isJsonMode()) { @@ -140,11 +140,11 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions recovery: confirmationRecovery(confirmCommand), }); } - const clack = (await import('../../utils/clack.js')).default; + const ui = (await import('../../utils/ui.js')).default; console.log(`\n${chalk.yellow('About to')} ${method} ${endpoint}`); if (hasBody) prettyPrint(body); - const ok = await clack.confirm({ message: 'Proceed?' }); - if (!ok || clack.isCancel(ok)) { + const ok = await ui.confirm({ message: 'Proceed?' }); + if (!ok || ui.isCancel(ok)) { exitWithCode(ExitCode.CANCELLED); } } diff --git a/src/commands/api/interactive.spec.ts b/src/commands/api/interactive.spec.ts index 2f3202b1..b52dd444 100644 --- a/src/commands/api/interactive.spec.ts +++ b/src/commands/api/interactive.spec.ts @@ -102,7 +102,7 @@ const mockConfirm = vi.fn(); const cancelSymbol = Symbol('cancel'); const mockIsCancel = vi.fn((value: unknown) => value === cancelSymbol); -vi.mock('../../utils/clack.js', () => ({ +vi.mock('../../utils/ui.js', () => ({ default: { select: (...args: unknown[]) => mockSelect(...args), text: (...args: unknown[]) => mockText(...args), diff --git a/src/commands/api/interactive.ts b/src/commands/api/interactive.ts index 4fd18f1c..f1f080ef 100644 --- a/src/commands/api/interactive.ts +++ b/src/commands/api/interactive.ts @@ -1,4 +1,4 @@ -import clack from '../../utils/clack.js'; +import ui from '../../utils/ui.js'; import { loadCatalog, endpointsByTag, type EndpointInfo } from './catalog.js'; import { apiRequest } from './request.js'; import { colorMethod, printResponse } from './format.js'; @@ -7,7 +7,7 @@ import { ExitCode, exitWithCode } from '../../utils/exit-codes.js'; import { exitWithError } from '../../utils/output.js'; function assertNotCancelled(value: T | symbol): T { - if (clack.isCancel(value)) exitWithCode(ExitCode.CANCELLED); + if (ui.isCancel(value)) exitWithCode(ExitCode.CANCELLED); return value as T; } @@ -16,7 +16,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise { const count = grouped.get(t)?.length ?? 0; @@ -27,7 +27,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise({ + await ui.select({ message: 'Select an endpoint:', options: endpoints.map((e) => ({ value: e, @@ -40,7 +40,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise { @@ -59,7 +59,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise { @@ -72,7 +72,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise 0) { const wantsOptional = assertNotCancelled( - await clack.confirm({ + await ui.confirm({ message: `Add optional query parameters? (${optionalParams.length} available)`, initialValue: false, }), @@ -81,7 +81,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise { @@ -137,7 +137,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise ({ const mockOpen = vi.fn().mockResolvedValue(undefined); vi.mock('open', () => ({ default: mockOpen })); -// Mock clack +// Mock the UI facade const mockSpinner = { start: vi.fn(), stop: vi.fn(), message: vi.fn(), }; -const mockClack = { +const mockUi = { log: { info: vi.fn(), warn: vi.fn(), @@ -25,7 +25,7 @@ const mockClack = { }, spinner: () => mockSpinner, }; -vi.mock('../utils/clack.js', () => ({ default: mockClack })); +vi.mock('../utils/ui.js', () => ({ default: mockUi })); // Mock output utilities const mockOutputJson = vi.fn(); @@ -95,7 +95,7 @@ describe('claim command', () => { await runClaim(); - expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found')); + expect(mockUi.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found')); }); it('exits with info when active environment is not unclaimed', async () => { @@ -108,7 +108,7 @@ describe('claim command', () => { await runClaim(); - expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found')); + expect(mockUi.log.info).toHaveBeenCalledWith(expect.stringContaining('No unclaimed environment found')); }); it('outputs JSON when no unclaimed environment in JSON mode', async () => { @@ -137,7 +137,7 @@ describe('claim command', () => { await runClaim(); - expect(mockClack.log.success).toHaveBeenCalledWith('Environment already claimed!'); + expect(mockUi.log.success).toHaveBeenCalledWith('Environment already claimed!'); expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); }); @@ -223,11 +223,11 @@ describe('claim command', () => { await claimPromise; const warnCall = vi - .mocked(mockClack.log.warn) + .mocked(mockUi.log.warn) .mock.calls.find((c) => /permanent|cannot be undone/i.test(String(c[0]))); expect(warnCall).toBeDefined(); // The permanence warning must fire before the browser is opened. - const warnOrder = vi.mocked(mockClack.log.warn).mock.invocationCallOrder[0]; + const warnOrder = vi.mocked(mockUi.log.warn).mock.invocationCallOrder[0]; const openOrder = vi.mocked(mockOpen).mock.invocationCallOrder[0]; expect(warnOrder).toBeLessThan(openOrder); }); @@ -303,7 +303,7 @@ describe('claim command', () => { await claimPromise; expect(mockSpinner.stop).toHaveBeenCalledWith('Claim timed out'); - expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Complete the claim in your browser')); + expect(mockUi.log.info).toHaveBeenCalledWith(expect.stringContaining('Complete the claim in your browser')); }); it('continues polling on transient poll errors', async () => { @@ -379,7 +379,7 @@ describe('claim command', () => { expect(mockSpinner.stop).toHaveBeenCalledWith('Claim token is invalid or expired.'); expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); - expect(mockClack.log.warn).toHaveBeenCalledWith(expect.stringContaining('workos auth login')); + expect(mockUi.log.warn).toHaveBeenCalledWith(expect.stringContaining('workos auth login')); }); it('shows connection issues after 3 consecutive poll failures', async () => { @@ -438,7 +438,7 @@ describe('claim command', () => { await claimPromise; expect(mockSpinner.stop).toHaveBeenCalledWith('Too many connection failures'); - expect(mockClack.log.error).toHaveBeenCalledWith(expect.stringContaining('Polling failed 10 times')); + expect(mockUi.log.error).toHaveBeenCalledWith(expect.stringContaining('Polling failed 10 times')); expect(mockMarkEnvironmentClaimed).not.toHaveBeenCalled(); }); @@ -464,7 +464,7 @@ describe('claim command', () => { await vi.advanceTimersByTimeAsync(6_000); await claimPromise; - expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('Could not open browser')); + expect(mockUi.log.info).toHaveBeenCalledWith(expect.stringContaining('Could not open browser')); }); it('timeout hint uses the npx form when launched via npm exec', async () => { @@ -491,7 +491,7 @@ describe('claim command', () => { await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5_000); await claimPromise; - expect(mockClack.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env list')); + expect(mockUi.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env list')); } finally { for (const k of NPM_KEYS) { if (saved[k] === undefined) delete process.env[k]; diff --git a/src/commands/claim.ts b/src/commands/claim.ts index fa476bf4..4f1bc5ae 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -7,7 +7,7 @@ */ import open from 'open'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { getActiveEnvironment, isUnclaimedEnvironment, markEnvironmentClaimed } from '../lib/config-store.js'; import { createClaimNonce, UnclaimedEnvApiError } from '../lib/unclaimed-env-api.js'; import { observeHostFailure } from '../lib/host-probe.js'; @@ -31,7 +31,7 @@ export async function runClaim(): Promise { if (isJsonMode()) { outputJson({ status: 'no_unclaimed_environment', message: 'No unclaimed environment found. Nothing to claim.' }); } else { - clack.log.info('No unclaimed environment found. Nothing to claim.'); + ui.log.info('No unclaimed environment found. Nothing to claim.'); } return; } @@ -41,7 +41,7 @@ export async function runClaim(): Promise { logInfo('[claim] Starting claim flow for environment:', activeEnv.name); try { - clack.log.step('Generating claim link...'); + ui.log.step('Generating claim link...'); const result = await createClaimNonce(activeEnv.clientId, activeEnv.claimToken); @@ -50,8 +50,8 @@ export async function runClaim(): Promise { if (isJsonMode()) { outputJson({ status: 'already_claimed', message: 'Environment already claimed!' }); } else { - clack.log.success('Environment already claimed!'); - clack.log.info(`Run \`${formatWorkOSCommand('auth login')}\` to connect your account.`); + ui.log.success('Environment already claimed!'); + ui.log.info(`Run \`${formatWorkOSCommand('auth login')}\` to connect your account.`); } return; } @@ -77,15 +77,15 @@ export async function runClaim(): Promise { }); } - clack.log.warn('Claiming permanently links this environment to your account and cannot be undone.'); - clack.log.info(`Open this URL to claim your environment:\n\n ${claimUrl}`); + ui.log.warn('Claiming permanently links this environment to your account and cannot be undone.'); + ui.log.info(`Open this URL to claim your environment:\n\n ${claimUrl}`); try { await open(claimUrl, { wait: false }); if (isAgentMode()) { - clack.log.info('Browser launch attempted. If it did not open on the host, use the URL above.'); + ui.log.info('Browser launch attempted. If it did not open on the host, use the URL above.'); } else { - clack.log.info('Browser opened automatically'); + ui.log.info('Browser opened automatically'); } } catch (openError) { observeHostFailure('browser-launch', openError, { @@ -94,11 +94,11 @@ export async function runClaim(): Promise { label: 'environment claim browser', }); logError('[claim] Failed to open browser:', openError instanceof Error ? openError.message : String(openError)); - clack.log.info('Could not open browser — open the URL above manually.'); + ui.log.info('Could not open browser — open the URL above manually.'); } // Poll for claim completion - const spinner = clack.spinner(); + const spinner = ui.spinner(); spinner.start('Waiting for claim...'); const startTime = Date.now(); @@ -111,7 +111,7 @@ export async function runClaim(): Promise { if (check.alreadyClaimed) { spinner.stop('Environment claimed!'); markEnvironmentClaimed(); - clack.log.info(`Run \`${formatWorkOSCommand('auth login')}\` to connect your account.`); + ui.log.info(`Run \`${formatWorkOSCommand('auth login')}\` to connect your account.`); return; } consecutiveFailures = 0; @@ -122,14 +122,14 @@ export async function runClaim(): Promise { // when the environment is claimed. Safe to promote to sandbox. spinner.stop('Claim token is invalid or expired.'); markEnvironmentClaimed(); - clack.log.warn(`Run \`${formatWorkOSCommand('auth login')}\` to set up your environment.`); + ui.log.warn(`Run \`${formatWorkOSCommand('auth login')}\` to set up your environment.`); return; } consecutiveFailures++; logError('[claim] Poll error:', pollError instanceof Error ? pollError.message : 'Unknown'); if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { spinner.stop('Too many connection failures'); - clack.log.error( + ui.log.error( `Polling failed ${consecutiveFailures} times in a row. Check your network and try again.\n` + `You can also complete the claim at: ${claimUrl}`, ); @@ -142,7 +142,7 @@ export async function runClaim(): Promise { } spinner.stop('Claim timed out'); - clack.log.info(`Complete the claim in your browser, then run \`${formatWorkOSCommand('env list')}\` to verify.`); + ui.log.info(`Complete the claim in your browser, then run \`${formatWorkOSCommand('env list')}\` to verify.`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logError('[claim] Error:', message); diff --git a/src/commands/connection.spec.ts b/src/commands/connection.spec.ts index 8a47b567..0c2e6d13 100644 --- a/src/commands/connection.spec.ts +++ b/src/commands/connection.spec.ts @@ -13,11 +13,11 @@ vi.mock('../lib/workos-client.js', () => ({ createWorkOSClient: () => ({ sdk: mockSdk }), })); -// Mock clack for confirmation prompts +// Mock the UI facade const mockConfirm = vi.fn(); const mockIsCancel = vi.fn(() => false); -vi.mock('../utils/clack.js', () => ({ +vi.mock('../utils/ui.js', () => ({ default: { confirm: (...args: unknown[]) => mockConfirm(...args), isCancel: (...args: unknown[]) => mockIsCancel(...args), @@ -142,7 +142,7 @@ describe('connection commands', () => { expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true); }); - it('cancels on clack cancel', async () => { + it('cancels on user cancel', async () => { mockConfirm.mockResolvedValue(Symbol('cancel')); mockIsCancel.mockReturnValue(true); await runConnectionDelete('conn_01ABC', {}, 'sk_test'); diff --git a/src/commands/connection.ts b/src/commands/connection.ts index 7053f080..b9e8cd39 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -5,7 +5,7 @@ import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; const handleApiError = createApiErrorHandler('Connection'); @@ -108,11 +108,11 @@ export async function runConnectionDelete( }); } - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: `Delete connection ${id}? This cannot be undone.`, }); - if (clack.isCancel(confirmed) || !confirmed) { + if (ui.isCancel(confirmed) || !confirmed) { console.log('Delete cancelled.'); return; } diff --git a/src/commands/debug.spec.ts b/src/commands/debug.spec.ts index 61f17cd1..25e472c0 100644 --- a/src/commands/debug.spec.ts +++ b/src/commands/debug.spec.ts @@ -66,10 +66,10 @@ vi.mock('../utils/output.js', () => ({ }), })); -// Mock clack +// Mock the UI facade const mockConfirm = vi.fn(); const mockIsCancel = vi.fn(() => false); -vi.mock('../utils/clack.js', () => ({ +vi.mock('../utils/ui.js', () => ({ default: { confirm: (...args: unknown[]) => mockConfirm(...args), isCancel: (...args: unknown[]) => mockIsCancel(...args), diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 675df5b4..dc7abe51 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { getCredentials, saveCredentials, @@ -241,15 +241,15 @@ export async function runDebugReset({ }); } - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: `Clear all ${targets}? This cannot be undone.`, }); - if (clack.isCancel(confirmed) || !confirmed) { + if (ui.isCancel(confirmed) || !confirmed) { if (isJsonMode()) { outputJson({ cleared: false, cancelled: true }); } else { - clack.log.info('Reset cancelled'); + ui.log.info('Reset cancelled'); } return; } @@ -262,7 +262,7 @@ export async function runDebugReset({ if (isJsonMode()) { outputJson({ cleared: true, credentials: clearCreds, config: clearConf, preferences: clearPrefs }); } else { - clack.log.success(`Cleared ${targets}`); + ui.log.success(`Cleared ${targets}`); } } @@ -358,7 +358,7 @@ export async function runDebugSimulate({ outputJson({ simulated: true, actions }); } else { for (const action of actions) { - clack.log.success(action); + ui.log.success(action); } } } diff --git a/src/commands/directory.spec.ts b/src/commands/directory.spec.ts index e06866cb..9f171b09 100644 --- a/src/commands/directory.spec.ts +++ b/src/commands/directory.spec.ts @@ -15,11 +15,11 @@ vi.mock('../lib/workos-client.js', () => ({ createWorkOSClient: () => ({ sdk: mockSdk }), })); -// Mock clack for confirmation prompts +// Mock the UI facade const mockConfirm = vi.fn(); const mockIsCancel = vi.fn(() => false); -vi.mock('../utils/clack.js', () => ({ +vi.mock('../utils/ui.js', () => ({ default: { confirm: (...args: unknown[]) => mockConfirm(...args), isCancel: (...args: unknown[]) => mockIsCancel(...args), @@ -172,7 +172,7 @@ describe('directory commands', () => { expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true); }); - it('cancels on clack cancel', async () => { + it('cancels on user cancel', async () => { mockConfirm.mockResolvedValue(Symbol('cancel')); mockIsCancel.mockReturnValue(true); await runDirectoryDelete('directory_01ABC', {}, 'sk_test'); diff --git a/src/commands/directory.ts b/src/commands/directory.ts index 2ca66ac3..cb3836a8 100644 --- a/src/commands/directory.ts +++ b/src/commands/directory.ts @@ -4,7 +4,7 @@ import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; const handleApiError = createApiErrorHandler('Directory'); @@ -101,11 +101,11 @@ export async function runDirectoryDelete( }); } - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: `Delete directory ${id}? This cannot be undone.`, }); - if (clack.isCancel(confirmed) || !confirmed) { + if (ui.isCancel(confirmed) || !confirmed) { console.log('Delete cancelled.'); return; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 747a6e5e..1fc636cf 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,6 @@ import type { ArgumentsCamelCase } from 'yargs'; import { runDoctor, outputReport } from '../doctor/index.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; import { CliExit } from '../utils/cli-exit.js'; @@ -37,7 +37,7 @@ export async function handleDoctor(argv: ArgumentsCamelCase): Promis } catch (error) { if (error instanceof CliExit) throw error; if (!options.json) { - clack.log.error(`Doctor failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + ui.log.error(`Doctor failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } else { console.error( JSON.stringify({ diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index 30d858e2..9494ca25 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -8,8 +8,8 @@ vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), })); -// Mock clack prompts -vi.mock('../utils/clack.js', () => ({ +// Mock the UI facade +vi.mock('../utils/ui.js', () => ({ default: { log: { success: vi.fn(), @@ -56,7 +56,7 @@ const { writeCredentialsEnv } = await import('../lib/env-writer.js'); const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const { CliExit } = await import('../utils/cli-exit.js'); -const clack = (await import('../utils/clack.js')).default; +const ui = (await import('../utils/ui.js')).default; describe('env commands', () => { beforeEach(() => { @@ -119,13 +119,13 @@ describe('env commands', () => { it('requires name and API key in agent mode without prompting', async () => { setInteractionMode({ mode: 'agent', source: 'env' }); await expect(runEnvAdd({ name: 'prod' })).rejects.toThrow(CliExit); - expect(clack.text).not.toHaveBeenCalled(); + expect(ui.text).not.toHaveBeenCalled(); }); it('requires name and API key in CI mode without prompting', async () => { setInteractionMode({ mode: 'ci', source: 'env' }); await expect(runEnvAdd({ name: 'prod' })).rejects.toThrow(CliExit); - expect(clack.text).not.toHaveBeenCalled(); + expect(ui.text).not.toHaveBeenCalled(); }); it('does not include placeholder commands in missing-args recovery metadata', async () => { @@ -174,7 +174,7 @@ describe('env commands', () => { await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); await runEnvRemove('prod'); const warnMsg = vi - .mocked(clack.log.warn) + .mocked(ui.log.warn) .mock.calls.map((c) => String(c[0])) .join('\n'); expect(warnMsg).toMatch(/local/i); @@ -197,7 +197,7 @@ describe('env commands', () => { }); await runEnvRemove('unclaimed'); const warnMsg = vi - .mocked(clack.log.warn) + .mocked(ui.log.warn) .mock.calls.map((c) => String(c[0])) .join('\n'); expect(warnMsg).toMatch(/local/i); @@ -263,7 +263,7 @@ describe('env commands', () => { describe('runEnvList', () => { it('shows info message when no environments', async () => { await runEnvList(); - expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('No environments configured')); + expect(ui.log.info).toHaveBeenCalledWith(expect.stringContaining('No environments configured')); }); it('does not throw when environments exist', async () => { @@ -395,7 +395,7 @@ describe('env commands', () => { await runEnvProvision(); expect(consoleOutput.join('\n')).toContain('sk_test_x'); - expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('env claim')); + expect(ui.log.info).toHaveBeenCalledWith(expect.stringContaining('env claim')); }); }); @@ -527,14 +527,14 @@ describe('env commands', () => { it('runEnvList empty hint uses the bare command when not launched via npx', async () => { await runEnvList(); - expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('workos env add')); - expect(clack.log.info).not.toHaveBeenCalledWith(expect.stringContaining('npx workos@latest')); + expect(ui.log.info).toHaveBeenCalledWith(expect.stringContaining('workos env add')); + expect(ui.log.info).not.toHaveBeenCalledWith(expect.stringContaining('npx workos@latest')); }); it('runEnvList empty hint uses npx form when launched via npm exec', async () => { process.env.npm_command = 'exec'; await runEnvList(); - expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env add')); + expect(ui.log.info).toHaveBeenCalledWith(expect.stringContaining('npx workos@latest env add')); }); it('unclaimed-table footer uses npx form when launched via npm exec', async () => { diff --git a/src/commands/env.ts b/src/commands/env.ts index 2d600a3f..94a57309 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { getConfig, saveConfig, isUnclaimedEnvironment, freshEnvKey } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; @@ -53,30 +53,30 @@ export async function runEnvAdd(options: { }); } else { // Interactive mode - const nameResult = await clack.text({ + const nameResult = await ui.text({ message: 'Enter a name for the environment (e.g., production, sandbox, local)', validate: (value) => validateEnvName(value), }); - if (clack.isCancel(nameResult)) exitWithCode(ExitCode.CANCELLED); + if (ui.isCancel(nameResult)) exitWithCode(ExitCode.CANCELLED); name = nameResult; - const typeResult = await clack.select({ + const typeResult = await ui.select({ message: 'Select the environment type', options: [ { value: 'production', label: 'Production' }, { value: 'sandbox', label: 'Sandbox' }, ], }); - if (clack.isCancel(typeResult)) exitWithCode(ExitCode.CANCELLED); + if (ui.isCancel(typeResult)) exitWithCode(ExitCode.CANCELLED); - const apiKeyResult = await clack.password({ + const apiKeyResult = await ui.password({ message: 'Enter the API key for this environment', validate: (value) => { if (!value) return 'API key is required'; return undefined; }, }); - if (clack.isCancel(apiKeyResult)) exitWithCode(ExitCode.CANCELLED); + if (ui.isCancel(apiKeyResult)) exitWithCode(ExitCode.CANCELLED); apiKey = apiKeyResult; const config = getOrCreateConfig(); @@ -95,9 +95,9 @@ export async function runEnvAdd(options: { } saveConfig(config); - clack.log.success(`Environment ${chalk.bold(name)} added`); + ui.log.success(`Environment ${chalk.bold(name)} added`); if (isFirst) { - clack.log.info(`Set as active environment`); + ui.log.info(`Set as active environment`); } return; } @@ -177,17 +177,17 @@ export async function runEnvProvision(): Promise { return; } - clack.log.success('Provisioned a new WorkOS environment'); + ui.log.success('Provisioned a new WorkOS environment'); console.log(''); console.log(` ${chalk.dim('API key')} ${result.apiKey}`); console.log(` ${chalk.dim('Client ID')} ${result.clientId}`); console.log(` ${chalk.dim('AuthKit')} ${result.authkitDomain}`); console.log(''); - clack.log.info( + ui.log.info( `Set as active environment (${key}). Run \`${formatWorkOSCommand('env claim')}\` to link it to your account (permanent).`, ); if (key !== 'unclaimed') { - clack.log.info(`Your earlier unclaimed environment(s) are kept. See \`${formatWorkOSCommand('env list')}\`.`); + ui.log.info(`Your earlier unclaimed environment(s) are kept. See \`${formatWorkOSCommand('env list')}\`.`); } } @@ -212,7 +212,7 @@ export async function runEnvRemove(name: string): Promise { delete config.environments[name]; if (!isJsonMode()) { - clack.log.warn( + ui.log.warn( wasUnclaimed ? `Removed only the local CLI config for "${name}". This environment was unclaimed — its claim token lived only here, so it can no longer be claimed.` : `Removed only the local CLI config for "${name}". The environment still exists in WorkOS.`, @@ -223,7 +223,7 @@ export async function runEnvRemove(name: string): Promise { const remaining = Object.keys(config.environments); config.activeEnvironment = remaining.length > 0 ? remaining[0] : undefined; if (config.activeEnvironment && !isJsonMode()) { - clack.log.info(`Active environment switched to ${chalk.bold(config.activeEnvironment)}`); + ui.log.info(`Active environment switched to ${chalk.bold(config.activeEnvironment)}`); } } @@ -260,11 +260,11 @@ export async function runEnvSwitch(name?: string): Promise { return { value: key, label }; }); - const selected = await clack.select({ + const selected = await ui.select({ message: 'Select an environment', options, }); - if (clack.isCancel(selected)) exitWithCode(ExitCode.CANCELLED); + if (ui.isCancel(selected)) exitWithCode(ExitCode.CANCELLED); name = selected as string; } @@ -290,7 +290,7 @@ export async function runEnvList(): Promise { if (isJsonMode()) { outputJson({ data: [] }); } else { - clack.log.info(`No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`); + ui.log.info(`No environments configured. Run \`${formatWorkOSCommand('env add')}\` to get started.`); } return; } diff --git a/src/commands/install.spec.ts b/src/commands/install.spec.ts index b686bd90..f2a899eb 100644 --- a/src/commands/install.spec.ts +++ b/src/commands/install.spec.ts @@ -4,15 +4,13 @@ vi.mock('../run.js', () => ({ runInstaller: vi.fn(), })); -vi.mock('./install-skill.js', () => ({ - autoInstallSkills: vi.fn(), +// The consolidated setup offer (skills + MCP) now runs behind one consented +// hook. handleInstall just invokes it after a successful install. +vi.mock('./setup.js', () => ({ + maybeRunSetupAfter: vi.fn(), })); -vi.mock('../lib/mcp-notice.js', () => ({ - maybeOfferMcpInstall: vi.fn(), -})); - -vi.mock('../utils/clack.js', () => ({ +vi.mock('../utils/ui.js', () => ({ default: { log: { info: vi.fn(), error: vi.fn() }, }, @@ -28,10 +26,8 @@ vi.mock('../utils/debug.js', () => ({ })); const { runInstaller } = await import('../run.js'); -const { autoInstallSkills } = await import('./install-skill.js'); -const { maybeOfferMcpInstall } = await import('../lib/mcp-notice.js'); -const clack = (await import('../utils/clack.js')).default; -const { isJsonMode, exitWithError } = await import('../utils/output.js'); +const { maybeRunSetupAfter } = await import('./setup.js'); +const { exitWithError } = await import('../utils/output.js'); const { CliExit } = await import('../utils/cli-exit.js'); const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); @@ -40,102 +36,53 @@ const { handleInstall } = await import('./install.js'); describe('handleInstall', () => { beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks resets call history but NOT implementations, so restore the + // default resolve here — otherwise the "setup offer throws" test's + // mockRejectedValue leaks into later tests (e.g. the CI-validation cases). + vi.mocked(maybeRunSetupAfter).mockResolvedValue(undefined); }); afterEach(() => { resetInteractionModeForTests(); }); - it('calls autoInstallSkills after successful install', async () => { + it('runs the setup offer after a successful install', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue(null); await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).resolves.toBeUndefined(); expect(runInstaller).toHaveBeenCalledOnce(); - expect(autoInstallSkills).toHaveBeenCalledOnce(); + expect(maybeRunSetupAfter).toHaveBeenCalledWith('install'); - // Verify order: autoInstallSkills called after runInstaller + // Order: setup offer runs after the installer. const runInstallerOrder = vi.mocked(runInstaller).mock.invocationCallOrder[0]; - const autoInstallOrder = vi.mocked(autoInstallSkills).mock.invocationCallOrder[0]; - expect(autoInstallOrder).toBeGreaterThan(runInstallerOrder); - }); - - it('offers the MCP install after skills, on the install-flow entry point', async () => { - vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue(null); - - await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).resolves.toBeUndefined(); - - expect(maybeOfferMcpInstall).toHaveBeenCalledWith({ entryPoint: 'install-flow' }); - const autoInstallOrder = vi.mocked(autoInstallSkills).mock.invocationCallOrder[0]; - const mcpOfferOrder = vi.mocked(maybeOfferMcpInstall).mock.invocationCallOrder[0]; - expect(mcpOfferOrder).toBeGreaterThan(autoInstallOrder); - }); - - it('prints an info line when skills were installed in a TTY session', async () => { - vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue({ - skills: ['workos', 'workos-widgets'], - agents: ['Claude Code'], - version: '0.4.0', - }); - vi.mocked(isJsonMode).mockReturnValue(false); - - await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).resolves.toBeUndefined(); - - expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('Installed 2 WorkOS skills for Claude Code')); + const setupOrder = vi.mocked(maybeRunSetupAfter).mock.invocationCallOrder[0]; + expect(setupOrder).toBeGreaterThan(runInstallerOrder); }); - it('does not print the info line when autoInstallSkills returns null', async () => { - vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue(null); - vi.mocked(isJsonMode).mockReturnValue(false); - - await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).resolves.toBeUndefined(); - - expect(clack.log.info).not.toHaveBeenCalled(); - }); - - it('suppresses the info line in JSON mode', async () => { - vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue({ - skills: ['workos'], - agents: ['Claude Code'], - version: '0.4.0', - }); - vi.mocked(isJsonMode).mockReturnValue(true); - - await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).resolves.toBeUndefined(); - - expect(clack.log.info).not.toHaveBeenCalled(); - }); - - it('does not call autoInstallSkills when runInstaller throws', async () => { + it('does not run setup when runInstaller throws', async () => { vi.mocked(runInstaller).mockRejectedValue(new Error('install failed')); await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow(CliExit); expect(runInstaller).toHaveBeenCalledOnce(); - expect(autoInstallSkills).not.toHaveBeenCalled(); + expect(maybeRunSetupAfter).not.toHaveBeenCalled(); }); - it('still exits 0 even if autoInstallSkills throws', async () => { + it('surfaces a CliExit if the setup offer throws (defense in depth)', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockRejectedValue(new Error('skill install exploded')); + // In production maybeRunSetupAfter never throws; this tests the outer catch. + vi.mocked(maybeRunSetupAfter).mockRejectedValue(new Error('setup exploded')); - // autoInstallSkills throwing will trigger the outer catch, which throws CliExit(1) - // But autoInstallSkills has its own internal catch in production — this tests defense in depth await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow(CliExit); expect(runInstaller).toHaveBeenCalledOnce(); - expect(autoInstallSkills).toHaveBeenCalledOnce(); + expect(maybeRunSetupAfter).toHaveBeenCalledOnce(); }); describe('CI-mode required-arg validation', () => { it('WORKOS_MODE=ci requires --api-key (validation triggered without the --ci flag)', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue(null); setInteractionMode({ mode: 'ci', source: 'env' }); await handleInstall({ _: ['install'], $0: 'workos' } as any); @@ -147,7 +94,6 @@ describe('handleInstall', () => { it('WORKOS_MODE=ci with all required args does not error', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue(null); setInteractionMode({ mode: 'ci', source: 'env' }); await handleInstall({ @@ -163,8 +109,6 @@ describe('handleInstall', () => { it('default (human) mode does not trigger CI validation', async () => { vi.mocked(runInstaller).mockResolvedValue(undefined as any); - vi.mocked(autoInstallSkills).mockResolvedValue(null); - await handleInstall({ _: ['install'], $0: 'workos' } as any); expect(exitWithError).not.toHaveBeenCalled(); diff --git a/src/commands/install.ts b/src/commands/install.ts index 8e4eb75f..5fae8719 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,12 +1,11 @@ import { runInstaller } from '../run.js'; import type { InstallerArgs } from '../run.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { exitWithError, isJsonMode } from '../utils/output.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; import { isCiMode } from '../utils/interaction-mode.js'; import type { ArgumentsCamelCase } from 'yargs'; -import { autoInstallSkills } from './install-skill.js'; -import { maybeOfferMcpInstall } from '../lib/mcp-notice.js'; +import { maybeRunSetupAfter } from './setup.js'; /** * Handle install command execution. @@ -32,17 +31,10 @@ export async function handleInstall(argv: ArgumentsCamelCase): Pr try { await runInstaller(options); - const skillResult = await autoInstallSkills(); - if (skillResult && !isJsonMode()) { - const skillWord = skillResult.skills.length === 1 ? 'skill' : 'skills'; - clack.log.info( - `Installed ${skillResult.skills.length} WorkOS ${skillWord} for ${skillResult.agents.join(', ')}. Your coding agent now has up-to-date WorkOS guidance.`, - ); - } - // Offer to connect the user's coding agent to WorkOS via MCP. Self-gating + // One consented moment offers skills + MCP together. Self-gating // (human/TTY-only, decline-respecting) and best-effort — never fails install. - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); + await maybeRunSetupAfter('install'); } catch (err) { const { getLogFilePath } = await import('../utils/debug.js'); const logPath = getLogFilePath(); @@ -58,7 +50,7 @@ export async function handleInstall(argv: ArgumentsCamelCase): Pr console.error(err.stack); } if (logPath) { - clack.log.info(`Debug logs: ${logPath}`); + ui.log.info(`Debug logs: ${logPath}`); } exitWithCode(ExitCode.GENERAL_ERROR); } diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts index dfd11893..dfb1df23 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -29,8 +29,8 @@ vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), })); -// Mock clack prompts -vi.mock('../utils/clack.js', () => ({ +// Mock the UI facade +vi.mock('../utils/ui.js', () => ({ default: { log: { success: vi.fn(), @@ -54,9 +54,10 @@ vi.mock('../lib/staging-api.js', () => ({ fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args), })); -// Mock skill install + JSON mode — installSkillsAfterLogin tests drive both. -vi.mock('./install-skill.js', () => ({ - autoInstallSkills: vi.fn(), +// The consolidated setup offer (skills + MCP) runs behind one consented hook +// after a successful login. runLogin just invokes it; gating lives in setup.ts. +vi.mock('./setup.js', () => ({ + maybeRunSetupAfter: vi.fn(), })); vi.mock('../utils/output.js', () => ({ @@ -80,12 +81,12 @@ vi.mock('node:os', async (importOriginal) => { }); const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); -const { provisionStagingEnvironment, installSkillsAfterLogin, runLogin } = await import('./login.js'); -const { autoInstallSkills } = await import('./install-skill.js'); +const { provisionStagingEnvironment, runLogin } = await import('./login.js'); +const { maybeRunSetupAfter } = await import('./setup.js'); const { isJsonMode, outputJson } = await import('../utils/output.js'); const { clearCredentials, setInsecureStorage } = await import('../lib/credentials.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); -const clackMod = await import('../utils/clack.js'); +const uiMod = await import('../utils/ui.js'); describe('login', () => { beforeEach(() => { @@ -359,7 +360,7 @@ describe('login', () => { it('prints manual fallback and attempts browser launch in agent mode', async () => { setInteractionMode({ mode: 'agent', source: 'env' }); - const infoSpy = vi.mocked(clackMod.default.log.info); + const infoSpy = vi.mocked(uiMod.default.log.info); await runLogin(); @@ -374,7 +375,7 @@ describe('login', () => { await runLogin(); - const successSpy = vi.mocked(clackMod.default.log.success); + const successSpy = vi.mocked(uiMod.default.log.success); const nowUsing = successSpy.mock.calls.map((c) => String(c[0])).find((m) => m.includes('Now using')); expect(nowUsing).toBeDefined(); expect(nowUsing).toContain('user@example.com'); @@ -387,7 +388,7 @@ describe('login', () => { environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, }); mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); - vi.mocked(clackMod.default.confirm).mockResolvedValueOnce(true); + vi.mocked(uiMod.default.confirm).mockResolvedValueOnce(true); await runLogin(); @@ -401,7 +402,7 @@ describe('login', () => { environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, }); mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); - vi.mocked(clackMod.default.confirm).mockResolvedValueOnce(false); + vi.mocked(uiMod.default.confirm).mockResolvedValueOnce(false); await runLogin(); @@ -418,10 +419,10 @@ describe('login', () => { await runLogin(); - expect(clackMod.default.confirm).not.toHaveBeenCalled(); + expect(uiMod.default.confirm).not.toHaveBeenCalled(); expect(getConfig()?.activeEnvironment).toBe('sandbox'); const warned = vi - .mocked(clackMod.default.log.warn) + .mocked(uiMod.default.log.warn) .mock.calls.map((c) => String(c[0])) .join('\n'); expect(warned).toContain('sandbox'); @@ -438,7 +439,7 @@ describe('login', () => { await runLogin(); - expect(clackMod.default.confirm).not.toHaveBeenCalled(); + expect(uiMod.default.confirm).not.toHaveBeenCalled(); expect(outputJson).toHaveBeenCalledWith( expect.objectContaining({ mismatch: true, @@ -449,84 +450,15 @@ describe('login', () => { }); }); - describe('installSkillsAfterLogin', () => { - it('invokes autoInstallSkills', async () => { - vi.mocked(autoInstallSkills).mockResolvedValueOnce(null); - - await installSkillsAfterLogin(); - - expect(autoInstallSkills).toHaveBeenCalledOnce(); - }); - - it('returns without throwing when autoInstallSkills rejects', async () => { - vi.mocked(autoInstallSkills).mockRejectedValueOnce(new Error('install boom')); - - // The whole point of the helper: login must keep its success even when - // skill install fails. Asserting no rejection IS the test. - await expect(installSkillsAfterLogin()).resolves.toBeUndefined(); - }); - - it('logs a one-line success message in human mode', async () => { - vi.mocked(autoInstallSkills).mockResolvedValueOnce({ - skills: ['workos', 'workos-widgets'], - agents: ['Claude Code', 'Codex'], - version: '0.4.0', - }); - - const infoSpy = vi.mocked(clackMod.default.log.info); - infoSpy.mockClear(); - - await installSkillsAfterLogin(); - - expect(infoSpy).toHaveBeenCalledOnce(); - const message = infoSpy.mock.calls[0]?.[0] as string; - expect(message).toContain('2 WorkOS skills'); - expect(message).toContain('Claude Code'); - expect(message).toContain('Codex'); - }); - - it('uses singular "skill" when exactly one skill installed', async () => { - vi.mocked(autoInstallSkills).mockResolvedValueOnce({ - skills: ['workos'], - agents: ['Claude Code'], - version: '0.4.0', - }); - - const infoSpy = vi.mocked(clackMod.default.log.info); - infoSpy.mockClear(); - - await installSkillsAfterLogin(); - - const message = infoSpy.mock.calls[0]?.[0] as string; - expect(message).toContain('1 WorkOS skill '); - expect(message).not.toContain('1 WorkOS skills'); - }); - - it('skips logging in JSON mode', async () => { - vi.mocked(isJsonMode).mockReturnValueOnce(true); - vi.mocked(autoInstallSkills).mockResolvedValueOnce({ - skills: ['workos'], - agents: ['Claude Code'], - version: '0.4.0', - }); - - const infoSpy = vi.mocked(clackMod.default.log.info); - infoSpy.mockClear(); - - await installSkillsAfterLogin(); - - expect(infoSpy).not.toHaveBeenCalled(); - }); - - it('skips logging when autoInstallSkills returns null', async () => { - vi.mocked(autoInstallSkills).mockResolvedValueOnce(null); - - const infoSpy = vi.mocked(clackMod.default.log.info); - infoSpy.mockClear(); + describe('setup offer after login', () => { + it('runs the consolidated setup offer on a successful login', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); - await installSkillsAfterLogin(); + await runLogin(); - expect(infoSpy).not.toHaveBeenCalled(); + // Skills no longer auto-install at login; the consented setup hook fires + // instead (its own gating decides whether anything is written). + expect(maybeRunSetupAfter).toHaveBeenCalledWith('login'); }); }); }); diff --git a/src/commands/login.ts b/src/commands/login.ts index 592fa12e..4b91703b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,6 +1,6 @@ import open from 'open'; import chalk from 'chalk'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js'; import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js'; import { refreshAccessToken } from '../lib/token-refresh-client.js'; @@ -9,47 +9,13 @@ import { fetchStagingCredentials } from '../lib/staging-api.js'; import { getConfig, saveConfig, getActiveEnvironment, setActiveEnvironment, freshEnvKey } from '../lib/config-store.js'; import type { CliConfig, EnvironmentConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; -import { autoInstallSkills } from './install-skill.js'; +import { maybeRunSetupAfter } from './setup.js'; import { isJsonMode, outputJson } from '../utils/output.js'; import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { ExitCode, exitWithAuthRequired, exitWithCode } from '../utils/exit-codes.js'; import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js'; import { observeHostFailure } from '../lib/host-probe.js'; -/** - * Best-effort skill install after a successful auth-login. - * - * Mirrors the install.ts hook copy, but wraps `autoInstallSkills` in its own - * try/catch AND a 30s timeout so a skill install hang (e.g. blocked filesystem - * call) never blocks login completion. Login already succeeded by the time - * this runs — the user having a working session is the contract that must hold. - * - * Extracted from runLogin so it can be unit-tested without standing up the - * device-auth polling loop. - */ -export const SKILL_INSTALL_TIMEOUT_MS = 30 * 1000; - -export async function installSkillsAfterLogin(): Promise { - let timeoutHandle: ReturnType | undefined; - try { - const timeout = new Promise((resolve) => { - timeoutHandle = setTimeout(() => resolve(null), SKILL_INSTALL_TIMEOUT_MS); - // Don't keep the event loop alive on this timer — process should exit - // immediately if everything else has resolved. - timeoutHandle.unref?.(); - }); - const result = await Promise.race([autoInstallSkills(), timeout]); - if (result && !isJsonMode()) { - const skillWord = result.skills.length === 1 ? 'skill' : 'skills'; - clack.log.info(`Installed ${result.skills.length} WorkOS ${skillWord} for ${result.agents.join(', ')}.`); - } - } catch { - // Skill install must never fail login. - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} - /** * Result of a post-login staging provision. Carries enough context for * `runLogin` to detect a cross-account switch and decide how to surface it @@ -180,27 +146,27 @@ export async function runLogin(): Promise { const authkitDomain = getAuthkitDomain(); - clack.log.step('Starting authentication...'); + ui.log.step('Starting authentication...'); let deviceAuth; try { deviceAuth = await requestDeviceCode({ clientId, authkitDomain }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); - clack.log.error(`Failed to start authentication: ${msg}`); + ui.log.error(`Failed to start authentication: ${msg}`); exitWithCode(ExitCode.GENERAL_ERROR); } - clack.log.info(`\nOpen this URL in your browser:\n`); + ui.log.info(`\nOpen this URL in your browser:\n`); console.log(` ${deviceAuth.verification_uri}`); console.log(`\nEnter code: ${deviceAuth.user_code}\n`); try { await open(deviceAuth.verification_uri_complete, { wait: false }); if (isAgentMode()) { - clack.log.info('Browser launch attempted. If it did not open on the host, use the manual URL and code above.'); + ui.log.info('Browser launch attempted. If it did not open on the host, use the manual URL and code above.'); } else { - clack.log.info('Browser opened automatically'); + ui.log.info('Browser opened automatically'); } } catch (error) { observeHostFailure('browser-launch', error, { @@ -208,10 +174,10 @@ export async function runLogin(): Promise { target: deviceAuth.verification_uri_complete, label: 'auth login browser', }); - clack.log.info('Could not open browser — open the URL above manually.'); + ui.log.info('Could not open browser — open the URL above manually.'); } - const spinner = clack.spinner(); + const spinner = ui.spinner(); spinner.start('Waiting for authentication...'); try { @@ -232,8 +198,8 @@ export async function runLogin(): Promise { }); spinner.stop('Authentication successful!'); - clack.log.success(`Logged in as ${result.email || result.userId}`); - clack.log.info(`Token expires in ${expiresInSec} seconds`); + ui.log.success(`Logged in as ${result.email || result.userId}`); + ui.log.info(`Token expires in ${expiresInSec} seconds`); const account = { email: result.email, userId: result.userId }; const provision = await provisionStagingEnvironment(result.accessToken, account); @@ -249,38 +215,38 @@ export async function runLogin(): Promise { if (provision.mismatch) { const priorLabel = provision.priorAccount?.email ?? provision.priorAccount?.clientId ?? provision.priorEnvName; if (isPromptAllowed()) { - const answer = await clack.confirm({ + const answer = await ui.confirm({ message: `You were using ${provision.priorEnvName} (${priorLabel}, a different account). Switch active environment to ${account.email ?? account.userId}'s Staging?`, initialValue: false, // default: keep current }); - if (!clack.isCancel(answer) && answer && provision.envName) { + if (!ui.isCancel(answer) && answer && provision.envName) { setActiveEnvironment(provision.envName); } } else { - clack.log.warn( + ui.log.warn( `Logged in as ${account.email ?? account.userId}, but the active environment "${provision.priorEnvName}" belongs to a different account (${priorLabel}). Keeping it active. Run \`${formatWorkOSCommand('env switch')}\` to change environments.`, ); } } const active = getActiveEnvironment(); if (active) { - clack.log.success(`Now using: ${active.name} (${active.type}) — ${account.email ?? account.userId}`); + ui.log.success(`Now using: ${active.name} (${active.type}) — ${account.email ?? account.userId}`); } else { - clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); + ui.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); } } else { - clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); + ui.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); } - await installSkillsAfterLogin(); + await maybeRunSetupAfter('login'); } catch (error) { if (error instanceof DeviceAuthTimeoutError) { spinner.stop('Authentication timed out'); - clack.log.error('Authentication timed out. Please try again.'); + ui.log.error('Authentication timed out. Please try again.'); } else { spinner.stop('Authentication failed'); const msg = error instanceof Error ? error.message : String(error); - clack.log.error(`Authentication error: ${msg}`); + ui.log.error(`Authentication error: ${msg}`); } exitWithCode(ExitCode.GENERAL_ERROR); } diff --git a/src/commands/logout.ts b/src/commands/logout.ts index ddcb8c6d..a187f759 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,9 +1,9 @@ -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { clearCredentials, hasCredentials, getCredentials } from '../lib/credentials.js'; export async function runLogout(): Promise { if (!hasCredentials()) { - clack.log.info('Not logged in'); + ui.log.info('Not logged in'); return; } @@ -11,8 +11,8 @@ export async function runLogout(): Promise { clearCredentials(); if (creds?.email) { - clack.log.success(`Logged out from ${creds.email}`); + ui.log.success(`Logged out from ${creds.email}`); } else { - clack.log.success('Logged out successfully'); + ui.log.success('Logged out successfully'); } } diff --git a/src/commands/mcp.spec.ts b/src/commands/mcp.spec.ts index d3abf6b1..500dd36b 100644 --- a/src/commands/mcp.spec.ts +++ b/src/commands/mcp.spec.ts @@ -8,12 +8,12 @@ vi.mock('../utils/exec-file.js', () => ({ execFileNoThrow: vi.fn(), })); -// clack.log writes to the raw stdout/stderr streams (not console.*), so capture +// ui.log writes to the raw stdout/stderr streams (not console.*), so capture // its human-mode output through a module mock instead of a console spy. -const { clackLogs } = vi.hoisted(() => ({ clackLogs: [] as string[] })); -vi.mock('../utils/clack.js', () => { +const { uiLogs } = vi.hoisted(() => ({ uiLogs: [] as string[] })); +vi.mock('../utils/ui.js', () => { const record = (msg: unknown) => { - clackLogs.push(String(msg)); + uiLogs.push(String(msg)); }; return { default: { @@ -66,7 +66,7 @@ let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); - clackLogs.length = 0; + uiLogs.length = 0; testHome = mkdtempSync(join(tmpdir(), 'mcp-test-')); vi.mocked(homedir).mockReturnValue(testHome); // Default: every shell-out succeeds (overridden per test). @@ -326,7 +326,7 @@ describe('runMcpInstall / runMcpRemove (human mode)', () => { makeDir('.cursor'); mockExec(() => ({ status: 0 })); await runMcpInstall(); - const joined = clackLogs.join('\n'); + const joined = uiLogs.join('\n'); expect(joined).toContain('Claude Code'); expect(joined).toContain('Codex'); expect(joined).toContain('Cursor'); @@ -336,7 +336,7 @@ describe('runMcpInstall / runMcpRemove (human mode)', () => { mockExec((_c, args) => (args[0] === '--version' ? { status: 1 } : { status: 0 })); const exit = await captureExit(() => runMcpInstall()); expect(exit).toBeUndefined(); - expect(clackLogs.join('\n')).toContain('No supported coding agents detected'); + expect(uiLogs.join('\n')).toContain('No supported coding agents detected'); }); it('exits 1 when any agent fails, after emitting the full matrix', async () => { @@ -349,7 +349,7 @@ describe('runMcpInstall / runMcpRemove (human mode)', () => { }); const exit = await captureExit(() => runMcpInstall()); expect(exit?.exitCode).toBe(1); - const joined = clackLogs.join('\n'); + const joined = uiLogs.join('\n'); expect(joined).toContain('Claude Code'); expect(joined).toContain('Cursor'); }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 10ce1170..30e3f055 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,10 +1,11 @@ -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { outputSuccess, outputJson, outputTable, exitWithError, isJsonMode } from '../utils/output.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; import { createMcpClients, detectMcpClients, MCP_AGENT_KEYS, + MCP_OUTCOME_LABELS, type McpAgentKey, type McpClientResult, } from '../lib/mcp-clients.js'; @@ -22,16 +23,6 @@ export interface McpCommandOptions { agent?: string[]; } -/** Human phrasing for each outcome (JSON mode emits the raw `outcome` value). */ -const OUTCOME_LABEL: Record = { - installed: 'installed', - 'already-installed': 'already installed', - removed: 'removed', - 'not-installed': 'not installed', - skipped: 'skipped', - failed: 'failed', -}; - /** * Validate `--agent` values against known keys. Unknown values exit with a * structured `unknown_agent` error. Returns undefined (no filter) when none @@ -54,7 +45,7 @@ function reportNoAgents(): void { if (isJsonMode()) { outputSuccess('No supported coding agents detected', { agents: [] }); } else { - clack.log.info('No supported coding agents detected (looked for Claude Code, Codex, Cursor).'); + ui.log.info('No supported coding agents detected (looked for Claude Code, Codex, Cursor).'); } } @@ -67,13 +58,13 @@ function reportResults(message: string, results: McpClientResult[]): void { outputSuccess(message, { agents: results }); } else { for (const r of results) { - const line = `${r.displayName}: ${OUTCOME_LABEL[r.outcome]}`; + const line = `${r.displayName}: ${MCP_OUTCOME_LABELS[r.outcome]}`; if (r.outcome === 'failed') { - clack.log.error(r.error ? `${line} — ${r.error}` : line); + ui.log.error(r.error ? `${line} — ${r.error}` : line); } else if (r.outcome === 'installed' || r.outcome === 'removed' || r.outcome === 'already-installed') { - clack.log.success(line); + ui.log.success(line); } else { - clack.log.info(line); + ui.log.info(line); } } } diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts new file mode 100644 index 00000000..96f6c86a --- /dev/null +++ b/src/commands/setup.spec.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── +const CANCEL = Symbol('cancel'); + +vi.mock('../utils/ui.js', () => ({ + default: { + heading: vi.fn(), + note: vi.fn(), + log: { info: vi.fn(), success: vi.fn(), error: vi.fn(), hint: vi.fn() }, + confirm: vi.fn(), + }, + isCancel: (v: unknown) => v === CANCEL, + CANCEL, +})); + +vi.mock('../utils/output.js', () => ({ + isJsonMode: vi.fn(() => false), + outputSuccess: vi.fn(), + exitWithError: vi.fn((e: { code: string }) => { + throw new Error(`exit:${e.code}`); + }), +})); + +vi.mock('../utils/exit-codes.js', () => ({ + ExitCode: { GENERAL_ERROR: 1 }, + exitWithCode: vi.fn((c: number) => { + throw new Error(`exitCode:${c}`); + }), +})); + +vi.mock('../utils/interaction-mode.js', () => ({ + isPromptAllowed: vi.fn(() => true), +})); + +vi.mock('../lib/preferences.js', () => ({ + isSetupDeclined: vi.fn(() => false), + isSetupCompleted: vi.fn(() => false), + recordSetupDeclined: vi.fn(), + recordSetupCompleted: vi.fn(), + clearSetupDecline: vi.fn(), +})); + +vi.mock('./install-skill.js', () => ({ + createAgents: vi.fn(() => ({ + 'claude-code': { name: 'claude-code', displayName: 'Claude Code' }, + cursor: { name: 'cursor', displayName: 'Cursor' }, + })), + detectAgents: vi.fn(), + refreshWorkOSSkills: vi.fn(), +})); + +vi.mock('../lib/mcp-clients.js', () => ({ + detectMcpClients: vi.fn(), + MCP_AGENT_KEYS: ['claude-code', 'codex', 'cursor'], + MCP_OUTCOME_LABELS: { + installed: 'installed', + 'already-installed': 'already installed', + removed: 'removed', + 'not-installed': 'not installed', + skipped: 'skipped', + failed: 'failed', + }, +})); + +vi.mock('../utils/analytics.js', () => ({ + analytics: { emitCommandEvent: vi.fn() }, +})); + +vi.mock('../utils/command-invocation.js', () => ({ + formatWorkOSCommand: (a: string) => `workos ${a}`, +})); + +const ui = (await import('../utils/ui.js')).default; +const { isJsonMode, outputSuccess } = await import('../utils/output.js'); +const { isPromptAllowed } = await import('../utils/interaction-mode.js'); +const prefs = await import('../lib/preferences.js'); +const { detectAgents, refreshWorkOSSkills } = await import('./install-skill.js'); +const { detectMcpClients } = await import('../lib/mcp-clients.js'); +const { analytics } = await import('../utils/analytics.js'); + +const { runSetup, maybeRunSetupAfter, SETUP_OFFER_TIMEOUT_MS } = await import('./setup.js'); + +// ── Fixtures ────────────────────────────────────────────────────────────────── +const claudeAgent = { name: 'claude-code', displayName: 'Claude Code', globalSkillsDir: '/x', detect: () => true }; +function mcpTarget(overrides: Partial> = {}) { + return { + key: 'claude-code', + displayName: 'Claude Code', + isAvailable: vi.fn(async () => true), + isInstalled: vi.fn(async () => false), + add: vi.fn(async () => ({ agent: 'claude-code', displayName: 'Claude Code', outcome: 'installed' })), + remove: vi.fn(), + ...overrides, + }; +} + +function detectSome() { + vi.mocked(detectAgents).mockReturnValue([claudeAgent as any]); + vi.mocked(detectMcpClients).mockResolvedValue([mcpTarget() as any]); +} +function detectNone() { + vi.mocked(detectAgents).mockReturnValue([]); + vi.mocked(detectMcpClients).mockResolvedValue([]); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isPromptAllowed).mockReturnValue(true); + vi.mocked(isJsonMode).mockReturnValue(false); + vi.mocked(prefs.isSetupDeclined).mockReturnValue(false); + vi.mocked(prefs.isSetupCompleted).mockReturnValue(false); + vi.mocked(refreshWorkOSSkills).mockResolvedValue({ + agents: [claudeAgent as any], + skills: ['workos', 'workos-widgets'], + version: '1.0.0', + perAgentBefore: {}, + perAgentAfter: {}, + }); +}); + +describe('runSetup — automatic triggers (login/install)', () => { + it('does nothing when prompting is not allowed (agent/CI/non-TTY)', async () => { + detectSome(); + vi.mocked(isPromptAllowed).mockReturnValue(false); + + await runSetup({ trigger: 'login' }); + + expect(ui.confirm).not.toHaveBeenCalled(); + expect(refreshWorkOSSkills).not.toHaveBeenCalled(); + // Bails BEFORE agent detection so a machine login never pays for the + // MCP client shell-outs (`claude mcp list`, etc.). + expect(detectMcpClients).not.toHaveBeenCalled(); + expect(detectAgents).not.toHaveBeenCalled(); + }); + + it('does nothing in JSON mode', async () => { + detectSome(); + vi.mocked(isJsonMode).mockReturnValue(true); + + await runSetup({ trigger: 'install' }); + + expect(ui.confirm).not.toHaveBeenCalled(); + expect(refreshWorkOSSkills).not.toHaveBeenCalled(); + }); + + it('does nothing when already declined (incl. legacy mcp decline)', async () => { + detectSome(); + vi.mocked(prefs.isSetupDeclined).mockReturnValue(true); + + await runSetup({ trigger: 'login' }); + + expect(ui.confirm).not.toHaveBeenCalled(); + }); + + it('does nothing when setup already completed', async () => { + detectSome(); + vi.mocked(prefs.isSetupCompleted).mockReturnValue(true); + + await runSetup({ trigger: 'login' }); + + expect(ui.confirm).not.toHaveBeenCalled(); + }); + + it('installs skills + MCP and records completion on accept', async () => { + detectSome(); + vi.mocked(ui.confirm).mockResolvedValue(true); + const target = mcpTarget(); + vi.mocked(detectMcpClients).mockResolvedValue([target as any]); + + await runSetup({ trigger: 'login' }); + + expect(refreshWorkOSSkills).toHaveBeenCalledWith({ agents: [claudeAgent] }); + expect(target.add).toHaveBeenCalledOnce(); + expect(prefs.recordSetupCompleted).toHaveBeenCalledOnce(); + expect(prefs.recordSetupDeclined).not.toHaveBeenCalled(); + expect(analytics.emitCommandEvent).toHaveBeenCalledWith( + 'setup offer', + expect.any(Number), + true, + expect.objectContaining({ extraAttributes: expect.objectContaining({ 'setup.accepted': true }) }), + ); + }); + + it('records an absolute decline and installs nothing on "no"', async () => { + detectSome(); + vi.mocked(ui.confirm).mockResolvedValue(false); + + await runSetup({ trigger: 'login' }); + + expect(prefs.recordSetupDeclined).toHaveBeenCalledOnce(); + expect(refreshWorkOSSkills).not.toHaveBeenCalled(); + expect(prefs.recordSetupCompleted).not.toHaveBeenCalled(); + }); + + it('treats cancel (ctrl-c) as skip — no decline recorded', async () => { + detectSome(); + vi.mocked(ui.confirm).mockResolvedValue(CANCEL); + + await runSetup({ trigger: 'login' }); + + expect(prefs.recordSetupDeclined).not.toHaveBeenCalled(); + expect(prefs.recordSetupCompleted).not.toHaveBeenCalled(); + expect(refreshWorkOSSkills).not.toHaveBeenCalled(); + }); + + it('stays silent when no supported agents are detected', async () => { + detectNone(); + + await runSetup({ trigger: 'login' }); + + expect(ui.confirm).not.toHaveBeenCalled(); + expect(ui.log.info).not.toHaveBeenCalled(); + }); +}); + +describe('runSetup — command trigger', () => { + it('reset clears the decline and returns without offering', async () => { + await runSetup({ trigger: 'command', reset: true }); + + expect(prefs.clearSetupDecline).toHaveBeenCalledOnce(); + expect(detectAgents).not.toHaveBeenCalled(); + }); + + it('reports "no agents" explicitly when invoked directly', async () => { + detectNone(); + + await runSetup({ trigger: 'command' }); + + expect(ui.log.info).toHaveBeenCalledWith(expect.stringContaining('No supported coding agents')); + }); + + it('ignores a prior decline/completion (always runs)', async () => { + detectSome(); + vi.mocked(prefs.isSetupDeclined).mockReturnValue(true); + vi.mocked(prefs.isSetupCompleted).mockReturnValue(true); + vi.mocked(ui.confirm).mockResolvedValue(true); + + await runSetup({ trigger: 'command' }); + + expect(refreshWorkOSSkills).toHaveBeenCalledOnce(); + }); + + it('errors with confirmation_required in non-interactive mode without --yes', async () => { + detectSome(); + vi.mocked(isPromptAllowed).mockReturnValue(false); + + await expect(runSetup({ trigger: 'command' })).rejects.toThrow('exit:confirmation_required'); + }); + + it('installs without prompting when --yes is passed', async () => { + detectSome(); + + await runSetup({ trigger: 'command', assumeYes: true }); + + expect(ui.confirm).not.toHaveBeenCalled(); + expect(refreshWorkOSSkills).toHaveBeenCalledOnce(); + expect(prefs.recordSetupCompleted).toHaveBeenCalledOnce(); + }); + + it('a "no" on a manual run does NOT record a permanent decline', async () => { + detectSome(); + vi.mocked(ui.confirm).mockResolvedValue(false); + + await runSetup({ trigger: 'command' }); + + expect(prefs.recordSetupDeclined).not.toHaveBeenCalled(); + }); + + it('skillsOnly skips MCP detection/install', async () => { + vi.mocked(detectAgents).mockReturnValue([claudeAgent as any]); + vi.mocked(ui.confirm).mockResolvedValue(true); + + await runSetup({ trigger: 'command', skillsOnly: true }); + + expect(detectMcpClients).not.toHaveBeenCalled(); + expect(refreshWorkOSSkills).toHaveBeenCalledOnce(); + }); + + it('mcpOnly skips skill install', async () => { + const target = mcpTarget(); + vi.mocked(detectMcpClients).mockResolvedValue([target as any]); + vi.mocked(ui.confirm).mockResolvedValue(true); + + await runSetup({ trigger: 'command', mcpOnly: true }); + + expect(detectAgents).not.toHaveBeenCalled(); + expect(refreshWorkOSSkills).not.toHaveBeenCalled(); + expect(target.add).toHaveBeenCalledOnce(); + }); + + it('rejects unknown --agents values', async () => { + await expect(runSetup({ trigger: 'command', agents: ['bogus'] })).rejects.toThrow('exit:unknown_agent'); + }); + + it('emits a JSON summary in JSON mode with --yes', async () => { + detectSome(); + vi.mocked(isJsonMode).mockReturnValue(true); + + await runSetup({ trigger: 'command', assumeYes: true }); + + expect(outputSuccess).toHaveBeenCalledWith('Setup complete', expect.objectContaining({ skills: expect.anything() })); + }); +}); + +describe('maybeRunSetupAfter', () => { + it('never throws even if the offer rejects', async () => { + detectSome(); + vi.mocked(ui.confirm).mockRejectedValue(new Error('boom')); + + await expect(maybeRunSetupAfter('login')).resolves.toBeUndefined(); + }); + + it('aborts a hung prompt after the deadline and resolves (never wedges login/install)', async () => { + vi.useFakeTimers(); + try { + detectSome(); + let captured: AbortSignal | undefined; + // A hung prompt settles to CANCEL only when its signal aborts — exactly + // what the real facade does when @inquirer throws on abort. (There is no + // Promise.race fallback anymore, so the mock must honor the signal.) + vi.mocked(ui.confirm).mockImplementation((opts: any) => { + captured = opts.signal; + return new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(CANCEL)); + }); + }); + + const pending = maybeRunSetupAfter('login'); + await vi.advanceTimersByTimeAsync(SETUP_OFFER_TIMEOUT_MS + 10); + + await expect(pending).resolves.toBeUndefined(); + expect(captured?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 00000000..d9e493c9 --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,274 @@ +/** + * `workos setup` — the consolidated, consented agent-setup moment. + * + * Owns BOTH surfaces that used to nag independently: + * - skills auto-install (previously silent + unconditional on login/install) + * - the MCP server offer (previously a separate prompt at end of `install`) + * + * One prompt, one place. It is called three ways: + * - trigger 'login' / 'install' → `maybeRunSetupAfter`, a best-effort hook that + * can never block or fail the parent flow, gated so it stays silent in + * agent/CI/non-TTY/JSON and never re-asks after a decline or completion. + * - trigger 'command' → the top-level `workos setup`, always runnable, with + * flags for granular / non-interactive use. + * + * The consent contract is the whole point: nothing is written to a coding agent + * unless the user says yes (or passes --yes). This replaces the auto-install + * that a customer called "prompt injection malware". + */ + +import { homedir } from 'node:os'; +import ui, { isCancel } from '../utils/ui.js'; +import { outputSuccess, exitWithError, isJsonMode } from '../utils/output.js'; +import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { isPromptAllowed } from '../utils/interaction-mode.js'; +import { + isSetupDeclined, + isSetupCompleted, + recordSetupDeclined, + recordSetupCompleted, + clearSetupDecline, +} from '../lib/preferences.js'; +import { createAgents, detectAgents, refreshWorkOSSkills, type AgentConfig } from './install-skill.js'; +import { + detectMcpClients, + MCP_AGENT_KEYS, + MCP_OUTCOME_LABELS, + type McpClientResult, + type McpClientTarget, +} from '../lib/mcp-clients.js'; +import { analytics } from '../utils/analytics.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +export type SetupTrigger = 'login' | 'install' | 'command'; + +export interface RunSetupOptions { + trigger: SetupTrigger; + /** Restrict to specific agent keys (e.g. claude-code, cursor). */ + agents?: string[]; + skillsOnly?: boolean; + mcpOnly?: boolean; + /** Skip the confirm and install directly (for non-interactive `workos setup --yes`). */ + assumeYes?: boolean; + /** Clear a prior decline so automatic offers resume, then return. */ + reset?: boolean; + /** Deadline signal — aborts a hung prompt (used by maybeRunSetupAfter). */ + signal?: AbortSignal; +} + +/** + * Deadline that bounds the interactive prompt (via AbortSignal) so an + * unanswered prompt can never wedge login/install. Once the user consents, the + * install itself runs to completion — it is not raced. + */ +export const SETUP_OFFER_TIMEOUT_MS = 30 * 1000; + +/** Validate an --agents filter against the known keys; exit with a structured error on unknown. */ +function validateAgentFilter(agents: string[] | undefined, known: string[]): string[] | undefined { + if (!agents || agents.length === 0) return undefined; + const unknown = agents.filter((a) => !known.includes(a)); + if (unknown.length > 0) { + exitWithError({ + code: 'unknown_agent', + message: `Unknown agent(s): ${unknown.join(', ')}. Supported: ${known.join(', ')}.`, + }); + } + return agents; +} + +/** Distinct display names across skill agents + MCP targets, for the offer copy. */ +function detectedNames(skillAgents: AgentConfig[], mcpTargets: McpClientTarget[]): string[] { + return Array.from(new Set([...skillAgents.map((a) => a.displayName), ...mcpTargets.map((t) => t.displayName)])); +} + +/** + * Run the consolidated setup flow. Behavior branches on `trigger`: + * - automatic (login/install): self-gates on mode + decline/complete, prompts, + * records an absolute decline on "no". + * - command: always runs; a "no" does NOT record a permanent decline. + */ +export async function runSetup(opts: RunSetupOptions): Promise { + if (opts.reset) { + clearSetupDecline(); + if (isJsonMode()) { + outputSuccess('Setup offers re-enabled', { reset: true }); + } else { + ui.log.success(`Setup offers re-enabled. Run \`${formatWorkOSCommand('setup')}\` to configure your agents.`); + } + return; + } + + const isCommand = opts.trigger === 'command'; + const agents = createAgents(homedir()); // cheap (path construction, no IO); built once + const agentFilter = isCommand + ? validateAgentFilter(opts.agents, [...Object.keys(agents), ...MCP_AGENT_KEYS]) + : opts.agents; + + // Gate BEFORE any agent detection — detectMcpClients shells out to `claude mcp + // list` etc., so a machine / declined / completed run must pay nothing. + if (!isCommand) { + // Automatic (login/install): silent in machine/non-interactive contexts, and + // never after a decline or a prior completion. + if (isJsonMode() || !isPromptAllowed()) return; + if (isSetupDeclined() || isSetupCompleted()) return; + } else if (!isPromptAllowed() && !opts.assumeYes) { + // Explicit `workos setup` in a non-interactive context needs --yes. + exitWithError({ + code: 'confirmation_required', + message: `Interactive setup needs a TTY. Re-run \`${formatWorkOSCommand('setup --yes')}\` to install non-interactively.`, + }); + } + + const wantSkills = !opts.mcpOnly; + const wantMcp = !opts.skillsOnly; + const skillAgents: AgentConfig[] = wantSkills ? detectAgents(agents, agentFilter) : []; + const mcpTargets: McpClientTarget[] = wantMcp ? await detectMcpClients(agentFilter) : []; + const names = detectedNames(skillAgents, mcpTargets); + + // Nothing to install to. Surface it only for an explicit invocation. + if (names.length === 0) { + if (isCommand) { + if (isJsonMode()) { + outputSuccess('No supported coding agents detected', { agents: [] }); + } else { + ui.log.info('No supported coding agents detected (looked for Claude Code, Codex, Cursor, Goose).'); + } + } + return; + } + + const startedAt = Date.now(); + + // The offer. + if (!opts.assumeYes) { + ui.heading('Set up your coding agent'); + const what = wantSkills && wantMcp ? 'WorkOS skills and the MCP server' : wantMcp ? 'the WorkOS MCP server' : 'WorkOS skills'; + ui.note( + `Add ${what} to ${names.join(', ')} so your coding agent can\n` + + `scaffold auth and manage WorkOS resources. Nothing is written until you confirm.`, + ); + + const answer = await ui.confirm({ message: 'Set up now?', initialValue: true, signal: opts.signal }); + // Cancel (ctrl-c / deadline) is not a decline — skip silently, ask again next time. + if (isCancel(answer)) return; + if (!answer) { + if (!isCommand) recordSetupDeclined(); + emitSetupEvent(opts.trigger, startedAt, false, { skills: [], mcpInstalled: [], mcpFailed: [] }); + ui.log.hint(`No problem. Run \`${formatWorkOSCommand('setup')}\` anytime.`); + return; + } + } + + // Install. + await installAndReport(opts, skillAgents, mcpTargets, startedAt); +} + +async function installAndReport( + opts: RunSetupOptions, + skillAgents: AgentConfig[], + mcpTargets: McpClientTarget[], + startedAt: number, +): Promise { + // Skills (local fs) and MCP (shell-outs to `claude mcp add` etc., which can + // each block toward a 10s timeout) are independent — run them concurrently, + // and run the per-agent MCP adds in parallel too. Empty inputs (skillsOnly / + // mcpOnly) resolve to null / [] without work. `target.add()` never rejects. + const [skillResult, mcpResults] = await Promise.all([ + skillAgents.length > 0 ? refreshWorkOSSkills({ agents: skillAgents }) : Promise.resolve(null), + Promise.all(mcpTargets.map((target) => target.add())), + ]); + const skillAgentNames = skillResult?.agents.map((a) => a.displayName) ?? []; + + recordSetupCompleted(); + + const mcpInstalled = mcpResults + .filter((r) => r.outcome === 'installed' || r.outcome === 'already-installed') + .map((r) => r.agent); + const mcpFailed = mcpResults.filter((r) => r.outcome === 'failed').map((r) => r.agent); + + emitSetupEvent(opts.trigger, startedAt, true, { + skills: skillResult?.agents.map((a) => a.name) ?? [], + mcpInstalled, + mcpFailed, + }); + + reportResults(skillResult ? { agents: skillAgentNames, count: skillResult.skills.length } : null, mcpResults); +} + +interface SkillSummary { + agents: string[]; + count: number; +} + +/** Emit the outcome in both output modes; exit non-zero if any MCP install failed. */ +function reportResults(skills: SkillSummary | null, mcp: McpClientResult[]): void { + if (isJsonMode()) { + outputSuccess('Setup complete', { skills, mcp }); + } else { + if (skills && skills.agents.length > 0) { + const word = skills.count === 1 ? 'skill' : 'skills'; + ui.log.success(`${skills.count} ${word} installed for ${skills.agents.join(', ')}`); + } + for (const r of mcp) { + const line = `MCP server: ${r.displayName} — ${MCP_OUTCOME_LABELS[r.outcome]}`; + if (r.outcome === 'failed') { + ui.log.error(r.error ? `${line} (${r.error})` : line); + } else { + ui.log.success(line); + } + } + if (mcp.some((r) => r.outcome === 'installed' || r.outcome === 'already-installed')) { + ui.log.hint('Your agent will authorize WorkOS via OAuth on first MCP use.'); + } + } + + if (mcp.some((r) => r.outcome === 'failed')) { + exitWithCode(ExitCode.GENERAL_ERROR); + } +} + +/** + * Queued adoption event (NOT capture()): setup runs as a sub-step after the + * installer session has ended, so folded session tags would never ship. A queued + * command event rides the CLI's final flush (the same pattern the old + * standalone MCP offer used). + */ +function emitSetupEvent( + trigger: SetupTrigger, + startedAt: number, + accepted: boolean, + agents: { skills: string[]; mcpInstalled: string[]; mcpFailed: string[] }, +): void { + analytics.emitCommandEvent('setup offer', Date.now() - startedAt, agents.mcpFailed.length === 0, { + extraAttributes: { + 'setup.trigger': trigger, + 'setup.accepted': accepted, + 'setup.skills_agents': agents.skills.join(','), + 'setup.mcp_installed': agents.mcpInstalled.join(','), + 'setup.mcp_failed': agents.mcpFailed.join(','), + }, + }); +} + +/** + * Best-effort setup offer after a successful `login` / `install`. + * + * Never throws into, wedges, or fails the parent flow: a try/catch swallows any + * error, and the deadline aborts the interactive prompt (AbortSignal → CANCEL, + * which releases stdin). The prompt is the only unbounded wait — detection and + * install are internally time-bounded — so aborting it is sufficient; the offer + * is NOT raced, so a consented install always runs to completion. The parent + * flow has already succeeded by the time this runs. + */ +export async function maybeRunSetupAfter(trigger: 'login' | 'install'): Promise { + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(), SETUP_OFFER_TIMEOUT_MS); + timer.unref?.(); + try { + await runSetup({ trigger, signal: deadline.signal }); + } catch { + // Setup must never fail or block login / install. + } finally { + clearTimeout(timer); + } +} diff --git a/src/integrations/dotnet/index.ts b/src/integrations/dotnet/index.ts index d107c662..57e4cc85 100644 --- a/src/integrations/dotnet/index.ts +++ b/src/integrations/dotnet/index.ts @@ -12,7 +12,7 @@ function hasCsproj(installDir: string): boolean { } } import { SPINNER_MESSAGE } from '../../lib/framework-config.js'; -import { getOrAskForWorkOSCredentials } from '../../utils/clack-utils.js'; +import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js'; import { analytics } from '../../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from '../../lib/constants.js'; import { initializeAgent, runAgent } from '../../lib/agent-interface.js'; diff --git a/src/integrations/elixir/index.ts b/src/integrations/elixir/index.ts index 36dd17a5..c200eec2 100644 --- a/src/integrations/elixir/index.ts +++ b/src/integrations/elixir/index.ts @@ -5,7 +5,7 @@ import { enableDebugLogs } from '../../utils/debug.js'; import { SPINNER_MESSAGE } from '../../lib/framework-config.js'; import { analytics } from '../../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from '../../lib/constants.js'; -import { getOrAskForWorkOSCredentials } from '../../utils/clack-utils.js'; +import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js'; import { initializeAgent, runAgent } from '../../lib/agent-interface.js'; import { writeEnvLocal } from '../../lib/env-writer.js'; import { getReference } from '@workos/skills'; diff --git a/src/integrations/go/index.ts b/src/integrations/go/index.ts index c2fdf082..03d46b0e 100644 --- a/src/integrations/go/index.ts +++ b/src/integrations/go/index.ts @@ -8,7 +8,7 @@ import { enableDebugLogs } from '../../utils/debug.js'; import { analytics } from '../../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from '../../lib/constants.js'; import { initializeAgent, runAgent } from '../../lib/agent-interface.js'; -import { getOrAskForWorkOSCredentials } from '../../utils/clack-utils.js'; +import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js'; import { autoConfigureWorkOSEnvironment } from '../../lib/workos-management.js'; import { validateInstallation } from '../../lib/validation/index.js'; import { parseEnvFile } from '../../utils/env-parser.js'; diff --git a/src/integrations/nextjs/index.ts b/src/integrations/nextjs/index.ts index d7d9179e..6753d15b 100644 --- a/src/integrations/nextjs/index.ts +++ b/src/integrations/nextjs/index.ts @@ -3,8 +3,8 @@ import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; import { getPackageVersion } from '../../utils/package-json.js'; -import { getPackageDotJson } from '../../utils/clack-utils.js'; -import clack from '../../utils/clack.js'; +import { getPackageDotJson } from '../../utils/ui-utils.js'; +import ui from '../../utils/ui.js'; import chalk from 'chalk'; import * as semver from 'semver'; import { getNextJsRouter, getNextJsVersionBucket, getNextJsRouterName, NextJsRouter } from './utils.js'; @@ -96,11 +96,11 @@ export async function run(options: InstallerOptions): Promise { if (coercedVersion && semver.lt(coercedVersion, MINIMUM_NEXTJS_VERSION)) { const docsUrl = config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl; - clack.log.warn( + ui.log.warn( `Sorry: the installer can't help you with Next.js ${nextVersion}. Upgrade to Next.js ${MINIMUM_NEXTJS_VERSION} or later, or check out the manual setup guide.`, ); - clack.log.info(`Setup Next.js manually: ${chalk.cyan(docsUrl)}`); - clack.outro('WorkOS AuthKit installer will see you next time!'); + ui.log.info(`Setup Next.js manually: ${chalk.cyan(docsUrl)}`); + ui.outro('WorkOS AuthKit installer will see you next time!'); return ''; } } diff --git a/src/integrations/nextjs/utils.spec.ts b/src/integrations/nextjs/utils.spec.ts index 13832f1d..13fcd3b1 100644 --- a/src/integrations/nextjs/utils.spec.ts +++ b/src/integrations/nextjs/utils.spec.ts @@ -3,22 +3,22 @@ import type { InteractionMode } from '../../utils/interaction-mode.js'; vi.mock('fast-glob', () => ({ default: vi.fn() })); -vi.mock('../../utils/clack.js', () => ({ +vi.mock('../../utils/ui.js', () => ({ default: { select: vi.fn(), isCancel: vi.fn(() => false), - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn() }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn(), detail: vi.fn() }, }, })); -// Passthrough — the guard itself is covered by clack-utils.spec.ts; here we only -// need clack.select's resolved value to flow through in the human path. -vi.mock('../../utils/clack-utils.js', () => ({ +// Passthrough — the guard itself is covered by ui-utils.spec.ts; here we only +// need ui.select's resolved value to flow through in the human path. +vi.mock('../../utils/ui-utils.js', () => ({ abortIfCancelled: vi.fn(async (p) => await p), })); const fg = (await import('fast-glob')).default; -const clack = (await import('../../utils/clack.js')).default; +const ui = (await import('../../utils/ui.js')).default; const { getNextJsRouter, NextJsRouter } = await import('./utils.js'); const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); @@ -36,7 +36,7 @@ describe('getNextJsRouter', () => { beforeEach(() => { resetInteractionModeForTests(); vi.clearAllMocks(); - vi.mocked(clack.isCancel).mockReturnValue(false); + vi.mocked(ui.isCancel).mockReturnValue(false); }); afterEach(() => { @@ -50,7 +50,7 @@ describe('getNextJsRouter', () => { const result = await getNextJsRouter({ installDir: '/proj' }); expect(result).toBe(NextJsRouter.PAGES_ROUTER); - expect(clack.select).not.toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); }); it.each(modes)('app-only detection returns app router without prompting (%s mode)', async (mode) => { @@ -60,18 +60,18 @@ describe('getNextJsRouter', () => { const result = await getNextJsRouter({ installDir: '/proj' }); expect(result).toBe(NextJsRouter.APP_ROUTER); - expect(clack.select).not.toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); }); it('ambiguous detection in human mode prompts and uses the answer', async () => { setInteractionMode({ mode: 'human', source: 'default' }); mockDetection({ pages: true, app: true }); - vi.mocked(clack.select).mockResolvedValueOnce(NextJsRouter.PAGES_ROUTER as never); + vi.mocked(ui.select).mockResolvedValueOnce(NextJsRouter.PAGES_ROUTER as never); const result = await getNextJsRouter({ installDir: '/proj' }); expect(result).toBe(NextJsRouter.PAGES_ROUTER); - expect(clack.select).toHaveBeenCalledOnce(); + expect(ui.select).toHaveBeenCalledOnce(); }); it('ambiguous detection in agent mode defaults to app router with a warning (no prompt)', async () => { @@ -81,8 +81,8 @@ describe('getNextJsRouter', () => { const result = await getNextJsRouter({ installDir: '/proj' }); expect(result).toBe(NextJsRouter.APP_ROUTER); - expect(clack.select).not.toHaveBeenCalled(); - expect(clack.log.warn).toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); + expect(ui.log.warn).toHaveBeenCalled(); }); it('ambiguous detection in ci mode defaults to app router with a warning (no prompt)', async () => { @@ -92,8 +92,8 @@ describe('getNextJsRouter', () => { const result = await getNextJsRouter({ installDir: '/proj' }); expect(result).toBe(NextJsRouter.APP_ROUTER); - expect(clack.select).not.toHaveBeenCalled(); - expect(clack.log.warn).toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); + expect(ui.log.warn).toHaveBeenCalled(); }); it('--router pages overrides ambiguous detection with no prompt', async () => { @@ -103,7 +103,7 @@ describe('getNextJsRouter', () => { const result = await getNextJsRouter({ installDir: '/proj', router: 'pages' }); expect(result).toBe(NextJsRouter.PAGES_ROUTER); - expect(clack.select).not.toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); }); it('--router app wins over detection with no prompt', async () => { @@ -113,6 +113,6 @@ describe('getNextJsRouter', () => { const result = await getNextJsRouter({ installDir: '/proj', router: 'app' }); expect(result).toBe(NextJsRouter.APP_ROUTER); - expect(clack.select).not.toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); }); }); diff --git a/src/integrations/nextjs/utils.ts b/src/integrations/nextjs/utils.ts index f6235e85..cfcd016c 100644 --- a/src/integrations/nextjs/utils.ts +++ b/src/integrations/nextjs/utils.ts @@ -1,6 +1,6 @@ import fg from 'fast-glob'; -import { abortIfCancelled } from '../../utils/clack-utils.js'; -import clack from '../../utils/clack.js'; +import { abortIfCancelled } from '../../utils/ui-utils.js'; +import ui from '../../utils/ui.js'; import { getVersionBucket } from '../../utils/semver.js'; import type { InstallerOptions } from '../../utils/types.js'; import { IGNORE_PATTERNS } from '../../lib/constants.js'; @@ -22,7 +22,7 @@ export async function getNextJsRouter({ // Explicit flag wins over detection (deterministic for agents). if (router) { const chosen = router === 'pages' ? NextJsRouter.PAGES_ROUTER : NextJsRouter.APP_ROUTER; - clack.log.info(`Using ${getNextJsRouterName(chosen)} (--router)`); + ui.log.info(`Using ${getNextJsRouterName(chosen)} (--router)`); return chosen; } @@ -43,12 +43,12 @@ export async function getNextJsRouter({ const hasAppDir = appMatches.length > 0; if (hasPagesDir && !hasAppDir) { - clack.log.info(`Detected ${getNextJsRouterName(NextJsRouter.PAGES_ROUTER)} 📃`); + ui.log.detail(`Detected ${getNextJsRouterName(NextJsRouter.PAGES_ROUTER)}`); return NextJsRouter.PAGES_ROUTER; } if (hasAppDir && !hasPagesDir) { - clack.log.info(`Detected ${getNextJsRouterName(NextJsRouter.APP_ROUTER)} 📱`); + ui.log.detail(`Detected ${getNextJsRouterName(NextJsRouter.APP_ROUTER)}`); return NextJsRouter.APP_ROUTER; } @@ -56,7 +56,7 @@ export async function getNextJsRouter({ // mode default to the app router (dominant/new-project case) with a warning // instead of prompting — the --router flag above is the escape hatch. if (!isPromptAllowed()) { - clack.log.warn( + ui.log.warn( 'Could not determine the Next.js router (both app/ and pages/ present, or neither). ' + 'Defaulting to app router. Pass --router app|pages to override.', ); @@ -64,7 +64,7 @@ export async function getNextJsRouter({ } const result: NextJsRouter = await abortIfCancelled( - clack.select({ + ui.select({ message: 'What router are you using?', options: [ { diff --git a/src/integrations/no-skill-tool.spec.ts b/src/integrations/no-skill-tool.spec.ts index 19750d5f..0b7a70b9 100644 --- a/src/integrations/no-skill-tool.spec.ts +++ b/src/integrations/no-skill-tool.spec.ts @@ -33,7 +33,9 @@ describe('no Skill tool references in integrations', () => { describe('allowedTools does not include Skill', () => { it('agent-interface.ts should not list Skill in allowedTools', () => { const content = readFileSync(join(import.meta.dirname, '..', 'lib', 'agent-interface.ts'), 'utf-8'); - const match = content.match(/allowedTools:\s*\[([^\]]+)\]/); + // `[^\]]*` (not `+`) so an intentionally-empty `allowedTools: []` still + // matches — the point is only that Skill is never auto-approved here. + const match = content.match(/allowedTools:\s*\[([^\]]*)\]/); expect(match).toBeTruthy(); expect(match![1]).not.toContain("'Skill'"); }); diff --git a/src/integrations/react-router/index.ts b/src/integrations/react-router/index.ts index 6423e3a6..ec3f234f 100644 --- a/src/integrations/react-router/index.ts +++ b/src/integrations/react-router/index.ts @@ -3,8 +3,8 @@ import type { FrameworkConfig } from '../../lib/framework-config.js'; import type { InstallerOptions } from '../../utils/types.js'; import { enableDebugLogs } from '../../utils/debug.js'; import { getPackageVersion } from '../../utils/package-json.js'; -import { getPackageDotJson } from '../../utils/clack-utils.js'; -import clack from '../../utils/clack.js'; +import { getPackageDotJson } from '../../utils/ui-utils.js'; +import ui from '../../utils/ui.js'; import chalk from 'chalk'; import * as semver from 'semver'; import { getReactRouterMode, getReactRouterModeName, getReactRouterVersionBucket, ReactRouterMode } from './utils.js'; @@ -99,11 +99,11 @@ export async function run(options: InstallerOptions): Promise { if (coercedVersion && semver.lt(coercedVersion, MINIMUM_REACT_ROUTER_VERSION)) { const docsUrl = config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl; - clack.log.warn( + ui.log.warn( `Sorry: the installer can't help you with React Router ${reactRouterVersion}. Upgrade to React Router ${MINIMUM_REACT_ROUTER_VERSION} or later, or check out the manual setup guide.`, ); - clack.log.info(`Setup React Router manually: ${chalk.cyan(docsUrl)}`); - clack.outro('WorkOS AuthKit installer will see you next time!'); + ui.log.info(`Setup React Router manually: ${chalk.cyan(docsUrl)}`); + ui.outro('WorkOS AuthKit installer will see you next time!'); return ''; } } diff --git a/src/integrations/react-router/utils.spec.ts b/src/integrations/react-router/utils.spec.ts index 5b5e05f9..093c5a2a 100644 --- a/src/integrations/react-router/utils.spec.ts +++ b/src/integrations/react-router/utils.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; vi.mock('fast-glob', () => ({ default: vi.fn(async () => []) })); -vi.mock('../../utils/clack.js', () => ({ +vi.mock('../../utils/ui.js', () => ({ default: { select: vi.fn(), isCancel: vi.fn(() => false), @@ -12,12 +12,12 @@ vi.mock('../../utils/clack.js', () => ({ // Passthrough abortIfCancelled + a package.json with no react-router version, so // getReactRouterMode always hits the ambiguous "no version" prompt branch. -vi.mock('../../utils/clack-utils.js', () => ({ +vi.mock('../../utils/ui-utils.js', () => ({ abortIfCancelled: vi.fn(async (p) => await p), getPackageDotJson: vi.fn(async () => ({})), })); -const clack = (await import('../../utils/clack.js')).default; +const ui = (await import('../../utils/ui.js')).default; const { getReactRouterMode, ReactRouterMode } = await import('./utils.js'); const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); @@ -25,7 +25,7 @@ describe('getReactRouterMode — ambiguous-branch defaults', () => { beforeEach(() => { resetInteractionModeForTests(); vi.clearAllMocks(); - vi.mocked(clack.isCancel).mockReturnValue(false); + vi.mocked(ui.isCancel).mockReturnValue(false); }); afterEach(() => { @@ -38,8 +38,8 @@ describe('getReactRouterMode — ambiguous-branch defaults', () => { const result = await getReactRouterMode({ installDir: '/proj' } as never); expect(result).toBe(ReactRouterMode.V7_FRAMEWORK); - expect(clack.select).not.toHaveBeenCalled(); - expect(clack.log.warn).toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); + expect(ui.log.warn).toHaveBeenCalled(); }); it('ci mode with no detectable version defaults to v7 Framework with a warning (no prompt)', async () => { @@ -48,17 +48,17 @@ describe('getReactRouterMode — ambiguous-branch defaults', () => { const result = await getReactRouterMode({ installDir: '/proj' } as never); expect(result).toBe(ReactRouterMode.V7_FRAMEWORK); - expect(clack.select).not.toHaveBeenCalled(); - expect(clack.log.warn).toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); + expect(ui.log.warn).toHaveBeenCalled(); }); it('human mode with no detectable version prompts and uses the answer', async () => { setInteractionMode({ mode: 'human', source: 'default' }); - vi.mocked(clack.select).mockResolvedValueOnce(ReactRouterMode.V6 as never); + vi.mocked(ui.select).mockResolvedValueOnce(ReactRouterMode.V6 as never); const result = await getReactRouterMode({ installDir: '/proj' } as never); expect(result).toBe(ReactRouterMode.V6); - expect(clack.select).toHaveBeenCalledOnce(); + expect(ui.select).toHaveBeenCalledOnce(); }); }); diff --git a/src/integrations/react-router/utils.ts b/src/integrations/react-router/utils.ts index c48479ce..c6483ded 100644 --- a/src/integrations/react-router/utils.ts +++ b/src/integrations/react-router/utils.ts @@ -1,7 +1,7 @@ import { major } from 'semver'; import fg from 'fast-glob'; -import { abortIfCancelled, getPackageDotJson } from '../../utils/clack-utils.js'; -import clack from '../../utils/clack.js'; +import { abortIfCancelled, getPackageDotJson } from '../../utils/ui-utils.js'; +import ui from '../../utils/ui.js'; import { getVersionBucket } from '../../utils/semver.js'; import type { InstallerOptions } from '../../utils/types.js'; import { IGNORE_PATTERNS } from '../../lib/constants.js'; @@ -83,7 +83,7 @@ async function hasDeclarativeRouter({ installDir }: Pick {}); -// Mock clack -vi.mock('../../utils/clack.js', () => ({ +// Mock the UI facade +vi.mock('../../utils/ui.js', () => ({ default: { intro: vi.fn(), log: { @@ -85,27 +85,27 @@ describe('CLIAdapter', () => { describe('start', () => { it('subscribes to events on start', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); - // Emit auth:success - uses clack.log.success + // Emit auth:success - uses ui.log.success emitter.emit('auth:success', {}); - expect(clack.default.log.success).toHaveBeenCalledWith('Authenticated'); + expect(ui.default.log.success).toHaveBeenCalledWith('Authenticated'); }); it('shows intro on start', async () => { - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); await adapter.start(); - expect(clack.default.intro).toHaveBeenCalledWith('Welcome to the WorkOS AuthKit installer'); + expect(ui.default.intro).toHaveBeenCalledWith('WorkOS', 'AuthKit installer'); }); it('is idempotent', async () => { - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); await adapter.start(); await adapter.start(); // Second call should be no-op - expect(clack.default.intro).toHaveBeenCalledTimes(1); + expect(ui.default.intro).toHaveBeenCalledTimes(1); }); }); @@ -114,13 +114,13 @@ describe('CLIAdapter', () => { await adapter.start(); await adapter.stop(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); vi.clearAllMocks(); // Emit an event - handler should NOT be called emitter.emit('auth:checking', {}); - expect(clack.default.log.step).not.toHaveBeenCalled(); + expect(ui.default.log.step).not.toHaveBeenCalled(); }); it('is idempotent', async () => { @@ -134,32 +134,32 @@ describe('CLIAdapter', () => { describe('event handling', () => { it('shows detection complete message', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('detection:complete', { integration: 'nextjs' }); - // Uses clack.log.success - expect(clack.default.log.success).toHaveBeenCalled(); + // Uses ui.log.success + expect(ui.default.log.success).toHaveBeenCalled(); }); it('shows spinner on agent:start', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('agent:start', {}); - expect(clack.default.spinner).toHaveBeenCalled(); + expect(ui.default.spinner).toHaveBeenCalled(); }); it('updates spinner on agent:progress', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn(), }; - vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + vi.mocked(ui.default.spinner).mockReturnValue(spinnerMock); emitter.emit('agent:start', {}); emitter.emit('agent:progress', { step: 'Installing', detail: 'packages' }); @@ -169,8 +169,8 @@ describe('CLIAdapter', () => { it('sends GIT_CONFIRMED on confirm', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); - vi.mocked(clack.default.confirm).mockResolvedValue(true); + const ui = await import('../../utils/ui.js'); + vi.mocked(ui.default.confirm).mockResolvedValue(true); emitter.emit('git:dirty', { files: ['file1.ts'] }); @@ -182,8 +182,8 @@ describe('CLIAdapter', () => { it('sends GIT_CANCELLED on decline', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); - vi.mocked(clack.default.confirm).mockResolvedValue(false); + const ui = await import('../../utils/ui.js'); + vi.mocked(ui.default.confirm).mockResolvedValue(false); emitter.emit('git:dirty', { files: ['file1.ts'] }); @@ -194,9 +194,9 @@ describe('CLIAdapter', () => { it('sends CREDENTIALS_SUBMITTED on credentials form', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); - vi.mocked(clack.default.text).mockResolvedValueOnce('client_123'); // clientId - vi.mocked(clack.default.password).mockResolvedValueOnce('sk_test'); // apiKey (now uses password input) + const ui = await import('../../utils/ui.js'); + vi.mocked(ui.default.text).mockResolvedValueOnce('client_123'); // clientId + vi.mocked(ui.default.password).mockResolvedValueOnce('sk_test'); // apiKey (now uses password input) emitter.emit('credentials:request', { requiresApiKey: true }); @@ -211,9 +211,9 @@ describe('CLIAdapter', () => { it('sends CANCEL when credentials form is cancelled', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); - vi.mocked(clack.default.isCancel).mockReturnValue(true); - vi.mocked(clack.default.text).mockResolvedValue(Symbol('cancel')); + const ui = await import('../../utils/ui.js'); + vi.mocked(ui.default.isCancel).mockReturnValue(true); + vi.mocked(ui.default.text).mockResolvedValue(Symbol('cancel')); emitter.emit('credentials:request', { requiresApiKey: false }); @@ -252,26 +252,26 @@ describe('CLIAdapter', () => { it('renders persistent step lines for file operations', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('agent:start', {}); emitter.emit('file:write', { path: '/proj/src/auth.ts', content: 'secret' }); emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); - const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + const stepCalls = vi.mocked(ui.default.log.step).mock.calls.map((c) => String(c[0])); expect(stepCalls.some((s) => s.includes('src/auth.ts'))).toBe(true); expect(stepCalls.some((s) => s.includes('src/app.ts'))).toBe(true); }); it('dedupes consecutive same-path file operations', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('agent:start', {}); emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'a', newContent: 'b' }); emitter.emit('file:edit', { path: '/proj/src/app.ts', oldContent: 'b', newContent: 'c' }); - const appCalls = vi.mocked(clack.default.log.step).mock.calls.filter((c) => String(c[0]).includes('src/app.ts')); + const appCalls = vi.mocked(ui.default.log.step).mock.calls.filter((c) => String(c[0]).includes('src/app.ts')); expect(appCalls).toHaveLength(1); }); @@ -279,9 +279,9 @@ describe('CLIAdapter', () => { vi.useFakeTimers(); try { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; - vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + vi.mocked(ui.default.spinner).mockReturnValue(spinnerMock); emitter.emit('agent:start', {}); emitter.emit('agent:progress', { step: 'Configuring middleware' }); @@ -298,9 +298,9 @@ describe('CLIAdapter', () => { it('restarts the spinner on the last phase message after logging a file op', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; - vi.mocked(clack.default.spinner).mockReturnValue(spinnerMock); + vi.mocked(ui.default.spinner).mockReturnValue(spinnerMock); emitter.emit('agent:start', {}); emitter.emit('agent:progress', { step: 'Configuring middleware' }); @@ -312,12 +312,12 @@ describe('CLIAdapter', () => { it('renders Bash tool calls as step lines (agent:tool)', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('agent:start', {}); emitter.emit('agent:tool', { kind: 'command', detail: 'pnpm add @workos-inc/authkit-nextjs' }); - const stepCalls = vi.mocked(clack.default.log.step).mock.calls.map((c) => String(c[0])); + const stepCalls = vi.mocked(ui.default.log.step).mock.calls.map((c) => String(c[0])); expect(stepCalls.some((s) => s.includes('pnpm add @workos-inc/authkit-nextjs'))).toBe(true); }); @@ -339,11 +339,11 @@ describe('CLIAdapter', () => { try { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('error', { message: 'authentication failed', stack: undefined }); - expect(clack.default.log.info).toHaveBeenCalledWith( + expect(ui.default.log.info).toHaveBeenCalledWith( 'Try running: npx workos@latest auth logout && npx workos@latest install', ); } finally { @@ -359,24 +359,24 @@ describe('CLIAdapter', () => { describe('staging success copy', () => { it('device path announces a fresh environment without "retrieved"', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('staging:fetching', {}); emitter.emit('staging:success', { source: 'device' }); - const calls = vi.mocked(clack.default.log.success).mock.calls.map((c) => String(c[0])); + const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); expect(calls).toContain('Set up a WorkOS environment for this install'); expect(calls.join('\n')).not.toMatch(/retrieved/i); }); it('stored path announces reuse of the active environment', async () => { await adapter.start(); - const clack = await import('../../utils/clack.js'); + const ui = await import('../../utils/ui.js'); emitter.emit('staging:fetching', {}); emitter.emit('staging:success', { source: 'stored' }); - const calls = vi.mocked(clack.default.log.success).mock.calls.map((c) => String(c[0])); + const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); expect(calls).toContain('Using your active WorkOS environment'); expect(calls.join('\n')).not.toMatch(/retrieved/i); }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 935b41c3..7987a73b 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -1,7 +1,7 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; import { relative } from 'node:path'; -import clack from '../../utils/clack.js'; +import ui from '../../utils/ui.js'; import chalk from 'chalk'; import { getConfig } from '../settings.js'; import { ProgressTracker } from '../progress-tracker.js'; @@ -9,16 +9,16 @@ import { renderCompletionSummary } from '../../utils/summary-box.js'; import { formatWorkOSCommand } from '../../utils/command-invocation.js'; /** - * CLI adapter that renders wizard events via clack. + * CLI adapter that renders wizard events via ui. * * Subscribes to InstallerEventEmitter and translates events into - * clack UI operations (logs, spinners, prompts). + * UI facade operations (logs, spinners, prompts). */ export class CLIAdapter implements InstallerAdapter { readonly emitter: InstallerEventEmitter; private sendEvent: AdapterConfig['sendEvent']; private debug: boolean; - private spinner: ReturnType | null = null; + private spinner: ReturnType | null = null; private isStarted = false; private progress = new ProgressTracker(); @@ -77,7 +77,7 @@ export class CLIAdapter implements InstallerAdapter { console.log(chalk.cyan(art)); console.log(); } else { - clack.intro('Welcome to the WorkOS AuthKit installer'); + ui.intro('WorkOS', 'AuthKit installer'); } // Handle Ctrl+C gracefully @@ -86,8 +86,8 @@ export class CLIAdapter implements InstallerAdapter { this.spinner.stop('Cancelled'); this.spinner = null; } - clack.log.warn('Installer cancelled'); - clack.outro('Your project was not modified'); + ui.log.warn('Installer cancelled'); + ui.outro('Your project was not modified'); process.exit(0); }; process.on('SIGINT', handleSigInt); @@ -208,30 +208,30 @@ export class CLIAdapter implements InstallerAdapter { }; private handleAuthSuccess = (): void => { - clack.log.success('Authenticated'); + ui.log.success('Authenticated'); }; private handleAuthFailure = ({ message }: InstallerEvents['auth:failure']): void => { - clack.log.error(`Auth failed: ${message}`); - clack.log.info('Visit https://dashboard.workos.com to verify your account'); + ui.log.error(`Auth failed: ${message}`); + ui.log.info('Visit https://dashboard.workos.com to verify your account'); }; private handleDetectionComplete = ({ integration }: InstallerEvents['detection:complete']): void => { - this.queueableLog(() => clack.log.success(`Detected ${chalk.bold(integration)}`)); + this.queueableLog(() => ui.log.success(`Detected ${chalk.bold(integration)}`)); }; private handleDetectionNone = (): void => { - this.queueableLog(() => clack.log.warn('Could not detect framework automatically')); + this.queueableLog(() => ui.log.warn('Could not detect framework automatically')); }; private handleCredentialsFound = (): void => { - clack.log.success('Found existing WorkOS credentials in .env.local'); + ui.log.success('Found existing WorkOS credentials in .env.local'); }; private handleEnvScanPrompt = async ({ files }: InstallerEvents['credentials:env:prompt']): Promise => { this.isPromptActive = true; const fileList = files.length === 1 ? files[0] : files.slice(0, 2).join(', '); - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: `Found ${fileList}. Check for existing WorkOS credentials?`, initialValue: true, }); @@ -239,16 +239,16 @@ export class CLIAdapter implements InstallerAdapter { this.flushPendingLogs(); this.sendEvent({ - type: clack.isCancel(confirmed) || !confirmed ? 'ENV_SCAN_DECLINED' : 'ENV_SCAN_APPROVED', + type: ui.isCancel(confirmed) || !confirmed ? 'ENV_SCAN_DECLINED' : 'ENV_SCAN_APPROVED', }); }; private handleDeviceStarted = ({ verificationUri, userCode }: InstallerEvents['device:started']): void => { - clack.log.info(`\nOpen this URL in your browser:\n`); + ui.log.info(`\nOpen this URL in your browser:\n`); console.log(` ${chalk.cyan(verificationUri)}`); console.log(`\nEnter code: ${chalk.bold(userCode)}\n`); - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start('Waiting for authentication...'); }; @@ -260,36 +260,36 @@ export class CLIAdapter implements InstallerAdapter { if (this.spinner) { this.spinner.stop('Authenticated'); } - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start('Fetching your WorkOS credentials...'); }; private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { if (source === 'device') { this.stopSpinner('Environment ready'); - clack.log.success('Set up a WorkOS environment for this install'); + ui.log.success('Set up a WorkOS environment for this install'); } else if (source === 'stored') { this.stopSpinner('Using active environment'); - clack.log.success('Using your active WorkOS environment'); + ui.log.success('Using your active WorkOS environment'); } else { this.stopSpinner('Environment ready'); - clack.log.success('Using your WorkOS environment'); + ui.log.success('Using your WorkOS environment'); } }; private handleEnvCredentialsFound = ({ sourcePath }: InstallerEvents['credentials:env:found']): void => { - clack.log.success(`Found existing WorkOS credentials in ${sourcePath}`); + ui.log.success(`Found existing WorkOS credentials in ${sourcePath}`); }; private handleGitDirty = async ({ files }: InstallerEvents['git:dirty']): Promise => { - clack.log.warn('You have uncommitted or untracked files:'); - files.slice(0, 5).forEach((f) => clack.log.info(chalk.dim(` ${f}`))); + ui.log.warn('You have uncommitted or untracked files:'); + files.slice(0, 5).forEach((f) => ui.log.info(chalk.dim(` ${f}`))); if (files.length > 5) { - clack.log.info(chalk.dim(` ... and ${files.length - 5} more`)); + ui.log.info(chalk.dim(` ... and ${files.length - 5} more`)); } this.isPromptActive = true; - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: 'Continue anyway?', initialValue: false, }); @@ -297,16 +297,16 @@ export class CLIAdapter implements InstallerAdapter { this.flushPendingLogs(); this.sendEvent({ - type: clack.isCancel(confirmed) || !confirmed ? 'GIT_CANCELLED' : 'GIT_CONFIRMED', + type: ui.isCancel(confirmed) || !confirmed ? 'GIT_CANCELLED' : 'GIT_CONFIRMED', }); }; private handleCredentialsRequest = async ({ requiresApiKey, }: InstallerEvents['credentials:request']): Promise => { - clack.log.step(`Get your credentials from ${chalk.cyan('https://dashboard.workos.com')}`); + ui.log.step(`Get your credentials from ${chalk.cyan('https://dashboard.workos.com')}`); - const clientId = await clack.text({ + const clientId = await ui.text({ message: 'Enter your WorkOS Client ID:', placeholder: 'client_...', validate: (value) => { @@ -320,15 +320,15 @@ export class CLIAdapter implements InstallerAdapter { }, }); - if (clack.isCancel(clientId)) { + if (ui.isCancel(clientId)) { this.sendEvent({ type: 'CANCEL' }); return; } let apiKey = ''; if (requiresApiKey) { - clack.log.info(chalk.dim('ℹ️ Your API key will be hidden for security and saved to .env.local')); - const apiKeyResult = await clack.password({ + ui.log.info(chalk.dim('ℹ️ Your API key will be hidden for security and saved to .env.local')); + const apiKeyResult = await ui.password({ message: 'Enter your WorkOS API Key:', validate: (value) => { if (!value || value.trim().length === 0) { @@ -341,13 +341,13 @@ export class CLIAdapter implements InstallerAdapter { }, }); - if (clack.isCancel(apiKeyResult)) { + if (ui.isCancel(apiKeyResult)) { this.sendEvent({ type: 'CANCEL' }); return; } apiKey = apiKeyResult as string; } else { - clack.log.info(chalk.dim('ℹ️ Client-only SDK - API key not required')); + ui.log.info(chalk.dim('ℹ️ Client-only SDK - API key not required')); } this.sendEvent({ @@ -358,13 +358,13 @@ export class CLIAdapter implements InstallerAdapter { }; private handleConfigComplete = (): void => { - clack.log.success('Environment configured'); + ui.log.success('Environment configured'); }; private handleAgentStart = (): void => { - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start(this.lastAgentMessage); - // No setInterval: clack animates its own frames, and the old 2s reset + // No setInterval: ui animates its own frames, and the old 2s reset // clobbered the current phase text set by handleAgentProgress. }; @@ -385,7 +385,7 @@ export class CLIAdapter implements InstallerAdapter { this.spinner = null; render(); if (wasRunning) { - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start(this.lastAgentMessage); } } @@ -394,7 +394,7 @@ export class CLIAdapter implements InstallerAdapter { if (path === this.lastFileOp) return; // dedupe consecutive same-path ops this.lastFileOp = path; const rel = relative(process.cwd(), path); - this.logAboveSpinner(() => clack.log.step(`${verb} ${chalk.dim(rel)}`)); + this.logAboveSpinner(() => ui.log.step(`${verb} ${chalk.dim(rel)}`)); } private handleFileWrite = ({ path }: InstallerEvents['file:write']): void => { @@ -407,7 +407,7 @@ export class CLIAdapter implements InstallerAdapter { private handleAgentTool = ({ detail }: InstallerEvents['agent:tool']): void => { const cmd = detail.length > 80 ? `${detail.slice(0, 77)}…` : detail; - this.logAboveSpinner(() => clack.log.step(`Running ${chalk.dim(cmd)}`)); + this.logAboveSpinner(() => ui.log.step(`Running ${chalk.dim(cmd)}`)); }; private handleValidationStart = (): void => { @@ -417,21 +417,21 @@ export class CLIAdapter implements InstallerAdapter { private handleValidationIssues = ({ issues }: InstallerEvents['validation:issues']): void => { for (const issue of issues) { if (issue.severity === 'error') { - clack.log.error(issue.message); + ui.log.error(issue.message); } else { - clack.log.warn(issue.message); + ui.log.warn(issue.message); } if (issue.hint) { - clack.log.info(`Hint: ${issue.hint}`); + ui.log.info(`Hint: ${issue.hint}`); } } }; private handleValidationComplete = ({ passed, issueCount }: InstallerEvents['validation:complete']): void => { if (passed) { - clack.log.success('Validation passed'); + ui.log.success('Validation passed'); } else { - clack.log.warn(`Validation found ${issueCount} issue(s)`); + ui.log.warn(`Validation found ${issueCount} issue(s)`); } }; @@ -445,7 +445,7 @@ export class CLIAdapter implements InstallerAdapter { // When we scaffolded a fresh app, the install ran in the current dir, so // point the user straight at the dev server. if (success && this.scaffolded) { - clack.log.info(`Start your app: ${chalk.cyan(`${this.scaffoldPackageManager} run dev`)}`); + ui.log.info(`Start your app: ${chalk.cyan(`${this.scaffoldPackageManager} run dev`)}`); } }; @@ -460,27 +460,27 @@ export class CLIAdapter implements InstallerAdapter { const isProcessExit = /process exited with code/i.test(message); if (isServiceError) { - clack.log.error('The AI service is temporarily unavailable.'); - clack.log.info('This is usually resolved within a few minutes. Please try again shortly.'); + ui.log.error('The AI service is temporarily unavailable.'); + ui.log.info('This is usually resolved within a few minutes. Please try again shortly.'); } else if (isRateLimit) { - clack.log.error('The AI service is currently rate-limited.'); - clack.log.info('Please wait a minute and try again.'); + ui.log.error('The AI service is currently rate-limited.'); + ui.log.info('Please wait a minute and try again.'); } else if (isNetworkError) { - clack.log.error('Could not connect to the AI service.'); - clack.log.info('Check your internet connection and try again.'); + ui.log.error('Could not connect to the AI service.'); + ui.log.info('Check your internet connection and try again.'); } else if (isProcessExit) { - clack.log.error('The AI agent process exited unexpectedly.'); - clack.log.info('Try running again. If this persists, run with --debug for details.'); + ui.log.error('The AI agent process exited unexpectedly.'); + ui.log.info('Try running again. If this persists, run with --debug for details.'); } else { - clack.log.error(message); + ui.log.error(message); } // Add actionable hints for common errors if (message.includes('authentication') || message.includes('auth')) { - clack.log.info(`Try running: ${formatWorkOSCommand('auth logout')} && ${formatWorkOSCommand('install')}`); + ui.log.info(`Try running: ${formatWorkOSCommand('auth logout')} && ${formatWorkOSCommand('install')}`); } if (message.includes('ENOENT') || message.includes('not found')) { - clack.log.info('Ensure you are in a project directory'); + ui.log.info('Ensure you are in a project directory'); } if (stack && this.debug) { @@ -493,7 +493,7 @@ export class CLIAdapter implements InstallerAdapter { private handleScaffoldPrompt = async ({ packageManager }: InstallerEvents['scaffold:prompt']): Promise => { this.scaffoldPackageManager = packageManager; this.isPromptActive = true; - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: 'This directory is empty. Scaffold a new Next.js app with AuthKit here?', initialValue: true, }); @@ -501,13 +501,13 @@ export class CLIAdapter implements InstallerAdapter { this.flushPendingLogs(); this.sendEvent({ - type: clack.isCancel(confirmed) || !confirmed ? 'SCAFFOLD_CANCELLED' : 'SCAFFOLD_CONFIRMED', + type: ui.isCancel(confirmed) || !confirmed ? 'SCAFFOLD_CANCELLED' : 'SCAFFOLD_CONFIRMED', }); }; private handleScaffoldStart = ({ packageManager }: InstallerEvents['scaffold:start']): void => { this.scaffoldPackageManager = packageManager; - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start(`Scaffolding a new Next.js app with ${packageManager} (this can take a minute)...`); }; @@ -527,12 +527,12 @@ export class CLIAdapter implements InstallerAdapter { private handleScaffoldFailed = ({ error }: InstallerEvents['scaffold:failed']): void => { this.stopSpinner('Scaffold failed'); - clack.log.error(`Could not scaffold the app: ${error}`); + ui.log.error(`Could not scaffold the app: ${error}`); }; private handleBranchPrompt = async ({ branch }: InstallerEvents['branch:prompt']): Promise => { this.isPromptActive = true; - const choice = await clack.select({ + const choice = await ui.select({ message: `You are on ${chalk.bold(branch)}. Create a feature branch?`, options: [ { value: 'create', label: 'Create feat/add-workos-authkit' }, @@ -543,7 +543,7 @@ export class CLIAdapter implements InstallerAdapter { this.isPromptActive = false; this.flushPendingLogs(); - if (clack.isCancel(choice) || choice === 'cancel') { + if (ui.isCancel(choice) || choice === 'cancel') { this.sendEvent({ type: 'BRANCH_CANCEL' }); } else if (choice === 'create') { this.sendEvent({ type: 'BRANCH_CREATE' }); @@ -553,7 +553,7 @@ export class CLIAdapter implements InstallerAdapter { }; private handleBranchCreated = ({ branch }: InstallerEvents['branch:created']): void => { - this.queueableLog(() => clack.log.success(`Created branch ${chalk.bold(branch)}`)); + this.queueableLog(() => ui.log.success(`Created branch ${chalk.bold(branch)}`)); }; // ===== Post-install Event Handlers ===== @@ -564,7 +564,7 @@ export class CLIAdapter implements InstallerAdapter { private handleCommitPrompt = async (): Promise => { this.isPromptActive = true; - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: 'Commit the changes?', initialValue: true, }); @@ -572,28 +572,28 @@ export class CLIAdapter implements InstallerAdapter { this.flushPendingLogs(); this.sendEvent({ - type: clack.isCancel(confirmed) || !confirmed ? 'COMMIT_DECLINED' : 'COMMIT_APPROVED', + type: ui.isCancel(confirmed) || !confirmed ? 'COMMIT_DECLINED' : 'COMMIT_APPROVED', }); }; private handleCommitGenerating = (): void => { - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start('Generating commit message...'); }; private handleCommitSuccess = ({ message }: InstallerEvents['postinstall:commit:success']): void => { this.stopSpinner('Committed'); - clack.log.success(`Committed: ${chalk.dim(message)}`); + ui.log.success(`Committed: ${chalk.dim(message)}`); }; private handleCommitFailed = ({ error }: InstallerEvents['postinstall:commit:failed']): void => { this.stopSpinner('Commit failed'); - clack.log.error(`Commit failed: ${error}`); + ui.log.error(`Commit failed: ${error}`); }; private handlePrPrompt = async (): Promise => { this.isPromptActive = true; - const confirmed = await clack.confirm({ + const confirmed = await ui.confirm({ message: 'Create a pull request?', initialValue: true, }); @@ -601,12 +601,12 @@ export class CLIAdapter implements InstallerAdapter { this.flushPendingLogs(); this.sendEvent({ - type: clack.isCancel(confirmed) || !confirmed ? 'PR_DECLINED' : 'PR_APPROVED', + type: ui.isCancel(confirmed) || !confirmed ? 'PR_DECLINED' : 'PR_APPROVED', }); }; private handlePrGenerating = (): void => { - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start('Generating PR description...'); }; @@ -614,28 +614,28 @@ export class CLIAdapter implements InstallerAdapter { if (this.spinner) { this.spinner.message('Pushing to remote...'); } else { - this.spinner = clack.spinner(); + this.spinner = ui.spinner(); this.spinner.start('Pushing to remote...'); } }; private handlePrSuccess = ({ url }: InstallerEvents['postinstall:pr:success']): void => { this.stopSpinner('PR created'); - clack.log.success(`Pull request created: ${chalk.cyan(url)}`); + ui.log.success(`Pull request created: ${chalk.cyan(url)}`); }; private handlePrFailed = ({ error }: InstallerEvents['postinstall:pr:failed']): void => { this.stopSpinner('PR creation failed'); - clack.log.error(`PR creation failed: ${error}`); + ui.log.error(`PR creation failed: ${error}`); }; private handlePushFailed = ({ error }: InstallerEvents['postinstall:push:failed']): void => { this.stopSpinner('Push failed'); - clack.log.error(`Push failed: ${error}`); + ui.log.error(`Push failed: ${error}`); }; private handleManualInstructions = ({ instructions }: InstallerEvents['postinstall:manual']): void => { - clack.log.info('GitHub CLI not found. Manual steps:'); + ui.log.info('GitHub CLI not found. Manual steps:'); console.log(chalk.dim(instructions)); }; } diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 5f6e74d0..2db6c170 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -499,7 +499,13 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt }, }, model: getConfig().model, - allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebFetch'], + // Empty on purpose. A tool listed here is auto-approved BEFORE `canUseTool` + // (installerCanUseTool) runs, which both defeats the Bash safety gate and + // triggers the SDK's CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning. Leaving this + // empty routes every tool call through canUseTool so the gate is + // authoritative. Tool *availability* comes from the `tools` preset below, + // not from this list. + allowedTools: [], sdkEnv, }; diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index dfdc6e85..c80e3497 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -13,7 +13,7 @@ import { getOrAskForWorkOSCredentials, getPackageDotJson, isUsingTypeScript, -} from '../utils/clack-utils.js'; +} from '../utils/ui-utils.js'; import { analytics } from '../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { initializeAgent, runAgent, type RetryConfig } from './agent-interface.js'; diff --git a/src/lib/config.ts b/src/lib/config.ts index b412efb2..80eeb6e7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -28,7 +28,7 @@ export type IntegrationConfig = { /** * Legacy detection configs for existing JS integrations. - * Used by clack-utils.ts for abort/cancel messages. + * Used by ui-utils.ts for abort/cancel messages. * New integrations do NOT need to be added here. */ export const INTEGRATION_CONFIG: Record = { diff --git a/src/lib/mcp-clients.ts b/src/lib/mcp-clients.ts index ab6935df..a28a148c 100644 --- a/src/lib/mcp-clients.ts +++ b/src/lib/mcp-clients.ts @@ -40,6 +40,20 @@ export type McpAgentKey = 'claude-code' | 'codex' | 'cursor'; export type McpOutcome = 'installed' | 'already-installed' | 'removed' | 'not-installed' | 'skipped' | 'failed'; +/** + * Human phrasing for each outcome (JSON mode emits the raw `outcome` value). + * Lives with the `McpOutcome` type so every consumer (`mcp` command, `setup`) + * stays in lockstep when an outcome is added. + */ +export const MCP_OUTCOME_LABELS: Record = { + installed: 'installed', + 'already-installed': 'already installed', + removed: 'removed', + 'not-installed': 'not installed', + skipped: 'skipped', + failed: 'failed', +}; + export interface McpClientResult { agent: McpAgentKey; displayName: string; diff --git a/src/lib/mcp-notice.spec.ts b/src/lib/mcp-notice.spec.ts deleted file mode 100644 index acb40025..00000000 --- a/src/lib/mcp-notice.spec.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { McpClientResult } from './mcp-clients.js'; - -// --- Controllable module state (read lazily inside mock factories) --- -let prefs: Record = {}; -let promptAllowed = true; -let humanMode = true; -let jsonMode = false; -let detectResult: unknown[] = []; - -const loadPreferencesMock = vi.fn(() => Promise.resolve(prefs)); -const savePreferencesMock = vi.fn(); -vi.mock('./preferences.js', () => ({ - loadPreferences: (...a: unknown[]) => loadPreferencesMock(...(a as [])), - savePreferences: (...a: unknown[]) => savePreferencesMock(...(a as [])), -})); - -vi.mock('../utils/interaction-mode.js', () => ({ - isPromptAllowed: () => promptAllowed, - isHumanMode: () => humanMode, -})); - -vi.mock('../utils/output.js', () => ({ isJsonMode: () => jsonMode })); - -const mockRenderStderrBox = vi.fn(); -vi.mock('../utils/box.js', () => ({ - renderStderrBox: (...a: unknown[]) => mockRenderStderrBox(...(a as [])), -})); - -const detectMcpClientsMock = vi.fn(() => Promise.resolve(detectResult)); -vi.mock('./mcp-clients.js', () => ({ - detectMcpClients: (...a: unknown[]) => detectMcpClientsMock(...(a as [])), -})); - -const confirmMock = vi.fn(); -const isCancelMock = vi.fn(() => false); -const clackLog = { success: vi.fn(), error: vi.fn(), info: vi.fn() }; -vi.mock('../utils/clack.js', () => ({ - default: { - confirm: (...a: unknown[]) => confirmMock(...(a as [])), - isCancel: (...a: unknown[]) => isCancelMock(...(a as [])), - log: clackLog, - }, -})); - -const captureMock = vi.fn(); -const emitCommandEventMock = vi.fn(); -vi.mock('../utils/analytics.js', () => ({ - analytics: { - capture: (...a: unknown[]) => captureMock(...(a as [])), - emitCommandEvent: (...a: unknown[]) => emitCommandEventMock(...(a as [])), - }, -})); - -const { - getMcpAskState, - recordMcpDeclined, - recordMcpBannerShown, - isAutoAskEligible, - maybeShowMcpNotice, - maybeOfferMcpInstall, - resetMcpNoticeState, - MCP_OFFER_TIMEOUT_MS, -} = await import('./mcp-notice.js'); -const { markStartupNoticeShown, resetStartupNoticeGate } = await import('./startup-notice-gate.js'); - -interface FakeClientOpts { - installed?: boolean; - add?: McpClientResult; - addThrows?: boolean; -} -function fakeClient(key: string, displayName: string, opts: FakeClientOpts = {}) { - return { - key, - displayName, - isAvailable: vi.fn(() => Promise.resolve(true)), - isInstalled: vi.fn(() => Promise.resolve(opts.installed ?? false)), - add: vi.fn(() => - opts.addThrows - ? Promise.reject(new Error('add exploded')) - : Promise.resolve(opts.add ?? ({ agent: key, displayName, outcome: 'installed' } as McpClientResult)), - ), - remove: vi.fn(), - }; -} - -beforeEach(() => { - vi.clearAllMocks(); - prefs = {}; - promptAllowed = true; - humanMode = true; - jsonMode = false; - detectResult = []; - loadPreferencesMock.mockImplementation(() => Promise.resolve(prefs)); - detectMcpClientsMock.mockImplementation(() => Promise.resolve(detectResult)); - savePreferencesMock.mockReset(); - confirmMock.mockReset(); - isCancelMock.mockReturnValue(false); - resetMcpNoticeState(); - resetStartupNoticeGate(); -}); - -describe('getMcpAskState', () => { - it('reads declined + bannerShown from prefs', async () => { - prefs = { mcp: { promptDeclined: true, bannerShownAt: '2026-01-01T00:00:00.000Z' } }; - expect(await getMcpAskState()).toEqual({ declined: true, bannerShown: true }); - }); - - it('defaults to false when no mcp prefs exist', async () => { - prefs = {}; - expect(await getMcpAskState()).toEqual({ declined: false, bannerShown: false }); - }); - - it('degrades to false/false when loadPreferences throws', async () => { - loadPreferencesMock.mockRejectedValueOnce(new Error('EIO')); - expect(await getMcpAskState()).toEqual({ declined: false, bannerShown: false }); - }); -}); - -describe('markers', () => { - it('recordMcpDeclined persists promptDeclined', async () => { - await recordMcpDeclined(); - expect(savePreferencesMock).toHaveBeenCalledWith({ mcp: { promptDeclined: true } }); - }); - - it('recordMcpBannerShown persists a bannerShownAt timestamp', async () => { - await recordMcpBannerShown(); - expect(savePreferencesMock).toHaveBeenCalledWith({ mcp: { bannerShownAt: expect.any(String) } }); - }); - - it('swallows prefs write failures (degrades to per-run memory)', async () => { - savePreferencesMock.mockImplementation(() => { - throw new Error('EROFS'); - }); - await expect(recordMcpDeclined()).resolves.toBeUndefined(); - await expect(recordMcpBannerShown()).resolves.toBeUndefined(); - }); -}); - -describe('isAutoAskEligible', () => { - it('false when declined — and never shells out to detect clients', async () => { - prefs = { mcp: { promptDeclined: true } }; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - expect(await isAutoAskEligible()).toBe(false); - expect(detectMcpClientsMock).not.toHaveBeenCalled(); - }); - - it('false when no agents are detected', async () => { - detectResult = []; - expect(await isAutoAskEligible()).toBe(false); - }); - - it('false when every detected agent already has the server', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: true })]; - expect(await isAutoAskEligible()).toBe(false); - }); - - it('true when at least one detected agent lacks the server', async () => { - detectResult = [ - fakeClient('claude-code', 'Claude Code', { installed: true }), - fakeClient('cursor', 'Cursor', { installed: false }), - ]; - expect(await isAutoAskEligible()).toBe(true); - }); -}); - -describe('maybeShowMcpNotice (banner)', () => { - it('renders once and records bannerShown when eligible in human mode', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); - expect(savePreferencesMock).toHaveBeenCalledWith({ mcp: { bannerShownAt: expect.any(String) } }); - }); - - it('emits a banner impression event when shown', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(emitCommandEventMock).toHaveBeenCalledTimes(1); - expect(emitCommandEventMock).toHaveBeenCalledWith('mcp offer', 0, true, { - extraAttributes: { 'mcp.entry_point': 'banner', 'mcp.shown': true }, - }); - }); - - it('does not render or emit a second time in the same session', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); - expect(emitCommandEventMock).toHaveBeenCalledTimes(1); - }); - - it('suppressed outside human mode', async () => { - humanMode = false; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); - }); - - it('suppressed in JSON mode', async () => { - jsonMode = true; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); - }); - - it('defers when another startup notice already fired this run', async () => { - markStartupNoticeShown(); // e.g. the telemetry notice or unclaimed warning won - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); - }); - - it('suppressed when the banner was already shown on a previous run', async () => { - prefs = { mcp: { bannerShownAt: '2025-01-01T00:00:00.000Z' } }; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); - }); - - it('suppressed for a declined user', async () => { - prefs = { mcp: { promptDeclined: true } }; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); - }); - - it('suppressed when nothing is installable (all agents already have it)', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: true })]; - await maybeShowMcpNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); - }); -}); - -describe('maybeOfferMcpInstall (install-flow prompt)', () => { - it('never prompts when prompting is not allowed (agent/CI/non-TTY)', async () => { - promptAllowed = false; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - expect(confirmMock).not.toHaveBeenCalled(); - expect(emitCommandEventMock).not.toHaveBeenCalled(); - expect(savePreferencesMock).not.toHaveBeenCalled(); - }); - - it('never prompts in JSON mode (e.g. `install --json` on a TTY) so output stays clean', async () => { - jsonMode = true; // human interaction mode but machine-readable output - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - expect(confirmMock).not.toHaveBeenCalled(); - expect(emitCommandEventMock).not.toHaveBeenCalled(); - }); - - it('installs to detected-and-missing agents on accept and captures the outcome', async () => { - const claude = fakeClient('claude-code', 'Claude Code', { installed: false }); - const cursor = fakeClient('cursor', 'Cursor', { installed: true }); // already has it → not a target - detectResult = [claude, cursor]; - confirmMock.mockResolvedValue(true); - - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - - // Prompt names only the installable agent. - const message = confirmMock.mock.calls[0][0].message as string; - expect(message).toContain('Claude Code'); - expect(message).not.toContain('Cursor'); - - expect(claude.add).toHaveBeenCalledTimes(1); - expect(cursor.add).not.toHaveBeenCalled(); - expect(emitCommandEventMock).toHaveBeenCalledWith('mcp offer', expect.any(Number), true, { - extraAttributes: { - 'mcp.entry_point': 'install-flow', - 'mcp.accepted': true, - 'mcp.agents_installed': 'claude-code', - 'mcp.agents_failed': '', - }, - }); - }); - - it('records the decline and captures accepted:false on explicit no', async () => { - const cursor = fakeClient('cursor', 'Cursor', { installed: false }); - detectResult = [cursor]; - confirmMock.mockResolvedValue(false); - - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - - expect(savePreferencesMock).toHaveBeenCalledWith({ mcp: { promptDeclined: true } }); - expect(cursor.add).not.toHaveBeenCalled(); - // A decline is a completed interaction, not an error: success stays true. - expect(emitCommandEventMock).toHaveBeenCalledWith('mcp offer', expect.any(Number), true, { - extraAttributes: { - 'mcp.entry_point': 'install-flow', - 'mcp.accepted': false, - 'mcp.agents_installed': '', - 'mcp.agents_failed': '', - }, - }); - }); - - it('treats ctrl-C (cancel) as neither a decline nor an install', async () => { - const cursor = fakeClient('cursor', 'Cursor', { installed: false }); - detectResult = [cursor]; - confirmMock.mockResolvedValue(Symbol('cancel')); - isCancelMock.mockReturnValue(true); - - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - - expect(savePreferencesMock).not.toHaveBeenCalled(); - expect(emitCommandEventMock).not.toHaveBeenCalled(); - expect(cursor.add).not.toHaveBeenCalled(); - }); - - it('does not prompt a user who already declined', async () => { - prefs = { mcp: { promptDeclined: true } }; - detectResult = [fakeClient('cursor', 'Cursor', { installed: false })]; - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - expect(confirmMock).not.toHaveBeenCalled(); - }); - - it('does not prompt when there is nothing to install', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: true })]; - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - expect(confirmMock).not.toHaveBeenCalled(); - }); - - it('never throws even if a client install throws (install flow unaffected)', async () => { - detectResult = [fakeClient('cursor', 'Cursor', { installed: false, addThrows: true })]; - confirmMock.mockResolvedValue(true); - await expect(maybeOfferMcpInstall({ entryPoint: 'install-flow' })).resolves.toBeUndefined(); - }); - - it('reports both installed and failed agents in the capture', async () => { - const ok = fakeClient('claude-code', 'Claude Code', { - installed: false, - add: { agent: 'claude-code', displayName: 'Claude Code', outcome: 'installed' }, - }); - const bad = fakeClient('cursor', 'Cursor', { - installed: false, - add: { agent: 'cursor', displayName: 'Cursor', outcome: 'failed', error: 'nope' }, - }); - detectResult = [ok, bad]; - confirmMock.mockResolvedValue(true); - - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - - // An accepted offer with a failed agent install is an error span: - // success flips to false while the per-agent lists carry the detail. - expect(emitCommandEventMock).toHaveBeenCalledWith('mcp offer', expect.any(Number), false, { - extraAttributes: { - 'mcp.entry_point': 'install-flow', - 'mcp.accepted': true, - 'mcp.agents_installed': 'claude-code', - 'mcp.agents_failed': 'cursor', - }, - }); - }); - - it('aborts a hung prompt at the deadline: stdin released, nothing recorded', async () => { - const CANCEL = Symbol('clack:cancel'); - const cursor = fakeClient('cursor', 'Cursor', { installed: false }); - detectResult = [cursor]; - // A prompt that never gets an answer: it only resolves (as a cancel, the - // way clack does) when the deadline signal aborts it. - confirmMock.mockImplementation( - (opts: { signal?: AbortSignal }) => - new Promise((resolve) => { - opts.signal?.addEventListener('abort', () => resolve(CANCEL), { once: true }); - }), - ); - isCancelMock.mockImplementation((v: unknown) => v === CANCEL); - - vi.useFakeTimers(); - try { - const offer = maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - await vi.advanceTimersByTimeAsync(MCP_OFFER_TIMEOUT_MS); - await expect(offer).resolves.toBeUndefined(); - } finally { - vi.useRealTimers(); - } - - // The prompt must receive the deadline signal — without it, the race - // resolves but the pending confirm keeps stdin (and the process) alive. - expect(confirmMock.mock.calls[0][0].signal).toBeInstanceOf(AbortSignal); - // A timeout is a cancel, not a decline: nothing persisted, nothing emitted. - expect(savePreferencesMock).not.toHaveBeenCalled(); - expect(emitCommandEventMock).not.toHaveBeenCalled(); - expect(cursor.add).not.toHaveBeenCalled(); - }); - - it('does not start installs when the answer lands at the deadline', async () => { - const cursor = fakeClient('cursor', 'Cursor', { installed: false }); - detectResult = [cursor]; - // Simulate a "yes" submitted in the same tick the deadline fires (submit - // beats cancel inside clack): the signal is already aborted by the time - // the flow sees the answer. - confirmMock.mockImplementation( - (opts: { signal?: AbortSignal }) => - new Promise((resolve) => { - opts.signal?.addEventListener('abort', () => resolve(true), { once: true }); - }), - ); - - vi.useFakeTimers(); - try { - const offer = maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - await vi.advanceTimersByTimeAsync(MCP_OFFER_TIMEOUT_MS); - await expect(offer).resolves.toBeUndefined(); - } finally { - vi.useRealTimers(); - } - - // The outer flow already moved on — no detached install work, no emission. - expect(cursor.add).not.toHaveBeenCalled(); - expect(emitCommandEventMock).not.toHaveBeenCalled(); - }); - - it('counts already-installed agents as installed in the emission', async () => { - const already = fakeClient('codex', 'Codex', { - installed: false, - add: { agent: 'codex', displayName: 'Codex', outcome: 'already-installed' }, - }); - detectResult = [already]; - confirmMock.mockResolvedValue(true); - - await maybeOfferMcpInstall({ entryPoint: 'install-flow' }); - - expect(emitCommandEventMock).toHaveBeenCalledWith('mcp offer', expect.any(Number), true, { - extraAttributes: expect.objectContaining({ 'mcp.agents_installed': 'codex', 'mcp.agents_failed': '' }), - }); - }); -}); diff --git a/src/lib/mcp-notice.ts b/src/lib/mcp-notice.ts deleted file mode 100644 index e8e37f9b..00000000 --- a/src/lib/mcp-notice.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * MCP-install onboarding surfaces + their shared gate state. - * - * One module owns all automatic-ask state so the install-flow prompt and the - * one-time banner can't drift apart. The decline contract is absolute: any - * explicit "no" records `promptDeclined` and no automatic surface ever asks - * again — `workos mcp install` stays available manually. - * - * Mirrors telemetry-notice.ts: persisted shown/declined state in the plain - * prefs store, mode-gated display, and a never-throws contract so a notice can - * never block or fail a command. The mode gate is the CLI's interaction-mode - * system (isPromptAllowed / isHumanMode), which already folds in CI markers, - * agent markers, and TTY state — non-TTY safety comes from using it. - */ - -import chalk from 'chalk'; -import clack from '../utils/clack.js'; -import { isHumanMode, isPromptAllowed } from '../utils/interaction-mode.js'; -import { isJsonMode } from '../utils/output.js'; -import { renderStderrBox } from '../utils/box.js'; -import { formatWorkOSCommand } from '../utils/command-invocation.js'; -import { analytics } from '../utils/analytics.js'; -import { loadPreferences, savePreferences } from './preferences.js'; -import { detectMcpClients, type McpClientResult, type McpClientTarget } from './mcp-clients.js'; -import { hasStartupNoticeShown, markStartupNoticeShown } from './startup-notice-gate.js'; - -export type McpAskState = { declined: boolean; bannerShown: boolean }; - -/** - * Ceiling on the whole install-flow offer so a wedged prompt can never hold up - * `workos install`. The deadline actively aborts the pending clack prompt (via - * AbortSignal) — clack's cancel path releases stdin and restores raw mode, so - * the process can actually exit; a race alone would leave the prompt's stdin - * handle keeping the event loop alive. Mirrors login.ts's - * SKILL_INSTALL_TIMEOUT_MS. - */ -export const MCP_OFFER_TIMEOUT_MS = 30 * 1000; - -let bannerShownThisSession = false; - -/** Human phrasing for each install outcome shown in the accept matrix. */ -const OUTCOME_LABEL: Record = { - installed: 'installed', - 'already-installed': 'already installed', - removed: 'removed', - 'not-installed': 'not installed', - skipped: 'skipped', - failed: 'failed', -}; - -/** - * Read the persisted automatic-ask state. Never throws — a missing/corrupt - * prefs file degrades to "nothing recorded" so gating still works in-memory. - */ -export async function getMcpAskState(): Promise { - try { - const prefs = await loadPreferences(); - return { - declined: prefs.mcp?.promptDeclined === true, - bannerShown: Boolean(prefs.mcp?.bannerShownAt), - }; - } catch { - return { declined: false, bannerShown: false }; - } -} - -/** - * Record an explicit decline. Absolute and permanent for automatic surfaces. - * Write failures are swallowed: gating degrades to per-run memory, never - * crashes a command. - */ -export async function recordMcpDeclined(): Promise { - try { - savePreferences({ mcp: { promptDeclined: true } }); - } catch { - // Swallow — a read-only prefs file must never break a command. - } -} - -/** Record that the one-time banner was shown, stamping the current time. */ -export async function recordMcpBannerShown(): Promise { - try { - savePreferences({ mcp: { bannerShownAt: new Date().toISOString() } }); - } catch { - // Swallow — see recordMcpDeclined. - } -} - -/** - * The detected agents that would actually receive an install: available on this - * machine AND missing the WorkOS server. Empty when there is nothing to offer. - * Never throws — detection failure yields an empty set. - */ -async function detectInstallTargets(): Promise { - try { - const clients = await detectMcpClients(); - if (clients.length === 0) return []; - const installed = await Promise.all(clients.map((c) => c.isInstalled())); - return clients.filter((_, i) => !installed[i]); - } catch { - return []; - } -} - -/** - * Would an automatic ask be appropriate right now? True only when the user has - * not declined AND at least one detected agent lacks the server. Does NOT gate - * on interaction mode — callers add that (isPromptAllowed for the prompt, - * isHumanMode for the banner). Checks `declined` first so declined machines - * never pay for the client shell-outs. - */ -export async function isAutoAskEligible(): Promise { - const { declined } = await getMcpAskState(); - if (declined) return false; - const targets = await detectInstallTargets(); - return targets.length > 0; -} - -/** - * One-time stderr banner nudging the user toward `workos mcp install`. Shown at - * most once per machine, only on a normal human-mode run, and never when an - * earlier startup notice already fired this run (telemetry notice and unclaimed - * warning take precedence). Records `bannerShownAt` before printing — - * shown-once beats seen-once (a lost banner is fine; a nag loop is not). Never - * throws. - */ -export async function maybeShowMcpNotice(): Promise { - try { - if (bannerShownThisSession) return; - if (!isHumanMode()) return; // suppress in agent / CI / non-TTY - if (isJsonMode()) return; // never on the machine-readable path - if (hasStartupNoticeShown()) return; // one-notice-per-run cap; telemetry/unclaimed win - const { bannerShown } = await getMcpAskState(); - if (bannerShown) return; // already shown once, ever - if (!(await isAutoAskEligible())) return; // declined, or nothing to offer - - // Claim the slot + persist BEFORE printing: a lost banner beats a nag loop. - bannerShownThisSession = true; - markStartupNoticeShown(); - await recordMcpBannerShown(); - - // Impression event. Queued (not capture()d) so it rides the CLI's final - // flush; capture() only folds session tags and no session exists here. - analytics.emitCommandEvent('mcp offer', 0, true, { - extraAttributes: { 'mcp.entry_point': 'banner', 'mcp.shown': true }, - }); - - const cmd = chalk.cyan(formatWorkOSCommand('mcp install')); - const inner = ` ${chalk.cyan('ℹ')} New: connect your coding agent to WorkOS. Run ${cmd} to add the WorkOS MCP server (Claude Code, Codex, Cursor). `; - renderStderrBox(inner, chalk.cyan); - } catch { - // Never block command startup. - } -} - -/** Emit the per-agent install matrix (human mode) without ever exiting. */ -function printInstallMatrix(results: McpClientResult[]): void { - for (const r of results) { - const line = `${r.displayName}: ${OUTCOME_LABEL[r.outcome]}`; - if (r.outcome === 'failed') { - clack.log.error(r.error ? `${line} — ${r.error}` : line); - } else { - clack.log.success(line); - } - } -} - -/** - * The real install-flow offer. Self-gating: renders nothing unless prompting is - * allowed, the user hasn't declined, and there is at least one agent to install - * to. On yes: install + print the matrix + emit the adoption event. On explicit - * no: record the decline + emit (a decline is an adoption signal). On cancel - * (ctrl-C): skip silently without recording — a cancel is not a decline. - */ -async function offerMcpInstall(signal: AbortSignal): Promise { - if (!isPromptAllowed()) return; // the entire mode gate (CI / agent / non-TTY) - if (isJsonMode()) return; // never prompt on the machine-readable path (e.g. `install --json` on a TTY) - const { declined } = await getMcpAskState(); - if (declined) return; // decline is absolute - const targets = await detectInstallTargets(); - if (targets.length === 0) return; // nothing to offer - - const names = targets.map((t) => t.displayName).join(', '); - const offerStartedAt = Date.now(); - // The deadline signal aborts a hung/unanswered prompt: clack resolves it as - // a cancel and releases stdin so the process can exit. - const answer = await clack.confirm({ - message: `Add the WorkOS MCP server to ${names}? Your coding agent gets tools to manage WorkOS resources (you'll authorize via OAuth on first use).`, - signal, - }); - - // Cancel (ctrl-C or deadline abort) is not a decline — skip silently, ask - // again next time. - if (clack.isCancel(answer)) return; - - // Adoption events are queued command events, NOT capture(): the installer - // session has already shut down by the time this offer runs (run-with-core - // fires session.end in its finally), so folded tags would never ship. A - // queued event rides the CLI's unconditional final flush (bin.ts) and the - // store-forward exit handler covers anything the flush misses. - if (!answer) { - // Record the decline BEFORE anything else so a later crash can't re-ask. - await recordMcpDeclined(); - // A decline is a completed interaction, not an error: success stays true. - analytics.emitCommandEvent('mcp offer', Date.now() - offerStartedAt, true, { - extraAttributes: { - 'mcp.entry_point': 'install-flow', - 'mcp.accepted': false, - 'mcp.agents_installed': '', - 'mcp.agents_failed': '', - }, - }); - return; - } - - // The user submitted right as the deadline fired: the outer flow has already - // moved on, so don't start install work it will never report on. - if (signal.aborted) return; - - const results: McpClientResult[] = []; - for (const target of targets) { - results.push(await target.add()); - } - printInstallMatrix(results); - - const installed = results - .filter((r) => r.outcome === 'installed' || r.outcome === 'already-installed') - .map((r) => r.agent); - const failed = results.filter((r) => r.outcome === 'failed').map((r) => r.agent); - // success=false when any agent failed, so the offer surfaces as an error - // span while mcp.agents_failed carries which ones. - analytics.emitCommandEvent('mcp offer', Date.now() - offerStartedAt, failed.length === 0, { - extraAttributes: { - 'mcp.entry_point': 'install-flow', - 'mcp.accepted': true, - 'mcp.agents_installed': installed.join(','), - 'mcp.agents_failed': failed.join(','), - }, - }); -} - -/** - * Offer to install the WorkOS MCP server at the end of `workos install`. - * - * Wraps offerMcpInstall best-effort: a try/catch AND a 30s deadline so the - * offer can never throw into, wedge, or fail the install flow. The deadline - * aborts the pending prompt (AbortSignal → clack cancel, which releases stdin); - * the race is a backstop in case the flow is wedged somewhere that can't be - * aborted (client shell-outs are separately bounded by their exec timeouts). - * `workos install` has already succeeded by the time this runs. - */ -export async function maybeOfferMcpInstall(_opts: { entryPoint: 'install-flow' }): Promise { - let timeoutHandle: ReturnType | undefined; - const deadline = new AbortController(); - try { - const timeout = new Promise((resolve) => { - timeoutHandle = setTimeout(() => { - deadline.abort(); - resolve(); - }, MCP_OFFER_TIMEOUT_MS); - // Don't keep the event loop alive on this timer — the process should exit - // as soon as everything else settles. - timeoutHandle.unref?.(); - }); - await Promise.race([offerMcpInstall(deadline.signal), timeout]); - } catch { - // The MCP offer must never fail or block `workos install`. - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} - -/** Reset per-session banner state (for testing). */ -export function resetMcpNoticeState(): void { - bannerShownThisSession = false; -} diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 5b9c5490..9b2c412b 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -25,8 +25,10 @@ export interface CliPreferences { noticeShownAt?: string; }; /** - * MCP-install onboarding state. Owned semantically by lib/mcp-notice.ts; the - * shape lives here so it rides the same plain-JSON prefs store as telemetry. + * Legacy MCP-install onboarding state. The MCP offer folded into `setup` (see + * commands/setup.ts); these fields are retained for back-compat so a user who + * declined the old standalone MCP offer is still treated as setup-declined + * (see isSetupDeclined). Kept in this plain-JSON store alongside telemetry. */ mcp?: { /** @@ -38,6 +40,18 @@ export interface CliPreferences { /** ISO timestamp the one-time MCP banner was shown. */ bannerShownAt?: string; }; + /** + * Consolidated agent-setup onboarding state (skills + MCP). Owned by + * commands/setup.ts. `declined` is absolute for the automatic surfaces + * (post-login / post-install); `workos setup` stays available manually. + * Legacy `mcp.promptDeclined` is treated as an implicit setup-decline on read + * (see isSetupDeclined) so users who already declined MCP are never re-asked. + */ + setup?: { + declined?: boolean; + /** ISO timestamp the user completed a setup run. */ + completedAt?: string; + }; } /** Effective source of the resolved telemetry-enabled decision. */ @@ -127,14 +141,15 @@ export function savePreferences(next: CliPreferences): void { // No existing file (or unreadable) — start from empty. } - const merged: CliPreferences = { - ...current, - ...next, - ...(current.telemetry || next.telemetry ? { telemetry: { ...current.telemetry, ...next.telemetry } } : {}), - // Deep-merge mcp too so writing one marker (e.g. bannerShownAt) never - // clobbers the other (promptDeclined), which are written at different times. - ...(current.mcp || next.mcp ? { mcp: { ...current.mcp, ...next.mcp } } : {}), - }; + // Shallow-merge each known nested group (one level) so writing one field + // (e.g. setup.completedAt) never clobbers a sibling written at a different + // time (e.g. setup.declined). A new nested group is one entry in this list. + const merged: CliPreferences = { ...current, ...next }; + for (const key of ['telemetry', 'mcp', 'setup'] as const) { + if (current[key] || next[key]) { + Object.assign(merged, { [key]: { ...current[key], ...next[key] } }); + } + } fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); fs.writeFileSync(filePath, JSON.stringify(merged), { encoding: 'utf8', mode: 0o600 }); @@ -166,6 +181,36 @@ export function markNoticeShown(): void { savePreferences({ telemetry: { noticeShownAt: new Date().toISOString() } }); } +/** + * Whether an automatic setup offer should be suppressed. Absolute once the user + * declines. Treats the legacy `mcp.promptDeclined` flag as an implicit decline + * so anyone who declined the old MCP offer is never re-prompted by the new flow. + */ +export function isSetupDeclined(): boolean { + const prefs = getPreferences(); + return prefs.setup?.declined === true || prefs.mcp?.promptDeclined === true; +} + +/** Whether the user has completed a setup run (ever). */ +export function isSetupCompleted(): boolean { + return !!getPreferences().setup?.completedAt; +} + +/** Persist an explicit setup decline. Throws on write failure (see savePreferences). */ +export function recordSetupDeclined(): void { + savePreferences({ setup: { declined: true } }); +} + +/** Persist a completed setup run, stamping the current time. */ +export function recordSetupCompleted(): void { + savePreferences({ setup: { completedAt: new Date().toISOString() } }); +} + +/** Clear the setup decline (new + legacy) so automatic offers resume. For `workos setup --reset`. */ +export function clearSetupDecline(): void { + savePreferences({ setup: { declined: false }, mcp: { promptDeclined: false } }); +} + /** * Tri-state env override for telemetry. * diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 47a5b623..83be33de 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -36,7 +36,7 @@ import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { getTelemetryUrl } from '../utils/urls.js'; import { analytics } from '../utils/analytics.js'; import { getVersion } from './settings.js'; -import { isInGitRepo, getUncommittedOrUntrackedFiles } from '../utils/clack-utils.js'; +import { isInGitRepo, getUncommittedOrUntrackedFiles } from '../utils/ui-utils.js'; import { getCurrentBranch, isProtectedBranch, @@ -105,7 +105,7 @@ export async function detectSingleIntegration( integration: string, options: Pick, ): Promise { - const { getPackageDotJson } = await import('../utils/clack-utils.js'); + const { getPackageDotJson } = await import('../utils/ui-utils.js'); const { hasPackageInstalled } = await import('../utils/package-json.js'); const { existsSync } = await import('node:fs'); const { join } = await import('node:path'); diff --git a/src/lib/telemetry-notice.spec.ts b/src/lib/telemetry-notice.spec.ts index c37d76f3..be76bebe 100644 --- a/src/lib/telemetry-notice.spec.ts +++ b/src/lib/telemetry-notice.spec.ts @@ -6,10 +6,10 @@ vi.mock('../utils/output.js', () => ({ isJsonMode: () => jsonMode, })); -// Spy on the box renderer instead of writing to stderr. -const mockRenderStderrBox = vi.fn(); +// Spy on the flat notice renderer instead of writing to stderr. +const mockRenderStderrNotice = vi.fn(); vi.mock('../utils/box.js', () => ({ - renderStderrBox: (...args: unknown[]) => mockRenderStderrBox(...args), + renderStderrNotice: (...args: unknown[]) => mockRenderStderrNotice(...args), })); // Control the persisted-state gates and spy on the mark. @@ -39,23 +39,23 @@ describe('telemetry-notice', () => { it('human + unshown + not-opted-out → renders once and marks shown', () => { maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); expect(mockMarkNoticeShown).toHaveBeenCalledTimes(1); }); it('renders the opt-out command via formatWorkOSCommand (npx-safe, not hardcoded)', () => { maybeShowTelemetryNotice(); - const inner = mockRenderStderrBox.mock.calls[0]?.[0] as string; + const inner = mockRenderStderrNotice.mock.calls[0]?.[0] as string; expect(inner).toContain(formatWorkOSCommand('telemetry opt-out')); }); it('second call in the same session → no second render (per-session guard)', () => { maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); expect(mockMarkNoticeShown).toHaveBeenCalledTimes(1); }); @@ -64,7 +64,7 @@ describe('telemetry-notice', () => { maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); expect(mockMarkNoticeShown).not.toHaveBeenCalled(); // The flag must stay unset so a real human still sees it later. expect(noticeShown).toBe(false); @@ -75,7 +75,7 @@ describe('telemetry-notice', () => { maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); expect(mockMarkNoticeShown).not.toHaveBeenCalled(); }); @@ -84,13 +84,13 @@ describe('telemetry-notice', () => { maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); expect(mockMarkNoticeShown).not.toHaveBeenCalled(); }); it('marks shown only AFTER rendering (display-then-persist order)', () => { const calls: string[] = []; - mockRenderStderrBox.mockImplementation(() => calls.push('render')); + mockRenderStderrNotice.mockImplementation(() => calls.push('render')); mockMarkNoticeShown.mockImplementation(() => { calls.push('mark'); noticeShown = true; @@ -102,7 +102,7 @@ describe('telemetry-notice', () => { }); it('never throws if rendering fails; does not mark on failure', () => { - mockRenderStderrBox.mockImplementation(() => { + mockRenderStderrNotice.mockImplementation(() => { throw new Error('render boom'); }); @@ -112,12 +112,12 @@ describe('telemetry-notice', () => { it('resetTelemetryNoticeState allows the notice to render again', () => { maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); // Simulate a fresh process where the flag was not persisted. noticeShown = false; resetTelemetryNoticeState(); maybeShowTelemetryNotice(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(2); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(2); }); }); diff --git a/src/lib/telemetry-notice.ts b/src/lib/telemetry-notice.ts index 74e5981b..92db2f2a 100644 --- a/src/lib/telemetry-notice.ts +++ b/src/lib/telemetry-notice.ts @@ -15,7 +15,7 @@ import chalk from 'chalk'; import { isJsonMode } from '../utils/output.js'; -import { renderStderrBox } from '../utils/box.js'; +import { renderStderrNotice } from '../utils/box.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { isNoticeShown, markNoticeShown, isTelemetryOptedOut } from './preferences.js'; import { markStartupNoticeShown } from './startup-notice-gate.js'; @@ -38,8 +38,9 @@ export function maybeShowTelemetryNotice(): void { if (isNoticeShown()) return; // already shown once, ever const optOut = chalk.cyan(formatWorkOSCommand('telemetry opt-out')); - const inner = ` ${chalk.cyan('ℹ')} WorkOS collects anonymous CLI usage telemetry. Run ${optOut} to disable it. `; - renderStderrBox(inner, chalk.cyan); + renderStderrNotice( + `${chalk.cyan('ℹ')} ${chalk.dim('Anonymous CLI usage telemetry is on.')} ${chalk.dim('Disable:')} ${optOut}`, + ); // Set the per-session guard and persist ONLY after a successful render, so a // render failure (caught below) lets a later command in this process retry // rather than silently suppressing the notice for the rest of the session. diff --git a/src/lib/unclaimed-env-provision.spec.ts b/src/lib/unclaimed-env-provision.spec.ts index c1346a9b..9502c517 100644 --- a/src/lib/unclaimed-env-provision.spec.ts +++ b/src/lib/unclaimed-env-provision.spec.ts @@ -9,8 +9,8 @@ vi.mock('../utils/debug.js', () => ({ logError: vi.fn(), })); -// Mock clack -const mockClack = { +// Mock the UI facade +const mockUi = { log: { info: vi.fn(), warn: vi.fn(), @@ -19,7 +19,7 @@ const mockClack = { success: vi.fn(), }, }; -vi.mock('../utils/clack.js', () => ({ default: mockClack })); +vi.mock('../utils/ui.js', () => ({ default: mockUi })); // Mock config-store — track calls const mockGetConfig = vi.fn(); @@ -214,7 +214,7 @@ describe('unclaimed-env-provision', () => { expect(result).toBe(false); expect(mockSaveConfig).toHaveBeenCalled(); - expect(mockClack.log.warn).toHaveBeenCalledWith(expect.stringContaining('config storage may be unreliable')); + expect(mockUi.log.warn).toHaveBeenCalledWith(expect.stringContaining('config storage may be unreliable')); }); it('returns false on API failure (network error)', async () => { @@ -235,7 +235,7 @@ describe('unclaimed-env-provision', () => { const result = await tryProvisionUnclaimedEnv({ installDir: testDir }); expect(result).toBe(false); - expect(mockClack.log.warn).toHaveBeenCalledWith(expect.stringContaining('falling back to login')); + expect(mockUi.log.warn).toHaveBeenCalledWith(expect.stringContaining('falling back to login')); }); it('returns false on API failure (server error)', async () => { diff --git a/src/lib/unclaimed-env-provision.ts b/src/lib/unclaimed-env-provision.ts index 787cca81..2b6b9420 100644 --- a/src/lib/unclaimed-env-provision.ts +++ b/src/lib/unclaimed-env-provision.ts @@ -13,7 +13,7 @@ import type { CliConfig } from './config-store.js'; import { writeCredentialsEnv } from './env-writer.js'; import { logInfo, logError } from '../utils/debug.js'; import { renderStderrBox } from '../utils/box.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface UnclaimedEnvProvisionOptions { @@ -72,7 +72,7 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt const readBack = getActiveEnvironment(); if (!readBack || readBack.type !== 'unclaimed') { logError('[unclaimed-env-provision] Config read-back failed after save — claim token may not persist'); - clack.log.warn('Environment provisioned but config storage may be unreliable. Falling back to login...'); + ui.log.warn('Environment provisioned but config storage may be unreliable. Falling back to login...'); return false; } @@ -87,11 +87,11 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt if (error instanceof UnclaimedEnvApiError) { if (error.statusCode === 429) { - clack.log.warn('WorkOS is busy, falling back to login...'); + ui.log.warn('WorkOS is busy, falling back to login...'); } } else { // Non-API errors (filesystem, keyring) are unexpected — surface to user - clack.log.warn(`Could not set up environment: ${message}. Falling back to login...`); + ui.log.warn(`Could not set up environment: ${message}. Falling back to login...`); } return false; diff --git a/src/lib/unclaimed-warning.spec.ts b/src/lib/unclaimed-warning.spec.ts index 301c237b..300862fb 100644 --- a/src/lib/unclaimed-warning.spec.ts +++ b/src/lib/unclaimed-warning.spec.ts @@ -30,10 +30,16 @@ vi.mock('./unclaimed-env-api.js', () => ({ UnclaimedEnvApiError: MockUnclaimedEnvApiError, })); -// Mock box utility -const mockRenderStderrBox = vi.fn(); +// Mock the flat notice renderer +const mockRenderStderrNotice = vi.fn(); vi.mock('../utils/box.js', () => ({ - renderStderrBox: (...args: unknown[]) => mockRenderStderrBox(...args), + renderStderrNotice: (...args: unknown[]) => mockRenderStderrNotice(...args), +})); + +// pill() is a pure string helper; stub it so the spec doesn't pull in the +// prompt engine via ui.js. +vi.mock('../utils/ui.js', () => ({ + pill: (label: string) => label, })); const { warnIfUnclaimed, resetUnclaimedWarningState } = await import('./unclaimed-warning.js'); @@ -55,7 +61,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); - expect(mockRenderStderrBox).toHaveBeenCalled(); + expect(mockRenderStderrNotice).toHaveBeenCalled(); }); it('does not show warning when active env is not unclaimed', async () => { @@ -68,7 +74,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); }); it('does not show warning when no active env', async () => { @@ -76,7 +82,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); }); it('shows warning only once per session (dedup)', async () => { @@ -88,11 +94,11 @@ describe('unclaimed-warning', () => { mockIsUnclaimedEnvironment.mockReturnValue(true); await warnIfUnclaimed(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); await warnIfUnclaimed(); // Second call should not add any more output (dedup) - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); }); it('suppresses warning in JSON mode', async () => { @@ -106,7 +112,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); }); it('resetUnclaimedWarningState allows re-testing', async () => { @@ -118,12 +124,12 @@ describe('unclaimed-warning', () => { mockIsUnclaimedEnvironment.mockReturnValue(true); await warnIfUnclaimed(); - expect(mockRenderStderrBox).toHaveBeenCalledTimes(1); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(1); resetUnclaimedWarningState(); await warnIfUnclaimed(); // Should have doubled the output (warning shown again after reset) - expect(mockRenderStderrBox).toHaveBeenCalledTimes(2); + expect(mockRenderStderrNotice).toHaveBeenCalledTimes(2); }); it('detects claimed status and updates config', async () => { @@ -140,7 +146,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); }); it('shows warning when claim check fails', async () => { @@ -156,7 +162,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); - expect(mockRenderStderrBox).toHaveBeenCalled(); + expect(mockRenderStderrNotice).toHaveBeenCalled(); }); it('promotes to claimed when claim check returns 401', async () => { @@ -173,7 +179,7 @@ describe('unclaimed-warning', () => { await warnIfUnclaimed(); expect(mockMarkEnvironmentClaimed).toHaveBeenCalled(); - expect(mockRenderStderrBox).not.toHaveBeenCalled(); + expect(mockRenderStderrNotice).not.toHaveBeenCalled(); }); it('never throws even if getActiveEnvironment throws', async () => { diff --git a/src/lib/unclaimed-warning.ts b/src/lib/unclaimed-warning.ts index bb48f40f..08ba4c59 100644 --- a/src/lib/unclaimed-warning.ts +++ b/src/lib/unclaimed-warning.ts @@ -12,7 +12,8 @@ import { getActiveEnvironment, isUnclaimedEnvironment, markEnvironmentClaimed } import { createClaimNonce, UnclaimedEnvApiError } from './unclaimed-env-api.js'; import { logError, logInfo } from '../utils/debug.js'; import { isJsonMode } from '../utils/output.js'; -import { renderStderrBox } from '../utils/box.js'; +import { renderStderrNotice } from '../utils/box.js'; +import { pill } from '../utils/ui.js'; import { markStartupNoticeShown } from './startup-notice-gate.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; @@ -61,8 +62,9 @@ export async function warnIfUnclaimed(): Promise { warningShownThisSession = true; if (!isJsonMode()) { - const inner = ` ${chalk.yellow('⚠ Unclaimed environment')} — Run ${chalk.cyan(formatWorkOSCommand('env claim'))} to keep your data. `; - renderStderrBox(inner, chalk.yellow); + renderStderrNotice( + `${pill('WARN', 'warn')} Unclaimed environment ${chalk.dim('— run')} ${chalk.cyan(formatWorkOSCommand('env claim'))} ${chalk.dim('to keep your data')}`, + ); // Claim the one-notice-per-run slot so the lower-priority MCP banner defers. markStartupNoticeShown(); } diff --git a/src/lib/workos-management.ts b/src/lib/workos-management.ts index ccfdafa3..be8cd6df 100644 --- a/src/lib/workos-management.ts +++ b/src/lib/workos-management.ts @@ -1,7 +1,7 @@ import type { Integration } from './constants.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { analytics } from '../utils/analytics.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import { getCallbackPath } from './port-detection.js'; const WORKOS_API_BASE = 'https://api.workos.com'; @@ -131,10 +131,7 @@ export async function autoConfigureWorkOSEnvironment( const callbackUrl = options.redirectUri || `${baseUrl}${callbackPath}`; const homepageUrlValue = options.homepageUrl || baseUrl; - clack.log.step('Configuring WorkOS dashboard settings via API...'); - clack.log.info(` Redirect URI: ${callbackUrl}`); - clack.log.info(` CORS origin: ${baseUrl}`); - clack.log.info(` Homepage URL: ${homepageUrlValue}`); + ui.log.step('Configuring WorkOS dashboard settings...'); try { const [redirectUri, corsOrigin, homepageUrl] = await Promise.all([ @@ -153,19 +150,24 @@ export async function autoConfigureWorkOSEnvironment( corsOrigin: corsOrigin.alreadyExists ? 'existed' : 'created', }); - // Build user feedback - const messages: string[] = []; - messages.push( - redirectUri.alreadyExists - ? `Redirect URI: ${callbackUrl} (already existed)` - : `Redirect URI: ${callbackUrl} (created)`, - ); - messages.push( - corsOrigin.alreadyExists ? `CORS origin: ${baseUrl} (already existed)` : `CORS origin: ${baseUrl} (created)`, - ); - messages.push(`Homepage URL: ${homepageUrlValue} (updated)`); - - clack.log.success('WorkOS dashboard configured:\n ' + messages.join('\n ')); + // Aligned key/value feedback: value in accent, a dim status for "already + // existed" vs. a green status for a fresh create/update. + ui.log.success('WorkOS dashboard configured'); + ui.rows([ + { + key: 'Redirect URI', + value: callbackUrl, + status: redirectUri.alreadyExists ? 'already set' : 'created', + statusKind: redirectUri.alreadyExists ? 'muted' : 'ok', + }, + { + key: 'CORS origin', + value: baseUrl, + status: corsOrigin.alreadyExists ? 'already set' : 'created', + statusKind: corsOrigin.alreadyExists ? 'muted' : 'ok', + }, + { key: 'Homepage URL', value: homepageUrlValue, status: 'updated', statusKind: 'ok' }, + ]); return results; } catch (error) { @@ -173,17 +175,17 @@ export async function autoConfigureWorkOSEnvironment( // Provide specific guidance for common errors if (message.includes('401') || message.includes('Invalid API key')) { - clack.log.warn('Could not configure WorkOS dashboard: Invalid API key'); + ui.log.warn('Could not configure WorkOS dashboard: Invalid API key'); } else if (message.includes('403') || message.includes('permission')) { - clack.log.warn('Could not configure WorkOS dashboard: API key lacks permission'); + ui.log.warn('Could not configure WorkOS dashboard: API key lacks permission'); } else if (message.includes('422') || message.includes('Validation')) { - clack.log.warn(`Could not configure WorkOS dashboard: Validation error`); - clack.log.info(` Error: ${message}`); + ui.log.warn(`Could not configure WorkOS dashboard: Validation error`); + ui.log.info(` Error: ${message}`); } else { - clack.log.warn(`Could not configure WorkOS dashboard: ${message}`); + ui.log.warn(`Could not configure WorkOS dashboard: ${message}`); } - clack.log.info('You can configure these settings manually in the WorkOS dashboard.'); + ui.log.info('You can configure these settings manually in the WorkOS dashboard.'); analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, { action: 'workos environment auto-config failed', diff --git a/src/steps/add-or-update-environment-variables.ts b/src/steps/add-or-update-environment-variables.ts index 6d081ae9..c24f8598 100644 --- a/src/steps/add-or-update-environment-variables.ts +++ b/src/steps/add-or-update-environment-variables.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import type { Integration } from '../lib/constants.js'; import { traceStep } from '../telemetry.js'; import { analytics } from '../utils/analytics.js'; -import clack from '../utils/clack.js'; +import ui from '../utils/ui.js'; import * as fs from 'fs'; import path from 'path'; @@ -64,14 +64,14 @@ export async function addOrUpdateEnvironmentVariablesStep({ encoding: 'utf8', flag: 'w', }); - clack.log.success(`Updated environment variables in ${chalk.bold.cyan(relativeEnvFilePath)}`); + ui.log.success(`Updated environment variables in ${chalk.bold.cyan(relativeEnvFilePath)}`); } else { - clack.log.success(`${chalk.bold.cyan(relativeEnvFilePath)} already has the necessary environment variables.`); + ui.log.success(`${chalk.bold.cyan(relativeEnvFilePath)} already has the necessary environment variables.`); } addedEnvVariables = true; } catch (error) { - clack.log.warning( + ui.log.warning( `Failed to update environment variables in ${chalk.bold.cyan( relativeEnvFilePath, )}. Please update them manually.`, @@ -95,11 +95,11 @@ export async function addOrUpdateEnvironmentVariablesStep({ encoding: 'utf8', flag: 'w', }); - clack.log.success(`Created ${chalk.bold.cyan(relativeEnvFilePath)} with environment variables.`); + ui.log.success(`Created ${chalk.bold.cyan(relativeEnvFilePath)} with environment variables.`); addedEnvVariables = true; } catch (error) { - clack.log.warning( + ui.log.warning( `Failed to create ${chalk.bold.cyan( relativeEnvFilePath, )} with environment variables. Please add them manually.`, @@ -136,10 +136,10 @@ export async function addOrUpdateEnvironmentVariablesStep({ encoding: 'utf8', flag: 'w', }); - clack.log.success(`Updated ${chalk.bold.cyan('.gitignore')} to include ${chalk.bold.cyan(envFileName)}.`); + ui.log.success(`Updated ${chalk.bold.cyan('.gitignore')} to include ${chalk.bold.cyan(envFileName)}.`); addedGitignore = true; } catch (error) { - clack.log.warning( + ui.log.warning( `Failed to update ${chalk.bold.cyan('.gitignore')} to include ${chalk.bold.cyan(envFileName)}.`, ); @@ -163,10 +163,10 @@ export async function addOrUpdateEnvironmentVariablesStep({ encoding: 'utf8', flag: 'w', }); - clack.log.success(`Created ${chalk.bold.cyan('.gitignore')} with environment files.`); + ui.log.success(`Created ${chalk.bold.cyan('.gitignore')} with environment files.`); addedGitignore = true; } catch (error) { - clack.log.warning(`Failed to create ${chalk.bold.cyan('.gitignore')} with environment files.`); + ui.log.warning(`Failed to create ${chalk.bold.cyan('.gitignore')} with environment files.`); analytics.capture('installer interaction', { action: 'failed to create gitignore', diff --git a/src/steps/run-prettier.ts b/src/steps/run-prettier.ts index 961bdd0f..d9d2658d 100644 --- a/src/steps/run-prettier.ts +++ b/src/steps/run-prettier.ts @@ -1,8 +1,8 @@ import type { Integration } from '../lib/constants.js'; import { traceStep } from '../telemetry.js'; import { analytics } from '../utils/analytics.js'; -import clack from '../utils/clack.js'; -import { getPackageDotJson, getUncommittedOrUntrackedFiles, isInGitRepo } from '../utils/clack-utils.js'; +import ui from '../utils/ui.js'; +import { getPackageDotJson, getUncommittedOrUntrackedFiles, isInGitRepo } from '../utils/ui-utils.js'; import { hasPackageInstalled } from '../utils/package-json.js'; import type { InstallerOptions } from '../utils/types.js'; import { spawn } from 'node:child_process'; @@ -38,7 +38,7 @@ export async function runPrettierStep({ return; } - const prettierSpinner = clack.spinner(); + const prettierSpinner = ui.spinner(); prettierSpinner.start('Running Prettier on your files.'); try { diff --git a/src/steps/upload-environment-variables/index.spec.ts b/src/steps/upload-environment-variables/index.spec.ts index 61bdbe71..87640dc5 100644 --- a/src/steps/upload-environment-variables/index.spec.ts +++ b/src/steps/upload-environment-variables/index.spec.ts @@ -10,7 +10,7 @@ vi.mock('./providers/vercel.js', () => ({ }, })); -vi.mock('../../utils/clack.js', () => ({ +vi.mock('../../utils/ui.js', () => ({ default: { select: vi.fn(), isCancel: vi.fn(() => false), @@ -22,7 +22,7 @@ vi.mock('../../utils/analytics.js', () => ({ analytics: { capture: vi.fn(), shutdown: vi.fn(), setTag: vi.fn() }, })); -const clack = (await import('../../utils/clack.js')).default; +const ui = (await import('../../utils/ui.js')).default; const { uploadEnvironmentVariablesStep } = await import('./index.js'); const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js'); @@ -33,7 +33,7 @@ describe('uploadEnvironmentVariablesStep — non-interactive skip', () => { beforeEach(() => { resetInteractionModeForTests(); vi.clearAllMocks(); - vi.mocked(clack.isCancel).mockReturnValue(false); + vi.mocked(ui.isCancel).mockReturnValue(false); }); afterEach(() => { @@ -46,16 +46,16 @@ describe('uploadEnvironmentVariablesStep — non-interactive skip', () => { const result = await uploadEnvironmentVariablesStep({}, { integration, options }); expect(result).toEqual([]); - expect(clack.select).not.toHaveBeenCalled(); + expect(ui.select).not.toHaveBeenCalled(); }); it('human mode reaches the prompt', async () => { setInteractionMode({ mode: 'human', source: 'default' }); - vi.mocked(clack.select).mockResolvedValueOnce(false as never); + vi.mocked(ui.select).mockResolvedValueOnce(false as never); const result = await uploadEnvironmentVariablesStep({}, { integration, options }); expect(result).toEqual([]); - expect(clack.select).toHaveBeenCalledOnce(); + expect(ui.select).toHaveBeenCalledOnce(); }); }); diff --git a/src/steps/upload-environment-variables/index.ts b/src/steps/upload-environment-variables/index.ts index 12665664..fd7791c9 100644 --- a/src/steps/upload-environment-variables/index.ts +++ b/src/steps/upload-environment-variables/index.ts @@ -1,8 +1,8 @@ import type { Integration } from '../../lib/constants.js'; import { traceStep } from '../../telemetry.js'; import { analytics } from '../../utils/analytics.js'; -import clack from '../../utils/clack.js'; -import { abortIfCancelled } from '../../utils/clack-utils.js'; +import ui from '../../utils/ui.js'; +import { abortIfCancelled } from '../../utils/ui-utils.js'; import { isPromptAllowed } from '../../utils/interaction-mode.js'; import type { InstallerOptions } from '../../utils/types.js'; import { EnvironmentProvider } from './EnvironmentProvider.js'; @@ -51,7 +51,7 @@ export const uploadEnvironmentVariablesStep = async ( } const upload: boolean = await abortIfCancelled( - clack.select({ + ui.select({ message: `It looks like you are using ${provider.name}. Would you like to upload the environment variables?`, options: [ { diff --git a/src/steps/upload-environment-variables/providers/vercel.ts b/src/steps/upload-environment-variables/providers/vercel.ts index aeb814da..8645ee7c 100644 --- a/src/steps/upload-environment-variables/providers/vercel.ts +++ b/src/steps/upload-environment-variables/providers/vercel.ts @@ -3,7 +3,7 @@ import { EnvironmentProvider } from '../EnvironmentProvider.js'; import * as fs from 'fs'; import * as path from 'path'; import type { InstallerOptions } from '../../../utils/types.js'; -import clack from '../../../utils/clack.js'; +import ui from '../../../utils/ui.js'; import chalk from 'chalk'; import { analytics } from '../../../utils/analytics.js'; import { SPAWN_OPTS } from '../../../utils/platform.js'; @@ -116,7 +116,7 @@ export class VercelEnvironmentProvider extends EnvironmentProvider { const results: Record = {}; for (const [key, value] of Object.entries(vars)) { - const spinner = clack.spinner(); + const spinner = ui.spinner(); spinner.start(`Uploading ${chalk.cyan(key)} to ${this.name}...`); await Promise.all(this.environments.map((environment) => this.uploadEnvironmentVariable(key, value, environment))) diff --git a/src/utils/box.ts b/src/utils/box.ts index 1b9cd093..6acf2b96 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -71,6 +71,18 @@ export function wrapAnsiAware(input: string, maxWidth: number): string[] { * is word-wrapped (ANSI-aware) and the box grows to multiple lines so the * border never breaks on a narrow terminal. */ +/** + * Flat, gutterless notice to stderr — the de-boxed replacement for + * `renderStderrBox` on the startup notices. Callers pass already-styled lines; + * this indents them two spaces and frames them with a single blank line above + * and below so the notice reads as its own beat without a border. + */ +export function renderStderrNotice(...lines: string[]): void { + console.error(''); + for (const ln of lines) console.error(` ${ln}`); + console.error(''); +} + export function renderStderrBox(inner: string, color: typeof chalk.yellow | typeof chalk.green): void { const cols = terminalWidth(); const plainLen = visibleWidth(inner); diff --git a/src/utils/clack.ts b/src/utils/clack.ts deleted file mode 100644 index 4a2fae11..00000000 --- a/src/utils/clack.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as clack from '@clack/prompts'; - -// Dashboard mode flag - when true, suppress console output -let dashboardMode = false; - -export function setDashboardMode(enabled: boolean): void { - dashboardMode = enabled; -} - -export function isDashboardMode(): boolean { - return dashboardMode; -} - -// Create a proxy that suppresses log output in dashboard mode -const clackProxy = new Proxy(clack, { - get(target, prop) { - const value = target[prop as keyof typeof clack]; - - // Suppress log methods in dashboard mode - if (prop === 'log' && dashboardMode) { - return { - info: () => {}, - success: () => {}, - warn: () => {}, - warning: () => {}, - error: () => {}, - step: () => {}, - message: () => {}, - }; - } - - // Suppress intro/outro in dashboard mode - if ((prop === 'intro' || prop === 'outro') && dashboardMode) { - return () => {}; - } - - return value; - }, -}); - -export default clackProxy; diff --git a/src/utils/debug.spec.ts b/src/utils/debug.spec.ts index 67f8c696..bf57169c 100644 --- a/src/utils/debug.spec.ts +++ b/src/utils/debug.spec.ts @@ -12,8 +12,8 @@ vi.mock('os', async () => { return { ...actual, homedir: () => testDir }; }); -// Mock clack to avoid side effects -vi.mock('./clack.js', () => ({ +// Mock the UI facade +vi.mock('./ui.js', () => ({ default: { log: { info: vi.fn() }, }, @@ -60,7 +60,7 @@ describe('debug logging', () => { const actual = await vi.importActual('os'); return { ...actual, homedir: () => testDir }; }); - vi.doMock('./clack.js', () => ({ + vi.doMock('./ui.js', () => ({ default: { log: { info: vi.fn() } }, })); @@ -94,7 +94,7 @@ describe('debug logging', () => { const actual = await vi.importActual('os'); return { ...actual, homedir: () => testDir }; }); - vi.doMock('./clack.js', () => ({ + vi.doMock('./ui.js', () => ({ default: { log: { info: vi.fn() } }, })); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -117,7 +117,7 @@ describe('debug logging', () => { const actual = await vi.importActual('os'); return { ...actual, homedir: () => testDir }; }); - vi.doMock('./clack.js', () => ({ + vi.doMock('./ui.js', () => ({ default: { log: { info: vi.fn() } }, })); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -141,7 +141,7 @@ describe('debug logging', () => { const actual = await vi.importActual('os'); return { ...actual, homedir: () => testDir }; }); - vi.doMock('./clack.js', () => ({ + vi.doMock('./ui.js', () => ({ default: { log: { info: vi.fn() } }, })); diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 0fddd882..9b313b00 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -4,7 +4,7 @@ import { homedir } from 'os'; import chalk from 'chalk'; import { prepareMessage } from './logging.js'; import { redactCredentials } from './redact.js'; -import clack from './clack.js'; +import ui from './ui.js'; import { isJsonMode } from './output.js'; let debugEnabled = false; @@ -69,7 +69,7 @@ function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown // Write to console if debug enabled if (debugEnabled && !isJsonMode()) { const color = level === 'ERROR' ? chalk.red : level === 'WARN' ? chalk.yellow : chalk.dim; - clack.log.info(color(`${emoji} ${msg}`)); + ui.log.info(color(`${emoji} ${msg}`)); } // Write to log file @@ -107,7 +107,7 @@ export function logError(...args: unknown[]): void { export function debug(...args: unknown[]): void { if (!isDebugEnabled()) return; const msg = args.map((a) => prepareMessage(a)).join(' '); - clack.log.info(chalk.dim(msg)); + ui.log.info(chalk.dim(msg)); } export function isDebugEnabled(): boolean { diff --git a/src/utils/environment.ts b/src/utils/environment.ts index aac7e501..c2a29ce8 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -1,4 +1,4 @@ -import { getPackageDotJson } from './clack-utils.js'; +import { getPackageDotJson } from './ui-utils.js'; import type { InstallerOptions } from './types.js'; import fg from 'fast-glob'; import { isHumanMode } from './interaction-mode.js'; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 41c47f50..2c075657 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1298,6 +1298,18 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'setup', + description: 'Set up your coding agent (install WorkOS skills + MCP server)', + options: [ + insecureStorageOpt, + { name: 'agents', type: 'string', description: 'Comma-separated agent keys', required: false, hidden: false }, + { name: 'skills-only', type: 'boolean', description: 'Install skills only', required: false, hidden: false }, + { name: 'mcp-only', type: 'boolean', description: 'Install the MCP server only', required: false, hidden: false }, + { name: 'yes', type: 'boolean', description: 'Install without prompting', required: false, hidden: false }, + { name: 'reset', type: 'boolean', description: 'Re-enable automatic setup offers', required: false, hidden: false }, + ], + }, { name: 'setup-org', description: 'One-shot organization onboarding', diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 27123441..22557c58 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { traceStep } from '../telemetry.js'; -import { getPackageDotJson, updatePackageDotJson } from './clack-utils.js'; +import { getPackageDotJson, updatePackageDotJson } from './ui-utils.js'; import { analytics } from './analytics.js'; import type { InstallerOptions } from './types.js'; diff --git a/src/utils/clack-utils.spec.ts b/src/utils/ui-utils.spec.ts similarity index 88% rename from src/utils/clack-utils.spec.ts rename to src/utils/ui-utils.spec.ts index 003cfe5e..04d288ed 100644 --- a/src/utils/clack-utils.spec.ts +++ b/src/utils/ui-utils.spec.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; * prompt is exactly the hang this guard fixes. */ -vi.mock('./clack.js', () => ({ +vi.mock('./ui.js', () => ({ default: { isCancel: vi.fn(() => false), cancel: vi.fn(), @@ -28,18 +28,18 @@ vi.mock('./analytics.js', () => ({ analytics: { shutdown: vi.fn(), setTag: vi.fn(), capture: vi.fn() }, })); -const clack = (await import('./clack.js')).default; +const ui = (await import('./ui.js')).default; const { setInteractionMode, resetInteractionModeForTests } = await import('./interaction-mode.js'); const { CliExit } = await import('./cli-exit.js'); const { setOutputMode } = await import('./output.js'); -const { abortIfCancelled, getOrAskForWorkOSCredentials } = await import('./clack-utils.js'); +const { abortIfCancelled, getOrAskForWorkOSCredentials } = await import('./ui-utils.js'); describe('abortIfCancelled — non-interactive guard', () => { beforeEach(() => { resetInteractionModeForTests(); setOutputMode('human'); vi.clearAllMocks(); - vi.mocked(clack.isCancel).mockReturnValue(false); + vi.mocked(ui.isCancel).mockReturnValue(false); }); afterEach(() => { @@ -74,7 +74,7 @@ describe('abortIfCancelled — non-interactive guard', () => { it('human mode passes a resolved value through', async () => { setInteractionMode({ mode: 'human', source: 'default' }); - vi.mocked(clack.isCancel).mockReturnValue(false); + vi.mocked(ui.isCancel).mockReturnValue(false); await expect(abortIfCancelled('value')).resolves.toBe('value'); }); }); @@ -96,8 +96,8 @@ describe('getOrAskForWorkOSCredentials — credential-source-aware copy', () => vi.clearAllMocks(); const result = await getOrAskForWorkOSCredentials({ ...base, credentialSource }); expect(result).toEqual({ apiKey: 'sk_test', clientId: 'client_x' }); - expect(clack.log.info).toHaveBeenCalledTimes(1); - expect(clack.log.info).toHaveBeenCalledWith('Using the WorkOS credentials you provided'); + expect(ui.log.info).toHaveBeenCalledTimes(1); + expect(ui.log.info).toHaveBeenCalledWith('Using the WorkOS credentials you provided'); } }); @@ -105,18 +105,18 @@ describe('getOrAskForWorkOSCredentials — credential-source-aware copy', () => for (const credentialSource of ['device', 'stored', 'env'] as const) { vi.clearAllMocks(); await getOrAskForWorkOSCredentials({ ...base, credentialSource }); - expect(clack.log.info).not.toHaveBeenCalled(); + expect(ui.log.info).not.toHaveBeenCalled(); } }); it('stays silent in dashboard mode', async () => { await getOrAskForWorkOSCredentials({ ...base, dashboard: true, credentialSource: 'cli' }); - expect(clack.log.info).not.toHaveBeenCalled(); + 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' }); - expect(clack.log.info).not.toHaveBeenCalled(); + expect(ui.log.info).not.toHaveBeenCalled(); }); }); diff --git a/src/utils/clack-utils.ts b/src/utils/ui-utils.ts similarity index 91% rename from src/utils/clack-utils.ts rename to src/utils/ui-utils.ts index ec91a73b..f3101537 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/ui-utils.ts @@ -14,7 +14,7 @@ import type { InstallerOptions } from './types.js'; import { getPackageVersion } from './package-json.js'; import { ISSUES_URL, type Integration } from '../lib/constants.js'; import { analytics } from './analytics.js'; -import clack from './clack.js'; +import ui from './ui.js'; import { INTEGRATION_CONFIG } from '../lib/config.js'; import { SPAWN_OPTS } from './platform.js'; import { isPromptAllowed } from './interaction-mode.js'; @@ -53,7 +53,7 @@ export interface CliSetupConfigContent { export async function abort(message?: string, status?: number): Promise { await analytics.shutdown('cancelled'); - clack.outro(message ?? 'Installer setup cancelled.'); + ui.outro(message ?? 'Installer setup cancelled.'); return process.exit(status ?? 1); } @@ -86,13 +86,10 @@ export async function abortIfCancelled( await analytics.shutdown('cancelled'); const resolvedInput = await input; - if ( - clack.isCancel(resolvedInput) || - (typeof resolvedInput === 'symbol' && resolvedInput.description === 'clack:cancel') - ) { + if (ui.isCancel(resolvedInput)) { const docsUrl = integration ? INTEGRATION_CONFIG[integration].docsUrl : 'https://workos.com/docs/user-management'; - clack.cancel( + ui.cancel( `Installer setup cancelled. You can read the documentation for ${ integration ?? 'WorkOS AuthKit' } at ${chalk.cyan(docsUrl)} to continue with the setup manually.`, @@ -104,15 +101,13 @@ export async function abortIfCancelled( } export function printWelcome(options: { wizardName: string; message?: string }): void { - // eslint-disable-next-line no-console - console.log(''); - clack.intro(chalk.inverse(` ${options.wizardName} `)); + ui.intro('WorkOS', options.wizardName); const welcomeText = options.message || `The ${options.wizardName} will help you set up WorkOS AuthKit for your application.\nThank you for using WorkOS AuthKit :)`; - clack.note(welcomeText); + ui.note(welcomeText); } export async function confirmContinueIfNoOrDirtyGitRepo(options: Pick): Promise { @@ -122,7 +117,7 @@ export async function confirmContinueIfNoOrDirtyGitRepo(options: Pick { const selection = await abortIfCancelled<{ value: string; index: number } | symbol>( - clack.select({ + ui.select({ maxItems: 12, message: message, options: items.map((item, index) => { @@ -243,15 +238,15 @@ export async function confirmContinueIfPackageVersionNotSupported({ return; } - clack.log.warn( + ui.log.warn( `You have an unsupported version of ${packageName} installed: ${packageId}@${packageVersion}`, ); - clack.note(note ?? `Please upgrade to ${acceptableVersions} if you wish to use the WorkOS AuthKit installer.`); + ui.note(note ?? `Please upgrade to ${acceptableVersions} if you wish to use the WorkOS AuthKit installer.`); const continueWithUnsupportedVersion = await abortIfCancelled( - clack.confirm({ + ui.confirm({ message: 'Do you want to continue anyway?', }), ); @@ -315,7 +310,7 @@ export async function installPackage({ return traceStep('install-package', async () => { if (alreadyInstalled && askBeforeUpdating) { const shouldUpdatePackage = await abortIfCancelled( - clack.confirm({ + ui.confirm({ message: `The ${chalk.bold.cyan( packageNameDisplayLabel ?? packageName, )} package is already installed. Do you want to update it to the latest version?`, @@ -327,7 +322,7 @@ export async function installPackage({ } } - const sdkInstallSpinner = clack.spinner(); + const sdkInstallSpinner = ui.spinner(); const pkgManager = packageManager || (await getPackageManager({ installDir })); @@ -379,7 +374,7 @@ export async function installPackage({ }); } catch (e) { sdkInstallSpinner.stop('Installation failed.'); - clack.log.error( + ui.log.error( `${chalk.red( 'Encountered the following error during installation:', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -435,7 +430,7 @@ export async function ensurePackageIsInstalled( } const continueWithoutPackage = await abortIfCancelled( - clack.confirm({ + ui.confirm({ message: `${packageName} does not seem to be installed. Do you still want to continue?`, initialValue: false, }), @@ -450,7 +445,7 @@ export async function ensurePackageIsInstalled( export async function getPackageDotJson({ installDir }: Pick): Promise { const packageJsonFileContents = await fs.promises.readFile(join(installDir, 'package.json'), 'utf8').catch(() => { - clack.log.error('Could not find package.json. Make sure to run the installer in the root of your app!'); + ui.log.error('Could not find package.json. Make sure to run the installer in the root of your app!'); return abort(); }); @@ -460,7 +455,7 @@ export async function getPackageDotJson({ installDir }: Pick 0 ? detectedPackageManagers[0] : npm; - clack.log.info(`CI mode: auto-selected package manager: ${selectedPackageManager.label}`); + ui.log.info(`CI mode: auto-selected package manager: ${selectedPackageManager.label}`); analytics.setTag('package-manager', selectedPackageManager.name); return selectedPackageManager; } @@ -520,7 +515,7 @@ export async function getPackageManager( : 'Please select your package manager.'; const selectedPackageManager = await abortIfCancelled( - clack.select({ + ui.select({ message, options: pkgOptions.map((packageManager) => ({ value: packageManager, @@ -565,7 +560,7 @@ export async function getOrAskForWorkOSCredentials( const source = _options.credentialSource; const userProvided = source === 'cli' || source === 'manual' || source === undefined; if (!_options.dashboard && !isJsonMode() && userProvided) { - clack.log.info('Using the WorkOS credentials you provided'); + ui.log.info('Using the WorkOS credentials you provided'); } return { apiKey: apiKey || '', clientId }; } @@ -583,7 +578,7 @@ export async function getOrAskForWorkOSCredentials( // Use existing credentials if both are present (or API key not required) if (existingClientId && (!requireApiKey || existingApiKey)) { if (!_options.dashboard) { - clack.log.success(`Found existing WorkOS credentials in .env.local`); + ui.log.success(`Found existing WorkOS credentials in .env.local`); } return { apiKey: existingApiKey || '', @@ -597,12 +592,12 @@ export async function getOrAskForWorkOSCredentials( } // Otherwise, prompt user for credentials - clack.log.step(`Get your credentials from ${chalk.cyan('https://dashboard.workos.com')}`); + ui.log.step(`Get your credentials from ${chalk.cyan('https://dashboard.workos.com')}`); if (requireApiKey && !apiKey) { - clack.log.info(`${chalk.dim('ℹ️ Your API key will be hidden for security and saved to .env.local')}`); + ui.log.info(`${chalk.dim('ℹ️ Your API key will be hidden for security and saved to .env.local')}`); apiKey = (await abortIfCancelled( - clack.password({ + ui.password({ message: 'Enter your WorkOS API Key', validate: (value) => { if (!value) return 'API Key is required'; @@ -614,12 +609,12 @@ export async function getOrAskForWorkOSCredentials( }), )) as string; } else if (!requireApiKey) { - clack.log.info(`${chalk.dim('ℹ️ Client-only SDK - API key not required')}`); + ui.log.info(`${chalk.dim('ℹ️ Client-only SDK - API key not required')}`); } if (!clientId) { clientId = (await abortIfCancelled( - clack.text({ + ui.text({ message: 'Enter your WorkOS Client ID', placeholder: 'client_...', validate: (value) => { diff --git a/src/utils/ui.spec.ts b/src/utils/ui.spec.ts new file mode 100644 index 00000000..963a312e --- /dev/null +++ b/src/utils/ui.spec.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + select: vi.fn(), + input: vi.fn(), + password: vi.fn(), +})); + +const inquirer = await import('@inquirer/prompts'); +const ui = (await import('./ui.js')).default; +const { isCancel, CANCEL, setDashboardMode } = await import('./ui.js'); + +function namedError(name: string): Error { + const e = new Error(name); + e.name = name; + return e; +} + +beforeEach(() => { + vi.clearAllMocks(); + setDashboardMode(false); +}); + +describe('isCancel / CANCEL', () => { + it('recognizes the CANCEL sentinel and nothing else', () => { + expect(isCancel(CANCEL)).toBe(true); + expect(isCancel(false)).toBe(false); + expect(isCancel('nope')).toBe(false); + expect(isCancel(Symbol('other'))).toBe(false); + }); +}); + +describe('prompt adapters (legacy shape → @inquirer)', () => { + it('confirm forwards message + initialValue→default and the abort signal', async () => { + vi.mocked(inquirer.confirm).mockResolvedValue(true); + const controller = new AbortController(); + + const result = await ui.confirm({ message: 'ok?', initialValue: false, signal: controller.signal }); + + expect(result).toBe(true); + expect(inquirer.confirm).toHaveBeenCalledWith({ message: 'ok?', default: false }, { signal: controller.signal }); + }); + + it('select maps options[{value,label,hint}] → choices[{value,name,description}] and initialValue → default', async () => { + vi.mocked(inquirer.select).mockResolvedValue('a'); + + const result = await ui.select({ + message: 'pick', + options: [ + { value: 'a', label: 'Option A', hint: 'the first' }, + { value: 'b', label: 'Option B' }, + ], + initialValue: 'b', + maxItems: 5, + }); + + expect(result).toBe('a'); + expect(inquirer.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'pick', + choices: [ + { value: 'a', name: 'Option A', description: 'the first' }, + { value: 'b', name: 'Option B', description: undefined }, + ], + default: 'b', + pageSize: 5, + }), + expect.anything(), + ); + }); + + it('text converts the validate contract (error string | Error | undefined) → inquirer (string | true)', async () => { + vi.mocked(inquirer.input).mockResolvedValue('value'); + + await ui.text({ + message: 'name', + validate: (v) => { + if (v === '') return 'required'; + if (v === 'boom') return new Error('bad value'); + return undefined; + }, + }); + + const passed = vi.mocked(inquirer.input).mock.calls[0][0] as { validate: (v: string) => Promise }; + await expect(passed.validate('x')).resolves.toBe(true); + await expect(passed.validate('')).resolves.toBe('required'); + // Error instances are unwrapped to their .message (regression guard). + await expect(passed.validate('boom')).resolves.toBe('bad value'); + }); + + it('text folds placeholder into the message (inquirer has no placeholder)', async () => { + vi.mocked(inquirer.input).mockResolvedValue('client_123'); + + await ui.text({ message: 'Enter your WorkOS Client ID', placeholder: 'client_...' }); + + expect(inquirer.input).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter your WorkOS Client ID (client_...)' }), + expect.anything(), + ); + }); + + it('password masks input and adapts validate', async () => { + vi.mocked(inquirer.password).mockResolvedValue('secret'); + + const result = await ui.password({ message: 'key' }); + + expect(result).toBe('secret'); + expect(inquirer.password).toHaveBeenCalledWith(expect.objectContaining({ mask: true }), expect.anything()); + }); +}); + +describe('cancellation (inquirer throws → CANCEL sentinel)', () => { + it('maps ExitPromptError (ctrl-c) to CANCEL', async () => { + vi.mocked(inquirer.confirm).mockRejectedValue(namedError('ExitPromptError')); + expect(isCancel(await ui.confirm({ message: 'q' }))).toBe(true); + }); + + it('maps AbortPromptError (signal abort) to CANCEL', async () => { + vi.mocked(inquirer.select).mockRejectedValue(namedError('AbortPromptError')); + expect(isCancel(await ui.select({ message: 'q', options: [{ value: 1 }] }))).toBe(true); + }); + + it('maps CancelPromptError to CANCEL', async () => { + vi.mocked(inquirer.password).mockRejectedValue(namedError('CancelPromptError')); + expect(isCancel(await ui.password({ message: 'q' }))).toBe(true); + }); + + it('rethrows non-cancel errors', async () => { + vi.mocked(inquirer.input).mockRejectedValue(new Error('disk full')); + await expect(ui.text({ message: 'q' })).rejects.toThrow('disk full'); + }); +}); + +describe('dashboard mode suppresses output', () => { + let logSpy: ReturnType; + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + 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('writes when dashboard mode is off', () => { + setDashboardMode(false); + ui.log.success('done'); + expect(logSpy).toHaveBeenCalled(); + }); +}); + +describe('flat output helpers', () => { + let logSpy: ReturnType; + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); + const lines = () => logSpy.mock.calls.map((c) => strip(String(c[0] ?? ''))); + const indentOf = (l: string) => l.match(/^ */)![0].length; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => logSpy.mockRestore()); + + it('intro renders "Title · subtitle" when a subtitle is given', () => { + ui.intro('WorkOS', 'AuthKit installer'); + expect(lines().some((l) => l.includes('WorkOS · AuthKit installer'))).toBe(true); + }); + + it('intro renders the title alone (no ·) when no subtitle', () => { + ui.intro('WorkOS'); + const titleLine = lines().find((l) => l.includes('WorkOS')); + expect(titleLine).toBeDefined(); + expect(titleLine).not.toContain('·'); + }); + + it('log.detail nests one level deeper than a sibling line', () => { + ui.log.success('parent'); + ui.log.detail('child'); + const out = lines(); + const parent = out.find((l) => l.includes('parent'))!; + const child = out.find((l) => l.includes('child'))!; + expect(indentOf(child)).toBeGreaterThan(indentOf(parent)); + expect(child).toContain('›'); + }); + + it('rows aligns values to the widest key and appends the status word', () => { + ui.rows([ + { key: 'Redirect URI', value: 'http://x/cb', status: 'created', statusKind: 'ok' }, + { key: 'CORS', value: 'http://x', status: 'already set' }, + ]); + const out = lines().filter((l) => l.includes('http')); + expect(out).toHaveLength(2); + // Keys padded to the widest key → both value columns start at the same offset. + expect(out[0].indexOf('http')).toBe(out[1].indexOf('http')); + expect(out[0]).toContain('created'); + expect(out[1]).toContain('already set'); + }); + + it('rows is a no-op for an empty set', () => { + ui.rows([]); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('pill pads the label with a single space on each side', () => { + expect(strip(ui.pill('WARN', 'warn'))).toBe(' WARN '); + expect(strip(ui.pill('INFO'))).toBe(' INFO '); + }); +}); diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 00000000..636fa1da --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,327 @@ +/** + * UI facade — flat, gutterless CLI output + interactive prompts. + * + * The single import seam for all CLI UI: every module imports the default `ui` + * from this file. Output is hand-styled ANSI (diffdad-style: 2-space indent, one + * accent color, no vertical gutter). Input (confirm/select/text/password) + * delegates to `@inquirer/prompts`. + * + * The prompt adapters keep a stable call shape (`{ message, options, + * initialValue, validate }`) and the `isCancel(answer)` pattern, so the input + * engine can be swapped underneath without touching the ~30 call sites. + * + * Cancellation: @inquirer THROWS on ctrl-c / signal-abort (ExitPromptError / + * AbortPromptError) instead of returning a symbol. Each adapted prompt catches + * those and returns the `CANCEL` symbol, so `if (ui.isCancel(x))` keeps working. + */ + +import chalk from 'chalk'; +import { + confirm as inquirerConfirm, + select as inquirerSelect, + input as inquirerInput, + password as inquirerPassword, +} from '@inquirer/prompts'; + +// ── 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; +} +export function isDashboardMode(): boolean { + return dashboardMode; +} + +// ── Palette (chalk auto-disables color when chalk.level === 0, set by +// setOutputMode in JSON mode) ──────────────────────────────────────────────── +const accent = chalk.hex('#6363f1'); // WorkOS indigo +const green = chalk.hex('#34d399'); +const red = chalk.hex('#f87171'); +const yellow = chalk.hex('#fbbf24'); +const cyan = chalk.hex('#7dd3fc'); // values, paths, URLs +const { dim, bold } = chalk; + +/** + * An inverse "badge" chip (e.g. a colored INFO / WARN label). Pure string + * helper so callers writing to stdout OR stderr can reuse it. chalk.level === 0 + * (JSON mode) strips the color to plain text automatically. + */ +export function pill(label: string, kind: 'info' | 'warn' = 'info'): string { + const text = ` ${label} `; + return kind === 'warn' ? chalk.bgHex('#fbbf24').black(text) : chalk.bgHex('#6363f1').white(text); +} + +const INDENT = ' '; + +/** Print one indented line to stdout (suppressed in dashboard mode). */ +function line(text = ''): void { + if (dashboardMode) return; + console.log(INDENT + text); +} + +// ── Output surface ─────────────────────────────────────────────────────────── + +/** + * Branded title line, framed by blank lines. With a subtitle it renders + * `Title · subtitle` (accent-bold name, dim subtitle) — the dad-style header. + */ +function intro(title: string, subtitle?: string): void { + if (dashboardMode) return; + console.log(''); + line(subtitle ? `${accent(bold(title))} ${dim('·')} ${dim(subtitle)}` : accent(bold(title))); + console.log(''); +} + +/** Closing line. */ +function outro(message = ''): void { + if (dashboardMode) return; + console.log(''); + if (message) line(dim(message)); + console.log(''); +} + +/** A titled section header — anchors a "moment" that owns several lines. */ +function heading(title: string): void { + if (dashboardMode) return; + console.log(''); + line(accent(bold(title))); +} + +/** Multi-line indented note. Body dim; optional bold title. */ +function note(message: string, title?: string): void { + if (dashboardMode) return; + console.log(''); + if (title) line(bold(title)); + for (const l of String(message).split('\n')) line(dim(l)); + console.log(''); +} + +const log = { + message: (m: string) => line(m), + 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}`), + /** A muted, low-priority aside (opt-out hints, "run X later"). One dim line. */ + hint: (m: string) => line(dim(m)), + /** A nested sub-step, indented one level under its parent line. */ + detail: (m: string) => line(` ${dim('›')} ${dim(m)}`), +}; + +// ── Aligned key/value rows ──────────────────────────────────────────────────── + +export type RowStatusKind = 'ok' | 'muted' | 'warn'; +export interface Row { + key: string; + value: string; + /** Optional trailing status word (e.g. "created", "already set", "updated"). */ + status?: string; + /** How to color the status word. Defaults to 'muted'. */ + statusKind?: RowStatusKind; +} + +/** + * Print a set of aligned key/value rows (leading ✓, dim key padded to the + * widest key in the set, accent value, optional colored status). Alignment is a + * property of the whole set, so callers pass every row at once. + */ +function rows(items: Row[]): void { + if (dashboardMode || 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) { + const status = it.status ? ` ${paint[it.statusKind ?? 'muted'](it.status)}` : ''; + line(`${green('✓')} ${dim(it.key.padEnd(width))} ${cyan(it.value)}${status}`); + } +} + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export interface Spinner { + start: (message?: string) => void; + message: (message: string) => void; + stop: (message?: string, code?: number) => void; +} + +/** spinner: start(msg) / message(msg) / stop(msg, code). */ +function spinner(): Spinner { + let timer: ReturnType | undefined; + let frame = 0; + let text = ''; + const isTty = Boolean(process.stdout.isTTY) && !dashboardMode; + const render = () => { + process.stdout.write(`\r${INDENT}${dim(SPINNER_FRAMES[(frame = (frame + 1) % SPINNER_FRAMES.length)])} ${text}`); + }; + return { + start(message = '') { + text = message; + if (dashboardMode) return; + if (isTty) { + render(); + timer = setInterval(render, 80); + } else { + line(`${dim('…')} ${text}`); + } + }, + message(message: string) { + text = message; + }, + stop(message?: string, code = 0) { + if (timer) clearInterval(timer); + if (dashboardMode) return; + if (isTty) process.stdout.write('\r\x1b[2K'); + const glyph = code === 0 ? green('✓') : red('✗'); + line(`${glyph} ${message ?? text}`); + }, + }; +} + +// ── Cancellation ────────────────────────────────────────────────────────────── + +/** + * Returned by a prompt when the user cancels (ctrl-c) or a signal aborts it. + * Typed as a plain `symbol` (not `unique symbol`) to preserve the exact + * generic-inference behavior at call sites like `assertNotCancelled(v: T | symbol)`. + */ +export const CANCEL: symbol = Symbol('workos.prompt.cancel'); + +/** Type guard that narrows a prompt result to a non-cancel value. */ +export function isCancel(value: unknown): value is symbol { + return value === CANCEL; +} + +/** Print a cancellation line. */ +export function cancel(message = 'Cancelled'): void { + if (dashboardMode) return; + console.error(INDENT + dim(message)); +} + +const CANCEL_ERROR_NAMES = new Set(['ExitPromptError', 'AbortPromptError', 'CancelPromptError']); +function isCancelError(error: unknown): boolean { + return error instanceof Error && CANCEL_ERROR_NAMES.has(error.name); +} + +/** + * 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; +function adaptValidate(validate?: ValidateFn) { + if (!validate) return undefined; + return async (value: string): Promise => { + const result = await validate(value); + if (result == null) return true; + return result instanceof Error ? result.message : String(result); + }; +} + +// ── Input surface (@inquirer under the hood) ───────────────────── + +interface ConfirmOptions { + message: string; + initialValue?: boolean; + signal?: AbortSignal; +} +async function confirm(options: ConfirmOptions): Promise { + try { + return await inquirerConfirm({ message: options.message, default: options.initialValue }, { signal: options.signal }); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } +} + +interface SelectOption { + value: T; + label?: string; + hint?: string; +} +interface SelectOptions { + message: string; + options: ReadonlyArray>; + initialValue?: T; + maxItems?: number; + signal?: AbortSignal; +} +async function select(options: SelectOptions): Promise { + try { + return await inquirerSelect( + { + message: options.message, + choices: options.options.map((o) => ({ value: o.value, name: o.label ?? String(o.value), description: o.hint })), + default: options.initialValue, + pageSize: options.maxItems, + }, + { signal: options.signal }, + ); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } +} + +interface TextOptions { + message: string; + placeholder?: string; + defaultValue?: string; + initialValue?: string; + validate?: ValidateFn; + signal?: AbortSignal; +} +async function text(options: TextOptions): Promise { + try { + // @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). + const message = options.placeholder ? `${options.message} (${options.placeholder})` : options.message; + return await inquirerInput( + { + message, + default: options.defaultValue ?? options.initialValue, + validate: adaptValidate(options.validate), + }, + { signal: options.signal }, + ); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } +} + +interface PasswordOptions { + message: string; + validate?: ValidateFn; + signal?: AbortSignal; +} +async function password(options: PasswordOptions): Promise { + try { + return await inquirerPassword({ message: options.message, mask: true, validate: adaptValidate(options.validate) }, { signal: options.signal }); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } +} + +// ── Default export (the `ui` facade) ──────────────────────────────────────── + +const ui = { + intro, + outro, + heading, + note, + rows, + pill, + log, + spinner, + confirm, + select, + text, + password, + isCancel, + cancel, +}; + +export default ui; From ab762a432e1a2c8411e3231726cb4cbb02970ebe Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 10:57:50 -0500 Subject: [PATCH 2/6] chore: formatting --- src/commands/setup.spec.ts | 5 ++++- src/commands/setup.ts | 3 ++- src/integrations/nextjs/utils.spec.ts | 10 +++++++++- src/utils/help-json.ts | 8 +++++++- src/utils/ui.ts | 16 +++++++++++++--- 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts index 96f6c86a..48738ce3 100644 --- a/src/commands/setup.spec.ts +++ b/src/commands/setup.spec.ts @@ -299,7 +299,10 @@ describe('runSetup — command trigger', () => { await runSetup({ trigger: 'command', assumeYes: true }); - expect(outputSuccess).toHaveBeenCalledWith('Setup complete', expect.objectContaining({ skills: expect.anything() })); + expect(outputSuccess).toHaveBeenCalledWith( + 'Setup complete', + expect.objectContaining({ skills: expect.anything() }), + ); }); }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index d9e493c9..9fb377ea 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -142,7 +142,8 @@ export async function runSetup(opts: RunSetupOptions): Promise { // The offer. if (!opts.assumeYes) { ui.heading('Set up your coding agent'); - const what = wantSkills && wantMcp ? 'WorkOS skills and the MCP server' : wantMcp ? 'the WorkOS MCP server' : 'WorkOS skills'; + const what = + wantSkills && wantMcp ? 'WorkOS skills and the MCP server' : wantMcp ? 'the WorkOS MCP server' : 'WorkOS skills'; ui.note( `Add ${what} to ${names.join(', ')} so your coding agent can\n` + `scaffold auth and manage WorkOS resources. Nothing is written until you confirm.`, diff --git a/src/integrations/nextjs/utils.spec.ts b/src/integrations/nextjs/utils.spec.ts index 13fcd3b1..d568d1e6 100644 --- a/src/integrations/nextjs/utils.spec.ts +++ b/src/integrations/nextjs/utils.spec.ts @@ -7,7 +7,15 @@ vi.mock('../../utils/ui.js', () => ({ default: { select: vi.fn(), isCancel: vi.fn(() => false), - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn(), detail: vi.fn() }, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + step: vi.fn(), + message: vi.fn(), + detail: vi.fn(), + }, }, })); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 2c075657..5037c570 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1307,7 +1307,13 @@ const commands: CommandSchema[] = [ { name: 'skills-only', type: 'boolean', description: 'Install skills only', required: false, hidden: false }, { name: 'mcp-only', type: 'boolean', description: 'Install the MCP server only', required: false, hidden: false }, { name: 'yes', type: 'boolean', description: 'Install without prompting', required: false, hidden: false }, - { name: 'reset', type: 'boolean', description: 'Re-enable automatic setup offers', required: false, hidden: false }, + { + name: 'reset', + type: 'boolean', + description: 'Re-enable automatic setup offers', + required: false, + hidden: false, + }, ], }, { diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 636fa1da..243a94a4 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -227,7 +227,10 @@ interface ConfirmOptions { } async function confirm(options: ConfirmOptions): Promise { try { - return await inquirerConfirm({ message: options.message, default: options.initialValue }, { signal: options.signal }); + return await inquirerConfirm( + { message: options.message, default: options.initialValue }, + { signal: options.signal }, + ); } catch (error) { if (isCancelError(error)) return CANCEL; throw error; @@ -251,7 +254,11 @@ async function select(options: SelectOptions): Promise { return await inquirerSelect( { message: options.message, - choices: options.options.map((o) => ({ value: o.value, name: o.label ?? String(o.value), description: o.hint })), + choices: options.options.map((o) => ({ + value: o.value, + name: o.label ?? String(o.value), + description: o.hint, + })), default: options.initialValue, pageSize: options.maxItems, }, @@ -298,7 +305,10 @@ interface PasswordOptions { } async function password(options: PasswordOptions): Promise { try { - return await inquirerPassword({ message: options.message, mask: true, validate: adaptValidate(options.validate) }, { signal: options.signal }); + return await inquirerPassword( + { message: options.message, mask: true, validate: adaptValidate(options.validate) }, + { signal: options.signal }, + ); } catch (error) { if (isCancelError(error)) return CANCEL; throw error; From f8a03f8a664dc939ca94cd9fc42b8aad25b94431 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 12:18:13 -0500 Subject: [PATCH 3/6] fix: serialize installer prompts and refine output, errors, and telemetry Follow-up UX + reliability pass on the CLI overhaul (PR #200). Prompts - Serialize all prompts through a single-flight mutex in the ui facade and pause any active spinner around a prompt. Fixes the concurrent git-dirty + protected-branch prompts that opened on one stdin (the "asks a question but moves on" bug), and stops a spinner's redraw from clobbering a prompt. - Guard prompts against JSON / non-TTY contexts; route JSON output to the headless adapter and have the CLI adapter fail fast on an unanswerable prompt instead of hanging or silently crashing. - Abort queued sibling prompts when a run is cancelled, so a cancelled run can't leave a now-moot question open. - Remove the 30s deadline that auto-dismissed the "Set up now?" confirm. Output - Replace the block-letter banner with a compact lock brand mark, flatten the completion summary and the provisioned/unclaimed stderr notices, and make the unclaimed-environment warning bolder with a clear claim CTA. Errors + telemetry - Rewrite install errors for humans with recovery hints and word-boundary matching (no more "author" reading as "auth"). - Emit telemetry for login staging/refresh/auth failures, setup cancel/errors, and MCP install failure reasons. - Fix abortIfCancelled flushing a "cancelled" session on every prompt (bogus session end plus ~3s of dead-time per prompt). --- src/bin.ts | 12 +- src/commands/api/index.ts | 13 +- src/commands/claim.ts | 7 +- src/commands/login.spec.ts | 13 ++ src/commands/login.ts | 37 +++- src/commands/setup.spec.ts | 52 +++--- src/commands/setup.ts | 76 ++++---- src/lib/adapters/cli-adapter.spec.ts | 6 +- src/lib/adapters/cli-adapter.ts | 128 +++++++++++-- src/lib/run-with-core.ts | 11 +- src/lib/telemetry-notice.ts | 10 +- src/lib/unclaimed-env-provision.spec.ts | 6 +- src/lib/unclaimed-env-provision.ts | 8 +- src/lib/unclaimed-warning.ts | 3 +- src/utils/box.spec.ts | 111 ++---------- src/utils/box.ts | 118 +----------- src/utils/recovery-hints.ts | 13 ++ src/utils/summary-box.spec.ts | 34 +++- src/utils/summary-box.ts | 57 +++++- src/utils/ui-utils.spec.ts | 12 ++ src/utils/ui-utils.ts | 6 +- src/utils/ui.spec.ts | 64 +++++++ src/utils/ui.ts | 231 ++++++++++++++++++------ 23 files changed, 650 insertions(+), 378 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 691ae129..a41d3922 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -43,7 +43,7 @@ import { outputError, exitWithError, } from './utils/output.js'; -import ui from './utils/ui.js'; +import ui, { PromptUnavailableError } from './utils/ui.js'; import { registerSubcommand } from './utils/register-subcommand.js'; import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js'; import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js'; @@ -2826,6 +2826,16 @@ async function runCli(): Promise { apiContext: error.context?.apiContext, }, }; + } else if (error instanceof PromptUnavailableError) { + // A prompt was attempted where the user can't answer (--json, or non-TTY + // stdin) on a direct command. Not a crash — surface a clear, structured + // error with its own code so scripts and telemetry can distinguish it. + process.exitCode = 1; + commandOutcome = { + success: false, + options: { flags, reason: 'validation_error', errorCode: 'prompt_unavailable' }, + }; + outputError({ code: 'prompt_unavailable', message: error.message }); } else { // Unexpected error (crash) process.exitCode = 1; diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index fc2e4a21..7e524be3 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -5,8 +5,8 @@ import { apiRequest } from './request.js'; import { resolveApiBaseUrl } from '../../lib/api-key.js'; import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js'; import { ExitCode, exitWithCode } from '../../utils/exit-codes.js'; -import { isCiMode, isPromptAllowed } from '../../utils/interaction-mode.js'; -import { confirmationRecovery } from '../../utils/recovery-hints.js'; +import { isCiMode, isPromptAllowed, getInteractionMode } from '../../utils/interaction-mode.js'; +import { confirmationRecovery, authLoginRecovery, missingArgsRecovery } from '../../utils/recovery-hints.js'; import { formatWorkOSCommand, formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; import { colorMethod, printResponse } from './format.js'; @@ -160,10 +160,19 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions printResponse(response, { includeStatus: options.include }); if (response.status >= 400) { + // Give the caller a concrete next step keyed off the status: re-auth for + // 401/403, discover endpoints for 404. + const recovery = + response.status === 401 || response.status === 403 + ? authLoginRecovery({ mode: getInteractionMode().mode }) + : response.status === 404 + ? missingArgsRecovery(formatWorkOSCommand('api ls'), 'List available endpoints, then re-run with a valid path.') + : undefined; exitWithError({ code: `http_${response.status}`, message: `API request failed with status ${response.status}`, apiContext: { status: response.status }, + ...(recovery && { recovery }), }); } } diff --git a/src/commands/claim.ts b/src/commands/claim.ts index 4f1bc5ae..e347b2fb 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -16,6 +16,7 @@ import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; import { sleep } from '../lib/helper-functions.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; +import { networkRetryRecovery } from '../utils/recovery-hints.js'; const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const POLL_INTERVAL_MS = 5_000; // 5 seconds @@ -146,6 +147,10 @@ export async function runClaim(): Promise { } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logError('[claim] Error:', message); - exitWithError({ code: 'claim_failed', message: `Claim failed: ${message}` }); + exitWithError({ + code: 'claim_failed', + message: `Could not claim this environment: ${message}`, + recovery: networkRetryRecovery({ command: formatWorkOSCommand('env claim') }), + }); } } diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts index dfb1df23..887fae10 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -52,6 +52,15 @@ vi.mock('../utils/ui.js', () => ({ const mockFetchStagingCredentials = vi.fn(); vi.mock('../lib/staging-api.js', () => ({ fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args), + StagingApiError: class StagingApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = 'StagingApiError'; + } + }, })); // The consolidated setup offer (skills + MCP) runs behind one consented hook @@ -60,6 +69,10 @@ vi.mock('./setup.js', () => ({ maybeRunSetupAfter: vi.fn(), })); +vi.mock('../utils/analytics.js', () => ({ + analytics: { capture: vi.fn(), captureException: vi.fn() }, +})); + vi.mock('../utils/output.js', () => ({ isJsonMode: vi.fn(() => false), exitWithError: vi.fn(), diff --git a/src/commands/login.ts b/src/commands/login.ts index 4b91703b..b14a1f5a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -5,7 +5,8 @@ import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, update import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js'; import { refreshAccessToken } from '../lib/token-refresh-client.js'; import { logInfo, logError } from '../utils/debug.js'; -import { fetchStagingCredentials } from '../lib/staging-api.js'; +import { fetchStagingCredentials, StagingApiError } from '../lib/staging-api.js'; +import { analytics } from '../utils/analytics.js'; import { getConfig, saveConfig, getActiveEnvironment, setActiveEnvironment, freshEnvKey } from '../lib/config-store.js'; import type { CliConfig, EnvironmentConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; @@ -105,6 +106,14 @@ export async function provisionStagingEnvironment( }; } catch (error) { logError('[login] Failed to provision staging environment:', error instanceof Error ? error.message : error); + // Best-effort, but the failure rate of this onboarding step must be visible: + // a silent failure here leaves the user with no active environment for every + // later command, indistinguishable in telemetry from a healthy setup. + analytics.captureException(error instanceof Error ? error : new Error(String(error)), { + command: 'auth.login', + phase: 'provision-staging', + statusCode: error instanceof StagingApiError ? error.statusCode : undefined, + }); return { provisioned: false, mismatch: false }; } } @@ -133,8 +142,13 @@ export async function runLogin(): Promise { console.log(chalk.dim(`Run \`${formatWorkOSCommand('auth logout')}\` to log out`)); return; } - } catch { - // Refresh failed, proceed with fresh login + // Refresh returned no token — record why before falling through to fresh + // login. A spike here surfaces token revocation / refresh-endpoint outages + // that self-heal into a browser login and would otherwise be invisible. + analytics.capture('token_refresh_failed', { errorType: result.errorType ?? 'unknown' }); + } catch (error) { + analytics.capture('token_refresh_failed', { errorType: error instanceof Error ? error.name : 'unknown' }); + // Refresh failed, proceed with fresh login. } } @@ -154,6 +168,10 @@ export async function runLogin(): Promise { } catch (error) { const msg = error instanceof Error ? error.message : String(error); ui.log.error(`Failed to start authentication: ${msg}`); + analytics.captureException(error instanceof Error ? error : new Error(msg), { + command: 'auth.login', + phase: 'device-code', + }); exitWithCode(ExitCode.GENERAL_ERROR); } @@ -240,14 +258,21 @@ export async function runLogin(): Promise { await maybeRunSetupAfter('login'); } catch (error) { - if (error instanceof DeviceAuthTimeoutError) { - spinner.stop('Authentication timed out'); + const isTimeout = error instanceof DeviceAuthTimeoutError; + if (isTimeout) { + spinner.stop('Authentication timed out', 1); ui.log.error('Authentication timed out. Please try again.'); } else { - spinner.stop('Authentication failed'); + spinner.stop('Authentication failed', 1); const msg = error instanceof Error ? error.message : String(error); ui.log.error(`Authentication error: ${msg}`); } + // Deliver the real cause to telemetry (the command event alone can't + // distinguish a timeout from a network/server auth failure). + analytics.captureException(error instanceof Error ? error : new Error(String(error)), { + command: 'auth.login', + phase: isTimeout ? 'timeout' : 'poll', + }); exitWithCode(ExitCode.GENERAL_ERROR); } } diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts index 48738ce3..5d3bb365 100644 --- a/src/commands/setup.spec.ts +++ b/src/commands/setup.spec.ts @@ -64,7 +64,7 @@ vi.mock('../lib/mcp-clients.js', () => ({ })); vi.mock('../utils/analytics.js', () => ({ - analytics: { emitCommandEvent: vi.fn() }, + analytics: { emitCommandEvent: vi.fn(), captureException: vi.fn() }, })); vi.mock('../utils/command-invocation.js', () => ({ @@ -79,7 +79,7 @@ const { detectAgents, refreshWorkOSSkills } = await import('./install-skill.js') const { detectMcpClients } = await import('../lib/mcp-clients.js'); const { analytics } = await import('../utils/analytics.js'); -const { runSetup, maybeRunSetupAfter, SETUP_OFFER_TIMEOUT_MS } = await import('./setup.js'); +const { runSetup, maybeRunSetupAfter } = await import('./setup.js'); // ── Fixtures ────────────────────────────────────────────────────────────────── const claudeAgent = { name: 'claude-code', displayName: 'Claude Code', globalSkillsDir: '/x', detect: () => true }; @@ -193,7 +193,7 @@ describe('runSetup — automatic triggers (login/install)', () => { expect(prefs.recordSetupCompleted).not.toHaveBeenCalled(); }); - it('treats cancel (ctrl-c) as skip — no decline recorded', async () => { + it('treats cancel (ctrl-c) as skip — no decline recorded, but emits a cancelled event', async () => { detectSome(); vi.mocked(ui.confirm).mockResolvedValue(CANCEL); @@ -202,6 +202,13 @@ describe('runSetup — automatic triggers (login/install)', () => { expect(prefs.recordSetupDeclined).not.toHaveBeenCalled(); expect(prefs.recordSetupCompleted).not.toHaveBeenCalled(); expect(refreshWorkOSSkills).not.toHaveBeenCalled(); + // The cut-off must be observable in telemetry (previously it was silent). + expect(analytics.emitCommandEvent).toHaveBeenCalledWith( + 'setup offer', + expect.any(Number), + expect.any(Boolean), + expect.objectContaining({ extraAttributes: expect.objectContaining({ 'setup.outcome': 'cancelled' }) }), + ); }); it('stays silent when no supported agents are detected', async () => { @@ -307,35 +314,24 @@ describe('runSetup — command trigger', () => { }); describe('maybeRunSetupAfter', () => { - it('never throws even if the offer rejects', async () => { + it('never throws even if the offer rejects, and reports the failure to telemetry', async () => { detectSome(); - vi.mocked(ui.confirm).mockRejectedValue(new Error('boom')); + const boom = new Error('boom'); + vi.mocked(ui.confirm).mockRejectedValue(boom); await expect(maybeRunSetupAfter('login')).resolves.toBeUndefined(); + + // The swallowed failure must still reach telemetry (previously dropped). + expect(analytics.captureException).toHaveBeenCalledWith(boom, { 'setup.trigger': 'login' }); }); - it('aborts a hung prompt after the deadline and resolves (never wedges login/install)', async () => { - vi.useFakeTimers(); - try { - detectSome(); - let captured: AbortSignal | undefined; - // A hung prompt settles to CANCEL only when its signal aborts — exactly - // what the real facade does when @inquirer throws on abort. (There is no - // Promise.race fallback anymore, so the mock must honor the signal.) - vi.mocked(ui.confirm).mockImplementation((opts: any) => { - captured = opts.signal; - return new Promise((resolve) => { - opts.signal?.addEventListener('abort', () => resolve(CANCEL)); - }); - }); - - const pending = maybeRunSetupAfter('login'); - await vi.advanceTimersByTimeAsync(SETUP_OFFER_TIMEOUT_MS + 10); - - await expect(pending).resolves.toBeUndefined(); - expect(captured?.aborted).toBe(true); - } finally { - vi.useRealTimers(); - } + it('does not pass an abort signal to the confirm (the prompt is not time-bounded)', async () => { + detectSome(); + vi.mocked(ui.confirm).mockResolvedValue(true); + + await maybeRunSetupAfter('login'); + + const confirmArgs = vi.mocked(ui.confirm).mock.calls[0][0] as { signal?: AbortSignal }; + expect(confirmArgs.signal).toBeUndefined(); }); }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 9fb377ea..bf521acd 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -21,6 +21,7 @@ import { homedir } from 'node:os'; import ui, { isCancel } from '../utils/ui.js'; import { outputSuccess, exitWithError, isJsonMode } from '../utils/output.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { CliExit } from '../utils/cli-exit.js'; import { isPromptAllowed } from '../utils/interaction-mode.js'; import { isSetupDeclined, @@ -52,17 +53,8 @@ export interface RunSetupOptions { assumeYes?: boolean; /** Clear a prior decline so automatic offers resume, then return. */ reset?: boolean; - /** Deadline signal — aborts a hung prompt (used by maybeRunSetupAfter). */ - signal?: AbortSignal; } -/** - * Deadline that bounds the interactive prompt (via AbortSignal) so an - * unanswered prompt can never wedge login/install. Once the user consents, the - * install itself runs to completion — it is not raced. - */ -export const SETUP_OFFER_TIMEOUT_MS = 30 * 1000; - /** Validate an --agents filter against the known keys; exit with a structured error on unknown. */ function validateAgentFilter(agents: string[] | undefined, known: string[]): string[] | undefined { if (!agents || agents.length === 0) return undefined; @@ -149,12 +141,16 @@ export async function runSetup(opts: RunSetupOptions): Promise { `scaffold auth and manage WorkOS resources. Nothing is written until you confirm.`, ); - const answer = await ui.confirm({ message: 'Set up now?', initialValue: true, signal: opts.signal }); - // Cancel (ctrl-c / deadline) is not a decline — skip silently, ask again next time. - if (isCancel(answer)) return; + const answer = await ui.confirm({ message: 'Set up now?', initialValue: true }); + // Cancel (ctrl-c) is not a decline — skip silently and ask again next time, + // but record it so the cut-off is observable in telemetry. + if (isCancel(answer)) { + emitSetupEvent(opts.trigger, startedAt, 'cancelled', { skills: [], mcpInstalled: [], mcpFailed: [] }); + return; + } if (!answer) { if (!isCommand) recordSetupDeclined(); - emitSetupEvent(opts.trigger, startedAt, false, { skills: [], mcpInstalled: [], mcpFailed: [] }); + emitSetupEvent(opts.trigger, startedAt, 'declined', { skills: [], mcpInstalled: [], mcpFailed: [] }); ui.log.hint(`No problem. Run \`${formatWorkOSCommand('setup')}\` anytime.`); return; } @@ -185,12 +181,18 @@ async function installAndReport( const mcpInstalled = mcpResults .filter((r) => r.outcome === 'installed' || r.outcome === 'already-installed') .map((r) => r.agent); - const mcpFailed = mcpResults.filter((r) => r.outcome === 'failed').map((r) => r.agent); + const failedResults = mcpResults.filter((r) => r.outcome === 'failed'); + const mcpFailed = failedResults.map((r) => r.agent); + // Carry a bounded, agent-tagged reason so the MCP-install failure CAUSE (not + // just the count) is observable in telemetry. Already user-safe — the same + // text is shown via ui.log.error. + const mcpFailedReasons = failedResults.map((r) => `${r.agent}:${(r.error ?? '').slice(0, 120)}`).join('; '); - emitSetupEvent(opts.trigger, startedAt, true, { + emitSetupEvent(opts.trigger, startedAt, 'accepted', { skills: skillResult?.agents.map((a) => a.name) ?? [], mcpInstalled, mcpFailed, + mcpFailedReasons, }); reportResults(skillResult ? { agents: skillAgentNames, count: skillResult.skills.length } : null, mcpResults); @@ -234,19 +236,25 @@ function reportResults(skills: SkillSummary | null, mcp: McpClientResult[]): voi * command event rides the CLI's final flush (the same pattern the old * standalone MCP offer used). */ +type SetupOutcome = 'accepted' | 'declined' | 'cancelled'; + function emitSetupEvent( trigger: SetupTrigger, startedAt: number, - accepted: boolean, - agents: { skills: string[]; mcpInstalled: string[]; mcpFailed: string[] }, + outcome: SetupOutcome, + agents: { skills: string[]; mcpInstalled: string[]; mcpFailed: string[]; mcpFailedReasons?: string }, ): void { analytics.emitCommandEvent('setup offer', Date.now() - startedAt, agents.mcpFailed.length === 0, { extraAttributes: { 'setup.trigger': trigger, - 'setup.accepted': accepted, + // `accepted` kept for back-compat dashboards; `outcome` distinguishes a + // deliberate "no" (declined) from a walk-away/ctrl-c (cancelled). + 'setup.accepted': outcome === 'accepted', + 'setup.outcome': outcome, 'setup.skills_agents': agents.skills.join(','), 'setup.mcp_installed': agents.mcpInstalled.join(','), 'setup.mcp_failed': agents.mcpFailed.join(','), + ...(agents.mcpFailedReasons ? { 'setup.mcp_failed_reasons': agents.mcpFailedReasons } : {}), }, }); } @@ -254,22 +262,26 @@ function emitSetupEvent( /** * Best-effort setup offer after a successful `login` / `install`. * - * Never throws into, wedges, or fails the parent flow: a try/catch swallows any - * error, and the deadline aborts the interactive prompt (AbortSignal → CANCEL, - * which releases stdin). The prompt is the only unbounded wait — detection and - * install are internally time-bounded — so aborting it is sufficient; the offer - * is NOT raced, so a consented install always runs to completion. The parent - * flow has already succeeded by the time this runs. + * Never throws into, wedges, or fails the parent flow — a try/catch swallows any + * error. The offer is only ever shown to a present human in an interactive TTY + * (runSetup early-returns in every machine/non-interactive context), so the + * confirm is NOT time-bounded: a question that auto-dismisses while the user is + * reading it is exactly the "it asked but moved on" bug. @inquirer already + * handles ctrl-c, and detection/install are internally time-bounded, so there is + * nothing left to race. The parent flow has already succeeded by the time this + * runs. Any failure is reported to telemetry before being swallowed. */ export async function maybeRunSetupAfter(trigger: 'login' | 'install'): Promise { - const deadline = new AbortController(); - const timer = setTimeout(() => deadline.abort(), SETUP_OFFER_TIMEOUT_MS); - timer.unref?.(); try { - await runSetup({ trigger, signal: deadline.signal }); - } catch { - // Setup must never fail or block login / install. - } finally { - clearTimeout(timer); + await runSetup({ trigger }); + } catch (error) { + // reportResults exits non-zero via CliExit on a failed MCP add — that's an + // intentional exit for the standalone command, not an exception to report + // (and it stays swallowed here so it never fails the parent login/install). + if (error instanceof CliExit) return; + // Setup must never fail or block login / install — but don't drop the signal. + analytics.captureException(error instanceof Error ? error : new Error(String(error)), { + 'setup.trigger': trigger, + }); } } diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index 97afaf70..d92cc656 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -21,6 +21,7 @@ vi.mock('../../utils/ui.js', () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn(), + clear: vi.fn(), })), confirm: vi.fn(), text: vi.fn(), @@ -158,6 +159,7 @@ describe('CLIAdapter', () => { start: vi.fn(), stop: vi.fn(), message: vi.fn(), + clear: vi.fn(), }; vi.mocked(ui.default.spinner).mockReturnValue(spinnerMock); @@ -280,7 +282,7 @@ describe('CLIAdapter', () => { try { await adapter.start(); const ui = await import('../../utils/ui.js'); - const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn(), clear: vi.fn() }; vi.mocked(ui.default.spinner).mockReturnValue(spinnerMock); emitter.emit('agent:start', {}); @@ -299,7 +301,7 @@ describe('CLIAdapter', () => { it('restarts the spinner on the last phase message after logging a file op', async () => { await adapter.start(); const ui = await import('../../utils/ui.js'); - const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + const spinnerMock = { start: vi.fn(), stop: vi.fn(), message: vi.fn(), clear: vi.fn() }; vi.mocked(ui.default.spinner).mockReturnValue(spinnerMock); emitter.emit('agent:start', {}); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 7987a73b..dbaf19f5 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -1,11 +1,11 @@ import type { InstallerAdapter, AdapterConfig } from './types.js'; import type { InstallerEventEmitter, InstallerEvents } from '../events.js'; import { relative } from 'node:path'; -import ui from '../../utils/ui.js'; +import ui, { PromptUnavailableError } from '../../utils/ui.js'; import chalk from 'chalk'; import { getConfig } from '../settings.js'; import { ProgressTracker } from '../progress-tracker.js'; -import { renderCompletionSummary } from '../../utils/summary-box.js'; +import { renderCompletionSummary, renderBrandMark } from '../../utils/summary-box.js'; import { formatWorkOSCommand } from '../../utils/command-invocation.js'; /** @@ -36,6 +36,12 @@ export class CLIAdapter implements InstallerAdapter { // SIGINT handler for cleanup private sigIntHandler: (() => void) | null = null; + // Aborts in-flight/queued prompts when the run ends (cancellation, ctrl-c). + // The installer's `preparing` state can queue a second prompt behind a live + // one (git-dirty + protected-branch); if the first is cancelled, this signal + // stops the queued sibling from opening a now-moot question. + private promptAbort: AbortController | null = null; + // Last phase message shown on the agent spinner, restored after logging above it. private lastAgentMessage = 'Running AI agent...'; // Last file path rendered as a step line, to dedupe consecutive same-path ops. @@ -69,19 +75,22 @@ export class CLIAdapter implements InstallerAdapter { async start(): Promise { if (this.isStarted) return; this.isStarted = true; + this.promptAbort = new AbortController(); // Show intro const config = getConfig(); if (config.branding.showAsciiArt) { - const art = config.branding.useCompact ? config.branding.compactAsciiArt : config.branding.asciiArt; - console.log(chalk.cyan(art)); - console.log(); + // Compact brand mark (the lock + wordmark) instead of the full block banner. + console.log(''); + console.log(renderBrandMark('AuthKit installer')); + console.log(''); } else { ui.intro('WorkOS', 'AuthKit installer'); } // Handle Ctrl+C gracefully const handleSigInt = () => { + this.promptAbort?.abort(); if (this.spinner) { this.spinner.stop('Cancelled'); this.spinner = null; @@ -108,8 +117,11 @@ export class CLIAdapter implements InstallerAdapter { this.subscribe('credentials:env:prompt', this.handleEnvScanPrompt); this.subscribe('device:started', this.handleDeviceStarted); this.subscribe('device:success', this.handleDeviceSuccess); + this.subscribe('device:error', this.handleDeviceError); + this.subscribe('device:timeout', this.handleDeviceTimeout); this.subscribe('staging:fetching', this.handleStagingFetching); this.subscribe('staging:success', this.handleStagingSuccess); + this.subscribe('staging:error', this.handleStagingError); this.subscribe('credentials:env:found', this.handleEnvCredentialsFound); this.subscribe('config:complete', this.handleConfigComplete); this.subscribe('agent:start', this.handleAgentStart); @@ -152,6 +164,11 @@ export class CLIAdapter implements InstallerAdapter { async stop(): Promise { if (!this.isStarted) return; + // Abort any in-flight/queued prompt so a cancelled run can't leave a + // now-moot sibling question open (e.g. the branch prompt after git-cancel). + this.promptAbort?.abort(); + this.promptAbort = null; + // Remove SIGINT handler if (this.sigIntHandler) { process.off('SIGINT', this.sigIntHandler); @@ -171,9 +188,9 @@ export class CLIAdapter implements InstallerAdapter { this.isStarted = false; } - private stopSpinner(message: string): void { + private stopSpinner(message: string, code = 0): void { if (this.spinner) { - this.spinner.stop(message); + this.spinner.stop(message, code); this.spinner = null; } } @@ -193,10 +210,43 @@ export class CLIAdapter implements InstallerAdapter { handler: (payload: InstallerEvents[K]) => void | Promise, ): void { const boundHandler = handler.bind(this); - this.handlers.set(event, boundHandler as (...args: unknown[]) => void); - this.emitter.on(event, boundHandler); + // Handlers are invoked fire-and-forget by a plain EventEmitter, so an async + // rejection would become an unhandledRejection (silent crash). Route a + // PromptUnavailableError (prompt attempted where the user can't answer) to a + // clean fail-fast; re-surface anything else unchanged so real bugs still crash. + const safeHandler = (payload: InstallerEvents[K]): void => { + try { + const result = boundHandler(payload); + if (result instanceof Promise) result.catch((err) => this.onHandlerError(err)); + } catch (err) { + this.onHandlerError(err); + } + }; + this.handlers.set(event, safeHandler as (...args: unknown[]) => void); + this.emitter.on(event, safeHandler as typeof boundHandler); } + /** + * Terminal handler failure. A PromptUnavailableError means we reached a prompt + * in a context that can't answer it (non-TTY stdin) — the CLIAdapter only runs + * in human, non-JSON output, so this is always a real human at a broken input, + * never a JSON stream. Surface a clear message and exit before anything is + * written, rather than hanging or crashing. Re-throw anything else. + */ + private onHandlerError = (error: unknown): void => { + if (error instanceof PromptUnavailableError) { + this.spinner?.clear(); + this.spinner = null; + ui.log.error(error.message); + ui.log.hint('Re-run in an interactive terminal, or pass the flags that answer these prompts.'); + // Exit 1 (general error), matching the direct-command classification of + // this same error in bin.ts. NOT 4 — that's "auth required" (gh + // convention), which would send scripts into an auth-retry loop. + process.exit(1); + } + throw error; + }; + // ===== Event Handlers ===== private handleStateEnter = ({ state }: InstallerEvents['state:enter']): void => { @@ -281,6 +331,23 @@ export class CLIAdapter implements InstallerAdapter { ui.log.success(`Found existing WorkOS credentials in ${sourcePath}`); }; + // Automatic auth / credential fetch can fail and fall back to manual entry. + // These finalize the spinner with a failure glyph so it isn't left spinning + // (orphaned) over the manual-credentials prompt that follows. + private handleDeviceError = ({ message }: InstallerEvents['device:error']): void => { + this.stopSpinner('Automatic sign-in failed', 1); + if (this.debug) this.debugLog(`[device:error] ${message}`); + }; + + private handleDeviceTimeout = (): void => { + this.stopSpinner('Sign-in timed out', 1); + }; + + private handleStagingError = ({ message }: InstallerEvents['staging:error']): void => { + this.stopSpinner('Could not fetch WorkOS credentials', 1); + if (this.debug) this.debugLog(`[staging:error] ${message}`); + }; + private handleGitDirty = async ({ files }: InstallerEvents['git:dirty']): Promise => { ui.log.warn('You have uncommitted or untracked files:'); files.slice(0, 5).forEach((f) => ui.log.info(chalk.dim(` ${f}`))); @@ -292,6 +359,7 @@ export class CLIAdapter implements InstallerAdapter { const confirmed = await ui.confirm({ message: 'Continue anyway?', initialValue: false, + signal: this.promptAbort?.signal, }); this.isPromptActive = false; this.flushPendingLogs(); @@ -304,6 +372,12 @@ export class CLIAdapter implements InstallerAdapter { private handleCredentialsRequest = async ({ requiresApiKey, }: InstallerEvents['credentials:request']): Promise => { + // Guaranteed chokepoint: promptingManual is the fallback for any auth path, + // so clear any still-running spinner before the prompt opens (defense in + // depth on top of the device/staging error handlers and withPrompt's pause). + this.spinner?.clear(); + this.spinner = null; + ui.log.step(`Get your credentials from ${chalk.cyan('https://dashboard.workos.com')}`); const clientId = await ui.text({ @@ -436,6 +510,13 @@ export class CLIAdapter implements InstallerAdapter { }; private handleComplete = ({ success, summary, completion }: InstallerEvents['complete']): void => { + // Fires synchronously during the cancelled-state transition (emitCancelled), + // BEFORE a queued sibling prompt's microtask runs — so aborting here makes a + // cancelled run's still-queued prompt (e.g. the branch select after a + // git-dirty "No") open with an already-aborted signal and resolve to CANCEL + // without ever rendering a now-moot question. + this.promptAbort?.abort(); + this.stopSpinner(success ? 'Done' : 'Failed'); console.log(''); @@ -450,14 +531,18 @@ export class CLIAdapter implements InstallerAdapter { }; private handleError = ({ message, stack }: InstallerEvents['error']): void => { - this.stopSpinner('Error'); + this.stopSpinner('Failed', 1); - // Rewrite raw API/SDK errors into user-friendly messages + // Rewrite raw API/SDK errors into user-friendly messages with a next step. + // Matching is word-boundary / code-based so 'author' doesn't read as 'auth' + // and 'Module not found' doesn't read as a missing-directory error. const isServiceError = /\b50[0-9]\b/.test(message) || /server_error|internal_error|overloaded|service.*unavailable/i.test(message); - const isRateLimit = /\b429\b/.test(message) || /rate.limit/i.test(message); + const isRateLimit = /\b429\b/.test(message) || /\brate.?limit/i.test(message); const isNetworkError = /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed/i.test(message); const isProcessExit = /process exited with code/i.test(message); + const isAuthError = /\b(401|403|unauthorized|forbidden|authentication|authorization)\b/i.test(message); + const isMissingPath = /\bENOENT\b/.test(message); if (isServiceError) { ui.log.error('The AI service is temporarily unavailable.'); @@ -471,16 +556,18 @@ export class CLIAdapter implements InstallerAdapter { } else if (isProcessExit) { ui.log.error('The AI agent process exited unexpectedly.'); ui.log.info('Try running again. If this persists, run with --debug for details.'); + } else if (isAuthError) { + ui.log.error('Authentication failed.'); + ui.log.info(`Try running: ${formatWorkOSCommand('auth logout')} && ${formatWorkOSCommand('install')}`); + } else if (isMissingPath) { + ui.log.error(message); + ui.log.info('Make sure you are running this in your project directory.'); } else { + // Unknown error: still give the user somewhere to go next. ui.log.error(message); - } - - // Add actionable hints for common errors - if (message.includes('authentication') || message.includes('auth')) { - ui.log.info(`Try running: ${formatWorkOSCommand('auth logout')} && ${formatWorkOSCommand('install')}`); - } - if (message.includes('ENOENT') || message.includes('not found')) { - ui.log.info('Ensure you are in a project directory'); + ui.log.info( + `Re-run with ${chalk.cyan('--debug')} for details, or report it at ${chalk.cyan('https://github.com/workos/cli/issues')}`, + ); } if (stack && this.debug) { @@ -539,6 +626,7 @@ export class CLIAdapter implements InstallerAdapter { { value: 'continue', label: 'Continue on current branch' }, { value: 'cancel', label: 'Cancel' }, ], + signal: this.promptAbort?.signal, }); this.isPromptActive = false; this.flushPendingLogs(); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 83be33de..1d8e2f3c 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -212,7 +212,16 @@ export async function runWithCore(options: InstallerOptions): Promise { if (nonHumanMode && !isJsonMode()) { setOutputMode(resolveEffectiveOutputMode(getOutputMode(), getInteractionMode())); } - const headlessMode = nonHumanMode && isJsonMode(); + // Headless (no prompts, structured output) is for MACHINE output only: JSON. + // A prompt cannot render into a JSON stream, so any JSON run must be headless. + // We deliberately do NOT route a human session with non-TTY stdin here: + // headless auto-approves branch/commit/scaffold, and applying those unattended + // to a session the user never opted into would violate the "nothing is written + // 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; let adapter: InstallerAdapter; if (headlessMode) { diff --git a/src/lib/telemetry-notice.ts b/src/lib/telemetry-notice.ts index 92db2f2a..61717880 100644 --- a/src/lib/telemetry-notice.ts +++ b/src/lib/telemetry-notice.ts @@ -1,13 +1,13 @@ /** * First-run telemetry notice. * - * Prints a one-time, stderr-only box telling the user that anonymous CLI usage - * telemetry is being collected and how to turn it off. Shown at most once ever - * (backed by the persisted `noticeShownAt` timestamp in preferences.json), only - * in interactive human mode, and never on the machine-readable path. + * Prints a one-time, stderr-only notice telling the user that anonymous CLI + * usage telemetry is being collected and how to turn it off. Shown at most once + * ever (backed by the persisted `noticeShownAt` timestamp in preferences.json), + * only in interactive human mode, and never on the machine-readable path. * * Mirrors the structural pattern of unclaimed-warning.ts: a per-session guard, - * a `!isJsonMode()` gate, the shared renderStderrBox helper, and a never-throws + * a `!isJsonMode()` gate, the shared renderStderrNotice helper, and a never-throws * contract so it can never block command execution. The one structural * difference is persistence — this notice writes `noticeShownAt` the first time * it actually displays so it never re-shows across runs. diff --git a/src/lib/unclaimed-env-provision.spec.ts b/src/lib/unclaimed-env-provision.spec.ts index 9502c517..1014f18b 100644 --- a/src/lib/unclaimed-env-provision.spec.ts +++ b/src/lib/unclaimed-env-provision.spec.ts @@ -50,7 +50,7 @@ vi.mock('./unclaimed-env-api.js', () => ({ // Mock box utility vi.mock('../utils/box.js', () => ({ - renderStderrBox: vi.fn(), + renderStderrNotice: vi.fn(), })); const { tryProvisionUnclaimedEnv } = await import('./unclaimed-env-provision.js'); @@ -198,11 +198,11 @@ describe('unclaimed-env-provision', () => { it('shows provisioning message to user', async () => { mockProvisionUnclaimedEnvironment.mockResolvedValueOnce(validProvisionResult); - const { renderStderrBox } = await import('../utils/box.js'); + const { renderStderrNotice } = await import('../utils/box.js'); await tryProvisionUnclaimedEnv({ installDir: testDir }); - expect(renderStderrBox).toHaveBeenCalled(); + expect(renderStderrNotice).toHaveBeenCalled(); }); it('returns false when config read-back fails after save', async () => { diff --git a/src/lib/unclaimed-env-provision.ts b/src/lib/unclaimed-env-provision.ts index 2b6b9420..8dbf0071 100644 --- a/src/lib/unclaimed-env-provision.ts +++ b/src/lib/unclaimed-env-provision.ts @@ -12,7 +12,7 @@ import { getConfig, saveConfig, getActiveEnvironment, freshEnvKey } from './conf import type { CliConfig } from './config-store.js'; import { writeCredentialsEnv } from './env-writer.js'; import { logInfo, logError } from '../utils/debug.js'; -import { renderStderrBox } from '../utils/box.js'; +import { renderStderrNotice } from '../utils/box.js'; import ui from '../utils/ui.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; @@ -77,8 +77,10 @@ export async function tryProvisionUnclaimedEnv(options: UnclaimedEnvProvisionOpt } logInfo('[unclaimed-env-provision] Unclaimed environment provisioned and saved'); - const inner = ` ✓ ${chalk.green('Environment provisioned')} — Run ${chalk.cyan(formatWorkOSCommand('env claim'))} to keep it. `; - renderStderrBox(inner, chalk.green); + renderStderrNotice( + `${chalk.green('✓')} ${chalk.bold('Environment provisioned')} ${chalk.dim('— credentials saved to your project')}`, + `${chalk.dim('Run')} ${chalk.bold.cyan(formatWorkOSCommand('env claim'))} ${chalk.dim('to link it to your account.')}`, + ); return true; } catch (error) { diff --git a/src/lib/unclaimed-warning.ts b/src/lib/unclaimed-warning.ts index 08ba4c59..a30f7e73 100644 --- a/src/lib/unclaimed-warning.ts +++ b/src/lib/unclaimed-warning.ts @@ -63,7 +63,8 @@ export async function warnIfUnclaimed(): Promise { if (!isJsonMode()) { renderStderrNotice( - `${pill('WARN', 'warn')} Unclaimed environment ${chalk.dim('— run')} ${chalk.cyan(formatWorkOSCommand('env claim'))} ${chalk.dim('to keep your data')}`, + `${pill('WARN', 'warn')} ${chalk.bold('Unclaimed environment')} ${chalk.dim('— not linked to your account')}`, + `${chalk.dim('Run')} ${chalk.bold.cyan(formatWorkOSCommand('env claim'))} ${chalk.dim('to save it — its credentials live only on this machine.')}`, ); // Claim the one-notice-per-run slot so the lower-priority MCP banner defers. markStartupNoticeShown(); diff --git a/src/utils/box.spec.ts b/src/utils/box.spec.ts index 5fc3efb6..e62dcf4d 100644 --- a/src/utils/box.spec.ts +++ b/src/utils/box.spec.ts @@ -1,71 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import type chalk from 'chalk'; -import { renderStderrBox, wrapAnsiAware } from './box.js'; -import { stripAnsii } from './string.js'; +import { renderStderrNotice } from './box.js'; -// Identity "color" so border/structure assertions read cleanly. -const noColor = ((s: string) => s) as unknown as typeof chalk.yellow; - -// Build a self-closing SGR span explicitly. chalk auto-disables color in a -// non-TTY test env, so we synthesize the escapes the way chalk would on a real -// terminal — this keeps the ANSI-handling assertions deterministic. -const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`; -const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`; -const green = (s: string) => `\x1b[32m${s}\x1b[39m`; - -function withColumns(cols: number, fn: () => void): void { - const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, 'columns'); - const stdoutDesc = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); - Object.defineProperty(process.stderr, 'columns', { value: cols, configurable: true }); - Object.defineProperty(process.stdout, 'columns', { value: cols, configurable: true }); - try { - fn(); - } finally { - if (stderrDesc) Object.defineProperty(process.stderr, 'columns', stderrDesc); - else delete (process.stderr as { columns?: number }).columns; - if (stdoutDesc) Object.defineProperty(process.stdout, 'columns', stdoutDesc); - else delete (process.stdout as { columns?: number }).columns; - } -} - -describe('wrapAnsiAware', () => { - it('keeps short text on a single line', () => { - expect(wrapAnsiAware('hello world', 80)).toEqual(['hello world']); - }); - - it('wraps plain text to the visible width', () => { - const lines = wrapAnsiAware('one two three four five', 9); - for (const line of lines) { - expect(stripAnsii(line).length).toBeLessThanOrEqual(9); - } - expect(lines.join(' ')).toBe('one two three four five'); - }); - - it('treats a colored span with internal spaces as one atomic token', () => { - const span = cyan('keep me together'); - const lines = wrapAnsiAware(`run ${span} now please`, 20); - // The colored span (16 visible chars, with internal spaces) must land on a - // single line, never split across the wrap boundary. - const onSameLine = lines.some((l) => l.includes(span)); - expect(onSameLine).toBe(true); - }); - - it('measures width by visible characters, not ANSI bytes', () => { - const colored = `${cyan('aaa')} ${yellow('bbb')} ${green('ccc')}`; - const lines = wrapAnsiAware(colored, 7); // "aaa bbb" = 7 visible - for (const line of lines) { - expect(stripAnsii(line).length).toBeLessThanOrEqual(7); - } - // The ANSI bytes are far longer than 7; proves we wrapped on visible width. - expect(colored.length).toBeGreaterThan(7); - }); - - it('never returns an empty array', () => { - expect(wrapAnsiAware('', 10)).toEqual(['']); - }); -}); - -describe('renderStderrBox', () => { +describe('renderStderrNotice', () => { let errors: string[]; beforeEach(() => { @@ -79,45 +15,22 @@ describe('renderStderrBox', () => { vi.restoreAllMocks(); }); - it('renders a single-line box when content fits (historical layout)', () => { - withColumns(80, () => renderStderrBox(' hi ', noColor)); + it('frames a single line with blank lines above and below, indented two spaces', () => { + renderStderrNotice('hello'); - // blank, top, middle, bottom, blank - expect(errors).toHaveLength(5); - expect(errors[1]).toBe(' ┌────┐'); // border = visible length of " hi " (4) - expect(errors[2]).toBe(' │ hi │'); - expect(errors[3]).toBe(' └────┘'); + expect(errors).toEqual(['', ' hello', '']); }); - it('wraps to multiple lines on a narrow terminal without breaking the border', () => { - const msg = ' WorkOS collects anonymous CLI usage telemetry. Run workos telemetry opt-out to disable it. '; - withColumns(40, () => renderStderrBox(msg, noColor)); - - // No rendered line may exceed the terminal width. - for (const line of errors) { - expect(stripAnsii(line).length).toBeLessThanOrEqual(40); - } - - const top = errors.find((l) => l.includes('┌'))!; - const bottom = errors.find((l) => l.includes('└'))!; - const body = errors.filter((l) => l.includes('│')); - - // More than one body line proves it wrapped. - expect(body.length).toBeGreaterThan(1); + it('indents every line of a multi-line notice', () => { + renderStderrNotice('first', 'second'); - // Top and bottom borders are the same width, and every body line matches it. - expect(stripAnsii(top).length).toBe(stripAnsii(bottom).length); - for (const line of body) { - expect(stripAnsii(line).length).toBe(stripAnsii(top).length); - } + expect(errors).toEqual(['', ' first', ' second', '']); }); - it('preserves the colored command span when wrapping', () => { - const cmd = cyan('workos telemetry opt-out'); - const msg = ` WorkOS collects anonymous CLI usage telemetry. Run ${cmd} to disable it. `; - withColumns(44, () => renderStderrBox(msg, noColor)); + it('preserves already-styled (ANSI) content verbatim', () => { + const styled = '\x1b[32m✓\x1b[39m done'; + renderStderrNotice(styled); - const body = errors.filter((l) => l.includes('│')).join('\n'); - expect(body).toContain(cmd); // intact, not split mid-span + expect(errors[1]).toBe(` ${styled}`); }); }); diff --git a/src/utils/box.ts b/src/utils/box.ts index 6acf2b96..738b76f8 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,121 +1,13 @@ -import type chalk from 'chalk'; -import { stripAnsii } from './string.js'; - -/** Visible (printable) width of a string, ignoring ANSI escape sequences. */ -function visibleWidth(str: string): number { - return stripAnsii(str).length; -} - -/** Terminal width for stderr output, falling back to stdout then 80 columns. */ -function terminalWidth(): number { - return process.stderr.columns || process.stdout.columns || 80; -} - /** - * Word-wrap a string to a maximum visible width, preserving ANSI color. - * - * chalk emits self-closing color spans (e.g. `\x1b[36m…\x1b[39m`), so each - * colored fragment is atomic: we tokenize the input into whole colored spans - * and plain words, then greedily pack tokens into lines measured by their - * VISIBLE width. Because every token carries its own open+close codes, color - * never bleeds across a line break onto the border or padding. - * - * A single token wider than `maxWidth` (rare — only a very narrow terminal vs. - * a long unbroken word) overflows its own line rather than being split mid-span. - * Such an overflow can push the rendered box border past the terminal width; - * acceptable at standard widths. Note that a colored command produced by - * `formatWorkOSCommand` can be long (e.g. `npx workos@latest telemetry opt-out`) - * and stays a single unbreakable token by design. + * Flat, gutterless notice to stderr — the de-boxed startup/notice output. * - * Limitation: a colored span is grouped atomically only when it is a single SGR - * layer (one open code + one close code, as `chalk.cyan('…')` emits). Stacked - * styles such as bold+color (`\x1b[1m\x1b[36m…\x1b[39m\x1b[22m`) or two adjacent - * spans with no separating space are not guaranteed to stay on one line and may - * leave a reset code mid-line. All current callers use single-color spans only. - */ -export function wrapAnsiAware(input: string, maxWidth: number): string[] { - // A token is either: a full SGR-wrapped span (open code, content that may - // contain spaces, close code), a run of non-space/non-escape characters - // (a plain word), or a lone escape. Whitespace between tokens is dropped and - // re-inserted as single separating spaces. - const tokenRe = /\x1b\[[0-9;]*m[^\x1b]*?\x1b\[[0-9;]*m|[^\s\x1b]+|\x1b\[[0-9;]*m/g; - const tokens = input.match(tokenRe) ?? []; - - const lines: string[] = []; - let line = ''; - let lineWidth = 0; - - for (const token of tokens) { - const tokenWidth = visibleWidth(token); - if (lineWidth === 0) { - line = token; - lineWidth = tokenWidth; - } else if (lineWidth + 1 + tokenWidth <= maxWidth) { - line += ` ${token}`; - lineWidth += 1 + tokenWidth; - } else { - lines.push(line); - line = token; - lineWidth = tokenWidth; - } - } - if (line) lines.push(line); - return lines.length > 0 ? lines : ['']; -} - -/** - * Render a bordered box to stderr, wrapping to the terminal width. - * - * When the content fits on one line it renders exactly as a single-line box - * (the historical behavior). When it would overflow the terminal, the content - * is word-wrapped (ANSI-aware) and the box grows to multiple lines so the - * border never breaks on a narrow terminal. - */ -/** - * Flat, gutterless notice to stderr — the de-boxed replacement for - * `renderStderrBox` on the startup notices. Callers pass already-styled lines; - * this indents them two spaces and frames them with a single blank line above - * and below so the notice reads as its own beat without a border. + * Callers pass already-styled lines; this indents them two spaces and frames + * them with a single blank line above and below so the notice reads as its own + * beat without a border. This is the only notice primitive: the CLI no longer + * draws bordered boxes for startup notices (telemetry, unclaimed env, provision). */ export function renderStderrNotice(...lines: string[]): void { console.error(''); for (const ln of lines) console.error(` ${ln}`); console.error(''); } - -export function renderStderrBox(inner: string, color: typeof chalk.yellow | typeof chalk.green): void { - const cols = terminalWidth(); - const plainLen = visibleWidth(inner); - - // Fast path: content (including its own padding spaces) fits within the - // terminal. Render the single-line box byte-for-byte as before. - if (plainLen <= cols - 4) { - const border = '─'.repeat(plainLen); - console.error(''); - console.error(color(` ┌${border}┐`)); - console.error(color(' │') + inner + color('│')); - console.error(color(` └${border}┘`)); - console.error(''); - return; - } - - // Wrap path: trim the caller's outer padding, wrap to the available width, - // then re-pad each line to a uniform inner width with one space of padding - // on each side. Layout per line: " │ " + text + " │" = text + 6 columns. - const content = inner.replace(/^[ \t]+/, '').replace(/[ \t]+$/, ''); - const maxTextWidth = Math.max(1, cols - 6); - const wrapped = wrapAnsiAware(content, maxTextWidth); - - // Snug the box to the longest wrapped line rather than the full terminal. - const textWidth = Math.max(...wrapped.map(visibleWidth)); - const border = '─'.repeat(textWidth + 2); - - console.error(''); - console.error(color(` ┌${border}┐`)); - for (const ln of wrapped) { - const pad = ' '.repeat(Math.max(0, textWidth - visibleWidth(ln))); - console.error(`${color(' │')} ${ln}${pad} ${color('│')}`); - } - console.error(color(` └${border}┘`)); - console.error(''); -} diff --git a/src/utils/recovery-hints.ts b/src/utils/recovery-hints.ts index 4c4fb0b8..b3f1c787 100644 --- a/src/utils/recovery-hints.ts +++ b/src/utils/recovery-hints.ts @@ -91,6 +91,19 @@ export function confirmationRecovery(command?: string): RecoveryHints { }; } +/** Build a recovery hint for a transient network / connection failure. */ +export function networkRetryRecovery(options: { command?: string; docsUrl?: string } = {}): RecoveryHints { + return { + hints: [ + { + description: 'Check your network connection and try again.', + ...(options.command && { command: options.command }), + ...(options.docsUrl && { docsUrl: options.docsUrl }), + }, + ], + }; +} + /** Build a `missing_args` recovery hint, attaching a command only when it is directly runnable. */ export function missingArgsRecovery(command: string | undefined, description: string): RecoveryHints { return { diff --git a/src/utils/summary-box.spec.ts b/src/utils/summary-box.spec.ts index 36d45e89..a496f8d6 100644 --- a/src/utils/summary-box.spec.ts +++ b/src/utils/summary-box.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { renderSummaryBox, renderCompletionSummary, type SummaryBoxItem } from './summary-box.js'; +import { renderSummaryBox, renderCompletionSummary, renderBrandMark, type SummaryBoxItem } from './summary-box.js'; import type { CompletionData } from '../lib/events.js'; // Simple ANSI code stripper (avoids needing strip-ansi as a dependency) @@ -173,11 +173,41 @@ describe('summary-box', () => { expect(result).toContain('Start dev server to test authentication'); }); - it('renders the failure box unchanged', () => { + it('renders the failure summary', () => { const result = strip(renderCompletionSummary(false, 'Something went wrong')); expect(result).toContain('Installation Failed'); expect(result).toContain('Something went wrong'); }); + + 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. + const widths = new Set( + result + .split('\n') + .filter((l) => l.trim()) + .map((l) => l.length), + ); + expect(widths.size).toBeGreaterThan(1); + }); + }); + + describe('renderBrandMark', () => { + it('places the wordmark beside the lock and stays 6 lines tall', () => { + const result = strip(renderBrandMark('AuthKit installer')); + + 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); + }); + + it('renders without a subtitle', () => { + const result = strip(renderBrandMark()); + + expect(result).toContain('WorkOS'); + expect(result).not.toContain('AuthKit installer'); + }); }); }); diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index 88f3b932..a11808e3 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -17,15 +17,15 @@ 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 })); - return renderSummaryBox({ + return renderFlatSummary({ expression: 'success', title: 'WorkOS AuthKit Installed', items: [...shown, ...steps], footer: completion.docsUrl, }); } - // Fallback: preserve the original static box when no structured data is present. - return renderSummaryBox({ + // Fallback: preserve the original static next-steps when no structured data is present. + return renderFlatSummary({ expression: 'success', title: 'WorkOS AuthKit Installed', items: [ @@ -35,7 +35,7 @@ export function renderCompletionSummary(success: boolean, summary?: string, comp footer: 'https://workos.com/docs/authkit', }); } - return renderSummaryBox({ + return renderFlatSummary({ expression: 'error', title: 'Installation Failed', items: summary ? [{ type: 'error', text: summary }] : [], @@ -67,6 +67,55 @@ const ITEM_ICONS: Record = { error: chalk.red(symbols.error), }; +// ── Flat (de-boxed) rendering — the install opener + closer ─────────────────── + +const accent = chalk.hex('#6363f1'); // WorkOS indigo +const flatCyan = chalk.hex('#7dd3fc'); // values, paths, URLs + +/** Flat glyphs matching the ui facade (green ✓ / accent › / red ✗). */ +const FLAT_ICONS: Record = { + done: chalk.green('✓'), + pending: accent('›'), + 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. + */ +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; + }) + .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). + */ +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)}`); + 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; + out.push(` ${FLAT_ICONS[item.type]} ${text}`); + } + if (footer) out.push('', ` ${chalk.dim(footer)}`); + return out.join('\n'); +} + const MIN_WIDTH = 42; // Item prefix " X " = 4 visible chars before text const ITEM_PREFIX_LEN = 4; diff --git a/src/utils/ui-utils.spec.ts b/src/utils/ui-utils.spec.ts index 04d288ed..e934d7bd 100644 --- a/src/utils/ui-utils.spec.ts +++ b/src/utils/ui-utils.spec.ts @@ -32,6 +32,7 @@ const ui = (await import('./ui.js')).default; const { setInteractionMode, resetInteractionModeForTests } = await import('./interaction-mode.js'); const { CliExit } = await import('./cli-exit.js'); const { setOutputMode } = await import('./output.js'); +const { analytics } = await import('./analytics.js'); const { abortIfCancelled, getOrAskForWorkOSCredentials } = await import('./ui-utils.js'); describe('abortIfCancelled — non-interactive guard', () => { @@ -77,6 +78,17 @@ describe('abortIfCancelled — non-interactive guard', () => { vi.mocked(ui.isCancel).mockReturnValue(false); await expect(abortIfCancelled('value')).resolves.toBe('value'); }); + + it('does not flush a cancelled session on the happy path (no per-prompt dead-time)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + vi.mocked(ui.isCancel).mockReturnValue(false); + + await abortIfCancelled('value'); + + // shutdown('cancelled') must fire ONLY on an actual cancel — never on a + // resolved prompt (regression guard for the 3s-per-prompt flush bug). + expect(analytics.shutdown).not.toHaveBeenCalled(); + }); }); describe('getOrAskForWorkOSCredentials — credential-source-aware copy', () => { diff --git a/src/utils/ui-utils.ts b/src/utils/ui-utils.ts index f3101537..1b27dd4f 100644 --- a/src/utils/ui-utils.ts +++ b/src/utils/ui-utils.ts @@ -83,10 +83,14 @@ export async function abortIfCancelled( }); } - await analytics.shutdown('cancelled'); const resolvedInput = await input; if (ui.isCancel(resolvedInput)) { + // Flush a 'cancelled' session end ONLY on an actual cancel. Running this on + // every prompt (the previous behavior) emitted a bogus session end and added + // up to 3s of flush dead-time to each prompt on the happy path. + await analytics.shutdown('cancelled'); + const docsUrl = integration ? INTEGRATION_CONFIG[integration].docsUrl : 'https://workos.com/docs/user-management'; ui.cancel( diff --git a/src/utils/ui.spec.ts b/src/utils/ui.spec.ts index 963a312e..55575d2e 100644 --- a/src/utils/ui.spec.ts +++ b/src/utils/ui.spec.ts @@ -17,9 +17,19 @@ function namedError(name: string): Error { return e; } +let stdinTtyDesc: PropertyDescriptor | undefined; beforeEach(() => { vi.clearAllMocks(); setDashboardMode(false); + // 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). + stdinTtyDesc = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); +}); +afterEach(() => { + if (stdinTtyDesc) Object.defineProperty(process.stdin, 'isTTY', stdinTtyDesc); + else delete (process.stdin as { isTTY?: boolean }).isTTY; }); describe('isCancel / CANCEL', () => { @@ -132,6 +142,60 @@ describe('cancellation (inquirer throws → CANCEL sentinel)', () => { }); }); +describe('prompt coordination (withPrompt)', () => { + it('refuses to prompt in --json mode (would corrupt machine output)', async () => { + const { setOutputMode } = await import('./output.js'); + setOutputMode('json'); + try { + await expect(ui.confirm({ message: 'q' })).rejects.toMatchObject({ + name: 'PromptUnavailableError', + reason: 'json', + }); + expect(inquirer.confirm).not.toHaveBeenCalled(); + } finally { + setOutputMode('human'); + } + }); + + it('refuses to prompt on a non-TTY stdin (would hang forever)', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + await expect(ui.confirm({ message: 'q' })).rejects.toMatchObject({ + name: 'PromptUnavailableError', + reason: 'no-tty', + }); + expect(inquirer.confirm).not.toHaveBeenCalled(); + }); + + it('serializes concurrent prompts so two never share stdin at once', async () => { + const order: string[] = []; + let resolveFirst!: (v: boolean) => void; + vi.mocked(inquirer.confirm) + .mockImplementationOnce( + () => + new Promise((resolve) => { + order.push('open1'); + resolveFirst = resolve; + }), + ) + .mockImplementationOnce(async () => { + order.push('open2'); + return true; + }); + + // Fire two prompts "at once", as the parallel installer state does. + const p1 = ui.confirm({ message: 'first' }); + const p2 = ui.confirm({ message: 'second' }); + await new Promise((r) => setTimeout(r, 0)); + + // Only the first prompt has opened; the second is queued behind it. + expect(order).toEqual(['open1']); + + resolveFirst(true); + await Promise.all([p1, p2]); + expect(order).toEqual(['open1', 'open2']); + }); +}); + describe('dashboard mode suppresses output', () => { let logSpy: ReturnType; beforeEach(() => { diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 243a94a4..5c0614c5 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -22,6 +22,7 @@ import { input as inquirerInput, password as inquirerPassword, } from '@inquirer/prompts'; +import { isJsonMode } from './output.js'; // ── Dashboard mode ────────────────────────────────────────────────────────── // When true, suppress all human output (the Dashboard adapter drives its own UI). @@ -144,8 +145,21 @@ export interface Spinner { start: (message?: string) => void; message: (message: string) => void; stop: (message?: string, code?: number) => void; + /** Halt and erase the spinner line WITHOUT printing a final status line. */ + clear: () => void; } +/** + * The currently-running spinner, if any. A prompt pauses it before opening so + * the 80ms redraw interval can't overwrite the question (see withPrompt). + * Internal — not part of the public Spinner surface. + */ +interface PausableSpinner { + pause: () => void; + resume: () => void; +} +let activeSpinner: PausableSpinner | null = null; + /** spinner: start(msg) / message(msg) / stop(msg, code). */ function spinner(): Spinner { let timer: ReturnType | undefined; @@ -155,13 +169,22 @@ function spinner(): Spinner { const render = () => { process.stdout.write(`\r${INDENT}${dim(SPINNER_FRAMES[(frame = (frame + 1) % SPINNER_FRAMES.length)])} ${text}`); }; - return { + const clearLine = () => { + if (isTty) process.stdout.write('\r\x1b[2K'); + }; + const tick = () => { + if (isTty && !timer) { + render(); + timer = setInterval(render, 80); + } + }; + const handle: Spinner & PausableSpinner = { start(message = '') { text = message; if (dashboardMode) return; if (isTty) { - render(); - timer = setInterval(render, 80); + tick(); + activeSpinner = handle; } else { line(`${dim('…')} ${text}`); } @@ -170,13 +193,45 @@ function spinner(): Spinner { text = message; }, stop(message?: string, code = 0) { - if (timer) clearInterval(timer); + if (timer) { + clearInterval(timer); + timer = undefined; + } + if (activeSpinner === handle) activeSpinner = null; if (dashboardMode) return; - if (isTty) process.stdout.write('\r\x1b[2K'); + clearLine(); const glyph = code === 0 ? green('✓') : red('✗'); line(`${glyph} ${message ?? text}`); }, + // 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 + // doesn't resume it. + clear() { + if (timer) { + clearInterval(timer); + timer = undefined; + } + if (activeSpinner === handle) activeSpinner = null; + if (dashboardMode) return; + clearLine(); + }, + // Pause/resume let a prompt borrow the terminal: pause clears the spinner + // line and halts the redraw interval; resume restarts it. stop() is NOT + // called, so activeSpinner stays registered across the prompt. + pause() { + if (timer) { + clearInterval(timer); + timer = undefined; + } + clearLine(); + }, + 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(); + }, }; + return handle; } // ── Cancellation ────────────────────────────────────────────────────────────── @@ -204,6 +259,66 @@ function isCancelError(error: unknown): boolean { return error instanceof Error && CANCEL_ERROR_NAMES.has(error.name); } +// ── Prompt coordination ─────────────────────────────────────────────────────── + +/** + * Thrown when a prompt is attempted where the user cannot answer: machine + * (`--json`) output, or a non-interactive stdin (piped / no TTY). Previously + * these either corrupted machine output or hung forever waiting on a stdin that + * never delivers a keystroke. Callers that reach this generally have an upstream + * gap (they should have gated on isPromptAllowed()/isJsonMode() and offered a + * flag) — surfacing it fails fast with a clear next step instead of hanging. + */ +export class PromptUnavailableError extends Error { + constructor( + public readonly reason: 'json' | 'no-tty', + message: string, + ) { + super(message); + this.name = 'PromptUnavailableError'; + } +} + +/** + * Serializes every prompt through a single-flight chain so two @inquirer prompts + * can never share stdin at once. The installer's XState `preparing` state runs + * git-dirty and protected-branch checks in PARALLEL regions, each of which can + * open a prompt from a fire-and-forget event handler — without this, both prompts + * render on the same stdin and steal each other's keystrokes ("it asked a + * question but wasn't there to answer it"). It also pauses any live spinner so + * the 80ms redraw interval can't overwrite the question. + */ +let promptChain: Promise = Promise.resolve(); + +async function withPrompt(run: () => Promise): Promise { + if (isJsonMode()) { + throw new PromptUnavailableError( + 'json', + 'Cannot prompt for input in --json mode. Re-run without --json, or pass the flag that answers it (e.g. --yes / --force).', + ); + } + if (!process.stdin.isTTY) { + throw new PromptUnavailableError( + 'no-tty', + 'This step needs an interactive terminal. Re-run in a terminal, or pass the required flags to run non-interactively.', + ); + } + const prior = promptChain.catch(() => undefined); + let release!: () => void; + promptChain = new Promise((resolve) => { + release = resolve; + }); + await prior; + const spinner = activeSpinner; + spinner?.pause(); + try { + return await run(); + } finally { + spinner?.resume(); + release(); + } +} + /** * Adapt the validate contract (return error string / Error when invalid, * undefined when valid) to @inquirer's (return true when valid, string when not). @@ -226,15 +341,17 @@ interface ConfirmOptions { signal?: AbortSignal; } async function confirm(options: ConfirmOptions): Promise { - try { - return await inquirerConfirm( - { message: options.message, default: options.initialValue }, - { signal: options.signal }, - ); - } catch (error) { - if (isCancelError(error)) return CANCEL; - throw error; - } + return withPrompt(async () => { + try { + return await inquirerConfirm( + { message: options.message, default: options.initialValue }, + { signal: options.signal }, + ); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } + }); } interface SelectOption { @@ -250,24 +367,26 @@ interface SelectOptions { signal?: AbortSignal; } async function select(options: SelectOptions): Promise { - try { - return await inquirerSelect( - { - message: options.message, - choices: options.options.map((o) => ({ - value: o.value, - name: o.label ?? String(o.value), - description: o.hint, - })), - default: options.initialValue, - pageSize: options.maxItems, - }, - { signal: options.signal }, - ); - } catch (error) { - if (isCancelError(error)) return CANCEL; - throw error; - } + return withPrompt(async () => { + try { + return await inquirerSelect( + { + message: options.message, + choices: options.options.map((o) => ({ + value: o.value, + name: o.label ?? String(o.value), + description: o.hint, + })), + default: options.initialValue, + pageSize: options.maxItems, + }, + { signal: options.signal }, + ); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } + }); } interface TextOptions { @@ -279,23 +398,25 @@ interface TextOptions { signal?: AbortSignal; } async function text(options: TextOptions): Promise { - try { + return withPrompt(async () => { // @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). const message = options.placeholder ? `${options.message} (${options.placeholder})` : options.message; - return await inquirerInput( - { - message, - default: options.defaultValue ?? options.initialValue, - validate: adaptValidate(options.validate), - }, - { signal: options.signal }, - ); - } catch (error) { - if (isCancelError(error)) return CANCEL; - throw error; - } + try { + return await inquirerInput( + { + message, + default: options.defaultValue ?? options.initialValue, + validate: adaptValidate(options.validate), + }, + { signal: options.signal }, + ); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } + }); } interface PasswordOptions { @@ -304,15 +425,17 @@ interface PasswordOptions { signal?: AbortSignal; } async function password(options: PasswordOptions): Promise { - try { - return await inquirerPassword( - { message: options.message, mask: true, validate: adaptValidate(options.validate) }, - { signal: options.signal }, - ); - } catch (error) { - if (isCancelError(error)) return CANCEL; - throw error; - } + return withPrompt(async () => { + try { + return await inquirerPassword( + { message: options.message, mask: true, validate: adaptValidate(options.validate) }, + { signal: options.signal }, + ); + } catch (error) { + if (isCancelError(error)) return CANCEL; + throw error; + } + }); } // ── Default export (the `ui` facade) ──────────────────────────────────────── From aefdc64ac82d20febc0c184897761815f53dc680 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 12:20:08 -0500 Subject: [PATCH 4/6] chore: formatting --- src/commands/api/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index 7e524be3..ea961f8a 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -166,7 +166,10 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions response.status === 401 || response.status === 403 ? authLoginRecovery({ mode: getInteractionMode().mode }) : response.status === 404 - ? missingArgsRecovery(formatWorkOSCommand('api ls'), 'List available endpoints, then re-run with a valid path.') + ? missingArgsRecovery( + formatWorkOSCommand('api ls'), + 'List available endpoints, then re-run with a valid path.', + ) : undefined; exitWithError({ code: `http_${response.status}`, From e5c6ea8a6d4d94eff42120e229333d24f6bd984f Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 12:34:39 -0500 Subject: [PATCH 5/6] fix: gate workos setup on JSON mode and only complete on success Address Devin review findings on the consent-gated setup flow. - `workos setup --json` on a TTY no longer prompts. Output mode is JSON while interaction mode stays human, so isPromptAllowed() is true and the command path fell through to ui.confirm/ui.note, corrupting the machine-readable stdout stream. Require --yes under --json (mirrors the api command), consistent with the automatic path's isJsonMode() guard. - recordSetupCompleted() now fires only when something actually installed. A run where every install failed (e.g. transient `claude mcp add` timeouts, no skills to fall back on) previously persisted completion, and isSetupCompleted() suppresses all future automatic offers -- so a transient failure permanently stranded the user. Leave the pref unset on total failure so the next login/install re-offers. --- src/commands/setup.spec.ts | 33 +++++++++++++++++++++++++++++++++ src/commands/setup.ts | 21 ++++++++++++++++----- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts index 5d3bb365..66f07eed 100644 --- a/src/commands/setup.spec.ts +++ b/src/commands/setup.spec.ts @@ -182,6 +182,27 @@ describe('runSetup — automatic triggers (login/install)', () => { ); }); + it('does NOT record completion when every install fails (transient failure gets re-offered)', async () => { + // No skill agents to fall back on, and the only MCP target fails to add. + vi.mocked(detectAgents).mockReturnValue([]); + const target = mcpTarget({ + add: vi.fn(async () => ({ + agent: 'claude-code', + displayName: 'Claude Code', + outcome: 'failed', + error: 'timeout', + })), + }); + vi.mocked(detectMcpClients).mockResolvedValue([target as any]); + vi.mocked(ui.confirm).mockResolvedValue(true); + + // reportResults still exits non-zero on a failed MCP add. + await expect(runSetup({ trigger: 'login' })).rejects.toThrow('exitCode:1'); + + // Completion must stay unset so the next login/install re-offers. + expect(prefs.recordSetupCompleted).not.toHaveBeenCalled(); + }); + it('records an absolute decline and installs nothing on "no"', async () => { detectSome(); vi.mocked(ui.confirm).mockResolvedValue(false); @@ -255,6 +276,18 @@ describe('runSetup — command trigger', () => { await expect(runSetup({ trigger: 'command' })).rejects.toThrow('exit:confirmation_required'); }); + it('errors (never prompts) for --json on a TTY without --yes', async () => { + detectSome(); + // JSON output mode, but interaction mode stays human — the exact combo the + // old guard missed. A prompt here would corrupt machine-readable stdout. + vi.mocked(isJsonMode).mockReturnValue(true); + vi.mocked(isPromptAllowed).mockReturnValue(true); + + await expect(runSetup({ trigger: 'command' })).rejects.toThrow('exit:confirmation_required'); + expect(ui.confirm).not.toHaveBeenCalled(); + expect(ui.heading).not.toHaveBeenCalled(); + }); + it('installs without prompting when --yes is passed', async () => { detectSome(); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index bf521acd..ab863608 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -103,11 +103,14 @@ export async function runSetup(opts: RunSetupOptions): Promise { // never after a decline or a prior completion. if (isJsonMode() || !isPromptAllowed()) return; if (isSetupDeclined() || isSetupCompleted()) return; - } else if (!isPromptAllowed() && !opts.assumeYes) { - // Explicit `workos setup` in a non-interactive context needs --yes. + } else if ((!isPromptAllowed() || isJsonMode()) && !opts.assumeYes) { + // Explicit `workos setup` can't prompt in a non-interactive context, nor + // under --json — output mode is JSON while interaction mode stays human, so + // isPromptAllowed() is still true and a confirm would pollute the + // machine-readable stdout stream. Require --yes in both cases. exitWithError({ code: 'confirmation_required', - message: `Interactive setup needs a TTY. Re-run \`${formatWorkOSCommand('setup --yes')}\` to install non-interactively.`, + message: `Setup can't prompt here (non-interactive or --json). Re-run \`${formatWorkOSCommand('setup --yes')}\` to install without a prompt.`, }); } @@ -176,8 +179,6 @@ async function installAndReport( ]); const skillAgentNames = skillResult?.agents.map((a) => a.displayName) ?? []; - recordSetupCompleted(); - const mcpInstalled = mcpResults .filter((r) => r.outcome === 'installed' || r.outcome === 'already-installed') .map((r) => r.agent); @@ -188,6 +189,16 @@ async function installAndReport( // text is shown via ui.log.error. const mcpFailedReasons = failedResults.map((r) => `${r.agent}:${(r.error ?? '').slice(0, 120)}`).join('; '); + // Only mark setup complete when something actually landed. A run where every + // attempted install failed (e.g. transient `claude mcp add` timeouts, with no + // skills to fall back on) must NOT be treated as done: isSetupCompleted() + // suppresses every future automatic offer, so persisting completion here would + // strand the user after a transient failure. Leave the pref unset so the next + // login/install re-offers. + if (skillAgentNames.length > 0 || mcpInstalled.length > 0) { + recordSetupCompleted(); + } + emitSetupEvent(opts.trigger, startedAt, 'accepted', { skills: skillResult?.agents.map((a) => a.name) ?? [], mcpInstalled, From 7847906a729ee293328335809ef7f7385bb4e498 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 23 Jul 2026 10:42:53 -0500 Subject: [PATCH 6/6] refactor: dedup UI palette, lazy-load inquirer, and centralize prompt lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality cleanups from a /simplify pass on the CLI UX refactor: - Move the WorkOS brand palette to a single `palette` in cli-symbols.ts; ui.ts and summary-box.ts now share it instead of redefining the hexes. - Lazy-load the @inquirer/prompts barrel inside the four prompt fns so its ten widgets stay off every non-interactive path (--json, --version, resource commands) — most invocations. import() is module-cached. - Route stray blank-line writes through a guarded blank() sink and drop the redundant per-function dashboard-mode guards in intro/outro/heading/note. - Extract withPromptActive() in the CLI adapter, collapsing the copy-pasted isPromptActive/flushPendingLogs dance across six prompt handlers into one exception-safe try/finally. - Remove dead log.message and the never-passed note() title param. --- src/lib/adapters/cli-adapter.spec.ts | 8 ++ src/lib/adapters/cli-adapter.ts | 106 +++++++++++++++------------ src/utils/cli-symbols.ts | 13 ++++ src/utils/summary-box.ts | 5 +- src/utils/ui.ts | 58 ++++++++------- 5 files changed, 111 insertions(+), 79 deletions(-) diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index d92cc656..3d3981ab 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -64,6 +64,14 @@ vi.mock('../../utils/cli-symbols.js', () => ({ progressFilled: '▓', progressEmpty: '░', }, + // Identity functions so summary-box renders without chalk color codes. + palette: { + accent: (text: string) => text, + green: (text: string) => text, + red: (text: string) => text, + yellow: (text: string) => text, + cyan: (text: string) => text, + }, })); describe('CLIAdapter', () => { diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index dbaf19f5..4e975013 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -72,6 +72,22 @@ export class CLIAdapter implements InstallerAdapter { logs.forEach((fn) => fn()); } + /** + * Run a prompt with the log queue engaged: mark a prompt active so async log + * events (detection:complete, branch:created, …) buffer instead of scribbling + * over the question, then release and flush once it resolves. Wraps every + * prompt handler so the active/flush lifecycle lives in one place. + */ + private async withPromptActive(run: () => Promise): Promise { + this.isPromptActive = true; + try { + return await run(); + } finally { + this.isPromptActive = false; + this.flushPendingLogs(); + } + } + async start(): Promise { if (this.isStarted) return; this.isStarted = true; @@ -279,14 +295,13 @@ export class CLIAdapter implements InstallerAdapter { }; private handleEnvScanPrompt = async ({ files }: InstallerEvents['credentials:env:prompt']): Promise => { - this.isPromptActive = true; const fileList = files.length === 1 ? files[0] : files.slice(0, 2).join(', '); - const confirmed = await ui.confirm({ - message: `Found ${fileList}. Check for existing WorkOS credentials?`, - initialValue: true, - }); - this.isPromptActive = false; - this.flushPendingLogs(); + const confirmed = await this.withPromptActive(() => + ui.confirm({ + message: `Found ${fileList}. Check for existing WorkOS credentials?`, + initialValue: true, + }), + ); this.sendEvent({ type: ui.isCancel(confirmed) || !confirmed ? 'ENV_SCAN_DECLINED' : 'ENV_SCAN_APPROVED', @@ -355,14 +370,13 @@ export class CLIAdapter implements InstallerAdapter { ui.log.info(chalk.dim(` ... and ${files.length - 5} more`)); } - this.isPromptActive = true; - const confirmed = await ui.confirm({ - message: 'Continue anyway?', - initialValue: false, - signal: this.promptAbort?.signal, - }); - this.isPromptActive = false; - this.flushPendingLogs(); + const confirmed = await this.withPromptActive(() => + ui.confirm({ + message: 'Continue anyway?', + initialValue: false, + signal: this.promptAbort?.signal, + }), + ); this.sendEvent({ type: ui.isCancel(confirmed) || !confirmed ? 'GIT_CANCELLED' : 'GIT_CONFIRMED', @@ -579,13 +593,12 @@ export class CLIAdapter implements InstallerAdapter { private handleScaffoldPrompt = async ({ packageManager }: InstallerEvents['scaffold:prompt']): Promise => { this.scaffoldPackageManager = packageManager; - this.isPromptActive = true; - const confirmed = await ui.confirm({ - message: 'This directory is empty. Scaffold a new Next.js app with AuthKit here?', - initialValue: true, - }); - this.isPromptActive = false; - this.flushPendingLogs(); + const confirmed = await this.withPromptActive(() => + ui.confirm({ + message: 'This directory is empty. Scaffold a new Next.js app with AuthKit here?', + initialValue: true, + }), + ); this.sendEvent({ type: ui.isCancel(confirmed) || !confirmed ? 'SCAFFOLD_CANCELLED' : 'SCAFFOLD_CONFIRMED', @@ -618,18 +631,17 @@ export class CLIAdapter implements InstallerAdapter { }; private handleBranchPrompt = async ({ branch }: InstallerEvents['branch:prompt']): Promise => { - this.isPromptActive = true; - const choice = await ui.select({ - message: `You are on ${chalk.bold(branch)}. Create a feature branch?`, - options: [ - { value: 'create', label: 'Create feat/add-workos-authkit' }, - { value: 'continue', label: 'Continue on current branch' }, - { value: 'cancel', label: 'Cancel' }, - ], - signal: this.promptAbort?.signal, - }); - this.isPromptActive = false; - this.flushPendingLogs(); + const choice = await this.withPromptActive(() => + ui.select({ + message: `You are on ${chalk.bold(branch)}. Create a feature branch?`, + options: [ + { value: 'create', label: 'Create feat/add-workos-authkit' }, + { value: 'continue', label: 'Continue on current branch' }, + { value: 'cancel', label: 'Cancel' }, + ], + signal: this.promptAbort?.signal, + }), + ); if (ui.isCancel(choice) || choice === 'cancel') { this.sendEvent({ type: 'BRANCH_CANCEL' }); @@ -651,13 +663,12 @@ export class CLIAdapter implements InstallerAdapter { }; private handleCommitPrompt = async (): Promise => { - this.isPromptActive = true; - const confirmed = await ui.confirm({ - message: 'Commit the changes?', - initialValue: true, - }); - this.isPromptActive = false; - this.flushPendingLogs(); + const confirmed = await this.withPromptActive(() => + ui.confirm({ + message: 'Commit the changes?', + initialValue: true, + }), + ); this.sendEvent({ type: ui.isCancel(confirmed) || !confirmed ? 'COMMIT_DECLINED' : 'COMMIT_APPROVED', @@ -680,13 +691,12 @@ export class CLIAdapter implements InstallerAdapter { }; private handlePrPrompt = async (): Promise => { - this.isPromptActive = true; - const confirmed = await ui.confirm({ - message: 'Create a pull request?', - initialValue: true, - }); - this.isPromptActive = false; - this.flushPendingLogs(); + const confirmed = await this.withPromptActive(() => + ui.confirm({ + message: 'Create a pull request?', + initialValue: true, + }), + ); this.sendEvent({ type: ui.isCancel(confirmed) || !confirmed ? 'PR_DECLINED' : 'PR_APPROVED', diff --git a/src/utils/cli-symbols.ts b/src/utils/cli-symbols.ts index b54155f9..6ae9dd29 100644 --- a/src/utils/cli-symbols.ts +++ b/src/utils/cli-symbols.ts @@ -20,6 +20,19 @@ export const symbols = { progressEmpty: unicode ? '░' : '-', } as const; +/** + * WorkOS brand palette (hex). Single source shared by the `ui` facade and the + * install summary box so the flat CLI output and the summary render identical + * brand colors. chalk auto-disables color when `chalk.level === 0` (JSON mode). + */ +export const palette = { + accent: chalk.hex('#6363f1'), // WorkOS indigo + green: chalk.hex('#34d399'), + red: chalk.hex('#f87171'), + yellow: chalk.hex('#fbbf24'), + cyan: chalk.hex('#7dd3fc'), // values, paths, URLs +} as const; + /** * Pre-styled output functions for consistent CLI formatting. * Uses chalk for coloring with appropriate symbols. diff --git a/src/utils/summary-box.ts b/src/utils/summary-box.ts index a11808e3..cc38c112 100644 --- a/src/utils/summary-box.ts +++ b/src/utils/summary-box.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { isUnicodeSupported } from './vendor/is-unicorn-supported.js'; import { type LockExpression, getLockArt, LOCK_WIDTH } from './lock-art.js'; -import { symbols } from './cli-symbols.js'; +import { symbols, palette } from './cli-symbols.js'; import type { CompletionData } from '../lib/events.js'; /** Max number of changed files listed in the success box before collapsing. */ @@ -69,8 +69,7 @@ const ITEM_ICONS: Record = { // ── Flat (de-boxed) rendering — the install opener + closer ─────────────────── -const accent = chalk.hex('#6363f1'); // WorkOS indigo -const flatCyan = chalk.hex('#7dd3fc'); // values, paths, URLs +const { accent, cyan: flatCyan } = palette; /** Flat glyphs matching the ui facade (green ✓ / accent › / red ✗). */ const FLAT_ICONS: Record = { diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 5c0614c5..8d5f7578 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -16,13 +16,13 @@ */ import chalk from 'chalk'; -import { - confirm as inquirerConfirm, - select as inquirerSelect, - input as inquirerInput, - password as inquirerPassword, -} from '@inquirer/prompts'; import { isJsonMode } from './output.js'; +import { palette } from './cli-symbols.js'; + +// @inquirer/prompts is loaded lazily (inside each prompt fn) so the barrel — all +// ten widgets — stays off every non-interactive path (--json, --version, --help, +// 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). @@ -34,13 +34,9 @@ export function isDashboardMode(): boolean { return dashboardMode; } -// ── Palette (chalk auto-disables color when chalk.level === 0, set by -// setOutputMode in JSON mode) ──────────────────────────────────────────────── -const accent = chalk.hex('#6363f1'); // WorkOS indigo -const green = chalk.hex('#34d399'); -const red = chalk.hex('#f87171'); -const yellow = chalk.hex('#fbbf24'); -const cyan = chalk.hex('#7dd3fc'); // values, paths, URLs +// Brand palette (shared with summary-box via cli-symbols). chalk auto-disables +// color when chalk.level === 0, set by setOutputMode in JSON mode. +const { accent, green, red, yellow, cyan } = palette; const { dim, bold } = chalk; /** @@ -55,12 +51,20 @@ 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). */ function line(text = ''): void { if (dashboardMode) return; console.log(INDENT + text); } +/** Print a vertical blank line (suppressed in dashboard mode). */ +function blank(): void { + if (dashboardMode) return; + console.log(''); +} + // ── Output surface ─────────────────────────────────────────────────────────── /** @@ -68,38 +72,32 @@ function line(text = ''): void { * `Title · subtitle` (accent-bold name, dim subtitle) — the dad-style header. */ function intro(title: string, subtitle?: string): void { - if (dashboardMode) return; - console.log(''); + blank(); line(subtitle ? `${accent(bold(title))} ${dim('·')} ${dim(subtitle)}` : accent(bold(title))); - console.log(''); + blank(); } /** Closing line. */ function outro(message = ''): void { - if (dashboardMode) return; - console.log(''); + blank(); if (message) line(dim(message)); - console.log(''); + blank(); } /** A titled section header — anchors a "moment" that owns several lines. */ function heading(title: string): void { - if (dashboardMode) return; - console.log(''); + blank(); line(accent(bold(title))); } -/** Multi-line indented note. Body dim; optional bold title. */ -function note(message: string, title?: string): void { - if (dashboardMode) return; - console.log(''); - if (title) line(bold(title)); +/** Multi-line indented note (body dim, framed by blank lines). */ +function note(message: string): void { + blank(); for (const l of String(message).split('\n')) line(dim(l)); - console.log(''); + blank(); } const log = { - message: (m: string) => line(m), info: (m: string) => line(m), step: (m: string) => line(`${accent('›')} ${m}`), success: (m: string) => line(`${green('✓')} ${m}`), @@ -342,6 +340,7 @@ interface ConfirmOptions { } async function confirm(options: ConfirmOptions): Promise { return withPrompt(async () => { + const { confirm: inquirerConfirm } = await import('@inquirer/prompts'); try { return await inquirerConfirm( { message: options.message, default: options.initialValue }, @@ -368,6 +367,7 @@ interface SelectOptions { } async function select(options: SelectOptions): Promise { return withPrompt(async () => { + const { select: inquirerSelect } = await import('@inquirer/prompts'); try { return await inquirerSelect( { @@ -403,6 +403,7 @@ async function text(options: TextOptions): Promise { // 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). const message = options.placeholder ? `${options.message} (${options.placeholder})` : options.message; + const { input: inquirerInput } = await import('@inquirer/prompts'); try { return await inquirerInput( { @@ -426,6 +427,7 @@ interface PasswordOptions { } async function password(options: PasswordOptions): Promise { return withPrompt(async () => { + const { password: inquirerPassword } = await import('@inquirer/prompts'); try { return await inquirerPassword( { message: options.message, mask: true, validate: adaptValidate(options.validate) },