/** * Laptop-Umzug: Code + komplette V2-Dev-SQLite (ohne node_modules, .env). * Enthält personenbezogene Daten — nur an sich selbst / den Ziel-Laptop schicken. * * Usage: npm run pack:laptop */ import { mkdirSync, existsSync, rmSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { execFileSync } from 'node:child_process' const __dirname = dirname(fileURLToPath(import.meta.url)) const projectRoot = resolve(join(__dirname, '../..')) const outDir = join(projectRoot, 'dist-release') const stamp = new Date().toISOString().slice(0, 10) const zipName = `AssignmentMonitor-Umzug-${stamp}.zip` const zipPath = join(outDir, zipName) const stageName = 'AssigmentMonitorV2' const stageRoot = join(outDir, '_stage-laptop', stageName) const rootNorm = projectRoot.toLowerCase() if (!rootNorm.includes('assigmentmonitorv2')) { throw new Error('[pack:laptop] SAFETY: Skript nur innerhalb AssigmentMonitorV2 ausführen.') } const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3' if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) { throw new Error(`[pack:laptop] SAFETY: SQLITE_FILENAME ungültig: "${sqliteFilename}"`) } if (sqliteFilename === 'assignment-monitor.sqlite3') { throw new Error('[pack:laptop] SAFETY: Produktiv-DB-Dateiname blockiert.') } const dbPath = join(projectRoot, 'server', 'data', sqliteFilename) const EXCLUDE_DIR_NAMES = new Set([ 'node_modules', 'dist', 'dist-release', 'dist-ssr', '.vite', '.claude', '.vscode', '_quarantine', '_stage', '_stage-laptop', ]) const EXCLUDE_FILE_NAMES = new Set([ '.env', '.env.local', 'dist-release-log.txt', ]) function shouldSkip(absPath: string): boolean { const rel = relative(projectRoot, absPath).split(/[/\\]/).join('/') if (!rel || rel.startsWith('..')) return true if (rel === 'server/data' || rel.startsWith('server/data/')) return true if (rel === 'dist-release' || rel.startsWith('dist-release/')) return true const parts = rel.split('/') if (parts.some(p => EXCLUDE_DIR_NAMES.has(p) || p.startsWith('_quarantine'))) return true const base = parts[parts.length - 1] ?? '' if (EXCLUDE_FILE_NAMES.has(base)) return true if (base.endsWith('.tsbuildinfo')) return true if (base.startsWith('.env.') && base.endsWith('.local')) return true if (base.endsWith('.bak')) return true return false } async function copyTree(srcDir: string, destDir: string): Promise { const { readdirSync, statSync, mkdirSync: mkdir, copyFileSync: cp } = await import('node:fs') let count = 0 mkdir(destDir, { recursive: true }) for (const name of readdirSync(srcDir)) { const src = join(srcDir, name) if (shouldSkip(src)) continue const dest = join(destDir, name) const st = statSync(src) if (st.isDirectory()) { count += await copyTree(src, dest) } else if (st.isFile()) { cp(src, dest) count++ } } return count } function zipWithPowerShell(sourceFolder: string, targetZip: string): void { const parent = dirname(sourceFolder) const leaf = sourceFolder.split(/[/\\]/).pop() const ps = ` $ErrorActionPreference = 'Stop' if (Test-Path -LiteralPath '${targetZip.replace(/'/g, "''")}') { Remove-Item -LiteralPath '${targetZip.replace(/'/g, "''")}' -Force } Compress-Archive -Path (Join-Path '${parent.replace(/'/g, "''")}' '${leaf}') -DestinationPath '${targetZip.replace(/'/g, "''")}' -CompressionLevel Optimal ` execFileSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'inherit' }) } if (!existsSync(dbPath)) { throw new Error( `[pack:laptop] Datenbank fehlt: ${dbPath}\n` + 'Ohne SQLite wäre das nur ein Team-Paket — zuerst die App einmal starten oder pack:zip nutzen.', ) } mkdirSync(outDir, { recursive: true }) const stageParent = join(outDir, '_stage-laptop') if (existsSync(stageParent)) rmSync(stageParent, { recursive: true, force: true }) mkdirSync(stageRoot, { recursive: true }) console.log('[pack:laptop] Kopiere Projektdateien (ohne node_modules, .env; inkl. .git) …') const files = await copyTree(projectRoot, stageRoot) console.log(`[pack:laptop] ${files} Dateien → Staging`) const stageDataDir = join(stageRoot, 'server', 'data') mkdirSync(stageDataDir, { recursive: true }) copyFileSync(dbPath, join(stageDataDir, sqliteFilename)) let extraDbFiles = 0 for (const suffix of ['-wal', '-shm']) { const extra = `${dbPath}${suffix}` if (existsSync(extra)) { copyFileSync(extra, join(stageDataDir, `${sqliteFilename}${suffix}`)) extraDbFiles++ } } writeFileSync( join(stageDataDir, 'README.txt'), [ 'Diese SQLite-Datei ist die vollständige lokale Datenbank (Personen, Assignments, Meetings).', `Datei: ${sqliteFilename}`, 'Nach dem Entpacken NICHT npm run setup / bootstrap / db:reset ausführen.', '', ].join('\n'), 'utf8', ) console.log(`[pack:laptop] DB kopiert: ${sqliteFilename}${extraDbFiles ? ` (+${extraDbFiles} WAL/SHM)` : ''}`) if (!existsSync(join(stageRoot, 'UMZUG.md'))) { throw new Error('[pack:laptop] UMZUG.md fehlt im Staging — Abbruch.') } if (!existsSync(join(stageRoot, '.env.example'))) { throw new Error('[pack:laptop] .env.example fehlt') } if (!existsSync(join(stageRoot, 'server', 'data', sqliteFilename))) { throw new Error('[pack:laptop] DB landete nicht im Staging — Abbruch.') } console.log(`[pack:laptop] Erzeuge ${zipName} …`) zipWithPowerShell(stageRoot, zipPath) rmSync(stageParent, { recursive: true, force: true }) const sizeMb = (readFileSync(zipPath).byteLength / (1024 * 1024)).toFixed(2) console.log(`[pack:laptop] Fertig: ${zipPath}`) console.log(`[pack:laptop] Größe: ${sizeMb} MB (E-Mail-tauglich, solange unter dem Postfach-Limit)`) console.log('[pack:laptop] Enthält personenbezogene Daten — nur an den Ziel-Laptop schicken.') console.log('[pack:laptop] Am Ziel: UMZUG.md folgen. Nicht npm run setup.') console.log('[pack:laptop] Produktiv (AssigmentMonitor) wurde nicht angefasst.')