@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.
- package/README.md +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/commands/fswrite.ts
DELETED
|
@@ -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
|
-
}
|