From a6e59bf829242d32fff124028449339c80bdd295 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 2 Apr 2026 20:35:37 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20syncDirectory=20=E2=80=94=20guard=20?= =?UTF-8?q?same-path=20copy=20and=20skip=20nested=20.git=20dirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs causing 'EACCES: permission denied, copyfile' when source and target are the same path (e.g. wizard with sourceDir == mosaicHome): 1. No same-path guard — syncDirectory tried to copy every file onto itself; git pack files are read-only (0444) so copyFileSync fails. 2. excludeGit only matched top-level .git — nested .git dirs like sources/agent-skills/.git were copied, hitting the same permission issue. Fixes: - Early return when resolve(source) === resolve(target) - Match .git dirs at any depth via dirName and relPath checks - Skip files inside .git/ paths Added file-ops.test.ts with 4 tests covering all cases. --- .../__tests__/platform/file-ops.test.ts | 88 +++++++++++++++++++ packages/mosaic/package.json | 2 +- packages/mosaic/src/platform/file-ops.ts | 13 ++- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 packages/mosaic/__tests__/platform/file-ops.test.ts diff --git a/packages/mosaic/__tests__/platform/file-ops.test.ts b/packages/mosaic/__tests__/platform/file-ops.test.ts new file mode 100644 index 0000000..26c8f0c --- /dev/null +++ b/packages/mosaic/__tests__/platform/file-ops.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + chmodSync, + rmSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { syncDirectory } from '../../src/platform/file-ops.js'; + +describe('syncDirectory', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-file-ops-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('is a no-op when source and target are the same path', () => { + const dir = join(tmpDir, 'same'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'file.txt'), 'hello'); + // Should not throw even with read-only files + const gitDir = join(dir, '.git', 'objects', 'pack'); + mkdirSync(gitDir, { recursive: true }); + const packFile = join(gitDir, 'pack-abc.idx'); + writeFileSync(packFile, 'data'); + chmodSync(packFile, 0o444); + + expect(() => syncDirectory(dir, dir)).not.toThrow(); + }); + + it('skips nested .git directories when excludeGit is true', () => { + const src = join(tmpDir, 'src'); + const dest = join(tmpDir, 'dest'); + + // Create source with a nested .git + mkdirSync(join(src, 'sources', 'skills', '.git', 'objects'), { recursive: true }); + writeFileSync(join(src, 'sources', 'skills', '.git', 'objects', 'pack.idx'), 'git-data'); + writeFileSync(join(src, 'sources', 'skills', 'SKILL.md'), 'skill content'); + writeFileSync(join(src, 'README.md'), 'readme'); + + syncDirectory(src, dest, { excludeGit: true }); + + // .git contents should NOT be copied + expect(existsSync(join(dest, 'sources', 'skills', '.git'))).toBe(false); + // Normal files should be copied + expect(readFileSync(join(dest, 'sources', 'skills', 'SKILL.md'), 'utf-8')).toBe( + 'skill content', + ); + expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('readme'); + }); + + it('copies nested .git directories when excludeGit is false', () => { + const src = join(tmpDir, 'src'); + const dest = join(tmpDir, 'dest'); + + mkdirSync(join(src, 'sub', '.git'), { recursive: true }); + writeFileSync(join(src, 'sub', '.git', 'HEAD'), 'ref: refs/heads/main'); + + syncDirectory(src, dest, { excludeGit: false }); + + expect(readFileSync(join(dest, 'sub', '.git', 'HEAD'), 'utf-8')).toBe('ref: refs/heads/main'); + }); + + it('respects preserve option', () => { + const src = join(tmpDir, 'src'); + const dest = join(tmpDir, 'dest'); + + mkdirSync(src, { recursive: true }); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(src, 'SOUL.md'), 'new soul'); + writeFileSync(join(dest, 'SOUL.md'), 'old soul'); + writeFileSync(join(src, 'README.md'), 'new readme'); + + syncDirectory(src, dest, { preserve: ['SOUL.md'] }); + + expect(readFileSync(join(dest, 'SOUL.md'), 'utf-8')).toBe('old soul'); + expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('new readme'); + }); +}); diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 16cd384..8be25ec 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -1,6 +1,6 @@ { "name": "@mosaic/mosaic", - "version": "0.0.3", + "version": "0.0.3-1", "description": "Mosaic agent framework — installation wizard and meta package", "type": "module", "main": "dist/index.js", diff --git a/packages/mosaic/src/platform/file-ops.ts b/packages/mosaic/src/platform/file-ops.ts index 5b67f56..cce7887 100644 --- a/packages/mosaic/src/platform/file-ops.ts +++ b/packages/mosaic/src/platform/file-ops.ts @@ -9,7 +9,7 @@ import { unlinkSync, statSync, } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; const MAX_BACKUPS = 3; @@ -68,6 +68,9 @@ export function syncDirectory( target: string, options: { preserve?: string[]; excludeGit?: boolean } = {}, ): void { + // Guard: source and target are the same directory — nothing to sync + if (resolve(source) === resolve(target)) return; + const preserveSet = new Set(options.preserve ?? []); // Collect files from source @@ -77,9 +80,10 @@ export function syncDirectory( const stat = statSync(src); if (stat.isDirectory()) { const relPath = relative(relBase, src); + const dirName = relPath.split('/').pop() ?? ''; - // Skip .git - if (options.excludeGit && relPath === '.git') return; + // Skip any .git directory (top-level or nested, e.g. sources/agent-skills/.git) + if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return; // Skip preserved paths at top level if (preserveSet.has(relPath) && existsSync(dest)) return; @@ -91,6 +95,9 @@ export function syncDirectory( } else { const relPath = relative(relBase, src); + // Skip files inside .git directories + if (options.excludeGit && relPath.includes('/.git/')) return; + // Skip preserved files at top level if (preserveSet.has(relPath) && existsSync(dest)) return; -- 2.49.1 From e83674ac51d4dcab73e20cb742dd018271fa9903 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 2 Apr 2026 20:38:29 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20mosaic=20sync=20=E2=80=94=20auto-sta?= =?UTF-8?q?sh=20dirty=20worktree=20before=20pull=20--rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git pull --rebase fails with 'cannot pull with rebase: You have unstaged changes' when the skills repo has local modifications. Fix: detect dirty index/worktree, stash before pull, restore after. Also gracefully handle pull failures (warn and continue with existing checkout) and stash pop conflicts. --- .../tools/_scripts/mosaic-sync-skills | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills index 4958374..45ea77d 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills +++ b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills @@ -55,7 +55,26 @@ mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR" if [[ $fetch -eq 1 ]]; then if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR" - git -C "$SKILLS_REPO_DIR" pull --rebase + + # Stash any local changes (dirty index or worktree) before pulling + local_changes=0 + if ! git -C "$SKILLS_REPO_DIR" diff --quiet 2>/dev/null || \ + ! git -C "$SKILLS_REPO_DIR" diff --cached --quiet 2>/dev/null; then + local_changes=1 + echo "[mosaic-skills] Stashing local changes..." + git -C "$SKILLS_REPO_DIR" stash push -q -m "mosaic-sync-skills auto-stash" + fi + + if ! git -C "$SKILLS_REPO_DIR" pull --rebase; then + echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2 + fi + + # Restore stashed changes + if [[ $local_changes -eq 1 ]]; then + echo "[mosaic-skills] Restoring local changes..." + git -C "$SKILLS_REPO_DIR" stash pop -q 2>/dev/null || \ + echo "[mosaic-skills] WARN: stash pop had conflicts — check $SKILLS_REPO_DIR" >&2 + fi else echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR" mkdir -p "$(dirname "$SKILLS_REPO_DIR")" -- 2.49.1