diff --git a/.github/workflows/gui-checks.yml b/.github/workflows/gui-checks.yml index 2277602e9189..efe1b9e26a9e 100644 --- a/.github/workflows/gui-checks.yml +++ b/.github/workflows/gui-checks.yml @@ -89,6 +89,13 @@ jobs: fail-on-error: false fail-on-warning: false + - name: 📥 Download Enso Engine + continue-on-error: true + working-directory: app/project-manager-shim + run: pnpm run download-engine + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 🧪 Unit Tests id: unit-tests continue-on-error: true @@ -158,8 +165,6 @@ jobs: - name: 🎭 Playwright Tests working-directory: app/gui run: pnpm run test:integration --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: ⬆️ Upload blob report to GitHub Actions Artifacts if: ${{ !cancelled() }} diff --git a/app/gui/project-manager-shim-middleware/index.ts b/app/gui/project-manager-shim-middleware/index.ts index 3f9996594b8f..84d74a745685 100644 --- a/app/gui/project-manager-shim-middleware/index.ts +++ b/app/gui/project-manager-shim-middleware/index.ts @@ -49,7 +49,11 @@ import type { Readable } from 'node:stream' import { finished } from 'node:stream/promises' import { createGzip } from 'node:zlib' import * as projectManagement from 'project-manager-shim' -import { handleFilesystemCommand, toJSONRPCError, toJSONRPCResult } from 'project-manager-shim' +import { + handleFilesystemCommand, + handleProjectServiceRequest, + isProjectServiceRequest, +} from 'project-manager-shim/handler' import { ProjectService } from 'project-manager-shim/projectService' import { tarFsPack, unzipEntries, zipWriteStream } from './archive' @@ -212,30 +216,14 @@ export class ProjectManagerShimMiddleware { break } } - } else if (requestPath.startsWith('/api/project-service/')) { - switch (`${request.method} ${requestPath}`) { - case 'POST /api/project-service/project/create': { - interface ResponseBody { - readonly name: string - readonly projectsDirectory: Path - } - bodyJson(request) - .then(async (body) => { - const projectService = await this.getProjectService() - return projectService.createProject(body.name, body.projectsDirectory) - }) - .then((result) => { - response.writeHead(HTTP_STATUS_OK, COMMON_HEADERS).end(toJSONRPCResult(result)) - }) - .catch((err) => { - console.error(err) - response - .writeHead(HTTP_STATUS_OK, COMMON_HEADERS) - .end(toJSONRPCError('project/create failed', err)) - }) - break - } - } + } else if (isProjectServiceRequest(requestPath)) { + handleProjectServiceRequest( + request, + response, + requestPath, + () => this.getProjectService(), + COMMON_HEADERS, + ) } else if (requestPath.startsWith('/api/')) { switch (`${request.method} ${requestPath}`) { case `POST /api/${EXPORT_ARCHIVE_PATH}`: { @@ -348,16 +336,6 @@ export class ProjectManagerShimMiddleware { } } -/** Read JSON from an HTTP request body. */ -async function bodyJson(request: http.IncomingMessage): Promise { - const chunks: Buffer[] = [] - for await (const chunk of request) { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) - } - const body = Buffer.concat(chunks).toString('utf-8') - return JSON.parse(body) as T -} - /** Return whether a file exists. */ async function fileExists(path: string) { try { diff --git a/app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts b/app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts index b608793100b2..20ac6abec108 100644 --- a/app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts +++ b/app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts @@ -56,19 +56,24 @@ export class ProjectManager { private reconnecting = false private resolvers = new Map void>() private rejecters = new Map void>() - private socketPromise: Promise + private socketPromise: Promise | null = null /** Create a {@link ProjectManager} */ constructor( private readonly connectionUrl: string, public readonly rootDirectory: Path, ) { - this.socketPromise = this.reconnect() + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply + // eslint-disable-next-line react-hooks/rules-of-hooks + const enableProjectService = useFeatureFlag('enableProjectService') + if (!enableProjectService.value) { + this.socketPromise = this.reconnect() + } } /** Begin reconnecting the {@link WebSocket}. */ reconnect() { - if (this.reconnecting) { + if (this.reconnecting && this.socketPromise) { return this.socketPromise } this.reconnecting = true @@ -129,8 +134,10 @@ export class ProjectManager { /** Dispose of the {@link ProjectManager}. */ async dispose() { - const socket = await this.socketPromise - socket.close() + if (this.socketPromise) { + const socket = await this.socketPromise + socket.close() + } } /** Get the state of a project given its path. */ @@ -157,7 +164,15 @@ export class ProjectManager { if (cached) { return cached.data } else { - const promise = this.sendRequest('project/open', fullParams) + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply + // eslint-disable-next-line react-hooks/rules-of-hooks + const enableProjectService = useFeatureFlag('enableProjectService') + let promise: Promise + if (enableProjectService.value) { + promise = this.runProjectServiceCommandJson('project/open', fullParams) + } else { + promise = this.sendRequest('project/open', fullParams) + } this.projects.set(fullParams.projectId, { state: backend.ProjectState.openInProgress, data: promise, @@ -190,11 +205,19 @@ export class ProjectManager { } const fullParams: CloseProjectParams = this.paramsWithPathToWithId(params) this.projects.delete(fullParams.projectId) - return this.sendRequest('project/close', fullParams) + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply + // eslint-disable-next-line react-hooks/rules-of-hooks + const enableProjectService = useFeatureFlag('enableProjectService') + if (enableProjectService.value) { + return this.runProjectServiceCommandJson('project/close', fullParams) + } else { + return this.sendRequest('project/close', fullParams) + } } /** Create a new project. */ async createProject(params: CreateProjectParams): Promise { + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply // eslint-disable-next-line react-hooks/rules-of-hooks const enableProjectService = useFeatureFlag('enableProjectService') let result: Omit @@ -232,7 +255,14 @@ export class ProjectManager { /** Rename a project. */ async renameProject(params: WithProjectPath): Promise { const fullParams: RenameProjectParams = this.paramsWithPathToWithId(params) - await this.sendRequest('project/rename', fullParams) + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply + // eslint-disable-next-line react-hooks/rules-of-hooks + const enableProjectService = useFeatureFlag('enableProjectService') + if (enableProjectService.value) { + await this.runProjectServiceCommandJson('project/rename', fullParams) + } else { + await this.sendRequest('project/rename', fullParams) + } const state = this.projects.get(fullParams.projectId) if (state?.state === backend.ProjectState.opened) { this.projects.set(fullParams.projectId, { @@ -255,10 +285,15 @@ export class ProjectManager { params: WithProjectPath, ): Promise { const fullParams: DuplicateProjectParams = this.paramsWithPathToWithId(params) - const result = await this.sendRequest>( - 'project/duplicate', - fullParams, - ) + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply + // eslint-disable-next-line react-hooks/rules-of-hooks + const enableProjectService = useFeatureFlag('enableProjectService') + let result: Omit + if (enableProjectService.value) { + result = await this.runProjectServiceCommandJson('project/duplicate', fullParams) + } else { + result = await this.sendRequest('project/duplicate', fullParams) + } // Update `internalDirectories` by listing the project's parent directory, because the // directory name of the project is unknown. Deleting the directory is not an option because // that will prevent ALL descendants of the parent directory from being updated. @@ -279,7 +314,14 @@ export class ProjectManager { if (cached && backend.IS_OPENING_OR_OPENED[cached.state]) { await this.closeProject({ projectPath: params.projectPath }) } - await this.sendRequest('project/delete', fullParams) + // This is a Vue function, not a React hook, so the React hooks rule doesn't apply + // eslint-disable-next-line react-hooks/rules-of-hooks + const enableProjectService = useFeatureFlag('enableProjectService') + if (enableProjectService.value) { + await this.runProjectServiceCommandJson('project/delete', fullParams) + } else { + await this.sendRequest('project/delete', fullParams) + } this.projectIds.delete(params.projectPath) this.projects.delete(fullParams.projectId) const siblings = this.directories.get(fullParams.projectsDirectory) @@ -466,6 +508,8 @@ export class ProjectManager { /** Send a JSON-RPC request to the project manager. */ private async sendRequest(method: string, params: unknown): Promise { + // Initialize socket lazily if not already initialized + this.socketPromise ??= this.reconnect() const socket = await this.socketPromise const id = this.id++ socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) diff --git a/app/gui/src/dashboard/services/ProjectManager/types.ts b/app/gui/src/dashboard/services/ProjectManager/types.ts index 741a1829d657..0a68626e0ba6 100644 --- a/app/gui/src/dashboard/services/ProjectManager/types.ts +++ b/app/gui/src/dashboard/services/ProjectManager/types.ts @@ -140,7 +140,6 @@ export interface CreateProject { /** The return value of the "open project" endpoint. */ export interface OpenProject { - readonly engineVersion: string readonly languageServerJsonAddress: IpWithSocket readonly languageServerBinaryAddress: IpWithSocket readonly projectName: ProjectName diff --git a/app/ide-desktop/client/src/server.ts b/app/ide-desktop/client/src/server.ts index 12366994a754..ced460f816e7 100644 --- a/app/ide-desktop/client/src/server.ts +++ b/app/ide-desktop/client/src/server.ts @@ -15,7 +15,12 @@ import type * as vite from 'vite' import { COOP_COEP_CORP_HEADERS } from 'enso-common' import GLOBAL_CONFIG from 'enso-common/src/config.json' with { type: 'json' } import * as projectManagement from 'project-manager-shim' -import { handleFilesystemCommand } from 'project-manager-shim' +import { + handleFilesystemCommand, + handleProjectServiceRequest, + isProjectServiceRequest, +} from 'project-manager-shim/handler' +import { ProjectService } from 'project-manager-shim/projectService' import * as ydocServer from 'ydoc-server' import { tarFsPack, unzipEntries, zipWriteStream } from '@/archive' @@ -196,12 +201,21 @@ async function findPort(port: number): Promise { export class Server { private projectsRootDirectory: string private devServer?: vite.ViteDevServer + private projectService?: ProjectService /** Create a simple HTTP server. */ constructor(public config: Config) { this.projectsRootDirectory = projectManagement.getProjectsDirectory().replace(/\\/g, '/') } + /** Get the project service. */ + getProjectService(): ProjectService { + if (!this.projectService) { + this.projectService = ProjectService.default() + } + return this.projectService + } + /** Server constructor. */ static async create(config: Config): Promise { const localConfig = Object.assign({}, config) @@ -316,6 +330,15 @@ export class Server { ), { end: true }, ) + } else if (isProjectServiceRequest(requestUrl)) { + const headers = Object.fromEntries(COOP_COEP_CORP_HEADERS) + handleProjectServiceRequest( + request, + response, + requestUrl, + async () => this.getProjectService(), + headers, + ) } else if (request.url?.startsWith('/api/')) { const route = new URL(`https://example.com${requestUrl.replace('/api/', '/')}`) const params = route.searchParams diff --git a/app/project-manager-shim/package.json b/app/project-manager-shim/package.json index 051b29a57dc0..d0b4a5a4fd11 100644 --- a/app/project-manager-shim/package.json +++ b/app/project-manager-shim/package.json @@ -9,6 +9,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./handler": { + "source": "./src/handler/index.ts", + "types": "./dist/handler/index.d.ts", + "import": "./dist/handler/index.js" + }, "./projectService": { "source": "./src/projectService/index.ts", "types": "./dist/projectService/index.d.ts", @@ -19,17 +24,21 @@ "scripts": { "test:unit": "vitest run", "compile": "tsc", - "lint": "eslint . --cache --max-warnings=0" + "lint": "eslint . --cache --max-warnings=0", + "download-engine": "node scripts/download-engine.js" }, "devDependencies": { + "@types/extract-zip": "^2.0.3", "@types/node": "catalog:", "@types/tar": "^6.1.13", + "extract-zip": "^2.0.1", "typescript": "catalog:", "vitest": "catalog:" }, "dependencies": { "enso-common": "workspace:*", "tar": "^6.2.1", + "trash": "^9.0.0", "yaml": "^2.7.0" } } diff --git a/app/project-manager-shim/scripts/download-engine.js b/app/project-manager-shim/scripts/download-engine.js new file mode 100644 index 000000000000..04a4a8303b5b --- /dev/null +++ b/app/project-manager-shim/scripts/download-engine.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import { rmSync } from 'fs' +import { dirname, join } from 'path' +import process from 'process' +import { fileURLToPath } from 'url' +import { downloadEnsoEngine } from '../dist/projectService/ensoRunner.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const projectRoot = join(__dirname, '../../..') +const builtDistributionPath = join(projectRoot, 'built-distribution') + +// Delete the built-distribution directory +rmSync(builtDistributionPath, { recursive: true, force: true }) + +// Download the engine +downloadEnsoEngine(projectRoot) + .then(() => { + process.exit(0) + }) + .catch((error) => { + console.error('Failed to download engine:', error) + process.exit(1) + }) diff --git a/app/project-manager-shim/src/filesystem.ts b/app/project-manager-shim/src/handler/filesystem.ts similarity index 95% rename from app/project-manager-shim/src/filesystem.ts rename to app/project-manager-shim/src/handler/filesystem.ts index f9d769fb9423..38a9eada56a0 100644 --- a/app/project-manager-shim/src/filesystem.ts +++ b/app/project-manager-shim/src/handler/filesystem.ts @@ -4,7 +4,8 @@ import * as fs from 'node:fs/promises' import type * as http from 'node:http' import * as path from 'node:path' import * as yaml from 'yaml' -import * as projectManagement from './projectManagement.js' +import * as projectManagement from '../projectManagement.js' +import { toJSONRPCError, toJSONRPCResult } from './jsonrpc.js' // ======================= // === ProjectMetadata === @@ -84,20 +85,6 @@ export interface ProjectEntry { // === Handlers === // ================ -/** JSON-RPC result wrapper */ -export function toJSONRPCResult(result: unknown): string { - return JSON.stringify({ jsonrpc: '2.0', id: 0, result }) -} - -/** JSON-RPC error wrapper */ -export function toJSONRPCError(message: string, data?: unknown): string { - return JSON.stringify({ - jsonrpc: '2.0', - id: 0, - error: { code: 0, message, ...(data != null ? { data } : {}) }, - }) -} - /** * Return a {@link ProjectMetadata} if the metadata is a valid metadata object, * else return `null`. diff --git a/app/project-manager-shim/src/handler/index.ts b/app/project-manager-shim/src/handler/index.ts new file mode 100644 index 000000000000..1835fc8fa5c0 --- /dev/null +++ b/app/project-manager-shim/src/handler/index.ts @@ -0,0 +1,6 @@ +/** + * @file Re-exports for handler module. + */ + +export * from './filesystem.js' +export * from './projectService.js' diff --git a/app/project-manager-shim/src/handler/jsonrpc.ts b/app/project-manager-shim/src/handler/jsonrpc.ts new file mode 100644 index 000000000000..e67cb79047cc --- /dev/null +++ b/app/project-manager-shim/src/handler/jsonrpc.ts @@ -0,0 +1,13 @@ +/** JSON-RPC result wrapper */ +export function toJSONRPCResult(result: unknown): string { + return JSON.stringify({ jsonrpc: '2.0', id: 0, result }) +} + +/** JSON-RPC error wrapper */ +export function toJSONRPCError(message: string, data?: unknown): string { + return JSON.stringify({ + jsonrpc: '2.0', + id: 0, + error: { code: 0, message, ...(data != null ? { data } : {}) }, + }) +} diff --git a/app/project-manager-shim/src/handler/projectService.ts b/app/project-manager-shim/src/handler/projectService.ts new file mode 100644 index 000000000000..6b785095fb26 --- /dev/null +++ b/app/project-manager-shim/src/handler/projectService.ts @@ -0,0 +1,157 @@ +import { Path, UUID } from 'enso-common/src/services/Backend' +import type * as http from 'node:http' +import { ProjectService, type CloudParams } from '../projectService/index.js' +import { toJSONRPCError, toJSONRPCResult } from './jsonrpc.js' + +const HTTP_STATUS_OK = 200 + +/** Read JSON from an HTTP request body. */ +async function bodyJson(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of request) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + const body = Buffer.concat(chunks).toString('utf-8') + return JSON.parse(body) as T +} + +/** Check if this is a project service request */ +export function isProjectServiceRequest(requestPath: string): boolean { + return requestPath.startsWith('/api/project-service/') +} + +/** Handle project service requests */ +export async function handleProjectServiceRequest( + request: http.IncomingMessage, + response: http.ServerResponse, + requestPath: string, + getProjectService: () => Promise, + headers: Record, +): Promise { + switch (`${request.method} ${requestPath}`) { + case 'POST /api/project-service/project/create': { + interface ResponseBody { + readonly name: string + readonly projectsDirectory: Path + } + bodyJson(request) + .then(async (body) => { + const projectService = await getProjectService() + return projectService.createProject(body.name, body.projectsDirectory) + }) + .then((result) => { + response.writeHead(HTTP_STATUS_OK, headers).end(toJSONRPCResult(result)) + }) + .catch((err) => { + response + .writeHead(HTTP_STATUS_OK, headers) + .end(toJSONRPCError('project/create failed', err)) + }) + break + } + case 'POST /api/project-service/project/open': { + interface Body { + readonly projectId: UUID + readonly projectsDirectory: Path + readonly cloud?: CloudParams + } + bodyJson(request) + .then(async (body) => { + const projectService = await getProjectService() + return projectService.openProject(body.projectId, body.projectsDirectory, body.cloud) + }) + .then((result) => { + response.writeHead(HTTP_STATUS_OK, headers).end(toJSONRPCResult(result)) + }) + .catch((err) => { + response + .writeHead(HTTP_STATUS_OK, headers) + .end(toJSONRPCError('project/open failed', err)) + }) + break + } + case 'POST /api/project-service/project/close': { + interface Body { + readonly projectId: UUID + } + bodyJson(request) + .then(async (body) => { + const projectService = await getProjectService() + return projectService.closeProject(body.projectId) + }) + .then(() => { + response.writeHead(HTTP_STATUS_OK, headers).end(toJSONRPCResult(null)) + }) + .catch((err) => { + response + .writeHead(HTTP_STATUS_OK, headers) + .end(toJSONRPCError('project/close failed', err)) + }) + break + } + case 'POST /api/project-service/project/delete': { + interface Body { + readonly projectId: UUID + readonly projectsDirectory: Path + } + bodyJson(request) + .then(async (body) => { + const projectService = await getProjectService() + return projectService.deleteProject(body.projectId, body.projectsDirectory) + }) + .then(() => { + response.writeHead(HTTP_STATUS_OK, headers).end(toJSONRPCResult(null)) + }) + .catch((err) => { + response + .writeHead(HTTP_STATUS_OK, headers) + .end(toJSONRPCError('project/delete failed', err)) + }) + break + } + case 'POST /api/project-service/project/duplicate': { + interface Body { + readonly projectId: UUID + readonly projectsDirectory: Path + } + bodyJson(request) + .then(async (body) => { + const projectService = await getProjectService() + return projectService.duplicateProject(body.projectId, body.projectsDirectory) + }) + .then((result) => { + response.writeHead(HTTP_STATUS_OK, headers).end(toJSONRPCResult(result)) + }) + .catch((err) => { + response + .writeHead(HTTP_STATUS_OK, headers) + .end(toJSONRPCError('project/duplicate failed', err)) + }) + break + } + case 'POST /api/project-service/project/rename': { + interface Body { + readonly projectId: UUID + readonly name: string + readonly projectsDirectory: Path + } + bodyJson(request) + .then(async (body) => { + const projectService = await getProjectService() + return projectService.renameProject(body.projectId, body.name, body.projectsDirectory) + }) + .then(() => { + response.writeHead(HTTP_STATUS_OK, headers).end(toJSONRPCResult(null)) + }) + .catch((err) => { + response + .writeHead(HTTP_STATUS_OK, headers) + .end(toJSONRPCError('project/rename failed', err)) + }) + break + } + default: { + throw new Error(`Unknown project service request ${requestPath}`) + } + } +} diff --git a/app/project-manager-shim/src/index.ts b/app/project-manager-shim/src/index.ts index 17bd9b1c2aa6..344ae1844a35 100644 --- a/app/project-manager-shim/src/index.ts +++ b/app/project-manager-shim/src/index.ts @@ -1,3 +1,2 @@ -export * from './filesystem.js' export * from './projectManagement.js' export { downloadEnsoEngine, findEnsoExecutable } from './projectService/ensoRunner.js' diff --git a/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts b/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts new file mode 100644 index 000000000000..a4ba799a35c3 --- /dev/null +++ b/app/project-manager-shim/src/projectService/__tests__/ProjectService.test.ts @@ -0,0 +1,1091 @@ +import { UUID } from 'enso-common/src/services/Backend' +import { Path } from 'enso-common/src/utilities/file' +import * as crypto from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { EnsoRunner, findEnsoExecutable } from '../ensoRunner.js' +import { ProjectService } from '../index.js' + +// Test timeout for operations involving language server startup +const LANGUAGE_SERVER_TEST_TIMEOUT = 35000 + +describe('ProjectService', () => { + let ensoPath: Path | undefined + let projectService: ProjectService + let tempDir: string + let projectsDirectory: Path + + beforeAll(async () => { + // Try to find the Enso executable providing path to repository root + const repositoryRoot = path.join(__dirname, '..', '..', '..', '..', '..') + ensoPath = findEnsoExecutable(repositoryRoot) + + if (ensoPath) { + // Use real Enso runner if available + const runner = new EnsoRunner(ensoPath) + projectService = new ProjectService(runner) + } else { + // Fail the test if executable is not available + throw new Error('Enso executable not found. Cannot run tests without Enso runtime.') + } + }) + + beforeEach(async () => { + // Create a temporary directory for test projects + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'project-service-test-')) + projectsDirectory = Path(tempDir) + + if (!ensoPath) { + // Fail the test if executable is not available + throw new Error('Enso executable not found. Cannot run tests without Enso runtime.') + } + }) + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 1000 }) + } catch (error) { + console.error('Failed to clean up temp directory:', error) + } + }) + + describe('createProject', () => { + test('should create a new project with a valid name', async () => { + const projectName = 'TestProject' + const result = await projectService.createProject(projectName, projectsDirectory) + + // Verify the result structure + expect(result).toBeDefined() + expect(result.projectId).toBeDefined() + expect(result.projectName).toBe(projectName) + expect(result.projectNormalizedName).toBe('TestProject') + expect(result.projectPath).toContain(tempDir) + + // Verify project directory was created + const projectExists = await fs + .access(result.projectPath) + .then(() => true) + .catch(() => false) + expect(projectExists).toBe(true) + + // Verify package.yaml was created + const packageYamlPath = path.join(result.projectPath, 'package.yaml') + const packageYamlExists = await fs + .access(packageYamlPath) + .then(() => true) + .catch(() => false) + expect(packageYamlExists).toBe(true) + + // Verify project metadata was created + const metadataPath = path.join(result.projectPath, '.enso', 'project.json') + const metadataExists = await fs + .access(metadataPath) + .then(() => true) + .catch(() => false) + expect(metadataExists).toBe(true) + + // Read and verify metadata content + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + expect(metadata.id).toBe(result.projectId) + expect(metadata.kind).toBe('UserProject') + expect(metadata.created).toBeDefined() + }) + + test('should handle duplicate project names by appending suffix', async () => { + const projectName = 'DuplicateProject' + + // Create first project + const result1 = await projectService.createProject(projectName, projectsDirectory) + expect(result1.projectName).toBe(projectName) + + // Create second project with same name + const result2 = await projectService.createProject(projectName, projectsDirectory) + expect(result2.projectName).toBe(`${projectName}_1`) + + // Create third project with same name + const result3 = await projectService.createProject(projectName, projectsDirectory) + expect(result3.projectName).toBe(`${projectName}_2`) + + // Verify all projects were created + const project1Exists = await fs + .access(result1.projectPath) + .then(() => true) + .catch(() => false) + const project2Exists = await fs + .access(result2.projectPath) + .then(() => true) + .catch(() => false) + const project3Exists = await fs + .access(result3.projectPath) + .then(() => true) + .catch(() => false) + + expect(project1Exists).toBe(true) + expect(project2Exists).toBe(true) + expect(project3Exists).toBe(true) + }) + + test('should normalize project names correctly', async () => { + const testCases = [ + { input: 'myProject', expectedName: 'myProject', expectedNormalized: 'MyProject' }, + { input: 'Project_123', expectedName: 'Project_123', expectedNormalized: 'Project_123' }, + { input: 'Test Project', expectedName: 'Test Project', expectedNormalized: 'TestProject' }, + ] + + for (const testCase of testCases) { + const result = await projectService.createProject(testCase.input, projectsDirectory) + + expect(result.projectName).toBe(testCase.expectedName) + expect(result.projectNormalizedName).toBe(testCase.expectedNormalized) + + // Verify the normalized name is used for the directory + expect(result.projectPath).toContain(testCase.expectedNormalized) + } + }) + + test('should reject empty project names', async () => { + await expect(projectService.createProject('', projectsDirectory)).rejects.toThrow( + 'Project name cannot be empty', + ) + + await expect(projectService.createProject(' ', projectsDirectory)).rejects.toThrow( + 'Project name cannot be empty', + ) + }) + + test('should create project with template if specified', async () => { + const projectName = 'TemplateProject' + const template = 'default' + + const result = await projectService.createProject(projectName, projectsDirectory, template) + + expect(result).toBeDefined() + expect(result.projectName).toBe(projectName) + + // Verify project was created + const projectExists = await fs + .access(result.projectPath) + .then(() => true) + .catch(() => false) + expect(projectExists).toBe(true) + }) + + test('should generate unique UUIDs for each project', async () => { + const result1 = await projectService.createProject('Project1', projectsDirectory) + const result2 = await projectService.createProject('Project2', projectsDirectory) + + expect(result1.projectId).toBeDefined() + expect(result2.projectId).toBeDefined() + expect(result1.projectId).not.toBe(result2.projectId) + + // Verify UUIDs are valid format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + expect(result1.projectId).toMatch(uuidRegex) + expect(result2.projectId).toMatch(uuidRegex) + }) + + test('should set correct timestamps in project metadata', async () => { + const beforeCreation = new Date() + const result = await projectService.createProject('TimestampProject', projectsDirectory) + const afterCreation = new Date() + + // Read project metadata + const metadataPath = path.join(result.projectPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + + // Parse the created timestamp + const createdDate = new Date(metadata.created) + + // Verify the timestamp is within the expected range + expect(createdDate.getTime()).toBeGreaterThanOrEqual(beforeCreation.getTime()) + expect(createdDate.getTime()).toBeLessThanOrEqual(afterCreation.getTime()) + + // Verify RFC3339 format + expect(metadata.created).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/) + }) + }) + + describe('openProject', () => { + test( + 'should successfully open an existing project', + async () => { + // First create a project + const createResult = await projectService.createProject('ProjectToOpen', projectsDirectory) + + // Open the project + const openResult = await projectService.openProject( + createResult.projectId, + projectsDirectory, + ) + + // Verify the result structure + expect(openResult).toBeDefined() + expect(openResult.languageServerJsonAddress).toBeDefined() + expect(openResult.languageServerBinaryAddress).toBeDefined() + expect(openResult.projectName).toBe('ProjectToOpen') + expect(openResult.projectNormalizedName).toBe('ProjectToOpen') + expect(openResult.projectNamespace).toBe('local') + + // Verify sockets have valid structure + expect(openResult.languageServerJsonAddress.host).toBe('127.0.0.1') + expect(openResult.languageServerJsonAddress.port).toBeGreaterThan(0) + expect(openResult.languageServerBinaryAddress.host).toBe('127.0.0.1') + expect(openResult.languageServerBinaryAddress.port).toBeGreaterThan(0) + + // Close the project to clean up + await projectService.closeProject(createResult.projectId) + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + + test( + 'should update lastOpened timestamp when opening project', + async () => { + // Create a project + const createResult = await projectService.createProject( + 'TimestampTestProject', + projectsDirectory, + ) + + const beforeOpen = new Date() + // Open the project + await projectService.openProject(createResult.projectId, projectsDirectory) + const afterOpen = new Date() + + // Read project metadata to check lastOpened + const metadataPath = path.join(createResult.projectPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + + expect(metadata.lastOpened).toBeDefined() + + // Parse the lastOpened timestamp + const lastOpenedDate = new Date(metadata.lastOpened) + // Verify the timestamp is within the expected range + expect(lastOpenedDate.getTime()).toBeGreaterThanOrEqual(beforeOpen.getTime()) + expect(lastOpenedDate.getTime()).toBeLessThanOrEqual(afterOpen.getTime()) + + // Verify RFC3339 format + expect(metadata.lastOpened).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/) + + // Clean up + await projectService.closeProject(createResult.projectId) + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + + test('should fail when opening non-existent project', async () => { + const nonExistentId = crypto.randomUUID() as UUID + + await expect(projectService.openProject(nonExistentId, projectsDirectory)).rejects.toThrow( + `Project not found: ${nonExistentId}`, + ) + }) + + test( + 'should be able to open two projects simultaneously', + async () => { + // Create two projects + const project1 = await projectService.createProject( + 'SimultaneousProject1', + projectsDirectory, + ) + const project2 = await projectService.createProject( + 'SimultaneousProject2', + projectsDirectory, + ) + + // Open both projects + const openResult1 = await projectService.openProject(project1.projectId, projectsDirectory) + const openResult2 = await projectService.openProject(project2.projectId, projectsDirectory) + + // Verify both projects opened successfully + expect(openResult1).toBeDefined() + expect(openResult2).toBeDefined() + + // Verify both have valid language server addresses + expect(openResult1.languageServerJsonAddress).toBeDefined() + expect(openResult1.languageServerBinaryAddress).toBeDefined() + expect(openResult2.languageServerJsonAddress).toBeDefined() + expect(openResult2.languageServerBinaryAddress).toBeDefined() + + // Verify they have different ports + expect(openResult1.languageServerJsonAddress.port).not.toBe( + openResult2.languageServerJsonAddress.port, + ) + expect(openResult1.languageServerBinaryAddress.port).not.toBe( + openResult2.languageServerBinaryAddress.port, + ) + + // Verify both projects have correct names + expect(openResult1.projectName).toBe('SimultaneousProject1') + expect(openResult2.projectName).toBe('SimultaneousProject2') + + // Verify both projects have 'local' namespace + expect(openResult1.projectNamespace).toBe('local') + expect(openResult2.projectNamespace).toBe('local') + + // Verify both language servers are running by checking ports + const isPortInUse = async (port: number): Promise => { + try { + const response = await fetch(`http://127.0.0.1:${port}/_health`) + return response.ok + } catch { + return false + } + } + + const project1Running = await isPortInUse(openResult1.languageServerJsonAddress.port) + const project2Running = await isPortInUse(openResult2.languageServerJsonAddress.port) + + expect(project1Running).toBe(true) + expect(project2Running).toBe(true) + + // Clean up - close both projects + await projectService.closeProject(project1.projectId) + await projectService.closeProject(project2.projectId) + }, + LANGUAGE_SERVER_TEST_TIMEOUT * 2, + ) + }) + + describe('closeProject', () => { + test( + 'should successfully close an open project', + async () => { + // Create and open a project + const createResult = await projectService.createProject('ProjectToClose', projectsDirectory) + + await projectService.openProject(createResult.projectId, projectsDirectory) + + // Close the project - should not throw + await expect(projectService.closeProject(createResult.projectId)).resolves.not.toThrow() + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + + test( + 'should handle closing already closed project gracefully', + async () => { + // Create and open a project + const createResult = await projectService.createProject( + 'DoubleCloseProject', + projectsDirectory, + ) + + await projectService.openProject(createResult.projectId, projectsDirectory) + + // Close the project once + await projectService.closeProject(createResult.projectId) + + // Attempt to close again - should handle gracefully + await expect(projectService.closeProject(createResult.projectId)).resolves.not.toThrow() + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + + test('should handle closing a project that was never opened', async () => { + // Create a project but don't open it + const createResult = await projectService.createProject( + 'NeverOpenedProject', + projectsDirectory, + ) + + // Attempt to close - should handle gracefully + await expect(projectService.closeProject(createResult.projectId)).resolves.not.toThrow() + }) + + test( + 'should properly terminate language server process', + async () => { + // Create and open a project + const createResult = await projectService.createProject( + 'ProcessTerminationProject', + projectsDirectory, + ) + + const openResult = await projectService.openProject( + createResult.projectId, + projectsDirectory, + ) + + // Verify server is running by checking if port is in use + const jsonPort = openResult.languageServerJsonAddress.port + + // Helper function to check if port is in use + const isPortInUse = async (port: number): Promise => { + try { + const response = await fetch(`http://127.0.0.1:${port}/_health`) + return response.ok + } catch { + return false + } + } + + // Verify server is initially running + const isRunningBefore = await isPortInUse(jsonPort) + expect(isRunningBefore).toBe(true) + + // Close the project + await projectService.closeProject(createResult.projectId) + + // Wait a bit for the process to fully terminate + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Verify server is no longer running + const isRunningAfter = await isPortInUse(jsonPort) + expect(isRunningAfter).toBe(false) + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + }) + + describe('deleteProject', () => { + test('should successfully delete a project', async () => { + // Create a project + const createResult = await projectService.createProject('ProjectToDelete', projectsDirectory) + const projectPath = createResult.projectPath + + // Verify project exists + const existsBefore = await fs + .access(projectPath) + .then(() => true) + .catch(() => false) + expect(existsBefore).toBe(true) + + // Delete the project + await projectService.deleteProject(createResult.projectId, projectsDirectory) + + // Verify project no longer exists at original location + const existsAfter = await fs + .access(projectPath) + .then(() => true) + .catch(() => false) + expect(existsAfter).toBe(false) + }) + + test('should fail when deleting non-existent project', async () => { + const nonExistentId = crypto.randomUUID() as UUID + + await expect(projectService.deleteProject(nonExistentId, projectsDirectory)).rejects.toThrow( + `Project '${nonExistentId}' not found`, + ) + }) + + test('should handle multiple projects and only delete the specified one', async () => { + // Create multiple projects + const project1 = await projectService.createProject('Project1', projectsDirectory) + const project2 = await projectService.createProject('Project2', projectsDirectory) + const project3 = await projectService.createProject('Project3', projectsDirectory) + + // Delete only project2 + await projectService.deleteProject(project2.projectId, projectsDirectory) + + // Verify project1 and project3 still exist + const project1Exists = await fs + .access(project1.projectPath) + .then(() => true) + .catch(() => false) + const project2Exists = await fs + .access(project2.projectPath) + .then(() => true) + .catch(() => false) + const project3Exists = await fs + .access(project3.projectPath) + .then(() => true) + .catch(() => false) + + expect(project1Exists).toBe(true) + expect(project2Exists).toBe(false) + expect(project3Exists).toBe(true) + }) + + test('should successfully delete a project with special characters in name', async () => { + // Create a project with special characters + const projectName = 'Test Project #1' + const createResult = await projectService.createProject(projectName, projectsDirectory) + const projectPath = createResult.projectPath + + // Verify project exists + const existsBefore = await fs + .access(projectPath) + .then(() => true) + .catch(() => false) + expect(existsBefore).toBe(true) + + // Delete the project + await projectService.deleteProject(createResult.projectId, projectsDirectory) + + // Verify project no longer exists + const existsAfter = await fs + .access(projectPath) + .then(() => true) + .catch(() => false) + expect(existsAfter).toBe(false) + }) + }) + + describe('duplicateProject', () => { + test('should successfully duplicate a project', async () => { + // Create an original project + const originalName = 'OriginalProject' + const originalResult = await projectService.createProject(originalName, projectsDirectory) + + // Add some content to the original project to verify it gets copied + const testFilePath = path.join(originalResult.projectPath, 'src', 'Main.enso') + await fs.mkdir(path.dirname(testFilePath), { recursive: true }) + await fs.writeFile(testFilePath, 'main = "Hello from original"') + + // Duplicate the project + const duplicateResult = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + + // Verify the duplicate result structure + expect(duplicateResult).toBeDefined() + expect(duplicateResult.projectId).toBeDefined() + expect(duplicateResult.projectName).toBe(`${originalName} (copy)`) + expect(duplicateResult.projectNormalizedName).toBe('OriginalProjectcopy') + expect(duplicateResult.projectPath).toBeDefined() + + // Verify the duplicate has a different ID + expect(duplicateResult.projectId).not.toBe(originalResult.projectId) + + // Verify the duplicate project exists + const duplicateExists = await fs + .access(duplicateResult.projectPath) + .then(() => true) + .catch(() => false) + expect(duplicateExists).toBe(true) + + // Verify the content was copied + const duplicatedFilePath = path.join(duplicateResult.projectPath, 'src', 'Main.enso') + const duplicatedContent = await fs.readFile(duplicatedFilePath, 'utf-8') + expect(duplicatedContent).toBe('main = "Hello from original"') + + // Verify duplicate metadata + const metadataPath = path.join(duplicateResult.projectPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + expect(metadata.id).toBe(duplicateResult.projectId) + expect(metadata.created).toBeDefined() + expect(metadata.lastOpened).oneOf([undefined, null]) + }) + + test('should handle duplicate names when duplicating multiple times', async () => { + // Create an original project + const originalName = 'ProjectToDuplicateMultiple' + const originalResult = await projectService.createProject(originalName, projectsDirectory) + + // First duplicate + const duplicate1 = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + expect(duplicate1.projectName).toBe(`${originalName} (copy)`) + + // Second duplicate of the same original + const duplicate2 = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + expect(duplicate2.projectName).toBe(`${originalName} (copy)_1`) + + // Third duplicate + const duplicate3 = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + expect(duplicate3.projectName).toBe(`${originalName} (copy)_2`) + + // Verify all projects exist + const allExist = await Promise.all([ + fs + .access(originalResult.projectPath) + .then(() => true) + .catch(() => false), + fs + .access(duplicate1.projectPath) + .then(() => true) + .catch(() => false), + fs + .access(duplicate2.projectPath) + .then(() => true) + .catch(() => false), + fs + .access(duplicate3.projectPath) + .then(() => true) + .catch(() => false), + ]) + expect(allExist).toEqual([true, true, true, true]) + }) + + test('should duplicate a project with special characters in name', async () => { + // Create a project with special characters + const originalName = 'Test Project #1 & More' + const originalResult = await projectService.createProject(originalName, projectsDirectory) + + // Duplicate the project + const duplicateResult = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + + expect(duplicateResult.projectName).toBe(`${originalName} (copy)`) + + // Verify the duplicate exists + const duplicateExists = await fs + .access(duplicateResult.projectPath) + .then(() => true) + .catch(() => false) + expect(duplicateExists).toBe(true) + }) + + test('should fail when duplicating non-existent project', async () => { + const nonExistentId = crypto.randomUUID() as UUID + + await expect( + projectService.duplicateProject(nonExistentId, projectsDirectory), + ).rejects.toThrow(`Project not found: ${nonExistentId}`) + }) + + test('should preserve project structure when duplicating', async () => { + // Create a project with a specific structure + const originalName = 'ProjectWithStructure' + const originalResult = await projectService.createProject(originalName, projectsDirectory) + + // Add various files and directories to the original + const srcDir = path.join(originalResult.projectPath, 'src') + const testDir = path.join(originalResult.projectPath, 'test') + const configFile = path.join(originalResult.projectPath, 'config.yaml') + + await fs.mkdir(srcDir, { recursive: true }) + await fs.mkdir(testDir, { recursive: true }) + await fs.writeFile(path.join(srcDir, 'Main.enso'), 'main = "Hello"') + await fs.writeFile(path.join(srcDir, 'Utils.enso'), 'util = "Utility"') + await fs.writeFile(path.join(testDir, 'Test.enso'), 'test = "Test"') + await fs.writeFile(configFile, 'setting: value') + + // Duplicate the project + const duplicateResult = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + + // Verify all files and directories were copied + const duplicateSrcMain = path.join(duplicateResult.projectPath, 'src', 'Main.enso') + const duplicateSrcUtils = path.join(duplicateResult.projectPath, 'src', 'Utils.enso') + const duplicateTest = path.join(duplicateResult.projectPath, 'test', 'Test.enso') + const duplicateConfig = path.join(duplicateResult.projectPath, 'config.yaml') + + const [mainContent, utilsContent, testContent, configContent] = await Promise.all([ + fs.readFile(duplicateSrcMain, 'utf-8'), + fs.readFile(duplicateSrcUtils, 'utf-8'), + fs.readFile(duplicateTest, 'utf-8'), + fs.readFile(duplicateConfig, 'utf-8'), + ]) + + expect(mainContent).toBe('main = "Hello"') + expect(utilsContent).toBe('util = "Utility"') + expect(testContent).toBe('test = "Test"') + expect(configContent).toBe('setting: value') + }) + + test('should generate new UUID and creation timestamp for duplicate', async () => { + // Create an original project + const originalResult = await projectService.createProject( + 'ProjectForMetadataTest', + projectsDirectory, + ) + + // Read original metadata + const originalMetadataPath = path.join(originalResult.projectPath, '.enso', 'project.json') + const originalMetadataContent = await fs.readFile(originalMetadataPath, 'utf-8') + const originalMetadata = JSON.parse(originalMetadataContent) + + // Duplicate the project + await new Promise((resolve) => setTimeout(resolve, 10)) + const beforeDuplicate = new Date() + const duplicateResult = await projectService.duplicateProject( + originalResult.projectId, + projectsDirectory, + ) + const afterDuplicate = new Date() + + // Read duplicate metadata + const duplicateMetadataPath = path.join(duplicateResult.projectPath, '.enso', 'project.json') + const duplicateMetadataContent = await fs.readFile(duplicateMetadataPath, 'utf-8') + const duplicateMetadata = JSON.parse(duplicateMetadataContent) + + // Verify different UUID + expect(duplicateMetadata.id).not.toBe(originalMetadata.id) + expect(duplicateMetadata.id).toBe(duplicateResult.projectId) + + // Verify new creation timestamp + expect(duplicateMetadata.created).not.toBe(originalMetadata.created) + const duplicateCreatedDate = new Date(duplicateMetadata.created) + expect(duplicateCreatedDate.getTime()).toBeGreaterThanOrEqual(beforeDuplicate.getTime()) + expect(duplicateCreatedDate.getTime()).toBeLessThanOrEqual(afterDuplicate.getTime()) + + // Verify namespace is preserved + expect(duplicateMetadata.namespace).toBe(originalMetadata.namespace) + }) + }) + + describe('renameProject', () => { + test('should successfully rename a project when language server is not running', async () => { + // Create a project + const originalName = 'OriginalProjectName' + const createResult = await projectService.createProject(originalName, projectsDirectory) + + // Add some content to verify it's preserved after rename + const testFilePath = path.join(createResult.projectPath, 'src', 'Main.enso') + await fs.mkdir(path.dirname(testFilePath), { recursive: true }) + await fs.writeFile(testFilePath, 'main = "Hello from project"') + + const newName = 'RenamedProjectName' + + // Rename the project + await projectService.renameProject(createResult.projectId, newName, projectsDirectory) + + // Verify the directory was renamed + const oldDirectoryPath = createResult.projectPath + const newDirectoryPath = path.join(path.dirname(oldDirectoryPath), 'RenamedProjectName') + + // Verify metadata is preserved + const metadataPath = path.join(newDirectoryPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + expect(metadata.id).toBe(createResult.projectId) + + // Verify package name is updated + const packagePath = path.join(newDirectoryPath, 'package.yaml') + const packageContent = await fs.readFile(packagePath, 'utf-8') + expect(packageContent).contain(newName) + + const oldDirExists = await fs + .access(oldDirectoryPath) + .then(() => true) + .catch(() => false) + const newDirExists = await fs + .access(newDirectoryPath) + .then(() => true) + .catch(() => false) + + expect(oldDirExists).toBe(false) + expect(newDirExists).toBe(true) + + // Verify content was preserved + const renamedTestFilePath = path.join(newDirectoryPath, 'src', 'Main.enso') + const content = await fs.readFile(renamedTestFilePath, 'utf-8') + expect(content).toBe('main = "Hello from project"') + }) + + test( + 'should defer directory rename when language server is running', + async () => { + // Create a project + const originalName = 'RunningProjectToRename' + const createResult = await projectService.createProject(originalName, projectsDirectory) + + // Open the project to start the language server + await projectService.openProject(createResult.projectId, projectsDirectory) + + // Rename the project while it's running + const newName = 'RenamedRunningProject' + try { + await projectService.renameProject(createResult.projectId, newName, projectsDirectory) + } catch { + /* Expected error for uninitialized test project */ + } + + const oldDirectoryPath = createResult.projectPath + // Verify metadata is preserved + const metadataPath = path.join(oldDirectoryPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + expect(metadata.id).toBe(createResult.projectId) + + // Verify package name is updated + const packagePath = path.join(oldDirectoryPath, 'package.yaml') + const packageContent = await fs.readFile(packagePath, 'utf-8') + expect(packageContent).contain(newName) + + // Verify directory has NOT been renamed yet (deferred) + const oldDirExists = await fs + .access(createResult.projectPath) + .then(() => true) + .catch(() => false) + expect(oldDirExists).toBe(true) + + // Close the project to trigger the deferred rename + await projectService.closeProject(createResult.projectId) + + // Now verify the directory was renamed after closing + const newDirectoryPath = path.join(path.dirname(createResult.projectPath), newName) + const oldDirExistsAfter = await fs + .access(createResult.projectPath) + .then(() => true) + .catch(() => false) + const newDirExistsAfter = await fs + .access(newDirectoryPath) + .then(() => true) + .catch(() => false) + + expect(oldDirExistsAfter).toBe(false) + expect(newDirExistsAfter).toBe(true) + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + + test('should fail when renaming to an existing project name', async () => { + // Create two projects + const _project1 = await projectService.createProject('Project1', projectsDirectory) + const project2 = await projectService.createProject('Project2', projectsDirectory) + + // Try to rename project2 to project1's name + await expect( + projectService.renameProject(project2.projectId, 'Project1', projectsDirectory), + ).rejects.toThrow("Project with name 'Project1' already exists.") + }) + + test('should fail when renaming non-existent project', async () => { + const nonExistentId = crypto.randomUUID() as UUID + + await expect( + projectService.renameProject(nonExistentId, 'NewName', projectsDirectory), + ).rejects.toThrow(`Project not found: ${nonExistentId}`) + }) + + test('should reject empty new name', async () => { + // Create a project + const createResult = await projectService.createProject('ProjectToRename', projectsDirectory) + + // Try to rename with empty name + await expect( + projectService.renameProject(createResult.projectId, '', projectsDirectory), + ).rejects.toThrow('Project name cannot be empty') + + await expect( + projectService.renameProject(createResult.projectId, ' ', projectsDirectory), + ).rejects.toThrow('Project name cannot be empty') + }) + + test('should handle special characters in new name', async () => { + // Create a project + const originalName = 'SimpleProject' + const createResult = await projectService.createProject(originalName, projectsDirectory) + + const newName = 'Project #1 & Special' + + // Rename the project + await projectService.renameProject(createResult.projectId, newName, projectsDirectory) + + const newDirectoryPath = path.join(path.dirname(createResult.projectPath), 'Project1Special') + // Verify metadata is preserved + const metadataPath = path.join(newDirectoryPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + expect(metadata.id).toBe(createResult.projectId) + + // Verify package name is updated + const packagePath = path.join(newDirectoryPath, 'package.yaml') + const packageContent = await fs.readFile(packagePath, 'utf-8') + expect(packageContent).contain(newName) + + // Verify the directory was renamed with normalized name + const newDirExists = await fs + .access(newDirectoryPath) + .then(() => true) + .catch(() => false) + expect(newDirExists).toBe(true) + }) + + test('should preserve project content after rename', async () => { + // Create a project with content + const originalName = 'ProjectWithContent' + const createResult = await projectService.createProject(originalName, projectsDirectory) + + // Add various files and directories + const srcDir = path.join(createResult.projectPath, 'src') + const testDir = path.join(createResult.projectPath, 'test') + await fs.mkdir(srcDir, { recursive: true }) + await fs.mkdir(testDir, { recursive: true }) + await fs.writeFile(path.join(srcDir, 'Main.enso'), 'main = "Main content"') + await fs.writeFile(path.join(srcDir, 'Utils.enso'), 'utils = "Utils content"') + await fs.writeFile(path.join(testDir, 'Test.enso'), 'test = "Test content"') + + const newName = 'RenamedProjectWithContent' + + // Rename the project + await projectService.renameProject(createResult.projectId, newName, projectsDirectory) + + // Verify all content is preserved + const newDirectoryPath = path.join( + path.dirname(createResult.projectPath), + 'RenamedProjectWithContent', + ) + const mainContent = await fs.readFile( + path.join(newDirectoryPath, 'src', 'Main.enso'), + 'utf-8', + ) + const utilsContent = await fs.readFile( + path.join(newDirectoryPath, 'src', 'Utils.enso'), + 'utf-8', + ) + const testContent = await fs.readFile( + path.join(newDirectoryPath, 'test', 'Test.enso'), + 'utf-8', + ) + + expect(mainContent).toBe('main = "Main content"') + expect(utilsContent).toBe('utils = "Utils content"') + expect(testContent).toBe('test = "Test content"') + }) + + test( + 'should allow renaming an opened project multiple times', + async () => { + // Create a project + const originalName = 'ProjectToRenameMultipleTimes' + const createResult = await projectService.createProject(originalName, projectsDirectory) + + // Open the project to start the language server + await projectService.openProject(createResult.projectId, projectsDirectory) + + // First rename while it's running + const firstName = 'FirstRename' + try { + await projectService.renameProject(createResult.projectId, firstName, projectsDirectory) + } catch { + /* Expected error for uninitialized test project */ + } + + // Verify first rename was applied to package.yaml + const packagePath1 = path.join(createResult.projectPath, 'package.yaml') + const packageContent1 = await fs.readFile(packagePath1, 'utf-8') + expect(packageContent1).contain(firstName) + + // Second rename while still running + const secondName = 'SecondRename' + try { + await projectService.renameProject(createResult.projectId, secondName, projectsDirectory) + } catch { + /* Expected error for uninitialized test project */ + } + + // Verify second rename was applied + const packageContent2 = await fs.readFile(packagePath1, 'utf-8') + expect(packageContent2).contain(secondName) + expect(packageContent2).not.contain(firstName) + + // Third rename while still running + const thirdName = 'ThirdRename' + try { + await projectService.renameProject(createResult.projectId, thirdName, projectsDirectory) + } catch { + /* Expected error for uninitialized test project */ + } + + // Verify third rename was applied + const packageContent3 = await fs.readFile(packagePath1, 'utf-8') + expect(packageContent3).contain(thirdName) + expect(packageContent3).not.contain(secondName) + + // Close the project to trigger the final deferred rename + await projectService.closeProject(createResult.projectId) + + // Verify the directory was renamed to the final name + const finalDirectoryPath = path.join(path.dirname(createResult.projectPath), thirdName) + const finalDirExists = await fs + .access(finalDirectoryPath) + .then(() => true) + .catch(() => false) + expect(finalDirExists).toBe(true) + + // Verify the original directory no longer exists + const originalDirExists = await fs + .access(createResult.projectPath) + .then(() => true) + .catch(() => false) + expect(originalDirExists).toBe(false) + + // Verify metadata is preserved with correct ID + const metadataPath = path.join(finalDirectoryPath, '.enso', 'project.json') + const metadataContent = await fs.readFile(metadataPath, 'utf-8') + const metadata = JSON.parse(metadataContent) + expect(metadata.id).toBe(createResult.projectId) + }, + LANGUAGE_SERVER_TEST_TIMEOUT, + ) + + test( + 'should handle renaming multiple running projects independently', + async () => { + // Create two projects + const project1 = await projectService.createProject('RunningProject1', projectsDirectory) + const project2 = await projectService.createProject('RunningProject2', projectsDirectory) + + // Open both projects + await projectService.openProject(project1.projectId, projectsDirectory) + await projectService.openProject(project2.projectId, projectsDirectory) + + // Rename both projects while they're running + try { + await projectService.renameProject( + project1.projectId, + 'RenamedRunning1', + projectsDirectory, + ) + } catch { + /* Expected error for uninitialized test project */ + } + try { + await projectService.renameProject( + project2.projectId, + 'RenamedRunning2', + projectsDirectory, + ) + } catch { + /* Expected error for uninitialized test project */ + } + + // Verify package name is updated + const package1Path = path.join(project1.projectPath, 'package.yaml') + const package1Content = await fs.readFile(package1Path, 'utf-8') + expect(package1Content).contain('RenamedRunning1') + + const package2Path = path.join(project2.projectPath, 'package.yaml') + const package2Content = await fs.readFile(package2Path, 'utf-8') + expect(package2Content).contain('RenamedRunning2') + + // Close both projects + await projectService.closeProject(project1.projectId) + await projectService.closeProject(project2.projectId) + + // Verify both directories were renamed + const newPath1 = path.join(path.dirname(project1.projectPath), 'RenamedRunning1') + const newPath2 = path.join(path.dirname(project2.projectPath), 'RenamedRunning2') + + const exists1 = await fs + .access(newPath1) + .then(() => true) + .catch(() => false) + const exists2 = await fs + .access(newPath2) + .then(() => true) + .catch(() => false) + + expect(exists1).toBe(true) + expect(exists2).toBe(true) + }, + LANGUAGE_SERVER_TEST_TIMEOUT * 2, + ) + }) +}) diff --git a/app/project-manager-shim/src/projectService/ensoRunner.ts b/app/project-manager-shim/src/projectService/ensoRunner.ts index 3adfd04e12de..15f482ad6677 100644 --- a/app/project-manager-shim/src/projectService/ensoRunner.ts +++ b/app/project-manager-shim/src/projectService/ensoRunner.ts @@ -1,4 +1,5 @@ import { Path } from 'enso-common/src/utilities/file' +import extractZip from 'extract-zip' import * as childProcess from 'node:child_process' import * as fs from 'node:fs' import { createWriteStream } from 'node:fs' @@ -8,30 +9,58 @@ import { pipeline } from 'node:stream/promises' import { extract } from 'tar' export interface Runner { - createProject( - path: Path, - name: string, - engineVersion?: string, - projectTemplate?: string, + createProject(path: Path, name: string, projectTemplate?: string): Promise + openProject( + projectPath: Path, + projectId: string, + extraEnv?: Array<[string, string]>, + ): Promise + closeProject(projectId: string): Promise + isProjectRunning(projectId: string): Promise + renameProject( + projectId: string, + namespace: string, + oldPackage: string, + newPackage: string, + ): Promise + registerShutdownHook( + projectId: string, + hookType: ShutdownHookType, + hook: () => Promise, ): Promise } +export interface LanguageServerSockets { + readonly jsonSocket: Socket + readonly secureJsonSocket?: Socket + readonly binarySocket: Socket + readonly secureBinarySocket?: Socket +} + +export interface Socket { + readonly host: string + readonly port: number +} + +export type ShutdownHookType = 'rename-project-directory' + +interface RunningProject { + process: childProcess.ChildProcess + sockets: LanguageServerSockets + shutdownHooks: Map Promise> +} + +const DEFAULT_JSONRPC_PORT = 30616 + /** Implementation of Runner that uses the Enso executable. */ export class EnsoRunner implements Runner { + private runningProjects: Map = new Map() + /** Creates a new EnsoRunner with the path to the Enso executable. */ constructor(private ensoPath: Path) {} /** Creates a new Enso project at the specified path. */ - async createProject( - projectPath: Path, - name: string, - engineVersion?: string, - projectTemplate?: string, - ): Promise { - if (!this.ensoPath) { - throw new Error('Enso executable not found') - } - + async createProject(projectPath: Path, name: string, projectTemplate?: string): Promise { const args: string[] = [] args.push('--new', projectPath) args.push('--new-project-name', name) @@ -66,6 +95,320 @@ export class EnsoRunner implements Runner { }) }) } + + /** Opens a project and starts its language server. */ + async openProject( + projectPath: Path, + projectId: string, + extraEnv?: Array<[string, string]>, + ): Promise { + // Check if the project is already running + const runningProject = this.runningProjects.get(projectId) + if (runningProject) { + return runningProject.sockets + } + // Find available ports for the language server + const jsonPort = await this.findAvailablePort(DEFAULT_JSONRPC_PORT) + const binaryPort = await this.findAvailablePort(jsonPort + 1) + // Create log file for this language server instance (overwrite if exists) + const logFileName = `language-server-${jsonPort}.log` + const logStream = fs.createWriteStream(logFileName, { flags: 'w' }) + logStream.write(`=== Language Server Started at ${new Date().toISOString()} ===\n`) + logStream.write(`Project ID: ${projectId}\n`) + logStream.write(`Project Path: ${projectPath}\n`) + logStream.write(`JSON Port: ${jsonPort}\n`) + logStream.write(`Binary Port: ${binaryPort}\n`) + logStream.write(`===========================================\n\n`) + + const rootId = crypto.randomUUID() + const args: string[] = [ + '--server', + '--root-id', + rootId, + '--project-id', + projectId, + '--path', + projectPath, + '--interface', + '127.0.0.1', + '--rpc-port', + jsonPort.toString(), + '--data-port', + binaryPort.toString(), + ] + + const env = { ...process.env } + if (extraEnv) { + for (const [key, value] of extraEnv) { + env[key] = value + } + } + + return new Promise((resolve, reject) => { + const serverProcess = childProcess.spawn(this.ensoPath, args, { + env, + detached: false, + }) + + let stderr = '' + let resolved = false + + // Health check function + const checkServerHealth = async (): Promise => { + try { + const response = await fetch(`http://127.0.0.1:${jsonPort}/_health`) + logStream.write( + `[HEALTH CHECK] Checking readiness at ${new Date().toISOString()}: ${response.status}\n`, + ) + return response.ok + } catch { + return false + } + } + + // Start polling for server readiness after initial delay + const startHealthCheck = () => { + const pollInterval = setInterval(async () => { + if (resolved) { + clearInterval(pollInterval) + return + } + + const isReady = await checkServerHealth() + if (isReady) { + clearInterval(pollInterval) + resolved = true + logStream.write(`[HEALTH CHECK] Server is ready at ${new Date().toISOString()}\n`) + const sockets: LanguageServerSockets = { + jsonSocket: { host: '127.0.0.1', port: jsonPort }, + binarySocket: { host: '127.0.0.1', port: binaryPort }, + } + this.runningProjects.set(projectId, { + process: serverProcess, + sockets: sockets, + shutdownHooks: new Map(), + }) + resolve(sockets) + } + }, 250) // Poll every 250ms + } + + // Start health check after initial delay + setTimeout(startHealthCheck, 250) + + serverProcess.stdout.on('data', (data) => { + const dataStr = data.toString() + logStream.write(`[STDOUT] ${dataStr}`) + }) + + serverProcess.stderr.on('data', (data) => { + const dataStr = data.toString() + stderr += dataStr + logStream.write(`[STDERR] ${dataStr}`) + }) + + serverProcess.on('error', (error) => { + logStream.write(`[ERROR] ${error.message}\n`) + if (!resolved) { + reject(new Error(`Failed to start language server: ${error.message}`)) + } + }) + + serverProcess.on('close', async (code) => { + logStream.write(`\n[PROCESS EXIT] Code: ${code} at ${new Date().toISOString()}\n`) + logStream.end() + + // Execute shutdown hooks if the process exits unexpectedly + const runningProject = this.runningProjects.get(projectId) + if (runningProject && runningProject.shutdownHooks) { + for (const [hookType, hook] of runningProject.shutdownHooks) { + try { + runningProject.shutdownHooks.delete(hookType) + await hook() + } catch (error) { + console.error( + `Error executing shutdown hook '${hookType}' for project ${projectId}:`, + error, + ) + } + } + } + + // Remove from running projects when it closes + this.runningProjects.delete(projectId) + if (!resolved) { + reject(new Error(`Language server process exited with code ${code}. stderr: ${stderr}`)) + } + }) + + // Timeout after 30 seconds if server doesn't start + setTimeout(() => { + if (!resolved) { + serverProcess.kill('SIGKILL') + logStream.write(`[TIMEOUT] Language server startup timeout after 30 seconds\n`) + reject(new Error('Language server startup timeout')) + } + }, 30000) + }) + } + + /** Closes a project and stops its language server. */ + async closeProject(projectId: string): Promise { + const runningProject = this.runningProjects.get(projectId) + + if (!runningProject) { + // Project is not running or already closed + return + } + + const { process, shutdownHooks } = runningProject + + return new Promise((resolve) => { + // Function to execute shutdown hooks + const executeShutdownHooks = async () => { + for (const [hookType, hook] of shutdownHooks) { + try { + shutdownHooks.delete(hookType) + await hook() + } catch (error) { + console.error( + `Error executing shutdown hook '${hookType}' for project ${projectId}:`, + error, + ) + } + } + } + + // Set a timeout in case the process doesn't exit gracefully + const timeout = setTimeout(async () => { + if (!process.killed) { + process.kill('SIGKILL') + } + await executeShutdownHooks() + this.runningProjects.delete(projectId) + resolve() + }, 30000) + + // Listen for the process to exit + process.on('exit', async () => { + clearTimeout(timeout) + await executeShutdownHooks() + this.runningProjects.delete(projectId) + resolve() + }) + + // Send line break to stdin to trigger graceful shutdown + if (process.stdin && !process.stdin.destroyed) { + process.stdin.write('\n') + } else { + process.kill('SIGTERM') + } + }) + } + + /** Checks if a project's language server is currently running. */ + async isProjectRunning(projectId: string): Promise { + return this.runningProjects.has(projectId) + } + + /** Registers an action to be executed when the project is closed. */ + async registerShutdownHook( + projectId: string, + hookType: ShutdownHookType, + hook: () => Promise, + ): Promise { + const runningProject = this.runningProjects.get(projectId) + + if (!runningProject) { + // If project is not running, execute the hook immediately + await hook() + return + } + + runningProject.shutdownHooks.set(hookType, hook) + } + + /** Renames the running language server project. */ + async renameProject( + projectId: string, + namespace: string, + oldPackage: string, + newPackage: string, + ): Promise { + const runningProject = this.runningProjects.get(projectId) + if (!runningProject) { + throw new Error(`Project ${projectId} is not running`) + } + + const { sockets } = runningProject + const requestBody = { + namespace: namespace, + oldName: oldPackage, + newName: newPackage, + } + + try { + // Send POST request to the language server's rename endpoint + const response = await fetch( + `http://127.0.0.1:${sockets.jsonSocket.port}/refactoring/renameProject`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + ) + + if (!response.ok) { + const errorBody = await response.text() + let errorMessage = `Failed to rename project: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorBody) + if (errorJson.error) { + errorMessage = `Failed to rename project: ${errorJson.error}` + } + } catch { + if (errorBody) { + errorMessage += ` - ${errorBody}` + } + } + throw new Error(errorMessage) + } + } catch (error) { + if (error instanceof Error) { + throw error + } else { + throw new Error(`Failed to rename project: ${error}`) + } + } + } + + /** Finds an available port starting from the given port number. */ + private async findAvailablePort(startPort: number): Promise { + const net = await import('node:net') + + return new Promise((resolve) => { + const tryPort = (port: number) => { + const server = net.createServer() + + server.listen(port, '127.0.0.1') + + server.on('listening', () => { + server.close(() => { + resolve(port) + }) + }) + + server.on('error', () => { + // Port is in use, try the next one + tryPort(port + 1) + }) + } + + tryPort(startPort) + }) + } } /** Find the path to the `enso` executable. */ @@ -79,11 +422,11 @@ export function findEnsoExecutable(workDir: string = '.'): Path | undefined { return Path(filePath) } - let ensoExecutable: string + let ensoExecutables: string[] if (os.platform() === 'win32') { - ensoExecutable = 'enso.exe' + ensoExecutables = ['enso.exe', 'enso.bat'] } else { - ensoExecutable = 'enso' + ensoExecutables = ['enso'] } // Check ENSO_RUNNER_PATH environment variable first @@ -104,12 +447,14 @@ export function findEnsoExecutable(workDir: string = '.'): Path | undefined { if (stat.isDirectory()) { const distDirs = fs.readdirSync(ensoDistPath) for (const distDir of distDirs) { - const ensoPath = path.join(ensoDistPath, distDir, 'bin', ensoExecutable) - try { - fs.accessSync(ensoPath) - return checkExecutable(ensoPath) - } catch { - // File doesn't exist, continue searching + for (const ensoExecutable of ensoExecutables) { + const ensoPath = path.join(ensoDistPath, distDir, 'bin', ensoExecutable) + try { + fs.accessSync(ensoPath) + return checkExecutable(ensoPath) + } catch { + // File doesn't exist, continue searching + } } } } @@ -129,12 +474,14 @@ export function findEnsoExecutable(workDir: string = '.'): Path | undefined { if (topStat.isDirectory()) { const subDirs = fs.readdirSync(topPath) for (const subDir of subDirs) { - const ensoPath = path.join(topPath, subDir, 'bin', ensoExecutable) - try { - fs.accessSync(ensoPath) - return checkExecutable(ensoPath) - } catch { - // File doesn't exist, continue searching + for (const ensoExecutable of ensoExecutables) { + const ensoPath = path.join(topPath, subDir, 'bin', ensoExecutable) + try { + fs.accessSync(ensoPath) + return checkExecutable(ensoPath) + } catch { + // File doesn't exist, continue searching + } } } } @@ -149,17 +496,24 @@ export function findEnsoExecutable(workDir: string = '.'): Path | undefined { } /** - * Downloads the latest Enso engine prerelease from GitHub. + * Downloads the latest Enso engine from GitHub. * * This function automatically detects the current platform (macOS, Linux, or Windows) * and architecture (amd64 or aarch64) to download the appropriate engine binary. - * The engine is downloaded from the latest GitHub prerelease and extracted to - * the built-distribution directory. + * The engine is downloaded from GitHub and extracted to the built-distribution directory. + * + * The type of release to download is controlled by the DOWNLOAD_ENSO_RUNNER environment variable: + * - If set to 'release': downloads the latest stable release + * - If set to 'prerelease' or not set: downloads the latest prerelease * @param projectRoot - The root directory of the project where the engine will be installed * @returns A promise that resolves to the path where the engine was extracted */ export async function downloadEnsoEngine(projectRoot: string): Promise { - console.log('Downloading latest Enso engine...') + // Check if we should download release or prerelease + const downloadType = process.env.DOWNLOAD_ENSO_RUNNER + const useRelease = downloadType === 'release' + + console.log(`Downloading latest Enso engine (${useRelease ? 'release' : 'prerelease'})...`) // Determine platform and architecture const platform = os.platform() @@ -189,7 +543,7 @@ export async function downloadEnsoEngine(projectRoot: string): Promise { throw new Error(`Unsupported architecture: ${arch}`) } - // Fetch all releases from GitHub API and find the latest prerelease + // Fetch all releases from GitHub API const releasesUrl = 'https://api.github.com/repos/enso-org/enso/releases' const headers: HeadersInit = {} if (process.env.GITHUB_TOKEN) { @@ -203,32 +557,35 @@ export async function downloadEnsoEngine(projectRoot: string): Promise { const releases = await releasesResponse.json() - // Find the latest prerelease with the matching asset - const prereleases = releases.filter((release: any) => release.prerelease) + // Filter based on whether we want releases or prereleases + const targetReleases = + useRelease ? + releases.filter((release: any) => !release.prerelease) + : releases.filter((release: any) => release.prerelease) - if (prereleases.length === 0) { - throw new Error('No prereleases found') + if (targetReleases.length === 0) { + throw new Error(`No ${useRelease ? 'releases' : 'prereleases'} found`) } let releaseData: any = null let asset: any = null let assetName: string = '' - // Iterate through prereleases to find one with matching asset - for (const prerelease of prereleases) { - const version = prerelease.tag_name + // Iterate through target releases to find one with matching asset + for (const targetRelease of targetReleases) { + const version = targetRelease.tag_name assetName = `enso-engine-${version}-${platformString}-${archString}${extensionString}` - asset = prerelease.assets.find((a: any) => a.name === assetName) + asset = targetRelease.assets.find((a: any) => a.name === assetName) if (asset) { - releaseData = prerelease + releaseData = targetRelease break } } if (!releaseData || !asset) { throw new Error( - `Could not find asset: enso-engine-*-${platformString}-${archString}${extensionString} in any prerelease`, + `Could not find asset: enso-engine-*-${platformString}-${archString}${extensionString} in any ${useRelease ? 'release' : 'prerelease'}`, ) } @@ -271,23 +628,7 @@ export async function downloadEnsoEngine(projectRoot: string): Promise { }), ) } else { - await new Promise((resolve, reject) => { - const unzipProcess = childProcess.spawn('unzip', ['-o', archivePath, '-d', extractDir], { - stdio: 'ignore', - }) - - unzipProcess.on('error', (error) => { - reject(new Error(`Failed to extract zip: ${error.message}`)) - }) - - unzipProcess.on('close', (code) => { - if (code === 0) { - resolve() - } else { - reject(new Error(`unzip process exited with code ${code}`)) - } - }) - }) + await extractZip(archivePath, { dir: extractDir }) } // Clean up the archive file diff --git a/app/project-manager-shim/src/projectService/index.ts b/app/project-manager-shim/src/projectService/index.ts index 72cd7fde3d98..fb0adace9071 100644 --- a/app/project-manager-shim/src/projectService/index.ts +++ b/app/project-manager-shim/src/projectService/index.ts @@ -8,34 +8,32 @@ import { UUID } from 'enso-common/src/services/Backend' import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime' import { Path } from 'enso-common/src/utilities/file' import * as crypto from 'node:crypto' -import { EnsoRunner, findEnsoExecutable, type Runner } from './ensoRunner.js' +import { + type LanguageServerSockets, + type Runner, + type Socket, + EnsoRunner, + findEnsoExecutable, +} from './ensoRunner.js' import * as nameValidation from './nameValidation.js' -import { ProjectFileRepository, type Project, type ProjectRepository } from './projectRepository.js' +import { + type Project, + type ProjectMetadata, + type ProjectRepository, + ProjectFileRepository, +} from './projectRepository.js' // ================== // === Data Types === // ================== export interface RunningLanguageServerInfo { - readonly engineVersion: string // SemVer format readonly sockets: LanguageServerSockets readonly projectName: string readonly projectNormalizedName: string readonly projectNamespace: string } -export interface LanguageServerSockets { - readonly jsonSocket: Socket - readonly secureJsonSocket?: Socket - readonly binarySocket: Socket - readonly secureBinarySocket?: Socket -} - -export interface Socket { - readonly host: string - readonly port: number -} - export interface CloudParams { readonly cloudProjectDirectoryPath: Path readonly cloudProjectId: string @@ -58,6 +56,23 @@ export interface CreateProject { readonly projectPath: Path } +/** The return value of the "open project" endpoint. */ +export interface OpenProject { + readonly languageServerJsonAddress: Socket + readonly languageServerBinaryAddress: Socket + readonly projectName: string + readonly projectNormalizedName: string + readonly projectNamespace: string +} + +/** The return value of the "duplicate project" endpoint. */ +export interface DuplicatedProject { + readonly projectId: UUID + readonly projectName: string + readonly projectPath: Path + readonly projectNormalizedName: string +} + // ======================= // === ProjectService ==== // ======================= @@ -88,7 +103,6 @@ export class ProjectService { async createProject( projectName: string, projectsDirectory: Path, - engineVersion?: string, projectTemplate?: string, ): Promise { const projectId = this.generateUUID() @@ -117,7 +131,7 @@ export class ProjectService { } // Create project structure - await this.runner.createProject(projectPath, actualName, engineVersion, projectTemplate) + await this.runner.createProject(projectPath, actualName, projectTemplate) // Update metadata await repo.update(project) @@ -132,44 +146,154 @@ export class ProjectService { } } - /** Deletes a user project. */ - async deleteUserProject(_projectId: string, _projectsDirectory?: Path): Promise { - // TODO: Implement deleteUserProject - throw new Error('deleteUserProject not implemented yet') + /** Opens a project and starts its language server. */ + async openProject( + projectId: UUID, + projectsDirectory: Path, + cloud?: CloudParams, + ): Promise { + this.logger.debug('Opening project', projectId) + + // Get the project repository + const repo = this.getProjectRepository(projectsDirectory) + + // Get the project from the repository + const project = await repo.findById(projectId) + if (!project) { + throw new Error(`Project not found: ${projectId}`) + } + + // Update the lastOpened timestamp + const openTime = toRfc3339(new Date()) + const updatedProject = { ...project, lastOpened: openTime } + await repo.update(updatedProject) + + // Prepare cloud environment variables if provided + const extraEnv: Array<[string, string]> = [] + if (cloud) { + extraEnv.push(['ENSO_CLOUD_PROJECT_DIRECTORY_PATH', cloud.cloudProjectDirectoryPath]) + extraEnv.push(['ENSO_CLOUD_PROJECT_ID', cloud.cloudProjectId]) + extraEnv.push(['ENSO_CLOUD_PROJECT_SESSION_ID', cloud.cloudProjectSessionId]) + } + + // Start the language server + const sockets = await this.runner.openProject( + project.path, + projectId, + extraEnv.length > 0 ? extraEnv : undefined, + ) + + // Return the OpenProject response + return { + languageServerJsonAddress: sockets.jsonSocket, + languageServerBinaryAddress: sockets.binarySocket, + projectName: project.name, + projectNormalizedName: nameValidation.normalizedName(project.name), + projectNamespace: project.namespace, + } } - /** Renames a project. */ - async renameProject( - _projectId: string, - _newName: string, - _projectsDirectory?: Path, - ): Promise { - // TODO: Implement renameProject - throw new Error('renameProject not implemented yet') + /** Closes a project and stops its language server. */ + async closeProject(projectId: UUID): Promise { + this.logger.debug('Closing project', projectId) + await this.runner.closeProject(projectId) } - /** Opens a project and starts its language server. */ - async openProject( - _progressTracker: any, - _clientId: string, - _projectId: string, - _cloud?: CloudParams, - _projectsDirectory?: Path, - ): Promise { - // TODO: Implement openProject - throw new Error('openProject not implemented yet') + /** Deletes a user project. */ + async deleteProject(projectId: UUID, projectsDirectory: Path): Promise { + this.logger.debug('Deleting project', projectId) + + const repo = this.getProjectRepository(projectsDirectory) + const project = await repo.findById(projectId) + if (!project) { + throw new Error(`Project '${projectId}' not found`) + } + + try { + await repo.moveToTrash(project.path) + this.logger.debug('Project moved to trash', projectId) + } catch (error) { + // If moving to trash fails, permanently delete + await repo.delete(project.path) + this.logger.debug('Project permanently deleted', projectId, error) + } } - /** Closes a project and stops its language server. */ - async closeProject(_clientId: string, _projectId: string): Promise { - // TODO: Implement closeProject - throw new Error('closeProject not implemented yet') + /** Duplicates a project. */ + async duplicateProject(projectId: UUID, projectsDirectory: Path): Promise { + this.logger.debug('Duplicating project', projectId) + const repo = this.getProjectRepository(projectsDirectory) + // Get the original project + const originalProject = await repo.findById(projectId) + if (!originalProject) { + throw new Error(`Project not found: ${projectId}`) + } + // Generate a suggested name for the duplicated project + const suggestedName = this.getNameForDuplicatedProject(originalProject.name) + // Get an available name (checking for conflicts) + const newName = await this.getNameForNewProject(suggestedName, repo) + // Validate the new name + await this.validateProjectName(newName) + // Create new metadata + const newProjectId = this.generateUUID() + const creationTime = toRfc3339(new Date()) + const newMetadata: ProjectMetadata = { + id: newProjectId, + name: newName, + namespace: originalProject.namespace, + created: creationTime, + } + // Copy the project + const newProject = await repo.copyProject(originalProject, newName, newMetadata) + return { + projectId: newProject.id, + projectName: newProject.name, + projectNormalizedName: nameValidation.normalizedName(newProject.name), + projectPath: newProject.path, + } } - /** Duplicates a user project. */ - async duplicateUserProject(_projectId: string, _projectsDirectory?: Path): Promise { - // TODO: Implement duplicateUserProject - throw new Error('duplicateUserProject not implemented yet') + /** Renames a project. */ + async renameProject(projectId: UUID, newName: string, projectsDirectory: Path): Promise { + this.logger.debug('Renaming project', projectId, 'to', newName) + // Validate the new project name + await this.validateProjectName(newName) + // Get the repository + const repo = this.getProjectRepository(projectsDirectory) + // Check if project exists + const project = await repo.findById(projectId) + if (!project) { + throw new Error(`Project not found: ${projectId}`) + } + // Check if new name already exists + await this.checkIfNameExists(newName, repo) + // Get the old package name (normalized) + const oldNormalizedName = nameValidation.normalizedName(project.name) + // Get the namespace + const namespace = project.namespace + // Create new package name (normalized) + const newNormalizedName = nameValidation.normalizedName(newName) + // Rename in the repository (updates metadata) + await repo.rename(projectId, newName) + // Check if language server is running for this project + const isRunning = await this.runner.isProjectRunning(projectId) + if (isRunning) { + // Register a shutdown hook to rename the directory after the server stops + await this.runner.registerShutdownHook(projectId, 'rename-project-directory', async () => { + this.logger.info(`Executing deferred directory rename for project ${projectId}`) + try { + await repo.renameProjectDirectory(project.path, newNormalizedName) + this.logger.info(`Successfully renamed project directory for ${projectId}`) + } catch (error) { + this.logger.error(`Failed to rename project directory for ${projectId}:`, error) + } + }) + // Send rename command to the running server + await this.runner.renameProject(projectId, namespace, oldNormalizedName, newNormalizedName) + } else { + // If server is not running, rename the directory immediately + await repo.renameProjectDirectory(project.path, newNormalizedName) + } } // ======================== @@ -218,4 +342,8 @@ export class ProjectService { throw new Error(`Project with name '${name}' already exists.`) } } + + private getNameForDuplicatedProject(projectName: string): string { + return `${projectName} (copy)` + } } diff --git a/app/project-manager-shim/src/projectService/projectRepository.ts b/app/project-manager-shim/src/projectService/projectRepository.ts index ef2ee21e63af..53b7f1459b08 100644 --- a/app/project-manager-shim/src/projectService/projectRepository.ts +++ b/app/project-manager-shim/src/projectService/projectRepository.ts @@ -4,6 +4,7 @@ import { Path } from 'enso-common/src/utilities/file' import * as crypto from 'node:crypto' import * as fs from 'node:fs/promises' import * as path from 'node:path' +import trash from 'trash' import * as yaml from 'yaml' import * as nameValidation from './nameValidation.js' @@ -32,16 +33,17 @@ export interface ProjectRepository { exists(name: string): Promise findPathForNewProject(normalizedName: string): Promise update(project: Project): Promise - delete(projectId: string): Promise - moveToTrash(projectId: string): Promise - rename(projectId: string, name: string): Promise - findById(projectId: string): Promise + delete(path: Path): Promise + moveToTrash(path: Path): Promise + rename(projectId: UUID, name: string): Promise + renameProjectDirectory(oldPath: Path, newNormalizedName: string): Promise + findById(projectId: UUID): Promise find(predicate: (project: Project) => boolean): Promise getAll(): Promise - moveProject(projectId: string, newName: string): Promise + moveProject(projectId: UUID, newName: string): Promise copyProject(project: Project, newName: string, newMetadata: ProjectMetadata): Promise - getPackageName(projectId: string): Promise - getPackageNamespace(projectId: string): Promise + getPackageName(projectId: UUID): Promise + getPackageNamespace(projectId: UUID): Promise tryLoadProject(directory: Path): Promise } @@ -50,6 +52,7 @@ const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json' interface PackageYaml { name?: string + normalizedName?: string namespace?: string edition?: string jvmModeEnabled?: boolean @@ -92,32 +95,18 @@ export class ProjectFileRepository implements ProjectRepository { await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)) } - /** Deletes a project by ID. */ - async delete(projectId: string): Promise { - const project = await this.findById(projectId) - if (!project) { - throw new Error(`Project '${projectId}' not found`) - } - await fs.rm(project.path, { recursive: true, force: true }) + /** Deletes a path. */ + async delete(path: Path): Promise { + await fs.rm(path, { recursive: true, force: true }) } - /** Moves a project to trash. */ - async moveToTrash(projectId: string): Promise { - const project = await this.findById(projectId) - if (!project) { - throw new Error(`Project '${projectId}' not found`) - } - - // TODO: Simple implementation: move to a .trash directory - // This should use platform-specific trash APIs - const trashPath = path.join(this.projectsPath, '.trash', path.basename(project.path)) - await fs.mkdir(path.dirname(trashPath), { recursive: true }) - await fs.rename(project.path, trashPath) - return true + /** Moves a path to system trash. */ + async moveToTrash(path: Path): Promise { + await trash(path) } /** Renames a project. */ - async rename(projectId: string, name: string): Promise { + async rename(projectId: UUID, name: string): Promise { const project = await this.findById(projectId) if (!project) { throw new Error(`Project '${projectId}' not found`) @@ -125,8 +114,15 @@ export class ProjectFileRepository implements ProjectRepository { await this.renamePackage(project.path, name) } + /** Renames the project directory on disk. */ + async renameProjectDirectory(oldPath: Path, newNormalizedName: string): Promise { + const newPath = await this.findTargetPath(newNormalizedName) + await fs.rename(oldPath, newPath) + return newPath + } + /** Finds a project by ID. */ - async findById(projectId: string): Promise { + async findById(projectId: UUID): Promise { const projects = await this.getAll() return projects.find((p) => p.id === projectId) ?? null } @@ -158,7 +154,7 @@ export class ProjectFileRepository implements ProjectRepository { } /** Moves a project to a new location. */ - async moveProject(projectId: string, newName: string): Promise { + async moveProject(projectId: UUID, newName: string): Promise { const project = await this.findById(projectId) if (!project) { throw new Error(`Project '${projectId}' not found`) @@ -178,9 +174,8 @@ export class ProjectFileRepository implements ProjectRepository { ): Promise { const normalizedName = nameValidation.normalizedName(newName) const targetPath = await this.findTargetPath(normalizedName) - - await this.copyDirectory(project.path, targetPath) - + // Copy directory + await fs.cp(project.path, targetPath, { recursive: true }) // Update metadata const metadataPath = path.join(targetPath, PROJECT_METADATA_RELATIVE_PATH) const metadata: ProjectJson = { @@ -191,7 +186,6 @@ export class ProjectFileRepository implements ProjectRepository { } await fs.mkdir(path.dirname(metadataPath), { recursive: true }) await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)) - // Update package name await this.renamePackage(targetPath, newName) @@ -203,7 +197,7 @@ export class ProjectFileRepository implements ProjectRepository { } /** Gets the package name for a project. */ - async getPackageName(projectId: string): Promise { + async getPackageName(projectId: UUID): Promise { const project = await this.findById(projectId) if (!project) { throw new Error(`Project '${projectId}' not found`) @@ -216,7 +210,7 @@ export class ProjectFileRepository implements ProjectRepository { } /** Gets the package namespace for a project. */ - async getPackageNamespace(projectId: string): Promise { + async getPackageNamespace(projectId: UUID): Promise { const project = await this.findById(projectId) if (!project) { throw new Error(`Project '${projectId}' not found`) @@ -286,6 +280,7 @@ export class ProjectFileRepository implements ProjectRepository { const content = await fs.readFile(packagePath, 'utf-8') const pkg = yaml.parse(content) as PackageYaml pkg.name = newName + delete pkg.normalizedName await fs.writeFile(packagePath, yaml.stringify(pkg)) } @@ -305,22 +300,6 @@ export class ProjectFileRepository implements ProjectRepository { } } - private async copyDirectory(source: Path, destination: Path): Promise { - await fs.mkdir(destination, { recursive: true }) - const entries = await fs.readdir(source, { withFileTypes: true }) - - for (const entry of entries) { - const sourcePath = path.join(source, entry.name) - const destPath = path.join(destination, entry.name) - - if (entry.isDirectory()) { - await this.copyDirectory(Path(sourcePath), Path(destPath)) - } else { - await fs.copyFile(sourcePath, destPath) - } - } - } - private async resolveClashingIds(projects: Project[]): Promise { const idGroups = new Map() diff --git a/app/project-manager-shim/tsconfig.json b/app/project-manager-shim/tsconfig.json index aaa83845bc60..9959b6a318d2 100644 --- a/app/project-manager-shim/tsconfig.json +++ b/app/project-manager-shim/tsconfig.json @@ -7,5 +7,5 @@ "isolatedModules": true, "composite": true }, - "include": ["src/**/*"] + "include": ["src/**/*", "scripts/**/*"] } diff --git a/eslint.config.mjs b/eslint.config.mjs index 79f70de50b9d..81dd14163b4b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -216,6 +216,7 @@ const config = [ 'eslint.config.mjs', // 'app/ydoc-server/vitest.config.ts', 'app/ydoc-shared/vitest.config.ts', + 'app/project-manager-shim/scripts/*.js', 'app/ide-desktop/icons/src/index.js', ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef71a0a6f468..07a24e16464d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -828,16 +828,25 @@ importers: tar: specifier: ^6.2.1 version: 6.2.1 + trash: + specifier: ^9.0.0 + version: 9.0.0 yaml: specifier: ^2.7.0 version: 2.7.0 devDependencies: + '@types/extract-zip': + specifier: ^2.0.3 + version: 2.0.3 '@types/node': specifier: 'catalog:' version: 24.2.1 '@types/tar': specifier: ^6.1.13 version: 6.1.13 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1 typescript: specifier: 'catalog:' version: 5.7.2 @@ -2818,6 +2827,18 @@ packages: resolution: {integrity: sha512-fw7U8Y7BBV9NZVLs7lyM8lpQ9YUK7XBNJjN4HhkY3nqkKpSiIQ62PVtPYtW7uZME5072QXQNQBhHJzG3t+NH0w==} engines: {node: '>=16'} + '@sindresorhus/chunkify@1.0.0': + resolution: {integrity: sha512-YJOcVaEasXWcttXetXn0jd6Gtm9wFHQ1gViTPcxhESwkMCOoA4kwFsNr9EGcmsARGx7jXQZWmOR4zQotRcI9hw==} + engines: {node: '>=18'} + + '@sindresorhus/df@1.0.1': + resolution: {integrity: sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==} + engines: {node: '>=0.10.0'} + + '@sindresorhus/df@3.1.1': + resolution: {integrity: sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==} + engines: {node: '>=8'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2842,6 +2863,10 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} + '@stroncium/procfs@1.2.1': + resolution: {integrity: sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==} + engines: {node: '>=8'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3065,6 +3090,10 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/extract-zip@2.0.3': + resolution: {integrity: sha512-yrO7h+0qOIGxHCmBeL5fKFzR+PBafh9LG6sOLBFFi2JuN+Hj663TAxfnqJh5vkQn963VimrhBF1GZzea3A+4Ig==} + deprecated: This is a stub types definition. extract-zip provides its own type definitions, so you do not need this installed. + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -3580,6 +3609,14 @@ packages: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} + array-union@1.0.2: + resolution: {integrity: sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==} + engines: {node: '>=0.10.0'} + + array-uniq@1.0.3: + resolution: {integrity: sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==} + engines: {node: '>=0.10.0'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -4360,6 +4397,10 @@ packages: dir-compare@3.3.0: resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==} + dir-glob@2.2.2: + resolution: {integrity: sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==} + engines: {node: '>=4'} + discontinuous-range@1.0.0: resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} @@ -4729,6 +4770,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@2.1.0: + resolution: {integrity: sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==} + engines: {node: ^8.12.0 || >=9.7.0} + execa@9.6.0: resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} engines: {node: ^18.19.0 || >=20.5.0} @@ -5038,6 +5083,10 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@7.1.1: + resolution: {integrity: sha512-yANWAN2DUcBtuus5Cpd+SKROzXHs2iVXFZt/Ykrfz6SAXqacLX25NZpltE+39ceMexYF4TtEadjuSTw8+3wX4g==} + engines: {node: '>=4'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -5180,6 +5229,9 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@3.3.10: + resolution: {integrity: sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -5348,6 +5400,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@1.1.0: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} @@ -5375,6 +5431,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -5782,6 +5842,9 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -5808,6 +5871,10 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -5911,6 +5978,14 @@ packages: moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + mount-point@3.0.0: + resolution: {integrity: sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==} + engines: {node: '>=0.10.0'} + + move-file@3.1.0: + resolution: {integrity: sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + mrmime@2.0.0: resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} engines: {node: '>=10'} @@ -6014,6 +6089,10 @@ packages: engines: {node: '>= 4'} hasBin: true + npm-run-path@3.1.0: + resolution: {integrity: sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==} + engines: {node: '>=8'} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -6073,6 +6152,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + ono@4.0.11: resolution: {integrity: sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g==} @@ -6088,6 +6171,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-homedir@1.0.2: + resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} + engines: {node: '>=0.10.0'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -6096,6 +6183,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-finally@2.0.1: + resolution: {integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==} + engines: {node: '>=8'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -6104,6 +6195,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.3: + resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} + engines: {node: '>=18'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -6895,6 +6990,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -6916,6 +7014,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@1.0.0: + resolution: {integrity: sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==} + engines: {node: '>=0.10.0'} + slashes@3.0.12: resolution: {integrity: sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==} @@ -7077,6 +7179,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} @@ -7270,6 +7376,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trash@9.0.0: + resolution: {integrity: sha512-6U3A0olN4C16iiPZvoF93AcZDNZtv/nI2bHb2m/sO3h/m8VPzg9tPdd3n3LVcYLWz7ui0AHaXYhIuRjzGW9ptg==} + engines: {node: '>=18'} + truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} @@ -7425,6 +7535,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + user-home@2.0.0: + resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==} + engines: {node: '>=0.10.0'} + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -7775,6 +7889,14 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xdg-basedir@4.0.0: + resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} + engines: {node: '>=8'} + + xdg-trashdir@3.1.0: + resolution: {integrity: sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==} + engines: {node: '>=10'} + xhr@2.6.0: resolution: {integrity: sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==} @@ -10441,6 +10563,14 @@ snapshots: '@shaderfrog/glsl-parser@2.1.5': {} + '@sindresorhus/chunkify@1.0.0': {} + + '@sindresorhus/df@1.0.1': {} + + '@sindresorhus/df@3.1.1': + dependencies: + execa: 2.1.0 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -10463,6 +10593,8 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 + '@stroncium/procfs@1.2.1': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -10728,6 +10860,12 @@ snapshots: '@types/estree@1.0.8': {} + '@types/extract-zip@2.0.3': + dependencies: + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + '@types/fs-extra@9.0.13': dependencies: '@types/node': 24.2.1 @@ -11444,6 +11582,12 @@ snapshots: get-intrinsic: 1.2.7 is-string: 1.1.1 + array-union@1.0.2: + dependencies: + array-uniq: 1.0.3 + + array-uniq@1.0.3: {} + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -12291,6 +12435,10 @@ snapshots: buffer-equal: 1.0.1 minimatch: 3.1.2 + dir-glob@2.2.2: + dependencies: + path-type: 3.0.0 + discontinuous-range@1.0.0: {} dlv@1.1.3: {} @@ -12826,6 +12974,18 @@ snapshots: events@3.3.0: {} + execa@2.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 3.1.0 + onetime: 5.1.2 + p-finally: 2.0.1 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + execa@9.6.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -13170,6 +13330,15 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@7.1.1: + dependencies: + array-union: 1.0.2 + dir-glob: 2.2.2 + glob: 7.2.3 + ignore: 3.3.10 + pify: 3.0.0 + slash: 1.0.0 + gopd@1.2.0: {} got@11.8.6: @@ -13330,6 +13499,8 @@ snapshots: ieee754@1.2.1: {} + ignore@3.3.10: {} + ignore@5.3.2: {} image-size@0.5.5: {} @@ -13477,6 +13648,8 @@ snapshots: is-number@7.0.0: {} + is-path-inside@4.0.0: {} + is-plain-obj@1.1.0: {} is-plain-obj@2.1.0: {} @@ -13498,6 +13671,8 @@ snapshots: dependencies: call-bound: 1.0.3 + is-stream@2.0.1: {} + is-stream@4.0.1: {} is-string@1.1.1: @@ -13945,6 +14120,8 @@ snapshots: memorystream@0.3.1: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -13962,6 +14139,8 @@ snapshots: mime@2.6.0: {} + mimic-fn@2.1.0: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -14062,6 +14241,16 @@ snapshots: moo@0.5.2: {} + mount-point@3.0.0: + dependencies: + '@sindresorhus/df': 1.0.1 + pify: 2.3.0 + pinkie-promise: 2.0.1 + + move-file@3.1.0: + dependencies: + path-exists: 5.0.0 + mrmime@2.0.0: {} ms@2.0.0: {} @@ -14156,6 +14345,10 @@ snapshots: shell-quote: 1.8.2 string.prototype.padend: 3.1.6 + npm-run-path@3.1.0: + dependencies: + path-key: 3.1.1 + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -14220,6 +14413,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + ono@4.0.11: dependencies: format-util: 1.0.5 @@ -14242,6 +14439,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-homedir@1.0.2: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.2.7 @@ -14250,6 +14449,8 @@ snapshots: p-cancelable@2.1.1: {} + p-finally@2.0.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -14258,6 +14459,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.3: {} + package-json-from-dist@1.0.1: {} pako@0.2.9: {} @@ -15179,6 +15382,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: {} @@ -15203,6 +15408,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@1.0.0: {} + slashes@3.0.12: {} slice-ansi@3.0.0: @@ -15400,6 +15607,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + strip-final-newline@4.0.0: {} strip-indent@3.0.0: @@ -15633,6 +15842,16 @@ snapshots: dependencies: punycode: 2.3.1 + trash@9.0.0: + dependencies: + '@sindresorhus/chunkify': 1.0.0 + '@stroncium/procfs': 1.2.1 + globby: 7.1.1 + is-path-inside: 4.0.0 + move-file: 3.1.0 + p-map: 7.0.3 + xdg-trashdir: 3.1.0 + truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 @@ -15806,6 +16025,10 @@ snapshots: dependencies: react: 18.3.1 + user-home@2.0.0: + dependencies: + os-homedir: 1.0.2 + utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} @@ -16190,6 +16413,15 @@ snapshots: dependencies: is-wsl: 3.1.0 + xdg-basedir@4.0.0: {} + + xdg-trashdir@3.1.0: + dependencies: + '@sindresorhus/df': 3.1.1 + mount-point: 3.0.0 + user-home: 2.0.0 + xdg-basedir: 4.0.0 + xhr@2.6.0: dependencies: global: 4.4.0