From 6573c76d70a5970b2100e54def806e0291003193 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 28 May 2026 08:15:02 +0300 Subject: [PATCH 1/2] feat(api-service, dashboard, novu): Onboarding experiment (#11319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paweł --- .cursor/skills/ink-tui/SKILL.md | 156 +++ .../skills/ink-tui/references/ARCHITECTURE.md | 151 +++ .cursor/skills/ink-tui/references/INK-API.md | 304 +++++ .cursor/skills/ink-tui/references/INKJS-UI.md | 318 ++++++ .cursor/skills/ink-tui/references/PATTERNS.md | 288 +++++ .../skills/ink-tui/references/PRIMITIVES.md | 129 +++ .../ink-tui/references/TERMINAL-COMPAT.md | 158 +++ .source | 2 +- apps/api/src/app.module.ts | 6 + apps/api/src/app/agents/agents.controller.ts | 9 + .../integrations/integrations.controller.ts | 1 + .../generate-slack-oauth-url.usecase.ts | 11 +- .../slack-quick-setup.usecase.ts | 11 +- apps/dashboard/src/main.tsx | 5 + apps/dashboard/src/pages/cli-auth.tsx | 273 +++++ apps/dashboard/src/utils/routes.ts | 1 + packages/novu/package.json | 19 +- packages/novu/scripts/build-ui.mjs | 58 + .../src/commands/connect/analytics/events.ts | 33 + .../novu/src/commands/connect/api/agents.ts | 253 ++++ .../novu/src/commands/connect/api/client.ts | 108 ++ .../src/commands/connect/api/integrations.ts | 123 ++ .../src/commands/connect/api/subscribers.ts | 24 + packages/novu/src/commands/connect/index.ts | 86 ++ .../connect/pipeline/channels/email.ts | 73 ++ .../connect/pipeline/channels/slack.ts | 141 +++ .../connect/pipeline/channels/telegram.ts | 113 ++ .../connect/pipeline/integration-helpers.ts | 89 ++ .../commands/connect/pipeline/poll-until.ts | 31 + .../src/commands/connect/pipeline/runner.ts | 247 ++++ .../src/commands/connect/resolve-options.ts | 32 + packages/novu/src/commands/connect/types.ts | 39 + packages/novu/src/commands/connect/ui/app.tsx | 1013 +++++++++++++++++ .../novu/src/commands/connect/ui/index.tsx | 195 ++++ .../src/commands/connect/ui/logging-ui.ts | 198 ++++ packages/novu/src/commands/connect/ui/qr.ts | 43 + .../novu/src/commands/connect/ui/store.ts | 84 ++ packages/novu/src/commands/connect/ui/ui.ts | 104 ++ .../novu/src/commands/connect/ui/use-store.ts | 9 + packages/novu/src/commands/dev/enums.ts | 16 + .../commands/dev/resolve-region-urls.spec.ts | 40 + .../src/commands/dev/resolve-region-urls.ts | 71 ++ packages/novu/src/commands/dev/utils.ts | 4 +- packages/novu/src/commands/index.ts | 1 + .../app-agent/ts/app/page.module.css | 7 +- packages/novu/src/commands/wizard/README.md | 168 +++ .../wizard/agent/build-subagent-prompt.ts | 345 ++++++ .../wizard/agent/build-user-prompt.ts | 300 +++++ .../wizard/agent/can-use-tool.spec.ts | 102 ++ .../src/commands/wizard/agent/can-use-tool.ts | 181 +++ .../src/commands/wizard/agent/commandments.ts | 41 + .../commands/wizard/agent/install-agents.ts | 139 +++ .../src/commands/wizard/agent/iterator.ts | 357 ++++++ .../commands/wizard/agent/stop-hook.spec.ts | 66 ++ .../src/commands/wizard/agent/stop-hook.ts | 118 ++ .../commands/wizard/agent/system-prompt.ts | 21 + .../commands/wizard/agent/tool-labels.spec.ts | 55 + .../src/commands/wizard/agent/tool-labels.ts | 118 ++ .../src/commands/wizard/analytics/events.ts | 32 + .../commands/wizard/auth/device-auth.spec.ts | 128 +++ .../src/commands/wizard/auth/device-auth.ts | 205 ++++ .../src/commands/wizard/auth/resolve-auth.ts | 121 ++ .../wizard/context/classify-workspace.ts | 174 +++ .../context/detect-install-targets.spec.ts | 110 ++ .../wizard/context/detect-install-targets.ts | 291 +++++ .../wizard/context/detect-project.spec.ts | 172 +++ .../commands/wizard/context/detect-project.ts | 129 +++ .../wizard/context/rebalance-goal.spec.ts | 116 ++ .../commands/wizard/context/rebalance-goal.ts | 137 +++ .../wizard/context/summarise-topology.spec.ts | 97 ++ .../wizard/context/summarise-topology.ts | 107 ++ packages/novu/src/commands/wizard/index.ts | 105 ++ .../wizard/mcp/clients/claude-code.ts | 19 + .../src/commands/wizard/mcp/clients/cline.ts | 16 + .../src/commands/wizard/mcp/clients/codex.ts | 58 + .../src/commands/wizard/mcp/clients/cursor.ts | 18 + .../src/commands/wizard/mcp/clients/index.ts | 58 + .../src/commands/wizard/mcp/clients/types.ts | 21 + .../src/commands/wizard/mcp/clients/utils.ts | 57 + .../src/commands/wizard/mcp/clients/vscode.ts | 27 + .../commands/wizard/mcp/clients/windsurf.ts | 16 + .../novu/src/commands/wizard/mcp/installer.ts | 45 + .../src/commands/wizard/mcp/server-config.ts | 23 + .../src/commands/wizard/pipeline/runner.ts | 423 +++++++ .../commands/wizard/pipeline/steps/auth.ts | 19 + .../wizard/pipeline/steps/build-outro.ts | 76 ++ .../wizard/pipeline/steps/detect-project.ts | 7 + .../wizard/pipeline/steps/install-mcp.spec.ts | 204 ++++ .../wizard/pipeline/steps/install-mcp.ts | 123 ++ .../pipeline/steps/install-packages.spec.ts | 218 ++++ .../wizard/pipeline/steps/install-packages.ts | 425 +++++++ .../wizard/pipeline/steps/install-skills.ts | 49 + .../wizard/pipeline/steps/run-agent.spec.ts | 321 ++++++ .../wizard/pipeline/steps/run-agent.ts | 907 +++++++++++++++ .../wizard/pipeline/steps/validate.spec.ts | 221 ++++ .../wizard/pipeline/steps/validate.ts | 425 +++++++ .../wizard/pipeline/steps/write-report.ts | 74 ++ .../wizard/report/build-report.spec.ts | 418 +++++++ .../commands/wizard/report/build-report.ts | 536 +++++++++ .../skills/check-claude-settings.spec.ts | 92 ++ .../wizard/skills/check-claude-settings.ts | 79 ++ .../wizard/skills/content/env-setup/SKILL.md | 38 + .../wizard/skills/install-skills.spec.ts | 244 ++++ .../commands/wizard/skills/install-skills.ts | 388 +++++++ packages/novu/src/commands/wizard/types.ts | 173 +++ packages/novu/src/commands/wizard/ui/app.tsx | 45 + .../wizard/ui/components/auth-pane.tsx | 108 ++ .../ui/components/bootstrap-welcome.tsx | 74 ++ .../wizard/ui/components/command-footer.tsx | 39 + .../wizard/ui/components/errors-overlay.tsx | 208 ++++ .../wizard/ui/components/help-overlay.tsx | 52 + .../wizard/ui/components/live-tail.tsx | 141 +++ .../wizard/ui/components/mcp-pane.tsx | 106 ++ .../wizard/ui/components/outro-pane.tsx | 136 +++ .../wizard/ui/components/skills-pane.tsx | 70 ++ packages/novu/src/commands/wizard/ui/flows.ts | 29 + .../ui/hooks/use-bootstrap-countdown.ts | 22 + .../commands/wizard/ui/hooks/use-elapsed.ts | 30 + .../wizard/ui/hooks/use-mouse-scroll.ts | 167 +++ .../wizard/ui/hooks/use-scroll-keys.ts | 139 +++ .../wizard/ui/hooks/use-slash-input.ts | 94 ++ .../wizard/ui/hooks/use-stdout-dimensions.ts | 43 + .../src/commands/wizard/ui/hooks/use-store.ts | 23 + .../novu/src/commands/wizard/ui/index.tsx | 178 +++ .../novu/src/commands/wizard/ui/ink-ui.ts | 41 + .../novu/src/commands/wizard/ui/logging-ui.ts | 163 +++ .../wizard/ui/markdown/code-block.tsx | 51 + .../src/commands/wizard/ui/markdown/diff.ts | 50 + .../commands/wizard/ui/markdown/render.tsx | 251 ++++ .../src/commands/wizard/ui/markdown/table.tsx | 38 + .../wizard/ui/overlays/chat-overlay.tsx | 190 ++++ .../wizard/ui/primitives/card-layout.tsx | 43 + .../ui/primitives/dissolve-transition.tsx | 25 + .../commands/wizard/ui/primitives/divider.tsx | 30 + .../commands/wizard/ui/primitives/index.ts | 11 + .../wizard/ui/primitives/loading-box.tsx | 24 + .../wizard/ui/primitives/log-viewer.tsx | 49 + .../wizard/ui/primitives/picker-menu.tsx | 105 ++ .../wizard/ui/primitives/progress-list.tsx | 162 +++ .../wizard/ui/primitives/screen-container.tsx | 60 + .../ui/primitives/screen-error-boundary.tsx | 46 + .../wizard/ui/primitives/split-view.tsx | 43 + .../wizard/ui/primitives/wizard-header.tsx | 49 + .../src/commands/wizard/ui/print-outro.ts | 41 + .../novu/src/commands/wizard/ui/router.ts | 39 + .../commands/wizard/ui/screen-registry.tsx | 20 + .../wizard/ui/screens/exit-screen.tsx | 11 + .../commands/wizard/ui/screens/run-screen.tsx | 202 ++++ .../novu/src/commands/wizard/ui/services.ts | 14 + .../src/commands/wizard/ui/slash-commands.ts | 24 + packages/novu/src/commands/wizard/ui/store.ts | 457 ++++++++ packages/novu/src/commands/wizard/ui/theme.ts | 38 + .../wizard/ui/utils/format-duration.spec.ts | 56 + .../wizard/ui/utils/format-duration.ts | 56 + .../src/commands/wizard/ui/wizard-session.ts | 104 ++ .../novu/src/commands/wizard/ui/wizard-ui.ts | 72 ++ .../wizard/utils/package-managers.spec.ts | 99 ++ .../commands/wizard/utils/package-managers.ts | 208 ++++ packages/novu/src/index.ts | 94 ++ packages/novu/tsconfig.json | 13 +- packages/novu/tsconfig.ui.json | 21 + packages/shared/src/types/feature-flags.ts | 2 + pnpm-lock.yaml | 589 +++++++++- 163 files changed, 20100 insertions(+), 25 deletions(-) create mode 100644 .cursor/skills/ink-tui/SKILL.md create mode 100644 .cursor/skills/ink-tui/references/ARCHITECTURE.md create mode 100644 .cursor/skills/ink-tui/references/INK-API.md create mode 100644 .cursor/skills/ink-tui/references/INKJS-UI.md create mode 100644 .cursor/skills/ink-tui/references/PATTERNS.md create mode 100644 .cursor/skills/ink-tui/references/PRIMITIVES.md create mode 100644 .cursor/skills/ink-tui/references/TERMINAL-COMPAT.md create mode 100644 apps/dashboard/src/pages/cli-auth.tsx create mode 100644 packages/novu/scripts/build-ui.mjs create mode 100644 packages/novu/src/commands/connect/analytics/events.ts create mode 100644 packages/novu/src/commands/connect/api/agents.ts create mode 100644 packages/novu/src/commands/connect/api/client.ts create mode 100644 packages/novu/src/commands/connect/api/integrations.ts create mode 100644 packages/novu/src/commands/connect/api/subscribers.ts create mode 100644 packages/novu/src/commands/connect/index.ts create mode 100644 packages/novu/src/commands/connect/pipeline/channels/email.ts create mode 100644 packages/novu/src/commands/connect/pipeline/channels/slack.ts create mode 100644 packages/novu/src/commands/connect/pipeline/channels/telegram.ts create mode 100644 packages/novu/src/commands/connect/pipeline/integration-helpers.ts create mode 100644 packages/novu/src/commands/connect/pipeline/poll-until.ts create mode 100644 packages/novu/src/commands/connect/pipeline/runner.ts create mode 100644 packages/novu/src/commands/connect/resolve-options.ts create mode 100644 packages/novu/src/commands/connect/types.ts create mode 100644 packages/novu/src/commands/connect/ui/app.tsx create mode 100644 packages/novu/src/commands/connect/ui/index.tsx create mode 100644 packages/novu/src/commands/connect/ui/logging-ui.ts create mode 100644 packages/novu/src/commands/connect/ui/qr.ts create mode 100644 packages/novu/src/commands/connect/ui/store.ts create mode 100644 packages/novu/src/commands/connect/ui/ui.ts create mode 100644 packages/novu/src/commands/connect/ui/use-store.ts create mode 100644 packages/novu/src/commands/dev/resolve-region-urls.spec.ts create mode 100644 packages/novu/src/commands/dev/resolve-region-urls.ts create mode 100644 packages/novu/src/commands/wizard/README.md create mode 100644 packages/novu/src/commands/wizard/agent/build-subagent-prompt.ts create mode 100644 packages/novu/src/commands/wizard/agent/build-user-prompt.ts create mode 100644 packages/novu/src/commands/wizard/agent/can-use-tool.spec.ts create mode 100644 packages/novu/src/commands/wizard/agent/can-use-tool.ts create mode 100644 packages/novu/src/commands/wizard/agent/commandments.ts create mode 100644 packages/novu/src/commands/wizard/agent/install-agents.ts create mode 100644 packages/novu/src/commands/wizard/agent/iterator.ts create mode 100644 packages/novu/src/commands/wizard/agent/stop-hook.spec.ts create mode 100644 packages/novu/src/commands/wizard/agent/stop-hook.ts create mode 100644 packages/novu/src/commands/wizard/agent/system-prompt.ts create mode 100644 packages/novu/src/commands/wizard/agent/tool-labels.spec.ts create mode 100644 packages/novu/src/commands/wizard/agent/tool-labels.ts create mode 100644 packages/novu/src/commands/wizard/analytics/events.ts create mode 100644 packages/novu/src/commands/wizard/auth/device-auth.spec.ts create mode 100644 packages/novu/src/commands/wizard/auth/device-auth.ts create mode 100644 packages/novu/src/commands/wizard/auth/resolve-auth.ts create mode 100644 packages/novu/src/commands/wizard/context/classify-workspace.ts create mode 100644 packages/novu/src/commands/wizard/context/detect-install-targets.spec.ts create mode 100644 packages/novu/src/commands/wizard/context/detect-install-targets.ts create mode 100644 packages/novu/src/commands/wizard/context/detect-project.spec.ts create mode 100644 packages/novu/src/commands/wizard/context/detect-project.ts create mode 100644 packages/novu/src/commands/wizard/context/rebalance-goal.spec.ts create mode 100644 packages/novu/src/commands/wizard/context/rebalance-goal.ts create mode 100644 packages/novu/src/commands/wizard/context/summarise-topology.spec.ts create mode 100644 packages/novu/src/commands/wizard/context/summarise-topology.ts create mode 100644 packages/novu/src/commands/wizard/index.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/claude-code.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/cline.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/codex.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/cursor.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/index.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/types.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/utils.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/vscode.ts create mode 100644 packages/novu/src/commands/wizard/mcp/clients/windsurf.ts create mode 100644 packages/novu/src/commands/wizard/mcp/installer.ts create mode 100644 packages/novu/src/commands/wizard/mcp/server-config.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/runner.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/auth.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/build-outro.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/detect-project.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/install-mcp.spec.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/install-mcp.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/install-packages.spec.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/install-packages.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/install-skills.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/run-agent.spec.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/run-agent.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/validate.spec.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/validate.ts create mode 100644 packages/novu/src/commands/wizard/pipeline/steps/write-report.ts create mode 100644 packages/novu/src/commands/wizard/report/build-report.spec.ts create mode 100644 packages/novu/src/commands/wizard/report/build-report.ts create mode 100644 packages/novu/src/commands/wizard/skills/check-claude-settings.spec.ts create mode 100644 packages/novu/src/commands/wizard/skills/check-claude-settings.ts create mode 100644 packages/novu/src/commands/wizard/skills/content/env-setup/SKILL.md create mode 100644 packages/novu/src/commands/wizard/skills/install-skills.spec.ts create mode 100644 packages/novu/src/commands/wizard/skills/install-skills.ts create mode 100644 packages/novu/src/commands/wizard/types.ts create mode 100644 packages/novu/src/commands/wizard/ui/app.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/auth-pane.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/bootstrap-welcome.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/command-footer.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/errors-overlay.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/help-overlay.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/live-tail.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/mcp-pane.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/outro-pane.tsx create mode 100644 packages/novu/src/commands/wizard/ui/components/skills-pane.tsx create mode 100644 packages/novu/src/commands/wizard/ui/flows.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-bootstrap-countdown.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-elapsed.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-mouse-scroll.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-scroll-keys.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-slash-input.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-stdout-dimensions.ts create mode 100644 packages/novu/src/commands/wizard/ui/hooks/use-store.ts create mode 100644 packages/novu/src/commands/wizard/ui/index.tsx create mode 100644 packages/novu/src/commands/wizard/ui/ink-ui.ts create mode 100644 packages/novu/src/commands/wizard/ui/logging-ui.ts create mode 100644 packages/novu/src/commands/wizard/ui/markdown/code-block.tsx create mode 100644 packages/novu/src/commands/wizard/ui/markdown/diff.ts create mode 100644 packages/novu/src/commands/wizard/ui/markdown/render.tsx create mode 100644 packages/novu/src/commands/wizard/ui/markdown/table.tsx create mode 100644 packages/novu/src/commands/wizard/ui/overlays/chat-overlay.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/card-layout.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/dissolve-transition.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/divider.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/index.ts create mode 100644 packages/novu/src/commands/wizard/ui/primitives/loading-box.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/log-viewer.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/picker-menu.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/progress-list.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/screen-container.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/screen-error-boundary.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/split-view.tsx create mode 100644 packages/novu/src/commands/wizard/ui/primitives/wizard-header.tsx create mode 100644 packages/novu/src/commands/wizard/ui/print-outro.ts create mode 100644 packages/novu/src/commands/wizard/ui/router.ts create mode 100644 packages/novu/src/commands/wizard/ui/screen-registry.tsx create mode 100644 packages/novu/src/commands/wizard/ui/screens/exit-screen.tsx create mode 100644 packages/novu/src/commands/wizard/ui/screens/run-screen.tsx create mode 100644 packages/novu/src/commands/wizard/ui/services.ts create mode 100644 packages/novu/src/commands/wizard/ui/slash-commands.ts create mode 100644 packages/novu/src/commands/wizard/ui/store.ts create mode 100644 packages/novu/src/commands/wizard/ui/theme.ts create mode 100644 packages/novu/src/commands/wizard/ui/utils/format-duration.spec.ts create mode 100644 packages/novu/src/commands/wizard/ui/utils/format-duration.ts create mode 100644 packages/novu/src/commands/wizard/ui/wizard-session.ts create mode 100644 packages/novu/src/commands/wizard/ui/wizard-ui.ts create mode 100644 packages/novu/src/commands/wizard/utils/package-managers.spec.ts create mode 100644 packages/novu/src/commands/wizard/utils/package-managers.ts create mode 100644 packages/novu/tsconfig.ui.json diff --git a/.cursor/skills/ink-tui/SKILL.md b/.cursor/skills/ink-tui/SKILL.md new file mode 100644 index 00000000000..0a017f561b5 --- /dev/null +++ b/.cursor/skills/ink-tui/SKILL.md @@ -0,0 +1,156 @@ +--- +name: ink-tui-wizard +description: > + Build terminal user interfaces (TUIs) using Ink (React for CLIs) and @inkjs/ui + with a reactive, session-driven wizard pattern. Use when creating interactive CLI + installation wizards, setup flows, or multi-step terminal applications in + Node.js/TypeScript. Covers reactive screen resolution, declarative flow pipelines, + overlay interrupts, session state management, Ink components, Flexbox terminal layout, + and graceful degradation across terminal environments. +license: MIT +compatibility: Requires Node.js 18+. Designed for Claude Code or similar coding agents. +metadata: + author: posthog + version: "0.3" + domain: cli-tui +--- + +# Ink TUI Wizard Skill + +Build beautiful, interactive terminal wizard interfaces using Ink (React for CLIs). + +Ink is the dominant Node.js TUI framework — used by Claude Code (Anthropic), Gemini CLI +(Google), GitHub Copilot CLI, Cloudflare Wrangler, Shopify CLI, Prisma, and many others. + +## When to use this skill + +- Creating multi-step CLI installation or setup wizards +- Building reactive, session-driven terminal interfaces +- Adding real-time progress, spinners, or status displays to CLI tools +- Any Node.js/TypeScript CLI that needs more than sequential prompts + +## Core architecture + +This skill follows a **reactive session-driven** pattern: the rendered screen is a pure +function of session state. Business logic sets state through store setters. The router +derives which screen should be active. Nobody imperatively pushes screens around. + +See [references/ARCHITECTURE.md](references/ARCHITECTURE.md) for the full reactive +architecture: session, router, store, screen resolution, overlays, and data flow. + +### Key concepts + +- **WizardSession** (`src/lib/wizard-session.ts`) — single source of truth for all wizard decisions +- **WizardRouter** (`src/ui/tui/router.ts`) — declarative flow pipelines with `isComplete` predicates per screen +- **WizardStore** (`src/ui/tui/store.ts`) — nanostores-backed reactive store with explicit setters that trigger React re-renders via `useSyncExternalStore` +- **WizardUI** (`src/ui/wizard-ui.ts`) — interface bridging business logic to store; implemented by `InkUI` (TUI) and `LoggingUI` (CI) +- **Screen registry** (`src/ui/tui/screen-registry.tsx`) — factory function mapping screen names to components (App.tsx never changes) +- **Services** (`src/ui/tui/services/`) — injected into screens via props (no dynamic imports in React components) +- **Overlays** — interrupt stack for outage/error modals, orthogonal to flows + +### Adding a screen + +1. Create the component in `src/ui/tui/screens/` +2. Add to `Screen` enum in `router.ts` +3. Add a `FlowEntry` to the flow array with an `isComplete` predicate +4. Register in `screen-registry.tsx` + +No other files change. + +### Adding store state + +Two patterns depending on the data: + +- **Session state** (affects screen resolution): add field to `WizardSession`, add setter to `WizardStore` that calls `emitChange()`, add method to `WizardUI` interface + both implementations +- **Observation state** (display-only, e.g., agent progress): add private atom to `WizardStore`, add getter + setter, add method to `WizardUI` interface + both implementations + +Read `store.ts` for examples of both patterns. + +### Layout primitives + +The project has reusable layout primitives in `src/ui/tui/primitives/`. +**Always use these instead of building from scratch.** + +All primitives are barrel-exported from `src/ui/tui/primitives/index.ts`. +See [references/PRIMITIVES.md](references/PRIMITIVES.md) for the catalog. +Read each primitive's source file for its current props interface. + +Shared style constants (`Colors`, `Icons`, `HAlign`, `VAlign`) live in +`src/ui/tui/styles.ts`. + +**Playground**: Run `pnpm try --playground` to see all primitives in action. + +### Enums everywhere + +All state comparisons use TypeScript enums — no string literals. See the source files for current values: + +- `Screen`, `Overlay`, `Flow` — in `router.ts` +- `RunPhase`, `OutroKind` — in `wizard-session.ts` +- `TaskStatus` — in `wizard-ui.ts` + +### Key dependencies + +``` +ink # Core: React renderer for terminals (uses Yoga for Flexbox) +react # Peer dependency +@inkjs/ui # Official component library: Select, TextInput, Spinner, + # ProgressBar, ConfirmInput, MultiSelect, Badge, + # StatusMessage, Alert, OrderedList, UnorderedList +figures # Unicode/ASCII symbol fallbacks (cross-platform) +``` + +**Do NOT use** the older standalone packages (`ink-text-input`, `ink-select-input`, +`ink-spinner`). The `@inkjs/ui` package supersedes them. + +### Project structure + +``` +src/ui/tui/ +├── App.tsx # Thin shell — calls screen registry factory +├── store.ts # WizardStore: nanostores + session setters +├── router.ts # WizardRouter: flow pipelines + overlay stack +├── ink-ui.ts # InkUI: bridges getUI() calls to store setters +├── start-tui.ts # TUI startup: dark mode, store, renderer +├── screen-registry.tsx # Maps screen names to components + services +├── styles.ts # Colors, Icons, alignment enums +├── screens/ # One file per screen — read for current set +├── primitives/ # Reusable layout components — read index.ts for exports +├── services/ # Injectable service interfaces +└── components/ + └── TitleBar.tsx # Top bar with version + feedback email +``` + +## Ink rendering model + +Ink is `react-dom` but for terminals. It uses Yoga (Facebook's Flexbox engine) for layout. +Every `` is a flex container. All visible text MUST be inside ``. + +| Browser | Ink | +| ------------------- | ---------------------------------------------- | +| `
` | `` | +| `` | `` | +| CSS / className | Props directly on `` and `` | +| `onClick` | `useInput()` hook | +| `window.innerWidth` | `useStdout().stdout.columns` | +| scroll | `` + manual offset | +| `display: block` | `` | +| `display: flex` | Default — every `` is already flex | + +## Terminal compatibility + +- **Small terminals**: Check `useStdout().stdout.columns` and `.rows` +- **Piped input**: Detect `!process.stdin.isTTY` and fall back to LoggingUI +- **CI environments**: `--ci` flag uses LoggingUI (no TUI, no prompts) +- **Dark mode**: `start-tui.ts` forces black background via ANSI escape codes +- **True black text**: Use `color="#000000"` not `color="black"` (terminals render ANSI black as grey) +- **Ctrl+C**: Ink handles via `useApp().exit()` + +## Reference files + +- [references/ARCHITECTURE.md](references/ARCHITECTURE.md) — **Reactive architecture**: session, router, store, screen resolution, overlays, data flow. **Read this first when working on screen flow or state.** +- [references/PRIMITIVES.md](references/PRIMITIVES.md) — **TUI layout primitives**: catalog of all custom components with source file pointers. +- [references/INK-API.md](references/INK-API.md) — Complete Ink component and hook API reference +- [references/INKJS-UI.md](references/INKJS-UI.md) — @inkjs/ui component catalog with examples +- [references/TERMINAL-COMPAT.md](references/TERMINAL-COMPAT.md) — Terminal detection and graceful degradation +- [references/PATTERNS.md](references/PATTERNS.md) — Layout patterns and design recipes +- [scripts/scaffold.sh](scripts/scaffold.sh) — Bootstrap a new Ink wizard project diff --git a/.cursor/skills/ink-tui/references/ARCHITECTURE.md b/.cursor/skills/ink-tui/references/ARCHITECTURE.md new file mode 100644 index 00000000000..aa14ccaabbf --- /dev/null +++ b/.cursor/skills/ink-tui/references/ARCHITECTURE.md @@ -0,0 +1,151 @@ +# TUI Architecture: Reactive Flow, State, and Screen Management + +## Core principle + +The rendered screen is a pure function of session state. Nobody imperatively pushes screens around. Business logic sets state through store setters, the router derives which screen should be active. + +## Session + +**Source of truth:** `src/lib/wizard-session.ts` — read the `WizardSession` interface for current fields. + +`WizardSession` is the single source of truth for every decision the wizard needs: CLI args, detection results, OAuth credentials, lifecycle phase, and runtime display data. + +`buildSession(args)` creates a session from CLI args. Pre-TUI fields (`installDir`, `integration`, `frameworkConfig`) can be set directly. Reactive fields that affect screen resolution must go through store setters. + +Key enums (defined in `wizard-session.ts`): +- `RunPhase` — lifecycle phase (Idle → Running → Completed | Error) +- `OutroKind` — outro outcome (Success, Error, Cancel) + +## Router + +**Source of truth:** `src/ui/tui/router.ts` — read the flow arrays for current screen predicates. + +### Screen resolution + +The `WizardRouter` has a `resolve(session)` method that walks the flow pipeline and returns the first incomplete screen: + +```ts +resolve(session: WizardSession): ScreenName { + if (overlays.length > 0) return top overlay; + for (entry of flow) { + if (entry.show && !entry.show(session)) continue; // skip hidden + if (entry.isComplete && entry.isComplete(session)) continue; // skip complete + return entry.screen; // first incomplete = active + } + return last screen; // all complete +} +``` + +There is no cursor. No `advance()`. No `jumpTo()`. The screen is resolved fresh every render. + +### Flow definitions + +Flows are declarative arrays of `FlowEntry`: + +```ts +interface FlowEntry { + screen: Screen; + show?: (session: WizardSession) => boolean; // skip if false + isComplete?: (session: WizardSession) => boolean; // resolved if true +} +``` + +See `router.ts` for the current wizard flow pipeline and `isComplete` predicates per screen. + +### Enums + +See `router.ts` for current values: +- `Screen` — flow screen names +- `Overlay` — interrupt screen names +- `Flow` — named flow pipelines + +### Overlays + +Overlays are interrupts — they push on top of the flow and pop to resume: + +```ts +store.pushOverlay(Overlay.Outage); // outage screen appears +store.popOverlay(); // flow screen resumes +``` + +Overlays don't affect the flow. They're orthogonal. + +### Adding a screen + +1. Add to `Screen` enum in `router.ts` +2. Add a `FlowEntry` to the flow array with an `isComplete` predicate +3. Create the component in `screens/` +4. Register in `screen-registry.tsx` + +No other files change. + +## Store + +**Source of truth:** `src/ui/tui/store.ts` — read the class for current setters, atoms, and accessors. + +`WizardStore` uses nanostores atoms internally and exposes `subscribe()`/`getSnapshot()` for React's `useSyncExternalStore`. + +### Pattern: session setters + +Every session mutation that affects screen resolution goes through an explicit setter. Each setter mutates the field and calls `emitChange()`, which bumps a version counter and triggers React re-renders. On the next render, `store.currentScreen` calls `router.resolve(session)`. + +Read the "Session setters" section of `store.ts` for the current list. + +### Pattern: observation state + +Agent-produced data (not part of session flow) is stored in separate atoms on the store: + +- `$statusMessages` / `pushStatus()` — agent log lines +- `$tasks` / `syncTodos()`, `setTasks()` — agent task progress +- `$eventPlan` / `setEventPlan()` — planned analytics events from `.posthog-events.json` + +These follow the same pattern: private atom → public getter → public setter that calls `emitChange()`. + +## WizardUI interface + +**Source of truth:** `src/ui/wizard-ui.ts` — read the interface for current methods. + +The bridge between business logic and the store. Business logic calls `getUI()` methods, which translate to store setters in the TUI implementation (`InkUI` in `src/ui/tui/ink-ui.ts`). + +Two categories of methods: +- **Session-mutating** — trigger screen resolution (e.g., `startRun()`, `setCredentials()`, `outro()`) +- **Observation** — display-only updates (e.g., `pushStatus()`, `syncTodos()`, `setEventPlan()`) + +There are NO prompt methods. The TUI screens own all user input. + +Both `InkUI` (TUI) and `LoggingUI` (`src/ui/logging-ui.ts`, CI mode) implement this interface. + +## Screen registry + +**Source of truth:** `src/ui/tui/screen-registry.tsx` + +Maps screen names to React components. App.tsx calls the factory. Adding a screen to the registry requires no changes to App.tsx. + +## Services + +**Source of truth:** `src/ui/tui/services/` + +Screens receive services via props instead of importing business logic. Services are created in the registry and injected into screens. Testable, swappable, no dynamic imports in React components. + +## Error boundaries + +`ScreenContainer` wraps every screen in a `ScreenErrorBoundary`. On crash: +1. Sets `outroData` with error message +2. Sets `runPhase = Error` +3. Router resolves to Outro + +See `src/ui/tui/primitives/ScreenErrorBoundary.tsx`. + +## Dark mode + +`start-tui.ts` forces a black terminal background via ANSI escape codes on startup and resets on exit. + +## Data flow summary + +``` +Business logic → getUI().setX() + → InkUI → store.setX() + → Store → atom.set(value); emitChange() + → React re-render → store.currentScreen → router.resolve(session) + → Router → walks flow, returns first incomplete screen +``` diff --git a/.cursor/skills/ink-tui/references/INK-API.md b/.cursor/skills/ink-tui/references/INK-API.md new file mode 100644 index 00000000000..2d126fe662b --- /dev/null +++ b/.cursor/skills/ink-tui/references/INK-API.md @@ -0,0 +1,304 @@ +# Ink API Reference + +Complete reference for Ink's built-in components and hooks. +Source: https://github.com/vadimdemedes/ink (v5.x, 35k+ stars) + +## Components + +### `` + +Displays text with styling. All visible text MUST be inside ``. +Nested `` is allowed for inline styling. `` CANNOT be inside ``. + +```tsx +I am green +Hex color +RGB color +Bold +Italic +Underlined +Strikethrough +Inversed +Dimmed + +// Nested inline styling +Status: Ready +``` + +**Props:** +- `color: string` — Text color. Supports chalk color names, hex (#005cc5), rgb() +- `backgroundColor: string` — Background color. Same format as color +- `dimColor: boolean` — Make color less bright +- `bold: boolean` +- `italic: boolean` +- `underline: boolean` +- `strikethrough: boolean` +- `inverse: boolean` — Swap foreground/background +- `wrap: 'wrap' | 'truncate' | 'truncate-start' | 'truncate-middle' | 'truncate-end'` + Default: `'wrap'`. Controls text overflow behavior. + +```tsx +// Truncation examples +Hello World // "Hello\nWorld" +Hello World // "Hello…" +Hello World // "He…ld" +Hello World // "…World" +``` + +### `` + +Essential layout component. Like `
` in the browser. +Every `` is a Flexbox container by default. + +**Dimension props:** +- `width: number | string` — Width in spaces. Supports percentages: `width="50%"` +- `height: number | string` — Height in lines. Supports percentages +- `minWidth: number` +- `minHeight: number` + +**Padding props:** +- `padding: number` — All sides +- `paddingX: number` — Left and right +- `paddingY: number` — Top and bottom +- `paddingTop / paddingBottom / paddingLeft / paddingRight: number` + +**Margin props:** +- `margin: number` — All sides +- `marginX: number` — Left and right +- `marginY: number` — Top and bottom +- `marginTop / marginBottom / marginLeft / marginRight: number` + +**Gap props:** +- `gap: number` — Shorthand for columnGap + rowGap +- `columnGap: number` +- `rowGap: number` + +**Flex props:** +- `flexDirection: 'row' | 'row-reverse' | 'column' | 'column-reverse'` +- `flexGrow: number` (default: 0) +- `flexShrink: number` (default: 1) +- `flexBasis: number | string` +- `flexWrap: 'nowrap' | 'wrap' | 'wrap-reverse'` +- `alignItems: 'flex-start' | 'center' | 'flex-end'` +- `alignSelf: 'auto' | 'flex-start' | 'center' | 'flex-end'` +- `justifyContent: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'` + +**Border props:** +- `borderStyle: 'single' | 'double' | 'round' | 'bold' | 'singleDouble' | 'doubleSingle' | 'classic' | BoxStyle` +- `borderColor: string` — All sides. Same color format as Text +- `borderTopColor / borderRightColor / borderBottomColor / borderLeftColor: string` +- `borderDimColor: boolean` — Dim all border colors +- `borderTopDimColor / borderRightDimColor / borderBottomDimColor / borderLeftDimColor: boolean` +- `borderTop / borderRight / borderBottom / borderLeft: boolean` (default: true) — Show/hide individual sides + +Custom border style: +```tsx + + Custom borders + +``` + +**Background:** +- `backgroundColor: string` — Fills entire Box area. Inherited by child Text unless overridden. + +```tsx + + Background with border and padding + +``` + +**Visibility:** +- `display: 'flex' | 'none'` — Set to `none` to hide +- `overflow: 'visible' | 'hidden'` — Shorthand for overflowX + overflowY +- `overflowX / overflowY: 'visible' | 'hidden'` + +### `` + +Adds newline characters. Must be used within ``. + +- `count: number` (default: 1) + +### `` + +Flexible space that expands along the major axis. Like `flex-grow: 1`. + +```tsx + + Left + + Right + +``` + +### `` + +Permanently renders output above everything else. Items are rendered once and never +re-rendered. Ideal for completed tasks, logs, or any output that doesn't change. + +Used by Gatsby (page generation list), tap (test results), and similar tools. + +```tsx + + {(task) => ( + + ✔ {task.title} + + )} + + +{/* Live content below — keeps updating */} + + Completed: {completedTasks.length} + +``` + +**Props:** +- `items: Array` — Array of items to render +- `style: object` — Styles for the container (same as Box props) +- `children: (item: T, index: number) => ReactNode` — Render function. Must return element with `key`. + +**Important:** Only NEW items appended to the array are rendered. Changes to previously +rendered items are ignored. This is by design for performance. + +### `` + +Transforms string output of child components before rendering. Only applies to +`` children. Must not change output dimensions. + +```tsx + output.toUpperCase()}> + Hello World + +// Renders: "HELLO WORLD" + +// Hanging indent (different transform per line): + + index === 0 ? line : ' ' + line +}> + {longParagraph} + +``` + +## Hooks + +### `useInput(handler, options?)` + +Handle keyboard input. The handler receives each character or the full pasted string. + +```tsx +useInput((input, key) => { + if (input === 'q') exit(); + if (key.leftArrow) { /* ... */ } + if (key.return) { /* ... */ } + if (key.escape) { /* ... */ } + if (key.tab) { /* ... */ } + if (key.backspace) { /* ... */ } + if (key.delete) { /* ... */ } + if (key.pageUp) { /* ... */ } + if (key.pageDown) { /* ... */ } + if (key.upArrow) { /* ... */ } + if (key.downArrow) { /* ... */ } + if (key.ctrl) { /* Ctrl held */ } + if (key.shift) { /* Shift held */ } + if (key.meta) { /* Meta/Alt held */ } +}); +``` + +**Options:** +- `isActive: boolean` — Enable/disable the handler. Default: true. Useful for + disabling input in inactive tabs or when a modal is open. + +### `useApp()` + +Returns: `{ exit: (error?: Error) => void }` + +Call `exit()` to quit the app. Pass an Error to exit with non-zero code. + +### `useStdin()` + +Returns: `{ stdin: NodeJS.ReadStream, isRawModeSupported: boolean, setRawMode: (mode: boolean) => void }` + +### `useStdout()` + +Returns: `{ stdout: NodeJS.WriteStream, write: (data: string) => void }` + +Key properties on `stdout`: +- `stdout.columns` — Terminal width +- `stdout.rows` — Terminal height + +### `useStderr()` + +Returns: `{ stderr: NodeJS.WriteStream, write: (data: string) => void }` + +### `useFocus(options?)` + +Makes a component focusable. Navigate between focusable components with Tab/Shift+Tab. + +```tsx +const { isFocused } = useFocus(); +// or +const { isFocused } = useFocus({ autoFocus: true }); // focus on mount +const { isFocused } = useFocus({ isActive: false }); // temporarily disable +const { isFocused } = useFocus({ id: 'my-input' }); // named focus target +``` + +### `useFocusManager()` + +Programmatically control focus. + +```tsx +const { focusNext, focusPrevious, focus, enableFocus, disableFocus } = useFocusManager(); + +focusNext(); // Move focus to next focusable element +focusPrevious(); // Move focus to previous +focus('my-input'); // Focus specific element by id +disableFocus(); // Disable all focus management +enableFocus(); // Re-enable focus management +``` + +### `useCursor()` + +Set cursor position relative to Ink output. Use `string-width` for accurate positioning +with CJK/emoji characters. + +```tsx +const { setCursorPosition } = useCursor(); +setCursorPosition({ x: 5, y: 1 }); // position cursor +setCursorPosition(undefined); // hide cursor +``` + +## `render()` API + +```tsx +import { render } from 'ink'; + +const { unmount, waitUntilExit, rerender, clear } = render(); + +await waitUntilExit(); // Promise that resolves when app exits +unmount(); // Manually unmount +rerender(); // Re-render with new props +clear(); // Clear output +``` + +## Testing + +Ink provides `ink-testing-library` for unit testing: + +```tsx +import { render } from 'ink-testing-library'; + +const { lastFrame, stdin, frames } = render(); + +// Check rendered output +expect(lastFrame()).toContain('Hello'); + +// Simulate input +stdin.write('q'); + +// Check all frames rendered +expect(frames).toHaveLength(2); +``` diff --git a/.cursor/skills/ink-tui/references/INKJS-UI.md b/.cursor/skills/ink-tui/references/INKJS-UI.md new file mode 100644 index 00000000000..14dd59a3b3e --- /dev/null +++ b/.cursor/skills/ink-tui/references/INKJS-UI.md @@ -0,0 +1,318 @@ +# @inkjs/ui Component Reference + +Official component library for Ink. Provides themeable, production-ready UI widgets. +Source: https://github.com/vadimdemedes/ink-ui + +Install: `npm install @inkjs/ui` + +All components import from `@inkjs/ui`. Do NOT use the older standalone packages +(ink-text-input, ink-select-input, ink-spinner) — this package supersedes them. + +## Input Components + +### TextInput + +Single-line text input. + +```tsx +import { TextInput } from '@inkjs/ui'; + + { /* value is the entered string */ }} +/> +``` + +### EmailInput + +Text input validated for email format. + +```tsx +import { EmailInput } from '@inkjs/ui'; + + { /* validated email string */ }} +/> +``` + +### PasswordInput + +Masked text input for sensitive values. + +```tsx +import { PasswordInput } from '@inkjs/ui'; + + { /* password string */ }} +/> +``` + +### ConfirmInput + +Yes/No confirmation prompt. + +```tsx +import { ConfirmInput } from '@inkjs/ui'; + + { /* user confirmed */ }} + onCancel={() => { /* user cancelled */ }} +/> +``` + +### Select + +Scrollable single-select list. User picks one option. + +```tsx +import { Select } from '@inkjs/ui'; + + + + +// vs. Full-screen: takes over the terminal + + {/* ... */} + +``` + +## Tab navigation pattern + +### Tab bar component + +```tsx +import React from 'react'; +import { Box, Text } from 'ink'; +import figures from 'figures'; + +type Tab = { label: string; status: 'pending' | 'active' | 'complete' }; + +const TabBar = ({ tabs, activeIndex }: { tabs: Tab[], activeIndex: number }) => ( + + {tabs.map((tab, i) => { + const icon = tab.status === 'complete' + ? figures.tick + : tab.status === 'active' + ? figures.pointer + : figures.bullet; + + const color = tab.status === 'complete' + ? 'green' + : i === activeIndex + ? 'cyan' + : 'gray'; + + return ( + + {icon} {tab.label} + + ); + })} + +); +``` + +### Tab switching with useInput + +```tsx +const [activeTab, setActiveTab] = useState(0); + +useInput((input, key) => { + if (key.leftArrow) setActiveTab(i => Math.max(0, i - 1)); + if (key.rightArrow) setActiveTab(i => Math.min(TABS.length - 1, i + 1)); + + // Number keys for direct tab access + const num = parseInt(input, 10); + if (num >= 1 && num <= TABS.length) setActiveTab(num - 1); +}, { isActive: !isInputFocused }); // disable when typing in an input +``` + +**Important:** Use `isActive: false` on the tab-switching `useInput` when the user +is focused on a text input or other component that needs arrow keys. Otherwise +arrow keys will switch tabs instead of navigating within the component. + +### Conditional rendering for tab content + +```tsx +// Simple: mount/unmount (loses state when switching away) +{activeTab === 0 && } + +// Preserve state: render all but hide inactive +{TABS.map((_, i) => ( + + + +))} +``` + +## State management patterns + +### Centralized wizard state hook + +```tsx +interface WizardState { + framework: string | null; + language: 'typescript' | 'javascript' | null; + apiKey: string | null; + features: string[]; + installStatus: 'idle' | 'running' | 'success' | 'error'; + error: string | null; +} + +const initialState: WizardState = { + framework: null, + language: null, + apiKey: null, + features: [], + installStatus: 'idle', + error: null, +}; + +export function useWizardState() { + const [state, setState] = useState(initialState); + + const update = (patch: Partial) => + setState(prev => ({ ...prev, ...patch })); + + const isStepComplete = (step: number): boolean => { + switch (step) { + case 0: return state.framework !== null; + case 1: return state.apiKey !== null; + case 2: return state.installStatus === 'success'; + case 3: return false; // verification is terminal + default: return false; + } + }; + + return { state, update, isStepComplete }; +} +``` + +### Tab-to-tab data flow + +Pass wizard state down to tabs, and `onComplete` callbacks up: + +```tsx +const App = () => { + const { state, update, isStepComplete } = useWizardState(); + const [activeTab, setActiveTab] = useState(0); + + const advanceTab = () => + setActiveTab(i => Math.min(TABS.length - 1, i + 1)); + + return ( + + {activeTab === 0 && ( + { update({ framework: fw }); advanceTab(); }} + /> + )} + {activeTab === 1 && ( + { update(config); advanceTab(); }} + /> + )} + {activeTab === 2 && ( + advanceTab()} /> + )} + + ); +}; +``` + +## Progress and completion patterns + +### Spinner → result replacement + +Show a spinner while working, then replace in-place with the result: + +```tsx +const Step = ({ label, status }: { label: string; status: 'pending' | 'running' | 'done' | 'error' }) => ( + + {status === 'running' && } + {status === 'done' && {figures.tick}} + {status === 'error' && {figures.cross}} + {status === 'pending' && {figures.bullet}} + {label} + +); +``` + +### Multi-step progress list + +```tsx +const steps = [ + { id: 'deps', label: 'Installing dependencies', status: 'done' }, + { id: 'config', label: 'Writing configuration', status: 'running' }, + { id: 'snippet', label: 'Adding code snippet', status: 'pending' }, + { id: 'verify', label: 'Verifying setup', status: 'pending' }, +]; + + + {steps.map(step => )} + +``` + +## Debug logging + +Never write debug output to stdout — it will corrupt the Ink display. +Write to a file or stderr instead: + +```tsx +import { writeFileSync, appendFileSync } from 'node:fs'; + +const debug = (msg: string) => { + if (process.env.DEBUG) { + appendFileSync('/tmp/wizard-debug.log', `${new Date().toISOString()} ${msg}\n`); + } +}; +``` + +Or use `useStderr()`: +```tsx +const { write } = useStderr(); +write('Debug: something happened\n'); +``` diff --git a/.cursor/skills/ink-tui/references/PRIMITIVES.md b/.cursor/skills/ink-tui/references/PRIMITIVES.md new file mode 100644 index 00000000000..eaa43e6e722 --- /dev/null +++ b/.cursor/skills/ink-tui/references/PRIMITIVES.md @@ -0,0 +1,129 @@ +# TUI Layout Primitives + +Custom layout primitives for the PostHog Setup Wizard TUI. These replace raw Ink/`@inkjs/ui` usage with opinionated, styled components that enforce visual consistency. + +**Import**: All primitives are barrel-exported from `src/ui/tui/primitives/index.ts`. + +```ts +import { ScreenContainer, TabContainer, PickerMenu, ... } from '../primitives/index.js'; +``` + +**Styles**: Shared constants live in `src/ui/tui/styles.ts` — read that file for current `Colors`, `Icons`, `HAlign`, `VAlign` values. + +--- + +## Primitive catalog + +Each primitive's props interface is defined in its source file. Read the file for the current API. + +### ScreenContainer +`src/ui/tui/primitives/ScreenContainer.tsx` + +Top-level app shell. Renders TitleBar, routes between screens via `store.currentScreen` (router-driven), and plays a horizontal wipe transition on screen changes. Wraps each screen in `ScreenErrorBoundary`. + +### TabContainer +`src/ui/tui/primitives/TabContainer.tsx` + +Self-contained tabbed interface with status bar. Manages its own active tab state. Arrow keys switch tabs. + +Layout (top to bottom): +1. Active tab content (`flexGrow`) +2. Status bar (single-line, top border, muted text) +3. Spacer +4. Tab bar (active = inverse accent, inactive = muted) + +Tabs array can be built conditionally — see `RunScreen.tsx` for an example of a tab that only appears when data is available. + +### PickerMenu +`src/ui/tui/primitives/PickerMenu.tsx` + +Single and multi select. Fully custom renderers — does NOT use `@inkjs/ui` Select/MultiSelect. + +- **Single select**: `▸` triangle cursor on focused item, enter selects +- **Multi select** (`mode="multi"`): `◻`/`◼` toggles with space, enter submits + +### ConfirmationInput +`src/ui/tui/primitives/ConfirmationInput.tsx` + +Continue/cancel prompt with two bordered button boxes. Left/right arrows switch focus, enter activates focused, escape always cancels. + +### DissolveTransition +`src/ui/tui/primitives/DissolveTransition.tsx` + +Horizontal wipe with split-flap/digital rain texture. Used internally by ScreenContainer. When `transitionKey` changes, a band of shade characters sweeps across covering old content, then reveals new content. + +### ProgressList +`src/ui/tui/primitives/ProgressList.tsx` + +Task checklist with status icons and progress counter. Shows a `LoadingBox` placeholder when items array is empty. + +- `◼` green = completed, `▶` cyan = in-progress, `◻` gray = pending +- Shows `activeForm` text when in-progress (replaces label) + +### EventPlanViewer +`src/ui/tui/primitives/EventPlanViewer.tsx` + +Pure render component for planned analytics events. Takes an `events` array prop and renders each event name (bold) with description (dim). Used in RunScreen's conditional "Event plan" tab. + +### SplitView +`src/ui/tui/primitives/SplitView.tsx` + +Two-pane horizontal layout (50/50 split). + +### CardLayout +`src/ui/tui/primitives/CardLayout.tsx` + +Aligns a single child within available space using flexbox alignment (`HAlign`, `VAlign`). + +### LogViewer +`src/ui/tui/primitives/LogViewer.tsx` + +Real-time log file tail. Watches a file with `fs.watch` and displays the latest lines that fit in the available terminal height. + +### LoadingBox +`src/ui/tui/primitives/LoadingBox.tsx` + +Spinner with message. Uses `@inkjs/ui` Spinner. + +### Divider +`src/ui/tui/primitives/Divider.tsx` + +Responsive horizontal rule. Uses `measureElement` to measure its parent's width, then fills with a repeating character (`─` by default). Props: `dimColor` (default `true`), `char` (default `'─'`). + +--- + +## Responsive layout with measureElement + +Ink provides `measureElement(ref)` to get the pixel-equivalent `{ width, height }` of a rendered element. Use it with a `useRef` + `useEffect` to build components that adapt to their container size: + +```tsx +import { Box, Text, measureElement } from 'ink'; +import { useRef, useState, useEffect } from 'react'; + +const ref = useRef(null); +const [width, setWidth] = useState(0); + +useEffect(() => { + if (ref.current) { + const { width: measured } = measureElement(ref.current); + setWidth(measured); + } +}, []); + + + {'─'.repeat(width)} + +``` + +See `Divider.tsx` for a working example. For terminal resize reactivity, combine with the `useStdoutDimensions` hook from `src/ui/tui/hooks/useStdoutDimensions.ts`. + +--- + +## Design conventions + +- **Borders**: Always `borderStyle="single"` (not `"round"`) for cross-terminal compatibility +- **Accent color**: `Colors.accent` for highlights, active states, prompt headers +- **Dim for inactive**: Use `dimColor` on unfocused/inactive items +- **Muted for secondary**: `Colors.muted` for status text, inactive tabs, borders +- **No bare strings**: All text must be in `` (Ink requirement) +- **Hex color caution**: Hex colors (like `Colors.accent`) can bleed in some terminals. If a component's text unexpectedly inherits color, set an explicit `color` on its `` elements. diff --git a/.cursor/skills/ink-tui/references/TERMINAL-COMPAT.md b/.cursor/skills/ink-tui/references/TERMINAL-COMPAT.md new file mode 100644 index 00000000000..eacd1f371d0 --- /dev/null +++ b/.cursor/skills/ink-tui/references/TERMINAL-COMPAT.md @@ -0,0 +1,158 @@ +# Terminal Compatibility Reference + +How to detect terminal capabilities and degrade gracefully across environments. + +## Terminal detection + +### TTY detection + +```tsx +const isTTY = process.stdin.isTTY && process.stdout.isTTY; +const isCI = Boolean(process.env.CI); +const isPiped = !process.stdin.isTTY; +``` + +If not a TTY (piped input, CI, etc.), skip the full Ink TUI and fall back to +non-interactive mode with defaults or flag-based configuration. + +### Terminal dimensions + +```tsx +import { useStdout } from 'ink'; + +const { stdout } = useStdout(); +const columns = stdout.columns; // width in characters +const rows = stdout.rows; // height in lines +``` + +Listen for resize: +```tsx +useEffect(() => { + const onResize = () => { /* re-read stdout.columns/rows */ }; + stdout.on('resize', onResize); + return () => stdout.off('resize', onResize); +}, []); +``` + +### Color support + +Ink uses chalk internally. Colors outside the terminal's gamut are automatically +coerced to the closest available value. + +Respect user preferences: +```tsx +// NO_COLOR standard: https://no-color.org/ +const noColor = 'NO_COLOR' in process.env; + +// FORCE_COLOR forces color even in non-TTY contexts +const forceColor = 'FORCE_COLOR' in process.env; +``` + +## Unicode and symbol fallbacks + +Use the `figures` package for cross-platform symbols: + +```bash +npm install figures +``` + +```tsx +import figures from 'figures'; + +// figures.tick → ✓ (or √ on Windows CMD) +// figures.cross → ✗ (or × on Windows CMD) +// figures.bullet → ● (or * on Windows CMD) +// figures.pointer → ❯ (or > on Windows CMD) +// figures.arrowRight → → (or > on Windows CMD) +// figures.line → ─ (or - on Windows CMD) +``` + +For custom detection: +```tsx +const supportsUnicode = process.env.TERM !== 'dumb' + && !process.env.CI + && process.platform !== 'win32'; +``` + +## Responsive layouts + +Adapt to terminal width: + +```tsx +const { stdout } = useStdout(); +const isNarrow = stdout.columns < 60; +const isShort = stdout.rows < 20; + +return ( + + {/* Collapse tab labels on narrow terminals */} + + {TABS.map((tab, i) => ( + + {isNarrow ? tab[0] : tab} + + ))} + + + {/* Skip borders on very narrow terminals */} + + {children} + + +); +``` + +## Cross-terminal testing checklist + +Test on all of these before shipping: + +- [ ] macOS Terminal.app +- [ ] iTerm2 +- [ ] VS Code integrated terminal +- [ ] Hyper +- [ ] Windows Terminal +- [ ] PowerShell +- [ ] CMD (Command Prompt) +- [ ] Linux (GNOME Terminal, Konsole, Alacritty, kitty) +- [ ] SSH sessions +- [ ] tmux / screen + +Common issues: +- **Overflow rendering**: Content wider than terminal renders on top of itself. + Always constrain widths or use `wrap="truncate"` on Text. +- **Color schemes**: Light vs dark terminals. Use semantic colors (green=success, + red=error) rather than absolute colors that may clash with backgrounds. +- **Character sets**: Box-drawing characters (─│┐└ etc.) may not render in all + terminals. The `figures` package handles common fallbacks, but custom border + characters may need manual ASCII alternatives. + +## Non-interactive fallback + +When the terminal doesn't support interactive mode: + +```tsx +// cli.tsx +import { render } from 'ink'; + +if (!process.stdin.isTTY || process.env.CI) { + // Non-interactive mode: use flags or defaults + await runNonInteractive(parsedArgs); +} else { + // Full TUI mode + const { waitUntilExit } = render(); + await waitUntilExit(); +} +``` + +Or use @inquirer/prompts as a simpler fallback: +```tsx +import { select, confirm } from '@inquirer/prompts'; + +const framework = await select({ + message: 'Select your framework', + choices: [ + { name: 'Next.js', value: 'nextjs' }, + { name: 'React', value: 'react' }, + ], +}); +``` diff --git a/.source b/.source index 01f6c40523d..2229fe9a976 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 01f6c40523d14c042eb38b0a66d9a889e91e23b3 +Subproject commit 2229fe9a976354d58c4cf11cde37e870ab5e6fd9 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7c09e0ad7c4..daef50fd4ca 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -84,6 +84,12 @@ const enterpriseImports = (): Array, }, + { + path: ROUTES.CLI_AUTH, + element: , + }, { // Public, unauthenticated mobile setup page for Telegram. Mounted outside // AuthRoute so unauthenticated visitors are not redirected to sign-in. diff --git a/apps/dashboard/src/pages/cli-auth.tsx b/apps/dashboard/src/pages/cli-auth.tsx new file mode 100644 index 00000000000..bcdb49a2b34 --- /dev/null +++ b/apps/dashboard/src/pages/cli-auth.tsx @@ -0,0 +1,273 @@ +import { useAuth as useClerkAuth } from '@clerk/react'; +import { FeatureFlagsKeysEnum, PermissionsEnum } from '@novu/shared'; +import { AnimatePresence, motion } from 'motion/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { RiCheckLine, RiCommandLine, RiLockLine } from 'react-icons/ri'; +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; +import { AuthLayout } from '@/components/auth-layout'; +import { PageMeta } from '@/components/page-meta'; +import { Button } from '@/components/primitives/button'; +import { Card, CardContent, CardHeader } from '@/components/primitives/card'; +import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { useAuth } from '@/context/auth/hooks'; +import { EnvironmentProvider } from '@/context/environment/environment-provider'; +import { useEnvironment } from '@/context/environment/hooks'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { useFetchApiKeys } from '@/hooks/use-fetch-api-keys'; +import { useHasPermission } from '@/hooks/use-has-permission'; +import { buildRoute, ROUTES } from '@/utils/routes'; + +const CALLBACK_HOST_ALLOWLIST = new Set(['127.0.0.1', 'localhost']); + +function isLoopbackCallback(callbackUrl: string | null): callbackUrl is string { + if (!callbackUrl) return false; + try { + const url = new URL(callbackUrl); + if (url.protocol !== 'http:') return false; + + return CALLBACK_HOST_ALLOWLIST.has(url.hostname); + } catch { + return false; + } +} + +export const CliAuthPage = () => { + const { isLoaded, isSignedIn } = useClerkAuth(); + + if (!isLoaded) { + return null; + } + + if (!isSignedIn) { + const search = window.location.search; + const redirectUrl = `${ROUTES.CLI_AUTH}${search}`; + const signInUrl = `${ROUTES.SIGN_IN}?redirect_url=${encodeURIComponent(redirectUrl)}`; + + return ; + } + + return ( + + + + + + + ); +}; + +function CliAuthContent() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { currentUser } = useAuth(); + const { currentEnvironment, environments, switchEnvironment } = useEnvironment(); + const apiKeysQuery = useFetchApiKeys(); + const has = useHasPermission(); + const isLlmGatewayEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_LLM_GATEWAY_ENABLED); + const [isAuthorizing, setIsAuthorizing] = useState(false); + const [didAuthorize, setDidAuthorize] = useState(false); + + const callbackUrl = searchParams.get('cli_callback'); + const cliState = searchParams.get('state'); + const callerName = searchParams.get('name'); + const callbackOk = isLoopbackCallback(callbackUrl); + const canReadApiKeys = has({ permission: PermissionsEnum.API_KEY_READ }); + + // Two callers today: `novu-wizard` (default) and `novu-connect` (agent + // provisioning). Each gets its own subtitle + scope copy so the dashboard + // explains what the user is actually authorizing. + const isConnect = callerName === 'novu-connect'; + const callerDisplayName = isConnect ? 'Novu Connect' : 'Novu Wizard'; + const callerSubtitle = isConnect + ? 'to provision your AI agent and connect it to the channels you pick.' + : 'in order to integrate Novu into your project.'; + + const apiKey = apiKeysQuery.data?.data?.[0]?.key; + + const developmentEnvironment = useMemo(() => environments?.find((env) => env.name === 'Development'), [environments]); + + useEffect(() => { + if (developmentEnvironment && currentEnvironment?._id !== developmentEnvironment._id) { + switchEnvironment(developmentEnvironment.slug ?? developmentEnvironment._id); + } + }, [developmentEnvironment, currentEnvironment?._id, switchEnvironment]); + + const handleAuthorize = useCallback(async () => { + if (!callbackOk || !apiKey || !currentEnvironment) { + return; + } + + setIsAuthorizing(true); + try { + const response = await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + state: cliState, + apiKey, + environmentId: currentEnvironment._id, + environmentSlug: currentEnvironment.slug ?? null, + environmentName: currentEnvironment.name, + organizationId: currentEnvironment._organizationId, + user: currentUser + ? { + id: currentUser._id, + email: currentUser.email ?? null, + firstName: currentUser.firstName ?? null, + lastName: currentUser.lastName ?? null, + } + : null, + }), + }); + + if (!response.ok) { + throw new Error(`Callback responded with ${response.status}`); + } + + setDidAuthorize(true); + showSuccessToast('Novu CLI authorized. You can return to your terminal.'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to reach the CLI callback'; + showErrorToast(`Authorization failed: ${message}`); + } finally { + setIsAuthorizing(false); + } + }, [callbackOk, callbackUrl, apiKey, currentEnvironment, cliState, currentUser]); + + function handleCancel() { + navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: currentEnvironment?.slug ?? 'default' })); + } + + const isLoading = apiKeysQuery.isLoading || !currentEnvironment; + + const reason = (() => { + if (!callbackOk) return 'This page must be opened from the Novu CLI.'; + if (!isConnect && !isLlmGatewayEnabled) { + return `${callerDisplayName} is not enabled for your account yet.`; + } + if (!canReadApiKeys) return 'You need the api_key:read permission to authorize the CLI.'; + if (isLoading) return null; + if (!apiKey) return 'No API key is available in this environment.'; + + return null; + })(); + + const canAuthorize = !reason && !isLoading && !!apiKey && !isAuthorizing && !didAuthorize; + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key !== 'Enter') return; + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + + const target = event.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { + return; + } + + if (!canAuthorize) return; + + event.preventDefault(); + handleAuthorize(); + } + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [canAuthorize, handleAuthorize]); + + return ( +
+ + +
+ +

Authorize Novu CLI

+
+

+ {callerDisplayName} is requesting access to your{' '} + {currentEnvironment?.name ?? '...'} environment {callerSubtitle} +

+
+ + + {reason ? ( +
+ + {reason} +
+ ) : null} + + + {didAuthorize ? ( + +
+ + + + You can close this tab and return to your terminal. +
+
+ ) : ( + + + + + )} +
+
+
+
+ ); +} + +function ScopeList({ isConnect }: { isConnect: boolean }) { + const scopes = isConnect + ? [ + 'Read your Novu API key for the selected environment', + 'Create and manage agents on your behalf', + 'Connect channels (Slack, Telegram, and more) to your agent', + ] + : [ + 'Read your Novu API key for the selected environment', + 'Trigger workflows on your behalf during the integration', + 'Create or update workflows via Novu MCP', + ]; + + return ( +
    + {scopes.map((scope) => ( +
  • + + {scope} +
  • + ))} +
+ ); +} diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 6a9ba7709c0..1124b7aad29 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -16,6 +16,7 @@ export const ROUTES = { INBOX_EMBED_SUCCESS: '/onboarding/inbox/success', ROOT: '/', LOCAL_STUDIO_AUTH: '/local-studio/auth', + CLI_AUTH: '/cli/auth', ENV: '/env', SETTINGS: '/settings', SETTINGS_ACCOUNT: '/settings/account', diff --git a/packages/novu/package.json b/packages/novu/package.json index 85e7b4f3b81..e89856ff782 100644 --- a/packages/novu/package.json +++ b/packages/novu/package.json @@ -18,7 +18,7 @@ ], "scripts": { "prebuild": "rimraf dist", - "build": "pnpm prebuild && tsc -p tsconfig.json && cp -r src/commands/init/templates/app* dist/src/commands/init/templates && cp -r src/commands/init/templates/github dist/src/commands/init/templates", + "build": "pnpm prebuild && tsc -p tsconfig.json && tsc -p tsconfig.ui.json && node scripts/build-ui.mjs && cp -r src/commands/init/templates/app* dist/src/commands/init/templates && cp -r src/commands/init/templates/github dist/src/commands/init/templates && cp -r src/commands/wizard/skills/content dist/src/commands/wizard/skills", "build:prod": "pnpm prebuild && pnpm build", "precommit": "lint-staged", "start": "pnpm start:dev", @@ -52,10 +52,13 @@ "devDependencies": { "@types/configstore": "^5.0.1", "@types/cross-spawn": "6.0.0", + "@types/diff": "7.0.2", "@types/gradient-string": "^1.1.6", "@types/inquirer": "^8.2.0", "@types/mocha": "10.0.2", "@types/prompts": "2.4.2", + "@types/qrcode": "^1.5.5", + "@types/react": "^19.0.0", "@types/uuid": "^8.3.4", "@types/validate-npm-package-name": "3.0.0", "@types/ws": "^8.5.3", @@ -66,7 +69,9 @@ "vitest": "^1.2.1" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.114", "@babel/parser": "^7.29.0", + "@inkjs/ui": "2.0.0", "@novu/framework": "workspace:*", "@novu/ntfr-client": "^0.0.5", "@novu/shared": "workspace:*", @@ -74,21 +79,33 @@ "async-sema": "3.0.1", "axios": "^1.16.1", "chalk": "4.1.2", + "cli-highlight": "2.1.11", + "cli-table3": "0.6.5", + "clipboardy": "4.0.0", "commander": "^9.0.0", "configstore": "^5.0.0", "cross-spawn": "7.0.5", + "diff": "9.0.0", "dotenv": "^16.6.1", "esbuild": "^0.25.0", "fast-glob": "3.3.1", + "figures": "6.1.0", "form-data": "^4.0.5", "get-port": "^5.1.1", "gradient-string": "^2.0.0", + "ink": "^7.0.1", + "ink-scroll-view": "^0.3.6", "inquirer": "^8.2.0", "jwt-decode": "^3.1.2", + "marked": "12.0.2", + "nanostores": "1.2.0", "open": "^8.4.0", "ora": "^5.4.1", "picocolors": "^1.0.0", "prompts": "2.4.2", + "qrcode": "^1.5.4", + "react": "^19.2.0", + "string-width": "8.2.0", "uuid": "^11.1.1", "validate-npm-package-name": "3.0.0", "ws": "^8.17.1", diff --git a/packages/novu/scripts/build-ui.mjs b/packages/novu/scripts/build-ui.mjs new file mode 100644 index 00000000000..dcce198c0df --- /dev/null +++ b/packages/novu/scripts/build-ui.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +const sharedConfig = { + bundle: true, + platform: 'node', + format: 'esm', + target: ['node18'], + jsx: 'automatic', + jsxImportSource: 'react', + sourcemap: false, + logLevel: 'info', + external: [ + 'react', + 'react/jsx-runtime', + 'ink', + 'ink-scroll-view', + '@inkjs/ui', + 'ink-spinner', + 'chalk', + 'marked', + 'cli-highlight', + 'cli-table3', + 'diff', + 'clipboardy', + 'string-width', + '@anthropic-ai/claude-agent-sdk', + 'open', + 'nanostores', + ], + banner: { + js: [ + "import { createRequire as __novuCreateRequire } from 'node:module';", + "import { fileURLToPath as __novuFileURLToPath } from 'node:url';", + "import { dirname as __novuDirname } from 'node:path';", + 'const require = __novuCreateRequire(import.meta.url);', + 'const __filename = __novuFileURLToPath(import.meta.url);', + 'const __dirname = __novuDirname(__filename);', + ].join(' '), + }, +}; + +await build({ + ...sharedConfig, + entryPoints: [resolve(root, 'src/commands/wizard/ui/index.tsx')], + outfile: resolve(root, 'dist/src/commands/wizard/ui/index.mjs'), +}); + +await build({ + ...sharedConfig, + entryPoints: [resolve(root, 'src/commands/connect/ui/index.tsx')], + outfile: resolve(root, 'dist/src/commands/connect/ui/index.mjs'), +}); diff --git a/packages/novu/src/commands/connect/analytics/events.ts b/packages/novu/src/commands/connect/analytics/events.ts new file mode 100644 index 00000000000..470614abf1c --- /dev/null +++ b/packages/novu/src/commands/connect/analytics/events.ts @@ -0,0 +1,33 @@ +import { AnalyticService } from '../../../services/analytics.service'; + +export const CONNECT_EVENTS = { + STARTED: 'Connect Started', + AUTH_COMPLETED: 'Connect Auth Completed', + AGENT_LISTED: 'Connect Agents Listed', + AGENT_CREATED: 'Connect Agent Created', + AGENT_REUSED: 'Connect Agent Reused', + SLACK_OAUTH_OPENED: 'Connect Slack Oauth Opened', + SLACK_CONNECTED: 'Connect Slack Connected', + TELEGRAM_CONNECTED: 'Connect Telegram Connected', + EMAIL_CONNECTED: 'Connect Email Connected', + WELCOME_SENT: 'Connect Welcome Sent', + COMPLETED: 'Connect Completed', + ERROR: 'Connect Error', +} as const; + +export type ConnectEvent = (typeof CONNECT_EVENTS)[keyof typeof CONNECT_EVENTS]; + +export function trackConnect( + analytics: AnalyticService, + anonymousId: string | undefined, + event: ConnectEvent | string, + data: Record = {} +): void { + if (!anonymousId) return; + + analytics.track({ + identity: { anonymousId }, + event, + data, + }); +} diff --git a/packages/novu/src/commands/connect/api/agents.ts b/packages/novu/src/commands/connect/api/agents.ts new file mode 100644 index 00000000000..86154f49bf6 --- /dev/null +++ b/packages/novu/src/commands/connect/api/agents.ts @@ -0,0 +1,253 @@ +import type { ConnectApiClient } from './client'; + +export interface AgentRecord { + _id: string; + identifier: string; + name: string; + description?: string; + active?: boolean; + runtime?: 'self-hosted' | 'managed'; +} + +export interface GeneratedAgentSpec { + name: string; + identifier: string; + systemPrompt: string; + /** Catalog IDs of Claude built-in tool types — already in the wire format expected by `POST /agents`. */ + tools: string[]; + /** MCP server catalog IDs — already in the wire format expected by `POST /agents`. */ + mcpServers: string[]; + /** Skills with only `skillId`; the `type` is implicitly 'anthropic' for generator output. */ + skills: Array<{ skillId: string }>; +} + +export interface CreateManagedAgentInput { + name: string; + identifier: string; + integrationId: string; + providerId: string; + systemPrompt: string; + tools: string[]; + mcpServers: string[]; + skills: Array<{ skillId: string }>; +} + +export interface AgentIntegrationLink { + _id: string; + integrationId: string; + integrationIdentifier: string; + providerId: string; + channel?: string; + active?: boolean; + connectedAt?: string | null; +} + +export async function listAgents(client: ConnectApiClient): Promise { + const res = await client.axios.get<{ data?: AgentRecord[] } | AgentRecord[]>('/v1/agents'); + const body = res.data; + + return Array.isArray(body) ? body : (body.data ?? []); +} + +export async function generateAgent(client: ConnectApiClient, prompt: string): Promise { + const res = await client.axios.post<{ data?: GeneratedAgentSpec } | GeneratedAgentSpec>('/v1/agents/generate', { + prompt, + runtime: 'managed', + }); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as GeneratedAgentSpec); +} + +export async function createManagedAgent(client: ConnectApiClient, input: CreateManagedAgentInput): Promise { + const res = await client.axios.post<{ data?: AgentRecord } | AgentRecord>('/v1/agents', { + name: input.name, + identifier: input.identifier, + runtime: 'managed', + managedRuntime: { + providerId: input.providerId, + integrationId: input.integrationId, + systemPrompt: input.systemPrompt, + tools: input.tools, + mcpServers: input.mcpServers, + // Generate-managed-agent returns `{ skillId }` only; the agent-create + // DTO expects each entry to also carry `type` (defaults to 'anthropic' + // for catalog-provided skills). + skills: input.skills.map((s) => ({ type: 'anthropic' as const, skillId: s.skillId })), + }, + }); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as AgentRecord); +} + +export async function addAgentIntegration( + client: ConnectApiClient, + agentIdentifier: string, + integrationIdentifier: string +): Promise { + const res = await client.axios.post<{ data?: AgentIntegrationLink } | AgentIntegrationLink>( + `/v1/agents/${encodeURIComponent(agentIdentifier)}/integrations`, + { integrationIdentifier } + ); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as AgentIntegrationLink); +} + +export interface AgentEmailIntegrationDetail extends AgentIntegrationLink { + /** Embedded integration record returned by `POST /v1/agents/:id/integrations`. */ + integration?: { + _id?: string; + identifier?: string; + name?: string; + providerId?: string; + channel?: string; + active?: boolean; + sharedInboundAddress?: string; + }; +} + +/** + * `POST /v1/agents/:id/integrations` with `providerId: 'novu-email-agent'` + * triggers the server's special-case branch (see add-agent-integration + * usecase) that auto-creates a per-agent Novu Email integration with a + * unique shared inbound address (e.g. `myagent-abc@agentconnect.sh`) and + * links it to the agent in one shot. Returns the agent integration link + * with the embedded integration record, including `sharedInboundAddress`. + */ +export async function addAgentEmailIntegration( + client: ConnectApiClient, + agentIdentifier: string +): Promise { + const res = await client.axios.post<{ data?: AgentEmailIntegrationDetail } | AgentEmailIntegrationDetail>( + `/v1/agents/${encodeURIComponent(agentIdentifier)}/integrations`, + { providerId: 'novu-email-agent' } + ); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as AgentEmailIntegrationDetail); +} + +export async function listAgentIntegrations( + client: ConnectApiClient, + agentIdentifier: string +): Promise { + const res = await client.axios.get<{ data?: AgentIntegrationLink[] } | AgentIntegrationLink[]>( + `/v1/agents/${encodeURIComponent(agentIdentifier)}/integrations` + ); + const body = res.data; + + return Array.isArray(body) ? body : (body.data ?? []); +} + +export async function sendAgentWelcomeMessage( + client: ConnectApiClient, + agentIdentifier: string, + integrationIdentifier: string +): Promise { + await client.axios.post(`/v1/agents/${encodeURIComponent(agentIdentifier)}/welcome-message`, { + integrationIdentifier, + }); +} + +// ---- Telegram -------------------------------------------------------------- + +export interface TelegramConfigureResult { + webhookUrl: string; + configuredAt: string; + botUsername: string; +} + +export interface TelegramMobileLinkResult { + /** Signed JWT identifying this mobile-setup session. */ + token: string; + /** Absolute URL the user opens on their phone to paste the BotFather token. */ + url: string; + /** ISO-8601 expiry. */ + expiresAt: string; +} + +export interface TelegramSubscriberLinkResult { + /** `https://t.me/?start=` — opens Telegram on phone, sends `/start ` to the bot. */ + deepLinkUrl: string; + /** Bot username (no leading `@`). */ + botUsername: string; + expiresAt: string; +} + +export async function configureTelegramAgentWebhook( + client: ConnectApiClient, + agentIdentifier: string, + integrationId: string +): Promise { + const res = await client.axios.post<{ data?: TelegramConfigureResult } | TelegramConfigureResult>( + `/v1/agents/${encodeURIComponent(agentIdentifier)}/integrations/${encodeURIComponent(integrationId)}/telegram/configure`, + {} + ); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as TelegramConfigureResult); +} + +export async function issueTelegramMobileLink( + client: ConnectApiClient, + agentIdentifier: string, + integrationId: string, + subscriberId?: string +): Promise { + const res = await client.axios.post<{ data?: TelegramMobileLinkResult } | TelegramMobileLinkResult>( + `/v1/agents/${encodeURIComponent(agentIdentifier)}/integrations/${encodeURIComponent(integrationId)}/telegram/mobile-link`, + subscriberId ? { subscriberId } : {} + ); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as TelegramMobileLinkResult); +} + +export interface TelegramMobileLinkStatus { + valid: boolean; + reason?: 'expired' | 'used' | 'invalid'; + agentName?: string; + providerName?: string; +} + +/** + * Public endpoint — needs no auth header (the signed JWT in the query string + * authenticates the request). We're polling this to detect when the user + * has finished pasting their BotFather token on the mobile setup page; the + * server marks the token's jti as consumed and subsequent status checks + * return `{ valid: false, reason: 'used' }`. + * + * Why this and not `GET /v1/integrations`: ApiKey-authed callers never get + * decrypted credentials back from the integration list endpoint (intentional + * security gate in canUserAccessCredentials), so we can't see the bot-token + * field flip from undefined → set. The status endpoint sidesteps that. + */ +export async function getTelegramMobileLinkStatus( + client: ConnectApiClient, + token: string +): Promise { + const res = await client.axios.get<{ data?: TelegramMobileLinkStatus } | TelegramMobileLinkStatus>( + '/v1/agents/public/telegram/mobile-configure/status', + { params: { token } } + ); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as TelegramMobileLinkStatus); +} + +export async function issueTelegramSubscriberLink( + client: ConnectApiClient, + agentIdentifier: string, + integrationId: string, + subscriberId: string +): Promise { + const res = await client.axios.post<{ data?: TelegramSubscriberLinkResult } | TelegramSubscriberLinkResult>( + `/v1/agents/${encodeURIComponent(agentIdentifier)}/integrations/${encodeURIComponent(integrationId)}/telegram/subscriber-link`, + { subscriberId } + ); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as TelegramSubscriberLinkResult); +} diff --git a/packages/novu/src/commands/connect/api/client.ts b/packages/novu/src/commands/connect/api/client.ts new file mode 100644 index 00000000000..e98352877a2 --- /dev/null +++ b/packages/novu/src/commands/connect/api/client.ts @@ -0,0 +1,108 @@ +import https from 'node:https'; +import axios, { AxiosError, AxiosInstance } from 'axios'; + +export class NovuApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly url: string, + readonly body: unknown + ) { + super(message); + this.name = 'NovuApiError'; + } +} + +export interface ConnectApiClient { + readonly axios: AxiosInstance; + readonly apiUrl: string; +} + +export function createConnectApiClient(input: { apiUrl: string; secretKey: string }): ConnectApiClient { + const baseURL = input.apiUrl.replace(/\/$/, ''); + const debug = process.env.NOVU_CLI_DEBUG === '1' || process.env.NOVU_CLI_DEBUG === 'true'; + const instance = axios.create({ + baseURL, + headers: { + Authorization: `ApiKey ${input.secretKey}`, + 'Content-Type': 'application/json', + }, + // Generous timeout: the /agents/generate call runs an LLM and can take + // 20–40 s for complex prompts. 60 s keeps the hang detection (so a + // misconfigured / non-running API still surfaces an error instead of + // spinning forever) without false-positive-failing the slow LLM calls. + timeout: 60_000, + // Loopback / *.localhost only: dev APIs often use self-signed TLS that Node + // rejects. RFC-1918 LAN IPs (10.x, 192.168.x) are reachable on the same + // network — do not disable verification for those. + httpsAgent: isLoopbackHost(baseURL) ? new https.Agent({ rejectUnauthorized: false }) : undefined, + }); + + if (debug) { + instance.interceptors.request.use((config) => { + process.stderr.write(`[novu connect] → ${config.method?.toUpperCase()} ${config.baseURL}${config.url}\n`); + if (config.data) { + process.stderr.write(`[novu connect] body: ${JSON.stringify(config.data).slice(0, 500)}\n`); + } + + return config; + }); + instance.interceptors.response.use((response) => { + process.stderr.write( + `[novu connect] ← ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}\n` + ); + + return response; + }); + } + + instance.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + const status = error.response?.status ?? 0; + const method = error.config?.method?.toUpperCase() ?? 'GET'; + const url = `${method} ${error.config?.baseURL ?? ''}${error.config?.url ?? ''}`; + const body = error.response?.data; + const fallback = + (error as AxiosError & { code?: string }).code === 'ECONNREFUSED' + ? `Could not reach the Novu API at ${error.config?.baseURL}. Is it running?` + : (error as AxiosError & { code?: string }).code === 'ECONNABORTED' + ? `Request to ${url} timed out. Is the API healthy?` + : error.message; + const message = extractMessage(body) ?? fallback; + if (debug && body) { + process.stderr.write(`[novu connect] ! ${status} ${url}\n ${JSON.stringify(body).slice(0, 1000)}\n`); + } + throw new NovuApiError(message, status, url, body); + } + ); + + return { axios: instance, apiUrl: baseURL }; +} + +function isLoopbackHost(url: string): boolean { + try { + const { hostname } = new URL(url); + + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname.startsWith('127.') || + hostname === '::1' || + hostname === '[::1]' + ); + } catch { + return false; + } +} + +function extractMessage(body: unknown): string | undefined { + if (!body || typeof body !== 'object') return undefined; + const obj = body as Record; + if (typeof obj.message === 'string') return obj.message; + if (Array.isArray(obj.message)) return obj.message.join('; '); + if (typeof obj.error === 'string') return obj.error; + + return undefined; +} diff --git a/packages/novu/src/commands/connect/api/integrations.ts b/packages/novu/src/commands/connect/api/integrations.ts new file mode 100644 index 00000000000..228be213ef9 --- /dev/null +++ b/packages/novu/src/commands/connect/api/integrations.ts @@ -0,0 +1,123 @@ +import type { ConnectApiClient } from './client'; + +export interface IntegrationRecord { + _id: string; + identifier: string; + name: string; + providerId: string; + channel?: string; + kind?: string; + active?: boolean; +} + +export async function listIntegrations(client: ConnectApiClient): Promise { + const res = await client.axios.get<{ data?: IntegrationRecord[] } | IntegrationRecord[]>('/v1/integrations'); + const body = res.data; + + return Array.isArray(body) ? body : (body.data ?? []); +} + +export async function createSlackIntegration( + client: ConnectApiClient, + input: { name: string; environmentId: string } +): Promise { + const res = await client.axios.post<{ data?: IntegrationRecord } | IntegrationRecord>('/v1/integrations', { + providerId: 'slack', + channel: 'chat', + name: input.name, + active: true, + credentials: {}, + _environmentId: input.environmentId, + }); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as IntegrationRecord); +} + +export async function createTelegramIntegration( + client: ConnectApiClient, + input: { name: string; environmentId: string } +): Promise { + const res = await client.axios.post<{ data?: IntegrationRecord } | IntegrationRecord>('/v1/integrations', { + providerId: 'telegram', + channel: 'chat', + name: input.name, + active: true, + credentials: {}, + _environmentId: input.environmentId, + }); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as IntegrationRecord); +} + +export async function slackQuickSetup( + client: ConnectApiClient, + integrationId: string, + input: { configToken: string; agentId: string } +): Promise { + await client.axios.post(`/v1/integrations/${encodeURIComponent(integrationId)}/slack-quick-setup`, { + configToken: input.configToken, + agentId: input.agentId, + }); +} + +/** + * Returns the count of channel connections currently bound to the integration. + * We use this as the OAuth-complete signal: after the user finishes the Slack + * install, the chat-oauth callback creates a ChannelConnection record, and + * the count goes from N to N+1. + */ +export async function countChannelConnectionsForIntegration( + client: ConnectApiClient, + integrationIdentifier: string +): Promise { + const res = await client.axios.get<{ + data?: unknown[]; + totalCount?: number; + }>('/v1/channel-connections', { + params: { integrationIdentifier, limit: 1 }, + }); + const body = res.data; + if (typeof body.totalCount === 'number') return body.totalCount; + + return Array.isArray(body.data) ? body.data.length : 0; +} + +export interface ConnectOauthUrlInput { + integrationIdentifier: string; + agentIdentifier: string; + /** + * Required for `subscriber` mode (default). The chat-oauth callback uses + * this to attach the SLACK_USER channel endpoint that `welcome-message` + * later looks up — without it, OAuth completes but the welcome DM never + * fires because the use case finds no endpoint of the expected type. + */ + subscriberId: string; +} + +export async function generateConnectOauthUrl(client: ConnectApiClient, input: ConnectOauthUrlInput): Promise { + const res = await client.axios.post<{ data?: { url?: string } } | { url?: string } | string>( + '/v1/integrations/channel-connections/oauth', + { + integrationIdentifier: input.integrationIdentifier, + subscriberId: input.subscriberId, + connectionMode: 'subscriber', + // `autoLinkUser` makes the chat-oauth callback create a SLACK_USER + // endpoint bound to this subscriber, using authed_user.id from Slack's + // oauth.v2.access response. That endpoint is what welcome-message + // queries to know which Slack user to DM. + autoLinkUser: true, + // Carry the agent identifier on the connection for observability / + // future scoping. Optional in subscriber mode. + context: { agent: input.agentIdentifier }, + } + ); + const body = res.data; + + if (typeof body === 'string') return body; + if ('data' in body && body.data?.url) return body.data.url; + if ('url' in body && body.url) return body.url; + + throw new Error('Channel-connections OAuth response did not include a URL'); +} diff --git a/packages/novu/src/commands/connect/api/subscribers.ts b/packages/novu/src/commands/connect/api/subscribers.ts new file mode 100644 index 00000000000..f1cdf322fb3 --- /dev/null +++ b/packages/novu/src/commands/connect/api/subscribers.ts @@ -0,0 +1,24 @@ +import type { ConnectApiClient } from './client'; + +export interface UpsertSubscriberInput { + subscriberId: string; + firstName?: string | null; + lastName?: string | null; + email?: string | null; +} + +/** + * `POST /v2/subscribers` upserts by default — if the subscriberId exists the + * record is updated, otherwise it is created. We need this to seed a real + * subscriber before generating a Slack OAuth URL in `subscriber` mode; without + * one the chat-oauth callback would have nothing to attach the SLACK_USER + * channel endpoint to, and `welcome-message` would silently no-op. + */ +export async function upsertSubscriber(client: ConnectApiClient, input: UpsertSubscriberInput): Promise { + await client.axios.post('/v2/subscribers', { + subscriberId: input.subscriberId, + firstName: input.firstName ?? undefined, + lastName: input.lastName ?? undefined, + email: input.email ?? undefined, + }); +} diff --git a/packages/novu/src/commands/connect/index.ts b/packages/novu/src/commands/connect/index.ts new file mode 100644 index 00000000000..327f86620d4 --- /dev/null +++ b/packages/novu/src/commands/connect/index.ts @@ -0,0 +1,86 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import chalk from 'chalk'; +import { AnalyticService } from '../../services/analytics.service'; +import { CONNECT_EVENTS, trackConnect } from './analytics/events'; +import { runConnectPipeline } from './pipeline/runner'; +import type { ConnectCommandOptions } from './types'; +import { createLoggingUI } from './ui/logging-ui'; +import type { ConnectUI } from './ui/ui'; + +const analytics = new AnalyticService(); + +interface UiBundle { + mountConnectUI: (params: { options: ConnectCommandOptions }) => { + ui: ConnectUI; + done: Promise; + }; +} + +// Hide the import from TypeScript's CJS transform so we can dynamically pull +// in the ESM Ink bundle at runtime without ts-node trying to require() it. +const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string +) => Promise; + +async function loadInkUi(): Promise { + const bundlePath = path.join(__dirname, 'ui', 'index.mjs'); + try { + const url = pathToFileURL(bundlePath).href; + + return (await dynamicImport(url)) as UiBundle; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load Novu Connect UI bundle from ${bundlePath}. Underlying error: ${message}`); + } +} + +export async function connectCommand(options: ConnectCommandOptions, anonymousId?: string): Promise { + trackConnect(analytics, anonymousId, CONNECT_EVENTS.STARTED, { + region: options.region, + apiUrl: options.apiUrl, + connectDashboardUrl: options.connectDashboardUrl, + ci: !!options.ci, + hasPrompt: !!options.prompt, + skipSlack: !!options.skipSlack, + }); + + try { + if (shouldUseLoggingMode(options)) { + const ui = createLoggingUI(); + const result = await runConnectPipeline({ + options, + ui, + onTrack: (event, data) => trackConnect(analytics, anonymousId, event, data ?? {}), + }); + if (result.exitCode !== 0) process.exitCode = result.exitCode; + } else { + const { mountConnectUI } = await loadInkUi(); + const mounted = mountConnectUI({ options }); + const result = await runConnectPipeline({ + options, + ui: mounted.ui, + onTrack: (event, data) => trackConnect(analytics, anonymousId, event, data ?? {}), + }); + const exitCode = (await mounted.done) || result.exitCode; + if (exitCode !== 0) process.exitCode = exitCode; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + trackConnect(analytics, anonymousId, CONNECT_EVENTS.ERROR, { message }); + console.error(chalk.red(`Connect failed: ${message}`)); + process.exitCode = 1; + } finally { + await analytics.flush(); + } +} + +function shouldUseLoggingMode(options: ConnectCommandOptions): boolean { + if (options.ci) return true; + if (process.env.NOVU_CONNECT_PLAIN === '1' || process.env.NOVU_CONNECT_PLAIN === 'true') return true; + if (process.env.CI === 'true') return true; + if (!process.stdout.isTTY) return true; + if (!process.stdin.isTTY) return true; + + return false; +} diff --git a/packages/novu/src/commands/connect/pipeline/channels/email.ts b/packages/novu/src/commands/connect/pipeline/channels/email.ts new file mode 100644 index 00000000000..e0442338d2f --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/channels/email.ts @@ -0,0 +1,73 @@ +import open from 'open'; +import { CONNECT_EVENTS } from '../../analytics/events'; +import { addAgentEmailIntegration } from '../../api/agents'; +import type { ConnectApiClient } from '../../api/client'; +import type { IntegrationRecord } from '../../api/integrations'; +import type { AgentSummary } from '../../types'; +import type { ConnectUI } from '../../ui/ui'; +import { pollForAgentLinkConnected } from '../integration-helpers'; +import { CHANNEL_POLL_INTERVAL_MS, CHANNEL_POLL_TIMEOUT_MS } from '../poll-until'; + +export async function connectEmailForAgent( + client: ConnectApiClient, + agent: AgentSummary, + ui: ConnectUI, + track: (event: string, data?: Record) => void +): Promise<{ connected: boolean; integration: IntegrationRecord }> { + ui.addingEmailIntegration(); + + const link = await addAgentEmailIntegration(client, agent.identifier); + const inboundAddress = link.integration?.sharedInboundAddress; + if (!inboundAddress) { + throw new Error( + 'The server did not return an inbound address for the email integration. ' + + 'Make sure NOVU_AGENT_SHARED_INBOUND_DOMAIN is configured on the API.' + ); + } + + const integration: IntegrationRecord = { + _id: link.integration?._id ?? link.integrationId, + identifier: link.integrationIdentifier, + name: link.integration?.name ?? 'Novu Email', + providerId: link.integration?.providerId ?? 'novu-email-agent', + channel: 'email', + active: link.integration?.active !== false, + }; + + if (link.connectedAt) { + ui.emailConnected(); + track(CONNECT_EVENTS.EMAIL_CONNECTED, { + agent: agent.identifier, + alreadyConnected: true, + }); + + return { connected: true, integration }; + } + + const subject = `Hi ${agent.name}!`; + const body = `Hey ${agent.name},\n\nThis is my first email — say hi back and tell me what you can do?\n\nThanks!`; + const mailtoUrl = `mailto:${inboundAddress}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + + await ui.awaitEmailOpen({ inboundAddress, mailtoUrl }); + void open(mailtoUrl).catch(() => undefined); + ui.showEmailWaiting({ inboundAddress }); + + const connected = await pollForAgentLinkConnected(client, agent.identifier, integration.identifier, { + intervalMs: CHANNEL_POLL_INTERVAL_MS, + timeoutMs: CHANNEL_POLL_TIMEOUT_MS, + }); + if (!connected) { + throw new Error( + `We didn't see your email at ${inboundAddress} within ${Math.round(CHANNEL_POLL_TIMEOUT_MS / 1000)}s. ` + + 'Re-run `npx novu connect` once you have sent the test message.' + ); + } + + ui.emailConnected(); + track(CONNECT_EVENTS.EMAIL_CONNECTED, { + agent: agent.identifier, + alreadyConnected: false, + }); + + return { connected: true, integration }; +} diff --git a/packages/novu/src/commands/connect/pipeline/channels/slack.ts b/packages/novu/src/commands/connect/pipeline/channels/slack.ts new file mode 100644 index 00000000000..9d3aafe7e65 --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/channels/slack.ts @@ -0,0 +1,141 @@ +import open from 'open'; +import { CONNECT_EVENTS } from '../../analytics/events'; +import type { ConnectApiClient } from '../../api/client'; +import { NovuApiError } from '../../api/client'; +import { + countChannelConnectionsForIntegration, + createSlackIntegration, + generateConnectOauthUrl, + type IntegrationRecord, + slackQuickSetup, +} from '../../api/integrations'; +import type { AgentSummary, ConnectCommandOptions } from '../../types'; +import type { ConnectUI } from '../../ui/ui'; +import { + ensureAgentIntegrationLinked, + resolveIntegrationForAgent, +} from '../integration-helpers'; +import { CHANNEL_POLL_INTERVAL_MS, CHANNEL_POLL_TIMEOUT_MS, pollUntil } from '../poll-until'; + +const SLACK_PROVIDER_ID = 'slack'; + +export async function connectSlackForAgent( + client: ConnectApiClient, + agent: AgentSummary, + ui: ConnectUI, + options: ConnectCommandOptions, + environmentId: string, + subscriberId: string, + track: (event: string, data?: Record) => void +): Promise<{ connected: boolean; integration: IntegrationRecord }> { + ui.addingSlackIntegration(); + + const slackIntegration = await resolveIntegrationForAgent(client, agent, environmentId, { + providerId: SLACK_PROVIDER_ID, + create: createSlackIntegration, + }); + + await ensureAgentIntegrationLinked(client, agent.identifier, slackIntegration.identifier); + + const baselineConnections = await countChannelConnectionsForIntegration(client, slackIntegration.identifier); + if (baselineConnections > 0) { + ui.slackConnected(); + track(CONNECT_EVENTS.SLACK_CONNECTED, { agent: agent.identifier, alreadyConnected: true }); + + return { connected: true, integration: slackIntegration }; + } + + const authorizeUrl = await getAuthorizeUrlWithQuickSetupFallback( + client, + agent, + slackIntegration, + ui, + options, + subscriberId + ); + track(CONNECT_EVENTS.SLACK_OAUTH_OPENED, { agent: agent.identifier }); + ui.showSlackOAuthUrl(authorizeUrl); + + void open(authorizeUrl).catch(() => undefined); + + ui.pollingForSlackConnection(); + const connected = await pollUntil( + async () => { + const count = await countChannelConnectionsForIntegration(client, slackIntegration.identifier); + + return count > baselineConnections ? 'done' : 'pending'; + }, + { intervalMs: CHANNEL_POLL_INTERVAL_MS, timeoutMs: CHANNEL_POLL_TIMEOUT_MS } + ); + if (!connected) { + throw new Error( + `Slack OAuth was not completed within ${Math.round(CHANNEL_POLL_TIMEOUT_MS / 1000)} seconds. ` + + 'Re-run `npx novu connect` once you have authorized the Slack app.' + ); + } + + ui.slackConnected(); + track(CONNECT_EVENTS.SLACK_CONNECTED, { agent: agent.identifier, alreadyConnected: false }); + + return { connected: true, integration: slackIntegration }; +} + +async function getAuthorizeUrlWithQuickSetupFallback( + client: ConnectApiClient, + agent: AgentSummary, + slackIntegration: IntegrationRecord, + ui: ConnectUI, + options: ConnectCommandOptions, + subscriberId: string +): Promise { + const buildUrl = () => + generateConnectOauthUrl(client, { + integrationIdentifier: slackIntegration.identifier, + agentIdentifier: agent.identifier, + subscriberId, + }); + + try { + return await buildUrl(); + } catch (err) { + if (!isMissingSlackCredentialsError(err)) throw err; + + await runSlackQuickSetup(client, agent, slackIntegration, ui, options, { retry: false }); + + try { + return await buildUrl(); + } catch (retryErr) { + if (!isMissingSlackCredentialsError(retryErr)) throw retryErr; + + await runSlackQuickSetup(client, agent, slackIntegration, ui, options, { retry: true }); + + return await buildUrl(); + } + } +} + +async function runSlackQuickSetup( + client: ConnectApiClient, + agent: AgentSummary, + slackIntegration: IntegrationRecord, + ui: ConnectUI, + options: ConnectCommandOptions, + flags: { retry: boolean } +): Promise { + const configToken = options.slackConfigToken?.trim() + ? options.slackConfigToken.trim() + : await ui.promptForSlackConfigToken({ retry: flags.retry }); + + ui.runningSlackQuickSetup(); + await slackQuickSetup(client, slackIntegration._id, { + configToken, + agentId: agent.id, + }); +} + +function isMissingSlackCredentialsError(err: unknown): boolean { + if (!(err instanceof NovuApiError)) return false; + if (err.status !== 404) return false; + + return /missing credentials/i.test(err.message); +} diff --git a/packages/novu/src/commands/connect/pipeline/channels/telegram.ts b/packages/novu/src/commands/connect/pipeline/channels/telegram.ts new file mode 100644 index 00000000000..a2473b6c0b6 --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/channels/telegram.ts @@ -0,0 +1,113 @@ +import { CONNECT_EVENTS } from '../../analytics/events'; +import { + configureTelegramAgentWebhook, + getTelegramMobileLinkStatus, + issueTelegramMobileLink, + issueTelegramSubscriberLink, +} from '../../api/agents'; +import type { ConnectApiClient } from '../../api/client'; +import { NovuApiError } from '../../api/client'; +import { createTelegramIntegration, type IntegrationRecord } from '../../api/integrations'; +import type { AgentSummary } from '../../types'; +import { renderQR } from '../../ui/qr'; +import type { ConnectUI } from '../../ui/ui'; +import { + ensureAgentIntegrationLinked, + pollForAgentLinkConnected, + resolveIntegrationForAgent, +} from '../integration-helpers'; +import { CHANNEL_POLL_INTERVAL_MS, CHANNEL_POLL_TIMEOUT_MS, pollUntil } from '../poll-until'; + +const TELEGRAM_PROVIDER_ID = 'telegram'; +const TELEGRAM_CHANNEL = 'chat'; +const BOTFATHER_URL = 'https://t.me/botfather'; + +export async function connectTelegramForAgent( + client: ConnectApiClient, + agent: AgentSummary, + ui: ConnectUI, + environmentId: string, + subscriberId: string, + track: (event: string, data?: Record) => void +): Promise<{ connected: boolean; integration: IntegrationRecord }> { + ui.addingTelegramIntegration(); + + const integration = await resolveIntegrationForAgent(client, agent, environmentId, { + providerId: TELEGRAM_PROVIDER_ID, + channel: TELEGRAM_CHANNEL, + create: createTelegramIntegration, + }); + + const existingLink = await ensureAgentIntegrationLinked(client, agent.identifier, integration.identifier); + if (existingLink?.connectedAt) { + ui.telegramConnected(); + track(CONNECT_EVENTS.TELEGRAM_CONNECTED, { + agent: agent.identifier, + alreadyConnected: true, + }); + + return { connected: true, integration }; + } + + const botfatherQr = await renderQR(BOTFATHER_URL); + await ui.showTelegramIntro({ botfatherQr }); + + const mobileLink = await issueTelegramMobileLink(client, agent.identifier, integration._id, subscriberId); + const mobileQr = await renderQR(mobileLink.url); + ui.showTelegramLinkToken({ mobileQr, mobileUrl: mobileLink.url }); + + const tokenSaved = await pollUntil( + async () => { + const status = await getTelegramMobileLinkStatus(client, mobileLink.token); + if (!status.valid && status.reason === 'used') return 'done'; + if (!status.valid) return 'failed'; + + return 'pending'; + }, + { intervalMs: CHANNEL_POLL_INTERVAL_MS, timeoutMs: CHANNEL_POLL_TIMEOUT_MS } + ); + if (!tokenSaved) { + throw new Error( + `The bot token wasn't saved within ${Math.round(CHANNEL_POLL_TIMEOUT_MS / 1000)} seconds. ` + + 'Re-run `npx novu connect` to get a fresh setup link.' + ); + } + + try { + await configureTelegramAgentWebhook(client, agent.identifier, integration._id); + } catch (err) { + if (err instanceof NovuApiError && (err.status === 400 || err.status === 409)) { + // Already configured by the consume endpoint — fine. + } else { + throw err; + } + } + + const subscriberLink = await issueTelegramSubscriberLink(client, agent.identifier, integration._id, subscriberId); + const deepLinkQr = await renderQR(subscriberLink.deepLinkUrl); + ui.showTelegramTest({ + deepLinkQr, + deepLinkUrl: subscriberLink.deepLinkUrl, + botUsername: subscriberLink.botUsername, + }); + + const connected = await pollForAgentLinkConnected(client, agent.identifier, integration.identifier, { + intervalMs: CHANNEL_POLL_INTERVAL_MS, + timeoutMs: CHANNEL_POLL_TIMEOUT_MS, + }); + if (!connected) { + throw new Error( + `We didn't see a /start message on @${subscriberLink.botUsername} within ` + + `${Math.round(CHANNEL_POLL_TIMEOUT_MS / 1000)} seconds. Re-run \`npx novu connect\` once you've ` + + 'opened the bot in Telegram and tapped Start.' + ); + } + + ui.telegramConnected(); + track(CONNECT_EVENTS.TELEGRAM_CONNECTED, { + agent: agent.identifier, + alreadyConnected: false, + }); + + return { connected: true, integration }; +} diff --git a/packages/novu/src/commands/connect/pipeline/integration-helpers.ts b/packages/novu/src/commands/connect/pipeline/integration-helpers.ts new file mode 100644 index 00000000000..3474bb8658f --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/integration-helpers.ts @@ -0,0 +1,89 @@ +import type { AgentIntegrationLink } from '../api/agents'; +import { addAgentIntegration, listAgentIntegrations } from '../api/agents'; +import type { ConnectApiClient } from '../api/client'; +import { NovuApiError } from '../api/client'; +import type { IntegrationRecord } from '../api/integrations'; +import { listIntegrations } from '../api/integrations'; +import type { AgentSummary } from '../types'; +import { pollUntil } from './poll-until'; + +export type IntegrationResolver = ( + client: ConnectApiClient, + input: { name: string; environmentId: string } +) => Promise; + +export type ProviderMatch = { + providerId: string; + channel?: string; + create: IntegrationResolver; +}; + +/** + * Reuse an integration already linked to the agent, or create a fresh + * agent-branded integration. Each agent gets its own provider app/bot. + */ +export async function resolveIntegrationForAgent( + client: ConnectApiClient, + agent: AgentSummary, + environmentId: string, + match: ProviderMatch +): Promise { + const links = await listAgentIntegrations(client, agent.identifier); + const alreadyLinked = links.find( + (l) => + l.providerId === match.providerId && + (match.channel === undefined || l.channel === match.channel) && + l.active !== false + ); + if (alreadyLinked) { + const integrations = await listIntegrations(client); + const integration = integrations.find((i) => i.identifier === alreadyLinked.integrationIdentifier); + if (integration) return integration; + } + + return match.create(client, { name: agent.name, environmentId }); +} + +/** Ensure the agent↔integration link exists; 409 duplicate is a no-op. */ +export async function ensureAgentIntegrationLinked( + client: ConnectApiClient, + agentIdentifier: string, + integrationIdentifier: string +): Promise { + const links = await listAgentIntegrations(client, agentIdentifier); + const existingLink = links.find((l) => l.integrationIdentifier === integrationIdentifier); + if (existingLink) return existingLink; + + try { + await addAgentIntegration(client, agentIdentifier, integrationIdentifier); + } catch (err) { + if (!(err instanceof NovuApiError) || err.status !== 409) throw err; + } + + return undefined; +} + +export function findAgentIntegrationLink( + links: AgentIntegrationLink[], + integrationIdentifier: string +): AgentIntegrationLink | undefined { + return links.find((l) => l.integrationIdentifier === integrationIdentifier); +} + +/** Poll until `connectedAt` is set on the agent↔integration link (first inbound message). */ +export async function pollForAgentLinkConnected( + client: ConnectApiClient, + agentIdentifier: string, + integrationIdentifier: string, + options: { intervalMs: number; timeoutMs: number } +): Promise { + return pollUntil( + async () => { + const links = await listAgentIntegrations(client, agentIdentifier); + const link = findAgentIntegrationLink(links, integrationIdentifier); + + return link?.connectedAt ? 'done' : 'pending'; + }, + options + ); +} diff --git a/packages/novu/src/commands/connect/pipeline/poll-until.ts b/packages/novu/src/commands/connect/pipeline/poll-until.ts new file mode 100644 index 00000000000..8f13b972a9b --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/poll-until.ts @@ -0,0 +1,31 @@ +export const CHANNEL_POLL_INTERVAL_MS = 2_000; +export const CHANNEL_POLL_TIMEOUT_MS = 5 * 60 * 1000; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export type PollOutcome = 'done' | 'pending' | 'failed'; + +/** + * Poll `probe` until it returns `done`, `failed`, or the deadline elapses. + * Transient probe errors are ignored and polling continues until the deadline. + */ +export async function pollUntil( + probe: () => Promise, + options: { intervalMs: number; timeoutMs: number } +): Promise { + const deadline = Date.now() + options.timeoutMs; + while (Date.now() < deadline) { + try { + const outcome = await probe(); + if (outcome === 'done') return true; + if (outcome === 'failed') return false; + } catch { + // transient — keep polling + } + await sleep(options.intervalMs); + } + + return false; +} diff --git a/packages/novu/src/commands/connect/pipeline/runner.ts b/packages/novu/src/commands/connect/pipeline/runner.ts new file mode 100644 index 00000000000..73cde82ba15 --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/runner.ts @@ -0,0 +1,247 @@ +import { resolveAuth } from '../../wizard/auth/resolve-auth'; +import type { ResolvedAuth, WizardCommandOptions } from '../../wizard/types'; +import { CONNECT_EVENTS } from '../analytics/events'; +import { + type AgentRecord, + createManagedAgent, + generateAgent, + listAgents, + sendAgentWelcomeMessage, +} from '../api/agents'; +import { type ConnectApiClient, createConnectApiClient, NovuApiError } from '../api/client'; +import { type IntegrationRecord, listIntegrations } from '../api/integrations'; +import { upsertSubscriber } from '../api/subscribers'; +import type { AgentSummary, ChannelChoice, ConnectCommandOptions } from '../types'; +import type { ConnectUI } from '../ui/ui'; +import { connectEmailForAgent } from './channels/email'; +import { connectSlackForAgent } from './channels/slack'; +import { connectTelegramForAgent } from './channels/telegram'; + +const NOVU_ANTHROPIC_PROVIDER_ID = 'novu-anthropic'; +const AGENT_INTEGRATION_KIND = 'agent'; + +export interface ConnectPipelineInput { + options: ConnectCommandOptions; + ui: ConnectUI; + onTrack?: (event: string, data?: Record) => void; +} + +export interface ConnectPipelineResult { + exitCode: number; +} + +export async function runConnectPipeline(input: ConnectPipelineInput): Promise { + const { options, ui, onTrack } = input; + const track = onTrack ?? (() => undefined); + + try { + await ui.showWelcome(); + + ui.authStarted(); + const auth = await resolveAuth(toWizardAuthOptions(options), { + onStatus: (m) => ui.authStatus(m), + onDashboardUrl: (u) => ui.authDashboardUrl(u), + name: 'novu-connect', + authDashboardUrl: options.connectDashboardUrl, + }); + track(CONNECT_EVENTS.AUTH_COMPLETED, { source: auth.source, region: options.region }); + ui.authCompleted(auth.environmentName ?? null); + + const client = createConnectApiClient({ apiUrl: auth.apiUrl, secretKey: auth.secretKey }); + + ui.listingAgents(); + const existingAgents = await listAgents(client); + track(CONNECT_EVENTS.AGENT_LISTED, { count: existingAgents.length }); + + let agent: AgentSummary; + let flow: 'created' | 'reused'; + + if (existingAgents.length > 0 && !options.prompt) { + const pick = await ui.pickExistingOrCreate(existingAgents.map(toSummary)); + if (pick.action === 'use') { + agent = pick.agent; + flow = 'reused'; + track(CONNECT_EVENTS.AGENT_REUSED, { identifier: agent.identifier }); + } else { + agent = await createAgentFlow(client, ui, options); + flow = 'created'; + track(CONNECT_EVENTS.AGENT_CREATED, { identifier: agent.identifier }); + } + } else { + agent = await createAgentFlow(client, ui, options); + flow = 'created'; + track(CONNECT_EVENTS.AGENT_CREATED, { identifier: agent.identifier }); + } + + ui.agentCreated(agent); + + let channelConnected = false; + let connectedChannel: ChannelChoice | null = null; + let connectedIntegration: IntegrationRecord | null = null; + + const channel: ChannelChoice = options.skipSlack ? 'skip' : (options.channel ?? (await ui.pickChannel())); + + switch (channel) { + case 'skip': + ui.slackSkipped(); + break; + case 'slack': { + const subscriberId = await ensureSubscriberForUser(client, auth); + const result = await connectSlackForAgent( + client, + agent, + ui, + options, + auth.environmentId, + subscriberId, + track + ); + connectedIntegration = result.integration; + channelConnected = result.connected; + if (channelConnected) connectedChannel = 'slack'; + break; + } + case 'telegram': { + const subscriberId = await ensureSubscriberForUser(client, auth); + const result = await connectTelegramForAgent( + client, + agent, + ui, + auth.environmentId, + subscriberId, + track + ); + connectedIntegration = result.integration; + channelConnected = result.connected; + if (channelConnected) connectedChannel = 'telegram'; + break; + } + case 'email': { + const result = await connectEmailForAgent(client, agent, ui, track); + connectedIntegration = result.integration; + channelConnected = result.connected; + if (channelConnected) connectedChannel = 'email'; + break; + } + default: + ui.channelComingSoon(channel); + break; + } + + if (channelConnected && connectedIntegration) { + ui.sendingWelcome(); + try { + await sendAgentWelcomeMessage(client, agent.identifier, connectedIntegration.identifier); + track(CONNECT_EVENTS.WELCOME_SENT, { agent: agent.identifier }); + } catch (err) { + ui.failure(`Could not send the welcome message: ${describeError(err)}`); + } + } + + ui.success({ + agent, + dashboardUrl: auth.dashboardUrl.replace(/\/$/, ''), + environmentSlug: auth.environmentSlug ?? null, + connectedChannel, + }); + + track(CONNECT_EVENTS.COMPLETED, { flow, channel: connectedChannel ?? channel }); + + const exitCode = await ui.shutdown(); + + return { exitCode }; + } catch (err) { + const message = describeError(err); + ui.failure(message); + track(CONNECT_EVENTS.ERROR, { message }); + const exitCode = await ui.shutdown(); + + return { exitCode: exitCode || 1 }; + } +} + +async function createAgentFlow( + client: ConnectApiClient, + ui: ConnectUI, + options: ConnectCommandOptions +): Promise { + ui.loadingIntegrations(); + const integrations = await listIntegrations(client); + const novuAnthropic = integrations.find( + (i) => i.providerId === NOVU_ANTHROPIC_PROVIDER_ID && i.kind === AGENT_INTEGRATION_KIND && i.active !== false + ); + + if (!novuAnthropic) { + throw new Error( + "This environment doesn't have a Novu-managed Claude integration. " + + 'Set one up in the dashboard, then re-run `npx novu connect`.' + ); + } + + const prompt = await ui.promptForDescription(options.prompt); + if (prompt.trim().length < 8) { + throw new Error('Agent description must be at least 8 characters.'); + } + + ui.generatingAgent(); + const generated = await generateAgent(client, prompt.trim()); + + ui.creatingAgent(generated.name); + const created = await createManagedAgent(client, { + name: generated.name, + identifier: generated.identifier, + integrationId: novuAnthropic._id, + providerId: NOVU_ANTHROPIC_PROVIDER_ID, + systemPrompt: generated.systemPrompt, + tools: generated.tools, + mcpServers: generated.mcpServers, + skills: generated.skills, + }); + + return toSummary(created); +} + +async function ensureSubscriberForUser(client: ConnectApiClient, auth: ResolvedAuth): Promise { + if (auth.user?.id) { + const subscriberId = `connect:${auth.user.id}`; + await upsertSubscriber(client, { + subscriberId, + firstName: auth.user.firstName ?? undefined, + lastName: auth.user.lastName ?? undefined, + email: auth.user.email ?? undefined, + }); + + return subscriberId; + } + + const fallback = `cli:${auth.organizationId ?? 'anonymous'}:${Date.now()}`; + await upsertSubscriber(client, { subscriberId: fallback }); + + return fallback; +} + +function toSummary(agent: AgentRecord | AgentSummary): AgentSummary { + const id = '_id' in agent ? agent._id : agent.id; + + return { id, identifier: agent.identifier, name: agent.name }; +} + +function describeError(err: unknown): string { + if (err instanceof NovuApiError) { + return `${err.message} (${err.status} ${err.url})`; + } + if (err instanceof Error) return err.message; + + return String(err); +} + +function toWizardAuthOptions(options: ConnectCommandOptions): WizardCommandOptions { + return { + secretKey: options.secretKey, + apiUrl: options.apiUrl, + dashboardUrl: options.dashboardUrl, + region: options.region, + yes: false, + ci: !!options.ci, + }; +} diff --git a/packages/novu/src/commands/connect/resolve-options.ts b/packages/novu/src/commands/connect/resolve-options.ts new file mode 100644 index 00000000000..c1ab1e78bcc --- /dev/null +++ b/packages/novu/src/commands/connect/resolve-options.ts @@ -0,0 +1,32 @@ +import { CloudRegionEnum } from '../dev/enums'; +import { resolveRegionUrls } from '../dev/resolve-region-urls'; +import type { ConnectCommandOptions } from './types'; + +export const CONNECT_REGION_VALUES = Object.values(CloudRegionEnum) as CloudRegionEnum[]; + +export type ConnectCommandInput = Omit & { + apiUrl?: string; + dashboardUrl?: string; + connectDashboardUrl?: string; +}; + +export function resolveConnectCommandOptions(input: ConnectCommandInput): ConnectCommandOptions { + const region = input.region; + if (!CONNECT_REGION_VALUES.includes(region)) { + throw new Error(`Invalid --region "${region}". Expected one of: ${CONNECT_REGION_VALUES.join(', ')}.`); + } + + const urls = resolveRegionUrls(region, { + apiUrl: input.apiUrl, + dashboardUrl: input.dashboardUrl, + connectDashboardUrl: input.connectDashboardUrl, + }); + + return { + ...input, + region, + apiUrl: urls.apiUrl, + dashboardUrl: urls.dashboardUrl, + connectDashboardUrl: urls.connectDashboardUrl, + }; +} diff --git a/packages/novu/src/commands/connect/types.ts b/packages/novu/src/commands/connect/types.ts new file mode 100644 index 00000000000..adaddb1cab2 --- /dev/null +++ b/packages/novu/src/commands/connect/types.ts @@ -0,0 +1,39 @@ +import type { CloudRegionEnum } from '../dev/enums'; + +export type ChannelChoice = 'slack' | 'email' | 'whatsapp' | 'telegram' | 'teams' | 'skip'; + +export const CHANNEL_CHOICES: readonly ChannelChoice[] = ['slack', 'email', 'whatsapp', 'telegram', 'teams', 'skip']; + +export interface ConnectCommandOptions { + secretKey?: string; + region: CloudRegionEnum; + apiUrl: string; + dashboardUrl: string; + /** Browser-auth UI for `novu connect` (e.g. connect.novu.co); distinct from `dashboardUrl`. */ + connectDashboardUrl: string; + /** Pre-fill the agent description, skipping the input screen. Enables non-interactive runs. */ + prompt?: string; + /** Pre-select the channel to connect, skipping the picker. Currently only `slack` is implemented. */ + channel?: ChannelChoice; + /** + * @deprecated Pass `--channel none` (or just skip the picker) instead. Kept so existing + * scripts don't break; treated as `channel === 'none'`. + */ + skipSlack?: boolean; + /** Pre-fill the Slack App Configuration Token, skipping the paste screen. */ + slackConfigToken?: string; + /** Force the non-interactive logging UI (no Ink TUI). Used in CI / piped-stdin shells. */ + ci?: boolean; +} + +export interface AgentSummary { + id: string; + identifier: string; + name: string; +} + +export interface ConnectFlowResult { + agent: AgentSummary; + flow: 'created' | 'reused'; + slackConnected: boolean; +} diff --git a/packages/novu/src/commands/connect/ui/app.tsx b/packages/novu/src/commands/connect/ui/app.tsx new file mode 100644 index 00000000000..5280b4bd4eb --- /dev/null +++ b/packages/novu/src/commands/connect/ui/app.tsx @@ -0,0 +1,1013 @@ +import { Select, TextInput } from '@inkjs/ui'; +import { Box, Text, useApp, useInput } from 'ink'; +// biome-ignore lint/correctness/noUnusedImports: classic-JSX linter falls back here because tsconfig.json excludes ui/. +import React from 'react'; +import type { ChannelChoice } from '../types'; +import type { ConnectStore } from './store'; +import { useStore } from './use-store'; + +/** + * Channel brand colours used to tint the orb when a channel is hovered or + * active. These are well-known brand-colour hexes; we deliberately do NOT + * render any brand logos — just colour + a single letter glyph as an + * integration identifier. + */ +const CHANNEL_TINTS: Record = { + slack: '#ECB22E', // Slack yellow + telegram: '#26A5E4', // Telegram blue + email: '#34A853', // generic mail green + whatsapp: '#25D366', // WhatsApp green + teams: '#5059C9', // Teams indigo + skip: 'white', +}; +const DEFAULT_ORB_COLOR = 'white'; + +/** + * Plain text channel names rendered inside the orb. Plain words, not logos. + * `skip` is undefined so the orb stays plain when the user opts out. + */ +const CHANNEL_LABELS: Partial> = { + slack: 'SLACK', + telegram: 'TELEGRAM', + email: 'EMAIL', + whatsapp: 'WHATSAPP', + teams: 'TEAMS', +}; + +export interface AppProps { + store: ConnectStore; + /** Called by the app once it has mounted, so the controller can wire the Ink exit. */ + registerExit: (exit: () => void) => void; +} + +const NEW_AGENT_VALUE = '__new__'; + +export function App({ store, registerExit }: AppProps): React.ReactElement { + const phase = useStore(store.phase); + const { exit } = useApp(); + + // Tracks which channel the user is hovering in the picker, so the orb can + // tint to that brand colour before they commit. Reset to `null` when we + // leave the picker — the channel-specific phases below derive their tint + // directly from the phase kind. + const [hoveredChannel, setHoveredChannel] = React.useState(null); + + React.useEffect(() => { + registerExit(exit); + }, [exit, registerExit]); + + // Global Ctrl+C handler. We render Ink with `exitOnCtrlC: false` so child + // input handlers (Select, TextInput, etc.) get a clean shot at keystrokes + // without Ink unmounting under them. The side-effect is Ctrl+C goes + // nowhere unless we wire it ourselves — this top-level handler runs + // regardless of which phase / focused widget is active. Exit code 130 + // matches the conventional SIGINT exit. + useInput((input, key) => { + if (key.ctrl && input === 'c') { + process.exitCode = 130; + exit(); + } + }); + + React.useEffect(() => { + if (phase.kind !== 'pick-channel') setHoveredChannel(null); + }, [phase.kind]); + + const tintColor = computeOrbTint(phase, hoveredChannel); + const label = computeOrbLabel(phase, hoveredChannel); + + // Layout pattern: the orb lives at the top of every screen, always + // breathing/shimmering. Everything else slots beneath it, horizontally + // centered so the welcome text / phase content / QR codes all line up + // visually with the orb's center. Single persistent visual identity + // instead of a different header/spinner per phase. + return ( + + + + + ); +} + +/** + * Derive the orb's colour from the current phase plus, for the picker only, + * the channel currently being hovered. Falls back to white whenever there's + * no channel context (auth, generating, etc.) so the orb stays neutral + * outside of channel selection. + */ +function computeOrbTint( + phase: ReturnType, + hoveredChannel: ChannelChoice | null +): string { + switch (phase.kind) { + case 'pick-channel': + return hoveredChannel ? CHANNEL_TINTS[hoveredChannel] : DEFAULT_ORB_COLOR; + case 'adding-slack': + case 'paste-slack-token': + case 'running-slack-quick-setup': + case 'waiting-slack': + return CHANNEL_TINTS.slack; + case 'adding-telegram': + case 'telegram-intro': + case 'telegram-link-token': + case 'telegram-test': + return CHANNEL_TINTS.telegram; + case 'adding-email': + case 'email-ready': + return CHANNEL_TINTS.email; + case 'success': + return phase.connectedChannel ? CHANNEL_TINTS[phase.connectedChannel] : DEFAULT_ORB_COLOR; + default: + return DEFAULT_ORB_COLOR; + } +} + +/** + * Pick the channel label (SLACK / TELEGRAM / EMAIL / WHATSAPP / TEAMS) + * rendered inside the orb for the current phase. Returns undefined when + * there's no channel context — the orb stays plain on auth/generating/etc. + */ +function computeOrbLabel( + phase: ReturnType, + hoveredChannel: ChannelChoice | null +): string | undefined { + switch (phase.kind) { + case 'pick-channel': + return hoveredChannel ? CHANNEL_LABELS[hoveredChannel] : undefined; + case 'adding-slack': + case 'paste-slack-token': + case 'running-slack-quick-setup': + case 'waiting-slack': + return CHANNEL_LABELS.slack; + case 'adding-telegram': + case 'telegram-intro': + case 'telegram-link-token': + case 'telegram-test': + return CHANNEL_LABELS.telegram; + case 'adding-email': + case 'email-ready': + return CHANNEL_LABELS.email; + case 'success': + return phase.connectedChannel ? CHANNEL_LABELS[phase.connectedChannel] : undefined; + default: + return undefined; + } +} + +function PhaseContent({ + phase, + onChannelHover, +}: { + phase: ReturnType; + onChannelHover: (channel: ChannelChoice | null) => void; +}): React.ReactElement { + switch (phase.kind) { + case 'welcome': + return ; + + case 'auth': + return ( + + {phase.status} + {phase.dashboardUrl ? ( + + If your browser didn't open, visit: + {phase.dashboardUrl} + + ) : null} + + ); + + case 'listing-agents': + return Checking for existing agents…; + + case 'loading-integrations': + return Looking up managed integrations…; + + case 'pick': { + const options = [ + ...phase.agents.map((agent) => ({ + label: `${agent.name} (${agent.identifier})`, + value: agent.id, + })), + { label: '+ Create a new agent', value: NEW_AGENT_VALUE }, + ]; + + return ( + + You already have agents in this environment. What would you like to do? +