diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 5df7d88285a..c580f835e01 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -1,12 +1,9 @@ import chalk from "chalk"; import path from "node:path"; -import os from "node:os"; import process from "node:process"; import {isLogLevelEnabled} from "@ui5/logger"; import baseMiddleware from "../middlewares/base.js"; -import Configuration from "@ui5/project/config/Configuration"; -import FrameworkCache from "@ui5/project/internal/ui5Framework/cache"; -import CacheManager from "@ui5/project/internal/build/cache/CacheManager"; +import {getUi5DataDirOrDefault, formatPath} from "../../dataDir.js"; import { CACHE_CLEAN_HELP_USAGE, displayCacheCleanWarning, @@ -63,20 +60,6 @@ async function getConfirmation(argv) { }); } -async function resolveCacheUi5DataDir() { - // TODO: Consolidate ui5DataDir resolution once PR #1456 follow-up cleanup is done. - // Keep behavior aligned with existing main-branch resolution order. - let ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - if (ui5DataDir) { - return path.resolve(process.cwd(), ui5DataDir); - } - return path.join(os.homedir(), ".ui5"); -} - function withAbsPath(entries, ui5DataDir) { return entries.map((entry) => { return {...entry, absPath: getAbsPath(ui5DataDir, entry)}; @@ -91,12 +74,18 @@ function getAbsPath(ui5DataDir, cacheEntry) { } async function handleCache(argv) { - const ui5DataDir = await resolveCacheUi5DataDir(); + // Lazy loading to prevent unnecessary imports when the command is not executed + const [{default: FrameworkCache}, {default: CacheManager}] = await Promise.all([ + import("@ui5/project/internal/ui5Framework/cache"), + import("@ui5/project/internal/build/cache/CacheManager"), + ]); + + const ui5DataDir = await getUi5DataDirOrDefault({cwd: process.cwd()}); const isVerbose = isLogLevelEnabled("verbose"); if (isVerbose) { // logger.verbose pollutes output with framework noise. - process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + process.stderr.write(`Checking cache at ${chalk.bold(formatPath(ui5DataDir))} …\n`); } const [frameworkInfo, buildInfo] = await Promise.all([ diff --git a/packages/cli/lib/cli/commands/helpers/cacheOutput.js b/packages/cli/lib/cli/commands/helpers/cacheOutput.js index b6a2544be1d..6295a0437ed 100644 --- a/packages/cli/lib/cli/commands/helpers/cacheOutput.js +++ b/packages/cli/lib/cli/commands/helpers/cacheOutput.js @@ -1,5 +1,6 @@ import chalk from "chalk"; import process from "node:process"; +import {formatPath} from "../../../dataDir.js"; const GROUP_FRAMEWORK = "Framework"; const GROUP_BUILD = "Build"; @@ -47,14 +48,14 @@ function writeCategoryHeader(title) { function writePreviewItem(absPath, detail) { process.stderr.write( - ` ${PREVIEW_MARKER} ${chalk.dim(absPath)}` + + ` ${PREVIEW_MARKER} ${chalk.dim(formatPath(absPath))}` + `${detail ? ` ${ITEM_DIVIDER} ${detail}` : ""}\n` ); } function writeCleanupItem(absPath, detail) { process.stderr.write( - ` ${SUCCESS_MARKER} Removed ${chalk.dim(absPath)}` + + ` ${SUCCESS_MARKER} Removed ${chalk.dim(formatPath(absPath))}` + `${detail ? ` ${ITEM_DIVIDER} ${detail}` : ""}\n` ); } diff --git a/packages/cli/lib/dataDir.js b/packages/cli/lib/dataDir.js new file mode 100644 index 00000000000..11b9c2ea1e3 --- /dev/null +++ b/packages/cli/lib/dataDir.js @@ -0,0 +1,57 @@ +import path from "node:path"; +import os from "node:os"; +import process from "node:process"; +import Configuration from "@ui5/project/config/Configuration"; + +// TODO: This module only consolidates ui5DataDir resolution within the cli package. +// A general, cross-package cleanup of ui5DataDir resolution is tracked in PR #1456. + +/** + * Resolves the UI5 data directory from the UI5_DATA_DIR environment variable or the + * UI5 configuration. The environment variable takes precedence over the configured value. + * + * @param {object} options + * @param {string} options.cwd Directory a relative data-dir value is resolved against + * @returns {Promise} Absolute path to the UI5 data directory, + * or undefined when neither source provides a value + */ +export async function getUi5DataDir({cwd}) { + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + return ui5DataDir ? path.resolve(cwd, ui5DataDir) : undefined; +} + +/** + * Like {@link getUi5DataDir}, but falls back to <home>/.ui5 when neither the + * environment variable nor the configuration provides a value. + * + * @param {object} options + * @param {string} options.cwd Directory a relative data-dir value is resolved against + * @returns {Promise} Absolute path to the UI5 data directory + */ +export async function getUi5DataDirOrDefault({cwd}) { + return (await getUi5DataDir({cwd})) ?? path.join(os.homedir(), ".ui5"); +} + +/** + * Shortens an absolute path for display by replacing the user's home directory with + * ~ (e.g. ~/.ui5). Intended for console and + * error output only — never for values used in actual filesystem operations. + * + * @param {string} filePath Path to format for display + * @returns {string} The path with the home directory replaced by ~, or the + * original path when it does not reside within the home directory + */ +export function formatPath(filePath) { + const home = os.homedir(); + if (filePath === home) { + return "~"; + } + if (filePath.startsWith(home + path.sep)) { + return "~" + filePath.slice(home.length); + } + return filePath; +} diff --git a/packages/cli/lib/framework/utils.js b/packages/cli/lib/framework/utils.js index 799c8a35253..ddbab11ef00 100644 --- a/packages/cli/lib/framework/utils.js +++ b/packages/cli/lib/framework/utils.js @@ -1,6 +1,5 @@ -import path from "node:path"; import {graphFromStaticFile, graphFromPackageDependencies} from "@ui5/project/graph"; -import Configuration from "@ui5/project/config/Configuration"; +import {getUi5DataDir} from "../dataDir.js"; export async function getRootProjectConfiguration(projectGraphOptions) { let graph; @@ -49,16 +48,6 @@ export async function frameworkResolverResolveVersion({frameworkName, frameworkV }); } -async function getUi5DataDir({cwd}) { - // ENV var should take precedence over the dataDir from the configuration. - let ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - return ui5DataDir ? path.resolve(cwd, ui5DataDir) : undefined; -} - const utils = { getRootProjectConfiguration, getFrameworkResolver, diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 2a423cdff4d..713cd4c789d 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -22,8 +22,9 @@ function getDefaultArgv() { }; } -// Stable absolute path used as the resolved ui5DataDir in most tests -const TEST_UI5_DATA_DIR = path.resolve("test-ui5-home"); +// Stable absolute path used as the resolved ui5DataDir in most tests. Anchored outside the home +// directory so path assertions are not affected by the ~ shortening applied to home-dir paths. +const TEST_UI5_DATA_DIR = path.join(path.resolve(path.sep), "test-ui5-home"); // Typical framework stub result shape: { path, libraryCount, versionCount } const FRAMEWORK_STUB = {path: "framework", libraryCount: 18, versionCount: 5}; @@ -45,10 +46,7 @@ test.beforeEach(async (t) => { // Prevent real env var from leaking into tests delete process.env.UI5_DATA_DIR; - t.context.configurationGetUi5DataDirStub = sinon.stub().returns(TEST_UI5_DATA_DIR); - t.context.configurationFromFileStub = sinon.stub().resolves({ - getUi5DataDir: t.context.configurationGetUi5DataDirStub, - }); + t.context.getUi5DataDirOrDefaultStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); @@ -62,11 +60,6 @@ test.beforeEach(async (t) => { t.context.yesnoStub = sinon.stub(); t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { - "@ui5/project/config/Configuration": { - default: { - fromFile: t.context.configurationFromFileStub, - }, - }, "@ui5/project/internal/ui5Framework/cache": { default: class { static getCacheInfo = t.context.frameworkCacheGetCacheInfo; @@ -86,6 +79,10 @@ test.beforeEach(async (t) => { "yesno": { default: t.context.yesnoStub, }, + }, { + "../../../../lib/dataDir.js": { + getUi5DataDirOrDefault: t.context.getUi5DataDirOrDefaultStub + } }); }); @@ -137,9 +134,9 @@ test.serial("Command definition is correct", (t) => { // ─── ui5DataDir resolution ────────────────────────────────────────────────── -test.serial("ui5 cache clean: uses resolved path from configuration", async (t) => { +test.serial("ui5 cache clean: uses resolved path from getUi5DataDirOrDefault", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, - stderrWriteStub, configurationFromFileStub, configurationGetUi5DataDirStub} = t.context; + stderrWriteStub, getUi5DataDirOrDefaultStub} = t.context; frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -148,40 +145,21 @@ test.serial("ui5 cache clean: uses resolved path from configuration", async (t) setLogLevel("verbose"); await cache.handler(argv); - t.is(configurationFromFileStub.callCount, 1, "Configuration.fromFile called exactly once"); - t.is(configurationGetUi5DataDirStub.callCount, 1, "Configuration#getUi5DataDir called exactly once"); + t.is(getUi5DataDirOrDefaultStub.callCount, 1, "getUi5DataDirOrDefault called exactly once"); t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, - "getCacheInfo receives the path returned by configuration"); + "getCacheInfo receives the resolved path"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Resolved ui5DataDir shown in checking line"); }); -test.serial("ui5 cache clean: prefers UI5_DATA_DIR env var over configuration", async (t) => { - const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, - configurationFromFileStub} = t.context; - - const envUi5DataDir = path.resolve("env-ui5-home"); - process.env.UI5_DATA_DIR = envUi5DataDir; - frameworkCacheGetCacheInfo.resolves(null); - buildCacheGetCacheInfo.resolves(null); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(configurationFromFileStub.callCount, 0, - "Configuration.fromFile must not be called when UI5_DATA_DIR is set"); - t.is(frameworkCacheGetCacheInfo.firstCall.args[0], envUi5DataDir, - "getCacheInfo receives value from UI5_DATA_DIR"); -}); - -test.serial("ui5 cache clean: falls back to ~/.ui5 when configuration has no value", async (t) => { +test.serial("ui5 cache clean: uses ~/.ui5 fallback provided by getUi5DataDirOrDefault", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, - stderrWriteStub, configurationGetUi5DataDirStub} = t.context; + stderrWriteStub, getUi5DataDirOrDefaultStub} = t.context; const fallbackUi5DataDir = path.join(os.homedir(), ".ui5"); - configurationGetUi5DataDirStub.returns(undefined); + getUi5DataDirOrDefaultStub.resolves(fallbackUi5DataDir); frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -193,7 +171,10 @@ test.serial("ui5 cache clean: falls back to ~/.ui5 when configuration has no val "getCacheInfo receives default ~/.ui5 path when no configured value exists"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes(fallbackUi5DataDir), "Fallback ui5DataDir shown in checking line"); + const shortenedDataDir = "~" + path.sep + ".ui5"; + t.true(allOutput.includes(shortenedDataDir), + "Fallback ui5DataDir shown with ~ in checking line"); + t.false(allOutput.includes(os.homedir()), "Full home directory is not printed"); }); // ─── Basic flow ───────────────────────────────────────────────────────────── diff --git a/packages/cli/test/lib/dataDir.js b/packages/cli/test/lib/dataDir.js new file mode 100644 index 00000000000..a8ca6c40136 --- /dev/null +++ b/packages/cli/test/lib/dataDir.js @@ -0,0 +1,130 @@ +import test from "ava"; +import sinonGlobal from "sinon"; +import esmock from "esmock"; +import path from "node:path"; +import os from "node:os"; + +test.beforeEach(async (t) => { + // Tests either rely on not having UI5_DATA_DIR defined, or explicitly define it + t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR; + delete process.env.UI5_DATA_DIR; + + const sinon = t.context.sinon = sinonGlobal.createSandbox(); + + t.context.ConfigurationGetUi5DataDirStub = sinon.stub().returns(undefined); + t.context.ConfigurationStub = { + fromFile: sinon.stub().resolves({ + getUi5DataDir: t.context.ConfigurationGetUi5DataDirStub + }) + }; + + t.context.dataDir = await esmock.p("../../lib/dataDir.js", { + "@ui5/project/config/Configuration": t.context.ConfigurationStub + }); +}); + +test.afterEach.always((t) => { + if (typeof t.context.originalUi5DataDirEnv === "undefined") { + delete process.env.UI5_DATA_DIR; + } else { + process.env.UI5_DATA_DIR = t.context.originalUi5DataDirEnv; + } + t.context.sinon.restore(); + esmock.purge(t.context.dataDir); +}); + +test.serial("getUi5DataDir: no value defined", async (t) => { + const {ConfigurationGetUi5DataDirStub, dataDir} = t.context; + + const result = await dataDir.getUi5DataDir({ + cwd: path.resolve("foo") + }); + + t.is(result, undefined); + + t.is(ConfigurationGetUi5DataDirStub.callCount, 1); +}); + +test.serial("getUi5DataDir: from environment variable", async (t) => { + const {ConfigurationGetUi5DataDirStub, dataDir} = t.context; + + // Environment variable must be preferred over configuration value + ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); + process.env.UI5_DATA_DIR = ".ui5-data-dir-from-env-variable"; + + const result = await dataDir.getUi5DataDir({ + cwd: path.resolve("foo") + }); + + t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-env-variable")); + + t.is(ConfigurationGetUi5DataDirStub.callCount, 0); +}); + +test.serial("getUi5DataDir: from Configuration", async (t) => { + const {ConfigurationGetUi5DataDirStub, dataDir} = t.context; + + ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); + + const result = await dataDir.getUi5DataDir({ + cwd: path.resolve("foo") + }); + + t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-configuration")); + + t.is(ConfigurationGetUi5DataDirStub.callCount, 1); +}); + +test.serial("getUi5DataDirOrDefault: returns resolved value when configured", async (t) => { + const {ConfigurationGetUi5DataDirStub, dataDir} = t.context; + + ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); + + const result = await dataDir.getUi5DataDirOrDefault({ + cwd: path.resolve("foo") + }); + + t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-configuration")); +}); + +test.serial("getUi5DataDirOrDefault: falls back to ~/.ui5 when no value defined", async (t) => { + const {dataDir} = t.context; + + const result = await dataDir.getUi5DataDirOrDefault({ + cwd: path.resolve("foo") + }); + + t.is(result, path.join(os.homedir(), ".ui5")); +}); + +test.serial("formatPath: replaces home directory with ~", (t) => { + const {dataDir} = t.context; + + const input = path.join(os.homedir(), ".ui5", "server", "server.key"); + const expected = "~" + path.sep + path.join(".ui5", "server", "server.key"); + + t.is(dataDir.formatPath(input), expected); +}); + +test.serial("formatPath: returns ~ for the home directory itself", (t) => { + const {dataDir} = t.context; + + t.is(dataDir.formatPath(os.homedir()), "~"); +}); + +test.serial("formatPath: leaves paths outside the home directory unchanged", (t) => { + const {dataDir} = t.context; + + const input = path.join(path.resolve(path.sep), "custom", "data-dir"); + + t.is(dataDir.formatPath(input), input); +}); + +test.serial("formatPath: does not shorten a sibling directory sharing the home prefix", (t) => { + const {dataDir} = t.context; + + // A path like "-backup" must not be treated as residing within the home directory. + const input = os.homedir() + "-backup"; + + t.is(dataDir.formatPath(input), input); +}); diff --git a/packages/cli/test/lib/framework/utils.js b/packages/cli/test/lib/framework/utils.js index 0ac6f4aaf37..88cefd1f583 100644 --- a/packages/cli/test/lib/framework/utils.js +++ b/packages/cli/test/lib/framework/utils.js @@ -1,7 +1,6 @@ import test from "ava"; import sinonGlobal from "sinon"; import esmock from "esmock"; -import path from "node:path"; test.beforeEach(async (t) => { // Tests either rely on not having UI5_DATA_DIR defined, or explicitly define it @@ -250,48 +249,3 @@ test.serial("frameworkResolverResolveVersion", async (t) => { } ]); }); - -test.serial("getUi5DataDir: no value defined", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, undefined); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 1); -}); - -test.serial("getUi5DataDir: from environment variable", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - // Environment variable must be preferred over configuration value - ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); - process.env.UI5_DATA_DIR = ".ui5-data-dir-from-env-variable"; - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-env-variable")); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 0); -}); - -test.serial("getUi5DataDir: from Configuration", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-configuration")); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 1); -});