Sichert den lokalen Entwicklungsstand nach Gitea: Nav/Dashboard, Personen-Excel/Archiv, Team-Setup, Assignment-Übergabe und C6/C7/T2. Co-authored-by: Cursor <cursoragent@cursor.com>
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
/**
|
|
* 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<number> {
|
|
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.')
|