Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/cli/lib/cli/commands/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import chalk from "chalk";
import path from "node:path";
import os from "node:os";
import process from "node:process";
import baseMiddleware from "../middlewares/base.js";
import Configuration from "@ui5/project/config/Configuration";

const cacheCommand = {
command: "cache",
describe: "Manage UI5 CLI cache",
middlewares: [baseMiddleware],
handler: handleCache
};

cacheCommand.builder = function(cli) {
return cli
.demandCommand(1, "Command required. Available command is 'clean'")
.command("clean", "Remove all cached UI5 data", {
handler: handleCache,
builder: noop,
middlewares: [baseMiddleware],
})
.example("$0 cache clean",
"Remove all cached UI5 data");
};

function noop() {}

/**
* Format a byte size as a human-readable string.
*
* @param {number} bytes Size in bytes
* @returns {string} Formatted size string
*/
function formatSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
} else if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

async function handleCache() {
// Resolve UI5 data directory
let ui5DataDir = process.env.UI5_DATA_DIR;
if (!ui5DataDir) {
const config = await Configuration.fromFile();
ui5DataDir = config.getUi5DataDir();
}
if (ui5DataDir) {
ui5DataDir = path.resolve(process.cwd(), ui5DataDir);
} else {
ui5DataDir = path.join(os.homedir(), ".ui5");
}

const {cleanCache} = await import("@ui5/project/cache/CacheCleanup");
const result = await cleanCache({ui5DataDir});

if (result.totalCount === 0) {
process.stderr.write("Nothing to clean\n");
return;
}

for (const entry of result.entries) {
const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : "";
process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`);
}

process.stderr.write(
`\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` +
(result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n"
);
}

export default cacheCommand;
81 changes: 81 additions & 0 deletions packages/cli/test/lib/cli/commands/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import test from "ava";
import sinon from "sinon";
import esmock from "esmock";

function getDefaultArgv() {
return {
"_": ["cache", "clean"],
"loglevel": "info",
"log-level": "info",
"logLevel": "info",
"perf": false,
"silent": false,
"$0": "ui5"
};
}

test.beforeEach(async (t) => {
t.context.argv = getDefaultArgv();

t.context.stderrWriteStub = sinon.stub(process.stderr, "write");

const {default: Configuration} = await import("@ui5/project/config/Configuration");
t.context.Configuration = Configuration;
sinon.stub(Configuration, "fromFile").resolves(new Configuration({}));

t.context.cleanCacheStub = sinon.stub();

t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", {
"@ui5/project/config/Configuration": t.context.Configuration,
"@ui5/project/cache/CacheCleanup": {
cleanCache: t.context.cleanCacheStub,
},
});
});

test.afterEach.always((t) => {
sinon.restore();
esmock.purge(t.context.cache);
});

test.serial("ui5 cache clean: nothing to clean", async (t) => {
const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context;

cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0});

argv["_"] = ["cache", "clean"];
await cache.handler(argv);

t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n");
});

test.serial("ui5 cache clean: removes entries and reports", async (t) => {
const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context;

cleanCacheStub.resolves({
entries: [
{path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024},
{path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024},
],
totalSize: 23 * 1024 * 1024,
totalCount: 2,
});

argv["_"] = ["cache", "clean"];
await cache.handler(argv);

// Should have 4 writes: 2 entries + 1 newline + summary
t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr");
// Check that summary mentions entries count
const allOutput = stderrWriteStub.args.map((a) => a[0]).join("");
t.true(allOutput.includes("2 entries"), "Summary mentions entry count");
t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size");
});

test("Command definition is correct", (t) => {
// Import without esmock for structure check
t.is(t.context.cache.command, "cache");
t.is(t.context.cache.describe, "Manage UI5 CLI cache");
t.is(typeof t.context.cache.builder, "function");
t.is(typeof t.context.cache.handler, "function");
});
165 changes: 165 additions & 0 deletions packages/project/lib/cache/CacheCleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import path from "node:path";
import fs from "node:fs/promises";
import {DatabaseSync} from "node:sqlite";

/**
* Get the size of a directory tree recursively.
*
* @param {string} dirPath Absolute path to directory
* @returns {Promise<number>} Total size in bytes
*/
async function getDirectorySize(dirPath) {
let total = 0;
let entries;
try {
entries = await fs.readdir(dirPath, {withFileTypes: true});
} catch {
return 0;
}
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
total += await getDirectorySize(entryPath);
} else {
try {
const stat = await fs.stat(entryPath);
total += stat.size;
} catch {
// Skip inaccessible files
}
}
}
return total;
}

/**
* Clean a single directory by removing it entirely.
*
* @param {string} dirPath Absolute path to directory
* @param {string} displayPath Path to display in results
* @param {string} type Type of cache entry
* @returns {Promise<Array<{path: string, type: string, size: number}>>} Removed entries
*/
async function cleanDirectory(dirPath, displayPath, type) {
const removed = [];
try {
await fs.access(dirPath);
} catch {
return removed;
}

const size = await getDirectorySize(dirPath);
try {
await fs.rm(dirPath, {recursive: true, force: true});
removed.push({path: displayPath, type, size});
} catch {
// Skip on failure
}
return removed;
}

/**
* Clean build cache directory by clearing all records from the SQLite database.
*
* @param {string} buildCacheDir Path to buildCache/
* @returns {Promise<Array<{path: string, type: string, size: number}>>} Removed entries
*/
async function cleanBuildCache(buildCacheDir) {
const removed = [];
try {
await fs.access(buildCacheDir);
} catch {
return removed;
}

let versionDirs;
try {
versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true});
} catch {
return removed;
}

const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"];

for (const versionDir of versionDirs) {
if (!versionDir.isDirectory()) {
continue;
}

const dbPath = path.join(buildCacheDir, versionDir.name, "cache.db");
try {
await fs.access(dbPath);
} catch {
continue;
}

const statBefore = await fs.stat(dbPath);
const sizeBefore = statBefore.size;

const db = new DatabaseSync(dbPath);
db.exec("BEGIN");
for (const table of tables) {
db.exec(`DELETE FROM ${table}`);
}
db.exec("COMMIT");
db.exec("VACUUM");
db.close();

const statAfter = await fs.stat(dbPath);
const freedSize = sizeBefore - statAfter.size;

removed.push({
path: `buildCache/${versionDir.name}`,
type: "buildCache",
size: freedSize,
});
}

return removed;
}

/**
* Scans the UI5 data directory and removes all cache entries.
*
* @param {object} options
* @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory
* @returns {Promise<{entries: Array<{path: string, type: string, size: number}>,
* totalSize: number, totalCount: number}>}
*/
export async function cleanCache({ui5DataDir}) {
const allRemoved = [];

// Clean framework packages
allRemoved.push(...await cleanDirectory(
path.join(ui5DataDir, "framework", "packages"),
"framework/packages",
"framework"
));

// Clean cacache
allRemoved.push(...await cleanDirectory(
path.join(ui5DataDir, "framework", "cacache"),
"framework/cacache",
"cacache"
));

// Clean build cache (special: clears DB records, not files)
allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache")));

// Clean misc dirs
const miscDirs = [
["framework/staging", "staging"],
["framework/locks", "locks"],
["server", "server"],
];
for (const [rel, type] of miscDirs) {
allRemoved.push(...await cleanDirectory(path.join(ui5DataDir, rel), rel, type));
}

const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0);
return {
entries: allRemoved,
totalSize,
totalCount: allRemoved.length,
};
}
1 change: 1 addition & 0 deletions packages/project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"./graph/ProjectGraph": "./lib/graph/ProjectGraph.js",
"./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js",
"./graph": "./lib/graph/graph.js",
"./cache/CacheCleanup": "./lib/cache/CacheCleanup.js",
"./package.json": "./package.json"
},
"engines": {
Expand Down
Loading