diff --git a/CHANGELOG.md b/CHANGELOG.md index dda8cb8..3e32a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ ### Fixed +- Remote npm browsing fetches only the visible result page instead of crawling the full `pi-package` catalog, honors `Retry-After` on HTTP 429 responses, and reports exhausted rate limits inside the UI. +- Remote refresh bypasses both runtime and persistent search caches, community search stays scoped to `pi-package`, and author labels prefer usernames over fallback email addresses. - 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. diff --git a/README.md b/README.md index 16cf1c7..35efcb4 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ version manager, or install project-local with `pi install npm:pi-extmgr -l`. - **Unified manager UI** - Local extensions (`~/.pi/agent/extensions`, `.pi/extensions`) and installed packages in one list - Grouped sections for local extensions vs installed packages - - Compact rows with selected-item details below the list, so large extension sets stay scannable + - Responsive master/detail layout on wide terminals, with a compact stacked fallback on narrow terminals + - Focused primary filters and contextual one-line controls; secondary actions remain available through the command palette and help - 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** @@ -59,25 +60,32 @@ version manager, or install project-local with `pi install npm:pi-extmgr -l`. - 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`) + - Quick actions (`A`, `u`, `X`) and coordinated bulk package actions (`B`) - **Remote discovery and install** - - npm search/browse with pagination, inline browse search, and keyboard page navigation + - npm search/browse with server-side pagination, inline browse search, keyboard page navigation, and rate-limit-aware retries + - Weekly npm downloads in browse rows/details plus popularity sorting (`o` cycles sort modes) - 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 + - Search results render immediately; weekly download metrics hydrate in the background and update the visible rows - Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc` -- **Auto-update** +- **Scheduled update checks** - Interactive wizard (`t` in manager, or `/extensions auto-update`) - Persistent schedule restored on startup and session switch - Background checks + status bar updates for installed npm + git packages +- **Task-oriented workspaces** + - Installed: local extensions, packages, updates, and staged changes + - Discover: community npm search, package inspection, downloads, and popularity sorting + - Profiles: inline current-vs-target diffs (scope, version, ref, filters, checksum); applying always passes through the review screen + - Health: runtime conflicts, compatibility, reload state, recoverable trash, conflict remediation, and a conservative “fix all safe issues” action (never removes packages) - **Operational visibility** - Session history (`/extensions history`) - Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches) - - Status line summary (`pkg count • auto-update • known updates`) - - History now records local extension deletions and auto-update configuration changes + - Status line summary (`pkg count • scheduled update checks • known updates`) + - History now records local extension deletions and scheduled update checks configuration changes - **Interactive + non-interactive support** - Works in TUI and non-UI modes - - Non-interactive commands for list/install/remove/update/auto-update + - Non-interactive commands for list/install/remove/update/scheduled update checks (checks discover updates; package updates remain explicit) ## Usage @@ -91,6 +99,7 @@ Open the manager: | Key | Action | | ------------- | ----------------------------------------------------- | +| `Tab` / `Shift+Tab` | Next / previous workspace: Installed / Discover / Profiles / Health | | `↑↓` | Navigate | | `PageUp/Down` | Jump through longer lists | | `Home/End` | Jump to top or bottom | @@ -98,17 +107,19 @@ Open the manager: | `S` | Save local extension changes | | `Enter` / `A` | Actions on selected item | | `/` / `Ctrl+F`| Search visible items | -| `Tab` / `Shift+Tab` | Cycle filters | -| `1-5` | Filters: All / Local / Packages / Updates / Disabled | +| `1-7` | Filter: All / Local / Packages / Updates / Disabled / Favorites / Recent | | `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` | Remote package search | +| `B` | Bulk actions for selected packages | +| `W` / `L` / `D` | Save / load / delete manager views | +| `*` | Toggle favorite for selected item | | `U` | Update all packages | -| `t` | Auto-update wizard | -| `P` / `M` | Quick actions palette | +| `t` | Scheduled update checks wizard | +| `P` / `M` | Workspace screens and actions | | `R` | Browse remote packages | | `?` / `H` | Help | | `Esc` | Clear search or exit | @@ -128,6 +139,12 @@ Open the manager: /extensions uninstall [source] # Alias: remove /extensions update [source] # Update one package (or all when omitted) /extensions auto-update [every] # No arg opens wizard in UI; accepts 1d, 1w, 1mo, never, etc. +/extensions doctor # Inspect ownership and package compatibility +/extensions profile list # List named profiles +/extensions profile save # Save the current package profile +/extensions profile apply # Safely apply a profile +/extensions profile delete # Delete a named profile +/extensions trash # Manage local extension trash /extensions history [options] # View change history (supports filters) /extensions clear-cache # Clear persistent + runtime extmgr caches ``` @@ -190,13 +207,16 @@ Examples: - **Package extension config**: Select a package and press `c` (or Enter/A → Configure) to enable/disable individual package entrypoints. - After saving package extension config, run /reload to apply changes. - **Two install modes**: - - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading + - **Managed** (npm): uses explicit `pi update` updates, stored in pi's package cache, supports Pi package manifest/convention loading - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, so it only accepts runnable standalone layouts (manifest-declared/root entrypoints), requires `tar` on `PATH`, and rejects packages whose runtime `dependencies` are not already bundled with the package contents -- **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions. -- **Auto-update/update badges cover npm + git packages**: extmgr now uses pi's package manager APIs for structured update detection instead of parsing `pi list` output. +- **Scheduled update-check schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions. +- **Scheduled update-check badges cover npm + git packages**: extmgr now uses pi's package manager APIs for structured update detection instead of parsing `pi list` output. - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues. - **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles. -- **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly. +- **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly. Successful mutations persist a versioned reload-required marker; cancellation and failure do not. The marker survives reopening the manager and clears after a successful reload. +- **Saved manager state**: Views, favorites, recent items, and bulk selections are stored atomically in `~/.pi/agent/.extmgr-cache/views-.json`. Named profiles are stored in `profiles.json`; project policies load from `.pi/extmgr-policy.json`. +- **Trash lifecycle**: Local removals move to `~/.pi/agent/.extmgr-trash/` with persistent records. Undo refuses to overwrite a replacement file, and expired or missing records are cleaned up. +- **Metadata safety**: Missing compatibility, provenance, checksum, or target-version metadata is reported as `unknown`, never `safe`. Target-version previews, exact target badges, and update-policy/maintenance-window enforcement are intentionally deferred because Pi does not expose structured target metadata. ## License diff --git a/docs/assets/extension-manager.png b/docs/assets/extension-manager.png deleted file mode 100644 index 5a5795e..0000000 Binary files a/docs/assets/extension-manager.png and /dev/null differ diff --git a/docs/assets/installed-workspace.png b/docs/assets/installed-workspace.png new file mode 100644 index 0000000..94583dd Binary files /dev/null and b/docs/assets/installed-workspace.png differ diff --git a/docs/assets/tailwind.css b/docs/assets/tailwind.css index 041c79d..2591c4e 100644 --- a/docs/assets/tailwind.css +++ b/docs/assets/tailwind.css @@ -1,731 +1 @@ -*, -:after, -:before { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgba(59, 130, 246, 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} -::backdrop { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgba(59, 130, 246, 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} /*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/ -*, -:after, -:before { - box-sizing: border-box; - border: 0 solid #e5e7eb; -} -:after, -:before { - --tw-content: ""; -} -:host, -html { - line-height: 1.5; - -webkit-text-size-adjust: 100%; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - font-family: Inter, system-ui, sans-serif; - font-feature-settings: normal; - font-variation-settings: normal; - -webkit-tap-highlight-color: transparent; -} -body { - margin: 0; - line-height: inherit; -} -hr { - height: 0; - color: inherit; - border-top-width: 1px; -} -abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: inherit; - font-weight: inherit; -} -a { - color: inherit; - text-decoration: inherit; -} -b, -strong { - font-weight: bolder; -} -code, -kbd, -pre, -samp { - font-family: - JetBrains Mono, - monospace; - font-feature-settings: normal; - font-variation-settings: normal; - font-size: 1em; -} -small { - font-size: 80%; -} -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} -sub { - bottom: -0.25em; -} -sup { - top: -0.5em; -} -table { - text-indent: 0; - border-color: inherit; - border-collapse: collapse; -} -button, -input, -optgroup, -select, -textarea { - font-family: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - font-size: 100%; - font-weight: inherit; - line-height: inherit; - letter-spacing: inherit; - color: inherit; - margin: 0; - padding: 0; -} -button, -select { - text-transform: none; -} -button, -input:where([type="button"]), -input:where([type="reset"]), -input:where([type="submit"]) { - -webkit-appearance: button; - background-color: transparent; - background-image: none; -} -:-moz-focusring { - outline: auto; -} -:-moz-ui-invalid { - box-shadow: none; -} -progress { - vertical-align: baseline; -} -::-webkit-inner-spin-button, -::-webkit-outer-spin-button { - height: auto; -} -[type="search"] { - -webkit-appearance: textfield; - outline-offset: -2px; -} -::-webkit-search-decoration { - -webkit-appearance: none; -} -::-webkit-file-upload-button { - -webkit-appearance: button; - font: inherit; -} -summary { - display: list-item; -} -blockquote, -dd, -dl, -figure, -h1, -h2, -h3, -h4, -h5, -h6, -hr, -p, -pre { - margin: 0; -} -fieldset { - margin: 0; -} -fieldset, -legend { - padding: 0; -} -menu, -ol, -ul { - list-style: none; - margin: 0; - padding: 0; -} -dialog { - padding: 0; -} -textarea { - resize: vertical; -} -input::-moz-placeholder, -textarea::-moz-placeholder { - opacity: 1; - color: #9ca3af; -} -input::placeholder, -textarea::placeholder { - opacity: 1; - color: #9ca3af; -} -[role="button"], -button { - cursor: pointer; -} -:disabled { - cursor: default; -} -audio, -canvas, -embed, -iframe, -img, -object, -svg, -video { - display: block; - vertical-align: middle; -} -img, -video { - max-width: 100%; - height: auto; -} -[hidden]:where(:not([hidden="until-found"])) { - display: none; -} -html { - scroll-behavior: smooth; -} -::-moz-selection { - background: rgba(34, 197, 94, 0.3); - color: #fff; -} -::selection { - background: rgba(34, 197, 94, 0.3); - color: #fff; -} -.fixed { - position: fixed; -} -.left-0 { - left: 0; -} -.right-0 { - right: 0; -} -.top-0 { - top: 0; -} -.z-50 { - z-index: 50; -} -.mx-auto { - margin-left: auto; - margin-right: auto; -} -.mb-1 { - margin-bottom: 0.25rem; -} -.mb-2 { - margin-bottom: 0.5rem; -} -.mb-4 { - margin-bottom: 1rem; -} -.mb-6 { - margin-bottom: 1.5rem; -} -.mb-8 { - margin-bottom: 2rem; -} -.ml-4 { - margin-left: 1rem; -} -.ml-auto { - margin-left: auto; -} -.mt-4 { - margin-top: 1rem; -} -.flex { - display: flex; -} -.inline-flex { - display: inline-flex; -} -.grid { - display: grid; -} -.hidden { - display: none; -} -.h-10 { - height: 2.5rem; -} -.h-16 { - height: 4rem; -} -.h-3 { - height: 0.75rem; -} -.h-4 { - height: 1rem; -} -.h-5 { - height: 1.25rem; -} -.w-10 { - width: 2.5rem; -} -.w-24 { - width: 6rem; -} -.w-3 { - width: 0.75rem; -} -.w-4 { - width: 1rem; -} -.w-5 { - width: 1.25rem; -} -.max-w-2xl { - max-width: 42rem; -} -.max-w-5xl { - max-width: 64rem; -} -.flex-col { - flex-direction: column; -} -.flex-wrap { - flex-wrap: wrap; -} -.items-center { - align-items: center; -} -.justify-center { - justify-content: center; -} -.justify-between { - justify-content: space-between; -} -.gap-12 { - gap: 3rem; -} -.gap-2 { - gap: 0.5rem; -} -.gap-4 { - gap: 1rem; -} -.gap-6 { - gap: 1.5rem; -} -.space-y-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.5rem * (1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); -} -.overflow-hidden { - overflow: hidden; -} -.overflow-x-auto { - overflow-x: auto; -} -.rounded { - border-radius: 0.25rem; -} -.rounded-full { - border-radius: 9999px; -} -.rounded-lg { - border-radius: 0.5rem; -} -.rounded-xl { - border-radius: 0.75rem; -} -.border { - border-width: 1px; -} -.border-b { - border-bottom-width: 1px; -} -.border-t { - border-top-width: 1px; -} -.border-neutral-800 { - --tw-border-opacity: 1; - border-color: rgb(38 38 38 / var(--tw-border-opacity, 1)); -} -.border-neutral-800\/50 { - border-color: rgba(38, 38, 38, 0.5); -} -.border-pi-600\/20 { - border-color: rgba(22, 163, 74, 0.2); -} -.bg-neutral-600 { - --tw-bg-opacity: 1; - background-color: rgb(82 82 82 / var(--tw-bg-opacity, 1)); -} -.bg-neutral-700 { - --tw-bg-opacity: 1; - background-color: rgb(64 64 64 / var(--tw-bg-opacity, 1)); -} -.bg-neutral-800 { - --tw-bg-opacity: 1; - background-color: rgb(38 38 38 / var(--tw-bg-opacity, 1)); -} -.bg-neutral-800\/50 { - background-color: rgba(38, 38, 38, 0.5); -} -.bg-neutral-900 { - --tw-bg-opacity: 1; - background-color: rgb(23 23 23 / var(--tw-bg-opacity, 1)); -} -.bg-neutral-950 { - --tw-bg-opacity: 1; - background-color: rgb(10 10 10 / var(--tw-bg-opacity, 1)); -} -.bg-neutral-950\/80 { - background-color: hsla(0, 0%, 4%, 0.8); -} -.bg-pi-600 { - --tw-bg-opacity: 1; - background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1)); -} -.bg-pi-600\/10 { - background-color: rgba(22, 163, 74, 0.1); -} -.p-4 { - padding: 1rem; -} -.p-6 { - padding: 1.5rem; -} -.px-1\.5 { - padding-left: 0.375rem; - padding-right: 0.375rem; -} -.px-4 { - padding-left: 1rem; - padding-right: 1rem; -} -.px-5 { - padding-left: 1.25rem; - padding-right: 1.25rem; -} -.px-6 { - padding-left: 1.5rem; - padding-right: 1.5rem; -} -.py-0\.5 { - padding-top: 0.125rem; - padding-bottom: 0.125rem; -} -.py-2 { - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} -.py-2\.5 { - padding-top: 0.625rem; - padding-bottom: 0.625rem; -} -.py-20 { - padding-top: 5rem; - padding-bottom: 5rem; -} -.py-3 { - padding-top: 0.75rem; - padding-bottom: 0.75rem; -} -.py-8 { - padding-top: 2rem; - padding-bottom: 2rem; -} -.pb-20 { - padding-bottom: 5rem; -} -.pb-24 { - padding-bottom: 6rem; -} -.pt-32 { - padding-top: 8rem; -} -.font-mono { - font-family: - JetBrains Mono, - monospace; -} -.font-sans { - font-family: Inter, system-ui, sans-serif; -} -.text-2xl { - font-size: 1.5rem; - line-height: 2rem; -} -.text-4xl { - font-size: 2.25rem; - line-height: 2.5rem; -} -.text-lg { - font-size: 1.125rem; - line-height: 1.75rem; -} -.text-sm { - font-size: 0.875rem; - line-height: 1.25rem; -} -.text-xs { - font-size: 0.75rem; - line-height: 1rem; -} -.font-medium { - font-weight: 500; -} -.font-semibold { - font-weight: 600; -} -.leading-relaxed { - line-height: 1.625; -} -.leading-tight { - line-height: 1.25; -} -.text-neutral-300 { - --tw-text-opacity: 1; - color: rgb(212 212 212 / var(--tw-text-opacity, 1)); -} -.text-neutral-400 { - --tw-text-opacity: 1; - color: rgb(163 163 163 / var(--tw-text-opacity, 1)); -} -.text-neutral-500 { - --tw-text-opacity: 1; - color: rgb(115 115 115 / var(--tw-text-opacity, 1)); -} -.text-neutral-600 { - --tw-text-opacity: 1; - color: rgb(82 82 82 / var(--tw-text-opacity, 1)); -} -.text-pi-400 { - --tw-text-opacity: 1; - color: rgb(74 222 128 / var(--tw-text-opacity, 1)); -} -.text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)); -} -.antialiased { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.shadow-2xl { - --tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); - --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); - box-shadow: - var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); -} -.backdrop-blur-md { - --tw-backdrop-blur: blur(12px); - -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) - var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) - var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) - var(--tw-backdrop-sepia); - backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) - var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) - var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); -} -.transition-colors { - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 0.15s; -} -.gradient-text { - background: linear-gradient(135deg, #22c55e, #16a34a); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} -@keyframes fadeIn { - 0% { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} -.terminal::-webkit-scrollbar { - height: 8px; - width: 8px; -} -.terminal::-webkit-scrollbar-track { - background: #171717; -} -.terminal::-webkit-scrollbar-thumb { - background: #404040; - border-radius: 4px; -} -.terminal::-webkit-scrollbar-thumb:hover { - background: #525252; -} -.hover\:bg-neutral-700:hover { - --tw-bg-opacity: 1; - background-color: rgb(64 64 64 / var(--tw-bg-opacity, 1)); -} -.hover\:bg-pi-500:hover { - --tw-bg-opacity: 1; - background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); -} -.hover\:text-neutral-300:hover { - --tw-text-opacity: 1; - color: rgb(212 212 212 / var(--tw-text-opacity, 1)); -} -.hover\:text-neutral-400:hover { - --tw-text-opacity: 1; - color: rgb(163 163 163 / var(--tw-text-opacity, 1)); -} -.hover\:text-white:hover { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)); -} -@media (min-width: 640px) { - .sm\:flex { - display: flex; - } - .sm\:grid-cols-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .sm\:flex-row { - flex-direction: row; - } - .sm\:text-5xl { - font-size: 3rem; - line-height: 1; - } -} +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{scroll-behavior:smooth}::-moz-selection{background:rgba(34,197,94,.3);color:#fff}::selection{background:rgba(34,197,94,.3);color:#fff}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.inset-0{inset:0}.left-0{left:0}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-4{top:1rem}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-auto{height:auto}.max-h-\[92vh\]{max-height:92vh}.w-10{width:2.5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-5xl{max-width:64rem}.max-w-full{max-width:100%}.shrink-0{flex-shrink:0}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-12{-moz-column-gap:3rem;column-gap:3rem}.gap-y-12{row-gap:3rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-neutral-700{--tw-border-opacity:1;border-color:rgb(64 64 64/var(--tw-border-opacity,1))}.border-neutral-800{--tw-border-opacity:1;border-color:rgb(38 38 38/var(--tw-border-opacity,1))}.border-neutral-800\/50{border-color:rgba(38,38,38,.5)}.border-pi-600\/20{border-color:rgba(22,163,74,.2)}.bg-black\/85{background-color:rgba(0,0,0,.85)}.bg-neutral-600{--tw-bg-opacity:1;background-color:rgb(82 82 82/var(--tw-bg-opacity,1))}.bg-neutral-700{--tw-bg-opacity:1;background-color:rgb(64 64 64/var(--tw-bg-opacity,1))}.bg-neutral-800{--tw-bg-opacity:1;background-color:rgb(38 38 38/var(--tw-bg-opacity,1))}.bg-neutral-800\/50{background-color:rgba(38,38,38,.5)}.bg-neutral-900{--tw-bg-opacity:1;background-color:rgb(23 23 23/var(--tw-bg-opacity,1))}.bg-neutral-900\/30{background-color:hsla(0,0%,9%,.3)}.bg-neutral-950{--tw-bg-opacity:1;background-color:rgb(10 10 10/var(--tw-bg-opacity,1))}.bg-neutral-950\/80{background-color:hsla(0,0%,4%,.8)}.bg-pi-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-pi-600\/10{background-color:rgba(22,163,74,.1)}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-20{padding-bottom:5rem}.pb-24{padding-bottom:6rem}.pt-32{padding-top:8rem}.text-left{text-align:left}.font-mono{font-family:JetBrains Mono,monospace}.font-sans{font-family:Inter,system-ui,sans-serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.text-neutral-300{--tw-text-opacity:1;color:rgb(212 212 212/var(--tw-text-opacity,1))}.text-neutral-400{--tw-text-opacity:1;color:rgb(163 163 163/var(--tw-text-opacity,1))}.text-neutral-500{--tw-text-opacity:1;color:rgb(115 115 115/var(--tw-text-opacity,1))}.text-neutral-600{--tw-text-opacity:1;color:rgb(82 82 82/var(--tw-text-opacity,1))}.text-pi-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/80{color:hsla(0,0%,100%,.8)}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.gradient-text{background:linear-gradient(135deg,#22c55e,#16a34a);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}@keyframes fadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.terminal::-webkit-scrollbar{height:8px;width:8px}.terminal::-webkit-scrollbar-track{background:#171717}.terminal::-webkit-scrollbar-thumb{background:#404040;border-radius:4px}.terminal::-webkit-scrollbar-thumb:hover{background:#525252}.hover\:border-pi-600\/50:hover{border-color:rgba(22,163,74,.5)}.hover\:bg-neutral-700:hover{--tw-bg-opacity:1;background-color:rgb(64 64 64/var(--tw-bg-opacity,1))}.hover\:bg-pi-500:hover{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.hover\:text-neutral-300:hover{--tw-text-opacity:1;color:rgb(212 212 212/var(--tw-text-opacity,1))}.hover\:text-pi-500:hover{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:flex{display:flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index 76fae82..623d0f3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ pi-extmgr — Extension Manager for Pi @@ -70,8 +70,12 @@

class="text-neutral-300 hover:text-white underline underline-offset-4" >Pi - extensions from a unified interface. Built on Pi's native package flows, without the - extra directory spelunking or config-file editing. + extensions across four task-oriented workspaces — + Installed, + Discover, + Profiles, and + Health. Built on Pi's native package flows, + without the extra directory spelunking or config-file editing.

@@ -183,9 +187,15 @@

$ pi install npm:pi-extmgr
✓ Extension installed successfully
$ pi
-
+
> /extensions
+
+ Shift+Tab ‹ +  [Installed] +  Discover  Profiles  Health +  › Tab +
@@ -205,6 +215,8 @@

Press + Tab + to switch workspaces, S to save changes
@@ -222,17 +234,18 @@

See it in action

- Unified manager — local extensions and packages in one list (click to enlarge) + Installed workspace — local extensions and packages with a responsive master/detail + layout (click to enlarge)

@@ -249,7 +262,8 @@

See it in action

/>

- Remote browser — search and install from npm (click to enlarge) + Discover workspace — search and install from npm with weekly downloads and + popularity sorting (click to enlarge)

@@ -259,7 +273,7 @@

See it in action

-
+
See it in action />
-

Unified Manager

+

Installed

- Local extensions and installed packages in one list. Toggle with Space, save with S. + Local extensions and packages in one responsive master/detail list. Toggle with + Space, save with S, filter with 1-7.

@@ -301,9 +316,58 @@

Unified Manager

/>
-

Remote Discovery

+

Discover

+

+ Browse and install from npm or git with weekly downloads, popularity sorting, and + instant results that hydrate in the background. +

+
+
+
+ + + +
+

Profiles

- Browse and install from npm or git. Search packages, install by source, done. + Save, export, and apply named package sets with inline current-vs-target diffs. + Every apply passes through a review screen. +

+
+
+
+ + + +
+

Health

+

+ Runtime conflicts, compatibility, reload state, and recoverable trash — with a + conservative fix-all that never removes packages.

@@ -324,11 +388,35 @@

Remote Discovery

/>
-

Auto-Update

+

Scheduled Updates

Set it and forget it. Persistent schedules, background checks, status bar updates.

+
+
+ + + +
+

Safe by Default

+

+ Staged toggles with unsaved-change guards, recoverable trash for removals, and + rollback on failed operations. +

+
@@ -379,55 +467,63 @@

Install

Quick Reference

- /extensions + /extensions Open manager
- Space / Enter - Toggle extension + Tab / Shift+Tab + Switch workspace +
+
+ Space / Enter + Toggle / actions
- S + S Save changes
- i - Quick install + 1-7 + Filter items +
+
+ / + Search visible items
- f - Quick search + i + Quick install
- R - Browse remote + f / R + Search / browse remote
- U - Update all packages + u / U + Update selected / all
- u - Update selected + B + Bulk package actions
- P - Command palette + P / M + Workspaces & actions
- t - Auto-update wizard + t + Update checks wizard
- X + X Remove selected
- ? + ? Help
- Esc + Esc Exit / cancel
diff --git a/package.json b/package.json index b003664..051fadd 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "format:check": "biome format .", "typecheck": "tsc --noEmit -p tsconfig.json", "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", + "test": "node --import=tsx --import=./test/helpers/hermetic-environment.mjs --test ./test/*.test.ts", + "check": "tsc --noEmit -p tsconfig.json && node --import=tsx ./scripts/smoke-test.mjs && pnpm test && pnpm run lint && pnpm run format:check", "release": "release-it", "release:patch": "release-it patch", "release:minor": "release-it minor", diff --git a/src/commands/auto-update.ts b/src/commands/auto-update.ts index 20ef666..37db6c5 100644 --- a/src/commands/auto-update.ts +++ b/src/commands/auto-update.ts @@ -41,13 +41,13 @@ export async function handleAutoUpdateSubcommand( if (!duration) { const status = getAutoUpdateStatus(ctx); - notify(ctx, `Auto-update: ${status}`, "info"); + notify(ctx, `Scheduled update checks: ${status}`, "info"); const usage = [ "Usage: /extensions auto-update ", "", "Duration examples:", - " never - Disable auto-updates", + " never - Disable scheduled update checks", " 1h - Check every hour", " 2h - Check every 2 hours", " 1d - Check daily", diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..f2e55dc --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,41 @@ +import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { getProfileStorePath, readProfileStore } from "../profiles/store.js"; +import { packageSourceString } from "../utils/package-source.js"; + +export interface LocalCompletionIndex { + installedPackages: string[]; + savedProfiles: string[]; +} + +let index: LocalCompletionIndex = { installedPackages: [], savedProfiles: [] }; + +export async function refreshLocalCompletionIndex( + cwd: string, + projectTrusted = false +): Promise { + const settings = SettingsManager.create(cwd, getAgentDir(), { projectTrusted }); + const installedPackages = [ + ...(settings.getGlobalSettings().packages ?? []), + ...(settings.getProjectSettings().packages ?? []), + ].map(packageSourceString); + const savedProfiles = Object.keys((await readProfileStore(getProfileStorePath())).profiles); + index = { + installedPackages: [...new Set(installedPackages)].sort(), + savedProfiles: [...new Set(savedProfiles)].sort(), + }; + return getLocalCompletionIndex(); +} + +export function getLocalCompletionIndex(): LocalCompletionIndex { + return { + installedPackages: [...index.installedPackages], + savedProfiles: [...index.savedProfiles], + }; +} + +export function setLocalCompletionIndexForTests(value?: Partial): void { + index = { + installedPackages: [...(value?.installedPackages ?? [])], + savedProfiles: [...(value?.savedProfiles ?? [])], + }; +} diff --git a/src/commands/history.ts b/src/commands/history.ts index 6eaa6ec..235ee76 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -1,4 +1,5 @@ import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { basename } from "node:path"; import { type ChangeAction, formatChangeEntry, @@ -165,7 +166,7 @@ function showHistoryHelp(ctx: ExtensionCommandContext): void { " --failed Show only failed entries", " --package Filter by package/source/extension id", " --since Show only entries newer than duration (e.g. 30m, 24h, 7d, 1mo)", - " --global Read all persisted sessions from ~/.pi/agent/sessions (non-interactive mode only)", + " --global Read all persisted Pi sessions (non-interactive mode only)", "", "Examples:", " /extensions history --failed --limit 50", @@ -178,13 +179,7 @@ function showHistoryHelp(ctx: ExtensionCommandContext): void { } function formatSessionSuffix(sessionFile: string): string { - const marker = "/.pi/agent/sessions/"; - const normalized = sessionFile.replace(/\\/g, "/"); - const index = normalized.indexOf(marker); - if (index >= 0) { - return normalized.slice(index + marker.length); - } - return sessionFile; + return basename(sessionFile) || sessionFile; } export async function handleHistorySubcommand( diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..8db2e85 --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,1208 @@ +import { writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { + type ExtensionAPI, + type ExtensionCommandContext, + getAgentDir, + type PackageSource, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { inspectInstalledPackageCompatibility } from "../doctor/compatibility.js"; +import { getPackageCatalog } from "../packages/catalog.js"; +import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; +import { type ProfilePlan, planProfileApplication } from "../profiles/apply.js"; +import { readPackageManifestSnapshot } from "../profiles/checksum.js"; +import { + loadProjectProfilePolicy, + type ProfilePackageDiagnostic, + validateProfilePolicy, +} from "../profiles/compare.js"; +import { + type ExtmgrProfile, + getEffectivePackageSource, + getProfilePackageIdentity, + isExactNpmVersion, + normalizeProfile, + type ProfilePackage, + parseExternalProfile, +} from "../profiles/schema.js"; +import { type LoadedProfileSource, loadProfileSource } from "../profiles/source.js"; +import { + deleteNamedProfile, + getNamedProfile, + getProfileStorePath, + markProfileRestorePointIncomplete, + readProfileRestorePoints, + readProfileStore, + saveNamedProfile, + saveProfileRestorePoint, +} from "../profiles/store.js"; +import { type InstalledPackage } from "../types/index.js"; +import { runTaskWithLoader } from "../ui/async-task.js"; +import { showProfileDiff } from "../ui/profile-review.js"; +import { isProjectTrusted } from "../utils/mode.js"; +import { notify } from "../utils/notify.js"; +import { + getPackageSourceKind, + normalizePackageIdentity, + packageSourceString, + parsePackageNameAndVersion, + splitGitRepoAndRef, + stripGitSourcePrefix, +} from "../utils/package-source.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; +import { markReloadRequired } from "../utils/reload-state.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; +import { confirmAction, confirmReload } from "../utils/ui-helpers.js"; + +export const PROFILE_USAGE = + "Usage: /extensions profile [name|source] [--json|--strict|--force|--name ]"; + +export interface ProfileApplicationOperation { + action: "install" | "remove" | "settings" | "verify" | "rollback"; + source?: string; + scope?: "global" | "project"; + status: "completed" | "failed"; + error?: string; +} + +export interface ProfileApplicationOutcome { + applied: boolean; + reloaded: boolean; + restored?: boolean; + restorePointId?: string; + operations?: ProfileApplicationOperation[]; +} + +function profileMutationSource(pkg: ProfilePackage, cwd: string): string { + const source = getEffectivePackageSource(pkg); + if ( + getPackageSourceKind(source) !== "local" || + !( + source.startsWith("./") || + source.startsWith("../") || + source.startsWith(".\\") || + source.startsWith("..\\") + ) + ) { + return source; + } + const root = pkg.scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(); + return resolve(root, source.replace(/\\/g, "/")); +} + +function packageSettingsMatch(entry: PackageSource, pkg: InstalledPackage, cwd: string): boolean { + const root = pkg.scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(); + return ( + normalizePackageIdentity(packageSourceString(entry), { cwd: root }) === + normalizePackageIdentity(pkg.source, { + ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), + cwd: root, + }) + ); +} + +function packageRoot(path: string | undefined): string | undefined { + if (!path) return undefined; + return /(?:^|[\\/])package\.json$/i.test(path) ? dirname(path) : path; +} + +async function resolveInstalledGitCommit( + pkg: InstalledPackage, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const cwd = packageRoot(pkg.resolvedPath); + if (!pi || !cwd) return undefined; + try { + const result = await pi.exec("git", ["rev-parse", "HEAD"], { + cwd, + timeout: 5_000, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + const commit = result.stdout.trim(); + return result.code === 0 && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(commit) + ? commit + : undefined; + } catch (error) { + if (ctx.signal?.aborted) throw error; + return undefined; + } +} + +async function toProfilePackage( + pkg: InstalledPackage, + configured: PackageSource | undefined, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const configuredSource = configured ? packageSourceString(configured) : pkg.source; + const sourceKind = getPackageSourceKind(configuredSource); + const parsed = parsePackageNameAndVersion(configuredSource); + const manifest = await readPackageManifestSnapshot(pkg.resolvedPath); + const installedVersion = + manifest?.version ?? (isExactNpmVersion(pkg.version) ? pkg.version : undefined); + const configuredGit = + sourceKind === "git" ? splitGitRepoAndRef(stripGitSourcePrefix(configuredSource)) : undefined; + const installedCommit = + sourceKind === "git" ? await resolveInstalledGitCommit(pkg, ctx, pi) : undefined; + const filters = + configured && typeof configured === "object" && Array.isArray(configured.extensions) + ? configured.extensions.filter((filter): filter is string => typeof filter === "string") + : undefined; + const packageSettings = + configured && typeof configured === "object" + ? Object.fromEntries( + Object.entries(configured) + .filter(([key]) => key !== "source" && key !== "extensions") + .map(([key, value]) => [key, structuredClone(value)]) + ) + : undefined; + + // Saved/exported profiles are snapshots. Strip mutable npm ranges and git + // refs when the installed artifact gives us an exact reproducible target. + const source = + sourceKind === "npm" && installedVersion + ? `npm:${parsed.name}` + : sourceKind === "git" && installedCommit && configuredGit + ? `${configuredSource.startsWith("git:") ? "git:" : configuredSource.startsWith("git+") ? "git+" : ""}${configuredGit.repo}` + : configuredSource; + const version = sourceKind === "npm" ? (installedVersion ?? parsed.version) : undefined; + const ref = sourceKind === "git" ? (installedCommit ?? configuredGit?.ref) : undefined; + const locked = + (sourceKind === "npm" && Boolean(installedVersion)) || + (sourceKind === "git" && Boolean(installedCommit)); + + return { + source, + scope: pkg.scope, + ...(version ? { version } : {}), + ...(ref ? { ref } : {}), + ...(locked ? { resolution: "locked" as const } : {}), + ...(filters !== undefined ? { filters } : {}), + ...(packageSettings && Object.keys(packageSettings).length > 0 ? { packageSettings } : {}), + ...(manifest ? { manifestFingerprint: manifest.fingerprint } : {}), + }; +} + +export async function getCurrentProfile( + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const packages = await getInstalledPackagesAllScopes(ctx); + const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { + projectTrusted: isProjectTrusted(ctx), + }); + const global = settings.getGlobalSettings(); + const project = settings.getProjectSettings(); + const profiles = await Promise.all( + packages.map((pkg) => { + const scoped = pkg.scope === "project" ? project : global; + const configured = scoped.packages?.find((entry) => + packageSettingsMatch(entry, pkg, ctx.cwd) + ); + return toProfilePackage(pkg, configured, ctx, pi); + }) + ); + return normalizeProfile({ name: "current", packages: profiles }); +} + +function formatPlan(plan: ProfilePlan): string { + return [ + `Add: ${plan.add.length}`, + ...plan.add.map((pkg) => ` + ${getEffectivePackageSource(pkg)} (${pkg.scope})`), + `Remove: ${plan.remove.length}`, + ...plan.remove.map((pkg) => ` - ${getEffectivePackageSource(pkg)} (${pkg.scope})`), + `Change: ${plan.update.length}`, + ...plan.update.map( + ({ from, to }) => + ` ~ ${getEffectivePackageSource(from)} (${from.scope}) -> ${getEffectivePackageSource(to)} (${to.scope})` + ), + ].join("\n"); +} + +async function resolveNamedOrSourceProfile( + requested: string | undefined, + ctx: ExtensionCommandContext +): Promise { + const store = await readProfileStore(getProfileStorePath()); + if (requested) { + const named = getNamedProfile(store, requested); + if (named) return named; + const loaded = await loadProfileSource(requested, { + cwd: ctx.cwd, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + const parsed = parseExternalProfile(loaded.value); + if (!parsed.ok) + throw new Error(parsed.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n")); + return parsed.profile; + } + if (!ctx.hasUI) return undefined; + const names = Object.keys(store.profiles).sort(); + if (names.length === 0) return undefined; + const choice = await ctx.ui.select("Select saved profile", names); + return choice ? getNamedProfile(store, choice) : undefined; +} + +function configuredEntry( + settings: ReturnType, + desired: ProfilePackage, + cwd: string +): PackageSource | undefined { + return settings.packages?.find( + (entry) => + normalizePackageIdentity(packageSourceString(entry), { + cwd: desired.scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(), + }) === + getProfilePackageIdentity(desired, { + projectCwd: cwd, + globalCwd: getAgentDir(), + }) + ); +} + +function buildScopedPackageSettings( + settings: ReturnType, + desired: ProfilePackage[], + cwd: string +): PackageSource[] { + return desired.map((pkg) => { + const source = profileMutationSource(pkg, cwd); + const existing = configuredEntry(settings, pkg, cwd); + const packageSettings = pkg.packageSettings ? structuredClone(pkg.packageSettings) : undefined; + if (existing && typeof existing === "object") { + const next: Record = packageSettings + ? { ...packageSettings, source } + : { ...existing, source }; + if (pkg.filters) next.extensions = [...pkg.filters]; + else delete next.extensions; + return next as PackageSource; + } + if (packageSettings) { + const next: Record = { ...packageSettings, source }; + if (pkg.filters) next.extensions = [...pkg.filters]; + return next as PackageSource; + } + return pkg.filters ? { source, extensions: [...pkg.filters] } : source; + }); +} + +async function persistProfileConfiguration( + desired: ExtmgrProfile, + ctx: ExtensionCommandContext +): Promise { + const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { + projectTrusted: isProjectTrusted(ctx), + }); + throwIfSettingsErrors(settings, "Profile application"); + const global = settings.getGlobalSettings(); + const project = settings.getProjectSettings(); + settings.setPackages( + buildScopedPackageSettings( + global, + desired.packages.filter((pkg) => pkg.scope === "global"), + ctx.cwd + ) + ); + settings.setProjectPackages( + buildScopedPackageSettings( + project, + desired.packages.filter((pkg) => pkg.scope === "project"), + ctx.cwd + ) + ); + await settings.flush(); + throwIfSettingsErrors(settings, "Profile application"); +} + +interface InstalledRuntimeTarget { + pkg: InstalledPackage; + version?: string; + gitCommit?: string; +} + +async function describeInstalledRuntimeTargets( + installed: InstalledPackage[], + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + return Promise.all( + installed.map(async (pkg) => { + const snapshot = await readPackageManifestSnapshot(pkg.resolvedPath); + const sourceVersion = parsePackageNameAndVersion(pkg.source).version; + const version = + snapshot?.version ?? + (isExactNpmVersion(pkg.version) ? pkg.version : undefined) ?? + (isExactNpmVersion(sourceVersion) ? sourceVersion : undefined); + const gitCommit = + getPackageSourceKind(pkg.source) === "git" + ? await resolveInstalledGitCommit(pkg, ctx, pi) + : undefined; + return { + pkg, + ...(version ? { version } : {}), + ...(gitCommit ? { gitCommit } : {}), + }; + }) + ); +} + +function installedRuntimeMatchesProfileTarget( + runtime: InstalledRuntimeTarget, + target: ProfilePackage, + ctx: ExtensionCommandContext +): boolean { + const { pkg: candidate } = runtime; + if (candidate.scope !== target.scope) return false; + const expectedIdentity = getProfilePackageIdentity(target, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + const actualIdentity = normalizePackageIdentity(candidate.source, { + ...(candidate.resolvedPath ? { resolvedPath: candidate.resolvedPath } : {}), + cwd: candidate.scope === "project" ? getProjectConfigDir(ctx.cwd) : getAgentDir(), + }); + if (actualIdentity !== expectedIdentity) return false; + + const expectedSource = getEffectivePackageSource(target); + const expectedKind = getPackageSourceKind(expectedSource); + if (expectedKind === "npm") { + const expectedVersion = parsePackageNameAndVersion(expectedSource).version; + return !expectedVersion || runtime.version === expectedVersion; + } + if (expectedKind === "git") { + const expectedRef = splitGitRepoAndRef(stripGitSourcePrefix(expectedSource)).ref; + if (!expectedRef) return true; + if (runtime.gitCommit) return runtime.gitCommit === expectedRef; + const configuredRef = splitGitRepoAndRef(stripGitSourcePrefix(candidate.source)).ref; + return configuredRef === expectedRef; + } + return true; +} + +export async function calculateProfileDiagnostics( + desired: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const installed = await getInstalledPackagesAllScopes(ctx); + const [runtimeTargets, compatibility] = await Promise.all([ + describeInstalledRuntimeTargets(installed, ctx, pi), + inspectInstalledPackageCompatibility(installed), + ]); + return desired.packages.map((pkg) => { + const source = getEffectivePackageSource(pkg); + const runtime = runtimeTargets.find((candidate) => + installedRuntimeMatchesProfileTarget(candidate, pkg, ctx) + ); + const local = runtime + ? compatibility.find( + (candidate) => + candidate.scope === runtime.pkg.scope && candidate.source === runtime.pkg.source + ) + : undefined; + const compatibilityStatus = + local && (local.node === "incompatible" || local.pi === "incompatible") + ? "failed" + : !local || local.node === "unknown" || local.pi === "unknown" + ? "unknown" + : "verified"; + return { + source, + scope: pkg.scope, + compatibility: compatibilityStatus, + // Pi's public package APIs do not expose artifact integrity evidence. + integrity: "unknown", + notes: [ + ...(!local + ? ["exact target is not installed; compatibility cannot be established before install"] + : local.reasons), + "artifact integrity is unavailable through Pi public APIs", + ], + }; + }); +} + +function validateOwnedProfile(profile: ExtmgrProfile): string[] { + const parsed = parseExternalProfile(profile); + return parsed.ok ? [] : parsed.errors.map((issue) => `${issue.path}: ${issue.message}`); +} + +function requiresInstall( + change: { from: ProfilePackage; to: ProfilePackage }, + ctx?: ExtensionCommandContext +): boolean { + if (change.from.scope !== change.to.scope) return true; + const fromSource = getEffectivePackageSource(change.from); + const toSource = getEffectivePackageSource(change.to); + if (getPackageSourceKind(fromSource) === "local" && getPackageSourceKind(toSource) === "local") { + if (!ctx) return fromSource !== toSource; + return ( + getProfilePackageIdentity(change.from, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }) !== + getProfilePackageIdentity(change.to, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }) + ); + } + return fromSource !== toSource; +} + +async function verifyInstalledTargets( + desired: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const installed = await getInstalledPackagesAllScopes(ctx); + const runtimeTargets = await describeInstalledRuntimeTargets(installed, ctx, pi); + const missing: string[] = []; + for (const pkg of desired.packages) { + const source = getEffectivePackageSource(pkg); + if ( + !runtimeTargets.some((candidate) => installedRuntimeMatchesProfileTarget(candidate, pkg, ctx)) + ) { + missing.push(`${source} (${pkg.scope})`); + } + } + return missing; +} + +function profileSourcesMatch( + left: ProfilePackage, + right: ProfilePackage, + ctx: ExtensionCommandContext +): boolean { + const leftSource = getEffectivePackageSource(left); + const rightSource = getEffectivePackageSource(right); + const sameSource = + getPackageSourceKind(leftSource) === "local" && getPackageSourceKind(rightSource) === "local" + ? getProfilePackageIdentity(left, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }) === + getProfilePackageIdentity(right, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }) + : leftSource === rightSource; + return left.scope === right.scope && sameSource; +} + +async function verifyFinalProfile( + desired: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const actual = await getCurrentProfile(ctx, pi); + const drift: string[] = []; + for (const pkg of desired.packages) { + const match = actual.packages.find( + (candidate) => + profileSourcesMatch(candidate, pkg, ctx) && + JSON.stringify(candidate.filters) === JSON.stringify(pkg.filters) && + (pkg.packageSettings === undefined || + JSON.stringify(candidate.packageSettings ?? {}) === JSON.stringify(pkg.packageSettings)) + ); + if (!match) drift.push(`${getEffectivePackageSource(pkg)} (${pkg.scope})`); + } + for (const pkg of actual.packages) { + const match = desired.packages.some((candidate) => profileSourcesMatch(candidate, pkg, ctx)); + if (!match) drift.push(`unexpected ${getEffectivePackageSource(pkg)} (${pkg.scope})`); + } + return [...new Set(drift)]; +} + +async function rollbackProfile( + current: ExtmgrProfile, + desired: ExtmgrProfile, + plan: ProfilePlan, + ctx: ExtensionCommandContext, + operations: ProfileApplicationOperation[], + pi: ExtensionAPI +): Promise { + const errors: string[] = []; + const attempt = async ( + operation: Omit, + run: () => Promise + ): Promise => { + try { + await run(); + operations.push({ ...operation, status: "completed" }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(message); + operations.push({ ...operation, status: "failed", error: message }); + } + }; + for (const change of plan.update.filter((item) => requiresInstall(item, ctx))) { + await attempt( + { + action: "rollback", + source: profileMutationSource(change.from, ctx.cwd), + scope: change.from.scope, + }, + () => + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).install( + profileMutationSource(change.from, ctx.cwd), + change.from.scope + ) + ); + } + for (const pkg of plan.remove) { + await attempt( + { action: "rollback", source: profileMutationSource(pkg, ctx.cwd), scope: pkg.scope }, + () => + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).install( + profileMutationSource(pkg, ctx.cwd), + pkg.scope + ) + ); + } + await attempt({ action: "rollback" }, () => persistProfileConfiguration(current, ctx)); + for (const pkg of plan.add) { + await attempt( + { action: "rollback", source: profileMutationSource(pkg, ctx.cwd), scope: pkg.scope }, + () => + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).remove( + profileMutationSource(pkg, ctx.cwd), + pkg.scope + ) + ); + } + for (const change of plan.update.filter((item) => item.from.scope !== item.to.scope)) { + await attempt( + { + action: "rollback", + source: profileMutationSource(change.to, ctx.cwd), + scope: change.to.scope, + }, + () => + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).remove( + profileMutationSource(change.to, ctx.cwd), + change.to.scope + ) + ); + } + const drift = await verifyFinalProfile(current, ctx, pi).catch((error) => [String(error)]); + return errors.length === 0 && drift.length === 0 && desired.schemaVersion === 1; +} + +/** Apply only after strict preflight, local policy diagnostics, and confirmation. */ +export async function applyProfileWithOutcome( + current: ExtmgrProfile, + desired: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + options?: { reviewed?: boolean } +): Promise { + const validationProblems = [ + ...validateOwnedProfile(current).map((problem) => `current: ${problem}`), + ...validateOwnedProfile(desired).map((problem) => `desired: ${problem}`), + ]; + if (validationProblems.length > 0) { + notify( + ctx, + `Profile validation rejected application:\n${validationProblems.map((problem) => `- ${problem}`).join("\n")}`, + "error" + ); + return { applied: false, reloaded: false, operations: [] }; + } + const diagnostics = await calculateProfileDiagnostics(desired, ctx, pi); + const policy = await loadProjectProfilePolicy(ctx.cwd, undefined, isProjectTrusted(ctx)); + const violations = policy ? validateProfilePolicy(desired, policy, diagnostics) : []; + if (violations.length > 0) { + notify( + ctx, + `Profile policy rejected application:\n${violations.map((violation) => `- ${violation.message}`).join("\n")}`, + "error" + ); + return { applied: false, reloaded: false, operations: [] }; + } + + const plan = planProfileApplication(current, desired, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + if (plan.add.length + plan.remove.length + plan.update.length === 0) { + notify(ctx, "Profile already matches the installed package state.", "info"); + return { applied: false, reloaded: false, operations: [] }; + } + if ( + options?.reviewed !== true && + !(await confirmAction( + ctx, + "Apply profile", + `${desired.name}\n\n${formatPlan(plan)}\n\nApply these changes?` + )) + ) { + notify(ctx, "Profile application cancelled.", "info"); + return { applied: false, reloaded: false, operations: [] }; + } + + const restorePoint = await saveProfileRestorePoint(current, `Before applying ${desired.name}`); + const operations: ProfileApplicationOperation[] = []; + let pendingOperation: Omit | undefined; + try { + await runTaskWithLoader( + ctx, + { title: "Apply profile", message: `Applying ${desired.name}...`, cancellable: false }, + async ({ setMessage }) => { + for (const pkg of plan.add) { + const source = profileMutationSource(pkg, ctx.cwd); + setMessage(`Installing ${source}...`); + pendingOperation = { action: "install", source, scope: pkg.scope }; + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).install(source, pkg.scope); + operations.push({ ...pendingOperation, status: "completed" }); + pendingOperation = undefined; + } + for (const change of plan.update.filter((item) => requiresInstall(item, ctx))) { + const source = profileMutationSource(change.to, ctx.cwd); + setMessage(`Installing replacement ${source}...`); + pendingOperation = { action: "install", source, scope: change.to.scope }; + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).install(source, change.to.scope); + operations.push({ ...pendingOperation, status: "completed" }); + pendingOperation = undefined; + } + pendingOperation = { action: "verify" }; + const missingBeforePersist = await verifyInstalledTargets( + { + ...desired, + packages: desired.packages.filter( + (pkg) => + plan.add.includes(pkg) || + plan.update.some((change) => change.to === pkg && requiresInstall(change, ctx)) + ), + }, + ctx, + pi + ); + if (missingBeforePersist.length > 0) + throw new Error( + `Installed result verification failed: ${missingBeforePersist.join(", ")}` + ); + pendingOperation = undefined; + + setMessage("Preserving complete package settings and filters..."); + pendingOperation = { action: "settings" }; + await persistProfileConfiguration(desired, ctx); + operations.push({ ...pendingOperation, status: "completed" }); + pendingOperation = undefined; + + for (const pkg of plan.remove) { + const source = profileMutationSource(pkg, ctx.cwd); + setMessage(`Removing obsolete ${source}...`); + pendingOperation = { action: "remove", source, scope: pkg.scope }; + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).remove(source, pkg.scope); + operations.push({ ...pendingOperation, status: "completed" }); + pendingOperation = undefined; + } + for (const change of plan.update.filter((item) => item.from.scope !== item.to.scope)) { + const source = profileMutationSource(change.from, ctx.cwd); + setMessage(`Removing old-scope ${source}...`); + pendingOperation = { action: "remove", source, scope: change.from.scope }; + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).remove(source, change.from.scope); + operations.push({ ...pendingOperation, status: "completed" }); + pendingOperation = undefined; + } + pendingOperation = { action: "verify" }; + const drift = await verifyFinalProfile(desired, ctx, pi); + if (drift.length > 0) + throw new Error(`Final-state verification detected drift: ${drift.join(", ")}`); + operations.push({ ...pendingOperation, status: "completed" }); + pendingOperation = undefined; + return undefined; + } + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + operations.push({ + ...(pendingOperation ?? { action: "verify" }), + status: "failed", + error: message, + }); + const restored = await rollbackProfile(current, desired, plan, ctx, operations, pi); + if (!restored) { + await markProfileRestorePointIncomplete(restorePoint.id); + await markReloadRequired(`Profile ${desired.name} rollback is incomplete.`); + } + notify( + ctx, + `Profile ${desired.name} failed: ${message}\nRollback ${restored ? "completed" : "incomplete"}. Restore point: ${restorePoint.id}\n${operations.map((item) => `- ${item.action} ${item.source ?? "configuration"}: ${item.status}${item.error ? ` (${item.error})` : ""}`).join("\n")}`, + "error" + ); + return { + applied: false, + reloaded: false, + restored, + restorePointId: restorePoint.id, + operations, + }; + } + + notify(ctx, `Applied profile ${desired.name}.`, "info"); + const reloaded = await confirmReload(ctx, "Profile package configuration changed."); + return { applied: true, reloaded, restorePointId: restorePoint.id, operations }; +} + +/** Route every interactive apply through the same inline review gate. */ +export async function reviewAndApplyProfileWithOutcome( + current: ExtmgrProfile, + desired: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + if (!ctx.hasUI) return applyProfileWithOutcome(current, desired, ctx, pi); + + const plan = planProfileApplication(current, desired, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + if (plan.add.length + plan.remove.length + plan.update.length === 0) { + return applyProfileWithOutcome(current, desired, ctx, pi); + } + + const policy = await loadProjectProfilePolicy(ctx.cwd, undefined, isProjectTrusted(ctx)); + const diagnostics = policy ? await calculateProfileDiagnostics(desired, ctx, pi) : []; + const violations = policy ? validateProfilePolicy(desired, policy, diagnostics) : []; + const review = await showProfileDiff(current, desired, violations, ctx); + if (review !== "apply") { + return { applied: false, reloaded: false, operations: [] }; + } + return applyProfileWithOutcome(current, desired, ctx, pi, { reviewed: true }); +} + +interface ParsedOptions { + positionals: string[]; + json: boolean; + strict: boolean; + force: boolean; + name?: string; +} + +function parseOptions(tokens: string[]): ParsedOptions { + const positionals: string[] = []; + let json = false; + let strict = false; + let force = false; + let name: string | undefined; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === "--json") json = true; + else if (token === "--strict") strict = true; + else if (token === "--force" || token === "--replace") force = true; + else if (token === "--name") { + const value = tokens[index + 1]; + if (!value) throw new Error("--name requires a value."); + name = value; + index += 1; + } else if (token?.startsWith("--")) throw new Error(`Unknown option: ${token}`); + else if (token) positionals.push(token); + } + return { positionals, json, strict, force, ...(name ? { name } : {}) }; +} + +function withImportMetadata(profile: ExtmgrProfile, loaded: LoadedProfileSource): ExtmgrProfile { + return { + ...profile, + importMetadata: { + origin: loaded.origin, + finalOrigin: loaded.finalOrigin, + ...(loaded.fetchedAt ? { fetchedAt: loaded.fetchedAt } : {}), + contentFingerprint: loaded.contentFingerprint, + warnings: [...loaded.warnings], + }, + }; +} + +async function saveImportedProfile( + profile: ExtmgrProfile, + loaded: LoadedProfileSource, + options: ParsedOptions, + ctx: ExtensionCommandContext +): Promise { + let imported = withImportMetadata( + { ...profile, ...(options.name ? { name: options.name.trim() } : {}) }, + loaded + ); + if (!imported.name.trim()) throw new Error("Imported profile requires a name or --name value."); + const storePath = getProfileStorePath(); + const existing = getNamedProfile(await readProfileStore(storePath), imported.name); + let replace = options.force; + if (existing && !replace) { + if (!ctx.hasUI) + throw new Error( + `A saved profile named ${imported.name} already exists; pass --force to replace it.` + ); + const choice = await ctx.ui.select("Profile name collision", ["Overwrite", "Rename", "Cancel"]); + if (choice === "Overwrite") replace = true; + else if (choice === "Rename") { + const renamed = await ctx.ui.input("Imported profile name", imported.name); + if (!renamed?.trim()) return; + imported = { ...imported, name: renamed.trim() }; + } else return; + } + await saveNamedProfile(storePath, imported, { replace }); + notify(ctx, `Imported profile ${imported.name}. It was saved but not applied.`, "info"); +} + +async function handleImport( + tokens: string[], + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const options = parseOptions(tokens); + const source = options.positionals[0]; + if (!source) + throw new Error( + "Usage: /extensions profile import [--name ] [--force]" + ); + const loaded = await loadProfileSource(source, { + cwd: ctx.cwd, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + const parsed = parseExternalProfile(loaded.value, { requireName: !options.name }); + if (!parsed.ok) + throw new Error(parsed.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n")); + const profile = options.name ? { ...parsed.profile, name: options.name.trim() } : parsed.profile; + const current = await getCurrentProfile(ctx, pi); + const plan = planProfileApplication(current, profile, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + const importDiagnostics = await calculateProfileDiagnostics(profile, ctx, pi); + const importPolicy = await loadProjectProfilePolicy(ctx.cwd, undefined, isProjectTrusted(ctx)); + const importViolations = importPolicy + ? validateProfilePolicy(profile, importPolicy, importDiagnostics) + : []; + const summary = [ + `Origin: ${loaded.origin}`, + `Final origin: ${loaded.finalOrigin}`, + `Origin status: ${loaded.immutableOrigin === true ? "immutable" : loaded.immutableOrigin === false ? "floating" : "local"}`, + `Content fingerprint: ${loaded.contentFingerprint}`, + `Schema: v${parsed.migration.fromVersion}${parsed.migration.migrated ? " (migrated)" : ""}`, + `Packages: ${profile.packages.length} (${profile.packages.filter((pkg) => pkg.scope === "global").length} global, ${profile.packages.filter((pkg) => pkg.scope === "project").length} project)`, + `Preview: ${plan.add.length} add, ${plan.remove.length} remove, ${plan.update.length} change`, + `Policy: ${importViolations.length === 0 ? "pass" : `${importViolations.length} violation(s)`}`, + `Compatibility: ${importDiagnostics.filter((item) => item.compatibility === "unknown").length} unknown`, + `Integrity: ${importDiagnostics.filter((item) => item.integrity === "unknown").length} unknown`, + ...importViolations.map((violation) => `Policy violation: ${violation.message}`), + ...[...parsed.warnings, ...loaded.warnings].map((warning) => `Warning: ${warning}`), + ].join("\n"); + notify(ctx, summary, loaded.warnings.length > 0 ? "warning" : "info"); + if (ctx.hasUI) { + const action = await ctx.ui.select("Import profile", ["Save", "Review changes", "Cancel"]); + if (action === "Cancel" || !action) return; + if (action === "Review changes") { + notify(ctx, formatPlan(plan), "info"); + if ( + !(await confirmAction( + ctx, + "Save imported profile", + "Save this profile without applying it?" + )) + ) + return; + } + } + await saveImportedProfile(profile, loaded, options, ctx); +} + +export interface ProfileCheckResult { + ok: boolean; + valid: boolean; + drift: boolean | null; + strict: boolean; + status: "ok" | "drift" | "invalid" | "policy-violation" | "diagnostic-failure" | "origin-warning"; + counts: { add: number; remove: number; change: number }; + policyViolations: string[]; + compatibilityUnknown: string[]; + compatibilityFailed: string[]; + integrityUnknown: string[]; + integrityFailed: string[]; + originWarnings: string[]; + changes: { + add: ProfilePackage[]; + remove: ProfilePackage[]; + update: Array<{ from: ProfilePackage; to: ProfilePackage }>; + }; + error?: string; +} + +function invalidProfileCheckResult(error: unknown, strict: boolean): ProfileCheckResult { + return { + ok: false, + valid: false, + drift: null, + strict, + status: "invalid", + counts: { add: 0, remove: 0, change: 0 }, + policyViolations: [], + compatibilityUnknown: [], + compatibilityFailed: [], + integrityUnknown: [], + integrityFailed: [], + originWarnings: [], + changes: { add: [], remove: [], update: [] }, + error: error instanceof Error ? error.message : String(error), + }; +} + +/** + * Check semantics: + * - non-strict mode validates and reports drift without treating drift as failure; + * - confirmed diagnostic failures and project-policy violations always fail; + * - strict mode additionally fails on drift or a floating-origin warning; + * - unknown diagnostics remain informational unless project policy requires them. + */ +export async function checkProfileSource( + source: string, + ctx: ExtensionCommandContext, + options?: { strict?: boolean; pi?: ExtensionAPI } +): Promise { + const strict = options?.strict ?? false; + try { + const loaded = await loadProfileSource(source, { + cwd: ctx.cwd, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + const parsed = parseExternalProfile(loaded.value); + if (!parsed.ok) + throw new Error(parsed.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ")); + const current = await getCurrentProfile(ctx, options?.pi); + const plan = planProfileApplication(current, parsed.profile, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + const diagnostics = await calculateProfileDiagnostics(parsed.profile, ctx, options?.pi); + const policy = await loadProjectProfilePolicy(ctx.cwd, undefined, isProjectTrusted(ctx)); + const violations = policy ? validateProfilePolicy(parsed.profile, policy, diagnostics) : []; + const originWarnings = [ + ...new Set([...loaded.warnings, ...(parsed.profile.importMetadata?.warnings ?? [])]), + ]; + const drift = plan.add.length + plan.remove.length + plan.update.length > 0; + const hasDiagnosticFailure = diagnostics.some( + (item) => item.compatibility === "failed" || item.integrity === "failed" + ); + const status: ProfileCheckResult["status"] = + violations.length > 0 + ? "policy-violation" + : hasDiagnosticFailure + ? "diagnostic-failure" + : drift + ? "drift" + : originWarnings.length > 0 + ? "origin-warning" + : "ok"; + return { + ok: + violations.length === 0 && + !hasDiagnosticFailure && + (!strict || (!drift && originWarnings.length === 0)), + valid: true, + drift, + strict, + status, + counts: { add: plan.add.length, remove: plan.remove.length, change: plan.update.length }, + policyViolations: violations.map((violation) => violation.message), + compatibilityUnknown: diagnostics + .filter((item) => item.compatibility === "unknown") + .map((item) => `${item.source} (${item.scope})`), + compatibilityFailed: diagnostics + .filter((item) => item.compatibility === "failed") + .map((item) => `${item.source} (${item.scope})`), + integrityUnknown: diagnostics + .filter((item) => item.integrity === "unknown") + .map((item) => `${item.source} (${item.scope})`), + integrityFailed: diagnostics + .filter((item) => item.integrity === "failed") + .map((item) => `${item.source} (${item.scope})`), + originWarnings, + changes: plan, + }; + } catch (error) { + return invalidProfileCheckResult(error, strict); + } +} + +async function handleCheck( + tokens: string[], + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const options = parseOptions(tokens); + const source = options.positionals[0]; + const result = source + ? await checkProfileSource(source, ctx, { strict: options.strict, ...(pi ? { pi } : {}) }) + : invalidProfileCheckResult( + new Error("Usage: /extensions profile check [--json] [--strict]"), + options.strict + ); + if (options.json) { + const encoded = JSON.stringify(result); + if (ctx.hasUI) ctx.ui.notify(encoded, result.ok ? "info" : "error"); + else console.log(encoded); + return; + } + notify( + ctx, + [ + `Profile: ${result.valid ? "valid" : "invalid"}`, + `Drift: ${result.drift === null ? "unknown" : result.drift ? "yes" : "no"}`, + `Changes: ${result.counts.add} add, ${result.counts.remove} remove, ${result.counts.change} change`, + `Status: ${result.status}`, + ...(result.policyViolations.length + ? [`Policy violations: ${result.policyViolations.join("; ")}`] + : []), + ...(result.compatibilityFailed.length + ? [`Compatibility failed: ${result.compatibilityFailed.join(", ")}`] + : []), + ...(result.compatibilityUnknown.length + ? [`Compatibility unknown: ${result.compatibilityUnknown.join(", ")}`] + : []), + ...(result.integrityFailed.length + ? [`Integrity failed: ${result.integrityFailed.join(", ")}`] + : []), + ...(result.integrityUnknown.length + ? [`Integrity unknown: ${result.integrityUnknown.join(", ")}`] + : []), + ...result.originWarnings.map((warning) => `Warning: ${warning}`), + ...(result.error ? [`Error: ${result.error}`] : []), + ...(options.strict + ? [ + "Strict mode fails on drift, confirmed diagnostic failures, policy violations, and floating-origin warnings. Unknown diagnostics fail only when policy requires them.", + "Pi's command API has no supported process status channel; failures are reported without terminating Pi.", + ] + : []), + ].join("\n"), + result.ok ? "info" : "error" + ); +} + +async function handleRecover( + tokens: string[], + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const points = await readProfileRestorePoints(); + const requested = tokens[0]; + if (!requested || requested === "list") { + notify( + ctx, + points.length + ? `Profile restore points:\n${points.map((point, index) => `${index + 1}. ${point.id}${point.incomplete ? " (incomplete rollback)" : ""} - ${point.reason}`).join("\n")}` + : "No profile restore points.", + "info" + ); + return; + } + const point = + points.find((candidate) => candidate.id === requested) ?? points[Number(requested) - 1]; + if (!point) throw new Error(`Profile restore point not found: ${requested}`); + if (!pi) throw new Error("Profile recovery requires the extension API."); + const current = await getCurrentProfile(ctx, pi); + await reviewAndApplyProfileWithOutcome( + current, + { ...point.profile, name: `restore-${point.id}` }, + ctx, + pi + ); +} + +export async function handleProfileSubcommand( + tokens: string[], + ctx: ExtensionCommandContext, + pi?: ExtensionAPI +): Promise { + const action = tokens[0]; + if ( + !action || + ![ + "export", + "save", + "list", + "delete", + "dry-run", + "apply", + "compare", + "import", + "check", + "recover", + ].includes(action) + ) { + notify(ctx, PROFILE_USAGE, "info"); + return; + } + try { + if (action === "import") return await handleImport(tokens.slice(1), ctx, pi); + if (action === "check") return await handleCheck(tokens.slice(1), ctx, pi); + if (action === "recover") return await handleRecover(tokens.slice(1), ctx, pi); + const requested = tokens[1]; + const storePath = getProfileStorePath(); + if (action === "list") { + const names = Object.keys((await readProfileStore(storePath)).profiles).sort(); + notify( + ctx, + names.length ? `Saved profiles:\n${names.join("\n")}` : "No saved profiles.", + "info" + ); + return; + } + if (action === "save") { + if (!requested?.trim()) { + notify(ctx, "Usage: /extensions profile save ", "info"); + return; + } + const profile = { ...(await getCurrentProfile(ctx, pi)), name: requested.trim() }; + const existing = getNamedProfile(await readProfileStore(storePath), profile.name); + let replace = tokens.includes("--force") || tokens.includes("--replace"); + if (existing && !replace && ctx.hasUI) + replace = await confirmAction(ctx, "Replace saved profile", `Replace ${profile.name}?`); + await saveNamedProfile(storePath, profile, { replace }); + notify(ctx, `Saved profile ${profile.name}.`, "info"); + return; + } + if (action === "delete") { + if (!requested || !(await deleteNamedProfile(storePath, requested))) + notify(ctx, `Saved profile not found: ${requested ?? "(missing name)"}`, "warning"); + else notify(ctx, `Deleted profile ${requested}.`, "info"); + return; + } + + const current = await getCurrentProfile(ctx, pi); + if (action === "export") { + if (!requested) { + notify(ctx, "Usage: /extensions profile export ", "info"); + return; + } + const destination = resolve(ctx.cwd, requested); + await writeFile(destination, `${JSON.stringify(current, null, 2)}\n`, { flag: "wx" }); + notify(ctx, `Exported profile to ${destination}`, "info"); + return; + } + const desired = await resolveNamedOrSourceProfile(requested, ctx); + if (!desired) { + notify(ctx, "No saved profile selected.", "info"); + return; + } + const plan = planProfileApplication(current, desired, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + if (action === "apply") { + if (!pi) throw new Error("Profile application requires the extension API."); + await reviewAndApplyProfileWithOutcome(current, desired, ctx, pi); + return; + } + notify(ctx, formatPlan(plan), "info"); + } catch (error) { + notify( + ctx, + `Profile ${action} failed: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + } +} diff --git a/src/commands/registry.ts b/src/commands/registry.ts index e128b82..d8015ff 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -1,23 +1,56 @@ import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { type AutocompleteItem } from "@earendil-works/pi-tui"; -import { - promptRemove, - removePackage, - showInstalledPackagesList, - updatePackage, - updatePackages, -} from "../packages/management.js"; +import { inspectInstalledPackageCompatibility } from "../doctor/compatibility.js"; +import { findRuntimeConflicts } from "../doctor/conflicts.js"; +import { getRuntimeOwners } from "../doctor/runtime.js"; +import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; +import { promptRemove, removePackage, showInstalledPackagesList } from "../packages/management.js"; import { showRemote } from "../ui/remote.js"; import { showInstalledPackagesLegacy, showInteractive, showListOnly } from "../ui/unified.js"; import { notify } from "../utils/notify.js"; import { handleAutoUpdateSubcommand } from "./auto-update.js"; +import { getLocalCompletionIndex } from "./completion.js"; import { clearMetadataCacheCommand } from "./cache.js"; import { handleHistorySubcommand } from "./history.js"; import { handleInstallSubcommand, INSTALL_USAGE } from "./install.js"; +import { handleProfileSubcommand } from "./profile.js"; import { type CommandDefinition, type CommandId } from "./types.js"; +import { handleUpdateSubcommand } from "./update.js"; +import { handleTrashSubcommand } from "./trash.js"; const REMOVE_USAGE = "Usage: /extensions remove "; +async function showDoctor(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const owners = getRuntimeOwners(pi); + const conflicts = findRuntimeConflicts(owners); + const packages = await getInstalledPackagesAllScopes(ctx); + const compatibility = await inspectInstalledPackageCompatibility(packages); + const lines = [`Runtime ownership: ${owners.length} command/tool entries`]; + lines.push("Installed package compatibility:"); + if (compatibility.length === 0) { + lines.push("- none installed"); + } else { + for (const diagnostic of compatibility) { + lines.push( + `- ${diagnostic.source} (${diagnostic.scope}): Node ${diagnostic.node}, Pi ${diagnostic.pi}` + ); + for (const reason of diagnostic.reasons) lines.push(` ${reason}`); + } + } + if (conflicts.length === 0) { + lines.push("No command or tool conflicts detected."); + } else { + lines.push(`Conflicts detected: ${conflicts.length}`); + for (const conflict of conflicts) { + lines.push(`- ${conflict.kind} ${conflict.name}:`); + for (const owner of conflict.owners) { + lines.push(` ${owner.source} (${owner.scope}) ${owner.path}`); + } + } + } + notify(ctx, lines.join("\n"), conflicts.length > 0 ? "warning" : "info"); +} + function requireInteractiveCommand(ctx: ExtensionCommandContext, feature: string): void { notify(ctx, `${feature} requires interactive mode.`, "warning"); } @@ -34,7 +67,9 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void { " /extensions remove - Remove a package", " /extensions update [source] - Update one package or all packages", " /extensions history [opts] - Show history (supports filters)", - " /extensions auto-update - Configure auto-update (e.g. 1d, 1w, 1mo, never)", + " /extensions doctor - Inspect runtime ownership/conflicts", + " /extensions profile - Save, import, check, review, or apply a profile", + " /extensions auto-update - Configure scheduled update checks (e.g. 1d, 1w, 1mo, never)", "", "History examples:", " /extensions history --failed --limit 50", @@ -62,7 +97,9 @@ const COMMAND_DEFINITIONS: Record = { id: "remote", description: "Browse community packages", aliases: ["packages"], - runInteractive: (tokens, ctx, pi) => showRemote(tokens.join(" "), ctx, pi), + runInteractive: async (tokens, ctx, pi) => { + await showRemote(tokens.join(" "), ctx, pi); + }, runNonInteractive: (_tokens, ctx) => { requireInteractiveCommand(ctx, "Remote package browsing"); showNonInteractiveHelp(ctx); @@ -77,7 +114,9 @@ const COMMAND_DEFINITIONS: Record = { search: { id: "search", description: "Search npm for packages", - runInteractive: (tokens, ctx, pi) => showRemote(`search ${tokens.join(" ")}`, ctx, pi), + runInteractive: async (tokens, ctx, pi) => { + await showRemote(`search ${tokens.join(" ")}`, ctx, pi); + }, runNonInteractive: (_tokens, ctx) => { requireInteractiveCommand(ctx, "Search"); showNonInteractiveHelp(ctx); @@ -86,8 +125,10 @@ const COMMAND_DEFINITIONS: Record = { install: { id: "install", description: "Install a package", - runInteractive: (tokens, ctx, pi) => - tokens.length > 0 ? handleInstallSubcommand(tokens, ctx, pi) : showRemote("install", ctx, pi), + runInteractive: async (tokens, ctx, pi) => { + if (tokens.length > 0) await handleInstallSubcommand(tokens, ctx, pi); + else await showRemote("install", ctx, pi); + }, runNonInteractive: (tokens, ctx, pi) => tokens.length > 0 ? handleInstallSubcommand(tokens, ctx, pi) @@ -106,11 +147,9 @@ const COMMAND_DEFINITIONS: Record = { }, update: { id: "update", - description: "Update one package or all packages", - runInteractive: (tokens, ctx, pi) => - tokens.length > 0 ? updatePackage(tokens.join(" "), ctx, pi) : updatePackages(ctx, pi), - runNonInteractive: (tokens, ctx, pi) => - tokens.length > 0 ? updatePackage(tokens.join(" "), ctx, pi) : updatePackages(ctx, pi), + description: "Preview or update selected packages", + runInteractive: (tokens, ctx, pi) => handleUpdateSubcommand(tokens, ctx, pi), + runNonInteractive: (tokens, ctx, pi) => handleUpdateSubcommand(tokens, ctx, pi), }, history: { id: "history", @@ -124,12 +163,30 @@ const COMMAND_DEFINITIONS: Record = { runInteractive: (_tokens, ctx, pi) => clearMetadataCacheCommand(ctx, pi), runNonInteractive: (_tokens, ctx, pi) => clearMetadataCacheCommand(ctx, pi), }, + trash: { + id: "trash", + description: "List, restore, or purge local extension trash", + runInteractive: (tokens, ctx, pi) => handleTrashSubcommand(tokens, ctx, pi), + runNonInteractive: (tokens, ctx, pi) => handleTrashSubcommand(tokens, ctx, pi), + }, "auto-update": { id: "auto-update", - description: "Configure auto-update schedule", + description: "Configure scheduled update checks", runInteractive: (tokens, ctx, pi) => handleAutoUpdateSubcommand(tokens, ctx, pi), runNonInteractive: (tokens, ctx, pi) => handleAutoUpdateSubcommand(tokens, ctx, pi), }, + doctor: { + id: "doctor", + description: "Inspect runtime command/tool ownership and conflicts", + runInteractive: (_tokens, ctx, pi) => showDoctor(ctx, pi), + runNonInteractive: (_tokens, ctx, pi) => showDoctor(ctx, pi), + }, + profile: { + id: "profile", + description: "Save, import, check, review, or apply a package profile", + runInteractive: (tokens, ctx, pi) => handleProfileSubcommand(tokens, ctx, pi), + runNonInteractive: (tokens, ctx, pi) => handleProfileSubcommand(tokens, ctx, pi), + }, }; function buildCommandAliasMap( @@ -169,7 +226,76 @@ export function runResolvedCommand( return runner(resolved.args, ctx, pi); } +function completionItems(options: string[], prefix: string): AutocompleteItem[] | null { + const matches = options.filter((option) => option.toLowerCase().startsWith(prefix.toLowerCase())); + return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null; +} + export function getExtensionsAutocompleteItems(prefix: string): AutocompleteItem[] | null { + const commandPrefix = (prefix ?? "").trimStart(); + if (commandPrefix.includes(" ")) { + const trailingSpace = /\s$/.test(commandPrefix); + const tokens = commandPrefix.replace(/^\//, "").split(/\s+/).filter(Boolean); + const command = tokens.shift()?.toLowerCase() ?? ""; + const activePrefix = trailingSpace ? "" : (tokens.pop() ?? ""); + const completedArgs = tokens; + const local = getLocalCompletionIndex(); + + if ((command === "remove" || command === "uninstall") && completedArgs.length === 0) { + return completionItems(local.installedPackages, activePrefix); + } + if (command === "update" && completedArgs.length === 0) { + return completionItems(["--all", "--preview", ...local.installedPackages], activePrefix); + } + if (command === "profile") { + const actions = [ + "export", + "save", + "list", + "delete", + "dry-run", + "apply", + "compare", + "import", + "check", + "recover", + ]; + if (completedArgs.length === 0) return completionItems(actions, activePrefix); + const action = completedArgs[0]; + if (["delete", "dry-run", "apply", "compare"].includes(action ?? "")) { + return completionItems(local.savedProfiles, activePrefix); + } + if (action === "import") return completionItems(["--name", "--force"], activePrefix); + if (action === "check") return completionItems(["--json", "--strict"], activePrefix); + if (action === "recover") return completionItems(["list"], activePrefix); + return null; + } + if (command === "history") { + const historyActions = [ + "extension_toggle", + "extension_delete", + "package_install", + "package_update", + "package_remove", + "cache_clear", + "auto_update_config", + ]; + if (completedArgs.at(-1) === "--action") { + return completionItems(historyActions, activePrefix); + } + return completionItems( + ["--action", "--failed", "--success", "--global", "--limit", "--package", "--since"], + activePrefix + ); + } + const argumentOptions: Record = { + install: ["--global", "--project"], + "auto-update": ["daily", "weekly", "monthly", "never"], + trash: ["list", "restore", "purge", "all"], + }; + return completionItems(argumentOptions[command] ?? [], activePrefix); + } + const items = Object.values(COMMAND_DEFINITIONS).flatMap((def) => { const base = [{ value: def.id, description: def.description }]; const aliases = (def.aliases ?? []).map((alias) => ({ diff --git a/src/commands/trash.ts b/src/commands/trash.ts new file mode 100644 index 0000000..7c61270 --- /dev/null +++ b/src/commands/trash.ts @@ -0,0 +1,106 @@ +import { + getAgentDir, + type ExtensionAPI, + type ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { join } from "node:path"; +import { + listExtensionTrash, + purgeExtensionTrash, + undoExtensionTrash, +} from "../extensions/trash.js"; +import { confirmAction, confirmReload, formatListOutput } from "../utils/ui-helpers.js"; +import { notify } from "../utils/notify.js"; + +const TRASH_USAGE = "Usage: /extensions trash "; + +function getTrashRoot(): string { + return join(getAgentDir(), ".extmgr-trash"); +} + +async function selectRecord( + ctx: ExtensionCommandContext, + action: string, + records: Awaited>, + requestedIndex?: string +) { + if (requestedIndex) { + const index = Number(requestedIndex) - 1; + return Number.isInteger(index) && index >= 0 ? records[index] : undefined; + } + if (!ctx.hasUI) return undefined; + const choice = await ctx.ui.select( + action === "restore" ? "Restore" : "Purge", + records.map((record, index) => `[${index + 1}] ${record.originalPath}`) + ); + const match = choice?.match(/^\[(\d+)\]/); + const index = match?.[1] ? Number(match[1]) - 1 : -1; + return index >= 0 ? records[index] : undefined; +} + +export async function handleTrashSubcommand( + tokens: string[], + ctx: ExtensionCommandContext, + _pi: ExtensionAPI +): Promise { + const action = tokens[0] ?? "list"; + if (!["list", "restore", "purge"].includes(action)) { + notify(ctx, TRASH_USAGE, "info"); + return; + } + + try { + const records = await listExtensionTrash(getTrashRoot()); + if (action === "list") { + formatListOutput( + ctx, + "Trash", + records.map((record, index) => `[${index + 1}] ${record.originalPath}`) + ); + return; + } + if (records.length === 0) { + notify(ctx, "No trash records found.", "info"); + return; + } + + if (action === "purge" && tokens[1]?.toLowerCase() === "all") { + if (!(await confirmAction(ctx, "Purge", `Permanently delete ${records.length} record(s)?`))) { + notify(ctx, "Purge cancelled.", "info"); + return; + } + for (const record of records) await purgeExtensionTrash(record); + notify(ctx, `Purged ${records.length} record(s).`, "info"); + return; + } + + const record = await selectRecord(ctx, action, records, tokens[1]); + if (!record) { + notify(ctx, TRASH_USAGE, "info"); + return; + } + if (action === "purge") { + if (!(await confirmAction(ctx, "Purge", `Permanently delete ${record.originalPath}?`))) { + notify(ctx, "Purge cancelled.", "info"); + return; + } + await purgeExtensionTrash(record); + notify(ctx, `Purged ${record.originalPath}.`, "info"); + return; + } + + if (!(await confirmAction(ctx, "Restore", `Restore ${record.originalPath}?`))) { + notify(ctx, "Restore cancelled.", "info"); + return; + } + await undoExtensionTrash(record); + notify(ctx, `Restored ${record.originalPath}.`, "info"); + await confirmReload(ctx, "A local extension was restored."); + } catch (error) { + notify( + ctx, + `${action === "restore" ? "Restore" : action === "purge" ? "Purge" : "Trash"} failed: ${error instanceof Error ? error.message : "Unexpected error"}`, + "error" + ); + } +} diff --git a/src/commands/types.ts b/src/commands/types.ts index 8c02c6d..c1c59e1 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -11,7 +11,10 @@ export type CommandId = | "update" | "history" | "clear-cache" - | "auto-update"; + | "trash" + | "auto-update" + | "doctor" + | "profile"; export interface CommandDefinition { id: CommandId; diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..3cdd873 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,45 @@ +import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { getPackageCatalog } from "../packages/catalog.js"; +import { isProjectTrusted } from "../utils/mode.js"; +import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; +import { updatePackage, updatePackages } from "../packages/management.js"; +import { buildUpdatePreview } from "../packages/update-preview.js"; +import { notify } from "../utils/notify.js"; + +export async function handleUpdateSubcommand( + tokens: string[], + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + if (tokens.includes("--preview")) { + try { + const [installed, available] = await Promise.all([ + getInstalledPackagesAllScopes(ctx), + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).checkForAvailableUpdates(), + ]); + const preview = buildUpdatePreview(installed, available).filter((pkg) => pkg.updateAvailable); + notify( + ctx, + preview.length > 0 + ? `Updates available:\n${preview.map((pkg) => `- ${pkg.name}${pkg.currentVersion ? `@${pkg.currentVersion}` : ""} (${pkg.scope})`).join("\n")}` + : "All packages are up to date (or pinned).", + "info" + ); + } catch (error) { + notify( + ctx, + `Update preview failed: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + } + return; + } + + const sources = tokens.filter((token) => token !== "--all"); + if (tokens.includes("--all") || sources.length === 0) { + await updatePackages(ctx, pi); + return; + } + + for (const source of sources) await updatePackage(source, ctx, pi); +} diff --git a/src/constants.ts b/src/constants.ts index 9486c62..1c28f6f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,9 +10,6 @@ export const DISABLED_SUFFIX = ".disabled"; /** Number of items to display per page in paginated views */ export const PAGE_SIZE = 20; -/** Default cache time-to-live: 5 minutes */ -export const CACHE_TTL = 5 * 60 * 1000; - /** * Timeout values for various operations (in milliseconds) * diff --git a/src/doctor/compatibility.ts b/src/doctor/compatibility.ts new file mode 100644 index 0000000..ef0b7f9 --- /dev/null +++ b/src/doctor/compatibility.ts @@ -0,0 +1,170 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { type InstalledPackage } from "../types/index.js"; + +export interface CompatibilityInput { + packageName: string; + engines?: { node?: string }; + piVersion?: string; + requiredPi?: string; + nodeVersion?: string; +} + +export interface CompatibilityDiagnostic { + packageName: string; + node: "compatible" | "incompatible" | "unknown"; + pi: "compatible" | "incompatible" | "unknown"; + reasons: string[]; +} + +type Version = [number, number, number]; +type Comparator = { operator: ">=" | ">" | "<=" | "<" | "="; version: Version }; + +function parseVersion(value: string, allowPartial = false): Version | undefined { + const match = value.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); + if (!match?.[1]) return undefined; + if (!allowPartial && (match[2] === undefined || match[3] === undefined)) return undefined; + return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; +} + +function compare(left: Version, right: Version): number { + for (let index = 0; index < 3; index += 1) { + const l = left[index] ?? 0; + const r = right[index] ?? 0; + if (l !== r) return l < r ? -1 : 1; + } + return 0; +} + +function testComparator(actual: Version, comparator: Comparator): boolean { + const result = compare(actual, comparator.version); + switch (comparator.operator) { + case ">=": + return result >= 0; + case ">": + return result > 0; + case "<=": + return result <= 0; + case "<": + return result < 0; + case "=": + return result === 0; + } +} + +function expandToken(token: string): Comparator[] | undefined { + const caret = token.match(/^\^(.+)$/); + const tilde = token.match(/^~(.+)$/); + if (caret?.[1] || tilde?.[1]) { + const value = caret?.[1] ?? tilde?.[1]; + if (!value) return undefined; + const version = parseVersion(value, true); + if (!version) return undefined; + let upper: Version; + if (caret) { + upper = + version[0] > 0 + ? [version[0] + 1, 0, 0] + : version[1] > 0 + ? [0, version[1] + 1, 0] + : [0, 0, version[2] + 1]; + } else { + upper = [version[0], version[1] + 1, 0]; + } + return [ + { operator: ">=", version }, + { operator: "<", version: upper }, + ]; + } + const match = token.match(/^(>=|<=|>|<|=)?(v?\d+(?:\.\d+){0,2})$/); + if (!match?.[2]) return undefined; + const operator = (match[1] ?? "=") as Comparator["operator"]; + const version = parseVersion(match[2], operator !== "="); + return version ? [{ operator, version }] : undefined; +} + +function satisfiesRange( + actualValue: string | undefined, + range: string | undefined +): boolean | undefined { + if (!actualValue || !range) return undefined; + if (/\|\||\s+-\s+|!=|\*|\bx\b/i.test(range) || /\d-/.test(range) || /\d-/.test(actualValue)) + return undefined; + const actual = parseVersion(actualValue.replace(/^v/, "")); + if (!actual) return undefined; + const tokens = range.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return undefined; + const comparators: Comparator[] = []; + for (const token of tokens) { + const expanded = expandToken(token); + if (!expanded) return undefined; + comparators.push(...expanded); + } + return comparators.every((comparator) => testComparator(actual, comparator)); +} + +export function validateCompatibility(input: CompatibilityInput): CompatibilityDiagnostic { + const reasons: string[] = []; + const nodeCompatible = satisfiesRange(input.nodeVersion, input.engines?.node); + const piCompatible = satisfiesRange(input.piVersion, input.requiredPi); + const node = + nodeCompatible === undefined ? "unknown" : nodeCompatible ? "compatible" : "incompatible"; + const pi = piCompatible === undefined ? "unknown" : piCompatible ? "compatible" : "incompatible"; + if (node === "incompatible") reasons.push(`requires Node ${input.engines?.node}`); + if (pi === "incompatible") reasons.push(`requires Pi ${input.requiredPi}`); + if (node === "unknown" && input.engines?.node) + reasons.push(`unsupported or ambiguous Node range ${input.engines.node}`); + if (pi === "unknown" && input.requiredPi) + reasons.push(`unsupported or ambiguous Pi range ${input.requiredPi}`); + return { packageName: input.packageName, node, pi, reasons }; +} + +export interface InstalledCompatibilityDiagnostic extends CompatibilityDiagnostic { + scope: "global" | "project"; + source: string; +} + +export async function inspectInstalledPackageCompatibility( + packages: InstalledPackage[], + options?: { nodeVersion?: string; piVersion?: string } +): Promise { + const diagnostics = await Promise.all( + packages.map(async (pkg) => { + let engines: { node?: string } | undefined; + let requiredPi: string | undefined; + if (pkg.resolvedPath) { + try { + const manifestPath = /(?:^|[\\/])package\.json$/i.test(pkg.resolvedPath) + ? pkg.resolvedPath + : join(pkg.resolvedPath, "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + const manifestEngines = manifest.engines; + if ( + manifestEngines && + typeof manifestEngines === "object" && + !Array.isArray(manifestEngines) + ) { + const node = (manifestEngines as Record).node; + const pi = (manifestEngines as Record).pi; + engines = typeof node === "string" ? { node } : undefined; + requiredPi = typeof pi === "string" ? pi : undefined; + } + } catch { + // Missing or malformed package metadata remains unknown. + } + } + const diagnostic = validateCompatibility({ + packageName: pkg.name, + ...(engines ? { engines } : {}), + ...(requiredPi ? { requiredPi } : {}), + ...(options?.piVersion ? { piVersion: options.piVersion } : {}), + nodeVersion: options?.nodeVersion ?? process.version, + }); + return { ...diagnostic, scope: pkg.scope, source: pkg.source }; + }) + ); + return diagnostics.sort((left, right) => left.source.localeCompare(right.source)); +} diff --git a/src/doctor/conflicts.ts b/src/doctor/conflicts.ts new file mode 100644 index 0000000..eabfd7c --- /dev/null +++ b/src/doctor/conflicts.ts @@ -0,0 +1,27 @@ +import { type RuntimeOwner } from "./runtime.js"; + +export interface RuntimeConflict { + kind: RuntimeOwner["kind"]; + name: string; + owners: RuntimeOwner[]; +} + +export function findRuntimeConflicts(owners: RuntimeOwner[]): RuntimeConflict[] { + const groups = new Map(); + for (const owner of owners) { + const key = `${owner.kind}\0${owner.name}`; + const group = groups.get(key) ?? []; + group.push(owner); + groups.set(key, group); + } + + return [...groups.values()] + .filter((group) => new Set(group.map((owner) => owner.source)).size > 1) + .flatMap((group) => { + const first = group[0]; + return first ? [{ kind: first.kind, name: first.name, owners: group }] : []; + }) + .sort((left, right) => + `${left.kind}:${left.name}`.localeCompare(`${right.kind}:${right.name}`) + ); +} diff --git a/src/doctor/runtime.ts b/src/doctor/runtime.ts new file mode 100644 index 0000000..fb27781 --- /dev/null +++ b/src/doctor/runtime.ts @@ -0,0 +1,35 @@ +import { type ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export interface RuntimeOwner { + kind: "command" | "tool"; + name: string; + description?: string; + source: string; + scope: "user" | "project" | "temporary"; + origin: "package" | "top-level"; + path: string; +} + +export function getRuntimeOwners(pi: ExtensionAPI): RuntimeOwner[] { + const commands = pi.getCommands().map((command) => ({ + kind: "command" as const, + name: command.name, + ...(command.description ? { description: command.description } : {}), + source: command.sourceInfo.source, + scope: command.sourceInfo.scope, + origin: command.sourceInfo.origin, + path: command.sourceInfo.path, + })); + const tools = pi.getAllTools().map((tool) => ({ + kind: "tool" as const, + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + source: tool.sourceInfo.source, + scope: tool.sourceInfo.scope, + origin: tool.sourceInfo.origin, + path: tool.sourceInfo.path, + })); + return [...commands, ...tools].sort((left, right) => + `${left.kind}:${left.name}`.localeCompare(`${right.kind}:${right.name}`) + ); +} diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index 2f5c8ed..d411f0a 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -6,9 +6,14 @@ */ import { type Dirent } from "node:fs"; -import { readdir, rename, rm } from "node:fs/promises"; -import { homedir } from "node:os"; +import { readdir, rename } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; +import { + CONFIG_DIR_NAME, + getGlobalExtensionsDir, + getProjectExtensionsDir, + getExtmgrTrashDir, +} from "../utils/pi-paths.js"; import { DISABLED_SUFFIX } from "../constants.js"; import { readPackageManifest } from "../packages/extensions.js"; import { type ExtensionEntry, type Scope, type State } from "../types/index.js"; @@ -17,6 +22,7 @@ import { normalizeRelativePath, resolveRelativePathSelection, } from "../utils/relative-path-selection.js"; +import { moveToExtensionTrash, type TrashRecord } from "./trash.js"; interface RootConfig { root: string; @@ -41,11 +47,15 @@ interface RootConfig { export async function discoverExtensions(cwd: string): Promise { const roots: RootConfig[] = [ { - root: join(homedir(), ".pi", "agent", "extensions"), + root: getGlobalExtensionsDir(), scope: "global", - label: "~/.pi/agent/extensions", + label: "global extensions", + }, + { + root: getProjectExtensionsDir(cwd), + scope: "project", + label: `${CONFIG_DIR_NAME}/extensions`, }, - { root: join(cwd, ".pi", "extensions"), scope: "project", label: ".pi/extensions" }, ]; const all: ExtensionEntry[] = []; @@ -345,11 +355,12 @@ export async function removeLocalExtension( entry: Pick, cwd: string ): Promise< - { ok: true; removedPath: string; removedDirectory: boolean } | { ok: false; error: string } + | { ok: true; removedPath: string; removedDirectory: boolean; trashRecord: TrashRecord } + | { ok: false; error: string } > { try { - const globalRoot = join(homedir(), ".pi", "agent", "extensions"); - const projectRoot = join(cwd, ".pi", "extensions"); + const globalRoot = getGlobalExtensionsDir(); + const projectRoot = getProjectExtensionsDir(cwd); const activeExists = await fileExists(entry.activePath); const disabledExists = await fileExists(entry.disabledPath); @@ -364,13 +375,14 @@ export async function removeLocalExtension( const isIndexFile = /^index\.(ts|js)$/i.test(normalizedBase); const isInsideExtensionDir = parentDir !== globalRoot && parentDir !== projectRoot; + const trashRoot = getExtmgrTrashDir(); if (isIndexFile && isInsideExtensionDir) { - await rm(parentDir, { recursive: true, force: true }); - return { ok: true, removedPath: parentDir, removedDirectory: true }; + const trashRecord = await moveToExtensionTrash(parentDir, trashRoot); + return { ok: true, removedPath: parentDir, removedDirectory: true, trashRecord }; } - await rm(existingPath, { force: true }); - return { ok: true, removedPath: existingPath, removedDirectory: false }; + const trashRecord = await moveToExtensionTrash(existingPath, trashRoot); + return { ok: true, removedPath: existingPath, removedDirectory: false, trashRecord }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } diff --git a/src/extensions/trash.ts b/src/extensions/trash.ts new file mode 100644 index 0000000..d680600 --- /dev/null +++ b/src/extensions/trash.ts @@ -0,0 +1,263 @@ +import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileExists } from "../utils/fs.js"; + +export interface TrashRecord { + originalPath: string; + trashPath: string; + trashedAt: number; +} + +interface TrashFile { + version: 1; + records: TrashRecord[]; +} + +class TrashMetadataError extends Error { + constructor( + readonly kind: "malformed" | "unsupported" | "invalid", + message: string + ) { + super(message); + } +} + +const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const writeQueues = new Map>(); + +function recordsPath(trashRoot: string): string { + return join(trashRoot, "records.json"); +} + +function isContainedPath(root: string, path: string): boolean { + const normalizedRoot = resolve(root); + const normalizedPath = resolve(path); + const child = relative(normalizedRoot, normalizedPath); + return child !== "" && child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child); +} + +function normalizeRecord(value: unknown, trashRoot: string): TrashRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + typeof record.originalPath !== "string" || + !record.originalPath || + !isAbsolute(record.originalPath) || + typeof record.trashPath !== "string" || + !isContainedPath(trashRoot, record.trashPath) || + typeof record.trashedAt !== "number" || + !Number.isFinite(record.trashedAt) + ) + return undefined; + return { + originalPath: record.originalPath, + trashPath: resolve(record.trashPath), + trashedAt: record.trashedAt, + }; +} + +async function readTrashFile(trashRoot: string): Promise { + let raw: string; + try { + raw = await readFile(recordsPath(trashRoot), "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return { version: 1, records: [] }; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + throw new TrashMetadataError( + "malformed", + `Trash metadata is malformed: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new TrashMetadataError("malformed", "Trash metadata must be an object."); + const value = parsed as Record; + if (value.version !== 1) + throw new TrashMetadataError( + "unsupported", + `Unsupported trash metadata version: ${String(value.version)}` + ); + if (!Array.isArray(value.records)) + throw new TrashMetadataError("malformed", "Trash metadata records must be an array."); + const records: TrashRecord[] = []; + for (const rawRecord of value.records) { + const record = normalizeRecord(rawRecord, trashRoot); + if (!record) + throw new TrashMetadataError( + "invalid", + "Trash metadata contains an invalid or unsafe record." + ); + records.push(record); + } + return { version: 1, records }; +} + +async function writeTrashFile(trashRoot: string, file: TrashFile): Promise { + await mkdir(trashRoot, { recursive: true }); + for (const record of file.records) { + if (!normalizeRecord(record, trashRoot)) + throw new Error("Refusing to persist an invalid or non-contained trash record."); + } + const path = recordsPath(trashRoot); + const temporary = join(trashRoot, `.${process.pid}.${Date.now()}.records.tmp`); + try { + await writeFile(temporary, `${JSON.stringify(file, null, 2)}\n`, "utf8"); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +async function backupCorruptMetadata(trashRoot: string): Promise { + const source = recordsPath(trashRoot); + const backup = join( + trashRoot, + `records.corrupt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json` + ); + try { + await rename(source, backup); + } catch (error) { + throw new Error( + `Trash metadata is corrupt and could not be backed up; no replacement was written: ${error instanceof Error ? error.message : String(error)}` + ); + } + return backup; +} + +async function readForUpdate(trashRoot: string): Promise { + try { + return await readTrashFile(trashRoot); + } catch (error) { + if (!(error instanceof TrashMetadataError)) throw error; + await backupCorruptMetadata(trashRoot); + // Existing orphaned payloads remain untouched. Their original paths cannot + // be reconstructed safely from legacy filenames, so no identity is invented. + return { version: 1, records: [] }; + } +} + +async function updateTrashFile( + trashRoot: string, + update: (file: TrashFile) => TrashFile | Promise +): Promise { + const previous = writeQueues.get(trashRoot) ?? Promise.resolve(); + const next = previous.then(async () => + writeTrashFile(trashRoot, await update(await readForUpdate(trashRoot))) + ); + writeQueues.set( + trashRoot, + next.catch(() => undefined) + ); + await next; +} + +export async function moveToExtensionTrash(path: string, trashRoot: string): Promise { + await mkdir(trashRoot, { recursive: true }); + let trashPath = ""; + for (let attempt = 0; attempt < 10; attempt += 1) { + trashPath = join( + trashRoot, + `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}-${basename(path)}` + ); + if (!(await fileExists(trashPath))) break; + } + if (!trashPath || (await fileExists(trashPath))) + throw new Error("Unable to allocate a unique trash destination."); + + await rename(path, trashPath); + const record: TrashRecord = { + originalPath: resolve(path), + trashPath: resolve(trashPath), + trashedAt: Date.now(), + }; + try { + await updateTrashFile(trashRoot, (file) => ({ + version: 1, + records: [...file.records.filter((item) => item.trashPath !== record.trashPath), record], + })); + } catch (error) { + try { + if (!(await fileExists(path))) await rename(trashPath, path); + } catch (rollbackError) { + throw new Error( + `Trash record could not be saved and the extension could not be restored: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` + ); + } + throw error; + } + return record; +} + +export async function listExtensionTrash( + trashRoot: string, + options?: { now?: number; retentionMs?: number } +): Promise { + const now = options?.now ?? Date.now(); + const retentionMs = options?.retentionMs ?? TRASH_RETENTION_MS; + const file = await readTrashFile(trashRoot); + const kept: TrashRecord[] = []; + for (const record of file.records) { + const expired = now - record.trashedAt >= retentionMs; + if (expired || !(await fileExists(record.trashPath))) { + if (expired) await rm(record.trashPath, { recursive: true, force: true }); + continue; + } + kept.push(record); + } + if (kept.length !== file.records.length) + await updateTrashFile(trashRoot, () => ({ version: 1, records: kept })); + return kept; +} + +export async function listExtensionTrashOrphans(trashRoot: string): Promise { + try { + const records = new Set( + (await readTrashFile(trashRoot)).records.map((record) => resolve(record.trashPath)) + ); + const entries = await readdir(trashRoot); + return entries + .filter( + (name) => + name !== "records.json" && !name.startsWith("records.corrupt-") && !name.startsWith(".") + ) + .map((name) => resolve(trashRoot, name)) + .filter((path) => !records.has(path)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; + } +} + +export async function undoExtensionTrash(record: TrashRecord): Promise { + const trashRoot = dirname(record.trashPath); + if (!normalizeRecord(record, trashRoot)) + throw new Error("Cannot undo removal: the trash record is unsafe or malformed."); + if (await fileExists(record.originalPath)) + throw new Error(`Cannot undo removal: ${record.originalPath} already exists.`); + if (!(await fileExists(record.trashPath))) + throw new Error("Cannot undo removal: the trash entry is missing or expired."); + await mkdir(dirname(record.originalPath), { recursive: true }); + await rename(record.trashPath, record.originalPath); + await removeTrashRecord(record); +} + +export async function purgeExtensionTrash(record: TrashRecord): Promise { + const trashRoot = dirname(record.trashPath); + if (!normalizeRecord(record, trashRoot)) + throw new Error("Cannot purge an unsafe or malformed trash record."); + await rm(record.trashPath, { recursive: true, force: true }); + await removeTrashRecord(record); +} + +async function removeTrashRecord(record: TrashRecord): Promise { + const trashRoot = dirname(record.trashPath); + await updateTrashFile(trashRoot, (file) => ({ + version: 1, + records: file.records.filter((item) => item.trashPath !== record.trashPath), + })); +} diff --git a/src/index.ts b/src/index.ts index 26f195a..54cd2b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { showNonInteractiveHelp, showUnknownCommandMessage, } from "./commands/registry.js"; +import { refreshLocalCompletionIndex } from "./commands/completion.js"; import { installPackage } from "./packages/install.js"; import { type ContextProvider, @@ -24,8 +25,10 @@ import { } from "./utils/auto-update.js"; import { tokenizeArgs } from "./utils/command.js"; import { isPackageSource } from "./utils/format.js"; +import { clearReloadRequired } from "./utils/reload-state.js"; import { getAutoUpdateConfig, hydrateAutoUpdateConfig } from "./utils/settings.js"; import { updateExtmgrStatus } from "./utils/status.js"; +import { wasContextReloaded } from "./utils/ui-helpers.js"; async function executeExtensionsCommand( args: string, @@ -59,6 +62,13 @@ export default function extensionsManager(pi: ExtensionAPI) { getArgumentCompletions: getExtensionsAutocompleteItems, handler: async (args, ctx) => { await executeExtensionsCommand(args, ctx, pi); + if (wasContextReloaded(ctx)) return; + await refreshLocalCompletionIndex( + ctx.cwd, + typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() + ).catch((error) => { + console.warn("[extmgr] Failed to refresh local completions:", error); + }); }, }); @@ -69,6 +79,12 @@ export default function extensionsManager(pi: ExtensionAPI) { async function bootstrapSession(ctx: ExtensionCommandContext | ExtensionContext): Promise { // Restore persisted auto-update config into session entries so sync lookups are valid. await hydrateAutoUpdateConfig(pi, ctx); + await refreshLocalCompletionIndex( + ctx.cwd, + typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() + ).catch((error) => { + console.warn("[extmgr] Failed to load local completions:", error); + }); if (!ctx.hasUI) { stopAutoUpdateTimer(); @@ -90,7 +106,10 @@ export default function extensionsManager(pi: ExtensionAPI) { }); } - pi.on("session_start", async (_event, ctx) => { + pi.on("session_start", async (event, ctx) => { + if (event.reason === "reload") { + await clearReloadRequired(); + } await bootstrapSession(ctx); }); diff --git a/src/packages/badges.ts b/src/packages/badges.ts new file mode 100644 index 0000000..8176426 --- /dev/null +++ b/src/packages/badges.ts @@ -0,0 +1,20 @@ +import { type NpmPackage } from "../types/index.js"; + +export interface PackageBadges { + installed: boolean; + updateAvailable: boolean; + compatibility: "compatible" | "incompatible" | "unknown"; +} + +export function getRemotePackageBadges( + pkg: NpmPackage, + installedNames: Set, + updates: Set +): PackageBadges { + return { + installed: installedNames.has(pkg.name), + updateAvailable: updates.has(pkg.name), + // Registry search metadata does not provide trustworthy Pi/Node requirements. + compatibility: "unknown", + }; +} diff --git a/src/packages/catalog.ts b/src/packages/catalog.ts index b65f07d..b608244 100644 --- a/src/packages/catalog.ts +++ b/src/packages/catalog.ts @@ -1,12 +1,17 @@ import { DefaultPackageManager, getAgentDir, - type PackageSource, type ProgressEvent, SettingsManager, } from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type Scope } from "../types/index.js"; -import { normalizePackageIdentity, parsePackageNameAndVersion } from "../utils/package-source.js"; +import { + normalizePackageIdentity, + packageSourceString, + parsePackageNameAndVersion, +} from "../utils/package-source.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; type PiScope = "user" | "project"; type PiPackageUpdate = Awaited< @@ -28,7 +33,7 @@ export interface PackageCatalog { update(source?: string, onProgress?: (event: ProgressEvent) => void): Promise; } -type PackageCatalogFactory = (cwd: string) => PackageCatalog; +type PackageCatalogFactory = (cwd: string, projectTrusted?: boolean) => PackageCatalog; let packageCatalogFactory: PackageCatalogFactory = createDefaultPackageCatalog; @@ -36,10 +41,6 @@ function toScope(scope: PiScope): Scope { return scope === "project" ? "project" : "global"; } -function getPackageSource(pkg: PackageSource): string { - return typeof pkg === "string" ? pkg : pkg.source; -} - function createPackageRecord( source: string, scope: PiScope, @@ -61,7 +62,7 @@ function dedupeInstalledPackages(packages: InstalledPackage[], cwd: string): Ins const byIdentity = new Map(); for (const pkg of packages) { - const baseCwd = pkg.scope === "project" ? cwd : getAgentDir(); + const baseCwd = pkg.scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(); const identity = normalizePackageIdentity(pkg.source, { ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), cwd: baseCwd, @@ -82,18 +83,18 @@ function setProgressCallback( packageManager.setProgressCallback(onProgress); } -function createDefaultPackageCatalog(cwd: string): PackageCatalog { +function createDefaultPackageCatalog(cwd: string, projectTrusted = false): PackageCatalog { const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); return { listInstalledPackages(options) { const projectPackages = (settingsManager.getProjectSettings().packages ?? []).map((pkg) => - createPackageRecord(getPackageSource(pkg), "project", packageManager) + createPackageRecord(packageSourceString(pkg), "project", packageManager) ); const globalPackages = (settingsManager.getGlobalSettings().packages ?? []).map((pkg) => - createPackageRecord(getPackageSource(pkg), "user", packageManager) + createPackageRecord(packageSourceString(pkg), "user", packageManager) ); const installed = [...projectPackages, ...globalPackages]; @@ -116,9 +117,11 @@ function createDefaultPackageCatalog(cwd: string): PackageCatalog { setProgressCallback(packageManager, onProgress); try { + throwIfSettingsErrors(settingsManager, "Package installation"); await packageManager.install(source, { local: scope === "project" }); packageManager.addSourceToSettings(source, { local: scope === "project" }); await settingsManager.flush(); + throwIfSettingsErrors(settingsManager, "Package installation"); } finally { setProgressCallback(packageManager, undefined); } @@ -128,15 +131,17 @@ function createDefaultPackageCatalog(cwd: string): PackageCatalog { setProgressCallback(packageManager, onProgress); try { + throwIfSettingsErrors(settingsManager, "Package removal"); await packageManager.remove(source, { local: scope === "project" }); - const removed = packageManager.removeSourceFromSettings(source, { + // Settings may already have been persisted by a reviewed profile + // application. The package manager removal remains authoritative; a + // missing settings entry is not a reason to report a false mutation + // failure or undo a completed physical removal. + packageManager.removeSourceFromSettings(source, { local: scope === "project", }); await settingsManager.flush(); - - if (!removed) { - throw new Error(`No matching package found for ${source}`); - } + throwIfSettingsErrors(settingsManager, "Package removal"); } finally { setProgressCallback(packageManager, undefined); } @@ -154,8 +159,8 @@ function createDefaultPackageCatalog(cwd: string): PackageCatalog { }; } -export function getPackageCatalog(cwd: string): PackageCatalog { - return packageCatalogFactory(cwd); +export function getPackageCatalog(cwd: string, projectTrusted = false): PackageCatalog { + return packageCatalogFactory(cwd, projectTrusted); } export function setPackageCatalogFactory(factory?: PackageCatalogFactory): void { diff --git a/src/packages/discovery.ts b/src/packages/discovery.ts index c17eea8..334720a 100644 --- a/src/packages/discovery.ts +++ b/src/packages/discovery.ts @@ -9,17 +9,31 @@ import { type ExtensionContext, getAgentDir, } from "@earendil-works/pi-coding-agent"; -import { CACHE_TTL, TIMEOUTS } from "../constants.js"; +import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS } from "../constants.js"; +import { createAbortError, throwIfAborted } from "../utils/abort.js"; import { type InstalledPackage, type NpmPackage, type SearchCache } from "../types/index.js"; +import { + getCachedPackage, + getCachedPackageSize, + getCachedSearch, + getPackageDescriptions, + setCachedPackage, + setCachedPackageSize, + setCachedSearch, +} from "../utils/cache.js"; import { parseNpmSource } from "../utils/format.js"; import { readSummary } from "../utils/fs.js"; +import { isProjectTrusted } from "../utils/mode.js"; import { fetchWithTimeout } from "../utils/network.js"; import { execNpm } from "../utils/npm-exec.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; import { getPackageCatalog } from "./catalog.js"; const NPM_SEARCH_API = "https://registry.npmjs.org/-/v1/search"; -const NPM_SEARCH_PAGE_SIZE = 250; +const NPM_SEARCH_MAX_PAGE_SIZE = 250; +const NPM_SEARCH_MAX_RETRIES = 2; +const NPM_SEARCH_RETRY_DELAY_MS = 1_000; interface NpmSearchResultObject { package?: { @@ -44,48 +58,56 @@ interface NpmSearchResponse { objects?: NpmSearchResultObject[]; } -let searchCache: SearchCache | null = null; - -function createAbortError(): Error { - const error = new Error("Operation cancelled"); - error.name = "AbortError"; - return error; +interface NpmDownloadsPoint { + downloads?: number; } -function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw createAbortError(); - } +const searchCacheByPage = new Map(); +let latestSearchCacheKey: string | undefined; + +function getSearchCacheKey(query: string, offset: number): string { + return `${offset}\0${query}`; } -export function getSearchCache(): SearchCache | null { - return searchCache; +export function getSearchCache(query?: string, offset = 0): SearchCache | null { + const key = query ? getSearchCacheKey(query, offset) : latestSearchCacheKey; + return key ? (searchCacheByPage.get(key) ?? null) : null; } -export function setSearchCache(cache: SearchCache | null): void { - searchCache = cache; +export function setSearchCache(cache: SearchCache): void { + const key = getSearchCacheKey(cache.query, cache.offset); + searchCacheByPage.set(key, cache); + latestSearchCacheKey = key; } -export function clearSearchCache(): void { - searchCache = null; +export function clearSearchCache(query?: string): void { + if (!query) { + searchCacheByPage.clear(); + latestSearchCacheKey = undefined; + return; + } + + for (const [key, cache] of searchCacheByPage) { + if (cache.query === query) { + searchCacheByPage.delete(key); + } + } + + if (latestSearchCacheKey && !searchCacheByPage.has(latestSearchCacheKey)) { + latestSearchCacheKey = undefined; + } } -export function isCacheValid(query: string): boolean { - if (!searchCache) return false; - if (searchCache.query !== query) return false; - return Date.now() - searchCache.timestamp < CACHE_TTL; +export function isCacheValid(query: string, offset = 0): boolean { + const cache = getSearchCache(query, offset); + return cache ? Date.now() - cache.timestamp < CACHE_LIMITS.searchTTL : false; } -// Import persistent cache -import { - getCachedPackage, - getCachedPackageSize, - getCachedSearch, - getPackageDescriptions, - setCachedPackage, - setCachedPackageSize, - setCachedSearch, -} from "../utils/cache.js"; +export async function hydrateSearchCache(query: string, offset = 0): Promise { + const cached = await getCachedSearch(query, offset); + if (cached) setSearchCache(cached); + return cached; +} function getNpmPackageAuthor( pkg: NonNullable @@ -95,15 +117,15 @@ function getNpmPackageAuthor( 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(); } + if (publisher?.email?.trim()) { + return publisher.email.trim(); + } + const maintainerWithEmail = pkg.maintainers?.find((entry) => entry.email?.trim()); if (maintainerWithEmail?.email?.trim()) { return maintainerWithEmail.email.trim(); @@ -129,93 +151,219 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined { }; } -async function fetchNpmSearchPage( +function getRetryDelayMs(response: Response, retryNumber: number): number { + const retryAfter = response.headers.get("retry-after")?.trim(); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(seconds * 1_000, TIMEOUTS.npmSearch); + } + + const retryAt = Date.parse(retryAfter); + if (Number.isFinite(retryAt)) { + return Math.min(Math.max(0, retryAt - Date.now()), TIMEOUTS.npmSearch); + } + } + + return NPM_SEARCH_RETRY_DELAY_MS * 2 ** (retryNumber - 1); +} + +function waitForRetry(delayMs: number, signal?: AbortSignal): Promise { + throwIfAborted(signal); + if (delayMs === 0) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer); + reject(createAbortError()); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function fetchNpmRegistrySearchPage( query: string, - from: number, - signal?: AbortSignal -): Promise<{ - total: number; - resultCount: number; - packages: NpmPackage[]; -}> { + from = 0, + signal?: AbortSignal, + size = PAGE_SIZE +): Promise { + const pageSize = Number.isFinite(size) + ? Math.max(1, Math.min(Math.floor(size), NPM_SEARCH_MAX_PAGE_SIZE)) + : PAGE_SIZE; + const offset = Number.isFinite(from) ? Math.max(0, Math.floor(from)) : 0; const params = new URLSearchParams({ text: query, - size: String(NPM_SEARCH_PAGE_SIZE), - from: String(from), + size: String(pageSize), + from: String(offset), }); - const response = await fetchWithTimeout( - `${NPM_SEARCH_API}?${params.toString()}`, - TIMEOUTS.npmSearch, - signal - ); - - if (!response.ok) { - throw new Error(`npm registry search failed: HTTP ${response.status}`); + const url = `${NPM_SEARCH_API}?${params.toString()}`; + + let response: Response | undefined; + for (let attempt = 0; attempt <= NPM_SEARCH_MAX_RETRIES; attempt += 1) { + response = await fetchWithTimeout(url, TIMEOUTS.npmSearch, signal); + if (response.status !== 429 || attempt === NPM_SEARCH_MAX_RETRIES) { + break; + } + + await response.body?.cancel(); + await waitForRetry(getRetryDelayMs(response, attempt + 1), signal); + } + + if (!response?.ok) { + if (response?.status === 429) { + throw new Error("npm registry search is rate-limited (HTTP 429). Try again shortly."); + } + throw new Error(`npm registry search failed: HTTP ${response?.status ?? "unknown"}`); } const data = (await response.json()) as NpmSearchResponse; const objects = data.objects ?? []; - const packages = objects.map(toNpmPackage).filter((pkg): pkg is NpmPackage => !!pkg); + const results = objects.map(toNpmPackage).filter((pkg): pkg is NpmPackage => !!pkg); + const total = + typeof data.total === "number" && Number.isFinite(data.total) && data.total >= 0 + ? data.total + : offset + results.length; return { - total: - typeof data.total === "number" && Number.isFinite(data.total) ? data.total : packages.length, - resultCount: objects.length, - packages, + query, + results, + total, + offset, + timestamp: Date.now(), }; } -export async function fetchNpmRegistrySearchResults( - query: string, - signal?: AbortSignal -): Promise { - const packagesByName = new Map(); - let from = 0; - let total = Infinity; - - while (from < total) { - const page = await fetchNpmSearchPage(query, from, signal); - total = page.total; +function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { + if (signal?.aborted && error instanceof Error && error.name === "AbortError") throw error; +} - if (page.resultCount === 0) { - break; - } +async function fetchWeeklyDownloadsPoint( + name: string, + downloads: Map, + signal?: AbortSignal +): Promise { + try { + const response = await fetchWithTimeout( + `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`, + TIMEOUTS.weeklyDownloads, + signal + ); + if (!response.ok) return; + const payload = (await response.json()) as NpmDownloadsPoint; + if (typeof payload.downloads === "number") downloads.set(name, payload.downloads); + } catch (error) { + rethrowIfAborted(error, signal); + } +} - for (const pkg of page.packages) { - if (!packagesByName.has(pkg.name)) { - packagesByName.set(pkg.name, pkg); +async function fetchWeeklyDownloadsBulk( + names: readonly string[], + downloads: Map, + signal?: AbortSignal +): Promise { + try { + const encodedNames = names.map((name) => encodeURIComponent(name)).join(","); + const response = await fetchWithTimeout( + `https://api.npmjs.org/downloads/point/last-week/${encodedNames}`, + TIMEOUTS.weeklyDownloads, + signal + ); + if (!response.ok) return; + + const payload = (await response.json()) as Record< + string, + NpmDownloadsPoint | number | string | undefined + >; + for (const name of names) { + const point = payload[name]; + if (point && typeof point === "object" && typeof point.downloads === "number") { + downloads.set(name, point.downloads); } } + } catch (error) { + rethrowIfAborted(error, signal); + } +} - from += page.resultCount; +export async function fetchNpmWeeklyDownloads( + packageNames: readonly string[], + signal?: AbortSignal +): Promise> { + const names = [...new Set(packageNames.map((name) => name.trim()).filter(Boolean))]; + if (names.length === 0) return new Map(); + + // npm's bulk downloads endpoint rejects scoped packages; they need point lookups. + const scoped = names.filter((name) => name.startsWith("@")); + const unscoped = names.filter((name) => !name.startsWith("@")); + const downloads = new Map(); + const tasks: Promise[] = []; + + if (unscoped.length === 1) { + tasks.push(fetchWeeklyDownloadsPoint(unscoped[0] as string, downloads, signal)); + } else if (unscoped.length > 1) { + tasks.push(fetchWeeklyDownloadsBulk(unscoped, downloads, signal)); + } + for (const name of scoped) { + tasks.push(fetchWeeklyDownloadsPoint(name, downloads, signal)); } - return [...packagesByName.values()]; + await Promise.all(tasks); + return downloads; +} + +export async function addWeeklyDownloadsToSearchPage( + page: SearchCache, + signal?: AbortSignal +): Promise { + // Skip packages whose metrics are already known to avoid repeated fetches. + const missing = page.results + .filter((pkg) => pkg.weeklyDownloads === undefined) + .map((pkg) => pkg.name); + if (missing.length === 0) return page; + + const downloads = await fetchNpmWeeklyDownloads(missing, signal); + if (downloads.size === 0) return page; + + for (const pkg of page.results) { + const weeklyDownloads = downloads.get(pkg.name); + if (weeklyDownloads !== undefined) pkg.weeklyDownloads = weeklyDownloads; + } + setSearchCache(page); + await setCachedSearch(page); + return page; } export async function searchNpmPackages( query: string, ctx: ExtensionCommandContext, - options?: { signal?: AbortSignal } -): Promise { - const cached = await getCachedSearch(query); - if (cached) { - if (ctx.hasUI) { - ctx.ui.notify(`Using ${cached.length} cached results`, "info"); + options?: { signal?: AbortSignal; offset?: number; size?: number; forceRefresh?: boolean } +): Promise { + const offset = options?.offset ?? 0; + + if (!options?.forceRefresh) { + const runtimeCached = getSearchCache(query, offset); + if (runtimeCached && isCacheValid(query, offset)) { + return runtimeCached; } - return cached; - } - if (ctx.hasUI) { - ctx.ui.notify(`Searching npm for "${query}"...`, "info"); + const persisted = await hydrateSearchCache(query, offset); + if (persisted) { + if (ctx.hasUI) { + ctx.ui.notify(`Using ${persisted.results.length} cached results`, "info"); + } + return persisted; + } } - const packages = await fetchNpmRegistrySearchResults(query, options?.signal); - - // Cache the results - await setCachedSearch(query, packages); - - return packages; + const page = await fetchNpmRegistrySearchPage(query, offset, options?.signal, options?.size); + setSearchCache(page); + await setCachedSearch(page); + return page; } export async function getInstalledPackages( @@ -226,7 +374,7 @@ export async function getInstalledPackages( ): Promise { throwIfAborted(signal); - const packages = await getPackageCatalog(ctx.cwd).listInstalledPackages(); + const packages = await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).listInstalledPackages(); if (packages.length === 0) { return []; } @@ -237,7 +385,12 @@ export async function getInstalledPackages( } function getInstalledPackageIdentity(pkg: InstalledPackage, options?: { cwd?: string }): string { - const baseCwd = pkg.scope === "project" ? options?.cwd : getAgentDir(); + const baseCwd = + pkg.scope === "project" + ? options?.cwd + ? getProjectConfigDir(options.cwd) + : undefined + : getAgentDir(); return normalizePackageIdentity(pkg.source, { ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), @@ -250,21 +403,24 @@ export async function isSourceInstalled( ctx: ExtensionCommandContext | ExtensionContext, options?: { scope?: "global" | "project" } ): Promise { - const installed = await getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false }); - const expected = normalizePackageIdentity(source, { cwd: ctx.cwd }); - + const installed = await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).listInstalledPackages({ + dedupe: false, + }); return installed.some((pkg) => { - if (getInstalledPackageIdentity(pkg, { cwd: ctx.cwd }) !== expected) { - return false; - } - return options?.scope ? pkg.scope === options.scope : true; + if (options?.scope && pkg.scope !== options.scope) return false; + const baseCwds = + pkg.scope === "project" ? [ctx.cwd, getProjectConfigDir(ctx.cwd)] : [getAgentDir(), ctx.cwd]; + const actual = getInstalledPackageIdentity(pkg, { cwd: ctx.cwd }); + return baseCwds.some( + (baseCwd) => normalizePackageIdentity(source, { cwd: baseCwd }) === actual + ); }); } export async function getInstalledPackagesAllScopes( ctx: ExtensionCommandContext | ExtensionContext ): Promise { - return getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false }); + return getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).listInstalledPackages({ dedupe: false }); } async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise { diff --git a/src/packages/extension-resolution.ts b/src/packages/extension-resolution.ts new file mode 100644 index 0000000..76ee32c --- /dev/null +++ b/src/packages/extension-resolution.ts @@ -0,0 +1,36 @@ +import { + DefaultPackageManager, + getAgentDir, + type ResolvedResource, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { normalizeConfiguredPackageSource } from "../utils/package-source.js"; + +/** Resolve configured package extensions through Pi without installing missing sources. */ +export async function resolveConfiguredPackageExtensions( + cwd: string, + projectTrusted: boolean +): Promise { + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + try { + return (await packageManager.resolve(async () => "skip")).extensions; + } catch { + return []; + } +} + +export function resourcesForPackage( + resources: ResolvedResource[], + source: string, + scope: "global" | "project" +): ResolvedResource[] { + const expectedScope = scope === "project" ? "project" : "user"; + const expectedSource = normalizeConfiguredPackageSource(source); + return resources.filter( + (resource) => + resource.metadata.scope === expectedScope && + normalizeConfiguredPackageSource(resource.metadata.source) === expectedSource + ); +} diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index 1622d3e..714a334 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -1,11 +1,11 @@ import { execFile } from "node:child_process"; import { type Dirent } from "node:fs"; -import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type PackageExtensionEntry, @@ -14,20 +14,21 @@ import { } from "../types/index.js"; import { parseNpmSource } from "../utils/format.js"; import { fileExists, readSummary } from "../utils/fs.js"; +import { resolveConfiguredNpmRootCommand } from "../utils/npm-exec.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; import { matchesFilterPattern, normalizeRelativePath, resolveRelativePathSelection, } from "../utils/relative-path-selection.js"; -import { resolveConfiguredNpmRootCommand } from "../utils/npm-exec.js"; +import { normalizeConfiguredPackageSource } from "../utils/package-source.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; +import { resolveConfiguredPackageExtensions, resourcesForPackage } from "./extension-resolution.js"; interface PackageSettingsObject { source: string; extensions?: string[]; -} - -interface SettingsFile { - packages?: (string | PackageSettingsObject)[]; + [key: string]: unknown; } export interface PackageManifest { @@ -40,13 +41,7 @@ export interface PackageManifest { const execFileAsync = promisify(execFile); let globalNpmRootCache: { key: string; root: string | null } | undefined; - -function normalizeSource(source: string): string { - return source - .trim() - .replace(/\s+\((filtered|pinned)\)$/i, "") - .trim(); -} +const packageEntrypointCache = new Map>(); function normalizePackageRootCandidate(candidate: string): string { const resolved = resolve(candidate); @@ -97,11 +92,11 @@ async function resolveNpmPackageRoot( const packageName = parsed.name; const projectCandidates = [ - join(cwd, ".pi", "npm", "node_modules", packageName), + join(getProjectConfigDir(cwd), "npm", "node_modules", packageName), join(cwd, "node_modules", packageName), ]; - const packageDir = process.env.PI_PACKAGE_DIR || join(homedir(), ".pi", "agent"); + const packageDir = process.env.PI_PACKAGE_DIR || getAgentDir(); const globalCandidates = [join(packageDir, "npm", "node_modules", packageName)]; const npmGlobalRoot = await getGlobalNpmRoot(cwd); @@ -152,7 +147,8 @@ async function toPackageRoot(pkg: InstalledPackage, cwd: string): Promise { - try { - const raw = await readFile(path, "utf8"); - if (!raw.trim()) { - return {}; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw) as unknown; - } catch (error) { - if (options?.strict) { - throw new Error( - `Invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}` - ); - } - return {}; - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - if (options?.strict) { - throw new Error(`Invalid settings format in ${path}: expected a JSON object`); - } - return {}; - } - - return parsed as SettingsFile; - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return {}; - } - - if (options?.strict) { - throw error; - } - - return {}; - } -} - -async function writeSettingsFile(path: string, settings: SettingsFile): Promise { - const settingsDir = dirname(path); - await mkdir(settingsDir, { recursive: true }); - - const content = `${JSON.stringify(settings, null, 2)}\n`; - const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; - - try { - await writeFile(tmpPath, content, "utf8"); - await rename(tmpPath, path); - } catch { - await writeFile(path, content, "utf8"); - } finally { - await rm(tmpPath, { force: true }).catch(() => undefined); - } +function getScopedPackages( + settings: SettingsManager, + scope: Scope +): (string | PackageSettingsObject)[] { + const packages = + scope === "project" ? settings.getProjectSettings().packages : settings.getPackages(); + return packages ? [...packages] : []; } function findPackageSettingsIndex( - packages: SettingsFile["packages"] extends infer T ? NonNullable : never, + packages: (string | PackageSettingsObject)[], normalizedSource: string ): number { return packages.findIndex((pkg) => { if (typeof pkg === "string") { - return normalizeSource(pkg) === normalizedSource; + return normalizeConfiguredPackageSource(pkg) === normalizedSource; } - return normalizeSource(pkg.source) === normalizedSource; + return normalizeConfiguredPackageSource(pkg.source) === normalizedSource; }); } @@ -251,6 +193,7 @@ function toPackageSettingsObject( if (existing && typeof existing.source === "string") { return { + ...structuredClone(existing), source: existing.source, ...(Array.isArray(existing.extensions) ? { extensions: [...existing.extensions] } : {}), }; @@ -301,10 +244,13 @@ function updateExtensionMarkers( export async function validatePackageExtensionSettings( scope: Scope, - cwd: string + cwd: string, + projectTrusted = false ): Promise<{ ok: true } | { ok: false; error: string }> { try { - await readSettingsFile(getSettingsPath(scope, cwd), { strict: true }); + const settings = createSettingsManager(cwd, projectTrusted); + throwIfSettingsErrors(settings, "Package extension configuration"); + getScopedPackages(settings, scope); return { ok: true }; } catch (error) { return { @@ -318,17 +264,18 @@ export async function applyPackageExtensionStateChanges( packageSource: string, scope: Scope, changes: readonly { extensionPath: string; target: State }[], - cwd: string + cwd: string, + projectTrusted = false ): Promise<{ ok: true } | { ok: false; error: string }> { try { if (changes.length === 0) { return { ok: true }; } - const settingsPath = getSettingsPath(scope, cwd); - const settings = await readSettingsFile(settingsPath, { strict: true }); - const normalizedSource = normalizeSource(packageSource); - const packages = [...(settings.packages ?? [])]; + const settings = createSettingsManager(cwd, projectTrusted); + throwIfSettingsErrors(settings, "Package extension configuration"); + const normalizedSource = normalizeConfiguredPackageSource(packageSource); + const packages = getScopedPackages(settings, scope); const index = findPackageSettingsIndex(packages, normalizedSource); const packageEntry = toPackageSettingsObject(packages[index], packageSource); @@ -339,8 +286,11 @@ export async function applyPackageExtensionStateChanges( packageEntry.extensions = updateExtensionMarkers(packageEntry.extensions, normalizedChanges); + if (packageEntry.extensions.length === 0) { + delete packageEntry.extensions; + } const normalizedPackageEntry = - packageEntry.extensions.length > 0 ? packageEntry : packageEntry.source; + Object.keys(packageEntry).length > 1 ? packageEntry : packageEntry.source; if (index === -1) { packages.push(normalizedPackageEntry); @@ -348,8 +298,13 @@ export async function applyPackageExtensionStateChanges( packages[index] = normalizedPackageEntry; } - settings.packages = packages; - await writeSettingsFile(settingsPath, settings); + if (scope === "project") { + settings.setProjectPackages(packages); + } else { + settings.setPackages(packages); + } + await settings.flush(); + throwIfSettingsErrors(settings, "Package extension configuration"); return { ok: true }; } catch (error) { @@ -421,15 +376,16 @@ function getPackageFilterState(filters: string[] | undefined, extensionPath: str async function readPackageFilterMap( scope: Scope, - cwd: string + cwd: string, + projectTrusted: boolean ): Promise> { - const settings = await readSettingsFile(getSettingsPath(scope, cwd)); - const packages = settings.packages ?? []; + const settings = createSettingsManager(cwd, projectTrusted); + const packages = getScopedPackages(settings, scope); const filterMap = new Map(); for (const entry of packages) { if (typeof entry === "string") { - filterMap.set(normalizeSource(entry), undefined); + filterMap.set(normalizeConfiguredPackageSource(entry), undefined); continue; } @@ -438,7 +394,7 @@ async function readPackageFilterMap( } filterMap.set( - normalizeSource(entry.source), + normalizeConfiguredPackageSource(entry.source), Array.isArray(entry.extensions) ? entry.extensions : undefined ); } @@ -467,6 +423,7 @@ async function collectExtensionFilesFromDir( const absolutePath = join(startDir, entry.name); if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") continue; collected.push(...(await collectExtensionFilesFromDir(packageRoot, absolutePath))); continue; } @@ -532,7 +489,7 @@ async function resolveConventionExtensionEntrypoints(packageRoot: string): Promi return collectExtensionFilesFromDir(packageRoot, extensionsDir); } -export async function discoverPackageExtensionEntrypoints( +async function discoverPackageExtensionEntrypointsUncached( packageRoot: string, options?: { allowConventionDirectory?: boolean; @@ -569,23 +526,85 @@ export async function discoverPackageExtensionEntrypoints( return []; } +function getEntrypointCacheKey( + packageRoot: string, + options?: { allowConventionDirectory?: boolean; allowRootIndexFallback?: boolean } +): string { + return `${resolve(packageRoot)}\0${options?.allowConventionDirectory !== false}\0${options?.allowRootIndexFallback !== false}`; +} + +/** Clear the in-memory entrypoint cache, useful after package installation or removal. */ +export function clearPackageEntrypointCache(): void { + packageEntrypointCache.clear(); +} + +export function discoverPackageExtensionEntrypoints( + packageRoot: string, + options?: { + allowConventionDirectory?: boolean; + allowRootIndexFallback?: boolean; + } +): Promise { + const key = getEntrypointCacheKey(packageRoot, options); + const cached = packageEntrypointCache.get(key); + if (cached) return cached; + + const result = discoverPackageExtensionEntrypointsUncached(packageRoot, options); + packageEntrypointCache.set(key, result); + result.catch(() => packageEntrypointCache.delete(key)); + return result; +} + export async function discoverPackageExtensions( packages: InstalledPackage[], - cwd: string + cwd: string, + options?: { projectTrusted?: boolean } ): Promise { const entries: PackageExtensionEntry[] = []; + const projectTrusted = options?.projectTrusted ?? false; const [globalFilterMap, projectFilterMap] = await Promise.all([ - readPackageFilterMap("global", cwd), - readPackageFilterMap("project", cwd), + readPackageFilterMap("global", cwd, projectTrusted), + readPackageFilterMap("project", cwd, projectTrusted), ]); + const canonicalResources = await resolveConfiguredPackageExtensions(cwd, projectTrusted); + for (const pkg of packages) { + const resolvedResources = resourcesForPackage(canonicalResources, pkg.source, pkg.scope); + + if (resolvedResources.length > 0) { + for (const resource of resolvedResources) { + const packageRoot = resource.metadata.baseDir; + const extensionPath = packageRoot + ? normalizeRelativePath(relative(packageRoot, resource.path)) + : normalizeRelativePath(resource.path); + entries.push({ + id: `pkg-ext:${pkg.scope}:${pkg.source}:${extensionPath}`, + packageSource: pkg.source, + packageName: pkg.name, + packageScope: pkg.scope, + extensionPath, + absolutePath: resource.path, + displayName: `${pkg.name}/${extensionPath}`, + summary: (await fileExists(resource.path)) + ? await readSummary(resource.path) + : "package extension", + state: resource.enabled ? "enabled" : "disabled", + }); + } + continue; + } + + // Compatibility gap: Pi's resolver only returns actively configured resources. + // Characterization callers may provide detached package records, and standalone + // installs permit a root index fallback that Pi package conventions do not expose. + // Keep this bounded scanner for those cases only; never descend into dependencies. const packageRoot = await toPackageRoot(pkg, cwd); if (!packageRoot) continue; const packageFilters = (pkg.scope === "global" ? globalFilterMap : projectFilterMap).get( - normalizeSource(pkg.source) + normalizeConfiguredPackageSource(pkg.source) ) ?? undefined; const extensionPaths = await discoverPackageExtensionEntrypoints(packageRoot); for (const extensionPath of extensionPaths) { @@ -619,7 +638,14 @@ export async function setPackageExtensionState( extensionPath: string, scope: Scope, target: State, - cwd: string + cwd: string, + projectTrusted = false ): Promise<{ ok: true } | { ok: false; error: string }> { - return applyPackageExtensionStateChanges(packageSource, scope, [{ extensionPath, target }], cwd); + return applyPackageExtensionStateChanges( + packageSource, + scope, + [{ extensionPath, target }], + cwd, + projectTrusted + ); } diff --git a/src/packages/inspection.ts b/src/packages/inspection.ts new file mode 100644 index 0000000..10ce096 --- /dev/null +++ b/src/packages/inspection.ts @@ -0,0 +1,29 @@ +export interface PackageInspection { + name: string; + version?: string; + description?: string; + dependencies: string[]; + repository?: string; + provenance: "verified" | "unverified" | "unknown"; + compatibility: "compatible" | "incompatible" | "unknown"; +} + +export function inspectPackageMetadata(input: { + name: string; + version?: string; + description?: string; + dependencies?: Record; + repository?: string; + hasProvenance?: boolean; + compatibility?: "compatible" | "incompatible" | "unknown"; +}): PackageInspection { + return { + name: input.name, + ...(input.version ? { version: input.version } : {}), + ...(input.description ? { description: input.description } : {}), + dependencies: Object.keys(input.dependencies ?? {}).sort(), + ...(input.repository ? { repository: input.repository } : {}), + provenance: input.hasProvenance === true ? "verified" : "unknown", + compatibility: input.compatibility ?? "unknown", + }; +} diff --git a/src/packages/install.ts b/src/packages/install.ts index 6443f81..9a62746 100644 --- a/src/packages/install.ts +++ b/src/packages/install.ts @@ -1,36 +1,43 @@ /** * Package installation logic */ -import { cp, mkdir, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { - type ExtensionAPI, - type ExtensionCommandContext, - type ProgressEvent, -} from "@earendil-works/pi-coding-agent"; +import { mkdir, rename, rm } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { TIMEOUTS } from "../constants.js"; +import { getAgentDir, getExtmgrTrashDir, getProjectExtensionsDir } from "../utils/pi-paths.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"; -import { tryOperation } from "../utils/mode.js"; -import { fetchWithTimeout } from "../utils/network.js"; +import { isProjectTrusted, tryOperation } from "../utils/mode.js"; +import { + downloadToFile, + MAX_COMPRESSED_DOWNLOAD_BYTES, + MAX_DIRECT_EXTENSION_BYTES, +} from "../utils/network.js"; import { notify, error as notifyError, success } from "../utils/notify.js"; +import { getProgressMessage } from "../utils/progress.js"; import { execNpm } from "../utils/npm-exec.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; import { clearUpdatesAvailable } from "../utils/settings.js"; +import { moveToExtensionTrash, undoExtensionTrash, type TrashRecord } from "../extensions/trash.js"; import { updateExtmgrStatus } from "../utils/status.js"; import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js"; import { getPackageCatalog } from "./catalog.js"; import { clearSearchCache } from "./discovery.js"; -import { discoverPackageExtensionEntrypoints, readPackageManifest } from "./extensions.js"; +import { + clearPackageEntrypointCache, + discoverPackageExtensionEntrypoints, + readPackageManifest, +} from "./extensions.js"; export type InstallScope = "global" | "project"; export interface InstallOptions { scope?: InstallScope; + skipConfirmation?: boolean; } export interface InstallOutcome { @@ -40,14 +47,10 @@ export interface InstallOutcome { const INSTALL_SCOPE_CHOICES = { global: "Global (~/.pi/agent/settings.json)", - project: "Project (.pi/settings.json)", + project: ".pi/settings.json", cancel: "Cancel", } as const; -function getProgressMessage(event: ProgressEvent, fallback: string): string { - return event.message?.trim() || fallback; -} - async function resolveInstallScope( ctx: ExtensionCommandContext, explicitScope?: InstallScope @@ -66,9 +69,9 @@ async function resolveInstallScope( function getExtensionInstallDir(ctx: ExtensionCommandContext, scope: InstallScope): string { if (scope === "project") { - return join(ctx.cwd, ".pi", "extensions"); + return getProjectExtensionsDir(ctx.cwd); } - return join(homedir(), ".pi", "agent", "extensions"); + return join(getAgentDir(), "extensions"); } async function ensureTarAvailable( @@ -179,11 +182,9 @@ async function installPackageInternal( const normalized = normalizePackageSource(source); // Confirm installation - const confirmed = await confirmAction( - ctx, - "Install Package", - `Install ${normalized} (${scope})?` - ); + const confirmed = options?.skipConfirmation + ? true + : await confirmAction(ctx, "Install Package", `Install ${normalized} (${scope})?`); if (!confirmed) { notify(ctx, "Installation cancelled.", "info"); return { installed: false, reloaded: false }; @@ -201,9 +202,13 @@ async function installPackageInternal( fallbackWithoutLoader: true, }, async ({ setMessage }) => { - await getPackageCatalog(ctx.cwd).install(normalized, scope, (event) => { - setMessage(getProgressMessage(event, `Installing ${normalized}...`)); - }); + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).install( + normalized, + scope, + (event) => { + setMessage(getProgressMessage(event, `Installing ${normalized}...`)); + } + ); return undefined; } ); @@ -217,6 +222,7 @@ async function installPackageInternal( } clearSearchCache(); + clearPackageEntrypointCache(); logPackageInstall(pi, normalized, normalized, undefined, scope, true); success(ctx, `Installed ${normalized} (${scope})`); clearUpdatesAvailable(pi, ctx, [normalizePackageIdentity(normalized, { cwd: ctx.cwd })]); @@ -261,12 +267,19 @@ export async function installFromUrl( } const extensionDir = getExtensionInstallDir(ctx, scope); + const safeFileName = basename(fileName); + if (!safeFileName.endsWith(".ts") || safeFileName !== fileName) { + notifyError( + ctx, + "Installation failed: direct extension destination must be a plain .ts filename." + ); + return { installed: false, reloaded: false }; + } - // Confirm installation const confirmed = await confirmAction( ctx, "Install from URL", - `Download ${fileName} to ${scope} extensions?` + `Download ${safeFileName} to ${scope} extensions?` ); if (!confirmed) { notify(ctx, "Installation cancelled.", "info"); @@ -277,18 +290,46 @@ export async function installFromUrl( ctx, async () => { await mkdir(extensionDir, { recursive: true }); - notify(ctx, `Downloading ${fileName}...`, "info"); - - const response = await fetchWithTimeout(url, TIMEOUTS.packageInstall); - if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`); + const destPath = join(extensionDir, safeFileName); + const exists = await fileExists(destPath); + if (exists) { + if (!ctx.hasUI) + throw new Error( + `Destination already exists: ${destPath}. Interactive replacement confirmation is required.` + ); + const replace = await confirmAction( + ctx, + "Replace extension", + `${destPath} already exists. Move it to extmgr trash and replace it?` + ); + if (!replace) throw new Error("Replacement cancelled; existing extension was preserved."); } - const content = await response.text(); - const destPath = join(extensionDir, fileName); - await writeFile(destPath, content, "utf8"); - - return { fileName, destPath }; + const temporary = join( + extensionDir, + `.${safeFileName}.${process.pid}.${Date.now()}.download.tmp` + ); + let trashed: TrashRecord | undefined; + try { + notify(ctx, `Downloading ${safeFileName}...`, "info"); + await downloadToFile( + url, + temporary, + TIMEOUTS.packageInstall, + MAX_DIRECT_EXTENSION_BYTES, + ctx.signal + ); + if (exists) trashed = await moveToExtensionTrash(destPath, getExtmgrTrashDir()); + try { + await rename(temporary, destPath); + } catch (error) { + if (trashed) await undoExtensionTrash(trashed); + throw error; + } + return { fileName: safeFileName, destPath }; + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } }, "Installation failed" ); @@ -348,11 +389,13 @@ async function installPackageLocallyInternal( const extensionDir = getExtensionInstallDir(ctx, scope); // Confirm local installation - const confirmed = await confirmAction( - ctx, - "Install Locally", - `Download ${packageName} to ${scope} extensions?\n\nThis installs as a standalone extension (manual updates).` - ); + const confirmed = options?.skipConfirmation + ? true + : await confirmAction( + ctx, + "Install Locally", + `Download ${packageName} to ${scope} extensions?\n\nThis installs as a standalone extension (manual updates).` + ); if (!confirmed) { notify(ctx, "Installation cancelled.", "info"); return { installed: false, reloaded: false }; @@ -413,8 +456,11 @@ async function installPackageLocallyInternal( return { installed: false, reloaded: false }; } - // Download and extract - const tempDir = join(extensionDir, ".temp"); + // Download and extract into an install-specific temporary sibling. + const tempDir = join( + extensionDir, + `.extmgr-install-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + ); const extractResult = await tryOperation( ctx, async () => { @@ -423,13 +469,13 @@ async function installPackageLocallyInternal( showProgress(ctx, "Downloading", `${packageName}@${version}`); - const response = await fetchWithTimeout(tarballUrl, TIMEOUTS.packageInstall); - if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`); - } - - const buffer = await response.arrayBuffer(); - await writeFile(tarballPath, new Uint8Array(buffer)); + await downloadToFile( + tarballUrl, + tarballPath, + TIMEOUTS.packageInstall, + MAX_COMPRESSED_DOWNLOAD_BYTES, + ctx.signal + ); return { tarballPath }; }, @@ -453,10 +499,7 @@ async function installPackageLocallyInternal( const { tarballPath } = extractResult; // Extract - const extractDir = join( - tempDir, - `extracted-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - ); + const extractDir = join(tempDir, "ready"); const extractSuccess = await tryOperation( ctx, @@ -508,19 +551,42 @@ async function installPackageLocallyInternal( return { installed: false, reloaded: false }; } - // Copy to extensions dir + // Atomically swap only after the complete temporary package validates. const destResult = await tryOperation( ctx, async () => { const extDirName = packageName.replace(/[@/]/g, "-"); const destDir = join(extensionDir, extDirName); - - await rm(destDir, { recursive: true, force: true }); - - await cp(extractDir, destDir, { recursive: true }); - return destDir; + const exists = await fileExists(destDir); + if (exists) { + if (!ctx.hasUI && options?.skipConfirmation !== true) { + throw new Error( + `Destination already exists: ${destDir}. Explicit replacement confirmation is required.` + ); + } + if ( + ctx.hasUI && + !(await confirmAction( + ctx, + "Replace standalone extension", + `${destDir} already exists. Replace it after validation?` + )) + ) { + throw new Error("Replacement cancelled; existing extension was preserved."); + } + } + let trashed: TrashRecord | undefined; + try { + if (exists) trashed = await moveToExtensionTrash(destDir, getExtmgrTrashDir()); + await rename(extractDir, destDir); + } catch (error) { + await rm(destDir, { recursive: true, force: true }).catch(() => undefined); + if (trashed) await undoExtensionTrash(trashed); + throw error; + } + return { destDir, replaced: Boolean(trashed) }; }, - "Failed to copy extension" + "Failed to swap extension" ); await cleanupStandaloneTempArtifacts(tempDir, extractDir); @@ -540,8 +606,12 @@ async function installPackageLocallyInternal( } clearSearchCache(); + clearPackageEntrypointCache(); logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true); - success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}`); + success( + ctx, + `Installed ${packageName}@${version} locally to:\n${destResult.destDir}${destResult.replaced ? "\nThe previous installation is available in extmgr trash." : ""}` + ); const reloaded = await confirmReload(ctx, "Extension installed."); if (!reloaded) { diff --git a/src/packages/management.ts b/src/packages/management.ts index 10c9de6..4efb262 100644 --- a/src/packages/management.ts +++ b/src/packages/management.ts @@ -5,7 +5,6 @@ import { type ExtensionAPI, type ExtensionCommandContext, getAgentDir, - type ProgressEvent, } from "@earendil-works/pi-coding-agent"; import { UI } from "../constants.js"; import { type InstalledPackage } from "../types/index.js"; @@ -13,11 +12,13 @@ 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"; +import { isProjectTrusted, requireUI } from "../utils/mode.js"; import { notify, error as notifyError, success } from "../utils/notify.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; import { clearUpdatesAvailable } from "../utils/settings.js"; import { updateExtmgrStatus } from "../utils/status.js"; +import { getProgressMessage } from "../utils/progress.js"; import { confirmAction, confirmReload, @@ -30,6 +31,7 @@ import { getInstalledPackages, getInstalledPackagesAllScopes, } from "./discovery.js"; +import { clearPackageEntrypointCache } from "./extensions.js"; export interface PackageMutationOutcome { reloaded: boolean; @@ -43,10 +45,6 @@ const REMOVAL_SCOPE_CHOICES = { cancel: "Cancel", } as const; -function getProgressMessage(event: ProgressEvent, fallback: string): string { - return event.message?.trim() || fallback; -} - async function updatePackageInternal( source: string, ctx: ExtensionCommandContext, @@ -57,7 +55,10 @@ async function updatePackageInternal( const updateIdentity = normalizePackageIdentity(source, { cwd: ctx.cwd }); try { - const updates = await getPackageCatalog(ctx.cwd).checkForAvailableUpdates(); + const updates = await getPackageCatalog( + ctx.cwd, + isProjectTrusted(ctx) + ).checkForAvailableUpdates(); const hasUpdate = updates.some( (update) => normalizePackageIdentity(update.source) === updateIdentity ); @@ -79,7 +80,7 @@ async function updatePackageInternal( fallbackWithoutLoader: true, }, async ({ setMessage }) => { - await getPackageCatalog(ctx.cwd).update(source, (event) => { + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).update(source, (event) => { setMessage(getProgressMessage(event, `Updating ${source}...`)); }); return undefined; @@ -112,7 +113,10 @@ async function updatePackagesInternal( showProgress(ctx, "Updating", "all packages"); try { - const updates = await getPackageCatalog(ctx.cwd).checkForAvailableUpdates(); + const updates = await getPackageCatalog( + ctx.cwd, + isProjectTrusted(ctx) + ).checkForAvailableUpdates(); if (updates.length === 0) { notify(ctx, "All packages are already up to date.", "info"); logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true); @@ -130,7 +134,7 @@ async function updatePackagesInternal( fallbackWithoutLoader: true, }, async ({ setMessage }) => { - await getPackageCatalog(ctx.cwd).update(undefined, (event) => { + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).update(undefined, (event) => { setMessage(getProgressMessage(event, "Updating all packages...")); }); return undefined; @@ -196,6 +200,7 @@ function packageIdentity( function packageSourceIdentities(source: string, ctx: ExtensionCommandContext): Set { return new Set([ packageIdentity(source, { cwd: ctx.cwd }), + packageIdentity(source, { cwd: getProjectConfigDir(ctx.cwd) }), packageIdentity(source, { cwd: getAgentDir() }), ]); } @@ -208,7 +213,7 @@ function installedPackageMatchesSource( return identities.has( packageIdentity(pkg.source, { ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), - cwd: pkg.scope === "project" ? ctx.cwd : getAgentDir(), + cwd: pkg.scope === "project" ? getProjectConfigDir(ctx.cwd) : getAgentDir(), }) ); } @@ -300,9 +305,13 @@ async function executeRemovalTargets( fallbackWithoutLoader: true, }, async ({ setMessage }) => { - await getPackageCatalog(ctx.cwd).remove(target.source, target.scope, (event) => { - setMessage(getProgressMessage(event, `Removing ${target.source}...`)); - }); + await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).remove( + target.source, + target.scope, + (event) => { + setMessage(getProgressMessage(event, `Removing ${target.source}...`)); + } + ); return undefined; } ); @@ -388,6 +397,9 @@ async function removePackageInternal( const results = await executeRemovalTargets(targets, ctx, pi); clearSearchCache(); + if (results.some((result) => result.success)) { + clearPackageEntrypointCache(); + } const failures = results .filter((result): result is RemovalExecutionResult & { success: false; error: string } => diff --git a/src/packages/scopes.ts b/src/packages/scopes.ts new file mode 100644 index 0000000..6a135f3 --- /dev/null +++ b/src/packages/scopes.ts @@ -0,0 +1,212 @@ +import { resolve } from "node:path"; +import { type PackageSource, SettingsManager } from "@earendil-works/pi-coding-agent"; +import { type InstalledPackage, type Scope } from "../types/index.js"; +import { + getPackageSourceKind, + normalizePackageIdentity, + packageSourceString, +} from "../utils/package-source.js"; +import { CONFIG_DIR_NAME, getAgentDir, getProjectConfigDir } from "../utils/pi-paths.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; + +export interface PackageScopeComparison { + identity: string; + name: string; + global?: InstalledPackage; + project?: InstalledPackage; + status: "global-only" | "project-only" | "overridden" | "same" | "different"; +} + +/** Compare effective package records across global and project scopes. */ +export function comparePackageScopes( + packages: InstalledPackage[], + cwd?: string +): PackageScopeComparison[] { + const byIdentity = new Map(); + + for (const pkg of packages) { + const baseCwd = + pkg.scope === "project" ? (cwd ? getProjectConfigDir(cwd) : undefined) : getAgentDir(); + const identity = normalizePackageIdentity(pkg.source, { + ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), + ...(baseCwd ? { cwd: baseCwd } : {}), + }); + const existing = byIdentity.get(identity) ?? { + identity, + name: pkg.name, + status: "global-only" as const, + }; + if (pkg.scope === "project") existing.project = pkg; + else existing.global = pkg; + byIdentity.set(identity, existing); + } + + return [...byIdentity.values()] + .map((entry) => { + if (entry.global && entry.project) { + const globalSource = normalizePackageIdentity(entry.global.source, { + ...(entry.global.resolvedPath ? { resolvedPath: entry.global.resolvedPath } : {}), + cwd: getAgentDir(), + }); + const projectSource = normalizePackageIdentity(entry.project.source, { + ...(entry.project.resolvedPath ? { resolvedPath: entry.project.resolvedPath } : {}), + ...(cwd ? { cwd: getProjectConfigDir(cwd) } : {}), + }); + return { + ...entry, + status: globalSource === projectSource ? ("overridden" as const) : ("different" as const), + }; + } + return { + ...entry, + status: entry.project ? ("project-only" as const) : ("global-only" as const), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getPackageScopeLabel(scope: Scope): string { + return scope === "project" + ? `project (${CONFIG_DIR_NAME}/settings.json)` + : "global (~/.pi/agent/settings.json)"; +} + +function packageMatches(value: PackageSource, source: string, cwd: string, scope: Scope): boolean { + const baseCwd = scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(); + return ( + packageSourceString(value) === source || + normalizePackageIdentity(source, { cwd: baseCwd }) === + normalizePackageIdentity(packageSourceString(value), { cwd: baseCwd }) + ); +} + +function sourceForDestination(source: string, from: Scope, cwd: string): string { + if (getPackageSourceKind(source) !== "local") return source; + if ( + source.startsWith("./") || + source.startsWith("../") || + source.startsWith(".\\") || + source.startsWith("..\\") + ) { + const sourceRoot = from === "project" ? getProjectConfigDir(cwd) : getAgentDir(); + return resolve(sourceRoot, source.replace(/\\/g, "/")); + } + // Absolute, home-relative, file://, Windows-drive, and UNC sources resolve + // independently of settings scope and can be retained byte-for-byte. + return source; +} + +function withSource(entry: PackageSource, source: string): PackageSource { + return typeof entry === "string" ? source : { ...structuredClone(entry), source }; +} + +export interface MovePackageScopeResult { + source: string; + from: Scope; + to: Scope; + moved: boolean; + partial?: boolean; + conflict?: string; +} + +/** + * Promote a configured package between scopes while retaining the complete + * settings object (including filters and unknown package fields). + * + * The destination is written before the source is removed. If the second + * write fails, the package remains effective rather than disappearing. + */ +export async function movePackageBetweenScopes( + source: string, + from: Scope, + to: Scope, + cwd: string, + projectTrusted = false +): Promise { + if (from === to) { + return { source, from, to, moved: false, conflict: "Package is already in that scope." }; + } + + const settings = SettingsManager.create(cwd, getAgentDir(), { projectTrusted }); + try { + throwIfSettingsErrors(settings, "Package scope move"); + } catch (error) { + return { + source, + from, + to, + moved: false, + conflict: error instanceof Error ? error.message : String(error), + }; + } + const globalPackages = [...(settings.getGlobalSettings().packages ?? [])]; + const projectPackages = [...(settings.getProjectSettings().packages ?? [])]; + const sourcePackages = from === "global" ? globalPackages : projectPackages; + const destinationPackages = to === "global" ? globalPackages : projectPackages; + const sourceIndex = sourcePackages.findIndex((entry) => packageMatches(entry, source, cwd, from)); + + if (sourceIndex < 0) { + return { source, from, to, moved: false, conflict: "Package is not configured in that scope." }; + } + + const entry = sourcePackages[sourceIndex]; + if (!entry) { + return { source, from, to, moved: false, conflict: "Package is not configured in that scope." }; + } + const destinationSource = sourceForDestination(packageSourceString(entry), from, cwd); + const destinationEntryValue = withSource(entry, destinationSource); + const destinationIndex = destinationPackages.findIndex((candidate) => + packageMatches(candidate, destinationSource, cwd, to) + ); + if (destinationIndex >= 0) { + const destinationEntry = destinationPackages[destinationIndex]; + if ( + destinationEntry && + JSON.stringify(destinationEntry) !== JSON.stringify(destinationEntryValue) + ) { + return { + source, + from, + to, + moved: false, + conflict: "The destination has a different package configuration; no changes were made.", + }; + } + } else { + destinationPackages.push(destinationEntryValue); + } + + try { + if (to === "global") settings.setPackages(destinationPackages); + else settings.setProjectPackages(destinationPackages); + await settings.flush(); + throwIfSettingsErrors(settings, "Package scope move"); + } catch (error) { + return { + source, + from, + to, + moved: false, + conflict: error instanceof Error ? error.message : String(error), + }; + } + + sourcePackages.splice(sourceIndex, 1); + try { + if (from === "global") settings.setPackages(sourcePackages); + else settings.setProjectPackages(sourcePackages); + await settings.flush(); + throwIfSettingsErrors(settings, "Package scope move"); + } catch (error) { + return { + source: packageSourceString(entry), + from, + to, + moved: false, + partial: true, + conflict: `The package was copied to ${to}, but could not be removed from ${from}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + return { source: destinationSource, from, to, moved: true }; +} diff --git a/src/packages/sorting.ts b/src/packages/sorting.ts new file mode 100644 index 0000000..b046220 --- /dev/null +++ b/src/packages/sorting.ts @@ -0,0 +1,16 @@ +import { type NpmPackage } from "../types/index.js"; + +export type RemotePackageSort = "relevance" | "downloads" | "popular" | "recent" | "name"; + +export function sortRemotePackages(packages: NpmPackage[], sort: RemotePackageSort): NpmPackage[] { + if (sort === "relevance") return [...packages]; + return [...packages].sort((left, right) => { + if (sort === "name") return left.name.localeCompare(right.name); + if (sort === "downloads" || sort === "popular") { + // Stable sort preserves registry relevance until downloads hydrate, and + // preserves relevance for equal download counts afterward. + return (right.weeklyDownloads ?? -1) - (left.weeklyDownloads ?? -1); + } + return (right.date ?? "").localeCompare(left.date ?? "") || left.name.localeCompare(right.name); + }); +} diff --git a/src/packages/update-preview.ts b/src/packages/update-preview.ts new file mode 100644 index 0000000..b0546e9 --- /dev/null +++ b/src/packages/update-preview.ts @@ -0,0 +1,27 @@ +import { type InstalledPackage } from "../types/index.js"; +import { normalizePackageIdentity } from "../utils/package-source.js"; +import { type AvailablePackageUpdate } from "./catalog.js"; + +export interface PackageUpdatePreview { + source: string; + name: string; + scope: "global" | "project"; + currentVersion?: string; + updateAvailable: boolean; + metadataKnown: boolean; +} + +export function buildUpdatePreview( + installed: InstalledPackage[], + available: AvailablePackageUpdate[] +): PackageUpdatePreview[] { + const availableSources = new Set(available.map((item) => normalizePackageIdentity(item.source))); + return installed.map((pkg) => ({ + source: pkg.source, + name: pkg.name, + scope: pkg.scope, + ...(pkg.version ? { currentVersion: pkg.version } : {}), + updateAvailable: availableSources.has(normalizePackageIdentity(pkg.source)), + metadataKnown: Boolean(pkg.version), + })); +} diff --git a/src/profiles/apply.ts b/src/profiles/apply.ts new file mode 100644 index 0000000..d82f81c --- /dev/null +++ b/src/profiles/apply.ts @@ -0,0 +1,112 @@ +import { + type ExtmgrProfile, + getEffectivePackageSource, + getProfilePackageIdentity, + inferPackageResolution, + type ProfilePackage, +} from "./schema.js"; + +export interface ProfilePlan { + add: ProfilePackage[]; + remove: ProfilePackage[]; + update: Array<{ from: ProfilePackage; to: ProfilePackage }>; +} + +export interface ProfileIdentityOptions { + projectCwd?: string; + globalCwd?: string; +} + +function exactKey(pkg: ProfilePackage, options?: ProfileIdentityOptions): string { + return `${pkg.scope}\0${getProfilePackageIdentity(pkg, options)}`; +} + +function comparablePackage(pkg: ProfilePackage): unknown { + return { + source: getEffectivePackageSource(pkg), + scope: pkg.scope, + resolution: pkg.resolution ?? inferPackageResolution(pkg), + // The effective source already contains the npm version or git ref. Comparing + // the raw duplicate fields would report a change for equivalent encodings such + // as npm:demo@1.0.0 versus { source: npm:demo, version: 1.0.0 }. + // Omitted filters mean Pi's default, while [] disables every entrypoint. + filters: pkg.filters, + }; +} + +export function profilePackagesEqual(left: ProfilePackage, right: ProfilePackage): boolean { + if (JSON.stringify(comparablePackage(left)) !== JSON.stringify(comparablePackage(right))) { + return false; + } + // An omitted target preserves existing Pi settings; an explicit object (including + // {}) is a requested replacement and must be compared. + if (right.packageSettings === undefined) return true; + return ( + left.packageSettings !== undefined && + JSON.stringify(left.packageSettings) === JSON.stringify(right.packageSettings) + ); +} + +/** + * Match exact scope+identity first, then pair remaining identities as scope + * moves. This makes a scope move one reviewed change rather than a destructive + * remove/add pair. + */ +export function planProfileApplication( + current: ExtmgrProfile, + desired: ExtmgrProfile, + options?: ProfileIdentityOptions +): ProfilePlan { + const unmatchedCurrent = new Set(current.packages.map((_, index) => index)); + const matchedDesired = new Set(); + const update: Array<{ from: ProfilePackage; to: ProfilePackage }> = []; + + for (let desiredIndex = 0; desiredIndex < desired.packages.length; desiredIndex += 1) { + const target = desired.packages[desiredIndex]; + if (!target) continue; + const currentIndex = [...unmatchedCurrent].find((index) => { + const candidate = current.packages[index]; + return candidate ? exactKey(candidate, options) === exactKey(target, options) : false; + }); + if (currentIndex === undefined) continue; + unmatchedCurrent.delete(currentIndex); + matchedDesired.add(desiredIndex); + const previous = current.packages[currentIndex]; + if (previous && !profilePackagesEqual(previous, target)) + update.push({ from: previous, to: target }); + } + + for (let desiredIndex = 0; desiredIndex < desired.packages.length; desiredIndex += 1) { + if (matchedDesired.has(desiredIndex)) continue; + const target = desired.packages[desiredIndex]; + if (!target) continue; + const currentIndex = [...unmatchedCurrent].find((index) => { + const candidate = current.packages[index]; + return ( + candidate && + getProfilePackageIdentity(candidate, options) === getProfilePackageIdentity(target, options) + ); + }); + if (currentIndex === undefined) continue; + unmatchedCurrent.delete(currentIndex); + matchedDesired.add(desiredIndex); + const previous = current.packages[currentIndex]; + if (previous) update.push({ from: previous, to: target }); + } + + return { + add: desired.packages.filter((_, index) => !matchedDesired.has(index)), + remove: current.packages.filter((_, index) => unmatchedCurrent.has(index)), + update, + }; +} + +export async function applyProfile( + current: ExtmgrProfile, + desired: ExtmgrProfile, + options: { dryRun?: boolean; apply: (plan: ProfilePlan) => Promise } +): Promise { + const plan = planProfileApplication(current, desired); + if (!options.dryRun) await options.apply(plan); + return plan; +} diff --git a/src/profiles/checksum.ts b/src/profiles/checksum.ts new file mode 100644 index 0000000..94e35bd --- /dev/null +++ b/src/profiles/checksum.ts @@ -0,0 +1,59 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export interface PackageManifestSnapshot { + fingerprint: string; + version?: string; +} + +/** Read the installed manifest once for reproducibility and drift diagnostics. */ +export async function readPackageManifestSnapshot( + path: string | undefined +): Promise { + if (!path) return undefined; + try { + const manifest = await readFile( + /(?:^|[\\/])package\.json$/i.test(path) ? path : join(path, "package.json") + ); + let version: unknown; + try { + const parsed = JSON.parse(manifest.toString("utf8")) as unknown; + version = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).version + : undefined; + } catch { + // The fingerprint remains useful even when the manifest cannot be parsed. + } + return { + fingerprint: `sha256:${createHash("sha256").update(manifest).digest("hex")}`, + ...(typeof version === "string" && version.trim() ? { version: version.trim() } : {}), + }; + } catch { + return undefined; + } +} + +/** + * Fingerprint package.json only. This is useful for drift diagnostics, but is + * explicitly not extension artifact integrity or provenance evidence. + */ +export async function manifestFingerprint(path: string | undefined): Promise { + return (await readPackageManifestSnapshot(path))?.fingerprint; +} + +export async function verifyManifestFingerprint( + path: string | undefined, + expected: string | undefined +): Promise<"match" | "mismatch" | "unknown"> { + if (!expected) return "unknown"; + const actual = await manifestFingerprint(path); + if (!actual) return "unknown"; + return actual === expected ? "match" : "mismatch"; +} + +/** @deprecated Use manifestFingerprint; this never represented artifact integrity. */ +export const checksumPackagePath = manifestFingerprint; +/** @deprecated Use verifyManifestFingerprint; this never proves artifact integrity. */ +export const verifyPackageChecksum = verifyManifestFingerprint; diff --git a/src/profiles/compare.ts b/src/profiles/compare.ts new file mode 100644 index 0000000..1e99005 --- /dev/null +++ b/src/profiles/compare.ts @@ -0,0 +1,136 @@ +import { readFile } from "node:fs/promises"; +import { getProjectConfigPath } from "../utils/pi-paths.js"; +import { planProfileApplication } from "./apply.js"; +import { getEffectivePackageSource, type DiagnosticStatus, type ExtmgrProfile } from "./schema.js"; + +export interface ProfilePolicy { + allowedScopes?: Array<"global" | "project">; + allowedSources?: string[]; + forbiddenSources?: string[]; + requiredPackages?: string[]; + /** Legacy policy name. It now means locally verified artifact integrity. */ + requireChecksums?: boolean; + requireIntegrity?: boolean; + requireCompatibilityCheck?: boolean; +} + +export interface ProfilePolicyViolation { + packageSource?: string; + message: string; +} + +export interface ProfilePackageDiagnostic { + source: string; + scope: "global" | "project"; + compatibility: DiagnosticStatus; + integrity: DiagnosticStatus; + notes: string[]; +} + +export function compareProfiles( + left: ExtmgrProfile, + right: ExtmgrProfile +): ReturnType { + return planProfileApplication(left, right); +} + +export function validateProfilePolicy( + profile: ExtmgrProfile, + policy: ProfilePolicy, + diagnostics: ProfilePackageDiagnostic[] = [] +): ProfilePolicyViolation[] { + const violations: ProfilePolicyViolation[] = []; + for (const pkg of profile.packages) { + const effectiveSource = getEffectivePackageSource(pkg); + if (policy.allowedScopes && !policy.allowedScopes.includes(pkg.scope)) { + violations.push({ + packageSource: effectiveSource, + message: `scope ${pkg.scope} is not allowed`, + }); + } + if ( + policy.allowedSources && + !policy.allowedSources.includes(effectiveSource) && + !policy.allowedSources.includes(pkg.source) + ) { + violations.push({ packageSource: effectiveSource, message: "source is not allowed" }); + } + if ( + policy.forbiddenSources?.includes(effectiveSource) || + policy.forbiddenSources?.includes(pkg.source) + ) { + violations.push({ packageSource: effectiveSource, message: "source is forbidden" }); + } + const diagnostic = diagnostics.find( + (item) => item.scope === pkg.scope && item.source === effectiveSource + ); + if ( + (policy.requireChecksums || policy.requireIntegrity) && + diagnostic?.integrity !== "verified" + ) { + violations.push({ + packageSource: effectiveSource, + message: "artifact integrity is unknown or unverified", + }); + } + if (policy.requireCompatibilityCheck && diagnostic?.compatibility !== "verified") { + violations.push({ + packageSource: effectiveSource, + message: "local compatibility is unknown or failed", + }); + } + } + for (const requiredSource of policy.requiredPackages ?? []) { + if ( + !profile.packages.some( + (pkg) => pkg.source === requiredSource || getEffectivePackageSource(pkg) === requiredSource + ) + ) { + violations.push({ packageSource: requiredSource, message: "required package is missing" }); + } + } + return violations; +} + +export async function loadProjectProfilePolicy( + cwd: string, + path?: string, + projectTrusted = false +): Promise { + if (!path && !projectTrusted) return undefined; + const policyPath = path ?? getProjectConfigPath(cwd, "extmgr-policy.json"); + try { + const raw = JSON.parse(await readFile(policyPath, "utf8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + throw new Error(`Invalid profile policy in ${policyPath}`); + const value = raw as Record; + if (value.schemaVersion !== undefined && value.schemaVersion !== 1) + throw new Error(`Unsupported profile policy schema in ${policyPath}`); + const scopes = Array.isArray(value.allowedScopes) + ? value.allowedScopes.filter( + (scope): scope is "global" | "project" => scope === "global" || scope === "project" + ) + : undefined; + const strings = (candidate: unknown): string[] | undefined => + Array.isArray(candidate) + ? candidate.filter( + (entry): entry is string => typeof entry === "string" && Boolean(entry.trim()) + ) + : undefined; + const allowedSources = strings(value.allowedSources); + const forbiddenSources = strings(value.forbiddenSources); + const requiredPackages = strings(value.requiredPackages); + return { + ...(scopes && scopes.length > 0 ? { allowedScopes: scopes } : {}), + ...(allowedSources ? { allowedSources } : {}), + ...(forbiddenSources ? { forbiddenSources } : {}), + ...(requiredPackages ? { requiredPackages } : {}), + ...(value.requireChecksums === true ? { requireChecksums: true } : {}), + ...(value.requireIntegrity === true ? { requireIntegrity: true } : {}), + ...(value.requireCompatibilityCheck === true ? { requireCompatibilityCheck: true } : {}), + }; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; + throw error; + } +} diff --git a/src/profiles/schema.ts b/src/profiles/schema.ts new file mode 100644 index 0000000..5dec5e5 --- /dev/null +++ b/src/profiles/schema.ts @@ -0,0 +1,681 @@ +import { normalizePackageSource, parseNpmSource } from "../utils/format.js"; +import { + getPackageSourceKind, + normalizePackageIdentity, + splitGitRepoAndRef, + stripGitSourcePrefix, +} from "../utils/package-source.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; + +export const PROFILE_SCHEMA_VERSION = 1 as const; + +export type ProfileScope = "global" | "project"; +export type ProfileResolution = "locked" | "floating"; +export type DiagnosticStatus = "verified" | "failed" | "unknown"; + +export interface ProfilePackage { + /** Configured source retained for round-tripping. */ + source: string; + scope: ProfileScope; + /** Exact npm version when locked, or configured npm tag/range when floating. */ + version?: string; + /** Git ref. Commit hashes are treated as immutable locked refs. */ + ref?: string; + resolution?: ProfileResolution; + filters?: string[]; + /** Honest package.json-only diagnostic; never artifact integrity evidence. */ + manifestFingerprint?: string; + /** Legacy alias retained for v1 readers. It is never trusted as integrity evidence. */ + checksum?: string; + /** Locally established artifact integrity, when a public package API provides it. */ + integrity?: string; + /** Additional Pi package settings retained across profile export/apply. */ + packageSettings?: Record; + legacyMetadata?: Record; +} + +export interface ProfileImportMetadata { + origin: string; + finalOrigin?: string; + fetchedAt?: string; + contentFingerprint?: string; + warnings?: string[]; +} + +export interface ExtmgrProfile { + schemaVersion: 1; + name: string; + packages: ProfilePackage[]; + /** Imported claims are preserved for migration/display only and are never trusted. */ + checks?: { compatibility?: boolean; provenance?: boolean }; + importMetadata?: ProfileImportMetadata; +} + +export interface ProfileValidationIssue { + path: string; + code: string; + message: string; +} + +export interface ProfileMigration { + fromVersion: number; + toVersion: 1; + migrated: boolean; + notes: string[]; +} + +export type ExternalProfileParseResult = + | { ok: true; profile: ExtmgrProfile; migration: ProfileMigration; warnings: string[] } + | { ok: false; errors: ProfileValidationIssue[] }; + +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/i; +const EXACT_VERSION_PATTERN = + /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const IMMUTABLE_GIT_REF_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i; + +function hasWhitespaceOrControl(value: string): boolean { + return [...value].some((character) => character.charCodeAt(0) <= 0x20 || /\s/u.test(character)); +} + +function isValidGitRefSyntax(ref: string): boolean { + const forbidden = new Set(["~", "^", ":", "?", "*", "[", "\\"]); + if ( + !ref || + ref.length > 256 || + hasWhitespaceOrControl(ref) || + [...ref].some((character) => forbidden.has(character)) || + ref.includes("..") || + ref.includes("@{") || + ref.startsWith("/") || + ref.endsWith("/") || + ref.endsWith(".") + ) { + return false; + } + return ref.split("/").every((part) => part.length > 0 && !part.startsWith(".")); +} + +function isRecord(input: unknown): input is Record { + return Boolean(input) && typeof input === "object" && !Array.isArray(input); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function canonicalProfileSource(source: string): string { + const trimmed = source.trim(); + return getPackageSourceKind(trimmed) === "unknown" ? normalizePackageSource(trimmed) : trimmed; +} + +function sourceTarget(source: string): { version?: string; ref?: string; repo?: string } { + const canonical = canonicalProfileSource(source); + const npm = parseNpmSource(canonical); + if (npm?.version) return { version: npm.version }; + if (getPackageSourceKind(canonical) === "git") { + const parsed = splitGitRepoAndRef(stripGitSourcePrefix(canonical)); + return { repo: parsed.repo, ...(parsed.ref ? { ref: parsed.ref } : {}) }; + } + return {}; +} + +export function isExactNpmVersion(version: string | undefined): boolean { + return Boolean(version && EXACT_VERSION_PATTERN.test(version)); +} + +export function isImmutableGitRef(ref: string | undefined): boolean { + return Boolean(ref && IMMUTABLE_GIT_REF_PATTERN.test(ref)); +} + +export function isValidGitRef(ref: string | undefined): boolean { + return Boolean(ref && isValidGitRefSyntax(ref)); +} + +export function inferPackageResolution( + pkg: Pick +): ProfileResolution { + const source = canonicalProfileSource(pkg.source); + const target = sourceTarget(source); + const kind = getPackageSourceKind(source); + const version = pkg.version ?? target.version; + const ref = pkg.ref ?? target.ref; + if (kind === "npm" && isExactNpmVersion(version)) return "locked"; + if (kind === "git" && isImmutableGitRef(ref)) return "locked"; + return "floating"; +} + +/** Central package mutation source used by planning, applying, persistence, and display. */ +export function getEffectivePackageSource(pkg: ProfilePackage): string { + const source = canonicalProfileSource(pkg.source); + const kind = getPackageSourceKind(source); + const resolution = pkg.resolution ?? inferPackageResolution(pkg); + if (kind === "npm") { + const parsed = parseNpmSource(source); + if (!parsed) return source; + const target = pkg.version ?? parsed.version; + if (!target) return source; + if (resolution === "locked" && !isExactNpmVersion(target)) return source; + return `npm:${parsed.name}@${target}`; + } + if (kind === "git") { + const spec = stripGitSourcePrefix(source); + const parsed = splitGitRepoAndRef(spec); + const ref = pkg.ref ?? parsed.ref; + const prefix = source.startsWith("git:") ? "git:" : source.startsWith("git+") ? "git+" : ""; + const base = parsed.repo; + return ref ? `${prefix}${base}@${ref}` : source; + } + return source; +} + +export function getProfilePackageIdentity( + pkg: ProfilePackage, + options?: { projectCwd?: string; globalCwd?: string } +): string { + const cwd = + pkg.scope === "project" + ? options?.projectCwd + ? getProjectConfigDir(options.projectCwd) + : undefined + : options?.globalCwd; + return normalizePackageIdentity(getEffectivePackageSource(pkg), cwd ? { cwd } : undefined); +} + +const PROFILE_PACKAGE_FIELDS = new Set([ + "source", + "scope", + "version", + "ref", + "resolution", + "filters", + "manifestFingerprint", + "checksum", + "integrity", + "packageSettings", + "legacyMetadata", + "extensions", +]); + +function extractPackageSettings( + item: Record +): Record | undefined { + const settings: Record = Object.create(null) as Record; + const supplied = item.packageSettings; + const hasSuppliedSettings = isRecord(supplied); + if (hasSuppliedSettings) { + for (const [key, value] of Object.entries(supplied)) settings[key] = structuredClone(value); + } + for (const [key, value] of Object.entries(item)) { + if (!PROFILE_PACKAGE_FIELDS.has(key)) settings[key] = structuredClone(value); + } + // `source` and `extensions` are represented by the canonical profile fields. + delete settings.source; + delete settings.extensions; + return hasSuppliedSettings || Object.keys(settings).length > 0 ? settings : undefined; +} + +function normalizePackage(item: Record): ProfilePackage | undefined { + const rawSource = nonEmptyString(item.source); + if (!rawSource) return undefined; + const source = canonicalProfileSource(rawSource); + const scope: ProfileScope = item.scope === "project" ? "project" : "global"; + const version = nonEmptyString(item.version); + const ref = nonEmptyString(item.ref); + const nestedExtensions = isRecord(item.packageSettings) + ? item.packageSettings.extensions + : undefined; + const rawFilters = item.filters ?? item.extensions ?? nestedExtensions; + const filters = Array.isArray(rawFilters) + ? rawFilters.filter((filter): filter is string => typeof filter === "string") + : undefined; + const checksum = nonEmptyString(item.checksum); + const integrity = nonEmptyString(item.integrity); + const manifestFingerprint = nonEmptyString(item.manifestFingerprint); + const resolution = + item.resolution === "locked" || item.resolution === "floating" ? item.resolution : undefined; + const packageSettings = extractPackageSettings(item); + return { + source, + scope, + ...(resolution ? { resolution } : {}), + ...(version ? { version } : {}), + ...(ref ? { ref } : {}), + ...(filters ? { filters } : {}), + ...(manifestFingerprint ? { manifestFingerprint } : {}), + ...(checksum ? { checksum } : {}), + ...(integrity ? { integrity } : {}), + ...(packageSettings ? { packageSettings } : {}), + ...(isRecord(item.legacyMetadata) + ? { legacyMetadata: structuredClone(item.legacyMetadata) } + : {}), + }; +} + +/** + * Lenient normalization for already-owned in-memory data. External JSON must use + * parseExternalProfile() so malformed entries cannot be silently discarded. + */ +export function normalizeProfile(input: unknown): ExtmgrProfile { + const value = isRecord(input) ? input : {}; + const packages = Array.isArray(value.packages) + ? value.packages.flatMap((item) => { + if (!isRecord(item)) return []; + const normalized = normalizePackage(item); + return normalized ? [normalized] : []; + }) + : []; + const checks = isRecord(value.checks) + ? { + compatibility: value.checks.compatibility === true, + provenance: value.checks.provenance === true, + } + : undefined; + const importValue = isRecord(value.importMetadata) ? value.importMetadata : undefined; + const finalOrigin = nonEmptyString(importValue?.finalOrigin); + const fetchedAt = nonEmptyString(importValue?.fetchedAt); + const contentFingerprint = nonEmptyString(importValue?.contentFingerprint); + const importMetadata = importValue + ? { + origin: nonEmptyString(importValue.origin) ?? "unknown", + ...(finalOrigin ? { finalOrigin } : {}), + ...(fetchedAt ? { fetchedAt } : {}), + ...(contentFingerprint ? { contentFingerprint } : {}), + ...(Array.isArray(importValue.warnings) + ? { + warnings: importValue.warnings.filter( + (warning): warning is string => typeof warning === "string" + ), + } + : {}), + } + : undefined; + return { + schemaVersion: PROFILE_SCHEMA_VERSION, + name: nonEmptyString(value.name) ?? "unnamed", + packages, + ...(checks ? { checks } : {}), + ...(importMetadata ? { importMetadata } : {}), + }; +} + +function addIssue( + issues: ProfileValidationIssue[], + path: string, + code: string, + message: string +): void { + issues.push({ path, code, message }); +} + +function validateStringArray( + issues: ProfileValidationIssue[], + value: unknown, + path: string, + label: string +): void { + if (!Array.isArray(value)) { + addIssue(issues, path, "invalid-settings", `${label} must be an array of non-empty strings.`); + return; + } + value.forEach((entry, index) => { + if (typeof entry !== "string" || !entry.trim() || entry.includes("\0")) { + addIssue( + issues, + `${path}[${index}]`, + "invalid-settings", + `${label} entries must be non-empty strings without NUL characters.` + ); + } + }); +} + +/** Explicit, deterministic migration entry point for persisted/exported v1 profiles. */ +export function migrateProfileV1(input: unknown): { profile: ExtmgrProfile; notes: string[] } { + const parsed = parseExternalProfile(input); + if (!parsed.ok) + throw new Error(parsed.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n")); + return { profile: parsed.profile, notes: parsed.migration.notes }; +} + +/** Strict parser and explicit v1-to-canonical migration boundary for untrusted JSON. */ +export function parseExternalProfile( + input: unknown, + options?: { requireName?: boolean } +): ExternalProfileParseResult { + const issues: ProfileValidationIssue[] = []; + if (!isRecord(input)) { + return { + ok: false, + errors: [{ path: "$", code: "type", message: "Profile must be a JSON object." }], + }; + } + if (input.schemaVersion !== PROFILE_SCHEMA_VERSION) { + addIssue( + issues, + "schemaVersion", + "unsupported-version", + "Unsupported or missing profile schemaVersion; expected 1." + ); + } + const name = nonEmptyString(input.name); + if ((options?.requireName ?? true) && !name) { + addIssue(issues, "name", "invalid-name", "Profile name must be a non-empty string."); + } + if (!Array.isArray(input.packages)) { + addIssue(issues, "packages", "type", "Profile packages must be an array."); + } + if (input.checks !== undefined) { + if (!isRecord(input.checks)) { + addIssue(issues, "checks", "invalid-checks", "Profile checks must be an object."); + } else { + for (const field of ["compatibility", "provenance"] as const) { + if (input.checks[field] !== undefined && typeof input.checks[field] !== "boolean") { + addIssue(issues, `checks.${field}`, "invalid-check", `${field} check must be a boolean.`); + } + } + } + } + + if (input.importMetadata !== undefined) { + if (!isRecord(input.importMetadata)) { + addIssue(issues, "importMetadata", "invalid-metadata", "Import metadata must be an object."); + } else { + for (const field of ["origin", "finalOrigin", "fetchedAt", "contentFingerprint"] as const) { + if ( + input.importMetadata[field] !== undefined && + (typeof input.importMetadata[field] !== "string" || + !String(input.importMetadata[field]).trim()) + ) { + addIssue( + issues, + `importMetadata.${field}`, + "invalid-metadata", + `${field} must be a non-empty string.` + ); + } + } + if (input.importMetadata.warnings !== undefined) { + validateStringArray( + issues, + input.importMetadata.warnings, + "importMetadata.warnings", + "Warnings" + ); + } + } + } + + const packages: ProfilePackage[] = []; + const identities = new Set(); + if (Array.isArray(input.packages)) { + input.packages.forEach((raw, index) => { + const path = `packages[${index}]`; + if (!isRecord(raw)) { + addIssue(issues, path, "malformed-package", "Package entry must be an object."); + return; + } + const source = nonEmptyString(raw.source); + if (!source) + addIssue( + issues, + `${path}.source`, + "invalid-source", + "Package source must be a non-empty string." + ); + if (raw.scope !== "global" && raw.scope !== "project") { + addIssue( + issues, + `${path}.scope`, + "invalid-scope", + "Package scope must be global or project." + ); + } + const version = raw.version === undefined ? undefined : nonEmptyString(raw.version); + const ref = raw.ref === undefined ? undefined : nonEmptyString(raw.ref); + if (raw.version !== undefined && !version) + addIssue( + issues, + `${path}.version`, + "invalid-version", + "Package version must be a non-empty string." + ); + if (raw.ref !== undefined && !ref) + addIssue(issues, `${path}.ref`, "invalid-ref", "Package ref must be a non-empty string."); + if (version && ref) + addIssue( + issues, + path, + "conflicting-target", + "A package cannot declare both version and ref." + ); + if ( + raw.resolution !== undefined && + raw.resolution !== "locked" && + raw.resolution !== "floating" + ) { + addIssue( + issues, + `${path}.resolution`, + "invalid-resolution", + "Package resolution must be locked or floating." + ); + } + if (raw.packageSettings !== undefined && !isRecord(raw.packageSettings)) { + addIssue( + issues, + `${path}.packageSettings`, + "invalid-settings", + "Package settings must be an object." + ); + } + if (raw.legacyMetadata !== undefined && !isRecord(raw.legacyMetadata)) { + addIssue( + issues, + `${path}.legacyMetadata`, + "invalid-metadata", + "Legacy metadata must be an object." + ); + } + for (const field of ["skills", "prompts", "themes"] as const) { + if (raw[field] !== undefined) + validateStringArray(issues, raw[field], `${path}.${field}`, field); + if (isRecord(raw.packageSettings) && raw.packageSettings[field] !== undefined) { + validateStringArray( + issues, + raw.packageSettings[field], + `${path}.packageSettings.${field}`, + field + ); + } + } + if (isRecord(raw.packageSettings) && raw.packageSettings.extensions !== undefined) { + validateStringArray( + issues, + raw.packageSettings.extensions, + `${path}.packageSettings.extensions`, + "Extensions" + ); + } + if (source) { + const canonicalSource = canonicalProfileSource(source); + const kind = getPackageSourceKind(canonicalSource); + const embedded = sourceTarget(canonicalSource); + if (version && hasWhitespaceOrControl(version)) + addIssue( + issues, + `${path}.version`, + "invalid-version", + "Package version must not contain whitespace or control characters." + ); + if (ref && !isValidGitRef(ref) && kind === "git") + addIssue(issues, `${path}.ref`, "invalid-ref", "Git ref is not valid Git ref syntax."); + if (embedded.ref && !isValidGitRef(embedded.ref)) + addIssue( + issues, + `${path}.source`, + "invalid-source-ref", + "Git source contains an invalid ref." + ); + if (version && kind !== "npm") + addIssue( + issues, + `${path}.version`, + "conflicting-target", + "Only npm packages may declare version." + ); + if (ref && kind !== "git") + addIssue( + issues, + `${path}.ref`, + "conflicting-target", + "Only git packages may declare ref." + ); + if (version && embedded.version && version !== embedded.version) + addIssue( + issues, + path, + "conflicting-target", + "Source version conflicts with declared version." + ); + if (ref && embedded.ref && ref !== embedded.ref) + addIssue(issues, path, "conflicting-target", "Source ref conflicts with declared ref."); + if (kind === "git" && !embedded.repo?.trim()) + addIssue( + issues, + `${path}.source`, + "invalid-source", + "Git source must include a repository." + ); + if (raw.resolution === "locked") { + if (kind === "npm") { + const targetVersion = version ?? embedded.version; + if (!isExactNpmVersion(targetVersion)) + addIssue( + issues, + `${path}.resolution`, + "invalid-locked-target", + "Locked npm packages require an exact semantic version." + ); + } else if (kind === "git") { + const targetRef = ref ?? embedded.ref; + if (!isImmutableGitRef(targetRef)) + addIssue( + issues, + `${path}.resolution`, + "invalid-locked-target", + "Locked git packages require a full 40- or 64-character hexadecimal commit ref." + ); + } else { + addIssue( + issues, + `${path}.resolution`, + "invalid-locked-target", + "Only npm and git packages can be locked." + ); + } + } + } + for (const field of ["integrity", "manifestFingerprint", "checksum"] as const) { + if ( + raw[field] !== undefined && + (typeof raw[field] !== "string" || !SHA256_PATTERN.test(raw[field] as string)) + ) { + addIssue( + issues, + `${path}.${field}`, + "invalid-fingerprint", + `${field} must use sha256:<64 hexadecimal characters>.` + ); + } + } + if (raw.extensions !== undefined) + validateStringArray(issues, raw.extensions, `${path}.extensions`, "Extensions"); + const nestedExtensions = isRecord(raw.packageSettings) + ? raw.packageSettings.extensions + : undefined; + const filterRepresentations = [raw.filters, raw.extensions, nestedExtensions].filter( + (value) => value !== undefined + ); + if ( + filterRepresentations.length > 1 && + filterRepresentations.some( + (value) => JSON.stringify(value) !== JSON.stringify(filterRepresentations[0]) + ) + ) { + addIssue( + issues, + path, + "conflicting-filters", + "Package filters and extensions settings must agree when both are supplied." + ); + } + if (raw.filters !== undefined) { + if (!Array.isArray(raw.filters)) { + addIssue( + issues, + `${path}.filters`, + "invalid-filters", + "Package filters must be an array of non-empty strings." + ); + } else { + raw.filters.forEach((filter, filterIndex) => { + if (typeof filter !== "string" || !filter.trim() || filter.includes("\0")) { + addIssue( + issues, + `${path}.filters[${filterIndex}]`, + "invalid-filter", + "Filter must be a non-empty string without NUL characters." + ); + } + }); + } + } + if (source && (raw.scope === "global" || raw.scope === "project")) { + const identity = `${raw.scope}\0${normalizePackageIdentity(canonicalProfileSource(source))}`; + if (identities.has(identity)) + addIssue( + issues, + path, + "duplicate-package", + "Duplicate normalized package source and scope." + ); + identities.add(identity); + } + const normalized = normalizePackage(raw); + if (normalized) packages.push(normalized); + }); + } + + if (issues.length > 0) return { ok: false, errors: issues }; + const profile = normalizeProfile({ + ...input, + name: name ?? "unnamed", + packages, + }); + const notes: string[] = ["Schema v1 profile normalized through the explicit migration path."]; + if (profile.checks?.compatibility !== undefined || profile.checks?.provenance !== undefined) { + notes.push( + "Imported compatibility and provenance claims were retained as unverified legacy metadata." + ); + } + if (packages.some((pkg) => pkg.checksum)) { + notes.push( + "Legacy checksums were retained as unverified manifest-only metadata and are not artifact integrity evidence." + ); + } + return { + ok: true, + profile, + migration: { fromVersion: 1, toVersion: 1, migrated: notes.length > 0, notes }, + warnings: [...notes], + }; +} + +export function assertExternalProfile( + input: unknown, + options?: { requireName?: boolean } +): ExtmgrProfile { + const parsed = parseExternalProfile(input, options); + if (parsed.ok) return parsed.profile; + throw new Error(parsed.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n")); +} diff --git a/src/profiles/source.ts b/src/profiles/source.ts new file mode 100644 index 0000000..08b9539 --- /dev/null +++ b/src/profiles/source.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { open } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fetchBoundedBytes, MAX_PROFILE_BYTES } from "../utils/network.js"; + +export const PROFILE_FETCH_TIMEOUT_MS = 30_000; + +export interface LoadedProfileSource { + value: unknown; + raw: string; + origin: string; + finalOrigin: string; + fetchedAt?: string; + contentFingerprint: string; + immutableOrigin: boolean | "not-applicable"; + warnings: string[]; + remote: boolean; +} + +function fingerprint(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function rejectUnsupportedTransport(source: string): void { + if (/^(?:git:|git\+|ssh:|git@)/i.test(source)) { + throw new Error( + "Profile sources support local JSON paths and HTTPS URLs only; git and SSH transports are not supported." + ); + } +} + +export function normalizeProfileSourceUrl(source: string): { + url: URL; + warnings: string[]; + immutable: boolean; +} { + rejectUnsupportedTransport(source); + let url: URL; + try { + url = new URL(source); + } catch { + throw new Error("Profile URL is invalid."); + } + if (url.protocol !== "https:") throw new Error("Remote profile URLs must use HTTPS."); + if (url.username || url.password) + throw new Error("Remote profile URLs must not contain credentials."); + + if (url.hostname.toLowerCase() === "github.com") { + const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/); + if (!match?.[1] || !match[2] || !match[3] || !match[4]) { + throw new Error("GitHub profile URLs must use the /owner/repo/blob/ref/path form."); + } + let ref: string; + try { + ref = decodeURIComponent(match[3]); + } catch { + throw new Error("GitHub profile URL contains an invalid encoded ref."); + } + const encodedRef = ref + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); + url = new URL( + `https://raw.githubusercontent.com/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}/${encodedRef}/${match[4]}` + ); + } + + const warnings: string[] = []; + let immutable = false; + if (url.hostname.toLowerCase() === "raw.githubusercontent.com") { + const segments = url.pathname.split("/").filter(Boolean); + const ref = segments[2]; + immutable = Boolean(ref && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(ref)); + if (!immutable) + warnings.push( + "GitHub origin uses a floating ref; prefer an immutable 40- or 64-character commit URL." + ); + } else { + warnings.push( + "Remote profile origin is not content-addressed; prefer an immutable GitHub commit URL." + ); + } + return { url, warnings, immutable }; +} + +function parseJson(raw: string, context: string): unknown { + try { + return JSON.parse(raw) as unknown; + } catch (error) { + throw new Error( + `Invalid profile JSON from ${context}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +async function readBoundedLocalFile( + path: string, + maxBytes: number, + signal?: AbortSignal +): Promise { + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new Error("Profile byte limit must be a finite non-negative number."); + } + const handle = await open(path, "r"); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + signal?.throwIfAborted(); + const chunk = new Uint8Array(Math.min(64 * 1024, maxBytes - total + 1)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null); + signal?.throwIfAborted(); + if (bytesRead === 0) break; + total += bytesRead; + if (total > maxBytes) throw new Error(`Profile ${path} exceeds the ${maxBytes} byte limit.`); + chunks.push(chunk.subarray(0, bytesRead)); + } + } finally { + await handle.close(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function loadProfileSource( + source: string, + options: { cwd: string; signal?: AbortSignal; timeoutMs?: number; maxBytes?: number } +): Promise { + const requested = source.trim(); + if (!requested) throw new Error("Profile source is required."); + rejectUnsupportedTransport(requested); + const looksLikeUrl = /^[a-z][a-z0-9+.-]*:/i.test(requested) && !/^[a-zA-Z]:[\\/]/.test(requested); + const maxBytes = options.maxBytes ?? MAX_PROFILE_BYTES; + + if (!looksLikeUrl) { + options.signal?.throwIfAborted(); + const path = resolve(options.cwd, requested); + const bytes = await readBoundedLocalFile(path, maxBytes, options.signal); + options.signal?.throwIfAborted(); + const raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { + value: parseJson(raw, path), + raw, + origin: path, + finalOrigin: path, + contentFingerprint: fingerprint(bytes), + immutableOrigin: "not-applicable", + warnings: [], + remote: false, + }; + } + + const normalized = normalizeProfileSourceUrl(requested); + const loaded = await fetchBoundedBytes( + normalized.url.href, + options.timeoutMs ?? PROFILE_FETCH_TIMEOUT_MS, + maxBytes, + options.signal, + "Remote profile" + ); + const raw = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes); + const final = normalizeProfileSourceUrl(loaded.finalUrl.href); + const warnings = [...new Set([...normalized.warnings, ...final.warnings])]; + return { + value: parseJson(raw, normalized.url.href), + raw, + origin: requested, + finalOrigin: loaded.finalUrl.href, + fetchedAt: new Date().toISOString(), + contentFingerprint: fingerprint(loaded.bytes), + immutableOrigin: normalized.immutable && final.immutable, + warnings, + remote: true, + }; +} diff --git a/src/profiles/store.ts b/src/profiles/store.ts new file mode 100644 index 0000000..699b651 --- /dev/null +++ b/src/profiles/store.ts @@ -0,0 +1,266 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { getExtmgrCacheDir } from "../utils/pi-paths.js"; +import { type ExtmgrProfile, normalizeProfile, parseExternalProfile } from "./schema.js"; + +export interface ProfileStoreFile { + version: 1; + profiles: Record; +} + +export interface ProfileRestorePoint { + id: string; + createdAt: number; + reason: string; + profile: ExtmgrProfile; + incomplete?: boolean; +} + +interface RestorePointFile { + version: 1; + restorePoints: ProfileRestorePoint[]; +} + +const MAX_RESTORE_POINTS = 5; +const writeQueues = new Map>(); + +function safeDictionary(): Record { + return Object.create(null) as Record; +} + +function emptyStore(): ProfileStoreFile { + return { version: 1, profiles: safeDictionary() }; +} + +function hasOwn(object: object, key: PropertyKey): boolean { + return Object.hasOwn(object, key); +} + +export function normalizeProfileStore(input: unknown): ProfileStoreFile { + if (!input || typeof input !== "object" || Array.isArray(input)) return emptyStore(); + const value = input as Record; + if (value.version !== 1) return emptyStore(); + const profilesValue = value.profiles; + if (!profilesValue || typeof profilesValue !== "object" || Array.isArray(profilesValue)) { + return emptyStore(); + } + const profiles = safeDictionary(); + for (const [rawName, rawProfile] of Object.entries(profilesValue)) { + const name = rawName.trim(); + if (!name) continue; + const profile = normalizeProfile(rawProfile); + profiles[name] = { ...profile, name }; + } + return { version: 1, profiles }; +} + +function parseProfileStore(input: unknown, path: string): ProfileStoreFile { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error(`Unsupported or malformed profile store: ${path}`); + } + const value = input as Record; + if ( + value.version !== 1 || + !value.profiles || + typeof value.profiles !== "object" || + Array.isArray(value.profiles) + ) { + throw new Error(`Unsupported or malformed profile store: ${path}`); + } + const profiles = safeDictionary(); + for (const [rawName, rawProfile] of Object.entries(value.profiles)) { + const name = rawName.trim(); + if (!name) + throw new Error(`Unsupported or malformed profile store: ${path} (empty profile name)`); + if (hasOwn(profiles, name)) + throw new Error(`Unsupported or malformed profile store: ${path} (duplicate profile name)`); + const migrated = parseExternalProfile( + rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) + ? { ...(rawProfile as Record), name } + : rawProfile + ); + if (!migrated.ok) { + throw new Error( + `Unsupported or malformed profile store: ${path} (${name}: ${migrated.errors.map((issue) => issue.message).join("; ")})` + ); + } + profiles[name] = { ...migrated.profile, name }; + } + return { version: 1, profiles }; +} + +export async function readProfileStore(path: string): Promise { + try { + return parseProfileStore(JSON.parse(await readFile(path, "utf8")) as unknown, path); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyStore(); + if (error instanceof Error && error.message.startsWith("Unsupported or malformed")) throw error; + throw new Error( + `Unable to read profile store ${path}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +async function writeAtomically(path: string, value: unknown, label: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = join( + dirname(path), + `.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.${label}.tmp` + ); + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +async function enqueueWrite(path: string, operation: () => Promise): Promise { + const previous = writeQueues.get(path) ?? Promise.resolve(); + let result!: T; + const next = previous.then(async () => { + result = await operation(); + }); + writeQueues.set( + path, + next.catch(() => undefined) + ); + await next; + return result; +} + +export async function writeProfileStore(path: string, store: ProfileStoreFile): Promise { + const normalized = parseProfileStore(store, path); + await enqueueWrite(path, () => writeAtomically(path, normalized, "profiles")); +} + +export async function saveNamedProfile( + path: string, + profile: ExtmgrProfile, + options?: { replace?: boolean } +): Promise { + return enqueueWrite(path, async () => { + const store = await readProfileStore(path); + if (typeof profile.name !== "string" || !profile.name.trim()) { + throw new Error("Profile name must not be empty."); + } + const normalized = normalizeProfile(profile); + const validated = parseExternalProfile(normalized); + if (!validated.ok) { + throw new Error( + validated.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n") + ); + } + const name = normalized.name.trim(); + if (!name) throw new Error("Profile name must not be empty."); + if (hasOwn(store.profiles, name) && options?.replace !== true) { + throw new Error(`A saved profile named ${name} already exists.`); + } + store.profiles[name] = { ...normalized, name }; + const written = parseProfileStore(store, path); + await writeAtomically(path, written, "profiles"); + return written; + }); +} + +export async function deleteNamedProfile(path: string, name: string): Promise { + return enqueueWrite(path, async () => { + const store = await readProfileStore(path); + if (!hasOwn(store.profiles, name)) return false; + delete store.profiles[name]; + const written = parseProfileStore(store, path); + await writeAtomically(path, written, "profiles"); + return true; + }); +} + +export function getNamedProfile(store: ProfileStoreFile, name: string): ExtmgrProfile | undefined { + return hasOwn(store.profiles, name) ? store.profiles[name] : undefined; +} + +export function getProfileStorePath(): string { + return join(getExtmgrCacheDir(), "profiles.json"); +} + +export function getProfileRestorePointPath(): string { + return join(getExtmgrCacheDir(), "profile-restore-points.json"); +} + +export async function readProfileRestorePoints( + path = getProfileRestorePointPath() +): Promise { + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new Error("malformed restore point store"); + const value = parsed as Record; + if (value.version !== 1 || !Array.isArray(value.restorePoints)) + throw new Error("unsupported restore point store"); + return value.restorePoints.flatMap((raw) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const record = raw as Record; + if ( + typeof record.id !== "string" || + typeof record.createdAt !== "number" || + typeof record.reason !== "string" + ) + return []; + const parsedProfile = parseExternalProfile(record.profile); + if (!parsedProfile.ok) return []; + return [ + { + id: record.id, + createdAt: record.createdAt, + reason: record.reason, + profile: parsedProfile.profile, + ...(record.incomplete === true ? { incomplete: true } : {}), + }, + ]; + }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw new Error( + `Unable to read profile restore points ${path}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export async function saveProfileRestorePoint( + profile: ExtmgrProfile, + reason: string, + options?: { path?: string; incomplete?: boolean } +): Promise { + const path = options?.path ?? getProfileRestorePointPath(); + return enqueueWrite(path, async () => { + const existing = await readProfileRestorePoints(path); + const point: ProfileRestorePoint = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + createdAt: Date.now(), + reason, + profile: normalizeProfile(profile), + ...(options?.incomplete ? { incomplete: true } : {}), + }; + const file: RestorePointFile = { + version: 1, + restorePoints: [...existing, point].slice(-MAX_RESTORE_POINTS), + }; + await writeAtomically(path, file, "restore-points"); + return point; + }); +} + +export async function markProfileRestorePointIncomplete( + id: string, + path = getProfileRestorePointPath() +): Promise { + await enqueueWrite(path, async () => { + const points = await readProfileRestorePoints(path); + const file: RestorePointFile = { + version: 1, + restorePoints: points.map((point) => + point.id === id ? { ...point, incomplete: true } : point + ), + }; + await writeAtomically(path, file, "restore-points"); + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index bbfb462..71d223c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,7 +28,11 @@ export interface NpmPackage { author?: string | undefined; keywords?: string[] | undefined; date?: string | undefined; + weeklyDownloads?: number | undefined; size?: number | undefined; // Package size in bytes + installed?: boolean | undefined; + updateAvailable?: boolean | undefined; + compatibility?: "compatible" | "incompatible" | "unknown" | undefined; } export interface InstalledPackage { @@ -77,6 +81,7 @@ export interface PackageUnifiedItem { size?: number | undefined; // Package size in bytes updateAvailable?: boolean | undefined; extensionSummary?: PackageExtensionStateSummary | undefined; + extensionPaths?: string[] | undefined; } export type UnifiedItem = LocalUnifiedItem | PackageUnifiedItem; @@ -84,24 +89,50 @@ export type UnifiedItem = LocalUnifiedItem | PackageUnifiedItem; export interface SearchCache { query: string; results: NpmPackage[]; + total: number; + offset: number; timestamp: number; } // Action types for unified view +export type WorkspaceScreen = "installed" | "discover" | "profiles" | "health"; + export type UnifiedAction = | { type: "cancel" } + | { type: "workspace"; screen: WorkspaceScreen } | { type: "apply" } | { type: "remote" } | { type: "help" } | { type: "menu" } + | { + type: "bulk"; + itemIds: string[]; + action: "menu" | "update" | "remove" | "enable" | "disable"; + } + | { + type: "views"; + action: "save" | "load" | "delete" | "favorite"; + itemId?: string; + } | { type: "quick"; action: "install" | "search" | "update-all" | "auto-update" } | { type: "action"; itemId: string; - action?: "menu" | "update" | "remove" | "details" | "configure"; + action?: + | "menu" + | "update" + | "remove" + | "details" + | "configure" + | "enable" + | "disable" + | "compare" + | "move-global" + | "move-project"; }; export type BrowseAction = + | { type: "workspace"; screen: WorkspaceScreen } | { type: "package"; name: string } | { type: "prev" } | { type: "next" } diff --git a/src/ui/async-task.ts b/src/ui/async-task.ts index 00c4111..b9fb5ad 100644 --- a/src/ui/async-task.ts +++ b/src/ui/async-task.ts @@ -26,6 +26,46 @@ export interface TaskControls { setMessage: (message: string) => void; } +export interface GenerationRequest { + id: number; + signal: AbortSignal; + isCurrent: () => boolean; + commit: (apply: () => void) => boolean; +} + +export class RequestGeneration { + private generation = 0; + private controller: AbortController | undefined; + + begin(parentSignal?: AbortSignal): GenerationRequest { + this.controller?.abort(); + const controller = new AbortController(); + this.controller = controller; + const id = ++this.generation; + const signal = parentSignal + ? AbortSignal.any([parentSignal, controller.signal]) + : controller.signal; + const isCurrent = (): boolean => + id === this.generation && this.controller === controller && !signal.aborted; + return { + id, + signal, + isCurrent, + commit: (apply) => { + if (!isCurrent()) return false; + apply(); + return true; + }, + }; + } + + cancel(): void { + this.controller?.abort(); + this.controller = undefined; + this.generation += 1; + } +} + interface LoaderConfig { title: string; message: string; @@ -137,6 +177,7 @@ export async function runTaskWithLoader( task({ signal, setMessage: (message) => { + if (finished || signal.aborted) return; loader.setMessage(message); tui.requestRender(); }, diff --git a/src/ui/discover/browser.ts b/src/ui/discover/browser.ts new file mode 100644 index 0000000..952c819 --- /dev/null +++ b/src/ui/discover/browser.ts @@ -0,0 +1,344 @@ +/** Interactive browse component for the Discover workspace. */ +import { type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent"; +import { + type Focusable, + Input, + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; +import { PAGE_SIZE } from "../../constants.js"; +import { type RemotePackageSort, sortRemotePackages } from "../../packages/sorting.js"; +import { type BrowseAction, type NpmPackage } from "../../types/index.js"; +import { truncate } from "../../utils/format.js"; +import { activeKeyHint } from "../../utils/key-hints.js"; +import { composeColumns, formatCompactCount, TWO_PANE_MIN_WIDTH } from "../layout.js"; +import { getCenteredVisibleRange, moveListSelection } from "../list-navigation.js"; +import { buildWorkspaceNavigation, matchWorkspaceNavigation } from "../workspace/navigation.js"; +import { formatRemotePackageDetails, formatRemotePackageLabel } from "./formatting.js"; +import { type RemoteBrowseSource } from "./query.js"; + +export class RemotePackageBrowser implements Focusable { + private readonly searchInput = new Input(); + private selectedIndex = 0; + private searchActive = false; + private sortMode: RemotePackageSort = "downloads"; + private readonly originalPackages: NpmPackage[]; + private _focused = false; + + constructor( + private packages: NpmPackage[], + private readonly theme: Theme, + private readonly keybindings: KeybindingsManager, + 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.originalPackages = [...packages]; + this.packages = sortRemotePackages(this.originalPackages, this.sortMode); + 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(); + } + + /** Re-apply the active sort after background metadata hydration lands. */ + refreshSort(): void { + if (this.sortMode === "relevance") return; + const selectedName = this.packages[this.selectedIndex]?.name; + this.packages = sortRemotePackages(this.originalPackages, this.sortMode); + this.selectedIndex = Math.max( + 0, + this.packages.findIndex((pkg) => pkg.name === selectedName) + ); + } + + handleBrowseInput(data: string): boolean { + const workspaceScreen = matchWorkspaceNavigation(data, "discover"); + if (workspaceScreen) { + this.onAction({ type: "workspace", screen: workspaceScreen }); + return true; + } + + if (this.searchActive) { + if (this.keybindings.matches(data, "tui.select.confirm")) { + this.searchActive = false; + this.searchInput.focused = false; + this.onAction({ type: "search", query: this.searchInput.getValue().trim() }); + return true; + } + + if (this.keybindings.matches(data, "tui.select.cancel")) { + 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 (this.keybindings.matches(data, "tui.select.up")) { + this.moveSelection(-1, true); + return true; + } + + if (this.keybindings.matches(data, "tui.select.down")) { + this.moveSelection(1, true); + return true; + } + + if (this.keybindings.matches(data, "tui.select.pageUp")) { + this.moveSelection(-Math.max(1, this.maxVisibleItems - 1)); + return true; + } + + if (this.keybindings.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 && this.keybindings.matches(data, "tui.select.confirm")) { + 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 === "o" || data === "O") { + const modes: RemotePackageSort[] = ["downloads", "recent", "name", "relevance"]; + const nextIndex = (modes.indexOf(this.sortMode) + 1) % modes.length; + this.sortMode = modes[nextIndex] ?? "downloads"; + const selectedName = selected?.name; + this.packages = sortRemotePackages(this.originalPackages, this.sortMode); + this.selectedIndex = Math.max( + 0, + this.packages.findIndex((pkg) => pkg.name === selectedName) + ); + 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 (this.keybindings.matches(data, "tui.select.cancel")) { + this.onAction({ type: "cancel" }); + return true; + } + + return false; + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const lines: string[] = [ + truncateToWidth(buildWorkspaceNavigation(this.theme, "discover"), safeWidth, ""), + "", + ]; + + if (this.searchActive) { + lines.push(...this.searchInput.render(safeWidth)); + lines.push(""); + } else if (!this.queryLabel) { + lines.push( + truncateToWidth( + this.theme.fg( + "muted", + this.browseSource === "community" + ? " Community packages · / search" + : " Remote search results · / search" + ), + safeWidth, + "" + ), + "" + ); + } + + lines.push(truncateToWidth(this.buildSummaryLine(), safeWidth, "")); + lines.push(""); + + const listLines: string[] = []; + if (this.packages.length === 0) { + listLines.push( + truncateToWidth( + this.theme.fg("warning", " No packages found. Try / search or Esc to go back."), + safeWidth, + "" + ) + ); + } + + const { startIndex, endIndex } = this.getVisibleRange(); + for (const pkg of this.packages.slice(startIndex, endIndex)) { + listLines.push(this.renderPackageLine(pkg, safeWidth)); + } + + if (startIndex > 0 || endIndex < this.packages.length) { + listLines.push(""); + listLines.push( + this.theme.fg( + "dim", + ` Showing ${startIndex + 1}-${endIndex} of ${this.packages.length} on this page` + ) + ); + } + + const selected = this.packages[this.selectedIndex]; + if (selected && safeWidth >= TWO_PANE_MIN_WIDTH) { + const detailWidth = Math.max(1, Math.floor((safeWidth - 3) * 0.38)); + lines.push( + ...composeColumns( + listLines, + this.renderInspector(selected, detailWidth), + safeWidth, + this.theme.fg("borderMuted", " │ ") + ) + ); + } else { + lines.push(...listLines.map((line) => truncateToWidth(line, safeWidth, ""))); + if (selected) { + lines.push(""); + const detailText = formatRemotePackageDetails( + selected, + this.offset + this.selectedIndex + 1, + this.totalResults + ); + for (const line of wrapTextWithAnsi(detailText, Math.max(1, safeWidth - 4))) { + lines.push(truncateToWidth(this.theme.fg("dim", ` ${line}`), safeWidth, "")); + } + } + } + + lines.push(""); + lines.push(truncateToWidth(this.buildFooterLine(), safeWidth, "")); + 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"; + const sortLabel = this.sortMode === "popular" ? "downloads" : this.sortMode; + return ` ${this.theme.fg("accent", label)} • ${this.theme.fg("muted", `${this.offset + 1}-${rangeEnd} of ${this.totalResults}`)} • ${this.theme.fg("muted", `page ${pageNumber}/${pageCount}`)} • ${this.theme.fg("muted", `sort:${sortLabel}`)}`; + } + + private buildFooterLine(): string { + const parts = [activeKeyHint(this.keybindings, "tui.select.confirm", "details"), "/ search"]; + + if (this.showPrevious) parts.push("p previous"); + if (this.showLoadMore) parts.push("n next"); + parts.push("o sort", "m commands"); + parts.push(activeKeyHint(this.keybindings, "tui.select.cancel", "back")); + return ` ${this.theme.fg("dim", parts.join(" · "))}`; + } + + private renderInspector(pkg: NpmPackage, width: number): string[] { + const contentWidth = Math.max(1, width - 2); + const lines = [ + this.theme.fg("accent", this.theme.bold("Package details")), + "", + this.theme.bold(pkg.name), + pkg.version ? this.theme.fg("muted", `Version ${pkg.version}`) : "", + "", + ]; + + for (const line of wrapTextWithAnsi( + pkg.description || "No description provided.", + contentWidth + )) { + lines.push(line); + } + + lines.push(""); + const downloads = formatCompactCount(pkg.weeklyDownloads); + lines.push( + `${this.theme.fg("dim", "Weekly downloads")} ${downloads ?? this.theme.fg("dim", "unknown")}`, + `${this.theme.fg("dim", "Author")} ${pkg.author ? `by ${pkg.author}` : "unknown"}`, + `${this.theme.fg("dim", "Updated")} ${pkg.date?.slice(0, 10) ?? "unknown"}`, + `${this.theme.fg("dim", "Compatibility")} ${pkg.compatibility ?? "unknown"}` + ); + + if (pkg.keywords?.length) { + lines.push("", this.theme.fg("dim", pkg.keywords.slice(0, 6).join(" · "))); + } + if (pkg.installed) lines.push("", this.theme.fg("success", "Installed")); + if (pkg.updateAvailable) lines.push(this.theme.fg("warning", "Update available")); + + return lines.map((line) => truncateToWidth(` ${line}`, width, "")); + } + + 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, wrap = false): void { + this.selectedIndex = moveListSelection(this.selectedIndex, delta, this.packages.length, { + wrap, + }); + } + + private getVisibleRange(): { startIndex: number; endIndex: number } { + return getCenteredVisibleRange(this.selectedIndex, this.packages.length, this.maxVisibleItems); + } +} diff --git a/src/ui/discover/formatting.ts b/src/ui/discover/formatting.ts new file mode 100644 index 0000000..6190eb0 --- /dev/null +++ b/src/ui/discover/formatting.ts @@ -0,0 +1,36 @@ +/** Pure formatting helpers for Discover browse rows and detail panes. */ +import { type Theme } from "@earendil-works/pi-coding-agent"; +import { type NpmPackage } from "../../types/index.js"; +import { formatCompactCount } from "../layout.js"; +import { formatCount } from "./metadata.js"; + +export function formatRemotePackageLabel(pkg: NpmPackage, theme: Theme): string { + const name = theme.bold(pkg.name); + const version = pkg.version ? theme.fg("dim", `@${pkg.version}`) : ""; + const downloads = formatCompactCount(pkg.weeklyDownloads); + const badges = [ + pkg.installed ? theme.fg("success", "installed") : undefined, + pkg.updateAvailable ? theme.fg("warning", "update") : undefined, + ].filter(Boolean); + const popularity = downloads ? theme.fg("muted", ` · ${downloads}/wk`) : ""; + return `${name}${version}${popularity}${badges.length ? ` [${badges.join(" · ")}]` : ""}`; +} + +export function formatRemotePackageDetails( + pkg: NpmPackage, + selectedNumber: number, + totalResults: number +): string { + const parts = [ + pkg.description || "No description", + pkg.author ? `by ${pkg.author}` : undefined, + pkg.weeklyDownloads !== undefined + ? `${formatCount(pkg.weeklyDownloads)} downloads/week` + : 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(" • "); +} diff --git a/src/ui/discover/metadata.ts b/src/ui/discover/metadata.ts new file mode 100644 index 0000000..d0f2c66 --- /dev/null +++ b/src/ui/discover/metadata.ts @@ -0,0 +1,186 @@ +/** Package metadata caching and retrieval for the Discover workspace. */ +import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { CACHE_LIMITS, TIMEOUTS } from "../../constants.js"; +import { fetchNpmWeeklyDownloads } from "../../packages/discovery.js"; +import { inspectPackageMetadata } from "../../packages/inspection.js"; +import { validateCompatibility } from "../../doctor/compatibility.js"; +import { createAbortError, throwIfAborted } from "../../utils/abort.js"; +import { formatBytes } from "../../utils/format.js"; +import { execNpm } from "../../utils/npm-exec.js"; +import { RequestGeneration } from "../async-task.js"; + +interface PackageInfoCacheEntry { + timestamp: number; + text: string; +} + +interface NpmViewInfo { + description?: string; + version?: string; + author?: { name?: string } | string; + homepage?: string; + users?: Record; + dist?: { unpackedSize?: number }; + repository?: { url?: string } | string; + dependencies?: Record; + engines?: { node?: string; pi?: string }; +} + +// LRU Cache with size limit to prevent memory leaks +class PackageInfoCache { + private cache = new Map(); + private readonly maxSize: number; + private readonly ttl: number; + + constructor(maxSize: number, ttl: number) { + this.maxSize = maxSize; + this.ttl = ttl; + } + + get(name: string): PackageInfoCacheEntry | undefined { + const entry = this.cache.get(name); + if (!entry) return undefined; + + // Check if expired + if (Date.now() - entry.timestamp > this.ttl) { + this.cache.delete(name); + return undefined; + } + + // Move to end (most recently used) + this.cache.delete(name); + this.cache.set(name, entry); + return entry; + } + + set(name: string, entry: Omit): void { + if (this.cache.has(name)) { + this.cache.delete(name); + } else if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + if (firstKey) { + this.cache.delete(firstKey); + } + } + + this.cache.set(name, { + ...entry, + timestamp: Date.now(), + }); + } + + clear(): void { + this.cache.clear(); + } +} + +// Global LRU cache instance +export const packageInfoCache = new PackageInfoCache( + CACHE_LIMITS.packageInfoMaxSize, + CACHE_LIMITS.packageInfoTTL +); +const packageInfoRequests = new RequestGeneration(); + +export function clearRemotePackageInfoCache(): void { + packageInfoRequests.cancel(); + packageInfoCache.clear(); +} + +export function formatCount(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "unknown"; + return new Intl.NumberFormat().format(value); +} + +async function fetchWeeklyDownloads( + packageName: string, + signal?: AbortSignal +): Promise { + return (await fetchNpmWeeklyDownloads([packageName], signal)).get(packageName); +} + +export async function buildPackageInfoText( + packageName: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + signal?: AbortSignal +): Promise { + // Check cache first + const cached = packageInfoCache.get(packageName); + if (cached) { + return cached.text; + } + + const request = packageInfoRequests.begin(signal); + const [infoRes, weeklyDownloads] = await Promise.all([ + execNpm(pi, ["view", packageName, "--json"], ctx, { + timeout: TIMEOUTS.npmView, + signal: request.signal, + }), + fetchWeeklyDownloads(packageName, request.signal), + ]); + + throwIfAborted(request.signal); + + if (infoRes.code !== 0) { + throw new Error(infoRes.stderr || infoRes.stdout || `npm view failed (exit ${infoRes.code})`); + } + + const info = JSON.parse(infoRes.stdout) as NpmViewInfo; + const description = info.description ?? "No description"; + const version = info.version ?? "unknown"; + const author = typeof info.author === "object" ? info.author?.name : (info.author ?? "unknown"); + const homepage = info.homepage ?? ""; + const stars = info.users ? Object.keys(info.users).length : undefined; + const unpackedSize = info.dist?.unpackedSize; + const repository = typeof info.repository === "string" ? info.repository : info.repository?.url; + const compatibility = + info.engines?.node || info.engines?.pi + ? (() => { + const diagnostic = validateCompatibility({ + packageName, + ...(info.engines?.node ? { engines: { node: info.engines.node } } : {}), + ...(info.engines?.pi ? { requiredPi: info.engines.pi } : {}), + nodeVersion: process.version, + }); + if (diagnostic.node === "incompatible" || diagnostic.pi === "incompatible") { + return "incompatible" as const; + } + if (diagnostic.node === "unknown" || diagnostic.pi === "unknown") { + return "unknown" as const; + } + return "compatible" as const; + })() + : undefined; + const inspection = inspectPackageMetadata({ + name: packageName, + ...(info.version ? { version: info.version } : {}), + ...(info.description ? { description: info.description } : {}), + ...(info.dependencies ? { dependencies: info.dependencies } : {}), + ...(repository ? { repository } : {}), + ...(compatibility ? { compatibility } : {}), + }); + + const lines = [ + `${packageName}@${version}`, + description, + `Author: ${author}`, + `Weekly downloads: ${formatCount(weeklyDownloads)}`, + `Stars: ${formatCount(stars)}`, + `Unpacked size: ${typeof unpackedSize === "number" ? formatBytes(unpackedSize) : "unknown"}`, + `Dependencies: ${inspection.dependencies.length > 0 ? inspection.dependencies.join(", ") : "none declared"}`, + `Compatibility: ${inspection.compatibility}`, + `Provenance: ${inspection.provenance}`, + ]; + + if (homepage) lines.push(`Homepage: ${homepage}`); + if (repository) lines.push(`Repository: ${repository}`); + + const text = lines.join("\n"); + + throwIfAborted(request.signal); + if (!request.commit(() => packageInfoCache.set(packageName, { text }))) { + throw createAbortError(); + } + + return text; +} diff --git a/src/ui/discover/query.ts b/src/ui/discover/query.ts new file mode 100644 index 0000000..cda0a20 --- /dev/null +++ b/src/ui/discover/query.ts @@ -0,0 +1,102 @@ +import { type NpmPackage } from "../../types/index.js"; +import { normalizePackageSource, parseNpmSource, truncate } from "../../utils/format.js"; +import { getPackageSourceKind } from "../../utils/package-source.js"; + +export const COMMUNITY_BROWSE_QUERY = "keywords:pi-package"; +export type RemoteBrowseSource = "community" | "npm"; +export 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; +} + +export 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: `"${truncate(trimmed, 60)}" looks like a ${sourceKind === "local" ? "local path" : "git source"}. Remote browse searches npm package names and keywords. Use Install by source instead.`, + }; + } + const exactPackageName = findExactPackageLookup(trimmed); + return { + kind: "search", + rawQuery: trimmed, + searchQuery: exactPackageName ?? trimmed, + displayQuery: trimmed, + title: "Remote packages", + ...(exactPackageName ? { exactPackageName } : {}), + }; +} + +export 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} ${trimmed}`, + displayQuery: trimmed, + title: "Community packages", + }; +} + +export function resolveRemoteBrowseSource( + query: string, + source?: RemoteBrowseSource +): RemoteBrowseSource { + if (source) return source; + const trimmed = query.trim(); + return !trimmed || trimmed === COMMUNITY_BROWSE_QUERY ? "community" : "npm"; +} + +export function filterRemoteBrowseResults( + plan: Exclude, + packages: NpmPackage[] +): NpmPackage[] { + if (plan.kind !== "search" || !plan.exactPackageName) return packages; + return packages.filter((pkg) => pkg.name.toLowerCase() === plan.exactPackageName); +} diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 0218063..a9b3e2f 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -1,20 +1,25 @@ /** * Footer helpers for the unified extension manager UI */ +import { type KeybindingsManager } from "@earendil-works/pi-coding-agent"; import { type State, type UnifiedItem } from "../types/index.js"; +import { activeKeyHint } from "../utils/key-hints.js"; export interface FooterState { selectedType?: UnifiedItem["type"]; pendingChanges: number; + selectedPackages: number; } export function buildFooterState( staged: Map, byId: Map, - selectedItem?: UnifiedItem + selectedItem?: UnifiedItem, + selectedPackages = 0 ): FooterState { const state: FooterState = { pendingChanges: getPendingToggleChangeCount(staged, byId), + selectedPackages, }; if (selectedItem) { @@ -45,39 +50,18 @@ export function getPendingToggleChangeCount( /** * Build contextual keyboard shortcuts text for the footer. */ -export function buildFooterShortcuts(state: FooterState): string { - const parts: string[] = []; +export function buildFooterShortcuts(state: FooterState, keybindings?: KeybindingsManager): string { + const confirm = keybindings + ? activeKeyHint(keybindings, "tui.select.confirm", "actions") + : "Enter actions"; + const cancel = keybindings ? activeKeyHint(keybindings, "tui.select.cancel", "back") : "Esc back"; + const parts = ["↑↓ move", confirm]; - 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"); + if (state.selectedType === "local") parts.push("Space toggle"); + if (state.selectedType === "package") parts.push("Space select"); + if (state.selectedPackages > 0) parts.push(`B act on ${state.selectedPackages}`); + if (state.pendingChanges > 0) parts.push(`S save ${state.pendingChanges}`); + parts.push("/ search", "1-7 filter", "i install", "Tab screens", "? help", cancel); return parts.join(" · "); } diff --git a/src/ui/health.ts b/src/ui/health.ts new file mode 100644 index 0000000..51f69b3 --- /dev/null +++ b/src/ui/health.ts @@ -0,0 +1,539 @@ +/** Operational health screen: compatibility, conflicts, reload state, and trash. */ +import { join } from "node:path"; +import { + DynamicBorder, + type ExtensionAPI, + type ExtensionCommandContext, + getAgentDir, + type Theme, +} from "@earendil-works/pi-coding-agent"; +import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import { handleTrashSubcommand } from "../commands/trash.js"; +import { inspectInstalledPackageCompatibility } from "../doctor/compatibility.js"; +import { findRuntimeConflicts, type RuntimeConflict } from "../doctor/conflicts.js"; +import { getRuntimeOwners, type RuntimeOwner } from "../doctor/runtime.js"; +import { discoverExtensions, setExtensionState } from "../extensions/discovery.js"; +import { listExtensionTrash, type TrashRecord } from "../extensions/trash.js"; +import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; +import { removePackageWithOutcome } from "../packages/management.js"; +import { movePackageBetweenScopes } from "../packages/scopes.js"; +import { type InstalledPackage } from "../types/index.js"; +import { isProjectTrusted, requireCustomUI, runCustomUI } from "../utils/mode.js"; +import { notify } from "../utils/notify.js"; +import { normalizePackageIdentity } from "../utils/package-source.js"; +import { normalizePathIdentity } from "../utils/path-identity.js"; +import { + clearReloadRequired, + type ReloadRequiredState, + readReloadState, +} from "../utils/reload-state.js"; +import { confirmReload, markContextReloaded } from "../utils/ui-helpers.js"; +import { getStatusIcon } from "./theme.js"; +import { + buildWorkspaceNavigation, + matchWorkspaceNavigation, + type WorkspaceExit, +} from "./workspace/navigation.js"; + +function getTrashRoot(): string { + return join(getAgentDir(), ".extmgr-trash"); +} + +type HealthAction = + | { type: "refresh" } + | { type: "trash" } + | { type: "reload" } + | { type: "conflict" } + | { type: "fix-safe" } + | { type: "back" } + | { type: "workspace"; screen: "installed" | "discover" | "profiles" }; + +type LocalExtensionEntry = Awaited>[number]; + +interface HealthSnapshot { + owners: number; + conflicts: RuntimeConflict[]; + compatibility: Awaited>; + reload: ReloadRequiredState; + trash: TrashRecord[]; +} + +async function loadHealthSnapshot( + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + const packages = await getInstalledPackagesAllScopes(ctx); + const owners = getRuntimeOwners(pi); + return { + owners: owners.length, + conflicts: findRuntimeConflicts(owners), + compatibility: await inspectInstalledPackageCompatibility(packages), + reload: await readReloadState(), + trash: await listExtensionTrash(getTrashRoot()), + }; +} + +function ownerMatchesPackage(owner: RuntimeOwner, pkg: InstalledPackage): boolean { + // Direct string forms Pi uses for package-owned commands/tools. + if (owner.source === pkg.source || owner.source === pkg.name) return true; + if (owner.path === pkg.source) return true; + + // Normalized identity comparison covers version-suffixed and case variants. + const pkgIdentity = normalizePackageIdentity( + pkg.source, + pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined + ); + if (normalizePackageIdentity(owner.source) === pkgIdentity) return true; + + // Path containment: the owner entrypoint lives inside the package install dir. + if (pkg.resolvedPath && owner.path) { + const ownerPath = normalizePathIdentity(owner.path); + const packageRoot = normalizePathIdentity(pkg.resolvedPath); + if (ownerPath === packageRoot || ownerPath.startsWith(`${packageRoot}/`)) return true; + } + + return false; +} + +function ownerMatchesLocalExtension(owner: RuntimeOwner, entry: LocalExtensionEntry): boolean { + const candidates = [entry.activePath, entry.disabledPath].map(normalizePathIdentity); + const ownerPath = owner.path ? normalizePathIdentity(owner.path) : ""; + const ownerSource = owner.source ? normalizePathIdentity(owner.source) : ""; + return candidates.some((candidate) => candidate === ownerPath || candidate === ownerSource); +} + +/** Installed packages that own at least one side of the conflict. */ +export function findConflictPackageOwners( + conflict: RuntimeConflict, + packages: InstalledPackage[] +): InstalledPackage[] { + return packages.filter((pkg) => conflict.owners.some((owner) => ownerMatchesPackage(owner, pkg))); +} + +/** Local extensions that own at least one side of the conflict. */ +export function findConflictLocalOwners( + conflict: RuntimeConflict, + localEntries: LocalExtensionEntry[] +): LocalExtensionEntry[] { + return localEntries.filter((entry) => + conflict.owners.some((owner) => ownerMatchesLocalExtension(owner, entry)) + ); +} + +export interface SafeConflictFix { + conflict: string; + extension: LocalExtensionEntry; +} + +/** + * Deterministic, reversible fixes only: disable enabled local extensions that + * shadow a package-provided command or tool. Never removes packages. + */ +export function planSafeConflictFixes( + conflicts: RuntimeConflict[], + localEntries: LocalExtensionEntry[] +): SafeConflictFix[] { + const fixes: SafeConflictFix[] = []; + const seenPaths = new Set(); + + for (const conflict of conflicts) { + const hasPackageOwner = conflict.owners.some((owner) => owner.origin === "package"); + if (!hasPackageOwner) continue; + + for (const entry of findConflictLocalOwners(conflict, localEntries)) { + if (entry.state !== "enabled") continue; + const key = normalizePathIdentity(entry.activePath); + if (seenPaths.has(key)) continue; + seenPaths.add(key); + fixes.push({ conflict: `${conflict.kind} ${conflict.name}`, extension: entry }); + } + } + + return fixes; +} + +function renderHealthLines(snapshot: HealthSnapshot, width: number, theme: Theme): string[] { + const safeWidth = Math.max(1, width); + const lines: string[] = []; + const incompatible = snapshot.compatibility.filter( + (diagnostic) => diagnostic.node === "incompatible" || diagnostic.pi === "incompatible" + ); + const healthOk = + snapshot.conflicts.length === 0 && incompatible.length === 0 && !snapshot.reload.required; + + lines.push( + truncateToWidth(buildWorkspaceNavigation(theme, "health"), safeWidth, ""), + "", + truncateToWidth( + `${getStatusIcon(theme, healthOk ? "success" : "warning")} ${theme.bold("Health")}`, + safeWidth, + "" + ), + truncateToWidth( + theme.fg( + "muted", + healthOk ? "Runtime looks healthy." : "Review the findings below before continuing." + ), + safeWidth, + "" + ), + "" + ); + + lines.push(truncateToWidth(theme.fg("accent", theme.bold("Runtime")), safeWidth, "")); + lines.push(truncateToWidth(` ${snapshot.owners} command/tool entries loaded`, safeWidth, "")); + lines.push( + truncateToWidth( + ` ${snapshot.conflicts.length === 0 ? theme.fg("success", "No conflicts detected") : theme.fg("warning", `${snapshot.conflicts.length} conflict${snapshot.conflicts.length === 1 ? "" : "s"} detected`)}`, + safeWidth, + "" + ) + ); + for (const conflict of snapshot.conflicts.slice(0, 5)) { + lines.push( + truncateToWidth( + ` • ${conflict.kind} ${conflict.name} · ${conflict.owners.map((owner) => owner.source).join(", ")}`, + safeWidth, + "" + ) + ); + } + if (snapshot.conflicts.length > 5) { + lines.push(truncateToWidth(` … and ${snapshot.conflicts.length - 5} more`, safeWidth, "")); + } + + lines.push("", truncateToWidth(theme.fg("accent", theme.bold("Compatibility")), safeWidth, "")); + if (snapshot.compatibility.length === 0) { + lines.push(truncateToWidth(theme.fg("dim", " No installed packages"), safeWidth, "")); + } else { + for (const diagnostic of snapshot.compatibility.slice(0, 8)) { + const status = + diagnostic.node === "incompatible" || diagnostic.pi === "incompatible" + ? theme.fg("error", "incompatible") + : diagnostic.node === "unknown" || diagnostic.pi === "unknown" + ? theme.fg("muted", "unknown") + : theme.fg("success", "compatible"); + lines.push( + truncateToWidth(` ${status} · ${diagnostic.source} (${diagnostic.scope})`, safeWidth, "") + ); + for (const reason of diagnostic.reasons) { + lines.push(truncateToWidth(` ${reason}`, safeWidth, "")); + } + } + if (snapshot.compatibility.length > 8) { + lines.push( + truncateToWidth(` … and ${snapshot.compatibility.length - 8} more`, safeWidth, "") + ); + } + } + + lines.push("", truncateToWidth(theme.fg("accent", theme.bold("Reload")), safeWidth, "")); + if (snapshot.reload.required) { + lines.push(truncateToWidth(theme.fg("warning", " Reload required"), safeWidth, "")); + for (const reason of snapshot.reload.reasons) { + lines.push(truncateToWidth(` • ${reason}`, safeWidth, "")); + } + } else { + lines.push( + truncateToWidth(theme.fg("success", " Pi is up to date with extmgr changes"), safeWidth, "") + ); + } + + lines.push("", truncateToWidth(theme.fg("accent", theme.bold("Trash")), safeWidth, "")); + if (snapshot.trash.length === 0) { + lines.push( + truncateToWidth(theme.fg("dim", " No recoverable local extensions"), safeWidth, "") + ); + } else { + lines.push( + truncateToWidth( + ` ${snapshot.trash.length} recoverable extension${snapshot.trash.length === 1 ? "" : "s"}`, + safeWidth, + "" + ) + ); + for (const record of snapshot.trash.slice(0, 5)) { + for (const line of wrapTextWithAnsi(`• ${record.originalPath}`, Math.max(1, safeWidth - 2))) { + lines.push(truncateToWidth(` ${line}`, safeWidth, "")); + } + } + } + + lines.push( + "", + truncateToWidth( + theme.fg( + "dim", + "c conflict actions · f fix safe issues · r refresh · l reload · t trash actions · Esc back" + ), + safeWidth, + "" + ) + ); + return lines; +} + +async function showHealthPanel( + snapshot: HealthSnapshot, + ctx: ExtensionCommandContext +): Promise { + return runCustomUI(ctx, "Health", () => + ctx.ui.custom((tui, theme, keybindings, done) => { + const border = new DynamicBorder((s: string) => theme.fg("accent", s)); + let focused = false; + const component = { + get focused() { + return focused; + }, + set focused(value: boolean) { + focused = value; + }, + render(width: number) { + return [ + ...border.render(width), + ...renderHealthLines(snapshot, width, theme), + ...border.render(width), + ]; + }, + invalidate() { + border.invalidate(); + }, + handleInput(data: string) { + const screen = matchWorkspaceNavigation(data, "health"); + if (screen) { + if (screen !== "health") done({ type: "workspace", screen }); + return; + } + if (data === "r" || data === "R") { + done({ type: "refresh" }); + return; + } + if (data === "c" || data === "C") { + done({ type: "conflict" }); + return; + } + if (data === "f" || data === "F") { + done({ type: "fix-safe" }); + return; + } + if (data === "l" || data === "L") { + done({ type: "reload" }); + return; + } + if (data === "t" || data === "T") { + done({ type: "trash" }); + return; + } + if (keybindings.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape)) { + done({ type: "back" }); + return; + } + tui.requestRender(); + }, + }; + return component; + }) + ); +} + +async function handleFixSafeIssues( + snapshot: HealthSnapshot, + ctx: ExtensionCommandContext +): Promise { + const localEntries = await discoverExtensions(ctx.cwd); + const fixes = planSafeConflictFixes(snapshot.conflicts, localEntries); + if (fixes.length === 0) { + notify( + ctx, + "No safe automatic fixes are available. Packages are never removed automatically; use conflict actions for manual remediation.", + "info" + ); + return false; + } + + const summary = fixes + .map((fix) => `- Disable local ${fix.extension.displayName} (${fix.conflict})`) + .join("\n"); + const confirmed = await ctx.ui.confirm( + "Fix all safe issues", + `The following deterministic, reversible fixes will run:\n\n${summary}\n\nPackages are never removed automatically. Continue?` + ); + if (!confirmed) return false; + + const errors: string[] = []; + let changed = 0; + for (const fix of fixes) { + const result = await setExtensionState( + { activePath: fix.extension.activePath, disabledPath: fix.extension.disabledPath }, + "disabled" + ); + if (result.ok) changed += 1; + else errors.push(`${fix.extension.displayName}: ${result.error}`); + } + + if (errors.length > 0) { + notify( + ctx, + `Applied ${changed} fix(es), ${errors.length} failed:\n${errors.join("\n")}`, + "warning" + ); + } else { + notify(ctx, `Disabled ${changed} conflicting local extension(s).`, "info"); + } + if (changed === 0) return false; + return confirmReload(ctx, "Conflicting local extensions disabled."); +} + +async function handleConflictAction( + snapshot: HealthSnapshot, + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + let reloaded = false; + if (snapshot.conflicts.length === 0) { + notify(ctx, "No runtime conflicts need remediation.", "info"); + return reloaded; + } + + const conflictChoice = await ctx.ui.select( + "Select a conflict", + snapshot.conflicts.map((conflict) => `${conflict.kind} ${conflict.name}`) + ); + const conflict = snapshot.conflicts.find( + (candidate) => `${candidate.kind} ${candidate.name}` === conflictChoice + ); + if (!conflict) return reloaded; + + const packages = await getInstalledPackagesAllScopes(ctx); + const packageCandidates = findConflictPackageOwners(conflict, packages); + const localEntries = await discoverExtensions(ctx.cwd); + const localCandidates = findConflictLocalOwners(conflict, localEntries); + + const actions = new Map Promise>(); + actions.set("Inspect owners", async () => { + notify( + ctx, + conflict.owners + .map( + (owner) => + `${owner.kind} ${owner.name}\n ${owner.source} (${owner.scope})\n ${owner.path}` + ) + .join("\n"), + "info" + ); + }); + for (const pkg of packageCandidates) { + actions.set(`Remove package ${pkg.name} (${pkg.scope})`, async () => { + if ( + await ctx.ui.confirm( + "Remove conflicting package", + `Remove ${pkg.source} from ${pkg.scope}?` + ) + ) { + const outcome = await removePackageWithOutcome(pkg.source, ctx, pi); + reloaded ||= outcome.reloaded; + } + }); + const targetScope = pkg.scope === "global" ? "project" : "global"; + actions.set(`Move ${pkg.name} to ${targetScope}`, async () => { + if ( + !(await ctx.ui.confirm( + "Move conflicting package", + `Move ${pkg.source} from ${pkg.scope} to ${targetScope}?` + )) + ) { + return; + } + const result = await movePackageBetweenScopes( + pkg.source, + pkg.scope, + targetScope, + ctx.cwd, + isProjectTrusted(ctx) + ); + if (!result.moved) notify(ctx, result.conflict ?? "Package scope move failed.", "error"); + else reloaded = await confirmReload(ctx, "Package conflict scope changed."); + }); + } + for (const entry of localCandidates) { + actions.set(`Disable local ${entry.displayName}`, async () => { + if ( + await ctx.ui.confirm( + "Disable conflicting extension", + `Disable ${entry.displayName}? This changes its local extension state.` + ) + ) { + const result = await setExtensionState( + { activePath: entry.activePath, disabledPath: entry.disabledPath }, + "disabled" + ); + if (!result.ok) notify(ctx, result.error, "error"); + else reloaded = await confirmReload(ctx, "Conflicting local extension disabled."); + } + }); + } + + const choice = await ctx.ui.select("Conflict remediation", [...actions.keys(), "Back"]); + const action = choice ? actions.get(choice) : undefined; + if (action) await action(); + return reloaded; +} + +export async function showHealth( + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + if ( + !requireCustomUI( + ctx, + "Health", + "Use `/extensions doctor` and `/extensions trash` outside the full TUI." + ) + ) { + return undefined; + } + + while (true) { + let snapshot: HealthSnapshot; + try { + snapshot = await loadHealthSnapshot(ctx, pi); + } catch (error) { + notify( + ctx, + `Health check failed: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + return undefined; + } + + const action = await showHealthPanel(snapshot, ctx); + if (!action || action.type === "back") return undefined; + if (action.type === "workspace") return action.screen; + if (action.type === "refresh") continue; + if (action.type === "reload") { + await ctx.reload(); + markContextReloaded(ctx); + await clearReloadRequired(); + return "reloaded"; + } + if (action.type === "fix-safe") { + if (await handleFixSafeIssues(snapshot, ctx)) return "reloaded"; + continue; + } + if (action.type === "conflict") { + if (await handleConflictAction(snapshot, ctx, pi)) return "reloaded"; + continue; + } + + await handleTrashSubcommand(["list"], ctx, pi); + if (snapshot.trash.length > 0) { + const choice = await ctx.ui.select("Trash actions", [ + "Restore an extension", + "Purge an extension", + "Back", + ]); + if (choice === "Restore an extension") await handleTrashSubcommand(["restore"], ctx, pi); + if (choice === "Purge an extension") await handleTrashSubcommand(["purge"], ctx, pi); + } + } +} diff --git a/src/ui/help.ts b/src/ui/help.ts index 1023b9e..3f76bfb 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -4,61 +4,61 @@ import { type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { notify } from "../utils/notify.js"; -export function showHelp(ctx: ExtensionCommandContext): void { - const lines = [ +/** Keep help useful in a notification without repeating every footer hint. */ +export function buildHelpLines(): string[] { + return [ "Extensions Manager Help", "", - "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", - " Package rows show extension state first: ● all enabled / ○ all disabled / ◐ mixed", - " Packages then show a source-type icon with name@version, scope, and size when known", + "Workspace navigation", + " Tab / Shift+Tab Next / previous workspace screen", + "", + "Everyday controls", + " ↑↓ / PageUp/PageDown Navigate", + " Space Toggle local extension or select package", + " Enter Open actions for the selected item", + " / Search visible items", + " 1-4 Filter: All / Local / Packages / Updates", + " 5-7 Filter: Disabled / Favorites / Recent", + " Esc Clear search, cancel, or leave", + "", + "Package actions", + " B Bulk actions for selected packages", + " c Configure package entrypoints", + " u / U Update selected package / all packages", + " X Remove the selected package or local extension", + " V View full details and recent activity", "", - "Navigation:", - " ↑↓ Navigate list", - " 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 the selected item", - " / or Ctrl+F Search visible items", - " Tab/Shift+Tab Cycle filters", - " 1-5 Quick filters: All / Local / Packages / Updates / Disabled", - " Disabled includes packages with disabled extension entrypoints", - " 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 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 Clear search or cancel", + "Manager actions", + " S Save staged local-extension changes", + " i Install by source", + " f / R Search / browse remote packages", + " W / L / D Save / load / delete a manager view", + " * Toggle favorite", + " t Scheduled update checks settings", + " P / M Open workspace screens and actions", + " Discover · Profiles · Health", "", - "Extension Sources:", - " - ~/.pi/agent/extensions/ (global - G)", - " - .pi/extensions/ (project-local - P)", - " - npm packages installed via pi install", - " - git packages installed via pi install", + "Sources and safety", + " Every row shows its kind, scope, and effective state inline", + " Packages may be npm, git, or local sources", + " Missing compatibility or artifact-integrity evidence is unknown", + " Changes that affect loaded extensions show Reload required", "", - "Commands:", - " /extensions Open manager", - " /extensions list List local extensions", - " /extensions installed List installed packages (legacy)", - " /extensions remote Browse community packages", - " /extensions search Search for packages", - " /extensions install [--project|--global] Install package (npm:, git:, or path)", - " /extensions remove Remove installed package", - " /extensions update [s] Update package (or all packages)", - " /extensions history [o] Show history (supports filters)", - " e.g. --failed --since 30m | --global --action package_update", - " /extensions auto-update Show or change update schedule", + "Screens", + " Discover Browse and inspect community npm packages", + " Profiles Review and apply, export, or delete package sets", + " Health Review conflicts, compatibility, reload, and trash", + " f fixes safe issues (never removes packages)", + "", + "Commands", + " /extensions profile list|save|apply|delete Manage named profiles", + " /extensions profile import Import/save a local or HTTPS profile", + " /extensions profile check [--json|--strict] Validate and report drift", + " /extensions trash list|restore|purge Manage removed extensions", + " /extensions history [options] Inspect package activity", ]; +} - notify(ctx, lines.join("\n"), "info"); +export function showHelp(ctx: ExtensionCommandContext): void { + notify(ctx, buildHelpLines().join("\n"), "info"); } diff --git a/src/ui/installed/actions.ts b/src/ui/installed/actions.ts new file mode 100644 index 0000000..e25ae48 --- /dev/null +++ b/src/ui/installed/actions.ts @@ -0,0 +1,840 @@ +/** Action handling for the Installed workspace: menus, staged toggles, and mutations. */ +import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { removeLocalExtension, setExtensionState } from "../../extensions/discovery.js"; +import { undoExtensionTrash } from "../../extensions/trash.js"; +import { getPackageCatalog } from "../../packages/catalog.js"; +import { getInstalledPackagesAllScopes } from "../../packages/discovery.js"; +import { applyPackageExtensionStateChanges } from "../../packages/extensions.js"; +import { + removePackageWithOutcome, + updatePackagesWithOutcome, + updatePackageWithOutcome, +} from "../../packages/management.js"; +import { comparePackageScopes, movePackageBetweenScopes } from "../../packages/scopes.js"; +import { + type InstalledPackage, + type LocalUnifiedItem, + type State, + type UnifiedAction, + type UnifiedItem, +} from "../../types/index.js"; +import { promptAutoUpdateWizard } from "../../utils/auto-update.js"; +import { parseChoiceByLabel } from "../../utils/command.js"; +import { formatBytes } from "../../utils/format.js"; +import { + formatChangeEntry, + logExtensionDelete, + logExtensionToggle, + queryPackageTimeline, +} from "../../utils/history.js"; +import { isProjectTrusted } from "../../utils/mode.js"; +import { notify } from "../../utils/notify.js"; +import { normalizePackageIdentity } from "../../utils/package-source.js"; +import { markReloadRequired } from "../../utils/reload-state.js"; +import { updateExtmgrStatus } from "../../utils/status.js"; +import { confirmReload } from "../../utils/ui-helpers.js"; +import { type readSavedViews, writeSavedViews } from "../../utils/views.js"; +import { runTaskWithLoader } from "../async-task.js"; +import { getPendingToggleChangeCount } from "../footer.js"; +import { showHelp } from "../help.js"; +import { configurePackageExtensions } from "../package-config.js"; +import { showRemote } from "../remote.js"; +import { runAuxWorkspaceScreens } from "../workspace/router.js"; +import { formatPackageExtensionState } from "./formatting.js"; +import { getLocalItemCurrentPath, getToggleItemsForApply } from "./items.js"; +import { managerStateToView, type UnifiedManagerViewState } from "./state.js"; + +export async function applyStagedChanges( + items: LocalUnifiedItem[], + staged: Map, + pi: ExtensionAPI +): Promise<{ changed: number; errors: string[] }> { + let changed = 0; + const errors: string[] = []; + + for (const item of items) { + 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 + ); + + if (result.ok) { + changed++; + 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, fromState, target, false, result.error); + } + } + + return { changed, errors }; +} + +export async function applyToggleChangesFromManager( + items: UnifiedItem[], + staged: Map, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + options?: { promptReload?: boolean } +): Promise<{ changed: number; reloaded: boolean; hasErrors: boolean }> { + const toggleItems = getToggleItemsForApply(items); + const apply = await applyStagedChanges(toggleItems, staged, pi); + + if (apply.errors.length > 0) { + ctx.ui.notify( + `Applied ${apply.changed} change(s), ${apply.errors.length} failed.\n${apply.errors.join("\n")}`, + "warning" + ); + } else if (apply.changed === 0) { + ctx.ui.notify("No changes to apply.", "info"); + } else { + ctx.ui.notify(`Applied ${apply.changed} local extension change(s).`, "info"); + } + + if (apply.changed > 0) { + const shouldPromptReload = options?.promptReload ?? true; + + if (shouldPromptReload) { + const reloaded = await confirmReload(ctx, "Local extensions changed."); + return { changed: apply.changed, reloaded, hasErrors: apply.errors.length > 0 }; + } + + await markReloadRequired("Local extensions changed."); + ctx.ui.notify("Changes saved. Reload pi later to fully apply extension state updates.", "info"); + } + + return { changed: apply.changed, reloaded: false, hasErrors: apply.errors.length > 0 }; +} + +export async function resolvePendingChangesBeforeLeave( + items: UnifiedItem[], + staged: Map, + byId: Map, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + destinationLabel: string +): Promise<"continue" | "stay"> { + const pendingCount = getPendingToggleChangeCount(staged, byId); + if (pendingCount === 0) return "continue"; + + const choice = await ctx.ui.select(`Unsaved changes (${pendingCount})`, [ + `Save and continue to ${destinationLabel}`, + "Discard changes", + "Stay in manager", + ]); + + if (!choice || choice === "Stay in manager") { + return "stay"; + } + + if (choice === "Discard changes") { + staged.clear(); + return "continue"; + } + + const apply = await applyToggleChangesFromManager(items, staged, ctx, pi, { + promptReload: false, + }); + return apply.changed === 0 && apply.hasErrors ? "stay" : "continue"; +} + +const PALETTE_OPTIONS = { + discover: "Discover community packages", + profiles: "Profiles", + health: "Health and diagnostics", + install: "Install package by source", + search: "Search packages", + updateAll: "Update all packages", + autoUpdate: "Scheduled update checks", + help: "Help", + back: "Back", +} as const; + +type PaletteAction = keyof typeof PALETTE_OPTIONS; + +type QuickDestination = + | "discover" + | "profiles" + | "health" + | "install" + | "search" + | "update-all" + | "auto-update" + | "help"; + +const QUICK_DESTINATION_LABELS: Record = { + discover: "Discover", + profiles: "Profiles", + health: "Health", + install: "Install", + search: "Search", + "update-all": "Update", + "auto-update": "Scheduled update checks", + help: "Help", +}; + +const LOCAL_ACTION_OPTIONS = { + toggle: "Toggle enabled state", + details: "View full details", + remove: "Remove extension", + back: "Back", +} as const; + +const BULK_ACTION_OPTIONS = { + update: "Update selected packages", + remove: "Remove selected packages", + enable: "Enable selected package extensions", + disable: "Disable selected package extensions", + cancel: "Cancel", +} as const; + +const PACKAGE_ACTION_OPTIONS = { + details: "View full details", + configure: "Configure package extensions", + enable: "Enable all package extensions", + disable: "Disable all package extensions", + update: "Update package", + compare: "Compare scopes", + "move-global": "Move to global scope", + "move-project": "Move to project scope", + remove: "Remove package", + back: "Back", +} 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, + state: State, + ctx: ExtensionCommandContext +): Promise { + const labels = { + ...LOCAL_ACTION_OPTIONS, + toggle: state === "enabled" ? "Disable extension" : "Enable extension", + }; + const selection = parseChoiceByLabel( + labels, + await ctx.ui.select(item.displayName, Object.values(labels)) + ); + + if (!selection || selection === "back") { + return "cancel"; + } + + return selection; +} + +async function promptPackageActionSelection( + pkg: InstalledPackage, + ctx: ExtensionCommandContext +): Promise { + const options = Object.entries(PACKAGE_ACTION_OPTIONS) + .filter(([action]) => action !== (pkg.scope === "global" ? "move-global" : "move-project")) + .map(([, label]) => label); + const selection = parseChoiceByLabel( + PACKAGE_ACTION_OPTIONS, + await ctx.ui.select(pkg.name, options) + ); + + if (!selection || selection === "back") { + return "cancel"; + } + + 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)}` : ""; + const extensionState = formatPackageExtensionState(item.extensionSummary); + const extensionStr = extensionState ? `\nExtensions: ${extensionState}` : ""; + const timeline = queryPackageTimeline(ctx, item.source, { limit: 5 }); + const timelineText = + timeline.length > 0 + ? `\nRecent activity:\n${timeline.map((entry) => `- ${formatChangeEntry(entry)}`).join("\n")}` + : "\nRecent activity: none in this session"; + ctx.ui.notify( + `Name: ${item.displayName}\nVersion: ${item.version || "unknown"}\nSource: ${item.source}\nScope: ${item.scope}${extensionStr}${sizeStr}${item.description ? `\nDescription: ${item.description}` : ""}${timelineText}`, + "info" + ); +} + +async function navigateWithPendingGuard( + destination: QuickDestination, + items: UnifiedItem[], + staged: Map, + byId: Map, + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise<"reload" | "resume" | "stay" | "exit"> { + const pending = await resolvePendingChangesBeforeLeave( + items, + staged, + byId, + ctx, + pi, + QUICK_DESTINATION_LABELS[destination] + ); + if (pending === "stay") return "stay"; + + switch (destination) { + case "discover": + return (await showRemote("", ctx, pi)) ? "exit" : "reload"; + case "profiles": + case "health": { + const outcome = await runAuxWorkspaceScreens(destination, ctx, pi); + if (outcome.reloaded) return "exit"; + if (outcome.navigate === "discover") { + if (await showRemote("", ctx, pi)) return "exit"; + } + return "reload"; + } + case "install": + return (await showRemote("install", ctx, pi)) ? "exit" : "reload"; + case "search": + return (await showRemote("search", ctx, pi)) ? "exit" : "reload"; + case "update-all": { + const outcome = await updatePackagesWithOutcome(ctx, pi); + return outcome.reloaded ? "exit" : "reload"; + } + case "auto-update": + await promptAutoUpdateWizard(pi, ctx, (packages) => { + ctx.ui.notify( + `Updates available for ${packages.length} package(s): ${packages.join(", ")}`, + "info" + ); + }); + void updateExtmgrStatus(ctx, pi); + return "resume"; + case "help": + showHelp(ctx); + return "resume"; + } +} + +export async function handleUnifiedAction( + result: UnifiedAction, + items: UnifiedItem[], + staged: Map, + byId: Map, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + savedViews: Awaited>, + viewsPath: string, + currentViewState?: UnifiedManagerViewState +): Promise { + if (result.type === "workspace") { + if (result.screen === "installed") return "resume"; + const destination: QuickDestination = result.screen; + const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi); + if (outcome === "stay" || outcome === "resume") return "resume"; + return outcome === "exit"; + } + + if (result.type === "cancel") { + const pendingCount = getPendingToggleChangeCount(staged, byId); + if (pendingCount > 0) { + const choice = await ctx.ui.select(`Unsaved changes (${pendingCount})`, [ + "Save and exit", + "Exit without saving", + "Stay in manager", + ]); + + if (!choice || choice === "Stay in manager") { + 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 "resume"; + } + } + + return true; + } + + if (result.type === "views") { + if (result.action === "favorite") { + const itemId = result.itemId; + if (!itemId) return "resume"; + const favorites = new Set(savedViews.favorites); + if (favorites.has(itemId)) { + favorites.delete(itemId); + notify(ctx, "Removed from favorites.", "info"); + } else { + favorites.add(itemId); + notify(ctx, "Added to favorites.", "info"); + } + savedViews.favorites = [...favorites]; + await writeSavedViews(viewsPath, savedViews); + return "resume"; + } + + if (result.action === "save") { + if (!currentViewState) return "resume"; + const name = (await ctx.ui.input("Save manager view", "name"))?.trim(); + if (!name) return "resume"; + const existing = savedViews.views.find((view) => view.name === name); + if (existing && !(await ctx.ui.confirm("Overwrite view", `Replace saved view “${name}”?`))) { + return "resume"; + } + const now = Date.now(); + const saved = managerStateToView(currentViewState, name, existing?.createdAt ?? now); + savedViews.views = existing + ? savedViews.views.map((view) => (view.name === name ? saved : view)) + : [...savedViews.views, saved]; + await writeSavedViews(viewsPath, savedViews); + notify(ctx, `Saved view “${name}”.`, "info"); + return "resume"; + } + + if (result.action === "load") { + if (savedViews.views.length === 0) { + notify(ctx, "No saved views yet. Press W to save the current view.", "info"); + return "resume"; + } + const choice = await ctx.ui.select( + "Load manager view", + savedViews.views.map((view) => view.name) + ); + const selected = savedViews.views.find((view) => view.name === choice); + if (selected) { + savedViews.lastView = selected; + await writeSavedViews(viewsPath, savedViews); + notify(ctx, `Loaded view “${selected.name}”.`, "info"); + } + return "resume"; + } + + if (savedViews.views.length === 0) { + notify(ctx, "No saved views to delete.", "info"); + return "resume"; + } + const choice = await ctx.ui.select( + "Delete manager view", + savedViews.views.map((view) => view.name) + ); + if (choice && (await ctx.ui.confirm("Delete view", `Delete saved view “${choice}”?`))) { + savedViews.views = savedViews.views.filter((view) => view.name !== choice); + await writeSavedViews(viewsPath, savedViews); + notify(ctx, `Deleted view “${choice}”.`, "info"); + } + return "resume"; + } + + if (result.type === "bulk") { + const selectedPackages = result.itemIds + .map((id) => byId.get(id)) + .filter( + (item): item is Extract => item?.type === "package" + ); + if (selectedPackages.length === 0) return "resume"; + + const action = + result.action === "menu" + ? parseChoiceByLabel( + BULK_ACTION_OPTIONS, + await ctx.ui.select( + `${selectedPackages.length} selected packages`, + Object.values(BULK_ACTION_OPTIONS) + ) + ) + : result.action; + if (!action || action === "cancel") return "resume"; + + const confirmed = await ctx.ui.confirm( + "Bulk package operation", + `${BULK_ACTION_OPTIONS[action]} for ${selectedPackages.length} package(s)?` + ); + if (!confirmed) return "resume"; + + const results = await runTaskWithLoader( + ctx, + { + title: "Bulk package operation", + message: `${BULK_ACTION_OPTIONS[action]}...`, + cancellable: false, + fallbackWithoutLoader: true, + }, + async ({ setMessage }) => { + const catalog = getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)); + const completed: string[] = []; + const failed: string[] = []; + const skipped: string[] = []; + const availableUpdates = + action === "update" + ? new Set( + (await catalog.checkForAvailableUpdates()).map( + (update) => `${update.scope}\0${normalizePackageIdentity(update.source)}` + ) + ) + : undefined; + for (const item of selectedPackages) { + setMessage(`${BULK_ACTION_OPTIONS[action]}: ${item.displayName}...`); + try { + if (action === "update") { + if ( + !availableUpdates?.has(`${item.scope}\0${normalizePackageIdentity(item.source)}`) + ) { + skipped.push(`${item.displayName}: already current or pinned`); + continue; + } + await catalog.update(item.source, (event) => { + if (event.message) setMessage(event.message); + }); + } else if (action === "remove") { + await catalog.remove(item.source, item.scope, (event) => { + if (event.message) setMessage(event.message); + }); + } else { + if (!item.extensionPaths?.length) { + throw new Error("no package extension entrypoints were discovered"); + } + const target: State = action === "enable" ? "enabled" : "disabled"; + const changed = await applyPackageExtensionStateChanges( + item.source, + item.scope, + item.extensionPaths.map((extensionPath) => ({ extensionPath, target })), + ctx.cwd, + isProjectTrusted(ctx) + ); + if (!changed.ok) throw new Error(changed.error); + } + completed.push(item.displayName); + } catch (error) { + failed.push( + `${item.displayName}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + return { completed, failed, skipped }; + } + ); + + if (!results) return "resume"; + const summary = [ + `${results.completed.length} succeeded`, + `${results.failed.length} failed`, + `${results.skipped.length} skipped`, + results.completed.length > 0 + ? "Reload required: confirm Reload Required to apply changes." + : "Reload required: no", + ...results.failed.map((failure) => `- ${failure}`), + ...results.skipped.map((skipped) => `- ${skipped}`), + ].join("\n"); + ctx.ui.notify(summary, results.failed.length > 0 ? "warning" : "info"); + if (results.completed.length === 0) return "resume"; + + const reloaded = await confirmReload(ctx, "Bulk package changes completed."); + return reloaded; + } + + if (result.type === "remote") { + const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Remote"); + if (pending === "stay") return "resume"; + + return showRemote("", ctx, pi); + } + + if (result.type === "help") { + const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Help"); + if (pending === "stay") return "resume"; + + showHelp(ctx); + return "resume"; + } + + if (result.type === "menu") { + const choice = parseChoiceByLabel( + PALETTE_OPTIONS, + await ctx.ui.select("Extmgr workspace", Object.values(PALETTE_OPTIONS)) + ); + + const destinationByAction: Partial> = { + discover: "discover", + profiles: "profiles", + health: "health", + install: "install", + search: "search", + updateAll: "update-all", + autoUpdate: "auto-update", + help: "help", + }; + + const destination = choice ? destinationByAction[choice] : undefined; + if (!destination) { + return "resume"; + } + + const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi); + if (outcome === "stay" || outcome === "resume") return "resume"; + return outcome === "exit"; + } + + if (result.type === "quick") { + const quickDestinationMap: Record<(typeof result)["action"], QuickDestination> = { + install: "install", + search: "search", + "update-all": "update-all", + "auto-update": "auto-update", + }; + + 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"; + } + + if (result.type === "action") { + const item = byId.get(result.itemId); + if (!item) return false; + + if (item.type === "local") { + const currentState = staged.get(item.id) ?? item.state; + const selection = + !result.action || result.action === "menu" + ? await promptLocalActionSelection(item, currentState, ctx) + : result.action; + + if (selection === "cancel") { + return "resume"; + } + + if (selection === "toggle" || selection === "enable" || selection === "disable") { + const target = + selection === "enable" + ? "enabled" + : selection === "disable" + ? "disabled" + : currentState === "enabled" + ? "disabled" + : "enabled"; + if (target === item.originalState) staged.delete(item.id); + else staged.set(item.id, target); + return "resume"; + } + + if (selection === "details") { + showUnifiedItemDetails(item, ctx, currentState); + 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", + `Remove ${item.displayName} from disk?\n\nIt will be moved to trash, where you can restore it later.` + ); + if (!confirmed) return "resume"; + + const removal = await removeLocalExtension( + { activePath: item.activePath, disabledPath: item.disabledPath }, + ctx.cwd + ); + if (!removal.ok) { + logExtensionDelete(pi, item.id, false, removal.error); + ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error"); + return "resume"; + } + + logExtensionDelete(pi, item.id, true); + ctx.ui.notify( + `Moved ${item.displayName}${removal.removedDirectory ? " (directory)" : ""} to trash.`, + "info" + ); + const undo = await ctx.ui.confirm("Undo Removal", "Restore the extension from trash now?"); + if (undo) { + try { + await undoExtensionTrash(removal.trashRecord); + ctx.ui.notify(`Restored ${item.displayName}.`, "info"); + return "resume"; + } catch (error) { + ctx.ui.notify( + `Undo failed: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + } + } + + return await confirmReload(ctx, "Extension removed."); + } + + const pkg: InstalledPackage = { + source: item.source, + name: item.displayName, + ...(item.version ? { version: item.version } : {}), + scope: item.scope, + ...(item.resolvedPath ? { resolvedPath: item.resolvedPath } : {}), + ...(item.description ? { description: item.description } : {}), + ...(item.size !== undefined ? { size: item.size } : {}), + }; + + const selection = + !result.action || result.action === "menu" + ? await promptPackageActionSelection(pkg, ctx) + : result.action; + + if (selection === "cancel") { + return "resume"; + } + + if (selection === "details") { + showUnifiedItemDetails(item, ctx); + return "resume"; + } + + const pendingDestinationBySelection = { + configure: "configure package extensions", + enable: "enable package", + disable: "disable package", + compare: "compare package scopes", + "move-global": "move package to global scope", + "move-project": "move package to project scope", + 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 "compare": { + const comparisons = comparePackageScopes( + await getInstalledPackagesAllScopes(ctx), + ctx.cwd + ).filter( + (comparison) => + comparison.global?.source === item.source || comparison.project?.source === item.source + ); + const comparison = comparisons[0]; + if (!comparison) { + ctx.ui.notify("No package scope comparison is available.", "warning"); + } else { + ctx.ui.notify( + [ + `Package: ${comparison.name}`, + `Global: ${comparison.global?.source ?? "not configured"}`, + `Project: ${comparison.project?.source ?? "not configured"}`, + `Status: ${comparison.status}`, + ].join("\n"), + "info" + ); + } + return "resume"; + } + case "move-global": + case "move-project": { + const targetScope = selection === "move-global" ? "global" : "project"; + if (targetScope === item.scope) { + ctx.ui.notify(`Package is already in ${targetScope} scope.`, "info"); + return "resume"; + } + const confirmed = await ctx.ui.confirm( + "Move package scope", + `Move ${item.source} from ${item.scope} to ${targetScope}?` + ); + if (!confirmed) return "resume"; + const moved = await movePackageBetweenScopes( + item.source, + item.scope, + targetScope, + ctx.cwd, + isProjectTrusted(ctx) + ); + if (!moved.moved) { + ctx.ui.notify( + `${moved.partial ? "Package scope move partially completed" : "Package scope move failed"}: ${moved.conflict ?? "unknown error"}`, + moved.partial ? "warning" : "error" + ); + return moved.partial + ? await confirmReload(ctx, "Package scope move partially completed.") + : "resume"; + } + ctx.ui.notify(`Moved ${item.displayName} to ${targetScope} scope.`, "info"); + return await confirmReload(ctx, "Package scope changed."); + } + case "enable": + case "disable": { + if (!item.extensionPaths?.length) { + ctx.ui.notify("No package extension entrypoints were discovered.", "warning"); + return "resume"; + } + const target: State = selection === "enable" ? "enabled" : "disabled"; + const result = await applyPackageExtensionStateChanges( + item.source, + item.scope, + item.extensionPaths.map((extensionPath) => ({ extensionPath, target })), + ctx.cwd, + isProjectTrusted(ctx) + ); + if (!result.ok) { + ctx.ui.notify(`Package toggle failed: ${result.error}`, "error"); + return "resume"; + } + ctx.ui.notify( + `${target === "enabled" ? "Enabled" : "Disabled"} ${item.displayName}.`, + "info" + ); + return await confirmReload(ctx, "Package extension state changed."); + } + case "configure": { + const outcome = await configurePackageExtensions(pkg, ctx, pi); + return outcome.reloaded; + } + case "update": { + const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi); + return outcome.reloaded; + } + case "remove": { + const outcome = await removePackageWithOutcome(pkg.source, ctx, pi); + return outcome.reloaded; + } + } + } + + const apply = await applyToggleChangesFromManager(items, staged, ctx, pi); + return apply.reloaded ? true : "resume"; +} diff --git a/src/ui/installed/browser.ts b/src/ui/installed/browser.ts new file mode 100644 index 0000000..f59690b --- /dev/null +++ b/src/ui/installed/browser.ts @@ -0,0 +1,563 @@ +/** Interactive list browser component for the Installed workspace. */ +import { type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent"; +import { + type Focusable, + Input, + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; +import { type State, type UnifiedAction, type UnifiedItem } from "../../types/index.js"; +import { formatBytes } from "../../utils/format.js"; +import { getPackageSourceKind } from "../../utils/package-source.js"; +import { composeColumns, TWO_PANE_MIN_WIDTH } from "../layout.js"; +import { getCenteredVisibleRange, moveListSelection } from "../list-navigation.js"; +import { getStatusIcon } from "../theme.js"; +import { matchWorkspaceNavigation } from "../workspace/navigation.js"; +import { matchesUnifiedFilter, searchUnifiedItems } from "./filters.js"; +import { + compactDisplayPath, + formatPackageExtensionState, + formatUnifiedItemDescription, + formatUnifiedItemLabel, +} from "./formatting.js"; +import { getCurrentUnifiedItemState, getLocalItemCurrentPath } from "./items.js"; +import { + UNIFIED_FILTER_OPTIONS, + type UnifiedFilter, + type UnifiedManagerViewState, +} from "./state.js"; + +export class UnifiedManagerBrowser implements Focusable { + private readonly searchInput = new Input(); + private readonly filteredItems: UnifiedItem[] = []; + private selectedIndex = 0; + private filter: UnifiedFilter = "all"; + private searchActive = false; + private readonly bulkSelectedIds = new Set(); + private _focused = false; + + constructor( + private readonly items: UnifiedItem[], + private readonly staged: Map, + private readonly theme: Theme, + private readonly keybindings: KeybindingsManager, + private readonly cwd: string, + private readonly maxVisibleItems: number, + private readonly onAction: (action: UnifiedAction) => void, + private readonly favoriteIds: ReadonlySet = new Set(), + private readonly recentIds: ReadonlySet = new Set(), + initialState?: UnifiedManagerViewState + ) { + if (initialState) { + for (const id of initialState.selectedItemIds) { + if (this.items.some((item) => item.id === id && item.type === "package")) { + this.bulkSelectedIds.add(id); + } + } + 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(); + } + + getBulkSelectedCount(): number { + return this.bulkSelectedIds.size; + } + + getViewState(): UnifiedManagerViewState { + const selectedItemId = this.getSelectedItem()?.id; + return { + filter: this.filter, + searchQuery: this.getSearchQuery(), + ...(selectedItemId ? { selectedItemId } : {}), + selectedItemIds: [...this.bulkSelectedIds], + }; + } + + invalidate(): void { + this.searchInput.invalidate(); + } + + handleInput(data: string): void { + this.handleManagerInput(data); + } + + handleManagerInput(data: string): boolean { + const workspaceScreen = matchWorkspaceNavigation(data, "installed"); + if (workspaceScreen) { + this.onAction({ type: "workspace", screen: workspaceScreen }); + return true; + } + + if (this.searchActive) { + if (this.keybindings.matches(data, "tui.select.confirm")) { + this.searchActive = false; + this.searchInput.focused = false; + return true; + } + + if (this.keybindings.matches(data, "tui.select.cancel")) { + 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 (this.keybindings.matches(data, "tui.select.cancel") && this.getSearchQuery()) { + this.searchInput.setValue(""); + this.refreshVisibleItems(); + return true; + } + + const directFilter = UNIFIED_FILTER_OPTIONS.find((option) => option.key === data)?.id; + if (directFilter) { + this.setFilter(directFilter); + return true; + } + + if (this.keybindings.matches(data, "tui.select.up")) { + this.moveSelection(-1, true); + return true; + } + + if (this.keybindings.matches(data, "tui.select.down")) { + this.moveSelection(1, true); + return true; + } + + if (this.keybindings.matches(data, "tui.select.pageUp")) { + this.moveSelection(-Math.max(1, this.maxVisibleItems - 1)); + return true; + } + + if (this.keybindings.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 (data === " " && selectedItem?.type === "package") { + if (this.bulkSelectedIds.has(selectedItem.id)) this.bulkSelectedIds.delete(selectedItem.id); + else this.bulkSelectedIds.add(selectedItem.id); + return true; + } + + if (data === "B" && this.bulkSelectedIds.size > 0) { + this.onAction({ + type: "bulk", + itemIds: [...this.bulkSelectedIds], + action: "menu", + }); + return true; + } + + 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 (this.keybindings.matches(data, "tui.select.confirm") && 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 === "W") { + this.onAction({ type: "views", action: "save" }); + return true; + } + + if (data === "L") { + this.onAction({ type: "views", action: "load" }); + return true; + } + + if (data === "D") { + this.onAction({ type: "views", action: "delete" }); + return true; + } + + if (data === "*" && selectedId) { + this.onAction({ type: "views", action: "favorite", itemId: selectedId }); + 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 (this.keybindings.matches(data, "tui.select.cancel")) { + this.onAction({ type: "cancel" }); + return true; + } + + return false; + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const lines: string[] = []; + + const searchQuery = this.searchInput.getValue().trim(); + if (this.searchActive) { + lines.push(...this.searchInput.render(safeWidth)); + lines.push(""); + } else if (searchQuery) { + lines.push( + truncateToWidth(this.theme.fg("accent", ` Search: ${searchQuery}`), safeWidth, "") + ); + lines.push(""); + } + + lines.push(truncateToWidth(this.buildFilterLine(), safeWidth, "")); + lines.push(""); + + if (this.filteredItems.length === 0) { + const emptyMessage = searchQuery + ? ` No items match “${searchQuery}”. Try clearing search with Esc.` + : this.filter === "favorites" + ? " No favorites yet. Press * on an item to favorite it." + : this.filter === "recent" + ? " No recent items yet. Open an item to build your recent list." + : this.filter === "updates" + ? " No updates are currently known." + : this.filter === "disabled" + ? " No disabled extensions or package entrypoints." + : " No extensions or packages installed yet. Press i to install one."; + lines.push(truncateToWidth(this.theme.fg("warning", emptyMessage), safeWidth, "")); + return lines; + } + + const listLines: string[] = []; + 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) { + listLines.push(this.theme.fg("accent", ` Local extensions · ${localCount}`)); + for (const item of visibleLocalItems) { + listLines.push(this.renderItemLine(item, safeWidth)); + } + if (visiblePackageItems.length > 0) listLines.push(""); + } + + if (visiblePackageItems.length > 0) { + listLines.push(this.theme.fg("accent", ` Packages · ${packageCount}`)); + for (const item of visiblePackageItems) { + listLines.push(this.renderItemLine(item, safeWidth)); + } + } + + if (startIndex > 0 || endIndex < this.filteredItems.length) { + listLines.push( + "", + this.theme.fg( + "dim", + ` Showing ${startIndex + 1}-${endIndex} of ${this.filteredItems.length}` + ) + ); + } + + const selectedItem = this.getSelectedItem(); + if (selectedItem && safeWidth >= TWO_PANE_MIN_WIDTH) { + const detailWidth = Math.max(1, Math.floor((safeWidth - 3) * 0.38)); + lines.push( + ...composeColumns( + listLines, + this.renderInspector(selectedItem, detailWidth), + safeWidth, + this.theme.fg("borderMuted", " │ ") + ) + ); + } else { + lines.push(...listLines.map((line) => truncateToWidth(line, safeWidth, ""))); + 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, Math.max(1, safeWidth - 4))) { + lines.push(truncateToWidth(this.theme.fg("dim", ` ${line}`), safeWidth, "")); + } + } + } + + return lines; + } + + private buildFilterLine(): string { + const primaryFilterIds: UnifiedFilter[] = ["all", "local", "packages", "updates"]; + const visibleFilters = UNIFIED_FILTER_OPTIONS.filter( + ({ id }) => primaryFilterIds.includes(id) || id === this.filter + ); + const filters = visibleFilters + .map(({ id, key, label }) => + id === this.filter + ? this.theme.fg("accent", `[${key} ${label}]`) + : this.theme.fg("muted", `${key} ${label}`) + ) + .join(" "); + const searchHint = this.theme.fg( + this.searchActive || this.searchInput.getValue() ? "accent" : "dim", + "/ Search" + ); + return ` ${filters} ${searchHint}`; + } + + private renderInspector(item: UnifiedItem, width: number): string[] { + const contentWidth = Math.max(1, width - 2); + const lines = [this.theme.fg("accent", this.theme.bold("Details")), ""]; + const pushDescription = (description: string): void => { + for (const line of wrapTextWithAnsi(description, contentWidth)) lines.push(line); + }; + + if (item.type === "local") { + const state = getCurrentUnifiedItemState(item, this.staged) ?? item.state; + const changed = state !== item.originalState; + lines.push( + this.theme.bold(item.displayName), + this.theme.fg("muted", `Local extension · ${item.scope}`), + `${getStatusIcon(this.theme, state)} ${state}${changed ? this.theme.fg("warning", " · unsaved") : ""}`, + "" + ); + pushDescription(item.summary || "No description provided."); + lines.push( + "", + this.theme.fg("dim", "Path"), + compactDisplayPath(getLocalItemCurrentPath(item, state), this.cwd), + "", + this.theme.fg("dim", "Space toggle · Enter actions") + ); + } else { + const sourceKind = getPackageSourceKind(item.source); + const extensionState = formatPackageExtensionState(item.extensionSummary); + lines.push( + this.theme.bold(item.displayName), + this.theme.fg( + "muted", + `${sourceKind === "unknown" ? "Package" : sourceKind} package · ${item.scope}` + ), + item.version ? this.theme.fg("muted", `Version ${item.version}`) : "", + "" + ); + pushDescription(item.description || "No description provided."); + lines.push(""); + if (extensionState) lines.push(`${this.theme.fg("dim", "Extensions")} ${extensionState}`); + if (item.size !== undefined) + lines.push(`${this.theme.fg("dim", "Size")} ${formatBytes(item.size)}`); + if (item.updateAvailable) lines.push(this.theme.fg("warning", "Update available")); + if (item.extensionPaths?.length) { + lines.push("", this.theme.fg("dim", "Entrypoints")); + for (const path of item.extensionPaths.slice(0, 4)) lines.push(path); + if (item.extensionPaths.length > 4) { + lines.push(this.theme.fg("dim", `+${item.extensionPaths.length - 4} more`)); + } + } + lines.push( + "", + this.theme.fg("dim", "Source"), + item.source, + "", + this.theme.fg("dim", "Space select · Enter actions") + ); + } + + return lines + .filter((line, index, values) => line !== "" || values[index - 1] !== "") + .map((line) => truncateToWidth(` ${line}`, width, "")); + } + + private renderItemLine(item: UnifiedItem, width: number): string { + const state = getCurrentUnifiedItemState(item, this.staged); + const changed = item.type === "local" && state !== item.originalState; + const selectedForBulk = item.type === "package" && this.bulkSelectedIds.has(item.id); + const selectionMarker = selectedForBulk ? this.theme.fg("accent", " selected") : ""; + const prefix = this.getSelectedItem()?.id === item.id ? this.theme.fg("accent", "› ") : " "; + return truncateToWidth( + prefix + formatUnifiedItemLabel(item, state, this.theme, changed) + selectionMarker, + width + ); + } + + private refreshVisibleItems(preferredItemId?: string): void { + const previousSelectedId = preferredItemId ?? this.getSelectedItem()?.id; + const filteredByMode = this.items.filter((item) => + matchesUnifiedFilter(item, this.filter, this.staged, this.favoriteIds, this.recentIds) + ); + 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); + } + + private setFilter(filter: UnifiedFilter): void { + this.filter = filter; + this.refreshVisibleItems(); + } + + private moveSelection(delta: number, wrap = false): void { + this.selectedIndex = moveListSelection(this.selectedIndex, delta, this.filteredItems.length, { + wrap, + }); + } + + private getVisibleRange(): { startIndex: number; endIndex: number } { + return getCenteredVisibleRange( + this.selectedIndex, + this.filteredItems.length, + this.maxVisibleItems + ); + } +} diff --git a/src/ui/installed/filters.ts b/src/ui/installed/filters.ts new file mode 100644 index 0000000..f815325 --- /dev/null +++ b/src/ui/installed/filters.ts @@ -0,0 +1,160 @@ +/** Filtering and search scoring for Installed workspace items. */ +import { fuzzyMatch } from "@earendil-works/pi-tui"; +import { type State, type UnifiedItem } from "../../types/index.js"; +import { getPackageSourceKind } from "../../utils/package-source.js"; +import { compactDisplayPath, formatPackageExtensionState } from "./formatting.js"; +import { getCurrentUnifiedItemState, getLocalItemCurrentPath } from "./items.js"; +import { type UnifiedFilter } from "./state.js"; + +export function matchesUnifiedFilter( + item: UnifiedItem, + filter: UnifiedFilter, + staged: Map, + favoriteIds: ReadonlySet, + recentIds: ReadonlySet +): 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": + if (item.type === "local") { + return getCurrentUnifiedItemState(item, staged) === "disabled"; + } + return (item.extensionSummary?.disabled ?? 0) > 0; + case "favorites": + return favoriteIds.has(item.id); + case "recent": + return recentIds.has(item.id); + } +} + +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 ?? "", + formatPackageExtensionState(item.extensionSummary) ?? "", + item.extensionSummary + ? item.extensionSummary.disabled > 0 + ? item.extensionSummary.enabled > 0 + ? "mixed disabled" + : "disabled" + : "enabled" + : "", + ], + }; +} + +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; +} + +export 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); +} diff --git a/src/ui/installed/formatting.ts b/src/ui/installed/formatting.ts new file mode 100644 index 0000000..18f609c --- /dev/null +++ b/src/ui/installed/formatting.ts @@ -0,0 +1,127 @@ +/** Pure formatting helpers for the Installed workspace. */ +import { homedir } from "node:os"; +import { relative } from "node:path"; +import { type Theme } from "@earendil-works/pi-coding-agent"; +import { + type PackageExtensionStateSummary, + type State, + type UnifiedItem, +} from "../../types/index.js"; +import { formatBytes } from "../../utils/format.js"; +import { getPackageSourceKind } from "../../utils/package-source.js"; +import { getStatusIcon } from "../theme.js"; +import { getLocalItemCurrentPath } from "./items.js"; + +export function getPackageExtensionStatusIcon( + theme: Theme, + summary?: PackageExtensionStateSummary +): string { + if (!summary || summary.total === 0) return theme.fg("muted", "•"); + if (summary.disabled === 0) return getStatusIcon(theme, "enabled"); + if (summary.enabled === 0) return getStatusIcon(theme, "disabled"); + return theme.fg("warning", "●"); +} + +export function formatPackageExtensionState( + summary?: PackageExtensionStateSummary +): string | undefined { + if (!summary || summary.total === 0) return undefined; + if (summary.disabled === 0) { + return `${summary.total} enabled`; + } + if (summary.enabled === 0) { + return `${summary.total} disabled`; + } + return `${summary.enabled} enabled · ${summary.disabled} disabled`; +} + +function formatLocalState(state: State, changed: boolean): string { + return `${state}${changed ? " · unsaved" : ""}`; +} + +export function formatUnifiedItemLabel( + item: UnifiedItem, + state: State | undefined, + theme: Theme, + changed = false +): string { + if (item.type === "local") { + const currentState = state ?? item.state; + const status = getStatusIcon(theme, currentState); + const name = theme.bold(item.displayName); + const meta = theme.fg( + changed ? "warning" : "muted", + `local · ${item.scope} · ${formatLocalState(currentState, changed)}` + ); + return `${status} ${name} ${meta}`; + } + + const sourceKind = getPackageSourceKind(item.source); + const status = getPackageExtensionStatusIcon(theme, item.extensionSummary); + const name = theme.bold(item.displayName); + const version = item.version ? theme.fg("dim", `@${item.version}`) : ""; + const details = [ + sourceKind === "unknown" ? "package" : sourceKind, + item.scope, + formatPackageExtensionState(item.extensionSummary), + item.size !== undefined ? formatBytes(item.size) : undefined, + item.updateAvailable ? "update available" : undefined, + ].filter(Boolean); + const tone = item.updateAvailable ? "warning" : "muted"; + return `${status} ${name}${version} ${theme.fg(tone, details.join(" · "))}`; +} + +export function formatUnifiedItemDescription( + item: UnifiedItem, + state: State | undefined, + changed: boolean, + cwd: string +): string { + if (item.type === "local") { + const currentState = state ?? item.state; + return [ + item.summary || "No description", + `local · ${item.scope} · ${formatLocalState(currentState, changed)}`, + compactDisplayPath(getLocalItemCurrentPath(item, state), cwd), + ].join(" • "); + } + + const sourceKind = getPackageSourceKind(item.source); + const source = sourceKind === "local" ? compactDisplayPath(item.source, cwd) : item.source; + return [ + item.description || "No description", + `${sourceKind === "unknown" ? "package" : sourceKind} · ${item.scope}`, + formatPackageExtensionState(item.extensionSummary), + item.updateAvailable ? "update available" : undefined, + item.size !== undefined ? formatBytes(item.size) : undefined, + source, + ] + .filter(Boolean) + .join(" • "); +} + +export 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); +} diff --git a/src/ui/installed/items.ts b/src/ui/installed/items.ts new file mode 100644 index 0000000..05d66fd --- /dev/null +++ b/src/ui/installed/items.ts @@ -0,0 +1,159 @@ +/** Pure helpers that build and interrogate the unified Installed item list. */ +import { type discoverExtensions } from "../../extensions/discovery.js"; +import { + type InstalledPackage, + type LocalUnifiedItem, + type PackageExtensionEntry, + type PackageExtensionStateSummary, + type State, + type UnifiedItem, +} from "../../types/index.js"; +import { normalizePackageIdentity } from "../../utils/package-source.js"; +import { normalizePathIdentity } from "../../utils/path-identity.js"; +import { CONFIG_DIR_NAME } from "../../utils/pi-paths.js"; + +export function getPackageExtensionSummaryKey(scope: string, source: string): string { + return `${scope}\0${source}`; +} + +export function buildPackageExtensionSummaries( + entries: PackageExtensionEntry[] +): Map { + const summaries = new Map(); + + for (const entry of entries) { + const key = getPackageExtensionSummaryKey(entry.packageScope, entry.packageSource); + let summary = summaries.get(key); + if (!summary) { + summary = { enabled: 0, disabled: 0, total: 0 }; + summaries.set(key, summary); + } + + summary.total += 1; + if (entry.state === "enabled") { + summary.enabled += 1; + } else { + summary.disabled += 1; + } + } + + return summaries; +} + +function getLocalDisplayName( + entry: Awaited>[number] +): string { + const prefix = + entry.scope === "project" ? `${CONFIG_DIR_NAME}/extensions/` : "global extensions/"; + return entry.displayName.startsWith(prefix) + ? entry.displayName.slice(prefix.length) + : entry.displayName; +} + +export function buildUnifiedItems( + localEntries: Awaited>, + installedPackages: InstalledPackage[], + knownUpdates: Set, + packageExtensions: PackageExtensionEntry[] = [] +): UnifiedItem[] { + const items: UnifiedItem[] = []; + const localPaths = new Set(); + const packageSourceCounts = new Map(); + for (const pkg of installedPackages) { + packageSourceCounts.set(pkg.source, (packageSourceCounts.get(pkg.source) ?? 0) + 1); + } + const packageExtensionSummaries = buildPackageExtensionSummaries(packageExtensions); + const packageExtensionPaths = new Map(); + for (const entry of packageExtensions) { + const key = getPackageExtensionSummaryKey(entry.packageScope, entry.packageSource); + const paths = packageExtensionPaths.get(key) ?? []; + if (!paths.includes(entry.extensionPath)) paths.push(entry.extensionPath); + packageExtensionPaths.set(key, paths); + } + + // Add local extensions + for (const entry of localEntries) { + const currentPath = entry.state === "disabled" ? entry.disabledPath : entry.activePath; + localPaths.add(normalizePathIdentity(currentPath)); + items.push({ + type: "local", + id: entry.id, + displayName: getLocalDisplayName(entry), + summary: entry.summary, + scope: entry.scope, + state: entry.state, + activePath: entry.activePath, + disabledPath: entry.disabledPath, + originalState: entry.state, + }); + } + + for (const pkg of installedPackages) { + const pkgSourceNormalized = normalizePathIdentity(pkg.source); + const pkgResolvedNormalized = pkg.resolvedPath ? normalizePathIdentity(pkg.resolvedPath) : ""; + + let isDuplicate = false; + for (const localPath of localPaths) { + if (pkgSourceNormalized === localPath || pkgResolvedNormalized === localPath) { + isDuplicate = true; + break; + } + if (pkgResolvedNormalized && localPath.startsWith(`${pkgResolvedNormalized}/`)) { + isDuplicate = true; + break; + } + } + if (isDuplicate) continue; + + const packageKey = getPackageExtensionSummaryKey(pkg.scope, pkg.source); + const extensionSummary = packageExtensionSummaries.get(packageKey); + const extensionPaths = packageExtensionPaths.get(packageKey); + + items.push({ + type: "package", + id: + packageSourceCounts.get(pkg.source) === 1 + ? `pkg:${pkg.source}` + : `pkg:${pkg.source}:${pkg.scope}`, + displayName: pkg.name, + scope: pkg.scope, + source: pkg.source, + resolvedPath: pkg.resolvedPath, + version: pkg.version, + description: pkg.description, + size: pkg.size, + updateAvailable: knownUpdates.has(normalizePackageIdentity(pkg.source)), + ...(extensionSummary ? { extensionSummary } : {}), + ...(extensionPaths?.length ? { extensionPaths: [...extensionPaths] } : {}), + }); + } + + // Sort by type then display name. + items.sort((a, b) => { + const rank = (type: UnifiedItem["type"]): number => { + if (type === "local") return 0; + return 1; + }; + + const diff = rank(a.type) - rank(b.type); + if (diff !== 0) return diff; + return a.displayName.localeCompare(b.displayName); + }); + + return items; +} + +export function getCurrentUnifiedItemState( + item: UnifiedItem, + staged: Map +): State | undefined { + return item.type === "local" ? (staged.get(item.id) ?? item.state) : undefined; +} + +export function getLocalItemCurrentPath(item: LocalUnifiedItem, state?: State): string { + return (state ?? item.state) === "enabled" ? item.activePath : item.disabledPath; +} + +export function getToggleItemsForApply(items: UnifiedItem[]): LocalUnifiedItem[] { + return items.filter((item): item is LocalUnifiedItem => item.type === "local"); +} diff --git a/src/ui/installed/state.ts b/src/ui/installed/state.ts new file mode 100644 index 0000000..c604d84 --- /dev/null +++ b/src/ui/installed/state.ts @@ -0,0 +1,59 @@ +import { type SavedView } from "../../utils/views.js"; + +export type UnifiedFilter = + | "all" + | "local" + | "packages" + | "updates" + | "disabled" + | "favorites" + | "recent"; + +export interface UnifiedManagerViewState { + filter: UnifiedFilter; + searchQuery: string; + selectedItemId?: string; + selectedItemIds: string[]; +} + +export 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" }, + { id: "favorites", key: "6", label: "Favorites" }, + { id: "recent", key: "7", label: "Recent" }, +]; + +function isUnifiedFilter(value: string): value is UnifiedFilter { + return UNIFIED_FILTER_OPTIONS.some((option) => option.id === value); +} + +export function viewToManagerState( + view: SavedView | undefined +): UnifiedManagerViewState | undefined { + if (!view || !isUnifiedFilter(view.filter)) return undefined; + return { + filter: view.filter, + searchQuery: view.searchQuery, + ...(view.selectedItemId ? { selectedItemId: view.selectedItemId } : {}), + selectedItemIds: [...(view.selectedItemIds ?? [])], + }; +} + +export function managerStateToView( + state: UnifiedManagerViewState, + name: string, + now = Date.now() +): SavedView { + return { + name, + filter: state.filter, + searchQuery: state.searchQuery, + ...(state.selectedItemId ? { selectedItemId: state.selectedItemId } : {}), + selectedItemIds: [...state.selectedItemIds], + createdAt: now, + updatedAt: now, + }; +} diff --git a/src/ui/installed/summary.ts b/src/ui/installed/summary.ts new file mode 100644 index 0000000..18338e7 --- /dev/null +++ b/src/ui/installed/summary.ts @@ -0,0 +1,69 @@ +/** Summary/stats line for the Installed workspace header. */ +import { type Theme } from "@earendil-works/pi-coding-agent"; +import { type State, type UnifiedItem } from "../../types/index.js"; +import { getPendingToggleChangeCount } from "../footer.js"; +import { getCurrentUnifiedItemState } from "./items.js"; +import { type UnifiedFilter } from "./state.js"; + +export function buildManagerSummary( + items: UnifiedItem[], + staged: Map, + byId: Map, + theme: Theme, + options?: { + visibleItems?: readonly UnifiedItem[]; + filter?: UnifiedFilter; + searchQuery?: string; + selectedCount?: number; + } +): string { + const summaryItems = options?.visibleItems ?? items; + const filtered = + Boolean(options?.searchQuery) || + options?.filter === "local" || + options?.filter === "packages" || + options?.filter === "updates" || + options?.filter === "disabled" || + options?.filter === "favorites" || + options?.filter === "recent"; + 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 disabledCount = summaryItems.filter((item) => { + if (item.type === "local") { + return getCurrentUnifiedItemState(item, staged) === "disabled"; + } + return (item.extensionSummary?.disabled ?? 0) > 0; + }).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"}`)); + } + + if (updateCount > 0) { + parts.push(theme.fg("warning", `${updateCount} update${updateCount === 1 ? "" : "s"}`)); + } + + if (disabledCount > 0) { + parts.push(theme.fg("warning", `${disabledCount} disabled`)); + } + + if (pendingCount > 0) { + parts.push(theme.fg("warning", `${pendingCount} unsaved`)); + } + + if ((options?.selectedCount ?? 0) > 0) { + parts.push(theme.fg("accent", `${options?.selectedCount} selected · B to act`)); + } + + return parts.join(" • "); +} diff --git a/src/ui/layout.ts b/src/ui/layout.ts new file mode 100644 index 0000000..b0d996a --- /dev/null +++ b/src/ui/layout.ts @@ -0,0 +1,50 @@ +/** Shared responsive layout primitives for the workspace screens. */ +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +export const DETAIL_PANE_MIN_WIDTH = 34; +export const TWO_PANE_MIN_WIDTH = 96; + +function padToWidth(value: string, width: number): string { + const truncated = truncateToWidth(value, Math.max(0, width), ""); + return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); +} + +/** Single source of truth for master/detail pane widths. */ +export function computeTwoPaneWidths( + width: number, + dividerWidth: number +): { leftWidth: number; rightWidth: number } { + const available = Math.max(2, Math.max(1, width) - dividerWidth); + const rightWidth = Math.max(DETAIL_PANE_MIN_WIDTH, Math.floor(available * 0.38)); + const leftWidth = Math.max(1, available - rightWidth); + return { leftWidth, rightWidth }; +} + +/** Compose two independently rendered surfaces without exceeding terminal width. */ +export function composeColumns( + left: string[], + right: string[], + width: number, + divider: string +): string[] { + const safeWidth = Math.max(1, width); + const { leftWidth, rightWidth } = computeTwoPaneWidths(safeWidth, visibleWidth(divider)); + const rowCount = Math.max(left.length, right.length); + const lines: string[] = []; + + for (let index = 0; index < rowCount; index += 1) { + const leftLine = padToWidth(left[index] ?? "", leftWidth); + const rightLine = truncateToWidth(right[index] ?? "", rightWidth, ""); + lines.push(truncateToWidth(`${leftLine}${divider}${rightLine}`, safeWidth, "")); + } + + return lines; +} + +export function formatCompactCount(value: number | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined; + return new Intl.NumberFormat("en", { + notation: value >= 1_000 ? "compact" : "standard", + maximumFractionDigits: value >= 10_000 ? 0 : 1, + }).format(value); +} diff --git a/src/ui/list-navigation.ts b/src/ui/list-navigation.ts new file mode 100644 index 0000000..f375906 --- /dev/null +++ b/src/ui/list-navigation.ts @@ -0,0 +1,28 @@ +export function moveListSelection( + currentIndex: number, + delta: number, + itemCount: number, + options?: { wrap?: boolean } +): number { + if (itemCount <= 0) return 0; + if (options?.wrap && Math.abs(delta) === 1) { + return (currentIndex + delta + itemCount) % itemCount; + } + return Math.max(0, Math.min(itemCount - 1, currentIndex + delta)); +} + +export function getCenteredVisibleRange( + selectedIndex: number, + itemCount: number, + maxVisibleItems: number +): { startIndex: number; endIndex: number } { + const maxVisible = Math.max(1, maxVisibleItems); + const startIndex = Math.max( + 0, + Math.min(selectedIndex - Math.floor(maxVisible / 2), Math.max(0, itemCount - maxVisible)) + ); + return { + startIndex, + endIndex: Math.min(startIndex + maxVisible, itemCount), + }; +} diff --git a/src/ui/package-config.ts b/src/ui/package-config.ts index b6e0dee..4e70d04 100644 --- a/src/ui/package-config.ts +++ b/src/ui/package-config.ts @@ -26,13 +26,13 @@ import { import { type InstalledPackage, type PackageExtensionEntry, type State } from "../types/index.js"; import { fileExists } from "../utils/fs.js"; import { logExtensionToggle } from "../utils/history.js"; -import { requireCustomUI, runCustomUI } from "../utils/mode.js"; +import { isProjectTrusted, requireCustomUI, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; -import { getPackageSourceKind } from "../utils/package-source.js"; import { getSettingsListSelectedIndex } from "../utils/settings-list.js"; +import { activeKeyHint } from "../utils/key-hints.js"; import { confirmReload } from "../utils/ui-helpers.js"; import { runTaskWithLoader } from "./async-task.js"; -import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js"; +import { getChangeMarker, getStatusIcon } from "./theme.js"; export interface PackageConfigRow { id: string; @@ -76,20 +76,12 @@ function formatConfigRowLabel( changed: boolean ): string { const statusIcon = getStatusIcon(theme, state); - const scopeIcon = getScopeIcon(theme, pkg.scope); - const sourceKind = getPackageSourceKind(pkg.source); - const pkgIcon = getPackageIcon( - theme, - sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local" - ); const changeMarker = getChangeMarker(theme, changed); const name = theme.bold(row.extensionPath); - const availability = row.available - ? "" - : ` ${theme.fg("warning", "[missing]")}${theme.fg("dim", " (cannot toggle)")}`; - const summary = theme.fg("dim", row.summary); + const availability = row.available ? "" : ` ${theme.fg("warning", "missing · cannot toggle")}`; + const details = theme.fg("dim", `${state} · ${pkg.scope} · ${row.summary}`); - return `${statusIcon} ${pkgIcon} [${scopeIcon}] ${name}${availability} - ${summary}${changeMarker}`; + return `${statusIcon} ${name} ${details}${availability}${changeMarker}`; } function buildSettingItems( @@ -130,7 +122,7 @@ async function showConfigurePanel( ctx: ExtensionCommandContext ): Promise { return runCustomUI(ctx, "Package extension configuration", () => - ctx.ui.custom((tui, theme, _keybindings, done) => { + ctx.ui.custom((tui, theme, keybindings, done) => { const container = new Container(); const titleText = new Text("", 2, 0); const subtitleText = new Text("", 2, 0); @@ -148,10 +140,15 @@ async function showConfigurePanel( subtitleText.setText( theme.fg( "muted", - `${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space/Enter toggle • S save • Esc cancel` + `${rows.length} extension path${rows.length === 1 ? "" : "s"} • Space toggle • S save • ${activeKeyHint(keybindings, "tui.select.cancel", "cancel")}` + ) + ); + footerText.setText( + theme.fg( + "dim", + `${activeKeyHint(keybindings, "tui.select.up", "navigate")} / ${activeKeyHint(keybindings, "tui.select.down", "navigate")} · Space toggle · S save · ${activeKeyHint(keybindings, "tui.select.cancel", "back")}` ) ); - footerText.setText(theme.fg("dim", "↑↓ Navigate | Space/Enter Toggle | S Save | Esc Back")); for (const settingsItem of settingsItems) { const row = rowById.get(settingsItem.id); @@ -213,6 +210,27 @@ async function showConfigurePanel( syncThemedContent(); }, handleInput(data: string) { + if (keybindings.matches(data, "tui.select.cancel")) { + done({ type: "cancel" }); + return; + } + + if (keybindings.matches(data, "tui.select.confirm")) { + const selectedIndex = getSettingsListSelectedIndex(settingsList) ?? 0; + const selectedRow = rows[selectedIndex]; + if (selectedRow && !selectedRow.available) { + notify( + ctx, + `${selectedRow.extensionPath} is missing on disk and cannot be toggled.`, + "warning" + ); + return; + } + settingsList.handleInput?.("\r"); + tui.requestRender(); + return; + } + if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") { done({ type: "save" }); return; @@ -248,7 +266,8 @@ export async function applyPackageExtensionChanges( staged: Map, pkg: InstalledPackage, cwd: string, - pi: ExtensionAPI + pi: ExtensionAPI, + projectTrusted = false ): Promise<{ changed: number; errors: string[] }> { const errors: string[] = []; const changedRows = [...rows] @@ -277,7 +296,8 @@ export async function applyPackageExtensionChanges( pkg.source, pkg.scope, changedRows.map(({ row, target }) => ({ extensionPath: row.extensionPath, target })), - cwd + cwd, + projectTrusted ); if (!result.ok) { @@ -305,7 +325,11 @@ export async function configurePackageExtensions( return { changed: 0, reloaded: false }; } - const validation = await validatePackageExtensionSettings(pkg.scope, ctx.cwd); + const validation = await validatePackageExtensionSettings( + pkg.scope, + ctx.cwd, + isProjectTrusted(ctx) + ); if (!validation.ok) { notify(ctx, validation.error, "error"); return { changed: 0, reloaded: false }; @@ -321,7 +345,9 @@ export async function configurePackageExtensions( cancellable: false, }, async () => { - const discovered = await discoverPackageExtensions([pkg], ctx.cwd); + const discovered = await discoverPackageExtensions([pkg], ctx.cwd, { + projectTrusted: isProjectTrusted(ctx), + }); const rows = await buildPackageConfigRows(discovered); return { rows }; } @@ -372,7 +398,14 @@ export async function configurePackageExtensions( } } - const apply = await applyPackageExtensionChanges(rows, staged, pkg, ctx.cwd, pi); + const apply = await applyPackageExtensionChanges( + rows, + staged, + pkg, + ctx.cwd, + pi, + isProjectTrusted(ctx) + ); if (apply.changed === 0) { if (apply.errors.length > 0) { diff --git a/src/ui/profile-review.ts b/src/ui/profile-review.ts new file mode 100644 index 0000000..6f48183 --- /dev/null +++ b/src/ui/profile-review.ts @@ -0,0 +1,185 @@ +import { type ExtensionCommandContext, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import { planProfileApplication } from "../profiles/apply.js"; +import { type ProfilePolicyViolation } from "../profiles/compare.js"; +import { type ExtmgrProfile, type ProfilePackage } from "../profiles/schema.js"; +import { activeKeyHint } from "../utils/key-hints.js"; +import { runCustomUI } from "../utils/mode.js"; +import { composeColumns, TWO_PANE_MIN_WIDTH } from "./layout.js"; + +function describeProfilePackage(pkg: ProfilePackage): string { + const details = [ + pkg.scope, + pkg.version ? `@${pkg.version}` : undefined, + pkg.ref ? `ref:${pkg.ref}` : undefined, + pkg.filters !== undefined + ? `filters:${pkg.filters.length} entrypoint${pkg.filters.length === 1 ? "" : "s"}` + : undefined, + pkg.manifestFingerprint || pkg.checksum + ? `manifest:${(pkg.manifestFingerprint || pkg.checksum)?.slice(0, 10)}…` + : undefined, + ]; + return `${pkg.source} (${details.filter(Boolean).join(" · ")})`; +} + +function describeFilters(filters: string[] | undefined): string { + if (filters === undefined) return "default"; + return filters.length > 0 ? filters.join(", ") : "none"; +} + +/** Human-readable list of fields that differ between two profile packages. */ +export function describeProfilePackageChanges(from: ProfilePackage, to: ProfilePackage): string[] { + const changes: string[] = []; + if (from.scope !== to.scope) changes.push(`scope ${from.scope} → ${to.scope}`); + if (from.version !== to.version) { + changes.push(`version ${from.version ?? "unknown"} → ${to.version ?? "unknown"}`); + } + if (from.ref !== to.ref) changes.push(`ref ${from.ref ?? "none"} → ${to.ref ?? "none"}`); + if (JSON.stringify(from.filters) !== JSON.stringify(to.filters)) { + changes.push(`filters ${describeFilters(from.filters)} → ${describeFilters(to.filters)}`); + } + const fromFingerprint = from.manifestFingerprint || from.checksum; + const toFingerprint = to.manifestFingerprint || to.checksum; + if (fromFingerprint !== toFingerprint) { + changes.push( + `manifest fingerprint ${fromFingerprint ? `${fromFingerprint.slice(0, 10)}…` : "unknown"} → ${toFingerprint ? `${toFingerprint.slice(0, 10)}…` : "unknown"}` + ); + } + return changes; +} + +export interface ProfileDiffRenderContext { + fg(color: string, text: string): string; + bold(text: string): string; +} + +/** Pure renderer for the inline current-vs-target profile diff. */ +export function renderProfileDiffLines( + current: ExtmgrProfile, + desired: ExtmgrProfile, + violations: ProfilePolicyViolation[], + width: number, + theme: ProfileDiffRenderContext, + options: { + canApply: boolean; + cancelHint: string; + identity?: { projectCwd?: string; globalCwd?: string }; + } +): string[] { + const safeWidth = Math.max(1, width); + const plan = planProfileApplication(current, desired, options.identity); + const hasChanges = plan.add.length + plan.remove.length + plan.update.length > 0; + const marker = (symbol: "+" | "-" | "~"): string => + theme.fg(symbol === "+" ? "success" : symbol === "-" ? "error" : "warning", symbol); + + const lines: string[] = [ + truncateToWidth(theme.fg("accent", theme.bold("Profile diff")), safeWidth, ""), + truncateToWidth( + theme.fg( + "muted", + `${plan.add.length} added · ${plan.remove.length} removed · ${plan.update.length} changed` + ), + safeWidth, + "" + ), + "", + ]; + + const changeDetailLines = (from: ProfilePackage, to: ProfilePackage): string[] => + describeProfilePackageChanges(from, to).map((change) => theme.fg("muted", ` ${change}`)); + + if (safeWidth >= TWO_PANE_MIN_WIDTH) { + const left = [theme.fg("accent", theme.bold("Current"))]; + const right = [theme.fg("accent", theme.bold(`Target · ${desired.name}`))]; + for (const pkg of plan.remove) { + left.push(`${marker("-")} ${describeProfilePackage(pkg)}`); + right.push(""); + } + for (const pkg of plan.add) { + left.push(""); + right.push(`${marker("+")} ${describeProfilePackage(pkg)}`); + } + for (const change of plan.update) { + left.push(`${marker("~")} ${describeProfilePackage(change.from)}`); + right.push(`${marker("~")} ${describeProfilePackage(change.to)}`); + for (const detail of changeDetailLines(change.from, change.to)) { + left.push(""); + right.push(detail); + } + } + if (!hasChanges) { + left.push(theme.fg("success", "No package changes")); + right.push(theme.fg("success", "Already matches current state")); + } + lines.push(...composeColumns(left, right, safeWidth, theme.fg("borderMuted", " │ "))); + } else { + const pushWrapped = (text: string): void => { + for (const wrapped of wrapTextWithAnsi(text, safeWidth)) { + lines.push(truncateToWidth(wrapped, safeWidth, "")); + } + }; + pushWrapped(theme.fg("accent", theme.bold(`Target · ${desired.name}`))); + for (const pkg of plan.remove) pushWrapped(`${marker("-")} ${describeProfilePackage(pkg)}`); + for (const pkg of plan.add) pushWrapped(`${marker("+")} ${describeProfilePackage(pkg)}`); + for (const change of plan.update) { + pushWrapped(`${marker("~")} ${change.to.source}`); + for (const detail of changeDetailLines(change.from, change.to)) pushWrapped(detail); + } + if (!hasChanges) pushWrapped(theme.fg("success", "No package changes")); + } + + if (violations.length > 0) { + lines.push( + "", + truncateToWidth(theme.fg("error", theme.bold("Policy blocks application")), safeWidth, "") + ); + for (const violation of violations) { + for (const wrapped of wrapTextWithAnsi(` • ${violation.message}`, safeWidth)) { + lines.push(truncateToWidth(wrapped, safeWidth, "")); + } + } + } + + const hints = [options.canApply ? "a apply" : undefined, options.cancelHint].filter(Boolean); + lines.push("", truncateToWidth(theme.fg("dim", hints.join(" · ")), safeWidth, "")); + return lines; +} + +/** Render the mandatory interactive profile review gate. */ +export async function showProfileDiff( + current: ExtmgrProfile, + desired: ExtmgrProfile, + violations: ProfilePolicyViolation[], + ctx: ExtensionCommandContext +): Promise<"apply" | "back" | undefined> { + const plan = planProfileApplication(current, desired, { + projectCwd: ctx.cwd, + globalCwd: getAgentDir(), + }); + const hasChanges = plan.add.length + plan.remove.length + plan.update.length > 0; + const canApply = hasChanges && violations.length === 0; + + return runCustomUI(ctx, "Profile comparison", () => + ctx.ui.custom<"apply" | "back">((tui, theme, keybindings, done) => ({ + render(width: number) { + return renderProfileDiffLines(current, desired, violations, width, theme, { + canApply, + cancelHint: activeKeyHint(keybindings, "tui.select.cancel", "back"), + identity: { projectCwd: ctx.cwd, globalCwd: getAgentDir() }, + }); + }, + invalidate() {}, + handleInput(data: string) { + if (data === "a" || data === "A") { + if (canApply) done("apply"); + return; + } + if (keybindings.matches(data, "tui.select.cancel")) { + done("back"); + return; + } + tui.requestRender(); + }, + })) + ); +} diff --git a/src/ui/profiles.ts b/src/ui/profiles.ts new file mode 100644 index 0000000..4d085e0 --- /dev/null +++ b/src/ui/profiles.ts @@ -0,0 +1,236 @@ +/** Dedicated profile workspace for previewing and applying package sets. */ +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + DynamicBorder, + type ExtensionAPI, + type ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui"; +import { + getCurrentProfile, + handleProfileSubcommand, + reviewAndApplyProfileWithOutcome, +} from "../commands/profile.js"; +import { type ExtmgrProfile } from "../profiles/schema.js"; +import { getProfileStorePath, readProfileStore } from "../profiles/store.js"; +import { activeKeyHint } from "../utils/key-hints.js"; +import { requireCustomUI, runCustomUI } from "../utils/mode.js"; +import { notify } from "../utils/notify.js"; +import { + buildWorkspaceNavigation, + matchWorkspaceNavigation, + type WorkspaceExit, +} from "./workspace/navigation.js"; + +export { + describeProfilePackageChanges, + renderProfileDiffLines, +} from "./profile-review.js"; + +const SAVE_PROFILE = "__save_current__"; +const IMPORT_PROFILE = "__import_profile__"; +const BACK = "__back__"; +const NAV_PREFIX = "__nav__:"; + +type ProfileSelection = string; + +function profileDescription(profile: ExtmgrProfile): string { + const global = profile.packages.filter((pkg) => pkg.scope === "global").length; + const project = profile.packages.length - global; + const checks = + profile.checks?.compatibility || profile.checks?.provenance + ? " · legacy checks (unverified)" + : ""; + const origin = profile.importMetadata + ? ` · imported ${profile.importMetadata.contentFingerprint?.slice(0, 15) ?? "source"}` + : ""; + return `${profile.packages.length} package${profile.packages.length === 1 ? "" : "s"} · ${global} global · ${project} project${checks}${origin}`; +} + +async function selectProfile( + ctx: ExtensionCommandContext, + profiles: ExtmgrProfile[] +): Promise { + return runCustomUI(ctx, "Profiles", () => + ctx.ui.custom((tui, theme, keybindings, done) => { + const items: SelectItem[] = profiles.map((profile) => ({ + value: profile.name, + label: profile.name, + description: profileDescription(profile), + })); + items.push( + { + value: SAVE_PROFILE, + label: "Save current package set", + description: "Capture installed packages, scopes, filters, and honest diagnostics", + }, + { + value: IMPORT_PROFILE, + label: "Import profile", + description: "Load a local or HTTPS JSON profile; save/review only, never auto-apply", + }, + { value: BACK, label: "Back", description: "Return to the installed screen" } + ); + + const container = new Container(); + const title = new Text("", 2, 0); + const nav = new Text("", 2, 0); + const summary = new Text("", 2, 0); + const footer = new Text("", 2, 0); + const syncThemedContent = (): void => { + title.setText(theme.fg("accent", theme.bold("Profiles"))); + nav.setText(buildWorkspaceNavigation(theme, "profiles")); + summary.setText( + theme.fg( + "muted", + `${profiles.length} saved profile${profiles.length === 1 ? "" : "s"} · choose a profile to preview or apply it` + ) + ); + footer.setText( + theme.fg( + "dim", + `${activeKeyHint(keybindings, "tui.select.up", "navigate")} · ${activeKeyHint(keybindings, "tui.select.confirm", "choose")} · ${activeKeyHint(keybindings, "tui.select.cancel", "back")}` + ) + ); + }; + + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(title); + container.addChild(nav); + container.addChild(summary); + container.addChild(new Spacer(1)); + + const list = new SelectList(items, Math.min(items.length, 12), { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), + }); + list.onSelect = (item) => done(item.value as ProfileSelection); + list.onCancel = () => done(BACK); + container.addChild(list); + container.addChild(new Spacer(1)); + container.addChild(footer); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + syncThemedContent(); + + return { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + syncThemedContent(); + }, + handleInput(data: string) { + const screen = matchWorkspaceNavigation(data, "profiles"); + if (screen) { + if (screen !== "profiles") done(`${NAV_PREFIX}${screen}`); + return; + } + list.handleInput(data); + tui.requestRender(); + }, + }; + }) + ); +} + +async function runProfileAction( + profile: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise<{ reloaded: boolean }> { + const selection = profile.name; + const action = await ctx.ui.select(`Profile: ${selection}`, [ + "Review and apply", + "Export profile", + "Delete profile", + "Back", + ]); + + switch (action) { + case "Review and apply": { + const current = await getCurrentProfile(ctx, pi); + const outcome = await reviewAndApplyProfileWithOutcome(current, profile, ctx, pi); + return { reloaded: outcome.reloaded }; + } + case "Export profile": { + const path = await ctx.ui.input("Export profile", `${selection}.json`); + if (!path?.trim()) return { reloaded: false }; + try { + const destination = resolve(ctx.cwd, path.trim()); + await writeFile(destination, `${JSON.stringify(profile, null, 2)}\n`, { flag: "wx" }); + notify(ctx, `Exported profile to ${destination}`, "info"); + } catch (error) { + notify( + ctx, + `Profile export failed: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + } + return { reloaded: false }; + } + case "Delete profile": + if (await ctx.ui.confirm("Delete profile", `Delete saved profile “${selection}”?`)) { + await handleProfileSubcommand(["delete", selection], ctx, pi); + } + return { reloaded: false }; + default: + return { reloaded: false }; + } +} + +export async function showProfiles( + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + if ( + !requireCustomUI(ctx, "Profiles", "Use `/extensions profile ` outside the full TUI.") + ) { + return undefined; + } + + while (true) { + let store: Awaited>; + try { + store = await readProfileStore(getProfileStorePath()); + } catch (error) { + notify( + ctx, + `Profiles could not be loaded: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + return undefined; + } + + const profiles = Object.values(store.profiles).sort((left, right) => + left.name.localeCompare(right.name) + ); + const selection = await selectProfile(ctx, profiles); + if (!selection || selection === BACK) return undefined; + if (selection.startsWith(NAV_PREFIX)) { + return selection.slice(NAV_PREFIX.length) as Exclude; + } + + if (selection === SAVE_PROFILE) { + const name = await ctx.ui.input("Save current profile", "workstation"); + if (name?.trim()) await handleProfileSubcommand(["save", name.trim()], ctx, pi); + continue; + } + if (selection === IMPORT_PROFILE) { + const source = await ctx.ui.input("Import profile source", "./profile.json"); + if (source?.trim()) await handleProfileSubcommand(["import", source.trim()], ctx, pi); + continue; + } + + const profile = profiles.find((candidate) => candidate.name === selection); + if (profile) { + const outcome = await runProfileAction(profile, ctx, pi); + if (outcome.reloaded) return "reloaded"; + } + } +} diff --git a/src/ui/remote.ts b/src/ui/remote.ts index 122203b..8ca3f0c 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -1,153 +1,57 @@ /** - * Remote package browsing UI + * Remote package browsing UI (Discover workspace orchestration). + * + * Cohesive pieces live in ./discover: query planning, metadata caching, + * formatting, and the browse component. This module owns the screen loop, + * navigation queue, and install/detail flows. */ import { DynamicBorder, type ExtensionAPI, type ExtensionCommandContext, - type KeybindingsManager, - type Theme, } from "@earendil-works/pi-coding-agent"; +import { Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { PAGE_SIZE, UI } from "../constants.js"; +import { getRemotePackageBadges } from "../packages/badges.js"; import { - Container, - fuzzyMatch, - type Focusable, - Input, - Key, - matchesKey, - Spacer, - Text, - truncateToWidth, - wrapTextWithAnsi, -} from "@earendil-works/pi-tui"; -import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS, UI } from "../constants.js"; -import { + addWeeklyDownloadsToSearchPage, clearSearchCache, + getInstalledPackagesAllScopes, getSearchCache, + hydrateSearchCache, isCacheValid, searchNpmPackages, - setSearchCache, } from "../packages/discovery.js"; import { - installPackage, installPackageLocallyWithOutcome, installPackageWithOutcome, } from "../packages/install.js"; -import { type BrowseAction, type NpmPackage, type SearchCache } from "../types/index.js"; +import { type BrowseAction, type NpmPackage } from "../types/index.js"; +import { getKnownUpdates } from "../utils/auto-update.js"; import { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js"; -import { formatBytes, normalizePackageSource, parseNpmSource, truncate } from "../utils/format.js"; +import { 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 { - timestamp: number; - text: string; -} - -interface NpmViewInfo { - description?: string; - version?: string; - author?: { name?: string } | string; - homepage?: string; - users?: Record; - dist?: { unpackedSize?: number }; - repository?: { url?: string } | string; -} - -interface NpmDownloadsPoint { - downloads?: number; -} - -// LRU Cache with size limit to prevent memory leaks -class PackageInfoCache { - private cache = new Map(); - private readonly maxSize: number; - private readonly ttl: number; - - constructor(maxSize: number, ttl: number) { - this.maxSize = maxSize; - this.ttl = ttl; - } - - get(name: string): PackageInfoCacheEntry | undefined { - const entry = this.cache.get(name); - if (!entry) return undefined; - - // Check if expired - if (Date.now() - entry.timestamp > this.ttl) { - this.cache.delete(name); - return undefined; - } - - // Move to end (most recently used) - this.cache.delete(name); - this.cache.set(name, entry); - return entry; - } - - set(name: string, entry: Omit): void { - if (this.cache.has(name)) { - this.cache.delete(name); - } else if (this.cache.size >= this.maxSize) { - const firstKey = this.cache.keys().next().value; - if (firstKey) { - this.cache.delete(firstKey); - } - } - - this.cache.set(name, { - ...entry, - timestamp: Date.now(), - }); - } - - clear(): void { - this.cache.clear(); - } -} - -// Global LRU cache instance -const packageInfoCache = new PackageInfoCache( - CACHE_LIMITS.packageInfoMaxSize, - CACHE_LIMITS.packageInfoTTL -); - -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(); - } -} +import { RemotePackageBrowser } from "./discover/browser.js"; +import { buildPackageInfoText, packageInfoCache } from "./discover/metadata.js"; +import { + COMMUNITY_BROWSE_QUERY, + createCommunityBrowsePlan, + createRemoteBrowseQueryPlan, + filterRemoteBrowseResults, + type RemoteBrowseQueryPlan, + type RemoteBrowseSource, + resolveRemoteBrowseSource, +} from "./discover/query.js"; +import { runAuxWorkspaceScreens } from "./workspace/router.js"; + +export { clearRemotePackageInfoCache } from "./discover/metadata.js"; const REMOTE_MENU_CHOICES = { - browse: "🔍 Browse pi packages", - search: "🔎 Search packages", - install: "📥 Install by source", + browse: "Browse community packages", + search: "Search npm packages", + install: "Install by source", } as const; const PACKAGE_DETAILS_CHOICES = { @@ -157,318 +61,14 @@ 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"; - return error; -} - -function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw createAbortError(); - } -} - -function formatCount(value: number | undefined): string { - if (typeof value !== "number" || !Number.isFinite(value)) return "unknown"; - return new Intl.NumberFormat().format(value); -} - -async function fetchWeeklyDownloads( - packageName: string, - signal?: AbortSignal -): Promise { - try { - const encoded = encodeURIComponent(packageName); - 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; - return typeof data.downloads === "number" ? data.downloads : undefined; - } catch (error) { - if (signal?.aborted && error instanceof Error && error.name === "AbortError") { - throw error; - } - return undefined; - } -} - -async function buildPackageInfoText( - packageName: string, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - signal?: AbortSignal -): Promise { - // Check cache first - const cached = packageInfoCache.get(packageName); - if (cached) { - return cached.text; - } - - const [infoRes, weeklyDownloads] = await Promise.all([ - execNpm(pi, ["view", packageName, "--json"], ctx, { - timeout: TIMEOUTS.npmView, - ...(signal ? { signal } : {}), - }), - fetchWeeklyDownloads(packageName, signal), - ]); - - throwIfAborted(signal); - - if (infoRes.code !== 0) { - throw new Error(infoRes.stderr || infoRes.stdout || `npm view failed (exit ${infoRes.code})`); - } - - const info = JSON.parse(infoRes.stdout) as NpmViewInfo; - const description = info.description ?? "No description"; - const version = info.version ?? "unknown"; - const author = typeof info.author === "object" ? info.author?.name : (info.author ?? "unknown"); - const homepage = info.homepage ?? ""; - const stars = info.users ? Object.keys(info.users).length : undefined; - const unpackedSize = info.dist?.unpackedSize; - const repository = typeof info.repository === "string" ? info.repository : info.repository?.url; - - const lines = [ - `${packageName}@${version}`, - description, - `Author: ${author}`, - `Weekly downloads: ${formatCount(weeklyDownloads)}`, - `Stars: ${formatCount(stars)}`, - `Unpacked size: ${typeof unpackedSize === "number" ? formatBytes(unpackedSize) : "unknown"}`, - ]; - - if (homepage) lines.push(`Homepage: ${homepage}`); - if (repository) lines.push(`Repository: ${repository}`); - - const text = lines.join("\n"); - - throwIfAborted(signal); - packageInfoCache.set(packageName, { text }); - - return text; -} +/** True when a nested mutation reloaded pi and callers must stop using this context. */ +export type RemoteWorkspaceResult = boolean; export async function showRemote( args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI -): Promise { +): Promise { const { subcommand: sub, args: rest } = splitCommandArgs(args); const query = rest.join(" ").trim(); @@ -476,30 +76,29 @@ export async function showRemote( case "list": case "installed": // Legacy: redirect to unified view - ctx.ui.notify("📦 Use /extensions for the unified view.", "info"); - return; + ctx.ui.notify("Use /extensions for the Installed workspace.", "info"); + return false; case "install": if (query) { - await installPackage(query, ctx, pi); - } else { - await promptInstall(ctx, pi); + return (await installPackageWithOutcome(query, ctx, pi)).reloaded; } - return; + return promptInstall(ctx, pi); case "search": - await searchPackages(query, ctx, pi); - return; + return searchPackages(query, ctx, pi); case "browse": case "": - await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi); - return; + return browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi); } // Show remote menu - await showRemoteMenu(ctx, pi); + return showRemoteMenu(ctx, pi); } -async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { - if (!ctx.hasUI) return; +async function showRemoteMenu( + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + if (!ctx.hasUI) return false; const choice = parseChoiceByLabel( REMOTE_MENU_CHOICES, @@ -508,292 +107,13 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P switch (choice) { case "browse": - await browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi); - return; + return browseRemotePackages(ctx, COMMUNITY_BROWSE_QUERY, pi); case "search": - await promptSearch(ctx, pi); - return; + return promptSearch(ctx, pi); case "install": - await promptInstall(ctx, pi); - return; + return promptInstall(ctx, pi); default: - return; - } -} - -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 keybindings: KeybindingsManager, - 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 { - 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 (this.keybindings.matches(data, "tui.select.up")) { - this.moveSelection(-1); - return true; - } - - if (this.keybindings.matches(data, "tui.select.down")) { - this.moveSelection(1); - return true; - } - - if (this.keybindings.matches(data, "tui.select.pageUp")) { - this.moveSelection(-Math.max(1, this.maxVisibleItems - 1)); - return true; - } - - if (this.keybindings.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 }; + return false; } } @@ -805,7 +125,8 @@ async function selectBrowseAction( offset: number, totalResults: number, showPrevious: boolean, - showLoadMore: boolean + showLoadMore: boolean, + hydrateDownloads?: (signal: AbortSignal) => Promise ): Promise { if (!ctx.hasUI) return undefined; @@ -829,6 +150,20 @@ async function selectBrowseAction( const syncThemedContent = (): void => { title.setText(theme.fg("accent", theme.bold(plan.title))); }; + const hydrationController = new AbortController(); + let disposed = false; + if (hydrateDownloads) { + void hydrateDownloads(hydrationController.signal) + .then(() => { + // Ignore results that land after disposal or cancellation. + if (disposed || hydrationController.signal.aborted) return; + browser.refreshSort(); + tui.requestRender(); + }) + .catch(() => { + // Background hydration is best-effort; abort/network errors are expected. + }); + } syncThemedContent(); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); @@ -856,6 +191,10 @@ async function selectBrowseAction( browser.invalidate(); syncThemedContent(); }, + dispose() { + disposed = true; + hydrationController.abort(); + }, handleInput(data: string) { if (browser.handleBrowseInput(data)) { tui.requestRender(); @@ -866,13 +205,61 @@ async function selectBrowseAction( ); } +type BrowseRequest = { + ctx: ExtensionCommandContext; + query: string; + pi: ExtensionAPI; + offset: number; + source?: RemoteBrowseSource; + forceRefresh: boolean; +}; + +let browseNavigationActive = false; +const browseNavigationQueue: BrowseRequest[] = []; + export async function browseRemotePackages( ctx: ExtensionCommandContext, query: string, pi: ExtensionAPI, offset = 0, - source?: RemoteBrowseSource -): Promise { + source?: RemoteBrowseSource, + forceRefresh = false +): Promise { + const request: BrowseRequest = { + ctx, + query, + pi, + offset, + forceRefresh, + ...(source ? { source } : {}), + }; + if (browseNavigationActive) { + browseNavigationQueue.push(request); + return false; + } + + browseNavigationActive = true; + try { + let next: BrowseRequest | undefined = request; + while (next) { + if (await browseRemotePackagesPage(next)) return true; + next = browseNavigationQueue.shift(); + } + } finally { + browseNavigationQueue.length = 0; + browseNavigationActive = false; + } + return false; +} + +async function browseRemotePackagesPage({ + ctx, + query, + pi, + offset = 0, + source, + forceRefresh = false, +}: BrowseRequest): Promise { if ( !requireCustomUI( ctx, @@ -880,7 +267,7 @@ export async function browseRemotePackages( "Use `/extensions install ` to install directly outside the full interactive TUI." ) ) { - return; + return false; } const browseSource = resolveRemoteBrowseSource(query, source); @@ -890,65 +277,68 @@ export async function browseRemotePackages( : createRemoteBrowseQueryPlan(query); if (plan.kind === "unsupported") { notify(ctx, plan.message, "warning"); - return; + return false; } - const cacheQuery = browseSource === "community" ? COMMUNITY_BROWSE_QUERY : plan.rawQuery; - let allPackages: NpmPackage[] | undefined; - - 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; - } + const searchLabel = plan.displayQuery || "community packages"; + let searchPage: Awaited> | undefined; + if (!forceRefresh && isCacheValid(plan.searchQuery, offset)) { + searchPage = getSearchCache(plan.searchQuery, offset) ?? undefined; } - if (!allPackages) { - const searchLabel = - browseSource === "community" - ? "community packages" - : plan.displayQuery || "community packages"; - const results = await runTaskWithLoader( - ctx, - { - title: plan.title, - message: `Searching npm for ${truncate(searchLabel, 40)}...`, - }, - async ({ signal, setMessage }) => { - setMessage(`Searching npm for ${truncate(searchLabel, 40)}...`); - return searchNpmPackages( - browseSource === "community" ? COMMUNITY_BROWSE_QUERY : plan.searchQuery, - ctx, - { signal } - ); - } - ); + if (!searchPage && !forceRefresh) { + searchPage = (await hydrateSearchCache(plan.searchQuery, offset)) ?? undefined; + } - if (!results) { - notify(ctx, "Remote package search was cancelled.", "info"); - return; + if (!searchPage) { + try { + searchPage = await runTaskWithLoader( + ctx, + { + title: plan.title, + message: `Searching npm for ${truncate(searchLabel, 40)}...`, + }, + async ({ signal }) => { + return searchNpmPackages(plan.searchQuery, ctx, { + signal, + offset, + size: PAGE_SIZE, + forceRefresh, + }); + } + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + notify(ctx, `Remote package search failed: ${message}`, "warning"); + return false; } + } - if (browseSource === "community") { - setCommunityBrowseCache(results); - allPackages = filterCommunityBrowseResults(results, plan.displayQuery); - } else { - allPackages = filterRemoteBrowseResults(plan, results); - setSearchCache({ - query: plan.rawQuery, - results: allPackages, - timestamp: Date.now(), - }); - } + if (!searchPage) { + notify(ctx, "Remote package search was cancelled.", "info"); + return false; } - const totalResults = allPackages.length; - const packages = allPackages.slice(offset, offset + PAGE_SIZE); + const resolvedSearchPage = searchPage; + const installed = await getInstalledPackagesAllScopes(ctx); + const installedNames = new Set( + installed.flatMap((pkg) => { + const parsed = parseNpmSource(pkg.source); + return parsed?.name ? [parsed.name] : []; + }) + ); + const updateNames = new Set( + [...getKnownUpdates(ctx)].flatMap((identity) => { + const parsed = parseNpmSource(identity); + return parsed?.name ? [parsed.name] : []; + }) + ); + const packages = filterRemoteBrowseResults(plan, resolvedSearchPage.results).map((pkg) => ({ + ...pkg, + ...getRemotePackageBadges(pkg, installedNames, updateNames), + })); + const totalResults = + plan.kind === "search" && plan.exactPackageName ? packages.length : resolvedSearchPage.total; const reloadQuery = browseSource === "community" ? plan.displayQuery || COMMUNITY_BROWSE_QUERY : plan.rawQuery; @@ -960,13 +350,26 @@ export async function browseRemotePackages( ctx.ui.notify(msg, "info"); if (offset > 0) { - await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource); + return browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource); } - return; + return false; } - const showLoadMore = totalResults >= PAGE_SIZE && offset + PAGE_SIZE < totalResults; + const showLoadMore = offset + resolvedSearchPage.results.length < totalResults; const showPrevious = offset > 0; + const hydrateDownloads = packages.some((pkg) => pkg.weeklyDownloads === undefined) + ? async (signal: AbortSignal): Promise => { + await addWeeklyDownloadsToSearchPage(resolvedSearchPage, signal); + if (signal.aborted) return; + const hydrated = new Map( + resolvedSearchPage.results.map((pkg) => [pkg.name, pkg.weeklyDownloads] as const) + ); + for (const pkg of packages) { + const weeklyDownloads = hydrated.get(pkg.name); + if (weeklyDownloads !== undefined) pkg.weeklyDownloads = weeklyDownloads; + } + } + : undefined; const result = await selectBrowseAction( ctx, @@ -976,62 +379,114 @@ export async function browseRemotePackages( offset, totalResults, showPrevious, - showLoadMore + showLoadMore, + hydrateDownloads ); if (!result || result.type === "cancel") { - return; + return false; + } + + if (result.type === "workspace") { + if (result.screen === "installed") return false; + if (result.screen === "profiles" || result.screen === "health") { + const outcome = await runAuxWorkspaceScreens(result.screen, ctx, pi); + // After a reload the pre-reload context must not drive further UI. + if (outcome.reloaded) return true; + if (outcome.navigate === "installed") return false; + } + return browseRemotePackages(ctx, reloadQuery, pi, offset, browseSource); } switch (result.type) { case "prev": - await browseRemotePackages( + return browseRemotePackages( ctx, reloadQuery, pi, Math.max(0, offset - PAGE_SIZE), browseSource ); - return; case "next": - await browseRemotePackages(ctx, reloadQuery, pi, offset + PAGE_SIZE, browseSource); - return; + return browseRemotePackages(ctx, reloadQuery, pi, offset + PAGE_SIZE, browseSource); case "refresh": - if (browseSource === "community") { - clearCommunityBrowseCache(); - } else { - clearSearchCache(); - } - await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource); - return; + clearSearchCache(plan.searchQuery); + return browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource, true); case "search": { const nextQuery = result.query.trim(); if (browseSource === "community") { - await browseRemotePackages(ctx, nextQuery || COMMUNITY_BROWSE_QUERY, pi, 0, "community"); - return; + return browseRemotePackages(ctx, nextQuery || COMMUNITY_BROWSE_QUERY, pi, 0, "community"); } - await browseRemotePackages( + return 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 "install": { + if (await promptInstall(ctx, pi)) return true; + return browseRemotePackages(ctx, reloadQuery, pi, offset, browseSource); + } case "menu": - await showRemoteMenu(ctx, pi); - return; + return showRemoteMenu(ctx, pi); case "package": - await showPackageDetails(result.name, ctx, pi, reloadQuery, offset, browseSource); - return; + return showPackageDetails(result.name, ctx, pi, reloadQuery, offset, browseSource); } } +async function confirmMarketplaceInstall( + packageName: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + mode: "managed" | "standalone" +): Promise<"global" | "project" | undefined> { + const scopeChoice = parseChoiceByLabel( + { + global: "Global (~/.pi/agent/settings.json)", + project: ".pi/settings.json", + cancel: "Cancel", + }, + await ctx.ui.select("Install scope", [ + "Global (~/.pi/agent/settings.json)", + ".pi/settings.json", + "Cancel", + ]) + ); + if (!scopeChoice || scopeChoice === "cancel") return undefined; + + const info = + packageInfoCache.get(packageName)?.text ?? + (await runTaskWithLoader( + ctx, + { + title: "Pre-install review", + message: `Inspecting ${packageName} metadata...`, + }, + ({ signal }) => buildPackageInfoText(packageName, ctx, pi, signal) + )); + if (!info) { + notify(ctx, "Pre-install review cancelled; nothing was installed.", "info"); + return undefined; + } + + const review = [ + `Source: npm:${packageName}`, + `Scope: ${scopeChoice}`, + `Mode: ${mode}`, + "", + info, + "", + "Missing provenance or compatibility metadata is unknown, not safe.", + ].join("\n"); + if (!(await ctx.ui.confirm("Review before install", `${review}\n\nInstall now?`))) { + notify(ctx, "Installation cancelled.", "info"); + return undefined; + } + return scopeChoice; +} + async function showPackageDetails( packageName: string, ctx: ExtensionCommandContext, @@ -1039,10 +494,10 @@ async function showPackageDetails( previousQuery: string, previousOffset: number, browseSource?: RemoteBrowseSource -): Promise { +): Promise { if (!ctx.hasUI) { console.log(`Package: ${packageName}`); - return; + return false; } const choice = parseChoiceByLabel( @@ -1052,43 +507,47 @@ async function showPackageDetails( switch (choice) { case "installManaged": { - const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi); - if (outcome.reloaded) { - return; - } + const scope = await confirmMarketplaceInstall(packageName, ctx, pi, "managed"); + if (!scope) return false; + const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi, { + scope, + skipConfirmation: true, + }); + if (outcome.reloaded) return true; if (outcome.installed) { - await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); - return; + return browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); } - await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); - return; + return showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); } case "installStandalone": { - const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi); - if (outcome.reloaded) { - return; - } + const scope = await confirmMarketplaceInstall(packageName, ctx, pi, "standalone"); + if (!scope) return false; + const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi, { + scope, + skipConfirmation: true, + }); + if (outcome.reloaded) return true; if (outcome.installed) { - await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); - return; + return browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); } - await showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); - return; + return showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); } case "viewInfo": try { - const text = await runTaskWithLoader( - ctx, - { - title: packageName, - message: `Fetching package details for ${packageName}...`, - }, - ({ signal }) => buildPackageInfoText(packageName, ctx, pi, signal) - ); + const text = + packageInfoCache.get(packageName)?.text ?? + (await runTaskWithLoader( + ctx, + { + title: packageName, + message: `Fetching package details for ${packageName}...`, + }, + ({ signal }) => buildPackageInfoText(packageName, ctx, pi, signal) + )); if (!text) { notify(ctx, `Loading ${packageName} details was cancelled.`, "info"); - await showPackageDetails( + return showPackageDetails( packageName, ctx, pi, @@ -1096,7 +555,6 @@ async function showPackageDetails( previousOffset, browseSource ); - return; } ctx.ui.notify(text, "info"); @@ -1104,44 +562,45 @@ 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, browseSource); - return; + return showPackageDetails(packageName, ctx, pi, previousQuery, previousOffset, browseSource); case "back": - await browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); - return; + return browseRemotePackages(ctx, previousQuery, pi, previousOffset, browseSource); default: - return; + return false; } } -async function promptSearch(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { +async function promptSearch( + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { 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); + if (!query?.trim()) return false; + return searchPackages(query.trim(), ctx, pi); } async function searchPackages( query: string, ctx: ExtensionCommandContext, pi: ExtensionAPI -): Promise { - if (!query) { - await promptSearch(ctx, pi); - return; - } - await browseRemotePackages(ctx, query, pi); +): Promise { + if (!query) return promptSearch(ctx, pi); + return browseRemotePackages(ctx, query, pi); } -async function promptInstall(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { +async function promptInstall( + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { if (!ctx.hasUI) { notify( ctx, "Interactive input not available in non-interactive mode.\nUsage: /extensions install ", "warning" ); - return; + return false; } const source = await ctx.ui.input("Install package", "npm:@scope/pkg or git:https://..."); - if (!source) return; - await installPackage(source.trim(), ctx, pi); + if (!source) return false; + return (await installPackageWithOutcome(source.trim(), ctx, pi)).reloaded; } diff --git a/src/ui/theme.ts b/src/ui/theme.ts index bc40b3e..c8df30c 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -26,37 +26,6 @@ export function getStatusIcon( } } -/** - * Package type icons using ASCII/Unicode that work in all terminals - */ -export function getPackageIcon(theme: Theme, type: "npm" | "git" | "local" | "remote"): string { - switch (type) { - case "npm": - return theme.fg("accent", "◆"); // Diamond for npm - case "git": - return theme.fg("warning", "◇"); // Outline diamond for git - case "local": - return theme.fg("dim", "▪"); // Small square for local - case "remote": - return theme.fg("accent", "▸"); // Arrow for remote - } -} - -/** - * Scope indicators (Global vs Project) - */ -export function getScopeIcon( - theme: Theme, - scope: "global" | "project", - options?: { dimGlobal?: boolean } -): string { - const { dimGlobal = true } = options ?? {}; - if (scope === "global") { - return dimGlobal ? theme.fg("muted", "G") : theme.fg("dim", "G"); - } - return theme.fg("accent", "P"); -} - /** * Format extension state change indicator */ @@ -64,24 +33,3 @@ export function getChangeMarker(theme: Theme, hasChanges: boolean): string { if (!hasChanges) return ""; return ` ${theme.fg("warning", "*")}`; } - -/** - * Create a spinner character for loading states - */ -export function getSpinner(theme: Theme, frame: number): string { - const frames = ["◐", "◓", "◑", "◒"]; - return theme.fg("accent", frames[frame % frames.length] ?? "◐"); -} - -/** - * Format a size string with appropriate color - */ -export function formatSize(theme: Theme, sizeBytes: number): string { - if (sizeBytes < 1024) { - return theme.fg("dim", `${sizeBytes}B`); - } else if (sizeBytes < 1024 * 1024) { - return theme.fg("dim", `${(sizeBytes / 1024).toFixed(1)}KB`); - } else { - return theme.fg("warning", `${(sizeBytes / (1024 * 1024)).toFixed(1)}MB`); - } -} diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 72710eb..89ac37f 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -1,67 +1,42 @@ /** - * Unified extension manager UI - * Displays local extensions and installed packages in one view + * Unified extension manager UI (Installed workspace orchestration). + * Displays local extensions and installed packages in one view. + * + * Cohesive pieces live in ./installed: items, formatting, filters, browser, + * and actions. This module owns the screen loop and non-interactive fallbacks. */ -import { homedir } from "node:os"; -import { relative } from "node:path"; import { DynamicBorder, type ExtensionAPI, type ExtensionCommandContext, - type KeybindingsManager, - type Theme, } from "@earendil-works/pi-coding-agent"; -import { - Container, - type Focusable, - fuzzyMatch, - Input, - Key, - matchesKey, - Spacer, - Text, - truncateToWidth, - wrapTextWithAnsi, -} from "@earendil-works/pi-tui"; +import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import { UI } from "../constants.js"; -import { - discoverExtensions, - removeLocalExtension, - setExtensionState, -} from "../extensions/discovery.js"; +import { discoverExtensions } from "../extensions/discovery.js"; import { getInstalledPackages } from "../packages/discovery.js"; import { discoverPackageExtensions } from "../packages/extensions.js"; -import { - removePackageWithOutcome, - showInstalledPackagesList, - updatePackagesWithOutcome, - updatePackageWithOutcome, -} from "../packages/management.js"; -import { - type InstalledPackage, - type LocalUnifiedItem, - type PackageExtensionEntry, - type PackageExtensionStateSummary, - type State, - type UnifiedAction, - type UnifiedItem, -} from "../types/index.js"; -import { getKnownUpdates, promptAutoUpdateWizard } from "../utils/auto-update.js"; -import { parseChoiceByLabel } from "../utils/command.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 { showInstalledPackagesList } from "../packages/management.js"; +import { type State, type UnifiedAction } from "../types/index.js"; +import { getKnownUpdates } from "../utils/auto-update.js"; +import { formatEntry as formatExtEntry } from "../utils/format.js"; +import { hasCustomUI, isProjectTrusted, 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 { updateExtmgrStatus } from "../utils/status.js"; -import { confirmReload, formatListOutput } from "../utils/ui-helpers.js"; +import { readReloadState } from "../utils/reload-state.js"; +import { getSavedViewsPath, readSavedViews, writeSavedViews } from "../utils/views.js"; +import { 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 { buildFooterShortcuts, buildFooterState } from "./footer.js"; +import { handleUnifiedAction } from "./installed/actions.js"; +import { UnifiedManagerBrowser } from "./installed/browser.js"; +import { buildManagerSummary } from "./installed/summary.js"; +import { buildUnifiedItems } from "./installed/items.js"; +import { managerStateToView, viewToManagerState } from "./installed/state.js"; import { showRemote } from "./remote.js"; -import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js"; +import { buildWorkspaceNavigation } from "./workspace/navigation.js"; + +export { buildUnifiedItems }; + +let lastReloadNoticeAt: number | undefined; async function showInteractiveFallback( ctx: ExtensionCommandContext, @@ -85,6 +60,16 @@ export async function showInteractive( return; } + const reloadState = await readReloadState(); + if (reloadState.required && reloadState.changedAt !== lastReloadNoticeAt) { + lastReloadNoticeAt = reloadState.changedAt; + notify( + ctx, + `Reload required for pending changes${reloadState.reasons.length > 0 ? `: ${reloadState.reasons.join(", ")}` : "."}`, + "warning" + ); + } + // Main loop - keeps showing the menu until user explicitly exits while (true) { const shouldExit = await showInteractiveOnce(ctx, pi); @@ -122,7 +107,9 @@ async function showInteractiveOnce( ]); setMessage("Loading package extension states..."); - const packageExtensions = await discoverPackageExtensions(installedPackages, ctx.cwd); + const packageExtensions = await discoverPackageExtensions(installedPackages, ctx.cwd, { + projectTrusted: isProjectTrusted(ctx), + }); return { localEntries, installedPackages, packageExtensions }; } @@ -139,6 +126,8 @@ async function showInteractiveOnce( } const { localEntries, installedPackages, packageExtensions } = initialData; + const viewsPath = getSavedViewsPath(ctx.cwd); + let savedViews = await readSavedViews(viewsPath); // Build unified items list. const knownUpdates = getKnownUpdates(ctx); @@ -146,14 +135,17 @@ async function showInteractiveOnce( // If nothing found, show quick actions if (items.length === 0) { - const choice = await ctx.ui.select("No extensions or packages found", [ + const choice = await ctx.ui.select("Nothing installed yet", [ "Browse community packages", - "Cancel", + "Install by source", + "Back", ]); if (choice === "Browse community packages") { - await showRemote("", ctx, pi); - return false; + return showRemote("", ctx, pi); + } + if (choice === "Install by source") { + return showRemote("install", ctx, pi); } return true; } @@ -161,7 +153,7 @@ 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; + let managerState = viewToManagerState(savedViews.lastView); while (true) { let nextManagerState = managerState; @@ -174,6 +166,7 @@ async function showInteractiveOnce( const container = new Container(); const titleText = new Text("", 2, 0); + const navText = new Text("", 2, 0); const statsText = new Text("", 2, 0); const footerText = new Text("", 2, 0); let browser!: UnifiedManagerBrowser; @@ -189,12 +182,15 @@ async function showInteractiveOnce( ctx.cwd, Math.max(4, Math.min(UI.maxListHeight, tui.terminal.rows - 12)), complete, + new Set(savedViews.favorites), + new Set(savedViews.recent), managerState ); let lastWidth = tui.terminal.columns; container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); container.addChild(titleText); + container.addChild(navText); container.addChild(statsText); container.addChild(new Spacer(1)); container.addChild(browser); @@ -204,18 +200,28 @@ async function showInteractiveOnce( const syncThemedContent = (width = lastWidth): void => { lastWidth = width; - titleText.setText(theme.fg("accent", theme.bold("Extensions Manager"))); + titleText.setText(theme.fg("accent", theme.bold("Installed"))); + navText.setText(buildWorkspaceNavigation(theme, "installed")); statsText.setText( buildManagerSummary(items, staged, byId, theme, { visibleItems: browser.getVisibleItems(), filter: browser.getFilter(), searchQuery: browser.getSearchQuery(), + selectedCount: browser.getBulkSelectedCount(), }) ); footerText.setText( theme.fg( "dim", - buildFooterShortcuts(buildFooterState(staged, byId, browser.getSelectedItem())) + buildFooterShortcuts( + buildFooterState( + staged, + byId, + browser.getSelectedItem(), + browser.getBulkSelectedCount() + ), + keybindings + ) ) ); }; @@ -256,1338 +262,38 @@ async function showInteractiveOnce( return true; } - const outcome = await handleUnifiedAction(result, items, staged, byId, ctx, pi); - if (outcome === "resume") { - managerState = nextManagerState; - continue; - } - - return outcome; - } -} - -export function buildUnifiedItems( - localEntries: Awaited>, - installedPackages: InstalledPackage[], - knownUpdates: Set, - packageExtensions: PackageExtensionEntry[] = [] -): UnifiedItem[] { - const items: UnifiedItem[] = []; - const localPaths = new Set(); - const packageExtensionSummaries = buildPackageExtensionSummaries(packageExtensions); - - // Add local extensions - for (const entry of localEntries) { - const currentPath = entry.state === "disabled" ? entry.disabledPath : entry.activePath; - localPaths.add(normalizePathIdentity(currentPath)); - items.push({ - type: "local", - id: entry.id, - displayName: entry.displayName, - summary: entry.summary, - scope: entry.scope, - state: entry.state, - activePath: entry.activePath, - disabledPath: entry.disabledPath, - originalState: entry.state, - }); - } - - for (const pkg of installedPackages) { - const pkgSourceNormalized = normalizePathIdentity(pkg.source); - const pkgResolvedNormalized = pkg.resolvedPath ? normalizePathIdentity(pkg.resolvedPath) : ""; - - let isDuplicate = false; - for (const localPath of localPaths) { - if (pkgSourceNormalized === localPath || pkgResolvedNormalized === localPath) { - isDuplicate = true; - break; - } - if (pkgResolvedNormalized && localPath.startsWith(`${pkgResolvedNormalized}/`)) { - isDuplicate = true; - break; - } - } - if (isDuplicate) continue; - - const extensionSummary = packageExtensionSummaries.get( - getPackageExtensionSummaryKey(pkg.scope, pkg.source) - ); - - items.push({ - type: "package", - id: `pkg:${pkg.source}`, - displayName: pkg.name, - scope: pkg.scope, - source: pkg.source, - resolvedPath: pkg.resolvedPath, - version: pkg.version, - description: pkg.description, - size: pkg.size, - updateAvailable: knownUpdates.has(normalizePackageIdentity(pkg.source)), - ...(extensionSummary ? { extensionSummary } : {}), - }); - } - - // Sort by type then display name. - items.sort((a, b) => { - const rank = (type: UnifiedItem["type"]): number => { - if (type === "local") return 0; - return 1; - }; - - const diff = rank(a.type) - rank(b.type); - if (diff !== 0) return diff; - return a.displayName.localeCompare(b.displayName); - }); - - return items; -} - -function getPackageExtensionSummaryKey(scope: string, source: string): string { - return `${scope}\0${source}`; -} - -function buildPackageExtensionSummaries( - entries: PackageExtensionEntry[] -): Map { - const summaries = new Map(); - - for (const entry of entries) { - const key = getPackageExtensionSummaryKey(entry.packageScope, entry.packageSource); - let summary = summaries.get(key); - if (!summary) { - summary = { enabled: 0, disabled: 0, total: 0 }; - summaries.set(key, summary); - } - - summary.total += 1; - if (entry.state === "enabled") { - summary.enabled += 1; - } else { - summary.disabled += 1; - } - } - - return summaries; -} - -function getPackageExtensionStatusIcon( - theme: Theme, - summary?: PackageExtensionStateSummary -): string { - if (!summary || summary.total === 0) return ""; - if (summary.disabled === 0) return getStatusIcon(theme, "enabled"); - if (summary.enabled === 0) return getStatusIcon(theme, "disabled"); - return theme.fg("warning", "◐"); -} - -function formatPackageExtensionState(summary?: PackageExtensionStateSummary): string | undefined { - if (!summary || summary.total === 0) return undefined; - if (summary.disabled === 0) { - return `${summary.enabled}/${summary.total} package extensions enabled`; - } - if (summary.enabled === 0) { - return `all ${summary.total} package extension${summary.total === 1 ? "" : "s"} disabled`; - } - return `${summary.enabled}/${summary.total} package extensions enabled (${summary.disabled} disabled)`; -} - -function buildManagerSummary( - items: UnifiedItem[], - staged: Map, - 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 disabledCount = summaryItems.filter((item) => { - if (item.type === "local") { - return getCurrentUnifiedItemState(item, staged) === "disabled"; - } - return (item.extensionSummary?.disabled ?? 0) > 0; - }).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"}`)); - } - - if (updateCount > 0) { - parts.push(theme.fg("warning", `${updateCount} update${updateCount === 1 ? "" : "s"}`)); - } - - if (disabledCount > 0) { - parts.push(theme.fg("warning", `${disabledCount} disabled`)); - } - - 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 | undefined, - theme: Theme, - changed = false -): string { - if (item.type === "local") { - const statusIcon = getStatusIcon(theme, state ?? item.state); - const scopeIcon = getScopeIcon(theme, item.scope); - const changeMarker = getChangeMarker(theme, changed); - const name = theme.bold(item.displayName); - return `${statusIcon} [${scopeIcon}] ${name}${changeMarker}`; - } - - const sourceKind = getPackageSourceKind(item.source); - const pkgIcon = getPackageIcon( - theme, - sourceKind === "npm" || sourceKind === "git" || sourceKind === "local" ? sourceKind : "local" - ); - const extensionStatusIcon = getPackageExtensionStatusIcon(theme, item.extensionSummary); - const extensionStatusPrefix = extensionStatusIcon ? `${extensionStatusIcon} ` : ""; - 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]")}` : ""; - - return `${extensionStatusPrefix}${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`}`, - formatPackageExtensionState(item.extensionSummary), - 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": - if (item.type === "local") { - return getCurrentUnifiedItemState(item, staged) === "disabled"; - } - return (item.extensionSummary?.disabled ?? 0) > 0; - } -} - -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 ?? "", - formatPackageExtensionState(item.extensionSummary) ?? "", - item.extensionSummary - ? item.extensionSummary.disabled > 0 - ? item.extensionSummary.enabled > 0 - ? "mixed disabled" - : "disabled" - : "enabled" - : "", - ], - }; -} - -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 keybindings: KeybindingsManager, - 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 { - 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 (this.keybindings.matches(data, "tui.select.up")) { - this.moveSelection(-1); - return true; - } - - if (this.keybindings.matches(data, "tui.select.down")) { - this.moveSelection(1); - return true; - } - - if (this.keybindings.matches(data, "tui.select.pageUp")) { - this.moveSelection(-Math.max(1, this.maxVisibleItems - 1)); - return true; - } - - if (this.keybindings.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); - } - - private setFilter(filter: UnifiedFilter): void { - this.filter = filter; - this.refreshVisibleItems(); - } - - 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[] { - return items.filter((item): item is LocalUnifiedItem => item.type === "local"); -} - -async function applyToggleChangesFromManager( - items: UnifiedItem[], - staged: Map, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - options?: { promptReload?: boolean } -): Promise<{ changed: number; reloaded: boolean; hasErrors: boolean }> { - const toggleItems = getToggleItemsForApply(items); - const apply = await applyStagedChanges(toggleItems, staged, pi); - - if (apply.errors.length > 0) { - ctx.ui.notify( - `Applied ${apply.changed} change(s), ${apply.errors.length} failed.\n${apply.errors.join("\n")}`, - "warning" - ); - } else if (apply.changed === 0) { - ctx.ui.notify("No changes to apply.", "info"); - } else { - ctx.ui.notify(`Applied ${apply.changed} local extension change(s).`, "info"); - } - - if (apply.changed > 0) { - const shouldPromptReload = options?.promptReload ?? true; - - if (shouldPromptReload) { - const reloaded = await confirmReload(ctx, "Local extensions changed."); - return { changed: apply.changed, reloaded, hasErrors: apply.errors.length > 0 }; - } - - ctx.ui.notify("Changes saved. Reload pi later to fully apply extension state updates.", "info"); - } - - return { changed: apply.changed, reloaded: false, hasErrors: apply.errors.length > 0 }; -} - -async function resolvePendingChangesBeforeLeave( - items: UnifiedItem[], - staged: Map, - byId: Map, - ctx: ExtensionCommandContext, - pi: ExtensionAPI, - destinationLabel: string -): Promise<"continue" | "stay"> { - const pendingCount = getPendingToggleChangeCount(staged, byId); - if (pendingCount === 0) return "continue"; - - const choice = await ctx.ui.select(`Unsaved changes (${pendingCount})`, [ - `Save and continue to ${destinationLabel}`, - "Discard changes", - "Stay in manager", - ]); - - if (!choice || choice === "Stay in manager") { - return "stay"; - } - - if (choice === "Discard changes") { - staged.clear(); - return "continue"; - } - - const apply = await applyToggleChangesFromManager(items, staged, ctx, pi, { - promptReload: false, - }); - return apply.changed === 0 && apply.hasErrors ? "stay" : "continue"; -} - -const PALETTE_OPTIONS = { - install: "📥 Install package", - search: "🔎 Search packages", - browse: "🌐 Browse community packages", - updateAll: "⬆️ Update all packages", - autoUpdate: "🔁 Auto-update settings", - help: "❓ Help", - back: "Back", -} as const; - -type PaletteAction = keyof typeof PALETTE_OPTIONS; - -type QuickDestination = "install" | "search" | "browse" | "update-all" | "auto-update" | "help"; - -const QUICK_DESTINATION_LABELS: Record = { - install: "Install", - search: "Search", - browse: "Remote", - "update-all": "Update", - "auto-update": "Auto-update", - 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", - remove: "Remove package", - details: "View details", - 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 -): Promise { - const selection = parseChoiceByLabel( - PACKAGE_ACTION_OPTIONS, - await ctx.ui.select(pkg.name, Object.values(PACKAGE_ACTION_OPTIONS)) - ); - - if (!selection || selection === "back") { - return "cancel"; - } - - 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)}` : ""; - const extensionState = formatPackageExtensionState(item.extensionSummary); - const extensionStr = extensionState ? `\nExtensions: ${extensionState}` : ""; - ctx.ui.notify( - `Name: ${item.displayName}\nVersion: ${item.version || "unknown"}\nSource: ${item.source}\nScope: ${item.scope}${extensionStr}${sizeStr}${item.description ? `\nDescription: ${item.description}` : ""}`, - "info" - ); -} - -async function navigateWithPendingGuard( - destination: QuickDestination, - items: UnifiedItem[], - staged: Map, - byId: Map, - ctx: ExtensionCommandContext, - pi: ExtensionAPI -): Promise<"reload" | "resume" | "stay" | "exit"> { - const pending = await resolvePendingChangesBeforeLeave( - items, - staged, - byId, - ctx, - pi, - QUICK_DESTINATION_LABELS[destination] - ); - if (pending === "stay") return "stay"; - - switch (destination) { - case "install": - await showRemote("install", ctx, pi); - return "reload"; - case "search": - await showRemote("search", ctx, pi); - return "reload"; - case "browse": - await showRemote("", ctx, pi); - return "reload"; - case "update-all": { - const outcome = await updatePackagesWithOutcome(ctx, pi); - return outcome.reloaded ? "exit" : "reload"; - } - case "auto-update": - await promptAutoUpdateWizard(pi, ctx, (packages) => { - ctx.ui.notify( - `Updates available for ${packages.length} package(s): ${packages.join(", ")}`, - "info" - ); - }); - void updateExtmgrStatus(ctx, pi); - return "resume"; - case "help": - showHelp(ctx); - return "resume"; - } -} - -async function handleUnifiedAction( - result: UnifiedAction, - items: UnifiedItem[], - staged: Map, - byId: Map, - ctx: ExtensionCommandContext, - pi: ExtensionAPI -): Promise { - if (result.type === "cancel") { - const pendingCount = getPendingToggleChangeCount(staged, byId); - if (pendingCount > 0) { - const choice = await ctx.ui.select(`Unsaved changes (${pendingCount})`, [ - "Save and exit", - "Exit without saving", - "Stay in manager", - ]); - - if (!choice || choice === "Stay in manager") { - 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 "resume"; - } - } - - return true; - } - - if (result.type === "remote") { - const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Remote"); - if (pending === "stay") return "resume"; - - await showRemote("", ctx, pi); - return false; - } - - if (result.type === "help") { - const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Help"); - if (pending === "stay") return "resume"; - - showHelp(ctx); - return "resume"; - } - - if (result.type === "menu") { - const choice = parseChoiceByLabel( - PALETTE_OPTIONS, - await ctx.ui.select("Quick Actions", Object.values(PALETTE_OPTIONS)) - ); - - const destinationByAction: Partial> = { - install: "install", - search: "search", - browse: "browse", - updateAll: "update-all", - autoUpdate: "auto-update", - help: "help", - }; - - const destination = choice ? destinationByAction[choice] : undefined; - if (!destination) { - return "resume"; - } - - const outcome = await navigateWithPendingGuard(destination, items, staged, byId, ctx, pi); - if (outcome === "stay" || outcome === "resume") return "resume"; - return outcome === "exit"; - } - - if (result.type === "quick") { - const quickDestinationMap: Record<(typeof result)["action"], QuickDestination> = { - install: "install", - search: "search", - "update-all": "update-all", - "auto-update": "auto-update", - }; - - 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"; - } - - if (result.type === "action") { - const item = byId.get(result.itemId); - if (!item) return false; - - if (item.type === "local") { - 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 (nextManagerState) { + const lastView = managerStateToView( + nextManagerState, + "last-used", + savedViews.lastView?.createdAt ); - 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 "resume"; - - const removal = await removeLocalExtension( - { activePath: item.activePath, disabledPath: item.disabledPath }, - ctx.cwd - ); - if (!removal.ok) { - logExtensionDelete(pi, item.id, false, removal.error); - ctx.ui.notify(`Failed to remove extension: ${removal.error}`, "error"); - return "resume"; - } - - logExtensionDelete(pi, item.id, true); - ctx.ui.notify( - `Removed ${item.displayName}${removal.removedDirectory ? " (directory)" : ""}.`, - "info" - ); - - return await confirmReload(ctx, "Extension removed."); + const selectedItemId = nextManagerState.selectedItemId; + const recent = selectedItemId + ? [selectedItemId, ...savedViews.recent.filter((id) => id !== selectedItemId)].slice(0, 20) + : savedViews.recent; + savedViews = { ...savedViews, lastView, recent }; + await writeSavedViews(viewsPath, savedViews); } - const pkg: InstalledPackage = { - source: item.source, - name: item.displayName, - ...(item.version ? { version: item.version } : {}), - scope: item.scope, - ...(item.resolvedPath ? { resolvedPath: item.resolvedPath } : {}), - ...(item.description ? { description: item.description } : {}), - ...(item.size !== undefined ? { size: item.size } : {}), - }; - - const selection = - !result.action || result.action === "menu" - ? await promptPackageActionSelection(pkg, ctx) - : result.action; - - if (selection === "cancel") { - 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( + const outcome = await handleUnifiedAction( + result, items, staged, byId, ctx, pi, - pendingDestinationBySelection[selection] + savedViews, + viewsPath, + nextManagerState ); - if (pending === "stay") return "resume"; - - switch (selection) { - case "configure": { - const outcome = await configurePackageExtensions(pkg, ctx, pi); - return outcome.reloaded; - } - case "update": { - const outcome = await updatePackageWithOutcome(pkg.source, ctx, pi); - return outcome.reloaded; - } - case "remove": { - const outcome = await removePackageWithOutcome(pkg.source, ctx, pi); - return outcome.reloaded; - } + if (outcome === "resume") { + managerState = viewToManagerState(savedViews.lastView) ?? nextManagerState; + continue; } - } - - const apply = await applyToggleChangesFromManager(items, staged, ctx, pi); - return apply.reloaded ? true : "resume"; -} - -async function applyStagedChanges( - items: LocalUnifiedItem[], - staged: Map, - pi: ExtensionAPI -) { - let changed = 0; - const errors: string[] = []; - for (const item of items) { - 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 - ); - - if (result.ok) { - changed++; - 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, fromState, target, false, result.error); - } + return outcome; } - - return { changed, errors }; } // Legacy redirect @@ -1601,7 +307,7 @@ export async function showInstalledPackagesLegacy( } ctx.ui.notify( - "📦 Use /extensions for the unified view.\nInstalled packages are now shown alongside local extensions.", + "Use /extensions for the Installed workspace. Packages and local extensions are managed together there.", "info" ); await showInteractive(ctx, pi); diff --git a/src/ui/workspace/navigation.ts b/src/ui/workspace/navigation.ts new file mode 100644 index 0000000..e3a1ead --- /dev/null +++ b/src/ui/workspace/navigation.ts @@ -0,0 +1,51 @@ +/** Shared workspace navigation header and keyboard handling. */ +import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui"; +import { type WorkspaceScreen } from "../../types/index.js"; + +/** + * How a workspace screen ended: navigate to another screen, "reloaded" when + * pi was reloaded (callers must stop using pre-reload contexts), or undefined + * when the user simply backed out. + */ +export type WorkspaceExit = WorkspaceScreen | "reloaded" | undefined; + +export const WORKSPACE_SCREENS: ReadonlyArray<{ + id: WorkspaceScreen; + label: string; +}> = [ + { id: "installed", label: "Installed" }, + { id: "discover", label: "Discover" }, + { id: "profiles", label: "Profiles" }, + { id: "health", label: "Health" }, +]; + +export function buildWorkspaceNavigation( + theme: { fg(color: string, text: string): string }, + active: WorkspaceScreen +): string { + const screens = WORKSPACE_SCREENS.map(({ id, label }) => + id === active ? theme.fg("accent", `[${label}]`) : theme.fg("muted", label) + ).join(" "); + return `${theme.fg("dim", "Shift+Tab ‹")} ${screens} ${theme.fg("dim", "› Tab")}`; +} + +function adjacentWorkspace(active: WorkspaceScreen, direction: -1 | 1): WorkspaceScreen { + const index = WORKSPACE_SCREENS.findIndex(({ id }) => id === active); + const next = (index + direction + WORKSPACE_SCREENS.length) % WORKSPACE_SCREENS.length; + return WORKSPACE_SCREENS[next]?.id ?? "installed"; +} + +/** + * Match portable workspace navigation keys. Tab and Shift+Tab are decoded by + * pi-tui across legacy terminals, Kitty CSI-u input, tmux, and macOS terminals. + */ +export function matchWorkspaceNavigation( + data: string, + active: WorkspaceScreen +): WorkspaceScreen | undefined { + if (isKeyRelease(data)) return undefined; + // Check the modified form first so a permissive matcher cannot treat it as Tab. + if (matchesKey(data, Key.shift("tab"))) return adjacentWorkspace(active, -1); + if (matchesKey(data, Key.tab)) return adjacentWorkspace(active, 1); + return undefined; +} diff --git a/src/ui/workspace/router.ts b/src/ui/workspace/router.ts new file mode 100644 index 0000000..758f0a0 --- /dev/null +++ b/src/ui/workspace/router.ts @@ -0,0 +1,40 @@ +/** + * Routes between the auxiliary workspace screens (Profiles, Health). + * + * Installed and Discover own their own long-lived loops; this router only + * bounces between the lightweight screens and reports which primary screen + * the user asked for so the caller can resume or hand off its own loop. + */ +import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { type WorkspaceScreen } from "../../types/index.js"; +import { showHealth } from "../health.js"; +import { showProfiles } from "../profiles.js"; + +export type AuxWorkspaceScreen = Extract; +export type PrimaryWorkspaceScreen = Exclude; + +export interface AuxWorkspaceOutcome { + /** Primary screen the user navigated to, if any. */ + navigate?: PrimaryWorkspaceScreen; + /** True when pi was reloaded; callers must not reuse pre-reload contexts. */ + reloaded: boolean; +} + +/** + * Run the requested auxiliary screen until the user leaves. Bounces between + * Profiles and Health during workspace cycling and reports the final outcome. + */ +export async function runAuxWorkspaceScreens( + initial: AuxWorkspaceScreen, + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + let screen: AuxWorkspaceScreen = initial; + while (true) { + const exit = screen === "profiles" ? await showProfiles(ctx, pi) : await showHealth(ctx, pi); + if (exit === undefined) return { reloaded: false }; + if (exit === "reloaded") return { reloaded: true }; + if (exit === "installed" || exit === "discover") return { navigate: exit, reloaded: false }; + screen = exit; + } +} diff --git a/src/utils/abort.ts b/src/utils/abort.ts new file mode 100644 index 0000000..055a3ce --- /dev/null +++ b/src/utils/abort.ts @@ -0,0 +1,7 @@ +export function createAbortError(message = "Operation cancelled"): DOMException { + return new DOMException(message, "AbortError"); +} + +export function throwIfAborted(signal?: AbortSignal): void { + signal?.throwIfAborted(); +} diff --git a/src/utils/auto-update.ts b/src/utils/auto-update.ts index 0960f8f..1476b16 100644 --- a/src/utils/auto-update.ts +++ b/src/utils/auto-update.ts @@ -7,6 +7,7 @@ import { type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { getPackageCatalog } from "../packages/catalog.js"; +import { isProjectTrusted } from "./mode.js"; import { parseChoiceByLabel } from "./command.js"; import { logAutoUpdateConfig } from "./history.js"; import { notify } from "./notify.js"; @@ -101,7 +102,10 @@ export async function checkForUpdates( ctx: ExtensionCommandContext | ExtensionContext, onUpdateAvailable?: (packages: string[]) => void ): Promise { - const updates = await getPackageCatalog(ctx.cwd).checkForAvailableUpdates(); + const updates = await getPackageCatalog( + ctx.cwd, + isProjectTrusted(ctx) + ).checkForAvailableUpdates(); const updatesAvailable = updates.map((update) => normalizePackageIdentity(update.source)); const updatedPackageNames = updates.map((update) => update.displayName); @@ -128,7 +132,7 @@ export function getAutoUpdateStatus(ctx: ExtensionCommandContext | ExtensionCont const config = getAutoUpdateConfig(ctx); if (!config.enabled || config.intervalMs === 0) { - return "⏸ auto-update off"; + return "⏸ scheduled checks off"; } const indicator = isAutoUpdateRunning() ? "↻" : "⏸"; @@ -153,7 +157,7 @@ export async function promptAutoUpdateWizard( onUpdateAvailable?: (packages: string[]) => void ): Promise { if (!ctx.hasUI) { - notify(ctx, "Auto-update wizard requires interactive mode.", "warning"); + notify(ctx, "Scheduled update checks wizard requires interactive mode.", "warning"); return; } @@ -161,7 +165,7 @@ export async function promptAutoUpdateWizard( const choice = parseChoiceByLabel( AUTO_UPDATE_WIZARD_CHOICES, await ctx.ui.select( - `Auto-update (${current.displayText})`, + `Scheduled update checks (${current.displayText})`, Object.values(AUTO_UPDATE_WIZARD_CHOICES) ) ); @@ -184,7 +188,7 @@ export async function promptAutoUpdateWizard( return; } - const input = await ctx.ui.input("Auto-update interval", current.displayText || "1d"); + const input = await ctx.ui.input("Scheduled update-check interval", current.displayText || "1d"); if (!input?.trim()) return; const parsed = parseDuration(input.trim()); @@ -225,7 +229,7 @@ export function enableAutoUpdate( startAutoUpdateTimer(pi, getCtx, onUpdateAvailable); - notify(ctx, `Auto-update enabled: ${displayText}`, "info"); + notify(ctx, `Scheduled update checks enabled: ${displayText}`, "info"); } /** @@ -245,5 +249,5 @@ export function disableAutoUpdate( }); logAutoUpdateConfig(pi, "disabled", true); - notify(ctx, "Auto-update disabled", "info"); + notify(ctx, "Scheduled update checks disabled", "info"); } diff --git a/src/utils/cache.ts b/src/utils/cache.ts index fce715d..c563edc 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -2,17 +2,21 @@ * Persistent cache for package metadata to reduce npm API calls */ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import { CACHE_LIMITS } from "../constants.js"; -import { type InstalledPackage, type NpmPackage } from "../types/index.js"; +import { type InstalledPackage, type SearchCache } from "../types/index.js"; import { parseNpmSource } from "./format.js"; -const CACHE_DIR = process.env.PI_EXTMGR_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"; +import { getExtmgrCacheDir } from "./pi-paths.js"; + +function cacheDir(): string { + return getExtmgrCacheDir(); +} + +function cacheFile(): string { + return join(cacheDir(), "metadata.json"); +} +const CURRENT_SEARCH_CACHE_STRATEGY = "npm-registry-v1-page"; const CACHED_PACKAGE_FIELDS = [ "description", "version", @@ -43,6 +47,8 @@ interface CacheData { | { query: string; results: string[]; + total: number; + offset: number; timestamp: number; strategy: string; } @@ -157,6 +163,8 @@ function normalizeCacheFromDisk(input: unknown): CacheData { const query = input.lastSearch.query; const timestamp = input.lastSearch.timestamp; const results = input.lastSearch.results; + const total = input.lastSearch.total; + const offset = input.lastSearch.offset; const strategy = input.lastSearch.strategy; if ( @@ -174,6 +182,11 @@ function normalizeCacheFromDisk(input: unknown): CacheData { query, timestamp, results: normalizedResults, + total: + typeof total === "number" && Number.isFinite(total) && total >= 0 + ? total + : normalizedResults.length, + offset: typeof offset === "number" && Number.isInteger(offset) && offset >= 0 ? offset : 0, strategy: strategy.trim(), }; } @@ -191,18 +204,18 @@ function normalizeCacheFromDisk(input: unknown): CacheData { */ async function ensureCacheDir(): Promise { try { - await access(CACHE_DIR); + await access(cacheDir()); } catch { - await mkdir(CACHE_DIR, { recursive: true }); + await mkdir(cacheDir(), { recursive: true }); } } async function backupCorruptCacheFile(): Promise { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const backupPath = join(CACHE_DIR, `metadata.invalid-${stamp}.json`); + const backupPath = join(cacheDir(), `metadata.invalid-${stamp}.json`); try { - await rename(CACHE_FILE, backupPath); + await rename(cacheFile(), backupPath); console.warn(`[extmgr] Invalid metadata cache JSON. Backed up to ${backupPath}.`); } catch (error) { console.warn("[extmgr] Failed to backup invalid cache file:", error); @@ -217,7 +230,7 @@ async function loadCache(): Promise { try { await ensureCacheDir(); - const data = await readFile(CACHE_FILE, "utf8"); + const data = await readFile(cacheFile(), "utf8"); const trimmed = data.trim(); if (!trimmed) { @@ -255,16 +268,40 @@ async function loadCache(): Promise { /** * Save cache to disk */ +function pruneCache(cache: CacheData): void { + for (const [name, data] of cache.packages) { + if (!hasFreshCachedField(data)) { + cache.packages.delete(name); + } + } + + const maxEntries = CACHE_LIMITS.packageInfoMaxSize; + if (cache.packages.size <= maxEntries) return; + + const entries = [...cache.packages.entries()].sort( + ([, left], [, right]) => right.timestamp - left.timestamp + ); + cache.packages = new Map(entries.slice(0, maxEntries)); +} + async function saveCache(): Promise { if (!memoryCache) return; + pruneCache(memoryCache); await ensureCacheDir(); const data: { version: number; packages: Record; lastSearch?: - | { query: string; results: string[]; timestamp: number; strategy: string } + | { + query: string; + results: string[]; + total: number; + offset: number; + timestamp: number; + strategy: string; + } | undefined; } = { version: memoryCache.version, @@ -273,14 +310,14 @@ async function saveCache(): Promise { }; const content = `${JSON.stringify(data, null, 2)}\n`; - const tmpPath = join(CACHE_DIR, `metadata.${process.pid}.${Date.now()}.tmp`); + const tmpPath = join(cacheDir(), `metadata.${process.pid}.${Date.now()}.tmp`); try { await writeFile(tmpPath, content, "utf8"); - await rename(tmpPath, CACHE_FILE); + await rename(tmpPath, cacheFile()); } catch { // Fallback for filesystems where rename-overwrite can fail. - await writeFile(CACHE_FILE, content, "utf8"); + await writeFile(cacheFile(), content, "utf8"); } finally { await rm(tmpPath, { force: true }).catch(() => undefined); } @@ -433,10 +470,10 @@ export async function setCachedPackage( /** * Get cached search results */ -export async function getCachedSearch(query: string): Promise { +export async function getCachedSearch(query: string, offset: number): Promise { const cache = await loadCache(); - if (!cache.lastSearch || cache.lastSearch.query !== query) { + if (!cache.lastSearch || cache.lastSearch.query !== query || cache.lastSearch.offset !== offset) { return null; } @@ -448,12 +485,11 @@ export async function getCachedSearch(query: string): Promise { +export async function setCachedSearch(search: SearchCache): Promise { const cache = await loadCache(); // Update cache with new packages - for (const pkg of packages) { + for (const pkg of search.results) { cache.packages.set( pkg.name, mergeCachedPackageData(cache.packages.get(pkg.name), { @@ -492,9 +534,11 @@ export async function setCachedSearch(query: string, packages: NpmPackage[]): Pr // Store search results cache.lastSearch = { - query, - results: packages.map((p) => p.name), - timestamp: Date.now(), + query: search.query, + results: search.results.map((pkg) => pkg.name), + total: search.total, + offset: search.offset, + timestamp: search.timestamp, strategy: CURRENT_SEARCH_CACHE_STRATEGY, }; diff --git a/src/utils/format.ts b/src/utils/format.ts index c71a753..1410013 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -10,9 +10,8 @@ export function truncate(text: string, maxLength: number): string { } export function formatEntry(entry: ExtensionEntry): string { - const state = entry.state === "enabled" ? "on " : "off"; - const scope = entry.scope === "global" ? "G" : "P"; - return `[${state}] [${scope}] ${entry.displayName} - ${entry.summary}`; + const summary = entry.summary ? ` · ${entry.summary}` : ""; + return `${entry.displayName} · local · ${entry.scope} · ${entry.state}${summary}`; } export function formatInstalledPackageLabel(pkg: InstalledPackage, index?: number): string { diff --git a/src/utils/history.ts b/src/utils/history.ts index b5e368b..943d4fc 100644 --- a/src/utils/history.ts +++ b/src/utils/history.ts @@ -3,11 +3,11 @@ * This persists extension management actions to the session */ -import { type Dirent } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { + SessionManager, + type ExtensionAPI, + type ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; export type ChangeAction = | "extension_toggle" @@ -50,7 +50,6 @@ export interface GlobalHistoryEntry { } const EXT_CHANGE_CUSTOM_TYPE = "extmgr-change"; -const DEFAULT_SESSION_DIR = join(homedir(), ".pi", "agent", "sessions"); /** * Log an extension change to the session @@ -273,30 +272,15 @@ export function querySessionChanges( return applyHistoryFilters(getAllSessionChanges(ctx), filters); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -async function walkSessionFiles(dir: string): Promise { - const result: string[] = []; - - let entries: Dirent[]; - try { - entries = await readdir(dir, { withFileTypes: true, encoding: "utf8" }); - } catch { - return result; - } - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - result.push(...(await walkSessionFiles(fullPath))); - } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { - result.push(fullPath); - } - } - - return result; +/** Return a chronological activity timeline for one package. */ +export function queryPackageTimeline( + ctx: ExtensionCommandContext, + packageQuery: string, + options: Omit = {} +): ExtensionChangeEntry[] { + return querySessionChanges(ctx, { ...options, packageQuery }).sort( + (left, right) => left.timestamp - right.timestamp + ); } function pushGlobalHistoryEntry( @@ -339,45 +323,32 @@ function pushGlobalHistoryEntry( */ export async function queryGlobalHistory( filters: HistoryFilters = {}, - sessionDir = DEFAULT_SESSION_DIR + sessionDir?: string ): Promise { - const files = await walkSessionFiles(sessionDir); const matchedEntries: GlobalHistoryEntry[] = []; const limit = filters.limit ?? 20; + let sessions: Awaited>; + try { + sessions = sessionDir + ? await SessionManager.listAll(sessionDir) + : await SessionManager.listAll(); + } catch { + return matchedEntries; + } - for (const file of files) { - let text: string; + for (const session of sessions) { + let entries: ReturnType["getEntries"]>; try { - text = await readFile(file, "utf8"); + entries = SessionManager.open(session.path, sessionDir).getEntries(); } catch { continue; } - const lines = text.split("\n").filter(Boolean); - for (const line of lines) { - let parsed: unknown; - try { - parsed = JSON.parse(line) as unknown; - } catch { - continue; - } - - if (!isRecord(parsed)) continue; - - if ( - parsed.type !== "custom" || - parsed.customType !== EXT_CHANGE_CUSTOM_TYPE || - !parsed.data - ) { - continue; - } - - const change = asChangeEntry(parsed.data); - if (!change || !matchesHistoryFilters(change, filters)) { - continue; - } - - pushGlobalHistoryEntry(matchedEntries, { change, sessionFile: file }, limit); + for (const entry of entries) { + if (entry.type !== "custom" || entry.customType !== EXT_CHANGE_CUSTOM_TYPE) continue; + const change = asChangeEntry(entry.data); + if (!change || !matchesHistoryFilters(change, filters)) continue; + pushGlobalHistoryEntry(matchedEntries, { change, sessionFile: session.path }, limit); } } @@ -417,7 +388,7 @@ export function formatChangeEntry(entry: ExtensionChangeEntry): string { return `[${time}] ${icon} Cache cleared`; case "auto_update_config": - return `[${time}] ${icon} Auto-update ${entry.detail ?? "configuration changed"}`; + return `[${time}] ${icon} Scheduled update checks ${entry.detail ?? "configuration changed"}`; default: return `[${time}] ${icon} Unknown action`; diff --git a/src/utils/key-hints.ts b/src/utils/key-hints.ts new file mode 100644 index 0000000..9568321 --- /dev/null +++ b/src/utils/key-hints.ts @@ -0,0 +1,25 @@ +import { type KeybindingsManager } from "@earendil-works/pi-coding-agent"; +import { type Keybinding } from "@earendil-works/pi-tui"; + +function formatKey(key: string): string { + return key + .replace(/^ctrl\+/, "Ctrl+") + .replace(/^shift\+/, "Shift+") + .replace(/^alt\+/, "Alt+") + .replace(/^pageUp$/, "PgUp") + .replace(/^pageDown$/, "PgDn") + .replace(/^escape$/, "Esc") + .replace(/^enter$/, "Enter") + .replace(/^space$/, "Space") + .replace(/^tab$/, "Tab"); +} + +/** Render a hint from Pi's active public keybinding configuration. */ +export function activeKeyHint( + keybindings: KeybindingsManager, + keybinding: Keybinding, + label: string +): string { + const keys = keybindings.getKeys(keybinding).map(formatKey); + return `${keys.join("/") || "(unbound)"} ${label}`; +} diff --git a/src/utils/mode.ts b/src/utils/mode.ts index a1a8449..5ccd729 100644 --- a/src/utils/mode.ts +++ b/src/utils/mode.ts @@ -11,6 +11,11 @@ type AnyContext = ExtensionCommandContext | ExtensionContext; export type UICapability = "none" | "dialog" | "custom"; +/** Missing trust information is intentionally treated as untrusted. */ +export function isProjectTrusted(ctx: AnyContext): boolean { + return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted(); +} + export function getUICapability(ctx: AnyContext): UICapability { if (!ctx.hasUI) { return "none"; diff --git a/src/utils/network.ts b/src/utils/network.ts index 1e215a3..361345c 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -1,23 +1,274 @@ -export async function fetchWithTimeout( +import { open, rm } from "node:fs/promises"; +import { createAbortError as abortError } from "./abort.js"; + +export const MAX_COMPRESSED_DOWNLOAD_BYTES = 50 * 1024 * 1024; +export const MAX_METADATA_RESPONSE_BYTES = 5 * 1024 * 1024; +export const MAX_DIRECT_EXTENSION_BYTES = 512 * 1024; +export const MAX_PROFILE_BYTES = 1024 * 1024; + +export function assertSafeHttpsUrl(value: string | URL, label = "URL"): URL { + let url: URL; + try { + url = value instanceof URL ? new URL(value.href) : new URL(value); + } catch { + throw new Error(`${label} is not a valid URL`); + } + if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS`); + if (url.username || url.password) throw new Error(`${label} must not contain credentials`); + return url; +} + +export function validateFinalHttpsUrl(response: Response, requested: URL): URL { + const finalUrl = response.url + ? assertSafeHttpsUrl(response.url, "Final redirected URL") + : requested; + if (finalUrl.protocol !== "https:") throw new Error("Final redirected URL must use HTTPS"); + return finalUrl; +} + +async function readWithSignal( + reader: ReadableStreamDefaultReader, + signal?: AbortSignal +): Promise["read"]>>> { + if (!signal) return reader.read(); + if (signal.aborted) { + void reader.cancel().catch(() => undefined); + throw abortError(); + } + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = (): void => { + signal.removeEventListener("abort", onAbort); + }; + const onAbort = (): void => { + if (settled) return; + settled = true; + cleanup(); + void reader.cancel().catch(() => undefined); + reject(abortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + reader.read().then( + (result) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }, + (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } + ); + }); +} + +async function fetchResponseWithTimeout( url: string, timeoutMs: number, signal?: AbortSignal -): Promise { +): Promise<{ + response: Response; + signal: AbortSignal; + cleanup: () => void; + timedOut: () => boolean; +}> { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + let didTimeOut = false; + const timer = setTimeout(() => { + didTimeOut = true; + controller.abort(); + }, timeoutMs); + try { + if (signal?.aborted) throw abortError(); + const response = await fetch(url, { signal: combinedSignal, redirect: "follow" }); + return { + response, + signal: combinedSignal, + cleanup: () => clearTimeout(timer), + timedOut: () => didTimeOut, + }; + } catch (error) { + clearTimeout(timer); + if (signal?.aborted) throw abortError(); + if (didTimeOut) throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + throw error; + } +} +export async function fetchWithTimeout( + url: string, + timeoutMs: number, + signal?: AbortSignal, + maxBytes = MAX_METADATA_RESPONSE_BYTES +): Promise { + const requested = assertSafeHttpsUrl(url); + const pending = await fetchResponseWithTimeout(requested.href, timeoutMs, signal); try { - return await fetch(url, { signal: combinedSignal }); + const finalUrl = validateFinalHttpsUrl(pending.response, requested); + const bytes = await readBoundedResponse(pending.response, maxBytes, pending.signal, "Response"); + if (signal?.aborted) throw abortError(); + if (pending.timedOut()) + throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + const buffered = new Response(bytes, { + status: pending.response.status, + statusText: pending.response.statusText, + headers: pending.response.headers, + }); + Object.defineProperty(buffered, "url", { configurable: true, value: finalUrl.href }); + return buffered; } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - if (signal?.aborted) { - throw error; - } + if (signal?.aborted) throw abortError(); + if (pending.timedOut()) + throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + throw error; + } finally { + pending.cleanup(); + await pending.response.body?.cancel().catch(() => undefined); + } +} + +export async function fetchBoundedBytes( + url: string, + timeoutMs: number, + maxBytes: number, + signal?: AbortSignal, + label = "Download" +): Promise<{ bytes: Uint8Array; finalUrl: URL; response: Response }> { + const requested = assertSafeHttpsUrl(url); + const pending = await fetchResponseWithTimeout(requested.href, timeoutMs, signal); + try { + const finalUrl = validateFinalHttpsUrl(pending.response, requested); + if (!pending.response.ok) { + throw new Error(`${label} failed: ${pending.response.status} ${pending.response.statusText}`); + } + const bytes = await readBoundedResponse(pending.response, maxBytes, pending.signal, label); + if (pending.timedOut()) throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + return { bytes, finalUrl, response: pending.response }; + } catch (error) { + if (signal?.aborted) throw abortError(); + if (pending.timedOut()) + throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + throw error; + } finally { + pending.cleanup(); + await pending.response.body?.cancel().catch(() => undefined); + } +} + +export async function readBoundedResponse( + response: Response, + maxBytes: number, + signal?: AbortSignal, + label = "Download" +): Promise { + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new Error(`${label} byte limit must be a finite non-negative number`); + } + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) + throw new Error(`${label} exceeds the ${maxBytes} byte limit`); + if (!response.body) throw new Error(`${label} failed: response has no body`); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + signal?.throwIfAborted(); + const chunk = await readWithSignal(reader, signal); + signal?.throwIfAborted(); + if (chunk.done) break; + total += chunk.value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new Error(`${label} exceeds the ${maxBytes} byte limit`); + } + chunks.push(chunk.value); + } + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error; + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** Stream a download to disk with timeout, cancellation, redirect, and size limits. */ +export async function downloadToFile( + url: string, + destination: string, + timeoutMs: number, + maxBytes = MAX_COMPRESSED_DOWNLOAD_BYTES, + signal?: AbortSignal +): Promise { + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new Error("Download byte limit must be a finite non-negative number"); + } + const requested = assertSafeHttpsUrl(url); + const controller = new AbortController(); + const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + let handle: Awaited> | undefined; + let reader: ReadableStreamDefaultReader | undefined; + let response: Response | undefined; + let destinationCreated = false; + let completed = false; + + try { + if (signal?.aborted) throw abortError(); + response = await fetch(requested.href, { signal: combinedSignal, redirect: "follow" }); + validateFinalHttpsUrl(response, requested); + if (!response.ok) throw new Error(`Download failed: ${response.status} ${response.statusText}`); + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) + throw new Error(`Download exceeds the ${maxBytes} byte limit`); + if (!response.body) throw new Error("Download failed: response has no body"); + + handle = await open(destination, "wx"); + destinationCreated = true; + reader = response.body.getReader(); + let total = 0; + while (true) { + combinedSignal.throwIfAborted(); + const chunk = await readWithSignal(reader, combinedSignal); + combinedSignal.throwIfAborted(); + if (chunk.done) break; + total += chunk.value.byteLength; + if (total > maxBytes) { + controller.abort(); + throw new Error(`Download exceeds the ${maxBytes} byte limit`); + } + await handle.write(chunk.value); } + completed = true; + } catch (error) { + if (signal?.aborted) throw abortError(); + if (timedOut) throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); throw error; } finally { clearTimeout(timer); + await reader?.cancel().catch(() => undefined); + reader?.releaseLock(); + await response?.body?.cancel().catch(() => undefined); + await handle?.close().catch(() => undefined); + if (!completed && destinationCreated) + await rm(destination, { force: true }).catch(() => undefined); } } diff --git a/src/utils/npm-exec.ts b/src/utils/npm-exec.ts index a41ed41..4077e1b 100644 --- a/src/utils/npm-exec.ts +++ b/src/utils/npm-exec.ts @@ -56,7 +56,7 @@ function getSettingsNpmCommand(cwd: string): string[] | undefined { return cached.getNpmCommand(); } - const settingsManager = SettingsManager.create(cwd, agentDir); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); settingsManagersByPath.set(cacheKey, settingsManager); return settingsManager.getNpmCommand(); } diff --git a/src/utils/package-source.ts b/src/utils/package-source.ts index 091762f..50cdfd4 100644 --- a/src/utils/package-source.ts +++ b/src/utils/package-source.ts @@ -9,7 +9,11 @@ import { normalizePathIdentity } from "./path-identity.js"; export type PackageSourceKind = "npm" | "git" | "local" | "unknown"; -function sanitizeSource(source: string): string { +export function packageSourceString(value: string | { source: string }): string { + return typeof value === "string" ? value : value.source; +} + +export function normalizeConfiguredPackageSource(source: string): string { return source .trim() .replace(/\s+\((filtered|pinned)\)$/i, "") @@ -17,7 +21,7 @@ function sanitizeSource(source: string): string { } export function getPackageSourceKind(source: string): PackageSourceKind { - const normalized = sanitizeSource(source); + const normalized = normalizeConfiguredPackageSource(source); if (normalized.startsWith("npm:")) return "npm"; @@ -91,7 +95,7 @@ export function normalizePackageIdentity( source: string, options?: { resolvedPath?: string; cwd?: string } ): string { - const normalized = sanitizeSource(source); + const normalized = normalizeConfiguredPackageSource(source); const kind = getPackageSourceKind(normalized); if (kind === "npm") { @@ -116,13 +120,28 @@ export function normalizePackageIdentity( export function splitGitRepoAndRef(gitSpec: string): { repo: string; ref?: string | undefined } { const lastAt = gitSpec.lastIndexOf("@"); - if (lastAt <= 0) { - return { repo: gitSpec }; - } + if (lastAt <= 0) return { repo: gitSpec }; + + // Do not treat URL user-info (https://user@host/...) or the `git@host:` + // scp-style transport marker as a ref separator. + const authorityEnd = gitSpec.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*\//i)?.[0].length; + if (authorityEnd !== undefined && lastAt < authorityEnd - 1) return { repo: gitSpec }; + if (gitSpec.startsWith("git@") && lastAt === gitSpec.indexOf("@")) return { repo: gitSpec }; const tail = gitSpec.slice(lastAt + 1); - // Refs don't contain path separators or URL separators. - if (!tail || tail.includes("/") || tail.includes(":")) { + // Git refs may contain path separators (for example feature/team), but + // never URL/transport separators or malformed ref punctuation. + if ( + !tail || + tail.includes(":") || + tail.includes("\\") || + tail.includes("..") || + tail.includes("@{") || + tail.startsWith("/") || + tail.endsWith("/") || + tail.endsWith(".") || + [...tail].some((character) => "~^?*[]".includes(character) || /\s/u.test(character)) + ) { return { repo: gitSpec }; } diff --git a/src/utils/pi-paths.ts b/src/utils/pi-paths.ts new file mode 100644 index 0000000..aa44f9d --- /dev/null +++ b/src/utils/pi-paths.ts @@ -0,0 +1,33 @@ +import { CONFIG_DIR_NAME, getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent"; +import { join } from "node:path"; + +/** Resolve Pi-owned and extmgr-owned paths at call time so test overrides apply. */ +export function getProjectConfigDir(cwd: string): string { + return join(cwd, CONFIG_DIR_NAME); +} + +export function getProjectConfigPath(cwd: string, fileName: string): string { + return join(getProjectConfigDir(cwd), fileName); +} + +export function getExtmgrCacheDir(): string { + return process.env.PI_EXTMGR_CACHE_DIR || join(getAgentDir(), ".extmgr-cache"); +} + +export function getExtmgrTrashDir(): string { + return join(getAgentDir(), ".extmgr-trash"); +} + +export function getGlobalExtensionsDir(): string { + return join(getAgentDir(), "extensions"); +} + +export function getProjectExtensionsDir(cwd: string): string { + return join(getProjectConfigDir(cwd), "extensions"); +} + +export function getPackageStorageDir(): string { + return getPackageDir(); +} + +export { CONFIG_DIR_NAME, getAgentDir }; diff --git a/src/utils/progress.ts b/src/utils/progress.ts new file mode 100644 index 0000000..4ee5973 --- /dev/null +++ b/src/utils/progress.ts @@ -0,0 +1,5 @@ +import { type ProgressEvent } from "@earendil-works/pi-coding-agent"; + +export function getProgressMessage(event: ProgressEvent, fallback: string): string { + return event.message?.trim() || fallback; +} diff --git a/src/utils/reload-state.ts b/src/utils/reload-state.ts new file mode 100644 index 0000000..0ce18d8 --- /dev/null +++ b/src/utils/reload-state.ts @@ -0,0 +1,104 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { getExtmgrCacheDir } from "./pi-paths.js"; + +export interface ReloadRequiredState { + version: 1; + required: boolean; + changedAt?: number; + changes: number; + reasons: string[]; +} + +const DEFAULT_STATE: ReloadRequiredState = { + version: 1, + required: false, + changes: 0, + reasons: [], +}; + +function stateDir(): string { + return getExtmgrCacheDir(); +} + +function stateFile(): string { + return join(stateDir(), "reload-required.json"); +} + +let writeQueue: Promise = Promise.resolve(); + +function cloneDefault(): ReloadRequiredState { + return { ...DEFAULT_STATE, reasons: [] }; +} + +function normalizeState(input: unknown): ReloadRequiredState { + if (!input || typeof input !== "object" || Array.isArray(input)) return cloneDefault(); + const value = input as Record; + const reasons = Array.isArray(value.reasons) + ? value.reasons.filter((reason): reason is string => typeof reason === "string").slice(-8) + : []; + return { + version: 1, + required: value.required === true, + ...(typeof value.changedAt === "number" && Number.isFinite(value.changedAt) + ? { changedAt: value.changedAt } + : {}), + changes: + typeof value.changes === "number" && Number.isInteger(value.changes) && value.changes >= 0 + ? value.changes + : 0, + reasons, + }; +} + +async function readStateFromDisk(path: string): Promise { + try { + return normalizeState(JSON.parse(await readFile(path, "utf8"))); + } catch { + return cloneDefault(); + } +} + +async function writeStateToDisk(path: string, state: ReloadRequiredState): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = join(dirname(path), `.${process.pid}.${Date.now()}.reload.tmp`); + try { + await writeFile(temporary, `${JSON.stringify(normalizeState(state), null, 2)}\n`, "utf8"); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +export async function readReloadState(path = stateFile()): Promise { + await writeQueue; + return readStateFromDisk(path); +} + +export async function markReloadRequired(reason: string, path = stateFile()): Promise { + const normalizedReason = reason.trim() || "Extension configuration changed"; + writeQueue = writeQueue.then(async () => { + const current = await readStateFromDisk(path); + const reasons = [ + ...current.reasons.filter((item) => item !== normalizedReason), + normalizedReason, + ]; + await writeStateToDisk(path, { + version: 1, + required: true, + changedAt: Date.now(), + changes: current.changes + 1, + reasons: reasons.slice(-8), + }); + }); + await writeQueue; +} + +export async function clearReloadRequired(path = stateFile()): Promise { + writeQueue = writeQueue.then(() => writeStateToDisk(path, cloneDefault())); + await writeQueue; +} + +export function getReloadRequiredStatePath(): string { + return stateFile(); +} diff --git a/src/utils/settings-errors.ts b/src/utils/settings-errors.ts new file mode 100644 index 0000000..f20378d --- /dev/null +++ b/src/utils/settings-errors.ts @@ -0,0 +1,18 @@ +import { type SettingsManager } from "@earendil-works/pi-coding-agent"; + +export function throwIfSettingsErrors(settings: SettingsManager, operation: string): void { + const errors = settings.drainErrors(); + if (errors.length === 0) return; + + const details = errors + .map(({ scope, error }) => { + const message = /JSON|property name|Unexpected token/i.test(error.message) + ? `Invalid JSON: ${error.message}` + : error.message; + return `${scope}: ${message}`; + }) + .join("; "); + throw new Error( + `${operation} refused because Pi settings could not be read or written: ${details}` + ); +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 6f3beaa..1c7014c 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -4,7 +4,6 @@ */ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import { type ExtensionAPI, @@ -14,6 +13,7 @@ import { import { parseScheduleDuration } from "./duration.js"; import { fileExists } from "./fs.js"; import { normalizePackageIdentity } from "./package-source.js"; +import { getExtmgrCacheDir } from "./pi-paths.js"; export interface AutoUpdateConfig { intervalMs: number; @@ -31,10 +31,14 @@ const DEFAULT_CONFIG: AutoUpdateConfig = { }; const SETTINGS_KEY = "extmgr-auto-update"; -const SETTINGS_DIR = process.env.PI_EXTMGR_CACHE_DIR - ? process.env.PI_EXTMGR_CACHE_DIR - : join(homedir(), ".pi", "agent", ".extmgr-cache"); -const SETTINGS_FILE = join(SETTINGS_DIR, "auto-update.json"); + +function settingsDir(): string { + return getExtmgrCacheDir(); +} + +function settingsFile(): string { + return join(settingsDir(), "auto-update.json"); +} let settingsWriteQueue: Promise = Promise.resolve(); @@ -139,7 +143,7 @@ function getSessionConfig( */ async function ensureSettingsDir(): Promise { try { - await mkdir(SETTINGS_DIR, { recursive: true }); + await mkdir(settingsDir(), { recursive: true }); } catch (error) { console.warn("[extmgr] Failed to create settings directory:", error); } @@ -147,10 +151,10 @@ async function ensureSettingsDir(): Promise { async function backupCorruptSettingsFile(): Promise { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const backupPath = join(SETTINGS_DIR, `auto-update.invalid-${stamp}.json`); + const backupPath = join(settingsDir(), `auto-update.invalid-${stamp}.json`); try { - await rename(SETTINGS_FILE, backupPath); + await rename(settingsFile(), backupPath); console.warn( `[extmgr] Invalid auto-update settings JSON. Backed up to ${backupPath} and reset to defaults.` ); @@ -164,11 +168,11 @@ async function backupCorruptSettingsFile(): Promise { */ async function readConfigFromDisk(): Promise { try { - if (!(await fileExists(SETTINGS_FILE))) { + if (!(await fileExists(settingsFile()))) { return undefined; } - const raw = await readFile(SETTINGS_FILE, "utf8"); + const raw = await readFile(settingsFile(), "utf8"); if (!raw.trim()) { return undefined; } @@ -193,14 +197,14 @@ async function writeConfigToDisk(config: AutoUpdateConfig): Promise { await ensureSettingsDir(); const content = `${JSON.stringify(config, null, 2)}\n`; - const tmpPath = join(SETTINGS_DIR, `auto-update.${process.pid}.${Date.now()}.tmp`); + const tmpPath = join(settingsDir(), `auto-update.${process.pid}.${Date.now()}.tmp`); try { await writeFile(tmpPath, content, "utf8"); - await rename(tmpPath, SETTINGS_FILE); + await rename(tmpPath, settingsFile()); } catch { // Fallback for filesystems where rename-overwrite can fail. - await writeFile(SETTINGS_FILE, content, "utf8"); + await writeFile(settingsFile(), content, "utf8"); } finally { await rm(tmpPath, { force: true }).catch(() => undefined); } diff --git a/src/utils/status.ts b/src/utils/status.ts index 4d538aa..f4858f3 100644 --- a/src/utils/status.ts +++ b/src/utils/status.ts @@ -9,7 +9,9 @@ import { } from "@earendil-works/pi-coding-agent"; import { getPackageCatalog, type PackageCatalog } from "../packages/catalog.js"; import { getAutoUpdateStatus } from "./auto-update.js"; +import { isProjectTrusted } from "./mode.js"; import { normalizePackageIdentity } from "./package-source.js"; +import { getProjectConfigDir } from "./pi-paths.js"; import { getAutoUpdateConfigAsync, saveAutoUpdateConfig } from "./settings.js"; type CatalogInstalledPackages = Awaited>; @@ -23,7 +25,7 @@ function filterStaleUpdates( installedPackages.map((pkg) => normalizePackageIdentity(pkg.source, { ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), - cwd: pkg.scope === "project" ? cwd : getAgentDir(), + cwd: pkg.scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(), }) ) ); @@ -38,7 +40,7 @@ export async function updateExtmgrStatus( try { const [packages, autoUpdateConfig] = await Promise.all([ - getPackageCatalog(ctx.cwd).listInstalledPackages(), + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).listInstalledPackages(), getAutoUpdateConfigAsync(ctx), ]); const statusParts: string[] = []; diff --git a/src/utils/ui-helpers.ts b/src/utils/ui-helpers.ts index b418a0e..f65b17b 100644 --- a/src/utils/ui-helpers.ts +++ b/src/utils/ui-helpers.ts @@ -4,6 +4,18 @@ import { type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { UI } from "../constants.js"; import { error as notifyError, notify } from "./notify.js"; +import { clearReloadRequired, markReloadRequired } from "./reload-state.js"; + +const reloadedContexts = new WeakSet(); + +/** Mark a command context unusable after a successful in-process reload. */ +export function markContextReloaded(ctx: ExtensionCommandContext): void { + reloadedContexts.add(ctx); +} + +export function wasContextReloaded(ctx: ExtensionCommandContext): boolean { + return reloadedContexts.has(ctx); +} /** * Confirm and trigger reload @@ -11,8 +23,11 @@ import { error as notifyError, notify } from "./notify.js"; */ export async function confirmReload( ctx: ExtensionCommandContext, - reason: string + reason: string, + statePath?: string ): Promise { + await markReloadRequired(reason, statePath); + if (!ctx.hasUI) { notify(ctx, `Reload pi to apply changes. (${reason})`); return false; @@ -26,6 +41,8 @@ export async function confirmReload( try { await ctx.reload(); + markContextReloaded(ctx); + await clearReloadRequired(statePath); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/utils/views.ts b/src/utils/views.ts new file mode 100644 index 0000000..a482f96 --- /dev/null +++ b/src/utils/views.ts @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { getExtmgrCacheDir } from "./pi-paths.js"; + +export interface SavedView { + name: string; + filter: string; + searchQuery: string; + selectedItemId?: string; + selectedItemIds?: string[]; + createdAt: number; + updatedAt: number; +} + +export interface SavedViewsFile { + version: 1; + views: SavedView[]; + favorites: string[]; + recent: string[]; + lastView?: SavedView; +} + +const DEFAULT_VIEWS: SavedViewsFile = { version: 1, views: [], favorites: [], recent: [] }; +const WRITE_QUEUES = new Map>(); + +export function normalizeViewsFile(input: unknown): SavedViewsFile { + if (!input || typeof input !== "object" || Array.isArray(input)) + return structuredClone(DEFAULT_VIEWS); + const value = input as Record; + const normalizeView = (candidate: unknown): SavedView | undefined => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined; + const value = candidate as Record; + if ( + typeof value.name !== "string" || + !value.name.trim() || + typeof value.filter !== "string" || + typeof value.searchQuery !== "string" + ) { + return undefined; + } + const now = Date.now(); + return { + name: value.name.trim(), + filter: value.filter, + searchQuery: value.searchQuery, + ...(typeof value.selectedItemId === "string" && value.selectedItemId.trim() + ? { selectedItemId: value.selectedItemId.trim() } + : {}), + ...(Array.isArray(value.selectedItemIds) + ? { + selectedItemIds: value.selectedItemIds.filter( + (id): id is string => typeof id === "string" && Boolean(id.trim()) + ), + } + : {}), + createdAt: + typeof value.createdAt === "number" && Number.isFinite(value.createdAt) + ? value.createdAt + : now, + updatedAt: + typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) + ? value.updatedAt + : now, + }; + }; + const views = Array.isArray(value.views) + ? value.views.flatMap((view) => { + const normalized = normalizeView(view); + return normalized ? [normalized] : []; + }) + : []; + const strings = (candidate: unknown): string[] => + Array.isArray(candidate) + ? candidate + .filter((item): item is string => typeof item === "string" && Boolean(item.trim())) + .map((item) => item.trim()) + : []; + const lastView = normalizeView(value.lastView); + return { + version: 1, + views, + favorites: strings(value.favorites), + recent: strings(value.recent).slice(0, 20), + ...(lastView ? { lastView } : {}), + }; +} + +export async function readSavedViews(path: string): Promise { + try { + return normalizeViewsFile(JSON.parse(await readFile(path, "utf8"))); + } catch { + return structuredClone(DEFAULT_VIEWS); + } +} + +export async function writeSavedViews(path: string, data: SavedViewsFile): Promise { + const previous = WRITE_QUEUES.get(path) ?? Promise.resolve(); + const write = previous.then(async () => { + await mkdir(dirname(path), { recursive: true }); + const tmp = join(dirname(path), `.${process.pid}.${Date.now()}.tmp`); + try { + await writeFile(tmp, `${JSON.stringify(normalizeViewsFile(data), null, 2)}\n`, "utf8"); + await rename(tmp, path); + } finally { + await rm(tmp, { force: true }).catch(() => undefined); + } + }); + WRITE_QUEUES.set( + path, + write.catch(() => undefined) + ); + await write; +} + +export function getSavedViewsPath(cwd?: string): string { + const directory = getExtmgrCacheDir(); + const suffix = cwd ? `-${createHash("sha256").update(cwd).digest("hex").slice(0, 12)}` : ""; + return join(directory, `views${suffix}.json`); +} diff --git a/test/async-task.test.ts b/test/async-task.test.ts index b0e46e0..255ffe5 100644 --- a/test/async-task.test.ts +++ b/test/async-task.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { runTaskWithLoader } from "../src/ui/async-task.js"; +import { RequestGeneration, runTaskWithLoader } from "../src/ui/async-task.js"; void test("runTaskWithLoader falls back to running the task when custom UI degrades", async () => { let runs = 0; @@ -91,6 +91,75 @@ void test("runTaskWithLoader preserves undefined task results without treating t assert.equal(runs, 1); }); +void test("request generations abort obsolete work and reject stale commits", () => { + const requests = new RequestGeneration(); + const first = requests.begin(); + const second = requests.begin(); + let committed = ""; + + assert.equal(first.signal.aborted, true); + assert.equal( + first.commit(() => (committed = "stale")), + false + ); + assert.equal( + second.commit(() => (committed = "current")), + true + ); + assert.equal(committed, "current"); +}); + +void test("cancelled loaders block late status rendering", async () => { + let releaseTask: () => void = () => undefined; + const taskBlocked = new Promise((resolve) => { + releaseTask = resolve; + }); + let renders = 0; + + const result = await runTaskWithLoader( + { + hasUI: true, + ui: { + custom: async ( + factory: ( + tui: unknown, + theme: unknown, + keys: unknown, + done: (value: unknown) => void + ) => { handleInput?(data: string): void; dispose?(): void } + ) => { + let complete: (value: unknown) => void = () => undefined; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const component = factory( + { requestRender: () => (renders += 1) }, + { fg: (_name: string, text: string) => text, bold: (text: string) => text }, + {}, + complete + ); + component.handleInput?.("\u001b"); + const value = await completion; + component.dispose?.(); + return value; + }, + }, + } as never, + { title: "Cancel", message: "Working..." }, + async ({ setMessage }) => { + await taskBlocked; + setMessage("late update"); + return "late"; + } + ); + + assert.equal(result, undefined); + const rendersAtCancellation = renders; + releaseTask(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(renders, rendersAtCancellation); +}); + void test("runTaskWithLoader surfaces synchronous task throws as rejections", async () => { await assert.rejects( () => diff --git a/test/cache-history.test.ts b/test/cache-history.test.ts index 8ba8039..006dfc2 100644 --- a/test/cache-history.test.ts +++ b/test/cache-history.test.ts @@ -3,7 +3,11 @@ 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 "@earendil-works/pi-coding-agent"; +import { + SessionManager, + type ExtensionAPI, + type ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; import { clearMetadataCacheCommand } from "../src/commands/cache.js"; import { getSearchCache, setSearchCache } from "../src/packages/discovery.js"; import { @@ -11,6 +15,7 @@ import { logAutoUpdateConfig, logExtensionDelete, queryGlobalHistory, + queryPackageTimeline, querySessionChanges, } from "../src/utils/history.js"; import { createMockHarness } from "./helpers/mocks.js"; @@ -19,6 +24,8 @@ void test("clearMetadataCacheCommand clears runtime search cache and records his setSearchCache({ query: "demo", results: [{ name: "demo", description: "demo package" }], + total: 1, + offset: 0, timestamp: Date.now(), }); @@ -41,35 +48,62 @@ void test("queryGlobalHistory keeps the latest matching entries without loading 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 first = SessionManager.create(join(sessionDir, "first-project"), sessionDir); + first.appendMessage({ role: "user", content: "history", timestamp: Date.now() }); + first.appendMessage({ + role: "assistant", + content: [], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + first.appendCustomEntry("extmgr-change", { + action: "cache_clear", + timestamp: 10, + success: true, + }); + const second = SessionManager.create(join(sessionDir, "second-project"), sessionDir); + second.appendMessage({ role: "user", content: "history", timestamp: Date.now() }); + second.appendMessage({ + role: "assistant", + content: [], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); + second.appendCustomEntry("extmgr-change", { + action: "package_install", + timestamp: 30, + success: true, + packageName: "demo", + }); + second.appendCustomEntry("extmgr-change", { + action: "package_update", + timestamp: 20, + success: true, + packageName: "demo", + }); + await writeFile(join(sessionDir, "malformed.jsonl"), "not json\n", "utf8"); const changes = await queryGlobalHistory({ limit: 2 }, sessionDir); @@ -82,6 +116,31 @@ void test("queryGlobalHistory keeps the latest matching entries without loading } }); +void test("queryPackageTimeline returns package activity in chronological order", () => { + const entries: { type: "custom"; customType: string; data: unknown }[] = [ + { + type: "custom", + customType: "extmgr-change", + data: { action: "package_update", timestamp: 30, success: true, packageName: "demo" }, + }, + { + type: "custom", + customType: "extmgr-change", + data: { action: "package_install", timestamp: 10, success: true, packageName: "demo" }, + }, + ]; + const ctx = { + hasUI: false, + cwd: "/tmp", + sessionManager: { getEntries: () => entries }, + } as unknown as ExtensionCommandContext; + + assert.deepEqual( + queryPackageTimeline(ctx, "demo").map((change) => change.timestamp), + [10, 30] + ); +}); + void test("history records local extension deletion and auto-update config changes", () => { const entries: { type: "custom"; customType: string; data: unknown }[] = []; const pi = { @@ -111,5 +170,5 @@ void test("history records local extension deletion and auto-update config chang assert.ok(firstChange); assert.ok(secondChange); assert.match(formatChangeEntry(firstChange), /Deleted/); - assert.match(formatChangeEntry(secondChange), /Auto-update set to weekly/); + assert.match(formatChangeEntry(secondChange), /Scheduled update checks set to weekly/); }); diff --git a/test/command-orchestration.test.ts b/test/command-orchestration.test.ts index 3ac943c..030994d 100644 --- a/test/command-orchestration.test.ts +++ b/test/command-orchestration.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { setLocalCompletionIndexForTests } from "../src/commands/completion.js"; import { getExtensionsAutocompleteItems, resolveCommand, @@ -16,9 +17,11 @@ void test("resolveCommand defaults to local when no args are provided", () => { void test("resolveCommand maps aliases to command ids", () => { const remote = resolveCommand(["packages"]); const remove = resolveCommand(["uninstall", "npm:demo"]); + const doctor = resolveCommand(["doctor"]); assert.deepEqual(remote, { id: "remote", args: [] }); assert.deepEqual(remove, { id: "remove", args: ["npm:demo"] }); + assert.deepEqual(doctor, { id: "doctor", args: [] }); }); void test("autocomplete includes base commands and aliases", () => { @@ -29,6 +32,52 @@ void test("autocomplete includes base commands and aliases", () => { assert.ok(removeItems.some((item) => item.value === "uninstall")); }); +void test("autocomplete offers static command arguments without network requests", () => { + assert.deepEqual( + getExtensionsAutocompleteItems("install --p")?.map((item) => item.value), + ["--project"] + ); + assert.deepEqual( + getExtensionsAutocompleteItems("history --f")?.map((item) => item.value), + ["--failed"] + ); +}); + +void test("autocomplete uses only preloaded package and profile names", () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + throw new Error("autocomplete must not use the network"); + }) as typeof fetch; + setLocalCompletionIndexForTests({ + installedPackages: ["npm:alpha", "git:https://example.com/team/demo.git@main"], + savedProfiles: ["team", "workstation"], + }); + try { + assert.deepEqual( + getExtensionsAutocompleteItems("remove npm:a")?.map((item) => item.value), + ["npm:alpha"] + ); + assert.deepEqual( + getExtensionsAutocompleteItems("update git:")?.map((item) => item.value), + ["git:https://example.com/team/demo.git@main"] + ); + assert.deepEqual( + getExtensionsAutocompleteItems("profile apply te")?.map((item) => item.value), + ["team"] + ); + assert.deepEqual( + getExtensionsAutocompleteItems("history --action package_r")?.map((item) => item.value), + ["package_remove"] + ); + assert.equal(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + setLocalCompletionIndexForTests(); + } +}); + void test("runResolvedCommand install respects --project scope", async () => { const installs: { source: string; scope: "global" | "project" }[] = []; const restoreCatalog = mockPackageCatalog({ diff --git a/test/discovery-parser.test.ts b/test/discovery-parser.test.ts index 30bb3cb..6202866 100644 --- a/test/discovery-parser.test.ts +++ b/test/discovery-parser.test.ts @@ -125,7 +125,7 @@ void test("isSourceInstalled resolves project-relative local paths against cwd", const restoreCatalog = mockPackageCatalog({ packages: [ { - source: "./vendor/demo", + source: "../vendor/demo", name: "demo", scope: "project", resolvedPath: "/workspace/project/vendor/demo", @@ -147,7 +147,7 @@ void test("isSourceInstalled resolves project-relative local paths without resol const restoreCatalog = mockPackageCatalog({ packages: [ { - source: "./vendor/demo", + source: "../vendor/demo", name: "demo", scope: "project", }, diff --git a/test/doctor-compatibility.test.ts b/test/doctor-compatibility.test.ts new file mode 100644 index 0000000..39b5bd5 --- /dev/null +++ b/test/doctor-compatibility.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + inspectInstalledPackageCompatibility, + validateCompatibility, +} from "../src/doctor/compatibility.js"; + +void test("compatibility diagnostics reject packages requiring newer runtimes", () => { + const diagnostic = validateCompatibility({ + packageName: "demo", + engines: { node: ">=24" }, + requiredPi: ">=0.90", + nodeVersion: "22.20.0", + piVersion: "0.80.0", + }); + assert.equal(diagnostic.node, "incompatible"); + assert.equal(diagnostic.pi, "incompatible"); + assert.equal(diagnostic.reasons.length, 2); +}); + +void test("installed package compatibility reports Node results and unknown Pi metadata", async () => { + const diagnostics = await inspectInstalledPackageCompatibility([ + { + source: "npm:demo", + name: "demo", + scope: "project", + resolvedPath: "/missing/package", + }, + ]); + assert.equal(diagnostics[0]?.node, "unknown"); + assert.equal(diagnostics[0]?.pi, "unknown"); +}); + +void test("missing compatibility metadata is reported as unknown", () => { + assert.deepEqual(validateCompatibility({ packageName: "demo" }).reasons, []); + assert.equal(validateCompatibility({ packageName: "demo" }).node, "unknown"); +}); diff --git a/test/doctor-conflicts.test.ts b/test/doctor-conflicts.test.ts new file mode 100644 index 0000000..a23464f --- /dev/null +++ b/test/doctor-conflicts.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { findRuntimeConflicts } from "../src/doctor/conflicts.js"; + +void test("runtime conflict detection reports same names owned by different sources", () => { + const conflicts = findRuntimeConflicts([ + { + kind: "command", + name: "demo", + source: "npm:a", + scope: "user", + origin: "package", + path: "/a", + }, + { + kind: "command", + name: "demo", + source: "npm:b", + scope: "project", + origin: "package", + path: "/b", + }, + { kind: "tool", name: "demo", source: "npm:a", scope: "user", origin: "package", path: "/a" }, + ]); + assert.equal(conflicts.length, 1); + assert.equal(conflicts[0]?.name, "demo"); + assert.equal(conflicts[0]?.owners.length, 2); +}); diff --git a/test/doctor-runtime.test.ts b/test/doctor-runtime.test.ts new file mode 100644 index 0000000..9de4efe --- /dev/null +++ b/test/doctor-runtime.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getRuntimeOwners } from "../src/doctor/runtime.js"; + +void test("runtime ownership explorer uses public command and tool metadata", () => { + const owners = getRuntimeOwners({ + getCommands: () => [ + { + name: "demo", + source: "extension", + sourceInfo: { source: "npm:demo", scope: "project", origin: "package", path: "/tmp/demo" }, + }, + ], + getAllTools: () => [ + { + name: "demo_tool", + description: "A demo tool", + sourceInfo: { source: "npm:demo", scope: "project", origin: "package", path: "/tmp/demo" }, + }, + ], + } as never); + + assert.deepEqual( + owners.map((owner) => [owner.kind, owner.name, owner.source]), + [ + ["command", "demo", "npm:demo"], + ["tool", "demo_tool", "npm:demo"], + ] + ); +}); diff --git a/test/extension-trash.test.ts b/test/extension-trash.test.ts new file mode 100644 index 0000000..8946ef0 --- /dev/null +++ b/test/extension-trash.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +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 { handleTrashSubcommand } from "../src/commands/trash.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { removeLocalExtension } from "../src/extensions/discovery.js"; +import { + listExtensionTrash, + moveToExtensionTrash, + purgeExtensionTrash, + undoExtensionTrash, +} from "../src/extensions/trash.js"; + +void test("removeLocalExtension moves files to trash and exposes undo", async () => { + const home = await mkdtemp(join(tmpdir(), "pi-extmgr-trash-home-")); + const previousHome = process.env.HOME; + process.env.HOME = home; + const source = join(home, ".pi", "agent", "extensions", "demo.ts"); + try { + await mkdir(join(home, ".pi", "agent", "extensions"), { recursive: true }); + await writeFile(source, "export default {};\n", "utf8"); + const result = await removeLocalExtension( + { activePath: source, disabledPath: `${source}.disabled` }, + home + ); + assert.equal(result.ok, true); + if (result.ok) { + await undoExtensionTrash(result.trashRecord); + assert.equal(await readFile(source, "utf8"), "export default {};\n"); + } + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } +}); + +void test("trash record failures restore the original extension", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-trash-rollback-")); + const trash = join(root, "trash"); + const source = join(root, "extension.ts"); + try { + await mkdir(trash, { recursive: true }); + await mkdir(join(trash, "records.json")); + await writeFile(source, "original\n", "utf8"); + + await assert.rejects(() => moveToExtensionTrash(source, trash)); + assert.equal(await readFile(source, "utf8"), "original\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("trash records persist, clean up, and never overwrite a replacement", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-trash-lifecycle-")); + const trash = join(root, "trash"); + const source = join(root, "extension.ts"); + try { + await writeFile(source, "old\n", "utf8"); + const record = await moveToExtensionTrash(source, trash); + assert.equal((await listExtensionTrash(trash)).length, 1); + + await writeFile(source, "replacement\n", "utf8"); + await assert.rejects(() => undoExtensionTrash(record), /already exists/); + assert.equal(await readFile(source, "utf8"), "replacement\n"); + + await purgeExtensionTrash(record); + assert.deepEqual(await listExtensionTrash(trash), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("expired and missing trash records are removed from persistent listings", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-trash-expired-")); + const trash = join(root, "trash"); + const source = join(root, "extension.ts"); + try { + await writeFile(source, "old\n", "utf8"); + const record = await moveToExtensionTrash(source, trash); + assert.deepEqual( + await listExtensionTrash(trash, { now: record.trashedAt + 10, retentionMs: 1 }), + [] + ); + assert.deepEqual(await listExtensionTrash(trash), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("trash command restores a persisted record by index", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-trash-command-")); + const agentDir = join(root, "agent"); + const source = join(root, "extension.ts"); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile(source, "export default {};\n", "utf8"); + await moveToExtensionTrash(source, join(agentDir, ".extmgr-trash")); + const { pi, ctx } = createMockHarness({ hasUI: false, cwd: root, confirmResult: true }); + await handleTrashSubcommand(["restore", "1"], ctx, pi); + assert.equal(await readFile(source, "utf8"), "export default {};\n"); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(root, { recursive: true, force: true }); + } +}); + +void test("local extension trash supports undo without losing the original path", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-trash-")); + const source = join(root, "extension.ts"); + try { + await writeFile(source, "export default {};\n", "utf8"); + const record = await moveToExtensionTrash(source, join(root, "trash")); + await undoExtensionTrash(record); + assert.equal(await readFile(source, "utf8"), "export default {};\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/format.test.ts b/test/format.test.ts index 703860a..f9aaabc 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -1,6 +1,21 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { truncate } from "../src/utils/format.js"; +import { formatEntry, truncate } from "../src/utils/format.js"; + +void test("local extension formatting uses the same textual metadata as Installed rows", () => { + const line = formatEntry({ + id: "project:/workspace/.pi/extensions/demo.ts", + scope: "project", + state: "enabled", + activePath: "/workspace/.pi/extensions/demo.ts", + disabledPath: "/workspace/.pi/extensions/demo.ts.disabled", + displayName: "demo.ts", + summary: "Demo extension", + }); + assert.equal(line, "demo.ts · local · project · enabled · Demo extension"); + assert.equal(line.includes("[G]"), false); + assert.equal(line.includes("[P]"), false); +}); void test("truncate never exceeds maxLength when maxLength is 3 or less", () => { assert.equal(truncate("abcdef", 3), "abc"); diff --git a/test/health-conflicts.test.ts b/test/health-conflicts.test.ts new file mode 100644 index 0000000..c0f897d --- /dev/null +++ b/test/health-conflicts.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { type RuntimeConflict } from "../src/doctor/conflicts.js"; +import { + findConflictLocalOwners, + findConflictPackageOwners, + planSafeConflictFixes, +} from "../src/ui/health.js"; +import { type ExtensionEntry, type InstalledPackage } from "../src/types/index.js"; + +function conflictWith(owners: Array>): RuntimeConflict { + return { + kind: "command", + name: "demo", + owners: owners.map((owner) => ({ + kind: "command" as const, + name: "demo", + source: owner.source ?? "unknown", + scope: owner.scope ?? "user", + origin: owner.origin ?? "package", + path: owner.path ?? "/nowhere", + })), + }; +} + +const npmPackage: InstalledPackage = { + source: "npm:demo-pkg@1.2.3", + name: "demo-pkg", + version: "1.2.3", + scope: "global", + resolvedPath: "/home/user/.pi/agent/npm/node_modules/demo-pkg", +}; + +void test("conflict package owners match source, name, identity, and path containment", () => { + // Exact source string + assert.equal( + findConflictPackageOwners(conflictWith([{ source: "npm:demo-pkg@1.2.3" }]), [npmPackage]) + .length, + 1 + ); + // Bare package name (Pi sourceInfo.source often carries the package name) + assert.equal( + findConflictPackageOwners(conflictWith([{ source: "demo-pkg" }]), [npmPackage]).length, + 1 + ); + // Version-suffix / case variants through normalized identity + assert.equal( + findConflictPackageOwners(conflictWith([{ source: "npm:Demo-Pkg" }]), [npmPackage]).length, + 1 + ); + // Entrypoint path inside the package install directory + assert.equal( + findConflictPackageOwners( + conflictWith([ + { + source: "something-else", + path: "/home/user/.pi/agent/npm/node_modules/demo-pkg/extensions/index.ts", + }, + ]), + [npmPackage] + ).length, + 1 + ); + // Unrelated owner does not match + assert.equal( + findConflictPackageOwners( + conflictWith([{ source: "npm:other", path: "/elsewhere/entry.ts" }]), + [npmPackage] + ).length, + 0 + ); +}); + +const localEntry: ExtensionEntry = { + id: "project:/repo/.pi/extensions/demo.ts", + scope: "project", + state: "enabled", + activePath: "/repo/.pi/extensions/demo.ts", + disabledPath: "/repo/.pi/extensions/demo.ts.disabled", + displayName: "project/demo.ts", + summary: "demo", +}; + +void test("conflict local owners match active and disabled paths from either owner field", () => { + assert.equal( + findConflictLocalOwners(conflictWith([{ path: "/repo/.pi/extensions/demo.ts" }]), [localEntry]) + .length, + 1 + ); + assert.equal( + findConflictLocalOwners(conflictWith([{ source: "/repo/.pi/extensions/demo.ts.disabled" }]), [ + localEntry, + ]).length, + 1 + ); + assert.equal( + findConflictLocalOwners(conflictWith([{ path: "/other/entry.ts" }]), [localEntry]).length, + 0 + ); +}); + +void test("safe conflict fixes only disable enabled local extensions shadowing packages", () => { + const conflict: RuntimeConflict = { + kind: "command", + name: "demo", + owners: [ + { + kind: "command", + name: "demo", + source: "npm:demo-pkg@1.2.3", + scope: "user", + origin: "package", + path: "/home/user/.pi/agent/npm/node_modules/demo-pkg/extensions/index.ts", + }, + { + kind: "command", + name: "demo", + source: "/repo/.pi/extensions/demo.ts", + scope: "project", + origin: "top-level", + path: "/repo/.pi/extensions/demo.ts", + }, + ], + }; + + const fixes = planSafeConflictFixes([conflict], [localEntry]); + assert.equal(fixes.length, 1); + assert.equal(fixes[0]?.extension.activePath, localEntry.activePath); + assert.ok(fixes[0]?.conflict.includes("command demo")); + + // Already-disabled local extensions are not re-fixed. + const disabledEntry = { ...localEntry, state: "disabled" as const }; + assert.equal(planSafeConflictFixes([conflict], [disabledEntry]).length, 0); + + // Conflicts without a package owner never yield automatic fixes. + const localOnlyConflict: RuntimeConflict = { + ...conflict, + owners: conflict.owners.map((owner) => ({ ...owner, origin: "top-level" as const })), + }; + assert.equal(planSafeConflictFixes([localOnlyConflict], [localEntry]).length, 0); + + // The same extension is only fixed once across multiple conflicts. + assert.equal( + planSafeConflictFixes([conflict, { ...conflict, name: "demo2" }], [localEntry]).length, + 1 + ); +}); diff --git a/test/health-remediation.test.ts b/test/health-remediation.test.ts new file mode 100644 index 0000000..75cb6d8 --- /dev/null +++ b/test/health-remediation.test.ts @@ -0,0 +1,406 @@ +import assert from "node:assert/strict"; +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 { initTheme } from "@earendil-works/pi-coding-agent"; +import { moveToExtensionTrash } from "../src/extensions/trash.js"; +import { showHealth } from "../src/ui/health.js"; +import { markReloadRequired, readReloadState } from "../src/utils/reload-state.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; + +initTheme(); + +interface OwnerSpec { + name: string; + source: string; + scope?: "user" | "project" | "temporary"; + origin?: "package" | "top-level"; + path?: string; +} + +function stubRuntime(pi: unknown, owners: OwnerSpec[]): void { + (pi as { getCommands: () => unknown[] }).getCommands = () => + owners.map((owner) => ({ + name: owner.name, + source: "extension", + sourceInfo: { + source: owner.source, + scope: owner.scope ?? "user", + origin: owner.origin ?? "package", + path: owner.path ?? "/nowhere", + }, + })); + (pi as { getAllTools: () => unknown[] }).getAllTools = () => []; +} + +async function withEnv(run: (dirs: { cacheDir: string; agentDir: string }) => Promise) { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-health-cache-")); + const agentDir = await mkdtemp(join(tmpdir(), "pi-extmgr-health-agent-")); + const previousCache = process.env.PI_EXTMGR_CACHE_DIR; + const previousAgent = process.env.PI_CODING_AGENT_DIR; + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + process.env.PI_CODING_AGENT_DIR = agentDir; + try { + return await run({ cacheDir, agentDir }); + } finally { + if (previousCache === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = previousCache; + if (previousAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgent; + await rm(cacheDir, { recursive: true, force: true }); + await rm(agentDir, { recursive: true, force: true }); + } +} + +void test("health conflict remediation removes a conflicting package on request", async () => { + await withEnv(async () => { + const removed: Array<{ source: string; scope: string }> = []; + const restoreCatalog = mockPackageCatalog({ + packages: [ + { source: "npm:conflict-a", name: "conflict-a", scope: "global" }, + { source: "npm:conflict-b", name: "conflict-b", scope: "global" }, + ], + removeImpl: (source, scope) => { + removed.push({ source, scope }); + }, + }); + + try { + const { pi, ctx } = createMockHarness({ hasUI: true, confirmResult: true }); + stubRuntime(pi, [ + { name: "demo", source: "npm:conflict-a" }, + { name: "demo", source: "npm:conflict-b" }, + ]); + + const selections = [ + "command demo", // pick the conflict + "Remove package conflict-a (global)", // remediation + "Global", // removal scope prompt fallback (harness returns select strings) + ]; + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = (_title, options) => { + const next = selections.shift(); + if (next && options?.includes(next)) return Promise.resolve(next); + return Promise.resolve(next && options ? options.find((o) => o === next) : next); + }; + (ctx.ui as unknown as { confirm: (title: string) => Promise }).confirm = (title) => + Promise.resolve(title !== "Reload Required"); + + let healthCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("command/tool entries loaded"))) { + return completion; + } + healthCalls += 1; + component.handleInput?.(healthCalls === 1 ? "c" : "\u001b"); + return completion; + }); + + await showHealth(ctx, pi); + + assert.deepEqual(removed, [{ source: "npm:conflict-a", scope: "global" }]); + } finally { + restoreCatalog(); + } + }); +}); + +void test("health conflict remediation moves a package between scopes", async () => { + await withEnv(async ({ agentDir }) => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-health-move-")); + await mkdir(join(cwd, ".pi"), { recursive: true }); + await writeFile( + join(agentDir, "settings.json"), + JSON.stringify({ packages: ["npm:conflict-a"] }), + "utf8" + ); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { source: "npm:conflict-a", name: "conflict-a", scope: "global" }, + { source: "npm:conflict-b", name: "conflict-b", scope: "global" }, + ], + }); + + try { + const { pi, ctx } = createMockHarness({ cwd, hasUI: true, projectTrusted: true }); + stubRuntime(pi, [ + { name: "demo", source: "npm:conflict-a" }, + { name: "demo", source: "npm:conflict-b" }, + ]); + + const selections = ["command demo", "Move conflict-a to project"]; + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = () => Promise.resolve(selections.shift()); + (ctx.ui as unknown as { confirm: (title: string) => Promise }).confirm = (title) => + Promise.resolve(title === "Move conflicting package"); + + let healthCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("command/tool entries loaded"))) { + return completion; + } + healthCalls += 1; + component.handleInput?.(healthCalls === 1 ? "c" : "\u001b"); + return completion; + }); + + await showHealth(ctx, pi); + + const globalSettings = JSON.parse( + await readFile(join(agentDir, "settings.json"), "utf8") + ) as { packages?: unknown[] }; + const projectSettings = JSON.parse( + await readFile(join(cwd, ".pi", "settings.json"), "utf8") + ) as { packages?: unknown[] }; + assert.deepEqual(globalSettings.packages, []); + assert.deepEqual(projectSettings.packages, ["npm:conflict-a"]); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("health conflict remediation disables a conflicting local extension", async () => { + await withEnv(async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-health-disable-")); + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + const extensionPath = join(extensionsRoot, "conflicting.ts"); + await writeFile(extensionPath, "// conflicting\n", "utf8"); + const restoreCatalog = mockPackageCatalog({ + packages: [{ source: "npm:owner-pkg", name: "owner-pkg", scope: "global" }], + }); + + try { + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + stubRuntime(pi, [ + { name: "demo", source: "npm:owner-pkg" }, + { name: "demo", source: extensionPath, origin: "top-level", path: extensionPath }, + ]); + + const selections = ["command demo", "Disable local .pi/extensions/conflicting.ts"]; + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = () => Promise.resolve(selections.shift()); + (ctx.ui as unknown as { confirm: (title: string) => Promise }).confirm = (title) => + Promise.resolve(title === "Disable conflicting extension"); + + let healthCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("command/tool entries loaded"))) { + return completion; + } + healthCalls += 1; + component.handleInput?.(healthCalls === 1 ? "c" : "\u001b"); + return completion; + }); + + await showHealth(ctx, pi); + + const { access } = await import("node:fs/promises"); + await access(`${extensionPath}.disabled`); + await assert.rejects(access(extensionPath), "active file should have been renamed"); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("health fix-all-safe disables shadowing local extensions but never removes packages", async () => { + await withEnv(async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-health-fixsafe-")); + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + const extensionPath = join(extensionsRoot, "shadow.ts"); + await writeFile(extensionPath, "// shadow\n", "utf8"); + const removed: string[] = []; + const restoreCatalog = mockPackageCatalog({ + packages: [{ source: "npm:owner-pkg", name: "owner-pkg", scope: "global" }], + removeImpl: (source) => { + removed.push(source); + }, + }); + + try { + const { pi, ctx, confirmPrompts } = createMockHarness({ cwd, hasUI: true }); + stubRuntime(pi, [ + { name: "demo", source: "npm:owner-pkg" }, + { name: "demo", source: extensionPath, origin: "top-level", path: extensionPath }, + ]); + (ctx.ui as unknown as { confirm: (title: string) => Promise }).confirm = (title) => { + confirmPrompts.push(title); + return Promise.resolve(title === "Fix all safe issues"); + }; + + let healthCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("command/tool entries loaded"))) { + return completion; + } + healthCalls += 1; + component.handleInput?.(healthCalls === 1 ? "f" : "\u001b"); + return completion; + }); + + await showHealth(ctx, pi); + + const { access } = await import("node:fs/promises"); + await access(`${extensionPath}.disabled`); + assert.deepEqual(removed, [], "fix-all-safe must never remove packages"); + assert.ok(confirmPrompts.includes("Fix all safe issues")); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("health reload action ends the screen and clears the reload marker", async () => { + await withEnv(async ({ cacheDir }) => { + await markReloadRequired("test reload", join(cacheDir, "reload-required.json")); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const { pi, ctx, reloadCount } = createMockHarness({ hasUI: true }); + stubRuntime(pi, []); + + let healthCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("command/tool entries loaded"))) { + return completion; + } + healthCalls += 1; + component.handleInput?.("l"); + return completion; + }); + + const exit = await showHealth(ctx, pi); + + assert.equal(reloadCount(), 1); + assert.equal(healthCalls, 1, "reload must end the health loop, not reopen it"); + assert.equal(exit, "reloaded", "callers must learn the context was reloaded"); + assert.equal((await readReloadState(join(cacheDir, "reload-required.json"))).required, false); + } finally { + restoreCatalog(); + } + }); +}); + +void test("health resolves its trash root after environment overrides", async () => { + await withEnv(async ({ agentDir }) => { + const source = join(agentDir, "extension.ts"); + await writeFile(source, "// extension\n", "utf8"); + await moveToExtensionTrash(source, join(agentDir, ".extmgr-trash")); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const { pi, ctx } = createMockHarness({ hasUI: true }); + stubRuntime(pi, []); + let rendered: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("command/tool entries loaded"))) { + rendered = lines; + component.handleInput?.("\u001b"); + } + return completion; + }); + + await showHealth(ctx, pi); + + assert.ok(rendered.some((line) => line.includes("1 recoverable extension"))); + } finally { + restoreCatalog(); + } + }); +}); + +void test("health trash action lists trash and offers restore/purge choices", async () => { + await withEnv(async () => { + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const { pi, ctx, notifications, selectPrompts } = createMockHarness({ hasUI: true }); + stubRuntime(pi, []); + + let healthCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("command/tool entries loaded"))) { + return completion; + } + healthCalls += 1; + component.handleInput?.(healthCalls === 1 ? "t" : "\u001b"); + return completion; + }); + + await showHealth(ctx, pi); + + // Empty trash: the list command reports nothing to manage; no submenu. + assert.ok( + notifications.some((entry) => entry.message.toLowerCase().includes("no trash")), + `expected an empty-trash notification, got: ${JSON.stringify(notifications)}` + ); + assert.ok(!selectPrompts.includes("Trash actions")); + } finally { + restoreCatalog(); + } + }); +}); + +void test("health screen keeps every line within narrow terminal widths", async () => { + await withEnv(async () => { + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:a-package-with-a-particularly-long-name", + name: "a-package-with-a-particularly-long-name", + scope: "global", + }, + ], + }); + try { + const { pi, ctx } = createMockHarness({ hasUI: true }); + stubRuntime(pi, [ + { name: "demo", source: "npm:a-package-with-a-particularly-long-name" }, + { name: "demo", source: "npm:another-competing-package-name" }, + ]); + + const { visibleWidth } = await import("@earendil-works/pi-tui"); + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (component, lines, completion) => { + if (!lines.some((line) => line.includes("Health"))) return completion; + assert.ok( + lines.every((line) => visibleWidth(line) <= 30), + "health lines must stay within a 30-column terminal" + ); + component.handleInput?.("\u001b"); + return completion; + }, + { width: 30, height: 40 } + ); + + await showHealth(ctx, pi); + } finally { + restoreCatalog(); + } + }); +}); diff --git a/test/helpers/custom-component.ts b/test/helpers/custom-component.ts index 89ba475..d3d3167 100644 --- a/test/helpers/custom-component.ts +++ b/test/helpers/custom-component.ts @@ -40,6 +40,7 @@ export interface CaptureCustomComponentOptions { height?: number; matcher?: (lines: string[]) => boolean; mismatchTimeoutMs?: number; + keybindings?: Partial>; } export function captureCustomComponent( @@ -52,6 +53,17 @@ export function captureCustomComponent( completion: Promise ) => T | Promise ): Promise; +export function captureCustomComponent( + factory: unknown, + theme: unknown, + matcher: (lines: string[]) => boolean, + onReady: ( + component: TestCustomComponent, + lines: string[], + completion: Promise + ) => T | Promise, + options?: CaptureCustomComponentOptions +): Promise; export function captureCustomComponent( factory: unknown, theme: unknown, @@ -102,6 +114,21 @@ export async function captureCustomComponent( const width = options?.width ?? 120; const height = options?.height ?? 40; + const configuredKeybindings = options?.keybindings + ? { + ...mockKeybindings, + matches(data: string, keybinding: string): boolean { + const keys = options.keybindings?.[keybinding] ?? DEFAULT_KEYBINDINGS[keybinding]; + const keyList = Array.isArray(keys) ? keys : keys ? [keys] : []; + return keyList.some((key) => matchesKey(data, key)); + }, + getKeys(keybinding: string): KeyId[] { + const keys = options.keybindings?.[keybinding] ?? DEFAULT_KEYBINDINGS[keybinding]; + return keys ? (Array.isArray(keys) ? [...keys] : [keys]) : []; + }, + } + : mockKeybindings; + const component = await ( factory as ( tui: unknown, @@ -112,7 +139,7 @@ export async function captureCustomComponent( )( { requestRender: noop, terminal: { rows: height, columns: width } }, theme, - mockKeybindings, + configuredKeybindings, resolveCompletion ); diff --git a/test/helpers/hermetic-environment.mjs b/test/helpers/hermetic-environment.mjs new file mode 100644 index 0000000..f0259cd --- /dev/null +++ b/test/helpers/hermetic-environment.mjs @@ -0,0 +1,39 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const sandboxRoot = mkdtempSync(join(tmpdir(), "pi-extmgr-test-process-")); +const sandboxHome = join(sandboxRoot, "home"); +const agentDir = join(sandboxHome, ".pi", "agent"); +const cacheDir = join(agentDir, ".extmgr-cache"); +const emptyBinDir = join(sandboxRoot, "bin"); +const projectDir = join(sandboxRoot, "project"); + +for (const directory of [sandboxHome, agentDir, cacheDir, emptyBinDir, projectDir]) { + mkdirSync(directory, { recursive: true }); +} + +Object.assign(process.env, { + HOME: sandboxHome, + USERPROFILE: sandboxHome, + XDG_CACHE_HOME: join(sandboxRoot, "xdg-cache"), + XDG_CONFIG_HOME: join(sandboxRoot, "xdg-config"), + XDG_DATA_HOME: join(sandboxRoot, "xdg-data"), + PI_CODING_AGENT_DIR: agentDir, + PI_EXTMGR_CACHE_DIR: cacheDir, + PI_EXTMGR_TEST_CWD: projectDir, + npm_config_cache: join(sandboxRoot, "npm-cache"), + npm_config_prefix: join(sandboxRoot, "npm-prefix"), + GIT_CONFIG_GLOBAL: join(sandboxRoot, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: emptyBinDir, +}); + +globalThis.fetch = async (input) => { + const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + throw new Error(`Unexpected network access from test: ${target}`); +}; + +process.once("exit", () => { + rmSync(sandboxRoot, { recursive: true, force: true }); +}); diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index c772c8e..2ee4519 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -28,9 +28,18 @@ export interface MockHarnessOptions { selectResult?: string; confirmResult?: boolean; confirmImpl?: (title: string, message?: string) => boolean | Promise; + projectTrusted?: boolean; } -const OK: ExecResult = { code: 0, stdout: "ok", stderr: "", killed: false }; +function getDefaultTestCwd(): string { + const cwd = process.env.PI_EXTMGR_TEST_CWD; + if (!cwd) { + throw new Error( + "Test harness requires PI_EXTMGR_TEST_CWD; run tests through the package test script." + ); + } + return cwd; +} export function createMockHarness(options: MockHarnessOptions = {}): { pi: ExtensionAPI; @@ -113,7 +122,7 @@ export function createMockHarness(options: MockHarnessOptions = {}): { return { code: 0, stdout: lines.join("\n"), stderr: "", killed: false }; } } - return OK; + throw new Error(`Unexpected command from test: ${[command, ...args].join(" ")}`); }; const execImpl = (command: string, args: string[]): ExecResult | Promise => { @@ -170,7 +179,9 @@ export function createMockHarness(options: MockHarnessOptions = {}): { const ctx = { hasUI: options.hasUI ?? false, - cwd: options.cwd ?? "/tmp", + mode: options.hasUI ? "tui" : "print", + cwd: options.cwd ?? getDefaultTestCwd(), + isProjectTrusted: () => options.projectTrusted ?? true, ui, reload: () => { reloadCalls += 1; diff --git a/test/hermetic-environment.test.ts b/test/hermetic-environment.test.ts new file mode 100644 index 0000000..2032bbb --- /dev/null +++ b/test/hermetic-environment.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, relative } from "node:path"; +import test from "node:test"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { getExtmgrCacheDir } from "../src/utils/pi-paths.js"; + +function isInside(parent: string, child: string): boolean { + const candidate = relative(parent, child); + return candidate !== "" && !candidate.startsWith("..") && !isAbsolute(candidate); +} + +void test("test process redirects Pi state and user directories into one temporary sandbox", () => { + const home = homedir(); + const agentDir = getAgentDir(); + const cacheDir = getExtmgrCacheDir(); + const sandboxRoot = dirname(home); + + assert.match(sandboxRoot, /pi-extmgr-test-process-/); + assert.equal(isInside(sandboxRoot, agentDir), true); + assert.equal(isInside(sandboxRoot, cacheDir), true); + assert.equal(isInside(sandboxRoot, process.env.npm_config_cache ?? ""), true); + assert.equal(isInside(sandboxRoot, process.env.npm_config_prefix ?? ""), true); + assert.equal(isInside(sandboxRoot, process.env.PATH ?? ""), true); + assert.equal(isInside(sandboxRoot, process.env.PI_EXTMGR_TEST_CWD ?? ""), true); +}); + +void test("test process rejects network access unless a test installs an explicit fetch stub", async () => { + await assert.rejects( + () => fetch("https://example.test/should-not-run"), + /Unexpected network access/ + ); +}); diff --git a/test/install-remove.test.ts b/test/install-remove.test.ts index f303936..162663c 100644 --- a/test/install-remove.test.ts +++ b/test/install-remove.test.ts @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; -import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, 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, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { listExtensionTrash } from "../src/extensions/trash.js"; import { installFromUrl, installPackage, @@ -11,6 +12,7 @@ import { installPackageLocally, } from "../src/packages/install.js"; import { removePackage, updatePackage, updatePackages } from "../src/packages/management.js"; +import { getExtmgrTrashDir } from "../src/utils/pi-paths.js"; import { createMockHarness } from "./helpers/mocks.js"; import { mockPackageCatalog } from "./helpers/package-catalog.js"; @@ -607,10 +609,7 @@ void test("installPackageLocally removes temporary extraction artifacts after su return Promise.reject(new Error(`Unexpected fetch URL: ${url}`)); } - return Promise.resolve({ - ok: true, - arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer), - } as Response); + return Promise.resolve(new Response(new Uint8Array([1, 2, 3]), { status: 200 })); }) as typeof fetch; const { pi, ctx, entries } = createMockHarness({ @@ -671,6 +670,66 @@ void test("installPackageLocally removes temporary extraction artifacts after su } }); +void test("standalone replacement keeps the previous installation in extmgr trash", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-standalone-replace-")); + const originalFetch = globalThis.fetch; + const destination = join(cwd, ".pi", "extensions", "demo-pkg"); + + try { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "index.ts"), "// previous\n", "utf8"); + globalThis.fetch = (() => + Promise.resolve(new Response(new Uint8Array([1, 2, 3]), { status: 200 }))) as typeof fetch; + + const { pi, ctx } = createMockHarness({ + cwd, + execImpl: async (command, args) => { + if (command === "npm" && args[0] === "view") { + return { + code: 0, + stdout: JSON.stringify({ + version: "2.0.0", + dist: { tarball: "https://example.com/demo-pkg.tgz" }, + }), + stderr: "", + killed: false, + }; + } + if (command === "tar" && args[0] === "--version") { + return { code: 0, stdout: "tar 1.0.0", stderr: "", killed: false }; + } + if (command === "tar" && args.includes("-C")) { + const extractDir = args[args.indexOf("-C") + 1]; + assert.ok(extractDir); + await mkdir(extractDir, { recursive: true }); + await writeFile( + join(extractDir, "package.json"), + JSON.stringify({ name: "demo-pkg" }), + "utf8" + ); + await writeFile(join(extractDir, "index.ts"), "// replacement\n", "utf8"); + return { code: 0, stdout: "", stderr: "", killed: false }; + } + return { code: 0, stdout: "", stderr: "", killed: false }; + }, + }); + + await installPackageLocally("demo-pkg", ctx, pi, { + scope: "project", + skipConfirmation: true, + }); + + assert.equal(await readFile(join(destination, "index.ts"), "utf8"), "// replacement\n"); + const trash = await listExtensionTrash(getExtmgrTrashDir()); + const replacement = trash.find((record) => record.originalPath === destination); + assert.ok(replacement); + assert.equal(await readFile(join(replacement.trashPath, "index.ts"), "utf8"), "// previous\n"); + } finally { + globalThis.fetch = originalFetch; + await rm(cwd, { recursive: true, force: true }); + } +}); + void test("installPackageLocally rejects standalone packages with unresolved runtime dependencies", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-standalone-")); const originalFetch = globalThis.fetch; @@ -682,10 +741,7 @@ void test("installPackageLocally rejects standalone packages with unresolved run return Promise.reject(new Error(`Unexpected fetch URL: ${url}`)); } - return Promise.resolve({ - ok: true, - arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer), - } as Response); + return Promise.resolve(new Response(new Uint8Array([1, 2, 3]), { status: 200 })); }) as typeof fetch; const { pi, ctx, entries } = createMockHarness({ diff --git a/test/list-navigation.test.ts b/test/list-navigation.test.ts new file mode 100644 index 0000000..ded56f8 --- /dev/null +++ b/test/list-navigation.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getCenteredVisibleRange, moveListSelection } from "../src/ui/list-navigation.js"; + +void test("list navigation wraps arrow movement and clamps page movement", () => { + assert.equal(moveListSelection(0, -1, 4, { wrap: true }), 3); + assert.equal(moveListSelection(3, 1, 4, { wrap: true }), 0); + assert.equal(moveListSelection(0, -3, 4), 0); + assert.equal(moveListSelection(1, 10, 4), 3); + assert.equal(moveListSelection(8, 1, 0, { wrap: true }), 0); +}); + +void test("centered visible ranges follow selection without exceeding list bounds", () => { + assert.deepEqual(getCenteredVisibleRange(0, 10, 4), { startIndex: 0, endIndex: 4 }); + assert.deepEqual(getCenteredVisibleRange(5, 10, 4), { startIndex: 3, endIndex: 7 }); + assert.deepEqual(getCenteredVisibleRange(9, 10, 4), { startIndex: 6, endIndex: 10 }); + assert.deepEqual(getCenteredVisibleRange(0, 0, 4), { startIndex: 0, endIndex: 0 }); +}); diff --git a/test/metadata-cache.test.ts b/test/metadata-cache.test.ts index 174ca9f..5e5bea0 100644 --- a/test/metadata-cache.test.ts +++ b/test/metadata-cache.test.ts @@ -18,15 +18,21 @@ void test("metadata cache merges partial package updates without discarding rich 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.setCachedSearch({ + query: "keywords:pi-package", + results: [ + { + name: "demo", + description: "original description", + author: "ayagmar", + keywords: ["pi-package", "queue"], + date: "2026-04-04T00:00:00.000Z", + }, + ], + total: 1, + offset: 0, + timestamp: Date.now(), + }); await cache.setCachedPackage("demo", { name: "demo", @@ -50,6 +56,34 @@ void test("metadata cache merges partial package updates without discarding rich } }); +void test("metadata cache prunes expired entries and bounds package metadata", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-cache-prune-")); + 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-prune=${Date.now()}` + )) as typeof import("../src/utils/cache.js"); + + await cache.clearCache(); + for (let index = 0; index < CACHE_LIMITS.packageInfoMaxSize + 5; index += 1) { + await cache.setCachedPackage(`pkg-${index}`, { name: `pkg-${index}`, description: "cached" }); + } + + const stats = await cache.getCacheStats(); + assert.equal(stats.totalPackages, CACHE_LIMITS.packageInfoMaxSize); + assert.equal(stats.validEntries, CACHE_LIMITS.packageInfoMaxSize); + } 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; diff --git a/test/network.test.ts b/test/network.test.ts new file mode 100644 index 0000000..2ace1bd --- /dev/null +++ b/test/network.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { downloadToFile, fetchBoundedBytes, fetchWithTimeout } from "../src/utils/network.js"; + +void test("fetchWithTimeout enforces the timeout while reading the response body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + let timer: ReturnType | undefined; + const body = new ReadableStream({ + start(controller) { + timer = setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + }, + cancel() { + if (timer) clearTimeout(timer); + }, + }); + return Promise.resolve(new Response(body)); + }) as typeof fetch; + + try { + await assert.rejects(() => fetchWithTimeout("https://example.test", 10), /timed out after 1s/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchWithTimeout rejects an insecure redirected URL before consuming the body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + const response = new Response(new Uint8Array([1, 2, 3])); + Object.defineProperty(response, "url", { configurable: true, value: "http://example.test" }); + return Promise.resolve(response); + }) as typeof fetch; + + try { + await assert.rejects( + () => fetchWithTimeout("https://example.test/profile.json", 1_000), + /HTTPS/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchBoundedBytes times out a response that stalls after headers", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => + Promise.resolve( + new Response( + new ReadableStream({ + start() { + // Deliberately never enqueue or close the body. + }, + }) + ) + )) as typeof fetch; + + try { + await assert.rejects( + () => fetchBoundedBytes("https://example.test/profile.json", 10, 1024), + /timed out after 1s/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("downloadToFile times out a response that stalls after headers", async () => { + const originalFetch = globalThis.fetch; + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-download-stalled-")); + const destination = join(dir, "archive.tgz"); + globalThis.fetch = (() => + Promise.resolve( + new Response( + new ReadableStream({ + start() { + // Deliberately never enqueue or close the body. + }, + }) + ) + )) as typeof fetch; + + try { + await assert.rejects( + () => downloadToFile("https://example.test/archive", destination, 10, 1024), + /timed out after 1s/ + ); + await assert.rejects(access(destination)); + } finally { + globalThis.fetch = originalFetch; + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("downloadToFile accepts bounded streams", async () => { + const originalFetch = globalThis.fetch; + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-download-")); + const destination = join(dir, "archive.tgz"); + globalThis.fetch = (() => + Promise.resolve(new Response(new Uint8Array([1, 2, 3])))) as typeof fetch; + try { + await downloadToFile("https://example.test/archive", destination, 1_000, 3); + assert.deepEqual(new Uint8Array(await readFile(destination)), new Uint8Array([1, 2, 3])); + } finally { + globalThis.fetch = originalFetch; + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("downloadToFile never deletes a pre-existing destination", async () => { + const originalFetch = globalThis.fetch; + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-download-existing-")); + const destination = join(dir, "archive.tgz"); + await writeFile(destination, new Uint8Array([9, 8, 7])); + globalThis.fetch = (() => + Promise.resolve(new Response(new Uint8Array([1, 2, 3])))) as typeof fetch; + + try { + await assert.rejects( + () => downloadToFile("https://example.test/archive", destination, 1_000, 3), + { code: "EEXIST" } + ); + assert.deepEqual(new Uint8Array(await readFile(destination)), new Uint8Array([9, 8, 7])); + } finally { + globalThis.fetch = originalFetch; + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("downloadToFile rejects declared and streamed overflow and cleans partial files", async () => { + const originalFetch = globalThis.fetch; + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-download-")); + const destination = join(dir, "archive.tgz"); + try { + globalThis.fetch = (() => + Promise.resolve( + new Response(new Uint8Array([1]), { headers: { "content-length": "4" } }) + )) as typeof fetch; + await assert.rejects( + () => downloadToFile("https://example.test/archive", destination, 1_000, 3), + /exceeds/ + ); + await assert.rejects(access(destination)); + + globalThis.fetch = (() => + Promise.resolve(new Response(new Uint8Array([1, 2, 3, 4])))) as typeof fetch; + await assert.rejects( + () => downloadToFile("https://example.test/archive", destination, 1_000, 3), + /exceeds/ + ); + await assert.rejects(access(destination)); + } finally { + globalThis.fetch = originalFetch; + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("downloadToFile honors cancellation and removes partial files", async () => { + const originalFetch = globalThis.fetch; + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-download-")); + const destination = join(dir, "archive.tgz"); + const controller = new AbortController(); + globalThis.fetch = (() => { + let timer: ReturnType | undefined; + const body = new ReadableStream({ + start(stream) { + stream.enqueue(new Uint8Array([1])); + timer = setTimeout(() => stream.enqueue(new Uint8Array([2])), 10); + }, + cancel() { + if (timer) clearTimeout(timer); + }, + }); + return Promise.resolve(new Response(body)); + }) as typeof fetch; + try { + const pending = downloadToFile( + "https://example.test/archive", + destination, + 1_000, + 10, + controller.signal + ); + controller.abort(); + await assert.rejects(pending, { name: "AbortError" }); + await assert.rejects(access(destination)); + } finally { + globalThis.fetch = originalFetch; + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("fetchWithTimeout preserves caller cancellation while reading the body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + let timer: ReturnType | undefined; + const body = new ReadableStream({ + start(controller) { + timer = setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + }, + cancel() { + if (timer) clearTimeout(timer); + }, + }); + return Promise.resolve(new Response(body)); + }) as typeof fetch; + const controller = new AbortController(); + + try { + const pending = fetchWithTimeout("https://example.test", 1_000, controller.signal); + controller.abort(); + await assert.rejects(pending, { name: "AbortError" }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/test/npm-search.test.ts b/test/npm-search.test.ts index 1a47bde..7532f45 100644 --- a/test/npm-search.test.ts +++ b/test/npm-search.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { fetchNpmRegistrySearchResults } from "../src/packages/discovery.js"; +import { fetchNpmRegistrySearchPage, fetchNpmWeeklyDownloads } from "../src/packages/discovery.js"; function makeSearchPage(total: number, from: number, count: number) { return { @@ -20,7 +20,7 @@ function makeSearchPage(total: number, from: number, count: number) { }; } -void test("fetchNpmRegistrySearchResults paginates npm registry results beyond 250 packages", async () => { +void test("fetchNpmRegistrySearchPage only requests the visible registry page", async () => { const originalFetch = globalThis.fetch; const fetchCalls: string[] = []; @@ -28,12 +28,44 @@ void test("fetchNpmRegistrySearchResults paginates npm registry results beyond 2 const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; fetchCalls.push(url); - const parsedUrl = new URL(url); - const from = Number(parsedUrl.searchParams.get("from") ?? "0"); - const payload = from === 0 ? makeSearchPage(500, 0, 250) : makeSearchPage(500, 250, 250); + return Promise.resolve( + new Response(JSON.stringify(makeSearchPage(5_000, 40, 20)), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + }) as typeof fetch; + + try { + const page = await fetchNpmRegistrySearchPage("keywords:pi-package", 40); + + assert.equal(page.results.length, 20); + assert.equal(page.results[0]?.name, "pkg-40"); + assert.equal(page.results[19]?.name, "pkg-59"); + assert.equal(page.total, 5_000); + assert.equal(page.offset, 40); + assert.equal(fetchCalls.length, 1); + assert.ok(fetchCalls[0]?.includes("size=20")); + assert.ok(fetchCalls[0]?.includes("from=40")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchNpmRegistrySearchPage retries HTTP 429 using Retry-After", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + + globalThis.fetch = (() => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + new Response("rate limited", { status: 429, headers: { "retry-after": "0" } }) + ); + } return Promise.resolve( - new Response(JSON.stringify(payload), { + new Response(JSON.stringify(makeSearchPage(1, 0, 1)), { status: 200, headers: { "content-type": "application/json" }, }) @@ -41,20 +73,116 @@ void test("fetchNpmRegistrySearchResults paginates npm registry results beyond 2 }) as typeof fetch; try { - const results = await fetchNpmRegistrySearchResults("keywords:pi-package"); + const page = await fetchNpmRegistrySearchPage("demo"); + + assert.equal(fetchCalls, 2); + assert.equal(page.results[0]?.name, "pkg-0"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchNpmRegistrySearchPage reports a useful error after repeated HTTP 429 responses", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = (() => + Promise.resolve( + new Response("rate limited", { status: 429, headers: { "retry-after": "0" } }) + )) as typeof fetch; + + try { + await assert.rejects( + () => fetchNpmRegistrySearchPage("demo"), + /rate-limited \(HTTP 429\).*Try again shortly/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchNpmWeeklyDownloads batches unscoped names and points scoped names", async () => { + const originalFetch = globalThis.fetch; + const fetchCalls: string[] = []; + + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchCalls.push(url); + // npm rejects scoped packages in bulk queries; each request shape differs. + const body = url.includes("%40scope%2Fbeta") + ? { package: "@scope/beta", downloads: 830 } + : { alpha: { downloads: 12_500 }, gamma: { downloads: 77 } }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + }) as typeof fetch; + + try { + const downloads = await fetchNpmWeeklyDownloads(["alpha", "@scope/beta", "gamma", "alpha"]); - assert.equal(results.length, 500); - assert.equal(results[0]?.name, "pkg-0"); - assert.equal(results[499]?.name, "pkg-499"); assert.equal(fetchCalls.length, 2); - assert.ok(fetchCalls[0]?.includes("size=250")); - assert.ok(fetchCalls[1]?.includes("from=250")); + assert.ok(fetchCalls.every((url) => url.includes("api.npmjs.org/downloads/point/last-week/"))); + assert.ok( + fetchCalls.some((url) => url.endsWith("/alpha,gamma")), + "unscoped names should batch into one bulk request" + ); + assert.ok( + fetchCalls.some((url) => url.endsWith("/%40scope%2Fbeta")), + "scoped names need individual point lookups" + ); + assert.equal(downloads.get("alpha"), 12_500); + assert.equal(downloads.get("gamma"), 77); + assert.equal(downloads.get("@scope/beta"), 830); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchNpmWeeklyDownloads returns partial results when one request fails", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("%40scope%2Fbeta")) { + return Promise.resolve(new Response("{}", { status: 500 })); + } + return Promise.resolve( + new Response(JSON.stringify({ package: "alpha", downloads: 41 }), { status: 200 }) + ); + }) as typeof fetch; + + try { + const downloads = await fetchNpmWeeklyDownloads(["alpha", "@scope/beta"]); + assert.equal(downloads.get("alpha"), 41); + assert.equal(downloads.has("@scope/beta"), false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +void test("fetchNpmWeeklyDownloads propagates aborts", async () => { + const originalFetch = globalThis.fetch; + const controller = new AbortController(); + + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("The operation was aborted", "AbortError")) + ); + })) as typeof fetch; + + try { + const pending = fetchNpmWeeklyDownloads(["alpha"], controller.signal); + controller.abort(); + await assert.rejects(pending, (error: Error) => error.name === "AbortError"); } finally { globalThis.fetch = originalFetch; } }); -void test("fetchNpmRegistrySearchResults prefers maintainer usernames over earlier email-only entries", async () => { +void test("fetchNpmRegistrySearchPage prefers maintainer usernames over publisher emails", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = ((input: string | URL | Request) => { @@ -70,6 +198,7 @@ void test("fetchNpmRegistrySearchResults prefers maintainer usernames over earli package: { name: "demo-author", version: "1.0.0", + publisher: { email: "publisher@example.com" }, maintainers: [ { email: "fallback@example.com" }, { username: "preferred-user", email: "preferred@example.com" }, @@ -87,8 +216,8 @@ void test("fetchNpmRegistrySearchResults prefers maintainer usernames over earli }) as typeof fetch; try { - const results = await fetchNpmRegistrySearchResults("demo-author"); - assert.equal(results[0]?.author, "preferred-user"); + const page = await fetchNpmRegistrySearchPage("demo-author"); + assert.equal(page.results[0]?.author, "preferred-user"); } finally { globalThis.fetch = originalFetch; } diff --git a/test/package-badges.test.ts b/test/package-badges.test.ts new file mode 100644 index 0000000..fa45eea --- /dev/null +++ b/test/package-badges.test.ts @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getRemotePackageBadges } from "../src/packages/badges.js"; + +void test("remote package badges distinguish installed and update states", () => { + const badges = getRemotePackageBadges({ name: "demo" }, new Set(["demo"]), new Set(["demo"])); + assert.deepEqual(badges, { installed: true, updateAvailable: true, compatibility: "unknown" }); +}); diff --git a/test/package-config.test.ts b/test/package-config.test.ts index 3b195b7..e67e8d0 100644 --- a/test/package-config.test.ts +++ b/test/package-config.test.ts @@ -94,7 +94,7 @@ void test("buildPackageConfigRows only includes manifest entrypoints that still resolvedPath: pkgRoot, }; - const discovered = await discoverPackageExtensions([pkg], cwd); + const discovered = await discoverPackageExtensions([pkg], cwd, { projectTrusted: true }); const rows = await buildPackageConfigRows(discovered); assert.equal(rows.length, 1); @@ -148,7 +148,7 @@ void test("applyPackageExtensionChanges applies changed rows and preserves non-m resolvedPath: pkgRoot, }; - const discovered = await discoverPackageExtensions([pkg], cwd); + const discovered = await discoverPackageExtensions([pkg], cwd, { projectTrusted: true }); const rows = await buildPackageConfigRows(discovered); const staged = new Map(); @@ -157,7 +157,7 @@ void test("applyPackageExtensionChanges applies changed rows and preserves non-m staged.set(row.id, "enabled"); const { pi } = createPiRecorder(); - const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi); + const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi, true); assert.equal(result.changed, 1); assert.equal(result.errors.length, 0); @@ -211,7 +211,7 @@ void test("applyPackageExtensionChanges collapses marker-only package config bac resolvedPath: pkgRoot, }; - const discovered = await discoverPackageExtensions([pkg], cwd); + const discovered = await discoverPackageExtensions([pkg], cwd, { projectTrusted: true }); const rows = await buildPackageConfigRows(discovered); const staged = new Map(); @@ -220,7 +220,7 @@ void test("applyPackageExtensionChanges collapses marker-only package config bac staged.set(row.id, "enabled"); const { pi } = createPiRecorder(); - const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi); + const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi, true); assert.equal(result.changed, 1); assert.equal(result.errors.length, 0); @@ -278,7 +278,7 @@ void test("applyPackageExtensionChanges batches multiple row changes in one save resolvedPath: pkgRoot, }; - const discovered = await discoverPackageExtensions([pkg], cwd); + const discovered = await discoverPackageExtensions([pkg], cwd, { projectTrusted: true }); const rows = await buildPackageConfigRows(discovered); const staged = new Map(); @@ -290,7 +290,7 @@ void test("applyPackageExtensionChanges batches multiple row changes in one save staged.set(secondRow.id, "enabled"); const { pi } = createPiRecorder(); - const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi); + const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi, true); assert.equal(result.changed, 2); assert.equal(result.errors.length, 0); @@ -328,7 +328,7 @@ void test("applyPackageExtensionChanges reports settings parse failure and logs resolvedPath: pkgRoot, }; - const discovered = await discoverPackageExtensions([pkg], cwd); + const discovered = await discoverPackageExtensions([pkg], cwd, { projectTrusted: true }); const rows = await buildPackageConfigRows(discovered); const staged = new Map(); @@ -337,7 +337,7 @@ void test("applyPackageExtensionChanges reports settings parse failure and logs staged.set(row.id, "disabled"); const { pi, entries } = createPiRecorder(); - const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi); + const result = await applyPackageExtensionChanges(rows, staged, pkg, cwd, pi, true); assert.equal(result.changed, 0); assert.equal(result.errors.length, 1); diff --git a/test/package-extensions.test.ts b/test/package-extensions.test.ts index 8498f67..2b03ca9 100644 --- a/test/package-extensions.test.ts +++ b/test/package-extensions.test.ts @@ -3,9 +3,39 @@ 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 { discoverPackageExtensions, setPackageExtensionState } from "../src/packages/extensions.js"; +import { + clearPackageEntrypointCache, + discoverPackageExtensionEntrypoints, + discoverPackageExtensions, + setPackageExtensionState, +} from "../src/packages/extensions.js"; import { type InstalledPackage } from "../src/types/index.js"; +void test("discoverPackageExtensionEntrypoints reuses results until explicitly cleared", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); + const pkgRoot = join(cwd, "cached-package"); + + try { + await mkdir(pkgRoot, { recursive: true }); + await writeFile( + join(pkgRoot, "package.json"), + JSON.stringify({ name: "cached-package", pi: { extensions: ["./index.ts"] } }), + "utf8" + ); + await writeFile(join(pkgRoot, "index.ts"), "// cached\n", "utf8"); + + assert.deepEqual(await discoverPackageExtensionEntrypoints(pkgRoot), ["index.ts"]); + await rm(join(pkgRoot, "index.ts")); + assert.deepEqual(await discoverPackageExtensionEntrypoints(pkgRoot), ["index.ts"]); + + clearPackageEntrypointCache(); + assert.deepEqual(await discoverPackageExtensionEntrypoints(pkgRoot), []); + } finally { + clearPackageEntrypointCache(); + await rm(cwd, { recursive: true, force: true }); + } +}); + void test("discoverPackageExtensions expands manifest glob entrypoints", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); const pkgRoot = join(cwd, "vendor", "glob-manifest"); @@ -30,7 +60,7 @@ void test("discoverPackageExtensions expands manifest glob entrypoints", async ( }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.deepEqual( discovered.map((entry) => entry.extensionPath), ["extensions/a.ts", "extensions/b.ts"] @@ -63,7 +93,7 @@ void test("discoverPackageExtensions resolves manifest directory entrypoints", a }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.deepEqual( discovered.map((entry) => entry.extensionPath), ["extensions/index.ts", "extensions/sub/feature.js"] @@ -100,7 +130,7 @@ void test("discoverPackageExtensions resolves directory tokens with trailing sla }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.deepEqual( discovered.map((entry) => entry.extensionPath), ["extensions/index.ts", "extensions/sub/feature.js"] @@ -136,7 +166,7 @@ void test("discoverPackageExtensions ignores manifest entrypoints with leading s }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.deepEqual(discovered, []); } finally { await rm(cwd, { recursive: true, force: true }); @@ -169,7 +199,7 @@ void test("discoverPackageExtensions ignores manifest exact entrypoints that are }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.deepEqual( discovered.map((entry) => entry.extensionPath), ["index.ts"] @@ -202,7 +232,7 @@ void test("discoverPackageExtensions falls back to convention extensions directo }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.deepEqual( discovered.map((entry) => entry.extensionPath), ["extensions/index.ts", "extensions/nested/feature.js"] @@ -247,7 +277,7 @@ void test("discoverPackageExtensions reads manifest entrypoints and project filt }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 1); assert.equal(discovered[0]?.extensionPath, "index.ts"); assert.equal(discovered[0]?.state, "disabled"); @@ -295,7 +325,7 @@ void test("discoverPackageExtensions treats explicit empty extensions filter as }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 2); assert.ok(discovered.every((entry) => entry.state === "disabled")); } finally { @@ -355,7 +385,7 @@ void test("discoverPackageExtensions applies include and exclude filter patterns }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); const byPath = new Map(discovered.map((entry) => [entry.extensionPath, entry.state])); assert.equal(byPath.get("extensions/a.ts"), "enabled"); @@ -402,7 +432,7 @@ void test("discoverPackageExtensions handles invalid filter glob patterns safely }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 1); assert.equal(discovered[0]?.state, "disabled"); } finally { @@ -485,7 +515,7 @@ void test("discoverPackageExtensions resolves npm project package without resolv }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 1); assert.equal(discovered[0]?.extensionPath, "index.ts"); } finally { @@ -518,7 +548,7 @@ void test("discoverPackageExtensions resolves npm global package via PI_PACKAGE_ }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 1); assert.equal(discovered[0]?.extensionPath, "index.ts"); } finally { @@ -533,6 +563,31 @@ void test("discoverPackageExtensions resolves npm global package via PI_PACKAGE_ } }); +void test("discoverPackageExtensions resolves project local sources from .pi settings", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-project-local-package-")); + const pkgRoot = join(cwd, "vendor", "localpkg"); + try { + await mkdir(pkgRoot, { recursive: true }); + await writeFile( + join(pkgRoot, "package.json"), + JSON.stringify({ name: "localpkg", pi: { extensions: ["./index.ts"] } }), + "utf8" + ); + await writeFile(join(pkgRoot, "index.ts"), "// local package\n", "utf8"); + + const entries = await discoverPackageExtensions( + [{ source: "../vendor/localpkg", name: "localpkg", scope: "project" }], + cwd + ); + assert.deepEqual( + entries.map((entry) => entry.extensionPath), + ["index.ts"] + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + void test("discoverPackageExtensions resolves file:// package sources", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); const pkgRoot = join(cwd, "vendor", "filepkg"); @@ -554,7 +609,7 @@ void test("discoverPackageExtensions resolves file:// package sources", async () }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 1); assert.equal(discovered[0]?.extensionPath, "index.ts"); } finally { @@ -584,7 +639,7 @@ void test("discoverPackageExtensions handles resolved package.json paths", async }, ]; - const discovered = await discoverPackageExtensions(installed, cwd); + const discovered = await discoverPackageExtensions(installed, cwd, { projectTrusted: true }); assert.equal(discovered.length, 1); assert.equal(discovered[0]?.extensionPath, "index.ts"); } finally { @@ -630,6 +685,94 @@ void test("setPackageExtensionState fails safely when settings.json is invalid", } }); +void test("setPackageExtensionState preserves unrelated settings fields", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); + const agentDir = await mkdtemp(join(tmpdir(), "pi-extmgr-agent-")); + const oldAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + const settingsPath = join(agentDir, "settings.json"); + + try { + await writeFile( + settingsPath, + JSON.stringify( + { theme: "dark", customPolicy: { allow: false }, packages: ["npm:demo-pkg@1.0.0"] }, + null, + 2 + ), + "utf8" + ); + + const result = await setPackageExtensionState( + "npm:demo-pkg@1.0.0", + "extensions/main.ts", + "global", + "disabled", + cwd + ); + assert.equal(result.ok, true); + + const saved = JSON.parse(await readFile(settingsPath, "utf8")) as Record; + assert.equal(saved.theme, "dark"); + assert.deepEqual(saved.customPolicy, { allow: false }); + } finally { + if (oldAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = oldAgentDir; + } + await rm(cwd, { recursive: true, force: true }); + await rm(agentDir, { recursive: true, force: true }); + } +}); + +void test("setPackageExtensionState preserves unknown package fields and resource filters", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); + const agentDir = await mkdtemp(join(tmpdir(), "pi-extmgr-agent-")); + const oldAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + const settingsPath = join(agentDir, "settings.json"); + + try { + const originalPackage = { + source: "npm:demo-pkg@1.0.0", + extensions: ["-extensions/main.ts"], + skills: [], + prompts: ["prompts/review.md"], + themes: ["+themes/custom.json"], + autoload: false, + futurePiField: { keep: true }, + }; + await writeFile(settingsPath, JSON.stringify({ packages: [originalPackage] }, null, 2), "utf8"); + + const result = await setPackageExtensionState( + originalPackage.source, + "extensions/main.ts", + "global", + "enabled", + cwd + ); + assert.equal(result.ok, true); + + const saved = JSON.parse(await readFile(settingsPath, "utf8")) as { + packages: Array>; + }; + assert.deepEqual(saved.packages[0], { + source: originalPackage.source, + skills: [], + prompts: ["prompts/review.md"], + themes: ["+themes/custom.json"], + autoload: false, + futurePiField: { keep: true }, + }); + } finally { + if (oldAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = oldAgentDir; + await rm(cwd, { recursive: true, force: true }); + await rm(agentDir, { recursive: true, force: true }); + } +}); + void test("setPackageExtensionState preserves non-marker extension tokens", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); const agentDir = await mkdtemp(join(tmpdir(), "pi-extmgr-agent-")); diff --git a/test/package-inspection.test.ts b/test/package-inspection.test.ts new file mode 100644 index 0000000..3dbbc7a --- /dev/null +++ b/test/package-inspection.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { inspectPackageMetadata } from "../src/packages/inspection.js"; + +void test("package metadata inspection reports dependencies and unknown trust safely", () => { + const inspection = inspectPackageMetadata({ + name: "demo", + version: "1.0.0", + dependencies: { zed: "1", alpha: "2" }, + repository: "https://example.test/demo", + }); + assert.deepEqual(inspection.dependencies, ["alpha", "zed"]); + assert.equal(inspection.provenance, "unknown"); + assert.equal(inspection.compatibility, "unknown"); +}); diff --git a/test/package-scopes.test.ts b/test/package-scopes.test.ts new file mode 100644 index 0000000..e57a25f --- /dev/null +++ b/test/package-scopes.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +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 { getPackageCatalog } from "../src/packages/catalog.js"; +import { + comparePackageScopes, + getPackageScopeLabel, + movePackageBetweenScopes, +} from "../src/packages/scopes.js"; + +void test("comparePackageScopes identifies project overrides and scope-only packages", () => { + const result = comparePackageScopes([ + { source: "npm:demo@1.0.0", name: "demo", scope: "global" }, + { source: "npm:demo@1.0.0", name: "demo", scope: "project" }, + { source: "npm:global-only", name: "global-only", scope: "global" }, + ]); + + assert.deepEqual( + result.map(({ name, status }) => ({ name, status })), + [ + { name: "demo", status: "overridden" }, + { name: "global-only", status: "global-only" }, + ] + ); +}); + +void test("movePackageBetweenScopes preserves filters, unknown fields, and effective state", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-scopes-")); + const agentDir = join(root, "agent"); + const cwd = join(root, "project"); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + try { + await mkdir(join(cwd, ".pi"), { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, "settings.json"), + JSON.stringify({ + packages: [ + { + source: "npm:demo@1.2.3", + extensions: ["extensions/*.ts", "!extensions/legacy.ts"], + unknownPackageField: { keep: true }, + }, + ], + unrelated: { keep: true }, + }), + "utf8" + ); + + const result = await movePackageBetweenScopes("npm:demo@1.2.3", "global", "project", cwd, true); + assert.equal(result.moved, true); + + const globalSettings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")) as { + packages?: unknown[]; + unrelated?: unknown; + }; + const projectSettings = JSON.parse( + await readFile(join(cwd, ".pi", "settings.json"), "utf8") + ) as { packages?: Array> }; + assert.deepEqual(globalSettings.packages, []); + assert.deepEqual(globalSettings.unrelated, { keep: true }); + assert.deepEqual(projectSettings.packages?.[0], { + source: "npm:demo@1.2.3", + extensions: ["extensions/*.ts", "!extensions/legacy.ts"], + unknownPackageField: { keep: true }, + }); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(root, { recursive: true, force: true }); + } +}); + +void test("movePackageBetweenScopes refuses a conflicting destination", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-scopes-conflict-")); + const agentDir = join(root, "agent"); + const cwd = join(root, "project"); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + try { + await mkdir(join(cwd, ".pi"), { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, "settings.json"), + JSON.stringify({ packages: ["npm:demo@1.0.0"] }), + "utf8" + ); + await writeFile( + join(cwd, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:demo@2.0.0"] }), + "utf8" + ); + const result = await movePackageBetweenScopes("npm:demo@1.0.0", "global", "project", cwd, true); + assert.equal(result.moved, false); + assert.match(result.conflict ?? "", /different package configuration/); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(root, { recursive: true, force: true }); + } +}); + +void test("package mutations refuse malformed settings without reporting success", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-scopes-malformed-")); + const agentDir = join(root, "agent"); + const cwd = join(root, "project"); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "settings.json"), "{ invalid", "utf8"); + + await assert.rejects( + () => getPackageCatalog(cwd).install("npm:demo", "global"), + /Package installation refused/ + ); + const moved = await movePackageBetweenScopes("npm:demo", "global", "project", cwd, true); + assert.equal(moved.moved, false); + assert.match(moved.conflict ?? "", /Package scope move refused/); + assert.equal(await readFile(join(agentDir, "settings.json"), "utf8"), "{ invalid"); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(root, { recursive: true, force: true }); + } +}); + +void test("getPackageScopeLabel explains persisted package scope", () => { + assert.match(getPackageScopeLabel("project"), /\.pi\/settings\.json/); + assert.match(getPackageScopeLabel("global"), /\.pi\/agent\/settings\.json/); +}); diff --git a/test/package-sorting.test.ts b/test/package-sorting.test.ts new file mode 100644 index 0000000..de29de4 --- /dev/null +++ b/test/package-sorting.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sortRemotePackages } from "../src/packages/sorting.js"; + +void test("remote package sorting supports name, popularity, and recent modes without mutation", () => { + const packages = [ + { name: "zeta", date: "2026-01-01", weeklyDownloads: 42_000 }, + { name: "alpha", date: "2026-03-01", weeklyDownloads: 120 }, + ]; + assert.deepEqual( + sortRemotePackages(packages, "name").map((pkg) => pkg.name), + ["alpha", "zeta"] + ); + assert.deepEqual( + sortRemotePackages(packages, "recent").map((pkg) => pkg.name), + ["alpha", "zeta"] + ); + assert.deepEqual( + sortRemotePackages(packages, "popular").map((pkg) => pkg.name), + ["zeta", "alpha"] + ); + assert.deepEqual( + sortRemotePackages(packages, "downloads").map((pkg) => pkg.name), + ["zeta", "alpha"] + ); + assert.equal(packages[0]?.name, "zeta"); +}); + +void test("download sorting preserves registry relevance while metrics are unknown or tied", () => { + const packages = [{ name: "zeta" }, { name: "alpha" }, { name: "middle", weeklyDownloads: 20 }]; + assert.deepEqual( + sortRemotePackages(packages, "popular").map((pkg) => pkg.name), + ["middle", "zeta", "alpha"] + ); + assert.deepEqual( + sortRemotePackages( + [ + { name: "zeta", weeklyDownloads: 10 }, + { name: "alpha", weeklyDownloads: 10 }, + ], + "popular" + ).map((pkg) => pkg.name), + ["zeta", "alpha"] + ); +}); diff --git a/test/package-source.test.ts b/test/package-source.test.ts new file mode 100644 index 0000000..2ddf25e --- /dev/null +++ b/test/package-source.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { splitGitRepoAndRef } from "../src/utils/package-source.js"; + +void test("Git source parsing preserves refs that contain path separators", () => { + assert.deepEqual(splitGitRepoAndRef("https://github.com/example/demo.git@feature/team"), { + repo: "https://github.com/example/demo.git", + ref: "feature/team", + }); + assert.deepEqual(splitGitRepoAndRef("git@github.com:example/demo.git@release/candidate"), { + repo: "git@github.com:example/demo.git", + ref: "release/candidate", + }); +}); + +void test("Git source parsing does not mistake URL user-info for a ref", () => { + assert.deepEqual(splitGitRepoAndRef("https://user@example.com/example/demo.git"), { + repo: "https://user@example.com/example/demo.git", + }); +}); diff --git a/test/profile-apply-gate.test.ts b/test/profile-apply-gate.test.ts new file mode 100644 index 0000000..f04c772 --- /dev/null +++ b/test/profile-apply-gate.test.ts @@ -0,0 +1,233 @@ +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 { initTheme } from "@earendil-works/pi-coding-agent"; +import { handleProfileSubcommand } from "../src/commands/profile.js"; +import { getProfileStorePath, saveNamedProfile } from "../src/profiles/store.js"; +import { showProfiles } from "../src/ui/profiles.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; + +initTheme(); + +async function withProfileStore(run: () => Promise): Promise { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-apply-gate-")); + const previous = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + try { + return await run(); + } finally { + if (previous === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = previous; + await rm(cacheDir, { recursive: true, force: true }); + } +} + +void test("profile action menu offers no apply path that bypasses the diff review", async () => { + await withProfileStore(async () => { + await saveNamedProfile(getProfileStorePath(), { + schemaVersion: 1, + name: "gate", + packages: [{ source: "npm:demo", scope: "global", version: "1.0.0" }], + }); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + + try { + const { pi, ctx } = createMockHarness({ hasUI: true }); + let profileMenuOptions: string[] = []; + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = (title, options) => { + if (title.startsWith("Profile:")) { + profileMenuOptions = options ?? []; + } + return Promise.resolve(undefined); + }; + let profileSelections = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Save current package set"))) { + profileSelections += 1; + component.handleInput?.(profileSelections === 1 ? "\r" : "\u001b"); + return completion; + } + component.handleInput?.("\u001b"); + return completion; + }); + + await showProfiles(ctx, pi); + + assert.ok(profileMenuOptions.length > 0, "profile action menu should have been shown"); + assert.ok( + profileMenuOptions.includes("Review and apply"), + "apply must be labelled as review-first" + ); + assert.ok( + !profileMenuOptions.some( + (option) => option === "Apply profile" || option === "Preview changes" + ), + "no direct apply or separate preview path may exist" + ); + } finally { + restoreCatalog(); + } + }); +}); + +void test("the direct interactive apply command also requires the inline diff review", async () => { + await withProfileStore(async () => { + await saveNamedProfile(getProfileStorePath(), { + schemaVersion: 1, + name: "direct-gate", + packages: [{ source: "npm:demo", scope: "global", version: "1.0.0" }], + }); + let installs = 0; + const restoreCatalog = mockPackageCatalog({ + packages: [], + installImpl: () => { + installs += 1; + }, + }); + + try { + const { pi, ctx } = createMockHarness({ hasUI: true }); + let sawDiff = false; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Profile diff"))) { + sawDiff = true; + component.handleInput?.("\u001b"); + } + return completion; + }); + + await handleProfileSubcommand(["apply", "direct-gate"], ctx, pi); + + assert.equal(sawDiff, true); + assert.equal(installs, 0); + } finally { + restoreCatalog(); + } + }); +}); + +void test("backing out of the diff review never applies the profile", async () => { + await withProfileStore(async () => { + await saveNamedProfile(getProfileStorePath(), { + schemaVersion: 1, + name: "gate-cancel", + packages: [{ source: "npm:demo", scope: "global", version: "1.0.0" }], + }); + let installs = 0; + const restoreCatalog = mockPackageCatalog({ + packages: [], + installImpl: () => { + installs += 1; + }, + }); + + try { + const { pi, ctx } = createMockHarness({ hasUI: true }); + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = (title) => + Promise.resolve(title.startsWith("Profile:") ? "Review and apply" : undefined); + + let sawDiff = false; + let profileSelections = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Profile diff"))) { + sawDiff = true; + component.handleInput?.("\u001b"); // back out of review + return completion; + } + if (lines.some((line) => line.includes("Save current package set"))) { + profileSelections += 1; + component.handleInput?.(profileSelections === 1 ? "\r" : "\u001b"); + return completion; + } + component.handleInput?.("\u001b"); + return completion; + }); + + await showProfiles(ctx, pi); + + assert.equal(sawDiff, true, "review screen must be shown before any apply"); + assert.equal(installs, 0, "backing out of review must not mutate packages"); + } finally { + restoreCatalog(); + } + }); +}); + +void test("policy violations render in the diff and disable the apply shortcut", async () => { + await withProfileStore(async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-apply-policy-")); + const { mkdir, writeFile } = await import("node:fs/promises"); + await mkdir(join(cwd, ".pi"), { recursive: true }); + await writeFile( + join(cwd, ".pi", "extmgr-policy.json"), + JSON.stringify({ schemaVersion: 1, allowedScopes: ["global"] }), + "utf8" + ); + await saveNamedProfile(getProfileStorePath(), { + schemaVersion: 1, + name: "policy-block", + packages: [{ source: "npm:demo", scope: "project", version: "1.0.0" }], + }); + let installs = 0; + const restoreCatalog = mockPackageCatalog({ + packages: [], + installImpl: () => { + installs += 1; + }, + }); + + try { + const { pi, ctx } = createMockHarness({ cwd, hasUI: true, projectTrusted: true }); + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = (title) => + Promise.resolve(title.startsWith("Profile:") ? "Review and apply" : undefined); + + let diffLines: string[] = []; + let profileSelections = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Profile diff"))) { + diffLines = lines; + component.handleInput?.("a"); // apply must be inert under violations + component.handleInput?.("\u001b"); + return completion; + } + if (lines.some((line) => line.includes("Save current package set"))) { + profileSelections += 1; + component.handleInput?.(profileSelections === 1 ? "\r" : "\u001b"); + return completion; + } + component.handleInput?.("\u001b"); + return completion; + }); + + await showProfiles(ctx, pi); + + assert.ok(diffLines.some((line) => line.includes("Policy blocks application"))); + assert.ok(diffLines.some((line) => line.includes("scope project is not allowed"))); + assert.ok(!diffLines.some((line) => line.includes("a apply"))); + assert.equal(installs, 0, "policy-blocked profiles must never apply"); + } finally { + restoreCatalog(); + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/test/profile-command.test.ts b/test/profile-command.test.ts new file mode 100644 index 0000000..632d9c7 --- /dev/null +++ b/test/profile-command.test.ts @@ -0,0 +1,307 @@ +import assert from "node:assert/strict"; +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 { visibleWidth } from "@earendil-works/pi-tui"; +import { checkProfileSource, handleProfileSubcommand } from "../src/commands/profile.js"; +import { buildHelpLines } from "../src/ui/help.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; + +void test("profile export writes exact installed source, scope, and version", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-command-")); + const packageRoot = join(cwd, "installed-demo"); + await mkdir(packageRoot, { recursive: true }); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ name: "demo", version: "1.2.3" }), + "utf8" + ); + const restore = mockPackageCatalog({ + packages: [ + { + source: "npm:demo@^1.0.0", + name: "demo", + version: "^1.0.0", + scope: "project", + resolvedPath: packageRoot, + }, + ], + }); + try { + const { ctx } = createMockHarness({ cwd }); + await handleProfileSubcommand(["export", "profile.json"], ctx); + const profile = JSON.parse(await readFile(join(cwd, "profile.json"), "utf8")) as { + packages: Array<{ + source: string; + scope: string; + version: string; + resolution: string; + }>; + }; + assert.equal(profile.packages[0]?.source, "npm:demo"); + assert.equal(profile.packages[0]?.scope, "project"); + assert.equal(profile.packages[0]?.resolution, "locked"); + assert.equal(profile.packages[0]?.version, "1.2.3"); + assert.match( + (profile.packages[0] as { manifestFingerprint?: string }).manifestFingerprint ?? "", + /^sha256:[a-f0-9]{64}$/ + ); + } finally { + restore(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("profile export resolves a floating git ref to the installed commit", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-git-export-")); + const packageRoot = join(cwd, "installed-git"); + const commit = "0123456789abcdef0123456789abcdef01234567"; + await mkdir(packageRoot, { recursive: true }); + await writeFile(join(packageRoot, "package.json"), JSON.stringify({ name: "demo" }), "utf8"); + const restore = mockPackageCatalog({ + packages: [ + { + source: "git:https://github.com/example/demo.git@main", + name: "demo", + scope: "global", + resolvedPath: packageRoot, + }, + ], + }); + try { + const { ctx, pi } = createMockHarness({ + cwd, + execImpl: (command, args) => { + assert.equal(command, "git"); + assert.deepEqual(args, ["rev-parse", "HEAD"]); + return { code: 0, stdout: `${commit}\n`, stderr: "", killed: false }; + }, + }); + await handleProfileSubcommand(["export", "profile.json"], ctx, pi); + const profile = JSON.parse(await readFile(join(cwd, "profile.json"), "utf8")) as { + packages: Array<{ source: string; ref: string; resolution: string }>; + }; + assert.equal(profile.packages[0]?.source, "git:https://github.com/example/demo.git"); + assert.equal(profile.packages[0]?.ref, commit); + assert.equal(profile.packages[0]?.resolution, "locked"); + } finally { + restore(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("profile import --name supplies a missing document name", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-import-")); + const cache = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-import-cache-")); + const previousAgent = process.env.PI_CODING_AGENT_DIR; + const previousCache = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_CODING_AGENT_DIR = join(root, "agent"); + process.env.PI_EXTMGR_CACHE_DIR = cache; + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const source = join(root, "profile.json"); + await writeFile(source, JSON.stringify({ schemaVersion: 1, packages: [] }), "utf8"); + const { ctx, notifications } = createMockHarness({ cwd: root }); + await handleProfileSubcommand(["import", source, "--name", "team"], ctx); + const stored = JSON.parse(await readFile(join(cache, "profiles.json"), "utf8")) as { + profiles: Record; + }; + assert.equal(stored.profiles.team?.name, "team"); + assert.equal(notifications.length, 0); + } finally { + restoreCatalog(); + if (previousAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgent; + if (previousCache === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = previousCache; + await rm(root, { recursive: true, force: true }); + await rm(cache, { recursive: true, force: true }); + } +}); + +void test("profile check strict mode gates drift but not unverifiable optional diagnostics", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-check-")); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const driftPath = join(root, "drift.json"); + const cleanPath = join(root, "clean.json"); + await writeFile( + driftPath, + JSON.stringify({ + schemaVersion: 1, + name: "drift", + packages: [{ source: "npm:demo@1.0.0", scope: "global" }], + }), + "utf8" + ); + await writeFile( + cleanPath, + JSON.stringify({ schemaVersion: 1, name: "clean", packages: [] }), + "utf8" + ); + const { ctx } = createMockHarness({ cwd: root }); + + const advisory = await checkProfileSource(driftPath, ctx); + const strictDrift = await checkProfileSource(driftPath, ctx, { strict: true }); + const strictClean = await checkProfileSource(cleanPath, ctx, { strict: true }); + + assert.equal(advisory.status, "drift"); + assert.equal(advisory.ok, true); + assert.equal(strictDrift.status, "drift"); + assert.equal(strictDrift.ok, false); + assert.equal(strictClean.status, "ok"); + assert.equal(strictClean.ok, true); + } finally { + restoreCatalog(); + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile check strict mode rejects floating remote origins", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-check-origin-")); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => + Promise.resolve( + new Response(JSON.stringify({ schemaVersion: 1, name: "remote", packages: [] }), { + status: 200, + }) + )) as typeof fetch; + try { + const { ctx } = createMockHarness({ cwd: root }); + const result = await checkProfileSource( + "https://raw.githubusercontent.com/org/repo/main/profile.json", + ctx, + { strict: true } + ); + assert.equal(result.drift, false); + assert.equal(result.status, "origin-warning"); + assert.equal(result.ok, false); + assert.equal(result.originWarnings.length, 1); + } finally { + globalThis.fetch = originalFetch; + restoreCatalog(); + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile check retains warnings from previously imported remote profiles", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-check-imported-warning-")); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const source = join(root, "saved.json"); + await writeFile( + source, + JSON.stringify({ + schemaVersion: 1, + name: "saved-remote", + packages: [], + importMetadata: { + origin: "https://raw.githubusercontent.com/org/repo/main/profile.json", + warnings: ["GitHub origin uses a floating ref"], + }, + }), + "utf8" + ); + const { ctx } = createMockHarness({ cwd: root }); + const result = await checkProfileSource(source, ctx, { strict: true }); + assert.equal(result.status, "origin-warning"); + assert.equal(result.ok, false); + assert.deepEqual(result.originWarnings, ["GitHub origin uses a floating ref"]); + } finally { + restoreCatalog(); + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile check fails on a confirmed compatibility failure", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-check-incompatible-")); + const packageRoot = join(root, "installed-demo"); + await mkdir(packageRoot, { recursive: true }); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ name: "demo", version: "1.0.0", engines: { node: ">999" } }), + "utf8" + ); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:demo@1.0.0", + name: "demo", + version: "1.0.0", + scope: "global", + resolvedPath: packageRoot, + }, + ], + }); + try { + const source = join(root, "profile.json"); + await writeFile( + source, + JSON.stringify({ + schemaVersion: 1, + name: "incompatible", + packages: [ + { + source: "npm:demo", + version: "1.0.0", + resolution: "locked", + scope: "global", + }, + ], + }), + "utf8" + ); + const { ctx } = createMockHarness({ cwd: root }); + const result = await checkProfileSource(source, ctx); + assert.equal(result.drift, false); + assert.equal(result.status, "diagnostic-failure"); + assert.equal(result.ok, false); + assert.deepEqual(result.compatibilityFailed, ["npm:demo@1.0.0 (global)"]); + } finally { + restoreCatalog(); + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile check --json emits one deterministic machine-readable result", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-check-json-")); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + const originalLog = console.log; + const output: string[] = []; + console.log = (...values: unknown[]) => output.push(values.join(" ")); + try { + const source = join(root, "profile.json"); + await writeFile( + source, + JSON.stringify({ schemaVersion: 1, name: "clean", packages: [] }), + "utf8" + ); + const { ctx } = createMockHarness({ cwd: root }); + await handleProfileSubcommand(["check", source, "--json", "--strict"], ctx); + + assert.equal(output.length, 1); + const result = JSON.parse(output[0] ?? "") as { ok: boolean; status: string; strict: boolean }; + assert.deepEqual(result, { ...result, ok: true, status: "ok", strict: true }); + + await writeFile(source, JSON.stringify({ schemaVersion: 1, packages: "bad" }), "utf8"); + await handleProfileSubcommand(["check", source, "--json"], ctx); + assert.equal(output.length, 2); + const invalid = JSON.parse(output[1] ?? "") as { ok: boolean; status: string; valid: boolean }; + assert.deepEqual(invalid, { ...invalid, ok: false, status: "invalid", valid: false }); + } finally { + console.log = originalLog; + restoreCatalog(); + await rm(root, { recursive: true, force: true }); + } +}); + +void test("manager help stays compact and width-safe", () => { + const lines = buildHelpLines(); + assert.ok(lines.includes("Extensions Manager Help")); + assert.ok(lines.every((line) => visibleWidth(line) <= 88)); + assert.ok(lines.some((line) => line.includes("Bulk actions"))); + assert.ok(lines.some((line) => line.includes("Reload required"))); +}); diff --git a/test/profile-diff.test.ts b/test/profile-diff.test.ts new file mode 100644 index 0000000..528c22d --- /dev/null +++ b/test/profile-diff.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { normalizeProfile } from "../src/profiles/schema.js"; +import { describeProfilePackageChanges, renderProfileDiffLines } from "../src/ui/profiles.js"; + +initTheme(); + +const plainTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +void test("profile package change descriptions cover scope, version, ref, filters, and manifest fingerprint", () => { + const changes = describeProfilePackageChanges( + { + source: "npm:demo", + scope: "global", + version: "1.0.0", + ref: "main", + filters: ["extensions/a.ts"], + checksum: "sha256:aaaaaaaaaaaaaaaa", + }, + { + source: "npm:demo", + scope: "project", + version: "2.0.0", + ref: "release", + filters: ["extensions/b.ts"], + checksum: "sha256:bbbbbbbbbbbbbbbb", + } + ); + + assert.ok(changes.some((change) => change.includes("scope global → project"))); + assert.ok(changes.some((change) => change.includes("version 1.0.0 → 2.0.0"))); + assert.ok(changes.some((change) => change.includes("ref main → release"))); + assert.ok(changes.some((change) => change.includes("filters extensions/a.ts → extensions/b.ts"))); + assert.ok(changes.some((change) => change.includes("manifest fingerprint sha256:aaa"))); + assert.equal(changes.length, 5); +}); + +void test("profile diff rendering shows adds, removes, and per-field changes in wide mode", () => { + const current = normalizeProfile({ + name: "current", + packages: [ + { source: "npm:removed", scope: "global" }, + { source: "npm:changed", scope: "global", version: "1.0.0" }, + ], + }); + const desired = normalizeProfile({ + name: "target", + packages: [ + { source: "npm:added", scope: "project" }, + { source: "npm:changed", scope: "global", version: "2.0.0" }, + ], + }); + + const lines = renderProfileDiffLines(current, desired, [], 120, plainTheme, { + canApply: true, + cancelHint: "Esc back", + }); + + assert.ok(lines.every((line) => visibleWidth(line) <= 120)); + assert.ok(lines.some((line) => line.includes("1 added · 1 removed · 1 changed"))); + assert.ok(lines.some((line) => line.includes("Current"))); + assert.ok(lines.some((line) => line.includes("Target · target"))); + assert.ok(lines.some((line) => line.includes("+ npm:added"))); + assert.ok(lines.some((line) => line.includes("- npm:removed"))); + assert.ok(lines.some((line) => line.includes("~ npm:changed"))); + assert.ok(lines.some((line) => line.includes("version 1.0.0 → 2.0.0"))); + assert.ok(lines.some((line) => line.includes("a apply"))); +}); + +void test("profile diff rendering stays within narrow widths and hides apply when blocked", () => { + const current = normalizeProfile({ name: "current", packages: [] }); + const desired = normalizeProfile({ + name: "target", + packages: [ + { + source: "npm:very-long-package-name-that-would-overflow", + scope: "project", + version: "10.20.30", + }, + ], + }); + const violations = [{ message: "scope project is not allowed" }]; + + const lines = renderProfileDiffLines(current, desired, violations, 40, plainTheme, { + canApply: false, + cancelHint: "Esc back", + }); + + assert.ok(lines.every((line) => visibleWidth(line) <= 40)); + assert.ok(lines.some((line) => line.includes("Policy blocks application"))); + assert.ok(lines.some((line) => line.includes("scope project is not allowed"))); + assert.ok(!lines.some((line) => line.includes("a apply"))); + assert.ok(lines.some((line) => line.includes("npm:very-long-package-name"))); +}); + +void test("profile diff rendering reports when nothing changes", () => { + const profile = normalizeProfile({ + name: "same", + packages: [{ source: "npm:demo", scope: "global" }], + }); + const lines = renderProfileDiffLines(profile, profile, [], 120, plainTheme, { + canApply: false, + cancelHint: "Esc back", + }); + assert.ok(lines.some((line) => line.includes("No package changes"))); + assert.ok(lines.some((line) => line.includes("0 added · 0 removed · 0 changed"))); +}); diff --git a/test/profile-hardening-extra.test.ts b/test/profile-hardening-extra.test.ts new file mode 100644 index 0000000..3c553c6 --- /dev/null +++ b/test/profile-hardening-extra.test.ts @@ -0,0 +1,784 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { applyProfileWithOutcome, calculateProfileDiagnostics } from "../src/commands/profile.js"; +import { validateCompatibility } from "../src/doctor/compatibility.js"; +import { type PackageCatalog, setPackageCatalogFactory } from "../src/packages/catalog.js"; +import { planProfileApplication } from "../src/profiles/apply.js"; +import { normalizeProfile, parseExternalProfile } from "../src/profiles/schema.js"; +import { loadProfileSource } from "../src/profiles/source.js"; +import { + deleteNamedProfile, + getNamedProfile, + readProfileStore, + saveNamedProfile, +} from "../src/profiles/store.js"; +import { readReloadState } from "../src/utils/reload-state.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; + +async function withProfileEnvironment( + run: (root: string, cache: string) => Promise +): Promise { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-env-")); + const cache = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-cache-")); + const previousAgent = process.env.PI_CODING_AGENT_DIR; + const previousCache = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_CODING_AGENT_DIR = join(root, "agent"); + process.env.PI_EXTMGR_CACHE_DIR = cache; + try { + return await run(root, cache); + } finally { + if (previousAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgent; + if (previousCache === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = previousCache; + await rm(root, { recursive: true, force: true }); + await rm(cache, { recursive: true, force: true }); + } +} + +void test("strict external profile parsing rejects malformed entries and duplicate identities", () => { + const malformed = parseExternalProfile({ + schemaVersion: 99, + name: "team", + packages: [ + { source: "npm:demo", scope: "global", filters: ["", 4] }, + { source: "npm:demo@1.0.0", scope: "global", checksum: "sha256:not-a-fingerprint" }, + ], + }); + assert.equal(malformed.ok, false); + if (!malformed.ok) { + assert.ok(malformed.errors.some((issue) => issue.code === "unsupported-version")); + assert.ok(malformed.errors.some((issue) => issue.code === "invalid-filter")); + assert.ok(malformed.errors.some((issue) => issue.code === "duplicate-package")); + assert.ok(malformed.errors.some((issue) => issue.code === "invalid-fingerprint")); + } +}); + +void test("strict profile parsing validates locked targets and Git ref syntax", () => { + const invalid = parseExternalProfile({ + schemaVersion: 1, + name: "targets", + packages: [ + { source: "npm:demo@latest", scope: "global", resolution: "locked" }, + { + source: "git:https://example.test/demo.git", + scope: "global", + ref: "abcdef0", + resolution: "locked", + }, + { source: "git:https://example.test/demo.git", scope: "global", ref: "bad ref" }, + { + source: "git:https://example.test/demo.git@main", + scope: "global", + ref: "other", + }, + ], + }); + assert.equal(invalid.ok, false); + if (!invalid.ok) { + assert.equal( + invalid.errors.filter((issue) => issue.code === "invalid-locked-target").length, + 2 + ); + assert.ok(invalid.errors.some((issue) => issue.code === "invalid-ref")); + assert.ok(invalid.errors.some((issue) => issue.code === "conflicting-target")); + } + + assert.equal( + parseExternalProfile({ + schemaVersion: 1, + name: "prerelease", + packages: [ + { source: "npm:demo@1.2.3-beta.1+build.4", scope: "global", resolution: "locked" }, + ], + }).ok, + true + ); +}); + +void test("profile store treats prototype-shaped names as ordinary own keys", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-names-")); + const path = join(root, "profiles.json"); + try { + for (const name of ["__proto__", "constructor", "toString", "日本語"]) { + await saveNamedProfile(path, normalizeProfile({ name, packages: [] })); + } + const store = await readProfileStore(path); + assert.equal(getNamedProfile(store, "__proto__")?.name, "__proto__"); + assert.equal(getNamedProfile(store, "constructor")?.name, "constructor"); + assert.equal(Object.getPrototypeOf(store.profiles), null); + await assert.rejects( + () => saveNamedProfile(path, normalizeProfile({ name: "constructor", packages: [] })), + /already exists/ + ); + await assert.rejects( + () => saveNamedProfile(path, { schemaVersion: 1, name: " ", packages: [] }), + /must not be empty/ + ); + assert.equal(await deleteNamedProfile(path, "toString"), true); + assert.equal(getNamedProfile(await readProfileStore(path), "toString"), undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile planning executes exact npm and git targets and models scope moves as updates", () => { + const current = normalizeProfile({ + name: "current", + packages: [ + { source: "npm:demo", scope: "global", version: "2.0.0" }, + { source: "git:https://example.test/demo.git", scope: "project", ref: "main" }, + ], + }); + const desired = normalizeProfile({ + name: "desired", + packages: [ + { source: "npm:demo", scope: "project", version: "1.0.0" }, + { + source: "git:https://example.test/demo.git", + scope: "project", + ref: "0123456789abcdef0123456789abcdef01234567", + }, + ], + }); + const plan = planProfileApplication(current, desired); + assert.equal(plan.add.length, 0); + assert.equal(plan.remove.length, 0); + assert.equal(plan.update.length, 2); +}); + +void test("profile application installs before obsolete removals and restores on failed replacement", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-apply-")); + const cache = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-cache-")); + const previousAgent = process.env.PI_CODING_AGENT_DIR; + const previousCache = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_CODING_AGENT_DIR = join(root, "agent"); + process.env.PI_EXTMGR_CACHE_DIR = cache; + const operations: string[] = []; + const restoreCatalog = mockPackageCatalog({ + packages: [{ source: "npm:old@1.0.0", name: "old", scope: "global" }], + installImpl: (source) => { + operations.push(`install:${source}`); + if (source === "npm:new@2.0.0") throw new Error("replacement failed"); + }, + removeImpl: (source) => { + operations.push(`remove:${source}`); + }, + }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ + name: "current", + packages: [{ source: "npm:old@1.0.0", scope: "global" }], + }), + normalizeProfile({ + name: "target", + packages: [{ source: "npm:new", version: "2.0.0", scope: "global" }], + }), + ctx, + pi + ); + assert.equal(outcome.applied, false); + assert.equal(operations[0], "install:npm:new@2.0.0"); + assert.equal(operations.includes("remove:npm:old@1.0.0"), false); + assert.equal(outcome.restored, true); + assert.ok(outcome.restorePointId); + assert.match(await readFile(join(cache, "profile-restore-points.json"), "utf8"), /current/); + } finally { + restoreCatalog(); + if (previousAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgent; + if (previousCache === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = previousCache; + await rm(root, { recursive: true, force: true }); + await rm(cache, { recursive: true, force: true }); + } +}); + +void test("profile application contracts cover add/remove/versions/git/scope/filter and no-op", async () => { + await withProfileEnvironment(async (root) => { + const cases = [ + { + name: "addition", + current: [], + desired: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + installed: [], + expected: ["install:npm:demo@1.0.0:global"], + }, + { + name: "removal", + current: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + desired: [], + installed: [{ source: "npm:demo@1.0.0", name: "demo", scope: "global" as const }], + expected: ["remove:npm:demo@1.0.0:global"], + }, + { + name: "upgrade", + current: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + desired: [{ source: "npm:demo", version: "2.0.0", scope: "global" as const }], + installed: [{ source: "npm:demo@1.0.0", name: "demo", scope: "global" as const }], + expected: ["install:npm:demo@2.0.0:global"], + }, + { + name: "downgrade", + current: [{ source: "npm:demo@2.0.0", scope: "global" as const }], + desired: [{ source: "npm:demo", version: "1.0.0", scope: "global" as const }], + installed: [{ source: "npm:demo@2.0.0", name: "demo", scope: "global" as const }], + expected: ["install:npm:demo@1.0.0:global"], + }, + { + name: "git ref", + current: [{ source: "git:https://example.test/demo.git@main", scope: "global" as const }], + desired: [ + { + source: "git:https://example.test/demo.git", + ref: "0123456789abcdef0123456789abcdef01234567", + scope: "global" as const, + }, + ], + installed: [ + { + source: "git:https://example.test/demo.git@main", + name: "demo", + scope: "global" as const, + }, + ], + expected: [ + "install:git:https://example.test/demo.git@0123456789abcdef0123456789abcdef01234567:global", + ], + }, + { + name: "scope move", + current: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + desired: [{ source: "npm:demo@1.0.0", scope: "project" as const }], + installed: [{ source: "npm:demo@1.0.0", name: "demo", scope: "global" as const }], + expected: ["install:npm:demo@1.0.0:project", "remove:npm:demo@1.0.0:global"], + }, + { + name: "filter-only", + current: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + desired: [ + { source: "npm:demo@1.0.0", scope: "global" as const, filters: ["extensions/main.ts"] }, + ], + installed: [{ source: "npm:demo@1.0.0", name: "demo", scope: "global" as const }], + expected: [], + }, + { + name: "disable-all-entrypoints", + current: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + desired: [{ source: "npm:demo@1.0.0", scope: "global" as const, filters: [] as string[] }], + installed: [{ source: "npm:demo@1.0.0", name: "demo", scope: "global" as const }], + expected: [], + }, + { + name: "no-op", + current: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + desired: [{ source: "npm:demo@1.0.0", scope: "global" as const }], + installed: [{ source: "npm:demo@1.0.0", name: "demo", scope: "global" as const }], + expected: [], + noOp: true, + }, + ]; + + for (const scenario of cases) { + const mutations: string[] = []; + const restoreCatalog = mockPackageCatalog({ + packages: scenario.installed, + installImpl: (source, scope) => { + mutations.push(`install:${source}:${scope}`); + }, + removeImpl: (source, scope) => { + mutations.push(`remove:${source}:${scope}`); + }, + }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const desiredInput = { + schemaVersion: 1, + name: `${scenario.name}-desired`, + packages: scenario.desired, + }; + const migrated = + scenario.name === "addition" ? parseExternalProfile(desiredInput) : undefined; + if (migrated && !migrated.ok) assert.fail("v1 migration unexpectedly failed"); + if (migrated?.ok) assert.equal(migrated.migration.migrated, true); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ name: `${scenario.name}-current`, packages: scenario.current }), + migrated?.ok ? migrated.profile : normalizeProfile(desiredInput), + ctx, + pi + ); + assert.deepEqual(mutations, scenario.expected, scenario.name); + assert.equal(outcome.applied, scenario.noOp !== true, scenario.name); + } finally { + restoreCatalog(); + } + } + }); +}); + +void test("profile application resolves project-local sources from the .pi settings root", async () => { + await withProfileEnvironment(async (root) => { + const installedSources: string[] = []; + const restoreCatalog = mockPackageCatalog({ + packages: [], + installImpl: (source) => { + installedSources.push(source); + }, + }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + normalizeProfile({ + name: "local", + packages: [{ source: "../vendor/demo", scope: "project" }], + }), + ctx, + pi + ); + assert.equal(outcome.applied, true); + assert.deepEqual(installedSources, [join(root, "vendor", "demo")]); + const settings = JSON.parse(await readFile(join(root, ".pi", "settings.json"), "utf8")) as { + packages?: unknown[]; + }; + assert.deepEqual(settings.packages, [join(root, "vendor", "demo")]); + } finally { + restoreCatalog(); + } + }); +}); + +void test("profile application detects newly requested package settings", async () => { + await withProfileEnvironment(async (root) => { + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:demo@1.0.0", + name: "demo", + version: "1.0.0", + scope: "global", + }, + ], + }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ + name: "current", + packages: [{ source: "npm:demo@1.0.0", scope: "global" }], + }), + normalizeProfile({ + name: "target", + packages: [ + { + source: "npm:demo@1.0.0", + scope: "global", + packageSettings: { skills: ["skills/team.md"] }, + }, + ], + }), + ctx, + pi + ); + assert.equal(outcome.applied, true); + const settings = JSON.parse(await readFile(join(root, "agent", "settings.json"), "utf8")) as { + packages?: unknown[]; + }; + assert.deepEqual(settings.packages, [ + { source: "npm:demo@1.0.0", skills: ["skills/team.md"] }, + ]); + } finally { + restoreCatalog(); + } + }); +}); + +void test("profile application preserves complete package resource settings", async () => { + await withProfileEnvironment(async (root) => { + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + normalizeProfile({ + name: "configured", + packages: [ + { + source: "npm:demo@1.0.0", + scope: "global", + packageSettings: { + skills: ["skills/team.md"], + prompts: ["prompts/team.md"], + themes: ["themes/team.json"], + }, + }, + ], + }), + ctx, + pi + ); + assert.equal(outcome.applied, true); + const settings = JSON.parse(await readFile(join(root, "agent", "settings.json"), "utf8")) as { + packages?: unknown[]; + }; + assert.deepEqual(settings.packages, [ + { + source: "npm:demo@1.0.0", + skills: ["skills/team.md"], + prompts: ["prompts/team.md"], + themes: ["themes/team.json"], + }, + ]); + + const cleared = await applyProfileWithOutcome( + normalizeProfile({ + name: "current", + packages: [ + { + source: "npm:demo@1.0.0", + scope: "global", + packageSettings: { + skills: ["skills/team.md"], + prompts: ["prompts/team.md"], + themes: ["themes/team.json"], + }, + }, + ], + }), + normalizeProfile({ + name: "cleared", + packages: [{ source: "npm:demo@1.0.0", scope: "global", packageSettings: {} }], + }), + ctx, + pi + ); + assert.equal(cleared.applied, true); + const clearedSettings = JSON.parse( + await readFile(join(root, "agent", "settings.json"), "utf8") + ) as { packages?: unknown[] }; + assert.deepEqual(clearedSettings.packages, [{ source: "npm:demo@1.0.0" }]); + } finally { + restoreCatalog(); + } + }); +}); + +void test("profile diagnostics never reuse an installed manifest for a different target version", async () => { + await withProfileEnvironment(async (root) => { + const packageRoot = join(root, "installed-demo"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(packageRoot, { recursive: true }); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ name: "demo", version: "1.0.0", engines: { node: ">=0" } }), + "utf8" + ); + const restoreCatalog = mockPackageCatalog({ + packages: [ + { + source: "npm:demo@1.0.0", + name: "demo", + version: "1.0.0", + scope: "global", + resolvedPath: packageRoot, + }, + ], + }); + try { + const { ctx } = createMockHarness({ cwd: root, hasUI: false }); + const [diagnostic] = await calculateProfileDiagnostics( + normalizeProfile({ + name: "target", + packages: [{ source: "npm:demo", version: "2.0.0", scope: "global" }], + }), + ctx + ); + assert.equal(diagnostic?.compatibility, "unknown"); + assert.ok(diagnostic?.notes.some((note) => note.includes("exact target is not installed"))); + } finally { + restoreCatalog(); + } + }); +}); + +void test("malformed preflight and strict unknown diagnostics perform zero mutations", async () => { + await withProfileEnvironment(async (root) => { + const mutations: string[] = []; + const restoreCatalog = mockPackageCatalog({ + packages: [], + installImpl: (source) => { + mutations.push(source); + }, + }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false, projectTrusted: true }); + const malformed = normalizeProfile({ + name: "bad", + packages: [{ source: "npm:demo", scope: "global" }], + }); + const malformedPackage = malformed.packages[0]; + assert.ok(malformedPackage); + malformedPackage.scope = "invalid" as "global"; + const rejected = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + malformed, + ctx, + pi + ); + assert.equal(rejected.applied, false); + assert.deepEqual(mutations, []); + + await writeFile( + join(root, ".pi", "extmgr-policy.json"), + JSON.stringify({ + schemaVersion: 1, + requireIntegrity: true, + requireCompatibilityCheck: true, + }), + { flag: "w" } + ).catch(async () => { + const { mkdir } = await import("node:fs/promises"); + await mkdir(join(root, ".pi"), { recursive: true }); + await writeFile( + join(root, ".pi", "extmgr-policy.json"), + JSON.stringify({ + schemaVersion: 1, + requireIntegrity: true, + requireCompatibilityCheck: true, + }) + ); + }); + const strict = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + normalizeProfile({ name: "strict", packages: [{ source: "npm:demo", scope: "global" }] }), + ctx, + pi + ); + assert.equal(strict.applied, false); + assert.deepEqual(mutations, []); + } finally { + restoreCatalog(); + } + }); +}); + +void test("settings failure leaves an incomplete restore point and reload marker", async () => { + await withProfileEnvironment(async (root, cache) => { + const { mkdir } = await import("node:fs/promises"); + await mkdir(join(root, "agent"), { recursive: true }); + await writeFile(join(root, "agent", "settings.json"), "{ invalid", "utf8"); + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + normalizeProfile({ + name: "target", + packages: [{ source: "npm:demo@1.0.0", scope: "global" }], + }), + ctx, + pi + ); + assert.equal(outcome.applied, false); + assert.equal(outcome.restored, false); + assert.ok( + outcome.operations?.some( + (operation) => operation.action === "settings" && operation.status === "failed" + ) + ); + assert.equal((await readReloadState(join(cache, "reload-required.json"))).required, true); + assert.match( + await readFile(join(cache, "profile-restore-points.json"), "utf8"), + /"incomplete": true/ + ); + } finally { + restoreCatalog(); + } + }); +}); + +void test("rollback refuses to claim success when an unexpected package remains", async () => { + await withProfileEnvironment(async (root) => { + let packages: Array<{ source: string; name: string; scope: "global" | "project" }> = []; + const catalog: PackageCatalog = { + listInstalledPackages: () => Promise.resolve(packages.map((pkg) => ({ ...pkg }))), + checkForAvailableUpdates: () => Promise.resolve([]), + install: async (source, scope) => { + packages = [ + { source, name: "desired", scope }, + { source: "npm:unexpected@1.0.0", name: "unexpected", scope: "global" }, + ]; + }, + remove: async (source, scope) => { + packages = packages.filter((pkg) => !(pkg.source === source && pkg.scope === scope)); + }, + update: async () => undefined, + }; + setPackageCatalogFactory(() => catalog); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + normalizeProfile({ + name: "target", + packages: [{ source: "npm:desired@1.0.0", scope: "global" }], + }), + ctx, + pi + ); + assert.equal(outcome.applied, false); + assert.equal(outcome.restored, false); + assert.equal((await readReloadState()).required, true); + } finally { + setPackageCatalogFactory(); + } + }); +}); + +void test("final-state verification detects post-persist drift and rolls back", async () => { + await withProfileEnvironment(async (root) => { + let listCalls = 0; + let installed = false; + const catalog: PackageCatalog = { + listInstalledPackages: () => { + listCalls += 1; + if (!installed || listCalls >= 3) return Promise.resolve([]); + return Promise.resolve([{ source: "npm:demo@1.0.0", name: "demo", scope: "global" }]); + }, + checkForAvailableUpdates: () => Promise.resolve([]), + install: async () => { + installed = true; + }, + remove: async () => { + installed = false; + }, + update: async () => undefined, + }; + setPackageCatalogFactory(() => catalog); + try { + const { ctx, pi } = createMockHarness({ cwd: root, hasUI: false }); + const outcome = await applyProfileWithOutcome( + normalizeProfile({ name: "current", packages: [] }), + normalizeProfile({ + name: "target", + packages: [{ source: "npm:demo@1.0.0", scope: "global" }], + }), + ctx, + pi + ); + assert.equal(outcome.applied, false); + assert.ok( + outcome.operations?.some((operation) => + operation.error?.includes("Final-state verification") + ) + ); + } finally { + setPackageCatalogFactory(); + } + }); +}); + +void test("compatibility returns unknown instead of accepting complex semver ranges", () => { + assert.equal( + validateCompatibility({ + packageName: "demo", + engines: { node: ">=20 <22" }, + nodeVersion: "21.0.0", + }).node, + "compatible" + ); + assert.equal( + validateCompatibility({ + packageName: "demo", + engines: { node: ">=20 || <12" }, + nodeVersion: "21.0.0", + }).node, + "unknown" + ); + assert.equal( + validateCompatibility({ + packageName: "demo", + engines: { node: "^20.0.0" }, + nodeVersion: "21.0.0", + }).node, + "incompatible" + ); + assert.equal( + validateCompatibility({ + packageName: "demo", + engines: { node: "~20.1.0" }, + nodeVersion: "20.2.0", + }).node, + "incompatible" + ); + for (const range of ["!=21.0.0", ">=20 nonsense", ">=20.0.0-beta.1"]) { + assert.equal( + validateCompatibility({ + packageName: "demo", + engines: { node: range }, + nodeVersion: "21.0.0", + }).node, + "unknown" + ); + } +}); + +void test("profile source loader bounds local JSON and converts GitHub blob origins", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-source-")); + const originalFetch = globalThis.fetch; + try { + const local = join(root, "local.json"); + await writeFile(local, JSON.stringify({ schemaVersion: 1, name: "local", packages: [] })); + const loadedLocal = await loadProfileSource(local, { cwd: root }); + assert.equal(loadedLocal.remote, false); + await assert.rejects( + () => loadProfileSource(local, { cwd: root, maxBytes: 8 }), + /exceeds the 8 byte limit/ + ); + + let requested = ""; + globalThis.fetch = (async (input) => { + requested = String(input); + return new Response(JSON.stringify({ schemaVersion: 1, name: "remote", packages: [] }), { + status: 200, + }); + }) as typeof fetch; + const loadedRemote = await loadProfileSource( + "https://github.com/org/repo/blob/0123456789abcdef0123456789abcdef01234567/team.json", + { cwd: root } + ); + assert.equal( + requested, + "https://raw.githubusercontent.com/org/repo/0123456789abcdef0123456789abcdef01234567/team.json" + ); + assert.equal(loadedRemote.remote, true); + assert.equal(loadedRemote.immutableOrigin, true); + assert.deepEqual(loadedRemote.warnings, []); + + const generic = await loadProfileSource("https://example.test/profile.json", { cwd: root }); + assert.equal(generic.immutableOrigin, false); + assert.ok(generic.warnings.some((warning) => warning.includes("not content-addressed"))); + + await loadProfileSource("https://github.com/org/repo/blob/feature/team/profiles/team.json", { + cwd: root, + }); + assert.equal( + requested, + "https://raw.githubusercontent.com/org/repo/feature/team/profiles/team.json" + ); + + await loadProfileSource("https://github.com/org/repo/blob/feature%2Fteam/profile.json", { + cwd: root, + }); + assert.equal(requested, "https://raw.githubusercontent.com/org/repo/feature/team/profile.json"); + } finally { + globalThis.fetch = originalFetch; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/profiles.test.ts b/test/profiles.test.ts new file mode 100644 index 0000000..5aef6cd --- /dev/null +++ b/test/profiles.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { applyProfile, planProfileApplication } from "../src/profiles/apply.js"; +import { + compareProfiles, + loadProjectProfilePolicy, + validateProfilePolicy, +} from "../src/profiles/compare.js"; +import { normalizeProfile } from "../src/profiles/schema.js"; +import { + deleteNamedProfile, + readProfileStore, + saveNamedProfile, + writeProfileStore, +} from "../src/profiles/store.js"; + +void test("profile application produces a dry-run plan without mutating state", async () => { + const current = normalizeProfile({ + name: "current", + packages: [{ source: "npm:old", scope: "global" }], + }); + const desired = normalizeProfile({ + name: "desired", + packages: [{ source: "npm:new", scope: "project" }], + }); + let applied = false; + const plan = await applyProfile(current, desired, { + dryRun: true, + apply: async () => { + applied = true; + }, + }); + assert.equal(plan.add[0]?.source, "npm:new"); + assert.equal(plan.remove[0]?.source, "npm:old"); + assert.equal(applied, false); +}); + +void test("profile plans treat equivalent embedded and declared targets as equal", () => { + const current = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global", version: "1.0.0" }], + }); + const desired = normalizeProfile({ + packages: [{ source: "npm:demo@1.0.0", scope: "global" }], + }); + assert.equal(planProfileApplication(current, desired).update.length, 0); +}); + +void test("profile plans identify exact package state changes", () => { + const current = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global", version: "1.0.0" }], + }); + const desired = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global", version: "2.0.0" }], + }); + assert.equal(planProfileApplication(current, desired).update.length, 1); +}); + +void test("profile plans preserve omitted package settings but honor explicit clearing", () => { + const current = normalizeProfile({ + packages: [ + { + source: "npm:demo", + scope: "global", + packageSettings: { skills: ["skills/team.md"] }, + }, + ], + }); + const preserve = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global" }], + }); + const clear = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global", packageSettings: {} }], + }); + + assert.equal(planProfileApplication(current, preserve).update.length, 0); + assert.equal(planProfileApplication(current, clear).update.length, 1); +}); + +void test("profile plans distinguish default filters from explicitly disabling every entrypoint", () => { + const defaults = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global" }], + }); + const disabled = normalizeProfile({ + packages: [{ source: "npm:demo", scope: "global", filters: [] }], + }); + + assert.equal(planProfileApplication(defaults, disabled).update.length, 1); + assert.equal(planProfileApplication(disabled, defaults).update.length, 1); + assert.equal(planProfileApplication(disabled, disabled).update.length, 0); +}); + +void test("profile comparison and policy validation expose actionable differences", () => { + const left = normalizeProfile({ packages: [{ source: "npm:demo", scope: "global" }] }); + const right = normalizeProfile({ packages: [{ source: "npm:demo", scope: "project" }] }); + const scopePlan = compareProfiles(left, right); + assert.equal(scopePlan.update.length, 1); + assert.equal(scopePlan.update[0]?.from.scope, "global"); + assert.equal(scopePlan.update[0]?.to.scope, "project"); + assert.equal( + validateProfilePolicy(right, { allowedScopes: ["global"], requireChecksums: true }).length, + 2 + ); +}); + +void test("named profiles persist atomically and can be deleted", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-store-")); + const path = join(root, "profiles.json"); + try { + await saveNamedProfile(path, normalizeProfile({ name: " team ", packages: [] })); + assert.equal((await readProfileStore(path)).profiles.team?.name, "team"); + assert.equal(await deleteNamedProfile(path, "team"), true); + assert.equal(Object.keys((await readProfileStore(path)).profiles).length, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("concurrent named profile saves retain every update", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-store-race-")); + const path = join(root, "profiles.json"); + try { + await Promise.all( + Array.from({ length: 24 }, (_, index) => + saveNamedProfile(path, normalizeProfile({ name: `team-${index}`, packages: [] })) + ) + ); + const names = Object.keys((await readProfileStore(path)).profiles).sort(); + assert.deepEqual(names, Array.from({ length: 24 }, (_, index) => `team-${index}`).sort()); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile writes refuse malformed or unknown-version stores", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-store-invalid-")); + const path = join(root, "profiles.json"); + try { + await writeFile(path, "{ invalid", "utf8"); + await assert.rejects( + () => saveNamedProfile(path, normalizeProfile({ name: "team", packages: [] })), + /Unable to read profile store/ + ); + assert.equal(await readFile(path, "utf8"), "{ invalid"); + + const unsupported = { version: 99, profiles: {} }; + await writeFile(path, JSON.stringify(unsupported), "utf8"); + await assert.rejects( + () => + writeProfileStore(path, unsupported as unknown as Parameters[1]), + /Unsupported or malformed profile store/ + ); + await assert.rejects( + () => deleteNamedProfile(path, "team"), + /Unsupported or malformed profile store/ + ); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), unsupported); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("project policy loading rejects malformed policies and validates requirements", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-extmgr-policy-")); + try { + await writeFile( + join(root, "policy.json"), + JSON.stringify({ schemaVersion: 1, allowedScopes: ["project"], requireChecksums: true }), + "utf8" + ); + const policy = await loadProjectProfilePolicy(root, join(root, "policy.json")); + assert.equal( + validateProfilePolicy( + normalizeProfile({ packages: [{ source: "npm:demo", scope: "global" }] }), + policy ?? {} + ).length, + 2 + ); + await writeFile(join(root, "bad.json"), "{invalid", "utf8"); + await assert.rejects(() => loadProjectProfilePolicy(root, join(root, "bad.json"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test("profile schema preserves exact package versions, refs, filters, scopes, and checksums", () => { + const profile = normalizeProfile({ + schemaVersion: 99, + name: " team ", + packages: [ + { + source: " npm:demo ", + scope: "project", + version: "1.2.3", + ref: "sha256:abc", + filters: ["+extensions/main.ts", "-extensions/legacy.ts"], + checksum: "sha256:deadbeef", + }, + ], + checks: { compatibility: true, provenance: true }, + }); + assert.deepEqual(profile, { + schemaVersion: 1, + name: "team", + packages: [ + { + source: "npm:demo", + scope: "project", + version: "1.2.3", + ref: "sha256:abc", + filters: ["+extensions/main.ts", "-extensions/legacy.ts"], + checksum: "sha256:deadbeef", + }, + ], + checks: { compatibility: true, provenance: true }, + }); +}); diff --git a/test/reload-state.test.ts b/test/reload-state.test.ts new file mode 100644 index 0000000..6956a65 --- /dev/null +++ b/test/reload-state.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + clearReloadRequired, + markReloadRequired, + readReloadState, +} from "../src/utils/reload-state.js"; +import { confirmReload } from "../src/utils/ui-helpers.js"; + +void test("reload-required state is versioned, atomic, and coalesces reasons", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-reload-")); + const path = join(dir, "reload.json"); + try { + await Promise.all([ + markReloadRequired("Package installed", path), + markReloadRequired("Package installed", path), + markReloadRequired("Extension toggled", path), + ]); + + const state = await readReloadState(path); + assert.equal(state.version, 1); + assert.equal(state.required, true); + assert.equal(state.changes, 3); + assert.deepEqual(state.reasons, ["Package installed", "Extension toggled"]); + assert.equal((await readFile(path, "utf8")).endsWith("\n"), true); + + await clearReloadRequired(path); + assert.deepEqual(await readReloadState(path), { + version: 1, + required: false, + changes: 0, + reasons: [], + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("declining an interactive reload keeps the successful mutation pending", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-reload-declined-")); + const path = join(dir, "reload.json"); + try { + let reloads = 0; + const reloaded = await confirmReload( + { + hasUI: true, + ui: { confirm: async () => false }, + reload: async () => { + reloads += 1; + }, + } as never, + "Package installed.", + path + ); + + assert.equal(reloaded, false); + assert.equal(reloads, 0); + const state = await readReloadState(path); + assert.equal(state.version, 1); + assert.equal(state.required, true); + assert.equal(typeof state.changedAt, "number"); + assert.equal(state.changes, 1); + assert.deepEqual(state.reasons, ["Package installed."]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("a successful interactive reload clears the pending marker", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-reload-success-")); + const path = join(dir, "reload.json"); + try { + const reloaded = await confirmReload( + { + hasUI: true, + ui: { confirm: async () => true }, + reload: async () => undefined, + } as never, + "Extension changed.", + path + ); + + assert.equal(reloaded, true); + assert.deepEqual(await readReloadState(path), { + version: 1, + required: false, + changes: 0, + reasons: [], + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +void test("reload-required state ignores malformed persisted data", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-reload-invalid-")); + const path = join(dir, "reload.json"); + try { + await writeFile(path, "{invalid", "utf8"); + assert.deepEqual(await readReloadState(path), { + version: 1, + required: false, + changes: 0, + reasons: [], + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/remote-hydration.test.ts b/test/remote-hydration.test.ts new file mode 100644 index 0000000..96b3fd4 --- /dev/null +++ b/test/remote-hydration.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { initTheme } from "@earendil-works/pi-coding-agent"; + +// Keep hydration persistence away from the real extmgr cache. +process.env.PI_EXTMGR_CACHE_DIR = mkdtempSync(join(tmpdir(), "pi-extmgr-hydration-")); +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 { type NpmPackage } from "../src/types/index.js"; + +initTheme(); + +function setSearchPage( + query: string, + results: NpmPackage[], + total = results.length, + offset = 0 +): void { + setSearchCache({ query, results, total, offset, timestamp: Date.now() }); +} + +function nextTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +void test("browse hydrates weekly downloads in the background without delaying results", async () => { + setSearchPage("hydrate", [ + { name: "hydrate-target", version: "1.0.0", description: "Needs metrics" }, + ]); + + const originalFetch = globalThis.fetch; + let downloadFetches = 0; + globalThis.fetch = ((input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!url.includes("api.npmjs.org/downloads")) { + return Promise.resolve(new Response("{}", { status: 500 })); + } + downloadFetches += 1; + return Promise.resolve( + new Response(JSON.stringify({ package: "hydrate-target", downloads: 4321 }), { + status: 200, + }) + ); + }) as typeof fetch; + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let initialLines: string[] = []; + let hydratedLines: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, async (component, lines) => { + // Rows are visible immediately, before metrics land. + initialLines = lines; + // Poll: hydration persists metadata to the on-disk cache before finishing. + for (let attempt = 0; attempt < 200; attempt += 1) { + await nextTick(); + hydratedLines = component.render(120); + if (hydratedLines.some((line) => line.includes("4.3K/wk"))) break; + } + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "hydrate", pi); + + assert.ok(initialLines.some((line) => line.includes("hydrate-target@1.0.0"))); + assert.ok( + !initialLines.some((line) => line.includes("4.3K/wk")), + "metrics must not block the initial render" + ); + assert.equal(downloadFetches, 1); + assert.ok( + hydratedLines.some((line) => line.includes("4.3K/wk")), + "hydrated metrics should appear in rows after background fetch" + ); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + +void test("browse skips download hydration when metrics are already known", async () => { + setSearchPage("known", [ + { name: "known-pkg", version: "1.0.0", description: "Cached metrics", weeklyDownloads: 900 }, + ]); + + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve(new Response("{}", { status: 200 })); + }) as typeof fetch; + + const { pi, ctx } = createMockHarness({ hasUI: true }); + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, async () => { + await nextTick(); + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "known", pi); + assert.equal(fetchCalls, 0, "no network work when weekly downloads are already cached"); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + +void test("browse aborts download hydration when the screen is disposed", async () => { + setSearchPage("abort", [{ name: "abort-target", version: "1.0.0", description: "Slow metrics" }]); + + const originalFetch = globalThis.fetch; + let aborted = false; + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("The operation was aborted", "AbortError")); + }); + })) as typeof fetch; + + const { pi, ctx } = createMockHarness({ hasUI: true }); + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, () => ({ type: "cancel" })); + + try { + await browseRemotePackages(ctx, "abort", pi); + await nextTick(); + assert.equal(aborted, true, "disposal must abort in-flight metadata fetches"); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + +void test("browse ignores hydration failures and keeps rendering", async () => { + setSearchPage("failing", [ + { name: "failing-target", version: "1.0.0", description: "Metrics fail" }, + ]); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => Promise.reject(new Error("network down"))) as typeof fetch; + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let renderedAfterFailure: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, async (component) => { + await nextTick(); + renderedAfterFailure = component.render(120); + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "failing", pi); + assert.ok(renderedAfterFailure.some((line) => line.includes("failing-target@1.0.0"))); + assert.ok(!renderedAfterFailure.some((line) => line.includes("/wk"))); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); diff --git a/test/remote-ui.test.ts b/test/remote-ui.test.ts index f8c90e7..b0f0895 100644 --- a/test/remote-ui.test.ts +++ b/test/remote-ui.test.ts @@ -1,18 +1,41 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; import { clearMetadataCacheCommand } from "../src/commands/cache.js"; import { clearSearchCache, setSearchCache } from "../src/packages/discovery.js"; -import { browseRemotePackages } from "../src/ui/remote.js"; +import { browseRemotePackages, clearRemotePackageInfoCache, showRemote } 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"; +import { type NpmPackage } from "../src/types/index.js"; + +function setSearchPage( + query: string, + results: NpmPackage[], + total = results.length, + offset = 0 +): void { + setSearchCache({ query, results, total, offset, timestamp: Date.now() }); +} + +void test("remote install reports reloads so parent workspaces stop using stale contexts", async () => { + const restoreCatalog = mockPackageCatalog({ packages: [] }); + try { + const { pi, ctx, reloadCount } = createMockHarness({ + hasUI: true, + confirmResult: true, + selectResult: "Global (~/.pi/agent/settings.json)", + }); + const reloaded = await showRemote("install npm:demo", ctx, pi); + assert.equal(reloaded, true); + assert.equal(reloadCount(), 1); + } finally { + restoreCatalog(); + } +}); void test("browseRemotePackages honors an empty in-memory cache", async () => { - setSearchCache({ - query: "no-results", - results: [], - timestamp: Date.now(), - }); + setSearchPage("no-results", []); const { pi, ctx, notifications } = createMockHarness({ hasUI: true }); let customCalls = 0; @@ -38,6 +61,33 @@ void test("browseRemotePackages honors an empty in-memory cache", async () => { } }); +void test("browseRemotePackages keeps every rendered line within terminal width", async () => { + setSearchPage("narrow", [ + { + name: "very-long-community-package-name", + version: "123.456.789", + description: "A very long package description that must wrap without overflowing.", + author: "a-very-long-maintainer-name", + }, + ]); + const { pi, ctx } = createMockHarness({ hasUI: true }); + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (_component, lines) => { + assert.ok(lines.every((line) => visibleWidth(line) <= 24)); + return { type: "cancel" }; + }, + { width: 24, height: 20 } + ); + try { + await browseRemotePackages(ctx, "narrow", pi); + } finally { + clearSearchCache(); + } +}); + void test("browseRemotePackages rejects local-path queries instead of showing unrelated npm results", async () => { const { pi, ctx, notifications } = createMockHarness({ hasUI: true }); let customCalls = 0; @@ -72,17 +122,13 @@ void test("browseRemotePackages rejects local-path queries instead of showing un }); 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(), - }); + setSearchPage("demo", [ + { + name: "demo-pkg", + version: "1.0.0", + description: "Demo package", + }, + ]); const { pi, ctx } = createMockHarness({ hasUI: true }); let renderedLines: string[] = []; @@ -108,16 +154,65 @@ void test("browseRemotePackages shows inline search affordances in the browse UI } }); +void test("browseRemotePackages shows weekly downloads in rows and the detail pane", async () => { + setSearchPage("popular", [ + { + name: "popular-pi-package", + version: "2.0.0", + description: "A popular Pi package", + weeklyDownloads: 125_000, + }, + ]); + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let renderedLines: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "popular", pi); + assert.ok(renderedLines.some((line) => line.includes("125K/wk"))); + assert.ok(renderedLines.some((line) => line.includes("Weekly downloads"))); + } finally { + clearSearchCache(); + } +}); + +void test("browseRemotePackages sorts known weekly downloads by default", async () => { + setSearchPage("downloads-default", [ + { name: "low-downloads", version: "1.0.0", weeklyDownloads: 10 }, + { name: "high-downloads", version: "1.0.0", weeklyDownloads: 10_000 }, + ]); + const { pi, ctx } = createMockHarness({ hasUI: true }); + let renderedLines: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (_component, lines) => { + renderedLines = lines; + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "downloads-default", pi); + const high = renderedLines.findIndex((line) => line.includes("high-downloads")); + const low = renderedLines.findIndex((line) => line.includes("low-downloads")); + assert.ok(high >= 0 && low >= 0 && high < low); + assert.ok(renderedLines.some((line) => line.includes("sort:downloads"))); + } 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 packages = Array.from({ length: 25 }, (_, index) => ({ + name: `demo-pkg-${index + 1}`, + version: "1.0.0", + description: `Demo package ${index + 1}`, + })); + setSearchPage("demo", packages.slice(0, 20), packages.length); + setSearchPage("demo", packages.slice(20), packages.length, 20); const { pi, ctx } = createMockHarness({ hasUI: true }); let customCalls = 0; @@ -156,17 +251,13 @@ void test("browseRemotePackages supports next-page navigation from search result }); 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(), - }); + setSearchPage("demo", [ + { + name: "browse-default", + version: "1.0.0", + description: "Default browse result", + }, + ]); const nextQuery = "inline-demo"; const { pi, ctx } = createMockHarness({ hasUI: true }); @@ -186,17 +277,13 @@ void test("browseRemotePackages can start a new remote npm search from search re 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(), - }); + setSearchPage(nextQuery, [ + { + name: "inline-result", + version: "2.0.0", + description: "Inline search result", + }, + ]); component.handleInput?.("\r"); return completion; }); @@ -219,26 +306,24 @@ void test("browseRemotePackages can start a new remote npm search from search re } }); -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(), - }); +void test("browseRemotePackages scopes inline community searches to pi packages", async () => { + setSearchPage("keywords:pi-package", [ + { + name: "browse-default", + version: "1.0.0", + description: "Default browse result", + author: "someone", + }, + ]); + setSearchPage("keywords:pi-package copilot queue", [ + { + name: "pi-copilot-queue", + version: "2.0.0", + description: "Queue tools for Pi copilots", + author: "ayagmar", + keywords: ["pi-package", "queue", "copilot"], + }, + ]); const originalFetch = globalThis.fetch; let fetchCalls = 0; @@ -280,7 +365,7 @@ void test("browseRemotePackages filters community packages locally from the brow await browseRemotePackages(ctx, "keywords:pi-package", pi); assert.equal(customCalls, 2); - assert.equal(fetchCalls, 0); + assert.equal(fetchCalls, 2); 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"))); @@ -290,25 +375,21 @@ void test("browseRemotePackages filters community packages locally from the brow } }); -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(), - }); +void test("browseRemotePackages preserves npm community ranking and shows author in details", async () => { + setSearchPage("keywords:pi-package queue", [ + { + name: "queue-copilot", + version: "2.0.0", + description: "Copilot queue tools", + author: "ayagmar", + }, + { + name: "alpha-tool", + version: "1.0.0", + description: "Queue utilities for Pi", + author: "someone", + }, + ]); const { pi, ctx } = createMockHarness({ hasUI: true }); let renderedLines: string[] = []; @@ -340,6 +421,112 @@ void test("browseRemotePackages ranks community matches locally and shows author } }); +void test("browseRemotePackages refresh bypasses fresh persistent and runtime search caches", async () => { + setSearchPage("demo", [ + { + name: "old-result", + version: "1.0.0", + description: "Cached result", + }, + ]); + + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve( + new Response( + JSON.stringify({ + total: 1, + objects: [ + { + package: { + name: "fresh-result", + version: "2.0.0", + description: "Fresh result", + }, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + }) as typeof fetch; + + const { pi, ctx } = createMockHarness({ hasUI: true }); + let browserCalls = 0; + let refreshedLines: string[] = []; + + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ search")), + (component, lines, completion) => { + browserCalls += 1; + if (browserCalls === 1) { + component.handleInput?.("r"); + return completion; + } + + refreshedLines = lines; + return { type: "cancel" }; + } + ); + + try { + await browseRemotePackages(ctx, "demo", pi); + + assert.equal(fetchCalls, 3); + assert.equal(browserCalls, 2); + assert.ok(refreshedLines.some((line) => line.includes("fresh-result@2.0.0"))); + assert.ok(!refreshedLines.some((line) => line.includes("old-result@1.0.0"))); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + +void test("browseRemotePackages handles exhausted npm rate limits without a command error", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve( + new Response("rate limited", { status: 429, headers: { "retry-after": "0" } }) + ); + }) as typeof fetch; + + const { pi, ctx, notifications } = createMockHarness({ hasUI: true }); + ( + ctx.ui as unknown as { + custom: (factory: unknown, options?: unknown) => Promise; + } + ).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (_component, _lines, completion) => completion); + + try { + await browseRemotePackages(ctx, "rate-limited-query", pi); + + assert.equal(fetchCalls, 3); + assert.ok( + notifications.some( + (entry) => + entry.level === "warning" && + entry.message.includes("rate-limited (HTTP 429)") && + entry.message.includes("Try again shortly") + ) + ); + } finally { + globalThis.fetch = originalFetch; + clearSearchCache(); + } +}); + void test("clearMetadataCacheCommand clears the community browse runtime cache", async () => { const originalFetch = globalThis.fetch; let fetchCalls = 0; @@ -386,18 +573,14 @@ void test("clearMetadataCacheCommand clears the community browse runtime cache", try { await browseRemotePackages(ctx, "keywords:pi-package", pi); - assert.equal(fetchCalls, 1); + assert.equal(fetchCalls, 2); - setSearchCache({ - query: "demo", - results: [{ name: "demo-pkg", description: "Demo package" }], - timestamp: Date.now(), - }); + setSearchPage("demo", [{ name: "demo-pkg", description: "Demo package" }]); await clearMetadataCacheCommand(ctx, pi); await browseRemotePackages(ctx, "keywords:pi-package", pi); - assert.equal(fetchCalls, 2); + assert.equal(fetchCalls, 4); } finally { globalThis.fetch = originalFetch; clearSearchCache(); @@ -410,33 +593,52 @@ void test("browseRemotePackages returns to results after installing from package 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", + if (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" }, - } - ) + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + } + return Promise.resolve( + new Response(JSON.stringify({ downloads: 42 }), { + status: 200, + headers: { "content-type": "application/json" }, + }) ); }) as typeof fetch; const { pi, ctx, confirmPrompts, selectPrompts } = createMockHarness({ hasUI: true, - confirmImpl: (title) => title === "Install Package", + confirmImpl: (title) => title === "Review before install", + execImpl: (command, args) => + command === "npm" && args[0] === "view" + ? { + code: 0, + stdout: JSON.stringify({ + version: "1.0.0", + description: "Demo package", + dependencies: { "demo-dependency": "^1.0.0" }, + }), + stderr: "", + killed: false, + } + : { code: 0, stdout: "ok", stderr: "", killed: false }, }); const selectResults = ["Install via npm (managed)", "Global (~/.pi/agent/settings.json)"]; let browserCalls = 0; @@ -484,17 +686,14 @@ void test("browseRemotePackages returns to results after installing from package }); void test("browseRemotePackages returns to package details after a cancelled load", async () => { - setSearchCache({ - query: "demo", - results: [ - { - name: "demo-pkg", - version: "1.0.0", - description: "Demo package", - }, - ], - timestamp: Date.now(), - }); + clearRemotePackageInfoCache(); + setSearchPage("demo", [ + { + name: "demo-pkg", + version: "1.0.0", + description: "Demo package", + }, + ]); const { pi, ctx, notifications, selectPrompts } = createMockHarness({ hasUI: true }); const customResults: unknown[] = [ @@ -525,6 +724,7 @@ void test("browseRemotePackages returns to package details after a cancelled loa ) ); } finally { + clearRemotePackageInfoCache(); clearSearchCache(); } }); diff --git a/test/trust-paths.test.ts b/test/trust-paths.test.ts new file mode 100644 index 0000000..d30392b --- /dev/null +++ b/test/trust-paths.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +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 { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; +import { discoverPackageExtensions, setPackageExtensionState } from "../src/packages/extensions.js"; +import { getProfileStorePath } from "../src/profiles/store.js"; +import { getExtmgrTrashDir, getProjectConfigDir } from "../src/utils/pi-paths.js"; +import { getReloadRequiredStatePath } from "../src/utils/reload-state.js"; +import { getSavedViewsPath } from "../src/utils/views.js"; + +void test("untrusted project package filters are ignored and writes are rejected", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-untrusted-")); + const root = join(cwd, "vendor", "demo"); + const settingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + try { + await mkdir(root, { recursive: true }); + await mkdir(join(cwd, CONFIG_DIR_NAME), { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ pi: { extensions: ["index.ts"] } }) + ); + await writeFile(join(root, "index.ts"), "// demo\n"); + await writeFile( + settingsPath, + JSON.stringify({ packages: [{ source: "./vendor/demo", extensions: [] }] }) + ); + const pkg = { + source: "./vendor/demo", + name: "demo", + scope: "project" as const, + resolvedPath: root, + }; + + const untrusted = await discoverPackageExtensions([pkg], cwd, { projectTrusted: false }); + assert.equal(untrusted[0]?.state, "enabled"); + const rejected = await setPackageExtensionState( + pkg.source, + "index.ts", + "project", + "disabled", + cwd, + false + ); + assert.equal(rejected.ok, false); + assert.deepEqual(JSON.parse(await readFile(settingsPath, "utf8")), { + packages: [{ source: "./vendor/demo", extensions: [] }], + }); + + const trusted = await discoverPackageExtensions([pkg], cwd, { projectTrusted: true }); + assert.equal(trusted[0]?.state, "disabled"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +void test("Pi and extmgr path helpers resolve environment overrides at call time", () => { + const oldAgent = process.env.PI_CODING_AGENT_DIR; + const oldCache = process.env.PI_EXTMGR_CACHE_DIR; + try { + process.env.PI_CODING_AGENT_DIR = "/tmp/extmgr-agent-one"; + delete process.env.PI_EXTMGR_CACHE_DIR; + assert.equal(getExtmgrTrashDir(), "/tmp/extmgr-agent-one/.extmgr-trash"); + assert.equal(getProfileStorePath(), "/tmp/extmgr-agent-one/.extmgr-cache/profiles.json"); + assert.equal( + getReloadRequiredStatePath(), + "/tmp/extmgr-agent-one/.extmgr-cache/reload-required.json" + ); + assert.match( + getSavedViewsPath("/workspace"), + /^\/tmp\/extmgr-agent-one\/\.extmgr-cache\/views-/ + ); + + process.env.PI_EXTMGR_CACHE_DIR = "/tmp/extmgr-cache-override"; + assert.equal(getProfileStorePath(), "/tmp/extmgr-cache-override/profiles.json"); + assert.equal(getReloadRequiredStatePath(), "/tmp/extmgr-cache-override/reload-required.json"); + assert.equal(getProjectConfigDir("/workspace"), join("/workspace", CONFIG_DIR_NAME)); + } finally { + if (oldAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = oldAgent; + if (oldCache === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = oldCache; + } +}); diff --git a/test/ui-helpers.test.ts b/test/ui-helpers.test.ts index 129fe25..f20c14e 100644 --- a/test/ui-helpers.test.ts +++ b/test/ui-helpers.test.ts @@ -1,57 +1,20 @@ import assert from "node:assert/strict"; import test from "node:test"; import { type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; -import { confirmReload } from "../src/utils/ui-helpers.js"; +import { confirmReload, wasContextReloaded } from "../src/utils/ui-helpers.js"; -void test("confirmReload returns false without reloading when user cancels", async () => { - const notifications: { message: string; level: string | undefined }[] = []; - let reloadCalls = 0; - const ctx = { - hasUI: true, - ui: { - confirm: () => Promise.resolve(false), - notify: (message: string, level?: string) => { - notifications.push({ message, level }); - }, - }, - reload: () => { - reloadCalls += 1; - return Promise.resolve(); - }, - } as unknown as ExtensionCommandContext; - - const reloaded = await confirmReload(ctx, "Package updated."); - - assert.equal(reloaded, false); - assert.equal(reloadCalls, 0); - assert.deepEqual(notifications, []); -}); - -void test("confirmReload returns true after a successful reload", async () => { - const notifications: { message: string; level: string | undefined }[] = []; - let reloadCalls = 0; +void test("confirmReload marks a context stale after a successful reload", async () => { const ctx = { hasUI: true, - ui: { - confirm: () => Promise.resolve(true), - notify: (message: string, level?: string) => { - notifications.push({ message, level }); - }, - }, - reload: () => { - reloadCalls += 1; - return Promise.resolve(); - }, + ui: { confirm: () => Promise.resolve(true), notify: () => undefined }, + reload: () => Promise.resolve(), } as unknown as ExtensionCommandContext; - const reloaded = await confirmReload(ctx, "Package updated."); - - assert.equal(reloaded, true); - assert.equal(reloadCalls, 1); - assert.deepEqual(notifications, []); + assert.equal(await confirmReload(ctx, "Package updated."), true); + assert.equal(wasContextReloaded(ctx), true); }); -void test("confirmReload reports reload failures without throwing", async () => { +void test("confirmReload notifies the user when ctx.reload rejects", async () => { const notifications: { message: string; level: string | undefined }[] = []; const ctx = { hasUI: true, diff --git a/test/unified-items.test.ts b/test/unified-items.test.ts index 0da38ce..0f33c03 100644 --- a/test/unified-items.test.ts +++ b/test/unified-items.test.ts @@ -46,6 +46,22 @@ void test("buildUnifiedItems includes local + package rows only", () => { ); }); +void test("buildUnifiedItems gives same-source package rows scope-specific ids", () => { + const items = buildUnifiedItems( + [], + [ + { source: "npm:demo", name: "demo", scope: "global" }, + { source: "npm:demo", name: "demo", scope: "project" }, + ], + new Set() + ); + + assert.deepEqual( + items.map((item) => item.id), + ["pkg:npm:demo:global", "pkg:npm:demo:project"] + ); +}); + void test("buildUnifiedItems marks package update availability from knownUpdates", () => { const installedPackages = [createPackage("npm:pi-extmgr", "pi-extmgr")]; @@ -94,6 +110,7 @@ void test("buildUnifiedItems summarizes package extension enabled and disabled s disabled: 1, total: 2, }); + assert.deepEqual(packageRow.extensionPaths, ["extensions/enabled.ts", "extensions/disabled.ts"]); }); void test("buildUnifiedItems omits package rows that duplicate local extension paths", () => { diff --git a/test/unified-ui.test.ts b/test/unified-ui.test.ts index e2fe90c..e29c21a 100644 --- a/test/unified-ui.test.ts +++ b/test/unified-ui.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { initTheme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; import { showInteractive } from "../src/ui/unified.js"; import { captureCustomComponent } from "./helpers/custom-component.js"; import { createMockHarness } from "./helpers/mocks.js"; @@ -11,6 +12,10 @@ import { mockPackageCatalog } from "./helpers/package-catalog.js"; initTheme(); +function rendersLocalState(lines: string[], name: string, state: "enabled" | "disabled"): boolean { + return lines.some((line) => line.includes(name) && line.includes(`local · project · ${state}`)); +} + 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"); @@ -38,13 +43,13 @@ void test("/extensions keeps rows compact and moves selected details below the l await showInteractive(ctx, pi); const rowLine = renderedLines.find( - (line) => line.includes("● [P]") && line.includes("alpha-focus.ts") + (line) => line.includes("alpha-focus.ts") && line.includes("local · project · enabled") ); 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" + renderedLines.some((line) => line.includes("Focused detail text should stay")), + "expected selected extension summary in the details pane" ); assert.ok( renderedLines.some((line) => line.includes("Space toggle")), @@ -55,6 +60,31 @@ void test("/extensions keeps rows compact and moves selected details below the l } }); +void test("/extensions keeps narrow terminal lines within the requested width", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-narrow-")); + try { + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "very-long-extension-name.ts"), "// detail\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (_component, lines) => { + assert.ok(lines.every((line) => visibleWidth(line) <= 24)); + return { type: "cancel" }; + }, + { width: 24, height: 20 } + ); + + await showInteractive(ctx, pi); + } 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"); @@ -90,11 +120,11 @@ void test("/extensions groups local extensions and packages into sections", asyn await showInteractive(ctx, pi); assert.ok( - renderedLines.some((line) => line.includes("Local extensions (")), + renderedLines.some((line) => line.includes("Local extensions ·")), "expected local section header" ); assert.ok( - renderedLines.some((line) => line.includes("Installed packages (")), + renderedLines.some((line) => line.includes("Packages ·")), "expected package section header" ); } finally { @@ -103,6 +133,42 @@ void test("/extensions groups local extensions and packages into sections", asyn } }); +void test("/extensions arrow navigation wraps across local and package sections", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-wrap-")); + const extensionsRoot = join(cwd, ".pi", "extensions"); + const restoreCatalog = mockPackageCatalog({ + packages: [{ source: "npm:zeta@1.0.0", name: "zeta", version: "1.0.0", scope: "global" }], + }); + try { + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "alpha.ts"), "// local\n", "utf8"); + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + let wrappedLines: string[] = []; + let returnedLines: 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?.("\u001b[A"); + wrappedLines = component.render(120); + component.handleInput?.("\u001b[B"); + returnedLines = component.render(120); + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.ok(wrappedLines.some((line) => line.includes("›") && line.includes("zeta@1.0.0"))); + assert.ok(returnedLines.some((line) => line.includes("›") && line.includes("alpha.ts"))); + } 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({ @@ -145,7 +211,7 @@ void test("/extensions shows package sizes inline when known", async () => { } }); -void test("/extensions uses Enter for local actions instead of toggling state", async () => { +void test("/extensions uses the configured confirm binding for local actions", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-enter-")); const projectExtensionsRoot = join(cwd, ".pi", "extensions"); @@ -162,12 +228,13 @@ void test("/extensions uses Enter for local actions instead of toggling state", ctx.ui.theme, (lines) => lines.some((line) => line.includes("i install")), (component, _lines, completion) => { - component.handleInput?.("\r"); + component.handleInput?.("\u001b[13;5u"); return completion.then((value) => { rawAction = value; return { type: "cancel" }; }); - } + }, + { keybindings: { "tui.select.confirm": "ctrl+enter" } } ); await showInteractive(ctx, pi); @@ -296,8 +363,8 @@ void test("/extensions filters packages with the quick filter shortcuts", async 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("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(); @@ -335,7 +402,7 @@ void test("/extensions still toggles local items with Space", async () => { await showInteractive(ctx, pi); assert.ok( - afterSpace.some((line) => line.includes("○ [P]") && line.includes("alpha-space.ts")), + rendersLocalState(afterSpace, "alpha-space.ts", "disabled"), "expected Space to keep local toggling working" ); } finally { @@ -384,7 +451,7 @@ void test("/extensions keeps staged changes after viewing item details", async ( "expected details notification to be shown" ); assert.ok( - resumedLines.some((line) => line.includes("○ [P]") && line.includes("alpha-details.ts")), + rendersLocalState(resumedLines, "alpha-details.ts", "disabled"), "expected staged toggle to persist after viewing details" ); assert.ok( @@ -405,7 +472,7 @@ void test("/extensions keeps staged changes after backing out of the local actio 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"]; + const queuedSelections = ["Back", "Exit without saving"]; let managerCallCount = 0; let resumedLines: string[] = []; @@ -440,7 +507,7 @@ void test("/extensions keeps staged changes after backing out of the local actio "expected the local action menu to open" ); assert.ok( - resumedLines.some((line) => line.includes("○ [P]") && line.includes("alpha-menu.ts")), + rendersLocalState(resumedLines, "alpha-menu.ts", "disabled"), "expected staged toggle to persist after backing out of the action menu" ); assert.ok( @@ -501,11 +568,11 @@ void test("/extensions discards staged changes before resuming from help", async "expected discard to clear pending changes before the next manager render" ); assert.ok( - resumedLines.some((line) => line.includes("● [P]") && line.includes("alpha-discard.ts")), + rendersLocalState(resumedLines, "alpha-discard.ts", "enabled"), "expected discarded toggle to revert to the original enabled state" ); assert.ok( - !resumedLines.some((line) => line.includes("○ [P]") && line.includes("alpha-discard.ts")), + !rendersLocalState(resumedLines, "alpha-discard.ts", "disabled"), "expected no staged disabled state after discarding changes" ); } finally { @@ -513,6 +580,65 @@ void test("/extensions discards staged changes before resuming from help", async } }); +void test("/extensions bulk updates use one flow and summarize partial failures", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-unified-bulk-")); + const updated: string[] = []; + const restoreCatalog = mockPackageCatalog({ + packages: [ + { source: "npm:alpha", name: "alpha", scope: "global" }, + { source: "npm:beta", name: "beta", scope: "global" }, + ], + updates: [ + { source: "npm:alpha", displayName: "alpha", type: "npm", scope: "global" }, + { source: "npm:beta", displayName: "beta", type: "npm", scope: "global" }, + ], + updateImpl: (source) => { + if (source === "npm:beta") throw new Error("registry unavailable"); + if (source) updated.push(source); + }, + }); + try { + const { pi, ctx, notifications, confirmPrompts, reloadCount } = createMockHarness({ + cwd, + hasUI: true, + selectResult: "Update selected packages", + confirmImpl: (title) => title === "Bulk package operation", + }); + let managerCalls = 0; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = async (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (!lines.some((line) => line.includes("i install"))) return completion; + managerCalls += 1; + if (managerCalls === 1) { + component.handleInput?.("3"); + component.handleInput?.(" "); + component.handleInput?.("\u001b[B"); + component.handleInput?.(" "); + component.handleInput?.("B"); + return completion; + } + return { type: "cancel" }; + }); + + await showInteractive(ctx, pi); + + assert.deepEqual(updated, ["npm:alpha"]); + assert.equal(reloadCount(), 0); + assert.equal(confirmPrompts.filter((title) => title === "Reload Required").length, 1); + assert.ok( + notifications.some( + (entry) => + entry.message.includes("1 succeeded") && + entry.message.includes("1 failed") && + entry.message.includes("Reload required") + ) + ); + } finally { + restoreCatalog(); + 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"); @@ -558,10 +684,24 @@ void test("/extensions keeps staged changes when staying in the manager", async "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")), + rendersLocalState(resumedLines, "alpha-stay.ts", "disabled"), "expected staged toggle to persist after choosing to stay in the manager" ); } finally { await rm(cwd, { recursive: true, force: true }); } }); + +void test("manager hints use the active public selection bindings", async () => { + const { buildFooterShortcuts } = await import("../src/ui/footer.js"); + const hints = buildFooterShortcuts( + { selectedType: "package", pendingChanges: 0, selectedPackages: 2 }, + { + getKeys: (key: string) => (key === "tui.select.confirm" ? ["ctrl+enter"] : ["alt+left"]), + } as never + ); + + assert.match(hints, /Ctrl\+enter actions/); + assert.match(hints, /Alt\+left back/); + assert.match(hints, /B act on 2/); +}); diff --git a/test/update-command.test.ts b/test/update-command.test.ts new file mode 100644 index 0000000..c4e963a --- /dev/null +++ b/test/update-command.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { handleUpdateSubcommand } from "../src/commands/update.js"; +import { createMockHarness } from "./helpers/mocks.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; + +void test("update --preview reports updates without mutating packages", async () => { + let updates = 0; + const restore = mockPackageCatalog({ + packages: [{ source: "npm:demo", name: "demo", version: "1.0.0", scope: "global" }], + updates: [{ source: "npm:demo", displayName: "demo", type: "npm", scope: "global" }], + updateImpl: () => { + updates += 1; + }, + }); + try { + const { pi, ctx, notifications } = createMockHarness({ hasUI: true }); + await handleUpdateSubcommand(["--preview"], ctx, pi); + assert.equal(updates, 0); + assert.ok(notifications.some((entry) => entry.message.includes("demo@1.0.0"))); + } finally { + restore(); + } +}); diff --git a/test/update-preview.test.ts b/test/update-preview.test.ts new file mode 100644 index 0000000..a76d715 --- /dev/null +++ b/test/update-preview.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildUpdatePreview } from "../src/packages/update-preview.js"; + +void test("update preview marks only selected available package identities", () => { + const preview = buildUpdatePreview( + [ + { source: "npm:a", name: "a", scope: "global", version: "1.0.0" }, + { source: "npm:b", name: "b", scope: "project" }, + ], + [{ source: "npm:a", displayName: "a", type: "npm", scope: "global" }] + ); + assert.equal(preview[0]?.updateAvailable, true); + assert.equal(preview[0]?.metadataKnown, true); + assert.equal(preview[1]?.updateAvailable, false); + assert.equal(preview[1]?.metadataKnown, false); +}); diff --git a/test/views.test.ts b/test/views.test.ts new file mode 100644 index 0000000..dfaad40 --- /dev/null +++ b/test/views.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { normalizeViewsFile, readSavedViews, writeSavedViews } from "../src/utils/views.js"; + +void test("saved views normalize versioned view, favorite, and recent state", () => { + const result = normalizeViewsFile({ + version: 99, + views: [ + { name: " work ", filter: "packages", searchQuery: "demo", createdAt: 1, updatedAt: 2 }, + ], + favorites: [" demo ", 1], + recent: Array.from({ length: 30 }, (_, index) => `pkg-${index}`), + lastView: { + name: "last-used", + filter: "favorites", + searchQuery: "demo", + selectedItemId: "pkg:demo", + selectedItemIds: ["pkg:demo", "pkg:other"], + createdAt: 1, + updatedAt: 2, + }, + }); + assert.equal(result.version, 1); + assert.equal(result.views[0]?.name, "work"); + assert.deepEqual(result.favorites, ["demo"]); + assert.equal(result.recent.length, 20); + assert.equal(result.lastView?.selectedItemId, "pkg:demo"); + assert.deepEqual(result.lastView?.selectedItemIds, ["pkg:demo", "pkg:other"]); +}); + +void test("saved views use an atomic replacement write", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-views-")); + const path = join(dir, "views.json"); + try { + await Promise.all([ + writeSavedViews(path, { version: 1, views: [], favorites: ["first"], recent: [] }), + writeSavedViews(path, { version: 1, views: [], favorites: ["demo"], recent: [] }), + ]); + assert.deepEqual((await readSavedViews(path)).favorites, ["demo"]); + assert.equal((await readFile(path, "utf8")).endsWith("\n"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/workspace-navigation.test.ts b/test/workspace-navigation.test.ts new file mode 100644 index 0000000..1ef9a21 --- /dev/null +++ b/test/workspace-navigation.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { + buildWorkspaceNavigation, + matchWorkspaceNavigation, +} from "../src/ui/workspace/navigation.js"; + +initTheme(); + +const TAB = "\t"; +const SHIFT_TAB = "\u001b[Z"; + +void test("Tab and Shift+Tab cycle every workspace and wrap", () => { + assert.equal(matchWorkspaceNavigation(TAB, "installed"), "discover"); + assert.equal(matchWorkspaceNavigation(TAB, "discover"), "profiles"); + assert.equal(matchWorkspaceNavigation(TAB, "profiles"), "health"); + assert.equal(matchWorkspaceNavigation(TAB, "health"), "installed"); + + assert.equal(matchWorkspaceNavigation(SHIFT_TAB, "installed"), "health"); + assert.equal(matchWorkspaceNavigation(SHIFT_TAB, "health"), "profiles"); + assert.equal(matchWorkspaceNavigation(SHIFT_TAB, "profiles"), "discover"); + assert.equal(matchWorkspaceNavigation(SHIFT_TAB, "discover"), "installed"); +}); + +void test("workspace navigation accepts legacy and Kitty tab encodings", () => { + assert.equal(matchWorkspaceNavigation("\u001b[9u", "installed"), "discover"); + assert.equal(matchWorkspaceNavigation("\u001b[9;2u", "installed"), "health"); + assert.equal(matchWorkspaceNavigation("\u001b[9;1:2u", "discover"), "profiles"); + assert.equal(matchWorkspaceNavigation("\u001b[9;2:2u", "discover"), "installed"); +}); + +void test("workspace navigation ignores releases and unrelated input", () => { + assert.equal(matchWorkspaceNavigation("\u001b[9;1:3u", "installed"), undefined); + assert.equal(matchWorkspaceNavigation("\u001b[9;2:3u", "installed"), undefined); + assert.equal(matchWorkspaceNavigation("x", "installed"), undefined); + assert.equal(matchWorkspaceNavigation("[", "installed"), undefined); + assert.equal(matchWorkspaceNavigation("\u001b[P", "installed"), undefined); + assert.equal(matchWorkspaceNavigation("\u001b", "installed"), undefined); +}); + +void test("workspace navigation header highlights the active screen and explains cycling", () => { + const theme = { + fg: (color: string, text: string) => (color === "accent" ? `<${text}>` : text), + }; + const header = buildWorkspaceNavigation(theme, "profiles"); + assert.ok(header.includes("<[Profiles]>")); + assert.ok(header.includes("Installed")); + assert.ok(header.includes("Discover")); + assert.ok(header.includes("Health")); + assert.ok(header.includes("Shift+Tab ‹")); + assert.ok(header.includes("› Tab")); + + const plain = buildWorkspaceNavigation({ fg: (_c, text) => text }, "installed"); + assert.ok( + visibleWidth(plain) < 80, + `expected navigation to fit 80 columns, got ${visibleWidth(plain)}` + ); +}); diff --git a/test/workspace-routing.test.ts b/test/workspace-routing.test.ts new file mode 100644 index 0000000..053b53f --- /dev/null +++ b/test/workspace-routing.test.ts @@ -0,0 +1,338 @@ +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 "@earendil-works/pi-coding-agent"; +import { clearSearchCache, setSearchCache } from "../src/packages/discovery.js"; +import { browseRemotePackages } from "../src/ui/remote.js"; +import { showInteractive } from "../src/ui/unified.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; +import { createMockHarness } from "./helpers/mocks.js"; + +initTheme(); + +const TAB = "\t"; +const SHIFT_TAB = "\u001b[Z"; + +async function withCacheDir(prefix: string, run: () => Promise): Promise { + const cacheDir = await mkdtemp(join(tmpdir(), prefix)); + const previous = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + try { + return await run(); + } finally { + if (previous === undefined) delete process.env.PI_EXTMGR_CACHE_DIR; + else process.env.PI_EXTMGR_CACHE_DIR = previous; + await rm(cacheDir, { recursive: true, force: true }); + } +} + +void test("Shift+Tab routes Installed through Health to Profiles", async () => { + await withCacheDir("pi-extmgr-route-f3-", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-route-installed-")); + try { + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "route-demo.ts"), "// demo\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + (pi as { getCommands: () => unknown[] }).getCommands = () => []; + (pi as { getAllTools: () => unknown[] }).getAllTools = () => []; + const screens: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Loading"))) return completion; + if (lines.some((line) => line.includes("Save current package set"))) { + screens.push("profiles"); + component.handleInput?.("\u001b"); + return completion; + } + if (lines.some((line) => line.includes("command/tool entries loaded"))) { + screens.push("health"); + component.handleInput?.(SHIFT_TAB); + return completion; + } + screens.push("installed"); + if (screens.filter((screen) => screen === "installed").length === 1) { + component.handleInput?.(SHIFT_TAB); + return completion; + } + return { type: "cancel" }; + }); + + await showInteractive(ctx, pi); + + assert.deepEqual(screens, ["installed", "health", "profiles", "installed"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("Shift+Tab routes Installed to Health and back restores Installed", async () => { + await withCacheDir("pi-extmgr-route-f4-", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-route-health-")); + try { + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "route-health.ts"), "// demo\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + (pi as { getCommands: () => unknown[] }).getCommands = () => []; + (pi as { getAllTools: () => unknown[] }).getAllTools = () => []; + const screens: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Loading"))) return completion; + if (lines.some((line) => line.includes("command/tool entries loaded"))) { + screens.push("health"); + component.handleInput?.("\u001b"); + return completion; + } + screens.push("installed"); + if (screens.filter((screen) => screen === "installed").length === 1) { + component.handleInput?.(SHIFT_TAB); + return completion; + } + return { type: "cancel" }; + }); + + await showInteractive(ctx, pi); + + assert.deepEqual(screens, ["installed", "health", "installed"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("Tab routes Discover to Profiles and resumes browsing afterwards", async () => { + await withCacheDir("pi-extmgr-route-discover-", async () => { + setSearchCache({ + query: "route-demo", + results: [{ name: "route-pkg", version: "1.0.0", description: "Routing demo" }], + total: 1, + offset: 0, + timestamp: Date.now(), + }); + + const { pi, ctx } = createMockHarness({ hasUI: true }); + const screens: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Save current package set"))) { + screens.push("profiles"); + component.handleInput?.("\u001b"); + return completion; + } + screens.push("discover"); + if (screens.filter((screen) => screen === "discover").length === 1) { + component.handleInput?.(TAB); + return completion; + } + return { type: "cancel" }; + }); + + try { + await browseRemotePackages(ctx, "route-demo", pi); + assert.deepEqual(screens, ["discover", "profiles", "discover"]); + } finally { + clearSearchCache(); + } + }); +}); + +void test("Tab routes Profiles to Health without dropping back to the caller", async () => { + await withCacheDir("pi-extmgr-route-aux-", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-route-aux-cwd-")); + try { + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "route-aux.ts"), "// demo\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + (pi as { getCommands: () => unknown[] }).getCommands = () => []; + (pi as { getAllTools: () => unknown[] }).getAllTools = () => []; + const screens: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Loading"))) return completion; + if (lines.some((line) => line.includes("Save current package set"))) { + screens.push("profiles"); + component.handleInput?.(TAB); + return completion; + } + if (lines.some((line) => line.includes("command/tool entries loaded"))) { + screens.push("health"); + if (screens.filter((screen) => screen === "health").length === 1) { + component.handleInput?.(SHIFT_TAB); + } else { + component.handleInput?.("\u001b"); + } + return completion; + } + screens.push("installed"); + if (screens.filter((screen) => screen === "installed").length === 1) { + component.handleInput?.(SHIFT_TAB); + return completion; + } + return { type: "cancel" }; + }); + + await showInteractive(ctx, pi); + + assert.deepEqual(screens, ["installed", "health", "profiles", "health", "installed"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("Tab navigation works during search while ordinary characters stay typeable", async () => { + const { UnifiedManagerBrowser } = await import("../src/ui/installed/browser.js"); + const theme = { + fg: (_name: string, text: string) => text, + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + }; + const keybindings = { + matches: (data: string, binding: string): boolean => { + if (binding === "tui.select.confirm") return data === "\r"; + if (binding === "tui.select.cancel") return data === "\u001b"; + return false; + }, + getKeys: () => [], + }; + const actions: unknown[] = []; + const browser = new UnifiedManagerBrowser( + [ + { + type: "local", + id: "project:/tmp/demo.ts", + displayName: "demo.ts", + summary: "demo", + scope: "project", + state: "enabled", + activePath: "/tmp/demo.ts", + disabledPath: "/tmp/demo.ts.disabled", + originalState: "enabled", + }, + ], + new Map(), + theme as never, + keybindings as never, + "/tmp", + 10, + (action) => actions.push(action) + ); + + browser.handleManagerInput("/"); + browser.handleManagerInput("["); + browser.handleManagerInput("]"); + assert.equal(browser.getSearchQuery(), "[]", "ordinary characters must reach the search input"); + assert.deepEqual(actions, []); + + // Workspace navigation is global and remains predictable while search is focused. + browser.handleManagerInput(TAB); + browser.handleManagerInput(SHIFT_TAB); + assert.deepEqual(actions, [ + { type: "workspace", screen: "discover" }, + { type: "workspace", screen: "health" }, + ]); +}); + +void test("Tab wraps Health back to Installed", async () => { + await withCacheDir("pi-extmgr-route-cycle-health-", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-route-cycle-health-cwd-")); + try { + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "cycle-health.ts"), "// demo\n", "utf8"); + + const { pi, ctx } = createMockHarness({ cwd, hasUI: true }); + (pi as { getCommands: () => unknown[] }).getCommands = () => []; + (pi as { getAllTools: () => unknown[] }).getAllTools = () => []; + const screens: string[] = []; + + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + if (lines.some((line) => line.includes("Loading"))) return completion; + if (lines.some((line) => line.includes("command/tool entries loaded"))) { + screens.push("health"); + component.handleInput?.(TAB); + return completion; + } + screens.push("installed"); + if (screens.filter((screen) => screen === "installed").length === 1) { + component.handleInput?.(SHIFT_TAB); + return completion; + } + return { type: "cancel" }; + }); + + await showInteractive(ctx, pi); + + assert.deepEqual(screens, ["installed", "health", "installed"]); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +void test("Tab navigation from Installed preserves the staged-change guard", async () => { + await withCacheDir("pi-extmgr-route-guard-", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-route-guard-cwd-")); + try { + const extensionsRoot = join(cwd, ".pi", "extensions"); + await mkdir(extensionsRoot, { recursive: true }); + await writeFile(join(extensionsRoot, "guard-demo.ts"), "// demo\n", "utf8"); + + const { pi, ctx, selectPrompts } = createMockHarness({ cwd, hasUI: true }); + const queuedSelections = ["Stay in manager", "Exit without saving"]; + ( + ctx.ui as { select: (title: string, options?: string[]) => Promise } + ).select = (title) => { + selectPrompts.push(title); + return Promise.resolve(queuedSelections.shift()); + }; + + let managerCalls = 0; + let resumedLines: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (lines) => lines.some((line) => line.includes("/ Search")), + (component, lines, completion) => { + managerCalls += 1; + if (managerCalls === 1) { + component.handleInput?.(" "); // stage a toggle + component.handleInput?.(TAB); // try to leave for Discover + return completion; + } + resumedLines = lines; + return { type: "cancel" }; + } + ); + + await showInteractive(ctx, pi); + + assert.equal( + selectPrompts.filter((title) => title === "Unsaved changes (1)").length, + 2, + "Tab with staged changes must prompt, and staying must keep the pending change" + ); + assert.ok( + resumedLines.some((line) => line.includes("guard-demo.ts")), + "manager should resume with the staged item visible after choosing to stay" + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/test/workspace-ui.test.ts b/test/workspace-ui.test.ts new file mode 100644 index 0000000..605e97e --- /dev/null +++ b/test/workspace-ui.test.ts @@ -0,0 +1,158 @@ +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 { initTheme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { showHealth } from "../src/ui/health.js"; +import { showProfiles } from "../src/ui/profiles.js"; +import { saveNamedProfile, getProfileStorePath } from "../src/profiles/store.js"; +import { mockPackageCatalog } from "./helpers/package-catalog.js"; +import { captureCustomComponent } from "./helpers/custom-component.js"; +import { createMockHarness } from "./helpers/mocks.js"; + +initTheme(); + +void test("profiles screen keeps profile management in a dedicated workspace", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-profiles-ui-")); + const previousCacheDir = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + + try { + const { pi, ctx } = createMockHarness({ hasUI: true }); + let renderedLines: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + renderedLines = lines; + component.handleInput?.("\u001b"); + return completion; + }); + + await showProfiles(ctx, pi); + + assert.ok(renderedLines.some((line) => line.includes("Profiles"))); + assert.ok(renderedLines.some((line) => line.includes("Save current package set"))); + } 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("profiles screen renders an inline current-versus-target diff", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-profile-diff-ui-")); + const previousCacheDir = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + const restoreCatalog = mockPackageCatalog({ packages: [] }); + + try { + await saveNamedProfile(getProfileStorePath(), { + schemaVersion: 1, + name: "target", + packages: [{ source: "npm:demo", scope: "global", version: "1.0.0" }], + }); + const { pi, ctx } = createMockHarness({ hasUI: true }); + ( + ctx.ui as unknown as { + select: (title: string, options?: string[]) => Promise; + } + ).select = (title) => + Promise.resolve(title.startsWith("Profile:") ? "Review and apply" : undefined); + let customCalls = 0; + let diffLines: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => { + customCalls += 1; + if (customCalls === 1) { + return captureCustomComponent(factory, ctx.ui.theme, (component, _lines, completion) => { + component.handleInput?.("\r"); + return completion; + }); + } + if (customCalls === 2) { + return captureCustomComponent(factory, ctx.ui.theme, (component, lines, completion) => { + diffLines = lines; + component.handleInput?.("\u001b"); + return completion; + }); + } + return captureCustomComponent(factory, ctx.ui.theme, (component, _lines, completion) => { + component.handleInput?.("\u001b"); + return completion; + }); + }; + + await showProfiles(ctx, pi); + + assert.ok(diffLines.some((line) => line.includes("Current"))); + assert.ok(diffLines.some((line) => line.includes("Target · target"))); + assert.ok(diffLines.some((line) => line.includes("+ npm:demo"))); + } finally { + restoreCatalog(); + 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("profiles screen keeps every line within narrow terminal widths", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "pi-extmgr-profiles-narrow-")); + const previousCacheDir = process.env.PI_EXTMGR_CACHE_DIR; + process.env.PI_EXTMGR_CACHE_DIR = cacheDir; + + try { + await saveNamedProfile(getProfileStorePath(), { + schemaVersion: 1, + name: "a-profile-with-a-particularly-long-name", + packages: [{ source: "npm:very-long-package-name-for-narrow-testing", scope: "global" }], + }); + const { pi, ctx } = createMockHarness({ hasUI: true }); + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (component, lines, completion) => { + assert.ok( + lines.every((line) => visibleWidth(line) <= 30), + "profiles lines must stay within a 30-column terminal" + ); + component.handleInput?.("\u001b"); + return completion; + }, + { width: 30, height: 40 } + ); + + await showProfiles(ctx, pi); + } 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("health screen surfaces runtime, compatibility, reload, and trash sections", async () => { + const { pi, ctx } = createMockHarness({ hasUI: true }); + (pi as { getCommands: () => unknown[] }).getCommands = () => []; + (pi as { getAllTools: () => unknown[] }).getAllTools = () => []; + let renderedLines: string[] = []; + (ctx.ui as { custom: (factory: unknown) => Promise }).custom = (factory) => + captureCustomComponent( + factory, + ctx.ui.theme, + (component, lines, completion) => { + renderedLines = lines; + assert.ok(lines.every((line) => visibleWidth(line) <= 120)); + component.handleInput?.("\u001b"); + return completion; + }, + { width: 120, height: 50 } + ); + + await showHealth(ctx, pi); + + assert.ok(renderedLines.some((line) => line.includes("Health"))); + assert.ok(renderedLines.some((line) => line.includes("Runtime"))); + assert.ok(renderedLines.some((line) => line.includes("Compatibility"))); + assert.ok(renderedLines.some((line) => line.includes("Reload"))); + assert.ok(renderedLines.some((line) => line.includes("Trash"))); +});