diff --git a/tools/loop-cost/package-lock.json b/tools/loop-cost/package-lock.json index 7cc7ecc..edc4c92 100644 --- a/tools/loop-cost/package-lock.json +++ b/tools/loop-cost/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cobusgreyling/loop-cost", - "version": "1.0.0", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cobusgreyling/loop-cost", - "version": "1.0.0", + "version": "1.0.3", "license": "MIT", "dependencies": { "yaml": "^2.8.0" diff --git a/tools/loop-init/scripts/bundle-assets.mjs b/tools/loop-init/scripts/bundle-assets.mjs index 0158f6e..b2ab0c2 100644 --- a/tools/loop-init/scripts/bundle-assets.mjs +++ b/tools/loop-init/scripts/bundle-assets.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node -import { cp, rm, access } from 'node:fs/promises'; +import { cp, rm, access, rename, mkdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..'); +const LOCK_DIR = path.join(PACKAGE_ROOT, '.bundle-assets.lock'); async function exists(p) { try { @@ -15,21 +16,71 @@ async function exists(p) { } } -for (const dir of ['starters', 'templates']) { - const dest = path.join(PACKAGE_ROOT, dir); - const src = path.join(REPO_ROOT, dir); - if (!(await exists(src))) { - console.error(`bundle-assets: missing ${src}`); - process.exit(1); +async function replaceDirectory(src, dest) { + const tempDest = `${dest}.tmp-${process.pid}-${Date.now()}`; + let moved = false; + await rm(tempDest, { recursive: true, force: true }); + try { + await cp(src, tempDest, { recursive: true }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + await rm(dest, { recursive: true, force: true }); + try { + await rename(tempDest, dest); + moved = true; + return; + } catch (err) { + if (err?.code !== 'EEXIST' || attempt === 2) { + throw err; + } + } + } + } finally { + if (!moved) { + await rm(tempDest, { recursive: true, force: true }); + } } - await rm(dest, { recursive: true, force: true }); - await cp(src, dest, { recursive: true }); - console.log(`bundled ${dir}/ → tools/loop-init/${dir}/`); } -const registrySrc = path.join(REPO_ROOT, 'patterns', 'registry.yaml'); -const registryDest = path.join(PACKAGE_ROOT, 'registry.yaml'); -if (await exists(registrySrc)) { - await cp(registrySrc, registryDest); - console.log('bundled patterns/registry.yaml → tools/loop-init/registry.yaml'); -} \ No newline at end of file +async function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function acquireLock() { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + await mkdir(LOCK_DIR); + return async () => rm(LOCK_DIR, { recursive: true, force: true }); + } catch (err) { + if (err?.code !== 'EEXIST') { + throw err; + } + await sleep(50); + } + } + throw new Error(`bundle-assets: timed out waiting for ${LOCK_DIR}`); +} + +const releaseLock = await acquireLock(); +try { + for (const dir of ['starters', 'templates']) { + const dest = path.join(PACKAGE_ROOT, dir); + const src = path.join(REPO_ROOT, dir); + if (!(await exists(src))) { + throw new Error(`bundle-assets: missing ${src}`); + } + await replaceDirectory(src, dest); + console.log(`bundled ${dir}/ → tools/loop-init/${dir}/`); + } + + const registrySrc = path.join(REPO_ROOT, 'patterns', 'registry.yaml'); + const registryDest = path.join(PACKAGE_ROOT, 'registry.yaml'); + if (await exists(registrySrc)) { + await cp(registrySrc, registryDest); + console.log('bundled patterns/registry.yaml → tools/loop-init/registry.yaml'); + } +} finally { + await releaseLock(); +} diff --git a/tools/loop-init/test/cli.test.mjs b/tools/loop-init/test/cli.test.mjs index 96951d9..4d74327 100644 --- a/tools/loop-init/test/cli.test.mjs +++ b/tools/loop-init/test/cli.test.mjs @@ -9,6 +9,16 @@ import { promisify } from 'node:util'; const exec = promisify(execFile); const CLI = path.resolve('dist/cli.js'); +test('bundle-assets tolerates concurrent rebuilds', async () => { + await Promise.all([ + exec('node', ['scripts/bundle-assets.mjs']), + exec('node', ['scripts/bundle-assets.mjs']), + ]); + await access(path.join('starters', 'issue-triage', 'README.md')); + await access(path.join('templates', 'SKILL.md.issue-triage')); + await access('registry.yaml'); +}); + test('loop-init --help exits 0', async () => { const { stdout } = await exec('node', [CLI, '--help']); assert.match(stdout, /changelog-drafter/); @@ -75,4 +85,4 @@ test('loop-init scaffolds ci-sweeper with bundled assets', async () => { } finally { await rm(dir, { recursive: true, force: true }); } -}); \ No newline at end of file +});