UpdateCommand.kt
package com.depanalyzer.cli
import com.depanalyzer.core.ProjectAnalyzer
import com.depanalyzer.parser.ProjectType
import com.depanalyzer.repository.OssIndexClient
import com.depanalyzer.report.ReportGenerator
import com.depanalyzer.telemetry.TelemetryClient
import com.depanalyzer.telemetry.TelemetryEvent
import com.depanalyzer.update.*
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.optional
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.multiple
import com.github.ajalt.clikt.parameters.types.path
import com.github.ajalt.mordant.animation.animation
import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.input.MouseTracking
import com.github.ajalt.mordant.input.enterRawMode
import com.github.ajalt.mordant.input.isCtrlC
import com.github.ajalt.mordant.rendering.AnsiLevel
import com.github.ajalt.mordant.rendering.TextColors.*
import com.github.ajalt.mordant.rendering.TextStyles.bold
import com.github.ajalt.mordant.rendering.TextStyles.dim
import com.github.ajalt.mordant.table.table
import com.github.ajalt.mordant.terminal.Terminal
import com.github.ajalt.mordant.widgets.SelectList
import com.github.ajalt.mordant.widgets.Text
import java.nio.file.Path
import kotlin.io.path.writeText
class Update(
private val plannerFactory: (String?) -> UpdatePlanner = { token ->
AnalyzerUpdatePlanner(
analyzer = ProjectAnalyzer(ossIndexClient = OssIndexClient(token = token))
)
},
private val updaterFactory: (ProjectType) -> BuildFileUpdater = { type ->
when (type) {
ProjectType.MAVEN -> PomBuildFileUpdater()
ProjectType.GRADLE_GROOVY -> GradleGroovyBuildFileUpdater()
ProjectType.GRADLE_KOTLIN -> GradleKotlinBuildFileUpdater()
ProjectType.NPM -> NpmPackageJsonBuildFileUpdater()
ProjectType.PYTHON_POETRY -> PyprojectBuildFileUpdater()
ProjectType.PYTHON_REQUIREMENTS -> RequirementsBuildFileUpdater()
}
},
private val selectionProvider: (Terminal, List<UpdateSuggestion>) -> Set<UpdateSuggestion> = ::defaultSelectionProvider
) : CliktCommand(name = "update") {
override fun help(context: Context): String = "Actualiza dependencias con confirmación interactiva"
private val path: Path? by argument(help = "Ruta al directorio del proyecto (default: directorio actual)")
.path(mustExist = true, canBeFile = false)
.optional()
private val ossToken: String? by option("--oss-token", help = "Token de autenticación para OSS Index API")
private val dynamic: Boolean by option(
"--dynamic",
help = "Fuerza análisis dinámico (más preciso, más lento). Por defecto: análisis estático"
).flag(default = false)
private val dryRun: Boolean by option(
"--dry-run",
help = "Muestra qué cambiaría sin modificar archivos"
).flag(default = false)
private val onlySecurity: Boolean by option(
"--only-security",
help = "Solo sugiere actualizaciones que resuelven CVEs"
).flag(default = false)
private val planOnly: Boolean by option(
"--plan",
help = "Genera un plan JSON sin modificar archivos"
).flag(default = false)
private val applyIds: List<String> by option(
"--apply-id",
help = "Aplica una sugerencia concreta del plan; puede repetirse"
).multiple()
private val outputFile: String? by option(
"--output-file",
help = "Ruta del plan JSON; use '-' para stdout"
)
override fun run() {
trackCommandAndFlagFeatures()
try {
val terminal = if (System.getenv("NO_COLOR") != null) {
Terminal(ansiLevel = AnsiLevel.NONE)
} else {
Terminal(ansiLevel = AnsiLevel.TRUECOLOR)
}
val targetPath = path ?: Path.of(".")
if (planOnly) {
writePlan(targetPath)
return
}
val results = executeUpdate(
targetPath = targetPath,
terminal = terminal,
dryRun = getDryRunFromCli(),
onlySecurity = getOnlySecurityFromCli()
)
val appliedCount = results.count { it.applied }
val omittedCount = results.size - appliedCount
if (getDryRunFromCli()) {
echo("Resumen final (dry-run): simuladas=$appliedCount, omitidas=$omittedCount")
} else {
echo("Resumen final: aplicadas=$appliedCount, omitidas=$omittedCount")
}
} catch (e: Exception) {
TelemetryClient.send(
TelemetryEvent(
eventType = "error",
errorType = e.javaClass.simpleName,
errorMessage = e.message?.take(200)
)
)
throw e
} finally {
TelemetryClient.flush(timeoutMs = 2500L)
}
}
private fun trackCommandAndFlagFeatures() {
TelemetryClient.send(
TelemetryEvent(
eventType = "feature_used",
feature = "update_command"
)
)
if (getDynamicFromCli()) {
TelemetryClient.send(TelemetryEvent(eventType = "feature_used", feature = "flag_dynamic"))
}
if (getDryRunFromCli()) {
TelemetryClient.send(TelemetryEvent(eventType = "feature_used", feature = "flag_dry_run"))
}
if (getOnlySecurityFromCli()) {
TelemetryClient.send(TelemetryEvent(eventType = "feature_used", feature = "flag_only_security"))
}
if (planOnly) {
TelemetryClient.send(TelemetryEvent(eventType = "feature_used", feature = "flag_plan"))
}
if (getApplyIdsFromCli().isNotEmpty()) {
TelemetryClient.send(TelemetryEvent(eventType = "feature_used", feature = "flag_apply_id"))
}
}
internal fun executeUpdate(
targetPath: Path,
terminal: Terminal = Terminal(),
dryRun: Boolean = getDryRunFromCli(),
onlySecurity: Boolean = getOnlySecurityFromCli()
): List<UpdateResult> {
val planner = plannerFactory(getTokenFromCliOrEnv())
val plan = planner.plan(
targetPath,
UpdateAnalysisOptions(dynamic = getDynamicFromCli())
)
val updater = updaterFactory(plan.projectType)
val orderedSuggestions = plan.suggestions.sortedWith(
compareBy<UpdateSuggestion> { if (it.reason == UpdateReason.CVE) 0 else 1 }
.thenBy { it.coordinate }
)
val scopedSuggestions = if (onlySecurity) {
orderedSuggestions.filter { it.reason == UpdateReason.CVE }
} else {
orderedSuggestions
}
if (scopedSuggestions.isEmpty()) {
echo("No se encontraron dependencias desactualizadas para actualizar.")
return emptyList()
}
if (onlySecurity) {
terminal.println("Filtro activo --only-security: se mostrarán solo sugerencias por CVE")
}
if (dryRun) {
terminal.println("Modo --dry-run activo: no se realizarán cambios en archivos")
}
terminal.println(bold("Actualizaciones sugeridas para ${plan.buildFile.name}"))
terminal.println(bold("Formato: dependencia | actual -> nueva | razón"))
val requestedApplyIds = getApplyIdsFromCli()
val selectedSuggestions = if (requestedApplyIds.isNotEmpty()) {
val requestedIds = requestedApplyIds.toSet()
val selected = scopedSuggestions.filter { it.suggestionId in requestedIds }.toSet()
val missingIds = requestedIds - selected.map { it.suggestionId }.toSet()
require(missingIds.isEmpty()) {
"Sugerencias no encontradas o desactualizadas: ${missingIds.joinToString(", ")}"
}
selected
} else {
selectionProvider(terminal, scopedSuggestions)
}
val results = mutableListOf<UpdateResult>()
if (selectedSuggestions.isEmpty()) {
scopedSuggestions.forEach { suggestion ->
results.add(UpdateResult(suggestion, applied = false, note = "no seleccionada"))
}
renderSummary(terminal, results, dryRun)
return results
}
if (!dryRun) {
val backup = BuildFileBackup.ensureBackup(plan.buildFile)
terminal.println("Backup creado: ${backup.name}")
}
for (suggestion in scopedSuggestions) {
if (suggestion in selectedSuggestions) {
if (dryRun) {
results.add(UpdateResult(suggestion, applied = true, note = "dry-run: se aplicaría"))
continue
}
val applied = updater.applyUpdate(plan.buildFile, suggestion)
val note = if (applied) "aplicada" else "sin coincidencia editable"
results.add(UpdateResult(suggestion, applied, note))
} else {
results.add(UpdateResult(suggestion, applied = false, note = "no seleccionada"))
}
}
renderSummary(terminal, results, dryRun)
return results
}
private fun getTokenFromCliOrEnv(): String? {
val cliToken = runCatching { ossToken }.getOrNull()
return (cliToken ?: System.getenv("OSS_INDEX_TOKEN"))
?.trim()
?.takeUnless { it.isBlank() }
}
private fun writePlan(targetPath: Path) {
require(getApplyIdsFromCli().isEmpty()) { "--plan y --apply-id no pueden usarse juntos" }
val plan = plannerFactory(getTokenFromCliOrEnv()).plan(
targetPath,
UpdateAnalysisOptions(dynamic = getDynamicFromCli())
)
val suggestions = if (getOnlySecurityFromCli()) {
plan.suggestions.filter { it.reason == UpdateReason.CVE }
} else {
plan.suggestions
}
val json = ReportGenerator().toJsonUpdatePlan(
projectType = plan.projectType,
buildFile = plan.buildFile.absolutePath,
suggestions = suggestions
)
if (outputFile == "-") {
echo(json)
} else {
val path = outputFile?.let(Path::of) ?: Path.of("dependency-update-plan.json")
path.writeText(json)
echo("Plan JSON exportado a: $path")
}
}
private fun getDynamicFromCli(): Boolean {
return runCatching { dynamic }.getOrDefault(false)
}
private fun getDryRunFromCli(): Boolean {
return runCatching { dryRun }.getOrDefault(false)
}
private fun getOnlySecurityFromCli(): Boolean {
return runCatching { onlySecurity }.getOrDefault(false)
}
private fun getApplyIdsFromCli(): List<String> {
return runCatching { applyIds }.getOrDefault(emptyList())
}
private fun renderSummary(terminal: Terminal, results: List<UpdateResult>, dryRun: Boolean) {
val applied = results.filter { it.applied }
val omitted = results.filterNot { it.applied }
terminal.println()
terminal.println(bold("Resumen de actualizaciones"))
val summaryTable = table {
header { row("Estado", "Cantidad") }
body {
row(if (dryRun) "Simuladas" else "Aplicadas", applied.size.toString())
row("Omitidas", omitted.size.toString())
}
}
terminal.println(summaryTable)
if (applied.isNotEmpty()) {
terminal.println(bold(if (dryRun) "Cambios simulados" else "Cambios aplicados"))
val appliedTable = table {
header { row("Dependencia", "Cambio", "Razón", "Tipo", "Vía") }
body {
applied.forEach { result ->
row(
result.suggestion.coordinate,
"${result.suggestion.currentVersion} -> ${result.suggestion.newVersion}",
result.suggestion.reason.label(),
result.suggestion.targetType.label(),
result.suggestion.viaDirectCoordinate ?: "-"
)
}
}
}
terminal.println(appliedTable)
}
if (omitted.isNotEmpty()) {
terminal.println(bold("Cambios omitidos"))
val omittedTable = table {
header { row("Dependencia", "Cambio", "Razón", "Tipo", "Vía", "Nota") }
body {
omitted.forEach { result ->
row(
result.suggestion.coordinate,
"${result.suggestion.currentVersion} -> ${result.suggestion.newVersion}",
result.suggestion.reason.label(),
result.suggestion.targetType.label(),
result.suggestion.viaDirectCoordinate ?: "-",
result.note
)
}
}
}
terminal.println(omittedTable)
}
}
companion object {
private data class SelectionState(
val items: List<SelectList.Entry>,
val filterText: String = "",
val isFiltering: Boolean = false,
val cursor: Int = 0,
) {
val filteredIndexes: List<Int>
get() {
if (filterText.isBlank()) {
return items.indices.toList()
}
return items.mapIndexedNotNull { index, entry ->
if (entry.title.contains(filterText, ignoreCase = true)) index else null
}
}
}
private fun defaultSelectionProvider(
terminal: Terminal,
suggestions: List<UpdateSuggestion>
): Set<UpdateSuggestion> {
val labels = suggestions.map(::formatSelectionLabel)
val initialState = SelectionState(items = labels.map { SelectList.Entry(it) })
var state = initialState
val animation = terminal.animation<SelectionState> { current ->
val filteredItems = current.filteredIndexes.map { current.items[it] }
val cursorIndex = if (filteredItems.isEmpty()) -1 else current.cursor
SelectList(
entries = filteredItems,
title = Text(
if (current.isFiltering) {
"Buscar: ${current.filterText}"
} else {
"Selecciona dependencias para actualizar"
}
),
cursorIndex = cursorIndex,
styleOnHover = true,
cursorMarker = ">",
selectedMarker = "[x]",
unselectedMarker = "[ ]",
selectedStyle = green + bold,
cursorStyle = cyan + bold,
unselectedTitleStyle = gray,
unselectedMarkerStyle = gray,
captionBottom = Text(
dim("space/x marcar • a todo • A limpiar • j/k o ↑/↓ mover • / o f buscar • enter confirmar • esc/q cancelar")
)
)
}
animation.update(state)
terminal.enterRawMode(MouseTracking.Off).use { rawMode ->
while (true) {
val event = rawMode.readEvent()
if (event !is KeyboardEvent) {
continue
}
if (event.isCtrlC || (!state.isFiltering && keyIs(event, "q"))) {
animation.clear()
return emptySet()
}
if (state.isFiltering && keyIs(event, "escape")) {
state = state.copy(filterText = "", isFiltering = false, cursor = 0)
animation.update(state.ensureCursorBounds())
continue
}
if (!state.isFiltering && keyIs(event, "escape")) {
animation.clear()
return emptySet()
}
if (!event.ctrl && !event.alt && state.isFiltering) {
state = when {
keyIs(event, "enter") -> state.copy(isFiltering = false)
keyIs(event, "backspace") -> state.copy(
filterText = state.filterText.dropLast(1),
cursor = 0
)
event.key.length == 1 -> state.copy(filterText = state.filterText + event.key, cursor = 0)
keyIs(event, "arrowup") || keyIs(event, "k") -> state.moveCursor(-1)
keyIs(event, "arrowdown") || keyIs(event, "j") -> state.moveCursor(1)
else -> state
}
animation.update(state.ensureCursorBounds())
state = state.ensureCursorBounds()
continue
}
state = when {
isFilterTrigger(event) -> state.copy(isFiltering = true, filterText = "", cursor = 0)
!state.isFiltering && keyIs(event, "a") && event.shift -> state.setAllSelected(false)
!state.isFiltering && keyIs(event, "a") -> state.setAllSelected(true)
keyIs(event, "arrowup") || keyIs(event, "k") -> state.moveCursor(-1)
keyIs(event, "arrowdown") || keyIs(event, "j") -> state.moveCursor(1)
keyIs(event, " ") || keyIs(event, "space") || keyIs(event, "spacebar") || keyIs(
event,
"x"
) -> state.toggleCurrent()
keyIs(event, "enter") -> {
val selectedLabels = state.items.filter { it.selected }.map { it.title }.toSet()
animation.clear()
return suggestions.filterIndexed { index, _ -> labels[index] in selectedLabels }.toSet()
}
else -> state
}
state = state.ensureCursorBounds()
animation.update(state)
}
}
}
private fun SelectionState.moveCursor(delta: Int): SelectionState {
if (filteredIndexes.isEmpty()) return copy(cursor = 0)
val newCursor = (cursor + delta).coerceIn(0, filteredIndexes.lastIndex)
return copy(cursor = newCursor)
}
private fun SelectionState.toggleCurrent(): SelectionState {
val indexes = filteredIndexes
if (indexes.isEmpty()) return this
val itemIndex = indexes[cursor]
val updatedItems = items.toMutableList()
val current = updatedItems[itemIndex]
updatedItems[itemIndex] = current.copy(selected = !current.selected)
return copy(items = updatedItems)
}
private fun SelectionState.ensureCursorBounds(): SelectionState {
val indexes = filteredIndexes
if (indexes.isEmpty()) {
return copy(cursor = 0)
}
return copy(cursor = cursor.coerceIn(0, indexes.lastIndex))
}
private fun SelectionState.setAllSelected(selected: Boolean): SelectionState {
return copy(items = items.map { it.copy(selected = selected) })
}
private fun formatSelectionLabel(suggestion: UpdateSuggestion): String {
val base =
"${suggestion.coordinate} | ${suggestion.currentVersion} -> ${suggestion.newVersion} | ${suggestion.reason.label()} | ${suggestion.targetType.label()}"
return suggestion.viaDirectCoordinate?.let { "$base | via $it" } ?: base
}
private fun isFilterTrigger(event: KeyboardEvent): Boolean {
if (event.ctrl || event.alt) return false
val key = event.key.lowercase()
return key in setOf("/", "slash", "?", "numpaddivide", "divide", "f", "&") ||
(event.shift && key in setOf("7", "digit7"))
}
private fun keyIs(event: KeyboardEvent, expected: String): Boolean {
return event.key.equals(expected, ignoreCase = true)
}
}
}