|
4 | 4 | * zlib + tar-stream, no extra decompression dependencies. |
5 | 5 | */ |
6 | 6 | import { |
| 7 | + copyFileSync, |
7 | 8 | createWriteStream, |
8 | 9 | existsSync, |
| 10 | + lstatSync, |
9 | 11 | mkdirSync, |
10 | 12 | readdirSync, |
11 | 13 | readFileSync, |
12 | 14 | renameSync, |
13 | 15 | rmSync, |
14 | 16 | } from "node:fs"; |
15 | 17 | import { createHash } from "node:crypto"; |
16 | | -import { dirname, join } from "node:path"; |
| 18 | +import { dirname, join, relative } from "node:path"; |
17 | 19 | import { Readable } from "node:stream"; |
18 | 20 | import { pipeline } from "node:stream/promises"; |
19 | 21 | import { createBrotliDecompress } from "node:zlib"; |
@@ -85,27 +87,168 @@ export function computeDirContentHash(dir: string): string { |
85 | 87 | return `sha256:${hash.digest("hex")}`; |
86 | 88 | } |
87 | 89 |
|
| 90 | +export const WINDOWS_LEGACY_MAX_PATH = 259; |
| 91 | + |
| 92 | +export interface WindowsPathViolation { |
| 93 | + root: string; |
| 94 | + relativePath: string; |
| 95 | + pathLength: number; |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Project an extracted tree onto Windows-visible roots and return the longest path |
| 100 | + * that exceeds the legacy MAX_PATH budget. This protects host agents that do not |
| 101 | + * opt into long-path support even when the Node.js installer itself can write it. |
| 102 | + */ |
| 103 | +export function findWindowsPathViolation( |
| 104 | + sourceDir: string, |
| 105 | + projectedRoots: string[], |
| 106 | + maxPath = WINDOWS_LEGACY_MAX_PATH, |
| 107 | +): WindowsPathViolation | undefined { |
| 108 | + let longestViolation: WindowsPathViolation | undefined; |
| 109 | + const visit = (currentDir: string): void => { |
| 110 | + for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((a, b) => |
| 111 | + a.name.localeCompare(b.name), |
| 112 | + )) { |
| 113 | + const sourcePath = join(currentDir, entry.name); |
| 114 | + const relativePath = relative(sourceDir, sourcePath); |
| 115 | + for (const root of projectedRoots) { |
| 116 | + const pathLength = join(root, relativePath).length; |
| 117 | + if (pathLength > maxPath && pathLength > (longestViolation?.pathLength ?? 0)) { |
| 118 | + longestViolation = { root, relativePath, pathLength }; |
| 119 | + } |
| 120 | + } |
| 121 | + if (entry.isDirectory()) visit(sourcePath); |
| 122 | + } |
| 123 | + }; |
| 124 | + |
| 125 | + for (const root of projectedRoots) { |
| 126 | + if (root.length > maxPath && root.length > (longestViolation?.pathLength ?? 0)) { |
| 127 | + longestViolation = { root, relativePath: "", pathLength: root.length }; |
| 128 | + } |
| 129 | + } |
| 130 | + visit(sourceDir); |
| 131 | + return longestViolation; |
| 132 | +} |
| 133 | + |
| 134 | +interface TreeEntry { |
| 135 | + relativePath: string; |
| 136 | + type: "directory" | "file"; |
| 137 | +} |
| 138 | + |
| 139 | +function listTreeEntries(rootDir: string): TreeEntry[] { |
| 140 | + const entries: TreeEntry[] = []; |
| 141 | + const visit = (currentDir: string): void => { |
| 142 | + for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((a, b) => |
| 143 | + a.name.localeCompare(b.name), |
| 144 | + )) { |
| 145 | + const path = join(currentDir, entry.name); |
| 146 | + const relativePath = relative(rootDir, path); |
| 147 | + if (entry.isDirectory()) { |
| 148 | + entries.push({ relativePath, type: "directory" }); |
| 149 | + visit(path); |
| 150 | + } else if (entry.isFile()) { |
| 151 | + entries.push({ relativePath, type: "file" }); |
| 152 | + } |
| 153 | + } |
| 154 | + }; |
| 155 | + visit(rootDir); |
| 156 | + return entries; |
| 157 | +} |
| 158 | + |
| 159 | +/** Replace children without renaming the root directory, which may be held open on Windows. */ |
| 160 | +function reconcileDirectoryContents(sourceDir: string, destDir: string): void { |
| 161 | + const sourceEntries = listTreeEntries(sourceDir); |
| 162 | + const expectedPaths = new Set(sourceEntries.map((entry) => entry.relativePath)); |
| 163 | + mkdirSync(destDir, { recursive: true }); |
| 164 | + |
| 165 | + for (const entry of sourceEntries) { |
| 166 | + const sourcePath = join(sourceDir, entry.relativePath); |
| 167 | + const destPath = join(destDir, entry.relativePath); |
| 168 | + if (existsSync(destPath)) { |
| 169 | + const destStat = lstatSync(destPath); |
| 170 | + const typeMatches = entry.type === "directory" ? destStat.isDirectory() : destStat.isFile(); |
| 171 | + if (!typeMatches || destStat.isSymbolicLink()) { |
| 172 | + rmSync(destPath, { recursive: true, force: true }); |
| 173 | + } |
| 174 | + } |
| 175 | + if (entry.type === "directory") { |
| 176 | + mkdirSync(destPath, { recursive: true }); |
| 177 | + } else { |
| 178 | + mkdirSync(dirname(destPath), { recursive: true }); |
| 179 | + copyFileSync(sourcePath, destPath); |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + const staleEntries = listTreeEntries(destDir) |
| 184 | + .filter((entry) => !expectedPaths.has(entry.relativePath)) |
| 185 | + .sort((left, right) => right.relativePath.length - left.relativePath.length); |
| 186 | + for (const entry of staleEntries) { |
| 187 | + rmSync(join(destDir, entry.relativePath), { recursive: true, force: true }); |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +function isBlockedRenameError(error: unknown): boolean { |
| 192 | + const code = (error as NodeJS.ErrnoException | undefined)?.code; |
| 193 | + return code === "EPERM" || code === "EBUSY"; |
| 194 | +} |
| 195 | + |
| 196 | +function cleanupBackup(backup: string): void { |
| 197 | + try { |
| 198 | + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); |
| 199 | + } catch { |
| 200 | + /* keep the backup on disk rather than report a completed install as failed */ |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +export interface AtomicSwapOptions { |
| 205 | + /** Test seam; defaults to enabled only on Windows. */ |
| 206 | + allowInPlaceFallback?: boolean; |
| 207 | +} |
| 208 | + |
88 | 209 | /** |
89 | 210 | * Atomic swap: replace destDir with the extracted content from tmpDir. |
90 | 211 | * tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic. |
| 212 | + * If Windows blocks renaming an agent-open directory, preserve a copied backup and |
| 213 | + * reconcile its children in place so the stable directory handle remains valid. |
91 | 214 | */ |
92 | | -export function atomicSwap(tmpDir: string, destDir: string): void { |
| 215 | +export function atomicSwap(tmpDir: string, destDir: string, options: AtomicSwapOptions = {}): void { |
93 | 216 | mkdirSync(dirname(destDir), { recursive: true }); |
94 | 217 | const backup = `${destDir}.old-${Date.now()}`; |
95 | | - if (existsSync(destDir)) renameSync(destDir, backup); |
| 218 | + if (existsSync(destDir)) { |
| 219 | + try { |
| 220 | + renameSync(destDir, backup); |
| 221 | + } catch (error) { |
| 222 | + const allowInPlaceFallback = options.allowInPlaceFallback ?? process.platform === "win32"; |
| 223 | + if (!allowInPlaceFallback || !isBlockedRenameError(error)) throw error; |
| 224 | + |
| 225 | + try { |
| 226 | + reconcileDirectoryContents(destDir, backup); |
| 227 | + reconcileDirectoryContents(tmpDir, destDir); |
| 228 | + cleanupBackup(backup); |
| 229 | + return; |
| 230 | + } catch (fallbackError) { |
| 231 | + try { |
| 232 | + if (existsSync(backup)) reconcileDirectoryContents(backup, destDir); |
| 233 | + } catch { |
| 234 | + /* retain backup for manual recovery if an open file also blocks rollback */ |
| 235 | + } |
| 236 | + throw new Error( |
| 237 | + `Skill directory is in use and could not be updated in place. Close running agent hosts and retry. / Skill 目录正被占用,无法原地更新;请关闭正在运行的 Agent 后重试。 ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`, |
| 238 | + { cause: fallbackError }, |
| 239 | + ); |
| 240 | + } |
| 241 | + } |
| 242 | + } |
96 | 243 | try { |
97 | 244 | renameSync(tmpDir, destDir); |
98 | | - } catch (err) { |
| 245 | + } catch (error) { |
99 | 246 | // Swap failed → roll back the old directory to avoid leaving a hole |
100 | 247 | if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir); |
101 | | - throw err; |
| 248 | + throw error; |
102 | 249 | } |
103 | 250 | // Best-effort cleanup: the swap already succeeded, so a backup deletion failure |
104 | 251 | // (permissions, host safe-delete guards on large dirs) must not fail the install; |
105 | 252 | // leftover .old-* dirs are inert (skill status scans ignore them) |
106 | | - try { |
107 | | - if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); |
108 | | - } catch { |
109 | | - /* keep the backup on disk rather than report a completed install as failed */ |
110 | | - } |
| 253 | + cleanupBackup(backup); |
111 | 254 | } |
0 commit comments