import { constants, type Stats } from 'node:fs'; import { copyFile, lstat, mkdir, mkdtemp, readdir, rm, rmdir, stat, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; import type { ProjectConfig, ProjectTemplateId } from '../../shared/project-config'; import { createInitialProjectConfig, readProjectConfig } from './project-config'; import { initializeWorksPackageTemplate } from './project-package-template'; type StagedProjectEntries = { directories: string[]; files: string[]; }; export type ProjectDirectoryInitializationResult = { config: ProjectConfig; reusedExistingConfig: boolean; }; export type ProjectDirectoryInitializationInput = { projectPath: string; templateId: ProjectTemplateId; defaultModel?: string | null; allowExistingDirectory: boolean; }; export type ProjectDirectoryInitializationDependencies = { copyFile: typeof copyFile; }; const defaultDependencies: ProjectDirectoryInitializationDependencies = { copyFile, }; function isErrnoCode(error: unknown, code: string): boolean { return (error as NodeJS.ErrnoException | undefined)?.code === code; } function displayPath(relativePath: string): string { return relativePath.split(sep).join('/'); } function compareByDepth(left: string, right: string): number { const depthDifference = left.split(sep).length - right.split(sep).length; return depthDifference || left.localeCompare(right); } async function collectStagedEntries( stagingPath: string, relativeDirectory = '', ): Promise { const directories: string[] = []; const files: string[] = []; const entries = await readdir(join(stagingPath, relativeDirectory), { withFileTypes: true }); for (const entry of entries) { const relativePath = join(relativeDirectory, entry.name); if (entry.isDirectory()) { directories.push(relativePath); const nested = await collectStagedEntries(stagingPath, relativePath); directories.push(...nested.directories); files.push(...nested.files); continue; } if (entry.isFile()) { files.push(relativePath); continue; } throw new Error(`Unsupported staged project entry: ${displayPath(relativePath)}`); } return { directories: directories.sort(compareByDepth), files: files.sort((left, right) => left.localeCompare(right)), }; } async function readEntry(path: string) { try { return await lstat(path); } catch (error) { if (isErrnoCode(error, 'ENOENT')) return null; throw error; } } function conflictError(relativePath: string, detail = 'already exists'): Error { return new Error( `Project initialization conflict: ${displayPath(relativePath)} ${detail}. The selected folder was not changed.`, ); } async function rollbackCreatedEntries( createdFiles: string[], createdDirectories: string[], ): Promise { const rollbackErrors: string[] = []; for (const filePath of [...createdFiles].reverse()) { try { await rm(filePath, { force: true }); } catch (error) { rollbackErrors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`); } } for (const directoryPath of [...createdDirectories].reverse()) { try { await rmdir(directoryPath); } catch (error) { if (isErrnoCode(error, 'ENOENT') || isErrnoCode(error, 'ENOTEMPTY') || isErrnoCode(error, 'EEXIST')) { continue; } rollbackErrors.push(`${directoryPath}: ${error instanceof Error ? error.message : String(error)}`); } } return rollbackErrors; } async function mergeStagedProject( stagingPath: string, projectPath: string, dependencies: ProjectDirectoryInitializationDependencies, ): Promise { const staged = await collectStagedEntries(stagingPath); const missingDirectories: string[] = []; for (const relativePath of staged.directories) { const destination = join(projectPath, relativePath); const existing = await readEntry(destination); if (!existing) { missingDirectories.push(relativePath); continue; } if (!existing.isDirectory()) { throw conflictError(relativePath, 'already exists and is not a directory'); } } for (const relativePath of staged.files) { if (await readEntry(join(projectPath, relativePath))) { throw conflictError(relativePath); } } const createdDirectories: string[] = []; const createdFiles: string[] = []; try { for (const relativePath of missingDirectories) { const destination = join(projectPath, relativePath); try { await mkdir(destination); createdDirectories.push(destination); } catch (error) { if (isErrnoCode(error, 'EEXIST') && (await readEntry(destination))?.isDirectory()) { continue; } throw error; } } for (const relativePath of staged.files) { const destination = join(projectPath, relativePath); try { await dependencies.copyFile(join(stagingPath, relativePath), destination, constants.COPYFILE_EXCL); } catch (error) { if (isErrnoCode(error, 'EEXIST')) throw conflictError(relativePath); throw error; } createdFiles.push(destination); } } catch (error) { const rollbackErrors = await rollbackCreatedEntries(createdFiles, createdDirectories); if (rollbackErrors.length > 0) { throw new Error( `Project initialization failed and rollback was incomplete: ${rollbackErrors.join('; ')}`, { cause: error }, ); } throw error; } } async function requireExistingDirectory(projectPath: string): Promise { let projectStats: Stats; try { projectStats = await stat(projectPath); } catch (error) { if (isErrnoCode(error, 'ENOENT')) { throw new Error('The selected project folder no longer exists', { cause: error }); } throw error; } if (!projectStats.isDirectory()) { throw new Error('The selected project path is not a directory'); } } export async function initializeProjectDirectory( input: ProjectDirectoryInitializationInput, dependencies: ProjectDirectoryInitializationDependencies = defaultDependencies, ): Promise { let createdProjectDirectory = false; if (input.allowExistingDirectory) { await requireExistingDirectory(input.projectPath); const existing = await readProjectConfig(input.projectPath); if (existing.status === 'valid') { return { config: existing.config, reusedExistingConfig: true }; } if (existing.status === 'invalid') { throw new Error(`Existing project configuration is invalid: ${existing.error}`); } } else { await mkdir(input.projectPath); createdProjectDirectory = true; } let stagingPath: string | null = null; try { stagingPath = await mkdtemp(join(tmpdir(), 'niancode-project-init-')); try { await initializeWorksPackageTemplate(stagingPath, input.templateId); } catch (error) { throw new Error( `Project package template initialization failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } let config: ProjectConfig; try { config = await createInitialProjectConfig(stagingPath, input.templateId, { defaultModel: input.defaultModel, }); } catch (error) { throw new Error( `Project configuration initialization failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } await mergeStagedProject(stagingPath, input.projectPath, dependencies); return { config, reusedExistingConfig: false }; } catch (error) { if (createdProjectDirectory) { await rm(input.projectPath, { recursive: true, force: true }); } throw error; } finally { if (stagingPath) { await rm(stagingPath, { recursive: true, force: true }); } } }