javi-forge 1.29.0 → 1.30.0

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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Platform-agnostic transactional file-write core for the SkillGuard Claude
3
+ * PreToolUse installer (Slice 3a). This module owns all irreversible I/O behind
4
+ * a single `PlatformSecureFs` adapter interface: it NEVER spawns a process and
5
+ * NEVER branches on `process.platform`. Every ownership/identity/ACL/exclusive
6
+ * decision is delegated to the injected adapter, so the engine is reviewed once
7
+ * (platform-free) and exercised by a synchronous in-memory fake in tests.
8
+ *
9
+ * WU-2 defines the interface + result types here so the POSIX adapter can
10
+ * implement them; WU-3 grows this file with `runTransaction` + `TransactionDeps`.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import path from "node:path";
14
+ /** Bounded nonce retries for an exclusive backup create. */
15
+ const BACKUP_NONCE_CANDIDATES = 8;
16
+ class TxAbort extends Error {
17
+ step;
18
+ detail;
19
+ constructor(step, detail) {
20
+ super(`${step}${detail ? `: ${detail}` : ""}`);
21
+ this.step = step;
22
+ this.detail = detail;
23
+ }
24
+ }
25
+ function must(step, res) {
26
+ if (!res.ok)
27
+ throw new TxAbort(step, res.detail ?? res.refusal);
28
+ return res.value;
29
+ }
30
+ function sha256(bytes) {
31
+ return createHash("sha256").update(bytes).digest("hex");
32
+ }
33
+ /** Compact ISO-ms stamp: 2026-08-16T19:00:00.123Z -> 20260816T190000123Z. */
34
+ function timestamp(clock) {
35
+ return clock().toISOString().replace(/[-:.]/g, "");
36
+ }
37
+ function backupName(base, clock, nonce) {
38
+ return `${base}.javi-forge.bak.${timestamp(clock)}.${nonce}`;
39
+ }
40
+ function tempName(base, nonce) {
41
+ return `${base}.javi-forge.tmp.${process.pid}.${nonce}`;
42
+ }
43
+ /** Existing directory chain from the filesystem root through `leaf`, root first. */
44
+ function ancestorChain(leaf) {
45
+ const chain = [];
46
+ let current = leaf;
47
+ while (true) {
48
+ chain.push(current);
49
+ const parent = path.dirname(current);
50
+ if (parent === current)
51
+ break;
52
+ current = parent;
53
+ }
54
+ return chain.reverse();
55
+ }
56
+ /**
57
+ * Run the staged, two-target transaction. Preflight gates the private parent
58
+ * chain, creates `.claude`/`.claude/hooks` one exclusive `0o700` segment at a
59
+ * time, captures prior bytes (+ a forced-only persistent backup), stages each
60
+ * new byte set into a same-directory `0o600` temp, re-proves the full gate
61
+ * immediately before the first rename (JD-007), then commits asset-first and
62
+ * settings-second via same-directory rename + parent fsync. A failure after a
63
+ * commit triggers guarded reverse-order rollback from the in-memory captured
64
+ * bytes; lost proof or a post-write hash drift STOPS cleanup and returns
65
+ * manual-recovery guidance (never clobbers a concurrent change).
66
+ */
67
+ export async function runTransaction(input) {
68
+ const { secureFs, clock, nonce, projectDir } = input;
69
+ const claudeDir = path.join(projectDir, ".claude");
70
+ const hooksDir = path.join(claudeDir, "hooks");
71
+ const heldByPath = new Map();
72
+ const heldOrder = [];
73
+ const createdDirs = [];
74
+ const staged = [];
75
+ const committed = [];
76
+ const backups = [];
77
+ const needsWrite = (c) => c.desired !== null;
78
+ const anyWrite = needsWrite(input.asset) || needsWrite(input.settings);
79
+ async function gate(dirPath, handle) {
80
+ must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
81
+ must(`acl ${dirPath}`, await secureFs.proveNoExtendedAcl(dirPath));
82
+ heldByPath.set(dirPath, handle);
83
+ heldOrder.push(handle);
84
+ }
85
+ async function ensureDir(parent, fullPath) {
86
+ const opened = await secureFs.openDirNoFollow(fullPath);
87
+ if (opened.ok && opened.value) {
88
+ await gate(fullPath, opened.value);
89
+ return opened.value;
90
+ }
91
+ const created = must(`create ${fullPath}`, await secureFs.createDirExclusive(parent, path.basename(fullPath), 0o700));
92
+ // Post-create identity revalidation + full gate on the new segment.
93
+ must(`revalidate-created ${fullPath}`, await secureFs.revalidateIdentity(fullPath, created.identity));
94
+ createdDirs.push(created);
95
+ await gate(fullPath, created);
96
+ return created;
97
+ }
98
+ async function gateStillValid() {
99
+ for (const handle of heldOrder) {
100
+ const id = await secureFs.revalidateIdentity(handle.path, handle.identity);
101
+ if (!id.ok)
102
+ return false;
103
+ if (!(await secureFs.proveOwnershipAndMode(handle.path)).ok)
104
+ return false;
105
+ if (!(await secureFs.proveNoExtendedAcl(handle.path)).ok)
106
+ return false;
107
+ }
108
+ return true;
109
+ }
110
+ try {
111
+ // --- PREFLIGHT: gate the existing chain root..projectDir ---
112
+ for (const dirPath of ancestorChain(projectDir)) {
113
+ const handle = must(`openDir ${dirPath}`, await secureFs.openDirNoFollow(dirPath));
114
+ await gate(dirPath, handle);
115
+ }
116
+ // --- SEGMENT CREATION: .claude then .claude/hooks, one at a time ---
117
+ if (anyWrite) {
118
+ const projectHandle = heldByPath.get(projectDir);
119
+ const claudeHandle = await ensureDir(projectHandle, claudeDir);
120
+ if (needsWrite(input.asset))
121
+ await ensureDir(claudeHandle, hooksDir);
122
+ }
123
+ // --- CAPTURE + (FORCED) BACKUP + STAGE, asset then settings ---
124
+ for (const component of [input.asset, input.settings]) {
125
+ if (!needsWrite(component))
126
+ continue;
127
+ const parentPath = path.dirname(component.path);
128
+ const dir = heldByPath.get(parentPath);
129
+ const base = path.basename(component.path);
130
+ must(`revalidate ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
131
+ let prior = null;
132
+ if (component.capturePrior || component.forceBackup) {
133
+ prior = must(`capture ${component.path}`, await secureFs.captureFile(component.path));
134
+ must(`source-acl ${component.path}`, await secureFs.proveNoExtendedAcl(component.path));
135
+ if (component.forceBackup) {
136
+ backups.push(await writeBackup(dir, base, prior));
137
+ }
138
+ }
139
+ const tName = tempName(base, nonce());
140
+ must(`stage ${tName}`, await secureFs.writeExclusive(dir, tName, component.desired, 0o600));
141
+ must(`stage-mode ${tName}`, await secureFs.applyExactMode(path.join(dir.path, tName), prior ? prior.mode : 0o600));
142
+ // Identity revalidation after each writeExclusive (JD-B-005).
143
+ must(`revalidate-staged ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
144
+ staged.push({ dir, tempName: tName, target: component, prior });
145
+ }
146
+ // --- PRE-FIRST-RENAME FULL-GATE RE-PROVE (JD-007) ---
147
+ for (const handle of heldOrder) {
148
+ must(`recheck-id ${handle.path}`, await secureFs.revalidateIdentity(handle.path, handle.identity));
149
+ must(`recheck-own ${handle.path}`, await secureFs.proveOwnershipAndMode(handle.path));
150
+ must(`recheck-acl ${handle.path}`, await secureFs.proveNoExtendedAcl(handle.path));
151
+ }
152
+ // --- COMMIT: asset first, settings second ---
153
+ for (const entry of staged) {
154
+ const base = path.basename(entry.target.path);
155
+ must(`pre-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
156
+ const wroteHash = sha256(entry.target.desired);
157
+ must(`rename ${base}`, await secureFs.renameInDir(entry.dir, entry.tempName, base));
158
+ must(`post-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
159
+ committed.push({
160
+ path: entry.target.path,
161
+ dir: entry.dir,
162
+ wroteHash,
163
+ wasAbsent: entry.target.wasAbsent,
164
+ prior: entry.prior,
165
+ });
166
+ }
167
+ return {
168
+ ok: true,
169
+ committed: committed.map((c) => c.path),
170
+ backups,
171
+ errors: [],
172
+ };
173
+ }
174
+ catch (error) {
175
+ const errors = [
176
+ error instanceof TxAbort ? error.message : String(error),
177
+ ];
178
+ await rollback(committed, createdDirs, errors);
179
+ return {
180
+ ok: false,
181
+ committed: committed.map((c) => c.path),
182
+ backups,
183
+ errors,
184
+ };
185
+ }
186
+ finally {
187
+ for (const handle of [...createdDirs, ...heldOrder]) {
188
+ await handle.close().catch(() => { });
189
+ }
190
+ }
191
+ async function writeBackup(dir, base, prior) {
192
+ for (let attempt = 0; attempt < BACKUP_NONCE_CANDIDATES; attempt++) {
193
+ const name = backupName(base, clock, nonce());
194
+ const written = await secureFs.writeExclusive(dir, name, prior.bytes, 0o600);
195
+ if (!written.ok)
196
+ continue; // collision or transient — retry with a fresh nonce
197
+ must(`backup-mode ${name}`, await secureFs.applyExactMode(path.join(dir.path, name), prior.mode));
198
+ must(`backup-revalidate ${dir.path}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
199
+ return path.join(dir.path, name);
200
+ }
201
+ throw new TxAbort("backup", `no exclusive backup name after ${BACKUP_NONCE_CANDIDATES}`);
202
+ }
203
+ async function rollback(done, created, errors) {
204
+ for (const entry of [...done].reverse()) {
205
+ if (!(await gateStillValid())) {
206
+ errors.push(`STOP: lost parent-chain proof; manual recovery at ${entry.path}`);
207
+ return;
208
+ }
209
+ const current = await secureFs.captureFile(entry.path);
210
+ if (!current.ok || !current.value) {
211
+ errors.push(`STOP: cannot re-read ${entry.path}; manual recovery`);
212
+ return;
213
+ }
214
+ if (current.value.sha256 !== entry.wroteHash) {
215
+ errors.push(`STOP: ${entry.path} changed after commit; manual recovery (concurrent edit)`);
216
+ return;
217
+ }
218
+ const base = path.basename(entry.path);
219
+ if (entry.wasAbsent) {
220
+ await secureFs.unlinkIfIdentity(entry.dir, base, current.value.identity);
221
+ }
222
+ else if (entry.prior) {
223
+ const rName = tempName(base, nonce());
224
+ const wrote = await secureFs.writeExclusive(entry.dir, rName, entry.prior.bytes, 0o600);
225
+ if (!wrote.ok) {
226
+ errors.push(`STOP: cannot stage rollback for ${entry.path}`);
227
+ return;
228
+ }
229
+ await secureFs.applyExactMode(path.join(entry.dir.path, rName), entry.prior.mode);
230
+ await secureFs.renameInDir(entry.dir, rName, base);
231
+ }
232
+ }
233
+ // Remove only tx-created, identity-matched, still-empty segments, child-first.
234
+ for (const handle of [...created].reverse()) {
235
+ await secureFs.rmdirIfIdentityEmpty(handle);
236
+ }
237
+ }
238
+ }
239
+ //# sourceMappingURL=secure-fs-transaction.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {