feat(okr): atomisk org-profil-skrivehelper + test (SC1) [skip-docs]
This commit is contained in:
parent
e4f6c4d498
commit
63a642b29f
2 changed files with 148 additions and 0 deletions
65
scripts/write-org-profile.mjs
Normal file
65
scripts/write-org-profile.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
83
tests/org-profile-write.test.mjs
Normal file
83
tests/org-profile-write.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue