UNPKG

@mariozechner/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,302 lines 75.9 kB
import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.js"; import { parseGitUrl } from "../utils/git.js"; import { isLocalPath } from "../utils/paths.js"; import { isStdoutTakenOver } from "./output-guard.js"; const NETWORK_TIMEOUT_MS = 10000; const UPDATE_CHECK_CONCURRENCY = 4; const GIT_UPDATE_CONCURRENCY = 4; function isOfflineModeEnabled() { const value = process.env.PI_OFFLINE; if (!value) return false; return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } /** * Compute a numeric precedence rank for a resource based on its metadata. * Lower rank = higher precedence. Used to sort resolved resources so that * name-collision resolution ("first wins") produces the correct outcome. * * Precedence (highest to lowest): * 0 project + settings entry (source: "local", scope: "project") * 1 project + auto-discovered (source: "auto", scope: "project") * 2 user + settings entry (source: "local", scope: "user") * 3 user + auto-discovered (source: "auto", scope: "user") * 4 package resource (origin: "package") */ function resourcePrecedenceRank(m) { if (m.origin === "package") return 4; const scopeBase = m.scope === "project" ? 0 : 2; return scopeBase + (m.source === "local" ? 0 : 1); } const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"]; const FILE_PATTERNS = { extensions: /\.(ts|js)$/, skills: /\.md$/, prompts: /\.md$/, themes: /\.json$/, }; const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; function toPosixPath(p) { return p.split(sep).join("/"); } function getHomeDir() { return process.env.HOME || homedir(); } function prefixIgnorePattern(line, prefix) { const trimmed = line.trim(); if (!trimmed) return null; if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; let pattern = line; let negated = false; if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); } else if (pattern.startsWith("\\!")) { pattern = pattern.slice(1); } if (pattern.startsWith("/")) { pattern = pattern.slice(1); } const prefixed = prefix ? `${prefix}${pattern}` : pattern; return negated ? `!${prefixed}` : prefixed; } function addIgnoreRules(ig, dir, rootDir) { const relativeDir = relative(rootDir, dir); const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; for (const filename of IGNORE_FILE_NAMES) { const ignorePath = join(dir, filename); if (!existsSync(ignorePath)) continue; try { const content = readFileSync(ignorePath, "utf-8"); const patterns = content .split(/\r?\n/) .map((line) => prefixIgnorePattern(line, prefix)) .filter((line) => Boolean(line)); if (patterns.length > 0) { ig.add(patterns); } } catch { } } } function isPattern(s) { return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?"); } function isOverridePattern(s) { return s.startsWith("!") || s.startsWith("+") || s.startsWith("-"); } function hasGlobPattern(s) { return s.includes("*") || s.includes("?"); } function splitPatterns(entries) { const plain = []; const patterns = []; for (const entry of entries) { if (isPattern(entry)) { patterns.push(entry); } else { plain.push(entry); } } return { plain, patterns }; } function collectFiles(dir, filePattern, skipNodeModules = true, ignoreMatcher, rootDir) { const files = []; if (!existsSync(dir)) return files; const root = rootDir ?? dir; const ig = ignoreMatcher ?? ignore(); addIgnoreRules(ig, dir, root); try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith(".")) continue; if (skipNodeModules && entry.name === "node_modules") continue; const fullPath = join(dir, entry.name); let isDir = entry.isDirectory(); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { const stats = statSync(fullPath); isDir = stats.isDirectory(); isFile = stats.isFile(); } catch { continue; } } const relPath = toPosixPath(relative(root, fullPath)); const ignorePath = isDir ? `${relPath}/` : relPath; if (ig.ignores(ignorePath)) continue; if (isDir) { files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root)); } else if (isFile && filePattern.test(entry.name)) { files.push(fullPath); } } } catch { // Ignore errors } return files; } function collectSkillEntries(dir, mode, ignoreMatcher, rootDir) { const entries = []; if (!existsSync(dir)) return entries; const root = rootDir ?? dir; const ig = ignoreMatcher ?? ignore(); addIgnoreRules(ig, dir, root); try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { if (entry.name !== "SKILL.md") { continue; } const fullPath = join(dir, entry.name); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { isFile = statSync(fullPath).isFile(); } catch { continue; } } const relPath = toPosixPath(relative(root, fullPath)); if (isFile && !ig.ignores(relPath)) { entries.push(fullPath); return entries; } } for (const entry of dirEntries) { if (entry.name.startsWith(".")) continue; if (entry.name === "node_modules") continue; const fullPath = join(dir, entry.name); let isDir = entry.isDirectory(); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { const stats = statSync(fullPath); isDir = stats.isDirectory(); isFile = stats.isFile(); } catch { continue; } } const relPath = toPosixPath(relative(root, fullPath)); if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) { entries.push(fullPath); continue; } if (!isDir) continue; if (ig.ignores(`${relPath}/`)) continue; entries.push(...collectSkillEntries(fullPath, mode, ig, root)); } } catch { // Ignore errors } return entries; } function collectAutoSkillEntries(dir, mode) { return collectSkillEntries(dir, mode); } function findGitRepoRoot(startDir) { let dir = resolve(startDir); while (true) { if (existsSync(join(dir, ".git"))) { return dir; } const parent = dirname(dir); if (parent === dir) { return null; } dir = parent; } } function collectAncestorAgentsSkillDirs(startDir) { const skillDirs = []; const resolvedStartDir = resolve(startDir); const gitRepoRoot = findGitRepoRoot(resolvedStartDir); let dir = resolvedStartDir; while (true) { skillDirs.push(join(dir, ".agents", "skills")); if (gitRepoRoot && dir === gitRepoRoot) { break; } const parent = dirname(dir); if (parent === dir) { break; } dir = parent; } return skillDirs; } function collectAutoPromptEntries(dir) { const entries = []; if (!existsSync(dir)) return entries; const ig = ignore(); addIgnoreRules(ig, dir, dir); try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { if (entry.name.startsWith(".")) continue; if (entry.name === "node_modules") continue; const fullPath = join(dir, entry.name); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { isFile = statSync(fullPath).isFile(); } catch { continue; } } const relPath = toPosixPath(relative(dir, fullPath)); if (ig.ignores(relPath)) continue; if (isFile && entry.name.endsWith(".md")) { entries.push(fullPath); } } } catch { // Ignore errors } return entries; } function collectAutoThemeEntries(dir) { const entries = []; if (!existsSync(dir)) return entries; const ig = ignore(); addIgnoreRules(ig, dir, dir); try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { if (entry.name.startsWith(".")) continue; if (entry.name === "node_modules") continue; const fullPath = join(dir, entry.name); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { isFile = statSync(fullPath).isFile(); } catch { continue; } } const relPath = toPosixPath(relative(dir, fullPath)); if (ig.ignores(relPath)) continue; if (isFile && entry.name.endsWith(".json")) { entries.push(fullPath); } } } catch { // Ignore errors } return entries; } function readPiManifestFile(packageJsonPath) { try { const content = readFileSync(packageJsonPath, "utf-8"); const pkg = JSON.parse(content); return pkg.pi ?? null; } catch { return null; } } function resolveExtensionEntries(dir) { const packageJsonPath = join(dir, "package.json"); if (existsSync(packageJsonPath)) { const manifest = readPiManifestFile(packageJsonPath); if (manifest?.extensions?.length) { const entries = []; for (const extPath of manifest.extensions) { const resolvedExtPath = resolve(dir, extPath); if (existsSync(resolvedExtPath)) { entries.push(resolvedExtPath); } } if (entries.length > 0) { return entries; } } } const indexTs = join(dir, "index.ts"); const indexJs = join(dir, "index.js"); if (existsSync(indexTs)) { return [indexTs]; } if (existsSync(indexJs)) { return [indexJs]; } return null; } function collectAutoExtensionEntries(dir) { const entries = []; if (!existsSync(dir)) return entries; // First check if this directory itself has explicit extension entries (package.json or index) const rootEntries = resolveExtensionEntries(dir); if (rootEntries) { return rootEntries; } // Otherwise, discover extensions from directory contents const ig = ignore(); addIgnoreRules(ig, dir, dir); try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { if (entry.name.startsWith(".")) continue; if (entry.name === "node_modules") continue; const fullPath = join(dir, entry.name); let isDir = entry.isDirectory(); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { const stats = statSync(fullPath); isDir = stats.isDirectory(); isFile = stats.isFile(); } catch { continue; } } const relPath = toPosixPath(relative(dir, fullPath)); const ignorePath = isDir ? `${relPath}/` : relPath; if (ig.ignores(ignorePath)) continue; if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { entries.push(fullPath); } else if (isDir) { const resolvedEntries = resolveExtensionEntries(fullPath); if (resolvedEntries) { entries.push(...resolvedEntries); } } } } catch { // Ignore errors } return entries; } /** * Collect resource files from a directory based on resource type. * Extensions use smart discovery (index.ts in subdirs), others use recursive collection. */ function collectResourceFiles(dir, resourceType) { if (resourceType === "skills") { return collectSkillEntries(dir, "pi"); } if (resourceType === "extensions") { return collectAutoExtensionEntries(dir); } return collectFiles(dir, FILE_PATTERNS[resourceType]); } function matchesAnyPattern(filePath, patterns, baseDir) { const rel = toPosixPath(relative(baseDir, filePath)); const name = basename(filePath); const filePathPosix = toPosixPath(filePath); const isSkillFile = name === "SKILL.md"; const parentDir = isSkillFile ? dirname(filePath) : undefined; const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir)) : undefined; const parentName = isSkillFile ? basename(parentDir) : undefined; const parentDirPosix = isSkillFile ? toPosixPath(parentDir) : undefined; return patterns.some((pattern) => { const normalizedPattern = toPosixPath(pattern); if (minimatch(rel, normalizedPattern) || minimatch(name, normalizedPattern) || minimatch(filePathPosix, normalizedPattern)) { return true; } if (!isSkillFile) return false; return (minimatch(parentRel, normalizedPattern) || minimatch(parentName, normalizedPattern) || minimatch(parentDirPosix, normalizedPattern)); }); } function normalizeExactPattern(pattern) { const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern; return toPosixPath(normalized); } function matchesAnyExactPattern(filePath, patterns, baseDir) { if (patterns.length === 0) return false; const rel = toPosixPath(relative(baseDir, filePath)); const name = basename(filePath); const filePathPosix = toPosixPath(filePath); const isSkillFile = name === "SKILL.md"; const parentDir = isSkillFile ? dirname(filePath) : undefined; const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir)) : undefined; const parentDirPosix = isSkillFile ? toPosixPath(parentDir) : undefined; return patterns.some((pattern) => { const normalized = normalizeExactPattern(pattern); if (normalized === rel || normalized === filePathPosix) { return true; } if (!isSkillFile) return false; return normalized === parentRel || normalized === parentDirPosix; }); } function getOverridePatterns(entries) { return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-")); } function isEnabledByOverrides(filePath, patterns, baseDir) { const overrides = getOverridePatterns(patterns); const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)); const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1)); const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1)); let enabled = true; if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) { enabled = false; } if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) { enabled = true; } if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) { enabled = false; } return enabled; } /** * Apply patterns to paths and return a Set of enabled paths. * Pattern types: * - Plain patterns: include matching paths * - `!pattern`: exclude matching paths * - `+path`: force-include exact path (overrides exclusions) * - `-path`: force-exclude exact path (overrides force-includes) */ function applyPatterns(allPaths, patterns, baseDir) { const includes = []; const excludes = []; const forceIncludes = []; const forceExcludes = []; for (const p of patterns) { if (p.startsWith("+")) { forceIncludes.push(p.slice(1)); } else if (p.startsWith("-")) { forceExcludes.push(p.slice(1)); } else if (p.startsWith("!")) { excludes.push(p.slice(1)); } else { includes.push(p); } } // Step 1: Apply includes (or all if no includes) let result; if (includes.length === 0) { result = [...allPaths]; } else { result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir)); } // Step 2: Apply excludes if (excludes.length > 0) { result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir)); } // Step 3: Force-include (add back from allPaths, overriding exclusions) if (forceIncludes.length > 0) { for (const filePath of allPaths) { if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) { result.push(filePath); } } } // Step 4: Force-exclude (remove even if included or force-included) if (forceExcludes.length > 0) { result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir)); } return new Set(result); } export class DefaultPackageManager { cwd; agentDir; settingsManager; globalNpmRoot; globalNpmRootCommandKey; progressCallback; constructor(options) { this.cwd = options.cwd; this.agentDir = options.agentDir; this.settingsManager = options.settingsManager; } setProgressCallback(callback) { this.progressCallback = callback; } addSourceToSettings(source, options) { const scope = options?.local ? "project" : "user"; const currentSettings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); const currentPackages = currentSettings.packages ?? []; const normalizedSource = this.normalizePackageSourceForSettings(source, scope); const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope)); if (exists) { return false; } const nextPackages = [...currentPackages, normalizedSource]; if (scope === "project") { this.settingsManager.setProjectPackages(nextPackages); } else { this.settingsManager.setPackages(nextPackages); } return true; } removeSourceFromSettings(source, options) { const scope = options?.local ? "project" : "user"; const currentSettings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); const currentPackages = currentSettings.packages ?? []; const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope)); const changed = nextPackages.length !== currentPackages.length; if (!changed) { return false; } if (scope === "project") { this.settingsManager.setProjectPackages(nextPackages); } else { this.settingsManager.setPackages(nextPackages); } return true; } getInstalledPath(source, scope) { const parsed = this.parseSource(source); if (parsed.type === "npm") { const path = this.getNpmInstallPath(parsed, scope); return existsSync(path) ? path : undefined; } if (parsed.type === "git") { const path = this.getGitInstallPath(parsed, scope); return existsSync(path) ? path : undefined; } if (parsed.type === "local") { const baseDir = this.getBaseDirForScope(scope); const path = this.resolvePathFromBase(parsed.path, baseDir); return existsSync(path) ? path : undefined; } return undefined; } emitProgress(event) { this.progressCallback?.(event); } async withProgress(action, source, message, operation) { this.emitProgress({ type: "start", action, source, message }); try { await operation(); this.emitProgress({ type: "complete", action, source }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.emitProgress({ type: "error", action, source, message: errorMessage }); throw error; } } async resolve(onMissing) { const accumulator = this.createAccumulator(); const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); // Collect all packages with scope (project first so cwd resources win collisions) const allPackages = []; for (const pkg of projectSettings.packages ?? []) { allPackages.push({ pkg, scope: "project" }); } for (const pkg of globalSettings.packages ?? []) { allPackages.push({ pkg, scope: "user" }); } // Dedupe: project scope wins over global for same package identity const packageSources = this.dedupePackages(allPackages); await this.resolvePackageSources(packageSources, accumulator, onMissing); const globalBaseDir = this.agentDir; const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME); for (const resourceType of RESOURCE_TYPES) { const target = this.getTargetMap(accumulator, resourceType); const globalEntries = (globalSettings[resourceType] ?? []); const projectEntries = (projectSettings[resourceType] ?? []); this.resolveLocalEntries(projectEntries, resourceType, target, { source: "local", scope: "project", origin: "top-level", }, projectBaseDir); this.resolveLocalEntries(globalEntries, resourceType, target, { source: "local", scope: "user", origin: "top-level", }, globalBaseDir); } this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); return this.toResolvedPaths(accumulator); } async resolveExtensionSources(sources, options) { const accumulator = this.createAccumulator(); const scope = options?.temporary ? "temporary" : options?.local ? "project" : "user"; const packageSources = sources.map((source) => ({ pkg: source, scope })); await this.resolvePackageSources(packageSources, accumulator); return this.toResolvedPaths(accumulator); } listConfiguredPackages() { const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); const configuredPackages = []; for (const pkg of globalSettings.packages ?? []) { const source = typeof pkg === "string" ? pkg : pkg.source; configuredPackages.push({ source, scope: "user", filtered: typeof pkg === "object", installedPath: this.getInstalledPath(source, "user"), }); } for (const pkg of projectSettings.packages ?? []) { const source = typeof pkg === "string" ? pkg : pkg.source; configuredPackages.push({ source, scope: "project", filtered: typeof pkg === "object", installedPath: this.getInstalledPath(source, "project"), }); } return configuredPackages; } async install(source, options) { const parsed = this.parseSource(source); const scope = options?.local ? "project" : "user"; await this.withProgress("install", source, `Installing ${source}...`, async () => { if (parsed.type === "npm") { await this.installNpm(parsed, scope, false); return; } if (parsed.type === "git") { await this.installGit(parsed, scope); return; } if (parsed.type === "local") { const resolved = this.resolvePath(parsed.path); if (!existsSync(resolved)) { throw new Error(`Path does not exist: ${resolved}`); } return; } throw new Error(`Unsupported install source: ${source}`); }); } async installAndPersist(source, options) { await this.install(source, options); this.addSourceToSettings(source, options); } async remove(source, options) { const parsed = this.parseSource(source); const scope = options?.local ? "project" : "user"; await this.withProgress("remove", source, `Removing ${source}...`, async () => { if (parsed.type === "npm") { await this.uninstallNpm(parsed, scope); return; } if (parsed.type === "git") { await this.removeGit(parsed, scope); return; } if (parsed.type === "local") { return; } throw new Error(`Unsupported remove source: ${source}`); }); } async removeAndPersist(source, options) { await this.remove(source, options); return this.removeSourceFromSettings(source, options); } async update(source) { const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); const identity = source ? this.getPackageIdentity(source) : undefined; let matched = false; const updateSources = []; for (const pkg of globalSettings.packages ?? []) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue; matched = true; updateSources.push({ source: sourceStr, scope: "user" }); } for (const pkg of projectSettings.packages ?? []) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue; matched = true; updateSources.push({ source: sourceStr, scope: "project" }); } if (source && !matched) { throw new Error(this.buildNoMatchingPackageMessage(source, [ ...(globalSettings.packages ?? []), ...(projectSettings.packages ?? []), ])); } await this.updateConfiguredSources(updateSources); } async updateConfiguredSources(sources) { if (isOfflineModeEnabled() || sources.length === 0) { return; } const npmCandidates = []; const gitCandidates = []; for (const entry of sources) { const parsed = this.parseSource(entry.source); if (parsed.type === "local" || parsed.pinned) { continue; } if (parsed.type === "npm") { npmCandidates.push({ ...entry, parsed }); continue; } gitCandidates.push({ ...entry, parsed }); } const npmCheckTasks = npmCandidates.map((entry) => async () => ({ entry, shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope), })); const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY); const userNpmUpdates = []; const projectNpmUpdates = []; for (const result of npmCheckResults) { if (!result.shouldUpdate) { continue; } if (result.entry.scope === "user") { userNpmUpdates.push(result.entry); } else { projectNpmUpdates.push(result.entry); } } const tasks = []; if (userNpmUpdates.length > 0) { tasks.push(this.updateNpmBatch(userNpmUpdates, "user")); } if (projectNpmUpdates.length > 0) { tasks.push(this.updateNpmBatch(projectNpmUpdates, "project")); } if (gitCandidates.length > 0) { const gitTasks = gitCandidates.map((entry) => async () => this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => { await this.updateGit(entry.parsed, entry.scope); })); tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => { })); } await Promise.all(tasks); } async shouldUpdateNpmSource(source, scope) { const installedPath = this.getNpmInstallPath(source, scope); const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined; if (!installedVersion) { return true; } try { const latestVersion = await this.getLatestNpmVersion(source.name); return latestVersion !== installedVersion; } catch { // Preserve existing update behavior when version lookup fails. return true; } } async updateNpmBatch(sources, scope) { if (sources.length === 0) { return; } const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`; const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`; const specs = sources.map((entry) => `${entry.parsed.name}@latest`); await this.withProgress("update", sourceLabel, message, async () => { await this.installNpmBatch(specs, scope); }); } async installNpmBatch(specs, scope) { if (scope === "user") { await this.runNpmCommand(["install", "-g", ...specs]); return; } const installRoot = this.getNpmInstallRoot(scope, false); this.ensureNpmProject(installRoot); await this.runNpmCommand(["install", ...specs, "--prefix", installRoot]); } async checkForAvailableUpdates() { if (isOfflineModeEnabled()) { return []; } const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); const allPackages = []; for (const pkg of projectSettings.packages ?? []) { allPackages.push({ pkg, scope: "project" }); } for (const pkg of globalSettings.packages ?? []) { allPackages.push({ pkg, scope: "user" }); } const packageSources = this.dedupePackages(allPackages); const checks = packageSources .filter((entry) => entry.scope !== "temporary") .map((entry) => async () => { const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; const parsed = this.parseSource(source); if (parsed.type === "local" || parsed.pinned) { return undefined; } if (parsed.type === "npm") { const installedPath = this.getNpmInstallPath(parsed, entry.scope); if (!existsSync(installedPath)) { return undefined; } const hasUpdate = await this.npmHasAvailableUpdate(parsed, installedPath); if (!hasUpdate) { return undefined; } return { source, displayName: parsed.name, type: "npm", scope: entry.scope, }; } const installedPath = this.getGitInstallPath(parsed, entry.scope); if (!existsSync(installedPath)) { return undefined; } const hasUpdate = await this.gitHasAvailableUpdate(installedPath); if (!hasUpdate) { return undefined; } return { source, displayName: `${parsed.host}/${parsed.path}`, type: "git", scope: entry.scope, }; }); const results = await this.runWithConcurrency(checks, UPDATE_CHECK_CONCURRENCY); return results.filter((result) => result !== undefined); } async resolvePackageSources(sources, accumulator, onMissing) { for (const { pkg, scope } of sources) { const sourceStr = typeof pkg === "string" ? pkg : pkg.source; const filter = typeof pkg === "object" ? pkg : undefined; const parsed = this.parseSource(sourceStr); const metadata = { source: sourceStr, scope, origin: "package" }; if (parsed.type === "local") { const baseDir = this.getBaseDirForScope(scope); this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir); continue; } const installMissing = async () => { if (isOfflineModeEnabled()) { return false; } if (!onMissing) { await this.installParsedSource(parsed, scope); return true; } const action = await onMissing(sourceStr); if (action === "skip") return false; if (action === "error") throw new Error(`Missing source: ${sourceStr}`); await this.installParsedSource(parsed, scope); return true; }; if (parsed.type === "npm") { const installedPath = this.getNpmInstallPath(parsed, scope); const needsInstall = !existsSync(installedPath) || (parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath))); if (needsInstall) { const installed = await installMissing(); if (!installed) continue; } metadata.baseDir = installedPath; this.collectPackageResources(installedPath, accumulator, filter, metadata); continue; } if (parsed.type === "git") { const installedPath = this.getGitInstallPath(parsed, scope); if (!existsSync(installedPath)) { const installed = await installMissing(); if (!installed) continue; } else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) { await this.refreshTemporaryGitSource(parsed, sourceStr); } metadata.baseDir = installedPath; this.collectPackageResources(installedPath, accumulator, filter, metadata); } } } resolveLocalExtensionSource(source, accumulator, filter, metadata, baseDir) { const resolved = this.resolvePathFromBase(source.path, baseDir); if (!existsSync(resolved)) { return; } try { const stats = statSync(resolved); if (stats.isFile()) { metadata.baseDir = dirname(resolved); this.addResource(accumulator.extensions, resolved, metadata, true); return; } if (stats.isDirectory()) { metadata.baseDir = resolved; const resources = this.collectPackageResources(resolved, accumulator, filter, metadata); if (!resources) { this.addResource(accumulator.extensions, resolved, metadata, true); } } } catch { return; } } async installParsedSource(parsed, scope) { if (parsed.type === "npm") { await this.installNpm(parsed, scope, scope === "temporary"); return; } if (parsed.type === "git") { await this.installGit(parsed, scope); return; } } getPackageSourceString(pkg) { return typeof pkg === "string" ? pkg : pkg.source; } getSourceMatchKeyForInput(source) { const parsed = this.parseSource(source); if (parsed.type === "npm") { return `npm:${parsed.name}`; } if (parsed.type === "git") { return `git:${parsed.host}/${parsed.path}`; } return `local:${this.resolvePath(parsed.path)}`; } getSourceMatchKeyForSettings(source, scope) { const parsed = this.parseSource(source); if (parsed.type === "npm") { return `npm:${parsed.name}`; } if (parsed.type === "git") { return `git:${parsed.host}/${parsed.path}`; } const baseDir = this.getBaseDirForScope(scope); return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; } buildNoMatchingPackageMessage(source, configuredPackages) { const suggestion = this.findSuggestedConfiguredSource(source, configuredPackages); if (!suggestion) { return `No matching package found for ${source}`; } return `No matching package found for ${source}. Did you mean ${suggestion}?`; } findSuggestedConfiguredSource(source, configuredPackages) { const trimmedSource = source.trim(); const suggestions = new Set(); for (const pkg of configuredPackages) { const sourceStr = this.getPackageSourceString(pkg); const parsed = this.parseSource(sourceStr); if (parsed.type === "npm") { if (trimmedSource === parsed.name || trimmedSource === parsed.spec) { suggestions.add(sourceStr); } continue; } if (parsed.type === "git") { const shorthand = `${parsed.host}/${parsed.path}`; const shorthandWithRef = parsed.ref ? `${shorthand}@${parsed.ref}` : undefined; if (trimmedSource === shorthand || (shorthandWithRef && trimmedSource === shorthandWithRef)) { suggestions.add(sourceStr); } } } return suggestions.values().next().value; } packageSourcesMatch(existing, inputSource, scope) { const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope); const right = this.getSourceMatchKeyForInput(inputSource); return left === right; } normalizePackageSourceForSettings(source, scope) { const parsed = this.parseSource(source); if (parsed.type !== "local") { return source; } const baseDir = this.getBaseDirForScope(scope); const resolved = this.resolvePath(parsed.path); const rel = relative(baseDir, resolved); return rel || "."; } parseSource(source) { if (source.startsWith("npm:")) { const spec = source.slice("npm:".length).trim(); const { name, version } = this.parseNpmSpec(spec); return { type: "npm", spec, name, pinned: Boolean(version), }; } if (isLocalPath(source)) { return { type: "local", path: source }; } // Try parsing as git URL const gitParsed = parseGitUrl(source); if (gitParsed) { return gitParsed; } return { type: "local", path: source }; } async installedNpmMatchesPinnedVersion(source, installedPath) { const installedVersion = this.getInstalledNpmVersion(installedPath); if (!installedVersion) { return false; } const { version: pinnedVersion } = this.parseNpmSpec(source.spec); if (!pinnedVersion) { return true; } return installedVersion === pinnedVersion; } async npmHasAvailableUpdate(source, installedPath) { if (isOfflineModeEnabled()) { return false; } const installedVersion = this.getInstalledNpmVersion(installedPath); if (!installedVersion) { return false; } try { const latestVersion = await this.getLatestNpmVersion(source.name); return latestVersion !== installedVersion; } catch { return false; } } getInstalledNpmVersion(installedPath) { const packageJsonPath = join(installedPath, "package.json"); if (!existsSync(packageJsonPath)) return undefined; try { const content = readFileSync(packageJsonPath, "utf-8"); const pkg = JSON.parse(content); return pkg.version; } catch { return undefined; } } async getLatestNpmVersion(packageName) { const npmCommand = this.getNpmCommand(); const stdout = await this.runCommandCapture(npmCommand.command, [...npmCommand.args, "view", packageName, "version", "--json"], { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS }); const raw = stdout.trim(); if (!raw) throw new Error("Empty response from npm view"); return JSON.parse(raw); } async gitHasAvailableUpdate(installedPath) { if (isOfflineModeEnabled()) { return false; } try { const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, }); const remoteHead = await this.getRemoteGitHead(installedPath); return localHead.trim() !== remoteHead.trim(); } catch { return false; } } async getRemoteGitHead(installedPath) { const upstreamRef = await this.getGitUpstreamRef(installedPath); if (upstreamRef) { const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", upstreamRef]); const match = remoteHead.match(/^([0-9a-f]{40})\s+/m); if (match?.[1]) { return match[1]; } } const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", "HEAD"]); const match = remoteHead.match(/^([0-9a-f]{40})\s+HEAD$/m); if (!match?.[1]) { throw new Error("Failed to determine remote HEAD"); } return match[1]; } async getLocalGitUpdateTarget(installedPath) { try { const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, }); const trimmedUpstream = upstream.trim(); if (!trimmedUpstream.startsWith("origin/")) { throw new Error(`Unsupported upstream remote: ${trimmedUpstream}`); } const branch = trimmedUpstream.slice("origin/".length); if (!branch) { throw new Error("Missing upstream branch name"); } const head = await this.runCommandCapture("git", ["rev-parse", "@{upstream}"], { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, }); return { ref: "@{upstream}", head, fetchArgs: [ "fetch", "--prune", "--no-tags", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`, ], }; } catch { await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: installedPath }).catch(() => { }); const head = await this.runCommandCapture("git", ["rev-parse", "origin/HEAD"], { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, }); const originHeadRef = await this.runCommandCapture("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, }).catch(() => ""); const branch = originHeadRef.trim().replace(/^refs\/remotes\/origin\//, ""); if (branch) { return { ref: "origin/HEAD", head, fetchArgs: [ "fetch", "--prune", "--no-tags", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`, ], }; } return { ref: "origin/HEAD", head, fetchArgs: ["fetch", "--prune", "--no-tags", "origin", "+HEAD:refs/remotes/origin/HEAD"], }; } } async getGitUpstreamRef(installedPath) { try { const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, }); const trimmed = upstream.trim(); if (!trimmed.startsWith("origin/")) { return undefined; } const branch = trimmed.slice("origin/".length); return branch ? `refs/heads/${branch}` : undefined; } catch { return undefined; } } runGitRemoteCommand(installedPath, args) { return this.runCommandCapture("git", args, { cwd: installedPath, timeoutMs: NETWORK_TIMEOUT_MS, env: { GIT_TERMINAL_PROMPT: "0", }, }); } async runWithConcurrency(tasks, limit) { if (tasks.length === 0) { return []; } const results = new Array(tasks.length); let nextIndex = 0; const workerCount = Math.max(1, Math.min(limit, tasks.length)); const worker = async () => { while (true) { const index = nextIndex; nextIndex += 1; if (index >= tasks.length) { return; } results[index] = await tasks[index](); } }; await Promise.all(Array.from({ length: workerCount }, () => worker())); return results; } /** * Get a unique identity for a package, ignoring version/ref. * Used to detect when the same package is in both global and project settings. * For git packages, uses normalized host/path to ensure SSH and HTTPS URLs * for the same repository are treated as identical. */ getPackageIdentity(source, scope) { const parsed = this.parseSource(source); if (parsed.type === "npm") { return `npm:${parsed.name}`; } if (parsed.type === "git") { // Use host/path for identity to normalize SSH and HTTPS return `git:${parsed.host}/${parsed.path}`; } if (scope) { const baseDir = this.getBaseDirForScope(scope); return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; } return `local:${this.resolvePath(pa