From dfb5f1f792f899b67cafeb7c5c53fb45efb79e73 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:04:26 +0100 Subject: [PATCH 01/68] fix(remote): harden npm browse rate limits --- CHANGELOG.md | 2 + README.md | 2 +- src/constants.ts | 3 - src/packages/discovery.ts | 232 +++++++++++++++++------------ src/types/index.ts | 2 + src/ui/remote.ts | 214 +++++---------------------- src/utils/cache.ts | 51 +++++-- test/cache-history.test.ts | 2 + test/metadata-cache.test.ts | 24 +-- test/npm-search.test.ts | 81 +++++++--- test/remote-ui.test.ts | 288 +++++++++++++++++++++++------------- 11 files changed, 489 insertions(+), 412 deletions(-) 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..a73280c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ version manager, or install project-local with `pi install npm:pi-extmgr -l`. - Install, update, remove from UI and command line - Quick actions (`A`, `u`, `X`) and bulk update (`U`) - **Remote discovery and install** - - npm search/browse with pagination, 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 - 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 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/packages/discovery.ts b/src/packages/discovery.ts index c17eea8..1a7c331 100644 --- a/src/packages/discovery.ts +++ b/src/packages/discovery.ts @@ -9,9 +9,18 @@ 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 { type InstalledPackage, type NpmPackage, type SearchCache } from "../types/index.js"; import { parseNpmSource } from "../utils/format.js"; +import { + getCachedPackage, + getCachedPackageSize, + getCachedSearch, + getPackageDescriptions, + setCachedPackage, + setCachedPackageSize, + setCachedSearch, +} from "../utils/cache.js"; import { readSummary } from "../utils/fs.js"; import { fetchWithTimeout } from "../utils/network.js"; import { execNpm } from "../utils/npm-exec.js"; @@ -19,7 +28,9 @@ import { normalizePackageIdentity } from "../utils/package-source.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,7 +55,8 @@ interface NpmSearchResponse { objects?: NpmSearchResultObject[]; } -let searchCache: SearchCache | null = null; +const searchCacheByPage = new Map(); +let latestSearchCacheKey: string | undefined; function createAbortError(): Error { const error = new Error("Operation cancelled"); @@ -58,34 +70,43 @@ function throwIfAborted(signal?: AbortSignal): void { } } -export function getSearchCache(): SearchCache | null { - return searchCache; +function getSearchCacheKey(query: string, offset: number): string { + return `${offset}\0${query}`; } -export function setSearchCache(cache: SearchCache | null): void { - searchCache = cache; +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 clearSearchCache(): void { - searchCache = null; +export function setSearchCache(cache: SearchCache): void { + const key = getSearchCacheKey(cache.query, cache.offset); + searchCacheByPage.set(key, cache); + latestSearchCacheKey = key; } -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 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; + } } -// Import persistent cache -import { - getCachedPackage, - getCachedPackageSize, - getCachedSearch, - getPackageDescriptions, - setCachedPackage, - setCachedPackageSize, - setCachedSearch, -} from "../utils/cache.js"; +export function isCacheValid(query: string, offset = 0): boolean { + const cache = getSearchCache(query, offset); + return cache ? Date.now() - cache.timestamp < CACHE_LIMITS.searchTTL : false; +} function getNpmPackageAuthor( pkg: NonNullable @@ -95,15 +116,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 +150,120 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined { }; } -async function fetchNpmSearchPage( - query: string, - from: number, - signal?: AbortSignal -): Promise<{ - total: number; - resultCount: number; - packages: NpmPackage[]; -}> { - const params = new URLSearchParams({ - text: query, - size: String(NPM_SEARCH_PAGE_SIZE), - from: String(from), - }); - 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}`); +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); + } } - const data = (await response.json()) as NpmSearchResponse; - const objects = data.objects ?? []; - const packages = objects.map(toNpmPackage).filter((pkg): pkg is NpmPackage => !!pkg); + return NPM_SEARCH_RETRY_DELAY_MS * 2 ** (retryNumber - 1); +} - return { - total: - typeof data.total === "number" && Number.isFinite(data.total) ? data.total : packages.length, - resultCount: objects.length, - packages, - }; +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 fetchNpmRegistrySearchResults( +export async function fetchNpmRegistrySearchPage( 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; + 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(pageSize), + from: String(offset), + }); + const url = `${NPM_SEARCH_API}?${params.toString()}`; - if (page.resultCount === 0) { + 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; } - for (const pkg of page.packages) { - if (!packagesByName.has(pkg.name)) { - packagesByName.set(pkg.name, pkg); - } - } + await response.body?.cancel(); + await waitForRetry(getRetryDelayMs(response, attempt + 1), signal); + } - from += page.resultCount; + 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"}`); } - return [...packagesByName.values()]; + const data = (await response.json()) as NpmSearchResponse; + const objects = data.objects ?? []; + 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 { + query, + results, + total, + offset, + timestamp: Date.now(), + }; } 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 getCachedSearch(query, offset); + if (persisted) { + setSearchCache(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( diff --git a/src/types/index.ts b/src/types/index.ts index bbfb462..7d58b29 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,6 +84,8 @@ export type UnifiedItem = LocalUnifiedItem | PackageUnifiedItem; export interface SearchCache { query: string; results: NpmPackage[]; + total: number; + offset: number; timestamp: number; } diff --git a/src/ui/remote.ts b/src/ui/remote.ts index 122203b..d3a9b3d 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -10,7 +10,6 @@ import { } from "@earendil-works/pi-coding-agent"; import { Container, - fuzzyMatch, type Focusable, Input, Key, @@ -26,14 +25,13 @@ import { getSearchCache, 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 { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js"; import { formatBytes, normalizePackageSource, parseNpmSource, truncate } from "../utils/format.js"; import { requireCustomUI, runCustomUI } from "../utils/mode.js"; @@ -118,30 +116,6 @@ const packageInfoCache = new PackageInfoCache( export function clearRemotePackageInfoCache(): void { packageInfoCache.clear(); - clearCommunityBrowseCache(); -} - -function getCommunityBrowseCache(): SearchCache | null { - const cache = getSearchCache(); - if (!cache || cache.query !== COMMUNITY_BROWSE_QUERY) { - return null; - } - - return isCacheValid(COMMUNITY_BROWSE_QUERY) ? cache : null; -} - -function setCommunityBrowseCache(results: NpmPackage[]): void { - setSearchCache({ - query: COMMUNITY_BROWSE_QUERY, - results, - timestamp: Date.now(), - }); -} - -function clearCommunityBrowseCache(): void { - if (getSearchCache()?.query === COMMUNITY_BROWSE_QUERY) { - clearSearchCache(); - } } const REMOTE_MENU_CHOICES = { @@ -255,7 +229,7 @@ function createCommunityBrowsePlan( return { kind: "search", rawQuery: trimmed, - searchQuery: COMMUNITY_BROWSE_QUERY, + searchQuery: `${COMMUNITY_BROWSE_QUERY} ${trimmed}`, displayQuery: trimmed, title: "Community packages", }; @@ -270,94 +244,6 @@ function resolveRemoteBrowseSource(query: string, source?: RemoteBrowseSource): 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[] @@ -690,7 +576,7 @@ class RemotePackageBrowser implements Focusable { this.theme.fg( "muted", this.browseSource === "community" - ? " Browse community packages · / search to filter loaded packages" + ? " Browse community packages · / search to search community packages" : " Browse remote search results · / search to search npm packages" ) ); @@ -871,7 +757,8 @@ export async function browseRemotePackages( query: string, pi: ExtensionAPI, offset = 0, - source?: RemoteBrowseSource + source?: RemoteBrowseSource, + forceRefresh = false ): Promise { if ( !requireCustomUI( @@ -893,62 +780,45 @@ export async function browseRemotePackages( return; } - 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 (!results) { - notify(ctx, "Remote package search was cancelled.", "info"); + if (!searchPage) { + try { + searchPage = 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(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; } + } - 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; } - const totalResults = allPackages.length; - const packages = allPackages.slice(offset, offset + PAGE_SIZE); + const packages = filterRemoteBrowseResults(plan, searchPage.results); + const totalResults = + plan.kind === "search" && plan.exactPackageName ? packages.length : searchPage.total; const reloadQuery = browseSource === "community" ? plan.displayQuery || COMMUNITY_BROWSE_QUERY : plan.rawQuery; @@ -965,7 +835,7 @@ export async function browseRemotePackages( return; } - const showLoadMore = totalResults >= PAGE_SIZE && offset + PAGE_SIZE < totalResults; + const showLoadMore = offset + searchPage.results.length < totalResults; const showPrevious = offset > 0; const result = await selectBrowseAction( @@ -997,12 +867,8 @@ export async function browseRemotePackages( await browseRemotePackages(ctx, reloadQuery, pi, offset + PAGE_SIZE, browseSource); return; case "refresh": - if (browseSource === "community") { - clearCommunityBrowseCache(); - } else { - clearSearchCache(); - } - await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource); + clearSearchCache(plan.searchQuery); + await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource, true); return; case "search": { const nextQuery = result.query.trim(); diff --git a/src/utils/cache.ts b/src/utils/cache.ts index fce715d..f673305 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -5,14 +5,14 @@ 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"; +const CURRENT_SEARCH_CACHE_STRATEGY = "npm-registry-v1-page"; const CACHED_PACKAGE_FIELDS = [ "description", "version", @@ -43,6 +43,8 @@ interface CacheData { | { query: string; results: string[]; + total: number; + offset: number; timestamp: number; strategy: string; } @@ -157,6 +159,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 +178,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(), }; } @@ -264,7 +273,14 @@ async function saveCache(): Promise { 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, @@ -433,10 +449,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 +464,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 +513,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/test/cache-history.test.ts b/test/cache-history.test.ts index 8ba8039..341e8c4 100644 --- a/test/cache-history.test.ts +++ b/test/cache-history.test.ts @@ -19,6 +19,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(), }); diff --git a/test/metadata-cache.test.ts b/test/metadata-cache.test.ts index 174ca9f..d157ebe 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", diff --git a/test/npm-search.test.ts b/test/npm-search.test.ts index 1a47bde..7ae69ab 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 } 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,34 @@ void test("fetchNpmRegistrySearchResults paginates npm registry results beyond 2 }) as typeof fetch; try { - const results = await fetchNpmRegistrySearchResults("keywords:pi-package"); - - 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")); + 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("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 +116,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 +134,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/remote-ui.test.ts b/test/remote-ui.test.ts index f8c90e7..8b7aa34 100644 --- a/test/remote-ui.test.ts +++ b/test/remote-ui.test.ts @@ -6,13 +6,19 @@ import { browseRemotePackages } from "../src/ui/remote.js"; import { captureCustomComponent } from "./helpers/custom-component.js"; import { createMockHarness } from "./helpers/mocks.js"; import { mockPackageCatalog } from "./helpers/package-catalog.js"; +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("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; @@ -72,17 +78,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[] = []; @@ -109,15 +111,13 @@ void test("browseRemotePackages shows inline search affordances in the browse UI }); 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 +156,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 +182,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 +211,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; @@ -290,25 +280,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 +326,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, 1); + 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; @@ -388,11 +480,7 @@ void test("clearMetadataCacheCommand clears the community browse runtime cache", await browseRemotePackages(ctx, "keywords:pi-package", pi); assert.equal(fetchCalls, 1); - 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); @@ -484,17 +572,13 @@ 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(), - }); + setSearchPage("demo", [ + { + name: "demo-pkg", + version: "1.0.0", + description: "Demo package", + }, + ]); const { pi, ctx, notifications, selectPrompts } = createMockHarness({ hasUI: true }); const customResults: unknown[] = [ From 242e0689e2e50b947e76008e7d7797bfe5889ac1 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:16:08 +0100 Subject: [PATCH 02/68] perf(packages): cache package entrypoint discovery --- src/packages/extensions.ts | 32 +++++++++++++++++++++++++++++++- test/package-extensions.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index 1622d3e..53cc95f 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -40,6 +40,7 @@ export interface PackageManifest { const execFileAsync = promisify(execFile); let globalNpmRootCache: { key: string; root: string | null } | undefined; +const packageEntrypointCache = new Map>(); function normalizeSource(source: string): string { return source @@ -532,7 +533,7 @@ async function resolveConventionExtensionEntrypoints(packageRoot: string): Promi return collectExtensionFilesFromDir(packageRoot, extensionsDir); } -export async function discoverPackageExtensionEntrypoints( +async function discoverPackageExtensionEntrypointsUncached( packageRoot: string, options?: { allowConventionDirectory?: boolean; @@ -569,6 +570,35 @@ 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 diff --git a/test/package-extensions.test.ts b/test/package-extensions.test.ts index 8498f67..9fb989c 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"); From 30cede4f78d9fc0b00903baaaaf19e11783935ea Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:19:42 +0100 Subject: [PATCH 03/68] fix(network): enforce complete request timeouts --- src/utils/network.ts | 53 +++++++++++++++++++++++++++++++++++++++----- test/network.test.ts | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 test/network.test.ts diff --git a/src/utils/network.ts b/src/utils/network.ts index 1e215a3..602b848 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -4,16 +4,57 @@ export async function fetchWithTimeout( signal?: AbortSignal ): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + let timedOut = false; + let rejectTimeout!: (error: Error) => void; + const timeoutPromise = new Promise((_, reject) => { + rejectTimeout = reject; + }); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + rejectTimeout(new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`)); + }, timeoutMs); + const cancellationPromise = signal + ? new Promise((_, reject) => { + if (signal.aborted) { + reject(new DOMException("The operation was aborted", "AbortError")); + return; + } + signal.addEventListener( + "abort", + () => reject(new DOMException("The operation was aborted", "AbortError")), + { once: true } + ); + }) + : undefined; try { - return await fetch(url, { signal: combinedSignal }); + const operation = (async (): Promise => { + const response = await fetch(url, { signal: combinedSignal }); + + // `fetch()` resolves after headers arrive. Buffering the body here keeps + // the timeout active for the complete request, including slow/stalled bodies. + // extmgr's callers consume finite JSON/text/archive responses rather than + // streaming them, so returning an equivalent buffered Response is safe. + const body = await response.arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + })(); + operation.catch(() => undefined); + return await Promise.race([ + operation, + timeoutPromise, + ...(cancellationPromise ? [cancellationPromise] : []), + ]); } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - if (signal?.aborted) { - throw error; - } + if (error instanceof Error && error.name === "AbortError" && signal?.aborted) { + throw error; + } + if (timedOut) { throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); } throw error; diff --git a/test/network.test.ts b/test/network.test.ts new file mode 100644 index 0000000..3107aa5 --- /dev/null +++ b/test/network.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fetchWithTimeout } from "../src/utils/network.js"; + +void test("fetchWithTimeout enforces the timeout while reading the response body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + }, + }); + 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 preserves caller cancellation while reading the body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + }, + }); + 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; + } +}); From f2f5c637edc4bca011b15b7e7cc98d5cdf1e39cd Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:20:30 +0100 Subject: [PATCH 04/68] perf(cache): harden cache lifecycle --- src/utils/cache.ts | 17 +++++++++++++++++ test/metadata-cache.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/utils/cache.ts b/src/utils/cache.ts index f673305..c2cab39 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -264,9 +264,26 @@ 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: { diff --git a/test/metadata-cache.test.ts b/test/metadata-cache.test.ts index d157ebe..5e5bea0 100644 --- a/test/metadata-cache.test.ts +++ b/test/metadata-cache.test.ts @@ -56,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; From cd815051574f95da46f550ab92aecd0b638d0104 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:21:15 +0100 Subject: [PATCH 05/68] test(manager): cover package state and async races --- test/package-extensions.test.ts | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/package-extensions.test.ts b/test/package-extensions.test.ts index 9fb989c..1e77144 100644 --- a/test/package-extensions.test.ts +++ b/test/package-extensions.test.ts @@ -660,6 +660,47 @@ 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 non-marker extension tokens", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-extmgr-cwd-")); const agentDir = await mkdtemp(join(tmpdir(), "pi-extmgr-agent-")); From ba790f29c9d739fc54e2c1a6243f18f409c0d0b4 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:22:19 +0100 Subject: [PATCH 06/68] refactor(remote): replace recursive browser navigation --- src/ui/remote.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/ui/remote.ts b/src/ui/remote.ts index d3a9b3d..fa07ad5 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -752,6 +752,18 @@ 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, @@ -760,6 +772,40 @@ export async function browseRemotePackages( source?: RemoteBrowseSource, forceRefresh = false ): Promise { + const request: BrowseRequest = { + ctx, + query, + pi, + offset, + forceRefresh, + ...(source ? { source } : {}), + }; + if (browseNavigationActive) { + browseNavigationQueue.push(request); + return; + } + + browseNavigationActive = true; + try { + let next: BrowseRequest | undefined = request; + while (next) { + await browseRemotePackagesPage(next); + next = browseNavigationQueue.shift(); + } + } finally { + browseNavigationQueue.length = 0; + browseNavigationActive = false; + } +} + +async function browseRemotePackagesPage({ + ctx, + query, + pi, + offset = 0, + source, + forceRefresh = false, +}: BrowseRequest): Promise { if ( !requireCustomUI( ctx, From cf9a48a0bb7f6bd1430d923e71113bee97643790 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:23:47 +0100 Subject: [PATCH 07/68] feat(manager): coordinate mutations and reload state --- src/packages/install.ts | 8 +++++++- src/packages/management.ts | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/packages/install.ts b/src/packages/install.ts index 6443f81..3335929 100644 --- a/src/packages/install.ts +++ b/src/packages/install.ts @@ -25,7 +25,11 @@ 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"; @@ -217,6 +221,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 })]); @@ -540,6 +545,7 @@ async function installPackageLocallyInternal( } clearSearchCache(); + clearPackageEntrypointCache(); logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true); success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}`); diff --git a/src/packages/management.ts b/src/packages/management.ts index 10c9de6..d8e70ca 100644 --- a/src/packages/management.ts +++ b/src/packages/management.ts @@ -30,6 +30,7 @@ import { getInstalledPackages, getInstalledPackagesAllScopes, } from "./discovery.js"; +import { clearPackageEntrypointCache } from "./extensions.js"; export interface PackageMutationOutcome { reloaded: boolean; @@ -388,6 +389,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 } => From a96fad8b7162f633c1e39af1d7a4b81cc915e7d1 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:26:07 +0100 Subject: [PATCH 08/68] fix(ui): stabilize loading, focus, and terminal rendering --- src/ui/unified.ts | 49 +++++++++++++++++++++++++++++++---------- test/unified-ui.test.ts | 26 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 72710eb..199bab8 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -52,8 +52,8 @@ import { formatBytes, formatEntry as formatExtEntry } from "../utils/format.js"; import { logExtensionDelete, logExtensionToggle } from "../utils/history.js"; import { hasCustomUI, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; -import { normalizePathIdentity } from "../utils/path-identity.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; +import { normalizePathIdentity } from "../utils/path-identity.js"; import { updateExtmgrStatus } from "../utils/status.js"; import { confirmReload, formatListOutput } from "../utils/ui-helpers.js"; import { runTaskWithLoader } from "./async-task.js"; @@ -966,22 +966,31 @@ class UnifiedManagerBrowser implements Focusable { } 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(width)); + lines.push(...this.searchInput.render(safeWidth)); lines.push(""); } else if (searchQuery) { - lines.push(truncateToWidth(this.theme.fg("accent", ` Search: ${searchQuery}`), width, "")); + lines.push( + truncateToWidth(this.theme.fg("accent", ` Search: ${searchQuery}`), safeWidth, "") + ); lines.push(""); } - lines.push(truncateToWidth(this.buildFilterLine(), width, "")); + lines.push(truncateToWidth(this.buildFilterLine(), safeWidth, "")); lines.push(""); if (this.filteredItems.length === 0) { - lines.push(this.theme.fg("warning", " No matching extensions or packages")); + lines.push( + truncateToWidth( + this.theme.fg("warning", " No matching extensions or packages"), + safeWidth, + "" + ) + ); return lines; } @@ -993,9 +1002,15 @@ class UnifiedManagerBrowser implements Focusable { const visiblePackageItems = visibleItems.filter((item) => item.type === "package"); if (visibleLocalItems.length > 0) { - lines.push(this.theme.fg("accent", ` Local extensions (${localCount})`)); + lines.push( + truncateToWidth( + this.theme.fg("accent", ` Local extensions (${localCount})`), + safeWidth, + "" + ) + ); for (const item of visibleLocalItems) { - lines.push(this.renderItemLine(item, width)); + lines.push(this.renderItemLine(item, safeWidth)); } if (visiblePackageItems.length > 0) { lines.push(""); @@ -1003,9 +1018,15 @@ class UnifiedManagerBrowser implements Focusable { } if (visiblePackageItems.length > 0) { - lines.push(this.theme.fg("accent", ` Installed packages (${packageCount})`)); + lines.push( + truncateToWidth( + this.theme.fg("accent", ` Installed packages (${packageCount})`), + safeWidth, + "" + ) + ); for (const item of visiblePackageItems) { - lines.push(this.renderItemLine(item, width)); + lines.push(this.renderItemLine(item, safeWidth)); } } @@ -1014,7 +1035,11 @@ class UnifiedManagerBrowser implements Focusable { lines.push( this.theme.fg( "dim", - ` Showing ${startIndex + 1}-${endIndex} of ${this.filteredItems.length}` + truncateToWidth( + ` Showing ${startIndex + 1}-${endIndex} of ${this.filteredItems.length}`, + safeWidth, + "" + ) ) ); } @@ -1029,8 +1054,8 @@ class UnifiedManagerBrowser implements Focusable { selectedItem.type === "local" && selectedState !== selectedItem.originalState, this.cwd ); - for (const line of wrapTextWithAnsi(detailText, width - 4)) { - lines.push(this.theme.fg("dim", ` ${line}`)); + for (const line of wrapTextWithAnsi(detailText, Math.max(1, safeWidth - 4))) { + lines.push(truncateToWidth(this.theme.fg("dim", ` ${line}`), safeWidth, "")); } } diff --git a/test/unified-ui.test.ts b/test/unified-ui.test.ts index e2fe90c..ee64f1f 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"; @@ -55,6 +56,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"); From 17aed0278daca5cd4cedf85b1c0a81a541d57ab1 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:27:25 +0100 Subject: [PATCH 09/68] feat(manager): expand package rows --- src/types/index.ts | 1 + src/ui/footer.ts | 3 +++ src/ui/unified.ts | 29 ++++++++++++++++++++++++++--- test/unified-items.test.ts | 1 + 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 7d58b29..bb2bfe0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -77,6 +77,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; diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 0218063..76b250d 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -5,6 +5,7 @@ import { type State, type UnifiedItem } from "../types/index.js"; export interface FooterState { selectedType?: UnifiedItem["type"]; + expandable: boolean; pendingChanges: number; } @@ -15,6 +16,7 @@ export function buildFooterState( ): FooterState { const state: FooterState = { pendingChanges: getPendingToggleChangeCount(staged, byId), + expandable: selectedItem?.type === "package" && Boolean(selectedItem.extensionPaths?.length), }; if (selectedItem) { @@ -56,6 +58,7 @@ export function buildFooterShortcuts(state: FooterState): string { } if (state.selectedType === "package") { + if (state.expandable) parts.push("E expand"); parts.push("Enter/A actions"); parts.push("V details"); parts.push("c configure"); diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 199bab8..9b18ef9 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -275,6 +275,13 @@ export function buildUnifiedItems( const items: UnifiedItem[] = []; const localPaths = new Set(); 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) { @@ -310,9 +317,9 @@ export function buildUnifiedItems( } if (isDuplicate) continue; - const extensionSummary = packageExtensionSummaries.get( - getPackageExtensionSummaryKey(pkg.scope, pkg.source) - ); + const packageKey = getPackageExtensionSummaryKey(pkg.scope, pkg.source); + const extensionSummary = packageExtensionSummaries.get(packageKey); + const extensionPaths = packageExtensionPaths.get(packageKey); items.push({ type: "package", @@ -326,6 +333,7 @@ export function buildUnifiedItems( size: pkg.size, updateAvailable: knownUpdates.has(normalizePackageIdentity(pkg.source)), ...(extensionSummary ? { extensionSummary } : {}), + ...(extensionPaths?.length ? { extensionPaths: [...extensionPaths] } : {}), }); } @@ -721,6 +729,7 @@ class UnifiedManagerBrowser implements Focusable { private selectedIndex = 0; private filter: UnifiedFilter = "all"; private searchActive = false; + private readonly expandedPackageIds = new Set(); private _focused = false; constructor( @@ -923,6 +932,13 @@ class UnifiedManagerBrowser implements Focusable { } if (selectedId && selectedItem?.type === "package") { + if (data === "e" || data === "E") { + if (selectedItem.extensionPaths?.length) { + if (this.expandedPackageIds.has(selectedId)) this.expandedPackageIds.delete(selectedId); + else this.expandedPackageIds.add(selectedId); + } + return true; + } if (data === "u") { this.onAction({ type: "action", itemId: selectedId, action: "update" }); return true; @@ -1027,6 +1043,13 @@ class UnifiedManagerBrowser implements Focusable { ); for (const item of visiblePackageItems) { lines.push(this.renderItemLine(item, safeWidth)); + if (this.expandedPackageIds.has(item.id) && item.extensionPaths?.length) { + for (const extensionPath of item.extensionPaths) { + lines.push( + truncateToWidth(this.theme.fg("dim", ` ↳ ${extensionPath}`), safeWidth, "") + ); + } + } } } diff --git a/test/unified-items.test.ts b/test/unified-items.test.ts index 0da38ce..5d5ec0a 100644 --- a/test/unified-items.test.ts +++ b/test/unified-items.test.ts @@ -94,6 +94,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", () => { From 99edab8559afba30d21949ad8aae53c5a024cbc0 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:28:55 +0100 Subject: [PATCH 10/68] feat(manager): add bulk selection and package toggles --- src/types/index.ts | 1 + src/ui/footer.ts | 1 + src/ui/unified.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/types/index.ts b/src/types/index.ts index bb2bfe0..a34624a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,6 +97,7 @@ export type UnifiedAction = | { type: "remote" } | { type: "help" } | { type: "menu" } + | { type: "bulk"; itemIds: string[]; action: "update" | "remove" } | { type: "quick"; action: "install" | "search" | "update-all" | "auto-update" } | { type: "action"; diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 76b250d..292d3b1 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -59,6 +59,7 @@ export function buildFooterShortcuts(state: FooterState): string { if (state.selectedType === "package") { if (state.expandable) parts.push("E expand"); + parts.push("Space select · B update selected"); parts.push("Enter/A actions"); parts.push("V details"); parts.push("c configure"); diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 9b18ef9..f5e48bb 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -730,6 +730,7 @@ class UnifiedManagerBrowser implements Focusable { private filter: UnifiedFilter = "all"; private searchActive = false; private readonly expandedPackageIds = new Set(); + private readonly bulkSelectedIds = new Set(); private _focused = false; constructor( @@ -876,6 +877,21 @@ class UnifiedManagerBrowser implements Focusable { 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: "update", + }); + return true; + } + if (matchesKey(data, Key.ctrl("s")) || data === "s" || data === "S") { this.onAction({ type: "apply" }); return true; @@ -1102,9 +1118,15 @@ class UnifiedManagerBrowser implements Focusable { private renderItemLine(item: UnifiedItem, width: number): string { const state = getCurrentUnifiedItemState(item, this.staged); const changed = item.type === "local" && state !== item.originalState; + const selectionMarker = + item.type === "package" && this.bulkSelectedIds.has(item.id) + ? this.theme.fg("accent", "[x] ") + : item.type === "package" + ? "[ ] " + : ""; const prefix = this.getSelectedItem()?.id === item.id ? this.theme.fg("accent", "→ ") : " "; return truncateToWidth( - prefix + formatUnifiedItemLabel(item, state, this.theme, changed), + prefix + selectionMarker + formatUnifiedItemLabel(item, state, this.theme, changed), width ); } @@ -1432,6 +1454,28 @@ async function handleUnifiedAction( return true; } + 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 confirmed = await ctx.ui.confirm( + "Update selected packages", + `Update ${selectedPackages.length} selected package(s)?` + ); + if (!confirmed) return "resume"; + + for (const item of selectedPackages) { + const outcome = await updatePackageWithOutcome(item.source, ctx, pi); + if (outcome.reloaded) return true; + } + ctx.ui.notify(`Processed ${selectedPackages.length} selected package(s).`, "info"); + return "resume"; + } + if (result.type === "remote") { const pending = await resolvePendingChangesBeforeLeave(items, staged, byId, ctx, pi, "Remote"); if (pending === "stay") return "resume"; From 99ec7b9d769da3e024cb4d761f7768bfaa8e3457 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:29:42 +0100 Subject: [PATCH 11/68] feat(manager): compare and move package scopes --- src/packages/scopes.ts | 48 +++++++++++++++++++++++++++++++++++++ test/package-scopes.test.ts | 24 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/packages/scopes.ts create mode 100644 test/package-scopes.test.ts diff --git a/src/packages/scopes.ts b/src/packages/scopes.ts new file mode 100644 index 0000000..300c403 --- /dev/null +++ b/src/packages/scopes.ts @@ -0,0 +1,48 @@ +import { type InstalledPackage, type Scope } from "../types/index.js"; +import { normalizePackageIdentity } from "../utils/package-source.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[]): PackageScopeComparison[] { + const byIdentity = new Map(); + + for (const pkg of packages) { + const identity = normalizePackageIdentity(pkg.source); + 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); + const projectSource = normalizePackageIdentity(entry.project.source); + 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 (.pi/settings.json)" : "global (~/.pi/agent/settings.json)"; +} diff --git a/test/package-scopes.test.ts b/test/package-scopes.test.ts new file mode 100644 index 0000000..d512013 --- /dev/null +++ b/test/package-scopes.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { comparePackageScopes, getPackageScopeLabel } 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("getPackageScopeLabel explains persisted package scope", () => { + assert.match(getPackageScopeLabel("project"), /\.pi\/settings\.json/); + assert.match(getPackageScopeLabel("global"), /\.pi\/agent\/settings\.json/); +}); From 24ecf0e50fa00f07e1ef0b586963c23115dc0102 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:30:46 +0100 Subject: [PATCH 12/68] feat(manager): save views and improve empty states --- src/utils/views.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++++ test/views.test.ts | 33 +++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/utils/views.ts create mode 100644 test/views.test.ts diff --git a/src/utils/views.ts b/src/utils/views.ts new file mode 100644 index 0000000..6f521a2 --- /dev/null +++ b/src/utils/views.ts @@ -0,0 +1,71 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export interface SavedView { + name: string; + filter: string; + searchQuery: string; + selectedItemId?: string; + createdAt: number; + updatedAt: number; +} + +export interface SavedViewsFile { + version: 1; + views: SavedView[]; + favorites: string[]; + recent: string[]; +} + +const DEFAULT_VIEWS: SavedViewsFile = { version: 1, views: [], favorites: [], recent: [] }; + +export function normalizeViewsFile(input: unknown): SavedViewsFile { + if (!input || typeof input !== "object" || Array.isArray(input)) + return structuredClone(DEFAULT_VIEWS); + const value = input as Record; + const views = Array.isArray(value.views) + ? value.views + .filter((view): view is SavedView => { + if (!view || typeof view !== "object" || Array.isArray(view)) return false; + const candidate = view as Record; + return ( + typeof candidate.name === "string" && + typeof candidate.filter === "string" && + typeof candidate.searchQuery === "string" + ); + }) + .map((view) => ({ ...view, name: view.name.trim() })) + .filter((view) => view.name.length > 0) + : []; + const strings = (candidate: unknown): string[] => + Array.isArray(candidate) + ? candidate + .filter((item): item is string => typeof item === "string" && Boolean(item.trim())) + .map((item) => item.trim()) + : []; + return { + version: 1, + views, + favorites: strings(value.favorites), + recent: strings(value.recent).slice(0, 20), + }; +} + +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 { + 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); + } +} diff --git a/test/views.test.ts b/test/views.test.ts new file mode 100644 index 0000000..6749a87 --- /dev/null +++ b/test/views.test.ts @@ -0,0 +1,33 @@ +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}`), + }); + assert.equal(result.version, 1); + assert.equal(result.views[0]?.name, "work"); + assert.deepEqual(result.favorites, ["demo"]); + assert.equal(result.recent.length, 20); +}); + +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 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 }); + } +}); From 13557a5e9aca81d3519519ee3e4b3d703535807e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:31:36 +0100 Subject: [PATCH 13/68] feat(history): add package activity timelines --- src/utils/history.ts | 11 +++++++++++ test/cache-history.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/utils/history.ts b/src/utils/history.ts index b5e368b..dc581d3 100644 --- a/src/utils/history.ts +++ b/src/utils/history.ts @@ -273,6 +273,17 @@ export function querySessionChanges( return applyHistoryFilters(getAllSessionChanges(ctx), filters); } +/** 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 isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/test/cache-history.test.ts b/test/cache-history.test.ts index 341e8c4..11f953b 100644 --- a/test/cache-history.test.ts +++ b/test/cache-history.test.ts @@ -11,6 +11,7 @@ import { logAutoUpdateConfig, logExtensionDelete, queryGlobalHistory, + queryPackageTimeline, querySessionChanges, } from "../src/utils/history.js"; import { createMockHarness } from "./helpers/mocks.js"; @@ -84,6 +85,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 = { From 7b918f476d0720418b480764d124d702fec41c57 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:32:39 +0100 Subject: [PATCH 14/68] feat(doctor): add runtime ownership explorer --- src/doctor/runtime.ts | 35 +++++++++++++++++++++++++++++++++++ test/doctor-runtime.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/doctor/runtime.ts create mode 100644 test/doctor-runtime.test.ts 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/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"], + ] + ); +}); From b11831e60072a2f43fd1850878396ead75e8287f Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:33:36 +0100 Subject: [PATCH 15/68] feat(doctor): detect command and tool conflicts --- src/doctor/conflicts.ts | 27 +++++++++++++++++++++++++++ test/doctor-conflicts.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/doctor/conflicts.ts create mode 100644 test/doctor-conflicts.test.ts 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/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); +}); From 7e755800bd847bc50cc8a64eff8201f529d3b49d Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:34:38 +0100 Subject: [PATCH 16/68] feat(compatibility): validate Pi and Node compatibility --- src/doctor/compatibility.ts | 42 +++++++++++++++++++++++++++++++ test/doctor-compatibility.test.ts | 21 ++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/doctor/compatibility.ts create mode 100644 test/doctor-compatibility.test.ts diff --git a/src/doctor/compatibility.ts b/src/doctor/compatibility.ts new file mode 100644 index 0000000..cf0dd55 --- /dev/null +++ b/src/doctor/compatibility.ts @@ -0,0 +1,42 @@ +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[]; +} + +function versionParts(version: string | undefined): [number, number] | undefined { + const match = version?.match(/(?:^|\s|[>=<~^])v?(\d+)(?:\.(\d+))?/); + return match?.[1] ? [Number(match[1]), Number(match[2] ?? 0)] : undefined; +} + +function isAtLeast( + actual: [number, number] | undefined, + required: [number, number] | undefined +): boolean | undefined { + if (!actual || !required) return undefined; + return actual[0] > required[0] || (actual[0] === required[0] && actual[1] >= required[1]); +} + +export function validateCompatibility(input: CompatibilityInput): CompatibilityDiagnostic { + const reasons: string[] = []; + const nodeCompatible = isAtLeast( + versionParts(input.nodeVersion), + versionParts(input.engines?.node) + ); + const piCompatible = isAtLeast(versionParts(input.piVersion), versionParts(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}`); + return { packageName: input.packageName, node, pi, reasons }; +} diff --git a/test/doctor-compatibility.test.ts b/test/doctor-compatibility.test.ts new file mode 100644 index 0000000..5e7c61f --- /dev/null +++ b/test/doctor-compatibility.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { 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("missing compatibility metadata is reported as unknown", () => { + assert.deepEqual(validateCompatibility({ packageName: "demo" }).reasons, []); + assert.equal(validateCompatibility({ packageName: "demo" }).node, "unknown"); +}); From 7c8841073cb2a3602830734d11c501047b084c4f Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:35:46 +0100 Subject: [PATCH 17/68] feat(profiles): add profile schema and exact package state --- src/profiles/schema.ts | 59 ++++++++++++++++++++++++++++++++++++++++++ test/profiles.test.ts | 36 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/profiles/schema.ts create mode 100644 test/profiles.test.ts diff --git a/src/profiles/schema.ts b/src/profiles/schema.ts new file mode 100644 index 0000000..0b74b91 --- /dev/null +++ b/src/profiles/schema.ts @@ -0,0 +1,59 @@ +export interface ProfilePackage { + source: string; + scope: "global" | "project"; + version?: string; + ref?: string; + filters?: string[]; + checksum?: string; +} + +export interface ExtmgrProfile { + schemaVersion: 1; + name: string; + packages: ProfilePackage[]; + checks?: { compatibility?: boolean; provenance?: boolean }; +} + +export function normalizeProfile(input: unknown): ExtmgrProfile { + const value = + input && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : {}; + const packages = Array.isArray(value.packages) + ? value.packages.flatMap((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const pkg = item as Record; + if (typeof pkg.source !== "string" || !pkg.source.trim()) return []; + const scope: ProfilePackage["scope"] = pkg.scope === "project" ? "project" : "global"; + return [ + { + source: pkg.source.trim(), + scope, + ...(typeof pkg.version === "string" ? { version: pkg.version.trim() } : {}), + ...(typeof pkg.ref === "string" ? { ref: pkg.ref.trim() } : {}), + ...(Array.isArray(pkg.filters) + ? { + filters: pkg.filters.filter( + (filter): filter is string => typeof filter === "string" + ), + } + : {}), + ...(typeof pkg.checksum === "string" ? { checksum: pkg.checksum.trim() } : {}), + }, + ]; + }) + : []; + return { + schemaVersion: 1, + name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : "unnamed", + packages, + ...(value.checks && typeof value.checks === "object" && !Array.isArray(value.checks) + ? { + checks: { + compatibility: (value.checks as Record).compatibility === true, + provenance: (value.checks as Record).provenance === true, + }, + } + : {}), + }; +} diff --git a/test/profiles.test.ts b/test/profiles.test.ts new file mode 100644 index 0000000..fcc8ee9 --- /dev/null +++ b/test/profiles.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeProfile } from "../src/profiles/schema.js"; + +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 }, + }); +}); From af1df447c46e3d9244430492b3bb5fd2b5c603f0 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:36:26 +0100 Subject: [PATCH 18/68] feat(profiles): export and dry-run apply --- src/profiles/apply.ts | 38 ++++++++++++++++++++++++++++++++++++++ test/profiles.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/profiles/apply.ts diff --git a/src/profiles/apply.ts b/src/profiles/apply.ts new file mode 100644 index 0000000..0a79fcf --- /dev/null +++ b/src/profiles/apply.ts @@ -0,0 +1,38 @@ +import { type ExtmgrProfile, type ProfilePackage } from "./schema.js"; + +export interface ProfilePlan { + add: ProfilePackage[]; + remove: ProfilePackage[]; + update: Array<{ from: ProfilePackage; to: ProfilePackage }>; +} + +function key(pkg: ProfilePackage): string { + return `${pkg.scope}\0${pkg.source}`; +} + +export function planProfileApplication( + current: ExtmgrProfile, + desired: ExtmgrProfile +): ProfilePlan { + const currentByKey = new Map(current.packages.map((pkg) => [key(pkg), pkg])); + const desiredByKey = new Map(desired.packages.map((pkg) => [key(pkg), pkg])); + const add = desired.packages.filter((pkg) => !currentByKey.has(key(pkg))); + const remove = current.packages.filter((pkg) => !desiredByKey.has(key(pkg))); + const update = desired.packages.flatMap((pkg) => { + const previous = currentByKey.get(key(pkg)); + return previous && JSON.stringify(previous) !== JSON.stringify(pkg) + ? [{ from: previous, to: pkg }] + : []; + }); + return { add, remove, 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/test/profiles.test.ts b/test/profiles.test.ts index fcc8ee9..fa1687e 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -1,7 +1,39 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { applyProfile, planProfileApplication } from "../src/profiles/apply.js"; import { normalizeProfile } from "../src/profiles/schema.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 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 schema preserves exact package versions, refs, filters, scopes, and checksums", () => { const profile = normalizeProfile({ schemaVersion: 99, From 1e60ee368bbbfa41ea84111d2383b089dbb466fc Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:37:06 +0100 Subject: [PATCH 19/68] feat(profiles): compare profiles and add project policies --- src/profiles/compare.ts | 39 +++++++++++++++++++++++++++++++++++++++ test/profiles.test.ts | 11 +++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/profiles/compare.ts diff --git a/src/profiles/compare.ts b/src/profiles/compare.ts new file mode 100644 index 0000000..8cd3334 --- /dev/null +++ b/src/profiles/compare.ts @@ -0,0 +1,39 @@ +import { planProfileApplication } from "./apply.js"; +import { type ExtmgrProfile } from "./schema.js"; + +export interface ProfilePolicy { + allowedScopes?: Array<"global" | "project">; + requireChecksums?: boolean; + requireCompatibilityCheck?: boolean; +} + +export interface ProfilePolicyViolation { + packageSource?: string; + message: string; +} + +export function compareProfiles( + left: ExtmgrProfile, + right: ExtmgrProfile +): ReturnType { + return planProfileApplication(left, right); +} + +export function validateProfilePolicy( + profile: ExtmgrProfile, + policy: ProfilePolicy +): ProfilePolicyViolation[] { + const violations: ProfilePolicyViolation[] = []; + for (const pkg of profile.packages) { + if (policy.allowedScopes && !policy.allowedScopes.includes(pkg.scope)) { + violations.push({ packageSource: pkg.source, message: `scope ${pkg.scope} is not allowed` }); + } + if (policy.requireChecksums && !pkg.checksum) { + violations.push({ packageSource: pkg.source, message: "checksum is required" }); + } + } + if (policy.requireCompatibilityCheck && profile.checks?.compatibility !== true) { + violations.push({ message: "profile compatibility checks are required" }); + } + return violations; +} diff --git a/test/profiles.test.ts b/test/profiles.test.ts index fa1687e..5b29762 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { applyProfile, planProfileApplication } from "../src/profiles/apply.js"; +import { compareProfiles, validateProfilePolicy } from "../src/profiles/compare.js"; import { normalizeProfile } from "../src/profiles/schema.js"; void test("profile application produces a dry-run plan without mutating state", async () => { @@ -34,6 +35,16 @@ void test("profile plans identify exact package state changes", () => { assert.equal(planProfileApplication(current, desired).update.length, 1); }); +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" }] }); + assert.equal(compareProfiles(left, right).add.length, 1); + assert.equal( + validateProfilePolicy(right, { allowedScopes: ["global"], requireChecksums: true }).length, + 2 + ); +}); + void test("profile schema preserves exact package versions, refs, filters, scopes, and checksums", () => { const profile = normalizeProfile({ schemaVersion: 99, From a3629e55baf14458bbb769b3d7ab0cac73ed614e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:38:00 +0100 Subject: [PATCH 20/68] feat(updates): preview and selectively update packages --- src/packages/update-preview.ts | 27 +++++++++++++++++++++++++++ test/update-preview.test.ts | 17 +++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/packages/update-preview.ts create mode 100644 test/update-preview.test.ts 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/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); +}); From 1858bde9e851556eb46f92fa4c7484ed775878a5 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:39:02 +0100 Subject: [PATCH 21/68] feat(updates): add update policies and maintenance windows --- src/packages/update-policy.ts | 21 +++++++++++++++++++++ test/update-policy.test.ts | 15 +++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/packages/update-policy.ts create mode 100644 test/update-policy.test.ts diff --git a/src/packages/update-policy.ts b/src/packages/update-policy.ts new file mode 100644 index 0000000..96cc7fe --- /dev/null +++ b/src/packages/update-policy.ts @@ -0,0 +1,21 @@ +export interface UpdatePolicy { + packageSource: string; + enabled: boolean; + maintenanceWindow?: { startHour: number; endHour: number; timezone?: string }; + digest?: "none" | "daily" | "weekly"; +} + +export function isWithinMaintenanceWindow(policy: UpdatePolicy, date = new Date()): boolean { + if (!policy.enabled || !policy.maintenanceWindow) return false; + const hour = date.getHours(); + const { startHour, endHour } = policy.maintenanceWindow; + if (startHour === endHour) return true; + return startHour < endHour + ? hour >= startHour && hour < endHour + : hour >= startHour || hour < endHour; +} + +export function shouldUpdate(policy: UpdatePolicy | undefined, date = new Date()): boolean { + if (!policy?.enabled) return false; + return !policy.maintenanceWindow || isWithinMaintenanceWindow(policy, date); +} diff --git a/test/update-policy.test.ts b/test/update-policy.test.ts new file mode 100644 index 0000000..2193ab3 --- /dev/null +++ b/test/update-policy.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isWithinMaintenanceWindow, shouldUpdate } from "../src/packages/update-policy.js"; + +void test("update policies honor ordinary and overnight maintenance windows", () => { + const policy = { + packageSource: "npm:demo", + enabled: true, + maintenanceWindow: { startHour: 22, endHour: 2 }, + }; + assert.equal(isWithinMaintenanceWindow(policy, new Date(2026, 0, 1, 23)), true); + assert.equal(isWithinMaintenanceWindow(policy, new Date(2026, 0, 1, 1)), true); + assert.equal(isWithinMaintenanceWindow(policy, new Date(2026, 0, 1, 12)), false); + assert.equal(shouldUpdate(policy, new Date(2026, 0, 1, 12)), false); +}); From 091d84d6125320978dbd04f75c17feb091156527 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:39:48 +0100 Subject: [PATCH 22/68] feat(local): add trash and undo for local extensions --- src/extensions/trash.ts | 24 ++++++++++++++++++++++++ test/extension-trash.test.ts | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/extensions/trash.ts create mode 100644 test/extension-trash.test.ts diff --git a/src/extensions/trash.ts b/src/extensions/trash.ts new file mode 100644 index 0000000..3e0ba5a --- /dev/null +++ b/src/extensions/trash.ts @@ -0,0 +1,24 @@ +import { mkdir, rename, rm } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +export interface TrashRecord { + originalPath: string; + trashPath: string; + trashedAt: number; +} + +export async function moveToExtensionTrash(path: string, trashRoot: string): Promise { + await mkdir(trashRoot, { recursive: true }); + const trashPath = join(trashRoot, `${Date.now()}-${basename(path)}`); + await rename(path, trashPath); + return { originalPath: path, trashPath, trashedAt: Date.now() }; +} + +export async function undoExtensionTrash(record: TrashRecord): Promise { + await mkdir(dirname(record.originalPath), { recursive: true }); + await rename(record.trashPath, record.originalPath); +} + +export async function purgeExtensionTrash(record: TrashRecord): Promise { + await rm(record.trashPath, { recursive: true, force: true }); +} diff --git a/test/extension-trash.test.ts b/test/extension-trash.test.ts new file mode 100644 index 0000000..0372d7c --- /dev/null +++ b/test/extension-trash.test.ts @@ -0,0 +1,19 @@ +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 { moveToExtensionTrash, undoExtensionTrash } from "../src/extensions/trash.js"; + +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 }); + } +}); From 9d91e64dbeedc5742abcb8b4f47bd5f0f332c394 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:40:33 +0100 Subject: [PATCH 23/68] feat(remote): add package sorting --- src/packages/sorting.ts | 11 +++++++++++ test/package-sorting.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/packages/sorting.ts create mode 100644 test/package-sorting.test.ts diff --git a/src/packages/sorting.ts b/src/packages/sorting.ts new file mode 100644 index 0000000..25148d0 --- /dev/null +++ b/src/packages/sorting.ts @@ -0,0 +1,11 @@ +import { type NpmPackage } from "../types/index.js"; + +export type RemotePackageSort = "relevance" | "name" | "recent"; + +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); + return (right.date ?? "").localeCompare(left.date ?? "") || left.name.localeCompare(right.name); + }); +} diff --git a/test/package-sorting.test.ts b/test/package-sorting.test.ts new file mode 100644 index 0000000..71b1979 --- /dev/null +++ b/test/package-sorting.test.ts @@ -0,0 +1,19 @@ +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 and recent modes without mutating results", () => { + const packages = [ + { name: "zeta", date: "2026-01-01" }, + { name: "alpha", date: "2026-03-01" }, + ]; + assert.deepEqual( + sortRemotePackages(packages, "name").map((pkg) => pkg.name), + ["alpha", "zeta"] + ); + assert.deepEqual( + sortRemotePackages(packages, "recent").map((pkg) => pkg.name), + ["alpha", "zeta"] + ); + assert.equal(packages[0]?.name, "zeta"); +}); From 70d8908b229023edb84f9183ece03b74fd609afd Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:41:17 +0100 Subject: [PATCH 24/68] feat(remote): show installed and update badges --- src/packages/badges.ts | 20 ++++++++++++++++++++ test/package-badges.test.ts | 8 ++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/packages/badges.ts create mode 100644 test/package-badges.test.ts 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/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" }); +}); From 4fc63e6e047d769953325bae0e514b031ef856f8 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:41:53 +0100 Subject: [PATCH 25/68] feat(remote): inspect package metadata before install --- src/packages/inspection.ts | 29 +++++++++++++++++++++++++++++ test/package-inspection.test.ts | 15 +++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/packages/inspection.ts create mode 100644 test/package-inspection.test.ts diff --git a/src/packages/inspection.ts b/src/packages/inspection.ts new file mode 100644 index 0000000..c828550 --- /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"; +}): 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/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"); +}); From 00cb1339493b65ad639853a43cfde63852adccfe Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:42:59 +0100 Subject: [PATCH 26/68] feat(cli): improve extmgr command completion --- src/commands/registry.ts | 18 ++++++++++++++++++ test/command-orchestration.test.ts | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/commands/registry.ts b/src/commands/registry.ts index e128b82..9a8f71d 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -170,6 +170,24 @@ export function runResolvedCommand( } export function getExtensionsAutocompleteItems(prefix: string): AutocompleteItem[] | null { + const normalizedPrefix = prefix ?? ""; + const commandPrefix = normalizedPrefix.trimStart(); + const whitespace = commandPrefix.lastIndexOf(" "); + if (whitespace >= 0) { + const command = commandPrefix.slice(0, whitespace).replace(/^\//, "").split(/\s+/)[0] ?? ""; + const argumentPrefix = commandPrefix.slice(whitespace + 1).toLowerCase(); + const argumentOptions: Record = { + install: ["--global", "--project"], + remove: ["--global", "--project"], + update: ["--all", "--preview"], + "auto-update": ["daily", "weekly", "monthly", "never"], + history: ["--failed", "--success", "--global", "--limit", "--since"], + }; + const options = argumentOptions[command] ?? []; + const matches = options.filter((option) => option.startsWith(argumentPrefix)); + return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null; + } + 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/test/command-orchestration.test.ts b/test/command-orchestration.test.ts index 3ac943c..747dc7d 100644 --- a/test/command-orchestration.test.ts +++ b/test/command-orchestration.test.ts @@ -29,6 +29,17 @@ 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("runResolvedCommand install respects --project scope", async () => { const installs: { source: string; scope: "global" | "project" }[] = []; const restoreCatalog = mockPackageCatalog({ From f6a01ed646ea784047ff4dce7698f02aa3c04a61 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:45:39 +0100 Subject: [PATCH 27/68] feat(doctor): expose runtime diagnostics command --- src/commands/registry.ts | 27 +++++++++++++++++++++++++++ src/commands/types.ts | 3 ++- test/command-orchestration.test.ts | 2 ++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 9a8f71d..cea06c8 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -1,5 +1,7 @@ import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { type AutocompleteItem } from "@earendil-works/pi-tui"; +import { findRuntimeConflicts } from "../doctor/conflicts.js"; +import { getRuntimeOwners } from "../doctor/runtime.js"; import { promptRemove, removePackage, @@ -18,6 +20,24 @@ import { type CommandDefinition, type CommandId } from "./types.js"; const REMOVE_USAGE = "Usage: /extensions remove "; +async function showDoctor(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const owners = getRuntimeOwners(pi); + const conflicts = findRuntimeConflicts(owners); + const lines = [`Runtime ownership: ${owners.length} command/tool entries`]; + 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,6 +54,7 @@ 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 doctor - Inspect runtime ownership/conflicts", " /extensions auto-update - Configure auto-update (e.g. 1d, 1w, 1mo, never)", "", "History examples:", @@ -130,6 +151,12 @@ const COMMAND_DEFINITIONS: Record = { 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), + }, }; function buildCommandAliasMap( diff --git a/src/commands/types.ts b/src/commands/types.ts index 8c02c6d..614bf2c 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -11,7 +11,8 @@ export type CommandId = | "update" | "history" | "clear-cache" - | "auto-update"; + | "auto-update" + | "doctor"; export interface CommandDefinition { id: CommandId; diff --git a/test/command-orchestration.test.ts b/test/command-orchestration.test.ts index 747dc7d..ee35e25 100644 --- a/test/command-orchestration.test.ts +++ b/test/command-orchestration.test.ts @@ -16,9 +16,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", () => { From 4bc997184aaa9787e286b8eccc8ad5c903e7441e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:46:58 +0100 Subject: [PATCH 28/68] feat(profiles): expose export and dry-run commands --- src/commands/profile.ts | 67 ++++++++++++++++++++++++++++++++++++ src/commands/registry.ts | 9 +++++ src/commands/types.ts | 3 +- test/profile-command.test.ts | 30 ++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/commands/profile.ts create mode 100644 test/profile-command.test.ts diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..751e89c --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,67 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; +import { planProfileApplication } from "../profiles/apply.js"; +import { type ExtmgrProfile, normalizeProfile } from "../profiles/schema.js"; +import { notify } from "../utils/notify.js"; + +const PROFILE_USAGE = "Usage: /extensions profile "; + +async function currentProfile(ctx: ExtensionCommandContext): Promise { + const packages = await getInstalledPackagesAllScopes(ctx); + return normalizeProfile({ + name: "current", + packages: packages.map((pkg) => ({ + source: pkg.source, + scope: pkg.scope, + ...(pkg.version ? { version: pkg.version } : {}), + })), + }); +} + +async function readProfile(path: string): Promise { + return normalizeProfile(JSON.parse(await readFile(path, "utf8"))); +} + +function formatPlan(plan: ReturnType): string { + return [ + `Add: ${plan.add.length}`, + ...plan.add.map((pkg) => ` + ${pkg.source} (${pkg.scope})`), + `Remove: ${plan.remove.length}`, + ...plan.remove.map((pkg) => ` - ${pkg.source} (${pkg.scope})`), + `Change: ${plan.update.length}`, + ...plan.update.map(({ to }) => ` ~ ${to.source} (${to.scope})`), + ].join("\n"); +} + +export async function handleProfileSubcommand( + tokens: string[], + ctx: ExtensionCommandContext +): Promise { + const action = tokens[0]; + const requestedPath = tokens[1]; + if (!action || !requestedPath || !["export", "dry-run", "compare"].includes(action)) { + notify(ctx, PROFILE_USAGE, "info"); + return; + } + + const path = resolve(ctx.cwd, requestedPath); + try { + const current = await currentProfile(ctx); + if (action === "export") { + await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, { flag: "wx" }); + notify(ctx, `Exported profile to ${path}`, "info"); + return; + } + + const desired = await readProfile(path); + notify(ctx, formatPlan(planProfileApplication(current, desired)), "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 cea06c8..8676b9b 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -16,6 +16,7 @@ import { handleAutoUpdateSubcommand } from "./auto-update.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"; const REMOVE_USAGE = "Usage: /extensions remove "; @@ -55,6 +56,7 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void { " /extensions update [source] - Update one package or all packages", " /extensions history [opts] - Show history (supports filters)", " /extensions doctor - Inspect runtime ownership/conflicts", + " /extensions profile - Export, compare, or dry-run a profile", " /extensions auto-update - Configure auto-update (e.g. 1d, 1w, 1mo, never)", "", "History examples:", @@ -157,6 +159,12 @@ const COMMAND_DEFINITIONS: Record = { runInteractive: (_tokens, ctx, pi) => showDoctor(ctx, pi), runNonInteractive: (_tokens, ctx, pi) => showDoctor(ctx, pi), }, + profile: { + id: "profile", + description: "Export, compare, or dry-run a package profile", + runInteractive: (tokens, ctx) => handleProfileSubcommand(tokens, ctx), + runNonInteractive: (tokens, ctx) => handleProfileSubcommand(tokens, ctx), + }, }; function buildCommandAliasMap( @@ -209,6 +217,7 @@ export function getExtensionsAutocompleteItems(prefix: string): AutocompleteItem update: ["--all", "--preview"], "auto-update": ["daily", "weekly", "monthly", "never"], history: ["--failed", "--success", "--global", "--limit", "--since"], + profile: ["export", "dry-run", "compare"], }; const options = argumentOptions[command] ?? []; const matches = options.filter((option) => option.startsWith(argumentPrefix)); diff --git a/src/commands/types.ts b/src/commands/types.ts index 614bf2c..cf87d07 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -12,7 +12,8 @@ export type CommandId = | "history" | "clear-cache" | "auto-update" - | "doctor"; + | "doctor" + | "profile"; export interface CommandDefinition { id: CommandId; diff --git a/test/profile-command.test.ts b/test/profile-command.test.ts new file mode 100644 index 0000000..73bd654 --- /dev/null +++ b/test/profile-command.test.ts @@ -0,0 +1,30 @@ +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 { handleProfileSubcommand } from "../src/commands/profile.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 restore = mockPackageCatalog({ + packages: [{ source: "npm:demo@1.2.3", name: "demo", version: "1.2.3", scope: "project" }], + }); + 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 }>; + }; + assert.deepEqual(profile.packages[0], { + source: "npm:demo@1.2.3", + scope: "project", + version: "1.2.3", + }); + } finally { + restore(); + await rm(cwd, { recursive: true, force: true }); + } +}); From 84644776a9ec1c0ff92463ff1b2d32eb3b042e99 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:49:02 +0100 Subject: [PATCH 29/68] feat(local): wire trash and undo into removals --- src/extensions/discovery.ts | 15 +++++++++------ src/ui/unified.ts | 16 +++++++++++++++- test/extension-trash.test.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index 2f5c8ed..6dd12bb 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -6,7 +6,7 @@ */ import { type Dirent } from "node:fs"; -import { readdir, rename, rm } from "node:fs/promises"; +import { readdir, rename } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { DISABLED_SUFFIX } from "../constants.js"; @@ -17,6 +17,7 @@ import { normalizeRelativePath, resolveRelativePathSelection, } from "../utils/relative-path-selection.js"; +import { moveToExtensionTrash, type TrashRecord } from "./trash.js"; interface RootConfig { root: string; @@ -345,7 +346,8 @@ 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"); @@ -364,13 +366,14 @@ export async function removeLocalExtension( const isIndexFile = /^index\.(ts|js)$/i.test(normalizedBase); const isInsideExtensionDir = parentDir !== globalRoot && parentDir !== projectRoot; + const trashRoot = join(homedir(), ".pi", "agent", ".extmgr-trash"); 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/ui/unified.ts b/src/ui/unified.ts index f5e48bb..b4e90ab 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -29,6 +29,7 @@ import { removeLocalExtension, setExtensionState, } from "../extensions/discovery.js"; +import { undoExtensionTrash } from "../extensions/trash.js"; import { getInstalledPackages } from "../packages/discovery.js"; import { discoverPackageExtensions } from "../packages/extensions.js"; import { @@ -1582,9 +1583,22 @@ async function handleUnifiedAction( logExtensionDelete(pi, item.id, true); ctx.ui.notify( - `Removed ${item.displayName}${removal.removedDirectory ? " (directory)" : ""}.`, + `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."); } diff --git a/test/extension-trash.test.ts b/test/extension-trash.test.ts index 0372d7c..5e5e9bb 100644 --- a/test/extension-trash.test.ts +++ b/test/extension-trash.test.ts @@ -1,10 +1,35 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +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 { removeLocalExtension } from "../src/extensions/discovery.js"; import { moveToExtensionTrash, 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("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"); From 2ffc17d8f4a21a1dbfeaef7786535ff4d4802c2a Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:51:06 +0100 Subject: [PATCH 30/68] feat(remote): wire badges and sorting into browser --- src/types/index.ts | 3 +++ src/ui/remote.ts | 51 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index a34624a..e8edd50 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,9 @@ export interface NpmPackage { keywords?: string[] | undefined; date?: string | undefined; size?: number | undefined; // Package size in bytes + installed?: boolean | undefined; + updateAvailable?: boolean | undefined; + compatibility?: "compatible" | "incompatible" | "unknown" | undefined; } export interface InstalledPackage { diff --git a/src/ui/remote.ts b/src/ui/remote.ts index fa07ad5..17a147f 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -20,8 +20,10 @@ import { wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { CACHE_LIMITS, PAGE_SIZE, TIMEOUTS, UI } from "../constants.js"; +import { getRemotePackageBadges } from "../packages/badges.js"; import { clearSearchCache, + getInstalledPackagesAllScopes, getSearchCache, isCacheValid, searchNpmPackages, @@ -31,7 +33,9 @@ import { installPackageLocallyWithOutcome, installPackageWithOutcome, } from "../packages/install.js"; +import { type RemotePackageSort, sortRemotePackages } from "../packages/sorting.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 { requireCustomUI, runCustomUI } from "../utils/mode.js"; @@ -410,7 +414,12 @@ async function showRemoteMenu(ctx: ExtensionCommandContext, pi: ExtensionAPI): P function formatRemotePackageLabel(pkg: NpmPackage, theme: Theme): string { const name = theme.bold(pkg.name); const version = pkg.version ? theme.fg("dim", `@${pkg.version}`) : ""; - return `${name}${version}`; + const badges = [ + pkg.installed ? theme.fg("success", "installed") : undefined, + pkg.updateAvailable ? theme.fg("warning", "update") : undefined, + theme.fg("dim", `compat:${pkg.compatibility ?? "unknown"}`), + ].filter(Boolean); + return `${name}${version}${badges.length ? ` [${badges.join(" · ")}]` : ""}`; } function formatRemotePackageDetails( @@ -433,10 +442,12 @@ class RemotePackageBrowser implements Focusable { private readonly searchInput = new Input(); private selectedIndex = 0; private searchActive = false; + private sortMode: RemotePackageSort = "relevance"; + private readonly originalPackages: NpmPackage[]; private _focused = false; constructor( - private readonly packages: NpmPackage[], + private packages: NpmPackage[], private readonly theme: Theme, private readonly keybindings: KeybindingsManager, private readonly browseSource: RemoteBrowseSource, @@ -448,6 +459,7 @@ class RemotePackageBrowser implements Focusable { private readonly showLoadMore: boolean, private readonly onAction: (action: BrowseAction) => void ) { + this.originalPackages = [...packages]; this.searchInput.setValue(queryLabel); } @@ -537,6 +549,19 @@ class RemotePackageBrowser implements Focusable { return true; } + if (data === "o" || data === "O") { + const modes: RemotePackageSort[] = ["relevance", "name", "recent"]; + const nextIndex = (modes.indexOf(this.sortMode) + 1) % modes.length; + this.sortMode = modes[nextIndex] ?? "relevance"; + 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; @@ -626,7 +651,7 @@ class RemotePackageBrowser implements Focusable { 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}`)}`; + 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:${this.sortMode}`)}`; } private buildFooterLine(): string { @@ -639,7 +664,7 @@ class RemotePackageBrowser implements Focusable { parts.push("n next"); } - parts.push("r refresh", "i install", "m menu", "Esc back"); + parts.push("o sort", "r refresh", "i install", "m menu", "Esc back"); return ` ${this.theme.fg("dim", parts.join(" · "))}`; } @@ -862,7 +887,23 @@ async function browseRemotePackagesPage({ return; } - const packages = filterRemoteBrowseResults(plan, searchPage.results); + 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, searchPage.results).map((pkg) => ({ + ...pkg, + ...getRemotePackageBadges(pkg, installedNames, updateNames), + })); const totalResults = plan.kind === "search" && plan.exactPackageName ? packages.length : searchPage.total; const reloadQuery = From 61e45598008e4c91ebe067f50d530fb1b4d0f38e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:51:49 +0100 Subject: [PATCH 31/68] feat(remote): surface trust metadata in inspection --- src/ui/remote.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/remote.ts b/src/ui/remote.ts index 17a147f..c1e0a3d 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -28,6 +28,7 @@ import { isCacheValid, searchNpmPackages, } from "../packages/discovery.js"; +import { inspectPackageMetadata } from "../packages/inspection.js"; import { installPackage, installPackageLocallyWithOutcome, @@ -58,6 +59,7 @@ interface NpmViewInfo { users?: Record; dist?: { unpackedSize?: number }; repository?: { url?: string } | string; + dependencies?: Record; } interface NpmDownloadsPoint { @@ -333,6 +335,13 @@ async function buildPackageInfoText( 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 inspection = inspectPackageMetadata({ + name: packageName, + ...(info.version ? { version: info.version } : {}), + ...(info.description ? { description: info.description } : {}), + ...(info.dependencies ? { dependencies: info.dependencies } : {}), + ...(repository ? { repository } : {}), + }); const lines = [ `${packageName}@${version}`, @@ -341,6 +350,9 @@ async function buildPackageInfoText( `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}`); From 8757ab769d222aac051e5877edb06bdf7fc40568 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:53:33 +0100 Subject: [PATCH 32/68] feat(updates): expose preview and selective updates --- src/commands/registry.ts | 17 +++++--------- src/commands/update.ts | 44 +++++++++++++++++++++++++++++++++++++ test/update-command.test.ts | 24 ++++++++++++++++++++ 3 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 src/commands/update.ts create mode 100644 test/update-command.test.ts diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 8676b9b..4611c9e 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -2,13 +2,7 @@ import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works import { type AutocompleteItem } from "@earendil-works/pi-tui"; import { findRuntimeConflicts } from "../doctor/conflicts.js"; import { getRuntimeOwners } from "../doctor/runtime.js"; -import { - promptRemove, - removePackage, - showInstalledPackagesList, - updatePackage, - updatePackages, -} from "../packages/management.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"; @@ -18,6 +12,7 @@ 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"; const REMOVE_USAGE = "Usage: /extensions remove "; @@ -129,11 +124,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", diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..6b1b970 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,44 @@ +import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { getPackageCatalog } from "../packages/catalog.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).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/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(); + } +}); From 3b612c074ea4b6e48a80b7946185fb2c28e82adb Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:54:39 +0100 Subject: [PATCH 33/68] feat(history): surface timelines in package details --- src/ui/unified.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ui/unified.ts b/src/ui/unified.ts index b4e90ab..9ebe888 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -50,7 +50,12 @@ import { 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 { + formatChangeEntry, + logExtensionDelete, + logExtensionToggle, + queryPackageTimeline, +} from "../utils/history.js"; import { hasCustomUI, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; @@ -1371,8 +1376,13 @@ function showUnifiedItemDetails( 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}` : ""}`, + `Name: ${item.displayName}\nVersion: ${item.version || "unknown"}\nSource: ${item.source}\nScope: ${item.scope}${extensionStr}${sizeStr}${item.description ? `\nDescription: ${item.description}` : ""}${timelineText}`, "info" ); } From a837489f5f62e85bb616592889ac4dca89e4e6b3 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 17:55:50 +0100 Subject: [PATCH 34/68] feat(manager): toggle whole package extension state --- src/types/index.ts | 2 +- src/ui/unified.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index e8edd50..eca542e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -105,7 +105,7 @@ export type UnifiedAction = | { type: "action"; itemId: string; - action?: "menu" | "update" | "remove" | "details" | "configure"; + action?: "menu" | "update" | "remove" | "details" | "configure" | "enable" | "disable"; }; export type BrowseAction = diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 9ebe888..d68cc27 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -31,7 +31,10 @@ import { } from "../extensions/discovery.js"; import { undoExtensionTrash } from "../extensions/trash.js"; import { getInstalledPackages } from "../packages/discovery.js"; -import { discoverPackageExtensions } from "../packages/extensions.js"; +import { + applyPackageExtensionStateChanges, + discoverPackageExtensions, +} from "../packages/extensions.js"; import { removePackageWithOutcome, showInstalledPackagesList, @@ -1315,6 +1318,8 @@ const LOCAL_ACTION_OPTIONS = { const PACKAGE_ACTION_OPTIONS = { configure: "Configure extensions", + enable: "Enable whole package", + disable: "Disable whole package", update: "Update package", remove: "Remove package", details: "View details", @@ -1639,6 +1644,8 @@ async function handleUnifiedAction( const pendingDestinationBySelection = { configure: "configure package extensions", + enable: "enable package", + disable: "disable package", update: "update package", remove: "remove package", } satisfies Record, string>; @@ -1654,6 +1661,29 @@ async function handleUnifiedAction( if (pending === "stay") return "resume"; switch (selection) { + 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 + ); + 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; From ef366e7b1cc9f22117f06733e0d6a5eedc29bcc8 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:07:07 +0100 Subject: [PATCH 35/68] feat(reload): persist pending reload requirements --- src/ui/unified.ts | 2 + src/utils/reload-state.ts | 101 ++++++++++++++++++++++++++++++++++++++ src/utils/ui-helpers.ts | 4 ++ test/reload-state.test.ts | 55 +++++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 src/utils/reload-state.ts create mode 100644 test/reload-state.test.ts diff --git a/src/ui/unified.ts b/src/ui/unified.ts index d68cc27..9a1a083 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -63,6 +63,7 @@ import { hasCustomUI, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; import { normalizePathIdentity } from "../utils/path-identity.js"; +import { markReloadRequired } from "../utils/reload-state.js"; import { updateExtmgrStatus } from "../utils/status.js"; import { confirmReload, formatListOutput } from "../utils/ui-helpers.js"; import { runTaskWithLoader } from "./async-task.js"; @@ -1249,6 +1250,7 @@ async function applyToggleChangesFromManager( 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"); } diff --git a/src/utils/reload-state.ts b/src/utils/reload-state.ts new file mode 100644 index 0000000..3af22d1 --- /dev/null +++ b/src/utils/reload-state.ts @@ -0,0 +1,101 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface ReloadRequiredState { + version: 1; + required: boolean; + changedAt?: number; + changes: number; + reasons: string[]; +} + +const DEFAULT_STATE: ReloadRequiredState = { + version: 1, + required: false, + changes: 0, + reasons: [], +}; + +const STATE_DIR = process.env.PI_EXTMGR_CACHE_DIR + ? process.env.PI_EXTMGR_CACHE_DIR + : join(homedir(), ".pi", "agent", ".extmgr-cache"); +const STATE_FILE = join(STATE_DIR, "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 = STATE_FILE): Promise { + await writeQueue; + return readStateFromDisk(path); +} + +export async function markReloadRequired(reason: string, path = STATE_FILE): 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 = STATE_FILE): Promise { + writeQueue = writeQueue.then(() => writeStateToDisk(path, cloneDefault())); + await writeQueue; +} + +export function getReloadRequiredStatePath(): string { + return STATE_FILE; +} diff --git a/src/utils/ui-helpers.ts b/src/utils/ui-helpers.ts index b418a0e..5a87d29 100644 --- a/src/utils/ui-helpers.ts +++ b/src/utils/ui-helpers.ts @@ -4,6 +4,7 @@ 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"; /** * Confirm and trigger reload @@ -14,6 +15,7 @@ export async function confirmReload( reason: string ): Promise { if (!ctx.hasUI) { + await markReloadRequired(reason); notify(ctx, `Reload pi to apply changes. (${reason})`); return false; } @@ -25,7 +27,9 @@ export async function confirmReload( } try { + await markReloadRequired(reason); await ctx.reload(); + await clearReloadRequired(); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/test/reload-state.test.ts b/test/reload-state.test.ts new file mode 100644 index 0000000..19a4999 --- /dev/null +++ b/test/reload-state.test.ts @@ -0,0 +1,55 @@ +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"; + +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("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 }); + } +}); From 2b2a3c62073349ee99041fb435dedc2d6c0da70b Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:11:28 +0100 Subject: [PATCH 36/68] feat(manager): wire saved views and favorites --- src/types/index.ts | 5 ++ src/ui/footer.ts | 3 +- src/ui/unified.ts | 201 +++++++++++++++++++++++++++++++++++++++++---- src/utils/views.ts | 81 +++++++++++++----- test/views.test.ts | 14 +++- 5 files changed, 267 insertions(+), 37 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index eca542e..bbabde0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -101,6 +101,11 @@ export type UnifiedAction = | { type: "help" } | { type: "menu" } | { type: "bulk"; itemIds: string[]; action: "update" | "remove" } + | { + type: "views"; + action: "save" | "load" | "delete" | "favorite"; + itemId?: string; + } | { type: "quick"; action: "install" | "search" | "update-all" | "auto-update" } | { type: "action"; diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 292d3b1..759b545 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -73,7 +73,8 @@ export function buildFooterShortcuts(state: FooterState): string { parts.push("/ search"); parts.push("Tab filters"); - parts.push("1-5 filters"); + parts.push("1-7 filters"); + parts.push("W save view · L load · D delete · * favorite"); parts.push("i install"); parts.push("f remote search"); parts.push("U update all"); diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 9a1a083..697a645 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -64,6 +64,12 @@ import { notify } from "../utils/notify.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; import { normalizePathIdentity } from "../utils/path-identity.js"; import { markReloadRequired } from "../utils/reload-state.js"; +import { + getSavedViewsPath, + type SavedView, + readSavedViews, + writeSavedViews, +} from "../utils/views.js"; import { updateExtmgrStatus } from "../utils/status.js"; import { confirmReload, formatListOutput } from "../utils/ui-helpers.js"; import { runTaskWithLoader } from "./async-task.js"; @@ -149,6 +155,8 @@ async function showInteractiveOnce( } const { localEntries, installedPackages, packageExtensions } = initialData; + const viewsPath = getSavedViewsPath(); + let savedViews = await readSavedViews(viewsPath); // Build unified items list. const knownUpdates = getKnownUpdates(ctx); @@ -171,7 +179,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; @@ -199,6 +207,8 @@ 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; @@ -266,9 +276,33 @@ async function showInteractiveOnce( return true; } - const outcome = await handleUnifiedAction(result, items, staged, byId, ctx, pi); + if (nextManagerState) { + const lastView = managerStateToView( + nextManagerState, + "last-used", + savedViews.lastView?.createdAt + ); + 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 outcome = await handleUnifiedAction( + result, + items, + staged, + byId, + ctx, + pi, + savedViews, + viewsPath, + nextManagerState + ); if (outcome === "resume") { - managerState = nextManagerState; + managerState = viewToManagerState(savedViews.lastView) ?? nextManagerState; continue; } @@ -428,7 +462,9 @@ function buildManagerSummary( options?.filter === "local" || options?.filter === "packages" || options?.filter === "updates" || - options?.filter === "disabled"; + 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( @@ -467,7 +503,7 @@ function buildManagerSummary( return parts.join(" • "); } -type UnifiedFilter = "all" | "local" | "packages" | "updates" | "disabled"; +type UnifiedFilter = "all" | "local" | "packages" | "updates" | "disabled" | "favorites" | "recent"; interface UnifiedManagerViewState { filter: UnifiedFilter; @@ -481,8 +517,38 @@ const UNIFIED_FILTER_OPTIONS: Array<{ id: UnifiedFilter; key: string; label: str { 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); +} + +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 } : {}), + }; +} + +function managerStateToView( + state: UnifiedManagerViewState, + name: string, + now = Date.now() +): SavedView { + return { + name, + filter: state.filter, + searchQuery: state.searchQuery, + ...(state.selectedItemId ? { selectedItemId: state.selectedItemId } : {}), + createdAt: now, + updatedAt: now, + }; +} + function getCurrentUnifiedItemState( item: UnifiedItem, staged: Map @@ -589,7 +655,9 @@ function isAbsoluteDisplayPath(value: string): boolean { function matchesUnifiedFilter( item: UnifiedItem, filter: UnifiedFilter, - staged: Map + staged: Map, + favoriteIds: ReadonlySet, + recentIds: ReadonlySet ): boolean { switch (filter) { case "all": @@ -605,6 +673,10 @@ function matchesUnifiedFilter( 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); } } @@ -751,6 +823,8 @@ class UnifiedManagerBrowser implements Focusable { 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) { @@ -932,6 +1006,26 @@ class UnifiedManagerBrowser implements Focusable { 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; @@ -1026,13 +1120,18 @@ class UnifiedManagerBrowser implements Focusable { lines.push(""); if (this.filteredItems.length === 0) { - lines.push( - truncateToWidth( - this.theme.fg("warning", " No matching extensions or packages"), - safeWidth, - "" - ) - ); + 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; } @@ -1144,7 +1243,7 @@ class UnifiedManagerBrowser implements Focusable { private refreshVisibleItems(preferredItemId?: string): void { const previousSelectedId = preferredItemId ?? this.getSelectedItem()?.id; const filteredByMode = this.items.filter((item) => - matchesUnifiedFilter(item, this.filter, this.staged) + matchesUnifiedFilter(item, this.filter, this.staged, this.favoriteIds, this.recentIds) ); const query = this.searchInput.getValue().trim(); this.filteredItems.length = 0; @@ -1447,7 +1546,10 @@ async function handleUnifiedAction( staged: Map, byId: Map, ctx: ExtensionCommandContext, - pi: ExtensionAPI + pi: ExtensionAPI, + savedViews: Awaited>, + viewsPath: string, + currentViewState?: UnifiedManagerViewState ): Promise { if (result.type === "cancel") { const pendingCount = getPendingToggleChangeCount(staged, byId); @@ -1472,6 +1574,75 @@ async function handleUnifiedAction( 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)) diff --git a/src/utils/views.ts b/src/utils/views.ts index 6f521a2..d891d23 100644 --- a/src/utils/views.ts +++ b/src/utils/views.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { homedir } from "node:os"; export interface SavedView { name: string; @@ -15,27 +16,50 @@ export interface SavedViewsFile { 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() } + : {}), + 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 - .filter((view): view is SavedView => { - if (!view || typeof view !== "object" || Array.isArray(view)) return false; - const candidate = view as Record; - return ( - typeof candidate.name === "string" && - typeof candidate.filter === "string" && - typeof candidate.searchQuery === "string" - ); - }) - .map((view) => ({ ...view, name: view.name.trim() })) - .filter((view) => view.name.length > 0) + ? value.views.flatMap((view) => { + const normalized = normalizeView(view); + return normalized ? [normalized] : []; + }) : []; const strings = (candidate: unknown): string[] => Array.isArray(candidate) @@ -43,11 +67,13 @@ export function normalizeViewsFile(input: unknown): SavedViewsFile { .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 } : {}), }; } @@ -60,12 +86,27 @@ export async function readSavedViews(path: string): Promise { } export async function writeSavedViews(path: string, data: SavedViewsFile): Promise { - 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); - } + 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(): string { + const directory = process.env.PI_EXTMGR_CACHE_DIR + ? process.env.PI_EXTMGR_CACHE_DIR + : join(homedir(), ".pi", "agent", ".extmgr-cache"); + return join(directory, "views.json"); } diff --git a/test/views.test.ts b/test/views.test.ts index 6749a87..29fe4f9 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -13,18 +13,30 @@ void test("saved views normalize versioned view, favorite, and recent state", () ], favorites: [" demo ", 1], recent: Array.from({ length: 30 }, (_, index) => `pkg-${index}`), + lastView: { + name: "last-used", + filter: "favorites", + searchQuery: "demo", + selectedItemId: "pkg:demo", + 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"); }); 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 writeSavedViews(path, { version: 1, views: [], favorites: ["demo"], recent: [] }); + 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 { From 4b47b1ed18c995c5381200cd180e0e4731d7e55f Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:13:08 +0100 Subject: [PATCH 37/68] fix(trash): persist collision-safe undo records --- src/extensions/trash.ts | 147 ++++++++++++++++++++++++++++++++++- test/extension-trash.test.ts | 44 ++++++++++- 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/src/extensions/trash.ts b/src/extensions/trash.ts index 3e0ba5a..0a2178b 100644 --- a/src/extensions/trash.ts +++ b/src/extensions/trash.ts @@ -1,4 +1,4 @@ -import { mkdir, rename, rm } from "node:fs/promises"; +import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; export interface TrashRecord { @@ -7,18 +7,159 @@ export interface TrashRecord { trashedAt: number; } +interface TrashFile { + version: 1; + records: TrashRecord[]; +} + +const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const writeQueues = new Map>(); + +function recordsPath(trashRoot: string): string { + return join(trashRoot, "records.json"); +} + +function normalizeRecord(value: unknown): TrashRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + typeof record.originalPath !== "string" || + typeof record.trashPath !== "string" || + typeof record.trashedAt !== "number" || + !Number.isFinite(record.trashedAt) + ) { + return undefined; + } + return { + originalPath: record.originalPath, + trashPath: record.trashPath, + trashedAt: record.trashedAt, + }; +} + +function normalizeTrashFile(value: unknown): TrashFile { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: 1, records: [] }; + } + const recordsValue = (value as Record).records; + const records = Array.isArray(recordsValue) + ? recordsValue.flatMap((record: unknown) => { + const normalized = normalizeRecord(record); + return normalized ? [normalized] : []; + }) + : []; + return { version: 1, records }; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function readTrashFile(trashRoot: string): Promise { + try { + return normalizeTrashFile(JSON.parse(await readFile(recordsPath(trashRoot), "utf8"))); + } catch { + return { version: 1, records: [] }; + } +} + +async function writeTrashFile(trashRoot: string, file: TrashFile): Promise { + await mkdir(trashRoot, { recursive: true }); + 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 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 readTrashFile(trashRoot))) + ); + writeQueues.set( + trashRoot, + next.catch(() => undefined) + ); + await next; +} + export async function moveToExtensionTrash(path: string, trashRoot: string): Promise { await mkdir(trashRoot, { recursive: true }); - const trashPath = join(trashRoot, `${Date.now()}-${basename(path)}`); + let trashPath = ""; + for (let attempt = 0; attempt < 10; attempt++) { + trashPath = join( + trashRoot, + `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}-${basename(path)}` + ); + if (!(await fileExists(trashPath))) break; + } + await rename(path, trashPath); - return { originalPath: path, trashPath, trashedAt: Date.now() }; + const record: TrashRecord = { originalPath: path, trashPath, trashedAt: Date.now() }; + await updateTrashFile(trashRoot, (file) => ({ + version: 1, + records: [...file.records.filter((item) => item.trashPath !== trashPath), record], + })); + 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 undoExtensionTrash(record: TrashRecord): Promise { + 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 { 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/test/extension-trash.test.ts b/test/extension-trash.test.ts index 5e5e9bb..0fc975e 100644 --- a/test/extension-trash.test.ts +++ b/test/extension-trash.test.ts @@ -4,7 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { removeLocalExtension } from "../src/extensions/discovery.js"; -import { moveToExtensionTrash, undoExtensionTrash } from "../src/extensions/trash.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-")); @@ -30,6 +35,43 @@ void test("removeLocalExtension moves files to trash and exposes undo", async () } }); +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("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"); From 27afe6fbb7510dc53d0f70665f88b3c1e1985509 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:17:00 +0100 Subject: [PATCH 38/68] feat(manager): expose safe package scope moves --- src/packages/scopes.ts | 96 +++++++++++++++++++++++++++++++++++-- src/types/index.ts | 12 ++++- src/ui/footer.ts | 2 +- src/ui/unified.ts | 50 ++++++++++++++++++- test/package-scopes.test.ts | 86 ++++++++++++++++++++++++++++++++- 5 files changed, 239 insertions(+), 7 deletions(-) diff --git a/src/packages/scopes.ts b/src/packages/scopes.ts index 300c403..04913e7 100644 --- a/src/packages/scopes.ts +++ b/src/packages/scopes.ts @@ -1,3 +1,4 @@ +import { getAgentDir, SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type Scope } from "../types/index.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; @@ -14,7 +15,9 @@ export function comparePackageScopes(packages: InstalledPackage[]): PackageScope const byIdentity = new Map(); for (const pkg of packages) { - const identity = normalizePackageIdentity(pkg.source); + const identity = normalizePackageIdentity(pkg.source, { + ...(pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : {}), + }); const existing = byIdentity.get(identity) ?? { identity, name: pkg.name, @@ -28,8 +31,12 @@ export function comparePackageScopes(packages: InstalledPackage[]): PackageScope return [...byIdentity.values()] .map((entry) => { if (entry.global && entry.project) { - const globalSource = normalizePackageIdentity(entry.global.source); - const projectSource = normalizePackageIdentity(entry.project.source); + const globalSource = normalizePackageIdentity(entry.global.source, { + ...(entry.global.resolvedPath ? { resolvedPath: entry.global.resolvedPath } : {}), + }); + const projectSource = normalizePackageIdentity(entry.project.source, { + ...(entry.project.resolvedPath ? { resolvedPath: entry.project.resolvedPath } : {}), + }); return { ...entry, status: globalSource === projectSource ? ("overridden" as const) : ("different" as const), @@ -46,3 +53,86 @@ export function comparePackageScopes(packages: InstalledPackage[]): PackageScope export function getPackageScopeLabel(scope: Scope): string { return scope === "project" ? "project (.pi/settings.json)" : "global (~/.pi/agent/settings.json)"; } + +function sourceOf(value: PackageSource): string { + return typeof value === "string" ? value : value.source; +} + +function packageMatches(value: PackageSource, source: string, cwd: string, scope: Scope): boolean { + const baseCwd = scope === "project" ? cwd : getAgentDir(); + return ( + sourceOf(value) === source || + normalizePackageIdentity(source, { cwd: baseCwd }) === + normalizePackageIdentity(sourceOf(value), { cwd: baseCwd }) + ); +} + +export interface MovePackageScopeResult { + source: string; + from: Scope; + to: Scope; + moved: 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 +): Promise { + if (from === to) { + return { source, from, to, moved: false, conflict: "Package is already in that scope." }; + } + + const settings = SettingsManager.create(cwd, getAgentDir()); + 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 destinationIndex = destinationPackages.findIndex((candidate) => + packageMatches(candidate, sourceOf(entry), cwd, to) + ); + if (destinationIndex >= 0) { + const destinationEntry = destinationPackages[destinationIndex]; + if (destinationEntry && JSON.stringify(destinationEntry) !== JSON.stringify(entry)) { + return { + source, + from, + to, + moved: false, + conflict: "The destination has a different package configuration; no changes were made.", + }; + } + } else { + destinationPackages.push(structuredClone(entry)); + } + + if (to === "global") settings.setPackages(destinationPackages); + else settings.setProjectPackages(destinationPackages); + await settings.flush(); + + sourcePackages.splice(sourceIndex, 1); + if (from === "global") settings.setPackages(sourcePackages); + else settings.setProjectPackages(sourcePackages); + await settings.flush(); + + return { source: sourceOf(entry), from, to, moved: true }; +} diff --git a/src/types/index.ts b/src/types/index.ts index bbabde0..c1552a5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -110,7 +110,17 @@ export type UnifiedAction = | { type: "action"; itemId: string; - action?: "menu" | "update" | "remove" | "details" | "configure" | "enable" | "disable"; + action?: + | "menu" + | "update" + | "remove" + | "details" + | "configure" + | "enable" + | "disable" + | "compare" + | "move-global" + | "move-project"; }; export type BrowseAction = diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 759b545..378ac48 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -62,7 +62,7 @@ export function buildFooterShortcuts(state: FooterState): string { parts.push("Space select · B update selected"); parts.push("Enter/A actions"); parts.push("V details"); - parts.push("c configure"); + parts.push("c configure · scope actions in menu"); parts.push("u update"); parts.push("X remove"); } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 697a645..4fc726f 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -30,7 +30,7 @@ import { setExtensionState, } from "../extensions/discovery.js"; import { undoExtensionTrash } from "../extensions/trash.js"; -import { getInstalledPackages } from "../packages/discovery.js"; +import { getInstalledPackages, getInstalledPackagesAllScopes } from "../packages/discovery.js"; import { applyPackageExtensionStateChanges, discoverPackageExtensions, @@ -63,6 +63,7 @@ import { hasCustomUI, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; import { normalizePathIdentity } from "../utils/path-identity.js"; +import { comparePackageScopes, movePackageBetweenScopes } from "../packages/scopes.js"; import { markReloadRequired } from "../utils/reload-state.js"; import { getSavedViewsPath, @@ -1421,6 +1422,9 @@ const PACKAGE_ACTION_OPTIONS = { configure: "Configure extensions", enable: "Enable whole package", disable: "Disable whole package", + compare: "Compare scopes", + "move-global": "Move to global scope", + "move-project": "Move to project scope", update: "Update package", remove: "Remove package", details: "View details", @@ -1819,6 +1823,9 @@ async function handleUnifiedAction( 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>; @@ -1834,6 +1841,47 @@ async function handleUnifiedAction( if (pending === "stay") return "resume"; switch (selection) { + case "compare": { + const comparisons = comparePackageScopes(await getInstalledPackagesAllScopes(ctx)).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); + if (!moved.moved) { + ctx.ui.notify(`Package scope move failed: ${moved.conflict ?? "unknown error"}`, "error"); + return "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) { diff --git a/test/package-scopes.test.ts b/test/package-scopes.test.ts index d512013..4f2fe39 100644 --- a/test/package-scopes.test.ts +++ b/test/package-scopes.test.ts @@ -1,6 +1,13 @@ 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 { comparePackageScopes, getPackageScopeLabel } from "../src/packages/scopes.js"; +import { + comparePackageScopes, + getPackageScopeLabel, + movePackageBetweenScopes, +} from "../src/packages/scopes.js"; void test("comparePackageScopes identifies project overrides and scope-only packages", () => { const result = comparePackageScopes([ @@ -18,6 +25,83 @@ void test("comparePackageScopes identifies project overrides and scope-only pack ); }); +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); + 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); + 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("getPackageScopeLabel explains persisted package scope", () => { assert.match(getPackageScopeLabel("project"), /\.pi\/settings\.json/); assert.match(getPackageScopeLabel("global"), /\.pi\/agent\/settings\.json/); From f524ddc8064d6aa543e778baf53b225c959d3231 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:22:15 +0100 Subject: [PATCH 39/68] feat(profiles): persist and safely apply named profiles --- src/commands/profile.ts | 328 ++++++++++++++++++++++++++++++++++++--- src/commands/registry.ts | 4 +- src/profiles/checksum.ts | 25 +++ src/profiles/compare.ts | 60 ++++++- src/profiles/store.ts | 88 +++++++++++ test/profiles.test.ts | 46 +++++- 6 files changed, 525 insertions(+), 26 deletions(-) create mode 100644 src/profiles/checksum.ts create mode 100644 src/profiles/store.ts diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 751e89c..7d3d537 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -1,30 +1,96 @@ import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { + getAgentDir, + SettingsManager, + type ExtensionAPI, + type ExtensionCommandContext, + type PackageSource, +} from "@earendil-works/pi-coding-agent"; +import { getPackageCatalog } from "../packages/catalog.js"; import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; -import { planProfileApplication } from "../profiles/apply.js"; -import { type ExtmgrProfile, normalizeProfile } from "../profiles/schema.js"; +import { type InstalledPackage } from "../types/index.js"; +import { runTaskWithLoader } from "../ui/async-task.js"; import { notify } from "../utils/notify.js"; +import { parsePackageNameAndVersion, splitGitRepoAndRef } from "../utils/package-source.js"; +import { confirmAction, confirmReload } from "../utils/ui-helpers.js"; +import { checksumPackagePath, verifyPackageChecksum } from "../profiles/checksum.js"; +import { planProfileApplication, type ProfilePlan } from "../profiles/apply.js"; +import { loadProjectProfilePolicy, validateProfilePolicy } from "../profiles/compare.js"; +import { type ExtmgrProfile, type ProfilePackage, normalizeProfile } from "../profiles/schema.js"; +import { + getProfileStorePath, + readProfileStore, + saveNamedProfile, + deleteNamedProfile, +} from "../profiles/store.js"; -const PROFILE_USAGE = "Usage: /extensions profile "; +export const PROFILE_USAGE = + "Usage: /extensions profile [name|path]"; + +function sourceOf(value: PackageSource): string { + return typeof value === "string" ? value : value.source; +} + +function configuredPackage( + settings: ReturnType, + source: string +): PackageSource | undefined { + return settings.packages?.find((entry) => sourceOf(entry) === source); +} + +async function toProfilePackage( + pkg: InstalledPackage, + configured: PackageSource | undefined +): Promise { + const parsed = parsePackageNameAndVersion(pkg.source); + const gitRef = pkg.source.startsWith("git:") + ? splitGitRepoAndRef(pkg.source.slice(4)).ref + : undefined; + const filters = + configured && typeof configured === "object" && Array.isArray(configured.extensions) + ? configured.extensions.filter((filter): filter is string => typeof filter === "string") + : undefined; + const checksum = await checksumPackagePath(pkg.resolvedPath); + return { + source: pkg.source, + scope: pkg.scope, + ...(parsed.version ? { version: parsed.version } : {}), + ...(gitRef ? { ref: gitRef } : {}), + ...(filters ? { filters } : {}), + ...(checksum ? { checksum } : {}), + }; +} async function currentProfile(ctx: ExtensionCommandContext): Promise { const packages = await getInstalledPackagesAllScopes(ctx); - return normalizeProfile({ - name: "current", - packages: packages.map((pkg) => ({ - source: pkg.source, - scope: pkg.scope, - ...(pkg.version ? { version: pkg.version } : {}), - })), - }); + const settings = SettingsManager.create(ctx.cwd, getAgentDir()); + const profiles = await Promise.all( + packages.map((pkg) => { + const scopedSettings = + pkg.scope === "project" ? settings.getProjectSettings() : settings.getGlobalSettings(); + return toProfilePackage(pkg, configuredPackage(scopedSettings, pkg.source)); + }) + ); + return normalizeProfile({ name: "current", packages: profiles }); } async function readProfile(path: string): Promise { - return normalizeProfile(JSON.parse(await readFile(path, "utf8"))); + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Profile must be a JSON object."); + } + const value = parsed as Record; + if (value.schemaVersion !== 1) { + throw new Error("Unsupported or missing profile schemaVersion; expected 1."); + } + if (!Array.isArray(value.packages)) { + throw new Error("Profile packages must be an array."); + } + return normalizeProfile(parsed); } -function formatPlan(plan: ReturnType): string { +function formatPlan(plan: ProfilePlan): string { return [ `Add: ${plan.add.length}`, ...plan.add.map((pkg) => ` + ${pkg.source} (${pkg.scope})`), @@ -35,28 +101,246 @@ function formatPlan(plan: ReturnType): string { ].join("\n"); } +async function resolveNamedOrPathProfile( + requested: string | undefined, + ctx: ExtensionCommandContext +): Promise { + if (requested) { + const path = resolve(ctx.cwd, requested); + try { + return await readProfile(path); + } catch (error) { + if (!ctx.hasUI) throw error; + const store = await readProfileStore(getProfileStorePath()); + const named = store.profiles[requested]; + if (named) return named; + throw error; + } + } + + if (!ctx.hasUI) return undefined; + const store = await readProfileStore(getProfileStorePath()); + 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 ? store.profiles[choice] : undefined; +} + +function configuredEntry( + settings: ReturnType, + desired: ProfilePackage +): PackageSource | undefined { + return settings.packages?.find((entry) => sourceOf(entry) === desired.source); +} + +function buildScopedPackageSettings( + settings: ReturnType, + desired: ProfilePackage[] +): PackageSource[] { + return desired.map((pkg) => { + const existing = configuredEntry(settings, pkg); + if (existing && typeof existing === "object") { + return { + ...existing, + source: pkg.source, + ...(pkg.filters ? { extensions: [...pkg.filters] } : {}), + }; + } + return pkg.filters ? { source: pkg.source, extensions: [...pkg.filters] } : pkg.source; + }); +} + +async function persistProfileConfiguration( + desired: ExtmgrProfile, + ctx: ExtensionCommandContext +): Promise { + const settings = SettingsManager.create(ctx.cwd, getAgentDir()); + const global = settings.getGlobalSettings(); + const project = settings.getProjectSettings(); + settings.setPackages( + buildScopedPackageSettings( + global, + desired.packages.filter((pkg) => pkg.scope === "global") + ) + ); + settings.setProjectPackages( + buildScopedPackageSettings( + project, + desired.packages.filter((pkg) => pkg.scope === "project") + ) + ); + await settings.flush(); +} + +async function verifyProfileSafety( + current: ExtmgrProfile, + desired: ExtmgrProfile, + ctx: ExtensionCommandContext +): Promise { + const currentPackages = await getInstalledPackagesAllScopes(ctx); + const bySource = new Map(currentPackages.map((pkg) => [`${pkg.scope}\0${pkg.source}`, pkg])); + const problems: string[] = []; + for (const pkg of desired.packages) { + if (!pkg.checksum) { + problems.push(`${pkg.source} (${pkg.scope}): checksum metadata is unknown`); + continue; + } + const installed = bySource.get(`${pkg.scope}\0${pkg.source}`); + const result = await verifyPackageChecksum(installed?.resolvedPath, pkg.checksum); + if (result === "unknown") problems.push(`${pkg.source}: installed package metadata is unknown`); + else if (result === "mismatch") problems.push(`${pkg.source}: checksum mismatch`); + } + if (current.schemaVersion !== 1 || desired.schemaVersion !== 1) { + problems.push("profile schema is unknown"); + } + return problems; +} + +async function applyProfileFromCommand( + current: ExtmgrProfile, + desired: ExtmgrProfile, + ctx: ExtensionCommandContext, + pi: ExtensionAPI +): Promise { + const policy = await loadProjectProfilePolicy(ctx.cwd); + const violations = policy ? validateProfilePolicy(desired, policy) : []; + if (violations.length > 0) { + notify( + ctx, + `Profile policy rejected application:\n${violations.map((v) => `- ${v.message}`).join("\n")}`, + "error" + ); + return; + } + + const safetyProblems = await verifyProfileSafety(current, desired, ctx); + if (safetyProblems.length > 0) { + notify( + ctx, + `Profile application is not safe:\n${safetyProblems.map((problem) => `- ${problem}`).join("\n")}`, + "error" + ); + return; + } + + const plan = planProfileApplication(current, desired); + if (plan.add.length + plan.remove.length + plan.update.length === 0) { + notify(ctx, "Profile already matches the installed package state.", "info"); + return; + } + + if ( + !(await confirmAction( + ctx, + "Apply profile", + `${desired.name}\n\n${formatPlan(plan)}\n\nApply these changes?` + )) + ) { + notify(ctx, "Profile application cancelled.", "info"); + return; + } + + await runTaskWithLoader( + ctx, + { title: "Apply profile", message: `Applying ${desired.name}...`, cancellable: false }, + async ({ setMessage }) => { + const catalog = getPackageCatalog(ctx.cwd); + for (const pkg of plan.remove) { + setMessage(`Removing ${pkg.source}...`); + await catalog.remove(pkg.source, pkg.scope); + } + for (const pkg of plan.add) { + setMessage(`Installing ${pkg.source}...`); + await catalog.install(pkg.source, pkg.scope); + } + for (const change of plan.update) { + if (change.from.source === change.to.source) continue; + setMessage(`Changing ${change.to.source}...`); + await catalog.remove(change.from.source, change.from.scope); + await catalog.install(change.to.source, change.to.scope); + } + setMessage("Preserving package settings and filters..."); + await persistProfileConfiguration(desired, ctx); + return undefined; + } + ); + + notify(ctx, `Applied profile ${desired.name}.`, "info"); + await confirmReload(ctx, "Profile package configuration changed."); + void pi; +} + export async function handleProfileSubcommand( tokens: string[], - ctx: ExtensionCommandContext + ctx: ExtensionCommandContext, + pi?: ExtensionAPI ): Promise { const action = tokens[0]; - const requestedPath = tokens[1]; - if (!action || !requestedPath || !["export", "dry-run", "compare"].includes(action)) { + const requested = tokens[1]; + if ( + !action || + !["export", "save", "list", "delete", "dry-run", "apply", "compare"].includes(action) + ) { notify(ctx, PROFILE_USAGE, "info"); return; } - const path = resolve(ctx.cwd, requestedPath); try { + const storePath = getProfileStorePath(); + if (action === "list") { + const names = Object.keys((await readProfileStore(storePath)).profiles).sort(); + notify( + ctx, + names.length > 0 ? `Saved profiles:\n${names.join("\n")}` : "No saved profiles.", + "info" + ); + return; + } + if (action === "save") { + if (!requested) { + notify(ctx, "Usage: /extensions profile save ", "info"); + return; + } + const profile = await currentProfile(ctx); + profile.name = requested; + await saveNamedProfile(storePath, profile); + notify(ctx, `Saved profile ${requested}.`, "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 currentProfile(ctx); if (action === "export") { - await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, { flag: "wx" }); - notify(ctx, `Exported profile to ${path}`, "info"); + if (!requested) { + notify(ctx, "Usage: /extensions profile export ", "info"); + return; + } + await writeFile(resolve(ctx.cwd, requested), `${JSON.stringify(current, null, 2)}\n`, { + flag: "wx", + }); + notify(ctx, `Exported profile to ${resolve(ctx.cwd, requested)}`, "info"); return; } - const desired = await readProfile(path); - notify(ctx, formatPlan(planProfileApplication(current, desired)), "info"); + const desired = await resolveNamedOrPathProfile(requested, ctx); + if (!desired) { + notify(ctx, "No saved profile selected.", "info"); + return; + } + const plan = planProfileApplication(current, desired); + if (action === "apply") { + if (!pi) throw new Error("Profile application requires the extension API."); + await applyProfileFromCommand(current, desired, ctx, pi); + return; + } + notify(ctx, formatPlan(plan), "info"); } catch (error) { notify( ctx, diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 4611c9e..13e74d3 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -155,8 +155,8 @@ const COMMAND_DEFINITIONS: Record = { profile: { id: "profile", description: "Export, compare, or dry-run a package profile", - runInteractive: (tokens, ctx) => handleProfileSubcommand(tokens, ctx), - runNonInteractive: (tokens, ctx) => handleProfileSubcommand(tokens, ctx), + runInteractive: (tokens, ctx, pi) => handleProfileSubcommand(tokens, ctx, pi), + runNonInteractive: (tokens, ctx, pi) => handleProfileSubcommand(tokens, ctx, pi), }, }; diff --git a/src/profiles/checksum.ts b/src/profiles/checksum.ts new file mode 100644 index 0000000..34a8151 --- /dev/null +++ b/src/profiles/checksum.ts @@ -0,0 +1,25 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export async function checksumPackagePath(path: string | undefined): Promise { + if (!path) return undefined; + try { + const manifest = await readFile( + /(?:^|[\\/])package\.json$/i.test(path) ? path : join(path, "package.json") + ); + return `sha256:${createHash("sha256").update(manifest).digest("hex")}`; + } catch { + return undefined; + } +} + +export async function verifyPackageChecksum( + path: string | undefined, + expected: string | undefined +): Promise<"match" | "mismatch" | "unknown"> { + if (!expected) return "unknown"; + const actual = await checksumPackagePath(path); + if (!actual) return "unknown"; + return actual === expected ? "match" : "mismatch"; +} diff --git a/src/profiles/compare.ts b/src/profiles/compare.ts index 8cd3334..8f07c5d 100644 --- a/src/profiles/compare.ts +++ b/src/profiles/compare.ts @@ -1,8 +1,13 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { planProfileApplication } from "./apply.js"; import { type ExtmgrProfile } from "./schema.js"; export interface ProfilePolicy { allowedScopes?: Array<"global" | "project">; + allowedSources?: string[]; + forbiddenSources?: string[]; + requiredPackages?: string[]; requireChecksums?: boolean; requireCompatibilityCheck?: boolean; } @@ -28,8 +33,19 @@ export function validateProfilePolicy( if (policy.allowedScopes && !policy.allowedScopes.includes(pkg.scope)) { violations.push({ packageSource: pkg.source, message: `scope ${pkg.scope} is not allowed` }); } + if (policy.allowedSources && !policy.allowedSources.includes(pkg.source)) { + violations.push({ packageSource: pkg.source, message: "source is not allowed" }); + } + if (policy.forbiddenSources?.includes(pkg.source)) { + violations.push({ packageSource: pkg.source, message: "source is forbidden" }); + } if (policy.requireChecksums && !pkg.checksum) { - violations.push({ packageSource: pkg.source, message: "checksum is required" }); + violations.push({ packageSource: pkg.source, message: "checksum is unknown" }); + } + } + for (const requiredSource of policy.requiredPackages ?? []) { + if (!profile.packages.some((pkg) => pkg.source === requiredSource)) { + violations.push({ packageSource: requiredSource, message: "required package is missing" }); } } if (policy.requireCompatibilityCheck && profile.checks?.compatibility !== true) { @@ -37,3 +53,45 @@ export function validateProfilePolicy( } return violations; } + +export async function loadProjectProfilePolicy( + cwd: string, + path?: string +): Promise { + const policyPath = path ?? join(cwd, ".pi", "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.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/store.ts b/src/profiles/store.ts new file mode 100644 index 0000000..71934e1 --- /dev/null +++ b/src/profiles/store.ts @@ -0,0 +1,88 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { type ExtmgrProfile, normalizeProfile } from "./schema.js"; + +export interface ProfileStoreFile { + version: 1; + profiles: Record; +} + +const writeQueues = new Map>(); + +function emptyStore(): ProfileStoreFile { + return { version: 1, profiles: {} }; +} + +export function normalizeProfileStore(input: unknown): ProfileStoreFile { + if (!input || typeof input !== "object" || Array.isArray(input)) return emptyStore(); + const profilesValue = (input as Record).profiles; + if (!profilesValue || typeof profilesValue !== "object" || Array.isArray(profilesValue)) { + return emptyStore(); + } + const profiles: Record = {}; + for (const [name, value] of Object.entries(profilesValue)) { + if (!name.trim()) continue; + const profile = normalizeProfile(value); + if (profile.packages.length > 0 || profile.name !== "unnamed") { + profiles[name.trim()] = { ...profile, name: name.trim() }; + } + } + return { version: 1, profiles }; +} + +export async function readProfileStore(path: string): Promise { + try { + return normalizeProfileStore(JSON.parse(await readFile(path, "utf8"))); + } catch { + return emptyStore(); + } +} + +export async function writeProfileStore(path: string, store: ProfileStoreFile): Promise { + const previous = writeQueues.get(path) ?? Promise.resolve(); + const next = previous.then(async () => { + await mkdir(dirname(path), { recursive: true }); + const temporary = join(dirname(path), `.${process.pid}.${Date.now()}.profiles.tmp`); + try { + await writeFile( + temporary, + `${JSON.stringify(normalizeProfileStore(store), null, 2)}\n`, + "utf8" + ); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } + }); + writeQueues.set( + path, + next.catch(() => undefined) + ); + await next; +} + +export async function saveNamedProfile( + path: string, + profile: ExtmgrProfile +): Promise { + const store = await readProfileStore(path); + const normalized = normalizeProfile(profile); + store.profiles[normalized.name] = normalized; + await writeProfileStore(path, store); + return store; +} + +export async function deleteNamedProfile(path: string, name: string): Promise { + const store = await readProfileStore(path); + if (!store.profiles[name]) return false; + delete store.profiles[name]; + await writeProfileStore(path, store); + return true; +} + +export function getProfileStorePath(): string { + const directory = process.env.PI_EXTMGR_CACHE_DIR + ? process.env.PI_EXTMGR_CACHE_DIR + : join(process.env.HOME ?? "~", ".pi", "agent", ".extmgr-cache"); + return join(directory, "profiles.json"); +} diff --git a/test/profiles.test.ts b/test/profiles.test.ts index 5b29762..3c59578 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -1,7 +1,15 @@ import assert from "node:assert/strict"; +import { mkdtemp, 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, validateProfilePolicy } from "../src/profiles/compare.js"; +import { + compareProfiles, + loadProjectProfilePolicy, + validateProfilePolicy, +} from "../src/profiles/compare.js"; +import { deleteNamedProfile, readProfileStore, saveNamedProfile } from "../src/profiles/store.js"; import { normalizeProfile } from "../src/profiles/schema.js"; void test("profile application produces a dry-run plan without mutating state", async () => { @@ -45,6 +53,42 @@ void test("profile comparison and policy validation expose actionable difference ); }); +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("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, From ad975d6f6e8cdfa1efce6ce091bed3bdaf0e35b2 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:24:19 +0100 Subject: [PATCH 40/68] feat(doctor): diagnose installed package compatibility --- src/commands/registry.ts | 15 +++++++++ src/doctor/compatibility.ts | 54 +++++++++++++++++++++++++++++++ test/doctor-compatibility.test.ts | 18 ++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 13e74d3..c7452f4 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -1,7 +1,9 @@ import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { type AutocompleteItem } from "@earendil-works/pi-tui"; +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"; @@ -19,7 +21,20 @@ 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 { diff --git a/src/doctor/compatibility.ts b/src/doctor/compatibility.ts index cf0dd55..a1f4c1f 100644 --- a/src/doctor/compatibility.ts +++ b/src/doctor/compatibility.ts @@ -1,3 +1,7 @@ +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 }; @@ -40,3 +44,53 @@ export function validateCompatibility(input: CompatibilityInput): CompatibilityD if (pi === "incompatible") reasons.push(`requires Pi ${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/test/doctor-compatibility.test.ts b/test/doctor-compatibility.test.ts index 5e7c61f..39b5bd5 100644 --- a/test/doctor-compatibility.test.ts +++ b/test/doctor-compatibility.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { validateCompatibility } from "../src/doctor/compatibility.js"; +import { + inspectInstalledPackageCompatibility, + validateCompatibility, +} from "../src/doctor/compatibility.js"; void test("compatibility diagnostics reject packages requiring newer runtimes", () => { const diagnostic = validateCompatibility({ @@ -15,6 +18,19 @@ void test("compatibility diagnostics reject packages requiring newer runtimes", 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"); From 6a726d141bef385a9e9ff486fa73c8cf9b5e6bab Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:40:09 +0100 Subject: [PATCH 41/68] feat(remote): require consolidated install review --- src/packages/install.ts | 21 +++++------ src/ui/remote.ts | 78 +++++++++++++++++++++++++++++++++++++++-- test/remote-ui.test.ts | 61 +++++++++++++++++++++----------- 3 files changed, 127 insertions(+), 33 deletions(-) diff --git a/src/packages/install.ts b/src/packages/install.ts index 3335929..99df6e1 100644 --- a/src/packages/install.ts +++ b/src/packages/install.ts @@ -35,6 +35,7 @@ export type InstallScope = "global" | "project"; export interface InstallOptions { scope?: InstallScope; + skipConfirmation?: boolean; } export interface InstallOutcome { @@ -183,11 +184,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 }; @@ -353,11 +352,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 }; diff --git a/src/ui/remote.ts b/src/ui/remote.ts index c1e0a3d..64719e0 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -35,6 +35,7 @@ import { installPackageWithOutcome, } from "../packages/install.js"; import { type RemotePackageSort, sortRemotePackages } from "../packages/sorting.js"; +import { validateCompatibility } from "../doctor/compatibility.js"; import { type BrowseAction, type NpmPackage } from "../types/index.js"; import { getKnownUpdates } from "../utils/auto-update.js"; import { parseChoiceByLabel, splitCommandArgs } from "../utils/command.js"; @@ -60,6 +61,7 @@ interface NpmViewInfo { dist?: { unpackedSize?: number }; repository?: { url?: string } | string; dependencies?: Record; + engines?: { node?: string; pi?: string }; } interface NpmDownloadsPoint { @@ -341,6 +343,19 @@ async function buildPackageInfoText( ...(info.description ? { description: info.description } : {}), ...(info.dependencies ? { dependencies: info.dependencies } : {}), ...(repository ? { repository } : {}), + ...(info.engines?.node + ? { + compatibility: + validateCompatibility({ + packageName, + engines: { node: info.engines.node }, + ...(info.engines.pi ? { requiredPi: info.engines.pi } : {}), + nodeVersion: process.version, + }).node === "compatible" + ? ("compatible" as const) + : ("incompatible" as const), + } + : {}), }); const lines = [ @@ -997,6 +1012,55 @@ async function browseRemotePackagesPage({ } } +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: "Project (.pi/settings.json)", + cancel: "Cancel", + }, + await ctx.ui.select("Install scope", [ + "Global (~/.pi/agent/settings.json)", + "Project (.pi/settings.json)", + "Cancel", + ]) + ); + if (!scopeChoice || scopeChoice === "cancel") return undefined; + + const info = 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, @@ -1017,7 +1081,12 @@ async function showPackageDetails( switch (choice) { case "installManaged": { - const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi); + const scope = await confirmMarketplaceInstall(packageName, ctx, pi, "managed"); + if (!scope) return; + const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi, { + scope, + skipConfirmation: true, + }); if (outcome.reloaded) { return; } @@ -1029,7 +1098,12 @@ async function showPackageDetails( return; } case "installStandalone": { - const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi); + const scope = await confirmMarketplaceInstall(packageName, ctx, pi, "standalone"); + if (!scope) return; + const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi, { + scope, + skipConfirmation: true, + }); if (outcome.reloaded) { return; } diff --git a/test/remote-ui.test.ts b/test/remote-ui.test.ts index 8b7aa34..60645fe 100644 --- a/test/remote-ui.test.ts +++ b/test/remote-ui.test.ts @@ -498,33 +498,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; From 505751c995be184a76a04f00bb4405d2cf425963 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:42:07 +0100 Subject: [PATCH 42/68] feat(cli): complete local package and profile names --- src/commands/completion.ts | 41 ++++++++++++++++++++ src/commands/registry.ts | 62 +++++++++++++++++++++++------- src/index.ts | 7 ++++ test/command-orchestration.test.ts | 36 +++++++++++++++++ 4 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 src/commands/completion.ts diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..82e15b3 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,41 @@ +import { getAgentDir, SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; +import { getProfileStorePath, readProfileStore } from "../profiles/store.js"; + +export interface LocalCompletionIndex { + installedPackages: string[]; + savedProfiles: string[]; +} + +let index: LocalCompletionIndex = { installedPackages: [], savedProfiles: [] }; + +function sourceOf(source: PackageSource): string { + return typeof source === "string" ? source : source.source; +} + +export async function refreshLocalCompletionIndex(cwd: string): Promise { + const settings = SettingsManager.create(cwd, getAgentDir()); + const installedPackages = [ + ...(settings.getGlobalSettings().packages ?? []), + ...(settings.getProjectSettings().packages ?? []), + ].map(sourceOf); + 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/registry.ts b/src/commands/registry.ts index c7452f4..fd6d972 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -9,6 +9,7 @@ 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"; @@ -212,24 +213,59 @@ 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 normalizedPrefix = prefix ?? ""; - const commandPrefix = normalizedPrefix.trimStart(); - const whitespace = commandPrefix.lastIndexOf(" "); - if (whitespace >= 0) { - const command = commandPrefix.slice(0, whitespace).replace(/^\//, "").split(/\s+/)[0] ?? ""; - const argumentPrefix = commandPrefix.slice(whitespace + 1).toLowerCase(); + 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"]; + 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); + } + 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"], - remove: ["--global", "--project"], - update: ["--all", "--preview"], "auto-update": ["daily", "weekly", "monthly", "never"], - history: ["--failed", "--success", "--global", "--limit", "--since"], - profile: ["export", "dry-run", "compare"], }; - const options = argumentOptions[command] ?? []; - const matches = options.filter((option) => option.startsWith(argumentPrefix)); - return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null; + return completionItems(argumentOptions[command] ?? [], activePrefix); } const items = Object.values(COMMAND_DEFINITIONS).flatMap((def) => { diff --git a/src/index.ts b/src/index.ts index 26f195a..a0a691b 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, @@ -59,6 +60,9 @@ export default function extensionsManager(pi: ExtensionAPI) { getArgumentCompletions: getExtensionsAutocompleteItems, handler: async (args, ctx) => { await executeExtensionsCommand(args, ctx, pi); + await refreshLocalCompletionIndex(ctx.cwd).catch((error) => { + console.warn("[extmgr] Failed to refresh local completions:", error); + }); }, }); @@ -69,6 +73,9 @@ 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).catch((error) => { + console.warn("[extmgr] Failed to load local completions:", error); + }); if (!ctx.hasUI) { stopAutoUpdateTimer(); diff --git a/test/command-orchestration.test.ts b/test/command-orchestration.test.ts index ee35e25..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, @@ -42,6 +43,41 @@ void test("autocomplete offers static command arguments without network requests ); }); +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({ From 70f659eb2f416c2e9bf7a3db194902388c0a59ab Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:51:14 +0100 Subject: [PATCH 43/68] feat(manager): coordinate persistent bulk actions --- src/types/index.ts | 6 +- src/ui/footer.ts | 11 +++- src/ui/unified.ts | 138 ++++++++++++++++++++++++++++++++++++---- src/utils/views.ts | 14 +++- test/unified-ui.test.ts | 56 ++++++++++++++++ test/views.test.ts | 2 + 6 files changed, 211 insertions(+), 16 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index c1552a5..4a60f37 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -100,7 +100,11 @@ export type UnifiedAction = | { type: "remote" } | { type: "help" } | { type: "menu" } - | { type: "bulk"; itemIds: string[]; action: "update" | "remove" } + | { + type: "bulk"; + itemIds: string[]; + action: "menu" | "update" | "remove" | "enable" | "disable"; + } | { type: "views"; action: "save" | "load" | "delete" | "favorite"; diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 378ac48..3660745 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -7,15 +7,18 @@ export interface FooterState { selectedType?: UnifiedItem["type"]; expandable: boolean; 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, expandable: selectedItem?.type === "package" && Boolean(selectedItem.extensionPaths?.length), }; @@ -59,7 +62,7 @@ export function buildFooterShortcuts(state: FooterState): string { if (state.selectedType === "package") { if (state.expandable) parts.push("E expand"); - parts.push("Space select · B update selected"); + parts.push("Space select · B bulk actions"); parts.push("Enter/A actions"); parts.push("V details"); parts.push("c configure · scope actions in menu"); @@ -67,6 +70,10 @@ export function buildFooterShortcuts(state: FooterState): string { parts.push("X remove"); } + if (state.selectedPackages > 0) { + parts.push(`B bulk actions (${state.selectedPackages})`); + } + if (state.pendingChanges > 0) { parts.push(`S save (${state.pendingChanges})`); } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 4fc726f..a073454 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -30,6 +30,7 @@ import { setExtensionState, } from "../extensions/discovery.js"; import { undoExtensionTrash } from "../extensions/trash.js"; +import { getPackageCatalog } from "../packages/catalog.js"; import { getInstalledPackages, getInstalledPackagesAllScopes } from "../packages/discovery.js"; import { applyPackageExtensionStateChanges, @@ -156,7 +157,7 @@ async function showInteractiveOnce( } const { localEntries, installedPackages, packageExtensions } = initialData; - const viewsPath = getSavedViewsPath(); + const viewsPath = getSavedViewsPath(ctx.cwd); let savedViews = await readSavedViews(viewsPath); // Build unified items list. @@ -231,12 +232,20 @@ async function showInteractiveOnce( 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() + ) + ) ) ); }; @@ -455,6 +464,7 @@ function buildManagerSummary( visibleItems?: readonly UnifiedItem[]; filter?: UnifiedFilter; searchQuery?: string; + selectedCount?: number; } ): string { const summaryItems = options?.visibleItems ?? items; @@ -501,6 +511,10 @@ function buildManagerSummary( parts.push(theme.fg("warning", `${pendingCount} unsaved`)); } + if ((options?.selectedCount ?? 0) > 0) { + parts.push(theme.fg("accent", `${options?.selectedCount} selected`)); + } + return parts.join(" • "); } @@ -510,6 +524,7 @@ interface UnifiedManagerViewState { filter: UnifiedFilter; searchQuery: string; selectedItemId?: string; + selectedItemIds: string[]; } const UNIFIED_FILTER_OPTIONS: Array<{ id: UnifiedFilter; key: string; label: string }> = [ @@ -532,6 +547,7 @@ function viewToManagerState(view: SavedView | undefined): UnifiedManagerViewStat filter: view.filter, searchQuery: view.searchQuery, ...(view.selectedItemId ? { selectedItemId: view.selectedItemId } : {}), + selectedItemIds: [...(view.selectedItemIds ?? [])], }; } @@ -545,6 +561,7 @@ function managerStateToView( filter: state.filter, searchQuery: state.searchQuery, ...(state.selectedItemId ? { selectedItemId: state.selectedItemId } : {}), + selectedItemIds: [...state.selectedItemIds], createdAt: now, updatedAt: now, }; @@ -829,6 +846,11 @@ class UnifiedManagerBrowser implements Focusable { 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); @@ -863,12 +885,17 @@ class UnifiedManagerBrowser implements Focusable { 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], }; } @@ -972,7 +999,7 @@ class UnifiedManagerBrowser implements Focusable { this.onAction({ type: "bulk", itemIds: [...this.bulkSelectedIds], - action: "update", + action: "menu", }); return true; } @@ -1418,6 +1445,14 @@ const LOCAL_ACTION_OPTIONS = { back: "Back to manager", } 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 = { configure: "Configure extensions", enable: "Enable whole package", @@ -1655,18 +1690,99 @@ async function handleUnifiedAction( ); 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( - "Update selected packages", - `Update ${selectedPackages.length} selected package(s)?` + "Bulk package operation", + `${BULK_ACTION_OPTIONS[action]} for ${selectedPackages.length} package(s)?` ); if (!confirmed) return "resume"; - for (const item of selectedPackages) { - const outcome = await updatePackageWithOutcome(item.source, ctx, pi); - if (outcome.reloaded) return true; - } - ctx.ui.notify(`Processed ${selectedPackages.length} selected package(s).`, "info"); - 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); + 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 + ); + 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.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 ? true : false; } if (result.type === "remote") { diff --git a/src/utils/views.ts b/src/utils/views.ts index d891d23..b3af995 100644 --- a/src/utils/views.ts +++ b/src/utils/views.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; @@ -7,6 +8,7 @@ export interface SavedView { filter: string; searchQuery: string; selectedItemId?: string; + selectedItemIds?: string[]; createdAt: number; updatedAt: number; } @@ -45,6 +47,13 @@ export function normalizeViewsFile(input: unknown): SavedViewsFile { ...(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 @@ -104,9 +113,10 @@ export async function writeSavedViews(path: string, data: SavedViewsFile): Promi await write; } -export function getSavedViewsPath(): string { +export function getSavedViewsPath(cwd?: string): string { const directory = process.env.PI_EXTMGR_CACHE_DIR ? process.env.PI_EXTMGR_CACHE_DIR : join(homedir(), ".pi", "agent", ".extmgr-cache"); - return join(directory, "views.json"); + const suffix = cwd ? `-${createHash("sha256").update(cwd).digest("hex").slice(0, 12)}` : ""; + return join(directory, `views${suffix}.json`); } diff --git a/test/unified-ui.test.ts b/test/unified-ui.test.ts index ee64f1f..eba1e04 100644 --- a/test/unified-ui.test.ts +++ b/test/unified-ui.test.ts @@ -539,6 +539,62 @@ 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") + ) + ); + } 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"); diff --git a/test/views.test.ts b/test/views.test.ts index 29fe4f9..dfaad40 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -18,6 +18,7 @@ void test("saved views normalize versioned view, favorite, and recent state", () filter: "favorites", searchQuery: "demo", selectedItemId: "pkg:demo", + selectedItemIds: ["pkg:demo", "pkg:other"], createdAt: 1, updatedAt: 2, }, @@ -27,6 +28,7 @@ void test("saved views normalize versioned view, favorite, and recent state", () 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 () => { From 49607e6fe53f9c6677a0739b8dac3cf6da48c781 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:55:21 +0100 Subject: [PATCH 44/68] fix(ui): suppress stale async rendering --- src/ui/async-task.ts | 41 +++++++++++++++++++ src/ui/remote.ts | 91 ++++++++++++++++++++++++----------------- src/ui/unified.ts | 2 +- test/async-task.test.ts | 71 +++++++++++++++++++++++++++++++- test/remote-ui.test.ts | 32 ++++++++++++++- 5 files changed, 197 insertions(+), 40 deletions(-) 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/remote.ts b/src/ui/remote.ts index 64719e0..fa83593 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -45,7 +45,7 @@ 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"; +import { RequestGeneration, runTaskWithLoader } from "./async-task.js"; interface PackageInfoCacheEntry { timestamp: number; @@ -121,6 +121,7 @@ const packageInfoCache = new PackageInfoCache( CACHE_LIMITS.packageInfoMaxSize, CACHE_LIMITS.packageInfoTTL ); +const packageInfoRequests = new RequestGeneration(); export function clearRemotePackageInfoCache(): void { packageInfoCache.clear(); @@ -315,15 +316,16 @@ async function buildPackageInfoText( return cached.text; } + const request = packageInfoRequests.begin(signal); const [infoRes, weeklyDownloads] = await Promise.all([ execNpm(pi, ["view", packageName, "--json"], ctx, { timeout: TIMEOUTS.npmView, - ...(signal ? { signal } : {}), + signal: request.signal, }), - fetchWeeklyDownloads(packageName, signal), + fetchWeeklyDownloads(packageName, request.signal), ]); - throwIfAborted(signal); + throwIfAborted(request.signal); if (infoRes.code !== 0) { throw new Error(infoRes.stderr || infoRes.stdout || `npm view failed (exit ${infoRes.code})`); @@ -375,8 +377,10 @@ async function buildPackageInfoText( const text = lines.join("\n"); - throwIfAborted(signal); - packageInfoCache.set(packageName, { text }); + throwIfAborted(request.signal); + if (!request.commit(() => packageInfoCache.set(packageName, { text }))) { + throw createAbortError(); + } return text; } @@ -613,42 +617,51 @@ class RemotePackageBrowser implements Focusable { } render(width: number): string[] { + const safeWidth = Math.max(1, width); const lines: string[] = []; if (this.searchActive) { - lines.push(...this.searchInput.render(width)); + lines.push(...this.searchInput.render(safeWidth)); lines.push(""); } else if (this.queryLabel) { lines.push( - truncateToWidth(this.theme.fg("accent", ` Search: ${this.queryLabel}`), width, "") + truncateToWidth(this.theme.fg("accent", ` Search: ${this.queryLabel}`), safeWidth, "") ); lines.push(""); } else { lines.push( - this.theme.fg( - "muted", - this.browseSource === "community" - ? " Browse community packages · / search to search community packages" - : " Browse remote search results · / search to search npm packages" + truncateToWidth( + this.theme.fg( + "muted", + this.browseSource === "community" + ? " Browse community packages · / search to search community packages" + : " Browse remote search results · / search to search npm packages" + ), + safeWidth, + "" ) ); lines.push(""); } - lines.push(truncateToWidth(this.buildSummaryLine(), width, "")); + lines.push(truncateToWidth(this.buildSummaryLine(), safeWidth, "")); lines.push(""); const { startIndex, endIndex } = this.getVisibleRange(); for (const pkg of this.packages.slice(startIndex, endIndex)) { - lines.push(this.renderPackageLine(pkg, width)); + lines.push(this.renderPackageLine(pkg, safeWidth)); } 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` + truncateToWidth( + this.theme.fg( + "dim", + ` Showing ${startIndex + 1}-${endIndex} of ${this.packages.length} on this page` + ), + safeWidth, + "" ) ); } @@ -661,13 +674,13 @@ class RemotePackageBrowser implements Focusable { this.offset + this.selectedIndex + 1, this.totalResults ); - for (const line of wrapTextWithAnsi(detailText, width - 4)) { - lines.push(this.theme.fg("dim", ` ${line}`)); + 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(), width, "")); + lines.push(truncateToWidth(this.buildFooterLine(), safeWidth, "")); return lines; } @@ -1032,14 +1045,16 @@ async function confirmMarketplaceInstall( ); if (!scopeChoice || scopeChoice === "cancel") return undefined; - const info = await runTaskWithLoader( - ctx, - { - title: "Pre-install review", - message: `Inspecting ${packageName} metadata...`, - }, - ({ signal }) => buildPackageInfoText(packageName, ctx, pi, signal) - ); + 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; @@ -1116,14 +1131,16 @@ async function showPackageDetails( } 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"); diff --git a/src/ui/unified.ts b/src/ui/unified.ts index a073454..ad36328 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -1782,7 +1782,7 @@ async function handleUnifiedAction( if (results.completed.length === 0) return "resume"; const reloaded = await confirmReload(ctx, "Bulk package changes completed."); - return reloaded ? true : false; + return reloaded; } if (result.type === "remote") { 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/remote-ui.test.ts b/test/remote-ui.test.ts index 60645fe..993b4ce 100644 --- a/test/remote-ui.test.ts +++ b/test/remote-ui.test.ts @@ -1,8 +1,9 @@ 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 } 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"; @@ -44,6 +45,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; @@ -591,6 +619,7 @@ void test("browseRemotePackages returns to results after installing from package }); void test("browseRemotePackages returns to package details after a cancelled load", async () => { + clearRemotePackageInfoCache(); setSearchPage("demo", [ { name: "demo-pkg", @@ -628,6 +657,7 @@ void test("browseRemotePackages returns to package details after a cancelled loa ) ); } finally { + clearRemotePackageInfoCache(); clearSearchCache(); } }); From cf560a209b691f9cc29ada0ecf4964cf353bec63 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:56:58 +0100 Subject: [PATCH 45/68] docs(manager): document persistence and safety behavior --- README.md | 17 ++++++++++++++--- src/ui/help.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a73280c..7128226 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ 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 server-side pagination, inline browse search, keyboard page navigation, and rate-limit-aware retries - Path- and git-like queries are handled explicitly instead of surfacing unrelated npm results @@ -99,13 +99,16 @@ Open the manager: | `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` | Filters: 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 | @@ -128,6 +131,11 @@ 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 history [options] # View change history (supports filters) /extensions clear-cache # Clear persistent + runtime extmgr caches ``` @@ -196,7 +204,10 @@ Examples: - **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. - **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 and exact target badges are intentionally not offered when Pi does not expose structured target metadata. ## License diff --git a/src/ui/help.ts b/src/ui/help.ts index 1023b9e..1c2c6cf 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -25,8 +25,11 @@ export function showHelp(ctx: ExtensionCommandContext): void { " 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", + " 1-7 Filters: All / Local / Packages / Updates / Disabled / Favorites / Recent", " Disabled includes packages with disabled extension entrypoints", + " Space on pkg Select package for bulk actions; B opens the bulk action menu", + " W/L/D Save, load, or delete a manager view", + " * Toggle favorite for the selected item", " c Configure selected package extensions (reload after save)", " u Update selected package", " V View full details for the selected item", @@ -58,6 +61,16 @@ export function showHelp(ctx: ExtensionCommandContext): void { " /extensions history [o] Show history (supports filters)", " e.g. --failed --since 30m | --global --action package_update", " /extensions auto-update Show or change update schedule", + " /extensions doctor Inspect ownership and package compatibility", + " /extensions profile list List saved profiles", + " /extensions profile save Save current package state", + " /extensions profile apply Safely apply a profile", + "", + "Persistence:", + " ~/.pi/agent/.extmgr-cache/ stores reload, view, profile, and auto-update state", + " ~/.pi/agent/.extmgr-trash/ (or .extmgr-trash) stores removable extension records", + " .pi/extmgr-policy.json can enforce project profile policy", + " Missing compatibility, provenance, or checksum metadata is unknown—not safe", ]; notify(ctx, lines.join("\n"), "info"); From 7a36142b538762f3f80c430e6a3795e926945aff Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 18:58:10 +0100 Subject: [PATCH 46/68] fix(reload): restore persisted state across sessions --- src/index.ts | 6 +++++- src/profiles/store.ts | 3 ++- src/ui/unified.ts | 14 +++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index a0a691b..20d2a01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ 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"; @@ -97,7 +98,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/profiles/store.ts b/src/profiles/store.ts index 71934e1..1c3a54e 100644 --- a/src/profiles/store.ts +++ b/src/profiles/store.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { type ExtmgrProfile, normalizeProfile } from "./schema.js"; @@ -83,6 +84,6 @@ export async function deleteNamedProfile(path: string, name: string): Promise 0 ? `: ${reloadState.reasons.join(", ")}` : "."}`, + "warning" + ); + } + // Main loop - keeps showing the menu until user explicitly exits while (true) { const shouldExit = await showInteractiveOnce(ctx, pi); From cc821d7d2663aa0623bfbc9c730af02a76a9d6e7 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 19:00:27 +0100 Subject: [PATCH 47/68] feat(trash): expose persistent trash lifecycle commands --- README.md | 1 + src/commands/registry.ts | 8 +++ src/commands/trash.ts | 114 +++++++++++++++++++++++++++++++++++ src/commands/types.ts | 1 + src/ui/help.ts | 1 + test/extension-trash.test.ts | 22 +++++++ 6 files changed, 147 insertions(+) create mode 100644 src/commands/trash.ts diff --git a/README.md b/README.md index 7128226..4edcc89 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ Open the manager: /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 ``` diff --git a/src/commands/registry.ts b/src/commands/registry.ts index fd6d972..e3a4e11 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -16,6 +16,7 @@ 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 "; @@ -156,6 +157,12 @@ 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", @@ -264,6 +271,7 @@ export function getExtensionsAutocompleteItems(prefix: string): AutocompleteItem const argumentOptions: Record = { install: ["--global", "--project"], "auto-update": ["daily", "weekly", "monthly", "never"], + trash: ["list", "restore", "purge", "all"], }; return completionItems(argumentOptions[command] ?? [], activePrefix); } diff --git a/src/commands/trash.ts b/src/commands/trash.ts new file mode 100644 index 0000000..d17fb67 --- /dev/null +++ b/src/commands/trash.ts @@ -0,0 +1,114 @@ +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, + 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", + 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 trash", + `Permanently delete ${records.length} record(s)?` + )) + ) { + notify(ctx, "Trash purge cancelled.", "info"); + return; + } + for (const record of records) await purgeExtensionTrash(record); + notify(ctx, `Purged ${records.length} trash 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 trash", `Permanently delete ${record.originalPath}?`)) + ) { + notify(ctx, "Trash purge cancelled.", "info"); + return; + } + await purgeExtensionTrash(record); + notify(ctx, `Purged ${record.originalPath}.`, "info"); + return; + } + + if (!(await confirmAction(ctx, "Restore extension", `Restore ${record.originalPath}?`))) { + notify(ctx, "Trash 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, + `Trash ${action} failed: ${error instanceof Error ? error.message : String(error)}`, + "error" + ); + } +} diff --git a/src/commands/types.ts b/src/commands/types.ts index cf87d07..c1c59e1 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -11,6 +11,7 @@ export type CommandId = | "update" | "history" | "clear-cache" + | "trash" | "auto-update" | "doctor" | "profile"; diff --git a/src/ui/help.ts b/src/ui/help.ts index 1c2c6cf..70e8180 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -65,6 +65,7 @@ export function showHelp(ctx: ExtensionCommandContext): void { " /extensions profile list List saved profiles", " /extensions profile save Save current package state", " /extensions profile apply Safely apply a profile", + " /extensions trash list/restore/purge Manage local trash records", "", "Persistence:", " ~/.pi/agent/.extmgr-cache/ stores reload, view, profile, and auto-update state", diff --git a/test/extension-trash.test.ts b/test/extension-trash.test.ts index 0fc975e..3b7652e 100644 --- a/test/extension-trash.test.ts +++ b/test/extension-trash.test.ts @@ -3,6 +3,8 @@ 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, @@ -72,6 +74,26 @@ void test("expired and missing trash records are removed from persistent listing } }); +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"); From ac74ee5c06d1a5517d834d7b8a322b44b12fade5 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Thu, 16 Jul 2026 19:01:51 +0100 Subject: [PATCH 48/68] fix(remote): cancel cleared package detail requests --- src/ui/remote.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/remote.ts b/src/ui/remote.ts index fa83593..f6aacbd 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -124,6 +124,7 @@ const packageInfoCache = new PackageInfoCache( const packageInfoRequests = new RequestGeneration(); export function clearRemotePackageInfoCache(): void { + packageInfoRequests.cancel(); packageInfoCache.clear(); } From 479697c188ae5423f8ac49f57c1fb643b87d0189 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 00:54:04 +0100 Subject: [PATCH 49/68] fix(reload): persist declined reload decisions --- src/utils/ui-helpers.ts | 9 +++--- test/reload-state.test.ts | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/utils/ui-helpers.ts b/src/utils/ui-helpers.ts index 5a87d29..72dada2 100644 --- a/src/utils/ui-helpers.ts +++ b/src/utils/ui-helpers.ts @@ -12,10 +12,12 @@ import { clearReloadRequired, markReloadRequired } from "./reload-state.js"; */ export async function confirmReload( ctx: ExtensionCommandContext, - reason: string + reason: string, + statePath?: string ): Promise { + await markReloadRequired(reason, statePath); + if (!ctx.hasUI) { - await markReloadRequired(reason); notify(ctx, `Reload pi to apply changes. (${reason})`); return false; } @@ -27,9 +29,8 @@ export async function confirmReload( } try { - await markReloadRequired(reason); await ctx.reload(); - await clearReloadRequired(); + await clearReloadRequired(statePath); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/test/reload-state.test.ts b/test/reload-state.test.ts index 19a4999..bc696bb 100644 --- a/test/reload-state.test.ts +++ b/test/reload-state.test.ts @@ -8,6 +8,7 @@ import { 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-")); @@ -38,6 +39,63 @@ void test("reload-required state is versioned, atomic, and coalesces reasons", a } }); +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); + assert.deepEqual(await readReloadState(path), { + version: 1, + required: true, + changedAt: (await readReloadState(path)).changedAt, + changes: 1, + 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"); From 03aa2347c1c50ef3b781ade2e86f44847d81b7c9 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 00:55:46 +0100 Subject: [PATCH 50/68] fix(packages): preserve unknown filter settings --- src/packages/extensions.ts | 7 ++++- test/package-extensions.test.ts | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index 53cc95f..bce6828 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -24,6 +24,7 @@ import { resolveConfiguredNpmRootCommand } from "../utils/npm-exec.js"; interface PackageSettingsObject { source: string; extensions?: string[]; + [key: string]: unknown; } interface SettingsFile { @@ -252,6 +253,7 @@ function toPackageSettingsObject( if (existing && typeof existing.source === "string") { return { + ...structuredClone(existing), source: existing.source, ...(Array.isArray(existing.extensions) ? { extensions: [...existing.extensions] } : {}), }; @@ -340,8 +342,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); diff --git a/test/package-extensions.test.ts b/test/package-extensions.test.ts index 1e77144..a80fdb8 100644 --- a/test/package-extensions.test.ts +++ b/test/package-extensions.test.ts @@ -701,6 +701,53 @@ void test("setPackageExtensionState preserves unrelated settings fields", async } }); +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-")); From 9501220aeb31eb41ff26fa4f47ae44eb3e0cd263 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 00:57:42 +0100 Subject: [PATCH 51/68] fix(settings): reject unsafe mutation writes --- src/commands/profile.ts | 3 +++ src/packages/catalog.ts | 5 ++++ src/packages/scopes.ts | 48 +++++++++++++++++++++++++++++++----- src/ui/unified.ts | 9 +++++-- src/utils/settings-errors.ts | 12 +++++++++ test/package-scopes.test.ts | 26 +++++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 src/utils/settings-errors.ts diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 7d3d537..38906de 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -14,6 +14,7 @@ import { runTaskWithLoader } from "../ui/async-task.js"; import { notify } from "../utils/notify.js"; import { parsePackageNameAndVersion, splitGitRepoAndRef } from "../utils/package-source.js"; import { confirmAction, confirmReload } from "../utils/ui-helpers.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; import { checksumPackagePath, verifyPackageChecksum } from "../profiles/checksum.js"; import { planProfileApplication, type ProfilePlan } from "../profiles/apply.js"; import { loadProjectProfilePolicy, validateProfilePolicy } from "../profiles/compare.js"; @@ -155,6 +156,7 @@ async function persistProfileConfiguration( ctx: ExtensionCommandContext ): Promise { const settings = SettingsManager.create(ctx.cwd, getAgentDir()); + throwIfSettingsErrors(settings, "Profile application"); const global = settings.getGlobalSettings(); const project = settings.getProjectSettings(); settings.setPackages( @@ -170,6 +172,7 @@ async function persistProfileConfiguration( ) ); await settings.flush(); + throwIfSettingsErrors(settings, "Profile application"); } async function verifyProfileSafety( diff --git a/src/packages/catalog.ts b/src/packages/catalog.ts index b65f07d..36d27e5 100644 --- a/src/packages/catalog.ts +++ b/src/packages/catalog.ts @@ -7,6 +7,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type Scope } from "../types/index.js"; import { normalizePackageIdentity, parsePackageNameAndVersion } from "../utils/package-source.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; type PiScope = "user" | "project"; type PiPackageUpdate = Awaited< @@ -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,11 +131,13 @@ 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, { local: scope === "project", }); await settingsManager.flush(); + throwIfSettingsErrors(settingsManager, "Package removal"); if (!removed) { throw new Error(`No matching package found for ${source}`); diff --git a/src/packages/scopes.ts b/src/packages/scopes.ts index 04913e7..1efcce9 100644 --- a/src/packages/scopes.ts +++ b/src/packages/scopes.ts @@ -1,6 +1,7 @@ import { getAgentDir, SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type Scope } from "../types/index.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; export interface PackageScopeComparison { identity: string; @@ -72,6 +73,7 @@ export interface MovePackageScopeResult { from: Scope; to: Scope; moved: boolean; + partial?: boolean; conflict?: string; } @@ -93,6 +95,17 @@ export async function movePackageBetweenScopes( } const settings = SettingsManager.create(cwd, getAgentDir()); + 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; @@ -125,14 +138,37 @@ export async function movePackageBetweenScopes( destinationPackages.push(structuredClone(entry)); } - if (to === "global") settings.setPackages(destinationPackages); - else settings.setProjectPackages(destinationPackages); - await settings.flush(); + 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); - if (from === "global") settings.setPackages(sourcePackages); - else settings.setProjectPackages(sourcePackages); - await settings.flush(); + try { + if (from === "global") settings.setPackages(sourcePackages); + else settings.setProjectPackages(sourcePackages); + await settings.flush(); + throwIfSettingsErrors(settings, "Package scope move"); + } catch (error) { + return { + source: sourceOf(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: sourceOf(entry), from, to, moved: true }; } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 9724844..0171a11 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -2004,8 +2004,13 @@ async function handleUnifiedAction( if (!confirmed) return "resume"; const moved = await movePackageBetweenScopes(item.source, item.scope, targetScope, ctx.cwd); if (!moved.moved) { - ctx.ui.notify(`Package scope move failed: ${moved.conflict ?? "unknown error"}`, "error"); - return "resume"; + 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."); diff --git a/src/utils/settings-errors.ts b/src/utils/settings-errors.ts new file mode 100644 index 0000000..98d95d4 --- /dev/null +++ b/src/utils/settings-errors.ts @@ -0,0 +1,12 @@ +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; + + throw new Error( + `${operation} refused because Pi settings could not be read or written: ${errors + .map(({ scope, error }) => `${scope}: ${error.message}`) + .join("; ")}` + ); +} diff --git a/test/package-scopes.test.ts b/test/package-scopes.test.ts index 4f2fe39..2e2e462 100644 --- a/test/package-scopes.test.ts +++ b/test/package-scopes.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { getPackageCatalog } from "../src/packages/catalog.js"; import { comparePackageScopes, getPackageScopeLabel, @@ -102,6 +103,31 @@ void test("movePackageBetweenScopes refuses a conflicting destination", async () } }); +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); + 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/); From 03f495e5fa81b8f95ee306314d30ad3903224179 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 00:59:16 +0100 Subject: [PATCH 52/68] fix(profiles): guard stores and partial applies --- src/commands/profile.ts | 64 +++++++++++++++++++++++++++-------------- src/profiles/store.ts | 27 ++++++++++++++--- test/profiles.test.ts | 24 +++++++++++++++- 3 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 38906de..6c5979d 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -12,6 +12,7 @@ import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; import { type InstalledPackage } from "../types/index.js"; import { runTaskWithLoader } from "../ui/async-task.js"; import { notify } from "../utils/notify.js"; +import { markReloadRequired } from "../utils/reload-state.js"; import { parsePackageNameAndVersion, splitGitRepoAndRef } from "../utils/package-source.js"; import { confirmAction, confirmReload } from "../utils/ui-helpers.js"; import { throwIfSettingsErrors } from "../utils/settings-errors.js"; @@ -243,30 +244,49 @@ async function applyProfileFromCommand( return; } - await runTaskWithLoader( - ctx, - { title: "Apply profile", message: `Applying ${desired.name}...`, cancellable: false }, - async ({ setMessage }) => { - const catalog = getPackageCatalog(ctx.cwd); - for (const pkg of plan.remove) { - setMessage(`Removing ${pkg.source}...`); - await catalog.remove(pkg.source, pkg.scope); - } - for (const pkg of plan.add) { - setMessage(`Installing ${pkg.source}...`); - await catalog.install(pkg.source, pkg.scope); - } - for (const change of plan.update) { - if (change.from.source === change.to.source) continue; - setMessage(`Changing ${change.to.source}...`); - await catalog.remove(change.from.source, change.from.scope); - await catalog.install(change.to.source, change.to.scope); + let completedMutations = 0; + try { + await runTaskWithLoader( + ctx, + { title: "Apply profile", message: `Applying ${desired.name}...`, cancellable: false }, + async ({ setMessage }) => { + const catalog = getPackageCatalog(ctx.cwd); + for (const pkg of plan.remove) { + setMessage(`Removing ${pkg.source}...`); + await catalog.remove(pkg.source, pkg.scope); + completedMutations += 1; + } + for (const pkg of plan.add) { + setMessage(`Installing ${pkg.source}...`); + await catalog.install(pkg.source, pkg.scope); + completedMutations += 1; + } + for (const change of plan.update) { + if (change.from.source === change.to.source) continue; + setMessage(`Changing ${change.to.source}...`); + await catalog.remove(change.from.source, change.from.scope); + completedMutations += 1; + await catalog.install(change.to.source, change.to.scope); + completedMutations += 1; + } + setMessage("Preserving package settings and filters..."); + await persistProfileConfiguration(desired, ctx); + return undefined; } - setMessage("Preserving package settings and filters..."); - await persistProfileConfiguration(desired, ctx); - return undefined; + ); + } catch (error) { + if (completedMutations > 0) { + const message = error instanceof Error ? error.message : String(error); + await markReloadRequired(`Profile ${desired.name} partially applied.`); + notify( + ctx, + `Profile ${desired.name} partially applied: ${completedMutations} mutation(s) succeeded before failure. ${message}\nReload pi before continuing.`, + "error" + ); + return; } - ); + throw error; + } notify(ctx, `Applied profile ${desired.name}.`, "info"); await confirmReload(ctx, "Profile package configuration changed."); diff --git a/src/profiles/store.ts b/src/profiles/store.ts index 1c3a54e..9f90f78 100644 --- a/src/profiles/store.ts +++ b/src/profiles/store.ts @@ -16,7 +16,9 @@ function emptyStore(): ProfileStoreFile { export function normalizeProfileStore(input: unknown): ProfileStoreFile { if (!input || typeof input !== "object" || Array.isArray(input)) return emptyStore(); - const profilesValue = (input as Record).profiles; + 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(); } @@ -33,9 +35,26 @@ export function normalizeProfileStore(input: unknown): ProfileStoreFile { export async function readProfileStore(path: string): Promise { try { - return normalizeProfileStore(JSON.parse(await readFile(path, "utf8"))); - } catch { - return emptyStore(); + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed as Record).version !== 1 + ) { + throw new Error(`Unsupported or malformed profile store: ${path}`); + } + return normalizeProfileStore(parsed); + } 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)}` + ); } } diff --git a/test/profiles.test.ts b/test/profiles.test.ts index 3c59578..d7068d2 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -66,6 +66,28 @@ void test("named profiles persist atomically and can be deleted", async () => { } }); +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"); + + await writeFile(path, JSON.stringify({ version: 99, profiles: {} }), "utf8"); + await assert.rejects( + () => deleteNamedProfile(path, "team"), + /Unsupported or malformed profile store/ + ); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { version: 99, profiles: {} }); + } 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 { From e99240f2c943a8ca94fdbe3999f50f155a62e037 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:00:26 +0100 Subject: [PATCH 53/68] fix(trash): roll back unrecorded removals --- src/extensions/trash.ts | 21 +++++++++++++++++---- test/extension-trash.test.ts | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/extensions/trash.ts b/src/extensions/trash.ts index 0a2178b..ac8b023 100644 --- a/src/extensions/trash.ts +++ b/src/extensions/trash.ts @@ -108,10 +108,23 @@ export async function moveToExtensionTrash(path: string, trashRoot: string): Pro await rename(path, trashPath); const record: TrashRecord = { originalPath: path, trashPath, trashedAt: Date.now() }; - await updateTrashFile(trashRoot, (file) => ({ - version: 1, - records: [...file.records.filter((item) => item.trashPath !== trashPath), record], - })); + try { + await updateTrashFile(trashRoot, (file) => ({ + version: 1, + records: [...file.records.filter((item) => item.trashPath !== 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; } diff --git a/test/extension-trash.test.ts b/test/extension-trash.test.ts index 3b7652e..8946ef0 100644 --- a/test/extension-trash.test.ts +++ b/test/extension-trash.test.ts @@ -37,6 +37,22 @@ void test("removeLocalExtension moves files to trash and exposes undo", async () } }); +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"); From adf94235659a33eaed92008de7960c2796bbc0da Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:00:56 +0100 Subject: [PATCH 54/68] docs(manager): clarify deferred update policy --- README.md | 2 +- src/packages/update-policy.ts | 21 --------------------- src/ui/remote.ts | 4 ++-- src/ui/unified.ts | 2 +- test/update-policy.test.ts | 15 --------------- 5 files changed, 4 insertions(+), 40 deletions(-) delete mode 100644 src/packages/update-policy.ts delete mode 100644 test/update-policy.test.ts diff --git a/README.md b/README.md index 4edcc89..fdcd7ff 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Examples: - **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 and exact target badges are intentionally not offered when Pi does not expose structured target metadata. +- **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/src/packages/update-policy.ts b/src/packages/update-policy.ts deleted file mode 100644 index 96cc7fe..0000000 --- a/src/packages/update-policy.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface UpdatePolicy { - packageSource: string; - enabled: boolean; - maintenanceWindow?: { startHour: number; endHour: number; timezone?: string }; - digest?: "none" | "daily" | "weekly"; -} - -export function isWithinMaintenanceWindow(policy: UpdatePolicy, date = new Date()): boolean { - if (!policy.enabled || !policy.maintenanceWindow) return false; - const hour = date.getHours(); - const { startHour, endHour } = policy.maintenanceWindow; - if (startHour === endHour) return true; - return startHour < endHour - ? hour >= startHour && hour < endHour - : hour >= startHour || hour < endHour; -} - -export function shouldUpdate(policy: UpdatePolicy | undefined, date = new Date()): boolean { - if (!policy?.enabled) return false; - return !policy.maintenanceWindow || isWithinMaintenanceWindow(policy, date); -} diff --git a/src/ui/remote.ts b/src/ui/remote.ts index f6aacbd..26e1b3d 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -1069,8 +1069,8 @@ async function confirmMarketplaceInstall( info, "", "Missing provenance or compatibility metadata is unknown, not safe.", - ].join("\\n"); - if (!(await ctx.ui.confirm("Review before install", `${review}\\n\\nInstall now?`))) { + ].join("\n"); + if (!(await ctx.ui.confirm("Review before install", `${review}\n\nInstall now?`))) { notify(ctx, "Installation cancelled.", "info"); return undefined; } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 0171a11..7d86115 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -1887,7 +1887,7 @@ async function handleUnifiedAction( const confirmed = await ctx.ui.confirm( "Delete Local Extension", - `Delete ${item.displayName} from disk?\n\nThis cannot be undone.` + `Remove ${item.displayName} from disk?\n\nIt will be moved to trash, where you can restore it later.` ); if (!confirmed) return "resume"; diff --git a/test/update-policy.test.ts b/test/update-policy.test.ts deleted file mode 100644 index 2193ab3..0000000 --- a/test/update-policy.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { isWithinMaintenanceWindow, shouldUpdate } from "../src/packages/update-policy.js"; - -void test("update policies honor ordinary and overnight maintenance windows", () => { - const policy = { - packageSource: "npm:demo", - enabled: true, - maintenanceWindow: { startHour: 22, endHour: 2 }, - }; - assert.equal(isWithinMaintenanceWindow(policy, new Date(2026, 0, 1, 23)), true); - assert.equal(isWithinMaintenanceWindow(policy, new Date(2026, 0, 1, 1)), true); - assert.equal(isWithinMaintenanceWindow(policy, new Date(2026, 0, 1, 12)), false); - assert.equal(shouldUpdate(policy, new Date(2026, 0, 1, 12)), false); -}); From 7be838f73d1bb07aa8af120a69b8f54d4885bfe2 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:02:12 +0100 Subject: [PATCH 55/68] perf(remote): avoid cached search loader flicker --- src/packages/discovery.ts | 9 +++++++-- src/ui/remote.ts | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/packages/discovery.ts b/src/packages/discovery.ts index 1a7c331..32631cf 100644 --- a/src/packages/discovery.ts +++ b/src/packages/discovery.ts @@ -108,6 +108,12 @@ export function isCacheValid(query: string, offset = 0): boolean { return cache ? Date.now() - cache.timestamp < CACHE_LIMITS.searchTTL : false; } +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 ): string | undefined { @@ -250,9 +256,8 @@ export async function searchNpmPackages( return runtimeCached; } - const persisted = await getCachedSearch(query, offset); + const persisted = await hydrateSearchCache(query, offset); if (persisted) { - setSearchCache(persisted); if (ctx.hasUI) { ctx.ui.notify(`Using ${persisted.results.length} cached results`, "info"); } diff --git a/src/ui/remote.ts b/src/ui/remote.ts index 26e1b3d..320fbfe 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -25,6 +25,7 @@ import { clearSearchCache, getInstalledPackagesAllScopes, getSearchCache, + hydrateSearchCache, isCacheValid, searchNpmPackages, } from "../packages/discovery.js"; @@ -898,6 +899,10 @@ async function browseRemotePackagesPage({ searchPage = getSearchCache(plan.searchQuery, offset) ?? undefined; } + if (!searchPage && !forceRefresh) { + searchPage = (await hydrateSearchCache(plan.searchQuery, offset)) ?? undefined; + } + if (!searchPage) { try { searchPage = await runTaskWithLoader( From 528defaf3605bd2973812e0a5e0f3739b74c3c63 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:03:12 +0100 Subject: [PATCH 56/68] test(reload): assert persisted timestamp shape --- test/reload-state.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/test/reload-state.test.ts b/test/reload-state.test.ts index bc696bb..6956a65 100644 --- a/test/reload-state.test.ts +++ b/test/reload-state.test.ts @@ -58,13 +58,12 @@ void test("declining an interactive reload keeps the successful mutation pending assert.equal(reloaded, false); assert.equal(reloads, 0); - assert.deepEqual(await readReloadState(path), { - version: 1, - required: true, - changedAt: (await readReloadState(path)).changedAt, - changes: 1, - reasons: ["Package installed."], - }); + 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 }); } From 062302a0e918939a5b0dcb3ee6a4ec8cd7dc300e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:30:45 +0100 Subject: [PATCH 57/68] feat(ui): streamline extension manager hints --- src/ui/footer.ts | 52 +++++++++++++--------------------------- src/ui/package-config.ts | 12 +++++++--- src/ui/remote.ts | 25 ++++++++++++------- src/ui/unified.ts | 12 ++++++++-- src/utils/key-hints.ts | 25 +++++++++++++++++++ test/unified-ui.test.ts | 15 ++++++++++++ 6 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 src/utils/key-hints.ts diff --git a/src/ui/footer.ts b/src/ui/footer.ts index 3660745..499b7ff 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -1,7 +1,9 @@ /** * 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"]; @@ -50,46 +52,24 @@ export function getPendingToggleChangeCount( /** * Build contextual keyboard shortcuts text for the footer. */ -export function buildFooterShortcuts(state: FooterState): string { - const parts: string[] = []; - - if (state.selectedType === "local") { - parts.push("Space toggle"); - parts.push("Enter/A actions"); - parts.push("V details"); - parts.push("X remove"); - } +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", "clear/cancel") + : "Esc clear/cancel"; + const parts = ["↑↓ navigate", confirm, "/ search", "Tab filters"]; + if (state.selectedType === "local") parts.splice(1, 0, "Space toggle", "V details", "X remove"); if (state.selectedType === "package") { - if (state.expandable) parts.push("E expand"); - parts.push("Space select · B bulk actions"); - parts.push("Enter/A actions"); - parts.push("V details"); - parts.push("c configure · scope actions in menu"); - parts.push("u update"); - parts.push("X remove"); + parts.splice(1, 0, "Space select"); + if (state.expandable) parts.splice(2, 0, "E expand"); } - if (state.selectedPackages > 0) { - parts.push(`B bulk actions (${state.selectedPackages})`); + parts.push(`${state.selectedPackages} selected · B bulk actions`); } + if (state.pendingChanges > 0) parts.push(`S save (${state.pendingChanges})`); - if (state.pendingChanges > 0) { - parts.push(`S save (${state.pendingChanges})`); - } - - parts.push("/ search"); - parts.push("Tab filters"); - parts.push("1-7 filters"); - parts.push("W save view · L load · D delete · * favorite"); - parts.push("i install"); - parts.push("f remote search"); - parts.push("U update all"); - parts.push("t auto-update"); - parts.push("P palette"); - parts.push("R browse"); - parts.push("? help"); - parts.push("Esc clear/cancel"); - - return parts.join(" · "); + return `${parts.join(" · ")}\nMore: 1-7 filters · W/L/D views · * favorite · i install · f search · U update all · t auto-update · P palette · R browse · ? help · ${cancel}`; } diff --git a/src/ui/package-config.ts b/src/ui/package-config.ts index b6e0dee..17d5ccf 100644 --- a/src/ui/package-config.ts +++ b/src/ui/package-config.ts @@ -30,6 +30,7 @@ import { 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"; @@ -130,7 +131,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 +149,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); diff --git a/src/ui/remote.ts b/src/ui/remote.ts index 320fbfe..5ae774e 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -46,6 +46,7 @@ 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 { activeKeyHint } from "../utils/key-hints.js"; import { RequestGeneration, runTaskWithLoader } from "./async-task.js"; interface PackageInfoCacheEntry { @@ -649,6 +650,16 @@ class RemotePackageBrowser implements Focusable { lines.push(truncateToWidth(this.buildSummaryLine(), safeWidth, "")); lines.push(""); + if (this.packages.length === 0) { + lines.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)) { lines.push(this.renderPackageLine(pkg, safeWidth)); @@ -697,16 +708,12 @@ class RemotePackageBrowser implements Focusable { } private buildFooterLine(): string { - const parts = ["Enter details", "/ search"]; - - if (this.showPrevious) { - parts.push("p prev"); - } - if (this.showLoadMore) { - parts.push("n next"); - } + const parts = [activeKeyHint(this.keybindings, "tui.select.confirm", "details"), "/ search"]; - parts.push("o sort", "r refresh", "i install", "m menu", "Esc back"); + if (this.showPrevious) parts.push("p previous"); + if (this.showLoadMore) parts.push("n next"); + parts.push("o sort", "r refresh", "i install", "m menu"); + parts.push(activeKeyHint(this.keybindings, "tui.select.cancel", "back")); return ` ${this.theme.fg("dim", parts.join(" · "))}`; } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index 7d86115..d2396c8 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -256,7 +256,8 @@ async function showInteractiveOnce( byId, browser.getSelectedItem(), browser.getBulkSelectedCount() - ) + ), + keybindings ) ) ); @@ -523,8 +524,12 @@ function buildManagerSummary( parts.push(theme.fg("warning", `${pendingCount} unsaved`)); } + if (packageCount > 0) { + parts.push(theme.fg("muted", "Space selects packages")); + } + if ((options?.selectedCount ?? 0) > 0) { - parts.push(theme.fg("accent", `${options?.selectedCount} selected`)); + parts.push(theme.fg("accent", `${options?.selectedCount} selected · B to act`)); } return parts.join(" • "); @@ -1787,6 +1792,9 @@ async function handleUnifiedAction( `${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"); 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/test/unified-ui.test.ts b/test/unified-ui.test.ts index eba1e04..61e6eeb 100644 --- a/test/unified-ui.test.ts +++ b/test/unified-ui.test.ts @@ -647,3 +647,18 @@ void test("/extensions keeps staged changes when staying in the manager", async 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", expandable: false, 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 clear\/cancel/); + assert.match(hints, /2 selected/); + assert.match(hints, /B bulk actions/); +}); From bcc6e6aaeb396b98a1d84f933df2c0b6a6fad2d3 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:31:12 +0100 Subject: [PATCH 58/68] test(manager): assert bulk outcome state --- test/unified-ui.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/unified-ui.test.ts b/test/unified-ui.test.ts index 61e6eeb..c5eebc2 100644 --- a/test/unified-ui.test.ts +++ b/test/unified-ui.test.ts @@ -586,7 +586,10 @@ void test("/extensions bulk updates use one flow and summarize partial failures" 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) => + entry.message.includes("1 succeeded") && + entry.message.includes("1 failed") && + entry.message.includes("Reload required") ) ); } finally { From c70e93cc04b0dbf50f8bce2fa137bbbbfbddec71 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 01:45:46 +0100 Subject: [PATCH 59/68] fix(ui): harmonize help and trash actions --- src/commands/trash.ts | 28 ++++------ src/ui/help.ts | 101 +++++++++++++---------------------- test/profile-command.test.ts | 10 ++++ 3 files changed, 58 insertions(+), 81 deletions(-) diff --git a/src/commands/trash.ts b/src/commands/trash.ts index d17fb67..7c61270 100644 --- a/src/commands/trash.ts +++ b/src/commands/trash.ts @@ -30,7 +30,7 @@ async function selectRecord( } if (!ctx.hasUI) return undefined; const choice = await ctx.ui.select( - action, + action === "restore" ? "Restore" : "Purge", records.map((record, index) => `[${index + 1}] ${record.originalPath}`) ); const match = choice?.match(/^\[(\d+)\]/); @@ -54,7 +54,7 @@ export async function handleTrashSubcommand( if (action === "list") { formatListOutput( ctx, - "Trash records", + "Trash", records.map((record, index) => `[${index + 1}] ${record.originalPath}`) ); return; @@ -65,18 +65,12 @@ export async function handleTrashSubcommand( } if (action === "purge" && tokens[1]?.toLowerCase() === "all") { - if ( - !(await confirmAction( - ctx, - "Purge trash", - `Permanently delete ${records.length} record(s)?` - )) - ) { - notify(ctx, "Trash purge cancelled.", "info"); + 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} trash record(s).`, "info"); + notify(ctx, `Purged ${records.length} record(s).`, "info"); return; } @@ -86,10 +80,8 @@ export async function handleTrashSubcommand( return; } if (action === "purge") { - if ( - !(await confirmAction(ctx, "Purge trash", `Permanently delete ${record.originalPath}?`)) - ) { - notify(ctx, "Trash purge cancelled.", "info"); + if (!(await confirmAction(ctx, "Purge", `Permanently delete ${record.originalPath}?`))) { + notify(ctx, "Purge cancelled.", "info"); return; } await purgeExtensionTrash(record); @@ -97,8 +89,8 @@ export async function handleTrashSubcommand( return; } - if (!(await confirmAction(ctx, "Restore extension", `Restore ${record.originalPath}?`))) { - notify(ctx, "Trash restore cancelled.", "info"); + if (!(await confirmAction(ctx, "Restore", `Restore ${record.originalPath}?`))) { + notify(ctx, "Restore cancelled.", "info"); return; } await undoExtensionTrash(record); @@ -107,7 +99,7 @@ export async function handleTrashSubcommand( } catch (error) { notify( ctx, - `Trash ${action} failed: ${error instanceof Error ? error.message : String(error)}`, + `${action === "restore" ? "Restore" : action === "purge" ? "Purge" : "Trash"} failed: ${error instanceof Error ? error.message : "Unexpected error"}`, "error" ); } diff --git a/src/ui/help.ts b/src/ui/help.ts index 70e8180..160fedf 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -4,75 +4,50 @@ 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", + "Everyday controls", + " ↑↓ / PageUp/PageDown Navigate", + " Space Toggle local extension or select package", + " Enter Open actions for the selected item", + " / Search visible items", + " Tab / Shift+Tab Cycle filters", + " 1-7 All / Local / Packages / Updates / Disabled / Favorites / Recent", + " Esc Clear search, cancel, or leave", "", - "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-7 Filters: All / Local / Packages / Updates / Disabled / Favorites / Recent", - " Disabled includes packages with disabled extension entrypoints", - " Space on pkg Select package for bulk actions; B opens the bulk action menu", - " W/L/D Save, load, or delete a manager view", - " * Toggle favorite for the selected item", - " 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", + "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 details", + " E Expand package entrypoints when available", "", - "Extension Sources:", - " - ~/.pi/agent/extensions/ (global - G)", - " - .pi/extensions/ (project-local - P)", - " - npm packages installed via pi install", - " - git packages installed via pi install", + "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 Auto-update settings", + " P / M Quick actions palette", "", - "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", - " /extensions doctor Inspect ownership and package compatibility", - " /extensions profile list List saved profiles", - " /extensions profile save Save current package state", - " /extensions profile apply Safely apply a profile", - " /extensions trash list/restore/purge Manage local trash records", + "Sources and safety", + " G = global extension, P = project extension", + " Packages may be npm or git sources and show their scope inline", + " Missing compatibility, provenance, or checksum metadata is unknown", + " Changes that affect loaded extensions show Reload required", "", - "Persistence:", - " ~/.pi/agent/.extmgr-cache/ stores reload, view, profile, and auto-update state", - " ~/.pi/agent/.extmgr-trash/ (or .extmgr-trash) stores removable extension records", - " .pi/extmgr-policy.json can enforce project profile policy", - " Missing compatibility, provenance, or checksum metadata is unknown—not safe", + "Commands", + " /extensions profile list|save|apply|delete Manage named profiles", + " /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/test/profile-command.test.ts b/test/profile-command.test.ts index 73bd654..99b9f4d 100644 --- a/test/profile-command.test.ts +++ b/test/profile-command.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { handleProfileSubcommand } from "../src/commands/profile.js"; +import { buildHelpLines } from "../src/ui/help.js"; +import { visibleWidth } from "@earendil-works/pi-tui"; import { createMockHarness } from "./helpers/mocks.js"; import { mockPackageCatalog } from "./helpers/package-catalog.js"; @@ -28,3 +30,11 @@ void test("profile export writes exact installed source, scope, and version", as await rm(cwd, { 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"))); +}); From d16c59fe66ba1e89c2ceb82f67b887601a7fd0fe Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 02:04:18 +0100 Subject: [PATCH 60/68] fix(ui): honor configured selection bindings --- src/ui/package-config.ts | 21 +++++++++++++++++++++ src/ui/remote.ts | 8 ++++---- src/ui/unified.ts | 10 +++++----- test/helpers/custom-component.ts | 29 ++++++++++++++++++++++++++++- test/unified-ui.test.ts | 7 ++++--- 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/ui/package-config.ts b/src/ui/package-config.ts index 17d5ccf..ccf0947 100644 --- a/src/ui/package-config.ts +++ b/src/ui/package-config.ts @@ -219,6 +219,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; diff --git a/src/ui/remote.ts b/src/ui/remote.ts index 5ae774e..c68dc39 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -512,14 +512,14 @@ class RemotePackageBrowser implements Focusable { handleBrowseInput(data: string): boolean { if (this.searchActive) { - if (matchesKey(data, Key.enter)) { + 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 (matchesKey(data, Key.escape)) { + if (this.keybindings.matches(data, "tui.select.cancel")) { this.searchActive = false; this.searchInput.focused = false; this.searchInput.setValue(this.queryLabel); @@ -568,7 +568,7 @@ class RemotePackageBrowser implements Focusable { } const selected = this.packages[this.selectedIndex]; - if (selected && matchesKey(data, Key.enter)) { + if (selected && this.keybindings.matches(data, "tui.select.confirm")) { this.onAction({ type: "package", name: selected.name }); return true; } @@ -611,7 +611,7 @@ class RemotePackageBrowser implements Focusable { return true; } - if (matchesKey(data, Key.escape)) { + if (this.keybindings.matches(data, "tui.select.cancel")) { this.onAction({ type: "cancel" }); return true; } diff --git a/src/ui/unified.ts b/src/ui/unified.ts index d2396c8..a9cc9bf 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -926,13 +926,13 @@ class UnifiedManagerBrowser implements Focusable { handleManagerInput(data: string): boolean { if (this.searchActive) { - if (matchesKey(data, Key.enter)) { + if (this.keybindings.matches(data, "tui.select.confirm")) { this.searchActive = false; this.searchInput.focused = false; return true; } - if (matchesKey(data, Key.escape)) { + if (this.keybindings.matches(data, "tui.select.cancel")) { this.searchInput.setValue(""); this.searchActive = false; this.searchInput.focused = false; @@ -951,7 +951,7 @@ class UnifiedManagerBrowser implements Focusable { return true; } - if (matchesKey(data, Key.escape) && this.getSearchQuery()) { + if (this.keybindings.matches(data, "tui.select.cancel") && this.getSearchQuery()) { this.searchInput.setValue(""); this.refreshVisibleItems(); return true; @@ -1039,7 +1039,7 @@ class UnifiedManagerBrowser implements Focusable { return true; } - if (matchesKey(data, Key.enter) && selectedId) { + if (this.keybindings.matches(data, "tui.select.confirm") && selectedId) { this.onAction({ type: "action", itemId: selectedId, action: "menu" }); return true; } @@ -1138,7 +1138,7 @@ class UnifiedManagerBrowser implements Focusable { return true; } - if (matchesKey(data, Key.escape)) { + if (this.keybindings.matches(data, "tui.select.cancel")) { this.onAction({ type: "cancel" }); return true; } 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/unified-ui.test.ts b/test/unified-ui.test.ts index c5eebc2..a56735e 100644 --- a/test/unified-ui.test.ts +++ b/test/unified-ui.test.ts @@ -171,7 +171,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"); @@ -188,12 +188,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); From c86733f52996d76fcb0e472dab653d68933abc81 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 02:21:44 +0100 Subject: [PATCH 61/68] fix(settings): preserve trust and public persistence semantics --- README.md | 16 +-- src/commands/auto-update.ts | 4 +- src/commands/completion.ts | 7 +- src/commands/history.ts | 11 +- src/commands/profile.ts | 13 ++- src/commands/registry.ts | 4 +- src/commands/update.ts | 3 +- src/extensions/discovery.ts | 23 ++-- src/index.ts | 10 +- src/packages/catalog.ts | 10 +- src/packages/discovery.ts | 9 +- src/packages/extensions.ts | 182 +++++++++++++++++--------------- src/packages/install.ts | 40 ++++--- src/packages/management.ts | 26 +++-- src/packages/scopes.ts | 12 ++- src/profiles/compare.ts | 8 +- src/profiles/store.ts | 6 +- src/ui/footer.ts | 2 +- src/ui/help.ts | 2 +- src/ui/package-config.ts | 27 +++-- src/ui/remote.ts | 4 +- src/ui/unified.ts | 26 +++-- src/utils/auto-update.ts | 18 ++-- src/utils/cache.ts | 30 +++--- src/utils/history.ts | 86 ++++----------- src/utils/mode.ts | 5 + src/utils/network.ts | 65 ++++++++++-- src/utils/npm-exec.ts | 2 +- src/utils/pi-paths.ts | 33 ++++++ src/utils/reload-state.ts | 21 ++-- src/utils/settings-errors.ts | 12 ++- src/utils/settings.ts | 30 +++--- src/utils/status.ts | 3 +- src/utils/views.ts | 6 +- test/cache-history.test.ts | 93 ++++++++++------ test/helpers/mocks.ts | 3 + test/install-remove.test.ts | 10 +- test/package-config.test.ts | 18 ++-- test/package-extensions.test.ts | 28 ++--- test/package-scopes.test.ts | 6 +- 40 files changed, 543 insertions(+), 371 deletions(-) create mode 100644 src/utils/pi-paths.ts diff --git a/README.md b/README.md index fdcd7ff..f76553e 100644 --- a/README.md +++ b/README.md @@ -66,18 +66,18 @@ version manager, or install project-local with `pi install npm:pi-extmgr -l`. - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path) - Supports direct GitHub `.ts` installs and standalone local install for self-contained packages - Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc` -- **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 - **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 @@ -110,7 +110,7 @@ Open the manager: | `W` / `L` / `D` | Save / load / delete manager views | | `*` | Toggle favorite for selected item | | `U` | Update all packages | -| `t` | Auto-update wizard | +| `t` | Scheduled update checks wizard | | `P` / `M` | Quick actions palette | | `R` | Browse remote packages | | `?` / `H` | Help | @@ -199,10 +199,10 @@ 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. 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. 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 index 82e15b3..5bb6eec 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -12,8 +12,11 @@ function sourceOf(source: PackageSource): string { return typeof source === "string" ? source : source.source; } -export async function refreshLocalCompletionIndex(cwd: string): Promise { - const settings = SettingsManager.create(cwd, getAgentDir()); +export async function refreshLocalCompletionIndex( + cwd: string, + projectTrusted = false +): Promise { + const settings = SettingsManager.create(cwd, getAgentDir(), { projectTrusted }); const installedPackages = [ ...(settings.getGlobalSettings().packages ?? []), ...(settings.getProjectSettings().packages ?? []), 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 index 6c5979d..9f1dba4 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -8,6 +8,7 @@ import { type PackageSource, } 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 { type InstalledPackage } from "../types/index.js"; import { runTaskWithLoader } from "../ui/async-task.js"; @@ -66,7 +67,9 @@ async function toProfilePackage( async function currentProfile(ctx: ExtensionCommandContext): Promise { const packages = await getInstalledPackagesAllScopes(ctx); - const settings = SettingsManager.create(ctx.cwd, getAgentDir()); + const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { + projectTrusted: isProjectTrusted(ctx), + }); const profiles = await Promise.all( packages.map((pkg) => { const scopedSettings = @@ -156,7 +159,9 @@ async function persistProfileConfiguration( desired: ExtmgrProfile, ctx: ExtensionCommandContext ): Promise { - const settings = SettingsManager.create(ctx.cwd, getAgentDir()); + const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { + projectTrusted: isProjectTrusted(ctx), + }); throwIfSettingsErrors(settings, "Profile application"); const global = settings.getGlobalSettings(); const project = settings.getProjectSettings(); @@ -206,7 +211,7 @@ async function applyProfileFromCommand( ctx: ExtensionCommandContext, pi: ExtensionAPI ): Promise { - const policy = await loadProjectProfilePolicy(ctx.cwd); + const policy = await loadProjectProfilePolicy(ctx.cwd, undefined, isProjectTrusted(ctx)); const violations = policy ? validateProfilePolicy(desired, policy) : []; if (violations.length > 0) { notify( @@ -250,7 +255,7 @@ async function applyProfileFromCommand( ctx, { title: "Apply profile", message: `Applying ${desired.name}...`, cancellable: false }, async ({ setMessage }) => { - const catalog = getPackageCatalog(ctx.cwd); + const catalog = getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)); for (const pkg of plan.remove) { setMessage(`Removing ${pkg.source}...`); await catalog.remove(pkg.source, pkg.scope); diff --git a/src/commands/registry.ts b/src/commands/registry.ts index e3a4e11..a2d750f 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -69,7 +69,7 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void { " /extensions history [opts] - Show history (supports filters)", " /extensions doctor - Inspect runtime ownership/conflicts", " /extensions profile - Export, compare, or dry-run a profile", - " /extensions auto-update - Configure auto-update (e.g. 1d, 1w, 1mo, never)", + " /extensions auto-update - Configure scheduled update checks (e.g. 1d, 1w, 1mo, never)", "", "History examples:", " /extensions history --failed --limit 50", @@ -165,7 +165,7 @@ const COMMAND_DEFINITIONS: Record = { }, "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), }, diff --git a/src/commands/update.ts b/src/commands/update.ts index 6b1b970..3cdd873 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,5 +1,6 @@ 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"; @@ -14,7 +15,7 @@ export async function handleUpdateSubcommand( try { const [installed, available] = await Promise.all([ getInstalledPackagesAllScopes(ctx), - getPackageCatalog(ctx.cwd).checkForAvailableUpdates(), + getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).checkForAvailableUpdates(), ]); const preview = buildUpdatePreview(installed, available).filter((pkg) => pkg.updateAvailable); notify( diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index 6dd12bb..d411f0a 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -7,8 +7,13 @@ import { type Dirent } from "node:fs"; import { readdir, rename } from "node:fs/promises"; -import { homedir } from "node:os"; 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"; @@ -42,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[] = []; @@ -350,8 +359,8 @@ export async function removeLocalExtension( | { 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); @@ -366,7 +375,7 @@ export async function removeLocalExtension( const isIndexFile = /^index\.(ts|js)$/i.test(normalizedBase); const isInsideExtensionDir = parentDir !== globalRoot && parentDir !== projectRoot; - const trashRoot = join(homedir(), ".pi", "agent", ".extmgr-trash"); + const trashRoot = getExtmgrTrashDir(); if (isIndexFile && isInsideExtensionDir) { const trashRecord = await moveToExtensionTrash(parentDir, trashRoot); return { ok: true, removedPath: parentDir, removedDirectory: true, trashRecord }; diff --git a/src/index.ts b/src/index.ts index 20d2a01..99e9c1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,7 +61,10 @@ export default function extensionsManager(pi: ExtensionAPI) { getArgumentCompletions: getExtensionsAutocompleteItems, handler: async (args, ctx) => { await executeExtensionsCommand(args, ctx, pi); - await refreshLocalCompletionIndex(ctx.cwd).catch((error) => { + await refreshLocalCompletionIndex( + ctx.cwd, + typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() + ).catch((error) => { console.warn("[extmgr] Failed to refresh local completions:", error); }); }, @@ -74,7 +77,10 @@ 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).catch((error) => { + await refreshLocalCompletionIndex( + ctx.cwd, + typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() + ).catch((error) => { console.warn("[extmgr] Failed to load local completions:", error); }); diff --git a/src/packages/catalog.ts b/src/packages/catalog.ts index 36d27e5..9f65b11 100644 --- a/src/packages/catalog.ts +++ b/src/packages/catalog.ts @@ -29,7 +29,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; @@ -83,9 +83,9 @@ 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 { @@ -159,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 32631cf..ab39805 100644 --- a/src/packages/discovery.ts +++ b/src/packages/discovery.ts @@ -25,6 +25,7 @@ import { readSummary } from "../utils/fs.js"; import { fetchWithTimeout } from "../utils/network.js"; import { execNpm } from "../utils/npm-exec.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; +import { isProjectTrusted } from "../utils/mode.js"; import { getPackageCatalog } from "./catalog.js"; const NPM_SEARCH_API = "https://registry.npmjs.org/-/v1/search"; @@ -279,7 +280,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 []; } @@ -303,7 +304,9 @@ export async function isSourceInstalled( ctx: ExtensionCommandContext | ExtensionContext, options?: { scope?: "global" | "project" } ): Promise { - const installed = await getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false }); + const installed = await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).listInstalledPackages({ + dedupe: false, + }); const expected = normalizePackageIdentity(source, { cwd: ctx.cwd }); return installed.some((pkg) => { @@ -317,7 +320,7 @@ export async function isSourceInstalled( 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/extensions.ts b/src/packages/extensions.ts index bce6828..2dfa709 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -1,11 +1,16 @@ 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 { + DefaultPackageManager, + getAgentDir, + SettingsManager, + type ResolvedResource, +} from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type PackageExtensionEntry, @@ -20,6 +25,7 @@ import { resolveRelativePathSelection, } from "../utils/relative-path-selection.js"; import { resolveConfiguredNpmRootCommand } from "../utils/npm-exec.js"; +import { throwIfSettingsErrors } from "../utils/settings-errors.js"; interface PackageSettingsObject { source: string; @@ -27,10 +33,6 @@ interface PackageSettingsObject { [key: string]: unknown; } -interface SettingsFile { - packages?: (string | PackageSettingsObject)[]; -} - export interface PackageManifest { name?: string; dependencies?: Record; @@ -164,75 +166,21 @@ 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 {}; - } +function createSettingsManager(cwd: string, projectTrusted: boolean): SettingsManager { + return SettingsManager.create(cwd, getAgentDir(), { projectTrusted }); } -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) => { @@ -304,10 +252,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 { @@ -321,17 +272,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 settings = createSettingsManager(cwd, projectTrusted); + throwIfSettingsErrors(settings, "Package extension configuration"); const normalizedSource = normalizeSource(packageSource); - const packages = [...(settings.packages ?? [])]; + const packages = getScopedPackages(settings, scope); const index = findPackageSettingsIndex(packages, normalizedSource); const packageEntry = toPackageSettingsObject(packages[index], packageSource); @@ -354,8 +306,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) { @@ -427,10 +384,11 @@ 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) { @@ -606,15 +564,60 @@ export function discoverPackageExtensionEntrypoints( 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), ]); for (const pkg of packages) { + const packageManager = new DefaultPackageManager({ + cwd, + agentDir: getAgentDir(), + settingsManager: createSettingsManager(cwd, projectTrusted), + }); + let resolvedResources: ResolvedResource[] = []; + try { + if (!pkg.resolvedPath) throw new Error("package path is not characterized"); + const resolved = await packageManager.resolveExtensionSources([pkg.source], { + local: pkg.scope === "project", + }); + resolvedResources = resolved.extensions.filter( + (resource) => + resource.metadata.scope === (pkg.scope === "project" ? "project" : "user") && + normalizeSource(resource.metadata.source) === normalizeSource(pkg.source) + ); + } catch { + // Compatibility adapter for package records not representable by Pi's resolver. + } + + 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; + } + const packageRoot = await toPackageRoot(pkg, cwd); if (!packageRoot) continue; @@ -654,7 +657,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/install.ts b/src/packages/install.ts index 99df6e1..a88d856 100644 --- a/src/packages/install.ts +++ b/src/packages/install.ts @@ -2,7 +2,6 @@ * 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, @@ -10,13 +9,18 @@ import { type ProgressEvent, } from "@earendil-works/pi-coding-agent"; import { TIMEOUTS } from "../constants.js"; +import { getAgentDir, 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, + fetchWithTimeout, + MAX_COMPRESSED_DOWNLOAD_BYTES, +} from "../utils/network.js"; import { notify, error as notifyError, success } from "../utils/notify.js"; import { execNpm } from "../utils/npm-exec.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; @@ -45,7 +49,7 @@ 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; @@ -71,9 +75,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( @@ -204,9 +208,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; } ); @@ -429,13 +437,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 }; }, diff --git a/src/packages/management.ts b/src/packages/management.ts index d8e70ca..676a0cb 100644 --- a/src/packages/management.ts +++ b/src/packages/management.ts @@ -13,7 +13,7 @@ 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 { clearUpdatesAvailable } from "../utils/settings.js"; @@ -58,7 +58,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 ); @@ -80,7 +83,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; @@ -113,7 +116,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); @@ -131,7 +137,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; @@ -301,9 +307,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; } ); diff --git a/src/packages/scopes.ts b/src/packages/scopes.ts index 1efcce9..78bbeeb 100644 --- a/src/packages/scopes.ts +++ b/src/packages/scopes.ts @@ -1,4 +1,5 @@ -import { getAgentDir, SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; +import { SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; +import { CONFIG_DIR_NAME, getAgentDir } from "../utils/pi-paths.js"; import { type InstalledPackage, type Scope } from "../types/index.js"; import { normalizePackageIdentity } from "../utils/package-source.js"; import { throwIfSettingsErrors } from "../utils/settings-errors.js"; @@ -52,7 +53,9 @@ export function comparePackageScopes(packages: InstalledPackage[]): PackageScope } export function getPackageScopeLabel(scope: Scope): string { - return scope === "project" ? "project (.pi/settings.json)" : "global (~/.pi/agent/settings.json)"; + return scope === "project" + ? `project (${CONFIG_DIR_NAME}/settings.json)` + : "global (~/.pi/agent/settings.json)"; } function sourceOf(value: PackageSource): string { @@ -88,13 +91,14 @@ export async function movePackageBetweenScopes( source: string, from: Scope, to: Scope, - cwd: string + 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()); + const settings = SettingsManager.create(cwd, getAgentDir(), { projectTrusted }); try { throwIfSettingsErrors(settings, "Package scope move"); } catch (error) { diff --git a/src/profiles/compare.ts b/src/profiles/compare.ts index 8f07c5d..339ba1c 100644 --- a/src/profiles/compare.ts +++ b/src/profiles/compare.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { getProjectConfigPath } from "../utils/pi-paths.js"; import { planProfileApplication } from "./apply.js"; import { type ExtmgrProfile } from "./schema.js"; @@ -56,9 +56,11 @@ export function validateProfilePolicy( export async function loadProjectProfilePolicy( cwd: string, - path?: string + path?: string, + projectTrusted = false ): Promise { - const policyPath = path ?? join(cwd, ".pi", "extmgr-policy.json"); + 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)) { diff --git a/src/profiles/store.ts b/src/profiles/store.ts index 9f90f78..ddbd2ee 100644 --- a/src/profiles/store.ts +++ b/src/profiles/store.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { getExtmgrCacheDir } from "../utils/pi-paths.js"; import { type ExtmgrProfile, normalizeProfile } from "./schema.js"; export interface ProfileStoreFile { @@ -101,8 +101,6 @@ export async function deleteNamedProfile(path: string, name: string): Promise 0) parts.push(`S save (${state.pendingChanges})`); - return `${parts.join(" · ")}\nMore: 1-7 filters · W/L/D views · * favorite · i install · f search · U update all · t auto-update · P palette · R browse · ? help · ${cancel}`; + return `${parts.join(" · ")}\nMore: 1-7 filters · W/L/D views · * favorite · i install · f search · U update all · t scheduled checks · P palette · R browse · ? help · ${cancel}`; } diff --git a/src/ui/help.ts b/src/ui/help.ts index 160fedf..28d0882 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -32,7 +32,7 @@ export function buildHelpLines(): string[] { " f / R Search / browse remote packages", " W / L / D Save / load / delete a manager view", " * Toggle favorite", - " t Auto-update settings", + " t Scheduled update checks settings", " P / M Quick actions palette", "", "Sources and safety", diff --git a/src/ui/package-config.ts b/src/ui/package-config.ts index ccf0947..c9d47ef 100644 --- a/src/ui/package-config.ts +++ b/src/ui/package-config.ts @@ -26,7 +26,7 @@ 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"; @@ -275,7 +275,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] @@ -304,7 +305,8 @@ export async function applyPackageExtensionChanges( pkg.source, pkg.scope, changedRows.map(({ row, target }) => ({ extensionPath: row.extensionPath, target })), - cwd + cwd, + projectTrusted ); if (!result.ok) { @@ -332,7 +334,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 }; @@ -348,7 +354,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 }; } @@ -399,7 +407,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/remote.ts b/src/ui/remote.ts index c68dc39..8a83a76 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -1047,12 +1047,12 @@ async function confirmMarketplaceInstall( const scopeChoice = parseChoiceByLabel( { global: "Global (~/.pi/agent/settings.json)", - project: "Project (.pi/settings.json)", + project: ".pi/settings.json", cancel: "Cancel", }, await ctx.ui.select("Install scope", [ "Global (~/.pi/agent/settings.json)", - "Project (.pi/settings.json)", + ".pi/settings.json", "Cancel", ]) ); diff --git a/src/ui/unified.ts b/src/ui/unified.ts index a9cc9bf..2ceb331 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -60,7 +60,7 @@ import { logExtensionToggle, queryPackageTimeline, } from "../utils/history.js"; -import { hasCustomUI, runCustomUI } from "../utils/mode.js"; +import { hasCustomUI, isProjectTrusted, runCustomUI } from "../utils/mode.js"; import { notify } from "../utils/notify.js"; import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; import { normalizePathIdentity } from "../utils/path-identity.js"; @@ -152,7 +152,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 }; } @@ -1438,7 +1440,7 @@ const PALETTE_OPTIONS = { search: "🔎 Search packages", browse: "🌐 Browse community packages", updateAll: "⬆️ Update all packages", - autoUpdate: "🔁 Auto-update settings", + autoUpdate: "🔁 Scheduled update checks settings", help: "❓ Help", back: "Back", } as const; @@ -1452,7 +1454,7 @@ const QUICK_DESTINATION_LABELS: Record = { search: "Search", browse: "Remote", "update-all": "Update", - "auto-update": "Auto-update", + "auto-update": "Scheduled update checks", help: "Help", }; @@ -1734,7 +1736,7 @@ async function handleUnifiedAction( fallbackWithoutLoader: true, }, async ({ setMessage }) => { - const catalog = getPackageCatalog(ctx.cwd); + const catalog = getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)); const completed: string[] = []; const failed: string[] = []; const skipped: string[] = []; @@ -1772,7 +1774,8 @@ async function handleUnifiedAction( item.source, item.scope, item.extensionPaths.map((extensionPath) => ({ extensionPath, target })), - ctx.cwd + ctx.cwd, + isProjectTrusted(ctx) ); if (!changed.ok) throw new Error(changed.error); } @@ -2010,7 +2013,13 @@ async function handleUnifiedAction( `Move ${item.source} from ${item.scope} to ${targetScope}?` ); if (!confirmed) return "resume"; - const moved = await movePackageBetweenScopes(item.source, item.scope, targetScope, ctx.cwd); + 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"}`, @@ -2034,7 +2043,8 @@ async function handleUnifiedAction( item.source, item.scope, item.extensionPaths.map((extensionPath) => ({ extensionPath, target })), - ctx.cwd + ctx.cwd, + isProjectTrusted(ctx) ); if (!result.ok) { ctx.ui.notify(`Package toggle failed: ${result.error}`, "error"); 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 c2cab39..c563edc 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -2,16 +2,20 @@ * 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 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"); +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", @@ -200,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); @@ -226,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) { @@ -306,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); } diff --git a/src/utils/history.ts b/src/utils/history.ts index dc581d3..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 @@ -284,32 +283,6 @@ export function queryPackageTimeline( ); } -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; -} - function pushGlobalHistoryEntry( entries: GlobalHistoryEntry[], nextEntry: GlobalHistoryEntry, @@ -350,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); } } @@ -428,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/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 602b848..5770412 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -1,3 +1,7 @@ +import { open, rm } from "node:fs/promises"; + +export const MAX_COMPRESSED_DOWNLOAD_BYTES = 50 * 1024 * 1024; + export async function fetchWithTimeout( url: string, timeoutMs: number, @@ -32,11 +36,6 @@ export async function fetchWithTimeout( try { const operation = (async (): Promise => { const response = await fetch(url, { signal: combinedSignal }); - - // `fetch()` resolves after headers arrive. Buffering the body here keeps - // the timeout active for the complete request, including slow/stalled bodies. - // extmgr's callers consume finite JSON/text/archive responses rather than - // streaming them, so returning an equivalent buffered Response is safe. const body = await response.arrayBuffer(); return new Response(body, { status: response.status, @@ -51,14 +50,62 @@ export async function fetchWithTimeout( ...(cancellationPromise ? [cancellationPromise] : []), ]); } catch (error) { - if (error instanceof Error && error.name === "AbortError" && signal?.aborted) { - throw error; + if (error instanceof Error && error.name === "AbortError" && signal?.aborted) throw error; + if (timedOut) throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + throw error; + } finally { + clearTimeout(timer); + } +} + +/** Stream a compressed download to disk with an independent size limit. */ +export async function downloadToFile( + url: string, + destination: string, + timeoutMs: number, + maxBytes = MAX_COMPRESSED_DOWNLOAD_BYTES, + signal?: AbortSignal +): Promise { + 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 completed = false; + + try { + const response = await fetch(url, { signal: combinedSignal }); + 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 ${Math.round(maxBytes / 1024 / 1024)} MiB limit`); } - if (timedOut) { - throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + if (!response.body) throw new Error("Download failed: response has no body"); + + handle = await open(destination, "w"); + const reader = response.body.getReader(); + let total = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + total += chunk.value.byteLength; + if (total > maxBytes) { + controller.abort(); + throw new Error(`Download exceeds the ${Math.round(maxBytes / 1024 / 1024)} MiB limit`); + } + await handle.write(chunk.value); } + completed = true; + } catch (error) { + if (timedOut) throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); throw error; } finally { clearTimeout(timer); + await handle?.close().catch(() => undefined); + if (!completed) 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/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/reload-state.ts b/src/utils/reload-state.ts index 3af22d1..0ce18d8 100644 --- a/src/utils/reload-state.ts +++ b/src/utils/reload-state.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { getExtmgrCacheDir } from "./pi-paths.js"; export interface ReloadRequiredState { version: 1; @@ -17,10 +17,13 @@ const DEFAULT_STATE: ReloadRequiredState = { reasons: [], }; -const STATE_DIR = process.env.PI_EXTMGR_CACHE_DIR - ? process.env.PI_EXTMGR_CACHE_DIR - : join(homedir(), ".pi", "agent", ".extmgr-cache"); -const STATE_FILE = join(STATE_DIR, "reload-required.json"); +function stateDir(): string { + return getExtmgrCacheDir(); +} + +function stateFile(): string { + return join(stateDir(), "reload-required.json"); +} let writeQueue: Promise = Promise.resolve(); @@ -67,12 +70,12 @@ async function writeStateToDisk(path: string, state: ReloadRequiredState): Promi } } -export async function readReloadState(path = STATE_FILE): Promise { +export async function readReloadState(path = stateFile()): Promise { await writeQueue; return readStateFromDisk(path); } -export async function markReloadRequired(reason: string, path = STATE_FILE): Promise { +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); @@ -91,11 +94,11 @@ export async function markReloadRequired(reason: string, path = STATE_FILE): Pro await writeQueue; } -export async function clearReloadRequired(path = STATE_FILE): Promise { +export async function clearReloadRequired(path = stateFile()): Promise { writeQueue = writeQueue.then(() => writeStateToDisk(path, cloneDefault())); await writeQueue; } export function getReloadRequiredStatePath(): string { - return STATE_FILE; + return stateFile(); } diff --git a/src/utils/settings-errors.ts b/src/utils/settings-errors.ts index 98d95d4..f20378d 100644 --- a/src/utils/settings-errors.ts +++ b/src/utils/settings-errors.ts @@ -4,9 +4,15 @@ export function throwIfSettingsErrors(settings: SettingsManager, operation: stri 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: ${errors - .map(({ scope, error }) => `${scope}: ${error.message}`) - .join("; ")}` + `${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..ad0c995 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, @@ -31,10 +30,15 @@ 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"); +import { getExtmgrCacheDir } from "./pi-paths.js"; + +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..186a685 100644 --- a/src/utils/status.ts +++ b/src/utils/status.ts @@ -8,6 +8,7 @@ import { getAgentDir, } from "@earendil-works/pi-coding-agent"; import { getPackageCatalog, type PackageCatalog } from "../packages/catalog.js"; +import { isProjectTrusted } from "./mode.js"; import { getAutoUpdateStatus } from "./auto-update.js"; import { normalizePackageIdentity } from "./package-source.js"; import { getAutoUpdateConfigAsync, saveAutoUpdateConfig } from "./settings.js"; @@ -38,7 +39,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/views.ts b/src/utils/views.ts index b3af995..a482f96 100644 --- a/src/utils/views.ts +++ b/src/utils/views.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { homedir } from "node:os"; +import { getExtmgrCacheDir } from "./pi-paths.js"; export interface SavedView { name: string; @@ -114,9 +114,7 @@ export async function writeSavedViews(path: string, data: SavedViewsFile): Promi } export function getSavedViewsPath(cwd?: string): string { - const directory = process.env.PI_EXTMGR_CACHE_DIR - ? process.env.PI_EXTMGR_CACHE_DIR - : join(homedir(), ".pi", "agent", ".extmgr-cache"); + 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/cache-history.test.ts b/test/cache-history.test.ts index 11f953b..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 { @@ -44,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); @@ -139,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/helpers/mocks.ts b/test/helpers/mocks.ts index c772c8e..7dcb308 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -28,6 +28,7 @@ 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 }; @@ -170,7 +171,9 @@ export function createMockHarness(options: MockHarnessOptions = {}): { const ctx = { hasUI: options.hasUI ?? false, + mode: options.hasUI ? "tui" : "print", cwd: options.cwd ?? "/tmp", + isProjectTrusted: () => options.projectTrusted ?? true, ui, reload: () => { reloadCalls += 1; diff --git a/test/install-remove.test.ts b/test/install-remove.test.ts index f303936..cb9d80a 100644 --- a/test/install-remove.test.ts +++ b/test/install-remove.test.ts @@ -607,10 +607,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({ @@ -682,10 +679,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/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 a80fdb8..5860172 100644 --- a/test/package-extensions.test.ts +++ b/test/package-extensions.test.ts @@ -60,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"] @@ -93,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"] @@ -130,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"] @@ -166,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 }); @@ -199,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"] @@ -232,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"] @@ -277,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"); @@ -325,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 { @@ -385,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"); @@ -432,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 { @@ -515,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 { @@ -548,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 { @@ -584,7 +584,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 { @@ -614,7 +614,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 { diff --git a/test/package-scopes.test.ts b/test/package-scopes.test.ts index 2e2e462..e57a25f 100644 --- a/test/package-scopes.test.ts +++ b/test/package-scopes.test.ts @@ -50,7 +50,7 @@ void test("movePackageBetweenScopes preserves filters, unknown fields, and effec "utf8" ); - const result = await movePackageBetweenScopes("npm:demo@1.2.3", "global", "project", cwd); + 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 { @@ -93,7 +93,7 @@ void test("movePackageBetweenScopes refuses a conflicting destination", async () JSON.stringify({ packages: ["npm:demo@2.0.0"] }), "utf8" ); - const result = await movePackageBetweenScopes("npm:demo@1.0.0", "global", "project", cwd); + 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 { @@ -117,7 +117,7 @@ void test("package mutations refuse malformed settings without reporting success () => getPackageCatalog(cwd).install("npm:demo", "global"), /Package installation refused/ ); - const moved = await movePackageBetweenScopes("npm:demo", "global", "project", cwd); + 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"); From 3ecd6762d8f527e74782c42fbfe2644c7f261ca6 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 02:34:55 +0100 Subject: [PATCH 62/68] test(install): cover bounded download cleanup --- src/utils/network.ts | 2 ++ test/network.test.ts | 81 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/utils/network.ts b/src/utils/network.ts index 5770412..d9b6f55 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -90,7 +90,9 @@ export async function downloadToFile( const reader = response.body.getReader(); let total = 0; while (true) { + combinedSignal.throwIfAborted(); const chunk = await reader.read(); + combinedSignal.throwIfAborted(); if (chunk.done) break; total += chunk.value.byteLength; if (total > maxBytes) { diff --git a/test/network.test.ts b/test/network.test.ts index 3107aa5..00684c0 100644 --- a/test/network.test.ts +++ b/test/network.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; -import { fetchWithTimeout } from "../src/utils/network.js"; +import { downloadToFile, fetchWithTimeout } from "../src/utils/network.js"; void test("fetchWithTimeout enforces the timeout while reading the response body", async () => { const originalFetch = globalThis.fetch; @@ -20,6 +23,82 @@ void test("fetchWithTimeout enforces the timeout while reading the response body } }); +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 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 = (() => + Promise.resolve( + new Response( + new ReadableStream({ + start(stream) { + stream.enqueue(new Uint8Array([1])); + setTimeout(() => stream.enqueue(new Uint8Array([2])), 10); + }, + }) + ) + )) 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 = (() => { From 77c43dc75f9081f8dbadada57f78e2e3910a4e49 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 02:35:28 +0100 Subject: [PATCH 63/68] refactor(packages): prefer Pi resource resolution --- src/packages/extensions.ts | 41 +++++++++--------- test/trust-paths.test.ts | 85 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 test/trust-paths.test.ts diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index 2dfa709..a5ede57 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -26,6 +26,7 @@ import { } from "../utils/relative-path-selection.js"; import { resolveConfiguredNpmRootCommand } from "../utils/npm-exec.js"; import { throwIfSettingsErrors } from "../utils/settings-errors.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; interface PackageSettingsObject { source: string; @@ -101,11 +102,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); @@ -574,26 +575,24 @@ export async function discoverPackageExtensions( readPackageFilterMap("project", cwd, projectTrusted), ]); + const packageManager = new DefaultPackageManager({ + cwd, + agentDir: getAgentDir(), + settingsManager: createSettingsManager(cwd, projectTrusted), + }); + let canonicalResources: ResolvedResource[] = []; + try { + canonicalResources = (await packageManager.resolve(async () => "skip")).extensions; + } catch { + // Individual records can still use the compatibility adapter below. + } + for (const pkg of packages) { - const packageManager = new DefaultPackageManager({ - cwd, - agentDir: getAgentDir(), - settingsManager: createSettingsManager(cwd, projectTrusted), - }); - let resolvedResources: ResolvedResource[] = []; - try { - if (!pkg.resolvedPath) throw new Error("package path is not characterized"); - const resolved = await packageManager.resolveExtensionSources([pkg.source], { - local: pkg.scope === "project", - }); - resolvedResources = resolved.extensions.filter( - (resource) => - resource.metadata.scope === (pkg.scope === "project" ? "project" : "user") && - normalizeSource(resource.metadata.source) === normalizeSource(pkg.source) - ); - } catch { - // Compatibility adapter for package records not representable by Pi's resolver. - } + const resolvedResources = canonicalResources.filter( + (resource) => + resource.metadata.scope === (pkg.scope === "project" ? "project" : "user") && + normalizeSource(resource.metadata.source) === normalizeSource(pkg.source) + ); if (resolvedResources.length > 0) { for (const resource of resolvedResources) { 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; + } +}); From 93cece35d687ce5af7baf2ea88b3061f0a34a885 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 02:35:55 +0100 Subject: [PATCH 64/68] refactor(ui): extract manager state and remote queries --- src/ui/manager/state.ts | 59 +++++++++++++++++ src/ui/remote.ts | 136 +++------------------------------------- src/ui/remote/query.ts | 102 ++++++++++++++++++++++++++++++ src/ui/unified.ts | 63 +++---------------- 4 files changed, 179 insertions(+), 181 deletions(-) create mode 100644 src/ui/manager/state.ts create mode 100644 src/ui/remote/query.ts diff --git a/src/ui/manager/state.ts b/src/ui/manager/state.ts new file mode 100644 index 0000000..c604d84 --- /dev/null +++ b/src/ui/manager/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/remote.ts b/src/ui/remote.ts index 8a83a76..b9318b4 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -40,14 +40,22 @@ import { validateCompatibility } from "../doctor/compatibility.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 { formatBytes, 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 { activeKeyHint } from "../utils/key-hints.js"; import { RequestGeneration, runTaskWithLoader } from "./async-task.js"; +import { + COMMUNITY_BROWSE_QUERY, + createCommunityBrowsePlan, + createRemoteBrowseQueryPlan, + filterRemoteBrowseResults, + type RemoteBrowseQueryPlan, + type RemoteBrowseSource, + resolveRemoteBrowseSource, +} from "./remote/query.js"; interface PackageInfoCacheEntry { timestamp: number; @@ -143,130 +151,6 @@ 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} ${trimmed}`, - 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 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"; diff --git a/src/ui/remote/query.ts b/src/ui/remote/query.ts new file mode 100644 index 0000000..cda0a20 --- /dev/null +++ b/src/ui/remote/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/unified.ts b/src/ui/unified.ts index 2ceb331..2c6fe98 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -66,18 +66,20 @@ import { getPackageSourceKind, normalizePackageIdentity } from "../utils/package import { normalizePathIdentity } from "../utils/path-identity.js"; import { comparePackageScopes, movePackageBetweenScopes } from "../packages/scopes.js"; import { markReloadRequired, readReloadState } from "../utils/reload-state.js"; -import { - getSavedViewsPath, - type SavedView, - readSavedViews, - writeSavedViews, -} from "../utils/views.js"; +import { getSavedViewsPath, readSavedViews, writeSavedViews } from "../utils/views.js"; import { updateExtmgrStatus } from "../utils/status.js"; import { confirmReload, formatListOutput } from "../utils/ui-helpers.js"; import { runTaskWithLoader } from "./async-task.js"; import { buildFooterShortcuts, buildFooterState, getPendingToggleChangeCount } from "./footer.js"; import { showHelp } from "./help.js"; import { configurePackageExtensions } from "./package-config.js"; +import { + managerStateToView, + UNIFIED_FILTER_OPTIONS, + type UnifiedFilter, + type UnifiedManagerViewState, + viewToManagerState, +} from "./manager/state.js"; import { showRemote } from "./remote.js"; import { getChangeMarker, getPackageIcon, getScopeIcon, getStatusIcon } from "./theme.js"; @@ -537,55 +539,6 @@ function buildManagerSummary( return parts.join(" • "); } -type UnifiedFilter = "all" | "local" | "packages" | "updates" | "disabled" | "favorites" | "recent"; - -interface UnifiedManagerViewState { - filter: UnifiedFilter; - searchQuery: string; - selectedItemId?: string; - selectedItemIds: 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" }, - { 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); -} - -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 ?? [])], - }; -} - -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, - }; -} - function getCurrentUnifiedItemState( item: UnifiedItem, staged: Map From 8e5f1a18e9d64ed53701ae7493a9407135c6a6eb Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Fri, 17 Jul 2026 02:38:34 +0100 Subject: [PATCH 65/68] refactor(packages): isolate Pi extension resolution --- src/packages/extension-resolution.ts | 42 ++++++++++++++++++++++++++++ src/packages/extensions.ts | 31 ++++++-------------- 2 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 src/packages/extension-resolution.ts diff --git a/src/packages/extension-resolution.ts b/src/packages/extension-resolution.ts new file mode 100644 index 0000000..5e71299 --- /dev/null +++ b/src/packages/extension-resolution.ts @@ -0,0 +1,42 @@ +import { + DefaultPackageManager, + getAgentDir, + type ResolvedResource, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; + +function normalizeSource(source: string): string { + return source + .trim() + .replace(/\s+\((filtered|pinned)\)$/i, "") + .trim(); +} + +/** 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 = normalizeSource(source); + return resources.filter( + (resource) => + resource.metadata.scope === expectedScope && + normalizeSource(resource.metadata.source) === expectedSource + ); +} diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index a5ede57..cd57fcb 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -5,12 +5,7 @@ import { homedir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { - DefaultPackageManager, - getAgentDir, - SettingsManager, - type ResolvedResource, -} from "@earendil-works/pi-coding-agent"; +import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { type InstalledPackage, type PackageExtensionEntry, @@ -18,6 +13,7 @@ import { type State, } from "../types/index.js"; import { parseNpmSource } from "../utils/format.js"; +import { resolveConfiguredPackageExtensions, resourcesForPackage } from "./extension-resolution.js"; import { fileExists, readSummary } from "../utils/fs.js"; import { matchesFilterPattern, @@ -432,6 +428,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; } @@ -575,24 +572,10 @@ export async function discoverPackageExtensions( readPackageFilterMap("project", cwd, projectTrusted), ]); - const packageManager = new DefaultPackageManager({ - cwd, - agentDir: getAgentDir(), - settingsManager: createSettingsManager(cwd, projectTrusted), - }); - let canonicalResources: ResolvedResource[] = []; - try { - canonicalResources = (await packageManager.resolve(async () => "skip")).extensions; - } catch { - // Individual records can still use the compatibility adapter below. - } + const canonicalResources = await resolveConfiguredPackageExtensions(cwd, projectTrusted); for (const pkg of packages) { - const resolvedResources = canonicalResources.filter( - (resource) => - resource.metadata.scope === (pkg.scope === "project" ? "project" : "user") && - normalizeSource(resource.metadata.source) === normalizeSource(pkg.source) - ); + const resolvedResources = resourcesForPackage(canonicalResources, pkg.source, pkg.scope); if (resolvedResources.length > 0) { for (const resource of resolvedResources) { @@ -617,6 +600,10 @@ export async function discoverPackageExtensions( 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; From 95d8675bf3beda45d114f9f6bd887ac247962d1c Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 20 Jul 2026 22:39:57 +0100 Subject: [PATCH 66/68] test: isolate extmgr tests from host state --- package.json | 4 +-- test/helpers/hermetic-environment.mjs | 39 +++++++++++++++++++++ test/helpers/mocks.ts | 14 ++++++-- test/hermetic-environment.test.ts | 33 ++++++++++++++++++ test/ui-helpers.test.ts | 50 +-------------------------- 5 files changed, 86 insertions(+), 54 deletions(-) create mode 100644 test/helpers/hermetic-environment.mjs create mode 100644 test/hermetic-environment.test.ts 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/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 7dcb308..2ee4519 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -31,7 +31,15 @@ export interface MockHarnessOptions { 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; @@ -114,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 => { @@ -172,7 +180,7 @@ export function createMockHarness(options: MockHarnessOptions = {}): { const ctx = { hasUI: options.hasUI ?? false, mode: options.hasUI ? "tui" : "print", - cwd: options.cwd ?? "/tmp", + cwd: options.cwd ?? getDefaultTestCwd(), isProjectTrusted: () => options.projectTrusted ?? true, ui, reload: () => { 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/ui-helpers.test.ts b/test/ui-helpers.test.ts index 129fe25..230d211 100644 --- a/test/ui-helpers.test.ts +++ b/test/ui-helpers.test.ts @@ -3,55 +3,7 @@ import test from "node:test"; import { type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { confirmReload } 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; - const ctx = { - hasUI: true, - ui: { - confirm: () => Promise.resolve(true), - 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, true); - assert.equal(reloadCalls, 1); - assert.deepEqual(notifications, []); -}); - -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, From a912ac50977bf44694cf58fde4f2c4194dc72880 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 21 Jul 2026 17:50:57 +0100 Subject: [PATCH 67/68] feat(ui): split manager into task-oriented workspaces Installed, Discover, Profiles, and Health are now dedicated workspace screens cycled with Tab/Shift+Tab. Installed gains a responsive master/detail layout; Discover adds weekly-download hydration and popularity sorting; Profiles get inline current-vs-target diffs gated behind a review screen; Health consolidates conflicts, compatibility, reload state, and recoverable trash with a safe fix-all action. --- README.md | 16 +- docs/assets/extension-manager.png | Bin 79053 -> 0 bytes src/commands/completion.ts | 9 +- src/commands/profile.ts | 1165 ++++++++++++--- src/commands/registry.ts | 34 +- src/doctor/compatibility.ts | 100 +- src/extensions/trash.ts | 177 ++- src/index.ts | 2 + src/packages/catalog.ts | 28 +- src/packages/discovery.ts | 142 +- src/packages/extension-resolution.ts | 12 +- src/packages/extensions.ts | 29 +- src/packages/inspection.ts | 2 +- src/packages/install.ts | 133 +- src/packages/management.ts | 10 +- src/packages/scopes.ts | 66 +- src/packages/sorting.ts | 7 +- src/profiles/apply.ts | 104 +- src/profiles/checksum.ts | 42 +- src/profiles/compare.ts | 71 +- src/profiles/schema.ts | 690 ++++++++- src/profiles/source.ts | 181 +++ src/profiles/store.ts | 260 +++- src/types/index.ts | 5 + src/ui/discover/browser.ts | 344 +++++ src/ui/discover/formatting.ts | 36 + src/ui/discover/metadata.ts | 186 +++ src/ui/{remote => discover}/query.ts | 0 src/ui/footer.ts | 24 +- src/ui/health.ts | 539 +++++++ src/ui/help.ts | 27 +- src/ui/installed/actions.ts | 840 +++++++++++ src/ui/installed/browser.ts | 563 ++++++++ src/ui/installed/filters.ts | 160 +++ src/ui/installed/formatting.ts | 127 ++ src/ui/installed/items.ts | 159 ++ src/ui/{manager => installed}/state.ts | 0 src/ui/installed/summary.ts | 69 + src/ui/layout.ts | 50 + src/ui/list-navigation.ts | 28 + src/ui/package-config.ts | 17 +- src/ui/profile-review.ts | 185 +++ src/ui/profiles.ts | 236 +++ src/ui/remote.ts | 776 ++-------- src/ui/theme.ts | 52 - src/ui/unified.ts | 1837 +----------------------- src/ui/workspace/navigation.ts | 51 + src/ui/workspace/router.ts | 40 + src/utils/abort.ts | 7 + src/utils/format.ts | 5 +- src/utils/network.ts | 263 +++- src/utils/package-source.ts | 35 +- src/utils/progress.ts | 5 + src/utils/settings.ts | 2 +- src/utils/status.ts | 5 +- src/utils/ui-helpers.ts | 12 + test/discovery-parser.test.ts | 4 +- test/format.test.ts | 17 +- test/health-conflicts.test.ts | 147 ++ test/health-remediation.test.ts | 406 ++++++ test/install-remove.test.ts | 64 +- test/list-navigation.test.ts | 18 + test/network.test.ts | 128 +- test/npm-search.test.ts | 84 +- test/package-extensions.test.ts | 25 + test/package-sorting.test.ts | 32 +- test/package-source.test.ts | 20 + test/profile-apply-gate.test.ts | 233 +++ test/profile-command.test.ts | 285 +++- test/profile-diff.test.ts | 112 ++ test/profile-hardening-extra.test.ts | 784 ++++++++++ test/profiles.test.ts | 82 +- test/remote-hydration.test.ts | 167 +++ test/remote-ui.test.ts | 77 +- test/ui-helpers.test.ts | 13 +- test/unified-items.test.ts | 16 + test/unified-ui.test.ts | 75 +- test/workspace-navigation.test.ts | 60 + test/workspace-routing.test.ts | 338 +++++ test/workspace-ui.test.ts | 158 ++ 80 files changed, 10172 insertions(+), 3138 deletions(-) delete mode 100644 docs/assets/extension-manager.png create mode 100644 src/profiles/source.ts create mode 100644 src/ui/discover/browser.ts create mode 100644 src/ui/discover/formatting.ts create mode 100644 src/ui/discover/metadata.ts rename src/ui/{remote => discover}/query.ts (100%) create mode 100644 src/ui/health.ts create mode 100644 src/ui/installed/actions.ts create mode 100644 src/ui/installed/browser.ts create mode 100644 src/ui/installed/filters.ts create mode 100644 src/ui/installed/formatting.ts create mode 100644 src/ui/installed/items.ts rename src/ui/{manager => installed}/state.ts (100%) create mode 100644 src/ui/installed/summary.ts create mode 100644 src/ui/layout.ts create mode 100644 src/ui/list-navigation.ts create mode 100644 src/ui/profile-review.ts create mode 100644 src/ui/profiles.ts create mode 100644 src/ui/workspace/navigation.ts create mode 100644 src/ui/workspace/router.ts create mode 100644 src/utils/abort.ts create mode 100644 src/utils/progress.ts create mode 100644 test/health-conflicts.test.ts create mode 100644 test/health-remediation.test.ts create mode 100644 test/list-navigation.test.ts create mode 100644 test/package-source.test.ts create mode 100644 test/profile-apply-gate.test.ts create mode 100644 test/profile-diff.test.ts create mode 100644 test/profile-hardening-extra.test.ts create mode 100644 test/remote-hydration.test.ts create mode 100644 test/workspace-navigation.test.ts create mode 100644 test/workspace-routing.test.ts create mode 100644 test/workspace-ui.test.ts diff --git a/README.md b/README.md index f76553e..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** @@ -62,14 +63,21 @@ version manager, or install project-local with `pi install npm:pi-extmgr -l`. - Quick actions (`A`, `u`, `X`) and coordinated bulk package actions (`B`) - **Remote discovery and install** - 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` - **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) @@ -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,8 +107,7 @@ 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-7` | Filters: All / Local / Packages / Updates / Disabled / Favorites / Recent | +| `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 | @@ -111,7 +119,7 @@ Open the manager: | `*` | Toggle favorite for selected item | | `U` | Update all packages | | `t` | Scheduled update checks wizard | -| `P` / `M` | Quick actions palette | +| `P` / `M` | Workspace screens and actions | | `R` | Browse remote packages | | `?` / `H` | Help | | `Esc` | Clear search or exit | diff --git a/docs/assets/extension-manager.png b/docs/assets/extension-manager.png deleted file mode 100644 index 5a5795e76240b8bb237af29ccc6d2b067aa56fc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 79053 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 diff --git a/src/commands/completion.ts b/src/commands/completion.ts index 5bb6eec..f2e55dc 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -1,5 +1,6 @@ -import { getAgentDir, SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; +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[]; @@ -8,10 +9,6 @@ export interface LocalCompletionIndex { let index: LocalCompletionIndex = { installedPackages: [], savedProfiles: [] }; -function sourceOf(source: PackageSource): string { - return typeof source === "string" ? source : source.source; -} - export async function refreshLocalCompletionIndex( cwd: string, projectTrusted = false @@ -20,7 +17,7 @@ export async function refreshLocalCompletionIndex( const installedPackages = [ ...(settings.getGlobalSettings().packages ?? []), ...(settings.getProjectSettings().packages ?? []), - ].map(sourceOf); + ].map(packageSourceString); const savedProfiles = Object.keys((await readProfileStore(getProfileStorePath())).profiles); index = { installedPackages: [...new Set(installedPackages)].sort(), diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 9f1dba4..8db2e85 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -1,157 +1,290 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { - getAgentDir, - SettingsManager, 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 { isProjectTrusted } from "../utils/mode.js"; import { getInstalledPackagesAllScopes } from "../packages/discovery.js"; -import { type InstalledPackage } from "../types/index.js"; -import { runTaskWithLoader } from "../ui/async-task.js"; -import { notify } from "../utils/notify.js"; -import { markReloadRequired } from "../utils/reload-state.js"; -import { parsePackageNameAndVersion, splitGitRepoAndRef } from "../utils/package-source.js"; -import { confirmAction, confirmReload } from "../utils/ui-helpers.js"; -import { throwIfSettingsErrors } from "../utils/settings-errors.js"; -import { checksumPackagePath, verifyPackageChecksum } from "../profiles/checksum.js"; -import { planProfileApplication, type ProfilePlan } from "../profiles/apply.js"; -import { loadProjectProfilePolicy, validateProfilePolicy } from "../profiles/compare.js"; -import { type ExtmgrProfile, type ProfilePackage, normalizeProfile } from "../profiles/schema.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, - deleteNamedProfile, + 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|path]"; + "Usage: /extensions profile [name|source] [--json|--strict|--force|--name ]"; -function sourceOf(value: PackageSource): string { - return typeof value === "string" ? value : value.source; +export interface ProfileApplicationOperation { + action: "install" | "remove" | "settings" | "verify" | "rollback"; + source?: string; + scope?: "global" | "project"; + status: "completed" | "failed"; + error?: string; } -function configuredPackage( - settings: ReturnType, - source: string -): PackageSource | undefined { - return settings.packages?.find((entry) => sourceOf(entry) === source); +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 + configured: PackageSource | undefined, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI ): Promise { - const parsed = parsePackageNameAndVersion(pkg.source); - const gitRef = pkg.source.startsWith("git:") - ? splitGitRepoAndRef(pkg.source.slice(4)).ref - : undefined; + 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 checksum = await checksumPackagePath(pkg.resolvedPath); + 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: pkg.source, + source, scope: pkg.scope, - ...(parsed.version ? { version: parsed.version } : {}), - ...(gitRef ? { ref: gitRef } : {}), - ...(filters ? { filters } : {}), - ...(checksum ? { checksum } : {}), + ...(version ? { version } : {}), + ...(ref ? { ref } : {}), + ...(locked ? { resolution: "locked" as const } : {}), + ...(filters !== undefined ? { filters } : {}), + ...(packageSettings && Object.keys(packageSettings).length > 0 ? { packageSettings } : {}), + ...(manifest ? { manifestFingerprint: manifest.fingerprint } : {}), }; } -async function currentProfile(ctx: ExtensionCommandContext): Promise { +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 scopedSettings = - pkg.scope === "project" ? settings.getProjectSettings() : settings.getGlobalSettings(); - return toProfilePackage(pkg, configuredPackage(scopedSettings, pkg.source)); + 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 }); } -async function readProfile(path: string): Promise { - const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Profile must be a JSON object."); - } - const value = parsed as Record; - if (value.schemaVersion !== 1) { - throw new Error("Unsupported or missing profile schemaVersion; expected 1."); - } - if (!Array.isArray(value.packages)) { - throw new Error("Profile packages must be an array."); - } - return normalizeProfile(parsed); -} - function formatPlan(plan: ProfilePlan): string { return [ `Add: ${plan.add.length}`, - ...plan.add.map((pkg) => ` + ${pkg.source} (${pkg.scope})`), + ...plan.add.map((pkg) => ` + ${getEffectivePackageSource(pkg)} (${pkg.scope})`), `Remove: ${plan.remove.length}`, - ...plan.remove.map((pkg) => ` - ${pkg.source} (${pkg.scope})`), + ...plan.remove.map((pkg) => ` - ${getEffectivePackageSource(pkg)} (${pkg.scope})`), `Change: ${plan.update.length}`, - ...plan.update.map(({ to }) => ` ~ ${to.source} (${to.scope})`), + ...plan.update.map( + ({ from, to }) => + ` ~ ${getEffectivePackageSource(from)} (${from.scope}) -> ${getEffectivePackageSource(to)} (${to.scope})` + ), ].join("\n"); } -async function resolveNamedOrPathProfile( +async function resolveNamedOrSourceProfile( requested: string | undefined, ctx: ExtensionCommandContext ): Promise { + const store = await readProfileStore(getProfileStorePath()); if (requested) { - const path = resolve(ctx.cwd, requested); - try { - return await readProfile(path); - } catch (error) { - if (!ctx.hasUI) throw error; - const store = await readProfileStore(getProfileStorePath()); - const named = store.profiles[requested]; - if (named) return named; - throw error; - } + 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 store = await readProfileStore(getProfileStorePath()); 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 ? store.profiles[choice] : undefined; + return choice ? getNamedProfile(store, choice) : undefined; } function configuredEntry( settings: ReturnType, - desired: ProfilePackage + desired: ProfilePackage, + cwd: string ): PackageSource | undefined { - return settings.packages?.find((entry) => sourceOf(entry) === desired.source); + 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[] + desired: ProfilePackage[], + cwd: string ): PackageSource[] { return desired.map((pkg) => { - const existing = configuredEntry(settings, 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") { - return { - ...existing, - source: pkg.source, - ...(pkg.filters ? { extensions: [...pkg.filters] } : {}), - }; + const next: Record = packageSettings + ? { ...packageSettings, source } + : { ...existing, source }; + if (pkg.filters) next.extensions = [...pkg.filters]; + else delete next.extensions; + return next as PackageSource; } - return pkg.filters ? { source: pkg.source, extensions: [...pkg.filters] } : pkg.source; + 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; }); } @@ -168,77 +301,338 @@ async function persistProfileConfiguration( settings.setPackages( buildScopedPackageSettings( global, - desired.packages.filter((pkg) => pkg.scope === "global") + desired.packages.filter((pkg) => pkg.scope === "global"), + ctx.cwd ) ); settings.setProjectPackages( buildScopedPackageSettings( project, - desired.packages.filter((pkg) => pkg.scope === "project") + desired.packages.filter((pkg) => pkg.scope === "project"), + ctx.cwd ) ); await settings.flush(); throwIfSettingsErrors(settings, "Profile application"); } -async function verifyProfileSafety( - current: ExtmgrProfile, - desired: ExtmgrProfile, +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 currentPackages = await getInstalledPackagesAllScopes(ctx); - const bySource = new Map(currentPackages.map((pkg) => [`${pkg.scope}\0${pkg.source}`, pkg])); - const problems: string[] = []; + const installed = await getInstalledPackagesAllScopes(ctx); + const runtimeTargets = await describeInstalledRuntimeTargets(installed, ctx, pi); + const missing: string[] = []; for (const pkg of desired.packages) { - if (!pkg.checksum) { - problems.push(`${pkg.source} (${pkg.scope}): checksum metadata is unknown`); - continue; + const source = getEffectivePackageSource(pkg); + if ( + !runtimeTargets.some((candidate) => installedRuntimeMatchesProfileTarget(candidate, pkg, ctx)) + ) { + missing.push(`${source} (${pkg.scope})`); } - const installed = bySource.get(`${pkg.scope}\0${pkg.source}`); - const result = await verifyPackageChecksum(installed?.resolvedPath, pkg.checksum); - if (result === "unknown") problems.push(`${pkg.source}: installed package metadata is unknown`); - else if (result === "mismatch") problems.push(`${pkg.source}: checksum mismatch`); } - if (current.schemaVersion !== 1 || desired.schemaVersion !== 1) { - problems.push("profile schema is unknown"); + 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 problems; + return [...new Set(drift)]; } -async function applyProfileFromCommand( +async function rollbackProfile( current: ExtmgrProfile, desired: ExtmgrProfile, + plan: ProfilePlan, ctx: ExtensionCommandContext, + operations: ProfileApplicationOperation[], pi: ExtensionAPI -): Promise { - const policy = await loadProjectProfilePolicy(ctx.cwd, undefined, isProjectTrusted(ctx)); - const violations = policy ? validateProfilePolicy(desired, policy) : []; - if (violations.length > 0) { +): 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 policy rejected application:\n${violations.map((v) => `- ${v.message}`).join("\n")}`, + `Profile validation rejected application:\n${validationProblems.map((problem) => `- ${problem}`).join("\n")}`, "error" ); - return; + return { applied: false, reloaded: false, operations: [] }; } - - const safetyProblems = await verifyProfileSafety(current, desired, ctx); - if (safetyProblems.length > 0) { + 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 application is not safe:\n${safetyProblems.map((problem) => `- ${problem}`).join("\n")}`, + `Profile policy rejected application:\n${violations.map((violation) => `- ${violation.message}`).join("\n")}`, "error" ); - return; + return { applied: false, reloaded: false, operations: [] }; } - const plan = planProfileApplication(current, desired); + 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; + return { applied: false, reloaded: false, operations: [] }; } - if ( + options?.reviewed !== true && !(await confirmAction( ctx, "Apply profile", @@ -246,56 +640,476 @@ async function applyProfileFromCommand( )) ) { notify(ctx, "Profile application cancelled.", "info"); - return; + return { applied: false, reloaded: false, operations: [] }; } - let completedMutations = 0; + 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 }) => { - const catalog = getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)); - for (const pkg of plan.remove) { - setMessage(`Removing ${pkg.source}...`); - await catalog.remove(pkg.source, pkg.scope); - completedMutations += 1; - } for (const pkg of plan.add) { - setMessage(`Installing ${pkg.source}...`); - await catalog.install(pkg.source, pkg.scope); - completedMutations += 1; + 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) { - if (change.from.source === change.to.source) continue; - setMessage(`Changing ${change.to.source}...`); - await catalog.remove(change.from.source, change.from.scope); - completedMutations += 1; - await catalog.install(change.to.source, change.to.scope); - completedMutations += 1; + 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; } - setMessage("Preserving package settings and filters..."); + 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) { - if (completedMutations > 0) { - const message = error instanceof Error ? error.message : String(error); - await markReloadRequired(`Profile ${desired.name} partially applied.`); - notify( - ctx, - `Profile ${desired.name} partially applied: ${completedMutations} mutation(s) succeeded before failure. ${message}\nReload pi before continuing.`, - "error" - ); - return; + 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.`); } - throw error; + 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"); - await confirmReload(ctx, "Profile package configuration changed."); - void pi; + 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( @@ -304,68 +1118,83 @@ export async function handleProfileSubcommand( pi?: ExtensionAPI ): Promise { const action = tokens[0]; - const requested = tokens[1]; if ( !action || - !["export", "save", "list", "delete", "dry-run", "apply", "compare"].includes(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 > 0 ? `Saved profiles:\n${names.join("\n")}` : "No saved profiles.", + names.length ? `Saved profiles:\n${names.join("\n")}` : "No saved profiles.", "info" ); return; } if (action === "save") { - if (!requested) { + if (!requested?.trim()) { notify(ctx, "Usage: /extensions profile save ", "info"); return; } - const profile = await currentProfile(ctx); - profile.name = requested; - await saveNamedProfile(storePath, profile); - notify(ctx, `Saved profile ${requested}.`, "info"); + 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))) { + if (!requested || !(await deleteNamedProfile(storePath, requested))) notify(ctx, `Saved profile not found: ${requested ?? "(missing name)"}`, "warning"); - } else { - notify(ctx, `Deleted profile ${requested}.`, "info"); - } + else notify(ctx, `Deleted profile ${requested}.`, "info"); return; } - const current = await currentProfile(ctx); + const current = await getCurrentProfile(ctx, pi); if (action === "export") { if (!requested) { notify(ctx, "Usage: /extensions profile export ", "info"); return; } - await writeFile(resolve(ctx.cwd, requested), `${JSON.stringify(current, null, 2)}\n`, { - flag: "wx", - }); - notify(ctx, `Exported profile to ${resolve(ctx.cwd, requested)}`, "info"); + 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 resolveNamedOrPathProfile(requested, ctx); + const desired = await resolveNamedOrSourceProfile(requested, ctx); if (!desired) { notify(ctx, "No saved profile selected.", "info"); return; } - const plan = planProfileApplication(current, desired); + 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 applyProfileFromCommand(current, desired, ctx, pi); + await reviewAndApplyProfileWithOutcome(current, desired, ctx, pi); return; } notify(ctx, formatPlan(plan), "info"); diff --git a/src/commands/registry.ts b/src/commands/registry.ts index a2d750f..d8015ff 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -68,7 +68,7 @@ function showNonInteractiveHelp(ctx: ExtensionCommandContext): void { " /extensions update [source] - Update one package or all packages", " /extensions history [opts] - Show history (supports filters)", " /extensions doctor - Inspect runtime ownership/conflicts", - " /extensions profile - Export, compare, or dry-run a profile", + " /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:", @@ -97,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); @@ -112,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); @@ -121,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) @@ -177,7 +183,7 @@ const COMMAND_DEFINITIONS: Record = { }, profile: { id: "profile", - description: "Export, compare, or dry-run a package 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), }, @@ -242,12 +248,26 @@ export function getExtensionsAutocompleteItems(prefix: string): AutocompleteItem return completionItems(["--all", "--preview", ...local.installedPackages], activePrefix); } if (command === "profile") { - const actions = ["export", "save", "list", "delete", "dry-run", "apply", "compare"]; + 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") { diff --git a/src/doctor/compatibility.ts b/src/doctor/compatibility.ts index a1f4c1f..ef0b7f9 100644 --- a/src/doctor/compatibility.ts +++ b/src/doctor/compatibility.ts @@ -17,31 +17,105 @@ export interface CompatibilityDiagnostic { reasons: string[]; } -function versionParts(version: string | undefined): [number, number] | undefined { - const match = version?.match(/(?:^|\s|[>=<~^])v?(\d+)(?:\.(\d+))?/); - return match?.[1] ? [Number(match[1]), Number(match[2] ?? 0)] : undefined; +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 isAtLeast( - actual: [number, number] | undefined, - required: [number, number] | undefined +function satisfiesRange( + actualValue: string | undefined, + range: string | undefined ): boolean | undefined { - if (!actual || !required) return undefined; - return actual[0] > required[0] || (actual[0] === required[0] && actual[1] >= required[1]); + 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 = isAtLeast( - versionParts(input.nodeVersion), - versionParts(input.engines?.node) - ); - const piCompatible = isAtLeast(versionParts(input.piVersion), versionParts(input.requiredPi)); + 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 }; } diff --git a/src/extensions/trash.ts b/src/extensions/trash.ts index ac8b023..d680600 100644 --- a/src/extensions/trash.ts +++ b/src/extensions/trash.ts @@ -1,5 +1,6 @@ -import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +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; @@ -12,6 +13,15 @@ interface TrashFile { 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>(); @@ -19,57 +29,80 @@ function recordsPath(trashRoot: string): string { return join(trashRoot, "records.json"); } -function normalizeRecord(value: unknown): TrashRecord | undefined { +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: record.trashPath, + trashPath: resolve(record.trashPath), trashedAt: record.trashedAt, }; } -function normalizeTrashFile(value: unknown): TrashFile { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return { version: 1, records: [] }; - } - const recordsValue = (value as Record).records; - const records = Array.isArray(recordsValue) - ? recordsValue.flatMap((record: unknown) => { - const normalized = normalizeRecord(record); - return normalized ? [normalized] : []; - }) - : []; - return { version: 1, records }; -} - -async function fileExists(path: string): Promise { +async function readTrashFile(trashRoot: string): Promise { + let raw: string; try { - await access(path); - return true; - } catch { - return false; + raw = await readFile(recordsPath(trashRoot), "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return { version: 1, records: [] }; + throw error; } -} - -async function readTrashFile(trashRoot: string): Promise { + let parsed: unknown; try { - return normalizeTrashFile(JSON.parse(await readFile(recordsPath(trashRoot), "utf8"))); - } catch { - return { version: 1, records: [] }; + 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 { @@ -80,13 +113,41 @@ async function writeTrashFile(trashRoot: string, file: TrashFile): Promise } } +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 readTrashFile(trashRoot))) + writeTrashFile(trashRoot, await update(await readForUpdate(trashRoot))) ); writeQueues.set( trashRoot, @@ -98,26 +159,30 @@ async function updateTrashFile( export async function moveToExtensionTrash(path: string, trashRoot: string): Promise { await mkdir(trashRoot, { recursive: true }); let trashPath = ""; - for (let attempt = 0; attempt < 10; attempt++) { + 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: path, trashPath, trashedAt: Date.now() }; + 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 !== trashPath), record], + records: [...file.records.filter((item) => item.trashPath !== record.trashPath), record], })); } catch (error) { try { - if (!(await fileExists(path))) { - await rename(trashPath, path); - } + 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)}` @@ -144,27 +209,47 @@ export async function listExtensionTrash( } kept.push(record); } - - if (kept.length !== file.records.length) { + 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 { - if (await fileExists(record.originalPath)) { + 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))) { + 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); } diff --git a/src/index.ts b/src/index.ts index 99e9c1e..54cd2b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ 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, @@ -61,6 +62,7 @@ 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() diff --git a/src/packages/catalog.ts b/src/packages/catalog.ts index 9f65b11..b608244 100644 --- a/src/packages/catalog.ts +++ b/src/packages/catalog.ts @@ -1,12 +1,16 @@ 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"; @@ -37,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, @@ -62,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, @@ -91,10 +91,10 @@ function createDefaultPackageCatalog(cwd: string, projectTrusted = false): Packa 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]; @@ -133,15 +133,15 @@ function createDefaultPackageCatalog(cwd: string, projectTrusted = false): Packa 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(); throwIfSettingsErrors(settingsManager, "Package removal"); - - if (!removed) { - throw new Error(`No matching package found for ${source}`); - } } finally { setProgressCallback(packageManager, undefined); } diff --git a/src/packages/discovery.ts b/src/packages/discovery.ts index ab39805..334720a 100644 --- a/src/packages/discovery.ts +++ b/src/packages/discovery.ts @@ -10,8 +10,8 @@ import { getAgentDir, } from "@earendil-works/pi-coding-agent"; 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 { parseNpmSource } from "../utils/format.js"; import { getCachedPackage, getCachedPackageSize, @@ -21,11 +21,13 @@ import { 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 { isProjectTrusted } from "../utils/mode.js"; +import { getProjectConfigDir } from "../utils/pi-paths.js"; import { getPackageCatalog } from "./catalog.js"; const NPM_SEARCH_API = "https://registry.npmjs.org/-/v1/search"; @@ -56,20 +58,12 @@ interface NpmSearchResponse { objects?: NpmSearchResultObject[]; } -const searchCacheByPage = new Map(); -let latestSearchCacheKey: string | undefined; - -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}`; @@ -244,6 +238,106 @@ export async function fetchNpmRegistrySearchPage( }; } +function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { + if (signal?.aborted && error instanceof Error && error.name === "AbortError") throw error; +} + +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); + } +} + +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); + } +} + +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)); + } + + 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, @@ -291,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 } : {}), @@ -307,13 +406,14 @@ export async function isSourceInstalled( const installed = await getPackageCatalog(ctx.cwd, isProjectTrusted(ctx)).listInstalledPackages({ dedupe: false, }); - const expected = normalizePackageIdentity(source, { cwd: ctx.cwd }); - 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 + ); }); } diff --git a/src/packages/extension-resolution.ts b/src/packages/extension-resolution.ts index 5e71299..76ee32c 100644 --- a/src/packages/extension-resolution.ts +++ b/src/packages/extension-resolution.ts @@ -4,13 +4,7 @@ import { type ResolvedResource, SettingsManager, } from "@earendil-works/pi-coding-agent"; - -function normalizeSource(source: string): string { - return source - .trim() - .replace(/\s+\((filtered|pinned)\)$/i, "") - .trim(); -} +import { normalizeConfiguredPackageSource } from "../utils/package-source.js"; /** Resolve configured package extensions through Pi without installing missing sources. */ export async function resolveConfiguredPackageExtensions( @@ -33,10 +27,10 @@ export function resourcesForPackage( scope: "global" | "project" ): ResolvedResource[] { const expectedScope = scope === "project" ? "project" : "user"; - const expectedSource = normalizeSource(source); + const expectedSource = normalizeConfiguredPackageSource(source); return resources.filter( (resource) => resource.metadata.scope === expectedScope && - normalizeSource(resource.metadata.source) === expectedSource + normalizeConfiguredPackageSource(resource.metadata.source) === expectedSource ); } diff --git a/src/packages/extensions.ts b/src/packages/extensions.ts index cd57fcb..714a334 100644 --- a/src/packages/extensions.ts +++ b/src/packages/extensions.ts @@ -13,16 +13,17 @@ import { type State, } from "../types/index.js"; import { parseNpmSource } from "../utils/format.js"; -import { resolveConfiguredPackageExtensions, resourcesForPackage } from "./extension-resolution.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 { getProjectConfigDir } from "../utils/pi-paths.js"; +import { resolveConfiguredPackageExtensions, resourcesForPackage } from "./extension-resolution.js"; interface PackageSettingsObject { source: string; @@ -42,13 +43,6 @@ const execFileAsync = promisify(execFile); let globalNpmRootCache: { key: string; root: string | null } | undefined; const packageEntrypointCache = new Map>(); -function normalizeSource(source: string): string { - return source - .trim() - .replace(/\s+\((filtered|pinned)\)$/i, "") - .trim(); -} - function normalizePackageRootCandidate(candidate: string): string { const resolved = resolve(candidate); @@ -153,7 +147,8 @@ async function toPackageRoot(pkg: InstalledPackage, cwd: string): Promise { if (typeof pkg === "string") { - return normalizeSource(pkg) === normalizedSource; + return normalizeConfiguredPackageSource(pkg) === normalizedSource; } - return normalizeSource(pkg.source) === normalizedSource; + return normalizeConfiguredPackageSource(pkg.source) === normalizedSource; }); } @@ -279,7 +274,7 @@ export async function applyPackageExtensionStateChanges( const settings = createSettingsManager(cwd, projectTrusted); throwIfSettingsErrors(settings, "Package extension configuration"); - const normalizedSource = normalizeSource(packageSource); + const normalizedSource = normalizeConfiguredPackageSource(packageSource); const packages = getScopedPackages(settings, scope); const index = findPackageSettingsIndex(packages, normalizedSource); const packageEntry = toPackageSettingsObject(packages[index], packageSource); @@ -390,7 +385,7 @@ async function readPackageFilterMap( for (const entry of packages) { if (typeof entry === "string") { - filterMap.set(normalizeSource(entry), undefined); + filterMap.set(normalizeConfiguredPackageSource(entry), undefined); continue; } @@ -399,7 +394,7 @@ async function readPackageFilterMap( } filterMap.set( - normalizeSource(entry.source), + normalizeConfiguredPackageSource(entry.source), Array.isArray(entry.extensions) ? entry.extensions : undefined ); } @@ -609,7 +604,7 @@ export async function discoverPackageExtensions( 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) { diff --git a/src/packages/inspection.ts b/src/packages/inspection.ts index c828550..10ce096 100644 --- a/src/packages/inspection.ts +++ b/src/packages/inspection.ts @@ -15,7 +15,7 @@ export function inspectPackageMetadata(input: { dependencies?: Record; repository?: string; hasProvenance?: boolean; - compatibility?: "compatible" | "incompatible"; + compatibility?: "compatible" | "incompatible" | "unknown"; }): PackageInspection { return { name: input.name, diff --git a/src/packages/install.ts b/src/packages/install.ts index a88d856..9a62746 100644 --- a/src/packages/install.ts +++ b/src/packages/install.ts @@ -1,15 +1,11 @@ /** * Package installation logic */ -import { cp, mkdir, rm, writeFile } from "node:fs/promises"; -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, getProjectExtensionsDir } from "../utils/pi-paths.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"; @@ -18,13 +14,15 @@ import { logPackageInstall } from "../utils/history.js"; import { isProjectTrusted, tryOperation } from "../utils/mode.js"; import { downloadToFile, - fetchWithTimeout, 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"; @@ -53,10 +51,6 @@ const INSTALL_SCOPE_CHOICES = { cancel: "Cancel", } as const; -function getProgressMessage(event: ProgressEvent, fallback: string): string { - return event.message?.trim() || fallback; -} - async function resolveInstallScope( ctx: ExtensionCommandContext, explicitScope?: InstallScope @@ -273,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"); @@ -289,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" ); @@ -427,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 () => { @@ -467,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, @@ -522,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); @@ -556,7 +608,10 @@ 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 676a0cb..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"; @@ -16,8 +15,10 @@ import { logPackageRemove, logPackageUpdate } from "../utils/history.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, @@ -44,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, @@ -203,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() }), ]); } @@ -215,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(), }) ); } diff --git a/src/packages/scopes.ts b/src/packages/scopes.ts index 78bbeeb..6a135f3 100644 --- a/src/packages/scopes.ts +++ b/src/packages/scopes.ts @@ -1,7 +1,12 @@ -import { SettingsManager, type PackageSource } from "@earendil-works/pi-coding-agent"; -import { CONFIG_DIR_NAME, getAgentDir } from "../utils/pi-paths.js"; +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 { normalizePackageIdentity } from "../utils/package-source.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 { @@ -13,12 +18,18 @@ export interface PackageScopeComparison { } /** Compare effective package records across global and project scopes. */ -export function comparePackageScopes(packages: InstalledPackage[]): PackageScopeComparison[] { +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, @@ -35,9 +46,11 @@ export function comparePackageScopes(packages: InstalledPackage[]): PackageScope 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, @@ -58,19 +71,35 @@ export function getPackageScopeLabel(scope: Scope): string { : "global (~/.pi/agent/settings.json)"; } -function sourceOf(value: PackageSource): string { - return typeof value === "string" ? value : value.source; -} - function packageMatches(value: PackageSource, source: string, cwd: string, scope: Scope): boolean { - const baseCwd = scope === "project" ? cwd : getAgentDir(); + const baseCwd = scope === "project" ? getProjectConfigDir(cwd) : getAgentDir(); return ( - sourceOf(value) === source || + packageSourceString(value) === source || normalizePackageIdentity(source, { cwd: baseCwd }) === - normalizePackageIdentity(sourceOf(value), { 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; @@ -124,12 +153,17 @@ export async function movePackageBetweenScopes( 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, sourceOf(entry), cwd, to) + packageMatches(candidate, destinationSource, cwd, to) ); if (destinationIndex >= 0) { const destinationEntry = destinationPackages[destinationIndex]; - if (destinationEntry && JSON.stringify(destinationEntry) !== JSON.stringify(entry)) { + if ( + destinationEntry && + JSON.stringify(destinationEntry) !== JSON.stringify(destinationEntryValue) + ) { return { source, from, @@ -139,7 +173,7 @@ export async function movePackageBetweenScopes( }; } } else { - destinationPackages.push(structuredClone(entry)); + destinationPackages.push(destinationEntryValue); } try { @@ -165,7 +199,7 @@ export async function movePackageBetweenScopes( throwIfSettingsErrors(settings, "Package scope move"); } catch (error) { return { - source: sourceOf(entry), + source: packageSourceString(entry), from, to, moved: false, @@ -174,5 +208,5 @@ export async function movePackageBetweenScopes( }; } - return { source: sourceOf(entry), from, to, moved: true }; + return { source: destinationSource, from, to, moved: true }; } diff --git a/src/packages/sorting.ts b/src/packages/sorting.ts index 25148d0..b046220 100644 --- a/src/packages/sorting.ts +++ b/src/packages/sorting.ts @@ -1,11 +1,16 @@ import { type NpmPackage } from "../types/index.js"; -export type RemotePackageSort = "relevance" | "name" | "recent"; +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/profiles/apply.ts b/src/profiles/apply.ts index 0a79fcf..d82f81c 100644 --- a/src/profiles/apply.ts +++ b/src/profiles/apply.ts @@ -1,4 +1,10 @@ -import { type ExtmgrProfile, type ProfilePackage } from "./schema.js"; +import { + type ExtmgrProfile, + getEffectivePackageSource, + getProfilePackageIdentity, + inferPackageResolution, + type ProfilePackage, +} from "./schema.js"; export interface ProfilePlan { add: ProfilePackage[]; @@ -6,25 +12,93 @@ export interface ProfilePlan { update: Array<{ from: ProfilePackage; to: ProfilePackage }>; } -function key(pkg: ProfilePackage): string { - return `${pkg.scope}\0${pkg.source}`; +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 + desired: ExtmgrProfile, + options?: ProfileIdentityOptions ): ProfilePlan { - const currentByKey = new Map(current.packages.map((pkg) => [key(pkg), pkg])); - const desiredByKey = new Map(desired.packages.map((pkg) => [key(pkg), pkg])); - const add = desired.packages.filter((pkg) => !currentByKey.has(key(pkg))); - const remove = current.packages.filter((pkg) => !desiredByKey.has(key(pkg))); - const update = desired.packages.flatMap((pkg) => { - const previous = currentByKey.get(key(pkg)); - return previous && JSON.stringify(previous) !== JSON.stringify(pkg) - ? [{ from: previous, to: pkg }] - : []; - }); - return { add, remove, update }; + 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( diff --git a/src/profiles/checksum.ts b/src/profiles/checksum.ts index 34a8151..94e35bd 100644 --- a/src/profiles/checksum.ts +++ b/src/profiles/checksum.ts @@ -2,24 +2,58 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -export async function checksumPackagePath(path: string | undefined): Promise { +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") ); - return `sha256:${createHash("sha256").update(manifest).digest("hex")}`; + 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; } } -export async function verifyPackageChecksum( +/** + * 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 checksumPackagePath(path); + 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 index 339ba1c..1e99005 100644 --- a/src/profiles/compare.ts +++ b/src/profiles/compare.ts @@ -1,14 +1,16 @@ import { readFile } from "node:fs/promises"; import { getProjectConfigPath } from "../utils/pi-paths.js"; import { planProfileApplication } from "./apply.js"; -import { type ExtmgrProfile } from "./schema.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; } @@ -17,6 +19,14 @@ export interface ProfilePolicyViolation { message: string; } +export interface ProfilePackageDiagnostic { + source: string; + scope: "global" | "project"; + compatibility: DiagnosticStatus; + integrity: DiagnosticStatus; + notes: string[]; +} + export function compareProfiles( left: ExtmgrProfile, right: ExtmgrProfile @@ -26,31 +36,59 @@ export function compareProfiles( export function validateProfilePolicy( profile: ExtmgrProfile, - policy: ProfilePolicy + 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: pkg.source, message: `scope ${pkg.scope} is not allowed` }); + 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.allowedSources && !policy.allowedSources.includes(pkg.source)) { - violations.push({ packageSource: pkg.source, message: "source is not allowed" }); + if ( + policy.forbiddenSources?.includes(effectiveSource) || + policy.forbiddenSources?.includes(pkg.source) + ) { + violations.push({ packageSource: effectiveSource, message: "source is forbidden" }); } - if (policy.forbiddenSources?.includes(pkg.source)) { - violations.push({ packageSource: pkg.source, 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.requireChecksums && !pkg.checksum) { - violations.push({ packageSource: pkg.source, message: "checksum is unknown" }); + 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)) { + if ( + !profile.packages.some( + (pkg) => pkg.source === requiredSource || getEffectivePackageSource(pkg) === requiredSource + ) + ) { violations.push({ packageSource: requiredSource, message: "required package is missing" }); } } - if (policy.requireCompatibilityCheck && profile.checks?.compatibility !== true) { - violations.push({ message: "profile compatibility checks are required" }); - } return violations; } @@ -63,13 +101,11 @@ export async function loadProjectProfilePolicy( 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)) { + 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) { + 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" @@ -90,6 +126,7 @@ export async function loadProjectProfilePolicy( ...(forbiddenSources ? { forbiddenSources } : {}), ...(requiredPackages ? { requiredPackages } : {}), ...(value.requireChecksums === true ? { requireChecksums: true } : {}), + ...(value.requireIntegrity === true ? { requireIntegrity: true } : {}), ...(value.requireCompatibilityCheck === true ? { requireCompatibilityCheck: true } : {}), }; } catch (error) { diff --git a/src/profiles/schema.ts b/src/profiles/schema.ts index 0b74b91..5dec5e5 100644 --- a/src/profiles/schema.ts +++ b/src/profiles/schema.ts @@ -1,59 +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: "global" | "project"; + 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 = - input && typeof input === "object" && !Array.isArray(input) - ? (input as Record) - : {}; + const value = isRecord(input) ? input : {}; const packages = Array.isArray(value.packages) ? value.packages.flatMap((item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) return []; - const pkg = item as Record; - if (typeof pkg.source !== "string" || !pkg.source.trim()) return []; - const scope: ProfilePackage["scope"] = pkg.scope === "project" ? "project" : "global"; - return [ - { - source: pkg.source.trim(), - scope, - ...(typeof pkg.version === "string" ? { version: pkg.version.trim() } : {}), - ...(typeof pkg.ref === "string" ? { ref: pkg.ref.trim() } : {}), - ...(Array.isArray(pkg.filters) - ? { - filters: pkg.filters.filter( - (filter): filter is string => typeof filter === "string" - ), - } - : {}), - ...(typeof pkg.checksum === "string" ? { checksum: pkg.checksum.trim() } : {}), - }, - ]; + 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: 1, - name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : "unnamed", + schemaVersion: PROFILE_SCHEMA_VERSION, + name: nonEmptyString(value.name) ?? "unnamed", packages, - ...(value.checks && typeof value.checks === "object" && !Array.isArray(value.checks) - ? { - checks: { - compatibility: (value.checks as Record).compatibility === true, - provenance: (value.checks as Record).provenance === true, - }, + ...(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 index ddbd2ee..699b651 100644 --- a/src/profiles/store.ts +++ b/src/profiles/store.ts @@ -1,17 +1,39 @@ 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 } from "./schema.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: {} }; + return { version: 1, profiles: safeDictionary() }; +} + +function hasOwn(object: object, key: PropertyKey): boolean { + return Object.hasOwn(object, key); } export function normalizeProfileStore(input: unknown): ProfileStoreFile { @@ -22,85 +44,223 @@ export function normalizeProfileStore(input: unknown): ProfileStoreFile { if (!profilesValue || typeof profilesValue !== "object" || Array.isArray(profilesValue)) { return emptyStore(); } - const profiles: Record = {}; - for (const [name, value] of Object.entries(profilesValue)) { - if (!name.trim()) continue; - const profile = normalizeProfile(value); - if (profile.packages.length > 0 || profile.name !== "unnamed") { - profiles[name.trim()] = { ...profile, name: name.trim() }; + 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 { - const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; - if ( - !parsed || - typeof parsed !== "object" || - Array.isArray(parsed) || - (parsed as Record).version !== 1 - ) { - throw new Error(`Unsupported or malformed profile store: ${path}`); - } - return normalizeProfileStore(parsed); + 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; - } + 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)}` ); } } -export async function writeProfileStore(path: string, store: ProfileStoreFile): Promise { +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 () => { - await mkdir(dirname(path), { recursive: true }); - const temporary = join(dirname(path), `.${process.pid}.${Date.now()}.profiles.tmp`); - try { - await writeFile( - temporary, - `${JSON.stringify(normalizeProfileStore(store), null, 2)}\n`, - "utf8" - ); - await rename(temporary, path); - } finally { - await rm(temporary, { force: true }).catch(() => undefined); - } + 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 + profile: ExtmgrProfile, + options?: { replace?: boolean } ): Promise { - const store = await readProfileStore(path); - const normalized = normalizeProfile(profile); - store.profiles[normalized.name] = normalized; - await writeProfileStore(path, store); - return store; + 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 { - const store = await readProfileStore(path); - if (!store.profiles[name]) return false; - delete store.profiles[name]; - await writeProfileStore(path, store); - return true; + 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 { - const directory = getExtmgrCacheDir(); - return join(directory, "profiles.json"); + 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 4a60f37..71d223c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,6 +28,7 @@ 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; @@ -94,8 +95,11 @@ export interface SearchCache { } // 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" } @@ -128,6 +132,7 @@ export type UnifiedAction = }; export type BrowseAction = + | { type: "workspace"; screen: WorkspaceScreen } | { type: "package"; name: string } | { type: "prev" } | { type: "next" } 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/remote/query.ts b/src/ui/discover/query.ts similarity index 100% rename from src/ui/remote/query.ts rename to src/ui/discover/query.ts diff --git a/src/ui/footer.ts b/src/ui/footer.ts index abb9b20..a9b3e2f 100644 --- a/src/ui/footer.ts +++ b/src/ui/footer.ts @@ -7,7 +7,6 @@ import { activeKeyHint } from "../utils/key-hints.js"; export interface FooterState { selectedType?: UnifiedItem["type"]; - expandable: boolean; pendingChanges: number; selectedPackages: number; } @@ -21,7 +20,6 @@ export function buildFooterState( const state: FooterState = { pendingChanges: getPendingToggleChangeCount(staged, byId), selectedPackages, - expandable: selectedItem?.type === "package" && Boolean(selectedItem.extensionPaths?.length), }; if (selectedItem) { @@ -56,20 +54,14 @@ export function buildFooterShortcuts(state: FooterState, keybindings?: Keybindin const confirm = keybindings ? activeKeyHint(keybindings, "tui.select.confirm", "actions") : "Enter actions"; - const cancel = keybindings - ? activeKeyHint(keybindings, "tui.select.cancel", "clear/cancel") - : "Esc clear/cancel"; - const parts = ["↑↓ navigate", confirm, "/ search", "Tab filters"]; + const cancel = keybindings ? activeKeyHint(keybindings, "tui.select.cancel", "back") : "Esc back"; + const parts = ["↑↓ move", confirm]; - if (state.selectedType === "local") parts.splice(1, 0, "Space toggle", "V details", "X remove"); - if (state.selectedType === "package") { - parts.splice(1, 0, "Space select"); - if (state.expandable) parts.splice(2, 0, "E expand"); - } - if (state.selectedPackages > 0) { - parts.push(`${state.selectedPackages} selected · B bulk actions`); - } - if (state.pendingChanges > 0) parts.push(`S save (${state.pendingChanges})`); + 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(" · ")}\nMore: 1-7 filters · W/L/D views · * favorite · i install · f search · U update all · t scheduled checks · P palette · R browse · ? 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 28d0882..3f76bfb 100644 --- a/src/ui/help.ts +++ b/src/ui/help.ts @@ -9,13 +9,16 @@ export function buildHelpLines(): string[] { return [ "Extensions Manager Help", "", + "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", - " Tab / Shift+Tab Cycle filters", - " 1-7 All / Local / Packages / Updates / Disabled / Favorites / Recent", + " 1-4 Filter: All / Local / Packages / Updates", + " 5-7 Filter: Disabled / Favorites / Recent", " Esc Clear search, cancel, or leave", "", "Package actions", @@ -23,8 +26,7 @@ export function buildHelpLines(): string[] { " c Configure package entrypoints", " u / U Update selected package / all packages", " X Remove the selected package or local extension", - " V View details", - " E Expand package entrypoints when available", + " V View full details and recent activity", "", "Manager actions", " S Save staged local-extension changes", @@ -33,16 +35,25 @@ export function buildHelpLines(): string[] { " W / L / D Save / load / delete a manager view", " * Toggle favorite", " t Scheduled update checks settings", - " P / M Quick actions palette", + " P / M Open workspace screens and actions", + " Discover · Profiles · Health", "", "Sources and safety", - " G = global extension, P = project extension", - " Packages may be npm or git sources and show their scope inline", - " Missing compatibility, provenance, or checksum metadata is unknown", + " 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", "", + "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", ]; 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/manager/state.ts b/src/ui/installed/state.ts similarity index 100% rename from src/ui/manager/state.ts rename to src/ui/installed/state.ts 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 c9d47ef..4e70d04 100644 --- a/src/ui/package-config.ts +++ b/src/ui/package-config.ts @@ -28,12 +28,11 @@ import { fileExists } from "../utils/fs.js"; import { logExtensionToggle } from "../utils/history.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; @@ -77,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( 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 b9318b4..8ca3f0c 100644 --- a/src/ui/remote.ts +++ b/src/ui/remote.ts @@ -1,27 +1,20 @@ /** - * 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, - 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 { Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { PAGE_SIZE, UI } from "../constants.js"; import { getRemotePackageBadges } from "../packages/badges.js"; import { + addWeeklyDownloadsToSearchPage, clearSearchCache, getInstalledPackagesAllScopes, getSearchCache, @@ -29,24 +22,19 @@ import { isCacheValid, searchNpmPackages, } from "../packages/discovery.js"; -import { inspectPackageMetadata } from "../packages/inspection.js"; import { - installPackage, installPackageLocallyWithOutcome, installPackageWithOutcome, } from "../packages/install.js"; -import { type RemotePackageSort, sortRemotePackages } from "../packages/sorting.js"; -import { validateCompatibility } from "../doctor/compatibility.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, 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 { activeKeyHint } from "../utils/key-hints.js"; -import { RequestGeneration, runTaskWithLoader } from "./async-task.js"; +import { runTaskWithLoader } from "./async-task.js"; +import { RemotePackageBrowser } from "./discover/browser.js"; +import { buildPackageInfoText, packageInfoCache } from "./discover/metadata.js"; import { COMMUNITY_BROWSE_QUERY, createCommunityBrowsePlan, @@ -55,93 +43,15 @@ import { type RemoteBrowseQueryPlan, type RemoteBrowseSource, resolveRemoteBrowseSource, -} from "./remote/query.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 }; -} - -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(); - } -} +} from "./discover/query.js"; +import { runAuxWorkspaceScreens } from "./workspace/router.js"; -// Global LRU cache instance -const packageInfoCache = new PackageInfoCache( - CACHE_LIMITS.packageInfoMaxSize, - CACHE_LIMITS.packageInfoTTL -); -const packageInfoRequests = new RequestGeneration(); - -export function clearRemotePackageInfoCache(): void { - packageInfoRequests.cancel(); - packageInfoCache.clear(); -} +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 = { @@ -151,132 +61,14 @@ const PACKAGE_DETAILS_CHOICES = { back: "Back to results", } as const; -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 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 inspection = inspectPackageMetadata({ - name: packageName, - ...(info.version ? { version: info.version } : {}), - ...(info.description ? { description: info.description } : {}), - ...(info.dependencies ? { dependencies: info.dependencies } : {}), - ...(repository ? { repository } : {}), - ...(info.engines?.node - ? { - compatibility: - validateCompatibility({ - packageName, - engines: { node: info.engines.node }, - ...(info.engines.pi ? { requiredPi: info.engines.pi } : {}), - nodeVersion: process.version, - }).node === "compatible" - ? ("compatible" as const) - : ("incompatible" as const), - } - : {}), - }); - - 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; -} +/** 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(); @@ -284,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, @@ -316,328 +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}`) : ""; - const badges = [ - pkg.installed ? theme.fg("success", "installed") : undefined, - pkg.updateAvailable ? theme.fg("warning", "update") : undefined, - theme.fg("dim", `compat:${pkg.compatibility ?? "unknown"}`), - ].filter(Boolean); - return `${name}${version}${badges.length ? ` [${badges.join(" · ")}]` : ""}`; -} - -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 sortMode: RemotePackageSort = "relevance"; - 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.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 (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); - 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 && 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[] = ["relevance", "name", "recent"]; - const nextIndex = (modes.indexOf(this.sortMode) + 1) % modes.length; - this.sortMode = modes[nextIndex] ?? "relevance"; - 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[] = []; - - if (this.searchActive) { - lines.push(...this.searchInput.render(safeWidth)); - lines.push(""); - } else if (this.queryLabel) { - lines.push( - truncateToWidth(this.theme.fg("accent", ` Search: ${this.queryLabel}`), safeWidth, "") - ); - lines.push(""); - } else { - lines.push( - truncateToWidth( - this.theme.fg( - "muted", - this.browseSource === "community" - ? " Browse community packages · / search to search community packages" - : " Browse remote search results · / search to search npm packages" - ), - safeWidth, - "" - ) - ); - lines.push(""); - } - - lines.push(truncateToWidth(this.buildSummaryLine(), safeWidth, "")); - lines.push(""); - - if (this.packages.length === 0) { - lines.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)) { - lines.push(this.renderPackageLine(pkg, safeWidth)); - } - - if (startIndex > 0 || endIndex < this.packages.length) { - lines.push(""); - lines.push( - truncateToWidth( - this.theme.fg( - "dim", - ` Showing ${startIndex + 1}-${endIndex} of ${this.packages.length} on this page` - ), - safeWidth, - "" - ) - ); - } - - 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, 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"; - 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:${this.sortMode}`)}`; - } - - 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", "r refresh", "i install", "m menu"); - parts.push(activeKeyHint(this.keybindings, "tui.select.cancel", "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; } } @@ -649,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; @@ -673,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))); @@ -700,6 +191,10 @@ async function selectBrowseAction( browser.invalidate(); syncThemedContent(); }, + dispose() { + disposed = true; + hydrationController.abort(); + }, handleInput(data: string) { if (browser.handleBrowseInput(data)) { tui.requestRender(); @@ -729,7 +224,7 @@ export async function browseRemotePackages( offset = 0, source?: RemoteBrowseSource, forceRefresh = false -): Promise { +): Promise { const request: BrowseRequest = { ctx, query, @@ -740,20 +235,21 @@ export async function browseRemotePackages( }; if (browseNavigationActive) { browseNavigationQueue.push(request); - return; + return false; } browseNavigationActive = true; try { let next: BrowseRequest | undefined = request; while (next) { - await browseRemotePackagesPage(next); + if (await browseRemotePackagesPage(next)) return true; next = browseNavigationQueue.shift(); } } finally { browseNavigationQueue.length = 0; browseNavigationActive = false; } + return false; } async function browseRemotePackagesPage({ @@ -763,7 +259,7 @@ async function browseRemotePackagesPage({ offset = 0, source, forceRefresh = false, -}: BrowseRequest): Promise { +}: BrowseRequest): Promise { if ( !requireCustomUI( ctx, @@ -771,7 +267,7 @@ async function browseRemotePackagesPage({ "Use `/extensions install ` to install directly outside the full interactive TUI." ) ) { - return; + return false; } const browseSource = resolveRemoteBrowseSource(query, source); @@ -781,7 +277,7 @@ async function browseRemotePackagesPage({ : createRemoteBrowseQueryPlan(query); if (plan.kind === "unsupported") { notify(ctx, plan.message, "warning"); - return; + return false; } const searchLabel = plan.displayQuery || "community packages"; @@ -802,8 +298,7 @@ async function browseRemotePackagesPage({ title: plan.title, message: `Searching npm for ${truncate(searchLabel, 40)}...`, }, - async ({ signal, setMessage }) => { - setMessage(`Searching npm for ${truncate(searchLabel, 40)}...`); + async ({ signal }) => { return searchNpmPackages(plan.searchQuery, ctx, { signal, offset, @@ -815,15 +310,16 @@ async function browseRemotePackagesPage({ } catch (error) { const message = error instanceof Error ? error.message : String(error); notify(ctx, `Remote package search failed: ${message}`, "warning"); - return; + return false; } } if (!searchPage) { notify(ctx, "Remote package search was cancelled.", "info"); - return; + return false; } + const resolvedSearchPage = searchPage; const installed = await getInstalledPackagesAllScopes(ctx); const installedNames = new Set( installed.flatMap((pkg) => { @@ -837,12 +333,12 @@ async function browseRemotePackagesPage({ return parsed?.name ? [parsed.name] : []; }) ); - const packages = filterRemoteBrowseResults(plan, searchPage.results).map((pkg) => ({ + const packages = filterRemoteBrowseResults(plan, resolvedSearchPage.results).map((pkg) => ({ ...pkg, ...getRemotePackageBadges(pkg, installedNames, updateNames), })); const totalResults = - plan.kind === "search" && plan.exactPackageName ? packages.length : searchPage.total; + plan.kind === "search" && plan.exactPackageName ? packages.length : resolvedSearchPage.total; const reloadQuery = browseSource === "community" ? plan.displayQuery || COMMUNITY_BROWSE_QUERY : plan.rawQuery; @@ -854,13 +350,26 @@ async function browseRemotePackagesPage({ 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 = offset + searchPage.results.length < 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, @@ -870,55 +379,60 @@ async function browseRemotePackagesPage({ 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": clearSearchCache(plan.searchQuery); - await browseRemotePackages(ctx, reloadQuery, pi, 0, browseSource, true); - return; + 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); } } @@ -980,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( @@ -994,37 +508,29 @@ async function showPackageDetails( switch (choice) { case "installManaged": { const scope = await confirmMarketplaceInstall(packageName, ctx, pi, "managed"); - if (!scope) return; + if (!scope) return false; const outcome = await installPackageWithOutcome(`npm:${packageName}`, ctx, pi, { scope, skipConfirmation: true, }); - if (outcome.reloaded) { - return; - } + 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 scope = await confirmMarketplaceInstall(packageName, ctx, pi, "standalone"); - if (!scope) return; + if (!scope) return false; const outcome = await installPackageLocallyWithOutcome(packageName, ctx, pi, { scope, skipConfirmation: true, }); - if (outcome.reloaded) { - return; - } + 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 { @@ -1041,7 +547,7 @@ async function showPackageDetails( if (!text) { notify(ctx, `Loading ${packageName} details was cancelled.`, "info"); - await showPackageDetails( + return showPackageDetails( packageName, ctx, pi, @@ -1049,7 +555,6 @@ async function showPackageDetails( previousOffset, browseSource ); - return; } ctx.ui.notify(text, "info"); @@ -1057,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 2c6fe98..89ac37f 100644 --- a/src/ui/unified.ts +++ b/src/ui/unified.ts @@ -1,87 +1,40 @@ /** - * 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 { undoExtensionTrash } from "../extensions/trash.js"; -import { getPackageCatalog } from "../packages/catalog.js"; -import { getInstalledPackages, getInstalledPackagesAllScopes } from "../packages/discovery.js"; -import { - applyPackageExtensionStateChanges, - 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 { - formatChangeEntry, - logExtensionDelete, - logExtensionToggle, - queryPackageTimeline, -} from "../utils/history.js"; +import { discoverExtensions } from "../extensions/discovery.js"; +import { getInstalledPackages } from "../packages/discovery.js"; +import { discoverPackageExtensions } from "../packages/extensions.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 { getPackageSourceKind, normalizePackageIdentity } from "../utils/package-source.js"; -import { normalizePathIdentity } from "../utils/path-identity.js"; -import { comparePackageScopes, movePackageBetweenScopes } from "../packages/scopes.js"; -import { markReloadRequired, readReloadState } from "../utils/reload-state.js"; +import { readReloadState } from "../utils/reload-state.js"; import { getSavedViewsPath, readSavedViews, writeSavedViews } from "../utils/views.js"; -import { updateExtmgrStatus } from "../utils/status.js"; -import { confirmReload, formatListOutput } from "../utils/ui-helpers.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 { - managerStateToView, - UNIFIED_FILTER_OPTIONS, - type UnifiedFilter, - type UnifiedManagerViewState, - viewToManagerState, -} from "./manager/state.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; @@ -182,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; } @@ -210,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; @@ -233,6 +190,7 @@ async function showInteractiveOnce( 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); @@ -242,7 +200,8 @@ 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(), @@ -337,1730 +296,6 @@ async function showInteractiveOnce( } } -export function buildUnifiedItems( - localEntries: Awaited>, - installedPackages: InstalledPackage[], - knownUpdates: Set, - packageExtensions: PackageExtensionEntry[] = [] -): UnifiedItem[] { - const items: UnifiedItem[] = []; - const localPaths = new Set(); - 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: 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 packageKey = getPackageExtensionSummaryKey(pkg.scope, pkg.source); - const extensionSummary = packageExtensionSummaries.get(packageKey); - const extensionPaths = packageExtensionPaths.get(packageKey); - - 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 } : {}), - ...(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; -} - -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; - 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 (packageCount > 0) { - parts.push(theme.fg("muted", "Space selects packages")); - } - - if ((options?.selectedCount ?? 0) > 0) { - parts.push(theme.fg("accent", `${options?.selectedCount} selected · B to act`)); - } - - return parts.join(" • "); -} - -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, - 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; -} - -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 readonly expandedPackageIds = new Set(); - 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 { - 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; - } - - 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 (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 === "e" || data === "E") { - if (selectedItem.extensionPaths?.length) { - if (this.expandedPackageIds.has(selectedId)) this.expandedPackageIds.delete(selectedId); - else this.expandedPackageIds.add(selectedId); - } - return true; - } - 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 { 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( - truncateToWidth( - this.theme.fg("accent", ` Local extensions (${localCount})`), - safeWidth, - "" - ) - ); - for (const item of visibleLocalItems) { - lines.push(this.renderItemLine(item, safeWidth)); - } - if (visiblePackageItems.length > 0) { - lines.push(""); - } - } - - if (visiblePackageItems.length > 0) { - lines.push( - truncateToWidth( - this.theme.fg("accent", ` Installed packages (${packageCount})`), - safeWidth, - "" - ) - ); - for (const item of visiblePackageItems) { - lines.push(this.renderItemLine(item, safeWidth)); - if (this.expandedPackageIds.has(item.id) && item.extensionPaths?.length) { - for (const extensionPath of item.extensionPaths) { - lines.push( - truncateToWidth(this.theme.fg("dim", ` ↳ ${extensionPath}`), safeWidth, "") - ); - } - } - } - } - - if (startIndex > 0 || endIndex < this.filteredItems.length) { - lines.push(""); - lines.push( - this.theme.fg( - "dim", - truncateToWidth( - ` Showing ${startIndex + 1}-${endIndex} of ${this.filteredItems.length}`, - safeWidth, - "" - ) - ) - ); - } - - 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, Math.max(1, safeWidth - 4))) { - lines.push(truncateToWidth(this.theme.fg("dim", ` ${line}`), safeWidth, "")); - } - } - - 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 selectionMarker = - item.type === "package" && this.bulkSelectedIds.has(item.id) - ? this.theme.fg("accent", "[x] ") - : item.type === "package" - ? "[ ] " - : ""; - const prefix = this.getSelectedItem()?.id === item.id ? this.theme.fg("accent", "→ ") : " "; - return truncateToWidth( - prefix + selectionMarker + 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, 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 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 }; - } - - 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 }; -} - -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: "🔁 Scheduled update checks 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": "Scheduled update checks", - help: "Help", -}; - -const LOCAL_ACTION_OPTIONS = { - details: "View details", - remove: "Remove local extension", - back: "Back to manager", -} 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 = { - configure: "Configure extensions", - enable: "Enable whole package", - disable: "Disable whole package", - compare: "Compare scopes", - "move-global": "Move to global scope", - "move-project": "Move to project scope", - 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}` : ""; - 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 "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, - savedViews: Awaited>, - viewsPath: string, - currentViewState?: UnifiedManagerViewState -): 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 === "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"; - - 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 (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)).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"; -} - -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 { changed, errors }; -} - // Legacy redirect export async function showInstalledPackagesLegacy( ctx: ExtensionCommandContext, @@ -2072,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/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/network.ts b/src/utils/network.ts index d9b6f55..361345c 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -1,64 +1,212 @@ 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 async function fetchWithTimeout( +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 combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; - let timedOut = false; - let rejectTimeout!: (error: Error) => void; - const timeoutPromise = new Promise((_, reject) => { - rejectTimeout = reject; - }); + let didTimeOut = false; const timer = setTimeout(() => { - timedOut = true; + didTimeOut = true; controller.abort(); - rejectTimeout(new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`)); }, timeoutMs); - const cancellationPromise = signal - ? new Promise((_, reject) => { - if (signal.aborted) { - reject(new DOMException("The operation was aborted", "AbortError")); - return; - } - signal.addEventListener( - "abort", - () => reject(new DOMException("The operation was aborted", "AbortError")), - { once: true } - ); - }) - : undefined; + 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 { - const operation = (async (): Promise => { - const response = await fetch(url, { signal: combinedSignal }); - const body = await response.arrayBuffer(); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - })(); - operation.catch(() => undefined); - return await Promise.race([ - operation, - timeoutPromise, - ...(cancellationPromise ? [cancellationPromise] : []), - ]); + 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" && signal?.aborted) throw error; - if (timedOut) throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); + if (signal?.aborted) throw abortError(); + if (pending.timedOut()) + throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`); throw error; } finally { - clearTimeout(timer); + pending.cleanup(); + await pending.response.body?.cancel().catch(() => undefined); } } -/** Stream a compressed download to disk with an independent size limit. */ +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, @@ -66,6 +214,10 @@ export async function downloadToFile( 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; @@ -74,40 +226,49 @@ export async function downloadToFile( controller.abort(); }, timeoutMs); let handle: Awaited> | undefined; + let reader: ReadableStreamDefaultReader | undefined; + let response: Response | undefined; + let destinationCreated = false; let completed = false; try { - const response = await fetch(url, { signal: combinedSignal }); + 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 ${Math.round(maxBytes / 1024 / 1024)} MiB limit`); - } + 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, "w"); - const reader = response.body.getReader(); + handle = await open(destination, "wx"); + destinationCreated = true; + reader = response.body.getReader(); let total = 0; while (true) { combinedSignal.throwIfAborted(); - const chunk = await reader.read(); + 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 ${Math.round(maxBytes / 1024 / 1024)} MiB limit`); + 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) await rm(destination, { force: true }).catch(() => undefined); + if (!completed && destinationCreated) + await rm(destination, { force: true }).catch(() => undefined); } } 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/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/settings.ts b/src/utils/settings.ts index ad0c995..1c7014c 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -13,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; @@ -30,7 +31,6 @@ const DEFAULT_CONFIG: AutoUpdateConfig = { }; const SETTINGS_KEY = "extmgr-auto-update"; -import { getExtmgrCacheDir } from "./pi-paths.js"; function settingsDir(): string { return getExtmgrCacheDir(); diff --git a/src/utils/status.ts b/src/utils/status.ts index 186a685..f4858f3 100644 --- a/src/utils/status.ts +++ b/src/utils/status.ts @@ -8,9 +8,10 @@ import { getAgentDir, } from "@earendil-works/pi-coding-agent"; import { getPackageCatalog, type PackageCatalog } from "../packages/catalog.js"; -import { isProjectTrusted } from "./mode.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>; @@ -24,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(), }) ) ); diff --git a/src/utils/ui-helpers.ts b/src/utils/ui-helpers.ts index 72dada2..f65b17b 100644 --- a/src/utils/ui-helpers.ts +++ b/src/utils/ui-helpers.ts @@ -6,6 +6,17 @@ 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 * Returns true if reload was triggered @@ -30,6 +41,7 @@ export async function confirmReload( try { await ctx.reload(); + markContextReloaded(ctx); await clearReloadRequired(statePath); return true; } catch (error) { 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/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/install-remove.test.ts b/test/install-remove.test.ts index cb9d80a..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"; @@ -668,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; 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/network.test.ts b/test/network.test.ts index 00684c0..2ace1bd 100644 --- a/test/network.test.ts +++ b/test/network.test.ts @@ -1,16 +1,20 @@ import assert from "node:assert/strict"; -import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +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, fetchWithTimeout } from "../src/utils/network.js"; +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) { - setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + timer = setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + }, + cancel() { + if (timer) clearTimeout(timer); }, }); return Promise.resolve(new Response(body)); @@ -23,6 +27,74 @@ void test("fetchWithTimeout enforces the timeout while reading the response body } }); +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-")); @@ -38,6 +110,26 @@ void test("downloadToFile accepts bounded streams", async () => { } }); +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-")); @@ -71,17 +163,19 @@ void test("downloadToFile honors cancellation and removes partial files", async const dir = await mkdtemp(join(tmpdir(), "pi-extmgr-download-")); const destination = join(dir, "archive.tgz"); const controller = new AbortController(); - globalThis.fetch = (() => - Promise.resolve( - new Response( - new ReadableStream({ - start(stream) { - stream.enqueue(new Uint8Array([1])); - setTimeout(() => stream.enqueue(new Uint8Array([2])), 10); - }, - }) - ) - )) as typeof fetch; + 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", @@ -102,9 +196,13 @@ void test("downloadToFile honors cancellation and removes partial files", async 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) { - setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + timer = setTimeout(() => controller.enqueue(new TextEncoder().encode("late")), 100); + }, + cancel() { + if (timer) clearTimeout(timer); }, }); return Promise.resolve(new Response(body)); diff --git a/test/npm-search.test.ts b/test/npm-search.test.ts index 7ae69ab..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 { fetchNpmRegistrySearchPage } from "../src/packages/discovery.js"; +import { fetchNpmRegistrySearchPage, fetchNpmWeeklyDownloads } from "../src/packages/discovery.js"; function makeSearchPage(total: number, from: number, count: number) { return { @@ -100,6 +100,88 @@ void test("fetchNpmRegistrySearchPage reports a useful error after repeated HTTP } }); +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(fetchCalls.length, 2); + 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("fetchNpmRegistrySearchPage prefers maintainer usernames over publisher emails", async () => { const originalFetch = globalThis.fetch; diff --git a/test/package-extensions.test.ts b/test/package-extensions.test.ts index 5860172..2b03ca9 100644 --- a/test/package-extensions.test.ts +++ b/test/package-extensions.test.ts @@ -563,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"); diff --git a/test/package-sorting.test.ts b/test/package-sorting.test.ts index 71b1979..de29de4 100644 --- a/test/package-sorting.test.ts +++ b/test/package-sorting.test.ts @@ -2,10 +2,10 @@ 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 and recent modes without mutating results", () => { +void test("remote package sorting supports name, popularity, and recent modes without mutation", () => { const packages = [ - { name: "zeta", date: "2026-01-01" }, - { name: "alpha", date: "2026-03-01" }, + { 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), @@ -15,5 +15,31 @@ void test("remote package sorting supports name and recent modes without mutatin 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 index 99b9f4d..632d9c7 100644 --- a/test/profile-command.test.ts +++ b/test/profile-command.test.ts @@ -1,36 +1,303 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +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 { handleProfileSubcommand } from "../src/commands/profile.js"; -import { buildHelpLines } from "../src/ui/help.js"; 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.2.3", name: "demo", version: "1.2.3", scope: "project" }], + 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 }>; + packages: Array<{ + source: string; + scope: string; + version: string; + resolution: string; + }>; }; - assert.deepEqual(profile.packages[0], { - source: "npm:demo@1.2.3", - scope: "project", - version: "1.2.3", + 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")); 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 index d7068d2..5aef6cd 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -9,8 +9,13 @@ import { loadProjectProfilePolicy, validateProfilePolicy, } from "../src/profiles/compare.js"; -import { deleteNamedProfile, readProfileStore, saveNamedProfile } from "../src/profiles/store.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({ @@ -33,6 +38,16 @@ void test("profile application produces a dry-run plan without mutating state", 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" }], @@ -43,10 +58,47 @@ void test("profile plans identify exact package state changes", () => { 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" }] }); - assert.equal(compareProfiles(left, right).add.length, 1); + 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 @@ -66,6 +118,22 @@ void test("named profiles persist atomically and can be deleted", async () => { } }); +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"); @@ -77,12 +145,18 @@ void test("profile writes refuse malformed or unknown-version stores", async () ); assert.equal(await readFile(path, "utf8"), "{ invalid"); - await writeFile(path, JSON.stringify({ version: 99, profiles: {} }), "utf8"); + 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")), { version: 99, profiles: {} }); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), unsupported); } finally { await rm(root, { 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 993b4ce..b0f0895 100644 --- a/test/remote-ui.test.ts +++ b/test/remote-ui.test.ts @@ -3,7 +3,7 @@ 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, clearRemotePackageInfoCache } 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"; @@ -18,6 +18,22 @@ function setSearchPage( 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 () => { setSearchPage("no-results", []); @@ -138,6 +154,57 @@ 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 () => { const packages = Array.from({ length: 25 }, (_, index) => ({ name: `demo-pkg-${index + 1}`, @@ -298,7 +365,7 @@ void test("browseRemotePackages scopes inline community searches to pi packages" 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"))); @@ -414,7 +481,7 @@ void test("browseRemotePackages refresh bypasses fresh persistent and runtime se try { await browseRemotePackages(ctx, "demo", pi); - assert.equal(fetchCalls, 1); + 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"))); @@ -506,14 +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); 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(); diff --git a/test/ui-helpers.test.ts b/test/ui-helpers.test.ts index 230d211..f20c14e 100644 --- a/test/ui-helpers.test.ts +++ b/test/ui-helpers.test.ts @@ -1,7 +1,18 @@ 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 marks a context stale after a successful reload", async () => { + const ctx = { + hasUI: true, + ui: { confirm: () => Promise.resolve(true), notify: () => undefined }, + reload: () => Promise.resolve(), + } as unknown as ExtensionCommandContext; + + assert.equal(await confirmReload(ctx, "Package updated."), true); + assert.equal(wasContextReloaded(ctx), true); +}); void test("confirmReload notifies the user when ctx.reload rejects", async () => { const notifications: { message: string; level: string | undefined }[] = []; diff --git a/test/unified-items.test.ts b/test/unified-items.test.ts index 5d5ec0a..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")]; diff --git a/test/unified-ui.test.ts b/test/unified-ui.test.ts index a56735e..e29c21a 100644 --- a/test/unified-ui.test.ts +++ b/test/unified-ui.test.ts @@ -12,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"); @@ -39,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")), @@ -116,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 { @@ -129,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({ @@ -323,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(); @@ -362,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 { @@ -411,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( @@ -432,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[] = []; @@ -467,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( @@ -528,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 { @@ -644,7 +684,7 @@ 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 { @@ -655,14 +695,13 @@ void test("/extensions keeps staged changes when staying in the manager", async void test("manager hints use the active public selection bindings", async () => { const { buildFooterShortcuts } = await import("../src/ui/footer.js"); const hints = buildFooterShortcuts( - { selectedType: "package", expandable: false, pendingChanges: 0, selectedPackages: 2 }, + { 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 clear\/cancel/); - assert.match(hints, /2 selected/); - assert.match(hints, /B bulk actions/); + assert.match(hints, /Alt\+left back/); + assert.match(hints, /B act on 2/); }); 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"))); +}); From cc92038d11d9544e1608c1d12f6c8e559efb0212 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 21 Jul 2026 17:52:09 +0100 Subject: [PATCH 68/68] docs(landing): showcase workspaces and refresh screenshot Replace the old manager screenshot with the current Installed workspace (tabs + master/detail), add workspace-aware hero copy and terminal preview, expand the feature grid to six cards covering all four workspaces plus scheduled updates and safety, and update the quick-reference key table. --- docs/assets/installed-workspace.png | Bin 0 -> 162504 bytes docs/assets/tailwind.css | 732 +--------------------------- docs/index.html | 170 +++++-- 3 files changed, 134 insertions(+), 768 deletions(-) create mode 100644 docs/assets/installed-workspace.png diff --git a/docs/assets/installed-workspace.png b/docs/assets/installed-workspace.png new file mode 100644 index 0000000000000000000000000000000000000000..94583dd37112dbf33d422f245dbeffe4a23b6427 GIT binary patch literal 162504 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≺1xBKrz{1&&(@Q0<$M{B<{@K|GZ$&h`7%{=X15#sIqORq1kG*gAzc>6O<-hEY zGnzWR=Xr2|^>q^}poGOPu{^R>%rCBj=@N5!N^iP<7F_t@3GNZOX*$zM7=a#57(=)| z2tU-}(8fVkTl=ZRLREXdq_D6-a9~ek%JSe1zFVkxG{93ey+BAE-5

eH`JA#!Gk? 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%0DpZvg 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 loPGZC|8ABc$h_wozj>htuTb(E@2@A4lTv :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