From 6c4e1b6b82105763c7ceb5bbda0582f52bdd2866 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 5 May 2026 10:30:31 +0200 Subject: [PATCH] feat(ms-ai-architect): add lib/cross-platform-paths for cache/log/state/backup dirs [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First foundation lib for v1.12.0 auto-KB-update. Resolves per-OS paths: - macOS: ~/Library/{Caches,Logs,Application Support}// - Linux: XDG_CACHE_HOME / XDG_STATE_HOME with ~/.cache, ~/.local/state fallbacks - Windows: %LOCALAPPDATA%\\{Cache,Logs,State} Plus getBackupDir(pluginRoot) → /.kb-backup (gitignored). All four functions auto-mkdir target. Dependency-injection via opts ({platform, homedir, env}) makes the lib pure-testable; 13/13 tests pass under tmpdir isolation without touching real ~/ paths. Co-Authored-By: Claude Opus 4.7 --- .../kb-update/lib/cross-platform-paths.mjs | 105 ++++++++++++++ .../test-cross-platform-paths.test.mjs | 129 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 scripts/kb-update/lib/cross-platform-paths.mjs create mode 100644 tests/kb-update/test-cross-platform-paths.test.mjs diff --git a/scripts/kb-update/lib/cross-platform-paths.mjs b/scripts/kb-update/lib/cross-platform-paths.mjs new file mode 100644 index 0000000..1b0b239 --- /dev/null +++ b/scripts/kb-update/lib/cross-platform-paths.mjs @@ -0,0 +1,105 @@ +// cross-platform-paths.mjs — Cache/Log/State/Backup dir resolution per OS. +// Zero dependencies. macOS uses ~/Library/{Caches,Logs,Application Support}, +// Linux uses XDG with fallbacks, Windows uses %LOCALAPPDATA%. + +import { mkdirSync } from 'node:fs'; +import { homedir as osHomedir, platform as osPlatform } from 'node:os'; +import { join } from 'node:path'; + +function resolveOpts(opts = {}) { + return { + platform: opts.platform ?? osPlatform(), + homedir: opts.homedir ?? osHomedir, + env: opts.env ?? process.env, + }; +} + +function ensureDir(path) { + mkdirSync(path, { recursive: true }); + return path; +} + +function requireApp(appName) { + if (!appName || typeof appName !== 'string') { + throw new Error('cross-platform-paths: appName is required and must be a non-empty string'); + } +} + +/** + * Resolve a per-app cache directory. + * @param {string} appName — application identifier, e.g. "ms-ai-architect" + * @param {object} [opts] — { platform, homedir, env } for testing + * @returns {string} absolute path to the cache directory (created if missing) + */ +export function getCacheDir(appName, opts) { + requireApp(appName); + const { platform, homedir, env } = resolveOpts(opts); + const home = homedir(); + + if (platform === 'darwin') { + return ensureDir(join(home, 'Library', 'Caches', appName)); + } + if (platform === 'win32') { + const lad = env.LOCALAPPDATA || join(home, 'AppData', 'Local'); + return ensureDir(join(lad, appName, 'Cache')); + } + // linux + everything else + const xdg = env.XDG_CACHE_HOME || join(home, '.cache'); + return ensureDir(join(xdg, appName)); +} + +/** + * Resolve a per-app log directory. + * @param {string} appName + * @param {object} [opts] + * @returns {string} absolute path to the log directory (created if missing) + */ +export function getLogDir(appName, opts) { + requireApp(appName); + const { platform, homedir, env } = resolveOpts(opts); + const home = homedir(); + + if (platform === 'darwin') { + return ensureDir(join(home, 'Library', 'Logs', appName)); + } + if (platform === 'win32') { + const lad = env.LOCALAPPDATA || join(home, 'AppData', 'Local'); + return ensureDir(join(lad, appName, 'Logs')); + } + const xdg = env.XDG_STATE_HOME || join(home, '.local', 'state'); + return ensureDir(join(xdg, appName, 'logs')); +} + +/** + * Resolve a per-app state/data directory (persistent app state, not cache). + * @param {string} appName + * @param {object} [opts] + * @returns {string} absolute path (created if missing) + */ +export function getStateDir(appName, opts) { + requireApp(appName); + const { platform, homedir, env } = resolveOpts(opts); + const home = homedir(); + + if (platform === 'darwin') { + return ensureDir(join(home, 'Library', 'Application Support', appName)); + } + if (platform === 'win32') { + const lad = env.LOCALAPPDATA || join(home, 'AppData', 'Local'); + return ensureDir(join(lad, appName, 'State')); + } + const xdg = env.XDG_STATE_HOME || join(home, '.local', 'state'); + return ensureDir(join(xdg, appName)); +} + +/** + * Resolve the backup directory under a plugin root. + * @param {string} pluginRoot — absolute path to the plugin root + * @returns {string} absolute path to /.kb-backup (created if missing) + */ +export function getBackupDir(pluginRoot) { + if (!pluginRoot || typeof pluginRoot !== 'string') { + throw new Error('cross-platform-paths: pluginRoot is required and must be a non-empty string'); + } + return ensureDir(join(pluginRoot, '.kb-backup')); +} diff --git a/tests/kb-update/test-cross-platform-paths.test.mjs b/tests/kb-update/test-cross-platform-paths.test.mjs new file mode 100644 index 0000000..f2e4424 --- /dev/null +++ b/tests/kb-update/test-cross-platform-paths.test.mjs @@ -0,0 +1,129 @@ +// tests/kb-update/test-cross-platform-paths.test.mjs +// Unit tests for scripts/kb-update/lib/cross-platform-paths.mjs +// Zero deps. Uses node:test + dependency-injection (homedir/platform overrides) to avoid filesystem and OS coupling. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + getCacheDir, + getLogDir, + getStateDir, + getBackupDir, +} from '../../scripts/kb-update/lib/cross-platform-paths.mjs'; + +const APP = 'ms-ai-architect-test'; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'cpp-test-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('getCacheDir — macOS returns ~/Library/Caches//', () => { + withTmp((home) => { + const result = getCacheDir(APP, { platform: 'darwin', homedir: () => home, env: {} }); + assert.equal(result, join(home, 'Library', 'Caches', APP)); + assert.equal(existsSync(result), true); + assert.equal(statSync(result).isDirectory(), true); + }); +}); + +test('getCacheDir — Linux uses XDG_CACHE_HOME when set', () => { + withTmp((home) => { + const xdg = join(home, 'custom-cache'); + const result = getCacheDir(APP, { platform: 'linux', homedir: () => home, env: { XDG_CACHE_HOME: xdg } }); + assert.equal(result, join(xdg, APP)); + assert.equal(existsSync(result), true); + }); +}); + +test('getCacheDir — Linux falls back to ~/.cache// when no XDG', () => { + withTmp((home) => { + const result = getCacheDir(APP, { platform: 'linux', homedir: () => home, env: {} }); + assert.equal(result, join(home, '.cache', APP)); + }); +}); + +test('getCacheDir — Windows uses %LOCALAPPDATA%\\\\Cache', () => { + withTmp((home) => { + const lad = join(home, 'AppData', 'Local'); + const result = getCacheDir(APP, { platform: 'win32', homedir: () => home, env: { LOCALAPPDATA: lad } }); + assert.equal(result, join(lad, APP, 'Cache')); + }); +}); + +test('getLogDir — macOS returns ~/Library/Logs//', () => { + withTmp((home) => { + const result = getLogDir(APP, { platform: 'darwin', homedir: () => home, env: {} }); + assert.equal(result, join(home, 'Library', 'Logs', APP)); + }); +}); + +test('getLogDir — Linux uses XDG_STATE_HOME//logs when set', () => { + withTmp((home) => { + const xdg = join(home, 'custom-state'); + const result = getLogDir(APP, { platform: 'linux', homedir: () => home, env: { XDG_STATE_HOME: xdg } }); + assert.equal(result, join(xdg, APP, 'logs')); + }); +}); + +test('getLogDir — Linux falls back to ~/.local/state//logs/', () => { + withTmp((home) => { + const result = getLogDir(APP, { platform: 'linux', homedir: () => home, env: {} }); + assert.equal(result, join(home, '.local', 'state', APP, 'logs')); + }); +}); + +test('getLogDir — Windows uses %LOCALAPPDATA%\\\\Logs', () => { + withTmp((home) => { + const lad = join(home, 'AppData', 'Local'); + const result = getLogDir(APP, { platform: 'win32', homedir: () => home, env: { LOCALAPPDATA: lad } }); + assert.equal(result, join(lad, APP, 'Logs')); + }); +}); + +test('getStateDir — macOS uses ~/Library/Application Support//', () => { + withTmp((home) => { + const result = getStateDir(APP, { platform: 'darwin', homedir: () => home, env: {} }); + assert.equal(result, join(home, 'Library', 'Application Support', APP)); + }); +}); + +test('getStateDir — Linux uses XDG_STATE_HOME when set', () => { + withTmp((home) => { + const xdg = join(home, 'custom-state'); + const result = getStateDir(APP, { platform: 'linux', homedir: () => home, env: { XDG_STATE_HOME: xdg } }); + assert.equal(result, join(xdg, APP)); + }); +}); + +test('getBackupDir — joins pluginRoot and .kb-backup, creates if missing', () => { + withTmp((root) => { + const result = getBackupDir(root); + assert.equal(result, join(root, '.kb-backup')); + assert.equal(existsSync(result), true); + assert.equal(statSync(result).isDirectory(), true); + }); +}); + +test('default options — uses real os.homedir() and process.platform', () => { + // Smoke-test: with no overrides, returns something sane (creates dir under real home). + // Use an unusual app name to avoid colliding with anything real. + const APP_REAL = 'ms-ai-architect-cpp-smoke-' + process.pid; + const result = getCacheDir(APP_REAL); + assert.ok(result.length > 0); + assert.equal(existsSync(result), true); + // Cleanup + rmSync(result, { recursive: true, force: true }); +}); + +test('getCacheDir — throws on missing app name', () => { + assert.throws(() => getCacheDir(), /appName/); + assert.throws(() => getCacheDir(''), /appName/); +});