Makelore 2.0 initial clean snapshot
This commit is contained in:
262
electron/opencode/project-directory-initialization.ts
Normal file
262
electron/opencode/project-directory-initialization.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user