263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
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<StagedProjectEntries> {
|
|
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<string[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<ProjectDirectoryInitializationResult> {
|
|
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 });
|
|
}
|
|
}
|
|
}
|