forked from bai/curriculum-project-hub
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
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<Record<string, {
|
|
readonly status: "PENDING" | "COMPLETED";
|
|
readonly projectId: string;
|
|
readonly workspaceDir: string;
|
|
readonly importedAt: string;
|
|
readonly sourceRelativePath: string;
|
|
}>>;
|
|
}
|
|
|
|
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<LegacyProjectImportState> {
|
|
const sourceRoot = await realpath(input.sourceRoot);
|
|
const state = await readState(input.stateFile);
|
|
const projects = { ...state.projects };
|
|
const seen = new Set<string>();
|
|
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<string | undefined> {
|
|
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;
|
|
}
|
|
return parentId;
|
|
}
|
|
|
|
async function copyLegacyProject(
|
|
sourceDir: string,
|
|
workspaceDir: string,
|
|
entry: LegacyProjectManifestEntry,
|
|
): Promise<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<LegacyProjectImportState> {
|
|
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<string, unknown>;
|
|
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<void> {
|
|
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);
|
|
}
|