diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..ae4ca03f61c --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -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"; +import {cleanCache} from "@ui5/project/cache/CacheCleanup"; + +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 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; diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js new file mode 100644 index 00000000000..53fb40d1a22 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,81 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import Configuration from "@ui5/project/config/Configuration"; + +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"); + + 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"); +}); diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js new file mode 100644 index 00000000000..63b243c5535 --- /dev/null +++ b/packages/project/lib/cache/CacheCleanup.js @@ -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} 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>} 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>} 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, + }; +} diff --git a/packages/project/package.json b/packages/project/package.json index 78b19acca12..121bff20732 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -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": { diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js new file mode 100644 index 00000000000..04c0ea3e208 --- /dev/null +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -0,0 +1,109 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import {rimraf} from "rimraf"; +import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); + +test.after.always(async () => { + await rimraf(TEST_DIR).catch(() => {}); +}); + +/** + * Create a unique test directory for each test. + * + * @param {object} t AVA test context + * @returns {string} Path to the ui5DataDir fixture + */ +function createTestDir(t) { + const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + t.context.ui5DataDir = dir; + return dir; +} + +/** + * Create a framework package fixture. + * + * @param {string} ui5DataDir Base data directory + * @param {string} scope Package scope (e.g., "@openui5") + * @param {string} name Package name (e.g., "sap.ui.core") + * @param {string} version Version string + * @param {object} [options] + * @param {Date} [options.mtime] Custom mtime for the package file + * @returns {Promise} + */ +async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { + const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); + await fs.mkdir(pkgDir, {recursive: true}); + const filePath = path.join(pkgDir, "package.json"); + await fs.writeFile(filePath, JSON.stringify({name: `${scope}/${name}`, version})); + if (mtime) { + await fs.utimes(filePath, mtime, mtime); + } +} + +// ===== cleanCache: empty/nonexistent dir ===== + +test("cleanCache: returns empty result for nonexistent directory", async (t) => { + const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); + t.is(result.totalCount, 0); + t.is(result.totalSize, 0); + t.deepEqual(result.entries, []); +}); + +// ===== cleanCache: clean all ===== + +test("cleanCache: clean all removes framework packages", async (t) => { + const ui5DataDir = createTestDir(t); + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + await createPackage(ui5DataDir, "@openui5", "sap.m", "1.120.0"); + + const result = await cleanCache({ui5DataDir}); + + t.true(result.totalCount >= 1); + const frameworkEntries = result.entries.filter((e) => e.type === "framework"); + t.is(frameworkEntries.length, 1); + t.is(frameworkEntries[0].path, "framework/packages"); +}); + +// ===== cleanCache: build cache (full clean) ===== + +test("cleanCache: clean all clears buildCache database", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + // Create a real SQLite database with tables and some data + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); + db.exec("INSERT INTO index_cache VALUES ('proj', 'sig', 'source', 'data')"); + db.close(); + + const result = await cleanCache({ui5DataDir}); + + const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); + t.truthy(buildCacheEntry); + + // Verify directory and DB file still exist + await fs.access(buildCacheDir); + await fs.access(dbPath); + + // Verify tables are empty + const dbAfter = new DatabaseSync(dbPath); + const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; + const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; + t.is(contentCount, 0); + t.is(indexCount, 0); + dbAfter.close(); +}); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 305cdbb04b1..4de5b6f6821 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,7 +13,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 13); + t.is(Object.keys(packageJson.exports).length, 14); }); // Public API contract (exported modules)