From dc294495d68e2b520b0ddb68c6d67a42553a454b Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 18 Jun 2026 11:08:52 +0200 Subject: [PATCH] =?UTF-8?q?refactor(linkedin-studio):=20M0-1=20=E2=80=94?= =?UTF-8?q?=20data-root.mjs=20resolver=20twin=20+=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/scripts/__tests__/data-root.test.mjs | 80 ++++++++++++++++++++++ hooks/scripts/data-root.mjs | 39 +++++++++++ 2 files changed, 119 insertions(+) create mode 100644 hooks/scripts/__tests__/data-root.test.mjs create mode 100644 hooks/scripts/data-root.mjs diff --git a/hooks/scripts/__tests__/data-root.test.mjs b/hooks/scripts/__tests__/data-root.test.mjs new file mode 100644 index 0000000..d69063b --- /dev/null +++ b/hooks/scripts/__tests__/data-root.test.mjs @@ -0,0 +1,80 @@ +import { describe, test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { getDataRoot, getStateFile } from '../data-root.mjs'; + +// Resolver contract for the per-user data dir (M0). The resolver reads env at +// CALL time (not module load) so these env stubs take effect; restore in +// afterEach. Pattern: scripts/analytics/tests/storage-root.test.ts:14-26. +describe('data-root resolver', () => { + const saved = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA, + STATE_FILE: process.env.STATE_FILE, + }; + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + describe('getDataRoot', () => { + test('default subdir form: getDataRoot("analytics") = HOME/.claude/linkedin-studio/analytics', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + assert.equal( + getDataRoot('analytics'), + join('/home/tester', '.claude', 'linkedin-studio', 'analytics'), + ); + }); + + test('no-arg form returns the root itself (M5 — needed by the REMEMBER write)', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + assert.equal(getDataRoot(), join('/home/tester', '.claude', 'linkedin-studio')); + }); + + test('LINKEDIN_STUDIO_DATA override wins (subdir and root forms)', () => { + process.env.HOME = '/home/tester'; + process.env.LINKEDIN_STUDIO_DATA = '/tmp/lis-data'; + + assert.equal(getDataRoot('analytics'), join('/tmp/lis-data', 'analytics')); + assert.equal(getDataRoot(), '/tmp/lis-data'); + }); + + test('empty HOME + USERPROFILE falls back to os.homedir(), never "" (no relative-path trap)', () => { + delete process.env.HOME; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + const root = getDataRoot('analytics'); + assert.equal(root, join(homedir(), '.claude', 'linkedin-studio', 'analytics')); + assert.ok(!root.startsWith('.claude'), `root must be absolute, got relative: ${root}`); + }); + }); + + describe('getStateFile', () => { + test('default = HOME/.claude/linkedin-studio.local.md (mirrors state-updater.mjs:12)', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.STATE_FILE; + + assert.equal(getStateFile(), join('/home/tester', '.claude', 'linkedin-studio.local.md')); + }); + + test('STATE_FILE override wins', () => { + process.env.HOME = '/home/tester'; + process.env.STATE_FILE = '/tmp/custom-state.md'; + + assert.equal(getStateFile(), '/tmp/custom-state.md'); + }); + }); +}); diff --git a/hooks/scripts/data-root.mjs b/hooks/scripts/data-root.mjs new file mode 100644 index 0000000..6c0aa5d --- /dev/null +++ b/hooks/scripts/data-root.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// CANONICAL — twin of scripts/analytics/src/utils/storage.ts:getDataRoot. +// MUST STAY IN SYNC: identical default (~/.claude/linkedin-studio) + identical +// LINKEDIN_STUDIO_DATA override semantics. A twin exists (not a shared import) +// because the runtimes differ: hooks are plain .mjs run by Node with zero npm +// deps and cannot import the TypeScript module (which needs tsx). Change one → +// change the other; the twin-consistency test in __tests__/data-root.test.mjs +// guards the contract behaviorally. + +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// HOME with the codebase idiom (state-updater.mjs:11), but NEVER fall through to +// '' — that would turn the absolute data path into a CWD-relative one. os.homedir() +// is the floor (risk-assessor Low #2). +function resolveHome() { + return process.env.HOME || process.env.USERPROFILE || homedir(); +} + +// Canonical per-user data root: ~/.claude/linkedin-studio (mirrors the state-file +// location), overridable via LINKEDIN_STUDIO_DATA (test + power-user seam). +// No-arg / '' returns the root itself; a subdir returns root/subdir. +export function getDataRoot(subdir = '') { + const dataBase = process.env.LINKEDIN_STUDIO_DATA || join(resolveHome(), '.claude', 'linkedin-studio'); + return subdir ? join(dataBase, subdir) : dataBase; +} + +// State file lives beside the data dir (mirrors state-updater.mjs:12). +export function getStateFile() { + return process.env.STATE_FILE || join(resolveHome(), '.claude', 'linkedin-studio.local.md'); +} + +// Standalone: print the resolved paths for debugging (pattern: personalization-score.mjs:120). +if (process.argv[1] === fileURLToPath(import.meta.url)) { + console.log('data root:', getDataRoot()); + console.log('analytics:', getDataRoot('analytics')); + console.log('state file:', getStateFile()); +}