2323 */
2424import { createHash } from "node:crypto" ;
2525import {
26+ copyFileSync ,
2627 createWriteStream ,
2728 existsSync ,
29+ lstatSync ,
2830 mkdirSync ,
2931 readdirSync ,
3032 readFileSync ,
@@ -33,7 +35,7 @@ import {
3335 writeFileSync ,
3436} from "node:fs" ;
3537import { homedir } from "node:os" ;
36- import { dirname , join } from "node:path" ;
38+ import { dirname , join , relative } from "node:path" ;
3739import { Readable } from "node:stream" ;
3840import { pipeline } from "node:stream/promises" ;
3941import { createBrotliDecompress } from "node:zlib" ;
@@ -52,6 +54,7 @@ const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
5254
5355const INDEX_TIMEOUT_MS = 3000 ;
5456const DOWNLOAD_TIMEOUT_MS = 30000 ;
57+ const WINDOWS_LEGACY_MAX_PATH = 259 ;
5558
5659function getConfigDir ( ) {
5760 if ( process . env . BAILIAN_CONFIG_DIR ) return process . env . BAILIAN_CONFIG_DIR ;
@@ -170,24 +173,115 @@ function computeDirContentHash(dir) {
170173 return `sha256:${ hash . digest ( "hex" ) } ` ;
171174}
172175
176+ function listTreeEntries ( rootDir ) {
177+ const entries = [ ] ;
178+ const visit = ( currentDir ) => {
179+ for ( const entry of readdirSync ( currentDir , { withFileTypes : true } ) . sort ( ( left , right ) =>
180+ left . name . localeCompare ( right . name ) ,
181+ ) ) {
182+ const path = join ( currentDir , entry . name ) ;
183+ const relativePath = relative ( rootDir , path ) ;
184+ if ( entry . isDirectory ( ) ) {
185+ entries . push ( { relativePath, type : "directory" } ) ;
186+ visit ( path ) ;
187+ } else if ( entry . isFile ( ) ) {
188+ entries . push ( { relativePath, type : "file" } ) ;
189+ }
190+ }
191+ } ;
192+ visit ( rootDir ) ;
193+ return entries ;
194+ }
195+
196+ function assertWindowsCompatiblePaths ( sourceDir , projectedRoot ) {
197+ if ( process . platform !== "win32" ) return ;
198+ for ( const entry of listTreeEntries ( sourceDir ) ) {
199+ const pathLength = join ( projectedRoot , entry . relativePath ) . length ;
200+ if ( pathLength > WINDOWS_LEGACY_MAX_PATH ) {
201+ throw new Error (
202+ `Windows-incompatible skill path (${ pathLength } characters): ${ entry . relativePath } . ` +
203+ "The skill package must shorten this path before it can be installed safely." ,
204+ ) ;
205+ }
206+ }
207+ }
208+
209+ /** Replace children without renaming a root directory held open by a Windows agent. */
210+ function reconcileDirectoryContents ( sourceDir , destDir ) {
211+ const sourceEntries = listTreeEntries ( sourceDir ) ;
212+ const expectedPaths = new Set ( sourceEntries . map ( ( entry ) => entry . relativePath ) ) ;
213+ mkdirSync ( destDir , { recursive : true } ) ;
214+
215+ for ( const entry of sourceEntries ) {
216+ const sourcePath = join ( sourceDir , entry . relativePath ) ;
217+ const destPath = join ( destDir , entry . relativePath ) ;
218+ if ( existsSync ( destPath ) ) {
219+ const destStat = lstatSync ( destPath ) ;
220+ const typeMatches = entry . type === "directory" ? destStat . isDirectory ( ) : destStat . isFile ( ) ;
221+ if ( ! typeMatches || destStat . isSymbolicLink ( ) ) {
222+ rmSync ( destPath , { recursive : true , force : true } ) ;
223+ }
224+ }
225+ if ( entry . type === "directory" ) {
226+ mkdirSync ( destPath , { recursive : true } ) ;
227+ } else {
228+ mkdirSync ( dirname ( destPath ) , { recursive : true } ) ;
229+ copyFileSync ( sourcePath , destPath ) ;
230+ }
231+ }
232+
233+ const staleEntries = listTreeEntries ( destDir )
234+ . filter ( ( entry ) => ! expectedPaths . has ( entry . relativePath ) )
235+ . sort ( ( left , right ) => right . relativePath . length - left . relativePath . length ) ;
236+ for ( const entry of staleEntries ) {
237+ rmSync ( join ( destDir , entry . relativePath ) , { recursive : true , force : true } ) ;
238+ }
239+ }
240+
241+ function cleanupBackup ( backup ) {
242+ try {
243+ if ( existsSync ( backup ) ) rmSync ( backup , { recursive : true , force : true } ) ;
244+ } catch {
245+ /* keep the backup on disk rather than report a completed swap as failed */
246+ }
247+ }
248+
173249/** Atomic swap: tmpDir (same volume) → catalogDir. */
174250function atomicSwap ( tmpDir , catalogDir ) {
175251 mkdirSync ( dirname ( catalogDir ) , { recursive : true } ) ;
176252 const backup = `${ catalogDir } .old-${ Date . now ( ) } ` ;
177- if ( existsSync ( catalogDir ) ) renameSync ( catalogDir , backup ) ;
253+ if ( existsSync ( catalogDir ) ) {
254+ try {
255+ renameSync ( catalogDir , backup ) ;
256+ } catch ( error ) {
257+ if ( process . platform !== "win32" || ( error ?. code !== "EPERM" && error ?. code !== "EBUSY" ) ) {
258+ throw error ;
259+ }
260+ try {
261+ reconcileDirectoryContents ( catalogDir , backup ) ;
262+ reconcileDirectoryContents ( tmpDir , catalogDir ) ;
263+ cleanupBackup ( backup ) ;
264+ return ;
265+ } catch ( fallbackError ) {
266+ try {
267+ if ( existsSync ( backup ) ) reconcileDirectoryContents ( backup , catalogDir ) ;
268+ } catch {
269+ /* retain backup for manual recovery if an open file also blocks rollback */
270+ }
271+ throw new Error (
272+ `Skill directory is in use and could not be updated in place. Close running agent hosts and retry. ${ fallbackError instanceof Error ? fallbackError . message : String ( fallbackError ) } ` ,
273+ { cause : fallbackError } ,
274+ ) ;
275+ }
276+ }
277+ }
178278 try {
179279 renameSync ( tmpDir , catalogDir ) ;
180280 } catch ( err ) {
181281 if ( existsSync ( backup ) && ! existsSync ( catalogDir ) ) renameSync ( backup , catalogDir ) ;
182282 throw err ;
183283 }
184- // Best-effort cleanup (symmetric with core skills/extract.ts): the swap already
185- // succeeded, so a backup deletion failure must not fail the pre-download
186- try {
187- if ( existsSync ( backup ) ) rmSync ( backup , { recursive : true , force : true } ) ;
188- } catch {
189- /* keep the backup on disk rather than report a completed swap as failed */
190- }
284+ cleanupBackup ( backup ) ;
191285}
192286
193287async function main ( ) {
@@ -208,6 +302,7 @@ async function main() {
208302 try {
209303 mkdirSync ( tmpDir , { recursive : true } ) ;
210304 await extractTarBr ( tarBuf , tmpDir ) ;
305+ assertWindowsCompatiblePaths ( tmpDir , catalogDir ) ;
211306 // Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
212307 // before touching the canonical dir
213308 if ( entry . contentHash . startsWith ( "sha256:" ) ) {
0 commit comments