From 63a642b29fa570fde30bc866a4d80d6ab57e40cd Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 00:06:11 +0200 Subject: [PATCH] feat(okr): atomisk org-profil-skrivehelper + test (SC1) [skip-docs] --- scripts/write-org-profile.mjs | 65 +++++++++++++++++++++++++ tests/org-profile-write.test.mjs | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 scripts/write-org-profile.mjs create mode 100644 tests/org-profile-write.test.mjs diff --git a/scripts/write-org-profile.mjs b/scripts/write-org-profile.mjs new file mode 100644 index 0000000..ae72dea --- /dev/null +++ b/scripts/write-org-profile.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +// write-org-profile.mjs +// Purpose: atomically write the OKR org profile to the reinstall-surviving +// home path (~/.claude/okr/org/profil.md). On ANY write failure +// (EACCES/EROFS/ENOSPC/pathguard-block) it circuit-breaks to the +// project-local, gitignored .claude/okr.local.md so internal org state never +// reaches a public mirror. Never exits non-zero -- the calling command must +// not be blocked. Reads the full profile content from stdin (fd 0). +// Zero npm dependencies (node: builtins only). ASCII-clean identifiers. +// +// Mirrors the canonical home path defined in +// hooks/scripts/inject-okr-context.mjs:14 (the most-specific-wins read side). + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +// Read the entire profile content from stdin. Under execFileSync (both Claude +// Code's command call and the tests) the child's stdin is a closed pipe, so +// EOF arrives immediately -- no hang. +let content = ''; +try { + content = readFileSync(0, 'utf8'); +} catch { + content = ''; +} + +const homeTarget = join(homedir(), '.claude', 'okr', 'org', 'profil.md'); + +// Atomic write: temp file in the SAME directory, then renameSync over the +// target. rename is atomic on the same filesystem -- a crash mid-write can +// never leave a half-written profil.md. +function writeAtomic(target, data) { + const dir = dirname(target); + mkdirSync(dir, { recursive: true }); + const tmp = join(dir, `profil.md.${process.pid}.tmp`); + writeFileSync(tmp, data); + renameSync(tmp, target); +} + +try { + writeAtomic(homeTarget, content); + process.stdout.write(homeTarget); + process.exit(0); +} catch (err) { + // Circuit-breaker: the home write failed. Fall back to the project-local, + // gitignored config so org state stays off any public mirror. The cycle / + // historikk tree remains cwd-bound regardless; only the profile migrates. + const fallback = join(process.cwd(), '.claude', 'okr.local.md'); + try { + writeAtomic(fallback, content); + process.stderr.write( + `notice: kunne ikke skrive hjem-profil (${err.code || err.message}); ` + + `falt tilbake til prosjektlokal ${fallback}\n`, + ); + process.stdout.write(fallback); + } catch (err2) { + process.stderr.write( + `notice: kunne ikke skrive verken hjem-profil eller prosjektlokal fallback ` + + `(${err2.code || err2.message})\n`, + ); + } + process.exit(0); +} diff --git a/tests/org-profile-write.test.mjs b/tests/org-profile-write.test.mjs new file mode 100644 index 0000000..c72678b --- /dev/null +++ b/tests/org-profile-write.test.mjs @@ -0,0 +1,83 @@ +// org-profile-write.test.mjs +// Tester atomisk org-profil-skrivehelper (SC1): hjem-skriv, circuit-breaker +// fallback til prosjektlokal, og round-trip mot inject-okr-context. +// Spawner helperen som subprosess med kontrollert HOME + cwd + stdin. +// Zero npm deps. Plassert i tests/ (samme moenster som inject-okr-context.test.mjs). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const HELPER = join(ROOT, 'scripts', 'write-org-profile.mjs'); +const HOOK = join(ROOT, 'hooks', 'scripts', 'inject-okr-context.mjs'); + +function runHelper(cwd, home, input) { + // execFileSync returnerer stdout; helperen avslutter alltid med exit 0. + return execFileSync('node', [HELPER], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function runHook(cwd, home) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('hjem-skriv: helper skriver org-profil til ~/.claude/okr/org/profil.md', () => { + withDirs((home, work) => { + const profil = '---\nnavn: "HjemskrivOrg"\n---\n'; + const out = runHelper(work, home, profil); + const target = join(home, '.claude', 'okr', 'org', 'profil.md'); + assert.ok(existsSync(target), 'hjem-profil skal finnes etter skriv'); + assert.equal(readFileSync(target, 'utf8'), profil, 'innhold skal matche stdin'); + // realpathSync normaliserer macOS /var -> /private/var-symlink paa begge sider. + assert.equal(realpathSync(out.trim()), realpathSync(target), 'stdout skal rapportere faktisk brukt sti (hjem)'); + }); +}); + +test('circuit-breaker: uskrivbart hjem -> fallback til prosjektlokal uten error', () => { + withDirs((home, work) => { + // Gjoer hjem-skriv umulig: ~/.claude er en FIL, ikke katalog -> mkdirSync + // (recursive) under den feiler med ENOTDIR (rot-uavhengig, deterministisk). + writeFileSync(join(home, '.claude'), 'not a directory\n'); + const profil = '---\nnavn: "FallbackOrg"\n---\n'; + // execFileSync kaster hvis exit != 0; at dette IKKE kaster beviser exit 0. + const out = runHelper(work, home, profil); + const fallback = join(work, '.claude', 'okr.local.md'); + assert.ok(existsSync(fallback), 'fallback-profil skal finnes i prosjektlokal sti'); + assert.equal(readFileSync(fallback, 'utf8'), profil, 'fallback-innhold skal matche stdin'); + assert.equal(realpathSync(out.trim()), realpathSync(fallback), 'stdout skal rapportere fallback-sti'); + }); +}); + +test('round-trip: hjem-skrevet org reflekteres av inject-okr-context', () => { + withDirs((home, work) => { + const profil = '---\nnavn: "RoundTripOrg"\n---\n'; + runHelper(work, home, profil); + // Tom prosjektkatalog (work) -> hooken faller til hjem-profil (mest-spesifikk-vinner). + const injected = runHook(work, home); + assert.match(injected, /RoundTripOrg/, 'inject skal lese hjem-profilen helperen skrev'); + }); +});