Skip to content

Commit 2acce83

Browse files
committed
feat: Clean cache command
1 parent 3f1d72f commit 2acce83

5 files changed

Lines changed: 434 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import chalk from "chalk";
2+
import path from "node:path";
3+
import os from "node:os";
4+
import process from "node:process";
5+
import baseMiddleware from "../middlewares/base.js";
6+
import Configuration from "@ui5/project/config/Configuration";
7+
import {cleanCache} from "@ui5/project/cache/CacheCleanup";
8+
9+
const cacheCommand = {
10+
command: "cache",
11+
describe: "Manage UI5 CLI cache",
12+
middlewares: [baseMiddleware],
13+
handler: handleCache
14+
};
15+
16+
cacheCommand.builder = function(cli) {
17+
return cli
18+
.demandCommand(1, "Command required. Available command is 'clean'")
19+
.command("clean", "Remove all cached UI5 data", {
20+
handler: handleCache,
21+
builder: noop,
22+
middlewares: [baseMiddleware],
23+
})
24+
.example("$0 cache clean",
25+
"Remove all cached UI5 data");
26+
};
27+
28+
function noop() {}
29+
30+
/**
31+
* Format a byte size as a human-readable string.
32+
*
33+
* @param {number} bytes Size in bytes
34+
* @returns {string} Formatted size string
35+
*/
36+
function formatSize(bytes) {
37+
if (bytes < 1024) {
38+
return `${bytes} B`;
39+
} else if (bytes < 1024 * 1024) {
40+
return `${(bytes / 1024).toFixed(1)} KB`;
41+
} else if (bytes < 1024 * 1024 * 1024) {
42+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
43+
}
44+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
45+
}
46+
47+
async function handleCache() {
48+
// Resolve UI5 data directory
49+
let ui5DataDir = process.env.UI5_DATA_DIR;
50+
if (!ui5DataDir) {
51+
const config = await Configuration.fromFile();
52+
ui5DataDir = config.getUi5DataDir();
53+
}
54+
if (ui5DataDir) {
55+
ui5DataDir = path.resolve(process.cwd(), ui5DataDir);
56+
} else {
57+
ui5DataDir = path.join(os.homedir(), ".ui5");
58+
}
59+
60+
const result = await cleanCache({ui5DataDir});
61+
62+
if (result.totalCount === 0) {
63+
process.stderr.write("Nothing to clean\n");
64+
return;
65+
}
66+
67+
for (const entry of result.entries) {
68+
const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : "";
69+
process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`);
70+
}
71+
72+
process.stderr.write(
73+
`\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` +
74+
(result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n"
75+
);
76+
}
77+
78+
export default cacheCommand;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import test from "ava";
2+
import sinon from "sinon";
3+
import esmock from "esmock";
4+
import Configuration from "@ui5/project/config/Configuration";
5+
6+
function getDefaultArgv() {
7+
return {
8+
"_": ["cache", "clean"],
9+
"loglevel": "info",
10+
"log-level": "info",
11+
"logLevel": "info",
12+
"perf": false,
13+
"silent": false,
14+
"$0": "ui5"
15+
};
16+
}
17+
18+
test.beforeEach(async (t) => {
19+
t.context.argv = getDefaultArgv();
20+
21+
t.context.stderrWriteStub = sinon.stub(process.stderr, "write");
22+
23+
t.context.Configuration = Configuration;
24+
sinon.stub(Configuration, "fromFile").resolves(new Configuration({}));
25+
26+
t.context.cleanCacheStub = sinon.stub();
27+
28+
t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", {
29+
"@ui5/project/config/Configuration": t.context.Configuration,
30+
"@ui5/project/cache/CacheCleanup": {
31+
cleanCache: t.context.cleanCacheStub,
32+
},
33+
});
34+
});
35+
36+
test.afterEach.always((t) => {
37+
sinon.restore();
38+
esmock.purge(t.context.cache);
39+
});
40+
41+
test.serial("ui5 cache clean: nothing to clean", async (t) => {
42+
const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context;
43+
44+
cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0});
45+
46+
argv["_"] = ["cache", "clean"];
47+
await cache.handler(argv);
48+
49+
t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n");
50+
});
51+
52+
test.serial("ui5 cache clean: removes entries and reports", async (t) => {
53+
const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context;
54+
55+
cleanCacheStub.resolves({
56+
entries: [
57+
{path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024},
58+
{path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024},
59+
],
60+
totalSize: 23 * 1024 * 1024,
61+
totalCount: 2,
62+
});
63+
64+
argv["_"] = ["cache", "clean"];
65+
await cache.handler(argv);
66+
67+
// Should have 4 writes: 2 entries + 1 newline + summary
68+
t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr");
69+
// Check that summary mentions entries count
70+
const allOutput = stderrWriteStub.args.map((a) => a[0]).join("");
71+
t.true(allOutput.includes("2 entries"), "Summary mentions entry count");
72+
t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size");
73+
});
74+
75+
test("Command definition is correct", (t) => {
76+
// Import without esmock for structure check
77+
t.is(t.context.cache.command, "cache");
78+
t.is(t.context.cache.describe, "Manage UI5 CLI cache");
79+
t.is(typeof t.context.cache.builder, "function");
80+
t.is(typeof t.context.cache.handler, "function");
81+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import path from "node:path";
2+
import fs from "node:fs/promises";
3+
import {DatabaseSync} from "node:sqlite";
4+
5+
/**
6+
* Get the size of a directory tree recursively.
7+
*
8+
* @param {string} dirPath Absolute path to directory
9+
* @returns {Promise<number>} Total size in bytes
10+
*/
11+
async function getDirectorySize(dirPath) {
12+
let total = 0;
13+
let entries;
14+
try {
15+
entries = await fs.readdir(dirPath, {withFileTypes: true});
16+
} catch {
17+
return 0;
18+
}
19+
for (const entry of entries) {
20+
const entryPath = path.join(dirPath, entry.name);
21+
if (entry.isDirectory()) {
22+
total += await getDirectorySize(entryPath);
23+
} else {
24+
try {
25+
const stat = await fs.stat(entryPath);
26+
total += stat.size;
27+
} catch {
28+
// Skip inaccessible files
29+
}
30+
}
31+
}
32+
return total;
33+
}
34+
35+
/**
36+
* Clean a single directory by removing it entirely.
37+
*
38+
* @param {string} dirPath Absolute path to directory
39+
* @param {string} displayPath Path to display in results
40+
* @param {string} type Type of cache entry
41+
* @returns {Promise<Array<{path: string, type: string, size: number}>>} Removed entries
42+
*/
43+
async function cleanDirectory(dirPath, displayPath, type) {
44+
const removed = [];
45+
try {
46+
await fs.access(dirPath);
47+
} catch {
48+
return removed;
49+
}
50+
51+
const size = await getDirectorySize(dirPath);
52+
try {
53+
await fs.rm(dirPath, {recursive: true, force: true});
54+
removed.push({path: displayPath, type, size});
55+
} catch {
56+
// Skip on failure
57+
}
58+
return removed;
59+
}
60+
61+
/**
62+
* Clean build cache directory by clearing all records from the SQLite database.
63+
*
64+
* @param {string} buildCacheDir Path to buildCache/
65+
* @returns {Promise<Array<{path: string, type: string, size: number}>>} Removed entries
66+
*/
67+
async function cleanBuildCache(buildCacheDir) {
68+
const removed = [];
69+
try {
70+
await fs.access(buildCacheDir);
71+
} catch {
72+
return removed;
73+
}
74+
75+
let versionDirs;
76+
try {
77+
versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true});
78+
} catch {
79+
return removed;
80+
}
81+
82+
const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"];
83+
84+
for (const versionDir of versionDirs) {
85+
if (!versionDir.isDirectory()) {
86+
continue;
87+
}
88+
89+
const dbPath = path.join(buildCacheDir, versionDir.name, "cache.db");
90+
try {
91+
await fs.access(dbPath);
92+
} catch {
93+
continue;
94+
}
95+
96+
const statBefore = await fs.stat(dbPath);
97+
const sizeBefore = statBefore.size;
98+
99+
const db = new DatabaseSync(dbPath);
100+
db.exec("BEGIN");
101+
for (const table of tables) {
102+
db.exec(`DELETE FROM ${table}`);
103+
}
104+
db.exec("COMMIT");
105+
db.exec("VACUUM");
106+
db.close();
107+
108+
const statAfter = await fs.stat(dbPath);
109+
const freedSize = sizeBefore - statAfter.size;
110+
111+
removed.push({
112+
path: `buildCache/${versionDir.name}`,
113+
type: "buildCache",
114+
size: freedSize,
115+
});
116+
}
117+
118+
return removed;
119+
}
120+
121+
/**
122+
* Scans the UI5 data directory and removes all cache entries.
123+
*
124+
* @param {object} options
125+
* @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory
126+
* @returns {Promise<{entries: Array<{path: string, type: string, size: number}>,
127+
* totalSize: number, totalCount: number}>}
128+
*/
129+
export async function cleanCache({ui5DataDir}) {
130+
const allRemoved = [];
131+
132+
// Clean framework packages
133+
allRemoved.push(...await cleanDirectory(
134+
path.join(ui5DataDir, "framework", "packages"),
135+
"framework/packages",
136+
"framework"
137+
));
138+
139+
// Clean cacache
140+
allRemoved.push(...await cleanDirectory(
141+
path.join(ui5DataDir, "framework", "cacache"),
142+
"framework/cacache",
143+
"cacache"
144+
));
145+
146+
// Clean build cache (special: clears DB records, not files)
147+
allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache")));
148+
149+
// Clean misc dirs
150+
const miscDirs = [
151+
["framework/staging", "staging"],
152+
["framework/locks", "locks"],
153+
["server", "server"],
154+
];
155+
for (const [rel, type] of miscDirs) {
156+
allRemoved.push(...await cleanDirectory(path.join(ui5DataDir, rel), rel, type));
157+
}
158+
159+
const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0);
160+
return {
161+
entries: allRemoved,
162+
totalSize,
163+
totalCount: allRemoved.length,
164+
};
165+
}

packages/project/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"./graph/ProjectGraph": "./lib/graph/ProjectGraph.js",
3232
"./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js",
3333
"./graph": "./lib/graph/graph.js",
34+
"./cache/CacheCleanup": "./lib/cache/CacheCleanup.js",
3435
"./package.json": "./package.json"
3536
},
3637
"engines": {

0 commit comments

Comments
 (0)