From 760d4c91016ad776c122bdecf03668bbdc811d5e Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 18 Jun 2026 11:12:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(linkedin-studio):=20M0-2=20=E2=80=94?= =?UTF-8?q?=20storage.ts=20getDataRoot=20+=20external=20default=20(ANALYTI?= =?UTF-8?q?CS=5FROOT=20alias=20kept)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/analytics/src/utils/storage.ts | 50 ++++++++---- scripts/analytics/tests/storage-root.test.ts | 83 +++++++++++++------- 2 files changed, 91 insertions(+), 42 deletions(-) diff --git a/scripts/analytics/src/utils/storage.ts b/scripts/analytics/src/utils/storage.ts index 33c7e80..d24dae5 100644 --- a/scripts/analytics/src/utils/storage.ts +++ b/scripts/analytics/src/utils/storage.ts @@ -1,19 +1,19 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from "../models/types.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); - /** * Walk up from `startDir` until a directory containing `.claude-plugin/plugin.json` * is found. Returns that directory, or null if no marker is found before the * filesystem root. * - * Anchoring on the plugin marker keeps the analytics root correct by + * Anchoring on the plugin marker locates the plugin tree correctly by * construction regardless of whether this module runs from `src/utils/` * (under tsx) or `build/utils/` (compiled) — and survives a future source - * move that a hardcoded "../../../../" count would silently break. + * move that a hardcoded "../../../../" count would silently break. Since M0 it + * locates bundled read-only assets only; the per-user data root is external + * (getDataRoot), no longer derived from the plugin tree. */ export function findPluginRoot(startDir: string): string | null { let dir = resolve(startDir); @@ -33,20 +33,42 @@ export function findPluginRoot(startDir: string): string | null { } /** - * Get the analytics root directory from environment or default location. - * Default is assets/analytics under the plugin root (the dir holding - * .claude-plugin/plugin.json). The ANALYTICS_ROOT env override is the test seam. + * Resolve HOME with the codebase idiom, but never fall through to "" — that would + * turn the absolute data path into a CWD-relative one. os.homedir() is the floor. + */ +function resolveHome(): string { + return process.env.HOME || process.env.USERPROFILE || homedir(); +} + +/** + * CANONICAL — twin of hooks/scripts/data-root.mjs: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: the hooks are plain .mjs (no tsx) and cannot import this TS module. The + * twin-consistency test (hooks/scripts/__tests__/data-root.test.mjs) guards the + * contract behaviorally. + * + * Per-user data root: ~/.claude/linkedin-studio/, overridable via + * LINKEDIN_STUDIO_DATA (test + power-user seam, mirrors the state-file location). + */ +export function getDataRoot(subdir: string): string { + const dataBase = + process.env.LINKEDIN_STUDIO_DATA || + join(resolveHome(), ".claude", "linkedin-studio"); + return join(dataBase, subdir); +} + +/** + * Get the analytics root directory — since M0 the external per-user dir + * (getDataRoot("analytics")). The deprecated ANALYTICS_ROOT env override still + * wins for back-compat (D4) so cli.ts and existing prose keep working through the + * migration. findPluginRoot is preserved (above) for bundled read-only assets. */ export function getAnalyticsRoot(): string { if (process.env.ANALYTICS_ROOT) { return resolve(process.env.ANALYTICS_ROOT); } - - // Anchor on the .claude-plugin/plugin.json marker. Fall back to the legacy - // 4-levels-up count (scripts/analytics/{src,build}/utils -> plugin root) only - // if no marker is found (e.g. an unusual extraction without the manifest). - const pluginRoot = findPluginRoot(__dirname) ?? resolve(__dirname, "../../../../"); - return join(pluginRoot, "assets", "analytics"); + return getDataRoot("analytics"); } /** diff --git a/scripts/analytics/tests/storage-root.test.ts b/scripts/analytics/tests/storage-root.test.ts index c4ef29c..e3d6085 100644 --- a/scripts/analytics/tests/storage-root.test.ts +++ b/scripts/analytics/tests/storage-root.test.ts @@ -3,25 +3,34 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; -import { getAnalyticsRoot, findPluginRoot } from "../src/utils/storage.js"; +import { getAnalyticsRoot, getDataRoot, findPluginRoot } from "../src/utils/storage.js"; -// Regression lock for the fresh-clone / foreign-CWD analytics root resolution. -// The root must anchor on the .claude-plugin/plugin.json marker (correct by -// construction), NOT on a fragile count of "../" segments that silently breaks -// if the source layout is ever moved. -describe("analytics root resolution", () => { +// M0: the data root moved OUT of the plugin tree to the external per-user dir +// (~/.claude/linkedin-studio), behind getDataRoot(). getAnalyticsRoot() is now a +// thin alias = getDataRoot("analytics") that still honors the deprecated +// ANALYTICS_ROOT env for back-compat (D4). findPluginRoot stays — it now locates +// bundled read-only assets only (commit 798484b, the fresh-clone crash fix). +// +// The default must be stubbed via HOME + LINKEDIN_STUDIO_DATA save/restore so it +// never couples to the dev's real $HOME. Mirrors the .mjs twin test +// (hooks/scripts/__tests__/data-root.test.mjs) — keep them in sync. +describe("data root resolution", () => { let tempDir: string | undefined; - const savedEnv = process.env.ANALYTICS_ROOT; + const saved = { + ANALYTICS_ROOT: process.env.ANALYTICS_ROOT, + LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA, + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; afterEach(() => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } tempDir = undefined; - if (savedEnv === undefined) { - delete process.env.ANALYTICS_ROOT; - } else { - process.env.ANALYTICS_ROOT = savedEnv; + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; } }); @@ -46,32 +55,50 @@ describe("analytics root resolution", () => { }); }); + describe("getDataRoot", () => { + test("external default (HOME-stubbed): 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("LINKEDIN_STUDIO_DATA override wins", () => { + process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-data"; + + assert.equal(getDataRoot("analytics"), join("/tmp/lis-data", "analytics")); + }); + }); + describe("getAnalyticsRoot", () => { - test("honors ANALYTICS_ROOT override (resolved env path)", () => { + test("default (no env) now resolves to the EXTERNAL data dir, not in-plugin assets/analytics", () => { + process.env.HOME = "/home/tester"; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + delete process.env.ANALYTICS_ROOT; + + assert.equal( + getAnalyticsRoot(), + join("/home/tester", ".claude", "linkedin-studio", "analytics"), + ); + }); + + test("honors deprecated ANALYTICS_ROOT alias (resolved env path) for back-compat", () => { tempDir = mkdtempSync(join(tmpdir(), "analytics-root-")); process.env.ANALYTICS_ROOT = tempDir; assert.equal(getAnalyticsRoot(), resolve(tempDir)); }); - test("default (no env) anchors on the plugin dir, not scripts/analytics/assets", () => { + test("LINKEDIN_STUDIO_DATA drives the analytics root when ANALYTICS_ROOT is unset", () => { delete process.env.ANALYTICS_ROOT; + process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-data"; - const root = getAnalyticsRoot(); - const suffix = join("assets", "analytics"); - assert.ok(root.endsWith(suffix), `expected to end with ${suffix}, got ${root}`); - - // The parent of assets/analytics must be the real plugin root (holds the marker), - // proving the root is NOT scripts/analytics/assets/analytics. - const pluginRoot = root.slice(0, root.length - (suffix.length + 1)); - assert.ok( - existsSync(join(pluginRoot, ".claude-plugin", "plugin.json")), - `plugin marker missing under resolved root parent: ${pluginRoot}`, - ); - assert.ok( - !pluginRoot.endsWith(join("scripts", "analytics")), - `root wrongly anchored under scripts/analytics: ${pluginRoot}`, - ); + assert.equal(getAnalyticsRoot(), join("/tmp/lis-data", "analytics")); }); }); });