@opum-ai/lore 0.1.0 → 0.1.1

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.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,928 +0,0 @@
1
- /**
2
- * commands/fswrite.ts — the shared write seam for file-writing commands.
3
- *
4
- * Two write disciplines live here, one per command family:
5
- *
6
- * - **never-clobber** — `lore init` and `lore new` create files **only when absent**
7
- * ({@link createIfAbsent}), so a re-run (or a pre-existing user edit) is never overwritten.
8
- * A directory or symlink sitting where a file must go is a `conflict` {@link LoreError}
9
- * (exit 5) for both, instead of each command re-deriving the `wx`/`EEXIST`/`lstat` dance.
10
- * - **overwrite** — the refactoring commands (`lore replace`/`rename`/`supersede`) rewrite
11
- * the bytes of files that already exist. This is deliberately *not* never-clobber: the whole
12
- * point is to edit an existing doc in place. Most of these write a single file (or a handful) per
13
- * run and use the plain {@link writeFileOverwriting}; `lore replace` can rewrite many files in one
14
- * invocation, like `lore sync` below, so it uses the atomic {@link writeFileAtomic} instead
15
- * (LORE-116) — a crash or I/O error partway through must never leave one of those files truncated.
16
- *
17
- * Both disciplines share one symlink guard ({@link assertNoSymlinkInPath}, LORE-76/LORE-77), so a
18
- * symlinked ancestor directory or final target refuses loudly instead of silently redirecting a
19
- * write outside the repo. {@link ensureDir} itself calls it (LORE-93), so every caller that
20
- * creates a parent directory before writing gets the guard automatically — `lore init`'s
21
- * never-clobber loop and `lore scaffold`'s all-or-nothing `writeAllOrRollback` no longer need
22
- * their own separate call. A multi-file caller additionally sweeps its whole planned write set
23
- * with {@link assertNoSymlinkInAnyPath} before writing any single file, so a bad target refuses
24
- * the operation up front rather than after some files already landed.
25
- *
26
- * {@link classifyExistingFile} is a third, narrower helper for the never-clobber family: not a
27
- * write itself, but the byte-comparison `lore scaffold` uses (LORE-263) to tell an unchanged
28
- * re-run (nothing to do) apart from a genuine user edit (a real `conflict`) instead of treating
29
- * every pre-existing file the same way `createIfAbsent`'s plain absent/present check does.
30
- *
31
- * The overwrite discipline's two write-temp-then-rename commands ({@link writeFileAtomic},
32
- * {@link writeFileNoFollow}) share one more thing: their final commit `renameSync` goes through
33
- * {@link renameOverDestination} (LORE-256), a small bounded retry-with-backoff on the Windows
34
- * transient-lock codes (`EPERM`/`EBUSY`/`EACCES`) an antivirus scanner or the Search indexer can
35
- * intermittently trigger on that rename — see that function's own docstring.
36
- *
37
- * All side effects live in the command layer (lore-design §2.1); core stays pure. These
38
- * helpers are that side-effecting layer, factored to one module so the filesystem-conflict
39
- * semantics are identical across every command.
40
- */
41
-
42
- import {
43
- chmodSync,
44
- chownSync,
45
- existsSync,
46
- lstatSync,
47
- mkdirSync,
48
- readFileSync,
49
- renameSync,
50
- rmdirSync,
51
- rmSync,
52
- statSync,
53
- unlinkSync,
54
- writeFileSync,
55
- } from "node:fs";
56
- import { dirname, join } from "node:path";
57
- import { errnoCode, LoreError } from "../errors";
58
-
59
- /**
60
- * Refuse if any path segment from `root` down to (and including) `relPath` already exists as a
61
- * symlink — `lstatSync` per segment, never following. Standard `mkdirSync`/`writeFileSync` calls
62
- * always transparently resolve symlinks in the MIDDLE of a path (that's ordinary POSIX path
63
- * resolution, not something an `O_CREAT`/`O_EXCL`-style flag can disable) — no write discipline in
64
- * this module was ever safe against a symlinked ANCESTOR directory on its own. (A symlinked FINAL
65
- * component is a narrower story: `createIfAbsent`'s own `wx`+`lstat` check already refused that case
66
- * for `lore init`'s never-clobber writes before this guard existed, and `writeAllOrRollback`'s
67
- * `--force` overwrite branch follows a symlink at the final component too — this guard closes BOTH
68
- * gaps uniformly, at every segment, rather than leaving each write discipline with its own partial,
69
- * differently-shaped protection.) Shared by every write discipline this module owns:
70
- * `writeAllOrRollback`'s all-or-nothing scaffold writes (LORE-76) and `lore init`'s own
71
- * never-clobber `ensureDir`/`createIfAbsent` loop (LORE-77) both call this, rather than each
72
- * re-deriving the same `lstatSync`-per-segment walk. Mirrors this codebase's established READ-path
73
- * convention (`core/bundle.ts`, `commands/replace.ts`: explicit `lstatSync(...).isSymbolicLink()`,
74
- * never a stat-follows-symlinks helper) rather than inventing a new pattern. A path segment that
75
- * does not exist yet is fine — there is nothing to guard against until something is actually there
76
- * to redirect through.
77
- */
78
- export function assertNoSymlinkInPath(root: string, relPath: string): void {
79
- const segment = findSymlinkSegment(root, relPath);
80
- if (segment !== null) {
81
- throw new LoreError(
82
- "conflict",
83
- `refusing to write ${relPath}: "${segment}" is a symlink, not a real directory or file`,
84
- "lore does not write through a symlink (it may resolve outside the repo) — remove or replace it, then re-run",
85
- { path: relPath, symlink: segment },
86
- );
87
- }
88
- }
89
-
90
- /**
91
- * The same per-segment `lstatSync` walk {@link assertNoSymlinkInPath} throws on, exposed as a
92
- * non-throwing query: the first path segment (from `root` down to `relPath`) that already exists as
93
- * a symlink, or `null` if none does. Lets a caller that wants to silently treat a symlinked path as
94
- * "not the directory I think it is" (rather than refuse the whole operation) reuse the identical
95
- * walk instead of re-deriving it — see `commands/schema.ts`'s `isManagedSchemasDir`.
96
- */
97
- export function findSymlinkSegment(root: string, relPath: string): string | null {
98
- let prefix = root;
99
- for (const segment of relPath.split("/")) {
100
- if (segment === "") {
101
- continue; // relPath is always a POSIX-relative path; never emits an empty leading segment
102
- }
103
- prefix = join(prefix, segment);
104
- let stat: ReturnType<typeof lstatSync>;
105
- try {
106
- stat = lstatSync(prefix);
107
- } catch {
108
- continue; // does not exist yet at this segment — nothing here to redirect through
109
- }
110
- if (stat.isSymbolicLink()) {
111
- return segment;
112
- }
113
- }
114
- return null;
115
- }
116
-
117
- /**
118
- * `mkdir -p` for a directory a write is about to target, refusing first (via
119
- * {@link assertNoSymlinkInPath}) if any segment from `root` down to `relPath` is already a
120
- * symlink — every caller gets the LORE-76/77 guard for free rather than having to remember to
121
- * call it separately (LORE-93: five call sites — `new.ts`, `agents.ts`, `sync.ts`, `schema.ts`,
122
- * `rename.ts` — had no guard at all before this, since `mkdirSync` transparently follows a
123
- * symlinked ancestor). A permission failure maps to `denied`.
124
- */
125
- export function ensureDir(root: string, relPath: string): void {
126
- assertNoSymlinkInPath(root, relPath);
127
- try {
128
- mkdirSync(join(root, relPath), { recursive: true });
129
- } catch (cause) {
130
- throw ioError(cause, relPath, "create directory");
131
- }
132
- }
133
-
134
- /**
135
- * Refuse (via {@link assertNoSymlinkInPath}) if ANY of `relPaths` has a symlinked ancestor —
136
- * a preflight sweep across a whole planned multi-file write set, run BEFORE any single write in
137
- * that set begins. `ensureDir`'s own per-call guard alone is reactive: in a loop writing several
138
- * files, it would only refuse when the LOOP REACHES the bad target, by which point earlier
139
- * targets in the same set may already be written — exactly the partial-write outcome LORE-93
140
- * AC#5 says a multi-file operation must never produce. Calling this once, before the loop starts,
141
- * makes the operation either fully proceed or refuse before touching anything. Reuses the
142
- * identical per-segment walk `ensureDir` itself uses, rather than a second check pattern.
143
- */
144
- export function assertNoSymlinkInAnyPath(root: string, relPaths: Iterable<string>): void {
145
- for (const relPath of relPaths) {
146
- assertNoSymlinkInPath(root, relPath);
147
- }
148
- }
149
-
150
- /**
151
- * Atomically create a file only if it does not exist (`flag: "wx"`), returning `true`
152
- * when it was created and `false` when a regular file already existed. Using `wx` rather
153
- * than an `existsSync` precheck closes the TOCTOU window and guarantees the never-clobber
154
- * contract: a concurrent or pre-existing file is left exactly as it was. A **non-regular**
155
- * entry (directory, symlink, …) occupying the path is a structural `conflict`, not a benign
156
- * skip — surfaced so a malformed bundle never reads as a clean re-run.
157
- *
158
- * The classifying {@link existingIsRegularFile} stat itself can fail two different ways
159
- * (LORE-230): the documented raced-away case (the entry vanished again after `wx` just proved
160
- * it was there — `ENOENT`) degrades to that same benign skip, but any OTHER failure (`EACCES`,
161
- * `EIO`, …) is a genuine fault on an entry that demonstrably exists, not a benign condition —
162
- * it propagates out of {@link existingIsRegularFile} and is classified here via the shared
163
- * {@link ioError}, exactly like the `wx` write's own failures below, rather than being
164
- * misreported as "already exists, skipped".
165
- */
166
- export function createIfAbsent(absPath: string, contents: string, relPath: string): boolean {
167
- try {
168
- writeFileSync(absPath, contents, { flag: "wx" });
169
- return true;
170
- } catch (cause) {
171
- if (errnoCode(cause) === "EEXIST") {
172
- let isRegular: boolean;
173
- try {
174
- isRegular = existingIsRegularFile(absPath);
175
- } catch (statCause) {
176
- throw ioError(statCause, relPath, "check existing file");
177
- }
178
- if (isRegular) {
179
- return false;
180
- }
181
- throw conflictError(relPath);
182
- }
183
- throw ioError(cause, relPath, "write file");
184
- }
185
- }
186
-
187
- /**
188
- * Whether the entry already at `absPath` is absent, present with byte-identical `contents`, or
189
- * present but different — the three-way classification `lore scaffold`'s idempotent-when-unchanged
190
- * preflight needs (LORE-263), so a bare re-run can tell "nothing to do" (every generated file
191
- * already matches) apart from "the user edited this" (a real conflict `--force` is needed to fix)
192
- * without conflating the two the way a plain `existsSync` check does.
193
- *
194
- * Uses `lstatSync`, never following a symlink — mirrors this module's other never-clobber checks
195
- * ({@link existingIsRegularFile}) rather than inventing a new pattern. A **non-regular** entry
196
- * (directory, symlink, …) occupying the path is always `"differs"`, never `"unchanged"`: silently
197
- * treating a symlink whose target happens to read back identical bytes as "nothing to do" would
198
- * skip the write loop entirely and, with it, {@link assertNoSymlinkInPath}'s own guard — exactly the
199
- * write-outside-the-repo hazard LORE-76 closed. A read failure on an entry that does exist (e.g.
200
- * `EACCES`) is classified the same conservative way, for the identical reason: this function must
201
- * never claim a match it cannot prove. Only a path with nothing there yet — i.e. the initial
202
- * `lstat` itself failing, whether the file was never created or vanished in a race just BEFORE
203
- * this call reached it — degrades to `"missing"` (a benign, self-resolving condition, not a real
204
- * problem). A file that instead vanishes AFTER that `lstat` succeeds but before the subsequent
205
- * read (a narrower race) is conservatively classified `"differs"`, not `"missing"`, by the read
206
- * catch below — this function must never claim a match it cannot prove, so only the lstat-time
207
- * absence is treated as unambiguously benign.
208
- */
209
- export function classifyExistingFile(absPath: string, contents: string): "missing" | "unchanged" | "differs" {
210
- let stat: ReturnType<typeof lstatSync>;
211
- try {
212
- stat = lstatSync(absPath);
213
- } catch {
214
- return "missing";
215
- }
216
- if (!stat.isFile()) {
217
- return "differs";
218
- }
219
- let onDisk: string;
220
- try {
221
- onDisk = readFileSync(absPath, "utf8");
222
- } catch {
223
- return "differs";
224
- }
225
- return onDisk === contents ? "unchanged" : "differs";
226
- }
227
-
228
- /**
229
- * Overwrite (or create) a file with `contents`, the write discipline the refactoring commands
230
- * need: `lore replace`/`rename`/`supersede` edit the bytes of a doc that already exists, so —
231
- * unlike {@link createIfAbsent} — an existing regular file is *meant* to be replaced. Parent
232
- * directories are assumed to exist (a refactor writes back over a file it just read); a
233
- * permission failure maps to `denied` and a non-regular entry blocking the path to `conflict`,
234
- * via the shared {@link ioError}.
235
- */
236
- export function writeFileOverwriting(absPath: string, contents: string, relPath: string): void {
237
- try {
238
- writeFileSync(absPath, contents);
239
- } catch (cause) {
240
- throw ioError(cause, relPath, "write file");
241
- }
242
- }
243
-
244
- /**
245
- * The `errno` codes {@link renameOverDestination} treats as a transient, worth-retrying failure on
246
- * the commit rename: on Windows, an antivirus scanner or the Search indexer can briefly hold the
247
- * destination open, making an otherwise-correct `renameSync` intermittently fail with one of these
248
- * even though nothing is actually wrong with the write (LORE-256). Gated on the *errno code*, not
249
- * `process.platform` — the check is cheap, and a POSIX run essentially never produces one of these
250
- * codes in the narrow window between this module's own temp-file create and its commit rename
251
- * (nothing else in-process holds the destination open then), so this changes nothing observable
252
- * about the common POSIX case: still exactly one `renameSync` call, same as before this fix. A
253
- * PERSISTENT failure (POSIX or Windows) still ends in the identical classified error once the retry
254
- * budget below exhausts — retrying only delays, never changes, that outcome.
255
- */
256
- const RENAME_RETRY_CODES: ReadonlySet<string> = new Set(["EPERM", "EBUSY", "EACCES"]);
257
-
258
- /** Total `renameSync` attempts {@link renameOverDestination} makes before giving up — the first
259
- * attempt plus up to `RENAME_MAX_ATTEMPTS - 1` retries (4 total = 1 + 3). Small and bounded on
260
- * purpose: a genuinely stuck lock (or a real, persistent permission problem, which shares these
261
- * same codes) must still fail promptly rather than hang the command. */
262
- const RENAME_MAX_ATTEMPTS = 4;
263
-
264
- /** Backoff before retry number `attempt` (1-indexed: the delay after attempt `attempt` has just
265
- * failed, before the next one) — doubles each retry (20ms, 40ms, 80ms — ~140ms total at the
266
- * current {@link RENAME_MAX_ATTEMPTS} budget of 4), covering the sub-second window these transient
267
- * Windows locks typically clear within without the retry budget itself taking long to exhaust.
268
- * The `Math.min(..., 100)` cap is defensive headroom for a future `RENAME_MAX_ATTEMPTS` increase;
269
- * it never binds at the current budget (attempt only ever reaches 3, giving 80ms as the largest
270
- * value actually produced). */
271
- function renameRetryDelayMs(attempt: number): number {
272
- return Math.min(20 * 2 ** (attempt - 1), 100);
273
- }
274
-
275
- /**
276
- * A synchronous, blocking sleep for {@link renameOverDestination}'s backoff — `Bun.sleepSync`, not a
277
- * busy-wait spin. This package's `src/` runs on Bun only (`package.json` `engines.bun >= 1.2.23`;
278
- * `src/` already uses `Bun.spawn`, `Bun.spawnSync`, `Bun.Glob`, `Bun.TOML` elsewhere — only
279
- * `bin/lore.cjs` runs under Node), so `Bun.sleepSync` is a first-party synchronous sleep, not an
280
- * unverified cross-platform primitive: no busy-wait, no wall-clock (`Date.now()`) dependency, no
281
- * CPU pegged for the duration, and no event-loop-pegging spin. Kept as its own named function
282
- * (rather than calling `Bun.sleepSync` directly from {@link renameOverDestination}) as the single
283
- * seam for the backoff delay, matching this module's convention of factoring each discrete step of
284
- * a write discipline into its own named helper. This path currently has no dedicated test guard —
285
- * mutating the delay to zero still leaves every test in this suite passing.
286
- */
287
- function blockingSleep(ms: number): void {
288
- Bun.sleepSync(ms);
289
- }
290
-
291
- /**
292
- * `renameSync(tmpPath, absPath)` — the commit step both {@link writeFileAtomic} and
293
- * {@link writeFileNoFollow} share for their write-temp-then-rename discipline — wrapped with a
294
- * small bounded retry-with-backoff on the Windows transient-lock codes ({@link RENAME_RETRY_CODES},
295
- * LORE-256). Any OTHER failure code (`ENOENT`, `EISDIR`, …) is never retried: it is not a
296
- * transient-lock symptom, so surfacing it immediately is both faster and behaves exactly as it did
297
- * before this function existed. When the retry budget exhausts, the LAST failure is what's thrown —
298
- * both callers' existing `catch` blocks classify it via {@link ioError} exactly as they always have
299
- * (e.g. a persistent `EACCES`/`EPERM` still becomes the same `denied` {@link LoreError}), so a
300
- * genuinely persistent failure is never swallowed into a false success; only a truly transient one
301
- * gets the extra attempts. A SYSTEMIC retryable failure (e.g. a directory that went read-only, or
302
- * an AV lockdown affecting every file) multiplies this bounded per-file budget across every write
303
- * in a multi-file caller — {@link writeManyAtomicOrRollback}'s rollback writes included — rather
304
- * than failing fast the way a single, un-retried rename would have; still bounded and rare, but
305
- * worth knowing for a large `lore sync` run under such a condition.
306
- */
307
- function renameOverDestination(tmpPath: string, absPath: string): void {
308
- let attempt = 0;
309
- for (;;) {
310
- attempt += 1;
311
- try {
312
- renameSync(tmpPath, absPath);
313
- return;
314
- } catch (cause) {
315
- const code = errnoCode(cause);
316
- if (code === undefined || !RENAME_RETRY_CODES.has(code) || attempt >= RENAME_MAX_ATTEMPTS) {
317
- throw cause;
318
- }
319
- blockingSleep(renameRetryDelayMs(attempt));
320
- }
321
- }
322
- }
323
-
324
- /**
325
- * Overwrite (or create) a file **atomically**: write the new bytes to a sibling temp file, then
326
- * `renameSync` it over `absPath`. `lore sync` (LORE-26) and `lore replace` (LORE-116) are the
327
- * commands that can write many files in a single invocation, so a crash or kill mid-run must never
328
- * leave any *one* target file truncated or half-written — a plain `writeFileSync` truncates the
329
- * destination before writing, which a crash between those two steps would leave corrupted; a
330
- * same-directory rename is atomic (same filesystem, POSIX and NTFS both guarantee it) so the
331
- * destination is always either its old complete bytes or its new complete bytes, never a partial
332
- * write. This is per-file atomicity only — a bare loop calling this directly still leaves files
333
- * already written in that same run committed if a later write throws, with no cross-file rollback;
334
- * `lore replace`'s own caller documents that as a separate, deferred concern (`lore rename`'s
335
- * `writeFileOverwriting` loop carries the identical note). `lore sync` (LORE-120) instead routes its
336
- * whole write set through {@link writeManyAtomicOrRollback}, which adds that cross-file undo on top
337
- * of this function's own per-file atomicity — see its own doc. Every other command keeps
338
- * {@link writeFileOverwriting} — see that function's own doc for why a plain overwrite is the right
339
- * discipline there.
340
- *
341
- * The temp file is otherwise created with the process's default umask, which would silently
342
- * replace any non-default mode (or ownership) the destination already had — a doc made
343
- * group-writable, or locked down to `0600`, would flip back to the umask default on every sync
344
- * write. To avoid that (LORE-117), the destination's existing mode/ownership is `statSync`'d
345
- * *before* the temp write and, when present, carried onto the temp file (mode via `chmodSync`,
346
- * ownership best-effort via `chownSync`) before the rename replaces it — so the destination's
347
- * permissions/ownership survive the swap instead of reverting to whatever the umask would produce.
348
- * A destination that doesn't exist yet (first write) has nothing to preserve: the stat is only
349
- * ever used to conditionally apply preservation, never to gate or fail the write itself, so that
350
- * case falls through to plain default-umask behavior exactly as before this fix.
351
- *
352
- * The temp file itself is created with an EXCLUSIVE `writeFileSync(tmpPath, "", { flag: "wx" })`
353
- * (`wx` == `O_CREAT | O_EXCL`) — rather than a single `writeFileSync(tmpPath, contents)` call — with
354
- * `tmpFileExists` set to `true` the instant that create returns, mirroring {@link writeFileNoFollow}'s
355
- * identical discipline (LORE-231). A single `writeFileSync(tmpPath, contents)` both creates AND writes
356
- * the temp file in one call, so a failure partway through that single call (e.g. `ENOSPC`/`EDQUOT`/`EIO`
357
- * after the directory entry is allocated but before every byte lands) left the file on disk with
358
- * `tmpFileExists` still `false` — the catch block's cleanup below was skipped, leaking a stray
359
- * `.lore-sync-tmp-*` file. Splitting create-then-write into two steps (an empty exclusive `wx` create,
360
- * then a separate `flag: "w"` byte-write) means the guard is set the moment the temp file provably,
361
- * exclusively exists, so a failure during the write step is now cleaned up exactly like a failure
362
- * anywhere else after that point already was.
363
- *
364
- * The primitive is `writeFileSync({ flag: "wx" })`, NOT a numeric-flag `openSync(O_CREAT|O_EXCL)`,
365
- * for a Windows-portability reason (LORE-252): Bun on Windows spuriously throws `ENOENT` from that
366
- * `openSync` path even when the parent directory provably exists (`ensureDir` just created it),
367
- * breaking `lore agents`/`sync`/`replace`/`schema export`/`scaffold --force`. The `wx` `writeFileSync`
368
- * carries the same exclusive-create semantics (fail with `EEXIST` if the name already exists) and is
369
- * proven to work on Bun/Windows — {@link createIfAbsent} above relies on the identical primitive.
370
- * Because `writeFileSync` loops on a short write internally (the very behavior {@link writeAllBytes}
371
- * mirrors), the temp write no longer routes through `writeAllBytes`; that helper stays exported and
372
- * unit-tested standalone.
373
- *
374
- * The commit `renameSync` itself goes through {@link renameOverDestination} (LORE-256), not a bare
375
- * `renameSync` call — see that function's own docstring for the bounded retry-with-backoff it adds
376
- * on top of the plain rename. Everything above this point (temp-file create/write, mode/ownership
377
- * preservation) runs exactly once regardless of that retry; only the final commit step can repeat.
378
- */
379
- export function writeFileAtomic(absPath: string, contents: string, relPath: string): void {
380
- const tmpPath = join(dirname(absPath), `.lore-sync-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
381
- let tmpFileExists = false;
382
- try {
383
- // Capture the destination's current mode/ownership, if it exists, BEFORE writing the temp
384
- // file, so there's something to carry over once the write succeeds. Any stat failure (no
385
- // prior file at all -- the common `ENOENT` first-write case -- or something else transient)
386
- // is treated identically: there's nothing to preserve, so the write proceeds with plain
387
- // default-umask behavior rather than erroring on what is not actually a failure condition.
388
- let destStat: ReturnType<typeof statSync> | null = null;
389
- try {
390
- destStat = statSync(absPath);
391
- } catch {
392
- destStat = null;
393
- }
394
- // LORE-252: exclusively create an EMPTY temp file first (the LORE-231 two-phase split), then
395
- // write its bytes in a separate call. The exclusive `wx` create sets the leak guard the instant
396
- // the temp provably exists; the byte-write can then fail (ENOSPC/EDQUOT/EIO) and still be cleaned
397
- // up by the catch below. `writeFileSync({flag:"wx"})` is used instead of a numeric-flag
398
- // `openSync(O_CREAT|O_EXCL)` because Bun/Windows spuriously ENOENTs on that openSync path -- see
399
- // this function's docstring. `wx` == O_CREAT|O_EXCL, so an EEXIST name collision still throws
400
- // BEFORE `tmpFileExists` flips, so cleanup never unlinks a file this call did not create.
401
- try {
402
- writeFileSync(tmpPath, "", { flag: "wx" });
403
- } catch (cause) {
404
- throw ioError(cause, relPath, "write file");
405
- }
406
- tmpFileExists = true; // true the instant the temp file provably, exclusively exists -- the wx create made it
407
- try {
408
- writeFileSync(tmpPath, Buffer.from(contents, "utf8"), { flag: "w" });
409
- } catch (cause) {
410
- throw ioError(cause, relPath, "write file");
411
- }
412
- if (destStat !== null) {
413
- // Mode preservation is not best-effort: a failure here means the file is about to land with
414
- // the wrong permissions, so it's surfaced as a write failure (below) like any other step in
415
- // this sequence, rather than silently swallowed.
416
- chmodSync(tmpPath, destStat.mode & 0o7777);
417
- try {
418
- // Ownership IS best-effort: an unprivileged process cannot `chown` to an arbitrary uid/gid
419
- // (EPERM is the expected outcome on every non-root run) and some platforms don't support
420
- // `chown` at all -- neither should fail a write that has already correctly preserved mode.
421
- chownSync(tmpPath, destStat.uid, destStat.gid);
422
- } catch {
423
- // Swallowed: see above.
424
- }
425
- }
426
- renameOverDestination(tmpPath, absPath);
427
- } catch (cause) {
428
- let cleanupFailed = false;
429
- if (tmpFileExists) {
430
- try {
431
- unlinkSync(tmpPath);
432
- } catch {
433
- // The write/rename failure below is what's primarily reported; a failure here (rare — the
434
- // process is already failing) is folded into that error's own hint/input rather than
435
- // silently dropped, so a stray `.lore-sync-tmp-*` file is at least surfaced, not silent litter.
436
- cleanupFailed = true;
437
- }
438
- }
439
- // An exclusive-create failure (e.g. EACCES on a read-only directory — the most common real-world
440
- // trigger, or an EEXIST name collision) never creates `tmpPath` at all, so cleanup is skipped
441
- // above rather than attempted and its inevitable ENOENT misreported as "cleanup failed": the
442
- // error below must never claim a temp file remains when none was ever created. `cause` may
443
- // already be a LoreError here (the inner
444
- // catches above already classified it via `ioError`) -- reclassifying it a second time is a no-op
445
- // (`ioError` returns non-errno causes unchanged), matching `writeFileNoFollow`'s identical guard.
446
- const err = cause instanceof LoreError ? cause : ioError(cause, relPath, "write file");
447
- if (cleanupFailed && err instanceof LoreError) {
448
- throw new LoreError(
449
- err.type,
450
- err.message,
451
- `${err.hint ?? ""} A temp file may also remain at ${tmpPath} — remove it manually.`.trim(),
452
- typeof err.input === "object" && err.input !== null ? { ...err.input, staleTempFile: tmpPath } : err.input,
453
- );
454
- }
455
- throw err;
456
- }
457
- }
458
-
459
- // ── Atomic multi-file writes with rollback ─────────────────────────────────────
460
-
461
- /** One file {@link writeManyAtomicOrRollback} plans to write, with enough state to undo it. */
462
- export interface AtomicRollbackWrite {
463
- /** Absolute filesystem path to write. */
464
- readonly abs: string;
465
- /** Repo-relative path, for error messages (mirrors every other write helper's `relPath`). */
466
- readonly relPath: string;
467
- /** The file's bytes immediately before this write, or `undefined` if it did not exist yet — the
468
- * state rollback restores it to. Callers must capture this from disk *before* the write phase
469
- * starts (`lore sync`'s own re-read-then-diff loop already does, per-file, for exactly this
470
- * reason — see LORE-119). */
471
- readonly before: string | undefined;
472
- /** The new bytes to write. */
473
- readonly after: string;
474
- }
475
-
476
- /**
477
- * Write every entry in `writes` via {@link writeFileAtomic} (each individual write keeping its own
478
- * per-file atomicity — a crash mid-write never truncates a single target), and if any write in the
479
- * list throws, undo every write this call already applied — in reverse order — before rethrowing:
480
- * a file that had prior content is restored to its `before` bytes; a file that did not exist before
481
- * this call (`before: undefined`) is removed. This closes the gap {@link writeFileAtomic}'s own
482
- * docstring flags ("a failure partway through a multi-file commit loop still leaves files already
483
- * written in that same run committed, with no cross-file rollback") for callers that opt in by
484
- * routing their whole write set through here instead of a bare loop (`lore sync`, LORE-120).
485
- *
486
- * `writeAtomic` is injectable (defaulting to the real {@link writeFileAtomic}) so a test can force a
487
- * failure on a specific write deterministically, mirroring {@link writeAllBytes}'s own injected
488
- * `write` — the same reason applies: a real disk failure at an exact, chosen point in a multi-file
489
- * loop is not reliably reproducible against a real filesystem.
490
- *
491
- * Rollback is best-effort, matching {@link writeAllOrRollback}'s own discipline: a failure while
492
- * undoing one entry is swallowed (rather than thrown) so the original write failure — not a
493
- * secondary cleanup failure — is what the caller ultimately sees, and every other already-applied
494
- * entry still gets its own undo attempt rather than the whole rollback aborting on the first one
495
- * that fails.
496
- */
497
- export function writeManyAtomicOrRollback(
498
- writes: readonly AtomicRollbackWrite[],
499
- writeAtomic: (absPath: string, contents: string, relPath: string) => void = writeFileAtomic,
500
- ): void {
501
- const applied: AtomicRollbackWrite[] = [];
502
- try {
503
- for (const write of writes) {
504
- writeAtomic(write.abs, write.after, write.relPath);
505
- applied.push(write);
506
- }
507
- } catch (err) {
508
- for (const write of applied.reverse()) {
509
- try {
510
- if (write.before === undefined) {
511
- rmSync(write.abs, { force: true });
512
- } else {
513
- writeAtomic(write.abs, write.before, write.relPath);
514
- }
515
- } catch {
516
- // Best-effort rollback; see the shared discipline note above.
517
- }
518
- }
519
- throw err;
520
- }
521
- }
522
-
523
- /**
524
- * Relocate a file from `fromAbs` to `toAbs` — the filesystem half of `lore rename`. A rename
525
- * **renames the source** (rather than writing a new file and deleting the old) for one critical
526
- * reason: on a case-insensitive filesystem a case-only rename (`Foo.md` → `foo.md`) targets the
527
- * **same inode**, where a write-new-then-delete-old sequence would delete the very file it just
528
- * wrote — silent data loss. `renameSync` instead atomically changes the name (including its case)
529
- * and is crash-safe: the old path is never gone before the new one exists.
530
- *
531
- * The caller's own precheck (`rename.ts`'s `assertTargetFree`) only proves the destination was free
532
- * at PLAN time — an I/O-heavy window (rewriting every inbound file, regenerating indexes) can
533
- * separate that check from this function actually running, during which another process (or a
534
- * concurrent `lore` invocation) may create a file at `toAbs`. Without a fresh check here,
535
- * `renameSync` would atomically replace it without a trace (LORE-132). {@link assertMoveTargetSafe}
536
- * re-verifies immediately before the syscall, closing that window down to this one call — a
537
- * destination that appeared in the meantime and differs from the source is reported the same
538
- * `conflict` `assertTargetFree` raises, while a destination resolving to the source's own inode (the
539
- * case-only-rename case above) is let through unchanged. A permission failure maps to `denied` and a
540
- * non-regular blocker to `conflict` via the shared {@link ioError}.
541
- */
542
- export function moveFile(fromAbs: string, toAbs: string, relPath: string): void {
543
- assertMoveTargetSafe(fromAbs, toAbs, relPath);
544
- try {
545
- renameSync(fromAbs, toAbs);
546
- } catch (cause) {
547
- throw ioError(cause, relPath, "move file");
548
- }
549
- }
550
-
551
- /**
552
- * The last-mile half of {@link moveFile}'s TOCTOU close (LORE-132): re-stat the destination
553
- * immediately before the rename syscall and refuse if it is now occupied by anything other than the
554
- * source's own inode. Identity is compared via `statSync`'s `dev`/`ino` (not a path/byte comparison,
555
- * and not `lstatSync` — this deliberately mirrors `canonicalIdentity`'s follow-symlinks semantics, so
556
- * a destination that is a symlink resolving to the source is treated the same "already the source"
557
- * way a real case-only rename is) — the ONLY way to tell "this is the same physical file under a
558
- * different spelling" (a legitimate case-only rename, which must still succeed) apart from "this is
559
- * really a different file that raced in after the plan-time check" (a genuine collision, which must
560
- * fail loudly rather than being silently replaced). Either stat failing (destination genuinely
561
- * absent, or a transient error) is treated as nothing to guard against — `renameSync` itself raises
562
- * the real error if something is actually wrong.
563
- */
564
- function assertMoveTargetSafe(fromAbs: string, toAbs: string, relPath: string): void {
565
- let destStat: ReturnType<typeof statSync>;
566
- try {
567
- destStat = statSync(toAbs);
568
- } catch {
569
- return; // destination doesn't exist (or isn't stat-able) — nothing occupies it, proceed
570
- }
571
- let srcStat: ReturnType<typeof statSync>;
572
- try {
573
- srcStat = statSync(fromAbs);
574
- } catch {
575
- return; // can't identify the source here — fall through to renameSync, which raises the real error
576
- }
577
- if (destStat.dev === srcStat.dev && destStat.ino === srcStat.ino) {
578
- return; // same physical file (a case-only rename) — not a collision
579
- }
580
- throw new LoreError(
581
- "conflict",
582
- `cannot move to ${relPath}: a file already exists at that path`,
583
- "choose a target path that is not already taken (concept or not), or remove the conflicting file",
584
- { path: relPath },
585
- );
586
- }
587
-
588
- /**
589
- * Whether the entry already at `absPath` is a regular file. Uses `lstat` (does not
590
- * follow symlinks), so a symlink occupying a path is treated as the non-regular conflict
591
- * it is rather than silently honored via its target. Only the one DOCUMENTED failure mode
592
- * degrades to a benign `true`: the entry vanished in a concurrent race after the `wx`
593
- * EEXIST that led here, so `lstat` now fails with `ENOENT` — a self-resolving race, not a
594
- * real problem. Any OTHER `lstat` failure (`EACCES`, `EIO`, …) is a genuine fault on an
595
- * entry `wx`'s own `EEXIST` just proved is actually there (LORE-230) — it propagates to
596
- * {@link createIfAbsent}, which classifies and surfaces it, rather than being folded into
597
- * the same benign-skip outcome as the raced-away case.
598
- */
599
- function existingIsRegularFile(absPath: string): boolean {
600
- let stat: ReturnType<typeof lstatSync>;
601
- try {
602
- stat = lstatSync(absPath);
603
- } catch (cause) {
604
- if (errnoCode(cause) === "ENOENT") {
605
- return true;
606
- }
607
- throw cause;
608
- }
609
- return stat.isFile();
610
- }
611
-
612
- /**
613
- * Map a filesystem failure to a diagnostic. A permission error (`EACCES`/`EPERM`)
614
- * becomes a `denied` {@link LoreError}; a non-regular entry occupying a path lore needs — a directory
615
- * where a file must go (`EISDIR` on an overwrite, `EEXIST` on a never-clobber `mkdir`), a file
616
- * sitting on an ancestor segment (`ENOTDIR`), or a symlink loop encountered while the kernel
617
- * resolves the path (`ELOOP`) — becomes a `conflict` {@link LoreError}. Both carry an
618
- * actionable hint. Anything else is rethrown so a genuinely unexpected IO fault surfaces as an
619
- * uncaught failure (exit 1, "report this") rather than being mislabeled a user condition.
620
- */
621
- export function ioError(cause: unknown, relPath: string, action: string): unknown {
622
- const code = errnoCode(cause);
623
- if (code === "EACCES" || code === "EPERM") {
624
- return new LoreError(
625
- "denied",
626
- `permission denied trying to ${action} ${relPath}`,
627
- "check write permissions on the bundle directory, then re-run the command",
628
- { path: relPath, code },
629
- );
630
- }
631
- if (code === "EEXIST" || code === "ENOTDIR" || code === "EISDIR" || code === "ELOOP") {
632
- return conflictError(relPath, code);
633
- }
634
- return cause;
635
- }
636
-
637
- /** A `conflict` {@link LoreError}: a non-regular file blocks a path the command must create. */
638
- export function conflictError(relPath: string, code?: string): LoreError {
639
- return new LoreError(
640
- "conflict",
641
- `cannot write ${relPath}: a conflicting file already exists where lore needs to create it`,
642
- "remove or rename the conflicting entry, then re-run the command",
643
- code ? { path: relPath, code } : { path: relPath },
644
- );
645
- }
646
-
647
- // ── All-or-nothing scaffold writes ─────────────────────────────────────────────
648
-
649
- /** One file {@link writeAllOrRollback} plans to write, with the exact bytes to apply. */
650
- export interface WriteAllOrRollbackFile {
651
- /** Repo-relative POSIX path. */
652
- readonly path: string;
653
- /** The exact bytes to write — the caller's already-built content. */
654
- readonly contents: string;
655
- }
656
-
657
- /** One file {@link writeAllOrRollback} wrote (or, under `force`, overwrote), for the caller's report. */
658
- export interface WriteAllOrRollbackResult {
659
- /** Repo-relative POSIX path. */
660
- readonly path: string;
661
- /** Whether this call created the file fresh or overwrote a pre-existing one (`force` only). */
662
- readonly action: "created" | "updated";
663
- }
664
-
665
- /**
666
- * Ensure `dirs` exist and write every file in `files`, tracking an undo action per directory/file as
667
- * it succeeds. If any step throws partway through, every already-applied step this call made is
668
- * rolled back — directories removed (only if still empty; they can hold only files this same call
669
- * wrote, and those are undone first by the LIFO order below) and files restored to their pre-call
670
- * bytes, or removed if they did not exist before — before the original error is rethrown. This is
671
- * the shared all-or-nothing write primitive `commands/scaffold.ts` needs (LORE-39); `commands/rename.ts`
672
- * flags the identical need ("cross-file transactional rollback is a shared concern with `lore replace`,
673
- * deferred") and can adopt this later. Factored here — rather than kept private to one command — so
674
- * the filesystem-conflict semantics stay identical across every command (this module's own docstring).
675
- *
676
- * Every directory and file path is checked via {@link assertNoSymlinkInPath} BEFORE any mkdir/write
677
- * touches it (LORE-76) — a symlinked ancestor directory or a symlinked final target refuses loudly
678
- * rather than silently redirecting the write outside the repo; that check throws before this call's
679
- * own undo stack has anything to roll back for the offending step, so it composes with the rollback
680
- * discipline below without special-casing.
681
- *
682
- * Two write disciplines, chosen by `opts.force`:
683
- * - **not forced** — every file is expected to be absent (the caller's own preflight has typically
684
- * already confirmed this across the whole plan, so it can name every collision at once). Routed
685
- * through {@link createIfAbsent}'s atomic `wx` open rather than a plain `existsSync` + write, so a
686
- * file that appears in the TOCTOU window between the caller's preflight and this call is a loud
687
- * `conflict` — never a silent clobber.
688
- * - **forced** — a file may already exist and is meant to be overwritten. Its prior bytes are read
689
- * *before* the write, so a later failure elsewhere in this call can restore them. If a pre-existing
690
- * file's bytes cannot be read (e.g. write-only permissions, or a non-regular entry occupying the
691
- * path) the call refuses **before ever writing that file** — it never proceeds to
692
- * overwrite-then-maybe-delete it, because deleting a file that existed before this call ran is never
693
- * an acceptable rollback outcome. The overwrite itself goes through {@link writeFileNoFollow}, not
694
- * the shared {@link writeFileOverwriting} (LORE-92) or the in-place {@link writeFileAtomic} — a
695
- * symlink-safe **and** crash-safe write-to-temp-then-`renameSync` (LORE-130): a symlink swapped
696
- * into the destination's path in the TOCTOU window between `assertNoSymlinkInPath` above and this
697
- * write cannot be written through, because `writeFileNoFollow` both refuses a symlinked destination
698
- * up front AND, even if one raced in after that check, `renameSync` never follows a symlink at its
699
- * destination in the first place (POSIX replaces the link itself, never whatever it points to) — see
700
- * {@link writeFileNoFollow}'s own docstring for both properties in full. Layered on top of that,
701
- * the write itself is now crash-safe against a kill or process crash mid-write, not just against a
702
- * thrown JS exception: the destination is never opened/truncated in place, so a kill at any point
703
- * before the commit `renameSync` leaves it holding its complete prior bytes untouched, and a kill
704
- * after that single atomic rename leaves it holding the complete new bytes — never the partially
705
- * overwritten state an in-place `O_TRUNC` write could leave behind. The rollback restore below (on
706
- * a *later* failure) goes through the same {@link writeFileNoFollow}, for the identical reason — a
707
- * symlink swapped in after a successful write, or a kill during the restore write itself, should
708
- * get the same protection the forward write gets.
709
- *
710
- * Rollback is best-effort: a failure while undoing is swallowed so the original error — not a
711
- * secondary cleanup failure — is what the caller sees. A kill signal during the *undo* write itself
712
- * carries the same per-file crash-safety {@link writeFileNoFollow} now provides for the forward
713
- * write — see its own docstring — but a kill between two undo steps in this call's LIFO stack still
714
- * leaves whichever earlier steps hadn't run yet un-rolled-back, exactly as a thrown exception would;
715
- * this function's rollback was never, and still isn't, a transaction across the *whole* undo stack.
716
- */
717
- export function writeAllOrRollback(
718
- root: string,
719
- dirs: readonly string[],
720
- files: readonly WriteAllOrRollbackFile[],
721
- opts: { readonly force: boolean },
722
- ): WriteAllOrRollbackResult[] {
723
- const undo: Array<() => void> = [];
724
- try {
725
- for (const dir of dirs) {
726
- const abs = join(root, dir);
727
- const dirExisted = existsSync(abs);
728
- ensureDir(root, dir);
729
- if (!dirExisted) {
730
- undo.push(() => {
731
- try {
732
- // Only removes an EMPTY directory — never a recursive delete. This is a no-op if
733
- // this call's own file writes into it were not already undone first (they always
734
- // are, since `undo` is a single LIFO stack and every file undo below is pushed
735
- // after this directory's), so a directory this call did not create is never at risk.
736
- rmdirSync(abs);
737
- } catch {
738
- // Best-effort rollback; see the shared catch below.
739
- }
740
- });
741
- }
742
- }
743
- return files.map((file) => {
744
- assertNoSymlinkInPath(root, file.path);
745
- const abs = join(root, file.path);
746
- if (!opts.force) {
747
- if (!createIfAbsent(abs, file.contents, file.path)) {
748
- throw conflictError(file.path);
749
- }
750
- undo.push(() => rmSync(abs, { force: true }));
751
- return { path: file.path, action: "created" as const };
752
- }
753
- if (existsSync(abs)) {
754
- const before = readExistingOrThrow(abs, file.path);
755
- writeFileNoFollow(abs, file.contents, file.path);
756
- undo.push(() => writeFileNoFollow(abs, before, file.path));
757
- return { path: file.path, action: "updated" as const };
758
- }
759
- writeFileNoFollow(abs, file.contents, file.path);
760
- undo.push(() => rmSync(abs, { force: true }));
761
- return { path: file.path, action: "created" as const };
762
- });
763
- } catch (err) {
764
- for (const step of undo.reverse()) {
765
- try {
766
- step();
767
- } catch {
768
- // Best-effort rollback; the original write failure above is what's reported.
769
- }
770
- }
771
- throw err;
772
- }
773
- }
774
-
775
- /**
776
- * Read a pre-existing file's current bytes so {@link writeAllOrRollback} can restore them on
777
- * rollback — or refuse, via the shared {@link ioError} classification, rather than ever proceeding to
778
- * overwrite (and potentially later delete on rollback) a file whose original bytes could not be
779
- * captured. A file that existed before a call to {@link writeAllOrRollback} must never be destroyed
780
- * by that same call's own undo path.
781
- */
782
- function readExistingOrThrow(absPath: string, relPath: string): string {
783
- try {
784
- return readFileSync(absPath, "utf8");
785
- } catch (cause) {
786
- throw ioError(cause, relPath, "read pre-existing file before overwriting");
787
- }
788
- }
789
-
790
- /**
791
- * Write every byte of `buf` via `write`, looping on a short write — `write` accepting fewer bytes
792
- * than offered — rather than treating it as complete. Mirrors `writeFileSync`'s own internal loop
793
- * for a `Buffer` input, done for the same reason: a single `write(2)` is not guaranteed to consume
794
- * the whole buffer (e.g. a disk filling up mid-write returns a short, successful write, with the
795
- * real `ENOSPC` only surfacing on the *next* call) — treating one `write` call as the whole file
796
- * would silently truncate it with no error. `write` is injected (rather than this function calling
797
- * `writeSync` directly) so the loop's own accumulation logic can be unit-tested deterministically
798
- * with a fake writer that simulates short writes, since a genuine short write against a real fd
799
- * isn't reliably reproducible in a test.
800
- */
801
- export function writeAllBytes(write: (buf: Buffer, offset: number, length: number) => number, buf: Buffer): void {
802
- let offset = 0;
803
- while (offset < buf.length) {
804
- offset += write(buf, offset, buf.length - offset);
805
- }
806
- }
807
-
808
- /**
809
- * Overwrite (or create) a file, refusing to follow a symlink at the final path component — even
810
- * one planted *after* an earlier {@link assertNoSymlinkInPath} check on the same path already
811
- * passed (LORE-92) — via a **write-to-temp-then-`renameSync`** commit, which doubles as the fix
812
- * for a second, distinct hazard (LORE-130): the destination is never opened/truncated in place, so
813
- * a process kill or crash *mid-write* can never leave it holding a partially-overwritten mix of old
814
- * and new bytes — a kill before the commit rename leaves it holding its complete prior content
815
- * untouched, and a kill after that single rename leaves it holding the complete new content. (An
816
- * earlier version of this function opened the destination directly with `O_TRUNC | O_NOFOLLOW` and
817
- * wrote in place with a `writeSync` loop — TOCTOU-safe against the symlink race, per below, but not
818
- * safe against this separate crash-mid-write data-loss risk, since `O_TRUNC` discards the old bytes
819
- * before the new ones are durably in place.)
820
- *
821
- * The symlink refusal itself: an `lstatSync` on `absPath`, checked *before* any temp-file I/O
822
- * begins, refuses (the same `conflict`/"is a symlink" diagnosis `assertNoSymlinkInPath` uses) if a
823
- * symlink already occupies the destination. That check-then-act `lstatSync` cannot, on its own,
824
- * close a race against a symlink planted in the (now slightly wider, since a temp file is written
825
- * first) window between it and the commit `renameSync` — but unlike a plain `writeFileSync`
826
- * (which transparently follows a symlink at open time, mid-path or final component alike),
827
- * `renameSync` structurally **never** follows a symlink at its destination on POSIX: `rename(2)`
828
- * always replaces the directory entry itself, never whatever a symlink there points to. So even a
829
- * symlink that races in after the `lstatSync` check and before the rename can never cause a write
830
- * to land outside the repo — the only thing that residual race can change is whether this call
831
- * throws `conflict` (the common case: the up-front check already caught it) or the rename silently
832
- * replaces the racing symlink with the new file (never dereferencing it either way). This is a
833
- * strictly stronger guarantee against the write-outside-the-repo hazard than the previous
834
- * `O_NOFOLLOW`-at-open-time design had, not a weaker one, even though the *refusal* itself is no
835
- * longer a single atomic syscall. **Windows** is not characterized here beyond what the existing
836
- * test suite already scopes to (every symlink-specific test in this codebase is POSIX-only) — see
837
- * the historical note above for what the prior, Windows-non-guaranteeing design looked like.
838
- *
839
- * Mode and ownership are carried from the pre-existing destination onto the temp file (best-effort
840
- * for ownership, matching {@link writeFileAtomic}'s own LORE-117 discipline) before the commit
841
- * rename, since the old in-place `O_TRUNC` write never re-created the inode and so never reset
842
- * these — a fresh temp file otherwise lands with the process's default umask, silently dropping
843
- * e.g. a `0600` lockdown the destination had before this call.
844
- *
845
- * `writeAllOrRollback`'s `--force` branch and `lore schema export`'s per-file write loop
846
- * ({@link runSchema} in `commands/schema.ts`, LORE-123) are the callers — both write to leaf paths
847
- * that no bundle-loader walk vetted for symlinks first. Every other write discipline in this
848
- * module is either check-then-atomic-create ({@link createIfAbsent}'s `wx` open, independently
849
- * TOCTOU-safe on every platform: `O_CREAT|O_EXCL` refuses on ANY pre-existing entry at the path,
850
- * symlink or not, with no symlink-detection required) or doesn't need this
851
- * ({@link writeFileOverwriting}'s other callers, e.g. `lore rename`/`supersede` (and `lore replace`,
852
- * via {@link writeFileAtomic}), write back over a concept file the bundle loader just read, and
853
- * that loader already skips symlinked files during its walk, so their target was never a symlink to
854
- * begin with). The temp file is exclusively created with `writeFileSync({ flag: "wx" })` and then
855
- * written in a separate call, matching {@link writeFileAtomic}'s LORE-231 two-phase leak guard and
856
- * its LORE-252 Windows-safe primitive (a numeric-flag `openSync(O_CREAT|O_EXCL)` spuriously ENOENTs
857
- * on Bun/Windows) — see that function's docstring. The exclusive create is on the sibling `tmpPath`,
858
- * never on `absPath`, so it does not weaken the destination symlink refusal above. `writeFileSync`
859
- * loops on a short write internally, so the temp write no longer routes through {@link writeAllBytes}.
860
- *
861
- * The commit `renameSync` itself goes through {@link renameOverDestination} (LORE-256), the same
862
- * bounded retry-with-backoff {@link writeFileAtomic} uses on its own commit rename — see that
863
- * function's docstring. Retrying only the commit step leaves the symlink refusal above and the
864
- * LORE-231/LORE-252 temp-file discipline untouched: both still run exactly once per call.
865
- */
866
- export function writeFileNoFollow(absPath: string, contents: string, relPath: string): void {
867
- let destStat: ReturnType<typeof lstatSync> | undefined;
868
- try {
869
- destStat = lstatSync(absPath);
870
- } catch {
871
- destStat = undefined; // no pre-existing entry (or an unreadable ancestor) -- nothing to refuse or preserve
872
- }
873
- if (destStat?.isSymbolicLink()) {
874
- throw new LoreError(
875
- "conflict",
876
- `refusing to write ${relPath}: it is a symlink, not a real file`,
877
- "lore does not write through a symlink (it may resolve outside the repo) — remove or replace it, then re-run",
878
- { path: relPath, symlink: relPath },
879
- );
880
- }
881
-
882
- const tmpPath = join(dirname(absPath), `.lore-nofollow-tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
883
- let tmpFileExists = false;
884
- try {
885
- // LORE-252: same Windows-safe two-phase temp create as writeFileAtomic (see its note). An empty
886
- // exclusive `wx` create sets the LORE-231 leak guard the instant the temp provably, exclusively
887
- // exists, then a separate `flag: "w"` byte-write fills it -- a mid-write failure still reaches
888
- // the catch's unlinkSync cleanup. `writeFileSync({flag:"wx"})` replaces the numeric-flag
889
- // `openSync(O_CREAT|O_EXCL)` that Bun/Windows spuriously ENOENTs on. The exclusive create is on
890
- // the sibling tmpPath, so the destination symlink refusal above and the renameSync commit below
891
- // (LORE-92/LORE-130) are untouched.
892
- try {
893
- writeFileSync(tmpPath, "", { flag: "wx" });
894
- } catch (cause) {
895
- throw ioError(cause, relPath, "write file");
896
- }
897
- tmpFileExists = true;
898
- try {
899
- writeFileSync(tmpPath, Buffer.from(contents, "utf8"), { flag: "w" });
900
- } catch (cause) {
901
- throw ioError(cause, relPath, "write file");
902
- }
903
- if (destStat !== undefined) {
904
- // Mode preservation is not best-effort, matching writeFileAtomic's own discipline: a failure
905
- // here means the file is about to land with the wrong permissions, so it's surfaced as a
906
- // write failure rather than silently swallowed.
907
- chmodSync(tmpPath, destStat.mode & 0o7777);
908
- try {
909
- // Ownership IS best-effort: an unprivileged process cannot `chown` to an arbitrary uid/gid
910
- // (EPERM is the expected outcome on every non-root run), and this must not fail a write
911
- // that has already correctly preserved mode.
912
- chownSync(tmpPath, destStat.uid, destStat.gid);
913
- } catch {
914
- // Swallowed: see above.
915
- }
916
- }
917
- renameOverDestination(tmpPath, absPath);
918
- } catch (cause) {
919
- if (tmpFileExists) {
920
- try {
921
- unlinkSync(tmpPath);
922
- } catch {
923
- // Best-effort cleanup; the failure below is what's primarily reported.
924
- }
925
- }
926
- throw cause instanceof LoreError ? cause : ioError(cause, relPath, "write file");
927
- }
928
- }