From ba4a8fb55852dbda59eddf7eefb1a52be90b157f Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Mon, 27 Oct 2025 10:21:39 +0100 Subject: [PATCH 1/2] added spec for TUI --- spec-tui.md | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 spec-tui.md diff --git a/spec-tui.md b/spec-tui.md new file mode 100644 index 0000000..2098132 --- /dev/null +++ b/spec-tui.md @@ -0,0 +1,226 @@ +# Indexingco CLI TUI Specification + +## 1. Purpose & Context +- **Objective:** Add a polished terminal UI (`indexingco --tui`) that surfaces the same operational data our CLI already fetches (pipelines, filters, transformations) with rich navigation, live updates, and interactive actions—mirroring the ergonomics of tools like k9s. +- **Why now:** Operators are struggling with static JSON output, especially when monitoring multiple resources at once. A TUI should reduce cognitive load, speed up troubleshooting, and showcase the platform better during demos. +- **Success snapshot:** A teammate can launch `indexingco tui`, authenticate with an API key, browse resources, trigger common actions, and keep the dashboard open for live monitoring without touching the raw REST API or manual refresh loops. + +## 2. Goals & Non-Goals +- **Goals** + - Provide a curses-style dashboard featuring pipelines, filters, transformations, and request logs with near real-time polling. + - Reuse existing HTTP logic via a shared service layer to avoid duplication and respect current effect patterns. + - Offer actionable shortcuts (backfill/test/delete/etc.) from the UI with clear confirmation and feedback. + - Keep the experience accessible (keyboard-only), resilient (error surfaces without crashing), and themable. +- **Non-Goals** + - No initial support for arbitrary new resources (e.g., datasets, metrics) unless trivial to expose. + - No requirement to embed a full terminal shell inside the TUI. + - No expectation of offline usage or caching beyond in-memory state. + +## 3. Stakeholders & Handoff +- **Dev owner:** Engineer assigned to implement the TUI (recipient of this spec). +- **Reviewers:** CLI maintainers + DX advocate. +- **Consumers:** Internal operations team first, then external beta users. +- **Supporting artifacts:** Update `README.md`, add demo GIFs/screenshots, and create a walk-through doc for support staff. + +## 4. User Experience Requirements +### 4.1 Launch & Shutdown +- Entry point: `indexingco tui` (alias `indexingco --tui` accepted). +- Accept options: + - `--api-key` (overrides env) + - `--refresh ` (default 5) + - `--theme ` (optional) + - `--log-level ` (optional) +- Show a meaningful error if the API key is missing or rejected, but leave the TUI running with a retry prompt, keeping logs accessible. +- Graceful exit via `q`, `ctrl+c`, or menu item. + +### 4.2 Layout +- **Header/top bar:** Product name, environment indicator, API key status (redacted), refresh countdown, last updated timestamp, total resource counts (mirrors k9s summary strip). +- **Sidebar list:** Tabs for `Pipelines`, `Filters`, `Transformations`, `Activity Log`, plus future placeholders (dimmed) to telegraph expansion. Selected tab highlighted with bold accent similar to k9s’ nav column. +- **Main content area:** Table/grid showing the selected resource, supporting pagination, focused-row interactions, and inline status badges (e.g., pipeline running/paused). +- **Details pane (toggleable):** Displays JSON or formatted data for the selected entry; support split-view layout (`detail` on right) and pop-out modal (full height) like k9s’ detail drill-down. +- **Command bar:** Hidden by default; appears at bottom when the user presses `:` to enter vim-style commands (e.g., `:refresh`, `:filter pipelines status=active`). +- **Footer:** Keybindings cheat sheet (e.g., `r` refresh, `enter` inspect, `b` backfill, `t` test, `d` delete, `?` help) and mode indicator (`NORMAL`, `SEARCH`, `COMMAND`) inspired by k9s. + +### 4.3 Navigation & Interaction +- Keyboard-first: arrow keys / `hjkl` navigate lists; `gg` jumps to top, `G` to bottom, `ctrl+f`/`ctrl+b` page down/up. `tab` cycles focus between sidebar/content/details, while `shift+tab` cycles backwards. +- `enter` opens a detail modal (overlay) with scrollable JSON; `esc` closes and returns focus to the calling pane. +- Resource-specific actions (pipeline backfill/test/delete, transformation test/commit, filter create/remove) exposed via single-key shortcuts with confirm dialog. When an action is available, display k9s-style hints in the status bar (`[b] Backfill`). +- Search/filter flow mimics k9s: pressing `/` enters inline search mode for the active table; highlights matching rows as the user types; `n`/`N` jump to next/previous match. Pressing `:` opens the command bar for advanced commands (e.g., `:set refresh 10`, `:view pipelines failed`). +- Allow labeling/bookmarking: `m` + letter marks a resource (bookmark), `'` + letter jumps to it (small subset of k9s marks feature). +- Helper overlays: + - `?` opens keybinding reference. + - `s` cycles sort columns (same as pressing `:` then issuing a `sort` command). + - `g` followed by resource letter (`p`, `f`, `t`, `a`) jumps to the matching tab (mirrors k9s multi-level `g` navigation). + - `d` toggles detail pane; `v` toggles between table view and raw JSON view. +- Persist latest selection & UI state while background refresh occurs, preserving search/filter context after each poll. + +### 4.6 K9s-Inspired Patterns +- **Mode indicator:** Always show which mode the user is in (`NORMAL`, `SEARCH`, `COMMAND`, `INSERT` for modals) so muscle memory aligns with vim/k9s habits. +- **Command palette syntax:** Support a core set of colon commands: + - `:help` (same as `?`) + - `:set refresh ` (updates polling cadence) + - `:set theme ` + - `:filter =` (applies multi-field filters; display active filters beneath header) + - `:view [status]` (switch view) + - `:logs` (focus activity log pane) + - Future-proof to add `:exec`, `:describe`, etc. +- **Resource drill-down:** Pressing `l` or `enter` on a pipeline transitions into a nested view showing beats, webhook deliveries, etc., with breadcrumb indicator (`Pipelines › MyPipeline`). `h` goes back up—matching k9s’ tree navigation. +- **Split-pane resizing:** `shift+→/←` adjusts sidebar width; `shift+↑/↓` adjusts detail pane height, similar to k9s’ layout controls. +- **Visual cues:** Use subtle box borders and highlighted headers reminiscent of k9s; support dynamic row coloring (e.g., failing pipelines in red, healthy in green) while also providing text labels for accessibility. + +### 4.4 Feedback & Errors +- Show spinner/status text during initial fetch. +- On fetch failure: highlight status bar in red, show toast with error message, keep old data visible, and auto-retry with exponential backoff up to a limit. +- For action failures (e.g., backfill rejected) present modal with error info and suggestion to check logs. +- Maintain an activity log stream listing all executed actions + outcomes with timestamps. +- Provide subtle success confirmation (status bar flash + toast) for successful actions. + +### 4.5 Accessibility & Theming +- Support colorblind-friendly palette (avoid red/green only). Provide at least three theme options (dark, light, monochrome) selected via CLI option and settable in-session. +- Respect terminal resize events (`screen.on("resize")`) to reflow layout. +- Use textual cues alongside color for critical states. +- Keep ASCII-only unless we already rely on extended characters; optional to display simple icons (`▶`, `✓`) if the chosen font supports them. + +## 5. Functional Requirements +- **Resource Fetching** + - Pipelines: list, detail per pipeline, status, last run, networks, associated filter/transformation. + - Filters: list names, counts, preview values. + - Transformations: list names, last updated, allow content preview on demand (lazy fetch to avoid huge payloads). +- **Action Hooks** + - Pipelines: trigger backfill (with parameters), trigger test (prompt for network + beat/hash), delete pipeline. + - Filters: create/remove values (prompt file or inline? decision below). + - Transformations: run test (with file path prompt or support pulling cached transform?). + - Each action should reuse CLI’s HTTP calls and respect API key handling. +- **Polling** + - Default 5s interval; user-configurable between 2–60s. + - Pause polling while an action modal is open; resume after completion. + - Manual refresh with `r` resets timer. +- **Configuration** + - Reuse CLI `Config` module to read env or config file if we extend later. + - Consider storing TUI preferences (theme, refresh) in a `~/.indexingco-cli.json` file (optional stretch goal; confirm with PM). + +## 6. Architecture & Implementation Plans +- `src/tui/components/*`: Small composable components (Header, Sidebar, TableView, DetailModal, Toasts, ActivityLog, CommandPalette, ModeIndicator). +- `src/tui/hooks/*`: Custom hooks for polling, keybindings, service integration (`useResource`, `useHotkeys`, `useCommandBar`). + +### 6.2 CLI Integration +- Update `src/Cli.ts` to register `tui` command and optional `--tui` global flag. The command should call `launchTui` inside an `Effect.gen`, failing gracefully if the terminal isn’t interactive. +- Adjust `src/bin.ts` run pipeline to support a long-lived Ink render loop (`NodeRuntime.runMain` should remain, but ensure Ink handles cleanup). +- Provide helpful messaging when Ink fails to render (e.g., running in non-TTY environment). + +### 6.3 State Management +- Favor `Effect.Ref` or React context providers bridging to Ink components for global state (API key, polling cadence, error queue). +- `useResource` hook: + - Accepts fetch effect, refresh interval, and dependencies. + - Returns `{ data, isLoading, error, refresh, lastUpdated }`. + - Implements backoff and cancellation on unmount using `Effect.Scope`. +- Use `Effect.Runtime` to execute effects inside React hooks (`Effect.runPromise` or `Runtime.runPromise`). + +### 6.4 Data Modeling +- Define TypeScript interfaces for pipelines, filters, transformations to ensure consistent shape across CLI commands and TUI. +- Map API responses to typed structures; guard for missing or extra fields with runtime validation via `effect/Schema` if needed (stretch). +- Provide formatting helpers (e.g., format last run timestamp, shorten IDs). + +### 6.5 Logging & Telemetry +- Reuse existing logging infrastructure if any; otherwise, maintain an in-app log list plus optional `DEBUG=*` support via environment variable to dump raw API responses to stderr. +- Ensure logs do not leak full API keys (redact). + +### 6.6 Theming +- Create a `ThemeContext` with tokens for colors, backgrounds, accent styles. Provide at least dark/light/mono defaults. +- Hook theme selection into CLI option and allow runtime switching with `shift+t`. + +### 6.7 Error Handling +- All service calls should return discriminated unions (`{ _tag: "Success"; data } | { _tag: "Failure"; error }`) to make UI handling explicit. +- Centralize 401/403 detection to prompt for API key re-entry without tearing down the app. +- Handle network timeouts with a visible warning and exponential retry (initial 5s, max 60s). + +## 7. Tasks & Workstreams +### 7.1 Foundations +- [ ] Audit existing CLI commands to catalog required API calls and responses. +- [ ] Create `src/services/indexingCo.ts` and migrate shared logic (`getApiKey`, HTTP client configuration) from `pipelines.ts`, `filters.ts`, `transformations.ts`. +- [ ] Update commands to consume the new service module (regression test existing CLI flows). + +### 7.2 CLI Interface Updates +- [ ] Add `tui` command to `src/Cli.ts` and expose options (`--api-key`, `--refresh`, etc.). +- [ ] Wire `launchTui` into CLI runtime; ensure `indexingco --help` reflects new command. +- [ ] Add integration test covering invocation failure when no TTY is available. + +### 7.3 TUI Implementation +- [ ] Install dependencies: `ink`, `react`, `ink-select-input`, `ink-table` (or custom), `ink-spinner`, `chalk` (if not already). +- [ ] Configure TypeScript/tsup for JSX (update `tsconfig.src.json`, `tsup.config.ts` to include `src/tui/*.tsx`). +- [ ] Build root App with layout scaffolding, theme context, and mode indicator. +- [ ] Implement sidebar navigation component with keybindings. +- [ ] Implement resource table component capable of row focus, sorting, and virtualization if needed. +- [ ] Build detail pane/modal, toast notification system, and activity log components. +- [ ] Implement hooks: `usePollingResource`, `useKeymap`, `useCommandBar`, `useToasts`. +- [ ] Build command palette (`:`) with autocompletion suggestions for supported commands; display errors inline. +- [ ] Implement search mode (`/`) with incremental highlight, `n`/`N` navigation, and ability to clear search with `esc`. +- [ ] Implement bookmarking marks (`m`/`'`) and persistent selection memory. +- [ ] Support nested drill-down views with breadcrumb navigation (`h`/`l`). +- [ ] Connect pipelines view to data service; add quick actions and modals for backfill/test/delete. +- [ ] Connect filters & transformations views, including prompts for additional parameters. +- [ ] Implement activity log feed capturing all API interactions. +- [ ] Support runtime theme toggling and refresh interval adjustments. + +### 7.4 Testing & Quality +- [ ] Add Vitest unit tests for new service functions (mock HTTP client). +- [ ] Add tests for hooks (`usePollingResource`, `useCommandBar`) using `ink-testing-library`. +- [ ] Add interaction tests verifying vim-style navigation (`gg`, `G`, `/` search, `:` commands) using `ink-testing-library`. +- [ ] Provide snapshot tests for key components (Header, Table, Sidebar) with representative data. +- [ ] Manual QA checklist (documented in README or QA.md) includes verifying all keybindings match spec (mirroring k9s defaults). +- [ ] Verify TUI functions correctly on macOS Terminal, iTerm2, and at least one Linux terminal (tmux included). + +### 7.5 Documentation & Delivery +- [ ] Update `README.md` with TUI section (install, usage, keybindings, screenshots/GIF). +- [ ] Add changelog entry summarizing feature and breaking changes (if any). +- [ ] Record short loom or GIF demonstrating primary workflow. +- [ ] Provide internal doc (Notion/Confluence) summarizing deployment steps and tips. +- [ ] Ensure bundling (`tsup`) includes TUI assets; run `bun run build` to verify. + +## 8. Acceptance Criteria +- Vim-style navigation commands (`hjkl`, `gg`, `G`, `/`, `:`) operate as documented. +- Command palette supports at least the initial set of commands (`:help`, `:set`, `:filter`, `:view`, `:logs`). +- Running `indexingco tui` launches the dashboard, displays data within 5 seconds, and keeps refreshing without manual intervention. +- Keyboard shortcuts documented in the footer all work as stated. +- Performing pipeline actions from the TUI yields the same API behavior as current CLI commands, with success/failure feedback. +- CLI commands outside the TUI still function (no regressions). +- Automated tests covering services + hooks pass in CI; manual QA checklist signed off. +- README and changelog entries merged and accurate. + +## 9. Risks & Mitigations +- **Ink rendering quirks or perf issues:** Mitigate by testing early across terminals, limiting heavy re-renders, and using memoization. +- **API schema drift:** Centralize service layer and add lightweight schema guards to catch breaking changes fast. +- **Long-running effects leaking:** Use `Effect.Scope` to manage polling subscriptions; ensure cleanup on exit. +- **User confusion over API key handling:** Provide clear prompts and docs; consider inline redacted display with `press k to re-enter key`. +- **Dependency bloat:** Keep TUI dependencies minimal, evaluate alternatives (custom widgets) before adding packages. +- **Non-TTY environments (CI, pipes):** Detect via `process.stdout.isTTY`; fall back to error message and exit with non-zero status. + +## 10. Watch Outs & Guidance +- Provide discoverability for vim-style shortcuts (e.g., display `[Mode: NORMAL]` and show hints when users hold `:` or `/`). +- Keybinding conflicts: ensure new shortcuts do not collide with input forms; temporarily disable global shortcuts while in modals/command palette. +- Keep code comments minimal but add clarifying notes around complex Effect/Ink interoperability. +- Maintain strict TypeScript types; avoid `any`. +- Ensure the service refactor does not silently swallow errors—log and rethrow. +- Avoid blocking UI threads with synchronous JSON stringify of large payloads; paginate or truncate where necessary. +- When prompting for user input (e.g., pipeline test hash), ensure focus handling is intuitive and state resets after submission/cancel. +- Consider resilience for slow APIs: surface countdown/backoff info to avoid panic. +- Build with ASCII fallback; confirm the UI remains readable for users with fonts lacking box-drawing characters. +- Post-merge, coordinate release timing and announcement with product marketing (k9s angle). + +## 11. Deliverables Checklist +- [ ] `spec-tui.md` (this document) committed. +- [ ] Refactored service layer with tests. +- [ ] New TUI modules (`src/tui/*`) with Ink implementation. +- [ ] Updated CLI wiring. +- [ ] Automated tests (services, hooks, snapshots) green. +- [ ] Documentation updates (README, CHANGELOG, demo media). +- [ ] QA notes and sign-off. + +## 12. Follow-Up & Stretch Ideas +- Integrate websocket or SSE endpoints for real-time updates if available. +- Allow editing pipeline definitions directly in the TUI via embedded editor. +- Export selected resource as JSON via hotkey. +- Multi-account/API key support with quick switching. +- Telemetry opt-in to track usage patterns (with privacy review). + +Once these requirements are met, the feature should be ready for peer review and packaging into the next CLI release. From d1301c2acde4251bea8277a63a1a6606b4608d50 Mon Sep 17 00:00:00 2001 From: Samuel Huber Date: Tue, 28 Oct 2025 16:00:23 +0100 Subject: [PATCH 2/2] init tui --- CHANGELOG.md | 6 + README.md | 60 ++ bun.lock | 127 ++- package.json | 13 +- patches/vitest@2.1.9.patch | 21 + src/Cli.ts | 10 +- src/bin.ts | 14 +- src/commands/filters.ts | 99 +-- src/commands/index.ts | 1 + src/commands/pipelines.ts | 163 +--- src/commands/transformations.ts | 109 +-- src/commands/tui.ts | 87 +++ src/config/apiKey.ts | 38 + src/services/filters.ts | 91 +++ src/services/http.ts | 78 ++ src/services/index.ts | 5 + src/services/pipelines.ts | 153 ++++ src/services/transformations.ts | 113 +++ src/services/types.ts | 86 +++ src/tui/App.tsx | 1019 +++++++++++++++++++++++++ src/tui/components/CommandBar.tsx | 28 + src/tui/components/DetailsPane.tsx | 59 ++ src/tui/components/Footer.tsx | 58 ++ src/tui/components/Header.tsx | 100 +++ src/tui/components/HelpOverlay.tsx | 49 ++ src/tui/components/MessageBanner.tsx | 23 + src/tui/components/ResourceTable.tsx | 149 ++++ src/tui/components/Sidebar.tsx | 52 ++ src/tui/index.tsx | 20 + src/tui/state.ts | 316 ++++++++ src/tui/theme.ts | 63 ++ src/utils/guards.ts | 14 + test/all.test.ts | 5 + test/services/filters.test.ts | 29 + test/services/pipelines.test.ts | 54 ++ test/services/transformations.test.ts | 30 + test/tui/Header.test.tsx | 26 + test/tui/state.test.ts | 30 + test/vitest.setup.ts | 44 ++ tsconfig.src.json | 3 +- vitest.config.ts | 58 +- 41 files changed, 3178 insertions(+), 325 deletions(-) create mode 100644 patches/vitest@2.1.9.patch create mode 100644 src/commands/tui.ts create mode 100644 src/config/apiKey.ts create mode 100644 src/services/filters.ts create mode 100644 src/services/http.ts create mode 100644 src/services/index.ts create mode 100644 src/services/pipelines.ts create mode 100644 src/services/transformations.ts create mode 100644 src/services/types.ts create mode 100644 src/tui/App.tsx create mode 100644 src/tui/components/CommandBar.tsx create mode 100644 src/tui/components/DetailsPane.tsx create mode 100644 src/tui/components/Footer.tsx create mode 100644 src/tui/components/Header.tsx create mode 100644 src/tui/components/HelpOverlay.tsx create mode 100644 src/tui/components/MessageBanner.tsx create mode 100644 src/tui/components/ResourceTable.tsx create mode 100644 src/tui/components/Sidebar.tsx create mode 100644 src/tui/index.tsx create mode 100644 src/tui/state.ts create mode 100644 src/tui/theme.ts create mode 100644 src/utils/guards.ts create mode 100644 test/all.test.ts create mode 100644 test/services/filters.test.ts create mode 100644 test/services/pipelines.test.ts create mode 100644 test/services/transformations.test.ts create mode 100644 test/tui/Header.test.tsx create mode 100644 test/tui/state.test.ts create mode 100644 test/vitest.setup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8089092..e752506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @dtechvision/indexingco-cli +## 0.1.0 + +### Minor Changes + +- introduce the `indexingco tui` interactive dashboard with live polling, vim-style navigation, command palette, and in-app pipeline actions + ## 0.0.3 ### Patch Changes diff --git a/README.md b/README.md index 771d11f..7a9b781 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,66 @@ indexingco pipelines --api-key "your-api-key-here" - `test` - Test a transformation - `create` - Create/commit a transformation +### Terminal UI + +Launch the k9s-inspired terminal dashboard with: + +```bash +indexingco tui --api-key "your-api-key-here" +``` + +Key highlights: + +- Vim-style navigation (`hjkl`, `gg`, `G`, `/`, `:`) +- Sidebar quick-jump (`gp`, `gf`, `gt`, `ga`) +- Search with live highlighting (`/`, `n`, `N`) +- Command palette for runtime tweaks (`:set refresh 10`, `:set theme light`) +- Pipeline actions (`b` backfill, `t` test, `D` delete) with inline confirmation +- Bookmarking (`m` + letter, `'` + letter) and persistent selection across refreshes +- Split details pane toggle (`d`) and modal drill-down (`enter`) +- Activity log capturing every API interaction + +Use `K` inside the TUI to re-enter an API key and `?` to display the full keybinding reference. + +#### Keymap & Commands + +**Modes & Global Keys** + +- `hjkl` / arrow keys – move selection in the focused pane +- `tab` / `shift+tab` – cycle focus between sidebar → table → details +- `gg`, `G` – jump to table top / bottom +- `/` – enter search mode (type inline; `esc` clears, `enter` returns to normal) +- `:` – open the command bar (vim-style palette) +- `n` / `N` – next / previous search match +- `?` – show the keybinding overlay +- `K` – prompt to re-enter the API key +- `d` – toggle the detail pane (hidden ↔ split) +- `enter` – open the modal detail overlay for the selected row +- `v` – toggle table view ↔ raw JSON +- `m` + letter – bookmark current row; `'` + letter jumps to bookmark +- `gp`, `gf`, `gt`, `ga` – jump to Pipelines / Filters / Transformations / Activity tabs +- `r` – force immediate refresh (resets countdown) +- `q` / `ctrl+c` – quit the TUI + +**Command Bar (press `:` first)** + +- `:refresh` – refresh immediately (same as `r`) +- `:set refresh ` – adjust polling interval +- `:set theme ` – switch themes on the fly +- `:set log-level ` – toggle logging verbosity +- `:set api-key ` – swap API credentials without exiting +- `:filter ` – apply table filter (works like `/`) +- `:view ` – jump tabs +- `:logs` – shortcut for `:view activity` +- `:help` – show keybinding overlay +- `:quit` / `:q` – exit the TUI + +**Pipeline-Specific Hotkeys** (table focused) + +- `b` – backfill (prompts for network, then value) +- `t` – test (prompts for network, then beat/hash) +- `D` – delete after typing `yes` to confirm + ### Help ```bash diff --git a/bun.lock b/bun.lock index fca15a6..487b89f 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,11 @@ "workspaces": { "": { "name": "@template/cli", + "dependencies": { + "ink": "^4.4.1", + "react": "^18.3.1", + "react-reconciler": "^0.29.0", + }, "devDependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.8", @@ -16,6 +21,7 @@ "@eslint/eslintrc": "3.1.0", "@eslint/js": "9.10.0", "@types/node": "^22.5.2", + "@types/react": "^18.3.5", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", "effect": "latest", @@ -26,13 +32,20 @@ "eslint-plugin-import": "^2.30.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-sort-destructure-keys": "^2.0.0", + "ink-testing-library": "^4.0.0", + "patch-package": "^8.0.0", "tsup": "^8.2.4", "typescript": "^5.6.2", "vitest": "^2.0.5", }, }, }, + "overrides": { + "@changesets/get-github-info@0.6.0": "patches/@changesets__get-github-info@0.6.0.patch", + }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -379,6 +392,10 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -459,6 +476,8 @@ "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -467,6 +486,8 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -493,6 +514,8 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -537,6 +560,14 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@3.1.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^5.0.0" } }, "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -551,8 +582,12 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -691,6 +726,8 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -765,8 +802,14 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + "ink": ["ink@4.4.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^6.0.0", "auto-bind": "^5.0.1", "chalk": "^5.2.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^3.1.0", "code-excerpt": "^4.0.0", "indent-string": "^5.0.0", "is-ci": "^3.0.1", "is-lower-case": "^2.0.2", "is-upper-case": "^2.0.2", "lodash": "^4.17.21", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^6.0.0", "stack-utils": "^2.0.6", "string-width": "^5.1.2", "type-fest": "^0.12.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.1.0", "ws": "^8.12.0", "yoga-wasm-web": "~0.3.3" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA=="], + + "ink-testing-library": ["ink-testing-library@4.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "io-ts": ["io-ts@2.2.22", "", { "peerDependencies": { "fp-ts": "^2.5.0" } }, "sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA=="], @@ -791,6 +834,8 @@ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-ci": ["is-ci@3.0.1", "", { "dependencies": { "ci-info": "^3.2.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], @@ -799,11 +844,13 @@ "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], @@ -811,6 +858,8 @@ "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + "is-lower-case": ["is-lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], @@ -833,6 +882,8 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-upper-case": ["is-upper-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -841,6 +892,8 @@ "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -871,14 +924,20 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -897,6 +956,8 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "loupe": ["loupe@3.1.3", "", {}, "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -917,6 +978,8 @@ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -969,6 +1032,10 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], @@ -997,6 +1064,10 @@ "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1043,8 +1114,12 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], + "read-pkg": ["read-pkg@5.2.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.0", "normalize-package-data": "^2.5.0", "parse-json": "^5.0.0", "type-fest": "^0.6.0" } }, "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg=="], "read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "^4.1.0", "read-pkg": "^5.2.0", "type-fest": "^0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], @@ -1063,6 +1138,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rollup": ["rollup@4.43.0", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.43.0", "@rollup/rollup-android-arm64": "4.43.0", "@rollup/rollup-darwin-arm64": "4.43.0", "@rollup/rollup-darwin-x64": "4.43.0", "@rollup/rollup-freebsd-arm64": "4.43.0", "@rollup/rollup-freebsd-x64": "4.43.0", "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", "@rollup/rollup-linux-arm-musleabihf": "4.43.0", "@rollup/rollup-linux-arm64-gnu": "4.43.0", "@rollup/rollup-linux-arm64-musl": "4.43.0", "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", "@rollup/rollup-linux-riscv64-gnu": "4.43.0", "@rollup/rollup-linux-riscv64-musl": "4.43.0", "@rollup/rollup-linux-s390x-gnu": "4.43.0", "@rollup/rollup-linux-x64-gnu": "4.43.0", "@rollup/rollup-linux-x64-musl": "4.43.0", "@rollup/rollup-win32-arm64-msvc": "4.43.0", "@rollup/rollup-win32-ia32-msvc": "4.43.0", "@rollup/rollup-win32-x64-msvc": "4.43.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg=="], @@ -1077,6 +1154,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1099,9 +1178,11 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "slice-ansi": ["slice-ansi@6.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA=="], "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], @@ -1171,7 +1252,7 @@ "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], - "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1195,7 +1276,7 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], + "type-fest": ["type-fest@0.12.0", "", {}, "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -1251,6 +1332,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@4.0.1", "", { "dependencies": { "string-width": "^5.0.1" } }, "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], @@ -1265,6 +1348,10 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="], + + "@alcalzone/ansi-tokenize/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "@babel/core/@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1301,6 +1388,8 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + "eslint/@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], "eslint/@eslint/js": ["@eslint/js@9.28.0", "", {}, "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg=="], @@ -1321,12 +1410,22 @@ "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "external-editor/tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1341,6 +1440,8 @@ "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "patch-package/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1351,16 +1452,24 @@ "read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "read-pkg-up/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], + "read-yaml-file/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], "rollup/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + + "spawndamnit/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], @@ -1383,6 +1492,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "cli-truncate/slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "eslint-plugin-codegen/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "eslint-plugin-deprecation/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0" } }, "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA=="], @@ -1397,6 +1508,10 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "patch-package/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -1451,6 +1566,8 @@ "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], diff --git a/package.json b/package.json index 1fc7b12..27032de 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "type": "git", "url": "git+https://github.com/dtechvision/indexing-co-cli.git" }, + "dependencies": { + "ink": "^4.4.1", + "react": "^18.3.1", + "react-reconciler": "^0.29.0" + }, "publishConfig": { "access": "public", "directory": "dist" @@ -20,12 +25,13 @@ "check": "tsc -b tsconfig.json", "lint": "eslint \"**/{src,test,examples,scripts,dtslint}/**/*.{ts,mjs}\"", "lint-fix": "bun run lint --fix", - "test": "vitest run", + "test": "node ./node_modules/vitest/vitest.mjs run", "coverage": "vitest run --coverage", "copy-package-json": "bun scripts/copy-package-json.ts", "changeset-version": "changeset version && bun scripts/version.mjs", "changeset-publish": "bun run build && TEST_DIST= bun vitest && changeset publish", - "release": "bun scripts/release.mjs" + "release": "bun scripts/release.mjs", + "postinstall": "patch-package" }, "devDependencies": { "@changesets/changelog-github": "^0.5.0", @@ -40,9 +46,11 @@ "@eslint/eslintrc": "3.1.0", "@eslint/js": "9.10.0", "@types/node": "^22.5.2", + "@types/react": "^18.3.5", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", "effect": "latest", + "ink-testing-library": "^4.0.0", "eslint": "^9.10.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-codegen": "0.28.0", @@ -50,6 +58,7 @@ "eslint-plugin-import": "^2.30.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-sort-destructure-keys": "^2.0.0", + "patch-package": "^8.0.0", "tsup": "^8.2.4", "typescript": "^5.6.2", "vitest": "^2.0.5" diff --git a/patches/vitest@2.1.9.patch b/patches/vitest@2.1.9.patch new file mode 100644 index 0000000..1c0820a --- /dev/null +++ b/patches/vitest@2.1.9.patch @@ -0,0 +1,21 @@ +diff --git a/dist/chunks/utils.Cn0zI1t3.js b/dist/chunks/utils.Cn0zI1t3.js +index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644 +--- a/dist/chunks/utils.Cn0zI1t3.js ++++ b/dist/chunks/utils.Cn0zI1t3.js +@@ -13,7 +13,13 @@ function createThreadsRpcOptions({ + return { + post: (v) => { + port.postMessage(v); + }, + on: (fn) => { +- port.addListener("message", fn); ++ if (typeof port.addListener === "function") { ++ port.addListener("message", fn); ++ } else if (typeof port.on === "function") { ++ port.on("message", fn); ++ } else if (typeof port.addEventListener === "function") { ++ port.addEventListener("message", fn); ++ } + } + }; + } diff --git a/src/Cli.ts b/src/Cli.ts index 78eabaa..9d6f5a0 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1,9 +1,15 @@ import * as Command from "@effect/cli/Command" -import { filtersCommand, helloCommand, pipelinesCommand, transformationsCommand } from "./commands/index.js" +import { + filtersCommand, + helloCommand, + pipelinesCommand, + transformationsCommand, + tuiCommand +} from "./commands/index.js" // Main CLI with subcommands const cli = Command.make("indexingco").pipe( - Command.withSubcommands([helloCommand, pipelinesCommand, filtersCommand, transformationsCommand]) + Command.withSubcommands([helloCommand, pipelinesCommand, filtersCommand, transformationsCommand, tuiCommand]) ) export const run = Command.run(cli, { diff --git a/src/bin.ts b/src/bin.ts index 1ea1152..c11f051 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -7,12 +7,24 @@ import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import { run } from "./Cli.js" +const rewriteArgs = (argv: ReadonlyArray): Array => { + if (!argv.includes("--tui")) { + return [...argv] + } + const cleaned = argv.filter((value) => value !== "--tui") + const insertIndex = 2 + if (cleaned.includes("tui")) { + return cleaned + } + return [...cleaned.slice(0, insertIndex), "tui", ...cleaned.slice(insertIndex)] +} + const MainLive = Layer.mergeAll( NodeContext.layer, FetchHttpClient.layer ) -run(process.argv).pipe( +run(rewriteArgs(process.argv)).pipe( Effect.provide(MainLive), NodeRuntime.runMain({ disableErrorReporting: true }) ) diff --git a/src/commands/filters.ts b/src/commands/filters.ts index e8eba6b..b377497 100644 --- a/src/commands/filters.ts +++ b/src/commands/filters.ts @@ -2,47 +2,11 @@ import * as Args from "@effect/cli/Args" import * as Command from "@effect/cli/Command" import * as Options from "@effect/cli/Options" import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientRequest from "@effect/platform/HttpClientRequest" -import * as Config from "effect/Config" import * as Console from "effect/Console" import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import * as Redacted from "effect/Redacted" - -// Configuration for API key - reads from environment variable -const apiKeyConfig = Config.redacted("API_KEY_INDEXING_CO").pipe( - Config.withDescription("API key for indexing.co") -) - -// CLI option for API key -const apiKeyOption = Options.redacted("api-key").pipe( - Options.optional, - Options.withDescription("API key for indexing.co (overrides API_KEY_INDEXING_CO env var)") -) - -// Helper function to get API key -const getApiKey = (cliApiKey: Option.Option) => - Effect.gen(function*() { - const key = yield* (Option.isSome(cliApiKey) - ? Effect.succeed(cliApiKey.value) - : Config.option(apiKeyConfig).pipe( - Effect.flatMap((maybeKey) => - Option.isSome(maybeKey) - ? Effect.succeed(maybeKey.value) - : Effect.fail(new Error("API key not found")) - ) - )).pipe( - Effect.catchAll(() => - Effect.gen(function*() { - yield* Console.error( - "API key is required. Set API_KEY_INDEXING_CO environment variable or use --api-key option" - ) - return yield* Effect.fail(new Error("Missing API key")) - }) - ) - ) - return key - }) +import { apiKeyOption, resolveApiKey } from "../config/apiKey.js" +import { createFilter, listFilters, removeFilterValues } from "../services/filters.js" +import type { FilterMutationRequest } from "../services/types.js" // Note: List all filters endpoint doesn't exist // Use GET /filters/{name} to list values for a specific filter @@ -51,23 +15,12 @@ const getApiKey = (cliApiKey: Option.Option) => export const filtersListCommand = Command.make("list", { apiKey: apiKeyOption }, (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) - const request = HttpClientRequest.get("https://app.indexing.co/dw/filters").pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to fetch filters: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const filters = yield* listFilters(client, key) yield* Console.log("Filters fetched successfully:") - yield* Console.log(JSON.stringify(response, null, 2)) + yield* Console.log(JSON.stringify(filters.raw, null, 2)) })) // Command to remove values from a filter @@ -84,26 +37,14 @@ export const filtersRemoveCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) - const requestBody = { + const request: FilterMutationRequest = { + name: args.name, values: args.values } - const request = HttpClientRequest.del(`https://app.indexing.co/dw/filters/${args.name}`).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)), - HttpClientRequest.setHeader("Content-Type", "application/json"), - HttpClientRequest.bodyText(JSON.stringify(requestBody)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to remove values from filter: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* removeFilterValues(client, key, request) yield* Console.log(`Values removed from filter '${args.name}' successfully:`) yield* Console.log(JSON.stringify(response, null, 2)) @@ -124,26 +65,14 @@ export const filtersCreateCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) - const requestBody = { + const request: FilterMutationRequest = { + name: args.name, values: args.values } - const request = HttpClientRequest.post(`https://app.indexing.co/dw/filters/${args.name}`).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)), - HttpClientRequest.setHeader("Content-Type", "application/json"), - HttpClientRequest.bodyText(JSON.stringify(requestBody)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to create filter: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* createFilter(client, key, request) yield* Console.log(`Filter '${args.name}' created successfully:`) yield* Console.log(JSON.stringify(response, null, 2)) diff --git a/src/commands/index.ts b/src/commands/index.ts index e053c55..dccecd7 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,3 +2,4 @@ export { filtersCommand } from "./filters.js" export { helloCommand } from "./hello.js" export { pipelinesCommand } from "./pipelines.js" export { transformationsCommand } from "./transformations.js" +export { tuiCommand } from "./tui.js" diff --git a/src/commands/pipelines.ts b/src/commands/pipelines.ts index 3a073a5..391ca8a 100644 --- a/src/commands/pipelines.ts +++ b/src/commands/pipelines.ts @@ -2,69 +2,23 @@ import * as Args from "@effect/cli/Args" import * as Command from "@effect/cli/Command" import * as Options from "@effect/cli/Options" import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientRequest from "@effect/platform/HttpClientRequest" -import * as Config from "effect/Config" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import * as Redacted from "effect/Redacted" - -// Configuration for API key - reads from environment variable -const apiKeyConfig = Config.redacted("API_KEY_INDEXING_CO").pipe( - Config.withDescription("API key for indexing.co") -) - -// CLI option for API key -const apiKeyOption = Options.redacted("api-key").pipe( - Options.optional, - Options.withDescription("API key for indexing.co (overrides API_KEY_INDEXING_CO env var)") -) - -// Helper function to get API key -const getApiKey = (cliApiKey: Option.Option) => - Effect.gen(function*() { - const key = yield* (Option.isSome(cliApiKey) - ? Effect.succeed(cliApiKey.value) - : Config.option(apiKeyConfig).pipe( - Effect.flatMap((maybeKey) => - Option.isSome(maybeKey) - ? Effect.succeed(maybeKey.value) - : Effect.fail(new Error("API key not found")) - ) - )).pipe( - Effect.catchAll(() => - Effect.gen(function*() { - yield* Console.error( - "API key is required. Set API_KEY_INDEXING_CO environment variable or use --api-key option" - ) - return yield* Effect.fail(new Error("Missing API key")) - }) - ) - ) - return key - }) +import { apiKeyOption, resolveApiKey } from "../config/apiKey.js" +import { backfillPipeline, createPipeline, deletePipeline, listPipelines, testPipeline } from "../services/pipelines.js" +import type { PipelineBackfillRequest, PipelineCreateRequest, PipelineTestRequest } from "../services/types.js" // Command to list all pipelines export const pipelinesListCommand = Command.make("list", { apiKey: apiKeyOption }, (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) - const request = HttpClientRequest.get("https://app.indexing.co/dw/pipelines").pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to fetch pipelines: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const pipelines = yield* listPipelines(client, key) yield* Console.log("Pipelines fetched successfully:") - yield* Console.log(JSON.stringify(response, null, 2)) + yield* Console.log(JSON.stringify(pipelines.raw, null, 2)) })) // Command to create a pipeline @@ -104,7 +58,7 @@ export const pipelinesCreateCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) // Build headers object for webhook const headers: Record = {} @@ -112,7 +66,11 @@ export const pipelinesCreateCommand = Command.make( headers[args.authHeader.value] = args.authValue.value } - const requestBody = { + const connection: PipelineCreateRequest["delivery"]["connection"] = Object.keys(headers).length > 0 + ? { host: args.webhookUrl, headers } + : { host: args.webhookUrl } + + const requestBody: PipelineCreateRequest = { name: args.name, transformation: args.transformation, filter: args.filter, @@ -120,31 +78,14 @@ export const pipelinesCreateCommand = Command.make( networks: args.networks, delivery: { adapter: "HTTP", - connection: { - host: args.webhookUrl, - headers - } + connection } } // Add debug logging yield* Console.log("Request body:") yield* Console.log(JSON.stringify(requestBody, null, 2)) - - const request = HttpClientRequest.post("https://app.indexing.co/dw/pipelines").pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)), - HttpClientRequest.setHeader("Content-Type", "application/json"), - HttpClientRequest.bodyText(JSON.stringify(requestBody)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to create pipeline: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* createPipeline(client, key, requestBody) yield* Console.log(`Pipeline '${args.name}' created successfully:`) yield* Console.log(JSON.stringify(response, null, 2)) @@ -179,39 +120,17 @@ export const pipelinesBackfillCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) - // Build request body - const requestBody: any = { + const requestBody: PipelineBackfillRequest = { network: args.network, - value: args.value - } - - // Add optional parameters if provided - if (Option.isSome(args.beatStart)) { - requestBody.beatStart = args.beatStart.value - } - if (Option.isSome(args.beatEnd)) { - requestBody.beatEnd = args.beatEnd.value + value: args.value, + ...(Option.isSome(args.beatStart) ? { beatStart: args.beatStart.value } : {}), + ...(Option.isSome(args.beatEnd) ? { beatEnd: args.beatEnd.value } : {}), + ...(args.beats.length > 0 ? { beats: args.beats } : {}) } - if (args.beats.length > 0) { - requestBody.beats = args.beats - } - - const request = HttpClientRequest.post(`https://app.indexing.co/dw/pipelines/${args.name}/backfill`).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)), - HttpClientRequest.setHeader("Content-Type", "application/json"), - HttpClientRequest.bodyText(JSON.stringify(requestBody)) - ) - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to backfill pipeline: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* backfillPipeline(client, key, args.name, requestBody) yield* Console.log(`Pipeline '${args.name}' backfill initiated successfully:`) yield* Console.log(JSON.stringify(response, null, 2)) @@ -239,7 +158,7 @@ export const pipelinesTestCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) // Validate that either beat or hash is provided if (Option.isNone(args.beat) && Option.isNone(args.hash)) { @@ -247,26 +166,13 @@ export const pipelinesTestCommand = Command.make( return yield* Effect.fail(new Error("Missing required parameter: beat or hash")) } - // Build URL with appropriate parameter - let url = `https://app.indexing.co/dw/pipelines/${args.name}/test/${args.network}` - if (Option.isSome(args.beat)) { - url += `/${args.beat.value}` - } else if (Option.isSome(args.hash)) { - url += `/${args.hash.value}` + const request: PipelineTestRequest = { + network: args.network, + ...(Option.isSome(args.beat) ? { beat: args.beat.value } : {}), + ...(Option.isSome(args.hash) ? { hash: args.hash.value } : {}) } - const request = HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to test pipeline: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* testPipeline(client, key, args.name, request) yield* Console.log(`Pipeline '${args.name}' test result:`) yield* Console.log(JSON.stringify(response, null, 2)) @@ -283,20 +189,9 @@ export const pipelinesDeleteCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) - - const request = HttpClientRequest.del(`https://app.indexing.co/dw/pipelines/${args.name}`).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)) - ) + const key = yield* resolveApiKey(args.apiKey) - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to delete pipeline: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* deletePipeline(client, key, args.name) yield* Console.log(`Pipeline '${args.name}' deleted successfully:`) yield* Console.log(JSON.stringify(response, null, 2)) diff --git a/src/commands/transformations.ts b/src/commands/transformations.ts index d1ff035..e607cac 100644 --- a/src/commands/transformations.ts +++ b/src/commands/transformations.ts @@ -3,47 +3,12 @@ import * as Command from "@effect/cli/Command" import * as Options from "@effect/cli/Options" import * as FileSystem from "@effect/platform/FileSystem" import * as HttpClient from "@effect/platform/HttpClient" -import * as HttpClientRequest from "@effect/platform/HttpClientRequest" -import * as Config from "effect/Config" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import * as Redacted from "effect/Redacted" - -// Configuration for API key - reads from environment variable -const apiKeyConfig = Config.redacted("API_KEY_INDEXING_CO").pipe( - Config.withDescription("API key for indexing.co") -) - -// CLI option for API key -const apiKeyOption = Options.redacted("api-key").pipe( - Options.optional, - Options.withDescription("API key for indexing.co (overrides API_KEY_INDEXING_CO env var)") -) - -// Helper function to get API key -const getApiKey = (cliApiKey: Option.Option) => - Effect.gen(function*() { - const key = yield* (Option.isSome(cliApiKey) - ? Effect.succeed(cliApiKey.value) - : Config.option(apiKeyConfig).pipe( - Effect.flatMap((maybeKey) => - Option.isSome(maybeKey) - ? Effect.succeed(maybeKey.value) - : Effect.fail(new Error("API key not found")) - ) - )).pipe( - Effect.catchAll(() => - Effect.gen(function*() { - yield* Console.error( - "API key is required. Set API_KEY_INDEXING_CO environment variable or use --api-key option" - ) - return yield* Effect.fail(new Error("Missing API key")) - }) - ) - ) - return key - }) +import { apiKeyOption, resolveApiKey } from "../config/apiKey.js" +import { createTransformation, listTransformations, testTransformation } from "../services/transformations.js" +import type { TransformationTestRequest } from "../services/types.js" // Note: List all transformations endpoint doesn't exist @@ -54,23 +19,12 @@ export const transformationsListCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) - const request = HttpClientRequest.get("https://app.indexing.co/dw/transformations").pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)) - ) - - const response = yield* client.execute(request).pipe( - Effect.flatMap((response) => response.json), - Effect.catchAll((error) => { - return Console.error(`Failed to fetch transformations: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const transformations = yield* listTransformations(client, key) yield* Console.log("Transformations fetched successfully:") - yield* Console.log(JSON.stringify(response, null, 2)) + yield* Console.log(JSON.stringify(transformations.raw, null, 2)) }) ) @@ -97,7 +51,7 @@ export const transformationsTestCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) const fs = yield* FileSystem.FileSystem // Validate that either beat or hash is provided @@ -115,30 +69,14 @@ export const transformationsTestCommand = Command.make( }) ) - // Build URL with appropriate parameter - let url = `https://app.indexing.co/dw/transformations/test?network=${args.network}` - if (Option.isSome(args.beat)) { - url += `&beat=${args.beat.value}` - } else if (Option.isSome(args.hash)) { - url += `&hash=${args.hash.value}` + const request: TransformationTestRequest = { + network: args.network, + ...(Option.isSome(args.beat) ? { beat: args.beat.value } : {}), + ...(Option.isSome(args.hash) ? { hash: args.hash.value } : {}), + code: fileContent } - const response = yield* Effect.gen(function*() { - const request = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)), - HttpClientRequest.setHeader("Content-Type", "application/json"), - HttpClientRequest.bodyJson({ code: fileContent }) - ) - - const httpResponse = yield* client.execute(request) - return yield* httpResponse.json - }).pipe( - Effect.catchAll((error) => { - return Console.error(`Failed to test transformation: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* testTransformation(client, key, request) yield* Console.log("Transformation test result:") yield* Console.log(JSON.stringify(response, null, 2)) @@ -158,7 +96,7 @@ export const transformationsCreateCommand = Command.make( (args) => Effect.gen(function*() { const client = yield* HttpClient.HttpClient - const key = yield* getApiKey(args.apiKey) + const key = yield* resolveApiKey(args.apiKey) const fs = yield* FileSystem.FileSystem // Read the transformation file @@ -170,24 +108,7 @@ export const transformationsCreateCommand = Command.make( }) ) - const url = `https://app.indexing.co/dw/transformations/${args.name}` - - const response = yield* Effect.gen(function*() { - const request = yield* HttpClientRequest.post(url).pipe( - HttpClientRequest.setHeader("X-API-KEY", Redacted.value(key)), - HttpClientRequest.setHeader("Content-Type", "application/json"), - HttpClientRequest.bodyJson({ code: fileContent }) - ) - - const httpResponse = yield* client.execute(request) - return yield* httpResponse.json - }).pipe( - Effect.catchAll((error) => { - return Console.error(`Failed to create transformation: ${error}`).pipe( - Effect.flatMap(() => Effect.fail(error)) - ) - }) - ) + const response = yield* createTransformation(client, key, args.name, fileContent) yield* Console.log(`Transformation '${args.name}' created successfully:`) yield* Console.log(JSON.stringify(response, null, 2)) diff --git a/src/commands/tui.ts b/src/commands/tui.ts new file mode 100644 index 0000000..74752ea --- /dev/null +++ b/src/commands/tui.ts @@ -0,0 +1,87 @@ +import * as Command from "@effect/cli/Command" +import * as Options from "@effect/cli/Options" +import * as HttpClient from "@effect/platform/HttpClient" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Redacted from "effect/Redacted" +import { apiKeyOption, resolveApiKey } from "../config/apiKey.js" +import { startTui } from "../tui/index.js" + +const refreshOption = Options.integer("refresh").pipe( + Options.withDefault(5), + Options.withDescription("Refresh interval in seconds") +) + +const themeOption = Options.text("theme").pipe( + Options.optional, + Options.withDescription("Theme to use (dark|light|mono)") +) + +const logLevelOption = Options.text("log-level").pipe( + Options.optional, + Options.withDescription("Logging verbosity (info|debug)") +) + +const parseTheme = (value: string): "dark" | "light" | "mono" => { + if (value === "dark" || value === "light" || value === "mono") { + return value + } + throw new Error(`Unsupported theme '${value}'. Use dark, light, or mono.`) +} + +const parseLogLevel = (value: string): "info" | "debug" => { + if (value === "info" || value === "debug") { + return value + } + throw new Error(`Unsupported log level '${value}'. Use info or debug.`) +} + +export const tuiCommand = Command.make( + "tui", + { + apiKey: apiKeyOption, + refresh: refreshOption, + theme: themeOption, + logLevel: logLevelOption + }, + (args) => + Effect.gen(function*() { + const key = yield* resolveApiKey(args.apiKey) + const themeEffect = Option.match(args.theme, { + onNone: () => Effect.succeed("dark" as const), + onSome: (value) => + Effect.try({ + try: () => parseTheme(value), + catch: (error) => error as Error + }) + }) + const logLevelEffect = Option.match(args.logLevel, { + onNone: () => Effect.succeed("info" as const), + onSome: (value) => + Effect.try({ + try: () => parseLogLevel(value), + catch: (error) => error as Error + }) + }) + + const [selectedTheme, selectedLogLevel] = yield* Effect.all([themeEffect, logLevelEffect], { + concurrency: "unbounded" + }) + + const client = yield* HttpClient.HttpClient + const runtime = yield* Effect.runtime() + + yield* Effect.tryPromise({ + try: () => + startTui({ + runtime, + httpClient: client, + apiKey: Redacted.value(key), + refreshInterval: Math.max(1, args.refresh), + theme: selectedTheme, + logLevel: selectedLogLevel + }), + catch: (error) => error as Error + }) + }) +) diff --git a/src/config/apiKey.ts b/src/config/apiKey.ts new file mode 100644 index 0000000..3ce6c26 --- /dev/null +++ b/src/config/apiKey.ts @@ -0,0 +1,38 @@ +import * as Options from "@effect/cli/Options" +import * as Config from "effect/Config" +import * as Console from "effect/Console" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Redacted from "effect/Redacted" + +const apiKeyConfig = Config.redacted("API_KEY_INDEXING_CO").pipe( + Config.withDescription("API key for indexing.co") +) + +export const apiKeyOption = Options.redacted("api-key").pipe( + Options.optional, + Options.withDescription("API key for indexing.co (overrides API_KEY_INDEXING_CO env var)") +) + +export const resolveApiKey = (cliApiKey: Option.Option) => + Effect.gen(function*() { + const key = yield* (Option.isSome(cliApiKey) + ? Effect.succeed(cliApiKey.value) + : Config.option(apiKeyConfig).pipe( + Effect.flatMap((maybeKey) => + Option.isSome(maybeKey) + ? Effect.succeed(maybeKey.value) + : Effect.fail(new Error("API key not found")) + ) + )).pipe( + Effect.catchAll(() => + Effect.gen(function*() { + yield* Console.error( + "API key is required. Set API_KEY_INDEXING_CO environment variable or use --api-key option" + ) + return yield* Effect.fail(new Error("Missing API key")) + }) + ) + ) + return key + }) diff --git a/src/services/filters.ts b/src/services/filters.ts new file mode 100644 index 0000000..f6394c8 --- /dev/null +++ b/src/services/filters.ts @@ -0,0 +1,91 @@ +import * as HttpClient from "@effect/platform/HttpClient" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" +import { createJsonRequest, executeJson } from "./http.js" +import type { Filter, FilterMutationRequest } from "./types.js" +import { asString, asStringArray, isRecord } from "../utils/guards.js" + +export interface FilterServiceOptions { + readonly baseUrl?: string +} + +const normalizeFilter = (entity: unknown): Filter => { + if (!isRecord(entity)) { + throw new Error("Filter payload is not an object") + } + + const name = asString(entity.name) ?? asString(entity.id) + if (!name) { + throw new Error("Filter payload missing name") + } + + const values = asStringArray(entity.values) ?? [] + + return { + name, + values, + raw: entity + } +} + +const extractFilterCollection = (payload: unknown) => { + if (Array.isArray(payload)) { + return payload + } + + if (isRecord(payload)) { + const candidates = payload.filters ?? payload.items ?? payload.data ?? payload.results + if (Array.isArray(candidates)) { + return candidates + } + } + + return [] +} + +export const listFilters = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + options: FilterServiceOptions = {} +) => + executeJson( + client, + createJsonRequest("/filters", apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}) + }) + ).pipe( + Effect.map((payload) => ({ + items: extractFilterCollection(payload).map((entry) => normalizeFilter(entry)), + raw: payload + })) + ) + +export const createFilter = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + request: FilterMutationRequest, + options: FilterServiceOptions = {} +) => + executeJson( + client, + createJsonRequest(`/filters/${encodeURIComponent(request.name)}`, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "POST", + body: { values: request.values } + }) + ) + +export const removeFilterValues = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + request: FilterMutationRequest, + options: FilterServiceOptions = {} +) => + executeJson( + client, + createJsonRequest(`/filters/${encodeURIComponent(request.name)}`, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "DELETE", + body: { values: request.values } + }) + ) diff --git a/src/services/http.ts b/src/services/http.ts new file mode 100644 index 0000000..1dd4ede --- /dev/null +++ b/src/services/http.ts @@ -0,0 +1,78 @@ +import * as HttpClient from "@effect/platform/HttpClient" +import * as HttpClientRequest from "@effect/platform/HttpClientRequest" +import * as HttpClientResponse from "@effect/platform/HttpClientResponse" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" + +export const DEFAULT_BASE_URL = "https://app.indexing.co/dw" + +export const buildUrl = (path: string, baseUrl: string = DEFAULT_BASE_URL) => { + const normalizedPath = path.startsWith("/") ? path.slice(1) : path + return `${baseUrl}/${normalizedPath}` +} + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" + +const makeRequest = (method: HttpMethod, url: string) => { + switch (method) { + case "GET": + return HttpClientRequest.get(url) + case "POST": + return HttpClientRequest.post(url) + case "PUT": + return HttpClientRequest.put(url) + case "PATCH": + return HttpClientRequest.patch(url) + case "DELETE": + return HttpClientRequest.del(url) + } +} + +export interface RequestOptions { + readonly method?: HttpMethod + readonly baseUrl?: string + readonly body?: unknown + readonly headers?: Readonly> +} + +export const createJsonRequest = ( + path: string, + apiKey: Redacted.Redacted | string, + options: RequestOptions = {} +) => { + const method = options.method ?? "GET" + const url = buildUrl(path, options.baseUrl) + const request = makeRequest(method, url).pipe( + HttpClientRequest.setHeader("X-API-KEY", typeof apiKey === "string" ? apiKey : Redacted.value(apiKey)) + ) + + const withBody = + options.body === undefined + ? request + : request.pipe( + HttpClientRequest.setHeader("Content-Type", "application/json"), + HttpClientRequest.bodyText(JSON.stringify(options.body)) + ) + + const withHeaders = options.headers + ? Object.entries(options.headers).reduce( + (acc, [key, value]) => acc.pipe(HttpClientRequest.setHeader(key, value)), + withBody + ) + : withBody + + return withHeaders +} + +export const executeJson = ( + client: HttpClient.HttpClient, + request: HttpClientRequest.HttpClientRequest +) => + client.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => + response.json.pipe( + Effect.map((body) => body as A) + ) + ) + ) diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..9f244cf --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,5 @@ +export * from "./filters.js" +export * from "./http.js" +export * from "./pipelines.js" +export * from "./transformations.js" +export * from "./types.js" diff --git a/src/services/pipelines.ts b/src/services/pipelines.ts new file mode 100644 index 0000000..5ee8266 --- /dev/null +++ b/src/services/pipelines.ts @@ -0,0 +1,153 @@ +import * as HttpClient from "@effect/platform/HttpClient" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Redacted from "effect/Redacted" +import { createJsonRequest, executeJson } from "./http.js" +import type { + Pipeline, + PipelineBackfillRequest, + PipelineCreateRequest, + PipelineTestRequest +} from "./types.js" +import { asBoolean, asString, asStringArray, isRecord } from "../utils/guards.js" + +const coerceCollection = (payload: unknown): ReadonlyArray => { + if (Array.isArray(payload)) { + return payload + } + if (isRecord(payload)) { + const candidates = Option.fromNullable(payload.pipelines) + .pipe(Option.orElse(() => Option.fromNullable(payload.items))) + .pipe(Option.orElse(() => Option.fromNullable(payload.data))) + .pipe(Option.orElse(() => Option.fromNullable(payload.results))) + if (Option.isSome(candidates) && Array.isArray(candidates.value)) { + return candidates.value + } + } + return [] +} + +const normalizePipeline = (entity: unknown): Pipeline => { + if (!isRecord(entity)) { + throw new Error("Pipeline payload is not an object") + } + const name = asString(entity.name) ?? asString(entity.id) + if (!name) { + throw new Error("Pipeline payload missing name") + } + const id = asString(entity.id) + const status = asString(entity.status) ?? asString(entity.state) ?? asString(entity.pipelineStatus) + const filter = asString(entity.filter) ?? asString(entity.filterName) + const transformation = asString(entity.transformation) ?? asString(entity.transformationName) + const createdAt = asString(entity.createdAt ?? entity.created_at) + const updatedAt = asString(entity.updatedAt ?? entity.updated_at) + const summary = asString(entity.summary ?? entity.description) + const paused = asBoolean(entity.paused ?? entity.isPaused) + + return { + name, + networks: asStringArray(entity.networks) ?? [], + raw: entity, + ...(id ? { id } : {}), + ...(status ? { status } : {}), + ...(filter ? { filter } : {}), + ...(transformation ? { transformation } : {}), + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}), + ...(summary ? { summary } : {}), + ...(paused !== undefined ? { paused } : {}) + } +} + +export interface PipelineListOptions { + readonly baseUrl?: string +} + +export const listPipelines = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + options: PipelineListOptions = {} +) => + executeJson( + client, + createJsonRequest("/pipelines", apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}) + }) + ).pipe( + Effect.map((payload) => ({ + items: coerceCollection(payload).map((item) => normalizePipeline(item)), + raw: payload + })) + ) + +export const createPipeline = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + requestBody: PipelineCreateRequest, + options: PipelineListOptions = {} +) => + executeJson( + client, + createJsonRequest("/pipelines", apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "POST", + body: requestBody + }) + ) + +export const deletePipeline = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + name: string, + options: PipelineListOptions = {} +) => + executeJson( + client, + createJsonRequest(`/pipelines/${encodeURIComponent(name)}`, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "DELETE" + }) + ) + +export const testPipeline = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + name: string, + request: PipelineTestRequest, + options: PipelineListOptions = {} +) => { + if (!request.beat && !request.hash) { + return Effect.fail(new Error("Either beat or hash must be provided to test a pipeline")) + } + + let path = `/pipelines/${encodeURIComponent(name)}/test/${encodeURIComponent(request.network)}` + if (request.beat) { + path += `/${encodeURIComponent(request.beat)}` + } else if (request.hash) { + path += `/${encodeURIComponent(request.hash)}` + } + + return executeJson( + client, + createJsonRequest(path, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "POST" + }) + ) +} + +export const backfillPipeline = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + name: string, + request: PipelineBackfillRequest, + options: PipelineListOptions = {} +) => + executeJson( + client, + createJsonRequest(`/pipelines/${encodeURIComponent(name)}/backfill`, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "POST", + body: request + }) + ) diff --git a/src/services/transformations.ts b/src/services/transformations.ts new file mode 100644 index 0000000..c0d595d --- /dev/null +++ b/src/services/transformations.ts @@ -0,0 +1,113 @@ +import * as HttpClient from "@effect/platform/HttpClient" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Redacted from "effect/Redacted" +import { createJsonRequest, executeJson } from "./http.js" +import type { Transformation, TransformationTestRequest } from "./types.js" +import { asString, isRecord } from "../utils/guards.js" + +export interface TransformationServiceOptions { + readonly baseUrl?: string +} + +const coerceTransformations = (payload: unknown) => { + if (Array.isArray(payload)) { + return payload + } + if (isRecord(payload)) { + const candidates = Option.fromNullable(payload.transformations) + .pipe(Option.orElse(() => Option.fromNullable(payload.items))) + .pipe(Option.orElse(() => Option.fromNullable(payload.data))) + if (Option.isSome(candidates) && Array.isArray(candidates.value)) { + return candidates.value + } + } + return [] +} + +const normalizeTransformation = (entity: unknown): Transformation => { + if (!isRecord(entity)) { + throw new Error("Transformation payload is not an object") + } + + const name = asString(entity.name) ?? asString(entity.id) + if (!name) { + throw new Error("Transformation payload missing name") + } + const status = asString(entity.status) ?? asString(entity.state) + const version = asString(entity.version) + const language = asString(entity.language ?? entity.lang) + const checksum = asString(entity.checksum) + const createdAt = asString(entity.createdAt ?? entity.created_at) + const updatedAt = asString(entity.updatedAt ?? entity.updated_at) + + return { + name, + raw: entity, + ...(status ? { status } : {}), + ...(version ? { version } : {}), + ...(language ? { language } : {}), + ...(checksum ? { checksum } : {}), + ...(createdAt ? { createdAt } : {}), + ...(updatedAt ? { updatedAt } : {}) + } +} + +export const listTransformations = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + options: TransformationServiceOptions = {} +) => + executeJson( + client, + createJsonRequest("/transformations", apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}) + }) + ).pipe( + Effect.map((payload) => ({ + items: coerceTransformations(payload).map((entry) => normalizeTransformation(entry)), + raw: payload + })) + ) + +export const testTransformation = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + request: TransformationTestRequest, + options: TransformationServiceOptions = {} +) => { + if (!request.beat && !request.hash) { + return Effect.fail(new Error("Either beat or hash must be provided to test a transformation")) + } + const params = new URLSearchParams({ network: request.network }) + if (request.beat) { + params.set("beat", request.beat) + } + if (request.hash) { + params.set("hash", request.hash) + } + return executeJson( + client, + createJsonRequest(`/transformations/test?${params.toString()}`, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "POST", + body: { code: request.code } + }) + ) +} + +export const createTransformation = ( + client: HttpClient.HttpClient, + apiKey: Redacted.Redacted | string, + name: string, + code: string, + options: TransformationServiceOptions = {} +) => + executeJson( + client, + createJsonRequest(`/transformations/${encodeURIComponent(name)}`, apiKey, { + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + method: "POST", + body: { code } + }) + ) diff --git a/src/services/types.ts b/src/services/types.ts new file mode 100644 index 0000000..7dc9616 --- /dev/null +++ b/src/services/types.ts @@ -0,0 +1,86 @@ +export interface Pipeline { + readonly id?: string + readonly name: string + readonly status?: string + readonly filter?: string + readonly transformation?: string + readonly networks?: ReadonlyArray + readonly createdAt?: string + readonly updatedAt?: string + readonly summary?: string + readonly paused?: boolean + readonly raw: Record +} + +export interface PipelineList { + readonly items: ReadonlyArray + readonly raw: unknown +} + +export interface PipelineBackfillRequest { + readonly network: string + readonly value: string + readonly beatStart?: number + readonly beatEnd?: number + readonly beats?: ReadonlyArray +} + +export interface PipelineTestRequest { + readonly network: string + readonly beat?: string + readonly hash?: string +} + +export interface PipelineCreateRequest { + readonly name: string + readonly transformation: string + readonly filter: string + readonly filterKeys: ReadonlyArray + readonly networks: ReadonlyArray + readonly delivery: { + readonly adapter: string + readonly connection: { + readonly host: string + readonly headers?: Readonly> + } + } +} + +export interface Filter { + readonly name: string + readonly values: ReadonlyArray + readonly raw: Record +} + +export interface FilterList { + readonly items: ReadonlyArray + readonly raw: unknown +} + +export interface FilterMutationRequest { + readonly name: string + readonly values: ReadonlyArray +} + +export interface Transformation { + readonly name: string + readonly status?: string + readonly version?: string + readonly language?: string + readonly checksum?: string + readonly createdAt?: string + readonly updatedAt?: string + readonly raw: Record +} + +export interface TransformationList { + readonly items: ReadonlyArray + readonly raw: unknown +} + +export interface TransformationTestRequest { + readonly network: string + readonly beat?: string + readonly hash?: string + readonly code: string +} diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 0000000..7a6a91a --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,1019 @@ +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react" +import { Box, useApp, useInput } from "ink" +import * as Runtime from "effect/Runtime" +import type { StartTuiOptions } from "./index.js" +import { + computeSearchMatches, + initialState, + reducer +} from "./state.js" +import type { + ActivityEntry, + DetailMode, + FocusArea, + InputMode, + ResourceTab +} from "./state.js" +import Header from "./components/Header.js" +import Sidebar from "./components/Sidebar.js" +import ResourceTable, { type TableColumn } from "./components/ResourceTable.js" +import DetailsPane from "./components/DetailsPane.js" +import Footer from "./components/Footer.js" +import CommandBar from "./components/CommandBar.js" +import HelpOverlay from "./components/HelpOverlay.js" +import MessageBanner from "./components/MessageBanner.js" +import { getTheme } from "./theme.js" +import { + backfillPipeline, + deletePipeline, + listFilters, + listPipelines, + listTransformations, + testPipeline +} from "../services/index.js" +import type { + Filter, + Pipeline, + PipelineBackfillRequest, + PipelineTestRequest, + Transformation +} from "../services/types.js" + +const maskKey = (value: string) => { + if (!value) { + return "" + } + if (value.length <= 6) { + return "*".repeat(value.length) + } + return `${value.slice(0, 4)}…${value.slice(-2)}` +} + +const createActivityEntry = ( + source: ResourceTab | "system" | "command", + title: string, + status: ActivityEntry["status"], + message?: string, + metadata?: Record +): ActivityEntry => ({ + id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + source, + title, + status, + timestamp: Date.now(), + ...(message ? { message } : {}), + ...(metadata ? { metadata } : {}) +}) + +type ActionState = + | { type: "pipeline-backfill"; pipeline: Pipeline; stage: "network" | "value"; network?: string } + | { type: "pipeline-delete"; pipeline: Pipeline; stage: "confirm" } + | { type: "pipeline-test"; pipeline: Pipeline; stage: "network" | "target"; network?: string } + +const pageSize = 10 + +const App: React.FC = ({ + apiKey, + httpClient, + logLevel, + refreshInterval, + runtime, + theme +}) => { + const { exit } = useApp() + const [state, dispatch] = useReducer( + reducer, + initialState, + (base) => ({ + ...base, + refreshInterval, + refreshCountdown: refreshInterval, + theme, + logLevel + }) + ) + + const [apiKeyValue, setApiKeyValue] = useState(apiKey) + const [mask, setMask] = useState(maskKey(apiKey)) + const [commandHint, setCommandHint] = useState() + const [activeAction, setActiveAction] = useState(undefined) + const countdownRef = useRef() + const keyBufferTimer = useRef() + const refreshingRef = useRef(false) + + const currentTabState = state.tabStates[state.activeTab] + const selectedItem = useMemo(() => { + const items = currentTabState.items as ReadonlyArray + return items[currentTabState.selectedIndex] + }, [currentTabState.items, currentTabState.selectedIndex]) + + const selectedPipeline = state.activeTab === "pipelines" ? (selectedItem as Pipeline | undefined) : undefined + + const logActivity = useCallback( + (entry: ActivityEntry) => { + dispatch({ type: "appendActivity", entry }) + }, + [dispatch] + ) + + const setFocus = useCallback((focus: FocusArea) => { + dispatch({ type: "setFocus", focus }) + }, []) + + const cycleFocus = useCallback( + (direction: 1 | -1) => { + const order: FocusArea[] = ["sidebar", "content", "detail"] + const index = order.indexOf(state.focus) + const next = order[(index + direction + order.length) % order.length] + setFocus(next) + }, + [setFocus, state.focus] + ) + + const resetCountdown = useCallback( + (intervalSeconds: number) => { + dispatch({ type: "setRefreshCountdown", countdown: intervalSeconds }) + }, + [] + ) + + const fetchPipelines = useCallback(async () => { + dispatch({ type: "setTabLoading", tab: "pipelines", isLoading: true }) + try { + const response = await Runtime.runPromise(runtime, listPipelines(httpClient, apiKeyValue)) + dispatch({ type: "updateTabItems", tab: "pipelines", items: response.items, timestamp: Date.now() }) + logActivity(createActivityEntry("pipelines", "Synced pipelines", "success", undefined, { count: response.items.length })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + dispatch({ type: "setTabError", tab: "pipelines", error: message }) + logActivity(createActivityEntry("pipelines", "Pipeline sync failed", "error", message)) + } + }, [apiKeyValue, httpClient, logActivity, runtime]) + + const fetchFilters = useCallback(async () => { + dispatch({ type: "setTabLoading", tab: "filters", isLoading: true }) + try { + const response = await Runtime.runPromise(runtime, listFilters(httpClient, apiKeyValue)) + dispatch({ type: "updateTabItems", tab: "filters", items: response.items, timestamp: Date.now() }) + logActivity(createActivityEntry("filters", "Synced filters", "success", undefined, { count: response.items.length })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + dispatch({ type: "setTabError", tab: "filters", error: message }) + logActivity(createActivityEntry("filters", "Filter sync failed", "error", message)) + } + }, [apiKeyValue, httpClient, logActivity, runtime]) + + const fetchTransformations = useCallback(async () => { + dispatch({ type: "setTabLoading", tab: "transformations", isLoading: true }) + try { + const response = await Runtime.runPromise(runtime, listTransformations(httpClient, apiKeyValue)) + dispatch({ type: "updateTabItems", tab: "transformations", items: response.items, timestamp: Date.now() }) + logActivity(createActivityEntry("transformations", "Synced transformations", "success", undefined, { count: response.items.length })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + dispatch({ type: "setTabError", tab: "transformations", error: message }) + logActivity(createActivityEntry("transformations", "Transformation sync failed", "error", message)) + } + }, [apiKeyValue, httpClient, logActivity, runtime]) + + const refreshAll = useCallback(async () => { + if (refreshingRef.current) { + return + } + refreshingRef.current = true + dispatch({ type: "setMessage", message: null }) + try { + await Promise.all([fetchPipelines(), fetchFilters(), fetchTransformations()]) + resetCountdown(state.refreshInterval) + } finally { + refreshingRef.current = false + } + }, [fetchFilters, fetchPipelines, fetchTransformations, resetCountdown, state.refreshInterval]) + + useEffect(() => { + refreshAll().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + dispatch({ type: "setMessage", message: { type: "error", text: message } }) + }) + }, [refreshAll]) + + useEffect(() => { + countdownRef.current = setInterval(() => { + dispatch({ type: "tickRefresh" }) + }, 1000) + return () => { + if (countdownRef.current) { + clearInterval(countdownRef.current) + } + } + }, []) + + useEffect(() => { + if (state.refreshCountdown === 0) { + refreshAll().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + dispatch({ type: "setMessage", message: { type: "error", text: message } }) + }) + } + }, [refreshAll, state.refreshCountdown]) + + useEffect(() => { + if (!state.keyBuffer) { + return + } + if (keyBufferTimer.current) { + clearTimeout(keyBufferTimer.current) + } + keyBufferTimer.current = setTimeout(() => { + dispatch({ type: "setKeyBuffer", value: "" }) + }, 600) + return () => { + if (keyBufferTimer.current) { + clearTimeout(keyBufferTimer.current) + } + } + }, [state.keyBuffer]) + + const setSearchQuery = useCallback( + (tab: ResourceTab, query: string) => { + const matches = computeSearchMatches(state.tabStates[tab].items, query) + dispatch({ type: "setSearchQuery", tab, query, matches }) + if (matches.length > 0) { + dispatch({ type: "setSelection", tab, index: matches[0] }) + } + }, + [state.tabStates] + ) + + const clearSearch = useCallback( + (tab: ResourceTab) => { + dispatch({ type: "clearSearch", tab }) + }, + [] + ) + + const setMode = useCallback((mode: InputMode) => { + dispatch({ type: "setMode", mode }) + }, []) + + const moveSelection = useCallback( + (delta: number) => { + dispatch({ type: "moveSelection", tab: state.activeTab, delta }) + }, + [state.activeTab] + ) + + const jumpToIndex = useCallback( + (index: number) => { + dispatch({ type: "setSelection", tab: state.activeTab, index }) + }, + [state.activeTab] + ) + + const cycleSearchMatch = useCallback( + (direction: 1 | -1) => { + const tab = state.activeTab + const { searchMatches, currentMatch } = state.tabStates[tab] + if (searchMatches.length === 0) { + return + } + const next = (currentMatch + direction + searchMatches.length) % searchMatches.length + dispatch({ type: "setCurrentMatch", tab, current: next }) + dispatch({ type: "setSelection", tab, index: searchMatches[next] }) + }, + [state.activeTab, state.tabStates] + ) + + const setActiveTab = useCallback((tab: ResourceTab) => { + dispatch({ type: "setActiveTab", tab }) + setFocus("content") + }, [setFocus]) + + const toggleDetailMode = useCallback(() => { + const newMode: DetailMode = state.detailMode === "hidden" ? "split" : "hidden" + dispatch({ type: "setDetailMode", detailMode: newMode }) + }, [state.detailMode]) + + const openDetailModal = useCallback(() => { + dispatch({ type: "setDetailMode", detailMode: "modal" }) + }, []) + + const closeDetailModal = useCallback(() => { + if (state.detailMode === "modal") { + dispatch({ type: "setDetailMode", detailMode: "split" }) + } + }, [state.detailMode]) + + const toggleViewMode = useCallback(() => { + dispatch({ type: "setViewMode", view: state.viewMode === "table" ? "json" : "table" }) + }, [state.viewMode]) + + const handleBookmark = useCallback( + (key: string) => { + dispatch({ type: "addBookmark", key, tab: state.activeTab, index: state.tabStates[state.activeTab].selectedIndex }) + dispatch({ type: "setMessage", message: { type: "info", text: `Saved mark '${key}'` } }) + }, + [state.activeTab, state.tabStates] + ) + + const jumpToBookmark = useCallback( + (key: string) => { + const mark = state.bookmarks[key] + if (!mark) { + dispatch({ type: "setMessage", message: { type: "error", text: `No mark for '${key}'` } }) + return + } + setActiveTab(mark.tab) + dispatch({ type: "setSelection", tab: mark.tab, index: mark.index }) + }, + [setActiveTab, state.bookmarks] + ) + + const beginBackfill = useCallback(() => { + if (!selectedPipeline) { + dispatch({ type: "setMessage", message: { type: "error", text: "Select a pipeline first" } }) + return + } + setActiveAction({ type: "pipeline-backfill", pipeline: selectedPipeline, stage: "network" }) + setMode("COMMAND") + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint("network (e.g. base)") + }, [selectedPipeline, setMode, setActiveAction, setCommandHint]) + + const beginPipelineDelete = useCallback(() => { + if (!selectedPipeline) { + dispatch({ type: "setMessage", message: { type: "error", text: "Select a pipeline first" } }) + return + } + setActiveAction({ type: "pipeline-delete", pipeline: selectedPipeline, stage: "confirm" }) + setMode("COMMAND") + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint("type 'yes' to delete") + }, [selectedPipeline, setMode, setActiveAction, setCommandHint]) + + const beginPipelineTest = useCallback(() => { + if (!selectedPipeline) { + dispatch({ type: "setMessage", message: { type: "error", text: "Select a pipeline first" } }) + return + } + setActiveAction({ type: "pipeline-test", pipeline: selectedPipeline, stage: "network" }) + setMode("COMMAND") + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint("network (e.g. base)") + }, [selectedPipeline, setMode, setActiveAction, setCommandHint]) + + const handleCommandExecution = useCallback( + (rawCommand: string) => { + const command = rawCommand.trim() + if (!command) { + setMode("NORMAL") + return + } + dispatch({ type: "pushCommandHistory", command }) + + const segments = command.replace(/^:/, "").split(/\s+/) + const [head, ...rest] = segments + + switch (head) { + case "refresh": + refreshAll().catch(() => undefined) + dispatch({ type: "setMessage", message: { type: "info", text: "Refreshing…" } }) + break + case "set": { + const [key, value] = rest + if (key === "refresh") { + const intervalValue = Number(value) + if (!Number.isNaN(intervalValue) && intervalValue > 0) { + dispatch({ type: "setRefreshInterval", interval: intervalValue }) + dispatch({ type: "setMessage", message: { type: "info", text: `Refresh interval ${intervalValue}s` } }) + } else { + dispatch({ type: "setMessage", message: { type: "error", text: "Refresh must be > 0" } }) + } + } else if (key === "theme" && (value === "dark" || value === "light" || value === "mono")) { + dispatch({ type: "setTheme", theme: value }) + } else if (key === "log-level" && (value === "info" || value === "debug")) { + dispatch({ type: "setLogLevel", logLevel: value }) + } else if (key === "api-key" && value) { + const newKey = rest.slice(1).join(" ") + setApiKeyValue(newKey) + setMask(maskKey(newKey)) + dispatch({ type: "setMessage", message: { type: "info", text: "API key updated" } }) + } else { + dispatch({ type: "setMessage", message: { type: "error", text: `Unknown setting ${key}` } }) + } + break + } + case "help": + dispatch({ type: "toggleHelp", value: true }) + break + case "logs": + setActiveTab("activity") + break + case "filter": { + const [tabName, ...queryParts] = rest + const query = queryParts.join(" ") + if (tabName === "pipelines" || tabName === "filters" || tabName === "transformations") { + setSearchQuery(tabName, query) + setActiveTab(tabName) + } + break + } + case "view": { + const [tabName] = rest + if (tabName === "pipelines" || tabName === "filters" || tabName === "transformations" || tabName === "activity") { + setActiveTab(tabName) + } + break + } + case "quit": + case "q": + exit() + break + default: + dispatch({ type: "setMessage", message: { type: "error", text: `Unknown command: ${command}` } }) + } + + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint(undefined) + setMode("NORMAL") + }, + [exit, refreshAll, setActiveTab, setApiKeyValue, setMode, setSearchQuery, setCommandHint] + ) + + const handleCommandInput = useCallback( + (input: string) => { + dispatch({ type: "setCommandInput", value: state.commandInput + input }) + }, + [state.commandInput] + ) + + const handleCommandBackspace = useCallback(() => { + if (state.commandInput.length === 0) { + return + } + dispatch({ type: "setCommandInput", value: state.commandInput.slice(0, -1) }) + }, [state.commandInput]) + + const handleSearchInput = useCallback( + (value: string) => { + const tab = state.activeTab + setSearchQuery(tab, state.tabStates[tab].searchQuery + value) + }, + [setSearchQuery, state.activeTab, state.tabStates] + ) + + const handleSearchBackspace = useCallback(() => { + const tab = state.activeTab + const query = state.tabStates[tab].searchQuery + if (query.length === 0) { + return + } + setSearchQuery(tab, query.slice(0, -1)) + }, [setSearchQuery, state.activeTab, state.tabStates]) + + const handleActionSubmit = useCallback( + async (rawInput: string) => { + if (!activeAction) { + return false + } + const inputValue = rawInput.trim() + + const finish = (message?: { type: "info" | "error"; text: string }) => { + dispatch({ type: "setCommandInput", value: "" }) + setActiveAction(undefined) + setMode("NORMAL") + setCommandHint(undefined) + dispatch({ type: "setMessage", message: message ?? null }) + } + + try { + if (activeAction.type === "pipeline-backfill") { + if (activeAction.stage === "network") { + if (!inputValue) { + setCommandHint("network is required") + return true + } + setActiveAction({ ...activeAction, stage: "value", network: inputValue }) + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint("value (address or hash)") + return true + } + if (!inputValue || !activeAction.network) { + setCommandHint("value is required") + return true + } + const request: PipelineBackfillRequest = { + network: activeAction.network, + value: inputValue + } + dispatch({ type: "setMessage", message: { type: "info", text: "Backfilling pipeline…" } }) + await Runtime.runPromise(runtime, backfillPipeline(httpClient, apiKeyValue, activeAction.pipeline.name, request)) + logActivity(createActivityEntry("pipelines", `Backfill ${activeAction.pipeline.name}`, "success", undefined, request as unknown as Record)) + await fetchPipelines() + finish({ type: "info", text: "Backfill triggered" }) + return true + } + + if (activeAction.type === "pipeline-delete") { + if (inputValue.toLowerCase() !== "yes") { + setCommandHint("type 'yes' to confirm") + return true + } + dispatch({ type: "setMessage", message: { type: "info", text: "Deleting pipeline…" } }) + await Runtime.runPromise(runtime, deletePipeline(httpClient, apiKeyValue, activeAction.pipeline.name)) + logActivity(createActivityEntry("pipelines", `Deleted ${activeAction.pipeline.name}`, "success")) + await fetchPipelines() + finish({ type: "info", text: "Pipeline deleted" }) + return true + } + + if (activeAction.type === "pipeline-test") { + if (activeAction.stage === "network") { + if (!inputValue) { + setCommandHint("network is required") + return true + } + setActiveAction({ ...activeAction, stage: "target", network: inputValue }) + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint("beat: or hash:") + return true + } + if (!activeAction.network) { + setCommandHint("network missing") + return true + } + let request: PipelineTestRequest | undefined + if (inputValue.startsWith("beat:")) { + const beat = inputValue.slice(5).trim() + if (!beat) { + setCommandHint("beat value required") + return true + } + request = { network: activeAction.network, beat } + } else if (inputValue.startsWith("hash:")) { + const hash = inputValue.slice(5).trim() + if (!hash) { + setCommandHint("hash value required") + return true + } + request = { network: activeAction.network, hash } + } else if (/^0x/i.test(inputValue)) { + request = { network: activeAction.network, hash: inputValue } + } else if (/^\d+$/.test(inputValue)) { + request = { network: activeAction.network, beat: inputValue } + } + + if (!request) { + setCommandHint("prefix with beat: or hash:") + return true + } + + dispatch({ type: "setMessage", message: { type: "info", text: "Testing pipeline…" } }) + const response = await Runtime.runPromise(runtime, testPipeline(httpClient, apiKeyValue, activeAction.pipeline.name, request)) + logActivity(createActivityEntry("pipelines", `Test ${activeAction.pipeline.name}`, "success", undefined, { request, response })) + finish({ type: "info", text: "Pipeline test completed" }) + return true + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + finish({ type: "error", text: message }) + logActivity(createActivityEntry("pipelines", "Action failed", "error", message)) + return true + } + return false + }, + [activeAction, apiKeyValue, fetchPipelines, httpClient, logActivity, runtime, setActiveAction, setCommandHint, setMode] + ) + + useInput((input, key) => { + if (state.showHelp && key.escape) { + dispatch({ type: "toggleHelp", value: false }) + return + } + + if (state.detailMode === "modal" && key.escape) { + closeDetailModal() + return + } + + if (state.mode === "COMMAND") { + if (activeAction) { + if (key.return) { + void handleActionSubmit(state.commandInput) + } else if (key.escape) { + dispatch({ type: "setCommandInput", value: "" }) + setActiveAction(undefined) + setCommandHint(undefined) + setMode("NORMAL") + } else if (key.backspace) { + handleCommandBackspace() + } else if (input) { + handleCommandInput(input) + } + } else { + if (key.return) { + handleCommandExecution(state.commandInput) + } else if (key.escape) { + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint(undefined) + setMode("NORMAL") + } else if (key.backspace) { + handleCommandBackspace() + } else if (key.upArrow) { + const nextCursor = Math.min(state.commandHistory.length - 1, state.commandCursor + 1) + if (nextCursor >= 0) { + dispatch({ type: "setCommandCursor", cursor: nextCursor }) + dispatch({ type: "setCommandInput", value: state.commandHistory[nextCursor] }) + } + } else if (key.downArrow) { + const nextCursor = Math.max(-1, state.commandCursor - 1) + dispatch({ type: "setCommandCursor", cursor: nextCursor }) + dispatch({ type: "setCommandInput", value: nextCursor >= 0 ? state.commandHistory[nextCursor] : "" }) + } else if (input) { + handleCommandInput(input) + } + } + return + } + + if (state.mode === "SEARCH") { + if (key.return) { + setMode("NORMAL") + } else if (key.escape) { + clearSearch(state.activeTab) + setMode("NORMAL") + } else if (key.backspace) { + handleSearchBackspace() + } else if (input) { + handleSearchInput(input) + } + return + } + + if (key.ctrl && input === "c") { + exit() + return + } + + if (input === "q" && !key.ctrl) { + exit() + return + } + + if (key.tab) { + cycleFocus(key.shift ? -1 : 1) + return + } + + if (input === "K") { + setMode("COMMAND") + dispatch({ type: "setCommandInput", value: "set api-key " }) + setCommandHint("enter API key and press enter") + return + } + + if (input === ":") { + setMode("COMMAND") + dispatch({ type: "setCommandInput", value: "" }) + setCommandHint(undefined) + return + } + + if (input === "/") { + setMode("SEARCH") + clearSearch(state.activeTab) + return + } + + if (input === "?") { + dispatch({ type: "toggleHelp", value: true }) + return + } + + if (input === "r") { + refreshAll().catch(() => undefined) + return + } + + if (input === "v") { + toggleViewMode() + return + } + + if (input === "d" && state.focus !== "sidebar") { + toggleDetailMode() + return + } + + if (state.activeTab === "pipelines") { + if (input === "b") { + beginBackfill() + return + } + if (input === "t") { + beginPipelineTest() + return + } + if (input === "D") { + beginPipelineDelete() + return + } + } + + if (input === "g") { + if (state.keyBuffer === "g") { + jumpToIndex(0) + dispatch({ type: "setKeyBuffer", value: "" }) + } else { + dispatch({ type: "setKeyBuffer", value: "g" }) + } + return + } + + if (state.keyBuffer === "g") { + if (input === "a") { + setActiveTab("activity") + } else if (input === "p") { + setActiveTab("pipelines") + } else if (input === "f") { + setActiveTab("filters") + } else if (input === "t") { + setActiveTab("transformations") + } else if (input === "g") { + jumpToIndex(0) + } + dispatch({ type: "setKeyBuffer", value: "" }) + return + } + + if (input === "G") { + jumpToIndex(currentTabState.items.length - 1) + return + } + + if (input === "n") { + cycleSearchMatch(1) + return + } + + if (input === "N") { + cycleSearchMatch(-1) + return + } + + if (input === "m") { + dispatch({ type: "setKeyBuffer", value: "m" }) + return + } + + if (state.keyBuffer === "m") { + if (/^[a-z0-9]$/i.test(input)) { + handleBookmark(input) + } + dispatch({ type: "setKeyBuffer", value: "" }) + return + } + + if (input === "'") { + dispatch({ type: "setKeyBuffer", value: "'" }) + return + } + + if (state.keyBuffer === "'") { + if (/^[a-z0-9]$/i.test(input)) { + jumpToBookmark(input) + } + dispatch({ type: "setKeyBuffer", value: "" }) + return + } + + if (state.focus === "sidebar") { + if (input === "j" || key.downArrow) { + const tabs: ResourceTab[] = ["pipelines", "filters", "transformations", "activity"] + const index = tabs.indexOf(state.activeTab) + const nextTab = tabs[(index + 1) % tabs.length] + setActiveTab(nextTab) + } else if (input === "k" || key.upArrow) { + const tabs: ResourceTab[] = ["pipelines", "filters", "transformations", "activity"] + const index = tabs.indexOf(state.activeTab) + const nextTab = tabs[(index - 1 + tabs.length) % tabs.length] + setActiveTab(nextTab) + } + return + } + + if (state.focus === "content") { + if (input === "j" || key.downArrow) { + moveSelection(1) + } else if (input === "k" || key.upArrow) { + moveSelection(-1) + } else if (key.pageDown || (key.ctrl && input === "f")) { + moveSelection(pageSize) + } else if (key.pageUp || (key.ctrl && input === "b")) { + moveSelection(-pageSize) + } else if (key.return) { + openDetailModal() + } + return + } + + if (state.focus === "detail" && key.escape) { + closeDetailModal() + } + }) + + const themeColors = getTheme(state.theme) + + const pipelineColumns: TableColumn[] = useMemo( + () => [ + { key: "name", header: "Name", width: 4, render: (item) => ({ text: item.name }) }, + { + key: "status", + header: "Status", + width: 2, + render: (item) => { + const status = item.status ?? "unknown" + const color = status === "running" ? themeColors.success : status === "paused" ? themeColors.warning : themeColors.muted + return { text: status, color } + } + }, + { + key: "transformation", + header: "Transformation", + width: 3, + render: (item) => ({ text: item.transformation ?? "-" }) + }, + { + key: "filter", + header: "Filter", + width: 3, + render: (item) => ({ text: item.filter ?? "-" }) + }, + { + key: "networks", + header: "Networks", + width: 3, + render: (item) => ({ text: item.networks?.join(", ") ?? "-" }) + } + ], + [themeColors] + ) + + const filterColumns: TableColumn[] = useMemo( + () => [ + { key: "name", header: "Name", width: 4, render: (item) => ({ text: item.name }) }, + { + key: "values", + header: "Values", + width: 4, + render: (item) => ({ text: item.values.join(", ") }) + }, + { + key: "count", + header: "Count", + width: 2, + align: "right", + render: (item) => ({ text: String(item.values.length) }) + } + ], + [] + ) + + const transformationColumns: TableColumn[] = useMemo( + () => [ + { key: "name", header: "Name", width: 4, render: (item) => ({ text: item.name }) }, + { + key: "status", + header: "Status", + width: 2, + render: (item) => ({ text: item.status ?? "unknown" }) + }, + { + key: "version", + header: "Version", + width: 2, + render: (item) => ({ text: item.version ?? "-" }) + }, + { + key: "language", + header: "Language", + width: 2, + render: (item) => ({ text: item.language ?? "js" }) + } + ], + [] + ) + + const activityColumns: TableColumn[] = useMemo( + () => [ + { + key: "time", + header: "When", + width: 2, + render: (item) => ({ text: new Date(item.timestamp).toLocaleTimeString() }) + }, + { + key: "source", + header: "Source", + width: 2, + render: (item) => ({ text: item.source }) + }, + { + key: "title", + header: "Event", + width: 4, + render: (item) => ({ text: item.title }) + }, + { + key: "status", + header: "Status", + width: 2, + render: (item) => ({ text: item.status }) + } + ], + [] + ) + + const activeColumns = state.activeTab === "pipelines" + ? pipelineColumns + : state.activeTab === "filters" + ? filterColumns + : state.activeTab === "transformations" + ? transformationColumns + : activityColumns + + const headerCounts: Record = { + pipelines: state.tabStates.pipelines.items.length, + filters: state.tabStates.filters.items.length, + transformations: state.tabStates.transformations.items.length, + activity: state.tabStates.activity.items.length + } + + const environmentLabel = "Production" + + const tabItems = currentTabState.items as ReadonlyArray + + return ( + +
+ + {state.message && ( + + )} + + + + + + >} + currentMatch={currentTabState.currentMatch} + emptyMessage={currentTabState.isLoading ? "Loading…" : "No entries"} + isFocused={state.focus === "content"} + items={tabItems} + searchMatches={currentTabState.searchMatches} + selectedIndex={currentTabState.selectedIndex} + themeName={state.theme} + viewMode={state.viewMode} + /> + {state.detailMode !== "hidden" && state.detailMode !== "modal" && ( + + )} + + + {state.detailMode === "modal" && ( + + )} + + {state.showHelp && } + + + {state.mode === "COMMAND" && ( + + )} + +