Download packages/coding-agent/src/core/package-manager.ts from SaylorTwift/pi: direct link, hf CLI and curl.
- Browser
- Download file 84.7 kB
-
https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/src/core/package-manager.ts
- Command line
-
hf download hf://SaylorTwift/pi/packages/coding-agent/src/core/package-manager.ts
-
curl -L -o package-manager.ts https://huggingface.co/SaylorTwift/pi/resolve/main/packages/coding-agent/src/core/package-manager.ts
84.7 kB
| import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; | |
| import { createHash } from "node:crypto"; | |
| import { | |
| chmodSync, | |
| existsSync, | |
| globSync, | |
| mkdirSync, | |
| readdirSync, | |
| readFileSync, | |
| rmSync, | |
| statSync, | |
| writeFileSync, | |
| } from "node:fs"; | |
| import { homedir } from "node:os"; | |
| function getEnv(): NodeJS.ProcessEnv { | |
| if (process.platform !== "linux" || Object.keys(process.env).length > 0) { | |
| return process.env; | |
| } | |
| try { | |
| const data = readFileSync("/proc/self/environ", "utf-8"); | |
| const env: NodeJS.ProcessEnv = {}; | |
| for (const entry of data.split("\0")) { | |
| const idx = entry.indexOf("="); | |
| if (idx > 0) { | |
| env[entry.slice(0, idx)] = entry.slice(idx + 1); | |
| } | |
| } | |
| return env; | |
| } catch { | |
| return process.env; | |
| } | |
| } | |
| import { basename, dirname, join, relative, resolve, sep } from "node:path"; | |
| import type { Readable } from "node:stream"; | |
| import ignore from "ignore"; | |
| import { minimatch } from "minimatch"; | |
| import { gt, maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; | |
| import { CONFIG_DIR_NAME } from "../config.ts"; | |
| import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; | |
| import { type GitSource, parseGitUrl } from "../utils/git.ts"; | |
| import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; | |
| import { stripBom } from "../utils/text.ts"; | |
| import { isStdoutTakenOver } from "./output-guard.ts"; | |
| import { type PiManifest, readPiManifest } from "./pi-manifest.ts"; | |
| import type { PackageSource, SettingsManager } from "./settings-manager.ts"; | |
| const NETWORK_TIMEOUT_MS = 10000; | |
| const UPDATE_CHECK_CONCURRENCY = 4; | |
| const GIT_UPDATE_CONCURRENCY = 4; | |
| function isOfflineModeEnabled(): boolean { | |
| const value = process.env.PI_OFFLINE; | |
| if (!value) return false; | |
| return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; | |
| } | |
| function isExactNpmVersion(version: string | undefined): boolean { | |
| return valid(version ?? "") !== null; | |
| } | |
| function getNpmVersionRange(version: string | undefined): string | undefined { | |
| return version ? (validRange(version) ?? undefined) : undefined; | |
| } | |
| export interface PathMetadata { | |
| source: string; | |
| scope: SourceScope; | |
| origin: "package" | "top-level"; | |
| baseDir?: string; | |
| } | |
| export interface ResolvedResource { | |
| path: string; | |
| enabled: boolean; | |
| metadata: PathMetadata; | |
| } | |
| export interface ResolvedPaths { | |
| extensions: ResolvedResource[]; | |
| skills: ResolvedResource[]; | |
| prompts: ResolvedResource[]; | |
| themes: ResolvedResource[]; | |
| } | |
| export type MissingSourceAction = "install" | "skip" | "error"; | |
| export interface ProgressEvent { | |
| type: "start" | "progress" | "complete" | "error"; | |
| action: "install" | "remove" | "update" | "clone" | "pull"; | |
| source: string; | |
| message?: string; | |
| } | |
| export type ProgressCallback = (event: ProgressEvent) => void; | |
| export interface PackageUpdate { | |
| source: string; | |
| displayName: string; | |
| type: "npm" | "git"; | |
| scope: Exclude<SourceScope, "temporary">; | |
| } | |
| export interface ConfiguredPackage { | |
| source: string; | |
| scope: "user" | "project"; | |
| filtered: boolean; | |
| installedPath?: string; | |
| } | |
| export interface PackageManager { | |
| resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>; | |
| install(source: string, options?: { local?: boolean }): Promise<void>; | |
| installAndPersist(source: string, options?: { local?: boolean }): Promise<void>; | |
| remove(source: string, options?: { local?: boolean }): Promise<void>; | |
| removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>; | |
| update(source?: string): Promise<void>; | |
| listConfiguredPackages(): ConfiguredPackage[]; | |
| resolveExtensionSources( | |
| sources: string[], | |
| options?: { local?: boolean; temporary?: boolean }, | |
| ): Promise<ResolvedPaths>; | |
| addSourceToSettings(source: string, options?: { local?: boolean }): boolean; | |
| removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean; | |
| setProgressCallback(callback: ProgressCallback | undefined): void; | |
| getInstalledPath(source: string, scope: "user" | "project"): string | undefined; | |
| } | |
| interface PackageManagerOptions { | |
| cwd: string; | |
| agentDir: string; | |
| settingsManager: SettingsManager; | |
| } | |
| type SourceScope = "user" | "project" | "temporary"; | |
| type NpmSource = { | |
| type: "npm"; | |
| spec: string; | |
| name: string; | |
| version?: string; | |
| range?: string; | |
| pinned: boolean; | |
| }; | |
| type LocalSource = { | |
| type: "local"; | |
| path: string; | |
| }; | |
| type ParsedSource = NpmSource | GitSource | LocalSource; | |
| type InstalledSourceScope = Exclude<SourceScope, "temporary">; | |
| interface ConfiguredUpdateSource { | |
| source: string; | |
| scope: InstalledSourceScope; | |
| } | |
| interface NpmUpdateTarget extends ConfiguredUpdateSource { | |
| parsed: NpmSource; | |
| } | |
| interface GitUpdateTarget extends ConfiguredUpdateSource { | |
| parsed: GitSource; | |
| } | |
| interface ResourceAccumulator { | |
| extensions: Map<string, { metadata: PathMetadata; enabled: boolean }>; | |
| skills: Map<string, { metadata: PathMetadata; enabled: boolean }>; | |
| prompts: Map<string, { metadata: PathMetadata; enabled: boolean }>; | |
| themes: Map<string, { metadata: PathMetadata; enabled: boolean }>; | |
| } | |
| /** | |
| * 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: PathMetadata): number { | |
| if (m.origin === "package") return 4; | |
| const scopeBase = m.scope === "project" ? 0 : 2; | |
| return scopeBase + (m.source === "local" ? 0 : 1); | |
| } | |
| interface PackageFilter { | |
| autoload?: boolean; | |
| extensions?: string[]; | |
| skills?: string[]; | |
| prompts?: string[]; | |
| themes?: string[]; | |
| } | |
| type ResourceType = "extensions" | "skills" | "prompts" | "themes"; | |
| const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"]; | |
| const FILE_PATTERNS: Record<ResourceType, RegExp> = { | |
| extensions: /\.(ts|js)$/, | |
| skills: /\.md$/, | |
| prompts: /\.md$/, | |
| themes: /\.json$/, | |
| }; | |
| const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; | |
| type IgnoreMatcher = ReturnType<typeof ignore>; | |
| function toPosixPath(p: string): string { | |
| return p.split(sep).join("/"); | |
| } | |
| function getHomeDir(): string { | |
| return process.env.HOME || homedir(); | |
| } | |
| export function getExtensionTempFolder(agentDir: string): string { | |
| const tempFolder = join(agentDir, "tmp", "extensions"); | |
| mkdirSync(tempFolder, { recursive: true, mode: 0o700 }); | |
| chmodSync(tempFolder, 0o700); | |
| return tempFolder; | |
| } | |
| function prefixIgnorePattern(line: string, prefix: string): string | null { | |
| 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: IgnoreMatcher, dir: string, rootDir: string): void { | |
| 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): line is string => Boolean(line)); | |
| if (patterns.length > 0) { | |
| ig.add(patterns); | |
| } | |
| } catch {} | |
| } | |
| } | |
| function isPattern(s: string): boolean { | |
| return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?"); | |
| } | |
| function isOverridePattern(s: string): boolean { | |
| return s.startsWith("!") || s.startsWith("+") || s.startsWith("-"); | |
| } | |
| function hasGlobPattern(s: string): boolean { | |
| return s.includes("*") || s.includes("?"); | |
| } | |
| /** Glob entries discover visible paths; exact entries can target dot paths or symlinked trees. */ | |
| function expandPackageGlob(pattern: string, root: string): string[] { | |
| return globSync(pattern, { cwd: root }) | |
| .map((match) => resolve(root, match)) | |
| .filter((path) => | |
| relative(root, path) | |
| .split(sep) | |
| .every((segment) => segment === ".." || !segment.startsWith(".")), | |
| ) | |
| .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); | |
| } | |
| function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } { | |
| const plain: string[] = []; | |
| const patterns: string[] = []; | |
| for (const entry of entries) { | |
| if (isPattern(entry)) { | |
| patterns.push(entry); | |
| } else { | |
| plain.push(entry); | |
| } | |
| } | |
| return { plain, patterns }; | |
| } | |
| function collectFiles( | |
| dir: string, | |
| filePattern: RegExp, | |
| skipNodeModules = true, | |
| ignoreMatcher?: IgnoreMatcher, | |
| rootDir?: string, | |
| ): string[] { | |
| const files: string[] = []; | |
| 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; | |
| } | |
| type SkillDiscoveryMode = "pi" | "agents"; | |
| function collectSkillEntries( | |
| dir: string, | |
| mode: SkillDiscoveryMode, | |
| ignoreMatcher?: IgnoreMatcher, | |
| rootDir?: string, | |
| ): string[] { | |
| const entries: string[] = []; | |
| 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)); | |
| const shouldIncludeMarkdownFile = | |
| isFile && | |
| entry.name.endsWith(".md") && | |
| !ig.ignores(relPath) && | |
| ((mode === "pi" && dir === root) || (mode === "agents" && dir !== root)); | |
| if (shouldIncludeMarkdownFile) { | |
| 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: string, mode: SkillDiscoveryMode): string[] { | |
| return collectSkillEntries(dir, mode); | |
| } | |
| function findGitRepoRoot(startDir: string): string | null { | |
| 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: string): string[] { | |
| const skillDirs: string[] = []; | |
| 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: string): string[] { | |
| const entries: string[] = []; | |
| 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: string): string[] { | |
| const entries: string[] = []; | |
| 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 resolveExtensionEntries(dir: string): string[] | null { | |
| const packageJsonPath = join(dir, "package.json"); | |
| if (existsSync(packageJsonPath)) { | |
| const manifest = readPiManifest(packageJsonPath); | |
| if (manifest?.extensions?.length) { | |
| const entries: string[] = []; | |
| 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: string): string[] { | |
| const entries: string[] = []; | |
| 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: string, resourceType: ResourceType): string[] { | |
| if (resourceType === "skills") { | |
| return collectSkillEntries(dir, "pi"); | |
| } | |
| if (resourceType === "extensions") { | |
| return collectAutoExtensionEntries(dir); | |
| } | |
| return collectFiles(dir, FILE_PATTERNS[resourceType]); | |
| } | |
| function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean { | |
| 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: string): string { | |
| const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern; | |
| return toPosixPath(normalized); | |
| } | |
| function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean { | |
| 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: string[]): string[] { | |
| return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-")); | |
| } | |
| function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean { | |
| 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: string[], patterns: string[], baseDir: string): Set<string> { | |
| const includes: string[] = []; | |
| const excludes: string[] = []; | |
| const forceIncludes: string[] = []; | |
| const forceExcludes: string[] = []; | |
| 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: string[]; | |
| 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); | |
| } | |
| function applyAutoloadDisabledPatterns(allPaths: string[], patterns: string[], baseDir: string): Map<string, boolean> { | |
| const result = new Map<string, boolean>(); | |
| for (const pattern of patterns) { | |
| const target = pattern.slice( | |
| pattern.startsWith("+") || pattern.startsWith("-") || pattern.startsWith("!") ? 1 : 0, | |
| ); | |
| const enabled = !pattern.startsWith("-") && !pattern.startsWith("!"); | |
| const exact = pattern.startsWith("+") || pattern.startsWith("-"); | |
| for (const filePath of allPaths) { | |
| if ( | |
| exact ? matchesAnyExactPattern(filePath, [target], baseDir) : matchesAnyPattern(filePath, [target], baseDir) | |
| ) { | |
| result.set(filePath, enabled); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| export class DefaultPackageManager implements PackageManager { | |
| private cwd: string; | |
| private agentDir: string; | |
| private settingsManager: SettingsManager; | |
| private globalNpmRoot: string | undefined; | |
| private globalNpmRootCommandKey: string | undefined; | |
| private progressCallback: ProgressCallback | undefined; | |
| constructor(options: PackageManagerOptions) { | |
| this.cwd = resolvePath(options.cwd); | |
| this.agentDir = resolvePath(options.agentDir); | |
| this.settingsManager = options.settingsManager; | |
| } | |
| setProgressCallback(callback: ProgressCallback | undefined): void { | |
| this.progressCallback = callback; | |
| } | |
| addSourceToSettings(source: string, options?: { local?: boolean }): boolean { | |
| const scope: SourceScope = 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 matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope)); | |
| if (matchIndex !== -1) { | |
| const existing = currentPackages[matchIndex]; | |
| if (this.getPackageSourceString(existing) === normalizedSource) { | |
| return false; | |
| } | |
| const nextPackages = [...currentPackages]; | |
| nextPackages[matchIndex] = | |
| typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource }; | |
| if (scope === "project") { | |
| this.settingsManager.setProjectPackages(nextPackages); | |
| } else { | |
| this.settingsManager.setPackages(nextPackages); | |
| } | |
| return true; | |
| } | |
| const nextPackages = [...currentPackages, normalizedSource]; | |
| if (scope === "project") { | |
| this.settingsManager.setProjectPackages(nextPackages); | |
| } else { | |
| this.settingsManager.setPackages(nextPackages); | |
| } | |
| return true; | |
| } | |
| removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean { | |
| const scope: SourceScope = 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: string, scope: "user" | "project"): string | undefined { | |
| 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; | |
| } | |
| private emitProgress(event: ProgressEvent): void { | |
| this.progressCallback?.(event); | |
| } | |
| private async withProgress( | |
| action: ProgressEvent["action"], | |
| source: string, | |
| message: string, | |
| operation: () => Promise<void>, | |
| ): Promise<void> { | |
| 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?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> { | |
| 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: Array<{ pkg: PackageSource; scope: SourceScope }> = []; | |
| 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] ?? []) as string[]; | |
| const projectEntries = (projectSettings[resourceType] ?? []) as string[]; | |
| 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: string[], | |
| options?: { local?: boolean; temporary?: boolean }, | |
| ): Promise<ResolvedPaths> { | |
| const accumulator = this.createAccumulator(); | |
| const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user"; | |
| const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); | |
| await this.resolvePackageSources(packageSources, accumulator); | |
| return this.toResolvedPaths(accumulator); | |
| } | |
| listConfiguredPackages(): ConfiguredPackage[] { | |
| const globalSettings = this.settingsManager.getGlobalSettings(); | |
| const projectSettings = this.settingsManager.getProjectSettings(); | |
| const configuredPackages: ConfiguredPackage[] = []; | |
| 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: string, options?: { local?: boolean }): Promise<void> { | |
| const parsed = this.parseSource(source); | |
| const scope: SourceScope = options?.local ? "project" : "user"; | |
| this.assertProjectTrustedForScope(scope); | |
| 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: string, options?: { local?: boolean }): Promise<void> { | |
| await this.install(source, options); | |
| this.addSourceToSettings(source, options); | |
| } | |
| async remove(source: string, options?: { local?: boolean }): Promise<void> { | |
| const parsed = this.parseSource(source); | |
| const scope: SourceScope = options?.local ? "project" : "user"; | |
| this.assertProjectTrustedForScope(scope); | |
| 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: string, options?: { local?: boolean }): Promise<boolean> { | |
| await this.remove(source, options); | |
| return this.removeSourceFromSettings(source, options); | |
| } | |
| async update(source?: string): Promise<void> { | |
| const globalSettings = this.settingsManager.getGlobalSettings(); | |
| const projectSettings = this.settingsManager.getProjectSettings(); | |
| const identity = source ? this.getPackageIdentity(source) : undefined; | |
| let matched = false; | |
| const updateSources: ConfiguredUpdateSource[] = []; | |
| 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); | |
| } | |
| private async updateConfiguredSources(sources: ConfiguredUpdateSource[]): Promise<void> { | |
| if (isOfflineModeEnabled() || sources.length === 0) { | |
| return; | |
| } | |
| const npmCandidates: NpmUpdateTarget[] = []; | |
| const gitCandidates: GitUpdateTarget[] = []; | |
| for (const entry of sources) { | |
| const parsed = this.parseSource(entry.source); | |
| // Pinned npm versions are fixed. Pinned git refs are configured checkout targets, | |
| // so include them to reconcile an existing clone when the configured ref changes. | |
| if (parsed.type === "npm") { | |
| if (!parsed.pinned) { | |
| npmCandidates.push({ ...entry, parsed }); | |
| } | |
| } else if (parsed.type === "git") { | |
| 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: NpmUpdateTarget[] = []; | |
| const projectNpmUpdates: NpmUpdateTarget[] = []; | |
| 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: Promise<void>[] = []; | |
| 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); | |
| } | |
| private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise<boolean> { | |
| const installedPath = this.getManagedNpmInstallPath(source, scope); | |
| const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined; | |
| if (!installedVersion) { | |
| return true; | |
| } | |
| try { | |
| const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); | |
| return gt(targetVersion, installedVersion); | |
| } catch { | |
| // Preserve existing update behavior when version lookup fails. | |
| return true; | |
| } | |
| } | |
| private async updateNpmBatch(sources: NpmUpdateTarget[], scope: InstalledSourceScope): Promise<void> { | |
| 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.version ? entry.parsed.spec : `${entry.parsed.name}@latest`)); | |
| await this.withProgress("update", sourceLabel, message, async () => { | |
| await this.installNpmBatch(specs, scope); | |
| }); | |
| } | |
| private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise<void> { | |
| const installRoot = this.getNpmInstallRoot(scope, false); | |
| this.ensureNpmProject(installRoot); | |
| await this.runNpmCommand(this.getNpmInstallArgs(specs, installRoot)); | |
| } | |
| async checkForAvailableUpdates(): Promise<PackageUpdate[]> { | |
| if (isOfflineModeEnabled()) { | |
| return []; | |
| } | |
| const globalSettings = this.settingsManager.getGlobalSettings(); | |
| const projectSettings = this.settingsManager.getProjectSettings(); | |
| const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; | |
| 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 is { pkg: PackageSource; scope: Exclude<SourceScope, "temporary"> } => | |
| entry.scope !== "temporary", | |
| ) | |
| .map((entry) => async (): Promise<PackageUpdate | undefined> => { | |
| 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 is PackageUpdate => result !== undefined); | |
| } | |
| private async resolvePackageSources( | |
| sources: Array<{ pkg: PackageSource; scope: SourceScope }>, | |
| accumulator: ResourceAccumulator, | |
| onMissing?: (source: string) => Promise<MissingSourceAction>, | |
| ): Promise<void> { | |
| for (const { pkg, scope } of sources) { | |
| const sourceStr = typeof pkg === "string" ? pkg : pkg.source; | |
| const filter = typeof pkg === "object" ? pkg : undefined; | |
| const deltaBase = this.findAutoloadDeltaBase(pkg, scope, sources); | |
| const resolvedSource = deltaBase?.source ?? sourceStr; | |
| const resolvedScope = deltaBase?.scope ?? scope; | |
| const parsed = this.parseSource(resolvedSource); | |
| const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" }; | |
| if (parsed.type === "local") { | |
| const baseDir = this.getBaseDirForScope(resolvedScope); | |
| this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir); | |
| continue; | |
| } | |
| const installMissing = async (): Promise<boolean> => { | |
| if (isOfflineModeEnabled()) return false; | |
| if (!onMissing) { | |
| await this.installParsedSource(parsed, resolvedScope); | |
| return true; | |
| } | |
| const action = await onMissing(resolvedSource); | |
| if (action === "skip") return false; | |
| if (action === "error") throw new Error(`Missing source: ${resolvedSource}`); | |
| await this.installParsedSource(parsed, resolvedScope); | |
| return true; | |
| }; | |
| if (parsed.type === "npm") { | |
| let installedPath = this.getNpmInstallPath(parsed, resolvedScope); | |
| const needsInstall = | |
| !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); | |
| if (needsInstall) { | |
| const installed = await installMissing(); | |
| if (!installed) continue; | |
| installedPath = this.getNpmInstallPath(parsed, resolvedScope); | |
| } | |
| metadata.baseDir = installedPath; | |
| this.collectPackageResources(installedPath, accumulator, filter, metadata); | |
| continue; | |
| } | |
| if (parsed.type === "git") { | |
| const installedPath = this.getGitInstallPath(parsed, resolvedScope); | |
| if (!existsSync(installedPath)) { | |
| const installed = await installMissing(); | |
| if (!installed) continue; | |
| } else if (resolvedScope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) { | |
| await this.refreshTemporaryGitSource(parsed, resolvedSource); | |
| } | |
| metadata.baseDir = installedPath; | |
| this.collectPackageResources(installedPath, accumulator, filter, metadata); | |
| } | |
| } | |
| } | |
| private findAutoloadDeltaBase( | |
| pkg: PackageSource, | |
| scope: SourceScope, | |
| sources: Array<{ pkg: PackageSource; scope: SourceScope }>, | |
| ): { source: string; scope: SourceScope } | undefined { | |
| if (scope !== "project" || typeof pkg !== "object" || pkg.autoload !== false) return undefined; | |
| const identity = this.getPackageIdentity(pkg.source, scope); | |
| const userEntry = sources.find( | |
| (entry) => | |
| entry.scope === "user" && | |
| this.getPackageIdentity(this.getPackageSourceString(entry.pkg), "user") === identity, | |
| ); | |
| return userEntry ? { source: this.getPackageSourceString(userEntry.pkg), scope: "user" } : undefined; | |
| } | |
| private resolveLocalExtensionSource( | |
| source: LocalSource, | |
| accumulator: ResourceAccumulator, | |
| filter: PackageFilter | undefined, | |
| metadata: PathMetadata, | |
| baseDir: string, | |
| ): void { | |
| 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; | |
| } | |
| } | |
| private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> { | |
| if (parsed.type === "npm") { | |
| await this.installNpm(parsed, scope, scope === "temporary"); | |
| return; | |
| } | |
| if (parsed.type === "git") { | |
| await this.installGit(parsed, scope); | |
| return; | |
| } | |
| } | |
| private getPackageSourceString(pkg: PackageSource): string { | |
| return typeof pkg === "string" ? pkg : pkg.source; | |
| } | |
| private getSourceMatchKeyForInput(source: string): string { | |
| 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)}`; | |
| } | |
| private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string { | |
| 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)}`; | |
| } | |
| private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string { | |
| 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}?`; | |
| } | |
| private findSuggestedConfiguredSource(source: string, configuredPackages: PackageSource[]): string | undefined { | |
| const trimmedSource = source.trim(); | |
| const suggestions = new Set<string>(); | |
| 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; | |
| } | |
| private packageSourcesMatch(existing: PackageSource, inputSource: string, scope: SourceScope): boolean { | |
| const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope); | |
| const right = this.getSourceMatchKeyForInput(inputSource); | |
| return left === right; | |
| } | |
| private normalizePackageSourceForSettings(source: string, scope: SourceScope): string { | |
| 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 || "."; | |
| } | |
| private parseSource(source: string): ParsedSource { | |
| if (source.startsWith("npm:")) { | |
| const spec = source.slice("npm:".length).trim(); | |
| const { name, version } = this.parseNpmSpec(spec); | |
| return { | |
| type: "npm", | |
| spec, | |
| name, | |
| version, | |
| range: getNpmVersionRange(version), | |
| pinned: isExactNpmVersion(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 }; | |
| } | |
| private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise<boolean> { | |
| const installedVersion = this.getInstalledNpmVersion(installedPath); | |
| if (!installedVersion) { | |
| return false; | |
| } | |
| return source.range ? satisfies(installedVersion, source.range) : true; | |
| } | |
| private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise<boolean> { | |
| if (isOfflineModeEnabled()) { | |
| return false; | |
| } | |
| const installedVersion = this.getInstalledNpmVersion(installedPath); | |
| if (!installedVersion) { | |
| return false; | |
| } | |
| try { | |
| const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); | |
| return gt(targetVersion, installedVersion); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| private getInstalledNpmVersion(installedPath: string): string | undefined { | |
| const packageJsonPath = join(installedPath, "package.json"); | |
| if (!existsSync(packageJsonPath)) return undefined; | |
| try { | |
| const content = readFileSync(packageJsonPath, "utf-8"); | |
| const pkg = JSON.parse(stripBom(content)) as { version?: string }; | |
| return pkg.version; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| private async getLatestNpmVersion(packageSpec: string, range?: string): Promise<string> { | |
| const npmCommand = this.getNpmCommand(); | |
| const stdout = await this.runCommandCapture( | |
| npmCommand.command, | |
| [...npmCommand.args, "view", packageSpec, "version", "--json"], | |
| { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS }, | |
| ); | |
| const raw = stdout.trim(); | |
| if (!raw) throw new Error("Empty response from npm view"); | |
| const parsed = JSON.parse(raw) as unknown; | |
| if (typeof parsed === "string") { | |
| return parsed; | |
| } | |
| if (Array.isArray(parsed)) { | |
| const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0); | |
| const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0]; | |
| if (latest) return latest; | |
| } | |
| throw new Error("Unexpected response from npm view"); | |
| } | |
| private async gitHasAvailableUpdate(installedPath: string): Promise<boolean> { | |
| 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; | |
| } | |
| } | |
| private async getRemoteGitHead(installedPath: string): Promise<string> { | |
| 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]; | |
| } | |
| private async getLocalGitUpdateTarget( | |
| installedPath: string, | |
| ): Promise<{ ref: string; head: string; fetchArgs: string[] }> { | |
| 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"], | |
| }; | |
| } | |
| } | |
| private async getGitUpstreamRef(installedPath: string): Promise<string | undefined> { | |
| 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; | |
| } | |
| } | |
| private runGitRemoteCommand(installedPath: string, args: string[]): Promise<string> { | |
| return this.runCommandCapture("git", args, { | |
| cwd: installedPath, | |
| timeoutMs: NETWORK_TIMEOUT_MS, | |
| env: { | |
| GIT_TERMINAL_PROMPT: "0", | |
| }, | |
| }); | |
| } | |
| private async runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> { | |
| if (tasks.length === 0) { | |
| return []; | |
| } | |
| const results: T[] = 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. | |
| */ | |
| private getPackageIdentity(source: string, scope?: SourceScope): string { | |
| 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(parsed.path)}`; | |
| } | |
| /** | |
| * Dedupe packages: if same package identity appears in both global and project, | |
| * keep only the project one (project wins). A project entry with autoload=false | |
| * is a delta over the global entry, so both are kept (delta first). | |
| */ | |
| private dedupePackages( | |
| packages: Array<{ pkg: PackageSource; scope: SourceScope }>, | |
| ): Array<{ pkg: PackageSource; scope: SourceScope }> { | |
| const result: Array<{ pkg: PackageSource; scope: SourceScope }> = []; | |
| const seen = new Map<string, number>(); | |
| for (const entry of packages) { | |
| const identity = this.getPackageIdentity(this.getPackageSourceString(entry.pkg), entry.scope); | |
| const index = seen.get(identity); | |
| if (index === undefined) { | |
| seen.set(identity, result.length); | |
| result.push(entry); | |
| continue; | |
| } | |
| const existing = result[index]; | |
| if (existing?.scope === "project" && entry.scope === "user") { | |
| if (typeof existing.pkg === "object" && existing.pkg.autoload === false) result.push(entry); | |
| } else if (entry.scope === "project") { | |
| result[index] = entry; | |
| } | |
| } | |
| return result; | |
| } | |
| private parseNpmSpec(spec: string): { name: string; version?: string } { | |
| const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); | |
| if (!match) { | |
| return { name: spec }; | |
| } | |
| const name = match[1] ?? spec; | |
| const version = match[2]; | |
| return { name, version }; | |
| } | |
| private assertProjectTrustedForScope(scope: SourceScope): void { | |
| if (scope === "project" && !this.settingsManager.isProjectTrusted()) { | |
| throw new Error("Project is not trusted; refusing to access project package storage"); | |
| } | |
| } | |
| private getNpmCommand(): { command: string; args: string[] } { | |
| const configuredCommand = this.settingsManager.getNpmCommand(); | |
| if (!configuredCommand || configuredCommand.length === 0) { | |
| return { command: "npm", args: [] }; | |
| } | |
| const [command, ...args] = configuredCommand; | |
| if (!command) { | |
| throw new Error("Invalid npmCommand: first array entry must be a non-empty command"); | |
| } | |
| return { command, args }; | |
| } | |
| private getPackageManagerName(): string { | |
| const npmCommand = this.getNpmCommand(); | |
| const commandParts = [npmCommand.command, ...npmCommand.args]; | |
| const separatorIndex = commandParts.lastIndexOf("--"); | |
| const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command; | |
| return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : ""; | |
| } | |
| private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise<void> { | |
| const npmCommand = this.getNpmCommand(); | |
| await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options); | |
| } | |
| private getGitDependencyInstallArgs(): string[] { | |
| const configuredCommand = this.settingsManager.getNpmCommand(); | |
| if (configuredCommand && configuredCommand.length > 0) { | |
| return ["install"]; | |
| } | |
| return ["install", "--omit=dev"]; | |
| } | |
| private runNpmCommandSync(args: string[]): string { | |
| const npmCommand = this.getNpmCommand(); | |
| return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]); | |
| } | |
| private getNpmInstallArgs(specs: string[], installRoot: string): string[] { | |
| const packageManagerName = this.getPackageManagerName(); | |
| // Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules. | |
| // Disable peer dependency resolution for managed installs (npm's --legacy-peer-deps, and | |
| // equivalent bun/pnpm settings) so package managers do not install or solve host-provided | |
| // @earendil-works/pi-* peers. Stale auto-installed pi peers can otherwise block updates. | |
| if (packageManagerName === "bun") { | |
| return ["install", ...specs, "--cwd", installRoot, "--omit=peer"]; | |
| } | |
| if (packageManagerName === "pnpm") { | |
| return [ | |
| "install", | |
| ...specs, | |
| "--prefix", | |
| installRoot, | |
| "--config.auto-install-peers=false", | |
| "--config.strict-peer-dependencies=false", | |
| "--config.strict-dep-builds=false", | |
| ]; | |
| } | |
| return ["install", ...specs, "--prefix", installRoot, "--legacy-peer-deps"]; | |
| } | |
| private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> { | |
| const installRoot = this.getNpmInstallRoot(scope, temporary); | |
| this.ensureNpmProject(installRoot); | |
| await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot)); | |
| } | |
| private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> { | |
| const installRoot = this.getNpmInstallRoot(scope, false); | |
| if (!existsSync(installRoot)) { | |
| return; | |
| } | |
| const packageManagerName = this.getPackageManagerName(); | |
| if (packageManagerName === "bun") { | |
| await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]); | |
| return; | |
| } | |
| const args = ["uninstall", source.name, "--prefix", installRoot]; | |
| if (packageManagerName !== "pnpm") { | |
| args.push("--legacy-peer-deps"); | |
| } | |
| await this.runNpmCommand(args); | |
| } | |
| private async installGit(source: GitSource, scope: SourceScope): Promise<void> { | |
| const targetDir = this.getGitInstallPath(source, scope); | |
| if (existsSync(targetDir)) { | |
| if (source.ref) { | |
| await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD"); | |
| return; | |
| } | |
| const target = await this.getLocalGitUpdateTarget(targetDir); | |
| await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); | |
| return; | |
| } | |
| const gitRoot = this.getGitInstallRoot(scope); | |
| if (gitRoot) { | |
| this.ensureGitIgnore(gitRoot); | |
| } | |
| mkdirSync(dirname(targetDir), { recursive: true }); | |
| rmSync(this.getGitUpdateMarkerPath(targetDir), { force: true }); | |
| try { | |
| await this.runCommand("git", ["clone", source.repo, targetDir]); | |
| if (source.ref) { | |
| await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir }); | |
| } | |
| const packageJsonPath = join(targetDir, "package.json"); | |
| if (existsSync(packageJsonPath)) { | |
| await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); | |
| } | |
| } catch (error) { | |
| rmSync(targetDir, { recursive: true, force: true }); | |
| this.pruneEmptyGitParents(targetDir, gitRoot); | |
| throw error; | |
| } | |
| } | |
| private async updateGit(source: GitSource, scope: SourceScope): Promise<void> { | |
| const targetDir = this.getGitInstallPath(source, scope); | |
| if (!existsSync(targetDir)) { | |
| await this.installGit(source, scope); | |
| return; | |
| } | |
| if (source.ref) { | |
| await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD"); | |
| return; | |
| } | |
| const target = await this.getLocalGitUpdateTarget(targetDir); | |
| await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); | |
| } | |
| private hasMissingGitDependencies(targetDir: string): boolean { | |
| const packageJsonPath = join(targetDir, "package.json"); | |
| if (!existsSync(packageJsonPath)) return false; | |
| try { | |
| const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as { dependencies?: unknown }; | |
| if ( | |
| !manifest.dependencies || | |
| typeof manifest.dependencies !== "object" || | |
| Array.isArray(manifest.dependencies) | |
| ) { | |
| return false; | |
| } | |
| const nodeModulesDir = resolve(targetDir, "node_modules"); | |
| return Object.keys(manifest.dependencies).some((name) => { | |
| const dependencyPath = resolve(nodeModulesDir, name); | |
| if (!dependencyPath.startsWith(`${nodeModulesDir}${sep}`)) return false; | |
| return !existsSync(dependencyPath); | |
| }); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| private async repairMissingGitDependencies(targetDir: string): Promise<void> { | |
| if (!this.hasMissingGitDependencies(targetDir)) return; | |
| await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); | |
| } | |
| private getGitUpdateMarkerPath(targetDir: string): string { | |
| return join(dirname(targetDir), `.${basename(targetDir)}.pi-update-incomplete`); | |
| } | |
| private async cleanAndInstallGitDependencies(targetDir: string, markerPath: string): Promise<void> { | |
| // Clean untracked files (extensions should be pristine). If this fails after | |
| // deleting dependencies, repair them so the existing extension still loads. | |
| try { | |
| await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); | |
| } catch (error) { | |
| await this.repairMissingGitDependencies(targetDir).catch(() => {}); | |
| throw error; | |
| } | |
| const packageJsonPath = join(targetDir, "package.json"); | |
| if (existsSync(packageJsonPath)) { | |
| await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); | |
| } | |
| rmSync(markerPath, { force: true }); | |
| } | |
| private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise<void> { | |
| // Fetch only the ref we will reset to, avoiding unrelated branch/tag noise. | |
| await this.runCommand("git", fetchArgs, { cwd: targetDir }); | |
| const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { | |
| cwd: targetDir, | |
| timeoutMs: NETWORK_TIMEOUT_MS, | |
| }); | |
| const commitRef = `${ref}^{commit}`; | |
| const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], { | |
| cwd: targetDir, | |
| timeoutMs: NETWORK_TIMEOUT_MS, | |
| }); | |
| const markerPath = this.getGitUpdateMarkerPath(targetDir); | |
| if (localHead.trim() === targetHead.trim()) { | |
| if (existsSync(markerPath)) { | |
| await this.cleanAndInstallGitDependencies(targetDir, markerPath); | |
| } else { | |
| await this.repairMissingGitDependencies(targetDir); | |
| } | |
| return; | |
| } | |
| writeFileSync(markerPath, "", "utf-8"); | |
| await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir }); | |
| await this.cleanAndInstallGitDependencies(targetDir, markerPath); | |
| } | |
| private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise<void> { | |
| if (isOfflineModeEnabled()) { | |
| return; | |
| } | |
| try { | |
| await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => { | |
| await this.updateGit(source, "temporary"); | |
| }); | |
| } catch { | |
| // Keep cached temporary checkout if refresh fails. | |
| } | |
| } | |
| private async removeGit(source: GitSource, scope: SourceScope): Promise<void> { | |
| const targetDir = this.getGitInstallPath(source, scope); | |
| rmSync(targetDir, { recursive: true, force: true }); | |
| rmSync(this.getGitUpdateMarkerPath(targetDir), { force: true }); | |
| this.pruneEmptyGitParents(targetDir, this.getGitInstallRoot(scope)); | |
| } | |
| private pruneEmptyGitParents(targetDir: string, installRoot: string | undefined): void { | |
| if (!installRoot) return; | |
| const resolvedRoot = resolve(installRoot); | |
| let current = dirname(targetDir); | |
| while (current.startsWith(resolvedRoot) && current !== resolvedRoot) { | |
| if (!existsSync(current)) { | |
| current = dirname(current); | |
| continue; | |
| } | |
| const entries = readdirSync(current); | |
| if (entries.length > 0) { | |
| break; | |
| } | |
| try { | |
| rmSync(current, { recursive: true, force: true }); | |
| } catch { | |
| break; | |
| } | |
| current = dirname(current); | |
| } | |
| } | |
| private ensureNpmProject(installRoot: string): void { | |
| if (!existsSync(installRoot)) { | |
| mkdirSync(installRoot, { recursive: true }); | |
| } | |
| markPathIgnoredByCloudSync(installRoot); | |
| this.ensureGitIgnore(installRoot); | |
| const packageJsonPath = join(installRoot, "package.json"); | |
| if (!existsSync(packageJsonPath)) { | |
| const pkgJson = { name: "pi-extensions", private: true }; | |
| writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8"); | |
| } | |
| } | |
| private ensureGitIgnore(dir: string): void { | |
| if (!existsSync(dir)) { | |
| mkdirSync(dir, { recursive: true }); | |
| } | |
| const ignorePath = join(dir, ".gitignore"); | |
| if (!existsSync(ignorePath)) { | |
| writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8"); | |
| } | |
| } | |
| private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string { | |
| if (temporary) { | |
| return this.getTemporaryDir("npm"); | |
| } | |
| if (scope === "project") { | |
| this.assertProjectTrustedForScope(scope); | |
| return join(this.cwd, CONFIG_DIR_NAME, "npm"); | |
| } | |
| return join(this.agentDir, "npm"); | |
| } | |
| private getGlobalNpmRoot(): string { | |
| const npmCommand = this.getNpmCommand(); | |
| const commandKey = [npmCommand.command, ...npmCommand.args].join("\0"); | |
| if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) { | |
| return this.globalNpmRoot; | |
| } | |
| if (this.getPackageManagerName() === "bun") { | |
| const binDir = this.runNpmCommandSync(["pm", "bin", "-g"]).trim(); | |
| this.globalNpmRoot = join(dirname(binDir), "install", "global", "node_modules"); | |
| } else { | |
| this.globalNpmRoot = this.runNpmCommandSync(["root", "-g"]).trim(); | |
| } | |
| this.globalNpmRootCommandKey = commandKey; | |
| return this.globalNpmRoot; | |
| } | |
| private getPnpmGlobalPackagePath(packageName: string): string | undefined { | |
| if (this.getPackageManagerName() !== "pnpm") { | |
| return undefined; | |
| } | |
| const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]); | |
| const entries = JSON.parse(output) as Array<{ dependencies?: Record<string, { path?: string }> }>; | |
| for (const entry of entries) { | |
| const path = entry.dependencies?.[packageName]?.path; | |
| if (path) return path; | |
| } | |
| return undefined; | |
| } | |
| private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string { | |
| if (scope === "temporary") { | |
| return join(this.getTemporaryDir("npm"), "node_modules", source.name); | |
| } | |
| if (scope === "project") { | |
| this.assertProjectTrustedForScope(scope); | |
| return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); | |
| } | |
| return join(this.agentDir, "npm", "node_modules", source.name); | |
| } | |
| private getLegacyGlobalNpmInstallPath(source: NpmSource): string | undefined { | |
| try { | |
| return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name); | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { | |
| const managedPath = this.getManagedNpmInstallPath(source, scope); | |
| if (scope !== "user" || existsSync(managedPath)) { | |
| return managedPath; | |
| } | |
| const legacyPath = this.getLegacyGlobalNpmInstallPath(source); | |
| return legacyPath && existsSync(legacyPath) ? legacyPath : managedPath; | |
| } | |
| private getGitInstallPath(source: GitSource, scope: SourceScope): string { | |
| if (scope === "temporary") { | |
| return this.getTemporaryDir(`git-${source.host}`, source.path); | |
| } | |
| const installRoot = this.getGitInstallRoot(scope); | |
| if (!installRoot) { | |
| throw new Error("Missing git install root"); | |
| } | |
| return this.resolveManagedPath(installRoot, source.host, source.path); | |
| } | |
| private getGitInstallRoot(scope: SourceScope): string | undefined { | |
| if (scope === "temporary") { | |
| return undefined; | |
| } | |
| if (scope === "project") { | |
| this.assertProjectTrustedForScope(scope); | |
| return join(this.cwd, CONFIG_DIR_NAME, "git"); | |
| } | |
| return join(this.agentDir, "git"); | |
| } | |
| private getTemporaryDir(prefix: string, suffix?: string): string { | |
| const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix); | |
| const hash = createHash("sha256") | |
| .update(`${prefix}-${suffix ?? ""}`) | |
| .digest("hex") | |
| .slice(0, 8); | |
| return this.resolveManagedPath(root, hash, suffix ?? ""); | |
| } | |
| private resolveManagedPath(root: string, ...parts: string[]): string { | |
| const resolvedRoot = resolve(root); | |
| const resolvedPath = resolve(resolvedRoot, ...parts); | |
| if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) { | |
| throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`); | |
| } | |
| return resolvedPath; | |
| } | |
| private getBaseDirForScope(scope: SourceScope): string { | |
| if (scope === "project") { | |
| this.assertProjectTrustedForScope(scope); | |
| return join(this.cwd, CONFIG_DIR_NAME); | |
| } | |
| if (scope === "user") { | |
| return this.agentDir; | |
| } | |
| return this.cwd; | |
| } | |
| private resolvePath(input: string): string { | |
| return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true }); | |
| } | |
| private resolvePathFromBase(input: string, baseDir: string): string { | |
| return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); | |
| } | |
| private collectPackageResources( | |
| packageRoot: string, | |
| accumulator: ResourceAccumulator, | |
| filter: PackageFilter | undefined, | |
| metadata: PathMetadata, | |
| ): boolean { | |
| if (filter) { | |
| for (const resourceType of RESOURCE_TYPES) { | |
| const patterns = filter[resourceType]; | |
| const target = this.getTargetMap(accumulator, resourceType); | |
| if (filter.autoload === false) { | |
| this.applyPackageDeltaFilter(packageRoot, patterns ?? [], resourceType, target, metadata); | |
| } else if (patterns !== undefined) { | |
| this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata); | |
| } else { | |
| this.collectDefaultResources(packageRoot, resourceType, target, metadata); | |
| } | |
| } | |
| return true; | |
| } | |
| const manifest = readPiManifest(join(packageRoot, "package.json")); | |
| if (manifest) { | |
| for (const resourceType of RESOURCE_TYPES) { | |
| const entries = manifest[resourceType as keyof PiManifest]; | |
| this.addManifestEntries( | |
| entries, | |
| packageRoot, | |
| resourceType, | |
| this.getTargetMap(accumulator, resourceType), | |
| metadata, | |
| ); | |
| } | |
| return true; | |
| } | |
| let hasAnyDir = false; | |
| for (const resourceType of RESOURCE_TYPES) { | |
| const dir = join(packageRoot, resourceType); | |
| if (existsSync(dir)) { | |
| // Collect all files from the directory (all enabled by default) | |
| const files = collectResourceFiles(dir, resourceType); | |
| for (const f of files) { | |
| this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); | |
| } | |
| hasAnyDir = true; | |
| } | |
| } | |
| return hasAnyDir; | |
| } | |
| private collectDefaultResources( | |
| packageRoot: string, | |
| resourceType: ResourceType, | |
| target: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| metadata: PathMetadata, | |
| ): void { | |
| const manifest = readPiManifest(join(packageRoot, "package.json")); | |
| const entries = manifest?.[resourceType as keyof PiManifest]; | |
| if (entries) { | |
| this.addManifestEntries(entries, packageRoot, resourceType, target, metadata); | |
| return; | |
| } | |
| const dir = join(packageRoot, resourceType); | |
| if (existsSync(dir)) { | |
| // Collect all files from the directory (all enabled by default) | |
| const files = collectResourceFiles(dir, resourceType); | |
| for (const f of files) { | |
| this.addResource(target, f, metadata, true); | |
| } | |
| } | |
| } | |
| private applyPackageFilter( | |
| packageRoot: string, | |
| userPatterns: string[], | |
| resourceType: ResourceType, | |
| target: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| metadata: PathMetadata, | |
| ): void { | |
| const { allFiles } = this.collectManifestFiles(packageRoot, resourceType); | |
| if (userPatterns.length === 0) { | |
| // Empty array explicitly disables all resources of this type | |
| for (const f of allFiles) { | |
| this.addResource(target, f, metadata, false); | |
| } | |
| return; | |
| } | |
| // Apply user patterns | |
| const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot); | |
| for (const f of allFiles) { | |
| const enabled = enabledByUser.has(f); | |
| this.addResource(target, f, metadata, enabled); | |
| } | |
| } | |
| private applyPackageDeltaFilter( | |
| packageRoot: string, | |
| userPatterns: string[], | |
| resourceType: ResourceType, | |
| target: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| metadata: PathMetadata, | |
| ): void { | |
| if (userPatterns.length === 0) { | |
| return; | |
| } | |
| const { allFiles } = this.collectManifestFiles(packageRoot, resourceType); | |
| const enabledByUser = applyAutoloadDisabledPatterns(allFiles, userPatterns, packageRoot); | |
| for (const [filePath, enabled] of enabledByUser) { | |
| this.addResource(target, filePath, metadata, enabled); | |
| } | |
| } | |
| /** | |
| * Collect all files from a package for a resource type, applying manifest patterns. | |
| * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files | |
| * that pass the manifest's own patterns. | |
| */ | |
| private collectManifestFiles( | |
| packageRoot: string, | |
| resourceType: ResourceType, | |
| ): { allFiles: string[]; enabledByManifest: Set<string> } { | |
| const manifest = readPiManifest(join(packageRoot, "package.json")); | |
| const entries = manifest?.[resourceType as keyof PiManifest]; | |
| if (entries && entries.length > 0) { | |
| const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType); | |
| const manifestPatterns = entries.filter(isOverridePattern); | |
| const enabledByManifest = | |
| manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles); | |
| return { allFiles: Array.from(enabledByManifest), enabledByManifest }; | |
| } | |
| const conventionDir = join(packageRoot, resourceType); | |
| if (!existsSync(conventionDir)) { | |
| return { allFiles: [], enabledByManifest: new Set() }; | |
| } | |
| const allFiles = collectResourceFiles(conventionDir, resourceType); | |
| return { allFiles, enabledByManifest: new Set(allFiles) }; | |
| } | |
| private addManifestEntries( | |
| entries: string[] | undefined, | |
| root: string, | |
| resourceType: ResourceType, | |
| target: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| metadata: PathMetadata, | |
| ): void { | |
| if (!entries) return; | |
| const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType); | |
| const patterns = entries.filter(isOverridePattern); | |
| const enabledPaths = applyPatterns(allFiles, patterns, root); | |
| for (const f of allFiles) { | |
| if (enabledPaths.has(f)) { | |
| this.addResource(target, f, metadata, true); | |
| } | |
| } | |
| } | |
| private collectFilesFromManifestEntries(entries: string[], root: string, resourceType: ResourceType): string[] { | |
| const sourceEntries = entries.filter((entry) => !isOverridePattern(entry)); | |
| const resolved = sourceEntries.flatMap((entry) => { | |
| if (!hasGlobPattern(entry)) { | |
| return [resolve(root, entry)]; | |
| } | |
| return expandPackageGlob(entry, root); | |
| }); | |
| return this.collectFilesFromPaths(resolved, resourceType); | |
| } | |
| private resolveLocalEntries( | |
| entries: string[], | |
| resourceType: ResourceType, | |
| target: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| metadata: PathMetadata, | |
| baseDir: string, | |
| ): void { | |
| if (entries.length === 0) return; | |
| // Collect all files from plain entries (non-pattern entries) | |
| const { plain, patterns } = splitPatterns(entries); | |
| const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir)); | |
| const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType); | |
| // Determine which files are enabled based on patterns | |
| const enabledPaths = applyPatterns(allFiles, patterns, baseDir); | |
| // Add all files with their enabled state | |
| for (const f of allFiles) { | |
| this.addResource(target, f, metadata, enabledPaths.has(f)); | |
| } | |
| } | |
| private addAutoDiscoveredResources( | |
| accumulator: ResourceAccumulator, | |
| globalSettings: ReturnType<SettingsManager["getGlobalSettings"]>, | |
| projectSettings: ReturnType<SettingsManager["getProjectSettings"]>, | |
| globalBaseDir: string, | |
| projectBaseDir: string, | |
| ): void { | |
| const userMetadata: PathMetadata = { | |
| source: "auto", | |
| scope: "user", | |
| origin: "top-level", | |
| baseDir: globalBaseDir, | |
| }; | |
| const projectMetadata: PathMetadata = { | |
| source: "auto", | |
| scope: "project", | |
| origin: "top-level", | |
| baseDir: projectBaseDir, | |
| }; | |
| const userOverrides = { | |
| extensions: (globalSettings.extensions ?? []) as string[], | |
| skills: (globalSettings.skills ?? []) as string[], | |
| prompts: (globalSettings.prompts ?? []) as string[], | |
| themes: (globalSettings.themes ?? []) as string[], | |
| }; | |
| const projectOverrides = { | |
| extensions: (projectSettings.extensions ?? []) as string[], | |
| skills: (projectSettings.skills ?? []) as string[], | |
| prompts: (projectSettings.prompts ?? []) as string[], | |
| themes: (projectSettings.themes ?? []) as string[], | |
| }; | |
| const userDirs = { | |
| extensions: join(globalBaseDir, "extensions"), | |
| skills: join(globalBaseDir, "skills"), | |
| prompts: join(globalBaseDir, "prompts"), | |
| themes: join(globalBaseDir, "themes"), | |
| }; | |
| const projectDirs = { | |
| extensions: join(projectBaseDir, "extensions"), | |
| skills: join(projectBaseDir, "skills"), | |
| prompts: join(projectBaseDir, "prompts"), | |
| themes: join(projectBaseDir, "themes"), | |
| }; | |
| const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); | |
| const projectTrusted = this.settingsManager.isProjectTrusted(); | |
| const projectAgentsSkillDirs = projectTrusted | |
| ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) | |
| : []; | |
| const addResources = ( | |
| resourceType: ResourceType, | |
| paths: string[], | |
| metadata: PathMetadata, | |
| overrides: string[], | |
| baseDir: string, | |
| ) => { | |
| const target = this.getTargetMap(accumulator, resourceType); | |
| for (const path of paths) { | |
| const enabled = isEnabledByOverrides(path, overrides, baseDir); | |
| this.addResource(target, path, metadata, enabled); | |
| } | |
| }; | |
| if (projectTrusted) { | |
| // Project extensions from .pi/ | |
| addResources( | |
| "extensions", | |
| collectAutoExtensionEntries(projectDirs.extensions), | |
| projectMetadata, | |
| projectOverrides.extensions, | |
| projectBaseDir, | |
| ); | |
| // Project skills from .pi/ | |
| addResources( | |
| "skills", | |
| collectAutoSkillEntries(projectDirs.skills, "pi"), | |
| projectMetadata, | |
| projectOverrides.skills, | |
| projectBaseDir, | |
| ); | |
| } | |
| // Project skills from .agents/ (each with its own baseDir) | |
| for (const agentsSkillsDir of projectAgentsSkillDirs) { | |
| const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory | |
| const agentsMetadata: PathMetadata = { | |
| ...projectMetadata, | |
| baseDir: agentsBaseDir, | |
| }; | |
| addResources( | |
| "skills", | |
| collectAutoSkillEntries(agentsSkillsDir, "agents"), | |
| agentsMetadata, | |
| projectOverrides.skills, | |
| agentsBaseDir, | |
| ); | |
| } | |
| if (projectTrusted) { | |
| addResources( | |
| "prompts", | |
| collectAutoPromptEntries(projectDirs.prompts), | |
| projectMetadata, | |
| projectOverrides.prompts, | |
| projectBaseDir, | |
| ); | |
| addResources( | |
| "themes", | |
| collectAutoThemeEntries(projectDirs.themes), | |
| projectMetadata, | |
| projectOverrides.themes, | |
| projectBaseDir, | |
| ); | |
| } | |
| // User extensions from ~/.pi/agent/ | |
| addResources( | |
| "extensions", | |
| collectAutoExtensionEntries(userDirs.extensions), | |
| userMetadata, | |
| userOverrides.extensions, | |
| globalBaseDir, | |
| ); | |
| // User skills from ~/.pi/agent/ | |
| addResources( | |
| "skills", | |
| collectAutoSkillEntries(userDirs.skills, "pi"), | |
| userMetadata, | |
| userOverrides.skills, | |
| globalBaseDir, | |
| ); | |
| // User skills from ~/.agents/ (with its own baseDir) | |
| const userAgentsBaseDir = dirname(userAgentsSkillsDir); | |
| const userAgentsMetadata: PathMetadata = { | |
| ...userMetadata, | |
| baseDir: userAgentsBaseDir, | |
| }; | |
| addResources( | |
| "skills", | |
| collectAutoSkillEntries(userAgentsSkillsDir, "agents"), | |
| userAgentsMetadata, | |
| userOverrides.skills, | |
| userAgentsBaseDir, | |
| ); | |
| addResources( | |
| "prompts", | |
| collectAutoPromptEntries(userDirs.prompts), | |
| userMetadata, | |
| userOverrides.prompts, | |
| globalBaseDir, | |
| ); | |
| addResources( | |
| "themes", | |
| collectAutoThemeEntries(userDirs.themes), | |
| userMetadata, | |
| userOverrides.themes, | |
| globalBaseDir, | |
| ); | |
| } | |
| private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] { | |
| const files: string[] = []; | |
| for (const p of paths) { | |
| if (!existsSync(p)) continue; | |
| try { | |
| const stats = statSync(p); | |
| if (stats.isFile()) { | |
| files.push(p); | |
| } else if (stats.isDirectory()) { | |
| files.push(...collectResourceFiles(p, resourceType)); | |
| } | |
| } catch { | |
| // Ignore errors | |
| } | |
| } | |
| return files; | |
| } | |
| private getTargetMap( | |
| accumulator: ResourceAccumulator, | |
| resourceType: ResourceType, | |
| ): Map<string, { metadata: PathMetadata; enabled: boolean }> { | |
| switch (resourceType) { | |
| case "extensions": | |
| return accumulator.extensions; | |
| case "skills": | |
| return accumulator.skills; | |
| case "prompts": | |
| return accumulator.prompts; | |
| case "themes": | |
| return accumulator.themes; | |
| default: | |
| throw new Error(`Unknown resource type: ${resourceType}`); | |
| } | |
| } | |
| private addResource( | |
| map: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| path: string, | |
| metadata: PathMetadata, | |
| enabled: boolean, | |
| ): void { | |
| if (!path) return; | |
| if (!map.has(path)) { | |
| map.set(path, { metadata, enabled }); | |
| } | |
| } | |
| private createAccumulator(): ResourceAccumulator { | |
| return { | |
| extensions: new Map(), | |
| skills: new Map(), | |
| prompts: new Map(), | |
| themes: new Map(), | |
| }; | |
| } | |
| private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths { | |
| const mapToResolved = ( | |
| entries: Map<string, { metadata: PathMetadata; enabled: boolean }>, | |
| ): ResolvedResource[] => { | |
| const resolved = Array.from(entries.entries()).map(([path, { metadata, enabled }]) => ({ | |
| path, | |
| enabled, | |
| metadata, | |
| })); | |
| resolved.sort((a, b) => resourcePrecedenceRank(a.metadata) - resourcePrecedenceRank(b.metadata)); | |
| const seen = new Set<string>(); | |
| return resolved.filter((entry) => { | |
| const canonicalPath = canonicalizePath(entry.path); | |
| if (seen.has(canonicalPath)) return false; | |
| seen.add(canonicalPath); | |
| return true; | |
| }); | |
| }; | |
| return { | |
| extensions: mapToResolved(accumulator.extensions), | |
| skills: mapToResolved(accumulator.skills), | |
| prompts: mapToResolved(accumulator.prompts), | |
| themes: mapToResolved(accumulator.themes), | |
| }; | |
| } | |
| private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess { | |
| const env = getEnv(); | |
| return spawnProcess(command, args, { | |
| cwd: options?.cwd, | |
| stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", | |
| env, | |
| }); | |
| } | |
| private spawnCaptureCommand( | |
| command: string, | |
| args: string[], | |
| options?: { cwd?: string; env?: Record<string, string> }, | |
| ): ChildProcessByStdio<null, Readable, Readable> { | |
| const baseEnv = getEnv(); | |
| const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv; | |
| return spawnProcess(command, args, { | |
| cwd: options?.cwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| env, | |
| }); | |
| } | |
| private runCommandCapture( | |
| command: string, | |
| args: string[], | |
| options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> }, | |
| ): Promise<string> { | |
| return new Promise((resolvePromise, reject) => { | |
| const child = this.spawnCaptureCommand(command, args, options); | |
| let stdout = ""; | |
| let stderr = ""; | |
| let timedOut = false; | |
| const timeout = | |
| typeof options?.timeoutMs === "number" | |
| ? setTimeout(() => { | |
| timedOut = true; | |
| child.kill(); | |
| }, options.timeoutMs) | |
| : undefined; | |
| child.stdout?.on("data", (data) => { | |
| stdout += data.toString(); | |
| }); | |
| child.stderr?.on("data", (data) => { | |
| stderr += data.toString(); | |
| }); | |
| child.once("error", (error) => { | |
| if (timeout) clearTimeout(timeout); | |
| reject(error); | |
| }); | |
| child.once("close", (code, signal) => { | |
| if (timeout) clearTimeout(timeout); | |
| if (timedOut) { | |
| reject(new Error(`${command} ${args.join(" ")} timed out after ${options?.timeoutMs}ms`)); | |
| return; | |
| } | |
| if (code === 0) { | |
| resolvePromise(stdout.trim()); | |
| return; | |
| } | |
| const exitStatus = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`; | |
| reject(new Error(`${command} ${args.join(" ")} failed with ${exitStatus}: ${stderr || stdout}`)); | |
| }); | |
| }); | |
| } | |
| private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> { | |
| return new Promise((resolvePromise, reject) => { | |
| const child = this.spawnCommand(command, args, options); | |
| child.on("error", reject); | |
| child.on("exit", (code) => { | |
| if (code === 0) { | |
| resolvePromise(); | |
| } else { | |
| reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); | |
| } | |
| }); | |
| }); | |
| } | |
| private runCommandSync(command: string, args: string[]): string { | |
| const env = getEnv(); | |
| const result = spawnProcessSync(command, args, { | |
| stdio: ["ignore", "pipe", "pipe"], | |
| encoding: "utf-8", | |
| env, | |
| }); | |
| if (result.error || result.status !== 0) { | |
| throw new Error( | |
| `Failed to run ${command} ${args.join(" ")}: ${result.error?.message || result.stderr || result.stdout}`, | |
| ); | |
| } | |
| return (result.stdout || result.stderr || "").trim(); | |
| } | |
| } | |