@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
1,274 lines (1,273 loc) • 86.1 kB
JavaScript
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
function getEnv() {
if (process.platform !== "linux" || Object.keys(process.env).length > 0) {
return process.env;
}
try {
const data = readFileSync("/proc/self/environ", "utf-8");
const env = {};
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 { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver";
import { CONFIG_DIR_NAME } from "../config.js";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.js";
import { parseGitUrl } from "../utils/git.js";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.js";
import { isStdoutTakenOver } from "./output-guard.js";
const NETWORK_TIMEOUT_MS = 10000;
const UPDATE_CHECK_CONCURRENCY = 4;
const GIT_UPDATE_CONCURRENCY = 4;
function isOfflineModeEnabled() {
const value = process.env.PI_OFFLINE;
if (!value)
return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
function isExactNpmVersion(version) {
return valid(version ?? "") !== null;
}
function getNpmVersionRange(version) {
return version ? (validRange(version) ?? undefined) : undefined;
}
/**
* Compute a numeric precedence rank for a resource based on its metadata.
* Lower rank = higher precedence. Used to sort resolved resources so that
* name-collision resolution ("first wins") produces the correct outcome.
*
* Precedence (highest to lowest):
* 0 project + settings entry (source: "local", scope: "project")
* 1 project + auto-discovered (source: "auto", scope: "project")
* 2 user + settings entry (source: "local", scope: "user")
* 3 user + auto-discovered (source: "auto", scope: "user")
* 4 package resource (origin: "package")
*/
function resourcePrecedenceRank(m) {
if (m.origin === "package")
return 4;
const scopeBase = m.scope === "project" ? 0 : 2;
return scopeBase + (m.source === "local" ? 0 : 1);
}
const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"];
const FILE_PATTERNS = {
extensions: /\.(ts|js)$/,
skills: /\.md$/,
prompts: /\.md$/,
themes: /\.json$/,
};
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
function toPosixPath(p) {
return p.split(sep).join("/");
}
function getHomeDir() {
return process.env.HOME || homedir();
}
export function getExtensionTempFolder(agentDir) {
const tempFolder = join(agentDir, "tmp", "extensions");
mkdirSync(tempFolder, { recursive: true, mode: 0o700 });
chmodSync(tempFolder, 0o700);
return tempFolder;
}
function prefixIgnorePattern(line, prefix) {
const trimmed = line.trim();
if (!trimmed)
return null;
if (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))
return null;
let pattern = line;
let negated = false;
if (pattern.startsWith("!")) {
negated = true;
pattern = pattern.slice(1);
}
else if (pattern.startsWith("\\!")) {
pattern = pattern.slice(1);
}
if (pattern.startsWith("/")) {
pattern = pattern.slice(1);
}
const prefixed = prefix ? `${prefix}${pattern}` : pattern;
return negated ? `!${prefixed}` : prefixed;
}
function addIgnoreRules(ig, dir, rootDir) {
const relativeDir = relative(rootDir, dir);
const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = join(dir, filename);
if (!existsSync(ignorePath))
continue;
try {
const content = readFileSync(ignorePath, "utf-8");
const patterns = content
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line) => Boolean(line));
if (patterns.length > 0) {
ig.add(patterns);
}
}
catch { }
}
}
function isPattern(s) {
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?");
}
function isOverridePattern(s) {
return s.startsWith("!") || s.startsWith("+") || s.startsWith("-");
}
function hasGlobPattern(s) {
return s.includes("*") || s.includes("?");
}
function splitPatterns(entries) {
const plain = [];
const patterns = [];
for (const entry of entries) {
if (isPattern(entry)) {
patterns.push(entry);
}
else {
plain.push(entry);
}
}
return { plain, patterns };
}
function collectFiles(dir, filePattern, skipNodeModules = true, ignoreMatcher, rootDir) {
const files = [];
if (!existsSync(dir))
return files;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith("."))
continue;
if (skipNodeModules && entry.name === "node_modules")
continue;
const fullPath = join(dir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
}
catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath))
continue;
if (isDir) {
files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root));
}
else if (isFile && filePattern.test(entry.name)) {
files.push(fullPath);
}
}
}
catch {
// Ignore errors
}
return files;
}
function collectSkillEntries(dir, mode, ignoreMatcher, rootDir) {
const entries = [];
if (!existsSync(dir))
return entries;
const root = rootDir ?? dir;
const ig = ignoreMatcher ?? ignore();
addIgnoreRules(ig, dir, root);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name !== "SKILL.md") {
continue;
}
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
}
catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
if (isFile && !ig.ignores(relPath)) {
entries.push(fullPath);
return entries;
}
}
for (const entry of dirEntries) {
if (entry.name.startsWith("."))
continue;
if (entry.name === "node_modules")
continue;
const fullPath = join(dir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
}
catch {
continue;
}
}
const relPath = toPosixPath(relative(root, fullPath));
if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) {
entries.push(fullPath);
continue;
}
if (!isDir)
continue;
if (ig.ignores(`${relPath}/`))
continue;
entries.push(...collectSkillEntries(fullPath, mode, ig, root));
}
}
catch {
// Ignore errors
}
return entries;
}
function collectAutoSkillEntries(dir, mode) {
return collectSkillEntries(dir, mode);
}
function findGitRepoRoot(startDir) {
let dir = resolve(startDir);
while (true) {
if (existsSync(join(dir, ".git"))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
function collectAncestorAgentsSkillDirs(startDir) {
const skillDirs = [];
const resolvedStartDir = resolve(startDir);
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
let dir = resolvedStartDir;
while (true) {
skillDirs.push(join(dir, ".agents", "skills"));
if (gitRepoRoot && dir === gitRepoRoot) {
break;
}
const parent = dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return skillDirs;
}
function collectAutoPromptEntries(dir) {
const entries = [];
if (!existsSync(dir))
return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith("."))
continue;
if (entry.name === "node_modules")
continue;
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
}
catch {
continue;
}
}
const relPath = toPosixPath(relative(dir, fullPath));
if (ig.ignores(relPath))
continue;
if (isFile && entry.name.endsWith(".md")) {
entries.push(fullPath);
}
}
}
catch {
// Ignore errors
}
return entries;
}
function collectAutoThemeEntries(dir) {
const entries = [];
if (!existsSync(dir))
return entries;
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith("."))
continue;
if (entry.name === "node_modules")
continue;
const fullPath = join(dir, entry.name);
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
isFile = statSync(fullPath).isFile();
}
catch {
continue;
}
}
const relPath = toPosixPath(relative(dir, fullPath));
if (ig.ignores(relPath))
continue;
if (isFile && entry.name.endsWith(".json")) {
entries.push(fullPath);
}
}
}
catch {
// Ignore errors
}
return entries;
}
function readPiManifestFile(packageJsonPath) {
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content);
return pkg.pi ?? null;
}
catch {
return null;
}
}
function resolveExtensionEntries(dir) {
const packageJsonPath = join(dir, "package.json");
if (existsSync(packageJsonPath)) {
const manifest = readPiManifestFile(packageJsonPath);
if (manifest?.extensions?.length) {
const entries = [];
for (const extPath of manifest.extensions) {
const resolvedExtPath = resolve(dir, extPath);
if (existsSync(resolvedExtPath)) {
entries.push(resolvedExtPath);
}
}
if (entries.length > 0) {
return entries;
}
}
}
const indexTs = join(dir, "index.ts");
const indexJs = join(dir, "index.js");
if (existsSync(indexTs)) {
return [indexTs];
}
if (existsSync(indexJs)) {
return [indexJs];
}
return null;
}
function collectAutoExtensionEntries(dir) {
const entries = [];
if (!existsSync(dir))
return entries;
// First check if this directory itself has explicit extension entries (package.json or index)
const rootEntries = resolveExtensionEntries(dir);
if (rootEntries) {
return rootEntries;
}
// Otherwise, discover extensions from directory contents
const ig = ignore();
addIgnoreRules(ig, dir, dir);
try {
const dirEntries = readdirSync(dir, { withFileTypes: true });
for (const entry of dirEntries) {
if (entry.name.startsWith("."))
continue;
if (entry.name === "node_modules")
continue;
const fullPath = join(dir, entry.name);
let isDir = entry.isDirectory();
let isFile = entry.isFile();
if (entry.isSymbolicLink()) {
try {
const stats = statSync(fullPath);
isDir = stats.isDirectory();
isFile = stats.isFile();
}
catch {
continue;
}
}
const relPath = toPosixPath(relative(dir, fullPath));
const ignorePath = isDir ? `${relPath}/` : relPath;
if (ig.ignores(ignorePath))
continue;
if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
entries.push(fullPath);
}
else if (isDir) {
const resolvedEntries = resolveExtensionEntries(fullPath);
if (resolvedEntries) {
entries.push(...resolvedEntries);
}
}
}
}
catch {
// Ignore errors
}
return entries;
}
/**
* Collect resource files from a directory based on resource type.
* Extensions use smart discovery (index.ts in subdirs), others use recursive collection.
*/
function collectResourceFiles(dir, resourceType) {
if (resourceType === "skills") {
return collectSkillEntries(dir, "pi");
}
if (resourceType === "extensions") {
return collectAutoExtensionEntries(dir);
}
return collectFiles(dir, FILE_PATTERNS[resourceType]);
}
function matchesAnyPattern(filePath, patterns, baseDir) {
const rel = toPosixPath(relative(baseDir, filePath));
const name = basename(filePath);
const filePathPosix = toPosixPath(filePath);
const isSkillFile = name === "SKILL.md";
const parentDir = isSkillFile ? dirname(filePath) : undefined;
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir)) : undefined;
const parentName = isSkillFile ? basename(parentDir) : undefined;
const parentDirPosix = isSkillFile ? toPosixPath(parentDir) : undefined;
return patterns.some((pattern) => {
const normalizedPattern = toPosixPath(pattern);
if (minimatch(rel, normalizedPattern) ||
minimatch(name, normalizedPattern) ||
minimatch(filePathPosix, normalizedPattern)) {
return true;
}
if (!isSkillFile)
return false;
return (minimatch(parentRel, normalizedPattern) ||
minimatch(parentName, normalizedPattern) ||
minimatch(parentDirPosix, normalizedPattern));
});
}
function normalizeExactPattern(pattern) {
const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern;
return toPosixPath(normalized);
}
function matchesAnyExactPattern(filePath, patterns, baseDir) {
if (patterns.length === 0)
return false;
const rel = toPosixPath(relative(baseDir, filePath));
const name = basename(filePath);
const filePathPosix = toPosixPath(filePath);
const isSkillFile = name === "SKILL.md";
const parentDir = isSkillFile ? dirname(filePath) : undefined;
const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir)) : undefined;
const parentDirPosix = isSkillFile ? toPosixPath(parentDir) : undefined;
return patterns.some((pattern) => {
const normalized = normalizeExactPattern(pattern);
if (normalized === rel || normalized === filePathPosix) {
return true;
}
if (!isSkillFile)
return false;
return normalized === parentRel || normalized === parentDirPosix;
});
}
function getOverridePatterns(entries) {
return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"));
}
function isEnabledByOverrides(filePath, patterns, baseDir) {
const overrides = getOverridePatterns(patterns);
const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1));
const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1));
let enabled = true;
if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) {
enabled = false;
}
if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
enabled = true;
}
if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {
enabled = false;
}
return enabled;
}
/**
* Apply patterns to paths and return a Set of enabled paths.
* Pattern types:
* - Plain patterns: include matching paths
* - `!pattern`: exclude matching paths
* - `+path`: force-include exact path (overrides exclusions)
* - `-path`: force-exclude exact path (overrides force-includes)
*/
function applyPatterns(allPaths, patterns, baseDir) {
const includes = [];
const excludes = [];
const forceIncludes = [];
const forceExcludes = [];
for (const p of patterns) {
if (p.startsWith("+")) {
forceIncludes.push(p.slice(1));
}
else if (p.startsWith("-")) {
forceExcludes.push(p.slice(1));
}
else if (p.startsWith("!")) {
excludes.push(p.slice(1));
}
else {
includes.push(p);
}
}
// Step 1: Apply includes (or all if no includes)
let result;
if (includes.length === 0) {
result = [...allPaths];
}
else {
result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir));
}
// Step 2: Apply excludes
if (excludes.length > 0) {
result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir));
}
// Step 3: Force-include (add back from allPaths, overriding exclusions)
if (forceIncludes.length > 0) {
for (const filePath of allPaths) {
if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
result.push(filePath);
}
}
}
// Step 4: Force-exclude (remove even if included or force-included)
if (forceExcludes.length > 0) {
result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir));
}
return new Set(result);
}
function applyAutoloadDisabledPatterns(allPaths, patterns, baseDir) {
const result = new Map();
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 {
cwd;
agentDir;
settingsManager;
globalNpmRoot;
globalNpmRootCommandKey;
progressCallback;
constructor(options) {
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager;
}
setProgressCallback(callback) {
this.progressCallback = callback;
}
addSourceToSettings(source, options) {
const scope = options?.local ? "project" : "user";
const currentSettings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const 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, options) {
const scope = options?.local ? "project" : "user";
const currentSettings = scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope));
const changed = nextPackages.length !== currentPackages.length;
if (!changed) {
return false;
}
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
}
else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
getInstalledPath(source, scope) {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
const path = this.getNpmInstallPath(parsed, scope);
return existsSync(path) ? path : undefined;
}
if (parsed.type === "git") {
const path = this.getGitInstallPath(parsed, scope);
return existsSync(path) ? path : undefined;
}
if (parsed.type === "local") {
const baseDir = this.getBaseDirForScope(scope);
const path = this.resolvePathFromBase(parsed.path, baseDir);
return existsSync(path) ? path : undefined;
}
return undefined;
}
emitProgress(event) {
this.progressCallback?.(event);
}
async withProgress(action, source, message, operation) {
this.emitProgress({ type: "start", action, source, message });
try {
await operation();
this.emitProgress({ type: "complete", action, source });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action, source, message: errorMessage });
throw error;
}
}
async resolve(onMissing) {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
// Collect all packages with scope (project first so cwd resources win collisions)
const allPackages = [];
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
}
// Dedupe: project scope wins over global for same package identity
const packageSources = this.dedupePackages(allPackages);
await this.resolvePackageSources(packageSources, accumulator, onMissing);
const globalBaseDir = this.agentDir;
const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME);
for (const resourceType of RESOURCE_TYPES) {
const target = this.getTargetMap(accumulator, resourceType);
const globalEntries = (globalSettings[resourceType] ?? []);
const projectEntries = (projectSettings[resourceType] ?? []);
this.resolveLocalEntries(projectEntries, resourceType, target, {
source: "local",
scope: "project",
origin: "top-level",
}, projectBaseDir);
this.resolveLocalEntries(globalEntries, resourceType, target, {
source: "local",
scope: "user",
origin: "top-level",
}, globalBaseDir);
}
this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir);
return this.toResolvedPaths(accumulator);
}
async resolveExtensionSources(sources, options) {
const accumulator = this.createAccumulator();
const scope = options?.temporary ? "temporary" : options?.local ? "project" : "user";
const packageSources = sources.map((source) => ({ pkg: source, scope }));
await this.resolvePackageSources(packageSources, accumulator);
return this.toResolvedPaths(accumulator);
}
listConfiguredPackages() {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const configuredPackages = [];
for (const pkg of globalSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "user",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "user"),
});
}
for (const pkg of projectSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "project",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "project"),
});
}
return configuredPackages;
}
async install(source, options) {
const parsed = this.parseSource(source);
const scope = options?.local ? "project" : "user";
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, options) {
await this.install(source, options);
this.addSourceToSettings(source, options);
}
async remove(source, options) {
const parsed = this.parseSource(source);
const scope = options?.local ? "project" : "user";
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, options) {
await this.remove(source, options);
return this.removeSourceFromSettings(source, options);
}
async update(source) {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const identity = source ? this.getPackageIdentity(source) : undefined;
let matched = false;
const updateSources = [];
for (const pkg of globalSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, "user") !== identity)
continue;
matched = true;
updateSources.push({ source: sourceStr, scope: "user" });
}
for (const pkg of projectSettings.packages ?? []) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity)
continue;
matched = true;
updateSources.push({ source: sourceStr, scope: "project" });
}
if (source && !matched) {
throw new Error(this.buildNoMatchingPackageMessage(source, [
...(globalSettings.packages ?? []),
...(projectSettings.packages ?? []),
]));
}
await this.updateConfiguredSources(updateSources);
}
async updateConfiguredSources(sources) {
if (isOfflineModeEnabled() || sources.length === 0) {
return;
}
const npmCandidates = [];
const gitCandidates = [];
for (const entry of sources) {
const parsed = this.parseSource(entry.source);
// 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 = [];
const projectNpmUpdates = [];
for (const result of npmCheckResults) {
if (!result.shouldUpdate) {
continue;
}
if (result.entry.scope === "user") {
userNpmUpdates.push(result.entry);
}
else {
projectNpmUpdates.push(result.entry);
}
}
const tasks = [];
if (userNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(userNpmUpdates, "user"));
}
if (projectNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));
}
if (gitCandidates.length > 0) {
const gitTasks = gitCandidates.map((entry) => async () => this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => {
await this.updateGit(entry.parsed, entry.scope);
}));
tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => { }));
}
await Promise.all(tasks);
}
async shouldUpdateNpmSource(source, scope) {
const installedPath = this.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 targetVersion !== installedVersion;
}
catch {
// Preserve existing update behavior when version lookup fails.
return true;
}
}
async updateNpmBatch(sources, scope) {
if (sources.length === 0) {
return;
}
const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`;
const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`;
const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`));
await this.withProgress("update", sourceLabel, message, async () => {
await this.installNpmBatch(specs, scope);
});
}
async installNpmBatch(specs, scope) {
const installRoot = this.getNpmInstallRoot(scope, false);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(this.getNpmInstallArgs(specs, installRoot));
}
async checkForAvailableUpdates() {
if (isOfflineModeEnabled()) {
return [];
}
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const allPackages = [];
for (const pkg of projectSettings.packages ?? []) {
allPackages.push({ pkg, scope: "project" });
}
for (const pkg of globalSettings.packages ?? []) {
allPackages.push({ pkg, scope: "user" });
}
const packageSources = this.dedupePackages(allPackages);
const checks = packageSources
.filter((entry) => entry.scope !== "temporary")
.map((entry) => async () => {
const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source;
const parsed = this.parseSource(source);
if (parsed.type === "local" || parsed.pinned) {
return undefined;
}
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, entry.scope);
if (!existsSync(installedPath)) {
return undefined;
}
const hasUpdate = await this.npmHasAvailableUpdate(parsed, installedPath);
if (!hasUpdate) {
return undefined;
}
return {
source,
displayName: parsed.name,
type: "npm",
scope: entry.scope,
};
}
const installedPath = this.getGitInstallPath(parsed, entry.scope);
if (!existsSync(installedPath)) {
return undefined;
}
const hasUpdate = await this.gitHasAvailableUpdate(installedPath);
if (!hasUpdate) {
return undefined;
}
return {
source,
displayName: `${parsed.host}/${parsed.path}`,
type: "git",
scope: entry.scope,
};
});
const results = await this.runWithConcurrency(checks, UPDATE_CHECK_CONCURRENCY);
return results.filter((result) => result !== undefined);
}
async resolvePackageSources(sources, accumulator, onMissing) {
for (const { pkg, scope } of sources) {
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
const filter = typeof pkg === "object" ? pkg : undefined;
const deltaBase = this.findAutoloadDeltaBase(pkg, scope, sources);
const resolvedSource = deltaBase?.source ?? sourceStr;
const resolvedScope = deltaBase?.scope ?? scope;
const parsed = this.parseSource(resolvedSource);
const metadata = { 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 () => {
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);
}
}
}
findAutoloadDeltaBase(pkg, scope, sources) {
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;
}
resolveLocalExtensionSource(source, accumulator, filter, metadata, baseDir) {
const resolved = this.resolvePathFromBase(source.path, baseDir);
if (!existsSync(resolved)) {
return;
}
try {
const stats = statSync(resolved);
if (stats.isFile()) {
metadata.baseDir = dirname(resolved);
this.addResource(accumulator.extensions, resolved, metadata, true);
return;
}
if (stats.isDirectory()) {
metadata.baseDir = resolved;
const resources = this.collectPackageResources(resolved, accumulator, filter, metadata);
if (!resources) {
this.addResource(accumulator.extensions, resolved, metadata, true);
}
}
}
catch {
return;
}
}
async installParsedSource(parsed, scope) {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, scope === "temporary");
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope);
return;
}
}
getPackageSourceString(pkg) {
return typeof pkg === "string" ? pkg : pkg.source;
}
getSourceMatchKeyForInput(source) {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
return `git:${parsed.host}/${parsed.path}`;
}
return `local:${this.resolvePath(parsed.path)}`;
}
getSourceMatchKeyForSettings(source, scope) {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
return `git:${parsed.host}/${parsed.path}`;
}
const baseDir = this.getBaseDirForScope(scope);
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
}
buildNoMatchingPackageMessage(source, configuredPackages) {
const suggestion = this.findSuggestedConfiguredSource(source, configuredPackages);
if (!suggestion) {
return `No matching package found for ${source}`;
}
return `No matching package found for ${source}. Did you mean ${suggestion}?`;
}
findSuggestedConfiguredSource(source, configuredPackages) {
const trimmedSource = source.trim();
const suggestions = new Set();
for (const pkg of configuredPackages) {
const sourceStr = this.getPackageSourceString(pkg);
const parsed = this.parseSource(sourceStr);
if (parsed.type === "npm") {
if (trimmedSource === parsed.name || trimmedSource === parsed.spec) {
suggestions.add(sourceStr);
}
continue;
}
if (parsed.type === "git") {
const shorthand = `${parsed.host}/${parsed.path}`;
const shorthandWithRef = parsed.ref ? `${shorthand}@${parsed.ref}` : undefined;
if (trimmedSource === shorthand || (shorthandWithRef && trimmedSource === shorthandWithRef)) {
suggestions.add(sourceStr);
}
}
}
return suggestions.values().next().value;
}
packageSourcesMatch(existing, inputSource, scope) {
const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope);
const right = this.getSourceMatchKeyForInput(inputSource);
return left === right;
}
normalizePackageSourceForSettings(source, scope) {
const parsed = this.parseSource(source);
if (parsed.type !== "local") {
return source;
}
const baseDir = this.getBaseDirForScope(scope);
const resolved = this.resolvePath(parsed.path);
const rel = relative(baseDir, resolved);
return rel || ".";
}
parseSource(source) {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const { name, version } = this.parseNpmSpec(spec);
return {
type: "npm",
spec,
name,
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 };
}
async installedNpmMatchesConfiguredVersion(source, installedPath) {
const installedVersion = this.getInstalledNpmVersion(installedPath);
if (!installedVersion) {
return false;
}
return source.range ? satisfies(installedVersion, source.range) : true;
}
async npmHasAvailableUpdate(source, installedPath) {
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 targetVersion !== installedVersion;
}
catch {
return false;
}
}
getInstalledNpmVersion(installedPath) {
const packageJsonPath = join(installedPath, "package.json");
if (!existsSync(packageJsonPath))
return undefined;
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content);
return pkg.version;
}
catch {
return undefined;
}
}
async getLatestNpmVersion(packageSpec, range) {
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);
if (typeof parsed === "string") {
return parsed;
}
if (Array.isArray(parsed)) {
const versions = parsed.filter((value) => 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");
}
async gitHasAvailableUpdate(installedPath) {
if (isOfflineModeEnabled()) {
return false;
}
try {
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const remoteHead = await this.getRemoteGitHead(installedPath);
return localHead.trim() !== remoteHead.trim();
}
catch {
return false;
}
}
async getRemoteGitHead(installedPath) {
const upstreamRef = await this.getGitUpstreamRef(installedPath);
if (upstreamRef) {
const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", upstreamRef]);
const match = remoteHead.match(/^([0-9a-f]{40})\s+/m);
if (match?.[1]) {
return match[1];
}
}
const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", "HEAD"]);
const match = remoteHead.match(/^([0-9a-f]{40})\s+HEAD$/m);
if (!match?.[1]) {
throw new Error("Failed to determine remote HEAD");
}
return match[1];
}
async getLocalGitUpdateTarget(installedPath) {
try {
const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const trimmedUpstream = upstream.trim();
if (!trimmedUpstream.startsWith("origin/")) {
throw new Error(`Unsupported upstream remote: ${trimmedUpstream}`);
}
const branch = trimmedUpstream.slice("origin/".length);
if (!branch) {
throw new Error("Missing upstream branch name");
}
const head = await this.runCommandCapture("git", ["rev-parse", "@{upstream}"], {
cwd: installedPath,
timeoutMs: NETWORK_TIMEOUT_MS,
});
return {
ref: "@{upstream}",
head,