cclaw-cli 0.48.17 → 0.48.18

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.
@@ -118,26 +118,54 @@ async function writeFileAtomic(filePath, content, options = {}) {
118
118
  "." + path.basename(filePath) + ".tmp-" + process.pid + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8)
119
119
  );
120
120
  await fs.writeFile(tempPath, content, { encoding: "utf8" });
121
- try {
122
- await fs.rename(tempPath, filePath);
123
- if (options.mode !== undefined) {
124
- await fs.chmod(filePath, options.mode).catch(() => undefined);
125
- }
126
- } catch (error) {
127
- const code = error && typeof error === "object" && "code" in error ? error.code : null;
128
- if (code === "EXDEV") {
129
- try {
130
- await fs.copyFile(tempPath, filePath);
131
- } finally {
132
- await fs.unlink(tempPath).catch(() => undefined);
133
- }
121
+ // Windows' fs.rename can fail transiently with EPERM/EBUSY/EACCES when the
122
+ // destination file is held open by another process (antivirus, indexer,
123
+ // or a sibling hook invocation racing on the same file). Retry with tiny
124
+ // backoff before falling back to copyFile.
125
+ const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
126
+ let attempt = 0;
127
+ const maxAttempts = 6;
128
+ while (true) {
129
+ try {
130
+ await fs.rename(tempPath, filePath);
134
131
  if (options.mode !== undefined) {
135
132
  await fs.chmod(filePath, options.mode).catch(() => undefined);
136
133
  }
137
134
  return;
135
+ } catch (error) {
136
+ const code = error && typeof error === "object" && "code" in error ? error.code : null;
137
+ if (code === "EXDEV") {
138
+ try {
139
+ await fs.copyFile(tempPath, filePath);
140
+ } finally {
141
+ await fs.unlink(tempPath).catch(() => undefined);
142
+ }
143
+ if (options.mode !== undefined) {
144
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
145
+ }
146
+ return;
147
+ }
148
+ if (renameRetryableCodes.has(code) && attempt < maxAttempts) {
149
+ attempt += 1;
150
+ await hookSleep(10 * attempt + Math.floor(Math.random() * 10));
151
+ continue;
152
+ }
153
+ if (renameRetryableCodes.has(code)) {
154
+ // Last-resort fallback: copy-then-unlink. Not atomic, but the
155
+ // directory lock around this call already serializes writers.
156
+ try {
157
+ await fs.copyFile(tempPath, filePath);
158
+ if (options.mode !== undefined) {
159
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
160
+ }
161
+ return;
162
+ } finally {
163
+ await fs.unlink(tempPath).catch(() => undefined);
164
+ }
165
+ }
166
+ await fs.unlink(tempPath).catch(() => undefined);
167
+ throw error;
138
168
  }
139
- await fs.unlink(tempPath).catch(() => undefined);
140
- throw error;
141
169
  }
142
170
  }
143
171
 
package/dist/fs-utils.js CHANGED
@@ -75,35 +75,67 @@ export async function writeFileSafe(filePath, content, options = {}) {
75
75
  const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
76
76
  const targetMode = options.mode;
77
77
  await fs.writeFile(tempPath, content, { encoding: "utf8", ...(targetMode !== undefined ? { mode: targetMode } : {}) });
78
- try {
79
- await fs.rename(tempPath, filePath);
80
- if (targetMode !== undefined) {
81
- await fs.chmod(filePath, targetMode).catch(() => undefined);
78
+ // On Windows, `fs.rename` can fail transiently with EPERM / EBUSY / EACCES
79
+ // when the target file is briefly held open by another process (antivirus,
80
+ // search indexer, or a concurrent cclaw hook). Retry with small backoff
81
+ // before falling back to a non-atomic copy + unlink.
82
+ const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
83
+ let attempt = 0;
84
+ const maxAttempts = 6;
85
+ // eslint-disable-next-line no-constant-condition
86
+ while (true) {
87
+ try {
88
+ await fs.rename(tempPath, filePath);
89
+ if (targetMode !== undefined) {
90
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
91
+ }
92
+ return;
82
93
  }
83
- }
84
- catch (error) {
85
- const code = error?.code;
86
- // `rename` fails with EXDEV when the temp file and target live on
87
- // different filesystems (container bind mounts, tmpfs + rootfs,
88
- // cross-volume setups). Fall back to copy + unlink so atomic writes
89
- // still work copyFile is not fully atomic but is the best we can
90
- // do across devices, and we remove the temp even if copy fails.
91
- if (code === "EXDEV") {
92
- try {
93
- await fs.copyFile(tempPath, filePath);
94
- if (targetMode !== undefined) {
95
- await fs.chmod(filePath, targetMode).catch(() => undefined);
94
+ catch (error) {
95
+ const code = error?.code;
96
+ // `rename` fails with EXDEV when the temp file and target live on
97
+ // different filesystems (container bind mounts, tmpfs + rootfs,
98
+ // cross-volume setups). Fall back to copy + unlink so atomic writes
99
+ // still work copyFile is not fully atomic but is the best we can
100
+ // do across devices, and we remove the temp even if copy fails.
101
+ if (code === "EXDEV") {
102
+ try {
103
+ await fs.copyFile(tempPath, filePath);
104
+ if (targetMode !== undefined) {
105
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
106
+ }
107
+ }
108
+ finally {
109
+ await fs.unlink(tempPath).catch(() => undefined);
96
110
  }
111
+ return;
97
112
  }
98
- finally {
99
- await fs.unlink(tempPath).catch(() => undefined);
113
+ if (code !== undefined && renameRetryableCodes.has(code) && attempt < maxAttempts) {
114
+ attempt += 1;
115
+ const waitMs = 10 * attempt + Math.floor(Math.random() * 10);
116
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
117
+ continue;
100
118
  }
101
- return;
119
+ if (code !== undefined && renameRetryableCodes.has(code)) {
120
+ // Last-resort fallback on Windows: copy-then-unlink. Not atomic,
121
+ // but the caller is expected to have serialized via a directory
122
+ // lock so this only loses atomicity under extreme contention.
123
+ try {
124
+ await fs.copyFile(tempPath, filePath);
125
+ if (targetMode !== undefined) {
126
+ await fs.chmod(filePath, targetMode).catch(() => undefined);
127
+ }
128
+ return;
129
+ }
130
+ finally {
131
+ await fs.unlink(tempPath).catch(() => undefined);
132
+ }
133
+ }
134
+ // Other errors: try to clean up the temp to avoid littering the
135
+ // directory with orphaned `.tmp-<pid>-*` files, then rethrow.
136
+ await fs.unlink(tempPath).catch(() => undefined);
137
+ throw error;
102
138
  }
103
- // Other errors: try to clean up the temp to avoid littering the
104
- // directory with orphaned `.tmp-<pid>-*` files, then rethrow.
105
- await fs.unlink(tempPath).catch(() => undefined);
106
- throw error;
107
139
  }
108
140
  }
109
141
  export async function exists(filePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.17",
3
+ "version": "0.48.18",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {