Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ To add a new dependency to your project, use:
> grill install <git_url>
```

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/<directory-name>` (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.
Expand Down
9 changes: 7 additions & 2 deletions src/main/kotlin/config/WurstProjectConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
126 changes: 119 additions & 7 deletions src/main/kotlin/file/DependencyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Path> {
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" }
Comment thread
Frotty marked this conversation as resolved.
}.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)) {
Expand All @@ -59,11 +147,15 @@ object DependencyManager {
}

fun resolveName(dependency: String): Triple<String, String, String> {
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(":"))
Expand All @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -126,6 +230,7 @@ object DependencyManager {
.setURI(depURI)
.setBranch(branch)
.setDirectory(depFolder.toFile())
.setCredentialsProvider(GitCredentialProvider)
.call()
.use { Log.print("done\n") }
} catch (e: Exception) {
Expand Down Expand Up @@ -153,6 +258,7 @@ object DependencyManager {
git.fetch()
.setRemote("origin")
.setRemoveDeletedRefs(true)
.setCredentialsProvider(GitCredentialProvider)
.call()
if (!prepareRepo(git, branch)) {
return false
Expand Down Expand Up @@ -227,6 +333,7 @@ object DependencyManager {
.setRemote(depUri)
.setHeads(true)
.setTags(false)
.setCredentialsProvider(GitCredentialProvider)
.call()

val branchNames = refs.mapNotNull { ref ->
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -311,6 +421,7 @@ object DependencyManager {
git.fetch()
.setRemote("origin")
.setRemoveDeletedRefs(true)
.setCredentialsProvider(GitCredentialProvider)
.call()
}
val localHead = repository.resolve(Constants.HEAD)
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions src/main/kotlin/file/GitCredentialProvider.kt
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject failed credentials when resetting the provider

When a credential helper returns a stale or revoked credential, JGit invokes reset after the server rejects it before retrying authentication. This no-op leaves the helper unaware of the failure, so each subsequent fill can return the same invalid credential and private dependency operations cannot recover or trigger a fresh sign-in. The inspected git credential -h output explicitly lists reject alongside fill and approve; retain the filled credential and pass it to git credential reject here.

Useful? React with 👍 / 👎.

}
37 changes: 29 additions & 8 deletions src/main/kotlin/file/SetupApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -1396,22 +1401,37 @@ 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)
}

val REPO_REGEX = Regex("(https?://)([\\w.@-]+)(/)([\\w,-_]+)/([\\w,-_]+)(.git)?((/)?)")

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)
Comment thread
Frotty marked this conversation as resolved.
}
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}")
Expand All @@ -1422,6 +1442,7 @@ object SetupApp {
try {
val result = Git.lsRemoteRepository()
.setRemote(resolvedName.first)
.setCredentialsProvider(GitCredentialProvider)
.call()
if (!result.isEmpty()) {
Log.print("valid!\n")
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/wbschema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading