@dench.com/cli 2.4.0 → 2.4.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 (2) hide show
  1. package/fs-daemon.ts +56 -5
  2. package/package.json +1 -1
package/fs-daemon.ts CHANGED
@@ -15,8 +15,10 @@
15
15
  * `lastSyncedHash`, we download from Convex Storage to /workspace.
16
16
  * • Per-path 300ms trailing debounce on uploads (batches tight write
17
17
  * loops like editor saves).
18
- * • Optimistic conflict via `expectedPreviousHash`. On 409, we fetch
19
- * latest, write to `<path>.conflicted-<ts>`, surface in UI.
18
+ * • Optimistic conflict via `expectedPreviousHash`. On 409, we stash the
19
+ * local copy at the next free `<name> (N).<ext>` sibling (reusing an
20
+ * existing sibling that already holds the same bytes), then re-pull the
21
+ * latest canonical version. Surfaced in UI.
20
22
  * • Per-path circuit breaker: if a path syncs >5 times in 10s, pause
21
23
  * sync on that path and log loudly to stderr (the live state of
22
24
  * active breakers is exposed via `dench fs status`).
@@ -58,7 +60,7 @@ import {
58
60
  unlink,
59
61
  writeFile,
60
62
  } from "node:fs/promises";
61
- import { dirname, relative } from "node:path";
63
+ import { basename, dirname, extname, join, relative } from "node:path";
62
64
  import { ConvexClient, ConvexHttpClient } from "convex/browser";
63
65
  import { makeFunctionReference } from "convex/server";
64
66
  import { decodeInlineTextOrUndefined } from "./fs-daemon-binary";
@@ -149,6 +151,56 @@ function sha256(bytes: Uint8Array): string {
149
151
  return createHash("sha256").update(bytes).digest("hex");
150
152
  }
151
153
 
154
+ // Stash a conflicting local copy at a sibling path, using an incrementing
155
+ // suffix (`foo (1).txt`, `foo (2).txt`, …) instead of a timestamp so
156
+ // repeated conflicts produce a clean, stable series rather than an
157
+ // ever-growing pile of `.conflicted-<ts>` files. The file is written
158
+ // locally; the daemon's normal watcher path syncs it to Convex like any
159
+ // other file. Returns the local path written.
160
+ //
161
+ // Two properties keep the deterministic suffix safe:
162
+ // • Content-aware reuse — if a `(N)` slot already holds these exact bytes,
163
+ // the copy is already stashed, so we return it instead of minting yet
164
+ // another sibling. This is what keeps repeated conflicts on the same
165
+ // unsynced content from piling up (the whole point of the change), and
166
+ // it also means an unrelated remote `(N)` file mirrored to local disk is
167
+ // skipped rather than overwritten.
168
+ // • Atomic claim — otherwise we write with O_EXCL (`wx`), so two handlers
169
+ // racing for the same slot can't clobber each other; the loser gets
170
+ // EEXIST and advances to the next N.
171
+ async function stashConflictCopy(args: {
172
+ absPath: string;
173
+ bytes: Uint8Array;
174
+ hash: string;
175
+ }): Promise<string> {
176
+ const { absPath, bytes, hash } = args;
177
+ const dir = dirname(absPath);
178
+ const ext = extname(absPath);
179
+ const stem = basename(absPath, ext);
180
+ for (let i = 1; i <= 1000; i++) {
181
+ const candidate = join(dir, `${stem} (${i})${ext}`);
182
+ const existing = await readFile(candidate).catch(() => null);
183
+ if (existing) {
184
+ // Already holds our bytes → nothing to do. Different content → this
185
+ // slot belongs to something else, try the next one.
186
+ if (sha256(existing) === hash) return candidate;
187
+ continue;
188
+ }
189
+ try {
190
+ await writeFile(candidate, bytes, { flag: "wx" });
191
+ return candidate;
192
+ } catch (error) {
193
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
194
+ throw error;
195
+ }
196
+ }
197
+ // Every increment up to the cap is taken — fall back to a timestamp, which
198
+ // is effectively unique, so we still stash the bytes somewhere.
199
+ const fallback = join(dir, `${stem} (${Date.now()})${ext}`);
200
+ await writeFile(fallback, bytes);
201
+ return fallback;
202
+ }
203
+
152
204
  function normalizePosixWorkspacePath(input: string): string {
153
205
  let cleaned = input.replace(/\\/g, "/").replace(/\/+/g, "/");
154
206
  if (cleaned.startsWith("/workspace/")) {
@@ -772,8 +824,7 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
772
824
  if (result.versionConflict) {
773
825
  // Stash the local copy in a sibling file, then re-pull the remote
774
826
  // version into the canonical path.
775
- const conflicted = `${absPath}.conflicted-${Date.now()}`;
776
- await writeFile(conflicted, bytes);
827
+ const conflicted = await stashConflictCopy({ absPath, bytes, hash });
777
828
  console.warn(
778
829
  `[dench-fs-daemon] version conflict for ${posixPath}; local copy stashed at ${conflicted}`,
779
830
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
5
5
  "type": "module",
6
6
  "bin": {