import { createHash } from "node:crypto"; import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { PrismaClient } from "@prisma/client"; import { createFolder, createProjectFromOrgAdmin } from "../projectOnboarding.js"; export interface LegacyProjectManifestEntry { readonly legacyId: string; readonly name: string; readonly folderPath: readonly string[]; readonly sourceRelativePath: string; } export interface LegacyProjectImportState { readonly version: 1; readonly projects: Readonly>; } export async function importLegacyProjects(input: { readonly prisma: PrismaClient; readonly organizationId: string; readonly actorFeishuOpenId: string; readonly workspaceRoot: string; readonly sourceRoot: string; readonly stateFile: string; readonly projects: readonly LegacyProjectManifestEntry[]; readonly onProgress?: (message: string) => void; }): Promise { const sourceRoot = await realpath(input.sourceRoot); const state = await readState(input.stateFile); const projects = { ...state.projects }; const seen = new Set(); for (const entry of input.projects) { validateEntry(entry); if (seen.has(entry.legacyId)) throw new Error(`duplicate legacy project id: ${entry.legacyId}`); seen.add(entry.legacyId); const sourceDir = await confinedSourceDir(sourceRoot, entry.sourceRelativePath); const projectId = importedProjectId(input.organizationId, entry.legacyId); const existing = await input.prisma.project.findUnique({ where: { id: projectId } }); const recorded = projects[entry.legacyId]; if (recorded !== undefined && (recorded.projectId !== projectId || recorded.sourceRelativePath !== entry.sourceRelativePath)) { throw new Error(`legacy import state identity conflict: ${entry.legacyId}`); } if (recorded?.status === "COMPLETED" && existing === null) { throw new Error(`legacy import state references missing project: ${entry.legacyId} -> ${recorded.projectId}`); } if (existing !== null) { if (existing.organizationId !== input.organizationId || (recorded !== undefined && recorded.projectId !== existing.id)) { throw new Error(`legacy import identity conflict: ${entry.legacyId} -> ${existing.id}`); } if (await hasCompletionMarker(existing.workspaceDir, entry)) { await ensureImportAudit(input.prisma, existing.id, entry); projects[entry.legacyId] = { status: "COMPLETED", projectId: existing.id, workspaceDir: existing.workspaceDir, importedAt: recorded?.importedAt || new Date().toISOString(), sourceRelativePath: entry.sourceRelativePath, }; await writeState(input.stateFile, { version: 1, projects }); input.onProgress?.(`skip ${entry.legacyId}: recovered completed import`); continue; } if (recorded?.status !== "PENDING") { throw new Error(`refusing to remove legacy project without a matching pending record: ${entry.legacyId}`); } input.onProgress?.(`recover ${entry.legacyId}: remove incomplete target`); await removeIncompleteTarget(input.prisma, existing.id, existing.workspaceDir, input.workspaceRoot); } projects[entry.legacyId] = { status: "PENDING", projectId, workspaceDir: "", importedAt: "", sourceRelativePath: entry.sourceRelativePath, }; await writeState(input.stateFile, { version: 1, projects }); const folderId = await ensureFolderPath(input.prisma, input.organizationId, ["旧教学资产", ...entry.folderPath]); input.onProgress?.(`import ${entry.legacyId}: ${entry.name}`); const created = await createProjectFromOrgAdmin(input.prisma, { organizationId: input.organizationId, actorFeishuOpenId: input.actorFeishuOpenId, name: entry.name, workspaceRoot: input.workspaceRoot, folderId, projectId, }); try { await copyLegacyProject(sourceDir, created.workspaceDir, entry); } catch (error) { try { await removeIncompleteTarget(input.prisma, created.projectId, created.workspaceDir, input.workspaceRoot); delete projects[entry.legacyId]; await writeState(input.stateFile, { version: 1, projects }); } catch (cleanupError) { throw new AggregateError( [error, cleanupError], `legacy project import and target cleanup failed: ${entry.legacyId}`, ); } throw new Error(`legacy project import failed: ${entry.legacyId}: ${errorMessage(error)}`, { cause: error }); } await ensureImportAudit(input.prisma, created.projectId, entry); projects[entry.legacyId] = { status: "COMPLETED", projectId: created.projectId, workspaceDir: created.workspaceDir, importedAt: new Date().toISOString(), sourceRelativePath: entry.sourceRelativePath, }; await writeState(input.stateFile, { version: 1, projects }); } return { version: 1, projects }; } async function ensureFolderPath( prisma: PrismaClient, organizationId: string, parts: readonly string[], ): Promise { let parentId: string | undefined; for (const name of parts) { const existing = await prisma.folder.findFirst({ where: { organizationId, parentId: parentId ?? null, name, archivedAt: null }, select: { id: true }, }); if (existing !== null) { parentId = existing.id; continue; } const created = await createFolder(prisma, { organizationId, name, ...(parentId !== undefined ? { parentId } : {}), }); parentId = created.id; } if (parentId === undefined) throw new Error("legacy import folder path is empty"); return parentId; } async function copyLegacyProject( sourceDir: string, workspaceDir: string, entry: LegacyProjectManifestEntry, ): Promise { const sourceWorkspace = join(sourceDir, "workspace"); await assertNoSymlinks(sourceWorkspace); const names = await readdir(sourceWorkspace); for (const name of names) { if (name === ".claude" || name === ".cph") continue; await cp(join(sourceWorkspace, name), join(workspaceDir, name), { recursive: true, force: false, errorOnExist: true, preserveTimestamps: true, filter: (source) => { const parts = relative(sourceWorkspace, source).split(sep); return !parts.includes(".claude") && !parts.includes(".cph"); }, }); } const legacyDir = join(workspaceDir, ".legacy-source"); await mkdir(legacyDir, { mode: 0o750 }); await cp(join(sourceDir, "project.json"), join(legacyDir, "project.json"), { force: false, errorOnExist: true, preserveTimestamps: true, }); const rawDir = join(sourceDir, "_raw"); await assertNoSymlinks(rawDir).catch((error: unknown) => { if (isMissing(error)) return; throw error; }); await cp(rawDir, join(legacyDir, "raw"), { recursive: true, force: false, errorOnExist: true, preserveTimestamps: true, }).catch((error: unknown) => { if (isMissing(error)) return; throw error; }); await writeFile(join(legacyDir, "migration.json"), `${JSON.stringify({ source: "teaching-material-host-service", legacyProjectId: entry.legacyId, legacyPath: entry.sourceRelativePath, migratedAt: new Date().toISOString(), }, null, 2)}\n`, { mode: 0o640 }); } function importedProjectId(organizationId: string, legacyId: string): string { const digest = createHash("sha256") .update("teaching-material-host-service\0") .update(organizationId) .update("\0") .update(legacyId) .digest("hex") .slice(0, 32); return `legacy_${digest}`; } async function hasCompletionMarker( workspaceDir: string, entry: LegacyProjectManifestEntry, ): Promise { try { const marker = JSON.parse(await readFile(join(workspaceDir, ".legacy-source", "migration.json"), "utf8")) as unknown; return typeof marker === "object" && marker !== null && "legacyProjectId" in marker && marker.legacyProjectId === entry.legacyId && "legacyPath" in marker && marker.legacyPath === entry.sourceRelativePath; } catch (error) { if (isMissing(error)) return false; if (error instanceof SyntaxError) { throw new Error(`invalid legacy completion marker: ${workspaceDir}`, { cause: error }); } throw error; } } async function ensureImportAudit( prisma: PrismaClient, projectId: string, entry: LegacyProjectManifestEntry, ): Promise { const existing = await prisma.auditEntry.findFirst({ where: { projectId, action: "legacy_project.imported" }, select: { id: true }, }); if (existing !== null) return; await prisma.auditEntry.create({ data: { projectId, action: "legacy_project.imported", metadata: { source: "teaching-material-host-service", legacyProjectId: entry.legacyId, legacyPath: entry.sourceRelativePath, }, }, }); } async function removeIncompleteTarget( prisma: PrismaClient, projectId: string, workspaceDir: string, workspaceRoot: string, ): Promise { await assertConfinedExistingPath(workspaceRoot, workspaceDir); const failures: unknown[] = []; try { await prisma.project.delete({ where: { id: projectId } }); } catch (error) { failures.push(error); } try { await rm(workspaceDir, { recursive: true, force: true }); } catch (error) { failures.push(error); } if (failures.length > 0) throw new AggregateError(failures, `failed to remove incomplete legacy target: ${projectId}`); } async function assertConfinedExistingPath(root: string, path: string): Promise { const trustedRoot = await realpath(root); const candidate = await realpath(path); const rel = relative(trustedRoot, candidate); if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { throw new Error(`refusing to remove path outside workspace root: ${path}`); } } async function assertNoSymlinks(path: string): Promise { const metadata = await lstat(path); if (metadata.isSymbolicLink()) throw new Error(`legacy import rejects symbolic link: ${path}`); if (!metadata.isDirectory()) return; for (const entry of await readdir(path)) await assertNoSymlinks(join(path, entry)); } async function confinedSourceDir(sourceRoot: string, relativePath: string): Promise { const candidate = await realpath(resolve(sourceRoot, relativePath)); const rel = relative(sourceRoot, candidate); if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { throw new Error(`legacy source path escapes source root: ${relativePath}`); } return candidate; } function validateEntry(entry: LegacyProjectManifestEntry): void { if (typeof entry !== "object" || entry === null) throw new Error("invalid legacy project entry"); if (typeof entry.legacyId !== "string") throw new Error("legacy project id must be a string"); if (!/^[A-Za-z0-9_-]+$/.test(entry.legacyId)) throw new Error(`invalid legacy project id: ${entry.legacyId}`); if (typeof entry.name !== "string") throw new Error(`legacy project name must be a string: ${entry.legacyId}`); if (entry.name.trim() === "") throw new Error(`legacy project name is empty: ${entry.legacyId}`); if (typeof entry.sourceRelativePath !== "string") { throw new Error(`legacy source path must be a string: ${entry.legacyId}`); } if (entry.sourceRelativePath === "" || resolve("/", entry.sourceRelativePath) === "/") { throw new Error(`invalid legacy source path: ${entry.legacyId}`); } if (!Array.isArray(entry.folderPath)) throw new Error(`legacy folder path must be an array: ${entry.legacyId}`); for (const part of entry.folderPath) { if (typeof part !== "string" || part.trim() === "" || part === "." || part === ".." || part.includes("/") || part.includes("\\")) { throw new Error(`invalid legacy folder part for ${entry.legacyId}: ${part}`); } } } async function readState(path: string): Promise { try { const parsed = JSON.parse(await readFile(path, "utf8")) as LegacyProjectImportState; if (parsed.version !== 1 || typeof parsed.projects !== "object" || parsed.projects === null) { throw new Error(`invalid legacy import state: ${path}`); } for (const [legacyId, record] of Object.entries(parsed.projects)) validateStateRecord(path, legacyId, record); return parsed; } catch (error) { if (isMissing(error)) return { version: 1, projects: {} }; throw error; } } function validateStateRecord(path: string, legacyId: string, record: unknown): void { if (typeof record !== "object" || record === null || Array.isArray(record)) { throw new Error(`invalid legacy import state record: ${path}#${legacyId}`); } const values = record as Record; const expectedKeys = ["importedAt", "projectId", "sourceRelativePath", "status", "workspaceDir"]; if (Object.keys(values).sort().join("\0") !== expectedKeys.join("\0")) { throw new Error(`invalid legacy import state fields: ${path}#${legacyId}`); } if (values.status !== "PENDING" && values.status !== "COMPLETED") { throw new Error(`invalid legacy import state status: ${path}#${legacyId}`); } for (const field of ["projectId", "workspaceDir", "importedAt", "sourceRelativePath"] as const) { if (typeof values[field] !== "string") throw new Error(`invalid legacy import state ${field}: ${path}#${legacyId}`); } if (values.projectId === "" || values.sourceRelativePath === "") { throw new Error(`invalid legacy import state identity: ${path}#${legacyId}`); } if (values.status === "PENDING" && (values.workspaceDir !== "" || values.importedAt !== "")) { throw new Error(`invalid pending legacy import state: ${path}#${legacyId}`); } if (values.status === "COMPLETED" && (values.workspaceDir === "" || values.importedAt === "")) { throw new Error(`invalid completed legacy import state: ${path}#${legacyId}`); } } async function writeState(path: string, state: LegacyProjectImportState): Promise { await mkdir(dirname(path), { recursive: true, mode: 0o750 }); const temporary = `${path}.tmp`; await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, path); } function isMissing(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }