From faba9d6b8710a002b144c01cf14a0d3fd8756c81 Mon Sep 17 00:00:00 2001 From: Vaughan Date: Thu, 23 Jul 2026 11:05:17 +1000 Subject: [PATCH] fix: tolerate Bun's spurious EEXIST from recursive mkdir on Windows ReadOnly dirs On Windows, Bun (reproduced on 1.3.11) throws EEXIST from fs.mkdirSync(dir, { recursive: true }) when the directory already exists AND carries FILE_ATTRIBUTE_READONLY - an attribute Explorer sets as a "customized folder" marker that does not actually prevent writes. Node treats the identical call as a no-op success. gen-skill-docs.ts hits this in two places on affected machines: the section-template mkdir (ship/sections is a committed dir) fails Claude generation, and the per-host output mkdir fails every external host, so `gen:skill-docs --host all` exits 1 and ./setup aborts. Add mkdirpSync(): swallow EEXIST only after stat confirms an existing directory (a collision with a regular file still throws), and use it at every mkdir site in gen-skill-docs.ts and gen-llms-txt.ts. Verified on Windows 11 / Bun 1.3.11: with ReadOnly set on ship/sections and .agents/skills/gstack, --host claude and --host codex both fail before this change and succeed after; a file-collision still throws. Related: #2048 (same Bun EEXIST class in browse/mkdirSecure). --- scripts/fs-utils.ts | 29 +++++++++++++++++++++++++++++ scripts/gen-llms-txt.ts | 3 ++- scripts/gen-skill-docs.ts | 11 ++++++----- 3 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 scripts/fs-utils.ts diff --git a/scripts/fs-utils.ts b/scripts/fs-utils.ts new file mode 100644 index 0000000000..98fd01a2b1 --- /dev/null +++ b/scripts/fs-utils.ts @@ -0,0 +1,29 @@ +import * as fs from 'fs'; + +/** + * Drop-in replacement for fs.mkdirSync(dir, { recursive: true }). + * + * Bun on Windows (still present in 1.3.11) throws EEXIST from recursive + * mkdirSync when the target directory already exists AND carries the + * FILE_ATTRIBUTE_READONLY attribute (harmless on directories - Explorer + * repurposes it as a "customized folder" marker). Node treats the same + * call as a no-op success. See oven-sh/bun#16466 (the plain-dir variant, + * fixed) - the ReadOnly variant still reproduces. + * + * Guard: swallow EEXIST only after confirming the path is an existing + * directory; a collision with a regular file still throws. + */ +export function mkdirpSync(dir: string): void { + try { + fs.mkdirSync(dir, { recursive: true }); + } catch (error) { + if ((error as { code?: string }).code === 'EEXIST') { + try { + if (fs.statSync(dir).isDirectory()) return; + } catch { + // fall through and rethrow the original mkdir error + } + } + throw error; + } +} diff --git a/scripts/gen-llms-txt.ts b/scripts/gen-llms-txt.ts index 02e55d12de..6e19023035 100644 --- a/scripts/gen-llms-txt.ts +++ b/scripts/gen-llms-txt.ts @@ -20,6 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { discoverTemplates } from './discover-skills'; +import { mkdirpSync } from './fs-utils'; import { COMMAND_DESCRIPTIONS as BROWSE_COMMANDS } from '../browse/src/commands'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -224,7 +225,7 @@ export async function generateLlmsTxt(opts: GenerateOptions = {}): Promise { const result = await generateLlmsTxt(opts); const outputPath = opts.outputPath ?? OUTPUT; - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + mkdirpSync(path.dirname(outputPath)); fs.writeFileSync(outputPath, result.content, { encoding: 'utf-8' }); return result; } diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 71aa1a34ca..f70470cf68 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -13,6 +13,7 @@ import { COMMAND_DESCRIPTIONS } from '../browse/src/commands'; import { SNAPSHOT_FLAGS } from '../browse/src/snapshot'; import { discoverTemplates, discoverSectionTemplates } from './discover-skills'; import { writeLlmsTxt } from './gen-llms-txt'; +import { mkdirpSync } from './fs-utils'; import * as fs from 'fs'; import * as path from 'path'; import type { Host, TemplateContext } from './resolvers/types'; @@ -752,7 +753,7 @@ function processExternalHost( const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name); - fs.mkdirSync(outputDir, { recursive: true }); + mkdirpSync(outputDir); const outputPath = path.join(outputDir, 'SKILL.md'); // Guard against symlink loops @@ -787,7 +788,7 @@ function processExternalHost( // Config-driven: generate metadata (e.g., openai.yaml for Codex) if (hostConfig.generation.generateMetadata && !symlinkLoop) { const agentsDir = path.join(outputDir, 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); + mkdirpSync(agentsDir); const shortDescription = condenseOpenAIShortDescription(extractedDescription); fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), generateOpenAIYaml(name, shortDescription)); } @@ -921,7 +922,7 @@ function processSectionTemplate( const externalName = externalSkillName(skillDir, parentName); outputPath = path.join(ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName); } - if (!DRY_RUN) fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + if (!DRY_RUN) mkdirpSync(path.dirname(outputPath)); return { outputPath, content }; } @@ -996,7 +997,7 @@ for (const currentHost of hostsToRun) { } else { // In-place writes land in existing dirs; --out-dir needs the mirrored // skill dir created first. - if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + if (OUT_DIR) mkdirpSync(path.dirname(outputPath)); fs.writeFileSync(outputPath, content); console.log(`GENERATED: ${relOutput}`); } @@ -1058,7 +1059,7 @@ for (const currentHost of hostsToRun) { // Generate gstack-lite and gstack-full for OpenClaw host if (currentHost === 'openclaw' && !DRY_RUN) { const openclawDir = path.join(ROOT, 'openclaw'); - if (!fs.existsSync(openclawDir)) fs.mkdirSync(openclawDir, { recursive: true }); + mkdirpSync(openclawDir); const gstackLite = `# gstack-lite Planning Discipline