feat(ms-ai-architect): add lib/cross-platform-paths for cache/log/state/backup dirs [skip-docs]
First foundation lib for v1.12.0 auto-KB-update. Resolves per-OS paths:
- macOS: ~/Library/{Caches,Logs,Application Support}/<app>/
- Linux: XDG_CACHE_HOME / XDG_STATE_HOME with ~/.cache, ~/.local/state fallbacks
- Windows: %LOCALAPPDATA%\<app>\{Cache,Logs,State}
Plus getBackupDir(pluginRoot) → <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 <noreply@anthropic.com>
This commit is contained in:
parent
96ca7190b4
commit
57fcdf7158
3 changed files with 234 additions and 133 deletions
|
|
@ -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 <pluginRoot>/.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'));
|
||||
}
|
||||
|
|
@ -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/<app>/', () => {
|
||||
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/<app>/ 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%\\<app>\\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/<app>/', () => {
|
||||
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/<app>/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/<app>/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%\\<app>\\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/<app>/', () => {
|
||||
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/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue