diff --git a/README.md b/README.md index 5c00e55..db80c93 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ To add a new dependency to your project, use: > grill install ``` +For an offline dependency, pass an absolute `file:` URL to a local directory. `grill install file:///...` refreshes that dependency without fetching the project's other remote dependencies. Grill copies its contents into `_build/dependencies/` (the source `.git` directory is excluded; symlinks are rejected): + +```cmd +> grill install file:///C:/Users/me/wurst/MyDependency +``` + +Private HTTPS repositories use the credential helpers configured for your installed Git, including Git Credential Manager. If sign-in is needed, the helper can open its normal interactive flow. Grill does not store credentials itself. + ### Testing a project Use `test` to compile the project at the current location and run unit tests. diff --git a/src/main/kotlin/config/WurstProjectConfig.kt b/src/main/kotlin/config/WurstProjectConfig.kt index 2142a06..6b17a4e 100644 --- a/src/main/kotlin/config/WurstProjectConfig.kt +++ b/src/main/kotlin/config/WurstProjectConfig.kt @@ -227,12 +227,17 @@ object WurstProjectConfig { } } - fun handleUpdate(projectRoot: Path, gamePath: Path?, config: WurstProjectConfigData) { + fun handleUpdate( + projectRoot: Path, + gamePath: Path?, + config: WurstProjectConfigData, + localDependenciesOnly: Boolean = false + ) { Log.print("Updating project...\n") try { setupVSCode(projectRoot, gamePath) saveProjectConfig(projectRoot, config) - DependencyManager.updateDependencies(projectRoot, config) + DependencyManager.updateDependencies(projectRoot, config, localDependenciesOnly) Log.print("Project successfully updated!\nReload vscode to apply the changed dependencies.\n") } catch (e: Exception) { diff --git a/src/main/kotlin/file/DependencyManager.kt b/src/main/kotlin/file/DependencyManager.kt index 4b0e3d2..ebf2083 100644 --- a/src/main/kotlin/file/DependencyManager.kt +++ b/src/main/kotlin/file/DependencyManager.kt @@ -11,6 +11,7 @@ import org.eclipse.jgit.lib.Constants import java.io.File import java.io.ByteArrayOutputStream import java.io.IOException +import java.net.URI import java.nio.file.Files import java.nio.file.Path import java.util.Comparator @@ -22,17 +23,104 @@ object DependencyManager { private val log = KotlinLogging.logger {} var debug = false - fun updateDependencies(projectRoot: Path, projectConfig: WurstProjectConfigData) { + fun isLocalDependency(dependency: String): Boolean = + runCatching { URI(dependency).scheme.equals("file", ignoreCase = true) }.getOrDefault(false) + + private fun localDependencyPath(dependency: String): Path { + val uri = URI(dependency) + require(uri.scheme.equals("file", ignoreCase = true)) { "Local dependencies must use a file: URL." } + require(uri.query == null && uri.fragment == null) { "Local dependency URLs cannot contain a query or fragment." } + return Path.of(uri).toAbsolutePath().normalize() + } + + private fun hasSymbolicLinkComponent(path: Path): Boolean { + val normalized = path.toAbsolutePath().normalize() + var current = normalized.root ?: return Files.isSymbolicLink(normalized) + for (component in normalized) { + current = current.resolve(component) + if (Files.isSymbolicLink(current)) return true + } + return false + } + + private fun dependencyFiles(root: Path): List { + require(!hasSymbolicLinkComponent(root)) { + "Local dependency paths cannot contain symbolic links: $root" + } + require(Files.isDirectory(root)) { "Local dependency directory does not exist: $root" } + val entries = Files.walk(root).use { paths -> + paths.filter { path -> + path != root && path.none { it.toString() == ".git" } + }.toList() + } + require(entries.none(Files::isSymbolicLink)) { + "Local dependency directories cannot contain symbolic links: $root" + } + return entries.sorted() + } + + private fun localDependencyMatches(dependency: String, destination: Path): Boolean = try { + val source = localDependencyPath(dependency) + if (!Files.isDirectory(source) || !Files.isDirectory(destination) || source == destination || + destination.startsWith(source) || source.startsWith(destination)) { + false + } else { + val sourceFiles = dependencyFiles(source) + val destinationFiles = dependencyFiles(destination) + sourceFiles.map { source.relativize(it) } == destinationFiles.map { destination.relativize(it) } && + sourceFiles.zip(destinationFiles).all { (left, right) -> + Files.isDirectory(left) && Files.isDirectory(right) || + (Files.isRegularFile(left) && Files.isRegularFile(right) && Files.mismatch(left, right) == -1L) + } + } + } catch (_: Exception) { + false + } + + private fun copyLocalDependency(dependency: String, destination: Path) { + val source = localDependencyPath(dependency) + require(Files.isDirectory(source)) { "Local dependency directory does not exist: $source" } + val normalizedDestination = destination.toAbsolutePath().normalize() + require(source != normalizedDestination && !normalizedDestination.startsWith(source) && !source.startsWith(normalizedDestination)) { + "A local dependency cannot contain its _build destination." + } + val sourceEntries = dependencyFiles(source) + if (Files.exists(destination)) deleteDirectoryStream(destination) + Files.createDirectories(destination) + for (sourcePath in sourceEntries) { + val relative = source.relativize(sourcePath) + val target = destination.resolve(relative) + if (Files.isDirectory(sourcePath)) { + Files.createDirectories(target) + } else if (Files.isRegularFile(sourcePath)) { + Files.createDirectories(target.parent) + Files.copy(sourcePath, target) + } + } + } + + fun updateDependencies( + projectRoot: Path, + projectConfig: WurstProjectConfigData, + localDependenciesOnly: Boolean = false + ) { cleanupLegacyDependencyFile(projectRoot) log.info("\uD83D\uDD37 Installing dependencies..") Log.print("Updating dependencies...\n") for (dependency in projectConfig.dependencies) { val (depUri, dependencyName, requestedBranch) = resolveName(dependency) + val isLocal = isLocalDependency(depUri) + if (localDependenciesOnly && !isLocal) continue + val depFolder = projectRoot.resolve("_build/dependencies/$dependencyName") + if (isLocal) { + copyLocalDependency(depUri, depFolder) + Log.print("Updated local dependency - $dependencyName\n") + continue + } val branch = resolveBranch(depUri, requestedBranch) log.info("\t\uD83D\uDD39 Pulling <$dependencyName:$branch>") Log.print("Updating dependency - $dependencyName ..") - val depFolder = projectRoot.resolve("_build/dependencies/$dependencyName") if (Files.exists(depFolder)) { log.debug("dependency exists locally") if (!refreshRepo(depFolder, depUri, branch)) { @@ -59,11 +147,15 @@ object DependencyManager { } fun resolveName(dependency: String): Triple { - var dependencyName = dependency.substring(dependency.lastIndexOf("/") + 1) + var dependencyName = if (isLocalDependency(dependency)) { + localDependencyPath(dependency).fileName?.toString().orEmpty() + } else { + dependency.substring(dependency.lastIndexOf("/") + 1) + } var branch = "" var depURI = dependency - if (dependencyName.contains(":")) { + if (!isLocalDependency(dependency) && dependencyName.contains(":")) { depURI = depURI.substring(0, depURI.lastIndexOf(":")) branch = dependencyName.substring(dependencyName.lastIndexOf(":") + 1) dependencyName = dependencyName.substring(0, dependencyName.lastIndexOf(":")) @@ -77,6 +169,10 @@ object DependencyManager { val (_, dependencyName, _) = resolveName(dependency) Log.print("Checking dependency - $dependencyName ..") val depFolder = projectRoot.resolve("_build/dependencies/$dependencyName") + if (isLocalDependency(resolveName(dependency).first)) { + if (!localDependencyMatches(dependency, depFolder)) return true + continue + } if (Files.exists(depFolder)) { isGitRepoUpToDate(depFolder) } else { @@ -90,9 +186,17 @@ object DependencyManager { Log.print("Checking dependencies...\n") for (dependency in projectConfig.dependencies) { val (depUri, dependencyName, requestedBranch) = resolveName(dependency) + val depFolder = projectRoot.resolve("_build/dependencies/$dependencyName") + if (isLocalDependency(depUri)) { + if (!localDependencyMatches(depUri, depFolder)) { + Log.print("outdated\n") + return true + } + Log.print("ok\n") + continue + } val branch = resolveBranch(depUri, requestedBranch) Log.print("Checking dependency - $dependencyName ..") - val depFolder = projectRoot.resolve("_build/dependencies/$dependencyName") if (!Files.exists(depFolder.resolve(".git"))) { Log.print("missing\n") @@ -126,6 +230,7 @@ object DependencyManager { .setURI(depURI) .setBranch(branch) .setDirectory(depFolder.toFile()) + .setCredentialsProvider(GitCredentialProvider) .call() .use { Log.print("done\n") } } catch (e: Exception) { @@ -153,6 +258,7 @@ object DependencyManager { git.fetch() .setRemote("origin") .setRemoveDeletedRefs(true) + .setCredentialsProvider(GitCredentialProvider) .call() if (!prepareRepo(git, branch)) { return false @@ -227,6 +333,7 @@ object DependencyManager { .setRemote(depUri) .setHeads(true) .setTags(false) + .setCredentialsProvider(GitCredentialProvider) .call() val branchNames = refs.mapNotNull { ref -> @@ -272,7 +379,10 @@ object DependencyManager { FileRepository(depFolder.resolve(".git").toFile()).use { repository -> try { Git(repository).use { git -> - git.lsRemote().setHeads(true).call() + git.lsRemote() + .setHeads(true) + .setCredentialsProvider(GitCredentialProvider) + .call() val status = git.status().call() if (status.hasUncommittedChanges()) { Log.print("You have modified files in your dependencies folder.") @@ -311,6 +421,7 @@ object DependencyManager { git.fetch() .setRemote("origin") .setRemoveDeletedRefs(true) + .setCredentialsProvider(GitCredentialProvider) .call() } val localHead = repository.resolve(Constants.HEAD) @@ -339,7 +450,7 @@ object DependencyManager { } message.contains("Authentication", true) || message.contains("not authorized", true) -> { log.info("Reason: authentication failed.") - log.info("Try: check that the repo is public or that git credentials are available.") + log.info("Try: check that your installed Git can access this repo and has a credential helper configured.") } else -> { log.info("Reason: $message") @@ -357,6 +468,7 @@ object DependencyManager { .setRemote(depURI) .setHeads(true) .setTags(false) + .setCredentialsProvider(GitCredentialProvider) .call() .mapNotNull { ref -> ref.name.takeIf { it.startsWith(Constants.R_HEADS) }?.removePrefix(Constants.R_HEADS) diff --git a/src/main/kotlin/file/GitCredentialProvider.kt b/src/main/kotlin/file/GitCredentialProvider.kt new file mode 100644 index 0000000..235ae11 --- /dev/null +++ b/src/main/kotlin/file/GitCredentialProvider.kt @@ -0,0 +1,56 @@ +package file + +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.URIish + +/** + * Uses the user's configured Git credential helpers for JGit HTTPS requests. + * This lets helpers such as Git Credential Manager supply existing tokens or + * start their normal interactive sign-in flow without storing credentials in Grill. + */ +internal object GitCredentialProvider : CredentialsProvider() { + override fun isInteractive(): Boolean = true + + override fun supports(vararg items: CredentialItem): Boolean = + items.all { it is CredentialItem.Username || it is CredentialItem.Password } + + override fun get(uri: URIish, vararg items: CredentialItem): Boolean { + if (!uri.scheme.equals("https", ignoreCase = true) || uri.host.isNullOrBlank()) return false + + return try { + val input = buildString { + append("protocol=https\n") + val host = uri.host + if (uri.port > 0) ":${uri.port}" else "" + append("host=$host\n") + uri.path?.removePrefix("/")?.takeIf(String::isNotBlank)?.let { append("path=$it\n") } + uri.user?.takeIf(String::isNotBlank)?.let { append("username=$it\n") } + append('\n') + } + val process = ProcessBuilder("git", "credential", "fill") + .redirectErrorStream(true) + .start() + process.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(input) } + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readLines() } + if (process.waitFor() != 0) return false + + val values = output.mapNotNull { line -> + val separator = line.indexOf('=') + if (separator <= 0) null else line.substring(0, separator) to line.substring(separator + 1) + }.toMap() + val username = values["username"] ?: return false + val password = values["password"] ?: return false + items.forEach { item -> + when (item) { + is CredentialItem.Username -> item.value = username + is CredentialItem.Password -> item.value = password.toCharArray() + } + } + true + } catch (_: Exception) { + false + } + } + + override fun reset(uri: URIish?) = Unit +} diff --git a/src/main/kotlin/file/SetupApp.kt b/src/main/kotlin/file/SetupApp.kt index 2ffc434..54065b9 100644 --- a/src/main/kotlin/file/SetupApp.kt +++ b/src/main/kotlin/file/SetupApp.kt @@ -200,10 +200,15 @@ object SetupApp { } else { if (configData != null) { configData = handleInstallDep(configData) - configData = ensureProjectPatchRecorded(configData) + configData = ensureProjectPatchRecorded(configData) suggestPatchAlignment(configData) WurstProjectConfig.saveProjectConfig(setup.projectRoot, configData) - handleUpdateProject(configData) + handleUpdateProject( + configData, + localDependenciesOnly = DependencyManager.isLocalDependency( + DependencyManager.resolveName(setup.commandArg).first + ) + ) } else { missingProject() } @@ -1396,9 +1401,11 @@ object SetupApp { } } - private fun handleUpdateProject(configData: WurstProjectConfigData) { - WurstProjectConfig.handleUpdate(setup.projectRoot, null, configData) - ensureCoreJassFiles(setup.projectRoot, configData.wc3Patch) + private fun handleUpdateProject(configData: WurstProjectConfigData, localDependenciesOnly: Boolean = false) { + WurstProjectConfig.handleUpdate(setup.projectRoot, null, configData, localDependenciesOnly) + if (!localDependenciesOnly) { + ensureCoreJassFiles(setup.projectRoot, configData.wc3Patch) + } warnIfAgentsTemplateStale(setup.projectRoot) } @@ -1406,12 +1413,25 @@ object SetupApp { private fun handleInstallDep(configData: WurstProjectConfigData): WurstProjectConfigData { val resolvedName = DependencyManager.resolveName(setup.commandArg) + if (DependencyManager.isLocalDependency(resolvedName.first)) { + val source = runCatching { java.nio.file.Path.of(java.net.URI(resolvedName.first)) }.getOrNull() + if (source == null || !java.nio.file.Files.isDirectory(source) || resolvedName.second.isBlank()) { + log.error("❌ Local dependency directory does not exist: ${resolvedName.first}") + ExitHandler.exit(1) + } + if (configData.dependencies.contains(setup.commandArg)) { + log.info("✅ Dependency is already listed.") + return configData + } + log.info("🔹 Adding local dependency ${resolvedName.second}") + return configData.withAddedDependency(setup.commandArg) + } if (!REPO_REGEX.matches(resolvedName.first)) { log.error("❌ Unsupported dependency URL: ${setup.commandArg}") log.info("Accepted forms:") - log.info(" https://github.com/user/repo") - log.info(" https://github.com/user/repo:branch") - log.info("SSH repo URLs are not bundled in the slim CLI.") + log.info(" https://github.com/user/repo") + log.info(" https://github.com/user/repo:branch") + log.info(" file:///absolute/path/to/local-repo") ExitHandler.exit(1) } log.info("🔹 Installing ${resolvedName.second}") @@ -1422,6 +1442,7 @@ object SetupApp { try { val result = Git.lsRemoteRepository() .setRemote(resolvedName.first) + .setCredentialsProvider(GitCredentialProvider) .call() if (!result.isEmpty()) { Log.print("valid!\n") diff --git a/src/main/resources/wbschema.json b/src/main/resources/wbschema.json index 7aecb8d..d14aa0f 100644 --- a/src/main/resources/wbschema.json +++ b/src/main/resources/wbschema.json @@ -44,7 +44,7 @@ ] }, "dependencies": { - "description": "Git repository urls of this project's dependencies", + "description": "Git repository URLs or file: URLs to local dependency directories", "type": "array", "items": { "type": "string" diff --git a/src/test/kotlin/DependencyManagerTests.kt b/src/test/kotlin/DependencyManagerTests.kt new file mode 100644 index 0000000..8a50bd9 --- /dev/null +++ b/src/test/kotlin/DependencyManagerTests.kt @@ -0,0 +1,84 @@ +import config.newProjectConfig +import file.DependencyManager +import org.testng.Assert +import org.testng.SkipException +import org.testng.annotations.Test +import java.nio.file.Files + +class DependencyManagerTests { + @Test + fun localOnlyUpdateCopiesLocalDependencyAndSkipsRemoteDependencies() { + val project = Files.createTempDirectory("grill-local-dependency-project") + val source = Files.createTempDirectory("grill-local-dependency-source") + Files.writeString(source.resolve("Dependency.wurst"), "package Dependency\n") + val localUrl = source.toUri().toString() + val config = newProjectConfig( + dependencies = listOf("https://example.invalid/private-dependency", localUrl) + ) + + DependencyManager.updateDependencies(project, config, localDependenciesOnly = true) + + Assert.assertTrue(Files.exists(project.resolve("_build/dependencies/${source.fileName}/Dependency.wurst"))) + Assert.assertFalse(Files.exists(project.resolve("_build/dependencies/private-dependency"))) + } + + @Test + fun localDependencyWithSymlinkIsRejectedBeforeReplacingExistingCopy() { + val project = Files.createTempDirectory("grill-symlink-dependency-project") + val source = Files.createTempDirectory("grill-symlink-dependency-source") + val linkedDirectory = Files.createTempDirectory("grill-symlink-target") + Files.writeString(linkedDirectory.resolve("Nested.wurst"), "package Nested\n") + try { + Files.createSymbolicLink(source.resolve("linked"), linkedDirectory) + } catch (_: Exception) { + throw SkipException("The current environment does not allow creating symbolic links.") + } + + val localUrl = source.toUri().toString() + val dependencyName = source.fileName.toString() + val existingCopy = project.resolve("_build/dependencies/$dependencyName") + Files.createDirectories(existingCopy) + Files.writeString(existingCopy.resolve("PreviouslyInstalled.wurst"), "package PreviouslyInstalled\n") + + var rejected = false + try { + DependencyManager.updateDependencies(project, newProjectConfig(dependencies = listOf(localUrl))) + } catch (_: IllegalArgumentException) { + rejected = true + } + + Assert.assertTrue(rejected, "A local dependency containing a symbolic link should be rejected.") + Assert.assertTrue(Files.exists(existingCopy.resolve("PreviouslyInstalled.wurst"))) + } + + @Test + fun localDependencyRootSymlinkIsRejectedBeforeReplacingExistingCopy() { + val project = Files.createTempDirectory("grill-root-symlink-project") + val target = Files.createTempDirectory("grill-root-symlink-target") + Files.writeString(target.resolve("Dependency.wurst"), "package Dependency\n") + val parent = Files.createTempDirectory("grill-root-symlink-source") + val source = parent.resolve("dependency-link") + try { + Files.createSymbolicLink(source, target) + } catch (_: Exception) { + throw SkipException("The current environment does not allow creating symbolic links.") + } + + val existingCopy = project.resolve("_build/dependencies/${source.fileName}") + Files.createDirectories(existingCopy) + Files.writeString(existingCopy.resolve("PreviouslyInstalled.wurst"), "package PreviouslyInstalled\n") + + var rejected = false + try { + DependencyManager.updateDependencies( + project, + newProjectConfig(dependencies = listOf(source.toUri().toString())) + ) + } catch (_: IllegalArgumentException) { + rejected = true + } + + Assert.assertTrue(rejected, "A local dependency root that is a symbolic link should be rejected.") + Assert.assertTrue(Files.exists(existingCopy.resolve("PreviouslyInstalled.wurst"))) + } +}