#!/usr/bin/env node /** * Legacy classroom migration — P4 tooling. * * Scans the server classrooms dir (`data/classrooms/*.json`) and re-packages * every classroom into a published frozen bundle (design doc §7 "旧课件迁移": * migration = re-packaging, the packager consumes classroom-storage data * directly). * * Byte resolution: media/narration bytes must come from somewhere the server * can reach. Pass `--resolver ` to load a module exporting * `createResolvers(): Promise` (e.g. wired to the * asset registry). Without a resolver, classrooms whose media is only in * learner browsers are reported as `incomplete` — re-publish those from the * ops workbench instead (which packages from IndexedDB). * * Usage: * npx tsx scripts/migrate-coursewares.ts --base-url https://ops.example.com * npx tsx scripts/migrate-coursewares.ts --resolver ./scripts/asset-resolvers.mjs * * Exit codes: 0 = all migrated or cleanly reported; 1 = hard error. */ import { migrateAllClassrooms, type ClassroomByteResolvers } from '@/lib/courseware-repo/migrate'; import { createLogger } from '@/lib/logger'; const log = createLogger('MigrateCLI'); function parseArgs(argv: string[]): { baseUrl: string; resolver?: string; classroomsDir?: string } { const args: Record = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg.startsWith('--')) { const key = arg.slice(2); const value = argv[i + 1]; if (value && !value.startsWith('--')) { args[key] = value; i++; } else { args[key] = 'true'; } } } return { baseUrl: args['base-url'] ?? 'http://localhost:3000', resolver: args['resolver'], classroomsDir: args['classrooms-dir'], }; } async function main() { const { baseUrl, resolver, classroomsDir } = parseArgs(process.argv.slice(2)); let resolvers: ClassroomByteResolvers | undefined; if (resolver) { const resolverModule = await import(resolver); const factory = resolverModule.createResolvers ?? resolverModule.default; if (typeof factory !== 'function') { throw new Error(`Resolver module ${resolver} must export createResolvers()`); } resolvers = await factory(); log.info('Loaded byte resolvers from', resolver); } else { log.warn( 'No resolver: classrooms whose media lives only in learner browsers will be reported incomplete. ' + 'Re-publish those from the ops workbench (export menu → 发布课件).', ); } const report = await migrateAllClassrooms({ baseUrl, resolvers, classroomsDir }); console.log('\n=== 迁移报告 (Migration report) ==='); console.log(`已发布 published: ${report.published.length}`); for (const item of report.published) { if (item.status !== 'published') continue; console.log(` ✓ ${item.classroomId} → ${item.coursewareId} v${item.version}`); } console.log(`未完成 incomplete: ${report.incomplete.length}`); for (const item of report.incomplete) { if (item.status !== 'incomplete') continue; console.log(` ✗ ${item.classroomId} (${item.missing.length} missing resources)`); for (const line of item.missing.slice(0, 8)) console.log(` - ${line}`); if (item.missing.length > 8) console.log(` … ${item.missing.length - 8} more`); } console.log(`跳过 skipped: ${report.skipped.length}`, report.skipped.join(', ') || ''); if (report.incomplete.length > 0) { console.log('\n提示:未完成的课件请用运营端发布工作台重新发布(媒体在浏览器端时服务端无法取到字节)。'); } } main().catch((error) => { log.error('Migration failed:', error); process.exitCode = 1; });