diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 35779f5..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: CD - -on: - workflow_run: - workflows: ["CI"] - types: [completed] - -permissions: - contents: write - -jobs: - release: - # Only release when CI succeeded for a direct push to main/master, - # and avoid infinite loops from the bot's own release commit. - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') && - github.event.workflow_run.actor.login != 'github-actions[bot]' && - contains(github.event.workflow_run.head_commit.message, 'release') - runs-on: ubuntu-latest - environment: release - steps: - - name: Checkout tested branch - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_branch }} - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" - cache: "pnpm" - registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Bump patch version + create tag - run: 'npm version patch -m "chore(release): %s [skip-release]"' - - - name: Push version commit + tag - run: git push origin HEAD:${{ github.event.workflow_run.head_branch }} --follow-tags - - - name: Get new version - id: version - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - - - name: Publish to npm - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.version.outputs.version }} - name: v${{ steps.version.outputs.version }} - generate_release_notes: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cec2008 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Release + +on: + workflow_dispatch: + inputs: + release_type: + description: Release bump type + required: true + type: choice + options: + - patch + - minor + - major + default: patch + first_release: + description: Use --first-release for initial npm publish + required: true + type: boolean + default: false + +concurrency: + group: release-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_TYPE: ${{ inputs.release_type }} + FIRST_RELEASE: ${{ inputs.first_release }} + run: pnpm run release:ci diff --git a/.release-it.json b/.release-it.json new file mode 100644 index 0000000..1698f99 --- /dev/null +++ b/.release-it.json @@ -0,0 +1,27 @@ +{ + "git": { + "commitMessage": "chore(release): v${version}", + "tagName": "v${version}", + "requireCleanWorkingDir": true, + "requireUpstream": true, + "addUntrackedFiles": true, + "commit": true, + "tag": true, + "push": true + }, + "github": { + "release": true + }, + "npm": { + "publish": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": "conventionalcommits", + "infile": "CHANGELOG.md" + } + }, + "hooks": { + "before:init": "pnpm run check" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22c5a05 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## Unreleased + +- Expected release: TBD +- PR: TBD +- Authors: @ayagmar + +### Added + +- Documented new duration parsing and path identity utility work that supports history filters, scheduling, and path deduplication. + +### Changed + +- Release automation now serializes manual runs and only publishes from `main`. +- Community browse caching now follows the shared search-cache path. + +### Fixed + +- Unified manager interactions keep staged changes, filters, and selection when returning from details, action menus, and stay-in-manager prompts. +- Disabled local extensions deduplicate correctly, manifest entrypoints only resolve real files, and npm author selection now prefers maintainer usernames before fallback emails. +- Metadata cache freshness no longer refreshes inherited stale fields. +- Relative path selection rejects Windows absolute and UNC paths, and unified UI tests now use platform-safe temp directories. + diff --git a/README.md b/README.md index 9f61a60..5053726 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,29 @@ pi install npm:pi-extmgr If Pi is already running, use `/reload`. -Requires Node.js `>=22`. +Requires Node.js `>=22.20.0`. ## Features - **Unified manager UI** - Local extensions (`~/.pi/agent/extensions`, `.pi/extensions`) and installed packages in one list - - Scope indicators (global/project), status indicators, update badges + - Grouped sections for local extensions vs installed packages + - Compact rows with selected-item details below the list, so large extension sets stay scannable + - Built-in search and filter shortcuts for large extension sets + - Scope indicators (global/project), status indicators, update badges, and package sizes when known - **Package extension configuration panel** - Configure individual extension entrypoints inside an installed package (`c` on package row) - Works with manifest-declared entrypoints and conventional `extensions/` package layouts - Persists to package filters in `settings.json` (no manual JSON editing) - **Safe staged local extension toggles** - - Toggle with `Space/Enter`, apply with `S` + - Toggle with `Space`, apply with `S` - Unsaved-change guard when leaving (save/discard/stay) - **Package management** - Install, update, remove from UI and command line - Quick actions (`A`, `u`, `X`) and bulk update (`U`) - **Remote discovery and install** - - npm search/browse with pagination + - npm search/browse with pagination, inline browse search, and keyboard page navigation + - Path- and git-like queries are handled explicitly instead of surfacing unrelated npm results - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path) - Supports direct GitHub `.ts` installs and standalone local install for self-contained packages - Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc` @@ -66,20 +70,26 @@ Open the manager: | Key | Action | | ------------- | ----------------------------------------------------- | | `↑↓` | Navigate | -| `Space/Enter` | Toggle local extension on/off | +| `PageUp/Down` | Jump through longer lists | +| `Home/End` | Jump to top or bottom | +| `Space` | Toggle selected local extension on/off | | `S` | Save local extension changes | -| `Enter` / `A` | Actions on selected package (configure/update/remove) | +| `Enter` / `A` | Actions on selected item | +| `/` / `Ctrl+F`| Search visible items | +| `Tab` / `Shift+Tab` | Cycle filters | +| `1-5` | Filters: All / Local / Packages / Updates / Disabled | | `c` | Configure selected package extensions | | `u` | Update selected package directly | +| `V` | View full details for selected item | | `X` | Remove selected item (package/local extension) | | `i` | Quick install by source | -| `f` | Quick search | +| `f` | Remote package search | | `U` | Update all packages | | `t` | Auto-update wizard | | `P` / `M` | Quick actions palette | | `R` | Browse remote packages | | `?` / `H` | Help | -| `Esc` | Exit | +| `Esc` | Clear search or exit | ### Commands diff --git a/package.json b/package.json index 29661ec..e3a04be 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,12 @@ "smoke-test": "node --import=tsx ./scripts/smoke-test.mjs", "test": "node --import=tsx --test ./test/*.test.ts", "check": "tsc --noEmit -p tsconfig.json && node --import=tsx ./scripts/smoke-test.mjs && node --import=tsx --test ./test/*.test.ts && pnpm run lint && pnpm run format:check", + "release": "release-it", + "release:patch": "release-it patch", + "release:minor": "release-it minor", + "release:major": "release-it major", + "release:first": "release-it --first-release", + "release:ci": "node --import=tsx ./scripts/release-ci.ts", "prepublishOnly": "pnpm run check", "prepare": "husky" }, @@ -45,8 +51,10 @@ "@biomejs/biome": "^2.4.9", "@mariozechner/pi-coding-agent": "^0.63.1", "@mariozechner/pi-tui": "^0.63.1", + "@release-it/conventional-changelog": "^10.0.5", "@types/node": "^22.19.10", "husky": "^9.1.7", + "release-it": "^19.2.4", "tsx": "^4.21.0", "typescript": "^5.9.3" }, @@ -54,7 +62,7 @@ "license": "MIT", "packageManager": "pnpm@10.33.0", "engines": { - "node": ">=22" + "node": ">=22.20.0" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a300228..ef36100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: '@mariozechner/pi-tui': specifier: ^0.63.1 version: 0.63.1 + '@release-it/conventional-changelog': + specifier: ^10.0.5 + version: 10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@19.2.4(@types/node@22.19.15)) '@types/node': specifier: ^22.19.10 version: 22.19.15 husky: specifier: ^9.1.7 version: 9.1.7 + release-it: + specifier: ^19.2.4 + version: 19.2.4(@types/node@22.19.15) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -238,6 +244,18 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@conventional-changelog/git-client@2.6.0': + resolution: {integrity: sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.3.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -403,6 +421,140 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@mariozechner/clipboard-darwin-arm64@0.3.2': resolution: {integrity: sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==} engines: {node: '>= 10'} @@ -496,6 +648,65 @@ packages: '@mistralai/mistralai@1.14.1': resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} + '@nodeutils/defaults-deep@1.1.0': + resolution: {integrity: sha512-gG44cwQovaOFdSR02jR9IhVRpnDP64VN6JdjYJTfNz4J4fWn7TQnmrf22nSjRqlwlxPcW8PL/L3KbJg3tdwvpg==} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@phun-ky/typeof@2.0.3': + resolution: {integrity: sha512-oeQJs1aa8Ghke8JIK9yuq/+KjMiaYeDZ38jx7MhkXncXlUKjqQ3wEm2X3qCKyjo+ZZofZj+WsEEiqkTtRuE2xQ==} + engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -526,9 +737,27 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@release-it/conventional-changelog@10.0.6': + resolution: {integrity: sha512-aUb0IkcsBTMcOH5PPQ9Jv9lEOOVu2+rSgkE1ny+dzsTziQm2BhDRAtaFK/dw/HflthuXMWrqhhyfJhAV1AOEPQ==} + engines: {node: ^20.12.0 || >=22.0.0} + peerDependencies: + release-it: ^18.0.0 || ^19.0.0 + '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/hosted-git-info@1.0.2': + resolution: {integrity: sha512-aAmGQdMH+ZinytKuA2832u0ATeOFNYNk4meBEXtB5xaPotUgggYNhq5tYU/v17wEbmTW5P9iHNqNrFyrhnqBAg==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + '@sinclair/typebox@0.34.48': resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} @@ -740,6 +969,13 @@ packages: '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/parse-path@7.1.0': + resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} + deprecated: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed. + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -776,10 +1012,16 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -791,6 +1033,9 @@ packages: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -807,6 +1052,21 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -815,11 +1075,40 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -830,6 +1119,60 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-changelog-preset-loader@5.0.0: + resolution: {integrity: sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==} + engines: {node: '>=18'} + + conventional-changelog-writer@8.4.0: + resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} + engines: {node: '>=18'} + hasBin: true + + conventional-changelog@7.2.0: + resolution: {integrity: sha512-BEdgG+vPl53EVlTTk9sZ96aagFp0AQ5pw/ggiQMy2SClLbTo1r0l+8dSg79gkLOO5DS1Lswuhp5fWn6RwE+ivg==} + engines: {node: '>=18'} + hasBin: true + + conventional-commits-filter@5.0.0: + resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + conventional-recommended-bump@11.2.0: + resolution: {integrity: sha512-lqIdmw330QdMBgfL0e6+6q5OMKyIpy4OZNmepit6FS3GldhkG+70drZjuZ0A5NFpze5j85dlYs3GabQXl6sMHw==} + engines: {node: '>=18'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -847,14 +1190,40 @@ packages: supports-color: optional: true + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.6: + resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + degenerator@5.0.1: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -891,6 +1260,17 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@4.5.0: + resolution: {integrity: sha512-qifAYjuW5AM1eEEIsFnOwB+TGqu6ynU3OKj9WbUTOtUBHFPZqL03XUW34kbp3zm19Ald+U8dEyRXaVsUck+Y1g==} + engines: {node: '>=20'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -899,6 +1279,9 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -912,9 +1295,21 @@ packages: resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -952,6 +1347,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -959,6 +1358,16 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-up@8.1.1: + resolution: {integrity: sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==} + + git-url-parse@16.1.0: + resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -974,6 +1383,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -981,6 +1395,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hosted-git-info@8.1.0: + resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} + engines: {node: ^18.17.0 || >=20.5.0} + hosted-git-info@9.0.2: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -993,11 +1411,19 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1005,14 +1431,70 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@12.11.1: + resolution: {integrity: sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-ssh@1.4.1: + resolution: {integrity: sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + issue-parser@7.0.1: + resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} + engines: {node: ^18.17 || >=20.6.1} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -1023,6 +1505,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -1032,9 +1517,37 @@ packages: koffi@2.15.2: resolution: {integrity: sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==} + lodash.capitalize@4.2.1: + resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.uniqby@4.7.0: + resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -1043,11 +1556,22 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + macos-release@3.4.0: + resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} hasBin: true + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -1056,10 +1580,21 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1067,29 +1602,71 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} + new-github-release-url@2.0.0: + resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + normalize-package-data@7.0.1: + resolution: {integrity: sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -1102,6 +1679,14 @@ packages: zod: optional: true + ora@9.0.0: + resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} + engines: {node: '>=20'} + + os-name@6.1.0: + resolution: {integrity: sha512-zBd1G8HkewNd2A8oQ8c6BN/f/c9EId7rSUueOLGu28govmUctXmM+3765GwsByv9nYUdrLqHphXlYIc86saYsg==} + engines: {node: '>=18'} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} @@ -1114,6 +1699,13 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} + parse-path@7.1.0: + resolution: {integrity: sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==} + + parse-url@9.2.0: + resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==} + engines: {node: '>=14.13.0'} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -1130,13 +1722,34 @@ packages: resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} engines: {node: '>=14.0.0'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -1144,6 +1757,9 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + protocols@2.0.2: + resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} + proxy-agent@6.5.0: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} @@ -1154,6 +1770,22 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + release-it@19.2.4: + resolution: {integrity: sha512-BwaJwQYUIIAKuDYvpqQTSoy0U7zIy6cHyEjih/aNaFICphGahia4cjDANuFXb7gVZ51hIK9W0io6fjNQWXqICg==} + engines: {node: ^20.12.0 || >=22.0.0} + hasBin: true + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1165,6 +1797,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -1173,12 +1809,48 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -1195,13 +1867,36 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1210,6 +1905,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strnum@2.2.2: resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} @@ -1228,6 +1927,14 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -1243,11 +1950,23 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -1255,14 +1974,54 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + undici@7.24.5: resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} engines: {node: '>=20.18.1'} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + + url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + windows-release@6.1.0: + resolution: {integrity: sha512-1lOb3qdzw6OFmOzoY0nauhLG72TpWtb5qgYPiSh/62rjc1XidBSDio2qw0pwHh17VINF217ebIkZJdFLZFn9SA==} + engines: {node: '>=18'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1282,6 +2041,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1295,6 +2058,10 @@ packages: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -1302,6 +2069,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -1737,6 +2508,15 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@conventional-changelog/git-client@2.6.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.7.4 + optionalDependencies: + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -1826,6 +2606,131 @@ snapshots: - supports-color - utf-8-validate + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.19.15)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.15) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/confirm@5.1.21(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/core@10.3.2(@types/node@22.19.15)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.15) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/editor@4.2.23(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/expand@4.0.23(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/number@3.0.23(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/password@4.0.23(@types/node@22.19.15)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/prompts@7.10.1(@types/node@22.19.15)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.19.15) + '@inquirer/confirm': 5.1.21(@types/node@22.19.15) + '@inquirer/editor': 4.2.23(@types/node@22.19.15) + '@inquirer/expand': 4.0.23(@types/node@22.19.15) + '@inquirer/input': 4.3.1(@types/node@22.19.15) + '@inquirer/number': 3.0.23(@types/node@22.19.15) + '@inquirer/password': 4.0.23(@types/node@22.19.15) + '@inquirer/rawlist': 4.1.11(@types/node@22.19.15) + '@inquirer/search': 3.2.2(@types/node@22.19.15) + '@inquirer/select': 4.4.2(@types/node@22.19.15) + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/rawlist@4.1.11(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/search@3.2.2(@types/node@22.19.15)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.15) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/select@4.4.2(@types/node@22.19.15)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.19.15) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.19.15 + + '@inquirer/type@3.0.10(@types/node@22.19.15)': + optionalDependencies: + '@types/node': 22.19.15 + '@mariozechner/clipboard-darwin-arm64@0.3.2': optional: true @@ -1944,24 +2849,93 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.63.1': + '@mariozechner/pi-tui@0.63.1': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + marked: 15.0.12 + mime-types: 3.0.2 + optionalDependencies: + koffi: 2.15.2 + + '@mistralai/mistralai@1.14.1': + dependencies: + ws: 8.20.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@nodeutils/defaults-deep@1.1.0': + dependencies: + lodash: 4.18.1 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 - marked: 15.0.12 - mime-types: 3.0.2 - optionalDependencies: - koffi: 2.15.2 + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 - '@mistralai/mistralai@1.14.1': + '@octokit/request-error@7.1.0': dependencies: - ws: 8.20.0 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.8': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@phun-ky/typeof@2.0.3': {} '@protobufjs/aspromise@1.1.2': {} @@ -1986,8 +2960,30 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@release-it/conventional-changelog@10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@19.2.4(@types/node@22.19.15))': + dependencies: + '@conventional-changelog/git-client': 2.6.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + concat-stream: 2.0.0 + conventional-changelog: 7.2.0(conventional-commits-filter@5.0.0) + conventional-changelog-angular: 8.3.1 + conventional-changelog-conventionalcommits: 9.3.1 + conventional-recommended-bump: 11.2.0 + release-it: 19.2.4(@types/node@22.19.15) + semver: 7.7.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + '@silvia-odwyer/photon-node@0.3.4': {} + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/hosted-git-info@1.0.2': {} + + '@simple-libs/stream-utils@1.2.0': {} + '@sinclair/typebox@0.34.48': {} '@smithy/abort-controller@4.2.12': @@ -2312,6 +3308,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/normalize-package-data@2.4.4': {} + + '@types/parse-path@7.1.0': + dependencies: + parse-path: 7.1.0 + '@types/retry@0.12.0': {} '@types/yauzl@2.10.3': @@ -2342,16 +3344,24 @@ snapshots: any-promise@1.3.0: {} + array-ify@1.0.0: {} + ast-types@0.13.4: dependencies: tslib: 2.8.1 + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + balanced-match@4.0.4: {} base64-js@1.5.1: {} basic-ftp@5.2.0: {} + before-after-hook@4.0.0: {} + bignumber.js@9.3.1: {} bowser@2.14.1: {} @@ -2364,6 +3374,27 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.3: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.6 + dotenv: 17.4.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2371,6 +3402,24 @@ snapshots: chalk@5.6.2: {} + chardet@2.1.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -2380,6 +3429,10 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.0 + cli-spinners@3.4.0: {} + + cli-width@4.1.0: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -2392,6 +3445,75 @@ snapshots: color-name@1.1.4: {} + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.2.4: {} + + consola@3.4.2: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-preset-loader@5.0.0: {} + + conventional-changelog-writer@8.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + conventional-commits-filter: 5.0.0 + handlebars: 4.7.9 + meow: 13.2.0 + semver: 7.7.4 + + conventional-changelog@7.2.0(conventional-commits-filter@5.0.0): + dependencies: + '@conventional-changelog/git-client': 2.6.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + '@simple-libs/hosted-git-info': 1.0.2 + '@types/normalize-package-data': 2.4.4 + conventional-changelog-preset-loader: 5.0.0 + conventional-changelog-writer: 8.4.0 + conventional-commits-parser: 6.4.0 + fd-package-json: 2.0.0 + meow: 13.2.0 + normalize-package-data: 7.0.1 + transitivePeerDependencies: + - conventional-commits-filter + + conventional-commits-filter@5.0.0: {} + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + conventional-recommended-bump@11.2.0: + dependencies: + '@conventional-changelog/git-client': 2.6.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + conventional-changelog-preset-loader: 5.0.0 + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + meow: 13.2.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -2400,14 +3522,33 @@ snapshots: dependencies: ms: 2.1.3 + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.6: {} + degenerator@5.0.1: dependencies: ast-types: 0.13.4 escodegen: 2.1.0 esprima: 4.0.1 + destr@2.0.5: {} + diff@8.0.4: {} + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.1: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -2463,6 +3604,22 @@ snapshots: esutils@2.0.3: {} + eta@4.5.0: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + exsolve@1.0.8: {} + extend@3.0.2: {} extract-zip@2.0.1: @@ -2475,6 +3632,8 @@ snapshots: transitivePeerDependencies: - supports-color + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-uri@3.1.0: {} @@ -2489,10 +3648,18 @@ snapshots: path-expression-matcher: 1.2.0 strnum: 2.2.2 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -2538,6 +3705,8 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@8.0.1: {} + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -2550,6 +3719,24 @@ snapshots: transitivePeerDependencies: - supports-color + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.6 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + git-up@8.1.1: + dependencies: + is-ssh: 1.4.1 + parse-url: 9.2.0 + + git-url-parse@16.1.0: + dependencies: + git-up: 8.1.1 + glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -2571,10 +3758,23 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} highlight.js@10.7.3: {} + hosted-git-info@8.1.0: + dependencies: + lru-cache: 10.4.3 + hosted-git-info@9.0.2: dependencies: lru-cache: 11.2.7 @@ -2593,16 +3793,70 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@5.0.0: {} + husky@9.1.7: {} + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@7.0.5: {} + inherits@2.0.4: {} + + inquirer@12.11.1(@types/node@22.19.15): + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/prompts': 7.10.1(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) + mute-stream: 2.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 22.19.15 + ip-address@10.1.0: {} + is-docker@3.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-obj@2.0.0: {} + + is-ssh@1.4.1: + dependencies: + protocols: 2.0.2 + + is-stream@3.0.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + issue-parser@7.0.1: + dependencies: + lodash.capitalize: 4.2.1 + lodash.escaperegexp: 4.1.2 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.uniqby: 4.7.0 + + jiti@2.6.1: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -2614,6 +3868,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-with-bigint@3.5.8: {} + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -2628,55 +3884,148 @@ snapshots: koffi@2.15.2: optional: true + lodash.capitalize@4.2.1: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.uniqby@4.7.0: {} + + lodash@4.18.1: {} + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + long@5.3.2: {} + lru-cache@10.4.3: {} + lru-cache@11.2.7: {} lru-cache@7.18.3: {} + macos-release@3.4.0: {} + marked@15.0.12: {} + meow@13.2.0: {} + + merge-stream@2.0.0: {} + mime-db@1.54.0: {} mime-types@3.0.2: dependencies: mime-db: 1.54.0 + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 + minimist@1.2.8: {} + minipass@7.1.3: {} ms@2.1.3: {} + mute-stream@2.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + neo-async@2.6.2: {} + netmask@2.0.2: {} + new-github-release-url@2.0.0: + dependencies: + type-fest: 2.19.0 + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + normalize-package-data@7.0.1: + dependencies: + hosted-git-info: 8.1.0 + semver: 7.7.4 + validate-npm-package-license: 3.0.4 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nypm@0.6.5: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.0.4 + object-assign@4.1.1: {} + ohash@2.0.11: {} + once@1.4.0: dependencies: wrappy: 1.0.2 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + openai@6.26.0(ws@8.20.0)(zod@4.3.6): optionalDependencies: ws: 8.20.0 zod: 4.3.6 + ora@9.0.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.2.2 + string-width: 8.2.0 + strip-ansi: 7.2.0 + + os-name@6.1.0: + dependencies: + macos-release: 3.4.0 + windows-release: 6.1.0 + p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 @@ -2700,6 +4049,15 @@ snapshots: degenerator: 5.0.1 netmask: 2.0.2 + parse-path@7.1.0: + dependencies: + protocols: 2.0.2 + + parse-url@9.2.0: + dependencies: + '@types/parse-path': 7.1.0 + parse-path: 7.1.0 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -2712,13 +4070,29 @@ snapshots: path-expression-matcher@1.2.0: {} + path-key@3.1.1: {} + + path-key@4.0.0: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 minipass: 7.1.3 + pathe@2.0.3: {} + pend@1.2.0: {} + perfect-debounce@2.1.0: {} + + picomatch@4.0.4: {} + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -2740,6 +4114,8 @@ snapshots: '@types/node': 22.19.15 long: 5.3.2 + protocols@2.0.2: {} + proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 @@ -2760,20 +4136,90 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + rc9@2.1.2: + dependencies: + defu: 6.1.6 + destr: 2.0.5 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@5.0.0: {} + + release-it@19.2.4(@types/node@22.19.15): + dependencies: + '@nodeutils/defaults-deep': 1.1.0 + '@octokit/rest': 22.0.1 + '@phun-ky/typeof': 2.0.3 + async-retry: 1.3.3 + c12: 3.3.3 + ci-info: 4.4.0 + eta: 4.5.0 + git-url-parse: 16.1.0 + inquirer: 12.11.1(@types/node@22.19.15) + issue-parser: 7.0.1 + lodash.merge: 4.6.2 + mime-types: 3.0.2 + new-github-release-url: 2.0.0 + open: 10.2.0 + ora: 9.0.0 + os-name: 6.1.0 + proxy-agent: 6.5.0 + semver: 7.7.3 + tinyglobby: 0.2.15 + undici: 6.23.0 + url-join: 5.0.0 + wildcard-match: 5.1.4 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - magicast + - supports-color + require-directory@2.1.1: {} require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} retry@0.13.1: {} + run-applescript@7.1.0: {} + + run-async@4.0.6: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + + semver@7.7.3: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -2789,17 +4235,41 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 - source-map@0.6.1: - optional: true + source-map@0.6.1: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} std-env@3.10.0: {} + stdin-discarder@0.2.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2808,6 +4278,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@3.0.0: {} + strnum@2.2.2: {} strtok3@10.3.5: @@ -2826,6 +4298,13 @@ snapshots: dependencies: any-promise: 1.3.0 + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 @@ -2843,16 +4322,56 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@2.19.0: {} + + typedarray@0.0.6: {} + typescript@5.9.3: {} + uglify-js@3.19.3: + optional: true + uint8array-extras@1.5.0: {} undici-types@6.21.0: {} + undici@6.23.0: {} + undici@7.24.5: {} + universal-user-agent@7.0.3: {} + + url-join@5.0.0: {} + + util-deprecate@1.0.2: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + walk-up-path@4.0.0: {} + web-streams-polyfill@3.3.3: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wildcard-match@5.1.4: {} + + windows-release@6.1.0: + dependencies: + execa: 8.0.1 + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -2863,12 +4382,18 @@ snapshots: ws@8.20.0: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + y18n@5.0.8: {} yaml@2.8.3: {} yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.0: dependencies: cliui: 7.0.4 @@ -2884,6 +4409,8 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} zod-to-json-schema@3.25.1(zod@4.3.6): diff --git a/scripts/release-ci.ts b/scripts/release-ci.ts new file mode 100644 index 0000000..8fcf662 --- /dev/null +++ b/scripts/release-ci.ts @@ -0,0 +1,66 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const VALID_RELEASE_TYPES = ["patch", "minor", "major"] as const; + +type ReleaseType = (typeof VALID_RELEASE_TYPES)[number]; + +export function normalizeReleaseType(rawReleaseType: unknown): ReleaseType { + const releaseType = typeof rawReleaseType === "string" ? rawReleaseType.trim().toLowerCase() : ""; + + if (!VALID_RELEASE_TYPES.includes(releaseType as ReleaseType)) { + throw new Error( + `Invalid RELEASE_TYPE: ${JSON.stringify(rawReleaseType)}. Expected one of: ${VALID_RELEASE_TYPES.join(", ")}` + ); + } + + return releaseType as ReleaseType; +} + +export function parseFirstRelease(rawFirstRelease: unknown): boolean { + return typeof rawFirstRelease === "string" && rawFirstRelease.trim().toLowerCase() === "true"; +} + +export function buildReleaseItArgs(options: { + releaseType: unknown; + firstRelease: boolean; +}): string[] { + const args = ["exec", "release-it", normalizeReleaseType(options.releaseType), "--ci"]; + + if (options.firstRelease) { + args.push("--first-release"); + } + + return args; +} + +function run(): never { + const args = buildReleaseItArgs({ + releaseType: process.env.RELEASE_TYPE, + firstRelease: parseFirstRelease(process.env.FIRST_RELEASE), + }); + + console.log(`[release-ci] Running: pnpm ${args.join(" ")}`); + + const result = spawnSync("pnpm", args, { + stdio: "inherit", + env: process.env, + }); + + if (typeof result.status === "number") { + process.exit(result.status); + } + + if (result.error) { + throw result.error; + } + + process.exit(1); +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMain) { + run(); +} diff --git a/src/commands/auto-update.ts b/src/commands/auto-update.ts index fc22a0f..ee6c274 100644 --- a/src/commands/auto-update.ts +++ b/src/commands/auto-update.ts @@ -54,7 +54,7 @@ export async function handleAutoUpdateSubcommand( " 3d - Check every 3 days", " 1w - Check weekly", " 2w - Check every 2 weeks", - " 1mo - Check monthly (1m also works)", + " 1mo - Check monthly", " daily - Check daily (alias)", " weekly - Check weekly (alias)", ]; diff --git a/src/commands/history.ts b/src/commands/history.ts index 822ce73..b9e39b1 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -6,6 +6,7 @@ import { queryGlobalHistory, querySessionChanges, } from "../utils/history.js"; +import { parseLookbackDuration } from "../utils/duration.js"; import { notify } from "../utils/notify.js"; import { formatListOutput } from "../utils/ui-helpers.js"; @@ -37,36 +38,6 @@ type HistoryOptionHandler = (tokens: string[], index: number, state: HistoryPars const HISTORY_ACTION_SET = new Set(HISTORY_ACTIONS); -function parseHistorySinceDuration(input: string): number | undefined { - const normalized = input.toLowerCase().trim(); - const match = normalized.match( - /^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|wk|wks|week|weeks|mo|mos|month|months)$/ - ); - if (!match) return undefined; - - const value = Number.parseInt(match[1] ?? "", 10); - if (!Number.isFinite(value) || value <= 0) return undefined; - - const unit = match[2] ?? ""; - if (unit.startsWith("m") && !unit.startsWith("mo")) { - return value * 60 * 1000; - } - if (unit.startsWith("h")) { - return value * 60 * 60 * 1000; - } - if (unit.startsWith("d")) { - return value * 24 * 60 * 60 * 1000; - } - if (unit.startsWith("w")) { - return value * 7 * 24 * 60 * 60 * 1000; - } - if (unit.startsWith("mo")) { - return value * 30 * 24 * 60 * 60 * 1000; - } - - return undefined; -} - const HISTORY_OPTION_HANDLERS: Record = { "--help": (_tokens, _index, state) => { state.showHelp = true; @@ -142,7 +113,7 @@ const HISTORY_OPTION_HANDLERS: Record = { return 0; } - const ms = parseHistorySinceDuration(value); + const ms = parseLookbackDuration(value); if (!ms) { state.errors.push(`Invalid --since duration: ${value}`); } else { diff --git a/src/constants.ts b/src/constants.ts index c7d1cbe..9486c62 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,14 +26,6 @@ export const TIMEOUTS = { npmView: 10_000, /** Full package installation timeout (3 minutes) */ packageInstall: 180_000, - /** Package update timeout (2 minutes) */ - packageUpdate: 120_000, - /** Bulk package update timeout (5 minutes) */ - packageUpdateAll: 300_000, - /** Package removal timeout (1 minute) */ - packageRemove: 60_000, - /** Package listing timeout */ - listPackages: 10_000, /** Package metadata fetch timeout */ fetchPackageInfo: 30_000, /** Package extraction timeout */ diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index f26e567..2f5c8ed 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -10,8 +10,13 @@ import { readdir, rename, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { DISABLED_SUFFIX } from "../constants.js"; +import { readPackageManifest } from "../packages/extensions.js"; import { type ExtensionEntry, type Scope, type State } from "../types/index.js"; import { fileExists, readSummary } from "../utils/fs.js"; +import { + normalizeRelativePath, + resolveRelativePathSelection, +} from "../utils/relative-path-selection.js"; interface RootConfig { root: string; @@ -94,8 +99,7 @@ async function discoverInRoot( } if (item.isDirectory()) { - const entry = await parseDirectoryIndex(root, label, scope, name); - if (entry) found.push(entry); + found.push(...(await parseDirectoryExtensions(root, label, scope, name))); } } @@ -141,53 +145,131 @@ async function parseTopLevelFile( }; } +function stripDisabledSuffix(path: string): string { + return path.replace(/\.(ts|js)\.disabled$/i, ".$1"); +} + +function isExtensionEntrypointPath(path: string): boolean { + return /\.(ts|js)$/i.test(path); +} + +function isLocalExtensionFile(path: string): boolean { + return /\.(ts|js)(?:\.disabled)?$/i.test(path); +} + +async function collectLocalExtensionFiles(rootDir: string, startDir: string): Promise { + const collected: string[] = []; + + let entries: Dirent[]; + try { + entries = await readdir(startDir, { withFileTypes: true }); + } catch { + return collected; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + + const absolutePath = join(startDir, entry.name); + if (entry.isDirectory()) { + collected.push(...(await collectLocalExtensionFiles(rootDir, absolutePath))); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const relativePath = normalizeRelativePath(relative(rootDir, absolutePath)); + if (isLocalExtensionFile(relativePath)) { + collected.push(stripDisabledSuffix(relativePath)); + } + } + + return collected; +} + +async function resolveManifestLocalEntrypoints(dir: string): Promise { + const manifest = await readPackageManifest(dir); + const extensions = manifest?.pi?.extensions; + if (!Array.isArray(extensions)) { + return undefined; + } + + const entries = extensions.filter((value): value is string => typeof value === "string"); + const allFiles = await collectLocalExtensionFiles(dir, dir); + return resolveRelativePathSelection( + allFiles, + entries, + (path, files) => isExtensionEntrypointPath(path) && files.includes(path) + ); +} + +async function toDirectoryExtensionEntry( + root: string, + label: string, + scope: Scope, + dir: string, + extensionPath: string +): Promise { + const normalizedPath = normalizeRelativePath(extensionPath); + const activePath = join(dir, normalizedPath); + const disabledPath = `${activePath}${DISABLED_SUFFIX}`; + + let state: State; + let summaryPath: string; + if (await fileExists(activePath)) { + state = "enabled"; + summaryPath = activePath; + } else if (await fileExists(disabledPath)) { + state = "disabled"; + summaryPath = disabledPath; + } else { + return undefined; + } + + return { + id: `${scope}:${activePath}`, + scope, + state, + activePath, + disabledPath, + displayName: `${label}/${normalizeRelativePath(relative(root, activePath))}`, + summary: await readSummary(summaryPath), + }; +} + /** - * Parse a directory containing an index.ts/js file as an extension entry. - * - * @param root - Root directory path - * @param label - Display label for the root - * @param scope - "global" or "project" - * @param dirName - Name of the directory to parse - * @returns ExtensionEntry if index file found, undefined otherwise + * Parse a directory containing a manifest-declared entrypoint or index.ts/js file as one or more + * extension entries. */ -async function parseDirectoryIndex( +async function parseDirectoryExtensions( root: string, label: string, scope: Scope, dirName: string -): Promise { +): Promise { const dir = join(root, dirName); + const manifestEntrypoints = await resolveManifestLocalEntrypoints(dir); - for (const ext of [".ts", ".js"]) { - const activePath = join(dir, `index${ext}`); - const disabledPath = `${activePath}${DISABLED_SUFFIX}`; - - if (await fileExists(activePath)) { - return { - id: `${scope}:${activePath}`, - scope, - state: "enabled", - activePath, - disabledPath, - displayName: `${label}/${dirName}/index${ext}`, - summary: await readSummary(activePath), - }; - } - - if (await fileExists(disabledPath)) { - return { - id: `${scope}:${activePath}`, - scope, - state: "disabled", - activePath, - disabledPath, - displayName: `${label}/${dirName}/index${ext}`, - summary: await readSummary(disabledPath), - }; - } + if (manifestEntrypoints !== undefined) { + const entries = await Promise.all( + manifestEntrypoints.map((extensionPath) => + toDirectoryExtensionEntry(root, label, scope, dir, extensionPath) + ) + ); + return entries.filter((entry): entry is ExtensionEntry => Boolean(entry)); } - return undefined; + const fallbackEntries = await Promise.all( + ["index.ts", "index.js"].map((extensionPath) => + toDirectoryExtensionEntry(root, label, scope, dir, extensionPath) + ) + ); + + return fallbackEntries.filter((entry): entry is ExtensionEntry => Boolean(entry)).slice(0, 1); } /** diff --git a/src/packages/discovery.ts b/src/packages/discovery.ts index a18f469..d77f2d1 100644 --- a/src/packages/discovery.ts +++ b/src/packages/discovery.ts @@ -28,6 +28,14 @@ interface NpmSearchResultObject { description?: string; keywords?: string[]; date?: string; + publisher?: { + username?: string; + email?: string; + }; + maintainers?: Array<{ + username?: string; + email?: string; + }>; }; } @@ -79,6 +87,31 @@ import { setCachedSearch, } from "../utils/cache.js"; +function getNpmPackageAuthor( + pkg: NonNullable +): string | undefined { + const publisher = pkg.publisher; + if (publisher?.username?.trim()) { + return publisher.username.trim(); + } + + if (publisher?.email?.trim()) { + return publisher.email.trim(); + } + + const maintainerWithUsername = pkg.maintainers?.find((entry) => entry.username?.trim()); + if (maintainerWithUsername?.username?.trim()) { + return maintainerWithUsername.username.trim(); + } + + const maintainerWithEmail = pkg.maintainers?.find((entry) => entry.email?.trim()); + if (maintainerWithEmail?.email?.trim()) { + return maintainerWithEmail.email.trim(); + } + + return undefined; +} + function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined { const pkg = entry.package; if (!pkg) return undefined; @@ -90,6 +123,7 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined { name, version: pkg.version, description: pkg.description, + author: getNpmPackageAuthor(pkg), keywords: Array.isArray(pkg.keywords) ? pkg.keywords : undefined, date: pkg.date, }; diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index d5381fe..e89ad87 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import { type Dirent } from "node:fs"; import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, matchesGlob, relative, resolve } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; @@ -14,6 +14,11 @@ import { } from "../types/index.js"; import { parseNpmSource } from "../utils/format.js"; import { fileExists, readSummary } from "../utils/fs.js"; +import { + matchesFilterPattern, + normalizeRelativePath, + resolveRelativePathSelection, +} from "../utils/relative-path-selection.js"; import { resolveNpmCommand } from "../utils/npm-exec.js"; interface PackageSettingsObject { @@ -36,11 +41,6 @@ export interface PackageManifest { const execFileAsync = promisify(execFile); let globalNpmRootCache: string | null | undefined; -function normalizeRelativePath(value: string): string { - const normalized = value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""); - return normalized; -} - function normalizeSource(source: string): string { return source .trim() @@ -238,17 +238,17 @@ function toPackageSettingsObject( packageSource: string ): PackageSettingsObject { if (typeof existing === "string") { - return { source: existing, extensions: [] }; + return { source: existing }; } if (existing && typeof existing.source === "string") { return { source: existing.source, - extensions: Array.isArray(existing.extensions) ? [...existing.extensions] : [], + ...(Array.isArray(existing.extensions) ? { extensions: [...existing.extensions] } : {}), }; } - return { source: packageSource, extensions: [] }; + return { source: packageSource }; } function updateExtensionMarkers( @@ -276,7 +276,16 @@ function updateExtensionMarkers( for (const [extensionPath, target] of Array.from(changes.entries()).sort((a, b) => a[0].localeCompare(b[0]) )) { - nextTokens.push(`${target === "enabled" ? "+" : "-"}${extensionPath}`); + const baseFilters = + nextTokens.length > 0 + ? nextTokens + : existingTokens && existingTokens.length === 0 + ? [] + : undefined; + const baseState = getPackageFilterState(baseFilters, extensionPath); + if (target !== baseState) { + nextTokens.push(`${target === "enabled" ? "+" : "-"}${extensionPath}`); + } } return nextTokens; @@ -322,10 +331,13 @@ export async function applyPackageExtensionStateChanges( packageEntry.extensions = updateExtensionMarkers(packageEntry.extensions, normalizedChanges); + const normalizedPackageEntry = + packageEntry.extensions.length > 0 ? packageEntry : packageEntry.source; + if (index === -1) { - packages.push(packageEntry); + packages.push(normalizedPackageEntry); } else { - packages[index] = packageEntry; + packages[index] = normalizedPackageEntry; } settings.packages = packages; @@ -340,22 +352,6 @@ export async function applyPackageExtensionStateChanges( } } -function safeMatchesGlob(targetPath: string, pattern: string): boolean { - try { - return matchesGlob(targetPath, pattern); - } catch { - return false; - } -} - -function matchesFilterPattern(targetPath: string, pattern: string): boolean { - const normalizedPattern = normalizeRelativePath(pattern.trim()); - if (!normalizedPattern) return false; - if (targetPath === normalizedPattern) return true; - - return safeMatchesGlob(targetPath, normalizedPattern); -} - function getPackageFilterState(filters: string[] | undefined, extensionPath: string): State { // Omitted key => all enabled (pi default). if (filters === undefined) { @@ -415,58 +411,37 @@ function getPackageFilterState(filters: string[] | undefined, extensionPath: str return enabled ? "enabled" : "disabled"; } -async function getPackageExtensionState( - packageSource: string, - extensionPath: string, +async function readPackageFilterMap( scope: Scope, cwd: string -): Promise { - const settingsPath = getSettingsPath(scope, cwd); - const settings = await readSettingsFile(settingsPath); +): Promise> { + const settings = await readSettingsFile(getSettingsPath(scope, cwd)); const packages = settings.packages ?? []; - const normalizedSource = normalizeSource(packageSource); + const filterMap = new Map(); - const entry = packages.find((pkg) => { - if (typeof pkg === "string") { - return normalizeSource(pkg) === normalizedSource; + for (const entry of packages) { + if (typeof entry === "string") { + filterMap.set(normalizeSource(entry), undefined); + continue; } - return normalizeSource(pkg.source) === normalizedSource; - }); - if (!entry || typeof entry === "string") { - return "enabled"; + if (typeof entry.source !== "string") { + continue; + } + + filterMap.set( + normalizeSource(entry.source), + Array.isArray(entry.extensions) ? entry.extensions : undefined + ); } - return getPackageFilterState(entry.extensions, extensionPath); + return filterMap; } function isExtensionEntrypointPath(path: string): boolean { return /\.(ts|js)$/i.test(path); } -function hasGlobMagic(path: string): boolean { - return /[*?{}[\]]/.test(path); -} - -function isSafeRelativePath(path: string): boolean { - return path !== "" && path !== ".." && !path.startsWith("../") && !path.includes("/../"); -} - -function selectDirectoryFiles(allFiles: string[], directoryPath: string): string[] { - const prefix = `${directoryPath}/`; - return allFiles.filter((file) => file.startsWith(prefix)); -} - -function applySelection(selected: Set, files: Iterable, exclude: boolean): void { - for (const file of files) { - if (exclude) { - selected.delete(file); - } else { - selected.add(file); - } - } -} - async function collectExtensionFilesFromDir( packageRoot: string, startDir: string @@ -505,38 +480,12 @@ async function resolveManifestExtensionEntries( packageRoot: string, entries: string[] ): Promise { - const selected = new Set(); const allFiles = await collectExtensionFilesFromDir(packageRoot, packageRoot); - - for (const rawToken of entries) { - const token = rawToken.trim(); - if (!token) continue; - - const exclude = token.startsWith("!"); - const normalizedToken = normalizeRelativePath(exclude ? token.slice(1) : token); - const pattern = normalizedToken.replace(/[\\/]+$/g, ""); - if (!isSafeRelativePath(pattern)) { - continue; - } - - if (hasGlobMagic(pattern)) { - const matchedFiles = allFiles.filter((file) => matchesFilterPattern(file, pattern)); - applySelection(selected, matchedFiles, exclude); - continue; - } - - const directoryFiles = selectDirectoryFiles(allFiles, pattern); - if (directoryFiles.length > 0) { - applySelection(selected, directoryFiles, exclude); - continue; - } - - if (isExtensionEntrypointPath(pattern)) { - applySelection(selected, [pattern], exclude); - } - } - - return Array.from(selected).sort((a, b) => a.localeCompare(b)); + return resolveRelativePathSelection( + allFiles, + entries, + (path, files) => isExtensionEntrypointPath(path) && files.includes(path) + ); } export async function readPackageManifest( @@ -617,11 +566,19 @@ export async function discoverPackageExtensions( cwd: string ): Promise { const entries: PackageExtensionEntry[] = []; + const [globalFilterMap, projectFilterMap] = await Promise.all([ + readPackageFilterMap("global", cwd), + readPackageFilterMap("project", cwd), + ]); for (const pkg of packages) { const packageRoot = await toPackageRoot(pkg, cwd); if (!packageRoot) continue; + const packageFilters = + (pkg.scope === "global" ? globalFilterMap : projectFilterMap).get( + normalizeSource(pkg.source) + ) ?? undefined; const extensionPaths = await discoverPackageExtensionEntrypoints(packageRoot); for (const extensionPath of extensionPaths) { const normalizedPath = normalizeRelativePath(extensionPath); @@ -629,7 +586,7 @@ export async function discoverPackageExtensions( const summary = (await fileExists(absolutePath)) ? await readSummary(absolutePath) : "package extension"; - const state = await getPackageExtensionState(pkg.source, normalizedPath, pkg.scope, cwd); + const state = getPackageFilterState(packageFilters, normalizedPath); entries.push({ id: `pkg-ext:${pkg.scope}:${pkg.source}:${normalizedPath}`, diff --git a/src/packages/install.ts b/src/packages/install.ts index c141f05..2c5c4b3 100644 --- a/src/packages/install.ts +++ b/src/packages/install.ts @@ -11,6 +11,7 @@ import { } from "@mariozechner/pi-coding-agent"; import { TIMEOUTS } from "../constants.js"; import { runTaskWithLoader } from "../ui/async-task.js"; +import { parseChoiceByLabel } from "../utils/command.js"; import { normalizePackageSource } from "../utils/format.js"; import { fileExists } from "../utils/fs.js"; import { logPackageInstall } from "../utils/history.js"; @@ -32,6 +33,17 @@ export interface InstallOptions { scope?: InstallScope; } +export interface InstallOutcome { + installed: boolean; + reloaded: boolean; +} + +const INSTALL_SCOPE_CHOICES = { + global: "Global (~/.pi/agent/settings.json)", + project: "Project (.pi/settings.json)", + cancel: "Cancel", +} as const; + function getProgressMessage(event: ProgressEvent, fallback: string): string { return event.message?.trim() || fallback; } @@ -44,14 +56,12 @@ async function resolveInstallScope( if (!ctx.hasUI) return "global"; - const choice = await ctx.ui.select("Install scope", [ - "Global (~/.pi/agent/settings.json)", - "Project (.pi/settings.json)", - "Cancel", - ]); + const choice = parseChoiceByLabel( + INSTALL_SCOPE_CHOICES, + await ctx.ui.select("Install scope", Object.values(INSTALL_SCOPE_CHOICES)) + ); - if (!choice || choice === "Cancel") return undefined; - return choice.startsWith("Project") ? "project" : "global"; + return choice === "cancel" ? undefined : choice; } function getExtensionInstallDir(ctx: ExtensionCommandContext, scope: InstallScope): string { @@ -61,28 +71,6 @@ function getExtensionInstallDir(ctx: ExtensionCommandContext, scope: InstallScop return join(homedir(), ".pi", "agent", "extensions"); } -interface GithubUrlInfo { - owner: string; - repo: string; - branch: string; - filePath: string; -} - -/** - * Safely extracts regex match groups with validation - */ -function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo | undefined { - if (!match) return undefined; - - const [, owner, repo, branch, filePath] = match; - - if (!owner || !repo || !branch || !filePath) { - return undefined; - } - - return { owner, repo, branch, filePath }; -} - async function ensureTarAvailable( pi: ExtensionAPI, ctx: ExtensionCommandContext @@ -157,36 +145,35 @@ async function cleanupStandaloneTempArtifacts(tempDir: string, extractDir?: stri ); } -export async function installPackage( +async function installPackageInternal( source: string, ctx: ExtensionCommandContext, pi: ExtensionAPI, options?: InstallOptions -): Promise { +): Promise { const scope = await resolveInstallScope(ctx, options?.scope); if (!scope) { notify(ctx, "Installation cancelled.", "info"); - return; + return { installed: false, reloaded: false }; } // Check if it's a GitHub URL to a .ts file - handle as direct download const githubTsMatch = source.match( /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+\.ts)$/ ); - const githubInfo = safeExtractGithubMatch(githubTsMatch); - if (githubInfo) { - const rawUrl = `https://raw.githubusercontent.com/${githubInfo.owner}/${githubInfo.repo}/${githubInfo.branch}/${githubInfo.filePath}`; - const fileName = - githubInfo.filePath.split("/").pop() || `${githubInfo.owner}-${githubInfo.repo}.ts`; - await installFromUrl(rawUrl, fileName, ctx, pi, { scope }); - return; + if (githubTsMatch) { + const [, owner, repo, branch, filePath] = githubTsMatch; + if (owner && repo && branch && filePath) { + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`; + const fileName = filePath.split("/").pop() || `${owner}-${repo}.ts`; + return await installFromUrl(rawUrl, fileName, ctx, pi, { scope }); + } } // Check if it's already a raw URL to a .ts file if (source.match(/^https:\/\/raw\.githubusercontent\.com\/.*\.ts$/)) { const fileName = source.split("/").pop() || "extension.ts"; - await installFromUrl(source, fileName, ctx, pi, { scope }); - return; + return await installFromUrl(source, fileName, ctx, pi, { scope }); } const normalized = normalizePackageSource(source); @@ -199,7 +186,7 @@ export async function installPackage( ); if (!confirmed) { notify(ctx, "Installation cancelled.", "info"); - return; + return { installed: false, reloaded: false }; } showProgress(ctx, "Installing", normalized); @@ -226,7 +213,7 @@ export async function installPackage( logPackageInstall(pi, normalized, normalized, undefined, scope, false, errorMsg); notifyError(ctx, errorMsg); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } clearSearchCache(); @@ -238,6 +225,26 @@ export async function installPackage( if (!reloaded) { void updateExtmgrStatus(ctx, pi); } + + return { installed: true, reloaded }; +} + +export async function installPackage( + source: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + options?: InstallOptions +): Promise { + await installPackageInternal(source, ctx, pi, options); +} + +export async function installPackageWithOutcome( + source: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + options?: InstallOptions +): Promise { + return installPackageInternal(source, ctx, pi, options); } export async function installFromUrl( @@ -246,11 +253,11 @@ export async function installFromUrl( ctx: ExtensionCommandContext, pi: ExtensionAPI, options?: InstallOptions -): Promise { +): Promise { const scope = await resolveInstallScope(ctx, options?.scope); if (!scope) { notify(ctx, "Installation cancelled.", "info"); - return; + return { installed: false, reloaded: false }; } const extensionDir = getExtensionInstallDir(ctx, scope); @@ -263,7 +270,7 @@ export async function installFromUrl( ); if (!confirmed) { notify(ctx, "Installation cancelled.", "info"); - return; + return { installed: false, reloaded: false }; } const result = await tryOperation( @@ -289,7 +296,7 @@ export async function installFromUrl( if (!result) { logPackageInstall(pi, url, fileName, undefined, scope, false, "Installation failed"); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } const { fileName: name, destPath } = result; @@ -300,6 +307,8 @@ export async function installFromUrl( if (!reloaded) { void updateExtmgrStatus(ctx, pi); } + + return { installed: true, reloaded }; } /** @@ -324,16 +333,16 @@ function parsePackageInfo(viewOutput: string): { version: string; tarballUrl: st } } -export async function installPackageLocally( +async function installPackageLocallyInternal( packageName: string, ctx: ExtensionCommandContext, pi: ExtensionAPI, options?: InstallOptions -): Promise { +): Promise { const scope = await resolveInstallScope(ctx, options?.scope); if (!scope) { notify(ctx, "Installation cancelled.", "info"); - return; + return { installed: false, reloaded: false }; } const extensionDir = getExtensionInstallDir(ctx, scope); @@ -346,7 +355,7 @@ export async function installPackageLocally( ); if (!confirmed) { notify(ctx, "Installation cancelled.", "info"); - return; + return { installed: false, reloaded: false }; } const result = await tryOperation( @@ -384,7 +393,7 @@ export async function installPackageLocally( "Failed to fetch package info" ); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } const { version, tarballUrl } = result; @@ -401,7 +410,7 @@ export async function installPackageLocally( tarAvailability.error ); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } // Download and extract @@ -439,7 +448,7 @@ export async function installPackageLocally( "Download failed" ); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } const { tarballPath } = extractResult; @@ -496,7 +505,7 @@ export async function installPackageLocally( "Extraction failed" ); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } // Copy to extensions dir @@ -527,7 +536,7 @@ export async function installPackageLocally( "Failed to copy extension" ); void updateExtmgrStatus(ctx, pi); - return; + return { installed: false, reloaded: false }; } clearSearchCache(); @@ -538,4 +547,24 @@ export async function installPackageLocally( if (!reloaded) { void updateExtmgrStatus(ctx, pi); } + + return { installed: true, reloaded }; +} + +export async function installPackageLocally( + packageName: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + options?: InstallOptions +): Promise { + await installPackageLocallyInternal(packageName, ctx, pi, options); +} + +export async function installPackageLocallyWithOutcome( + packageName: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + options?: InstallOptions +): Promise { + return installPackageLocallyInternal(packageName, ctx, pi, options); } diff --git a/src/packages/management.ts b/src/packages/management.ts index f49c689..5028cc5 100644 --- a/src/packages/management.ts +++ b/src/packages/management.ts @@ -10,6 +10,7 @@ import { import { UI } from "../constants.js"; import { type InstalledPackage } from "../types/index.js"; import { runTaskWithLoader } from "../ui/async-task.js"; +import { parseChoiceByLabel } from "../utils/command.js"; import { formatInstalledPackageLabel } from "../utils/format.js"; import { logPackageRemove, logPackageUpdate } from "../utils/history.js"; import { requireUI } from "../utils/mode.js"; @@ -34,17 +35,13 @@ export interface PackageMutationOutcome { reloaded: boolean; } -const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = { - reloaded: false, -}; - const BULK_UPDATE_LABEL = "all packages"; - -function packageMutationOutcome( - overrides: Partial -): PackageMutationOutcome { - return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides }; -} +const REMOVAL_SCOPE_CHOICES = { + both: "Both global + project", + global: "Global only", + project: "Project only", + cancel: "Cancel", +} as const; function getProgressMessage(event: ProgressEvent, fallback: string): string { return event.message?.trim() || fallback; @@ -70,7 +67,7 @@ async function updatePackageInternal( logPackageUpdate(pi, source, source, undefined, true); clearUpdatesAvailable(pi, ctx, [updateIdentity]); void updateExtmgrStatus(ctx, pi); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } await runTaskWithLoader( @@ -94,7 +91,7 @@ async function updatePackageInternal( logPackageUpdate(pi, source, source, undefined, false, errorMsg); notifyError(ctx, errorMsg); void updateExtmgrStatus(ctx, pi); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } logPackageUpdate(pi, source, source, undefined, true); @@ -105,7 +102,7 @@ async function updatePackageInternal( if (!reloaded) { void updateExtmgrStatus(ctx, pi); } - return packageMutationOutcome({ reloaded }); + return { reloaded }; } async function updatePackagesInternal( @@ -121,7 +118,7 @@ async function updatePackagesInternal( logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true); clearUpdatesAvailable(pi, ctx); void updateExtmgrStatus(ctx, pi); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } await runTaskWithLoader( @@ -145,7 +142,7 @@ async function updatePackagesInternal( logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, false, errorMsg); notifyError(ctx, errorMsg); void updateExtmgrStatus(ctx, pi); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true); @@ -156,7 +153,7 @@ async function updatePackagesInternal( if (!reloaded) { void updateExtmgrStatus(ctx, pi); } - return packageMutationOutcome({ reloaded }); + return { reloaded }; } export async function updatePackage( @@ -230,25 +227,15 @@ interface RemovalTarget { name: string; } -function scopeChoiceFromLabel(choice: string | undefined): RemovalScopeChoice { - if (!choice || choice === "Cancel") return "cancel"; - if (choice.includes("Both")) return "both"; - if (choice.includes("Global")) return "global"; - if (choice.includes("Project")) return "project"; - return "cancel"; -} - async function selectRemovalScope(ctx: ExtensionCommandContext): Promise { if (!ctx.hasUI) return "global"; - const choice = await ctx.ui.select("Remove scope", [ - "Both global + project", - "Global only", - "Project only", - "Cancel", - ]); - - return scopeChoiceFromLabel(choice); + return ( + parseChoiceByLabel( + REMOVAL_SCOPE_CHOICES, + await ctx.ui.select("Remove scope", Object.values(REMOVAL_SCOPE_CHOICES)) + ) ?? "cancel" + ); } function buildRemovalTargets( @@ -374,18 +361,18 @@ async function removePackageInternal( if (scopeChoice === "cancel") { notify(ctx, "Removal cancelled.", "info"); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } if (matching.length === 0) { notify(ctx, `${source} is not installed.`, "info"); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } const targets = buildRemovalTargets(matching, ctx.hasUI, scopeChoice); if (targets.length === 0) { notify(ctx, "Nothing to remove.", "info"); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } const confirmed = await confirmAction( @@ -396,7 +383,7 @@ async function removePackageInternal( ); if (!confirmed) { notify(ctx, "Removal cancelled.", "info"); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } const results = await executeRemovalTargets(targets, ctx, pi); @@ -424,7 +411,7 @@ async function removePackageInternal( if (successfulRemovalCount === 0) { void updateExtmgrStatus(ctx, pi); - return NO_PACKAGE_MUTATION_OUTCOME; + return { reloaded: false }; } const reloaded = await confirmReload(ctx, "Removal complete."); @@ -432,7 +419,7 @@ async function removePackageInternal( void updateExtmgrStatus(ctx, pi); } - return packageMutationOutcome({ reloaded }); + return { reloaded }; } export async function removePackage( diff --git a/src/types/index.ts b/src/types/index.ts index a9ce88a..e17891c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,7 @@ export interface NpmPackage { name: string; version?: string | undefined; description?: string | undefined; + author?: string | undefined; keywords?: string[] | undefined; date?: string | undefined; size?: number | undefined; // Package size in bytes @@ -97,7 +98,7 @@ export type BrowseAction = | { type: "prev" } | { type: "next" } | { type: "refresh" } + | { type: "search"; query: string } + | { type: "install" } | { type: "menu" } - | { type: "main" } - | { type: "help" } | { type: "cancel" }; diff --git a/src/ui/footer.ts b/src/ui/footer.ts index a1d80ed..0218063 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -4,18 +4,24 @@ import { type State, type UnifiedItem } from "../types/index.js"; export interface FooterState { - hasLocals: boolean; - hasPackages: boolean; + selectedType?: UnifiedItem["type"]; + pendingChanges: number; } -/** - * Build footer state from visible items. - */ -export function buildFooterState(items: UnifiedItem[]): FooterState { - return { - hasLocals: items.some((i) => i.type === "local"), - hasPackages: items.some((i) => i.type === "package"), +export function buildFooterState( + staged: Map, + byId: Map, + selectedItem?: UnifiedItem +): FooterState { + const state: FooterState = { + pendingChanges: getPendingToggleChangeCount(staged, byId), }; + + if (selectedItem) { + state.selectedType = selectedItem.type; + } + + return state; } export function getPendingToggleChangeCount( @@ -37,27 +43,41 @@ export function getPendingToggleChangeCount( } /** - * Build keyboard shortcuts text for the footer. + * Build contextual keyboard shortcuts text for the footer. */ export function buildFooterShortcuts(state: FooterState): string { const parts: string[] = []; - parts.push("↑↓ Navigate"); - - if (state.hasLocals) parts.push("Space/Enter Toggle"); - if (state.hasLocals) parts.push("S Save"); - if (state.hasPackages) parts.push("Enter/A Actions"); - if (state.hasPackages) parts.push("c Configure"); - if (state.hasPackages) parts.push("u Update"); - if (state.hasPackages || state.hasLocals) parts.push("X Remove"); - - parts.push("i Install"); - parts.push("f Search"); - parts.push("U Update all"); - parts.push("t Auto-update"); - parts.push("P Palette"); - parts.push("R Browse"); - parts.push("? Help"); - parts.push("Esc Cancel"); - - return parts.join(" | "); + + if (state.selectedType === "local") { + parts.push("Space toggle"); + parts.push("Enter/A actions"); + parts.push("V details"); + parts.push("X remove"); + } + + if (state.selectedType === "package") { + parts.push("Enter/A actions"); + parts.push("V details"); + parts.push("c configure"); + parts.push("u update"); + parts.push("X remove"); + } + + if (state.pendingChanges > 0) { + parts.push(`S save (${state.pendingChanges})`); + } + + parts.push("/ search"); + parts.push("Tab filters"); + parts.push("1-5 filters"); + parts.push("i install"); + parts.push("f remote search"); + parts.push("U update all"); + parts.push("t auto-update"); + parts.push("P palette"); + parts.push("R browse"); + parts.push("? help"); + parts.push("Esc clear/cancel"); + + return parts.join(" · "); } diff --git a/src/ui/help.ts b/src/ui/help.ts index 08c4306..bee6ae6 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -2,6 +2,7 @@ * Help display */ import { type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { notify } from "../utils/notify.js"; export function showHelp(ctx: ExtensionCommandContext): void { const lines = [ @@ -9,25 +10,33 @@ export function showHelp(ctx: ExtensionCommandContext): void { "", "Unified View:", " Local extensions and npm/git packages are displayed together", + " The list is grouped into Local extensions and Installed packages sections", + " Rows stay compact; details for the selected item appear below the list", " Local extensions show ● enabled / ○ disabled with G/P scope", - " Packages show 📦 with name@version and G/P scope", + " Packages show a source-type icon with name@version, scope, and size when known", "", "Navigation:", " ↑↓ Navigate list", - " Space/Enter Toggle local extension enabled/disabled", + " PageUp/Down Jump through longer lists", + " Home/End Jump to top or bottom", + " Space Toggle selected local extension enabled/disabled", " S Save changes to local extensions", - " Enter/A Open actions for selected package", + " Enter/A Open actions for the selected item", + " / or Ctrl+F Search visible items", + " Tab/Shift+Tab Cycle filters", + " 1-5 Quick filters: All / Local / Packages / Updates / Disabled", " c Configure selected package extensions (reload after save)", " u Update selected package", + " V View full details for the selected item", " X Remove selected item (package or local extension)", " i Quick install by source", - " f Quick search", + " f Remote package search", " U Update all packages", " t Auto-update wizard", " P/M Quick actions palette", " R Browse remote packages", " ?/H Show this help", - " Esc Cancel", + " Esc Clear search or cancel", "", "Extension Sources:", " - ~/.pi/agent/extensions/ (global - G)", @@ -49,10 +58,5 @@ export function showHelp(ctx: ExtensionCommandContext): void { " /extensions auto-update Show or change update schedule", ]; - const output = lines.join("\n"); - if (ctx.hasUI) { - ctx.ui.notify(output, "info"); - } else { - console.log(output); - } + notify(ctx, lines.join("\n"), "info"); } diff --git a/src/ui/remote.ts b/src/ui/remote.ts index adee817..fdc56d5 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -5,22 +5,42 @@ import { DynamicBorder, type ExtensionAPI, type ExtensionCommandContext, + type Theme, } from "@mariozechner/pi-coding-agent"; -import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; -import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS } from "../constants.js"; import { + Container, + fuzzyMatch, + type Focusable, + getKeybindings, + Input, + Key, + matchesKey, + Spacer, + Text, + truncateToWidth, + wrapTextWithAnsi, +} from "@mariozechner/pi-tui"; +import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS, UI } from "../constants.js"; +import { + clearSearchCache, getSearchCache, isCacheValid, searchNpmPackages, setSearchCache, } from "../packages/discovery.js"; -import { installPackage, installPackageLocally } from "../packages/install.js"; -import { type BrowseAction, type NpmPackage } from "../types/index.js"; +import { + installPackage, + installPackageLocallyWithOutcome, + installPackageWithOutcome, +} from "../packages/install.js"; +import { type BrowseAction, type NpmPackage, type SearchCache } from "../types/index.js"; import { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js"; -import { dynamicTruncate, formatBytes, truncate } from "../utils/format.js"; +import { formatBytes, normalizePackageSource, parseNpmSource, truncate } from "../utils/format.js"; import { requireCustomUI, runCustomUI } from "../utils/mode.js"; +import { fetchWithTimeout } from "../utils/network.js"; import { notify } from "../utils/notify.js"; import { execNpm } from "../utils/npm-exec.js"; +import { getPackageSourceKind } from "../utils/package-source.js"; import { runTaskWithLoader } from "./async-task.js"; interface PackageInfoCacheEntry { @@ -98,6 +118,30 @@ const packageInfoCache = new PackageInfoCache( export function clearRemotePackageInfoCache(): void { packageInfoCache.clear(); + clearCommunityBrowseCache(); +} + +function getCommunityBrowseCache(): SearchCache | null { + const cache = getSearchCache(); + if (!cache || cache.query !== COMMUNITY_BROWSE_QUERY) { + return null; + } + + return isCacheValid(COMMUNITY_BROWSE_QUERY) ? cache : null; +} + +function setCommunityBrowseCache(results: NpmPackage[]): void { + setSearchCache({ + query: COMMUNITY_BROWSE_QUERY, + results, + timestamp: Date.now(), + }); +} + +function clearCommunityBrowseCache(): void { + if (getSearchCache()?.query === COMMUNITY_BROWSE_QUERY) { + clearSearchCache(); + } } const REMOTE_MENU_CHOICES = { @@ -113,6 +157,218 @@ const PACKAGE_DETAILS_CHOICES = { back: "Back to results", } as const; +const COMMUNITY_BROWSE_QUERY = "keywords:pi-package"; + +type RemoteBrowseSource = "community" | "npm"; + +type RemoteBrowseQueryPlan = + | { + kind: "browse"; + rawQuery: typeof COMMUNITY_BROWSE_QUERY; + searchQuery: typeof COMMUNITY_BROWSE_QUERY; + displayQuery: ""; + title: "Community packages"; + } + | { + kind: "search"; + rawQuery: string; + searchQuery: string; + displayQuery: string; + title: string; + exactPackageName?: string; + } + | { + kind: "unsupported"; + rawQuery: string; + message: string; + }; + +function findExactPackageLookup(query: string): string | undefined { + if (!query || /\s/.test(query)) { + return undefined; + } + + const parsed = parseNpmSource(normalizePackageSource(query)); + if (!parsed?.name) { + return undefined; + } + + if (query.startsWith("npm:") || Boolean(parsed.version) || parsed.name.startsWith("@")) { + return parsed.name.toLowerCase(); + } + + return undefined; +} + +function buildUnsupportedSearchMessage(query: string, kind: "local" | "git"): string { + const label = truncate(query, 60); + const sourceLabel = kind === "local" ? "local path" : "git source"; + return `"${label}" looks like a ${sourceLabel}. Remote browse searches npm package names and keywords. Use Install by source instead.`; +} + +function createRemoteBrowseQueryPlan(query: string): RemoteBrowseQueryPlan { + const trimmed = query.trim(); + if (!trimmed || trimmed === COMMUNITY_BROWSE_QUERY) { + return { + kind: "browse", + rawQuery: COMMUNITY_BROWSE_QUERY, + searchQuery: COMMUNITY_BROWSE_QUERY, + displayQuery: "", + title: "Community packages", + }; + } + + const sourceKind = getPackageSourceKind(trimmed); + if (sourceKind === "local" || sourceKind === "git") { + return { + kind: "unsupported", + rawQuery: trimmed, + message: buildUnsupportedSearchMessage(trimmed, sourceKind), + }; + } + + const exactPackageName = findExactPackageLookup(trimmed); + return { + kind: "search", + rawQuery: trimmed, + searchQuery: exactPackageName ?? trimmed, + displayQuery: trimmed, + title: "Remote packages", + ...(exactPackageName ? { exactPackageName } : {}), + }; +} + +function createCommunityBrowsePlan( + query: string +): Exclude { + const trimmed = query.trim(); + if (!trimmed || trimmed === COMMUNITY_BROWSE_QUERY) { + return { + kind: "browse", + rawQuery: COMMUNITY_BROWSE_QUERY, + searchQuery: COMMUNITY_BROWSE_QUERY, + displayQuery: "", + title: "Community packages", + }; + } + + return { + kind: "search", + rawQuery: trimmed, + searchQuery: COMMUNITY_BROWSE_QUERY, + displayQuery: trimmed, + title: "Community packages", + }; +} + +function resolveRemoteBrowseSource(query: string, source?: RemoteBrowseSource): RemoteBrowseSource { + if (source) { + return source; + } + + const trimmed = query.trim(); + return !trimmed || trimmed === COMMUNITY_BROWSE_QUERY ? "community" : "npm"; +} + +function getCommunitySearchFields(pkg: NpmPackage): { + primary: string[]; + secondary: string[]; +} { + return { + primary: [pkg.name, pkg.author ?? ""] + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0), + secondary: [pkg.description ?? "", ...(pkg.keywords ?? [])] + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0), + }; +} + +function scoreCommunityBrowseResult(pkg: NpmPackage, query: string): number | undefined { + const tokens = query + .trim() + .toLowerCase() + .split(/\s+/) + .filter((token) => token.length > 0); + if (tokens.length === 0) { + return 0; + } + + const fields = getCommunitySearchFields(pkg); + let totalScore = 0; + + for (const token of tokens) { + const primarySubstringScore = fields.primary.reduce((best, field) => { + const index = field.indexOf(token); + if (index < 0) { + return best; + } + return best === undefined ? index : Math.min(best, index); + }, undefined); + if (primarySubstringScore !== undefined) { + totalScore += primarySubstringScore; + continue; + } + + const secondarySubstringScore = fields.secondary.reduce((best, field) => { + const index = field.indexOf(token); + if (index < 0) { + return best; + } + const score = 100 + index; + return best === undefined ? score : Math.min(best, score); + }, undefined); + if (secondarySubstringScore !== undefined) { + totalScore += secondarySubstringScore; + continue; + } + + const primaryFuzzyScore = fields.primary.reduce((best, field) => { + const match = fuzzyMatch(token, field); + if (!match.matches) { + return best; + } + const score = 200 + match.score; + return best === undefined ? score : Math.min(best, score); + }, undefined); + if (primaryFuzzyScore !== undefined) { + totalScore += primaryFuzzyScore; + continue; + } + + return undefined; + } + + return totalScore; +} + +function filterCommunityBrowseResults(packages: NpmPackage[], query: string): NpmPackage[] { + const matches = packages + .map((pkg, index) => ({ + pkg, + index, + score: scoreCommunityBrowseResult(pkg, query), + })) + .filter( + (match): match is { pkg: NpmPackage; index: number; score: number } => + match.score !== undefined + ); + + matches.sort((a, b) => a.score - b.score || a.index - b.index); + return matches.map((match) => match.pkg); +} + +function filterRemoteBrowseResults( + plan: Exclude, + packages: NpmPackage[] +): NpmPackage[] { + if (plan.kind !== "search" || !plan.exactPackageName) { + return packages; + } + + return packages.filter((pkg) => pkg.name.toLowerCase() === plan.exactPackageName); +} + function createAbortError(): Error { const error = new Error("Operation cancelled"); error.name = "AbortError"; @@ -134,15 +390,13 @@ async function fetchWeeklyDownloads( packageName: string, signal?: AbortSignal ): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), TIMEOUTS.weeklyDownloads); - const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; - try { const encoded = encodeURIComponent(packageName); - const res = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encoded}`, { - signal: combinedSignal, - }); + const res = await fetchWithTimeout( + `https://api.npmjs.org/downloads/point/last-week/${encoded}`, + TIMEOUTS.weeklyDownloads, + signal + ); if (!res.ok) return undefined; const data = (await res.json()) as NpmDownloadsPoint; @@ -152,8 +406,6 @@ async function fetchWeeklyDownloads( throw error; } return undefined; - } finally { - clearTimeout(timer); } } @@ -238,7 +490,7 @@ export async function showRemote( return; case "browse": case "": - await browseRemotePackages(ctx, "keywords:pi-package", pi); + await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi); return; } @@ -256,7 +508,7 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P switch (choice) { case "browse": - await browseRemotePackages(ctx, "keywords:pi-package", pi); + await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi); return; case "search": await promptSearch(ctx, pi); @@ -269,9 +521,287 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P } } +function formatRemotePackageLabel(pkg: NpmPackage, theme: Theme): string { + const name = theme.bold(pkg.name); + const version = pkg.version ? theme.fg("dim", `@${pkg.version}`) : ""; + return `${name}${version}`; +} + +function formatRemotePackageDetails( + pkg: NpmPackage, + selectedNumber: number, + totalResults: number +): string { + const parts = [ + pkg.description || "No description", + pkg.author ? `by ${pkg.author}` : undefined, + `result ${selectedNumber} of ${totalResults}`, + pkg.keywords?.length ? `keywords: ${pkg.keywords.slice(0, 5).join(", ")}` : undefined, + pkg.date ? `updated ${pkg.date.slice(0, 10)}` : undefined, + ]; + + return parts.filter(Boolean).join(" • "); +} + +class RemotePackageBrowser implements Focusable { + private readonly searchInput = new Input(); + private selectedIndex = 0; + private searchActive = false; + private _focused = false; + + constructor( + private readonly packages: NpmPackage[], + private readonly theme: Theme, + private readonly browseSource: RemoteBrowseSource, + private readonly queryLabel: string, + private readonly totalResults: number, + private readonly offset: number, + private readonly maxVisibleItems: number, + private readonly showPrevious: boolean, + private readonly showLoadMore: boolean, + private readonly onAction: (action: BrowseAction) => void + ) { + this.searchInput.setValue(queryLabel); + } + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value && this.searchActive; + } + + invalidate(): void { + this.searchInput.invalidate(); + } + + handleBrowseInput(data: string): boolean { + const kb = getKeybindings(); + + if (this.searchActive) { + if (matchesKey(data, Key.enter)) { + this.searchActive = false; + this.searchInput.focused = false; + this.onAction({ type: "search", query: this.searchInput.getValue().trim() }); + return true; + } + + if (matchesKey(data, Key.escape)) { + this.searchActive = false; + this.searchInput.focused = false; + this.searchInput.setValue(this.queryLabel); + return true; + } + + this.searchInput.handleInput(data); + return true; + } + + if (data === "/" || matchesKey(data, Key.ctrl("f"))) { + this.searchActive = true; + this.searchInput.setValue(""); + this.searchInput.focused = this._focused; + return true; + } + + if (kb.matches(data, "tui.select.up")) { + this.moveSelection(-1); + return true; + } + + if (kb.matches(data, "tui.select.down")) { + this.moveSelection(1); + return true; + } + + if (kb.matches(data, "tui.select.pageUp")) { + this.moveSelection(-Math.max(1, this.maxVisibleItems - 1)); + return true; + } + + if (kb.matches(data, "tui.select.pageDown")) { + this.moveSelection(Math.max(1, this.maxVisibleItems - 1)); + return true; + } + + if (matchesKey(data, Key.home)) { + this.selectedIndex = 0; + return true; + } + + if (matchesKey(data, Key.end)) { + this.selectedIndex = Math.max(0, this.packages.length - 1); + return true; + } + + const selected = this.packages[this.selectedIndex]; + if (selected && matchesKey(data, Key.enter)) { + this.onAction({ type: "package", name: selected.name }); + return true; + } + + if ((data === "p" || data === "P") && this.showPrevious) { + this.onAction({ type: "prev" }); + return true; + } + + if ((data === "n" || data === "N") && this.showLoadMore) { + this.onAction({ type: "next" }); + return true; + } + + if (data === "r" || data === "R") { + this.onAction({ type: "refresh" }); + return true; + } + + if (data === "i") { + this.onAction({ type: "install" }); + return true; + } + + if (data === "m" || data === "M") { + this.onAction({ type: "menu" }); + return true; + } + + if (matchesKey(data, Key.escape)) { + this.onAction({ type: "cancel" }); + return true; + } + + return false; + } + + render(width: number): string[] { + const lines: string[] = []; + + if (this.searchActive) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } else if (this.queryLabel) { + lines.push( + truncateToWidth(this.theme.fg("accent", ` Search: ${this.queryLabel}`), width, "") + ); + lines.push(""); + } else { + lines.push( + this.theme.fg( + "muted", + this.browseSource === "community" + ? " Browse community packages · / search to filter loaded packages" + : " Browse remote search results · / search to search npm packages" + ) + ); + lines.push(""); + } + + lines.push(truncateToWidth(this.buildSummaryLine(), width, "")); + lines.push(""); + + const { startIndex, endIndex } = this.getVisibleRange(); + for (const pkg of this.packages.slice(startIndex, endIndex)) { + lines.push(this.renderPackageLine(pkg, width)); + } + + if (startIndex > 0 || endIndex < this.packages.length) { + lines.push(""); + lines.push( + this.theme.fg( + "dim", + ` Showing ${startIndex + 1}-${endIndex} of ${this.packages.length} on this page` + ) + ); + } + + const selected = this.packages[this.selectedIndex]; + if (selected) { + lines.push(""); + const detailText = formatRemotePackageDetails( + selected, + this.offset + this.selectedIndex + 1, + this.totalResults + ); + for (const line of wrapTextWithAnsi(detailText, width - 4)) { + lines.push(this.theme.fg("dim", ` ${line}`)); + } + } + + lines.push(""); + lines.push(truncateToWidth(this.buildFooterLine(), width, "")); + return lines; + } + + private buildSummaryLine(): string { + const pageCount = Math.max(1, Math.ceil(this.totalResults / PAGE_SIZE)); + const pageNumber = Math.floor(this.offset / PAGE_SIZE) + 1; + const rangeEnd = this.offset + this.packages.length; + const label = this.queryLabel + ? `Search: ${truncate(this.queryLabel, 40)}` + : "Community packages"; + return ` ${this.theme.fg("accent", label)} • ${this.theme.fg("muted", `${this.offset + 1}-${rangeEnd} of ${this.totalResults}`)} • ${this.theme.fg("muted", `page ${pageNumber}/${pageCount}`)}`; + } + + private buildFooterLine(): string { + const parts = ["Enter details", "/ search"]; + + if (this.showPrevious) { + parts.push("p prev"); + } + if (this.showLoadMore) { + parts.push("n next"); + } + + parts.push("r refresh", "i install", "m menu", "Esc back"); + return ` ${this.theme.fg("dim", parts.join(" · "))}`; + } + + private renderPackageLine(pkg: NpmPackage, width: number): string { + const prefix = + this.packages[this.selectedIndex]?.name === pkg.name ? this.theme.fg("accent", "→ ") : " "; + return truncateToWidth(prefix + formatRemotePackageLabel(pkg, this.theme), width); + } + + private moveSelection(delta: number): void { + if (this.packages.length === 0) { + this.selectedIndex = 0; + return; + } + + const nextIndex = this.selectedIndex + delta; + if (nextIndex < 0) { + this.selectedIndex = 0; + return; + } + + if (nextIndex >= this.packages.length) { + this.selectedIndex = this.packages.length - 1; + return; + } + + this.selectedIndex = nextIndex; + } + + private getVisibleRange(): { startIndex: number; endIndex: number } { + const maxVisible = Math.max(1, this.maxVisibleItems); + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(maxVisible / 2), + Math.max(0, this.packages.length - maxVisible) + ) + ); + const endIndex = Math.min(startIndex + maxVisible, this.packages.length); + return { startIndex, endIndex }; + } +} + async function selectBrowseAction( ctx: ExtensionCommandContext, - titleText: string, + plan: Exclude, + browseSource: RemoteBrowseSource, packages: NpmPackage[], offset: number, totalResults: number, @@ -280,77 +810,56 @@ async function selectBrowseAction( ): Promise { if (!ctx.hasUI) return undefined; - const items: SelectItem[] = packages.map((p) => ({ - value: `pkg:${p.name}`, - label: `${p.name}${p.version ? ` @${p.version}` : ""}`, - description: dynamicTruncate(p.description || "No description", 35), - })); - - if (showPrevious) { - items.push({ value: "nav:prev", label: "◀ Previous page" }); - } - if (showLoadMore) { - items.push({ - value: "nav:next", - label: `▶ Next page (${offset + 1}-${offset + packages.length} of ${totalResults})`, - }); - } - items.push({ value: "nav:refresh", label: "🔄 Refresh search" }); - items.push({ value: "nav:menu", label: "← Back to menu" }); - return runCustomUI(ctx, "Remote package browsing", () => ctx.ui.custom((tui, theme, _keybindings, done) => { const container = new Container(); - const title = new Text("", 1, 0); - const footer = new Text("", 1, 0); + const title = new Text("", 2, 0); + const browser = new RemotePackageBrowser( + packages, + theme, + browseSource, + plan.displayQuery, + totalResults, + offset, + Math.max(4, Math.min(UI.maxListHeight, tui.terminal.rows - 10)), + showPrevious, + showLoadMore, + done + ); const syncThemedContent = (): void => { - title.setText(theme.fg("accent", theme.bold(titleText))); - footer.setText(theme.fg("dim", "↑↓ wraps • enter select • esc cancel")); + title.setText(theme.fg("accent", theme.bold(plan.title))); }; + syncThemedContent(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(title); - - const selectList = new SelectList(items, Math.min(items.length, 12), { - selectedPrefix: (t) => theme.fg("accent", t), - selectedText: (t) => theme.fg("accent", t), - description: (t) => theme.fg("muted", t), - scrollInfo: (t) => theme.fg("dim", t), - noMatch: (t) => theme.fg("warning", t), - }); - - selectList.onSelect = (item) => { - if (item.value === "nav:prev") { - done({ type: "prev" }); - } else if (item.value === "nav:next") { - done({ type: "next" }); - } else if (item.value === "nav:refresh") { - done({ type: "refresh" }); - } else if (item.value === "nav:menu") { - done({ type: "menu" }); - } else if (item.value.startsWith("pkg:")) { - done({ type: "package", name: item.value.slice(4) }); - } else { - done({ type: "cancel" }); - } - }; - - selectList.onCancel = () => done({ type: "cancel" }); - - syncThemedContent(); - container.addChild(selectList); - container.addChild(footer); + container.addChild(new Spacer(1)); + container.addChild(browser); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + let focused = false; + return { - render: (w: number) => container.render(w), - invalidate: () => { + get focused() { + return focused; + }, + set focused(value: boolean) { + focused = value; + browser.focused = value; + }, + render(width: number) { + syncThemedContent(); + return container.render(width); + }, + invalidate() { container.invalidate(); + browser.invalidate(); syncThemedContent(); }, - handleInput: (data: string) => { - selectList.handleInput(data); - tui.requestRender(); + handleInput(data: string) { + if (browser.handleBrowseInput(data)) { + tui.requestRender(); + } }, }; }) @@ -361,7 +870,8 @@ export async function browseRemotePackages( ctx: ExtensionCommandContext, query: string, pi: ExtensionAPI, - offset = 0 + offset = 0, + source?: RemoteBrowseSource ): Promise { if ( !requireCustomUI( @@ -373,25 +883,49 @@ export async function browseRemotePackages( return; } + const browseSource = resolveRemoteBrowseSource(query, source); + const plan = + browseSource === "community" + ? createCommunityBrowsePlan(query) + : createRemoteBrowseQueryPlan(query); + if (plan.kind === "unsupported") { + notify(ctx, plan.message, "warning"); + return; + } + + const cacheQuery = browseSource === "community" ? COMMUNITY_BROWSE_QUERY : plan.rawQuery; let allPackages: NpmPackage[] | undefined; - if (isCacheValid(query)) { - const cache = getSearchCache(); + if (browseSource === "community") { + const cache = getCommunityBrowseCache(); if (cache) { + allPackages = filterCommunityBrowseResults(cache.results, plan.displayQuery); + } + } else if (isCacheValid(cacheQuery)) { + const cache = getSearchCache(); + if (cache?.query === cacheQuery) { allPackages = cache.results; } } if (!allPackages) { + const searchLabel = + browseSource === "community" + ? "community packages" + : plan.displayQuery || "community packages"; const results = await runTaskWithLoader( ctx, { - title: "Remote Packages", - message: `Searching npm for ${truncate(query, 40)}...`, + title: plan.title, + message: `Searching npm for ${truncate(searchLabel, 40)}...`, }, async ({ signal, setMessage }) => { - setMessage(`Searching npm for ${truncate(query, 40)}...`); - return searchNpmPackages(query, ctx, { signal }); + setMessage(`Searching npm for ${truncate(searchLabel, 40)}...`); + return searchNpmPackages( + browseSource === "community" ? COMMUNITY_BROWSE_QUERY : plan.searchQuery, + ctx, + { signal } + ); } ); @@ -400,40 +934,44 @@ export async function browseRemotePackages( return; } - allPackages = results; - setSearchCache({ - query, - results: allPackages, - timestamp: Date.now(), - }); + if (browseSource === "community") { + setCommunityBrowseCache(results); + allPackages = filterCommunityBrowseResults(results, plan.displayQuery); + } else { + allPackages = filterRemoteBrowseResults(plan, results); + setSearchCache({ + query: plan.rawQuery, + results: allPackages, + timestamp: Date.now(), + }); + } } - // Apply pagination from cached/filtered results const totalResults = allPackages.length; const packages = allPackages.slice(offset, offset + PAGE_SIZE); + const reloadQuery = + browseSource === "community" ? plan.displayQuery || COMMUNITY_BROWSE_QUERY : plan.rawQuery; if (packages.length === 0) { - const msg = offset > 0 ? "No more packages to show." : `No packages found for: ${query}`; + const msg = + offset > 0 + ? "No more packages to show." + : `No packages found for: ${plan.displayQuery || "community packages"}`; ctx.ui.notify(msg, "info"); if (offset > 0) { - await browseRemotePackages(ctx, query, pi, 0); + await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource); } return; } - // Add navigation options const showLoadMore = totalResults >= PAGE_SIZE && offset + PAGE_SIZE < totalResults; const showPrevious = offset > 0; - const titleText = - offset > 0 - ? `Search Results (${offset + 1}-${offset + packages.length} of ${totalResults})` - : `Search: ${truncate(query, 40)} (${totalResults})`; - const result = await selectBrowseAction( ctx, - titleText, + plan, + browseSource, packages, offset, totalResults, @@ -445,23 +983,51 @@ export async function browseRemotePackages( return; } - // Handle result switch (result.type) { case "prev": - await browseRemotePackages(ctx, query, pi, Math.max(0, offset - PAGE_SIZE)); + await browseRemotePackages( + ctx, + reloadQuery, + pi, + Math.max(0, offset - PAGE_SIZE), + browseSource + ); return; case "next": - await browseRemotePackages(ctx, query, pi, offset + PAGE_SIZE); + await browseRemotePackages(ctx, reloadQuery, pi, offset + PAGE_SIZE, browseSource); return; case "refresh": - setSearchCache(null); - await browseRemotePackages(ctx, query, pi, 0); + if (browseSource === "community") { + clearCommunityBrowseCache(); + } else { + clearSearchCache(); + } + await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource); + return; + case "search": { + const nextQuery = result.query.trim(); + if (browseSource === "community") { + await browseRemotePackages(ctx, nextQuery || COMMUNITY_BROWSE_QUERY, pi, 0, "community"); + return; + } + await browseRemotePackages( + ctx, + nextQuery || COMMUNITY_BROWSE_QUERY, + pi, + 0, + nextQuery ? "npm" : undefined + ); + return; + } + case "install": + await promptInstall(ctx, pi); + await browseRemotePackages(ctx, reloadQuery, pi, offset, browseSource); return; case "menu": await showRemoteMenu(ctx, pi); return; case "package": - await showPackageDetails(result.name, ctx, pi, query, offset); + await showPackageDetails(result.name, ctx, pi, reloadQuery, offset, browseSource); return; } } @@ -471,7 +1037,8 @@ async function showPackageDetails( ctx: ExtensionCommandContext, pi: ExtensionAPI, previousQuery: string, - previousOffset: number + previousOffset: number, + browseSource?: RemoteBrowseSource ): Promise { if (!ctx.hasUI) { console.log(`Package: ${packageName}`); @@ -484,12 +1051,30 @@ async function showPackageDetails( ); switch (choice) { - case "installManaged": - await installPackage(`npm:${packageName}`, ctx, pi); + case "installManaged": { + const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi); + if (outcome.reloaded) { + return; + } + if (outcome.installed) { + await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); + return; + } + await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); return; - case "installStandalone": - await installPackageLocally(packageName, ctx, pi); + } + case "installStandalone": { + const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi); + if (outcome.reloaded) { + return; + } + if (outcome.installed) { + await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); + return; + } + await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); return; + } case "viewInfo": try { const text = await runTaskWithLoader( @@ -503,7 +1088,14 @@ async function showPackageDetails( if (!text) { notify(ctx, `Loading ${packageName} details was cancelled.`, "info"); - await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset); + await showPackageDetails( + packageName, + ctx, + pi, + previousQuery, + previousOffset, + browseSource + ); return; } @@ -512,10 +1104,10 @@ async function showPackageDetails( const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Package: ${packageName}\n${message}`, "warning"); } - await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset); + await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); return; case "back": - await browseRemotePackages(ctx, previousQuery, pi, previousOffset); + await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); return; default: return; @@ -523,7 +1115,7 @@ async function showPackageDetails( } async function promptSearch(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { - const query = await ctx.ui.input("Search packages", "keywords:pi-package"); + const query = await ctx.ui.input("Search packages", "package name, keyword, or npm:@scope/pkg"); if (!query?.trim()) return; await searchPackages(query.trim(), ctx, pi); } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 84f34ab..d1862db 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -2,21 +2,26 @@ * Unified extension manager UI * Displays local extensions and installed packages in one view */ +import { homedir } from "node:os"; +import { relative } from "node:path"; import { DynamicBorder, type ExtensionAPI, type ExtensionCommandContext, - getSettingsListTheme, type Theme, } from "@mariozechner/pi-coding-agent"; import { Container, + type Focusable, + fuzzyMatch, + getKeybindings, + Input, Key, matchesKey, - type SettingItem, - SettingsList, Spacer, Text, + truncateToWidth, + wrapTextWithAnsi, } from "@mariozechner/pi-tui"; import { UI } from "../constants.js"; import { @@ -40,26 +45,20 @@ import { } from "../types/index.js"; import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js"; import { parseChoiceByLabel } from "../utils/command.js"; -import { dynamicTruncate, formatBytes, formatEntry as formatExtEntry } from "../utils/format.js"; +import { formatBytes, formatEntry as formatExtEntry } from "../utils/format.js"; import { logExtensionDelete, logExtensionToggle } from "../utils/history.js"; import { hasCustomUI, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; +import { normalizePathIdentity } from "../utils/path-identity.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; -import { getSettingsListSelectedIndex } from "../utils/settings-list.js"; import { updateExtmgrStatus } from "../utils/status.js"; -import { confirmReload } from "../utils/ui-helpers.js"; +import { confirmReload, formatListOutput } from "../utils/ui-helpers.js"; import { runTaskWithLoader } from "./async-task.js"; import { buildFooterShortcuts, buildFooterState, getPendingToggleChangeCount } from "./footer.js"; import { showHelp } from "./help.js"; import { configurePackageExtensions } from "./package-config.js"; import { showRemote } from "./remote.js"; -import { - formatSize, - getChangeMarker, - getPackageIcon, - getScopeIcon, - getStatusIcon, -} from "./theme.js"; +import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js"; async function showInteractiveFallback( ctx: ExtensionCommandContext, @@ -156,206 +155,108 @@ async function showInteractiveOnce( // Staged changes tracking for local extensions. const staged = new Map(); const byId = new Map(items.map((item) => [item.id, item])); + let managerState: UnifiedManagerViewState | undefined; - const result = await runCustomUI( - ctx, - "The unified extensions manager", - () => - ctx.ui.custom((tui, theme, _keybindings, done) => { - const container = new Container(); - - const titleText = new Text("", 2, 0); - const subtitleText = new Text("", 2, 0); - const quickText = new Text("", 2, 0); - const footerState = buildFooterState(items); - const footerText = new Text("", 2, 0); - - // Header - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - container.addChild(titleText); - container.addChild(subtitleText); - container.addChild(quickText); - container.addChild(new Spacer(1)); - - // Build settings items - const settingsItems = buildSettingsItems(items, staged, theme); - const syncThemedContent = (): void => { - titleText.setText(theme.fg("accent", theme.bold("Extensions Manager"))); - subtitleText.setText( - theme.fg( - "muted", - `${items.length} item${items.length === 1 ? "" : "s"} • Space/Enter toggle local • Enter/A actions • c configure pkg extensions • u update pkg • x remove selected` - ) - ); - quickText.setText( - theme.fg( - "dim", - "Quick: i Install | f Search | U Update all | t Auto-update | p Palette" - ) - ); - footerText.setText(theme.fg("dim", buildFooterShortcuts(footerState))); - - for (const settingsItem of settingsItems) { - const item = byId.get(settingsItem.id); - if (!item) continue; - - if (item.type === "local") { - const currentState = staged.get(item.id) ?? item.state; - const changed = currentState !== item.originalState; - settingsItem.label = formatUnifiedItemLabel(item, currentState, theme, changed); - } else { - settingsItem.label = formatUnifiedItemLabel(item, "enabled", theme, false); - } - } - }; - syncThemedContent(); - - const settingsList = new SettingsList( - settingsItems, - Math.min(items.length + 2, UI.maxListHeight), - getSettingsListTheme(), - (id: string, newValue: string) => { - const item = byId.get(id); - if (!item || item.type !== "local") return; - - const state = newValue as State; - if (state === item.originalState) { - staged.delete(id); - } else { - staged.set(id, state); - } - - const settingsItem = settingsItems.find((x) => x.id === id); - if (settingsItem) { - const changed = state !== item.originalState; - settingsItem.label = formatUnifiedItemLabel(item, state, theme, changed); - } - tui.requestRender(); - }, - () => done({ type: "cancel" }) - ); - - container.addChild(settingsList); - container.addChild(new Spacer(1)); - - // Footer with keyboard shortcuts - container.addChild(footerText); - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - - return { - render(width: number) { - return container.render(width); - }, - invalidate() { - container.invalidate(); - syncThemedContent(); - }, - handleInput(data: string) { - const selIdx = getSettingsListSelectedIndex(settingsList) ?? 0; - const selectedId = settingsItems[selIdx]?.id ?? settingsItems[0]?.id; - const selectedItem = selectedId ? byId.get(selectedId) : undefined; - - if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") { - done({ type: "apply" }); - return; - } - - // Enter on a package opens its action menu (fewer clicks) - if ( - (data === "\r" || data === "\n") && - selectedId && - selectedItem?.type === "package" - ) { - done({ type: "action", itemId: selectedId, action: "menu" }); - return; - } - - if (data === "a" || data === "A") { - if (selectedId) { - done({ type: "action", itemId: selectedId, action: "menu" }); - } - return; - } - - // Quick actions (global) - if (data === "i") { - done({ type: "quick", action: "install" }); - return; - } - if (data === "f") { - done({ type: "quick", action: "search" }); - return; - } - if (data === "U") { - done({ type: "quick", action: "update-all" }); - return; - } - if (data === "t" || data === "T") { - done({ type: "quick", action: "auto-update" }); - return; - } - - // Fast actions on selected row - if (selectedId && selectedItem?.type === "package") { - if (data === "u") { - done({ type: "action", itemId: selectedId, action: "update" }); - return; - } - if (data === "x" || data === "X") { - done({ type: "action", itemId: selectedId, action: "remove" }); - return; - } - if (data === "v" || data === "V") { - done({ type: "action", itemId: selectedId, action: "details" }); - return; - } - if (data === "c" || data === "C") { - done({ type: "action", itemId: selectedId, action: "configure" }); - return; - } - } + while (true) { + let nextManagerState = managerState; - if (selectedId && selectedItem?.type === "local") { - if (data === "x" || data === "X") { - done({ type: "action", itemId: selectedId, action: "remove" }); - return; + const result = await runCustomUI( + ctx, + "The unified extensions manager", + () => + ctx.ui.custom((tui, theme, _keybindings, done) => { + const container = new Container(); + + const titleText = new Text("", 2, 0); + const statsText = new Text("", 2, 0); + const footerText = new Text("", 2, 0); + let browser!: UnifiedManagerBrowser; + const complete = (action: UnifiedAction): void => { + nextManagerState = browser.getViewState(); + done(action); + }; + browser = new UnifiedManagerBrowser( + items, + staged, + theme, + ctx.cwd, + Math.max(4, Math.min(UI.maxListHeight, tui.terminal.rows - 12)), + complete, + managerState + ); + let lastWidth = tui.terminal.columns; + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(titleText); + container.addChild(statsText); + container.addChild(new Spacer(1)); + container.addChild(browser); + container.addChild(new Spacer(1)); + container.addChild(footerText); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + const syncThemedContent = (width = lastWidth): void => { + lastWidth = width; + titleText.setText(theme.fg("accent", theme.bold("Extensions Manager"))); + statsText.setText( + buildManagerSummary(items, staged, byId, theme, { + visibleItems: browser.getVisibleItems(), + filter: browser.getFilter(), + searchQuery: browser.getSearchQuery(), + }) + ); + footerText.setText( + theme.fg( + "dim", + buildFooterShortcuts(buildFooterState(staged, byId, browser.getSelectedItem())) + ) + ); + }; + + syncThemedContent(); + + let focused = false; + + return { + get focused() { + return focused; + }, + set focused(value: boolean) { + focused = value; + browser.focused = value; + }, + render(width: number) { + syncThemedContent(width); + return container.render(width); + }, + invalidate() { + container.invalidate(); + browser.invalidate(); + syncThemedContent(lastWidth); + }, + handleInput(data: string) { + if (browser.handleManagerInput(data)) { + tui.requestRender(); } - } - - if (data === "r" || data === "R") { - done({ type: "remote" }); - return; - } - if (data === "?" || data === "h" || data === "H") { - done({ type: "help" }); - return; - } - if (data === "m" || data === "M" || data === "p" || data === "P") { - done({ type: "menu" }); - return; - } - settingsList.handleInput?.(data); - tui.requestRender(); - }, - }; - }), - "Showing read-only local and installed package lists instead." - ); - - if (!result) { - await showInteractiveFallback(ctx, pi); - return true; - } + }, + }; + }), + "Showing read-only local and installed package lists instead." + ); - return await handleUnifiedAction(result, items, staged, byId, ctx, pi); -} + if (!result) { + await showInteractiveFallback(ctx, pi); + return true; + } -function normalizePathForDuplicateCheck(value: string): string { - const normalized = value.replace(/\\/g, "/"); - const looksWindowsPath = - /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\"); + const outcome = await handleUnifiedAction(result, items, staged, byId, ctx, pi); + if (outcome === "resume") { + managerState = nextManagerState; + continue; + } - return looksWindowsPath ? normalized.toLowerCase() : normalized; + return outcome; + } } export function buildUnifiedItems( @@ -368,7 +269,8 @@ export function buildUnifiedItems( // Add local extensions for (const entry of localEntries) { - localPaths.add(normalizePathForDuplicateCheck(entry.activePath)); + const currentPath = entry.state === "disabled" ? entry.disabledPath : entry.activePath; + localPaths.add(normalizePathIdentity(currentPath)); items.push({ type: "local", id: entry.id, @@ -383,10 +285,8 @@ export function buildUnifiedItems( } for (const pkg of installedPackages) { - const pkgSourceNormalized = normalizePathForDuplicateCheck(pkg.source); - const pkgResolvedNormalized = pkg.resolvedPath - ? normalizePathForDuplicateCheck(pkg.resolvedPath) - : ""; + const pkgSourceNormalized = normalizePathIdentity(pkg.source); + const pkgResolvedNormalized = pkg.resolvedPath ? normalizePathIdentity(pkg.resolvedPath) : ""; let isDuplicate = false; for (const localPath of localPaths) { @@ -429,45 +329,87 @@ export function buildUnifiedItems( return items; } -function buildSettingsItems( +function buildManagerSummary( items: UnifiedItem[], staged: Map, - theme: Theme -): SettingItem[] { - return items.map((item) => { - if (item.type === "local") { - const currentState = staged.get(item.id) ?? item.state; - const changed = currentState !== item.originalState; - return { - id: item.id, - label: formatUnifiedItemLabel(item, currentState, theme, changed), - currentValue: currentState, - values: ["enabled", "disabled"], - }; - } + byId: Map, + theme: Theme, + options?: { + visibleItems?: readonly UnifiedItem[]; + filter?: UnifiedFilter; + searchQuery?: string; + } +): string { + const summaryItems = options?.visibleItems ?? items; + const filtered = + Boolean(options?.searchQuery) || + options?.filter === "local" || + options?.filter === "packages" || + options?.filter === "updates" || + options?.filter === "disabled"; + const localCount = summaryItems.filter((item) => item.type === "local").length; + const packageCount = summaryItems.length - localCount; + const updateCount = summaryItems.filter( + (item) => item.type === "package" && item.updateAvailable + ).length; + const pendingCount = getPendingToggleChangeCount(staged, byId); + const parts = [ + filtered + ? theme.fg("accent", `showing ${summaryItems.length} of ${items.length}`) + : theme.fg("muted", `${items.length} item${items.length === 1 ? "" : "s"}`), + theme.fg("muted", `${localCount} local`), + ]; + + if (packageCount > 0) { + parts.push(theme.fg("muted", `${packageCount} package${packageCount === 1 ? "" : "s"}`)); + } - return { - id: item.id, - label: formatUnifiedItemLabel(item, "enabled", theme, false), - currentValue: "enabled", - values: ["enabled"], - }; - }); + if (updateCount > 0) { + parts.push(theme.fg("warning", `${updateCount} update${updateCount === 1 ? "" : "s"}`)); + } + + if (pendingCount > 0) { + parts.push(theme.fg("warning", `${pendingCount} unsaved`)); + } + + return parts.join(" • "); +} + +type UnifiedFilter = "all" | "local" | "packages" | "updates" | "disabled"; + +interface UnifiedManagerViewState { + filter: UnifiedFilter; + searchQuery: string; + selectedItemId?: string; +} + +const UNIFIED_FILTER_OPTIONS: Array<{ id: UnifiedFilter; key: string; label: string }> = [ + { id: "all", key: "1", label: "All" }, + { id: "local", key: "2", label: "Local" }, + { id: "packages", key: "3", label: "Packages" }, + { id: "updates", key: "4", label: "Updates" }, + { id: "disabled", key: "5", label: "Disabled" }, +]; + +function getCurrentUnifiedItemState( + item: UnifiedItem, + staged: Map +): State | undefined { + return item.type === "local" ? (staged.get(item.id) ?? item.state) : undefined; } function formatUnifiedItemLabel( item: UnifiedItem, - state: State, + state: State | undefined, theme: Theme, changed = false ): string { if (item.type === "local") { - const statusIcon = getStatusIcon(theme, state); + const statusIcon = getStatusIcon(theme, state ?? item.state); const scopeIcon = getScopeIcon(theme, item.scope); const changeMarker = getChangeMarker(theme, changed); const name = theme.bold(item.displayName); - const summary = theme.fg("dim", item.summary); - return `${statusIcon} [${scopeIcon}] ${name} - ${summary}${changeMarker}`; + return `${statusIcon} [${scopeIcon}] ${name}${changeMarker}`; } const sourceKind = getPackageSourceKind(item.source); @@ -478,30 +420,631 @@ function formatUnifiedItemLabel( const scopeIcon = getScopeIcon(theme, item.scope); const name = theme.bold(item.displayName); const version = item.version ? theme.fg("dim", `@${item.version}`) : ""; + const size = item.size !== undefined ? theme.fg("dim", ` • ${formatBytes(item.size)}`) : ""; const updateBadge = item.updateAvailable ? ` ${theme.fg("warning", "[update]")}` : ""; - // Build info parts - const infoParts: string[] = []; - - // Show description if available - // Reserved space: icon (2) + scope (3) + name (~25) + version (~10) + separator (3) = ~43 chars - if (item.description) { - infoParts.push(dynamicTruncate(item.description, 43)); - } else if (sourceKind === "npm") { - infoParts.push("npm"); - } else if (sourceKind === "git") { - infoParts.push("git"); - } else { - infoParts.push("local"); + return `${pkgIcon} [${scopeIcon}] ${name}${version}${size}${updateBadge}`; +} + +function getLocalItemCurrentPath(item: LocalUnifiedItem, state?: State): string { + return (state ?? item.state) === "enabled" ? item.activePath : item.disabledPath; +} + +function formatUnifiedItemDescription( + item: UnifiedItem, + state: State | undefined, + changed: boolean, + cwd: string +): string { + if (item.type === "local") { + const details = [ + item.summary, + "local extension", + item.scope, + changed ? `staged → ${state ?? item.state}` : (state ?? item.state), + compactDisplayPath(getLocalItemCurrentPath(item, state), cwd), + ]; + + return details.filter(Boolean).join(" • "); + } + + const sourceKind = getPackageSourceKind(item.source); + const source = sourceKind === "local" ? compactDisplayPath(item.source, cwd) : item.source; + const details = [ + item.description || "No description", + `${sourceKind === "unknown" ? "package" : `${sourceKind} package`}`, + item.scope, + source, + item.updateAvailable ? "update available" : undefined, + item.size !== undefined ? formatBytes(item.size) : undefined, + ]; + + return details.filter(Boolean).join(" • "); +} + +function compactDisplayPath(filePath: string, cwd: string): string { + const normalizedPath = filePath.replace(/\\/g, "/"); + const normalizedHome = homedir().replace(/\\/g, "/"); + + if (normalizedPath === normalizedHome) { + return "~"; + } + + if (normalizedPath.startsWith(`${normalizedHome}/`)) { + return `~/${normalizedPath.slice(normalizedHome.length + 1)}`; + } + + const relativePath = relative(cwd, filePath).replace(/\\/g, "/"); + if ( + relativePath && + relativePath !== ".." && + !relativePath.startsWith("../") && + !isAbsoluteDisplayPath(relativePath) + ) { + return `./${relativePath}`; + } + + return normalizedPath; +} + +function isAbsoluteDisplayPath(value: string): boolean { + return /^([a-zA-Z]:\/|\/|\\\\)/.test(value); +} + +function matchesUnifiedFilter( + item: UnifiedItem, + filter: UnifiedFilter, + staged: Map +): boolean { + switch (filter) { + case "all": + return true; + case "local": + return item.type === "local"; + case "packages": + return item.type === "package"; + case "updates": + return item.type === "package" && Boolean(item.updateAvailable); + case "disabled": + return item.type === "local" && getCurrentUnifiedItemState(item, staged) === "disabled"; + } +} + +function getUnifiedItemSearchFields( + item: UnifiedItem, + staged: Map, + cwd: string +): { primary: string[]; secondary: string[] } { + if (item.type === "local") { + const state = getCurrentUnifiedItemState(item, staged) ?? item.state; + return { + primary: [item.displayName, compactDisplayPath(getLocalItemCurrentPath(item, state), cwd)], + secondary: [item.summary], + }; + } + + const source = + getPackageSourceKind(item.source) === "local" + ? compactDisplayPath(item.source, cwd) + : item.source; + return { + primary: [item.displayName, source], + secondary: [item.version ?? "", item.description ?? ""], + }; +} + +function scoreUnifiedItemSearchMatch( + item: UnifiedItem, + query: string, + staged: Map, + cwd: string +): number | undefined { + const tokens = query + .trim() + .toLowerCase() + .split(/\s+/) + .filter((token) => token.length > 0); + if (tokens.length === 0) { + return 0; + } + + const fields = getUnifiedItemSearchFields(item, staged, cwd); + const primary = fields.primary + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); + const secondary = fields.secondary + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); + + let totalScore = 0; + + for (const token of tokens) { + const primarySubstringScore = primary.reduce((best, field) => { + const index = field.indexOf(token); + if (index < 0) { + return best; + } + return best === undefined ? index : Math.min(best, index); + }, undefined); + if (primarySubstringScore !== undefined) { + totalScore += primarySubstringScore; + continue; + } + + const secondarySubstringScore = secondary.reduce((best, field) => { + const index = field.indexOf(token); + if (index < 0) { + return best; + } + const score = 100 + index; + return best === undefined ? score : Math.min(best, score); + }, undefined); + if (secondarySubstringScore !== undefined) { + totalScore += secondarySubstringScore; + continue; + } + + const primaryFuzzyScore = primary.reduce((best, field) => { + const match = fuzzyMatch(token, field); + if (!match.matches) { + return best; + } + const score = 200 + match.score; + return best === undefined ? score : Math.min(best, score); + }, undefined); + if (primaryFuzzyScore !== undefined) { + totalScore += primaryFuzzyScore; + continue; + } + + return undefined; + } + + return totalScore; +} + +function searchUnifiedItems( + items: UnifiedItem[], + query: string, + staged: Map, + cwd: string +): UnifiedItem[] { + const matches = items + .map((item, index) => ({ + item, + index, + score: scoreUnifiedItemSearchMatch(item, query, staged, cwd), + })) + .filter( + (match): match is { item: UnifiedItem; index: number; score: number } => + match.score !== undefined + ); + + matches.sort((a, b) => a.score - b.score || a.index - b.index); + return matches.map((match) => match.item); +} + +class UnifiedManagerBrowser implements Focusable { + private readonly searchInput = new Input(); + private readonly filteredItems: UnifiedItem[] = []; + private selectedIndex = 0; + private filter: UnifiedFilter = "all"; + private searchActive = false; + private _focused = false; + + constructor( + private readonly items: UnifiedItem[], + private readonly staged: Map, + private readonly theme: Theme, + private readonly cwd: string, + private readonly maxVisibleItems: number, + private readonly onAction: (action: UnifiedAction) => void, + initialState?: UnifiedManagerViewState + ) { + if (initialState) { + this.filter = initialState.filter; + this.searchInput.setValue(initialState.searchQuery); + this.refreshVisibleItems(initialState.selectedItemId); + return; + } + + this.refreshVisibleItems(); + } + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value && this.searchActive; + } + + getSelectedItem(): UnifiedItem | undefined { + return this.filteredItems[this.selectedIndex]; + } + + getVisibleItems(): readonly UnifiedItem[] { + return this.filteredItems; + } + + getFilter(): UnifiedFilter { + return this.filter; + } + + getSearchQuery(): string { + return this.searchInput.getValue().trim(); + } + + getViewState(): UnifiedManagerViewState { + const selectedItemId = this.getSelectedItem()?.id; + return { + filter: this.filter, + searchQuery: this.getSearchQuery(), + ...(selectedItemId ? { selectedItemId } : {}), + }; + } + + invalidate(): void { + this.searchInput.invalidate(); + } + + handleInput(data: string): void { + this.handleManagerInput(data); + } + + handleManagerInput(data: string): boolean { + const kb = getKeybindings(); + + if (this.searchActive) { + if (matchesKey(data, Key.enter)) { + this.searchActive = false; + this.searchInput.focused = false; + return true; + } + + if (matchesKey(data, Key.escape)) { + this.searchInput.setValue(""); + this.searchActive = false; + this.searchInput.focused = false; + this.refreshVisibleItems(); + return true; + } + + this.searchInput.handleInput(data); + this.refreshVisibleItems(); + return true; + } + + if (data === "/" || matchesKey(data, Key.ctrl("f"))) { + this.searchActive = true; + this.searchInput.focused = this._focused; + return true; + } + + if (matchesKey(data, Key.escape) && this.getSearchQuery()) { + this.searchInput.setValue(""); + this.refreshVisibleItems(); + return true; + } + + if (matchesKey(data, Key.shift("tab"))) { + this.cycleFilter(-1); + return true; + } + + if (matchesKey(data, Key.tab)) { + this.cycleFilter(1); + return true; + } + + const directFilter = UNIFIED_FILTER_OPTIONS.find((option) => option.key === data)?.id; + if (directFilter) { + this.setFilter(directFilter); + return true; + } + + if (kb.matches(data, "tui.select.up")) { + this.moveSelection(-1); + return true; + } + + if (kb.matches(data, "tui.select.down")) { + this.moveSelection(1); + return true; + } + + if (kb.matches(data, "tui.select.pageUp")) { + this.moveSelection(-Math.max(1, this.maxVisibleItems - 1)); + return true; + } + + if (kb.matches(data, "tui.select.pageDown")) { + this.moveSelection(Math.max(1, this.maxVisibleItems - 1)); + return true; + } + + if (matchesKey(data, Key.home)) { + this.selectedIndex = 0; + return true; + } + + if (matchesKey(data, Key.end)) { + this.selectedIndex = Math.max(0, this.filteredItems.length - 1); + return true; + } + + const selectedItem = this.getSelectedItem(); + const selectedId = selectedItem?.id; + + if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") { + this.onAction({ type: "apply" }); + return true; + } + + if ((matchesKey(data, Key.space) || data === " ") && selectedItem?.type === "local") { + const currentState = + getCurrentUnifiedItemState(selectedItem, this.staged) ?? selectedItem.state; + const nextState: State = currentState === "enabled" ? "disabled" : "enabled"; + if (nextState === selectedItem.originalState) { + this.staged.delete(selectedItem.id); + } else { + this.staged.set(selectedItem.id, nextState); + } + this.refreshVisibleItems(selectedItem.id); + return true; + } + + if (matchesKey(data, Key.enter) && selectedId) { + this.onAction({ type: "action", itemId: selectedId, action: "menu" }); + return true; + } + + if (data === "a" || data === "A") { + if (selectedId) { + this.onAction({ type: "action", itemId: selectedId, action: "menu" }); + } + return true; + } + + if (data === "i") { + this.onAction({ type: "quick", action: "install" }); + return true; + } + + if (data === "f") { + this.onAction({ type: "quick", action: "search" }); + return true; + } + + if (data === "U") { + this.onAction({ type: "quick", action: "update-all" }); + return true; + } + + if (data === "t" || data === "T") { + this.onAction({ type: "quick", action: "auto-update" }); + return true; + } + + if (selectedId && (data === "v" || data === "V")) { + this.onAction({ type: "action", itemId: selectedId, action: "details" }); + return true; + } + + if (selectedId && selectedItem?.type === "package") { + if (data === "u") { + this.onAction({ type: "action", itemId: selectedId, action: "update" }); + return true; + } + if (data === "x" || data === "X") { + this.onAction({ type: "action", itemId: selectedId, action: "remove" }); + return true; + } + if (data === "c" || data === "C") { + this.onAction({ type: "action", itemId: selectedId, action: "configure" }); + return true; + } + } + + if (selectedId && selectedItem?.type === "local" && (data === "x" || data === "X")) { + this.onAction({ type: "action", itemId: selectedId, action: "remove" }); + return true; + } + + if (data === "r" || data === "R") { + this.onAction({ type: "remote" }); + return true; + } + + if (data === "?" || data === "h" || data === "H") { + this.onAction({ type: "help" }); + return true; + } + + if (data === "m" || data === "M" || data === "p" || data === "P") { + this.onAction({ type: "menu" }); + return true; + } + + if (matchesKey(data, Key.escape)) { + this.onAction({ type: "cancel" }); + return true; + } + + return false; + } + + render(width: number): string[] { + const lines: string[] = []; + + const searchQuery = this.searchInput.getValue().trim(); + if (this.searchActive) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } else if (searchQuery) { + lines.push(truncateToWidth(this.theme.fg("accent", ` Search: ${searchQuery}`), width, "")); + lines.push(""); + } + + lines.push(truncateToWidth(this.buildFilterLine(), width, "")); + lines.push(""); + + if (this.filteredItems.length === 0) { + lines.push(this.theme.fg("warning", " No matching extensions or packages")); + return lines; + } + + const { startIndex, endIndex } = this.getVisibleRange(); + const visibleItems = this.filteredItems.slice(startIndex, endIndex); + const localCount = this.filteredItems.filter((item) => item.type === "local").length; + const packageCount = this.filteredItems.length - localCount; + const visibleLocalItems = visibleItems.filter((item) => item.type === "local"); + const visiblePackageItems = visibleItems.filter((item) => item.type === "package"); + + if (visibleLocalItems.length > 0) { + lines.push(this.theme.fg("accent", ` Local extensions (${localCount})`)); + for (const item of visibleLocalItems) { + lines.push(this.renderItemLine(item, width)); + } + if (visiblePackageItems.length > 0) { + lines.push(""); + } + } + + if (visiblePackageItems.length > 0) { + lines.push(this.theme.fg("accent", ` Installed packages (${packageCount})`)); + for (const item of visiblePackageItems) { + lines.push(this.renderItemLine(item, width)); + } + } + + if (startIndex > 0 || endIndex < this.filteredItems.length) { + lines.push(""); + lines.push( + this.theme.fg( + "dim", + ` Showing ${startIndex + 1}-${endIndex} of ${this.filteredItems.length}` + ) + ); + } + + const selectedItem = this.getSelectedItem(); + if (selectedItem) { + lines.push(""); + const selectedState = getCurrentUnifiedItemState(selectedItem, this.staged); + const detailText = formatUnifiedItemDescription( + selectedItem, + selectedState, + selectedItem.type === "local" && selectedState !== selectedItem.originalState, + this.cwd + ); + for (const line of wrapTextWithAnsi(detailText, width - 4)) { + lines.push(this.theme.fg("dim", ` ${line}`)); + } + } + + return lines; + } + + private buildFilterLine(): string { + const filters = UNIFIED_FILTER_OPTIONS.map(({ id, key, label }) => { + const text = `${key}:${label}`; + return id === this.filter + ? this.theme.fg("accent", `[${text}]`) + : this.theme.fg("muted", text); + }).join(" "); + const searchHint = this.theme.fg( + this.searchActive || this.searchInput.getValue() ? "accent" : "dim", + "/ search" + ); + return ` ${filters} · ${searchHint}`; + } + + private renderItemLine(item: UnifiedItem, width: number): string { + const state = getCurrentUnifiedItemState(item, this.staged); + const changed = item.type === "local" && state !== item.originalState; + const prefix = this.getSelectedItem()?.id === item.id ? this.theme.fg("accent", "→ ") : " "; + return truncateToWidth( + prefix + formatUnifiedItemLabel(item, state, this.theme, changed), + width + ); + } + + private refreshVisibleItems(preferredItemId?: string): void { + const previousSelectedId = preferredItemId ?? this.getSelectedItem()?.id; + const filteredByMode = this.items.filter((item) => + matchesUnifiedFilter(item, this.filter, this.staged) + ); + const query = this.searchInput.getValue().trim(); + this.filteredItems.length = 0; + this.filteredItems.push( + ...(query ? searchUnifiedItems(filteredByMode, query, this.staged, this.cwd) : filteredByMode) + ); + + if (this.filteredItems.length === 0) { + this.selectedIndex = 0; + return; + } + + const nextSelectedIndex = previousSelectedId + ? this.filteredItems.findIndex((item) => item.id === previousSelectedId) + : -1; + if (nextSelectedIndex >= 0) { + this.selectedIndex = nextSelectedIndex; + return; + } + + this.selectedIndex = Math.min(this.selectedIndex, this.filteredItems.length - 1); } - // Show size if available - if (item.size !== undefined) { - infoParts.push(formatSize(theme, item.size)); + private setFilter(filter: UnifiedFilter): void { + this.filter = filter; + this.refreshVisibleItems(); } - const summary = theme.fg("dim", infoParts.join(" • ")); - return `${pkgIcon} [${scopeIcon}] ${name}${version}${updateBadge} - ${summary}`; + private cycleFilter(direction: -1 | 1): void { + const currentIndex = UNIFIED_FILTER_OPTIONS.findIndex((option) => option.id === this.filter); + const nextIndex = + (currentIndex + direction + UNIFIED_FILTER_OPTIONS.length) % UNIFIED_FILTER_OPTIONS.length; + const nextFilter = UNIFIED_FILTER_OPTIONS[nextIndex]?.id; + if (nextFilter) { + this.setFilter(nextFilter); + } + } + + private moveSelection(delta: number): void { + if (this.filteredItems.length === 0) { + this.selectedIndex = 0; + return; + } + + const nextIndex = this.selectedIndex + delta; + if (nextIndex < 0) { + this.selectedIndex = 0; + return; + } + + if (nextIndex >= this.filteredItems.length) { + this.selectedIndex = this.filteredItems.length - 1; + return; + } + + this.selectedIndex = nextIndex; + } + + private getVisibleRange(): { startIndex: number; endIndex: number } { + const maxVisible = Math.max(1, this.maxVisibleItems); + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(maxVisible / 2), + Math.max(0, this.filteredItems.length - maxVisible) + ) + ); + const endIndex = Math.min(startIndex + maxVisible, this.filteredItems.length); + return { startIndex, endIndex }; + } } function getToggleItemsForApply(items: UnifiedItem[]): LocalUnifiedItem[] { @@ -597,6 +1140,12 @@ const QUICK_DESTINATION_LABELS: Record = { help: "Help", }; +const LOCAL_ACTION_OPTIONS = { + details: "View details", + remove: "Remove local extension", + back: "Back to manager", +} as const; + const PACKAGE_ACTION_OPTIONS = { configure: "Configure extensions", update: "Update package", @@ -605,10 +1154,28 @@ const PACKAGE_ACTION_OPTIONS = { back: "Back to manager", } as const; +type LocalActionKey = keyof typeof LOCAL_ACTION_OPTIONS; type PackageActionKey = keyof typeof PACKAGE_ACTION_OPTIONS; +type LocalActionSelection = Exclude | "cancel"; type PackageActionSelection = Exclude | "cancel"; +async function promptLocalActionSelection( + item: LocalUnifiedItem, + ctx: ExtensionCommandContext +): Promise { + const selection = parseChoiceByLabel( + LOCAL_ACTION_OPTIONS, + await ctx.ui.select(item.displayName, Object.values(LOCAL_ACTION_OPTIONS)) + ); + + if (!selection || selection === "back") { + return "cancel"; + } + + return selection; +} + async function promptPackageActionSelection( pkg: InstalledPackage, ctx: ExtensionCommandContext @@ -625,6 +1192,27 @@ async function promptPackageActionSelection( return selection; } +function showUnifiedItemDetails( + item: UnifiedItem, + ctx: ExtensionCommandContext, + state?: State +): void { + if (item.type === "local") { + const currentState = state ?? item.state; + ctx.ui.notify( + `Name: ${item.displayName}\nScope: ${item.scope}\nState: ${currentState}\nPath: ${getLocalItemCurrentPath(item, currentState)}\nSummary: ${item.summary}`, + "info" + ); + return; + } + + const sizeStr = item.size !== undefined ? `\nSize: ${formatBytes(item.size)}` : ""; + ctx.ui.notify( + `Name: ${item.displayName}\nVersion: ${item.version || "unknown"}\nSource: ${item.source}\nScope: ${item.scope}${sizeStr}${item.description ? `\nDescription: ${item.description}` : ""}`, + "info" + ); +} + async function navigateWithPendingGuard( destination: QuickDestination, items: UnifiedItem[], @@ -632,7 +1220,7 @@ async function navigateWithPendingGuard( byId: Map, ctx: ExtensionCommandContext, pi: ExtensionAPI -): Promise<"done" | "stay" | "exit"> { +): Promise<"reload" | "resume" | "stay" | "exit"> { const pending = await resolvePendingChangesBeforeLeave( items, staged, @@ -646,16 +1234,16 @@ async function navigateWithPendingGuard( switch (destination) { case "install": await showRemote("install", ctx, pi); - return "done"; + return "reload"; case "search": await showRemote("search", ctx, pi); - return "done"; + return "reload"; case "browse": await showRemote("", ctx, pi); - return "done"; + return "reload"; case "update-all": { const outcome = await updatePackagesWithOutcome(ctx, pi); - return outcome.reloaded ? "exit" : "done"; + return outcome.reloaded ? "exit" : "reload"; } case "auto-update": await promptAutoUpdateWizard(pi, ctx, (packages) => { @@ -665,10 +1253,10 @@ async function navigateWithPendingGuard( ); }); void updateExtmgrStatus(ctx, pi); - return "done"; + return "resume"; case "help": showHelp(ctx); - return "done"; + return "resume"; } } @@ -679,7 +1267,7 @@ async function handleUnifiedAction( byId: Map, ctx: ExtensionCommandContext, pi: ExtensionAPI -): Promise { +): Promise { if (result.type === "cancel") { const pendingCount = getPendingToggleChangeCount(staged, byId); if (pendingCount > 0) { @@ -690,13 +1278,13 @@ async function handleUnifiedAction( ]); if (!choice || choice === "Stay in manager") { - return false; + return "resume"; } if (choice === "Save and exit") { const apply = await applyToggleChangesFromManager(items, staged, ctx, pi); if (apply.reloaded) return true; - if (apply.changed === 0 && apply.hasErrors) return false; + if (apply.changed === 0 && apply.hasErrors) return "resume"; } } @@ -705,7 +1293,7 @@ async function handleUnifiedAction( if (result.type === "remote") { const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Remote"); - if (pending === "stay") return false; + if (pending === "stay") return "resume"; await showRemote("", ctx, pi); return false; @@ -713,10 +1301,10 @@ async function handleUnifiedAction( if (result.type === "help") { const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Help"); - if (pending === "stay") return false; + if (pending === "stay") return "resume"; showHelp(ctx); - return false; + return "resume"; } if (result.type === "menu") { @@ -736,10 +1324,11 @@ async function handleUnifiedAction( const destination = choice ? destinationByAction[choice] : undefined; if (!destination) { - return false; + return "resume"; } const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi); + if (outcome === "stay" || outcome === "resume") return "resume"; return outcome === "exit"; } @@ -753,6 +1342,7 @@ async function handleUnifiedAction( const destination = quickDestinationMap[result.action]; const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi); + if (outcome === "stay" || outcome === "resume") return "resume"; return outcome === "exit"; } @@ -760,25 +1350,40 @@ async function handleUnifiedAction( const item = byId.get(result.itemId); if (!item) return false; - const pendingDestination = item.type === "local" ? "remove extension" : "package actions"; - const pending = await resolvePendingChangesBeforeLeave( - items, - staged, - byId, - ctx, - pi, - pendingDestination - ); - if (pending === "stay") return false; - if (item.type === "local") { - if (result.action !== "remove") return false; + const selection = + !result.action || result.action === "menu" + ? await promptLocalActionSelection(item, ctx) + : result.action; + + if (selection === "cancel") { + return "resume"; + } + + if (selection === "details") { + showUnifiedItemDetails(item, ctx, staged.get(item.id) ?? item.state); + return "resume"; + } + + if (selection !== "remove") { + return "resume"; + } + + const pending = await resolvePendingChangesBeforeLeave( + items, + staged, + byId, + ctx, + pi, + "remove extension" + ); + if (pending === "stay") return "resume"; const confirmed = await ctx.ui.confirm( "Delete Local Extension", `Delete ${item.displayName} from disk?\n\nThis cannot be undone.` ); - if (!confirmed) return false; + if (!confirmed) return "resume"; const removal = await removeLocalExtension( { activePath: item.activePath, disabledPath: item.disabledPath }, @@ -787,7 +1392,7 @@ async function handleUnifiedAction( if (!removal.ok) { logExtensionDelete(pi, item.id, false, removal.error); ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error"); - return false; + return "resume"; } logExtensionDelete(pi, item.id, true); @@ -814,9 +1419,30 @@ async function handleUnifiedAction( : result.action; if (selection === "cancel") { - return false; + return "resume"; } + if (selection === "details") { + showUnifiedItemDetails(item, ctx); + return "resume"; + } + + const pendingDestinationBySelection = { + configure: "configure package extensions", + update: "update package", + remove: "remove package", + } satisfies Record, string>; + + const pending = await resolvePendingChangesBeforeLeave( + items, + staged, + byId, + ctx, + pi, + pendingDestinationBySelection[selection] + ); + if (pending === "stay") return "resume"; + switch (selection) { case "configure": { const outcome = await configurePackageExtensions(pkg, ctx, pi); @@ -830,19 +1456,11 @@ async function handleUnifiedAction( const outcome = await removePackageWithOutcome(pkg.source, ctx, pi); return outcome.reloaded; } - case "details": { - const sizeStr = pkg.size !== undefined ? `\nSize: ${formatBytes(pkg.size)}` : ""; - ctx.ui.notify( - `Name: ${pkg.name}\nVersion: ${pkg.version || "unknown"}\nSource: ${pkg.source}\nScope: ${pkg.scope}${sizeStr}${pkg.description ? `\nDescription: ${pkg.description}` : ""}`, - "info" - ); - return false; - } } } const apply = await applyToggleChangesFromManager(items, staged, ctx, pi); - return apply.reloaded; + return apply.reloaded ? true : "resume"; } async function applyStagedChanges( @@ -857,6 +1475,7 @@ async function applyStagedChanges( const target = staged.get(item.id) ?? item.originalState; if (target === item.originalState) continue; + const fromState = item.originalState; const result = await setExtensionState( { activePath: item.activePath, disabledPath: item.disabledPath }, target @@ -864,10 +1483,13 @@ async function applyStagedChanges( if (result.ok) { changed++; - logExtensionToggle(pi, item.id, item.originalState, target, true); + item.state = target; + item.originalState = target; + staged.delete(item.id); + logExtensionToggle(pi, item.id, fromState, target, true); } else { errors.push(`${item.id}: ${result.error}`); - logExtensionToggle(pi, item.id, item.originalState, target, false, result.error); + logExtensionToggle(pi, item.id, fromState, target, false, result.error); } } @@ -895,23 +1517,9 @@ export async function showInstalledPackagesLegacy( export async function showListOnly(ctx: ExtensionCommandContext): Promise { const entries = await discoverExtensions(ctx.cwd); if (entries.length === 0) { - const msg = "No extensions found in ~/.pi/agent/extensions or .pi/extensions"; - if (ctx.hasUI) { - ctx.ui.notify(msg, "info"); - } else { - console.log(msg); - } + notify(ctx, "No extensions found in ~/.pi/agent/extensions or .pi/extensions", "info"); return; } - const lines = entries.map(formatExtEntry); - const output = lines.join("\n"); - const titledOutput = `Local extensions:\n${output}`; - - if (ctx.hasUI) { - ctx.ui.notify(titledOutput, "info"); - } else { - console.log("Local extensions:"); - console.log(output); - } + formatListOutput(ctx, "Local extensions", entries.map(formatExtEntry)); } diff --git a/src/utils/auto-update.ts b/src/utils/auto-update.ts index 4619ef2..1cba279 100644 --- a/src/utils/auto-update.ts +++ b/src/utils/auto-update.ts @@ -7,6 +7,7 @@ import { type ExtensionContext, } from "@mariozechner/pi-coding-agent"; import { getPackageCatalog } from "../packages/catalog.js"; +import { parseChoiceByLabel } from "./command.js"; import { logAutoUpdateConfig } from "./history.js"; import { notify } from "./notify.js"; import { normalizePackageIdentity } from "./package-source.js"; @@ -21,6 +22,15 @@ import { import { isTimerRunning, startTimer, stopTimer } from "./timer.js"; +const AUTO_UPDATE_WIZARD_CHOICES = { + off: "Off", + hour: "Every hour", + daily: "Daily", + weekly: "Weekly", + custom: "Custom...", + cancel: "Cancel", +} as const; + // Context provider for safe session handling export type ContextProvider = () => (ExtensionCommandContext | ExtensionContext) | undefined; @@ -148,35 +158,30 @@ export async function promptAutoUpdateWizard( } const current = getAutoUpdateConfig(ctx); - const choice = await ctx.ui.select(`Auto-update (${current.displayText})`, [ - "Off", - "Every hour", - "Daily", - "Weekly", - "Custom...", - "Cancel", - ]); - - if (!choice || choice === "Cancel") return; - - if (choice === "Off") { - disableAutoUpdate(pi, ctx); - return; - } - - if (choice === "Every hour") { - enableAutoUpdate(pi, ctx, 60 * 60 * 1000, "1 hour", onUpdateAvailable); - return; - } - - if (choice === "Daily") { - enableAutoUpdate(pi, ctx, 24 * 60 * 60 * 1000, "daily", onUpdateAvailable); - return; - } + const choice = parseChoiceByLabel( + AUTO_UPDATE_WIZARD_CHOICES, + await ctx.ui.select( + `Auto-update (${current.displayText})`, + Object.values(AUTO_UPDATE_WIZARD_CHOICES) + ) + ); - if (choice === "Weekly") { - enableAutoUpdate(pi, ctx, 7 * 24 * 60 * 60 * 1000, "weekly", onUpdateAvailable); - return; + switch (choice) { + case "off": + disableAutoUpdate(pi, ctx); + return; + case "hour": + enableAutoUpdate(pi, ctx, 60 * 60 * 1000, "1 hour", onUpdateAvailable); + return; + case "daily": + enableAutoUpdate(pi, ctx, 24 * 60 * 60 * 1000, "daily", onUpdateAvailable); + return; + case "weekly": + enableAutoUpdate(pi, ctx, 7 * 24 * 60 * 60 * 1000, "weekly", onUpdateAvailable); + return; + case "cancel": + case undefined: + return; } const input = await ctx.ui.input("Auto-update interval", current.displayText || "1d"); @@ -184,7 +189,7 @@ export async function promptAutoUpdateWizard( const parsed = parseDuration(input.trim()); if (!parsed) { - notify(ctx, "Invalid duration. Examples: 1h, 1d, 1w, 1m, never", "warning"); + notify(ctx, "Invalid duration. Examples: 1h, 1d, 1w, 1mo, never", "warning"); return; } diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 16b82e1..fce715d 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -13,13 +13,27 @@ const CACHE_DIR = process.env.PI_EXTMGR_CACHE_DIR : join(homedir(), ".pi", "agent", ".extmgr-cache"); const CACHE_FILE = join(CACHE_DIR, "metadata.json"); const CURRENT_SEARCH_CACHE_STRATEGY = "npm-registry-v1-paginated"; +const CACHED_PACKAGE_FIELDS = [ + "description", + "version", + "author", + "keywords", + "date", + "size", +] as const; + +type CachedPackageField = (typeof CACHED_PACKAGE_FIELDS)[number]; interface CachedPackageData { name: string; description?: string | undefined; version?: string | undefined; + author?: string | undefined; + keywords?: string[] | undefined; + date?: string | undefined; size?: number | undefined; timestamp: number; + fieldTimestamps?: Partial> | undefined; } interface CacheData { @@ -55,17 +69,66 @@ function normalizeCachedPackageEntry(key: string, value: unknown): CachedPackage name, timestamp, }; + const rawFieldTimestamps = isRecord(value.fieldTimestamps) ? value.fieldTimestamps : undefined; + + const getFieldTimestamp = (field: CachedPackageField): number => { + const fieldTimestamp = rawFieldTimestamps?.[field]; + return typeof fieldTimestamp === "number" && + Number.isFinite(fieldTimestamp) && + fieldTimestamp > 0 + ? fieldTimestamp + : timestamp; + }; if (typeof value.description === "string") { entry.description = value.description; + entry.fieldTimestamps = { + ...entry.fieldTimestamps, + description: getFieldTimestamp("description"), + }; } if (typeof value.version === "string") { entry.version = value.version; + entry.fieldTimestamps = { + ...entry.fieldTimestamps, + version: getFieldTimestamp("version"), + }; + } + + if (typeof value.author === "string") { + entry.author = value.author; + entry.fieldTimestamps = { + ...entry.fieldTimestamps, + author: getFieldTimestamp("author"), + }; + } + + if (Array.isArray(value.keywords)) { + const keywords = value.keywords.filter((item): item is string => typeof item === "string"); + if (keywords.length > 0) { + entry.keywords = keywords; + entry.fieldTimestamps = { + ...entry.fieldTimestamps, + keywords: getFieldTimestamp("keywords"), + }; + } + } + + if (typeof value.date === "string") { + entry.date = value.date; + entry.fieldTimestamps = { + ...entry.fieldTimestamps, + date: getFieldTimestamp("date"), + }; } if (typeof value.size === "number" && Number.isFinite(value.size) && value.size >= 0) { entry.size = value.size; + entry.fieldTimestamps = { + ...entry.fieldTimestamps, + size: getFieldTimestamp("size"), + }; } return entry; @@ -234,11 +297,117 @@ async function enqueueCacheSave(): Promise { return cacheWriteQueue; } +function setCachedPackageField( + data: CachedPackageData, + field: CachedPackageField, + value: CachedPackageData[CachedPackageField], + timestamp: number +): void { + switch (field) { + case "description": + data.description = value as string | undefined; + break; + case "version": + data.version = value as string | undefined; + break; + case "author": + data.author = value as string | undefined; + break; + case "keywords": + data.keywords = value as string[] | undefined; + break; + case "date": + data.date = value as string | undefined; + break; + case "size": + data.size = value as number | undefined; + break; + } + + data.fieldTimestamps = { + ...data.fieldTimestamps, + [field]: timestamp, + }; +} + +function getCachedFieldTimestamp(data: CachedPackageData, field: CachedPackageField): number { + return data.fieldTimestamps?.[field] ?? data.timestamp; +} + +function mergeCachedPackageData( + existing: CachedPackageData | undefined, + next: Omit +): CachedPackageData { + const timestamp = Date.now(); + const merged: CachedPackageData = { + name: next.name || existing?.name || "", + timestamp, + }; + + for (const field of CACHED_PACKAGE_FIELDS) { + const nextValue = next[field]; + if (nextValue !== undefined) { + setCachedPackageField(merged, field, nextValue, timestamp); + continue; + } + + const existingValue = existing?.[field]; + if (existingValue !== undefined && existing) { + setCachedPackageField(merged, field, existingValue, getCachedFieldTimestamp(existing, field)); + } + } + + return merged; +} + /** * Check if cached data is still valid (within TTL) */ -function isCacheValid(timestamp: number): boolean { - return Date.now() - timestamp < CACHE_LIMITS.metadataTTL; +function isCacheValid(timestamp: number | undefined): boolean { + return typeof timestamp === "number" && Date.now() - timestamp < CACHE_LIMITS.metadataTTL; +} + +function getFreshCachedField( + data: CachedPackageData, + field: CachedPackageField +): CachedPackageData[CachedPackageField] | undefined { + const value = data[field]; + if (value === undefined) { + return undefined; + } + + return isCacheValid(getCachedFieldTimestamp(data, field)) ? value : undefined; +} + +function hasFreshCachedField(data: CachedPackageData): boolean { + return CACHED_PACKAGE_FIELDS.some((field) => { + const value = data[field]; + return value !== undefined && isCacheValid(getCachedFieldTimestamp(data, field)); + }); +} + +function toFreshCachedPackage(data: CachedPackageData | undefined): CachedPackageData | null { + if (!data) { + return null; + } + + const fresh: CachedPackageData = { + name: data.name, + timestamp: data.timestamp, + }; + let hasFreshField = false; + + for (const field of CACHED_PACKAGE_FIELDS) { + const value = getFreshCachedField(data, field); + if (value === undefined) { + continue; + } + + hasFreshField = true; + setCachedPackageField(fresh, field, value, getCachedFieldTimestamp(data, field)); + } + + return hasFreshField ? fresh : null; } /** @@ -246,13 +415,7 @@ function isCacheValid(timestamp: number): boolean { */ export async function getCachedPackage(name: string): Promise { const cache = await loadCache(); - const data = cache.packages.get(name); - - if (!data || !isCacheValid(data.timestamp)) { - return null; - } - - return data; + return toFreshCachedPackage(cache.packages.get(name)); } /** @@ -260,13 +423,10 @@ export async function getCachedPackage(name: string): Promise + data: Omit ): Promise { const cache = await loadCache(); - cache.packages.set(name, { - ...data, - timestamp: Date.now(), - }); + cache.packages.set(name, mergeCachedPackageData(cache.packages.get(name), data)); await enqueueCacheSave(); } @@ -295,8 +455,12 @@ export async function getCachedSearch(query: string): Promise { const cache = await loadCache(); const data = cache.packages.get(name); - - if (data && isCacheValid(data.timestamp)) { - return data.size; - } - - return undefined; + return data ? (getFreshCachedField(data, "size") as number | undefined) : undefined; } /** @@ -412,14 +578,19 @@ export async function setCachedPackageSize(name: string, size: number): Promise< const cache = await loadCache(); const existing = cache.packages.get(name); + const timestamp = Date.now(); + if (existing) { - existing.size = size; - existing.timestamp = Date.now(); + existing.timestamp = timestamp; + setCachedPackageField(existing, "size", size, timestamp); } else { cache.packages.set(name, { name, size, - timestamp: Date.now(), + timestamp, + fieldTimestamps: { + size: timestamp, + }, }); } diff --git a/src/utils/duration.ts b/src/utils/duration.ts new file mode 100644 index 0000000..313642c --- /dev/null +++ b/src/utils/duration.ts @@ -0,0 +1,132 @@ +export type DurationUnit = "minute" | "hour" | "day" | "week" | "month"; + +interface ParsedDuration { + ms: number; + display: string; +} + +interface DurationAlias { + ms: number; + display: string; +} + +interface DurationUnitDefinition { + aliases: readonly string[]; + ms: number; + singular: string; + plural: string; +} + +interface ParseDurationOptions { + allowedUnits: readonly DurationUnit[]; + aliases?: Readonly>; +} + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; + +const DURATION_UNITS: Record = { + minute: { + aliases: ["m", "min", "mins", "minute", "minutes"], + ms: 60 * 1000, + singular: "minute", + plural: "minutes", + }, + hour: { + aliases: ["h", "hr", "hrs", "hour", "hours"], + ms: HOUR_MS, + singular: "hour", + plural: "hours", + }, + day: { + aliases: ["d", "day", "days"], + ms: DAY_MS, + singular: "day", + plural: "days", + }, + week: { + aliases: ["w", "wk", "wks", "week", "weeks"], + ms: WEEK_MS, + singular: "week", + plural: "weeks", + }, + month: { + aliases: ["mo", "mos", "month", "months"], + ms: MONTH_MS, + singular: "month", + plural: "months", + }, +}; + +function formatDisplay(value: number, unit: DurationUnit): string { + const definition = DURATION_UNITS[unit]; + return `${value} ${value === 1 ? definition.singular : definition.plural}`; +} + +function findDurationUnit( + rawUnit: string, + allowedUnits: readonly DurationUnit[] +): DurationUnit | undefined { + return allowedUnits.find((unit) => DURATION_UNITS[unit].aliases.includes(rawUnit)); +} + +export function parseDurationValue( + input: string, + options: ParseDurationOptions +): ParsedDuration | undefined { + const normalized = input.toLowerCase().trim(); + if (!normalized) { + return undefined; + } + + const alias = options.aliases?.[normalized]; + if (alias) { + return { ...alias }; + } + + const match = normalized.match(/^(\d+)\s*([a-z]+)$/i); + if (!match) { + return undefined; + } + + const value = Number.parseInt(match[1] ?? "", 10); + const rawUnit = match[2] ?? ""; + if (!Number.isFinite(value) || value <= 0 || !rawUnit) { + return undefined; + } + + const unit = findDurationUnit(rawUnit, options.allowedUnits); + if (!unit) { + return undefined; + } + + return { + ms: value * DURATION_UNITS[unit].ms, + display: formatDisplay(value, unit), + }; +} + +const SCHEDULE_DURATION_ALIASES = { + never: { ms: 0, display: "off" }, + off: { ms: 0, display: "off" }, + disable: { ms: 0, display: "off" }, + daily: { ms: DAY_MS, display: "daily" }, + day: { ms: DAY_MS, display: "daily" }, + weekly: { ms: WEEK_MS, display: "weekly" }, + week: { ms: WEEK_MS, display: "weekly" }, +} satisfies Record; + +export function parseScheduleDuration(input: string): ParsedDuration | undefined { + return parseDurationValue(input, { + allowedUnits: ["hour", "day", "week", "month"], + aliases: SCHEDULE_DURATION_ALIASES, + }); +} + +export function parseLookbackDuration(input: string): number | undefined { + return parseDurationValue(input, { + allowedUnits: ["minute", "hour", "day", "week", "month"], + })?.ms; +} diff --git a/src/utils/format.ts b/src/utils/format.ts index 85d499f..c71a753 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -9,36 +9,6 @@ export function truncate(text: string, maxLength: number): string { return `${text.slice(0, maxLength - 3)}...`; } -/** - * Get the terminal width, with a minimum fallback - */ -export function getTerminalWidth(minWidth = 80): number { - return Math.max(minWidth, process.stdout.columns || minWidth); -} - -/** - * Calculate available space for description based on fixed-width elements - */ -export function getDescriptionWidth( - totalWidth: number, - reservedSpace: number, - minDescWidth = 20 -): number { - return Math.max(minDescWidth, totalWidth - reservedSpace); -} - -/** - * Dynamic truncate that adapts to available terminal width - * @param text - Text to truncate - * @param reservedSpace - Space taken by fixed elements (icons, name, version, etc.) - * @param minWidth - Minimum terminal width to consider - */ -export function dynamicTruncate(text: string, reservedSpace: number, minWidth = 80): string { - const termWidth = getTerminalWidth(minWidth); - const maxDescWidth = getDescriptionWidth(termWidth, reservedSpace); - return truncate(text, maxDescWidth); -} - export function formatEntry(entry: ExtensionEntry): string { const state = entry.state === "enabled" ? "on " : "off"; const scope = entry.scope === "global" ? "G" : "P"; diff --git a/src/utils/history.ts b/src/utils/history.ts index 741d8f1..01d0f4f 100644 --- a/src/utils/history.ts +++ b/src/utils/history.ts @@ -299,6 +299,41 @@ async function walkSessionFiles(dir: string): Promise { return result; } +function pushGlobalHistoryEntry( + entries: GlobalHistoryEntry[], + nextEntry: GlobalHistoryEntry, + limit: number | undefined +): void { + if (!limit || limit <= 0) { + entries.push(nextEntry); + return; + } + + if (entries.length < limit) { + entries.push(nextEntry); + return; + } + + let oldestIndex = 0; + let oldestEntry = entries[oldestIndex]; + if (!oldestEntry) { + entries.push(nextEntry); + return; + } + + for (let index = 1; index < entries.length; index++) { + const entry = entries[index]; + if (entry && entry.change.timestamp < oldestEntry.change.timestamp) { + oldestIndex = index; + oldestEntry = entry; + } + } + + if (oldestEntry.change.timestamp <= nextEntry.change.timestamp) { + entries[oldestIndex] = nextEntry; + } +} + /** * Query change history across all persisted pi sessions. */ @@ -307,7 +342,8 @@ export async function queryGlobalHistory( sessionDir = DEFAULT_SESSION_DIR ): Promise { const files = await walkSessionFiles(sessionDir); - const all: GlobalHistoryEntry[] = []; + const matchedEntries: GlobalHistoryEntry[] = []; + const limit = filters.limit ?? 20; for (const file of files) { let text: string; @@ -337,16 +373,16 @@ export async function queryGlobalHistory( } const change = asChangeEntry(parsed.data); - if (change) { - all.push({ change, sessionFile: file }); + if (!change || !matchesHistoryFilters(change, filters)) { + continue; } + + pushGlobalHistoryEntry(matchedEntries, { change, sessionFile: file }, limit); } } - all.sort((a, b) => a.change.timestamp - b.change.timestamp); - - const filtered = all.filter((entry) => matchesHistoryFilters(entry.change, filters)); - return applyHistoryLimit(filtered, filters); + matchedEntries.sort((a, b) => a.change.timestamp - b.change.timestamp); + return matchedEntries; } /** diff --git a/src/utils/mode.ts b/src/utils/mode.ts index 840401e..3acb79a 100644 --- a/src/utils/mode.ts +++ b/src/utils/mode.ts @@ -66,7 +66,7 @@ export async function runCustomUI( } const suffix = fallbackMessage ? ` ${fallbackMessage}` : ""; - notify(ctx, `${featureName} requires the full interactive TUI.${suffix}`, "warning"); + notify(ctx, `${featureName} is unavailable in the current UI mode.${suffix}`, "warning"); return undefined; } diff --git a/src/utils/notify.ts b/src/utils/notify.ts index 2ba3657..e9fe00f 100644 --- a/src/utils/notify.ts +++ b/src/utils/notify.ts @@ -34,17 +34,3 @@ export function success(ctx: ExtensionCommandContext | ExtensionContext, message export function error(ctx: ExtensionCommandContext | ExtensionContext, message: string): void { notify(ctx, message, "error"); } - -/** - * Show warning message - */ -export function warning(ctx: ExtensionCommandContext | ExtensionContext, message: string): void { - notify(ctx, message, "warning"); -} - -/** - * Show info message - */ -export function info(ctx: ExtensionCommandContext | ExtensionContext, message: string): void { - notify(ctx, message, "info"); -} diff --git a/src/utils/package-source.ts b/src/utils/package-source.ts index 41a41ef..091762f 100644 --- a/src/utils/package-source.ts +++ b/src/utils/package-source.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { parseNpmSource } from "./format.js"; +import { normalizePathIdentity } from "./path-identity.js"; export type PackageSourceKind = "npm" | "git" | "local" | "unknown"; @@ -52,11 +53,7 @@ export function getPackageSourceKind(source: string): PackageSourceKind { } export function normalizeLocalSourceIdentity(source: string): string { - const normalized = source.replace(/\\/g, "/"); - const looksWindowsPath = - /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || source.includes("\\"); - - return looksWindowsPath ? normalized.toLowerCase() : normalized; + return normalizePathIdentity(source); } export function stripGitSourcePrefix(source: string): string { diff --git a/src/utils/path-identity.ts b/src/utils/path-identity.ts new file mode 100644 index 0000000..1e9def2 --- /dev/null +++ b/src/utils/path-identity.ts @@ -0,0 +1,7 @@ +export function normalizePathIdentity(value: string): string { + const normalized = value.replace(/\\/g, "/"); + const looksWindowsPath = + /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\"); + + return looksWindowsPath ? normalized.toLowerCase() : normalized; +} diff --git a/src/utils/relative-path-selection.ts b/src/utils/relative-path-selection.ts new file mode 100644 index 0000000..be6916d --- /dev/null +++ b/src/utils/relative-path-selection.ts @@ -0,0 +1,100 @@ +import { matchesGlob } from "node:path"; + +export function normalizeRelativePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +export function hasGlobMagic(path: string): boolean { + return /[*?{}[\]]/.test(path); +} + +export function isSafeRelativePath(path: string): boolean { + const normalizedPath = path.replace(/\\/g, "/"); + + return ( + normalizedPath !== "" && + normalizedPath !== ".." && + !normalizedPath.startsWith("/") && + !path.startsWith("\\") && + !/^[A-Za-z]:/.test(normalizedPath) && + !normalizedPath.startsWith("../") && + !normalizedPath.includes("/../") && + !normalizedPath.endsWith("/..") + ); +} + +export function safeMatchesGlob(targetPath: string, pattern: string): boolean { + try { + return matchesGlob(targetPath, pattern); + } catch { + return false; + } +} + +export function matchesFilterPattern(targetPath: string, pattern: string): boolean { + const normalizedPattern = normalizeRelativePath(pattern.trim()); + if (!normalizedPattern) return false; + if (targetPath === normalizedPattern) return true; + + return safeMatchesGlob(targetPath, normalizedPattern); +} + +export function selectDirectoryFiles(allFiles: readonly string[], directoryPath: string): string[] { + const prefix = `${directoryPath}/`; + return allFiles.filter((file) => file.startsWith(prefix)); +} + +export function applySelection( + selected: Set, + files: Iterable, + exclude: boolean +): void { + for (const file of files) { + if (exclude) { + selected.delete(file); + } else { + selected.add(file); + } + } +} + +export function resolveRelativePathSelection( + allFiles: readonly string[], + entries: readonly string[], + isExactPathSelectable: (path: string, allFiles: readonly string[]) => boolean +): string[] { + const selected = new Set(); + + for (const rawToken of entries) { + const token = rawToken.trim(); + if (!token) continue; + + const exclude = token.startsWith("!"); + const normalizedToken = normalizeRelativePath(exclude ? token.slice(1) : token); + const pattern = normalizedToken.replace(/[\\/]+$/g, ""); + if (!isSafeRelativePath(pattern)) { + continue; + } + + if (hasGlobMagic(pattern)) { + applySelection( + selected, + allFiles.filter((file) => matchesFilterPattern(file, pattern)), + exclude + ); + continue; + } + + const directoryFiles = selectDirectoryFiles(allFiles, pattern); + if (directoryFiles.length > 0) { + applySelection(selected, directoryFiles, exclude); + continue; + } + + if (isExactPathSelectable(pattern, allFiles)) { + applySelection(selected, [pattern], exclude); + } + } + + return Array.from(selected).sort((a, b) => a.localeCompare(b)); +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts deleted file mode 100644 index 30a95cd..0000000 --- a/src/utils/retry.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Retry utilities for async operations - */ - -export interface RetryOptions { - maxAttempts?: number; - delayMs?: number; - backoff?: "fixed" | "linear" | "exponential"; -} - -export async function retryWithBackoff( - operation: () => Promise, - options: RetryOptions = {} -): Promise { - const { maxAttempts = 5, delayMs = 100, backoff = "exponential" } = options; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const result = await operation(); - if (result !== undefined) { - return result; - } - - if (attempt < maxAttempts) { - const delay = - backoff === "exponential" - ? delayMs * 2 ** (attempt - 1) - : backoff === "linear" - ? delayMs * attempt - : delayMs; - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - return undefined; -} - -/** - * Wait for a condition to be true with timeout - */ -export async function waitForCondition( - condition: () => Promise | boolean, - options: RetryOptions = {} -): Promise { - const result = await retryWithBackoff(async () => { - const value = await condition(); - return value ? true : undefined; - }, options); - return result === true; -} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 856590b..abce3f7 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -11,6 +11,7 @@ import { type ExtensionCommandContext, type ExtensionContext, } from "@mariozechner/pi-coding-agent"; +import { parseScheduleDuration } from "./duration.js"; import { fileExists } from "./fs.js"; import { normalizePackageIdentity } from "./package-source.js"; @@ -304,71 +305,11 @@ export function clearUpdatesAvailable( } /** - * Parse duration string to milliseconds - * Supports: 1h, 2h, 1d, 7d, 1m, 3m, etc. - * Also supports: never, off, disable, daily, weekly + * Parse schedule duration strings for auto-update settings. + * Supports hours/days/weeks/months plus schedule aliases like `daily`, `weekly`, and `never`. */ export function parseDuration(input: string): { ms: number; display: string } | undefined { - const normalized = input.toLowerCase().trim(); - - // Special cases for disabling - if (normalized === "never" || normalized === "off" || normalized === "disable") { - return { ms: 0, display: "off" }; - } - - // Named schedules - if (normalized === "daily" || normalized === "day" || normalized === "1d") { - return { ms: 24 * 60 * 60 * 1000, display: "daily" }; - } - if (normalized === "weekly" || normalized === "week" || normalized === "1w") { - return { ms: 7 * 24 * 60 * 60 * 1000, display: "weekly" }; - } - - // Parse duration patterns: 1h, 2h, 3d, 7d, 1m, etc. - const durationMatch = normalized.match( - /^(\d+)\s*(h|hr|hrs|hour|hours|d|day|days|w|wk|wks|week|weeks|m|mo|mos|month|months)$/ - ); - if (durationMatch) { - const [, rawValue, rawUnit] = durationMatch; - if (!rawValue || !rawUnit) { - return undefined; - } - - const value = Number.parseInt(rawValue, 10); - const unit = rawUnit[0]; - if (!unit) { - return undefined; - } - - let ms: number; - let display: string; - - switch (unit) { - case "h": - ms = value * 60 * 60 * 1000; - display = value === 1 ? "1 hour" : `${value} hours`; - break; - case "d": - ms = value * 24 * 60 * 60 * 1000; - display = value === 1 ? "1 day" : `${value} days`; - break; - case "w": - ms = value * 7 * 24 * 60 * 60 * 1000; - display = value === 1 ? "1 week" : `${value} weeks`; - break; - case "m": - // Approximate months as 30 days - ms = value * 30 * 24 * 60 * 60 * 1000; - display = value === 1 ? "1 month" : `${value} months`; - break; - default: - return undefined; - } - - return { ms, display }; - } - - return undefined; + return parseScheduleDuration(input); } /** diff --git a/test/auto-update.test.ts b/test/auto-update.test.ts index 25c532d..092d4b9 100644 --- a/test/auto-update.test.ts +++ b/test/auto-update.test.ts @@ -22,10 +22,11 @@ void test("parseDuration supports flexible durations", () => { ms: 2 * 7 * 24 * 60 * 60 * 1000, display: "2 weeks", }); - assert.deepEqual(parseDuration("1m"), { + assert.deepEqual(parseDuration("1mo"), { ms: 30 * 24 * 60 * 60 * 1000, display: "1 month", }); + assert.equal(parseDuration("1m"), undefined); assert.deepEqual(parseDuration("never"), { ms: 0, display: "off" }); assert.equal(parseDuration("nope"), undefined); }); diff --git a/test/cache-history.test.ts b/test/cache-history.test.ts index d69a470..76c45f1 100644 --- a/test/cache-history.test.ts +++ b/test/cache-history.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { type ExtensionAPI, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; import { clearMetadataCacheCommand } from "../src/commands/cache.js"; @@ -7,6 +10,7 @@ import { formatChangeEntry, logAutoUpdateConfig, logExtensionDelete, + queryGlobalHistory, querySessionChanges, } from "../src/utils/history.js"; import { createMockHarness } from "./helpers/mocks.js"; @@ -32,6 +36,52 @@ void test("clearMetadataCacheCommand clears runtime search cache and records his assert.equal(historyEntry?.success, true); }); +void test("queryGlobalHistory keeps the latest matching entries without loading more than needed", async () => { + const sessionDir = await mkdtemp(join(tmpdir(), "pi-extmgr-history-")); + + try { + await mkdir(join(sessionDir, "nested"), { recursive: true }); + await writeFile( + join(sessionDir, "first.jsonl"), + [ + JSON.stringify({ type: "custom", customType: "other", data: {} }), + "not json", + JSON.stringify({ + type: "custom", + customType: "extmgr-change", + data: { action: "cache_clear", timestamp: 10, success: true }, + }), + ].join("\n"), + "utf8" + ); + await writeFile( + join(sessionDir, "nested", "second.jsonl"), + [ + JSON.stringify({ + type: "custom", + customType: "extmgr-change", + data: { action: "package_install", timestamp: 30, success: true, packageName: "demo" }, + }), + JSON.stringify({ + type: "custom", + customType: "extmgr-change", + data: { action: "package_update", timestamp: 20, success: true, packageName: "demo" }, + }), + ].join("\n"), + "utf8" + ); + + const changes = await queryGlobalHistory({ limit: 2 }, sessionDir); + + assert.deepEqual( + changes.map((entry) => entry.change.timestamp), + [20, 30] + ); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } +}); + void test("history records local extension deletion and auto-update config changes", () => { const entries: { type: "custom"; customType: string; data: unknown }[] = []; const pi = { diff --git a/test/duration.test.ts b/test/duration.test.ts new file mode 100644 index 0000000..de2173b --- /dev/null +++ b/test/duration.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseLookbackDuration, parseScheduleDuration } from "../src/utils/duration.js"; + +void test("parseScheduleDuration only accepts schedule-safe units", () => { + assert.deepEqual(parseScheduleDuration("1h"), { + ms: 60 * 60 * 1000, + display: "1 hour", + }); + assert.deepEqual(parseScheduleDuration("daily"), { + ms: 24 * 60 * 60 * 1000, + display: "daily", + }); + assert.equal(parseScheduleDuration("1m"), undefined); +}); + +void test("parseLookbackDuration distinguishes minutes from months", () => { + assert.equal(parseLookbackDuration("30m"), 30 * 60 * 1000); + assert.equal(parseLookbackDuration("1mo"), 30 * 24 * 60 * 60 * 1000); + assert.equal(parseLookbackDuration("weekly"), undefined); +}); diff --git a/test/helpers/custom-component.ts b/test/helpers/custom-component.ts new file mode 100644 index 0000000..d202dea --- /dev/null +++ b/test/helpers/custom-component.ts @@ -0,0 +1,105 @@ +const noop = (): undefined => undefined; + +export interface TestCustomComponent { + render(width: number): string[]; + handleInput?(data: string): void; + dispose?(): void; +} + +export interface CaptureCustomComponentOptions { + width?: number; + height?: number; + matcher?: (lines: string[]) => boolean; + mismatchTimeoutMs?: number; +} + +export function captureCustomComponent( + factory: unknown, + theme: unknown, + matcher: (lines: string[]) => boolean, + onReady: ( + component: TestCustomComponent, + lines: string[], + completion: Promise + ) => T | Promise +): Promise; +export function captureCustomComponent( + factory: unknown, + theme: unknown, + onReady: ( + component: TestCustomComponent, + lines: string[], + completion: Promise + ) => T | Promise, + options?: CaptureCustomComponentOptions +): Promise; +export async function captureCustomComponent( + factory: unknown, + theme: unknown, + matcherOrOnReady: + | ((lines: string[]) => boolean) + | (( + component: TestCustomComponent, + lines: string[], + completion: Promise + ) => T | Promise), + onReadyOrOptions?: + | (( + component: TestCustomComponent, + lines: string[], + completion: Promise + ) => T | Promise) + | CaptureCustomComponentOptions, + maybeOptions?: CaptureCustomComponentOptions +): Promise { + const matcher = + typeof onReadyOrOptions === "function" + ? (matcherOrOnReady as (lines: string[]) => boolean) + : undefined; + const onReady = + typeof onReadyOrOptions === "function" + ? onReadyOrOptions + : (matcherOrOnReady as ( + component: TestCustomComponent, + lines: string[], + completion: Promise + ) => T | Promise); + const options = typeof onReadyOrOptions === "function" ? maybeOptions : onReadyOrOptions; + + let resolveCompletion: (value: unknown) => void = () => undefined; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + + const width = options?.width ?? 120; + const height = options?.height ?? 40; + const component = await ( + factory as ( + tui: unknown, + theme: unknown, + keybindings: unknown, + done: (result: unknown) => void + ) => Promise | TestCustomComponent + )( + { requestRender: noop, terminal: { rows: height, columns: width } }, + theme, + {}, + resolveCompletion + ); + + try { + const lines = component.render(width); + if ((matcher ?? options?.matcher) && !(matcher ?? options?.matcher)?.(lines)) { + return await Promise.race([ + completion, + new Promise((resolve) => + setTimeout(() => resolve(undefined), options?.mismatchTimeoutMs ?? 50) + ), + ]); + } + + return await onReady(component, lines, completion); + } finally { + component.dispose?.(); + } +} diff --git a/test/install-remove.test.ts b/test/install-remove.test.ts index 810d992..da7006b 100644 --- a/test/install-remove.test.ts +++ b/test/install-remove.test.ts @@ -4,7 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { type ExtensionAPI, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; -import { installFromUrl, installPackage, installPackageLocally } from "../src/packages/install.js"; +import { + installFromUrl, + installPackage, + installPackageWithOutcome, + installPackageLocally, +} from "../src/packages/install.js"; import { removePackage, updatePackage, updatePackages } from "../src/packages/management.js"; import { createMockHarness } from "./helpers/mocks.js"; import { mockPackageCatalog } from "./helpers/package-catalog.js"; @@ -45,6 +50,36 @@ void test("installPackage normalizes git@ sources to git: prefix", async () => { } }); +void test("installPackageWithOutcome reports successful direct URL installs", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-url-outcome-")); + const originalFetch = globalThis.fetch; + + try { + globalThis.fetch = (async () => + new Response("// demo extension\n", { + status: 200, + headers: { "content-type": "text/plain" }, + })) as typeof fetch; + + const { pi, ctx } = createMockHarness({ cwd, confirmResult: false }); + const result = await installPackageWithOutcome( + "https://raw.githubusercontent.com/demo/ext/main/demo.ts", + ctx, + pi, + { scope: "project" } + ); + + assert.deepEqual(result, { installed: true, reloaded: false }); + const saved = await access(join(cwd, ".pi", "extensions", "demo.ts")) + .then(() => true) + .catch(() => false); + assert.equal(saved, true); + } finally { + globalThis.fetch = originalFetch; + await rm(cwd, { recursive: true, force: true }); + } +}); + void test("removePackage removes the selected package source", async () => { const removals: { source: string; scope: "global" | "project" }[] = []; const restoreCatalog = mockPackageCatalog({ diff --git a/test/local-discovery.test.ts b/test/local-discovery.test.ts new file mode 100644 index 0000000..8584055 --- /dev/null +++ b/test/local-discovery.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { discoverExtensions } from "../src/extensions/discovery.js"; + +void test("discoverExtensions includes manifest-declared local entrypoints, including disabled files", async () => { + const tempHome = await mkdtemp(join(tmpdir(), "pi-extmgr-home-")); + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-local-discovery-")); + const previousHome = process.env.HOME; + + try { + process.env.HOME = tempHome; + + const pkgRoot = join(cwd, ".pi", "extensions", "demo-pkg"); + await mkdir(join(pkgRoot, "extensions"), { recursive: true }); + await writeFile( + join(pkgRoot, "package.json"), + JSON.stringify( + { + name: "demo-pkg", + pi: { extensions: ["./custom.ts", "./extensions/*.ts"] }, + }, + null, + 2 + ), + "utf8" + ); + await writeFile(join(pkgRoot, "custom.ts"), "// custom entrypoint\n", "utf8"); + await writeFile( + join(pkgRoot, "extensions", "queue.ts.disabled"), + "// disabled entrypoint\n", + "utf8" + ); + + const entries = await discoverExtensions(cwd); + const customEntry = entries.find((entry) => entry.displayName.endsWith("demo-pkg/custom.ts")); + const disabledEntry = entries.find((entry) => + entry.displayName.endsWith("demo-pkg/extensions/queue.ts") + ); + + assert.equal(customEntry?.scope, "project"); + assert.equal(customEntry?.state, "enabled"); + assert.equal(disabledEntry?.scope, "project"); + assert.equal(disabledEntry?.state, "disabled"); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await rm(tempHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/metadata-cache.test.ts b/test/metadata-cache.test.ts new file mode 100644 index 0000000..174ca9f --- /dev/null +++ b/test/metadata-cache.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { CACHE_LIMITS } from "../src/constants.js"; + +void test("metadata cache merges partial package updates without discarding richer fields", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-cache-")); + const previousCacheDir = process.env.PI_EXTMGR_CACHE_DIR; + + try { + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + + const cache = (await import( + `../src/utils/cache.ts?cache-merge=${Date.now()}` + )) as typeof import("../src/utils/cache.js"); + + await cache.clearCache(); + await cache.setCachedPackageSize("demo", 2048); + await cache.setCachedSearch("keywords:pi-package", [ + { + name: "demo", + description: "original description", + author: "ayagmar", + keywords: ["pi-package", "queue"], + date: "2026-04-04T00:00:00.000Z", + }, + ]); + + await cache.setCachedPackage("demo", { + name: "demo", + description: "updated description", + }); + + const cached = await cache.getCachedPackage("demo"); + + assert.equal(cached?.description, "updated description"); + assert.equal(cached?.author, "ayagmar"); + assert.deepEqual(cached?.keywords, ["pi-package", "queue"]); + assert.equal(cached?.date, "2026-04-04T00:00:00.000Z"); + assert.equal(cached?.size, 2048); + } finally { + if (previousCacheDir === undefined) { + delete process.env.PI_EXTMGR_CACHE_DIR; + } else { + process.env.PI_EXTMGR_CACHE_DIR = previousCacheDir; + } + await rm(cacheDir, { recursive: true, force: true }); + } +}); + +void test("metadata cache keeps inherited fields on their original TTL", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-cache-ttl-")); + const previousCacheDir = process.env.PI_EXTMGR_CACHE_DIR; + const originalNow = Date.now; + let now = 1; + + try { + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + Date.now = () => now; + + const cache = (await import( + `../src/utils/cache.ts?cache-ttl=${Math.random()}` + )) as typeof import("../src/utils/cache.js"); + + await cache.clearCache(); + await cache.setCachedPackageSize("demo", 2048); + + now += 1_000; + await cache.setCachedPackage("demo", { + name: "demo", + description: "fresh description", + }); + + now = CACHE_LIMITS.metadataTTL + 500; + + const cached = await cache.getCachedPackage("demo"); + const size = await cache.getCachedPackageSize("demo"); + + assert.equal(cached?.description, "fresh description"); + assert.equal(size, undefined); + assert.equal(cached?.size, undefined); + } finally { + Date.now = originalNow; + if (previousCacheDir === undefined) { + delete process.env.PI_EXTMGR_CACHE_DIR; + } else { + process.env.PI_EXTMGR_CACHE_DIR = previousCacheDir; + } + await rm(cacheDir, { recursive: true, force: true }); + } +}); diff --git a/test/npm-search.test.ts b/test/npm-search.test.ts index c037eb7..1a47bde 100644 --- a/test/npm-search.test.ts +++ b/test/npm-search.test.ts @@ -53,3 +53,43 @@ void test("fetchNpmRegistrySearchResults paginates npm registry results beyond 2 globalThis.fetch = originalFetch; } }); + +void test("fetchNpmRegistrySearchResults prefers maintainer usernames over earlier email-only entries", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + assert.ok(url.includes("registry.npmjs.org/-/v1/search")); + + return Promise.resolve( + new Response( + JSON.stringify({ + total: 1, + objects: [ + { + package: { + name: "demo-author", + version: "1.0.0", + maintainers: [ + { email: "fallback@example.com" }, + { username: "preferred-user", email: "preferred@example.com" }, + ], + }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + }) as typeof fetch; + + try { + const results = await fetchNpmRegistrySearchResults("demo-author"); + assert.equal(results[0]?.author, "preferred-user"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/test/package-config.test.ts b/test/package-config.test.ts index cb9699d..6865b13 100644 --- a/test/package-config.test.ts +++ b/test/package-config.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { type ExtensionAPI, initTheme, type Theme } from "@mariozechner/pi-coding-agent"; +import { type ExtensionAPI, initTheme } from "@mariozechner/pi-coding-agent"; import { discoverPackageExtensions } from "../src/packages/extensions.js"; import { type InstalledPackage, type State } from "../src/types/index.js"; import { @@ -11,65 +11,11 @@ import { buildPackageConfigRows, configurePackageExtensions, } from "../src/ui/package-config.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; import { createMockHarness } from "./helpers/mocks.js"; initTheme(); -const noop = (): undefined => undefined; - -async function captureCustomComponent( - factory: unknown, - ctxTheme: Theme, - matcher: (lines: string[]) => boolean, - onMatch: ( - component: { - render(width: number): string[]; - handleInput?(data: string): void; - dispose?(): void; - }, - lines: string[], - completion: Promise - ) => unknown -): Promise { - let resolveCompletion: (value: unknown) => void = () => undefined; - const completion = new Promise((resolve) => { - resolveCompletion = resolve; - }); - - const component = await ( - factory as ( - tui: unknown, - theme: unknown, - keybindings: unknown, - done: (result: unknown) => void - ) => - | Promise<{ - render(width: number): string[]; - handleInput?(data: string): void; - dispose?(): void; - }> - | { - render(width: number): string[]; - handleInput?(data: string): void; - dispose?(): void; - } - )({ requestRender: noop, terminal: { rows: 40, columns: 120 } }, ctxTheme, {}, resolveCompletion); - - try { - const lines = component.render(120); - if (!matcher(lines)) { - return await Promise.race([ - completion, - new Promise((resolve) => setTimeout(() => resolve(undefined), 50)), - ]); - } - - return await onMatch(component, lines, completion); - } finally { - component.dispose?.(); - } -} - function createPiRecorder() { const entries: { customType: string; data: unknown }[] = []; @@ -128,7 +74,7 @@ void test("buildPackageConfigRows deduplicates duplicate extension paths", async } }); -void test("buildPackageConfigRows marks missing manifest entrypoints as unavailable", async () => { +void test("buildPackageConfigRows only includes manifest entrypoints that still exist", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-package-config-")); const pkgRoot = join(cwd, "vendor", "demo"); @@ -151,13 +97,13 @@ void test("buildPackageConfigRows marks missing manifest entrypoints as unavaila const discovered = await discoverPackageExtensions([pkg], cwd); const rows = await buildPackageConfigRows(discovered); - assert.equal(rows.length, 2); + assert.equal(rows.length, 1); const indexRow = rows.find((row) => row.extensionPath === "index.ts"); const missingRow = rows.find((row) => row.extensionPath === "missing.ts"); assert.equal(indexRow?.available, true); - assert.equal(missingRow?.available, false); + assert.equal(missingRow, undefined); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -226,6 +172,69 @@ void test("applyPackageExtensionChanges applies changed rows and preserves non-m } }); +void test("applyPackageExtensionChanges collapses marker-only package config back to the default string form", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-package-config-")); + const pkgRoot = join(cwd, "vendor", "demo"); + + try { + await mkdir(pkgRoot, { recursive: true }); + await mkdir(join(cwd, ".pi"), { recursive: true }); + + await writeFile( + join(pkgRoot, "package.json"), + JSON.stringify({ name: "demo", pi: { extensions: ["./index.ts"] } }, null, 2), + "utf8" + ); + await writeFile(join(pkgRoot, "index.ts"), "// demo extension\n", "utf8"); + + await writeFile( + join(cwd, ".pi", "settings.json"), + JSON.stringify( + { + packages: [ + { + source: "./vendor/demo", + extensions: ["-index.ts"], + }, + ], + }, + null, + 2 + ), + "utf8" + ); + + const pkg: InstalledPackage = { + source: "./vendor/demo", + name: "demo", + scope: "project", + resolvedPath: pkgRoot, + }; + + const discovered = await discoverPackageExtensions([pkg], cwd); + const rows = await buildPackageConfigRows(discovered); + + const staged = new Map(); + const row = rows.find((entry) => entry.extensionPath === "index.ts"); + assert.ok(row); + staged.set(row.id, "enabled"); + + const { pi } = createPiRecorder(); + const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi); + + assert.equal(result.changed, 1); + assert.equal(result.errors.length, 0); + + const saved = JSON.parse(await readFile(join(cwd, ".pi", "settings.json"), "utf8")) as { + packages: string[]; + }; + + assert.deepEqual(saved.packages, ["./vendor/demo"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + void test("applyPackageExtensionChanges batches multiple row changes in one save", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-package-config-")); const pkgRoot = join(cwd, "vendor", "demo"); @@ -290,11 +299,7 @@ void test("applyPackageExtensionChanges batches multiple row changes in one save packages: { source: string; extensions: string[] }[]; }; - assert.deepEqual(saved.packages[0]?.extensions, [ - "notes:keep", - "-extensions/a.ts", - "+extensions/b.ts", - ]); + assert.deepEqual(saved.packages[0]?.extensions, ["notes:keep", "+extensions/b.ts"]); } finally { await rm(cwd, { recursive: true, force: true }); } diff --git a/test/package-extensions.test.ts b/test/package-extensions.test.ts index 55cb453..8498f67 100644 --- a/test/package-extensions.test.ts +++ b/test/package-extensions.test.ts @@ -110,6 +110,75 @@ void test("discoverPackageExtensions resolves directory tokens with trailing sla } }); +void test("discoverPackageExtensions ignores manifest entrypoints with leading slashes", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); + const pkgRoot = join(cwd, "vendor", "absolute-like-manifest"); + + try { + await mkdir(join(pkgRoot, "extensions"), { recursive: true }); + await writeFile( + join(pkgRoot, "package.json"), + JSON.stringify( + { name: "absolute-like-manifest", pi: { extensions: ["/extensions/index.ts"] } }, + null, + 2 + ), + "utf8" + ); + await writeFile(join(pkgRoot, "extensions", "index.ts"), "// index\n", "utf8"); + + const installed: InstalledPackage[] = [ + { + source: "./vendor/absolute-like-manifest", + name: "absolute-like-manifest", + scope: "project", + resolvedPath: pkgRoot, + }, + ]; + + const discovered = await discoverPackageExtensions(installed, cwd); + assert.deepEqual(discovered, []); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("discoverPackageExtensions ignores manifest exact entrypoints that are missing", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); + const pkgRoot = join(cwd, "vendor", "missing-manifest-file"); + + try { + await mkdir(pkgRoot, { recursive: true }); + await writeFile( + join(pkgRoot, "package.json"), + JSON.stringify( + { name: "missing-manifest-file", pi: { extensions: ["./index.ts", "./missing.ts"] } }, + null, + 2 + ), + "utf8" + ); + await writeFile(join(pkgRoot, "index.ts"), "// index\n", "utf8"); + + const installed: InstalledPackage[] = [ + { + source: "./vendor/missing-manifest-file", + name: "missing-manifest-file", + scope: "project", + resolvedPath: pkgRoot, + }, + ]; + + const discovered = await discoverPackageExtensions(installed, cwd); + assert.deepEqual( + discovered.map((entry) => entry.extensionPath), + ["index.ts"] + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + void test("discoverPackageExtensions falls back to convention extensions directory", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); const pkgRoot = join(cwd, "vendor", "convention-package"); @@ -385,8 +454,7 @@ void test("setPackageExtensionState converts string package entries and keeps la const afterEnable = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")) as { packages: (string | { source: string; extensions?: string[] })[]; }; - const enabledEntry = afterEnable.packages[0] as { source: string; extensions?: string[] }; - assert.deepEqual(enabledEntry.extensions, ["+extensions/main.ts"]); + assert.equal(afterEnable.packages[0], "npm:demo-pkg@1.0.0"); } finally { if (oldAgentDir === undefined) { delete process.env.PI_CODING_AGENT_DIR; diff --git a/test/relative-path-selection.test.ts b/test/relative-path-selection.test.ts new file mode 100644 index 0000000..29dca98 --- /dev/null +++ b/test/relative-path-selection.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isSafeRelativePath, + resolveRelativePathSelection, +} from "../src/utils/relative-path-selection.js"; + +void test("isSafeRelativePath rejects Windows absolute and UNC paths", () => { + assert.equal(isSafeRelativePath("C:/repo/extensions/index.ts"), false); + assert.equal(isSafeRelativePath("C:\\repo\\extensions\\index.ts"), false); + assert.equal(isSafeRelativePath("\\\\server\\share\\index.ts"), false); + assert.equal(isSafeRelativePath("extensions/index.ts"), true); +}); + +void test("resolveRelativePathSelection ignores Windows absolute tokens", () => { + const selected = resolveRelativePathSelection( + ["extensions/index.ts"], + ["C:/repo/extensions/index.ts", "\\\\server\\share\\index.ts"], + () => true + ); + + assert.deepEqual(selected, []); +}); diff --git a/test/release-ci.test.ts b/test/release-ci.test.ts new file mode 100644 index 0000000..d158f37 --- /dev/null +++ b/test/release-ci.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildReleaseItArgs, + normalizeReleaseType, + parseFirstRelease, +} from "../scripts/release-ci.js"; + +void test("normalizeReleaseType accepts valid workflow inputs", () => { + assert.equal(normalizeReleaseType("patch"), "patch"); + assert.equal(normalizeReleaseType("minor"), "minor"); + assert.equal(normalizeReleaseType("major"), "major"); + assert.equal(normalizeReleaseType(" Minor "), "minor"); +}); + +void test("normalizeReleaseType rejects invalid workflow inputs", () => { + assert.throws(() => normalizeReleaseType(undefined), /Invalid RELEASE_TYPE/); + assert.throws(() => normalizeReleaseType("preminor"), /Invalid RELEASE_TYPE/); +}); + +void test("parseFirstRelease handles github actions boolean strings", () => { + assert.equal(parseFirstRelease("true"), true); + assert.equal(parseFirstRelease("false"), false); + assert.equal(parseFirstRelease(undefined), false); +}); + +void test("buildReleaseItArgs preserves the requested increment", () => { + assert.deepEqual(buildReleaseItArgs({ releaseType: "patch", firstRelease: false }), [ + "exec", + "release-it", + "patch", + "--ci", + ]); + + assert.deepEqual(buildReleaseItArgs({ releaseType: "minor", firstRelease: true }), [ + "exec", + "release-it", + "minor", + "--ci", + "--first-release", + ]); +}); diff --git a/test/remote-ui.test.ts b/test/remote-ui.test.ts index b75e6a6..f8c90e7 100644 --- a/test/remote-ui.test.ts +++ b/test/remote-ui.test.ts @@ -1,8 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { clearMetadataCacheCommand } from "../src/commands/cache.js"; import { clearSearchCache, setSearchCache } from "../src/packages/discovery.js"; import { browseRemotePackages } from "../src/ui/remote.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; void test("browseRemotePackages honors an empty in-memory cache", async () => { setSearchCache({ @@ -35,6 +38,451 @@ void test("browseRemotePackages honors an empty in-memory cache", async () => { } }); +void test("browseRemotePackages rejects local-path queries instead of showing unrelated npm results", async () => { + const { pi, ctx, notifications } = createMockHarness({ hasUI: true }); + let customCalls = 0; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = () => { + customCalls += 1; + return Promise.resolve(undefined); + }; + + try { + await browseRemotePackages( + ctx, + "/tmp/pi-clipboard-32423307-5cb5-418f-b6c5-dcb344d4a627.png", + pi + ); + + assert.equal(customCalls, 0); + assert.ok( + notifications.some( + (entry) => + entry.message.includes("looks like a local path") && + entry.message.includes("Install by source") + ) + ); + } finally { + clearSearchCache(); + } +}); + +void test("browseRemotePackages shows inline search affordances in the browse UI", async () => { + setSearchCache({ + query: "demo", + results: [ + { + name: "demo-pkg", + version: "1.0.0", + description: "Demo package", + }, + ], + timestamp: Date.now(), + }); + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let renderedLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "demo", pi); + + assert.ok(renderedLines.some((line) => line.includes("/ search"))); + assert.ok(renderedLines.some((line) => line.includes("page 1/1"))); + assert.ok(renderedLines.some((line) => line.includes("demo-pkg@1.0.0"))); + } finally { + clearSearchCache(); + } +}); + +void test("browseRemotePackages supports next-page navigation from search results", async () => { + setSearchCache({ + query: "demo", + results: Array.from({ length: 25 }, (_, index) => ({ + name: `demo-pkg-${index + 1}`, + version: "1.0.0", + description: `Demo package ${index + 1}`, + })), + timestamp: Date.now(), + }); + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let customCalls = 0; + let secondPageLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => { + customCalls += 1; + + if (customCalls === 1) { + return captureCustomComponent(factory, ctx.ui.theme, (component, _lines, completion) => { + component.handleInput?.("n"); + return completion; + }); + } + + return captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + secondPageLines = lines; + return { type: "cancel" }; + }); + }; + + try { + await browseRemotePackages(ctx, "demo", pi); + + assert.equal(customCalls, 2); + assert.ok(secondPageLines.some((line) => line.includes("21-25 of 25"))); + assert.ok(secondPageLines.some((line) => line.includes("page 2/2"))); + assert.ok(secondPageLines.some((line) => line.includes("demo-pkg-21@1.0.0"))); + } finally { + clearSearchCache(); + } +}); + +void test("browseRemotePackages can start a new remote npm search from search results", async () => { + setSearchCache({ + query: "demo", + results: [ + { + name: "browse-default", + version: "1.0.0", + description: "Default browse result", + }, + ], + timestamp: Date.now(), + }); + + const nextQuery = "inline-demo"; + const { pi, ctx } = createMockHarness({ hasUI: true }); + let customCalls = 0; + let searchedLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => { + customCalls += 1; + + if (customCalls === 1) { + return captureCustomComponent(factory, ctx.ui.theme, (component, _lines, completion) => { + component.handleInput?.("/"); + for (const char of nextQuery) { + component.handleInput?.(char); + } + setSearchCache({ + query: nextQuery, + results: [ + { + name: "inline-result", + version: "2.0.0", + description: "Inline search result", + }, + ], + timestamp: Date.now(), + }); + component.handleInput?.("\r"); + return completion; + }); + } + + return captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + searchedLines = lines; + return { type: "cancel" }; + }); + }; + + try { + await browseRemotePackages(ctx, "demo", pi); + + assert.equal(customCalls, 2); + assert.ok(searchedLines.some((line) => line.includes("Search: inline-demo"))); + assert.ok(searchedLines.some((line) => line.includes("inline-result@2.0.0"))); + } finally { + clearSearchCache(); + } +}); + +void test("browseRemotePackages filters community packages locally from the browse UI", async () => { + setSearchCache({ + query: "keywords:pi-package", + results: [ + { + name: "browse-default", + version: "1.0.0", + description: "Default browse result", + author: "someone", + }, + { + name: "pi-copilot-queue", + version: "2.0.0", + description: "Queue tools for Pi copilots", + author: "ayagmar", + keywords: ["pi-package", "queue", "copilot"], + }, + ], + timestamp: Date.now(), + }); + + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = ((..._args: unknown[]) => { + fetchCalls += 1; + throw new Error("unexpected fetch"); + }) as typeof fetch; + + const nextQuery = "copilot queue"; + const { pi, ctx } = createMockHarness({ hasUI: true }); + let customCalls = 0; + let searchedLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => { + customCalls += 1; + + if (customCalls === 1) { + return captureCustomComponent(factory, ctx.ui.theme, (component, _lines, completion) => { + component.handleInput?.("/"); + for (const char of nextQuery) { + component.handleInput?.(char); + } + component.handleInput?.("\r"); + return completion; + }); + } + + return captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + searchedLines = lines; + return { type: "cancel" }; + }); + }; + + try { + await browseRemotePackages(ctx, "keywords:pi-package", pi); + + assert.equal(customCalls, 2); + assert.equal(fetchCalls, 0); + assert.ok(searchedLines.some((line) => line.includes("Search: copilot queue"))); + assert.ok(searchedLines.some((line) => line.includes("pi-copilot-queue@2.0.0"))); + assert.ok(!searchedLines.some((line) => line.includes("browse-default@1.0.0"))); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + +void test("browseRemotePackages ranks community matches locally and shows author in details", async () => { + setSearchCache({ + query: "keywords:pi-package", + results: [ + { + name: "alpha-tool", + version: "1.0.0", + description: "Queue utilities for Pi", + author: "someone", + }, + { + name: "queue-copilot", + version: "2.0.0", + description: "Copilot queue tools", + author: "ayagmar", + }, + ], + timestamp: Date.now(), + }); + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let renderedLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "queue", pi, 0, "community"); + + const bestMatchIndex = renderedLines.findIndex((line) => line.includes("queue-copilot@2.0.0")); + const secondaryMatchIndex = renderedLines.findIndex((line) => + line.includes("alpha-tool@1.0.0") + ); + + assert.ok(bestMatchIndex >= 0); + assert.ok(secondaryMatchIndex >= 0); + assert.ok(bestMatchIndex < secondaryMatchIndex); + assert.ok(renderedLines.some((line) => line.includes("by ayagmar"))); + } finally { + clearSearchCache(); + } +}); + +void test("clearMetadataCacheCommand clears the community browse runtime cache", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchCalls += 1; + assert.ok(url.includes("registry.npmjs.org/-/v1/search")); + + return Promise.resolve( + new Response( + JSON.stringify({ + total: 1, + objects: [ + { + package: { + name: "pi-copilot-queue", + version: "1.0.0", + description: "Queue tools for Pi", + keywords: ["pi-package", "queue"], + }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + }) as typeof fetch; + + const { pi, ctx } = createMockHarness({ hasUI: true }); + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (_component, lines, completion) => { + if (lines.some((line) => line.includes("/ search"))) { + return { type: "cancel" }; + } + return completion; + }); + + try { + await browseRemotePackages(ctx, "keywords:pi-package", pi); + assert.equal(fetchCalls, 1); + + setSearchCache({ + query: "demo", + results: [{ name: "demo-pkg", description: "Demo package" }], + timestamp: Date.now(), + }); + + await clearMetadataCacheCommand(ctx, pi); + await browseRemotePackages(ctx, "keywords:pi-package", pi); + + assert.equal(fetchCalls, 2); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + +void test("browseRemotePackages returns to results after installing from package details", async () => { + const restoreCatalog = mockPackageCatalog(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + assert.ok(url.includes("registry.npmjs.org/-/v1/search")); + + return Promise.resolve( + new Response( + JSON.stringify({ + total: 1, + objects: [ + { + package: { + name: "demo-pkg", + version: "1.0.0", + description: "Demo package", + }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + }) as typeof fetch; + + const { pi, ctx, confirmPrompts, selectPrompts } = createMockHarness({ + hasUI: true, + confirmImpl: (title) => title === "Install Package", + }); + const selectResults = ["Install via npm (managed)", "Global (~/.pi/agent/settings.json)"]; + let browserCalls = 0; + let returnedLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("/ search"))) { + return completion; + } + + browserCalls += 1; + if (browserCalls === 1) { + component.handleInput?.("\r"); + return completion; + } + + returnedLines = lines; + return { type: "cancel" }; + }); + + (ctx.ui as { select: (title: string, items?: string[]) => Promise }).select = + (title) => { + selectPrompts.push(title); + return Promise.resolve(selectResults.shift()); + }; + + try { + await browseRemotePackages(ctx, "demo", pi); + + assert.equal(browserCalls, 2); + assert.ok(returnedLines.some((line) => line.includes("demo-pkg@1.0.0"))); + assert.ok(selectPrompts.includes("demo-pkg")); + assert.ok(selectPrompts.includes("Install scope")); + assert.ok(confirmPrompts.includes("Reload Required")); + } finally { + restoreCatalog(); + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + void test("browseRemotePackages returns to package details after a cancelled load", async () => { setSearchCache({ query: "demo", diff --git a/test/search-regression.test.ts b/test/search-regression.test.ts index a72fdc2..be8a0b1 100644 --- a/test/search-regression.test.ts +++ b/test/search-regression.test.ts @@ -3,67 +3,15 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { initTheme, type Theme } from "@mariozechner/pi-coding-agent"; +import { initTheme } from "@mariozechner/pi-coding-agent"; import { configurePackageExtensions } from "../src/ui/package-config.js"; import { showInteractive } from "../src/ui/unified.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; initTheme(); -const noop = (): undefined => undefined; - -async function captureCustomComponent( - factory: unknown, - ctxTheme: Theme, - matcher: (lines: string[]) => boolean, - onMatch: ( - component: { - render(width: number): string[]; - handleInput?(data: string): void; - dispose?(): void; - }, - lines: string[] - ) => unknown -): Promise { - let resolveCompletion: (value: unknown) => void = () => undefined; - const completion = new Promise((resolve) => { - resolveCompletion = resolve; - }); - - const component = await ( - factory as ( - tui: unknown, - theme: unknown, - keybindings: unknown, - done: (result: unknown) => void - ) => - | Promise<{ - render(width: number): string[]; - handleInput?(data: string): void; - dispose?(): void; - }> - | { - render(width: number): string[]; - handleInput?(data: string): void; - dispose?(): void; - } - )({ requestRender: noop, terminal: { rows: 40, columns: 120 } }, ctxTheme, {}, resolveCompletion); - - try { - const lines = component.render(120); - if (!matcher(lines)) { - return await Promise.race([ - completion, - new Promise((resolve) => setTimeout(() => resolve(undefined), 50)), - ]); - } - - return await onMatch(component, lines); - } finally { - component.dispose?.(); - } -} - async function createPackageWithExtensions(root: string, count: number): Promise { await mkdir(join(root, "extensions"), { recursive: true }); @@ -146,7 +94,7 @@ void test("/extensions manager does not start filtering on plain typing", async captureCustomComponent( factory, ctx.ui.theme, - (lines) => lines.some((line) => line.includes("Space/Enter toggle local")), + (lines) => lines.some((line) => line.includes("i install")), (component, lines) => { beforeTyping = lines; component.handleInput?.("z"); @@ -165,3 +113,55 @@ void test("/extensions manager does not start filtering on plain typing", async await rm(cwd, { recursive: true, force: true }); } }); + +void test("/extensions manager slash search hides unrelated fuzzy description matches", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-search-slash-")); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:pi-anycopy", + name: "pi-anycopy", + version: "0.2.3", + description: "Copy any tree node to the clipboard.", + scope: "global", + }, + { + source: "npm:pi-bash-live-view", + name: "pi-bash-live-view", + version: "0.1.1", + description: + "A pi extension that adds optional PTY-backed live terminal rendering to the bash tool via usePTY=true.", + scope: "global", + }, + ], + }); + + try { + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let afterSearch: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("i install")), + (component) => { + component.handleInput?.("/"); + for (const char of "anycopy") { + component.handleInput?.(char); + } + afterSearch = component.render(120); + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok(afterSearch.some((line) => line.includes("anycopy"))); + assert.ok(!afterSearch.some((line) => line.includes("pi-bash-live-view"))); + assert.ok(afterSearch.some((line) => line.includes("showing 1 of"))); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/unified-items.test.ts b/test/unified-items.test.ts index 2ae4669..f8648f8 100644 --- a/test/unified-items.test.ts +++ b/test/unified-items.test.ts @@ -69,6 +69,23 @@ void test("buildUnifiedItems omits package rows that duplicate local extension p assert.equal(items[0]?.type, "local"); }); +void test("buildUnifiedItems omits package rows that exactly match enabled local file sources", () => { + const localPath = "/tmp/extensions/demo.ts"; + const localEntries = [createLocalEntry(localPath, "demo.ts")]; + const installedPackages: InstalledPackage[] = [ + { + source: localPath, + name: "demo", + scope: "global", + }, + ]; + + const items = buildUnifiedItems(localEntries, installedPackages, new Set()); + + assert.equal(items.length, 1); + assert.equal(items[0]?.type, "local"); +}); + void test("buildUnifiedItems omits duplicate package rows with mixed path separators", () => { const localEntries = [ createLocalEntry("C:\\repo\\.pi\\extensions\\demo\\index.ts", "demo/index.ts"), @@ -88,6 +105,32 @@ void test("buildUnifiedItems omits duplicate package rows with mixed path separa assert.equal(items[0]?.type, "local"); }); +void test("buildUnifiedItems omits package rows that duplicate disabled local file paths", () => { + const localEntries: ExtensionEntry[] = [ + { + id: "project:/tmp/demo.ts", + scope: "project", + state: "disabled", + activePath: "/tmp/demo.ts", + disabledPath: "/tmp/demo.ts.disabled", + displayName: "demo.ts", + summary: "local extension", + }, + ]; + const installedPackages: InstalledPackage[] = [ + { + source: "/tmp/demo.ts.disabled", + name: "demo", + scope: "project", + }, + ]; + + const items = buildUnifiedItems(localEntries, installedPackages, new Set()); + + assert.equal(items.length, 1); + assert.equal(items[0]?.type, "local"); +}); + void test("buildUnifiedItems keeps case-sensitive POSIX paths distinct", () => { const localEntries = [createLocalEntry("/opt/extensions/Foo/index.ts", "Foo/index.ts")]; const installedPackages: InstalledPackage[] = [ diff --git a/test/unified-ui.test.ts b/test/unified-ui.test.ts new file mode 100644 index 0000000..02d4921 --- /dev/null +++ b/test/unified-ui.test.ts @@ -0,0 +1,506 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { initTheme } from "@mariozechner/pi-coding-agent"; +import { showInteractive } from "../src/ui/unified.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; + +initTheme(); + +void test("/extensions keeps rows compact and moves selected details below the list", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-ui-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + const summary = "Focused detail text should stay below the list, not inline with every row."; + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-focus.ts"), `// ${summary}\n`, "utf8"); + await writeFile(join(projectExtensionsRoot, "beta-focus.ts"), "// Secondary row\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let renderedLines: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("i install")), + (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + const rowLine = renderedLines.find( + (line) => line.includes("● [P]") && line.includes("alpha-focus.ts") + ); + assert.ok(rowLine, "expected compact local extension row"); + assert.ok(!rowLine.includes(summary), "row should not inline the full summary"); + assert.ok( + renderedLines.some((line) => line.includes(summary)), + "expected selected extension summary in the details area" + ); + assert.ok( + renderedLines.some((line) => line.includes("Space toggle")), + "expected contextual actions in the manager footer" + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions groups local extensions and packages into sections", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-groups-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:demo-group@1.0.0", + name: "demo-group", + version: "1.0.0", + scope: "global", + }, + ], + }); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-group.ts"), "// alpha\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let renderedLines: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok( + renderedLines.some((line) => line.includes("Local extensions (")), + "expected local section header" + ); + assert.ok( + renderedLines.some((line) => line.includes("Installed packages (")), + "expected package section header" + ); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions shows package sizes inline when known", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-size-")); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:demo-pkg@1.2.3", + name: "demo-pkg", + version: "1.2.3", + scope: "global", + description: "Demo package", + size: 115712, + }, + ], + }); + + try { + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let renderedLines: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("i install")), + (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok( + renderedLines.some((line) => line.includes("demo-pkg@1.2.3") && line.includes("113 KB")), + "expected known package size to be visible inline" + ); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions uses Enter for local actions instead of toggling state", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-enter-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-enter.ts"), "// alpha\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let rawAction: unknown; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("i install")), + (component, _lines, completion) => { + component.handleInput?.("\r"); + return completion.then((value) => { + rawAction = value; + return { type: "cancel" }; + }); + } + ); + + await showInteractive(ctx, pi); + + assert.deepEqual(rawAction, { + type: "action", + itemId: + rawAction && typeof rawAction === "object" ? (rawAction as { itemId: string }).itemId : "", + action: "menu", + }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions searches visible items only after activating search", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-search-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-search.ts"), "// alpha\n", "utf8"); + await writeFile(join(projectExtensionsRoot, "beta-search.ts"), "// beta\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let afterSearch: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component) => { + component.handleInput?.("/"); + component.handleInput?.("b"); + component.handleInput?.("e"); + component.handleInput?.("t"); + component.handleInput?.("a"); + component.handleInput?.("\r"); + afterSearch = component.render(120); + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok(afterSearch.some((line) => line.includes("beta-search.ts"))); + assert.ok(!afterSearch.some((line) => line.includes("alpha-search.ts"))); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions clears an inactive search with Escape before exiting", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-search-escape-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-search.ts"), "// alpha\n", "utf8"); + await writeFile(join(projectExtensionsRoot, "beta-search.ts"), "// beta\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let afterEscape: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component) => { + component.handleInput?.("/"); + component.handleInput?.("b"); + component.handleInput?.("e"); + component.handleInput?.("t"); + component.handleInput?.("a"); + component.handleInput?.("\r"); + component.handleInput?.("\u001b"); + afterEscape = component.render(120); + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok(afterEscape.some((line) => line.includes("alpha-search.ts"))); + assert.ok(afterEscape.some((line) => line.includes("beta-search.ts"))); + assert.ok(!afterEscape.some((line) => line.includes("Search: beta"))); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions filters packages with the quick filter shortcuts", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-filter-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:demo-filter@1.0.0", + name: "demo-filter", + version: "1.0.0", + scope: "global", + }, + ], + }); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-filter.ts"), "// alpha\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let filteredLines: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component) => { + component.handleInput?.("3"); + filteredLines = component.render(120); + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok(filteredLines.some((line) => line.includes("Installed packages (1)"))); + assert.ok(!filteredLines.some((line) => line.includes("Local extensions (1)"))); + assert.ok(filteredLines.some((line) => line.includes("demo-filter@1.0.0"))); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions still toggles local items with Space", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-space-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-space.ts"), "// alpha\n", "utf8"); + + const { pi, ctx } = createMockHarness({ + cwd, + hasUI: true, + selectResult: "Exit without saving", + }); + let afterSpace: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("i install")), + (component) => { + component.handleInput?.(" "); + afterSpace = component.render(120); + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok( + afterSpace.some((line) => line.includes("○ [P]") && line.includes("alpha-space.ts")), + "expected Space to keep local toggling working" + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions keeps staged changes after viewing item details", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-details-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-details.ts"), "// alpha\n", "utf8"); + + const { pi, ctx, notifications, selectPrompts } = createMockHarness({ + cwd, + hasUI: true, + selectResult: "Exit without saving", + }); + let managerCallCount = 0; + let resumedLines: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component, lines, completion) => { + managerCallCount += 1; + if (managerCallCount === 1) { + component.handleInput?.(" "); + component.handleInput?.("V"); + return completion; + } + + resumedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok( + notifications.some((entry) => entry.message.includes("alpha-details.ts")), + "expected details notification to be shown" + ); + assert.ok( + resumedLines.some((line) => line.includes("○ [P]") && line.includes("alpha-details.ts")), + "expected staged toggle to persist after viewing details" + ); + assert.ok( + selectPrompts.includes("Unsaved changes (1)"), + "expected pending changes to remain after viewing details" + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions keeps staged changes after backing out of the local action menu", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-local-back-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-menu.ts"), "// alpha\n", "utf8"); + + const { pi, ctx, selectPrompts } = createMockHarness({ cwd, hasUI: true }); + const queuedSelections = ["Back to manager", "Exit without saving"]; + let managerCallCount = 0; + let resumedLines: string[] = []; + + ( + ctx.ui as { select: (title: string, options?: string[]) => Promise } + ).select = (title) => { + selectPrompts.push(title); + return Promise.resolve(queuedSelections.shift()); + }; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component, lines, completion) => { + managerCallCount += 1; + if (managerCallCount === 1) { + component.handleInput?.(" "); + component.handleInput?.("\r"); + return completion; + } + + resumedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok( + selectPrompts.some((title) => title.includes("alpha-menu.ts")), + "expected the local action menu to open" + ); + assert.ok( + resumedLines.some((line) => line.includes("○ [P]") && line.includes("alpha-menu.ts")), + "expected staged toggle to persist after backing out of the action menu" + ); + assert.ok( + selectPrompts.includes("Unsaved changes (1)"), + "expected pending changes to remain after backing out of the action menu" + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("/extensions keeps staged changes when staying in the manager", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-stay-")); + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + + try { + await mkdir(projectExtensionsRoot, { recursive: true }); + await writeFile(join(projectExtensionsRoot, "alpha-stay.ts"), "// alpha\n", "utf8"); + + const { pi, ctx, selectPrompts } = createMockHarness({ cwd, hasUI: true }); + const queuedSelections = ["Stay in manager", "Exit without saving"]; + let managerCallCount = 0; + let resumedLines: string[] = []; + + ( + ctx.ui as { select: (title: string, options?: string[]) => Promise } + ).select = (title) => { + selectPrompts.push(title); + return Promise.resolve(queuedSelections.shift()); + }; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component, lines, completion) => { + managerCallCount += 1; + if (managerCallCount === 1) { + component.handleInput?.(" "); + component.handleInput?.("R"); + return completion; + } + + resumedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.equal( + selectPrompts.filter((title) => title === "Unsaved changes (1)").length, + 2, + "expected stay-in-manager flow to keep pending changes for the next cancel prompt" + ); + assert.ok( + resumedLines.some((line) => line.includes("○ [P]") && line.includes("alpha-stay.ts")), + "expected staged toggle to persist after choosing to stay in the manager" + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +});