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