Change the default location for Enso projects (#10318)

close #10240

Changelog:
- add: `desktop-environment` Java module to detect user environment configuration
- add: `ProjectsMigration` module containing the migration logic of the enso projects directory
- update: updated and cleaned up unused settings from the storage config
- add: `desktopEnvironment` TS module to detect user environment configuration in the `project-manager-shim`
- update: `project-manager-shim` with the new user projects directory
This commit is contained in:
Dmitry Bushev 2024-06-22 13:40:51 +01:00 committed by GitHub
parent b8a1b0c366
commit ad5f2c9121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 669 additions and 82 deletions

View File

@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises' import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs' import * as fsSync from 'node:fs'
import * as http from 'node:http' import * as http from 'node:http'
import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
import * as stream from 'node:stream' import * as stream from 'node:stream'
import * as mkcert from 'mkcert' import * as mkcert from 'mkcert'
@ -17,6 +16,7 @@ import * as common from 'enso-common'
import GLOBAL_CONFIG from 'enso-common/src/config.json' assert { type: 'json' } import GLOBAL_CONFIG from 'enso-common/src/config.json' assert { type: 'json' }
import * as contentConfig from 'enso-content-config' import * as contentConfig from 'enso-content-config'
import * as ydocServer from 'enso-gui2/ydoc-server' import * as ydocServer from 'enso-gui2/ydoc-server'
import * as projectManagement from 'enso-project-manager-shim/src/projectManagement'
import * as paths from '../paths' import * as paths from '../paths'
@ -92,7 +92,7 @@ export class Server {
/** Create a simple HTTP server. */ /** Create a simple HTTP server. */
constructor(public config: Config) { constructor(public config: Config) {
this.projectsRootDirectory = path.join(os.homedir(), 'enso/projects') this.projectsRootDirectory = projectManagement.getProjectsDirectory()
} }
/** Server constructor. */ /** Server constructor. */

View File

@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/projectManagerShimMiddleware.ts" ".": "./src/projectManagerShimMiddleware.ts",
"./src/projectManagement": "./src/projectManagement.ts"
}, },
"dependencies": { "dependencies": {
"yaml": "^2.4.1" "yaml": "^2.4.1"

View File

@ -0,0 +1,81 @@
/**
* @file This module contains the logic for the detection of user-specific desktop environment attributes.
*/
import * as childProcess from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
export const DOCUMENTS = getDocumentsPath()
const CHILD_PROCESS_TIMEOUT = 3000
/**
* Detects path of the user documents directory depending on the operating system.
*/
function getDocumentsPath(): string | undefined {
if (process.platform === 'linux') {
return getLinuxDocumentsPath()
} else if (process.platform === 'darwin') {
return getMacOsDocumentsPath()
} else if (process.platform === 'win32') {
return getWindowsDocumentsPath()
} else {
return
}
}
/**
* Returns the user documents path on Linux.
*/
function getLinuxDocumentsPath(): string {
const xdgDocumentsPath = getXdgDocumentsPath()
return xdgDocumentsPath ?? path.join(os.homedir(), 'enso')
}
/**
* Gets the documents directory from the XDG directory management system.
*/
function getXdgDocumentsPath(): string | undefined {
const out = childProcess.spawnSync('xdg-user-dir', ['DOCUMENTS'], {
timeout: CHILD_PROCESS_TIMEOUT,
})
if (out.error !== undefined) {
return
} else {
return out.stdout.toString().trim()
}
}
/**
* Get the user documents path. On macOS, `Documents` acts as a symlink pointing to the
* real locale-specific user documents directory.
*/
function getMacOsDocumentsPath(): string {
return path.join(os.homedir(), 'Documents')
}
/**
* Get the path to the `My Documents` Windows directory.
*/
function getWindowsDocumentsPath(): string | undefined {
const out = childProcess.spawnSync(
'reg',
[
'query',
'"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\ShellFolders"',
'/v',
'personal',
],
{ timeout: CHILD_PROCESS_TIMEOUT }
)
if (out.error !== undefined) {
return
} else {
const stdoutString = out.stdout.toString()
return stdoutString.split('\\s\\s+')[4]
}
}

View File

@ -17,6 +17,7 @@ import * as tar from 'tar'
import * as common from 'enso-common' import * as common from 'enso-common'
import * as buildUtils from 'enso-common/src/buildUtils' import * as buildUtils from 'enso-common/src/buildUtils'
import * as desktopEnvironment from './desktopEnvironment'
const logger = console const logger = console
@ -369,7 +370,12 @@ export function getProjectRoot(subtreePath: string): string | null {
/** Get the directory that stores Enso projects. */ /** Get the directory that stores Enso projects. */
export function getProjectsDirectory(): string { export function getProjectsDirectory(): string {
return pathModule.join(os.homedir(), 'enso', 'projects') const documentsPath = desktopEnvironment.DOCUMENTS
if (documentsPath === undefined) {
return pathModule.join(os.homedir(), 'enso', 'projects')
} else {
return pathModule.join(documentsPath, 'enso-projects')
}
} }
/** Check if the given project is installed, i.e. can be opened with the Project Manager. */ /** Check if the given project is installed, i.e. can be opened with the Project Manager. */

View File

@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises' import * as fs from 'node:fs/promises'
import * as fsSync from 'node:fs' import * as fsSync from 'node:fs'
import * as http from 'node:http' import * as http from 'node:http'
import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
import * as isHiddenFile from 'is-hidden-file' import * as isHiddenFile from 'is-hidden-file'
@ -22,7 +21,7 @@ import * as projectManagement from './projectManagement'
const HTTP_STATUS_OK = 200 const HTTP_STATUS_OK = 200
const HTTP_STATUS_BAD_REQUEST = 400 const HTTP_STATUS_BAD_REQUEST = 400
const HTTP_STATUS_NOT_FOUND = 404 const HTTP_STATUS_NOT_FOUND = 404
const PROJECTS_ROOT_DIRECTORY = path.join(os.homedir(), 'enso/projects') const PROJECTS_ROOT_DIRECTORY = projectManagement.getProjectsDirectory()
// ============= // =============
// === Types === // === Types ===

View File

@ -1120,6 +1120,7 @@ lazy val `project-manager` = (project in file("lib/scala/project-manager"))
.value .value
) )
.dependsOn(`akka-native`) .dependsOn(`akka-native`)
.dependsOn(`desktop-environment`)
.dependsOn(`version-output`) .dependsOn(`version-output`)
.dependsOn(editions) .dependsOn(editions)
.dependsOn(`edition-updater`) .dependsOn(`edition-updater`)
@ -2797,6 +2798,17 @@ lazy val `benchmarks-common` =
) )
.dependsOn(`polyglot-api`) .dependsOn(`polyglot-api`)
lazy val `desktop-environment` =
project
.in(file("lib/java/desktop-environment"))
.settings(
frgaalJavaCompilerSetting,
libraryDependencies ++= Seq(
"junit" % "junit" % junitVersion % Test,
"com.github.sbt" % "junit-interface" % junitIfVersion % Test
)
)
lazy val `bench-processor` = (project in file("lib/scala/bench-processor")) lazy val `bench-processor` = (project in file("lib/scala/bench-processor"))
.settings( .settings(
frgaalJavaCompilerSetting, frgaalJavaCompilerSetting,

View File

@ -0,0 +1,21 @@
package org.enso.desktopenvironment;
import java.io.IOException;
import java.nio.file.Path;
/** Provides information about user directories. */
public sealed interface Directories permits LinuxDirectories, MacOsDirectories, WindowsDirectories {
/**
* @return the user home directory.
*/
default Path getUserHome() {
return Path.of(System.getProperty("user.home"));
}
/**
* @return the user documents directory.
* @throws IOException when cannot detect the documents directory of the user.
*/
Path getDocuments() throws IOException;
}

View File

@ -0,0 +1,28 @@
package org.enso.desktopenvironment;
final class DirectoriesFactory {
private static final Directories INSTANCE = initDirectories();
private static Directories initDirectories() {
if (Platform.isLinux()) {
return new LinuxDirectories();
}
if (Platform.isMacOs()) {
return new MacOsDirectories();
}
if (Platform.isWindows()) {
return new WindowsDirectories();
}
throw new UnsupportedOperationException("Unsupported OS '" + Platform.getOsName() + "'");
}
private DirectoriesFactory() {}
public static Directories getInstance() {
return INSTANCE;
}
}

View File

@ -0,0 +1,38 @@
package org.enso.desktopenvironment;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
final class LinuxDirectories implements Directories {
private static final String[] PROCESS_XDG_DOCUMENTS = new String[] {"xdg-user-dir", "DOCUMENTS"};
/**
* Get the user 'Documents' directory.
*
* <p>Tries to obtain the documents directory from the XDG directory management system if
* available and falls back to {@code $HOME/enso}.
*
* @return the path to the user documents directory.
*/
@Override
public Path getDocuments() {
try {
return getXdgDocuments();
} catch (IOException | InterruptedException e) {
return getUserHome().resolve("enso");
}
}
private Path getXdgDocuments() throws IOException, InterruptedException {
var process = new ProcessBuilder(PROCESS_XDG_DOCUMENTS).start();
process.waitFor(3, TimeUnit.SECONDS);
var documentsString =
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return Path.of(documentsString.trim());
}
}

View File

@ -0,0 +1,27 @@
package org.enso.desktopenvironment;
import java.io.IOException;
import java.nio.file.Path;
final class MacOsDirectories implements Directories {
private static final String DOCUMENTS = "Documents";
/**
* Get the user documents path.
*
* <p>On macOS, the 'Documents' directory acts like a symlink and points to the real
* locale-dependent user documents folder.
*
* @return the path to the user documents directory.
* @throws IOException when unable to resolve the real documents path.
*/
@Override
public Path getDocuments() throws IOException {
try {
return getUserHome().resolve(DOCUMENTS).toRealPath();
} catch (IOException e) {
throw new IOException("Failed to resolve real MacOs documents path", e);
}
}
}

View File

@ -0,0 +1,31 @@
package org.enso.desktopenvironment;
public final class Platform {
private static final String OS_NAME = "os.name";
private static final String LINUX = "linux";
private static final String MAC = "mac";
private static final String WINDOWS = "windows";
private Platform() {}
public static String getOsName() {
return System.getProperty(OS_NAME);
}
public static boolean isLinux() {
return getOsName().toLowerCase().contains(LINUX);
}
public static boolean isMacOs() {
return getOsName().toLowerCase().contains(MAC);
}
public static boolean isWindows() {
return getOsName().toLowerCase().contains(WINDOWS);
}
public static Directories getDirectories() {
return DirectoriesFactory.getInstance();
}
}

View File

@ -0,0 +1,47 @@
package org.enso.desktopenvironment;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
final class WindowsDirectories implements Directories {
private static final String[] PROCESS_REG_QUERY =
new String[] {
"reg",
"query",
"\"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\ShellFolders\"",
"/v",
"personal"
};
/**
* Get the path to 'My Documents' user directory.
*
* <p>Method uses the registry query that may not work on Windows XP versions and below.
*
* @return the 'My Documents' user directory path.
* @throws IOException when fails to detect the user documents directory.
*/
@Override
public Path getDocuments() throws IOException {
try {
var process = new ProcessBuilder(PROCESS_REG_QUERY).start();
process.waitFor(3, TimeUnit.SECONDS);
var stdoutString =
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
var stdoutParts = stdoutString.split("\\s\\s+");
if (stdoutParts.length < 5) {
throw new IOException("Invalid Windows registry query output: '" + stdoutString + "'");
}
return Path.of(stdoutParts[4].trim());
} catch (IOException e) {
throw new IOException("Failed to run Windows registry query", e);
} catch (InterruptedException e) {
throw new IOException("Windows registry query timeout", e);
}
}
}

View File

@ -0,0 +1,30 @@
package org.enso.desktopenvironment;
import java.io.IOException;
import java.nio.file.Files;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
public class DirectoriesTest {
private static Directories directories;
@BeforeClass
public static void setup() {
directories = Platform.getDirectories();
}
@Test
public void getUserHome() {
var userHome = directories.getUserHome();
Assert.assertTrue("User home is not a directory: " + userHome, Files.isDirectory(userHome));
}
@Test
public void getDocuments() throws IOException {
var documents = directories.getDocuments();
Assert.assertTrue(
"User documents is not a directory" + documents, Files.isDirectory(documents));
}
}

View File

@ -0,0 +1,12 @@
package org.enso.desktopenvironment;
import org.junit.Assert;
import org.junit.Test;
public class PlatformTest {
@Test
public void getDirectories() {
Assert.assertNotNull(Platform.getDirectories());
}
}

View File

@ -1 +0,0 @@
package org.enso.projectmanager.event;

View File

@ -0,0 +1,118 @@
package org.enso.projectmanager.infrastructure.migration;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.HashSet;
import java.util.Set;
import org.apache.commons.io.FileUtils;
import org.enso.desktopenvironment.Platform;
import org.enso.projectmanager.boot.configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ProjectsMigration {
private static final Logger logger = LoggerFactory.getLogger(ProjectsMigration.class);
private ProjectsMigration() {}
public static void migrate(configuration.StorageConfig storageConfig) {
var oldProjectsPath =
Platform.getDirectories().getUserHome().resolve("enso").resolve("projects").toFile();
if (oldProjectsPath.isDirectory()) {
try {
File newProjectsPath = storageConfig.userProjectsPath();
migrateProjectsDirectory(oldProjectsPath, newProjectsPath);
} catch (IOException e) {
logger.error("Migration aborted. Failed to get user documents directory.", e);
}
}
}
static void migrateProjectsDirectory(File oldProjectsPath, File newProjectsPath) {
if (oldProjectsPath.isDirectory()) {
logger.info(
"Running projects migration from '{}' to '{}'.", oldProjectsPath, newProjectsPath);
if (newProjectsPath.isDirectory()) {
try {
logger.info(
"Both '{}' and '{}' project directories exist. Cleaning up.",
oldProjectsPath,
newProjectsPath);
FileUtils.deleteDirectory(oldProjectsPath);
return;
} catch (IOException e) {
logger.error(
"Both '{}' and '{}' project directories exist. Failed to clean up.",
oldProjectsPath,
newProjectsPath,
e);
return;
}
}
try {
logger.info(
"Moving projects directory from '{}' to '{}'.", oldProjectsPath, newProjectsPath);
moveDirectory(oldProjectsPath, newProjectsPath);
} catch (IOException ex) {
logger.error("Migration aborted. Failed to copy user projects directory.", ex);
return;
}
if (!Platform.isWindows()) {
try {
logger.info("Setting projects directory permissions '{}'.", newProjectsPath);
setProjectsDirectoryPermissions(newProjectsPath);
} catch (IOException ex) {
logger.error(
"Failed to set permissions on projects directory '{}'.", newProjectsPath, ex);
return;
}
}
logger.info("Projects migration successful.");
}
}
/**
* Moves the source directory to the destination directory by renaming the source directory. If
* renaming is not supported by the file system or the destination directory is located on a
* different file system, tries to copy the source directory and then delete the source.
*
* @param source the source path
* @param destination the destination path
* @throws IOException if the moving was unsuccessful
*/
static void moveDirectory(File source, File destination) throws IOException {
try {
Files.move(source.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
try {
FileUtils.copyDirectory(source, destination);
} catch (IOException ex) {
FileUtils.deleteQuietly(destination);
throw ex;
}
FileUtils.deleteDirectory(source);
}
}
/**
* Sets the projects directory permissions to {@code rwx------}.
*
* @param path the directory path.
* @throws IOException if the action is unsuccessful.
*/
static void setProjectsDirectoryPermissions(File path) throws IOException {
Set<PosixFilePermission> permissions = new HashSet<>();
permissions.add(PosixFilePermission.OWNER_READ);
permissions.add(PosixFilePermission.OWNER_WRITE);
permissions.add(PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(path.toPath(), permissions);
}
}

View File

@ -119,14 +119,12 @@ project-manager {
} }
storage { storage {
projects-root = ${user.home}/enso
projects-root = ${?PROJECTS_ROOT} projects-root = ${?PROJECTS_ROOT}
temporary-projects-path = ${project-manager.storage.projects-root}/tmp projects-directory = "enso-projects"
user-projects-path = ${project-manager.storage.projects-root}/projects metadata {
tutorials-path = ${project-manager.storage.projects-root}/tutorials project-metadata-directory = ".enso"
tutorials-cache-path = ${project-manager.storage.projects-root}/.tutorials-cache project-metadata-file-name = "project.json"
project-metadata-directory = ".enso" }
project-metadata-file-name = "project.json"
} }
timeout { timeout {

View File

@ -21,10 +21,7 @@ import org.enso.projectmanager.infrastructure.languageserver.{
} }
import org.enso.projectmanager.infrastructure.log.Slf4jLogging import org.enso.projectmanager.infrastructure.log.Slf4jLogging
import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.random.SystemGenerator
import org.enso.projectmanager.infrastructure.repository.{ import org.enso.projectmanager.infrastructure.repository.ProjectFileRepositoryFactory
ProjectFileRepository,
ProjectFileRepositoryFactory
}
import org.enso.projectmanager.infrastructure.time.RealClock import org.enso.projectmanager.infrastructure.time.RealClock
import org.enso.projectmanager.protocol.{ import org.enso.projectmanager.protocol.{
JsonRpcProtocolFactory, JsonRpcProtocolFactory,
@ -74,14 +71,6 @@ class MainModule[
lazy val projectRepositoryFactory = lazy val projectRepositoryFactory =
new ProjectFileRepositoryFactory[F](config.storage, clock, fileSystem, gen) new ProjectFileRepositoryFactory[F](config.storage, clock, fileSystem, gen)
lazy val projectRepository =
new ProjectFileRepository[F](
config.storage,
clock,
fileSystem,
gen
)
val distributionConfiguration = DefaultDistributionConfiguration val distributionConfiguration = DefaultDistributionConfiguration
val loggingService = Logging.GlobalLoggingService val loggingService = Logging.GlobalLoggingService
@ -149,7 +138,7 @@ class MainModule[
timeoutConfig = config.timeout timeoutConfig = config.timeout
) )
lazy val projectsEndpoint = new ProjectsEndpoint(projectRepository) lazy val projectsEndpoint = new ProjectsEndpoint(projectRepositoryFactory)
lazy val server = lazy val server =
new JsonRpcServer( new JsonRpcServer(
new JsonRpcProtocolFactory, new JsonRpcProtocolFactory,

View File

@ -21,6 +21,7 @@ import org.enso.projectmanager.boot.configuration.{
MainProcessConfig, MainProcessConfig,
ProjectManagerConfig ProjectManagerConfig
} }
import org.enso.projectmanager.infrastructure.migration.ProjectsMigration
import org.enso.projectmanager.protocol.JsonRpcProtocolFactory import org.enso.projectmanager.protocol.JsonRpcProtocolFactory
import org.enso.version.VersionDescription import org.enso.version.VersionDescription
import org.slf4j.event.Level import org.slf4j.event.Level
@ -53,11 +54,11 @@ object ProjectManager extends ZIOAppDefault with LazyLogging {
new JsonRpcProtocolFactory().getProtocol() new JsonRpcProtocolFactory().getProtocol()
) )
val computeThreadPool = new ScheduledThreadPoolExecutor( private val computeThreadPool = new ScheduledThreadPoolExecutor(
java.lang.Runtime.getRuntime.availableProcessors() java.lang.Runtime.getRuntime.availableProcessors()
) )
val computeExecutionContext: ExecutionContextExecutor = private val computeExecutionContext: ExecutionContextExecutor =
ExecutionContext.fromExecutor( ExecutionContext.fromExecutor(
computeThreadPool, computeThreadPool,
th => logger.error("An expected error occurred.", th) th => logger.error("An expected error occurred.", th)
@ -77,6 +78,7 @@ object ProjectManager extends ZIOAppDefault with LazyLogging {
private def mainProcess( private def mainProcess(
processConfig: MainProcessConfig processConfig: MainProcessConfig
): ZIO[ZAny, IOException, Unit] = { ): ZIO[ZAny, IOException, Unit] = {
ProjectsMigration.migrate(config.storage)
val mainModule = val mainModule =
new MainModule[ZIO[ZAny, +*, +*]]( new MainModule[ZIO[ZAny, +*, +*]](
config, config,

View File

@ -8,7 +8,7 @@ import org.enso.projectmanager.control.effect.{ErrorChannel, Sync}
import org.enso.projectmanager.infrastructure.file.BlockingFileSystem import org.enso.projectmanager.infrastructure.file.BlockingFileSystem
import org.enso.projectmanager.infrastructure.random.SystemGenerator import org.enso.projectmanager.infrastructure.random.SystemGenerator
import org.enso.projectmanager.infrastructure.repository.{ import org.enso.projectmanager.infrastructure.repository.{
ProjectFileRepository, ProjectFileRepositoryFactory,
ProjectRepository ProjectRepository
} }
import org.enso.projectmanager.infrastructure.time.RealClock import org.enso.projectmanager.infrastructure.time.RealClock
@ -47,17 +47,15 @@ object ProjectListCommand {
val clock = new RealClock[F] val clock = new RealClock[F]
val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout) val fileSystem = new BlockingFileSystem[F](config.timeout.ioTimeout)
val gen = new SystemGenerator[F] val gen = new SystemGenerator[F]
val storageConfig = projectsPath.fold(config.storage)(path =>
config.storage.copy(userProjectsPath = path)
)
val projectRepositoryFactory = new ProjectFileRepositoryFactory[F](
config.storage,
clock,
fileSystem,
gen
)
val projectRepository = val projectRepository =
new ProjectFileRepository[F]( projectRepositoryFactory.getProjectRepository(projectsPath)
storageConfig,
clock,
fileSystem,
gen
)
new ProjectListCommand[F](projectRepository, limitOpt) new ProjectListCommand[F](projectRepository, limitOpt)
} }

View File

@ -1,9 +1,11 @@
package org.enso.projectmanager.boot package org.enso.projectmanager.boot
import org.enso.desktopenvironment.Platform
import org.slf4j.event.Level import org.slf4j.event.Level
import java.io.File import java.io.{File, IOException}
import java.nio.file.Path import java.nio.file.Path
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
object configuration { object configuration {
@ -41,21 +43,37 @@ object configuration {
*/ */
case class ServerConfig(host: String, port: Int) case class ServerConfig(host: String, port: Int)
/** A configuration object for properties of project storage. /** A configuration object for metadata storage.
* *
* @param projectsRoot a project root * @param projectMetadataDirectory a directory name containing project metadata
* @param userProjectsPath a user project root
* @param projectMetadataDirectory a directory name containing project
* metadata
* @param projectMetadataFileName a name of project metadata file * @param projectMetadataFileName a name of project metadata file
*/ */
case class StorageConfig( case class MetadataStorageConfig(
projectsRoot: File,
userProjectsPath: File,
projectMetadataDirectory: String, projectMetadataDirectory: String,
projectMetadataFileName: String projectMetadataFileName: String
) )
/** A configuration object for properties of project storage.
*
* @param projectsRoot overrides user projects root directory
* @param projectsDirectory a user projects directory
* @param metadata a metadata storage config
*/
case class StorageConfig(
projectsRoot: Option[File],
projectsDirectory: String,
metadata: MetadataStorageConfig
) {
/** @return a path to the user projects directory. */
@throws[IOException]
def userProjectsPath: File = {
val projectsRootDirectory =
projectsRoot.getOrElse(Platform.getDirectories.getDocuments.toFile)
new File(projectsRootDirectory, projectsDirectory)
}
}
/** A configuration object for timeout properties. /** A configuration object for timeout properties.
* *
* @param ioTimeout a timeout for IO operations * @param ioTimeout a timeout for IO operations

View File

@ -15,7 +15,7 @@ import org.enso.projectmanager.control.core.CovariantFlatMap
import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.syntax._
import org.enso.projectmanager.control.effect.Exec import org.enso.projectmanager.control.effect.Exec
import org.enso.projectmanager.infrastructure.repository.{ import org.enso.projectmanager.infrastructure.repository.{
ProjectRepository, ProjectRepositoryFactory,
ProjectRepositoryFailure ProjectRepositoryFailure
} }
@ -26,10 +26,13 @@ import scala.util.{Failure, Success}
final class ProjectsEndpoint[ final class ProjectsEndpoint[
F[+_, +_]: Exec: CovariantFlatMap F[+_, +_]: Exec: CovariantFlatMap
](repo: ProjectRepository[F]) ](projectRepositoryFactory: ProjectRepositoryFactory[F])
extends Endpoint extends Endpoint
with LazyLogging { with LazyLogging {
private val projectRepository =
projectRepositoryFactory.getProjectRepository(None)
/** @inheritdoc */ /** @inheritdoc */
override def route: Route = override def route: Route =
projectsEndpoint projectsEndpoint
@ -74,7 +77,7 @@ final class ProjectsEndpoint[
projectId: UUID projectId: UUID
): Future[Either[ProjectRepositoryFailure, Option[EnsoProjectArchive]]] = ): Future[Either[ProjectRepositoryFailure, Option[EnsoProjectArchive]]] =
Exec[F].exec { Exec[F].exec {
repo projectRepository
.findById(projectId) .findById(projectId)
.map(projectOpt => .map(projectOpt =>
projectOpt.map(project => projectOpt.map(project =>

View File

@ -1,9 +1,8 @@
package org.enso.projectmanager.infrastructure.repository package org.enso.projectmanager.infrastructure.repository
import java.io.File import java.io.File
import io.circe.generic.auto._ import io.circe.generic.auto._
import org.enso.projectmanager.boot.configuration.StorageConfig import org.enso.projectmanager.boot.configuration.MetadataStorageConfig
import org.enso.projectmanager.control.core.CovariantFlatMap import org.enso.projectmanager.control.core.CovariantFlatMap
import org.enso.projectmanager.control.core.syntax._ import org.enso.projectmanager.control.core.syntax._
import org.enso.projectmanager.control.effect.syntax._ import org.enso.projectmanager.control.effect.syntax._
@ -27,7 +26,7 @@ import shapeless.{Coproduct, Inl, Inr}
/** File based implementation of the project metadata storage. /** File based implementation of the project metadata storage.
* *
* @param directory a project directory * @param directory a project directory
* @param storageConfig a storage config * @param metadataStorageConfig a metadata storage config
* @param clock a clock * @param clock a clock
* @param fileSystem a file system abstraction * @param fileSystem a file system abstraction
* @param gen a random generator * @param gen a random generator
@ -36,7 +35,7 @@ final class MetadataFileStorage[
F[+_, +_]: ErrorChannel: CovariantFlatMap F[+_, +_]: ErrorChannel: CovariantFlatMap
]( ](
directory: File, directory: File,
storageConfig: StorageConfig, metadataStorageConfig: MetadataStorageConfig,
clock: Clock[F], clock: Clock[F],
fileSystem: FileSystem[F], fileSystem: FileSystem[F],
gen: Generator[F] gen: Generator[F]
@ -89,8 +88,8 @@ final class MetadataFileStorage[
new File( new File(
project, project,
new File( new File(
storageConfig.projectMetadataDirectory, metadataStorageConfig.projectMetadataDirectory,
storageConfig.projectMetadataFileName metadataStorageConfig.projectMetadataFileName
).toString ).toString
) )
} }

View File

@ -4,9 +4,8 @@ import java.io.File
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.attribute.FileTime import java.nio.file.attribute.FileTime
import java.util.UUID import java.util.UUID
import org.enso.pkg.{Package, PackageManager} import org.enso.pkg.{Package, PackageManager}
import org.enso.projectmanager.boot.configuration.StorageConfig import org.enso.projectmanager.boot.configuration.MetadataStorageConfig
import org.enso.projectmanager.control.core.{ import org.enso.projectmanager.control.core.{
Applicative, Applicative,
CovariantFlatMap, CovariantFlatMap,
@ -31,7 +30,7 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata}
/** File based implementation of the project repository. /** File based implementation of the project repository.
* *
* @param storageConfig a storage config * @param metadataStorageConfig a metadata storage config
* @param clock a clock * @param clock a clock
* @param fileSystem a file system abstraction * @param fileSystem a file system abstraction
* @param gen a random generator * @param gen a random generator
@ -39,7 +38,8 @@ import org.enso.projectmanager.model.{Project, ProjectMetadata}
class ProjectFileRepository[ class ProjectFileRepository[
F[+_, +_]: Sync: ErrorChannel: CovariantFlatMap: Applicative F[+_, +_]: Sync: ErrorChannel: CovariantFlatMap: Applicative
]( ](
storageConfig: StorageConfig, projectsPath: File,
metadataStorageConfig: MetadataStorageConfig,
clock: Clock[F], clock: Clock[F],
fileSystem: FileSystem[F], fileSystem: FileSystem[F],
gen: Generator[F] gen: Generator[F]
@ -60,7 +60,7 @@ class ProjectFileRepository[
/** @inheritdoc */ /** @inheritdoc */
override def getAll(): F[ProjectRepositoryFailure, List[Project]] = { override def getAll(): F[ProjectRepositoryFailure, List[Project]] = {
fileSystem fileSystem
.list(storageConfig.userProjectsPath) .list(projectsPath)
.map(_.filter(_.isDirectory)) .map(_.filter(_.isDirectory))
.recover { case FileNotFound | NotDirectory => .recover { case FileNotFound | NotDirectory =>
Nil Nil
@ -243,7 +243,7 @@ class ProjectFileRepository[
for { for {
project <- getProject(projectId) project <- getProject(projectId)
primaryPath = new File(storageConfig.userProjectsPath, newName) primaryPath = new File(projectsPath, newName)
finalPath <- finalPath <-
if (isLocationOk(project.path, primaryPath)) { if (isLocationOk(project.path, primaryPath)) {
CovariantFlatMap[F].pure(primaryPath) CovariantFlatMap[F].pure(primaryPath)
@ -283,7 +283,7 @@ class ProjectFileRepository[
.tailRecM[ProjectRepositoryFailure, Int, File](0) { number => .tailRecM[ProjectRepositoryFailure, Int, File](0) { number =>
val path = val path =
new File( new File(
storageConfig.userProjectsPath, projectsPath,
moduleName + genSuffix(number) moduleName + genSuffix(number)
) )
fileSystem fileSystem
@ -307,7 +307,7 @@ class ProjectFileRepository[
private def metadataStorage(projectPath: File): MetadataFileStorage[F] = private def metadataStorage(projectPath: File): MetadataFileStorage[F] =
new MetadataFileStorage[F]( new MetadataFileStorage[F](
projectPath, projectPath,
storageConfig, metadataStorageConfig,
clock, clock,
fileSystem, fileSystem,
gen gen

View File

@ -21,9 +21,14 @@ class ProjectFileRepositoryFactory[
override def getProjectRepository( override def getProjectRepository(
projectsDirectory: Option[File] projectsDirectory: Option[File]
): ProjectRepository[F] = { ): ProjectRepository[F] = {
val config = projectsDirectory.fold(storageConfig)(dir => val projectsPath =
storageConfig.copy(userProjectsPath = dir) projectsDirectory.getOrElse(storageConfig.userProjectsPath)
new ProjectFileRepository[F](
projectsPath,
storageConfig.metadata,
clock,
fileSystem,
gen
) )
new ProjectFileRepository[F](config, clock, fileSystem, gen)
} }
} }

View File

@ -0,0 +1,124 @@
package org.enso.projectmanager.infrastructure.migration;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import org.apache.commons.io.FileUtils;
import org.enso.desktopenvironment.Platform;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
public class ProjectsMigrationTest {
@Rule public TemporaryFolder tmp = new TemporaryFolder();
@Test
public void moveDirectory() throws IOException {
File oldProjectsDir = tmp.newFolder("old-projects");
File newProjectsDir = new File(tmp.getRoot(), "new-projects");
File project1 = createProjectStructure(oldProjectsDir, "Project1");
File project2 = createProjectStructure(oldProjectsDir, "Project2");
Assert.assertTrue(oldProjectsDir.isDirectory());
Assert.assertTrue(project1.isDirectory());
Assert.assertTrue(project2.isDirectory());
Assert.assertFalse(newProjectsDir.isDirectory());
ProjectsMigration.moveDirectory(oldProjectsDir, newProjectsDir);
Assert.assertFalse(oldProjectsDir.isDirectory());
Assert.assertTrue(newProjectsDir.isDirectory());
Assert.assertTrue(new File(newProjectsDir, "Project1").isDirectory());
Assert.assertTrue(new File(newProjectsDir, "Project2").isDirectory());
}
@Test
public void setProjectDirectoryPermissions() throws IOException {
if (!Platform.isWindows()) {
File projectsDir = tmp.newFolder("projects");
createProjectStructure(projectsDir, "Project1");
Assert.assertTrue(projectsDir.isDirectory());
ProjectsMigration.setProjectsDirectoryPermissions(projectsDir);
var permissions = Files.getPosixFilePermissions(projectsDir.toPath());
Assert.assertEquals("rwx------", PosixFilePermissions.toString(permissions));
}
}
@Test
public void migrateProjectsDirectoryIdempotent() throws IOException {
File oldProjectsDir = tmp.newFolder("old-projects");
File newProjectsDir = new File(tmp.getRoot(), "new-projects");
File project1 = createProjectStructure(oldProjectsDir, "Project1");
File project2 = createProjectStructure(oldProjectsDir, "Project2");
Assert.assertTrue(oldProjectsDir.isDirectory());
Assert.assertTrue(project1.isDirectory());
Assert.assertTrue(project2.isDirectory());
Assert.assertFalse(newProjectsDir.isDirectory());
ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir);
File newProject1 = new File(newProjectsDir, "Project1");
File newProject2 = new File(newProjectsDir, "Project2");
Assert.assertFalse(oldProjectsDir.isDirectory());
Assert.assertTrue(newProjectsDir.isDirectory());
Assert.assertTrue(newProject1.isDirectory());
Assert.assertTrue(newProject2.isDirectory());
ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir);
Assert.assertFalse(oldProjectsDir.isDirectory());
Assert.assertTrue(newProjectsDir.isDirectory());
Assert.assertTrue(newProject1.isDirectory());
Assert.assertTrue(newProject2.isDirectory());
}
@Test
public void migrateProjectsDirectoryCleanupWhenBothExist() throws IOException {
File oldProjectsDir = tmp.newFolder("old-projects");
File newProjectsDir = new File(tmp.getRoot(), "new-projects");
File project1 = createProjectStructure(newProjectsDir, "Project1");
File project2 = createProjectStructure(newProjectsDir, "Project2");
Assert.assertTrue(oldProjectsDir.isDirectory());
Assert.assertTrue(newProjectsDir.isDirectory());
Assert.assertTrue(project1.isDirectory());
Assert.assertTrue(project2.isDirectory());
ProjectsMigration.migrateProjectsDirectory(oldProjectsDir, newProjectsDir);
Assert.assertFalse(oldProjectsDir.isDirectory());
Assert.assertTrue(newProjectsDir.isDirectory());
Assert.assertTrue(new File(newProjectsDir, "Project1").isDirectory());
Assert.assertTrue(new File(newProjectsDir, "Project2").isDirectory());
}
private static File createProjectStructure(File tmp, String name) throws IOException {
var projectDir = new File(tmp, name);
var srcDir = new File(projectDir, "src");
var ensoDir = new File(projectDir, ".enso");
FileUtils.forceMkdir(srcDir);
FileUtils.forceMkdir(ensoDir);
createNewFile(new File(projectDir, "package.yaml"));
createNewFile(new File(srcDir, "Main.enso"));
return projectDir;
}
private static void createNewFile(File file) throws IOException {
if (!file.createNewFile()) {
throw new IOException("File '" + file + "' already exists.");
}
}
}

View File

@ -56,13 +56,12 @@ project-manager {
} }
storage { storage {
projects-root = ${user.home}/enso projects-root = ${?PROJECTS_ROOT}
projects-root=${?PROJECTS_ROOT} projects-directory = "enso-projects"
project-index-path = ${project-manager.storage.projects-root}/.enso/project-index.json metadata {
temporary-projects-path = ${project-manager.storage.projects-root}/tmp project-metadata-directory = ".enso"
user-projects-path = ${project-manager.storage.projects-root}/projects project-metadata-file-name = "project.json"
tutorials-path = ${project-manager.storage.projects-root}/tutorials }
tutorials-cache-path = ${project-manager.storage.projects-root}/.tutorials-cache
} }
timeout { timeout {

View File

@ -108,15 +108,17 @@ class BaseServerSpec extends JsonRpcServerTestKit with BeforeAndAfterAll {
val testDistributionRoot = Files.createTempDirectory(null).toFile val testDistributionRoot = Files.createTempDirectory(null).toFile
sys.addShutdownHook(FileUtils.deleteQuietly(testDistributionRoot)) sys.addShutdownHook(FileUtils.deleteQuietly(testDistributionRoot))
val userProjectDir = new File(testProjectsRoot, "projects")
lazy val testStorageConfig = StorageConfig( lazy val testStorageConfig = StorageConfig(
projectsRoot = testProjectsRoot, projectsRoot = Some(testProjectsRoot),
userProjectsPath = userProjectDir, projectsDirectory = "enso-projects",
projectMetadataDirectory = ".enso", metadata = MetadataStorageConfig(
projectMetadataFileName = "project.json" projectMetadataDirectory = ".enso",
projectMetadataFileName = "project.json"
)
) )
lazy val userProjectDir = testStorageConfig.userProjectsPath
lazy val bootloaderConfig = config.bootloader lazy val bootloaderConfig = config.bootloader
lazy val timeoutConfig = config.timeout lazy val timeoutConfig = config.timeout

View File

@ -46,7 +46,7 @@ class FileSystemServiceSpec
def metadataFileStorage(directory: File) = def metadataFileStorage(directory: File) =
new MetadataFileStorage[ZIO[ZAny, +*, +*]]( new MetadataFileStorage[ZIO[ZAny, +*, +*]](
directory, directory,
config.storage, config.storage.metadata,
testClock, testClock,
fileSystem, fileSystem,
gen gen