/** * 작업실 프로젝트 파일 폴더 정돈 스크립트(2026-09-25). * npx tsx scripts/migrate-workshop-layout.ts [--user <이름>] # 계획만 출력(dry-run, 기본) * npx tsx scripts/migrate-workshop-layout.ts --apply [--user <이름>] # 실제 이동 + 프로젝트 데이터 경로 갱신 * npx tsx scripts/migrate-workshop-layout.ts --undo # 마지막 이동 되돌리기 * 규칙은 src/gateway/routes/workshop-layout.ts. 이동만 하고 삭제/덮어쓰기는 하지 않는다(충돌은 건너뛰고 보고). * --apply는 되돌리기용 manifest를 /.smallclaw/ 에 남긴다. */ import fs from 'fs'; import path from 'path'; import { getConfig } from '../src/config/config'; import { loadWorkshop, saveWorkshop } from '../src/gateway/routes/workshop-storage'; import { planProjectMoves, planPrint3dMoves, pruneEmptyDirs, projectDir, renameTextPaths, MovePlan, MoveConflict } from '../src/gateway/routes/workshop-layout'; const args = process.argv.slice(2); const apply = args.includes('--apply'); const undoIdx = args.indexOf('--undo'); const userIdx = args.indexOf('--user'); function workspaceOf(user: string): string { return path.join(getConfig().getConfigDir(), 'users', user, 'workspace'); } if (undoIdx >= 0) { const mf = JSON.parse(fs.readFileSync(args[undoIdx + 1], 'utf-8')); let n = 0; for (const m of [...mf.moves].reverse() as MovePlan[]) { const from = path.join(projectDir(mf.workspace, m.project), m.to); const to = path.join(projectDir(mf.workspace, m.project), m.from); if (!fs.existsSync(from) || fs.existsSync(to)) { console.log(`건너뜀: ${m.project}/${m.to}`); continue; } fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(from, to); n++; } for (const m of [...(mf.print3d || [])].reverse() as { from: string; to: string }[]) { const from = path.join(mf.workspace, m.to), to = path.join(mf.workspace, m.from); if (!fs.existsSync(from) || fs.existsSync(to)) continue; fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(from, to); n++; } // 프로젝트 데이터(assembly.scad 경로)도 원복 const data = loadWorkshop(mf.workspace); for (const p of data.projects) if (p.assembly && mf.scadRemap?.[p.id]) p.assembly.scad = mf.scadRemap[p.id].from; saveWorkshop(mf.workspace, data); console.log(`되돌림: 파일 ${n}개 (빈 폴더는 필요 시 다시 생성됨)`); process.exit(0); } const users = userIdx >= 0 ? [args[userIdx + 1]] : fs.readdirSync(path.join(getConfig().getConfigDir(), 'users')); for (const user of users) { const ws = workspaceOf(user); if (!fs.existsSync(path.join(ws, '.smallclaw', 'workshop.json'))) continue; const data = loadWorkshop(ws); const allMoves: MovePlan[] = []; const allConflicts: MoveConflict[] = []; for (const p of data.projects) { if (!fs.existsSync(projectDir(ws, p.id))) continue; const { moves, conflicts } = planProjectMoves(ws, p.id); allMoves.push(...moves); allConflicts.push(...conflicts); } console.log(`\n=== 사용자 ${user}: 이동 ${allMoves.length}개, 충돌 ${allConflicts.length}개 ${apply ? '(적용)' : '(계획만 — dry-run)'}`); let cur = ''; for (const m of allMoves) { if (m.project !== cur) { cur = m.project; console.log(`\n[${data.projects.find(p => p.id === cur)?.name ?? cur}] (${cur})`); } console.log(` ${m.from} → ${m.to}`); } for (const c of allConflicts) console.log(` ⚠ 충돌 건너뜀: ${c.project}/${c.from} → ${c.to} (${c.reason})`); const p3 = planPrint3dMoves(ws); if (p3.length) { console.log(`\n[워크스페이스 print3d/] 이동 ${p3.length}개`); for (const m of p3) console.log(` ${m.from} → ${m.to}`); } if (!apply || (!allMoves.length && !p3.length)) continue; // 1) 파일 이동(같은 파일시스템 rename — 용량과 무관하게 즉시, 내용 그대로) const done: MovePlan[] = []; for (const m of allMoves) { const root = projectDir(ws, m.project); const to = path.join(root, m.to); fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(path.join(root, m.from), to); done.push(m); } // 2) .scad 경로 참조(조립 명세) + 문서 문구 갱신 const scadRemap: Record = {}; for (const p of data.projects) { if (p.assembly?.scad) { const prefix = `workshop/${p.id}/`; const mv = done.find(m => m.project === p.id && prefix + m.from === p.assembly!.scad); if (mv) { scadRemap[p.id] = { from: p.assembly.scad, to: prefix + mv.to }; p.assembly.scad = prefix + mv.to; } } if (p.description) p.description = renameTextPaths(p.description); if (p.notes) p.notes = renameTextPaths(p.notes); } saveWorkshop(ws, data); // 저장 때 이전 상태가 이력에 자동 보존된다 // 3) 비게 된 폴더 정리(빈 폴더만) for (const p of data.projects) { const removed = fs.existsSync(projectDir(ws, p.id)) ? pruneEmptyDirs(projectDir(ws, p.id)) : []; if (removed.length) console.log(` 빈 폴더 정리 [${p.id}]: ${removed.join(', ')}`); } for (const m of p3) { const to = path.join(ws, m.to); fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(path.join(ws, m.from), to); } const oldMS = path.join(ws, 'print3d', 'model-search'); if (fs.existsSync(oldMS)) pruneEmptyDirs(oldMS), (fs.readdirSync(oldMS).length === 0 && fs.rmdirSync(oldMS)); const manifest = path.join(ws, '.smallclaw', `workshop-layout-migration-${Date.now()}.json`); fs.writeFileSync(manifest, JSON.stringify({ workspace: ws, at: new Date().toISOString(), moves: done, print3d: p3, scadRemap }, null, 2)); console.log(`\n완료 — 되돌리기: npx tsx scripts/migrate-workshop-layout.ts --undo ${manifest}`); }