@nurix/etna 0.20.27 → 0.20.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/bin/etna.js +61 -19
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/codex/agents/bookkeeper.toml +1 -1
- package/data/harnesses/code/harness.json +6 -2
- package/data/harnesses/code/hooks/awareness.mjs +50 -2
- package/data/harnesses/code/hooks/hooks.json +1 -1
- package/data/harnesses/code/hooks/lib/backlog-detector.mjs +82 -0
- package/data/harnesses/code/hooks/lib/records-door.mjs +54 -0
- package/data/harnesses/code/hooks/mount.mjs +470 -0
- package/data/harnesses/delivery/harness.json +4 -0
- package/data/harnesses/discover/harness.json +4 -0
- package/data/harnesses/discussion/harness.json +4 -0
- package/data/harnesses/etna/harness.json +4 -0
- package/data/harnesses/hr/harness.json +4 -0
- package/data/harnesses/kb-agi/harness.json +4 -0
- package/lib/install/materialize.mjs +51 -23
- package/lib/install/mount.mjs +448 -0
- package/lib/install/plan.mjs +24 -29
- package/package.json +6 -3
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// packages/cli/lib/install/mount.mjs
|
|
2
|
+
// The mount: a harness version's mounted files materialised from a version-keyed manifest and
|
|
3
|
+
// the machine's content-addressed cache, plus the managed `.gitignore` block and the mount record.
|
|
4
|
+
// The region between the shared-core markers is copied byte-for-byte into
|
|
5
|
+
// harness/code/hooks/mount.mjs (a bare clone has no lib/ to import); a test pins the two equal.
|
|
6
|
+
// Fuller account: docs/packages/cli.md § The mount.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { basename, dirname, join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
// --- shared mount core: start ---
|
|
23
|
+
const DEFAULT_ORIGIN = 'https://cdn.nustack.io';
|
|
24
|
+
const MOUNT_BLOCK_START = '# etna:mount:start';
|
|
25
|
+
const MOUNT_BLOCK_END = '# etna:mount:end';
|
|
26
|
+
const MOUNTED_CLAUDE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
|
|
27
|
+
const CONTRACT_NEGATIONS = [
|
|
28
|
+
'!.claude/harness.json',
|
|
29
|
+
'!.claude/settings.json',
|
|
30
|
+
'!.claude/hooks/',
|
|
31
|
+
'!.claude/hooks/mount.mjs',
|
|
32
|
+
'!.codex/hooks.json',
|
|
33
|
+
];
|
|
34
|
+
const FETCH_CONCURRENCY = 8;
|
|
35
|
+
const CAPABILITY_GATES = { '.claude/rules/cloudflare.md': 'worker' };
|
|
36
|
+
|
|
37
|
+
function sha256Of(bytes) {
|
|
38
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The machine cache every repository on the machine shares; ETNA_CACHE_DIR relocates it.
|
|
42
|
+
function cacheRoot(env = process.env) {
|
|
43
|
+
return env.ETNA_CACHE_DIR || join(homedir(), '.etna', 'cache');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// True for a path the mount owns; the contract layer (the committed half) answers false.
|
|
47
|
+
function isMountedPath(path) {
|
|
48
|
+
if (path === '.claude/hooks/mount.mjs' || path === '.codex/hooks.json') return false;
|
|
49
|
+
const parts = path.split('/');
|
|
50
|
+
if (parts[0] === '.claude') return parts.length > 2 && MOUNTED_CLAUDE_SUBDIRS.includes(parts[1]);
|
|
51
|
+
return (parts[0] === '.agents' || parts[0] === '.codex') && parts.length > 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Numeric dotted-version order: negative, zero or positive, like a sort comparator.
|
|
55
|
+
function compareVersions(a, b) {
|
|
56
|
+
const left = String(a).split('.').map((part) => parseInt(part, 10) || 0);
|
|
57
|
+
const right = String(b).split('.').map((part) => parseInt(part, 10) || 0);
|
|
58
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
59
|
+
const diff = (left[i] || 0) - (right[i] || 0);
|
|
60
|
+
if (diff !== 0) return diff;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readJsonFile(file) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Temp file then rename, so a concurrent reader sees the old file or the new one, never a torn one.
|
|
74
|
+
function writeAtomic(file, bytes) {
|
|
75
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
76
|
+
const temp = join(dirname(file), `.${basename(file)}.mount-${process.pid}`);
|
|
77
|
+
writeFileSync(temp, bytes);
|
|
78
|
+
renameSync(temp, file);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchBytes(url, fetchImpl, timeoutMs) {
|
|
82
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
83
|
+
if (!response.ok) throw new Error(`GET ${url} answered ${response.status}`);
|
|
84
|
+
return Buffer.from(await response.arrayBuffer());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Runs task over items with a bounded number in flight.
|
|
88
|
+
async function eachLimited(items, task) {
|
|
89
|
+
let next = 0;
|
|
90
|
+
const worker = async () => {
|
|
91
|
+
while (next < items.length) {
|
|
92
|
+
const item = items[next];
|
|
93
|
+
next += 1;
|
|
94
|
+
await task(item);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, items.length) }, worker));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The version manifest, from the machine cache when present, else fetched and cached.
|
|
101
|
+
async function resolveManifest({ origin, slug, version, cacheDir, fetchImpl, timeoutMs }) {
|
|
102
|
+
const cached = join(cacheDir, slug, version, 'manifest.json');
|
|
103
|
+
if (existsSync(cached)) return { manifest: JSON.parse(readFileSync(cached, 'utf8')), source: 'cache' };
|
|
104
|
+
const bytes = await fetchBytes(`${origin}/harness/${slug}/${version}/manifest.json`, fetchImpl, timeoutMs);
|
|
105
|
+
const manifest = JSON.parse(bytes.toString('utf8'));
|
|
106
|
+
writeAtomic(cached, bytes);
|
|
107
|
+
return { manifest, source: 'origin' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One file's bytes by sha256: the object cache first, else fetched, verified, and cached.
|
|
111
|
+
async function readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }) {
|
|
112
|
+
const file = join(cacheDir, 'objects', entry.sha256);
|
|
113
|
+
if (existsSync(file)) {
|
|
114
|
+
const bytes = readFileSync(file);
|
|
115
|
+
if (sha256Of(bytes) === entry.sha256) return bytes;
|
|
116
|
+
}
|
|
117
|
+
const bytes = await fetchBytes(`${origin}/${entry.key}`, fetchImpl, timeoutMs);
|
|
118
|
+
if (sha256Of(bytes) !== entry.sha256) throw new Error(`${entry.path}: fetched bytes do not match the manifest`);
|
|
119
|
+
writeAtomic(file, bytes);
|
|
120
|
+
return bytes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mountedEntries(manifest) {
|
|
124
|
+
return (manifest.files || []).filter(
|
|
125
|
+
(entry) => (entry.policy === undefined || entry.policy === 'mount') && isMountedPath(entry.path),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A Worker's config at the root, or one or two directory levels down (node_modules and dot dirs skipped).
|
|
130
|
+
function hasWorkerConfig(projectDir) {
|
|
131
|
+
const names = ['wrangler.jsonc', 'wrangler.toml'];
|
|
132
|
+
const search = (dir, depth) => {
|
|
133
|
+
if (names.some((name) => existsSync(join(dir, name)))) return true;
|
|
134
|
+
if (depth === 0) return false;
|
|
135
|
+
let entries = [];
|
|
136
|
+
try {
|
|
137
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return entries.some(
|
|
142
|
+
(entry) => entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.') && search(join(dir, entry.name), depth - 1),
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
return search(projectDir, 2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The mounted entries this project's capabilities admit; a gated rule mounts only where its capability is.
|
|
149
|
+
function admittedEntries(projectDir, entries) {
|
|
150
|
+
const capabilities = { worker: null };
|
|
151
|
+
return entries.filter((entry) => {
|
|
152
|
+
const gate = entry.requires || CAPABILITY_GATES[entry.path];
|
|
153
|
+
if (!gate) return true;
|
|
154
|
+
if (capabilities[gate] === null) capabilities[gate] = gate === 'worker' ? hasWorkerConfig(projectDir) : false;
|
|
155
|
+
return capabilities[gate] === true;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function filesBelow(root, rel) {
|
|
160
|
+
const full = join(root, rel);
|
|
161
|
+
let stat;
|
|
162
|
+
try {
|
|
163
|
+
stat = statSync(full);
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
if (!stat.isDirectory()) return [rel];
|
|
168
|
+
return readdirSync(full).flatMap((name) => filesBelow(root, `${rel}/${name}`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// On-disk files the mount may delete: inside owned component names, and the hooks tree bar mount.mjs.
|
|
172
|
+
function prunableFiles(projectDir, owned) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const sub of ['skills', 'agents', 'rules', 'scripts']) {
|
|
175
|
+
for (const name of owned[sub] || []) out.push(...filesBelow(projectDir, `.claude/${sub}/${name}`));
|
|
176
|
+
}
|
|
177
|
+
for (const rel of filesBelow(projectDir, '.claude/hooks')) if (rel !== '.claude/hooks/mount.mjs') out.push(rel);
|
|
178
|
+
for (const name of owned.skills || []) out.push(...filesBelow(projectDir, `.agents/skills/${name}`));
|
|
179
|
+
for (const name of owned.agents || []) {
|
|
180
|
+
out.push(...filesBelow(projectDir, `.codex/agents/${name.replace(/\.md$/, '')}.toml`));
|
|
181
|
+
}
|
|
182
|
+
return out.filter((rel) => rel !== '.claude/hooks');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function unionOwned(...maps) {
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const map of maps) {
|
|
188
|
+
for (const [sub, names] of Object.entries(map || {})) out[sub] = [...new Set([...(out[sub] || []), ...names])];
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function removeEmptyParents(projectDir, rel) {
|
|
194
|
+
let dir = dirname(rel);
|
|
195
|
+
while (dir.split('/').length > 2) {
|
|
196
|
+
const full = join(projectDir, dir);
|
|
197
|
+
try {
|
|
198
|
+
if (readdirSync(full).length > 0) return;
|
|
199
|
+
rmSync(full, { recursive: true, force: true });
|
|
200
|
+
} catch {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
dir = dirname(dir);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Writes the manifest's mounted files that differ on disk, then deletes owned names the manifest omits.
|
|
208
|
+
async function mountFromManifest({ projectDir, manifest, origin, cacheDir, fetchImpl, timeoutMs, priorOwned }) {
|
|
209
|
+
const all = mountedEntries(manifest);
|
|
210
|
+
const entries = admittedEntries(projectDir, all);
|
|
211
|
+
const listed = new Set(entries.map((entry) => entry.path));
|
|
212
|
+
const written = [];
|
|
213
|
+
const failed = [];
|
|
214
|
+
const removed = [];
|
|
215
|
+
const stale = entries.filter((entry) => {
|
|
216
|
+
const dest = join(projectDir, entry.path);
|
|
217
|
+
return !(existsSync(dest) && sha256Of(readFileSync(dest)) === entry.sha256);
|
|
218
|
+
});
|
|
219
|
+
await eachLimited(stale, async (entry) => {
|
|
220
|
+
try {
|
|
221
|
+
writeAtomic(join(projectDir, entry.path), await readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }));
|
|
222
|
+
written.push(entry.path);
|
|
223
|
+
} catch {
|
|
224
|
+
failed.push(entry.path);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
for (const rel of prunableFiles(projectDir, unionOwned(manifest.owned, priorOwned))) {
|
|
228
|
+
if (listed.has(rel)) continue;
|
|
229
|
+
rmSync(join(projectDir, rel), { force: true });
|
|
230
|
+
removeEmptyParents(projectDir, rel);
|
|
231
|
+
removed.push(rel);
|
|
232
|
+
}
|
|
233
|
+
return { written: written.sort(), failed: failed.sort(), removed, mountedPaths: all.map((entry) => entry.path) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The block's lines: contract negations, then the mounted top-level names and the state folder.
|
|
237
|
+
function ignoreBlockLines(mountedPaths) {
|
|
238
|
+
const names = new Set(['.claude/state/']);
|
|
239
|
+
for (const path of mountedPaths) {
|
|
240
|
+
const parts = path.split('/');
|
|
241
|
+
if (parts[0] === '.claude') names.add(parts.length > 3 ? `${parts.slice(0, 3).join('/')}/` : path);
|
|
242
|
+
else names.add(parts.length > 2 ? `${parts[0]}/${parts[1]}/` : path);
|
|
243
|
+
}
|
|
244
|
+
return [
|
|
245
|
+
MOUNT_BLOCK_START,
|
|
246
|
+
'# Managed by etna: the mounted harness, rewritten on every mount. Edit outside this block.',
|
|
247
|
+
...CONTRACT_NEGATIONS,
|
|
248
|
+
...[...names].sort(),
|
|
249
|
+
MOUNT_BLOCK_END,
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Rewrites the managed block at the end of .gitignore; a bare `.claude` line becomes `.claude/*`.
|
|
254
|
+
function writeIgnoreBlock(projectDir, mountedPaths) {
|
|
255
|
+
const file = join(projectDir, '.gitignore');
|
|
256
|
+
const before = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
257
|
+
const kept = [];
|
|
258
|
+
let inBlock = false;
|
|
259
|
+
for (const line of before.split('\n')) {
|
|
260
|
+
if (line.trim() === MOUNT_BLOCK_START) inBlock = true;
|
|
261
|
+
else if (inBlock) inBlock = line.trim() !== MOUNT_BLOCK_END;
|
|
262
|
+
else {
|
|
263
|
+
const bare = /^(\/?)\.claude\/?\s*$/.exec(line);
|
|
264
|
+
kept.push(bare ? `${bare[1]}.claude/*` : line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
while (kept.length && kept[kept.length - 1].trim() === '') kept.pop();
|
|
268
|
+
const block = ignoreBlockLines(mountedPaths).join('\n');
|
|
269
|
+
const after = (kept.length ? `${kept.join('\n')}\n\n` : '') + `${block}\n`;
|
|
270
|
+
if (after === before) return false;
|
|
271
|
+
writeAtomic(file, after);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Records what is mounted, beside the folder's self-ignoring `*` .gitignore.
|
|
276
|
+
function recordMount(projectDir, record) {
|
|
277
|
+
const dir = join(projectDir, '.claude', 'state');
|
|
278
|
+
mkdirSync(dir, { recursive: true });
|
|
279
|
+
if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), '*\n');
|
|
280
|
+
const body = { ...record, mountedAt: new Date().toISOString() };
|
|
281
|
+
writeAtomic(join(dir, 'mount.json'), `${JSON.stringify(body, null, 2)}\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function mountSettings(marker) {
|
|
285
|
+
const mount = (marker && marker.mount) || {};
|
|
286
|
+
return {
|
|
287
|
+
origin: String(mount.origin || DEFAULT_ORIGIN).replace(/\/+$/, ''),
|
|
288
|
+
track: mount.track === 'pinned' ? 'pinned' : 'latest',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Mounts the pinned version unless the mount record already names it.
|
|
293
|
+
async function mountHarness({ projectDir, cacheDir = cacheRoot(), fetchImpl = globalThis.fetch, timeoutMs = 5000, force = false }) {
|
|
294
|
+
const marker = readJsonFile(join(projectDir, '.claude', 'harness.json'));
|
|
295
|
+
if (!marker || !marker.harness || !marker.version) return { status: 'unmarked' };
|
|
296
|
+
const { origin } = mountSettings(marker);
|
|
297
|
+
const slug = marker.harness;
|
|
298
|
+
const version = marker.version;
|
|
299
|
+
const prior = readJsonFile(join(projectDir, '.claude', 'state', 'mount.json'));
|
|
300
|
+
if (!force && prior && prior.version === version) return { status: 'current', slug, version, digest: prior.digest };
|
|
301
|
+
let resolved;
|
|
302
|
+
try {
|
|
303
|
+
resolved = await resolveManifest({ origin, slug, version, cacheDir, fetchImpl, timeoutMs });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return { status: 'unavailable', slug, version, origin, reason: error.message };
|
|
306
|
+
}
|
|
307
|
+
const { manifest, source } = resolved;
|
|
308
|
+
const result = await mountFromManifest({
|
|
309
|
+
projectDir,
|
|
310
|
+
manifest,
|
|
311
|
+
origin,
|
|
312
|
+
cacheDir,
|
|
313
|
+
fetchImpl,
|
|
314
|
+
timeoutMs,
|
|
315
|
+
priorOwned: prior && prior.owned,
|
|
316
|
+
});
|
|
317
|
+
writeIgnoreBlock(projectDir, result.mountedPaths);
|
|
318
|
+
if (result.failed.length === 0) {
|
|
319
|
+
recordMount(projectDir, { version, digest: manifest.digest || null, owned: manifest.owned || null });
|
|
320
|
+
}
|
|
321
|
+
return { status: result.failed.length ? 'partial' : 'mounted', slug, version, source, ...result };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function readLatest({ origin, slug, fetchImpl, timeoutMs }) {
|
|
325
|
+
return JSON.parse((await fetchBytes(`${origin}/harness/${slug}/latest.json`, fetchImpl, timeoutMs)).toString('utf8'));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Stages a version into the cache, the manifest verified against the pointer and written last.
|
|
329
|
+
async function stageVersion({ origin, slug, latest, cacheDir, fetchImpl, timeoutMs }) {
|
|
330
|
+
const bytes = await fetchBytes(`${origin}/${latest.manifest}`, fetchImpl, timeoutMs);
|
|
331
|
+
if (sha256Of(bytes) !== latest.manifestSha256) {
|
|
332
|
+
throw new Error(`the ${slug} ${latest.version} manifest does not match latest.json's manifestSha256 — nothing staged`);
|
|
333
|
+
}
|
|
334
|
+
const manifest = JSON.parse(bytes.toString('utf8'));
|
|
335
|
+
await eachLimited(mountedEntries(manifest), (entry) => readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }));
|
|
336
|
+
writeAtomic(join(cacheDir, slug, latest.version, 'manifest.json'), bytes);
|
|
337
|
+
return manifest;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Asks latest.json for a newer version; stages it, and on track latest moves the pin.
|
|
341
|
+
async function checkForUpdate({ projectDir, cacheDir = cacheRoot(), fetchImpl = globalThis.fetch, timeoutMs = 2000 }) {
|
|
342
|
+
const markerFile = join(projectDir, '.claude', 'harness.json');
|
|
343
|
+
const marker = readJsonFile(markerFile);
|
|
344
|
+
if (!marker || !marker.harness || !marker.version) return { status: 'unmarked' };
|
|
345
|
+
const { origin, track } = mountSettings(marker);
|
|
346
|
+
const slug = marker.harness;
|
|
347
|
+
let latest;
|
|
348
|
+
try {
|
|
349
|
+
latest = await readLatest({ origin, slug, fetchImpl, timeoutMs });
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return { status: 'unreachable', reason: error.message };
|
|
352
|
+
}
|
|
353
|
+
if (!latest || !latest.version || compareVersions(latest.version, marker.version) <= 0) return { status: 'current' };
|
|
354
|
+
if (!existsSync(join(cacheDir, slug, latest.version, 'manifest.json'))) {
|
|
355
|
+
try {
|
|
356
|
+
await stageVersion({ origin, slug, latest, cacheDir, fetchImpl, timeoutMs: Math.max(timeoutMs, 5000) });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return { status: 'refused', reason: error.message, notice: `Harness update not staged: ${error.message}` };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const mounted = marker.version;
|
|
362
|
+
if (track === 'latest') writeAtomic(markerFile, `${JSON.stringify({ ...marker, version: latest.version }, null, 2)}\n`);
|
|
363
|
+
const tail = track === 'pinned' ? ' (track: pinned — bump `version` in .claude/harness.json to take it)' : '';
|
|
364
|
+
return {
|
|
365
|
+
status: 'staged',
|
|
366
|
+
from: mounted,
|
|
367
|
+
to: latest.version,
|
|
368
|
+
track,
|
|
369
|
+
notice: `Harness ${slug} ${latest.version} is ready (mounted ${mounted}); restart the session to run on it${tail}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
// --- shared mount core: end ---
|
|
373
|
+
|
|
374
|
+
// The manifest describing one version prefix: every planned file keyed under
|
|
375
|
+
// `harness/<slug>/<version>/`, with its sha256, byte size, and whether the mount or the contract owns it.
|
|
376
|
+
export function buildManifest(files, { slug, version, digest, placementVersion, owned }) {
|
|
377
|
+
const prefix = `harness/${slug}/${version}/`;
|
|
378
|
+
const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
379
|
+
const entries = sorted.map((file) => ({
|
|
380
|
+
path: file.path,
|
|
381
|
+
key: prefix + file.path,
|
|
382
|
+
sha256: sha256Of(file.content),
|
|
383
|
+
byteSize: Buffer.byteLength(file.content),
|
|
384
|
+
policy: isMountedPath(file.path) ? 'mount' : 'contract',
|
|
385
|
+
...(CAPABILITY_GATES[file.path] ? { requires: CAPABILITY_GATES[file.path] } : {}),
|
|
386
|
+
}));
|
|
387
|
+
const fingerprint = createHash('sha256');
|
|
388
|
+
for (const file of sorted) fingerprint.update(`${file.path}:${sha256Of(file.content)}\n`);
|
|
389
|
+
return {
|
|
390
|
+
slug,
|
|
391
|
+
version,
|
|
392
|
+
harnessVersion: version,
|
|
393
|
+
placementVersion,
|
|
394
|
+
digest,
|
|
395
|
+
fingerprint: fingerprint.digest('hex').slice(0, 12),
|
|
396
|
+
prefix,
|
|
397
|
+
fileCount: entries.length,
|
|
398
|
+
byteSize: entries.reduce((total, entry) => total + entry.byteSize, 0),
|
|
399
|
+
...(owned ? { owned } : {}),
|
|
400
|
+
files: entries,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Seeds the machine cache from a planned file list, so a later session mounts this version offline.
|
|
405
|
+
export function warmCacheFromPlan({ files, manifest, cacheDir = cacheRoot() }) {
|
|
406
|
+
for (const file of files) {
|
|
407
|
+
if (!isMountedPath(file.path)) continue;
|
|
408
|
+
const object = join(cacheDir, 'objects', sha256Of(file.content));
|
|
409
|
+
if (!existsSync(object)) writeAtomic(object, file.content);
|
|
410
|
+
}
|
|
411
|
+
const manifestFile = join(cacheDir, manifest.slug, manifest.version, 'manifest.json');
|
|
412
|
+
if (!existsSync(manifestFile)) writeAtomic(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Whether the origin serves this version's manifest: 'published', 'unpublished', or 'unreachable'.
|
|
416
|
+
export async function probeOrigin({ origin, slug, version, fetchImpl = globalThis.fetch, timeoutMs = 3000 }) {
|
|
417
|
+
try {
|
|
418
|
+
const response = await fetchImpl(`${origin}/harness/${slug}/${version}/manifest.json`, {
|
|
419
|
+
method: 'HEAD',
|
|
420
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
421
|
+
});
|
|
422
|
+
return response.ok ? 'published' : 'unpublished';
|
|
423
|
+
} catch {
|
|
424
|
+
return 'unreachable';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export {
|
|
429
|
+
DEFAULT_ORIGIN,
|
|
430
|
+
MOUNT_BLOCK_START,
|
|
431
|
+
MOUNT_BLOCK_END,
|
|
432
|
+
sha256Of,
|
|
433
|
+
cacheRoot,
|
|
434
|
+
isMountedPath,
|
|
435
|
+
hasWorkerConfig,
|
|
436
|
+
compareVersions,
|
|
437
|
+
mountSettings,
|
|
438
|
+
resolveManifest,
|
|
439
|
+
readObject,
|
|
440
|
+
mountFromManifest,
|
|
441
|
+
ignoreBlockLines,
|
|
442
|
+
writeIgnoreBlock,
|
|
443
|
+
recordMount,
|
|
444
|
+
mountHarness,
|
|
445
|
+
readLatest,
|
|
446
|
+
stageVersion,
|
|
447
|
+
checkForUpdate,
|
|
448
|
+
};
|
package/lib/install/plan.mjs
CHANGED
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
// project's living copy, never refreshed and never pruned.
|
|
22
22
|
// merge — folded into whatever the project already has, key by key. Only `.claude/settings.json`,
|
|
23
23
|
// which carries the user's own hooks and permissions beside the harness's.
|
|
24
|
+
// mount — the gitignored harness (`isMountedPath`), written here and at session start by `mount.mjs`.
|
|
24
25
|
|
|
25
26
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
26
27
|
import { join, relative, sep } from 'node:path';
|
|
27
28
|
import { buildCodexAdapter } from '../codex/agents-md.mjs';
|
|
28
29
|
import { toCodexCommand } from '../codex/hooks.mjs';
|
|
30
|
+
import { isMountedPath, hasWorkerConfig, DEFAULT_ORIGIN } from './mount.mjs';
|
|
29
31
|
|
|
30
32
|
// Subfolders bundled inside each harness payload that map 1:1 onto <project>/.claude/<name>/.
|
|
31
33
|
// Order matches Claude Code's own folder layout. hooks/ carries the hook scripts + lib/ (and
|
|
@@ -166,30 +168,7 @@ function codexHooks(bundleDir) {
|
|
|
166
168
|
const CAPABILITY_RULES = { 'cloudflare.md': targetHasWorkerConfig };
|
|
167
169
|
|
|
168
170
|
function targetHasWorkerConfig(targetDir) {
|
|
169
|
-
|
|
170
|
-
if (names.some((n) => existsSync(join(targetDir, n)))) return true;
|
|
171
|
-
let entries = [];
|
|
172
|
-
try {
|
|
173
|
-
entries = readdirSync(targetDir, { withFileTypes: true });
|
|
174
|
-
} catch {
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
for (const e of entries) {
|
|
178
|
-
if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
179
|
-
if (names.some((n) => existsSync(join(targetDir, e.name, n)))) return true;
|
|
180
|
-
// one more level: packages/*/wrangler.jsonc — the common monorepo shape
|
|
181
|
-
let sub = [];
|
|
182
|
-
try {
|
|
183
|
-
sub = readdirSync(join(targetDir, e.name), { withFileTypes: true });
|
|
184
|
-
} catch {
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
for (const s of sub) {
|
|
188
|
-
if (!s.isDirectory() || s.name === 'node_modules' || s.name.startsWith('.')) continue;
|
|
189
|
-
if (names.some((n) => existsSync(join(targetDir, e.name, s.name, n)))) return true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return false;
|
|
171
|
+
return hasWorkerConfig(targetDir);
|
|
193
172
|
}
|
|
194
173
|
|
|
195
174
|
// A rule the target's capabilities exclude. No targetDir (payload verification, byte-identity
|
|
@@ -209,19 +188,32 @@ function ruleExcluded(sub, rel, targetDir) {
|
|
|
209
188
|
//
|
|
210
189
|
// Read from the target, never from the bundle. An absent or unreadable marker returns nothing and
|
|
211
190
|
// the manifest default stands.
|
|
191
|
+
// `mount.origin` and `mount.track` are consumer-owned the same way.
|
|
212
192
|
function consumerOwnedMarkerKeys(targetDir) {
|
|
213
193
|
if (!targetDir) return {};
|
|
214
194
|
try {
|
|
215
195
|
const prior = JSON.parse(readFileSync(join(targetDir, '.claude', 'harness.json'), 'utf8'));
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
196
|
+
const keys = {};
|
|
197
|
+
if (typeof prior.docsRoot === 'string' && prior.docsRoot.trim()) keys.docsRoot = prior.docsRoot.trim();
|
|
198
|
+
const mount = prior.mount && typeof prior.mount === 'object' ? prior.mount : {};
|
|
199
|
+
if (typeof mount.origin === 'string' && mount.origin.trim()) keys.origin = mount.origin.trim();
|
|
200
|
+
if (mount.track === 'latest' || mount.track === 'pinned') keys.track = mount.track;
|
|
201
|
+
return keys;
|
|
219
202
|
} catch (e) {
|
|
220
203
|
return {};
|
|
221
204
|
}
|
|
222
205
|
}
|
|
223
206
|
|
|
224
|
-
|
|
207
|
+
// The marker's `mount` block: the CLI's flags, else the consumer's value, else the bundle's, else the defaults.
|
|
208
|
+
function markerMount(meta, prior, override = {}) {
|
|
209
|
+
const bundled = meta.mount && typeof meta.mount === 'object' ? meta.mount : {};
|
|
210
|
+
return {
|
|
211
|
+
origin: override.origin || prior.origin || bundled.origin || DEFAULT_ORIGIN,
|
|
212
|
+
track: override.track || prior.track || bundled.track || 'latest',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function planHarnessFiles(bundleDir, targetDir = null, options = {}) {
|
|
225
217
|
const claudeMdPath = join(bundleDir, 'CLAUDE.md');
|
|
226
218
|
if (!existsSync(claudeMdPath)) {
|
|
227
219
|
throw new Error(`Bundled CLAUDE.md missing at ${claudeMdPath}. Rebuild package data.`);
|
|
@@ -300,9 +292,11 @@ export function planHarnessFiles(bundleDir, targetDir = null) {
|
|
|
300
292
|
const markerPath = join(bundleDir, 'harness.json');
|
|
301
293
|
const meta = existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, 'utf8')) : {};
|
|
302
294
|
if (existsSync(markerPath)) {
|
|
295
|
+
const { origin, track, ...prior } = consumerOwnedMarkerKeys(targetDir);
|
|
296
|
+
const mount = markerMount(meta, { origin, track }, options.mount);
|
|
303
297
|
files.push({
|
|
304
298
|
path: '.claude/harness.json',
|
|
305
|
-
content: asJson({ ...meta, ...
|
|
299
|
+
content: asJson({ ...meta, ...prior, mount, owned }),
|
|
306
300
|
policy: 'own',
|
|
307
301
|
});
|
|
308
302
|
}
|
|
@@ -318,6 +312,7 @@ export function planHarnessFiles(bundleDir, targetDir = null) {
|
|
|
318
312
|
// Sorted so the plan is byte-stable across machines — readdir order is not guaranteed, and an
|
|
319
313
|
// unstable order would make two identical builds look different to anything comparing them.
|
|
320
314
|
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
315
|
+
for (const file of files) if (file.policy === 'own' && isMountedPath(file.path)) file.policy = 'mount';
|
|
321
316
|
|
|
322
317
|
const seen = new Set();
|
|
323
318
|
for (const file of files) {
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.20.
|
|
4
|
-
"description": "Bootstrap a project with a Nurix harness —
|
|
3
|
+
"version": "0.20.28",
|
|
4
|
+
"description": "Bootstrap a project with a Nurix harness — writes the committed contract (CLAUDE.md, AGENTS.md, .claude/harness.json, settings.json, the mount hook) and mounts the harness itself, gitignored, from a bundled payload; sessions keep it current from the CDN.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"etna": "bin/etna.js"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./mount": "./lib/install/mount.mjs"
|
|
11
|
+
},
|
|
9
12
|
"dependencies": {
|
|
10
13
|
"@clack/prompts": "^0.11.0"
|
|
11
14
|
},
|
|
@@ -43,5 +46,5 @@
|
|
|
43
46
|
"agent",
|
|
44
47
|
"plugin"
|
|
45
48
|
],
|
|
46
|
-
"etnaFingerprint": "
|
|
49
|
+
"etnaFingerprint": "47047f5258d8"
|
|
47
50
|
}
|