mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-27 11:05:11 +00:00
Add Windows app storage inspector canvas 🤖🤖🤖 (#2620)
* feat: add Windows app storage inspector canvas * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: address storage inspector review findings * Correction to package and plugin * fix: harden storage inspector cleanup * Harden cleanup operation outcomes * Add select a file or folder path in the result tabs to navigate the treemap to its deepest visible parent folder. * Fixes to ensure selftesst pass --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
5755234c4f
commit
df9116a689
@@ -0,0 +1,74 @@
|
||||
import { analyzeDockerImages } from "./docker-images.mjs";
|
||||
import { analyzeMicrosoftScout } from "./microsoft-scout.mjs";
|
||||
import { analyzeNpmCache, discoverNpmCachePath } from "./npm-cache.mjs";
|
||||
import { analyzeUvCache, discoverUvCachePaths } from "./uv-cache.mjs";
|
||||
import { analyzeVsCodeInsiders } from "./vscode-insiders.mjs";
|
||||
|
||||
export const CUSTOM_ANALYZERS = [
|
||||
{
|
||||
id: "vscode-insiders",
|
||||
name: "VS Code Insiders",
|
||||
description: "Inspect accumulated application versions and identify inactive installations.",
|
||||
analyze: analyzeVsCodeInsiders,
|
||||
},
|
||||
{
|
||||
id: "microsoft-scout",
|
||||
name: "Microsoft Scout",
|
||||
description: "Separate installed application files from user data and regenerable caches.",
|
||||
analyze: analyzeMicrosoftScout,
|
||||
},
|
||||
{
|
||||
id: "docker-images",
|
||||
name: "Docker images",
|
||||
description: "Inspect Docker image usage and managed storage without directly deleting Docker data files.",
|
||||
analyze: analyzeDockerImages,
|
||||
},
|
||||
{
|
||||
id: "npm-cache",
|
||||
name: "npm cache",
|
||||
description: "Inspect npm-managed package cache storage and use supported npm maintenance commands.",
|
||||
analyze: analyzeNpmCache,
|
||||
},
|
||||
{
|
||||
id: "uv-cache",
|
||||
name: "uv cache",
|
||||
description: "Inspect uv-managed Python package cache storage and use supported uv cache commands.",
|
||||
analyze: analyzeUvCache,
|
||||
},
|
||||
];
|
||||
|
||||
export function listCustomAnalyzers() {
|
||||
return CUSTOM_ANALYZERS.map(({ id, name, description }) => ({ id, name, description }));
|
||||
}
|
||||
|
||||
export async function discoverAnalyzerManagedPaths() {
|
||||
const [npmCache, uvPaths] = await Promise.all([
|
||||
discoverNpmCachePath(),
|
||||
discoverUvCachePaths(),
|
||||
]);
|
||||
return [
|
||||
{
|
||||
path: npmCache.path,
|
||||
analyzerId: "npm-cache",
|
||||
name: "npm cache",
|
||||
description: "npm-managed package cache. Use npm cache commands instead of direct file cleanup.",
|
||||
},
|
||||
...uvPaths.map((uvPath) => ({
|
||||
path: uvPath,
|
||||
analyzerId: "uv-cache",
|
||||
name: "uv cache",
|
||||
description: "uv-managed Python package cache. Use uv cache commands instead of direct file cleanup.",
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export async function runCustomAnalyzer(id, result) {
|
||||
const analyzer = CUSTOM_ANALYZERS.find((item) => item.id === id);
|
||||
if (!analyzer) {
|
||||
const error = new Error(`Unknown custom analyzer: ${id}`);
|
||||
error.code = "analyzer_unknown";
|
||||
throw error;
|
||||
}
|
||||
const analysis = await analyzer.analyze(result);
|
||||
return { ...analysis, id: analyzer.id, name: analyzer.name };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { getAnalyzerCommands } from "../core/analyzer-commands.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DOCKER_CLI_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalize(filePath) {
|
||||
return path.resolve(filePath).replaceAll("/", "\\").toLowerCase();
|
||||
}
|
||||
|
||||
function isWithin(candidatePath, parentPath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function getDirectoryAggregate(result, directoryPath) {
|
||||
const normalizedPath = normalize(directoryPath);
|
||||
return result.directories.find((directory) => normalize(directory.path) === normalizedPath);
|
||||
}
|
||||
|
||||
async function describeDirectory(result, id, name, directoryPath, kind) {
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(directoryPath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return undefined;
|
||||
}
|
||||
const aggregate = getDirectoryAggregate(result, directoryPath);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path: directoryPath,
|
||||
kind,
|
||||
bytes: aggregate?.bytes ?? 0,
|
||||
files: aggregate?.files ?? 0,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDockerSize(value) {
|
||||
const match = String(value ?? "").trim().match(/^([\d.]+)\s*(B|KB|MB|GB|TB)?$/i);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
const units = { b: 0, kb: 1, mb: 2, gb: 3, tb: 4 };
|
||||
return Number(match[1]) * Math.pow(1024, units[String(match[2] ?? "B").toLowerCase()] ?? 0);
|
||||
}
|
||||
|
||||
function parseImageRows(stdout) {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((image) => {
|
||||
const repository = String(image.Repository ?? "<none>");
|
||||
const tag = String(image.Tag ?? "<none>");
|
||||
const size = String(image.Size ?? "0 B");
|
||||
const imageId = String(image.ID ?? "");
|
||||
return {
|
||||
id: `docker-image-${imageId || `${repository}-${tag}`}`.replace(/[^a-zA-Z0-9._-]/g, "-"),
|
||||
imageId,
|
||||
repository,
|
||||
tag,
|
||||
size,
|
||||
bytes: parseDockerSize(size),
|
||||
createdAt: String(image.CreatedAt ?? ""),
|
||||
containers: Number.isFinite(Number(image.Containers)) ? Number(image.Containers) : undefined,
|
||||
dangling: repository === "<none>" && tag === "<none>",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectDockerImages() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("docker.exe", [
|
||||
"image",
|
||||
"ls",
|
||||
"--no-trunc",
|
||||
"--format",
|
||||
"{{json .}}",
|
||||
], {
|
||||
windowsHide: true,
|
||||
timeout: DOCKER_CLI_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const images = parseImageRows(stdout);
|
||||
return {
|
||||
status: "available",
|
||||
images,
|
||||
error: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
images: [],
|
||||
error: error.code === "ENOENT"
|
||||
? "Docker CLI was not found. Install or enable Docker Desktop to inspect images."
|
||||
: `Docker image inspection failed: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDockerRoots() {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? "", "AppData", "Local");
|
||||
const programData = process.env.PROGRAMDATA ?? "C:\\ProgramData";
|
||||
return [
|
||||
{
|
||||
id: "docker-desktop-data",
|
||||
name: "Docker Desktop data",
|
||||
path: path.resolve(localAppData, "Docker"),
|
||||
kind: "desktop-data",
|
||||
},
|
||||
{
|
||||
id: "docker-engine-data",
|
||||
name: "Docker Engine data",
|
||||
path: path.resolve(programData, "Docker"),
|
||||
kind: "engine-data",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function analyzeDockerImages(result) {
|
||||
const roots = getDockerRoots();
|
||||
const locations = (await Promise.all(
|
||||
roots.map((root) => describeDirectory(result, root.id, root.name, root.path, root.kind)),
|
||||
)).filter(Boolean);
|
||||
const docker = await inspectDockerImages();
|
||||
const rootPaths = locations.map((location) => location.path);
|
||||
const topFiles = result.largestFiles
|
||||
.filter((file) => rootPaths.some((root) => isWithin(file.path, root)))
|
||||
.sort((left, right) => right.bytes - left.bytes)
|
||||
.slice(0, 20);
|
||||
const imageBytes = docker.images.reduce((total, image) => total + image.bytes, 0);
|
||||
const danglingImages = docker.images.filter((image) => image.dangling);
|
||||
|
||||
return {
|
||||
id: "docker-images",
|
||||
status: locations.length || docker.status === "available" ? docker.status : "not-found",
|
||||
processCount: docker.status === "available" ? 1 : 0,
|
||||
locations,
|
||||
images: docker.images,
|
||||
topFiles,
|
||||
totalBytes: locations.reduce((total, location) => total + location.bytes, 0),
|
||||
imageBytes,
|
||||
danglingImages: danglingImages.length,
|
||||
cleanupItems: [],
|
||||
cleanupCommands: getAnalyzerCommands("docker-images"),
|
||||
message: docker.status === "available"
|
||||
? "Docker images were inspected through the Docker CLI. Use Docker commands or Docker Desktop to remove managed image data."
|
||||
: docker.error,
|
||||
inspectionError: docker.error,
|
||||
warning: "Do not delete Docker layer folders or VHDX files directly. Docker manages these files as a database; use Docker CLI or Docker Desktop so references and layers remain consistent.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PROCESS_SCRIPT = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$rows = @(Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.ExecutablePath -like '*\\Microsoft Scout\\*' -or
|
||||
$_.CommandLine -like '*\\Microsoft Scout\\*'
|
||||
} |
|
||||
Select-Object ProcessId, Name, ExecutablePath)
|
||||
ConvertTo-Json -Compress -Depth 3 -InputObject $rows
|
||||
`;
|
||||
|
||||
function normalize(filePath) {
|
||||
return path.resolve(filePath).replaceAll("/", "\\").toLowerCase();
|
||||
}
|
||||
|
||||
function isWithin(candidatePath, parentPath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function getDirectoryAggregate(result, directoryPath) {
|
||||
const normalizedPath = normalize(directoryPath);
|
||||
return result.directories.find((directory) => normalize(directory.path) === normalizedPath);
|
||||
}
|
||||
|
||||
async function describeDirectory(result, id, name, directoryPath, kind) {
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(directoryPath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
return undefined;
|
||||
}
|
||||
const aggregate = getDirectoryAggregate(result, directoryPath);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path: directoryPath,
|
||||
kind,
|
||||
bytes: aggregate?.bytes ?? 0,
|
||||
files: aggregate?.files ?? 0,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectProcesses() {
|
||||
if (process.platform !== "win32") {
|
||||
return { status: "unsupported", processCount: 0, processes: [] };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync("powershell.exe", [
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-EncodedCommand",
|
||||
Buffer.from(PROCESS_SCRIPT, "utf16le").toString("base64"),
|
||||
], { windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
||||
const parsed = JSON.parse(stdout.trim() || "[]");
|
||||
const processes = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return {
|
||||
status: processes.length ? "running" : "not-running",
|
||||
processCount: processes.length,
|
||||
processes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unknown",
|
||||
processCount: 0,
|
||||
processes: [],
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeMicrosoftScout(result) {
|
||||
const userProfile = process.env.USERPROFILE ?? "";
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(userProfile, "AppData", "Local");
|
||||
const installRoot = path.resolve(localAppData, "Programs", "Clawpilot", "Microsoft Scout");
|
||||
const userDataRoot = path.resolve(userProfile, ".scout");
|
||||
const processInspection = await inspectProcesses();
|
||||
|
||||
const locations = (await Promise.all([
|
||||
describeDirectory(result, "scout-install", "Installed application", installRoot, "application"),
|
||||
describeDirectory(result, "scout-user-data", "User data", userDataRoot, "user-data"),
|
||||
])).filter(Boolean);
|
||||
|
||||
const cleanupLocations = (await Promise.all([
|
||||
describeDirectory(result, "scout-copilot-cache", "Copilot package cache", path.join(userDataRoot, "copilot", "cache"), "cache"),
|
||||
describeDirectory(result, "scout-logs", "Logs", path.join(userDataRoot, "logs"), "logs"),
|
||||
describeDirectory(result, "scout-crashpad", "Crash reports", path.join(userDataRoot, "Crashpad"), "crash-reports"),
|
||||
describeDirectory(result, "scout-temp", "Temporary data", path.join(userDataRoot, "temp"), "temporary"),
|
||||
])).filter(Boolean);
|
||||
|
||||
const canClean = processInspection.status === "not-running";
|
||||
const cleanupItems = cleanupLocations.map((location) => ({
|
||||
...location,
|
||||
entryType: "directory",
|
||||
cleanupEligible: canClean,
|
||||
risk: "low",
|
||||
reason: `${location.name} can be regenerated by Microsoft Scout`,
|
||||
}));
|
||||
const roots = [installRoot, userDataRoot];
|
||||
const topFiles = result.largestFiles
|
||||
.filter((file) => roots.some((root) => isWithin(file.path, root)))
|
||||
.sort((left, right) => right.bytes - left.bytes)
|
||||
.slice(0, 20);
|
||||
|
||||
return {
|
||||
id: "microsoft-scout",
|
||||
status: locations.length ? processInspection.status : "not-found",
|
||||
processCount: processInspection.processCount,
|
||||
processInspectionError: processInspection.error,
|
||||
locations,
|
||||
cleanupItems,
|
||||
topFiles,
|
||||
totalBytes: locations.reduce((total, location) => total + location.bytes, 0),
|
||||
cleanupBytes: cleanupItems.reduce((total, item) => total + item.bytes, 0),
|
||||
message: !locations.length
|
||||
? "Microsoft Scout storage was not found in its standard Windows locations."
|
||||
: processInspection.status === "running"
|
||||
? "Microsoft Scout is running. Close it before cleaning its cache, logs, or temporary data."
|
||||
: processInspection.status === "not-running"
|
||||
? "Microsoft Scout is not running. Regenerable storage locations can be selected for cleanup."
|
||||
: "Microsoft Scout process state could not be verified, so cleanup is disabled.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { getAnalyzerCommands } from "../core/analyzer-commands.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const NPM_CLI_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalize(filePath) {
|
||||
return path.resolve(filePath).replaceAll("/", "\\").toLowerCase();
|
||||
}
|
||||
|
||||
function isWithin(candidatePath, parentPath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function getDirectoryAggregate(result, directoryPath) {
|
||||
const normalizedPath = normalize(directoryPath);
|
||||
return result.directories.find((directory) => normalize(directory.path) === normalizedPath);
|
||||
}
|
||||
|
||||
async function getConfiguredCachePath() {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? "", "AppData", "Local");
|
||||
const defaultPath = path.resolve(localAppData, "npm-cache");
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm.cmd", ["config", "get", "cache"], {
|
||||
windowsHide: true,
|
||||
timeout: NPM_CLI_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const configuredPath = stdout.trim();
|
||||
if (!configuredPath || configuredPath === "undefined") {
|
||||
throw new Error("npm did not return a cache path");
|
||||
}
|
||||
return { path: path.resolve(configuredPath), source: "npm config" };
|
||||
} catch (error) {
|
||||
return {
|
||||
path: defaultPath,
|
||||
source: "Windows default",
|
||||
error: error.code === "ENOENT"
|
||||
? "npm was not found, so the standard Windows npm cache location was checked instead."
|
||||
: `npm cache configuration could not be read: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverNpmCachePath() {
|
||||
return getConfiguredCachePath();
|
||||
}
|
||||
|
||||
async function describeCache(result, cachePath) {
|
||||
try {
|
||||
const cacheStats = await stat(cachePath);
|
||||
if (!cacheStats.isDirectory()) {
|
||||
return undefined;
|
||||
}
|
||||
const aggregate = getDirectoryAggregate(result, cachePath);
|
||||
return {
|
||||
id: "npm-cache",
|
||||
name: "npm cache",
|
||||
path: cachePath,
|
||||
bytes: aggregate?.bytes ?? 0,
|
||||
files: aggregate?.files ?? 0,
|
||||
modifiedAt: cacheStats.mtime.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeNpmCache(result) {
|
||||
const configuredCache = await getConfiguredCachePath();
|
||||
const location = await describeCache(result, configuredCache.path);
|
||||
const topFiles = location
|
||||
? result.largestFiles
|
||||
.filter((file) => isWithin(file.path, location.path))
|
||||
.sort((left, right) => right.bytes - left.bytes)
|
||||
.slice(0, 20)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: "npm-cache",
|
||||
status: location ? "available" : "not-found",
|
||||
location,
|
||||
configuredPath: configuredCache.path,
|
||||
configurationSource: configuredCache.source,
|
||||
configurationError: configuredCache.error,
|
||||
topFiles,
|
||||
totalBytes: location?.bytes ?? 0,
|
||||
cleanupItems: [],
|
||||
cleanupCommands: getAnalyzerCommands("npm-cache"),
|
||||
message: location
|
||||
? "npm owns this opaque cache. Verify it first; clear it only when you need to reclaim disk space."
|
||||
: "The configured npm cache folder was not found. npm creates it as packages are installed.",
|
||||
warning: "Do not delete files inside _cacache manually. npm verifies cache integrity on use and can rebuild the cache when packages are installed again.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { getAnalyzerCommands } from "../core/analyzer-commands.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const UV_CLI_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalize(filePath) {
|
||||
return path.resolve(filePath).replaceAll("/", "\\").toLowerCase();
|
||||
}
|
||||
|
||||
function isWithin(candidatePath, parentPath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function getDirectoryAggregate(result, directoryPath) {
|
||||
const normalizedPath = normalize(directoryPath);
|
||||
return result.directories.find((directory) => normalize(directory.path) === normalizedPath);
|
||||
}
|
||||
|
||||
function getDefaultUvRoot() {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? "", "AppData", "Local");
|
||||
return path.resolve(localAppData, "uv");
|
||||
}
|
||||
|
||||
async function getConfiguredCachePath(defaultCachePath) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("uv.exe", ["cache", "dir"], {
|
||||
windowsHide: true,
|
||||
timeout: UV_CLI_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const configuredPath = stdout.trim();
|
||||
if (!configuredPath) {
|
||||
throw new Error("uv did not return a cache path");
|
||||
}
|
||||
return { path: path.resolve(configuredPath), source: "uv cache dir" };
|
||||
} catch (error) {
|
||||
return {
|
||||
path: defaultCachePath,
|
||||
source: "Windows default",
|
||||
error: error.code === "ENOENT"
|
||||
? "uv was not found, so the standard Windows cache location was checked instead."
|
||||
: `uv cache configuration could not be read: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverUvCachePaths() {
|
||||
const rootPath = getDefaultUvRoot();
|
||||
const configuredCache = await getConfiguredCachePath(path.join(rootPath, "cache"));
|
||||
return [...new Set([rootPath, configuredCache.path])];
|
||||
}
|
||||
|
||||
async function describeDirectory(result, id, name, directoryPath) {
|
||||
try {
|
||||
const directoryStats = await stat(directoryPath);
|
||||
if (!directoryStats.isDirectory()) {
|
||||
return undefined;
|
||||
}
|
||||
const aggregate = getDirectoryAggregate(result, directoryPath);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path: directoryPath,
|
||||
bytes: aggregate?.bytes ?? 0,
|
||||
files: aggregate?.files ?? 0,
|
||||
modifiedAt: directoryStats.mtime.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeUvCache(result) {
|
||||
const rootPath = getDefaultUvRoot();
|
||||
const configuredCache = await getConfiguredCachePath(path.join(rootPath, "cache"));
|
||||
const [root, cache] = await Promise.all([
|
||||
describeDirectory(result, "uv-root", "uv data", rootPath),
|
||||
describeDirectory(result, "uv-cache", "uv cache", configuredCache.path),
|
||||
]);
|
||||
const locations = [root, cache].filter((location, index, all) => (
|
||||
location && all.findIndex((candidate) => candidate?.path === location.path) === index
|
||||
));
|
||||
const analysisRoots = locations.map((location) => location.path);
|
||||
const topFiles = result.largestFiles
|
||||
.filter((file) => analysisRoots.some((rootPath) => isWithin(file.path, rootPath)))
|
||||
.sort((left, right) => right.bytes - left.bytes)
|
||||
.slice(0, 20);
|
||||
const totalBytes = root
|
||||
? root.bytes + (cache && !isWithin(cache.path, root.path) ? cache.bytes : 0)
|
||||
: (cache?.bytes ?? 0);
|
||||
|
||||
return {
|
||||
id: "uv-cache",
|
||||
status: locations.length ? "available" : "not-found",
|
||||
root,
|
||||
cache,
|
||||
locations,
|
||||
configuredCachePath: configuredCache.path,
|
||||
configurationSource: configuredCache.source,
|
||||
configurationError: configuredCache.error,
|
||||
topFiles,
|
||||
totalBytes,
|
||||
cacheBytes: cache?.bytes ?? 0,
|
||||
cleanupItems: [],
|
||||
cleanupCommands: getAnalyzerCommands("uv-cache"),
|
||||
message: locations.length
|
||||
? "uv manages this cache as append-only storage. Use uv cache commands instead of modifying its files directly."
|
||||
: "uv storage was not found in its standard Windows location. uv creates cache storage as packages and Python versions are used.",
|
||||
warning: "Do not delete files or directories inside uv's cache directly. uv coordinates concurrent access and locks cache-modifying operations.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const VERSION_FOLDER_PATTERN = /^[a-f0-9]{10}$/i;
|
||||
const PROCESS_SCRIPT = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$rows = @(Get-CimInstance Win32_Process -Filter "Name = 'Code - Insiders.exe'" | Select-Object ExecutablePath, CommandLine)
|
||||
ConvertTo-Json -Compress -Depth 3 -InputObject $rows
|
||||
`;
|
||||
|
||||
function normalize(filePath) {
|
||||
return path.resolve(filePath).replaceAll("/", "\\").toLowerCase();
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function isWithin(candidatePath, parentPath) {
|
||||
const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath));
|
||||
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function getInstallRoot() {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? "", "AppData", "Local");
|
||||
return path.resolve(localAppData, "Programs", "Microsoft VS Code Insiders");
|
||||
}
|
||||
|
||||
async function getRunningVersionFolders(root) {
|
||||
if (process.platform !== "win32") {
|
||||
return {
|
||||
status: "unsupported",
|
||||
processCount: 0,
|
||||
versionFolders: [],
|
||||
error: "VS Code process inspection is only supported on Windows",
|
||||
};
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("powershell.exe", [
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-EncodedCommand",
|
||||
Buffer.from(PROCESS_SCRIPT, "utf16le").toString("base64"),
|
||||
], { windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
||||
const parsed = JSON.parse(stdout.trim() || "[]");
|
||||
const processes = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const rootPattern = new RegExp(`${escapeRegExp(normalize(root))}\\\\([a-f0-9]{10})\\\\`, "ig");
|
||||
const versionFolders = new Set();
|
||||
for (const process of processes) {
|
||||
const commandLine = String(process.CommandLine ?? "");
|
||||
let match;
|
||||
while ((match = rootPattern.exec(commandLine)) !== null) {
|
||||
versionFolders.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: processes.length > 0 ? "running" : "not-running",
|
||||
processCount: processes.length,
|
||||
versionFolders: [...versionFolders],
|
||||
};
|
||||
}
|
||||
|
||||
function getDirectoryAggregate(result, directoryPath) {
|
||||
const normalizedPath = normalize(directoryPath);
|
||||
return result.directories.find((directory) => normalize(directory.path) === normalizedPath);
|
||||
}
|
||||
|
||||
function getVersionGroupSummary(folders) {
|
||||
const groups = new Map();
|
||||
for (const folder of folders) {
|
||||
const current = groups.get(folder.version) ?? {
|
||||
version: folder.version,
|
||||
folders: 0,
|
||||
bytes: 0,
|
||||
oldest: folder.modifiedAt,
|
||||
newest: folder.modifiedAt,
|
||||
};
|
||||
current.folders += 1;
|
||||
current.bytes += folder.bytes;
|
||||
current.oldest = current.oldest < folder.modifiedAt ? current.oldest : folder.modifiedAt;
|
||||
current.newest = current.newest > folder.modifiedAt ? current.newest : folder.modifiedAt;
|
||||
groups.set(folder.version, current);
|
||||
}
|
||||
return [...groups.values()].sort((left, right) => right.newest.localeCompare(left.newest));
|
||||
}
|
||||
|
||||
export async function analyzeVsCodeInsiders(result) {
|
||||
const root = getInstallRoot();
|
||||
let rootStats;
|
||||
try {
|
||||
rootStats = await stat(root);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return {
|
||||
status: "not-found",
|
||||
root,
|
||||
message: "The standard VS Code Insiders installation folder was not found.",
|
||||
folders: [],
|
||||
versions: [],
|
||||
topFiles: [],
|
||||
recommendations: [],
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!rootStats.isDirectory()) {
|
||||
return {
|
||||
status: "not-found",
|
||||
root,
|
||||
message: "The standard VS Code Insiders installation path is not a folder.",
|
||||
folders: [],
|
||||
versions: [],
|
||||
topFiles: [],
|
||||
recommendations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const folders = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !VERSION_FOLDER_PATTERN.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const folderPath = path.join(root, entry.name);
|
||||
const packagePath = path.join(folderPath, "resources", "app", "package.json");
|
||||
let packageInfo;
|
||||
try {
|
||||
packageInfo = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const folderStats = await stat(folderPath);
|
||||
const aggregate = getDirectoryAggregate(result, folderPath);
|
||||
folders.push({
|
||||
name: entry.name,
|
||||
path: folderPath,
|
||||
version: typeof packageInfo.version === "string" ? packageInfo.version : "Unknown",
|
||||
bytes: aggregate?.bytes ?? 0,
|
||||
files: aggregate?.files ?? 0,
|
||||
modifiedAt: folderStats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
let processInspection;
|
||||
try {
|
||||
processInspection = await getRunningVersionFolders(root);
|
||||
} catch (error) {
|
||||
processInspection = {
|
||||
status: "unknown",
|
||||
processCount: 0,
|
||||
versionFolders: [],
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
const activeFolders = new Set(processInspection.versionFolders.map((name) => name.toLowerCase()));
|
||||
const orderedFolders = folders
|
||||
.map((folder) => ({
|
||||
...folder,
|
||||
id: `vscode-version-${folder.name.toLowerCase()}`,
|
||||
entryType: "directory",
|
||||
active: activeFolders.has(folder.name.toLowerCase()),
|
||||
reviewable: !activeFolders.has(folder.name.toLowerCase()) && processInspection.status === "running",
|
||||
cleanupEligible: !activeFolders.has(folder.name.toLowerCase())
|
||||
&& processInspection.status === "running"
|
||||
&& activeFolders.size > 0,
|
||||
risk: "medium",
|
||||
reason: "Inactive VS Code Insiders application version",
|
||||
}))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
const rootAggregate = getDirectoryAggregate(result, root);
|
||||
const versionBytes = folders.reduce((total, folder) => total + folder.bytes, 0);
|
||||
const inactiveFolders = orderedFolders.filter((folder) => !folder.active);
|
||||
const topFiles = result.largestFiles
|
||||
.filter((file) => isWithin(file.path, root))
|
||||
.sort((left, right) => right.bytes - left.bytes)
|
||||
.slice(0, 20);
|
||||
const recommendations = [];
|
||||
if (processInspection.status === "running" && activeFolders.size > 0) {
|
||||
recommendations.push({
|
||||
kind: "old-installations",
|
||||
risk: "medium",
|
||||
folders: inactiveFolders.length,
|
||||
bytes: inactiveFolders.reduce((total, folder) => total + folder.bytes, 0),
|
||||
message: "The active installation is marked. Inactive version folders can be selected and moved to the Recycle Bin.",
|
||||
});
|
||||
} else if (processInspection.status === "not-running" && folders.length > 0) {
|
||||
recommendations.push({
|
||||
kind: "old-installations",
|
||||
risk: "high",
|
||||
folders: Math.max(0, folders.length - 1),
|
||||
bytes: [...inactiveFolders]
|
||||
.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt))
|
||||
.slice(1)
|
||||
.reduce((total, folder) => total + folder.bytes, 0),
|
||||
message: "No VS Code Insiders process is running, so the active version could not be confirmed. Keep the newest installation until VS Code is opened once, then review older folders.",
|
||||
});
|
||||
} else if (processInspection.status === "unknown") {
|
||||
recommendations.push({
|
||||
kind: "process-check",
|
||||
risk: "high",
|
||||
folders: 0,
|
||||
bytes: 0,
|
||||
message: "The active version could not be verified; do not remove installation folders.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: processInspection.status,
|
||||
root,
|
||||
rootBytes: rootAggregate?.bytes ?? rootStats.size,
|
||||
versionBytes,
|
||||
nonVersionBytes: Math.max(0, (rootAggregate?.bytes ?? 0) - versionBytes),
|
||||
folderCount: folders.length,
|
||||
activeFolders: orderedFolders.filter((folder) => folder.active).map((folder) => folder.name),
|
||||
processCount: processInspection.processCount,
|
||||
processInspectionError: processInspection.error,
|
||||
folders: orderedFolders,
|
||||
versions: getVersionGroupSummary(folders),
|
||||
topFiles,
|
||||
recommendations,
|
||||
message: processInspection.status === "running"
|
||||
? "VS Code Insiders is running. Installation folders marked active are in use."
|
||||
: processInspection.status === "not-running"
|
||||
? "VS Code Insiders is not running; the current installation could not be confirmed."
|
||||
: "The VS Code Insiders process state could not be verified.",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user