From 4e79194746091cc81f012e49ad941dfec4b65f68 Mon Sep 17 00:00:00 2001 From: joe Date: Sat, 7 Nov 2026 09:33:26 +0000 Subject: [PATCH] Add Fabric CLI based project creator support - Add run_npm_package finalizer, which runs a package from npm or a git repository in a plugin-maintained npm environment, and warns the user about the code it will run - Add generate_files and replace_in_file finalizers - Add fabric_game_version property type - Bump the template format version to 4, and point the templates submodule to the v4 branch --- .gitmodules | 2 +- .../custom/CreatorTemplateProcessor.kt | 209 ++++--- .../creator/custom/TemplateDescriptor.kt | 4 +- .../custom/finalizers/CreatorFinalizer.kt | 32 ++ .../finalizers/GenerateFilesFinalizer.kt | 88 +++ .../finalizers/ReplaceInFileFinalizer.kt | 109 ++++ .../finalizers/RunNpmPackageFinalizer.kt | 318 +++++++++++ .../creator/custom/npm/NpmEnvironment.kt | 520 ++++++++++++++++++ .../types/FabricGameVersionCreatorProperty.kt | 124 +++++ src/main/resources/META-INF/plugin.xml | 9 + .../messages/MinecraftDevelopment.properties | 25 + 11 files changed, 1351 insertions(+), 89 deletions(-) create mode 100644 src/main/kotlin/creator/custom/finalizers/GenerateFilesFinalizer.kt create mode 100644 src/main/kotlin/creator/custom/finalizers/ReplaceInFileFinalizer.kt create mode 100644 src/main/kotlin/creator/custom/finalizers/RunNpmPackageFinalizer.kt create mode 100644 src/main/kotlin/creator/custom/npm/NpmEnvironment.kt create mode 100644 src/main/kotlin/creator/custom/types/FabricGameVersionCreatorProperty.kt diff --git a/.gitmodules b/.gitmodules index 11d7cef14..da89363c6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "templates"] path = templates - branch = v3 + branch = v4 url = https://github.com/minecraft-dev/templates diff --git a/src/main/kotlin/creator/custom/CreatorTemplateProcessor.kt b/src/main/kotlin/creator/custom/CreatorTemplateProcessor.kt index 14dbf7d80..952c37744 100644 --- a/src/main/kotlin/creator/custom/CreatorTemplateProcessor.kt +++ b/src/main/kotlin/creator/custom/CreatorTemplateProcessor.kt @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -44,6 +44,7 @@ import com.intellij.openapi.observable.properties.PropertyGraph import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.refreshAndFindVirtualFile @@ -119,6 +120,11 @@ class CreatorTemplateProcessor( } } } + + if (!reporter.hasErrors) { + // Always shown, even without properties, as these can include warnings about what the template does + CreatorFinalizer.buildUiAll(this, context, template.descriptor.finalizers.orEmpty()) + } } } @@ -230,55 +236,7 @@ class CreatorTemplateProcessor( val templateProperties = collectTemplateProperties() thisLogger().debug("Template properties: $templateProperties") - val generatedFiles = mutableListOf>() - for (file in template.descriptor.files.orEmpty()) { - if (file.condition != null && - !TemplateEvaluator.condition(templateProperties, file.condition).getOrElse { false } - ) { - continue - } - - val relativeTemplate = TemplateEvaluator.template(templateProperties, file.template).getOrNull() - ?: continue - val relativeDest = TemplateEvaluator.template(templateProperties, file.destination).getOrNull() - ?: continue - - try { - val templateContents = template.loadTemplateContents(relativeTemplate) - ?: continue - - val destPath = projectPath.resolve(relativeDest).toAbsolutePath() - if (!destPath.startsWith(projectPath)) { - // We want to make sure template files aren't 'escaping' the project directory - continue - } - - var fileTemplateProperties = templateProperties - if (file.properties != null) { - fileTemplateProperties = templateProperties.toMutableMap() - fileTemplateProperties.putAll(file.properties) - } - - val processedContent = TemplateEvaluator.template(fileTemplateProperties, templateContents) - .onFailure { t -> - val attachment = Attachment(relativeTemplate, templateContents) - thisLogger().error("Failed evaluate template '$relativeTemplate'", t, attachment) - } - .getOrNull() - ?: continue - - destPath.parent.createDirectories() - destPath.writeText(processedContent) - - generatedFiles.add(file to destPath) - } catch (t: Throwable) { - if (t is ControlFlowException) { - throw t - } - - thisLogger().error("Failed to process template file $file", t) - } - } + val generatedFiles = writeFiles(template, template.descriptor.files.orEmpty(), templateProperties, projectPath) val finalizeAction = suspend { WriteAction.runAndWait { @@ -286,16 +244,12 @@ class CreatorTemplateProcessor( // Apparently a module root is required for the reformat to work setupTempRootModule(project, projectPath) - val genVirtualFiles = generatedFiles - .mapNotNullTo(mutableListOf()) { - it.first to (it.second.refreshAndFindVirtualFile() ?: return@mapNotNullTo null) - } - reformatFiles(project, genVirtualFiles) - openFilesInEditor(project, genVirtualFiles) + reformatAndOpenFiles(project, generatedFiles) } val finalizers = template.descriptor.finalizers if (!finalizers.isNullOrEmpty()) { + context.wizardContext.putUserData(LOADED_TEMPLATE_KEY, template) CreatorFinalizer.executeAll(context.wizardContext, project, finalizers, templateProperties) } } @@ -325,38 +279,121 @@ class CreatorTemplateProcessor( return properties.mapValuesTo(into) { (_, prop) -> prop.get() } } - private fun reformatFiles( - project: Project, - files: MutableList> - ) { - val psiManager = PsiManager.getInstance(project) - val psiFiles = files.asSequence() - .filter { (desc, _) -> desc.reformat != false } - .mapNotNull { (_, file) -> psiManager.findFile(file) } - - val processor = ReformatCodeProcessor(project, psiFiles.toTypedArray(), null, false) - psiFiles.forEach(processor::setDoNotKeepLineBreaks) - - val insightSettings = CodeInsightSettings.getInstance() - val oldSecondReformat = insightSettings.ENABLE_SECOND_REFORMAT - insightSettings.ENABLE_SECOND_REFORMAT = true - try { - processor.run() - } finally { - insightSettings.ENABLE_SECOND_REFORMAT = oldSecondReformat + companion object { + /** + * The template whose files are being generated, so finalizers can read files from it. + */ + val LOADED_TEMPLATE_KEY = Key.create("mcdev.creator.loadedTemplate") + + /** + * Evaluates the given template files and writes them to the project directory. + * + * @return the files that were written, and where they were written to + */ + fun writeFiles( + template: LoadedTemplate, + files: List, + templateProperties: Map, + projectPath: Path, + ): List> { + val generatedFiles = mutableListOf>() + for (file in files) { + if (file.condition != null && + !TemplateEvaluator.condition(templateProperties, file.condition).getOrElse { false } + ) { + continue + } + + val relativeTemplate = TemplateEvaluator.template(templateProperties, file.template).getOrNull() + ?: continue + val relativeDest = TemplateEvaluator.template(templateProperties, file.destination).getOrNull() + ?: continue + + try { + val templateContents = template.loadTemplateContents(relativeTemplate) + ?: continue + + val destPath = projectPath.resolve(relativeDest).toAbsolutePath() + if (!destPath.startsWith(projectPath)) { + // We want to make sure template files aren't 'escaping' the project directory + continue + } + + var fileTemplateProperties = templateProperties + if (file.properties != null) { + fileTemplateProperties = templateProperties.toMutableMap() + fileTemplateProperties.putAll(file.properties) + } + + val processedContent = TemplateEvaluator.template(fileTemplateProperties, templateContents) + .onFailure { t -> + val attachment = Attachment(relativeTemplate, templateContents) + thisLogger().error("Failed evaluate template '$relativeTemplate'", t, attachment) + } + .getOrNull() + ?: continue + + destPath.parent.createDirectories() + destPath.writeText(processedContent) + + generatedFiles.add(file to destPath) + } catch (t: Throwable) { + if (t is ControlFlowException) { + throw t + } + + thisLogger().error("Failed to process template file $file", t) + } + } + return generatedFiles + } + + /** + * Reformats and opens the given generated files, as requested by their [TemplateFile]. Must be called in a + * write action. + */ + fun reformatAndOpenFiles(project: Project, generatedFiles: List>) { + val genVirtualFiles = generatedFiles + .mapNotNullTo(mutableListOf()) { + it.first to (it.second.refreshAndFindVirtualFile() ?: return@mapNotNullTo null) + } + reformatFiles(project, genVirtualFiles) + openFilesInEditor(project, genVirtualFiles) + } + + private fun reformatFiles( + project: Project, + files: MutableList> + ) { + val psiManager = PsiManager.getInstance(project) + val psiFiles = files.asSequence() + .filter { (desc, _) -> desc.reformat != false } + .mapNotNull { (_, file) -> psiManager.findFile(file) } + + val processor = ReformatCodeProcessor(project, psiFiles.toTypedArray(), null, false) + psiFiles.forEach(processor::setDoNotKeepLineBreaks) + + val insightSettings = CodeInsightSettings.getInstance() + val oldSecondReformat = insightSettings.ENABLE_SECOND_REFORMAT + insightSettings.ENABLE_SECOND_REFORMAT = true + try { + processor.run() + } finally { + insightSettings.ENABLE_SECOND_REFORMAT = oldSecondReformat + } } - } - private fun openFilesInEditor( - project: Project, - files: MutableList> - ) { - val fileEditorManager = FileEditorManager.getInstance(project) - val projectView = ProjectView.getInstance(project) - for ((desc, file) in files) { - if (desc.openInEditor == true) { - fileEditorManager.openFile(file, true) - projectView.select(null, file, false) + private fun openFilesInEditor( + project: Project, + files: MutableList> + ) { + val fileEditorManager = FileEditorManager.getInstance(project) + val projectView = ProjectView.getInstance(project) + for ((desc, file) in files) { + if (desc.openInEditor == true) { + fileEditorManager.openFile(file, true) + projectView.select(null, file, false) + } } } } diff --git a/src/main/kotlin/creator/custom/TemplateDescriptor.kt b/src/main/kotlin/creator/custom/TemplateDescriptor.kt index aa1859dd4..a1351815d 100644 --- a/src/main/kotlin/creator/custom/TemplateDescriptor.kt +++ b/src/main/kotlin/creator/custom/TemplateDescriptor.kt @@ -3,7 +3,7 @@ * * https://mcdev.io/ * - * Copyright (C) 2025 minecraft-dev + * Copyright (C) 2026 minecraft-dev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published @@ -40,7 +40,7 @@ data class TemplateDescriptor( companion object { - val SUPPORTED_FORMAT_VERSIONS = intArrayOf(3, 2, 1) + val SUPPORTED_FORMAT_VERSIONS = intArrayOf(4, 3, 2, 1) val LATEST_FORMAT_VERSION = SUPPORTED_FORMAT_VERSIONS[0]; } } diff --git a/src/main/kotlin/creator/custom/finalizers/CreatorFinalizer.kt b/src/main/kotlin/creator/custom/finalizers/CreatorFinalizer.kt index 41adcc871..b2c911135 100644 --- a/src/main/kotlin/creator/custom/finalizers/CreatorFinalizer.kt +++ b/src/main/kotlin/creator/custom/finalizers/CreatorFinalizer.kt @@ -20,6 +20,7 @@ package com.demonwav.mcdev.creator.custom.finalizers +import com.demonwav.mcdev.creator.custom.CreatorContext import com.demonwav.mcdev.creator.custom.TemplateEvaluator import com.demonwav.mcdev.creator.custom.TemplateValidationReporter import com.demonwav.mcdev.creator.custom.TemplateValidationReporterImpl @@ -31,6 +32,7 @@ import com.intellij.openapi.extensions.RequiredElement import com.intellij.openapi.project.Project import com.intellij.openapi.util.KeyedExtensionCollector import com.intellij.serviceContainer.BaseKeyedLazyInstance +import com.intellij.ui.dsl.builder.Panel import com.intellij.util.KeyedLazyInstance import com.intellij.util.xmlb.annotations.Attribute @@ -38,6 +40,13 @@ interface CreatorFinalizer { fun validate(reporter: TemplateValidationReporter, properties: Map) = Unit + /** + * Creates the UI elements to show in the creator when this finalizer is part of the template, such as settings + * the finalizer needs or warnings about what it will do. Identical (as in [equals]) elements created by multiple + * finalizers are only shown once. + */ + fun createUi(properties: Map): List = emptyList() + suspend fun execute( context: WizardContext, project: Project, @@ -83,6 +92,22 @@ interface CreatorFinalizer { } } + fun buildUiAll( + panel: Panel, + context: CreatorContext, + finalizers: List>, + ) { + val uis = finalizers.flatMap { properties -> + val finalizer = (properties["type"] as? String)?.let(COLLECTOR::findSingle) + ?: return@flatMap emptyList() + finalizer.createUi(properties) + } + + for (ui in uis.distinct()) { + ui.buildUi(panel, context) + } + } + suspend fun executeAll( context: WizardContext, project: Project, @@ -112,6 +137,13 @@ interface CreatorFinalizer { } } +/** + * A part of the creator UI contributed by a [CreatorFinalizer]. + */ +interface FinalizerUi { + fun buildUi(panel: Panel, context: CreatorContext) +} + class CreatorFinalizerBean : BaseKeyedLazyInstance(), KeyedLazyInstance { @Attribute("type") diff --git a/src/main/kotlin/creator/custom/finalizers/GenerateFilesFinalizer.kt b/src/main/kotlin/creator/custom/finalizers/GenerateFilesFinalizer.kt new file mode 100644 index 000000000..c62184883 --- /dev/null +++ b/src/main/kotlin/creator/custom/finalizers/GenerateFilesFinalizer.kt @@ -0,0 +1,88 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom.finalizers + +import com.demonwav.mcdev.creator.custom.CreatorTemplateProcessor +import com.demonwav.mcdev.creator.custom.TemplateFile +import com.demonwav.mcdev.creator.custom.TemplateValidationReporter +import com.google.gson.Gson +import com.intellij.ide.util.projectWizard.WizardContext +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project + +/** + * Generates template files at this point of the finalization, instead of before any finalizer runs. This allows + * overwriting files created by other finalizers, such as a project generated by an external tool. + * + * The `files` property has the same format as the template descriptor's `files`. + */ +class GenerateFilesFinalizer : CreatorFinalizer { + + override fun validate(reporter: TemplateValidationReporter, properties: Map) { + val files = properties["files"] as? List<*> + if (files == null) { + reporter.error("Missing list of 'files' to generate") + return + } + + for ((index, file) in files.withIndex()) { + if (file !is Map<*, *>) { + reporter.error("File #$index must be an object") + continue + } + + if (file["template"] !is String) { + reporter.error("File #$index is missing its 'template'") + } + if (file["destination"] !is String) { + reporter.error("File #$index is missing its 'destination'") + } + } + } + + override suspend fun execute( + context: WizardContext, + project: Project, + properties: Map, + templateProperties: Map + ) { + val template = context.getUserData(CreatorTemplateProcessor.LOADED_TEMPLATE_KEY) + if (template == null) { + thisLogger().error("No template available to generate files from") + return + } + + val gson = Gson() + val files = (properties["files"] as List<*>) + .map { gson.fromJson(gson.toJsonTree(it), TemplateFile::class.java) } + val generatedFiles = CreatorTemplateProcessor.writeFiles( + template, + files, + templateProperties, + context.projectDirectory, + ) + + WriteAction.runAndWait { + CreatorTemplateProcessor.reformatAndOpenFiles(project, generatedFiles) + } + } +} diff --git a/src/main/kotlin/creator/custom/finalizers/ReplaceInFileFinalizer.kt b/src/main/kotlin/creator/custom/finalizers/ReplaceInFileFinalizer.kt new file mode 100644 index 000000000..aa903f5d0 --- /dev/null +++ b/src/main/kotlin/creator/custom/finalizers/ReplaceInFileFinalizer.kt @@ -0,0 +1,109 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom.finalizers + +import com.demonwav.mcdev.creator.custom.TemplateEvaluator +import com.demonwav.mcdev.creator.custom.TemplateValidationReporter +import com.intellij.ide.util.projectWizard.WizardContext +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Replaces text in a file of the project, for example to adjust a file created by another finalizer. + * + * - `file`: the path of the file, relative to the project directory, evaluated as a template + * - `find`: the text to find, used as-is + * - `regex`: whether `find` is a regular expression, defaults to `false` + * - `replace`: the replacement text, evaluated as a template + */ +class ReplaceInFileFinalizer : CreatorFinalizer { + + override fun validate(reporter: TemplateValidationReporter, properties: Map) { + if (properties["file"] !is String) { + reporter.error("Missing 'file' to replace text in") + } + + val find = properties["find"] + if (find !is String || find.isEmpty()) { + reporter.error("Missing 'find' text") + } else if (properties["regex"] == true) { + runCatching { Regex(find) }.onFailure { reporter.error("Invalid 'find' regex: ${it.message}") } + } + + if (properties["replace"] !is String) { + reporter.error("Missing 'replace' text") + } + + val regex = properties["regex"] + if (regex != null && regex !is Boolean) { + reporter.error("'regex' must be a boolean") + } + } + + override suspend fun execute( + context: WizardContext, + project: Project, + properties: Map, + templateProperties: Map + ) { + val projectDir = context.projectDirectory + val relativePath = TemplateEvaluator.template(templateProperties, properties["file"] as String).getOrNull() + ?: return + val file = projectDir.resolve(relativePath).toAbsolutePath().normalize() + if (!file.startsWith(projectDir)) { + thisLogger().error("File $relativePath is outside of the project directory") + return + } + + val find = properties["find"] as String + val replace = TemplateEvaluator.template(templateProperties, properties["replace"] as String).getOrNull() + ?: return + + withContext(Dispatchers.IO) { + if (!file.isRegularFile()) { + thisLogger().warn("Could not replace text in $file, it does not exist") + return@withContext + } + + val text = file.readText() + val newText = if (properties["regex"] == true) { + // The replacement is used literally, so it can't accidentally reference groups + Regex(find).replace(text, Regex.escapeReplacement(replace)) + } else { + text.replace(find, replace) + } + + if (newText == text) { + thisLogger().warn("Could not find '$find' in $file") + return@withContext + } + file.writeText(newText) + } + + LocalFileSystem.getInstance().refreshNioFiles(listOf(file)) + } +} diff --git a/src/main/kotlin/creator/custom/finalizers/RunNpmPackageFinalizer.kt b/src/main/kotlin/creator/custom/finalizers/RunNpmPackageFinalizer.kt new file mode 100644 index 000000000..a6d4feb4b --- /dev/null +++ b/src/main/kotlin/creator/custom/finalizers/RunNpmPackageFinalizer.kt @@ -0,0 +1,318 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom.finalizers + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.creator.custom.CreatorContext +import com.demonwav.mcdev.creator.custom.TemplateEvaluator +import com.demonwav.mcdev.creator.custom.TemplateValidationReporter +import com.demonwav.mcdev.creator.custom.npm.NpmEntryPoint +import com.demonwav.mcdev.creator.custom.npm.NpmEnvironment +import com.demonwav.mcdev.creator.custom.npm.NpmPackageInfo +import com.demonwav.mcdev.creator.custom.npm.NpmPackageSource +import com.intellij.icons.AllIcons +import com.intellij.ide.util.projectWizard.WizardContext +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.observable.util.transform +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.COLUMNS_LARGE +import com.intellij.ui.dsl.builder.MAX_LINE_LENGTH_WORD_WRAP +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.util.ui.NamedColorUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Installs or updates an npm package in the plugin-maintained [NpmEnvironment], then runs one of its executables or + * scripts. + * + * The creator warns the user that the package will be run and who published it. + * + * Properties: + * - `package`: the name of an npm package, without any version + * - `version`: optional exact version or dist-tag of the npm package to use, defaults to `latest` + * - `repository`: the URL of a git repository with a package at its root, to use instead of an npm package. It is + * cloned and built from source, so it doesn't need to be published to npm + * - `build`: optional list of scripts from the repository's `package.json` to run once its dependencies are installed, + * in order to build it + * - `command`: the name of the executable to run, it must be provided by the package + * - `script`: a JavaScript file to run with Node.js instead of an executable, relative to the package's root + * - `args`: optional list of arguments, each one is evaluated as a template and dropped if it evaluates to a blank + * string, which allows arguments to be conditional + * - `workingDirectory`: optional path relative to the project directory to run the command in, defaults to the + * project directory + * - `input`: optional text to write to the command's standard input, evaluated as a template + */ +class RunNpmPackageFinalizer : CreatorFinalizer { + + override fun validate( + reporter: TemplateValidationReporter, + properties: Map + ) { + val packageName = properties["package"] + val repository = properties["repository"] + when { + packageName != null && repository != null -> + reporter.error("Only one of 'package' or 'repository' is allowed") + packageName == null && repository == null -> reporter.error("Missing 'package' or 'repository' string") + packageName != null && packageName !is String -> reporter.error("'package' must be a string") + repository != null && repository !is String -> reporter.error("'repository' must be a string") + // It would be passed to git as an option otherwise + repository is String && repository.startsWith("-") -> reporter.error("'repository' must be a git URL") + } + + for (key in listOf("version", "workingDirectory", "input")) { + val value = properties[key] + if (value != null && value !is String) { + reporter.error("'$key' must be a string") + } + } + + if (packageName == null && properties["version"] != null) { + reporter.error("'version' can only be used with 'package'") + } + if (repository == null && properties["build"] != null) { + reporter.error("'build' can only be used with 'repository'") + } + + val command = properties["command"] + val script = properties["script"] + when { + command != null && script != null -> reporter.error("Only one of 'command' or 'script' is allowed") + command == null && script == null -> reporter.error("Missing 'command' or 'script' string") + command != null && command !is String -> reporter.error("'command' must be a string") + script != null && script !is String -> reporter.error("'script' must be a string") + } + + for (key in listOf("args", "build")) { + val value = properties[key] + if (value != null && (value !is List<*> || value.any { it !is String })) { + reporter.error("'$key' must be a list of strings") + } + } + } + + override fun createUi(properties: Map): List = listOf( + NpmExecutableUi, + NpmPackageTrustUi(properties.source, properties.entryPoint), + ) + + override suspend fun execute( + context: WizardContext, + project: Project, + properties: Map, + templateProperties: Map + ) { + val source = properties.source + + @Suppress("UNCHECKED_CAST") + val args = (properties["args"] as? List).orEmpty() + .map { TemplateEvaluator.template(templateProperties, it).getOrThrow() } + .filter { it.isNotBlank() } + + val input = (properties["input"] as? String) + ?.let { TemplateEvaluator.template(templateProperties, it).getOrThrow() } + + val projectDir = context.projectDirectory.toAbsolutePath().normalize() + val workingDirectory = (properties["workingDirectory"] as? String) + ?.let { TemplateEvaluator.template(templateProperties, it).getOrThrow() } + ?.let { projectDir.resolve(it).normalize() } + ?: projectDir + if (!workingDirectory.startsWith(projectDir)) { + // Don't let templates run commands outside the project directory + throw IllegalArgumentException("Working directory $workingDirectory is outside of the project directory") + } + + withBackgroundProgress(project, MCDevBundle("creator.step.npm.run.description", source.displayName)) { + NpmEnvironment.runPackage(project, source, properties.entryPoint, args, workingDirectory, input) + } + + // The command most likely created or modified files, make sure the following finalizers can see them + LocalFileSystem.getInstance().refreshNioFiles(listOf(projectDir), false, true, null) + } + + private val Map.source: NpmPackageSource + get() { + val repository = this["repository"] as? String + ?: return NpmPackageSource.Registry(this["package"] as String, this["version"] as? String ?: "latest") + @Suppress("UNCHECKED_CAST") + return NpmPackageSource.Git(repository, (this["build"] as? List).orEmpty()) + } + + private val Map.entryPoint: NpmEntryPoint + get() = (this["command"] as? String)?.let(NpmEntryPoint::Command) + ?: NpmEntryPoint.Script(this["script"] as String) + + /** + * Lets the user choose which npm installation to use, and prevents the project from being created if none can be + * found. + */ + data object NpmExecutableUi : FinalizerUi { + + override fun buildUi(panel: Panel, context: CreatorContext) { + val pathProperty = context.graph.property(NpmEnvironment.customNpmPath) + val errorProperty = context.graph.property("") + + lateinit var textField: JBTextField + panel.row(MCDevBundle("creator.ui.npm_path.label")) { + val descriptor = FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() + .withTitle(MCDevBundle("creator.ui.npm_path.dialog.title")) + textFieldWithBrowseButton(descriptor) + .align(AlignX.FILL) + .columns(COLUMNS_LARGE) + .bindText(pathProperty) + .comment(MCDevBundle("creator.ui.npm_path.comment")) + .applyToComponent { textField = this.textField as JBTextField } + .validationOnApply { + val path = pathProperty.get() + val error = findError(NpmEnvironment.findNpm(path), path) + ?: return@validationOnApply null + // The wizard only outlines the field, focus it so that its error popup shows up in case the + // user missed the error below it + invokeLater(ModalityState.stateForComponent(textField)) { textField.requestFocusInWindow() } + error(error) + } + } + + panel.row("") { + icon(AllIcons.General.Error) + .gap(RightGap.SMALL) + label("") + .bindText(errorProperty) + .applyToComponent { foreground = NamedColorUtil.getErrorForeground() } + }.visibleIf(errorProperty.transform { it.isNotEmpty() }) + + val scope = context.childScope("NpmExecutableUi") + var job: Job? = null + fun update(path: String) { + job?.cancel() + job = scope.launch(Dispatchers.IO) { + val npm = NpmEnvironment.findNpm(path) + withContext(context.uiContext) { + textField.emptyText.text = if (npm != null && path.isBlank()) { + MCDevBundle("creator.ui.npm_path.detected", npm) + } else { + MCDevBundle("creator.ui.npm_path.auto_detect") + } + + errorProperty.set(findError(npm, path).orEmpty()) + } + } + } + + pathProperty.afterChange { path -> + NpmEnvironment.customNpmPath = path + update(path) + } + update(pathProperty.get()) + } + + private fun findError(npm: String?, path: String): String? = when { + npm != null -> null + path.isBlank() -> MCDevBundle("creator.ui.npm_path.not_found") + else -> MCDevBundle("creator.ui.npm_path.invalid") + } + } + + /** + * Warns the user that the template will run code from a package, and who published that package. + */ + data class NpmPackageTrustUi(val source: NpmPackageSource, val entryPoint: NpmEntryPoint) : FinalizerUi { + + override fun buildUi(panel: Panel, context: CreatorContext) { + panel.row { + icon(AllIcons.General.Warning) + .align(AlignY.TOP) + .gap(RightGap.SMALL) + when (source) { + is NpmPackageSource.Git -> { + val warning = MCDevBundle( + "creator.ui.npm_trust.git_warning", + escape(source.url), + escape(entryPoint.displayName), + ) + text(warning, MAX_LINE_LENGTH_WORD_WRAP) + .align(AlignX.FILL) + } + is NpmPackageSource.Registry -> { + val fetching = MCDevBundle("creator.ui.npm_trust.fetching") + val text = text(createText(source, fetching), MAX_LINE_LENGTH_WORD_WRAP) + .align(AlignX.FILL) + context.childScope("NpmPackageTrustUi").launch(Dispatchers.IO) { + val details = try { + describePublisher(NpmEnvironment.fetchPackageInfo(source)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger() + .info("Failed to fetch information about npm package ${source.name}", e) + MCDevBundle("creator.ui.npm_trust.fetch_failed") + } + + withContext(context.uiContext) { + text.component.text = createText(source, details) + } + } + } + } + } + } + + private fun createText(source: NpmPackageSource.Registry, details: String): String = + MCDevBundle("creator.ui.npm_trust.warning", escape(source.name), escape(entryPoint.displayName), details) + + private fun describePublisher(info: NpmPackageInfo): String { + val parts = mutableListOf() + if (info.publisher != null) { + parts += MCDevBundle( + "creator.ui.npm_trust.publisher", + escape(info.version ?: "?"), + escape(info.publisher), + ) + } + if (info.maintainers.isNotEmpty()) { + parts += MCDevBundle("creator.ui.npm_trust.maintainers", escape(info.maintainers.joinToString(", "))) + } + if (info.repository != null) { + parts += MCDevBundle("creator.ui.npm_trust.repository", escape(info.repository)) + } + return parts.joinToString(" ").ifEmpty { MCDevBundle("creator.ui.npm_trust.fetch_failed") } + } + + // Everything coming from the registry or the template is untrusted, make sure it can't inject any HTML + private fun escape(text: String) = StringUtil.escapeXmlEntities(text) + } +} diff --git a/src/main/kotlin/creator/custom/npm/NpmEnvironment.kt b/src/main/kotlin/creator/custom/npm/NpmEnvironment.kt new file mode 100644 index 000000000..a8faba899 --- /dev/null +++ b/src/main/kotlin/creator/custom/npm/NpmEnvironment.kt @@ -0,0 +1,520 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom.npm + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.update.PluginUtil +import com.intellij.ide.util.PropertiesComponent +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessOutput +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.io.NioFiles +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.io.DigestUtil +import com.intellij.util.io.HttpRequests +import com.intellij.util.io.URLUtil +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.isExecutable +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * A plugin-maintained npm environment. + * + * Packages are installed with `npm install --prefix `, which keeps them in `/node_modules` instead of the + * user's global npm installation, and commands are run with `npm exec --prefix `, which puts the installed + * packages' executables on the PATH. Packages are updated every time they are run. + * + * Packages can also come from git repositories, which don't need to be published to npm. They are cloned once and + * pulled every time they are run, then their dependencies are installed and their build scripts are run whenever the + * pull changed anything, and their files are run directly with Node.js. + */ +object NpmEnvironment { + + private val LOGGER = logger() + private val TIMEOUT = 10.minutes + private val ANSI_ESCAPE = Regex("\u001B\\[[0-9;?]*[ -/]*[@-~]") + + // Serializes all access to the environment, so concurrent project creations don't corrupt node_modules + private val mutex = Mutex() + + private const val CUSTOM_NPM_PATH_KEY = "mcdev.creator.npm.path" + private const val REGISTRY_URL = "https://registry.npmjs.org" + + val directory: Path + get() = PathManager.getSystemDir().resolve("mcdev-npm") + + /** + * The path to the npm executable chosen by the user, or an empty string to look it up in the PATH. + */ + var customNpmPath: String + get() = PropertiesComponent.getInstance().getValue(CUSTOM_NPM_PATH_KEY, "") + set(value) = PropertiesComponent.getInstance().setValue(CUSTOM_NPM_PATH_KEY, value.trim(), "") + + fun findNpm(customPath: String = customNpmPath): String? { + if (customPath.isNotBlank()) { + val file = runCatching { Path.of(customPath.trim()) }.getOrNull() + ?: return null + return file.toAbsolutePath().toString().takeIf { file.isRegularFile() && file.isExecutable() } + } + + val name = if (SystemInfo.isWindows) "npm.cmd" else "npm" + return PathEnvironmentVariableUtil.findInPath(name)?.absolutePath + } + + /** + * Fetches information about who published the given version (or dist-tag) of a package from the npm registry. + */ + fun fetchPackageInfo(source: NpmPackageSource.Registry): NpmPackageInfo { + // Scoped packages need their slash to be escaped + val url = "$REGISTRY_URL/${source.name.replace("/", "%2F")}/${URLUtil.encodeURIComponent(source.version)}" + val text = HttpRequests.request(url) + .userAgent(PluginUtil.useragent) + .readString() + val json = Json.parseToJsonElement(text).jsonObject + + fun JsonElement?.personName(): String? = when (this) { + is JsonObject -> this["name"]?.jsonPrimitive?.contentOrNull + is JsonPrimitive -> contentOrNull?.substringBefore('<')?.trim() + else -> null + } + + val repository = when (val repo = json["repository"]) { + is JsonObject -> repo["url"]?.jsonPrimitive?.contentOrNull + is JsonPrimitive -> repo.contentOrNull + else -> null + } + + return NpmPackageInfo( + version = json["version"]?.jsonPrimitive?.contentOrNull, + publisher = json["_npmUser"].personName(), + maintainers = (json["maintainers"] as? JsonArray).orEmpty().mapNotNull { it.personName() }, + repository = repository?.removePrefix("git+")?.removeSuffix(".git"), + ) + } + + /** + * Installs or updates the given package in the environment, then runs it. + * + * If a package from the registry cannot be updated, for example because the registry can't be reached, the + * previously installed version is used if there is one. + * + * @return `true` on success, otherwise an error notification is shown to the user and `false` is returned + */ + suspend fun runPackage( + project: Project, + source: NpmPackageSource, + entryPoint: NpmEntryPoint, + args: List, + workingDirectory: Path, + input: String?, + ): Boolean { + val npm = findNpmOrNotify(project) ?: return false + return mutex.withLock { + val commandLine = when (source) { + is NpmPackageSource.Registry -> prepareRegistryPackage(project, npm, source, entryPoint) + is NpmPackageSource.Git -> prepareGitPackage(project, npm, source, entryPoint) + } ?: return@withLock false + + commandLine.withParameters(args).withWorkingDirectory(workingDirectory) + val output = run(commandLine, input) + if (!output.isSuccess) { + notifyFailure(project, commandLine, output, NotificationType.ERROR) + return@withLock false + } + true + } + } + + private suspend fun prepareRegistryPackage( + project: Project, + npm: String, + source: NpmPackageSource.Registry, + entryPoint: NpmEntryPoint, + ): GeneralCommandLine? { + val packageDir = directory.resolve("node_modules").resolve(source.name).normalize() + if (!packageDir.startsWith(directory)) { + notifyError(project, MCDevBundle("creator.npm.invalid_package.content", source.name)) + return null + } + + if (!install(project, npm, source, packageDir)) { + return null + } + + return when (entryPoint) { + is NpmEntryPoint.Command -> { + // Make sure the command comes from the package the user was told about, and not some other package + if (entryPoint.name !in findPackageBinaries(packageDir)) { + notifyCommandNotFound(project, entryPoint, source) + return null + } + + // --no prevents npm from looking for the command in the registry if it's missing from the + // environment, which would download whatever package has the same name as the command, into npm's + // shared cache + GeneralCommandLine(npm, "exec", "--prefix", directory.toString(), "--no", "--", entryPoint.name) + } + is NpmEntryPoint.Script -> createNodeCommandLine(project, npm, packageDir, entryPoint.path, source) + } + } + + private suspend fun install( + project: Project, + npm: String, + source: NpmPackageSource.Registry, + packageDir: Path, + ): Boolean { + directory.createDirectories() + val commandLine = GeneralCommandLine( + npm, + "install", + "--prefix", + directory.toString(), + "--no-audit", + "--no-fund", + "${source.name}@${source.version}", + ).withWorkingDirectory(directory) + val output = run(commandLine, null) + if (output.isSuccess) { + return true + } + + if (!packageDir.resolve("package.json").isRegularFile()) { + notifyFailure(project, commandLine, output, NotificationType.ERROR) + return false + } + + // Not being able to update isn't fatal, the user can still create their project with the version they have + notifyFailure(project, commandLine, output, NotificationType.WARNING) + return true + } + + private suspend fun prepareGitPackage( + project: Project, + npm: String, + source: NpmPackageSource.Git, + entryPoint: NpmEntryPoint, + ): GeneralCommandLine? { + val checkout = cloneAndBuild(project, npm, source) ?: return null + val script = when (entryPoint) { + // The package isn't installed in an environment npm exec could use, run its binary directly + is NpmEntryPoint.Command -> findPackageBinaries(checkout)[entryPoint.name] ?: run { + notifyCommandNotFound(project, entryPoint, source) + return null + } + is NpmEntryPoint.Script -> entryPoint.path + } + return createNodeCommandLine(project, npm, checkout, script, source) + } + + /** + * Clones the repository, or pulls it if it was already built, then installs its dependencies and runs its build + * scripts if anything changed since the last build. + * + * If the repository cannot be pulled, for example because the user is offline, the last build is used as is. + * + * @return the directory of the built package, or `null` if it could not be built + */ + private suspend fun cloneAndBuild(project: Project, npm: String, source: NpmPackageSource.Git): Path? { + val checkout = directory.resolve("git").resolve(DigestUtil.sha256Hex(source.url.toByteArray()).take(16)) + // Holds the commit that was last built successfully. It's in .git so that it can't conflict with any pull + val builtMarker = checkout.resolve(".git").resolve("mcdev-built") + val builtCommit = runCatching { builtMarker.readText().trim() }.getOrNull() + + if (builtCommit != null) { + val pull = GeneralCommandLine("git", "pull", "--ff-only").withWorkingDirectory(checkout) + val pullOutput = runGit(project, pull) ?: return null + if (!pullOutput.isSuccess) { + // Not being able to update isn't fatal, the user can still create their project with their last build + notifyFailure(project, pull, pullOutput, NotificationType.WARNING) + return checkout + } + } else { + // Either it was never cloned, or the last clone or build failed, start over in case it left a broken state + runInterruptible(Dispatchers.IO) { NioFiles.deleteRecursively(checkout) } + val clone = GeneralCommandLine("git", "clone", "--depth", "1", "--", source.url, checkout.toString()) + val cloneOutput = runGit(project, clone) ?: return null + if (!cloneOutput.isSuccess) { + notifyFailure(project, clone, cloneOutput, NotificationType.ERROR) + return null + } + } + + val revParse = GeneralCommandLine("git", "rev-parse", "HEAD").withWorkingDirectory(checkout) + val revParseOutput = runGit(project, revParse) ?: return null + if (!revParseOutput.isSuccess) { + notifyFailure(project, revParse, revParseOutput, NotificationType.ERROR) + return null + } + val commit = revParseOutput.stdout.trim() + if (commit == builtCommit) { + return checkout + } + + // Make sure an interrupted or failed build is never mistaken for a successful one + runInterruptible(Dispatchers.IO) { Files.deleteIfExists(builtMarker) } + + // Building usually needs the dev dependencies too, which both commands install + val install = if (checkout.resolve("package-lock.json").isRegularFile()) "ci" else "install" + val commandLines = listOf(GeneralCommandLine(npm, install, "--no-audit", "--no-fund")) + + source.buildScripts.map { GeneralCommandLine(npm, "run", it) } + for (commandLine in commandLines) { + val output = run(commandLine.withWorkingDirectory(checkout), null) + if (!output.isSuccess) { + notifyFailure(project, commandLine, output, NotificationType.ERROR) + return null + } + } + + builtMarker.writeText(commit) + return checkout + } + + /** + * Creates a command line running a JavaScript file of a package with Node.js. + */ + private fun createNodeCommandLine( + project: Project, + npm: String, + packageDir: Path, + script: String, + source: NpmPackageSource, + ): GeneralCommandLine? { + val file = packageDir.resolve(script).normalize() + if (!file.startsWith(packageDir) || !file.isRegularFile()) { + val content = MCDevBundle( + "creator.npm.script_not_found.content", + StringUtil.escapeXmlEntities(script), + StringUtil.escapeXmlEntities(source.displayName), + ) + notifyError(project, content) + return null + } + + val node = findNode(npm) ?: run { + notifyError(project, MCDevBundle("creator.npm.node_not_found.content")) + return null + } + return GeneralCommandLine(node, file.toString()) + } + + private fun findNode(npm: String): String? { + // npm is normally installed alongside the Node.js it comes with + val name = if (SystemInfo.isWindows) "node.exe" else "node" + val sibling = Path.of(npm).resolveSibling(name) + if (sibling.isRegularFile() && sibling.isExecutable()) { + return sibling.toString() + } + return PathEnvironmentVariableUtil.findInPath(name)?.absolutePath + } + + /** + * @return the package's binaries, mapped to their files relative to the package directory + */ + private fun findPackageBinaries(packageDir: Path): Map { + val packageJson = packageDir.resolve("package.json") + if (!packageJson.isRegularFile()) { + return emptyMap() + } + + val json = runCatching { Json.parseToJsonElement(packageJson.readText()).jsonObject } + .onFailure { LOGGER.warn("Failed to read $packageJson", it) } + .getOrNull() + ?: return emptyMap() + return when (val bin = json["bin"]) { + // A single binary is named after the package, without its scope + is JsonPrimitive -> { + val name = json["name"]?.jsonPrimitive?.contentOrNull?.substringAfterLast('/') + ?: return emptyMap() + mapOf(name to bin.content) + } + is JsonObject -> bin.mapNotNull { (name, file) -> (file as? JsonPrimitive)?.let { name to it.content } } + .toMap() + else -> emptyMap() + } + } + + private fun notifyCommandNotFound(project: Project, entryPoint: NpmEntryPoint.Command, source: NpmPackageSource) { + val content = MCDevBundle( + "creator.npm.command_not_in_package.content", + StringUtil.escapeXmlEntities(entryPoint.name), + StringUtil.escapeXmlEntities(source.displayName), + ) + notifyError(project, content) + } + + /** + * @return the output of the git command, or `null` if git could not be started + */ + private suspend fun runGit(project: Project, commandLine: GeneralCommandLine): ProcessOutput? = + try { + run(commandLine, null) + } catch (e: ExecutionException) { + LOGGER.warn("Failed to run ${commandLine.commandLineString}", e) + notifyError(project, MCDevBundle("creator.npm.git_not_found.content")) + null + } + + private suspend fun run(commandLine: GeneralCommandLine, input: String?): ProcessOutput { + LOGGER.info("Running ${commandLine.commandLineString}") + return runInterruptible(Dispatchers.IO) { + val handler = CapturingProcessHandler(commandLine) + handler.processInput.use { stdin -> + if (input != null) { + stdin.write(input.toByteArray(commandLine.charset)) + } + } + handler.runProcess(TIMEOUT.inWholeMilliseconds.toInt()) + } + } + + private val ProcessOutput.isSuccess: Boolean + get() = exitCode == 0 && !isTimeout && !isCancelled + + private fun notifyFailure( + project: Project, + commandLine: GeneralCommandLine, + output: ProcessOutput, + type: NotificationType, + ) { + LOGGER.warn( + "Command ${commandLine.commandLineString} failed with exit code ${output.exitCode}" + + " (timeout = ${output.isTimeout})\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}" + ) + + val details = output.stderr.ifBlank { output.stdout } + .replace(ANSI_ESCAPE, "") + .lines() + .filter { it.isNotBlank() } + .takeLast(10) + .joinToString("
") { StringUtil.escapeXmlEntities(it) } + val content = MCDevBundle( + "creator.npm.command_failed.content", + StringUtil.escapeXmlEntities(commandLine.commandLineString), + output.exitCode, + details, + ) + notifyProblem(project, content, type) + } + + private fun notifyProblem(project: Project, content: String, type: NotificationType) { + if (type == NotificationType.ERROR) { + notifyError(project, content) + } else { + notify(project, MCDevBundle("creator.npm.update_failed.title"), content, type) + } + } + + private fun findNpmOrNotify(project: Project): String? { + val npm = findNpm() + if (npm == null) { + notifyError(project, MCDevBundle("creator.npm.not_found.content")) + } + return npm + } + + private fun notifyError(project: Project, content: String) = + notify(project, MCDevBundle("creator.npm.error.title"), content, NotificationType.ERROR) + + private fun notify(project: Project, title: String, content: String, type: NotificationType) { + NotificationGroupManager.getInstance().getNotificationGroup("Minecraft project creator") + .createNotification(title, content, type) + .notify(project) + } +} + +/** + * Where a package comes from. + */ +sealed interface NpmPackageSource { + val displayName: String + + /** + * A package published to the npm registry. + * + * @param version an exact version or dist-tag + */ + data class Registry(val name: String, val version: String) : NpmPackageSource { + override val displayName get() = name + } + + /** + * A package at the root of a git repository, which is cloned and built from source. + * + * @param buildScripts the scripts from its `package.json` to run once its dependencies are installed + */ + data class Git(val url: String, val buildScripts: List) : NpmPackageSource { + override val displayName get() = url + } +} + +/** + * What to run from a package. + */ +sealed interface NpmEntryPoint { + val displayName: String + + /** + * One of the executables declared in the package's `bin`. + */ + data class Command(val name: String) : NpmEntryPoint { + override val displayName get() = name + } + + /** + * A JavaScript file of the package, relative to its root, run with Node.js. + */ + data class Script(val path: String) : NpmEntryPoint { + override val displayName get() = path + } +} + +data class NpmPackageInfo( + val version: String?, + val publisher: String?, + val maintainers: List, + val repository: String?, +) diff --git a/src/main/kotlin/creator/custom/types/FabricGameVersionCreatorProperty.kt b/src/main/kotlin/creator/custom/types/FabricGameVersionCreatorProperty.kt new file mode 100644 index 000000000..3c33e2839 --- /dev/null +++ b/src/main/kotlin/creator/custom/types/FabricGameVersionCreatorProperty.kt @@ -0,0 +1,124 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2026 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom.types + +import com.demonwav.mcdev.asset.MCDevBundle +import com.demonwav.mcdev.creator.custom.BuiltinValidations +import com.demonwav.mcdev.creator.custom.CreatorContext +import com.demonwav.mcdev.creator.custom.TemplatePropertyDescriptor +import com.demonwav.mcdev.creator.custom.TemplateValidationReporter +import com.demonwav.mcdev.platform.fabric.util.FabricVersions +import com.demonwav.mcdev.util.MinecraftVersions +import com.demonwav.mcdev.util.SemanticVersion +import com.intellij.ui.ComboboxSpeedSearch +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.util.ui.AsyncProcessIcon +import javax.swing.DefaultComboBoxModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A Minecraft version supported by Fabric, without any of the other Fabric versions [FabricVersionsCreatorProperty] + * provides. Useful for templates delegating the rest of the version selection to an external tool, like Fabric CLI. + * + * Only stable versions are offered, plus the latest snapshot if it's newer than every stable version. The latest stable + * version is selected by default. + */ +class FabricGameVersionCreatorProperty( + descriptor: TemplatePropertyDescriptor, + context: CreatorContext +) : SemanticVersionCreatorProperty(descriptor, context) { + + private val loadingVersionsProperty = graph.property(true) + private val versionsModel = DefaultComboBoxModel() + + override fun buildUi(panel: Panel) { + panel.row(descriptor.translatedLabel) { + comboBox(versionsModel) + .bindItem(graphProperty) + .validationOnInput(BuiltinValidations.nonEmptyVersion) + .validationOnApply(BuiltinValidations.nonEmptyVersion) + .enabled(descriptor.editable != false) + .also { ComboboxSpeedSearch.installOn(it.component) } + + cell(AsyncProcessIcon(makeStorageKey("progress"))) + .visibleIf(loadingVersionsProperty) + label(MCDevBundle("creator.ui.versions_download.label")) + .visibleIf(loadingVersionsProperty) + }.propertyVisibility() + } + + override fun setupProperty(reporter: TemplateValidationReporter) { + super.setupProperty(reporter) + + val scope = context.childScope("FabricGameVersionCreatorProperty") + scope.launch(Dispatchers.Default) { + val versions = getGameVersions() + withContext(context.uiContext) { + versionsModel.removeAllElements() + versionsModel.addAll(versions.all) + if (graphProperty.get() !in versions.all) { + graphProperty.set(versions.latestStable ?: SemanticVersion(emptyList())) + } + // Clearing the model also cleared its selection, which won't be restored by the binding if the + // property kept its (remembered) value + versionsModel.selectedItem = graphProperty.get() + loadingVersionsProperty.set(false) + } + } + } + + companion object { + private var gameVersions: GameVersions? = null + + private suspend fun getGameVersions(): GameVersions { + gameVersions?.let { return it } + + val fabricVersions = FabricVersions.downloadData() + ?: return GameVersions(emptyList(), null) + val stableVersions = fabricVersions.game.asSequence() + .filter { it.stable } + .mapNotNull { SemanticVersion.tryParse(it.version) } + // Earlier versions aren't supported by Fabric's project generator + .filter { it >= MinecraftVersions.MC1_14_4 } + .distinct() + .toList() + + // Versions are listed newest first, so the latest snapshot is only first if no stable version replaced it + val latestSnapshot = fabricVersions.game.firstOrNull() + ?.takeUnless { it.stable } + ?.let { SemanticVersion.tryParse(it.version) } + return GameVersions(listOfNotNull(latestSnapshot) + stableVersions, stableVersions.firstOrNull()) + .also { gameVersions = it } + } + } + + private data class GameVersions(val all: List, val latestStable: SemanticVersion?) + + class Factory : CreatorPropertyFactory { + override fun create( + descriptor: TemplatePropertyDescriptor, + context: CreatorContext + ): CreatorProperty<*> = FabricGameVersionCreatorProperty(descriptor, context) + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index bce7b67e5..9e94a9ed4 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -200,6 +200,9 @@ + @@ -253,6 +256,12 @@ type="run_gradle_tasks"/> + + + diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index bcfbd6468..e3c9242b9 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -150,6 +150,31 @@ creator.step.maven.import_maven.description=Importing Maven project creator.step.reformat.description=Reformatting files +creator.ui.npm_path.label=npm Executable\: +creator.ui.npm_path.comment=Leave empty to use the npm found in the PATH. +creator.ui.npm_path.dialog.title=Select npm Executable +creator.ui.npm_path.auto_detect=Auto-detect from PATH +creator.ui.npm_path.detected=Auto-detected: {0} +creator.ui.npm_path.not_found=npm could not be found in the PATH. Install Node.js or select the npm executable, or the project cannot be created. +creator.ui.npm_path.invalid=The selected npm executable does not exist or cannot be executed. +creator.ui.npm_trust.warning=This template will download the npm package {0} and run {1} from it, along with any install scripts it has, on your computer. {2}
Only create this project if you trust the publisher of this package. +creator.ui.npm_trust.git_warning=This template will clone the git repository {0}, build it and run {1} from it, along with any install and build scripts it and its dependencies have, on your computer.
Only create this project if you trust the owners of this repository. +creator.ui.npm_trust.fetching=Fetching publisher information... +creator.ui.npm_trust.fetch_failed=Its publisher could not be determined. +creator.ui.npm_trust.publisher=Version {0} was published by {1}. +creator.ui.npm_trust.maintainers=Maintainers: {0}. +creator.ui.npm_trust.repository=Source repository: {0}. +creator.step.npm.run.description=Running npm package {0} +creator.npm.error.title=Project creation failed +creator.npm.update_failed.title=Could not update package, using the installed version +creator.npm.not_found.content=This template requires npm, but it could not be found on the PATH. Please install Node.js (https://nodejs.org/) and try again. +creator.npm.command_not_in_package.content=Command {0} is not provided by package {1}. +creator.npm.invalid_package.content={0} is not a valid npm package name. +creator.npm.script_not_found.content=Script {0} does not exist in package {1}. +creator.npm.node_not_found.content=This template requires Node.js, but it could not be found next to npm or on the PATH. Please install Node.js (https://nodejs.org/) and try again. +creator.npm.git_not_found.content=This template requires git, but it could not be found on the PATH. Please install git (https://git-scm.com/) and try again. +creator.npm.command_failed.content=Command {0} failed with exit code {1}:
{2} + creator.validation.custom.path_not_a_directory=Path is not a directory creator.validation.custom.path_not_a_file=Path is not a file