blob: ed6eec560faa1c72e8ca5cd798e6e4efc742b54a [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nlpcraft.model.tools.cmdline
import com.google.common.base.CaseFormat
import org.apache.commons.io.IOUtils
import org.apache.commons.io.input.{ReversedLinesFileReader, Tailer, TailerListenerAdapter}
import org.apache.commons.lang3.SystemUtils
import org.apache.commons.lang3.time.DurationFormatUtils
import org.apache.http.HttpResponse
import org.apache.http.client.ResponseHandler
import org.apache.http.client.methods.{HttpGet, HttpPost}
import org.apache.http.client.utils.URIBuilder
import org.apache.http.entity.StringEntity
import org.apache.http.impl.client.HttpClients
import org.apache.http.util.EntityUtils
import org.apache.nlpcraft.common._
import org.apache.nlpcraft.common.ansi.NCAnsi._
import org.apache.nlpcraft.common.ansi.{NCAnsi, NCAnsiProgressBar, NCAnsiSpinner}
import org.apache.nlpcraft.common.ascii.NCAsciiTable
import org.apache.nlpcraft.common.module.NCModule
import org.apache.nlpcraft.model.tools.cmdline.NCCliCommands._
import org.apache.nlpcraft.model.tools.cmdline.NCCliRestSpec._
import org.apache.nlpcraft.model.tools.test.NCTestAutoModelValidator
import org.jline.reader._
import org.jline.reader.impl.DefaultParser
import org.jline.reader.impl.DefaultParser.Bracket
import org.jline.reader.impl.history.DefaultHistory
import org.jline.terminal.{Terminal, TerminalBuilder}
import org.jline.utils.AttributedString
import org.jline.utils.InfoCmp.Capability
import java.io._
import java.lang.ProcessBuilder.Redirect
import java.nio.charset.StandardCharsets
import java.text.DateFormat
import java.util.Date
import java.util.regex.Pattern
import java.util.zip.ZipInputStream
import javax.lang.model.SourceVersion
import javax.net.ssl.SSLException
import scala.collection.mutable
import scala.compat.java8.OptionConverters._
import scala.jdk.CollectionConverters.{BufferHasAsJava, CollectionHasAsScala, SeqHasAsJava}
import scala.util.{Try, Using}
import scala.util.control.Breaks.{break, breakable}
import scala.util.control.Exception.ignoring
/**
* NLPCraft CLI.
*/
object NCCli extends NCCliBase {
var exitStatus = 0
var term: Terminal = _
U.ensureHomeDir()
NCModule.setModule(NCModule.CLI)
// Project templates for 'gen-project' command.
private lazy val PRJ_TEMPLATES: Map[String, Seq[String]] = {
val m = mutable.HashMap.empty[String, Seq[String]]
try
Using.resource(new ZipInputStream(U.getStream("cli/templates.zip"))) { zis =>
var entry = zis.getNextEntry
while (entry != null) {
val buf = new StringWriter
IOUtils.copy(zis, buf, StandardCharsets.UTF_8)
m += entry.getName -> buf.toString.split("\n").toIndexedSeq
entry = zis.getNextEntry
}
}
catch {
case e: IOException => throw new NCE(s"Failed to read templates", e)
}
m.toMap
}
case class HttpRestResponse(
code: Int,
data: String
)
case class ReplState(
var isServerOnline: Boolean = false,
var isProbeOnline: Boolean = false,
var accessToken: Option[String] = None, // Access token obtain with 'userEmail'.
var userEmail: Option[String] = None, // Email of the user with 'accessToken'.
var serverLog: Option[File] = None,
var probeLog: Option[File] = None,
var probes: List[Probe] = Nil, // List of connected probes.
var lastStartProbeArgs: Option[Seq[Argument]] = None,
var lastTestModelArgs: Option[Seq[Argument]] = None
) {
/**
* Resets server sub-state.
*/
def resetServer(): Unit = {
isServerOnline = false
accessToken = None
userEmail = None
serverLog = None
probes = Nil
}
/**
* Resets probe sub-state.
*/
def resetProbe(): Unit = {
isProbeOnline = false
probeLog = None
}
}
private val state = ReplState()
private final val NO_LOGO_CMD = CMDS.find(_.name == "no-logo").get
private final val NO_ANSI_CMD = CMDS.find(_.name == "no-ansi").get
private final val START_PRB_CMD = CMDS.find(_.name == "start-probe").get
private final val STOP_PRB_CMD = CMDS.find(_.name == "stop-probe").get
private final val STOP_SRV_CMD = CMDS.find(_.name == "stop-server").get
private final val TEST_MDL_CMD = CMDS.find(_.name == "test-model").get
private final val ANSI_CMD = CMDS.find(_.name == "ansi").get
private final val QUIT_CMD = CMDS.find(_.name == "quit").get
private final val HELP_CMD = CMDS.find(_.name == "help").get
private final val REST_CMD = CMDS.find(_.name == "rest").get
private final val CALL_CMD = CMDS.find(_.name == "call").get
private final val ASK_CMD = CMDS.find(_.name == "ask").get
private final val MODEL_SUGSYN_CMD = CMDS.find(_.name == "model-sugsyn").get
private final val MODEL_SYNS_CMD = CMDS.find(_.name == "model-syns").get
private final val MODEL_INFO_CMD = CMDS.find(_.name == "model-info").get
/**
* @param cmd
* @param args
* @param id
* @param dflt
*/
@throws[MissingParameter]
private def getParam(cmd: Command, args: Seq[Argument], id: String, dflt: String = null): String =
args.find(_.parameter.id == id).flatMap(_.value) match {
case Some(v) => v
case None =>
if (dflt == null)
throw MissingParameter(cmd, id)
dflt
}
/**
*
* @param cmd
* @param args
* @param id
* @param dflt
* @return
*/
@throws[InvalidParameter]
private def getIntParam(cmd: Command, args: Seq[Argument], id: String, dflt: Int): Int = {
getParamOpt(args, id) match {
case Some(num) =>
try
Integer.parseInt(num)
catch {
case _: Exception => throw InvalidParameter(cmd, id)
}
case None => dflt // Default.
}
}
/**
*
* @param cmd
* @param args
* @param id
* @param dflt
* @return
*/
@throws[InvalidParameter]
private def getDoubleParam(cmd: Command, args: Seq[Argument], id: String, dflt: Double): Double = {
getParamOpt(args, id) match {
case Some(num) =>
try
java.lang.Double.parseDouble(num)
catch {
case _: Exception => throw InvalidParameter(cmd, id)
}
case None => dflt // Default.
}
}
/**
* @param args
* @param id
*/
private def getParamOpt(args: Seq[Argument], id: String): Option[String] =
args.find(_.parameter.id == id).flatMap(_.value)
/**
* @param cmd
* @param args
* @param id
*/
private def isParam(cmd: Command, args: Seq[Argument], id: String): Boolean =
args.exists(_.parameter.id == id)
/**
* @param args
* @param id
*/
private def getParamOrNull(args: Seq[Argument], id: String): String =
args.find(_.parameter.id == id) match {
case Some(arg) => U.trimQuotes(arg.value.get)
case None => null
}
/**
* @param args
* @param id
*/
private def getParams(args: Seq[Argument], id: String): Seq[String] =
args.filter(_.parameter.id == id).map(arg => U.trimQuotes(arg.value.getOrElse("")))
/**
*
* @param args
* @return
*/
private def getModelsParams(args: Seq[Argument]): String =
U.splitTrimFilter(getParams( args, "models").mkString(","), ",").mkString(",")
/**
*
* @param args
* @return
*/
private def getCpParams(args: Seq[Argument]): String =
U.splitTrimFilter(
getParams( args, "cp").map(cp => normalizeCp(U.trimQuotes(cp))).mkString(CP_SEP),
CP_SEP
)
.mkString(CP_SEP)
/**
*
* @param args
* @param id
* @return
*/
private def getFlagParam(args: Seq[Argument], id: String, dflt: Boolean): Boolean =
args.find(_.parameter.id == id) match {
case Some(_) => true
case None => dflt
}
/**
*
* @param cmd
* @param args
* @param id
* @return
*/
private def getPathParam(cmd: Command, args: Seq[Argument], id: String, dflt: String = null): String = {
def makePath(p: String): String = {
val normPath = replacePathTilda(U.trimQuotes(p))
checkFilePath(normPath)
normPath
}
getParamOpt(args, id) match {
case Some(path) => makePath(path)
case None => if (dflt == null) null else makePath(dflt)
}
}
/**
*
* @param path
*/
private def replacePathTilda(path: String): String = {
require(path != null)
if (path.nonEmpty && path.head == '~') new File(SystemUtils.getUserHome, path.tail).getAbsolutePath else path
}
/**
*
* @param path
*/
private def checkFilePath(path: String): Unit = {
val file = new File(path)
if (!file.exists() || !file.isFile)
throw new IllegalArgumentException(s"File not found: ${c(file.getAbsolutePath)}")
}
/**
* Handles tilda and checks that every component of the given class path exists relative to the current user working
* directory of this process.
*
* @param cp Classpath to normalize.
* @return
*/
private def normalizeCp(cp: String): String =
U.splitTrimFilter(cp, CP_WIN_NIX_SEPS_REGEX).map(replacePathTilda).map(path => {
val normPath = replacePathTilda(path)
if (!normPath.contains("*") && !new File(normPath).exists())
throw new IllegalStateException(s"Classpath not found: ${c(path)}")
else
normPath
})
.mkString(CP_SEP)
/**
*
*/
private def cleanUpTempFiles(): Unit = {
val tstamp = U.now() - 1000 * 60 * 60 * 24 * 2 // 2 days ago.
val files = new File(SystemUtils.getUserHome, NLPCRAFT_LOC_DIR).listFiles()
if (files != null)
for (file <- files)
if (file.lastModified() < tstamp) {
val name = file.getName
if (name.startsWith("server_log") || name.startsWith("server_log") || name.startsWith(".pid_"))
file.delete()
}
}
/**
*
* @param endpoint
* @return
*/
private def restHealth(endpoint: String): Int =
httpGet(endpoint, "health", mkHttpHandler(_.getStatusLine.getStatusCode))
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdRest(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val restPath = getParam(cmd, args, "path") // REST call path (NOT a file system path).
val json = U.trimQuotes(getParam(cmd, args, "json"))
httpRest(cmd, restPath, json)
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not running from REPL.
*/
private [cmdline] def cmdStartServer(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val cfgPath = getPathParam(cmd, args, "config")
val igniteCfgPath = getPathParam(cmd, args, "igniteConfig")
val noWait = getFlagParam(args, "noWait", dflt = false)
val timeoutMins = getIntParam(cmd, args, "timeoutMins", 2)
val jvmOpts = getParamOpt(args, "jvmopts") match {
case Some(opts) => U.splitTrimFilter(U.trimQuotes(opts), " ")
case None => Seq("-ea", "-Xms2048m", "-XX:+UseG1GC")
}
// Ensure that there isn't another local server running.
loadServerBeacon() match {
case Some(b) => throw new IllegalStateException(
s"Existing server (pid ${c(b.pid)}) detected. " +
s"Use ${c("'stop-server'")} command to stop it, if necessary."
)
case None => ()
}
val logTstamp = U.now()
// Server log redirect.
val output = new File(SystemUtils.getUserHome, s".nlpcraft/server_log_$logTstamp.txt")
// Store in REPL state right away.
state.serverLog = Some(output)
val srvArgs = mutable.ArrayBuffer.empty[String]
srvArgs += JAVA
JVM_OPTS_RT_WARNS.foreach(srvArgs += _)
srvArgs ++= jvmOpts
srvArgs += "-DNLPCRAFT_ANSI_COLOR_DISABLED=true" // No ANSI colors for text log output to the file.
srvArgs += "-cp"
srvArgs += JAVA_CP
srvArgs += "org.apache.nlpcraft.NCStart"
srvArgs += "-server"
if (cfgPath != null)
srvArgs += s"-config=$cfgPath"
if (igniteCfgPath != null)
srvArgs += s"-igniteConfig=$igniteCfgPath"
val srvPb = new ProcessBuilder(srvArgs.asJava)
srvPb.directory(new File(USR_WORK_DIR))
srvPb.redirectErrorStream(true)
val bleachPb = new ProcessBuilder(
JAVA,
"-ea",
"-cp",
s"$JAVA_CP",
"org.apache.nlpcraft.model.tools.cmdline.NCCliAnsiBleach"
)
bleachPb.directory(new File(USR_WORK_DIR))
bleachPb.redirectOutput(Redirect.appendTo(output))
try {
logln(s"Server:")
logln(s" ${y("|--")} Log: ${c(output.getAbsolutePath)}")
logln(s" ${y("|--")} Server config: ${if (cfgPath == null) y("<default>") else c(cfgPath)}")
logln(s" ${y("|--")} Ignite config: ${if (igniteCfgPath == null) y("<default>") else c(igniteCfgPath)}")
logln(s" ${y("+--")} Command: \n ${c(srvArgs.mkString("\n "))}")
// Start the 'server | bleach > server log output' process pipeline.
val procs = ProcessBuilder.startPipeline(Seq(srvPb, bleachPb).asJava)
val srvPid = procs.get(0).pid()
// Store mapping file between PID and timestamp (once we have server PID).
// Note that the same timestamp is used in server log file.
ignoring(classOf[IOException]) {
new File(SystemUtils.getUserHome, s".nlpcraft/.pid_${srvPid}_tstamp_$logTstamp").createNewFile()
}
/**
*
*/
def showTip(): Unit = {
val tbl = new NCAsciiTable()
tbl += (s"${g("stop-server")}", "Stop the server.")
tbl += (s"${g("info-server")}", "Get server information.")
tbl += (s"${g("ping-server")}", "Ping the server.")
tbl += (s"${g("tail-server")}", "Tail the server log.")
tbl += (s"${g("info")}", "Get server & probe information.")
logln(s"Handy commands:\n${tbl.toString}")
}
if (noWait) {
logln(s"Server is starting...")
showTip()
}
else {
val progressBar = new NCAnsiProgressBar(
term.writer(),
NUM_SRV_SERVICES,
15,
true,
// ANSI is NOT disabled & we ARE NOT running from IDEA or Eclipse...
NCAnsi.isEnabled && IS_SCRIPT
)
log(s"Server is starting ")
progressBar.start()
// Tick progress bar "almost" right away to indicate the progress start.
new Thread(() => {
Thread.sleep(1.secs)
progressBar.ticked()
})
.start()
val tailer = Tailer.create(
state.serverLog.get,
new TailerListenerAdapter {
override def handle(line: String): Unit = {
if (TAILER_PTRN.matcher(line).matches())
progressBar.ticked()
}
},
500.ms
)
var beacon: NCCliServerBeacon = null
var online = false
val endOfWait = U.now() + timeoutMins.mins
while (U.now() < endOfWait && !online && ProcessHandle.of(srvPid).isPresent) {
if (progressBar.completed) {
// First, load the beacon, if any.
if (beacon == null)
beacon = loadServerBeacon(autoSignIn = true).orNull
// Once beacon is loaded, ensure that REST endpoint is live.
if (beacon != null)
online = Try(restHealth(beacon.restUrl) == 200).getOrElse(false)
}
if (!online)
Thread.sleep(2.secs) // Check every 2 secs.
}
tailer.stop()
progressBar.stop()
if (!online && U.now() >= endOfWait) // Timed out - attempt to kill the timed out process...
ProcessHandle.of(srvPid).asScala match {
case Some(ph) =>
if (ph.destroy())
error(s"Timed out server process terminated.")
if (beacon != null && beacon.beaconPath != null)
new File(beacon.beaconPath).delete()
case None => ()
}
if (!online) {
logln(r(" [Error]"))
error(s"Server start failed - check full log for errors: ${c(output.getAbsolutePath)}")
error(s"If the problem persists - remove ${c("~/.nlpcraft")} folder and try again.")
tailFile(output.getAbsolutePath, 20)
}
else {
logln(g(" [OK]"))
logServerInfo(beacon)
showTip()
}
}
}
catch {
case e: Exception => error(s"Server failed to start: ${y(e.getLocalizedMessage)}")
}
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not running from REPL.
*/
private [cmdline] def cmdTestModel(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
if (loadServerBeacon().isEmpty)
throw NoLocalServer()
val cfgPath = getPathParam(cmd, args, "config")
val addCp = getCpParams(args)
val mdls = getModelsParams(args)
val jvmOpts = getParamOpt(args, "jvmopts") match {
case Some(opts) => U.splitTrimFilter(U.trimQuotes(opts), " ")
case None => Seq("-ea", "-Xms1024m")
}
val intIds = getParamOpt(args, "intents")
val jvmArgs = mutable.ArrayBuffer.empty[String]
jvmArgs += JAVA
JVM_OPTS_RT_WARNS.foreach(jvmArgs += _)
jvmArgs ++= jvmOpts
if (cfgPath != null)
jvmArgs += s"-D${NCTestAutoModelValidator.PROP_PROBE_CFG}=$cfgPath"
if (mdls.nonEmpty)
jvmArgs += s"-D${NCTestAutoModelValidator.PROP_MODELS}=$mdls"
if (intIds.isDefined)
jvmArgs += s"-D${NCTestAutoModelValidator.PROP_INTENT_IDS}=${intIds.get}"
if (!NCAnsi.isEnabled)
jvmArgs += "-DNLPCRAFT_ANSI_COLOR_DISABLED=true"
jvmArgs += "-cp"
if (addCp.nonEmpty)
jvmArgs += s"$JAVA_CP$CP_SEP$addCp".replace(s"$CP_SEP$CP_SEP", CP_SEP)
else
jvmArgs += JAVA_CP
jvmArgs += "org.apache.nlpcraft.model.tools.test.NCTestAutoModelValidator"
val validatorPb = new ProcessBuilder(jvmArgs.asJava)
validatorPb.directory(new File(USR_WORK_DIR))
validatorPb.inheritIO()
// Capture this mode test arguments (used in restart command).
state.lastTestModelArgs = Some(args)
logln(s"Validator:")
logln(s" ${y("+--")} cmd: \n ${c(jvmArgs.mkString("\n "))}")
try {
validatorPb.start().onExit().get()
}
catch {
case _: InterruptedException => () // Ignore.
case e: Exception =>
error(s"Failed to run model validator: ${y(e.getLocalizedMessage)}")
state.lastTestModelArgs = None
}
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not running from REPL.
*/
private [cmdline] def cmdRestartProbe(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
if (!repl)
error(s"The ${y("'restart-probe'")} command only works in REPL mode - use ${c("'stop-probe'")} and ${c("'start-probe'")} commands instead.")
else if (state.lastStartProbeArgs.isEmpty)
error(s"Probe has not been previously started - see ${c("'start-probe'")} command.")
else {
if (loadProbeBeacon().isDefined)
cmdStopProbe(CMDS.find(_.name == STOP_PRB_CMD.name).get, Seq.empty[Argument], repl)
cmdStartProbe(CMDS.find(_.name == START_PRB_CMD.name).get, state.lastStartProbeArgs.get, repl)
}
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not running from REPL.
*/
private [cmdline] def cmdRetestModel(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
if (!repl)
error(s"The ${y("'retest-model'")} command only works in REPL mode - use ${c("'test-model'")} commands instead.")
else if (state.lastTestModelArgs.isEmpty)
error(s"Model has not been previously tested - see ${c("'test-model'")} command.")
else
cmdTestModel(CMDS.find(_.name == TEST_MDL_CMD.name).get, state.lastTestModelArgs.get, repl)
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not running from REPL.
*/
private [cmdline] def cmdStartProbe(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
// Ensure that there is a local server running since probe
// cannot finish its start unless there's a server to connect to.
if (loadServerBeacon().isEmpty)
throw NoLocalServer()
// Ensure that there isn't another local probe running.
loadProbeBeacon() match {
case Some(b) => throw new IllegalStateException(
s"Existing probe (pid ${c(b.pid)}) detected. " +
s"Use ${c("'stop-probe'")} command to stop it, if necessary."
)
case None => ()
}
val cfgPath = getPathParam(cmd, args, "config")
val noWait = getFlagParam(args, "noWait", dflt = false)
val addCp = getCpParams(args)
val timeoutMins = getIntParam(cmd, args, "timeoutMins", 1)
val mdls = getModelsParams(args)
val jvmOpts = getParamOpt(args, "jvmopts") match {
case Some(opts) => U.splitTrimFilter(U.trimQuotes(opts), " ")
case None => Seq("-ea", "-Xms1024m")
}
val logTstamp = U.now()
// Server log redirect.
val output = new File(SystemUtils.getUserHome, s".nlpcraft/probe_log_$logTstamp.txt")
// Store in REPL state right away.
state.probeLog = Some(output)
val prbArgs = mutable.ArrayBuffer.empty[String]
prbArgs += JAVA
JVM_OPTS_RT_WARNS.foreach(prbArgs += _)
prbArgs ++= jvmOpts
prbArgs += "-DNLPCRAFT_ANSI_COLOR_DISABLED=true" // No ANSI colors for text log output to the file.
if (mdls.nonEmpty)
prbArgs += "-Dconfig.override_with_env_vars=true"
prbArgs += "-cp"
prbArgs += (if (addCp.isEmpty) JAVA_CP else s"$JAVA_CP$CP_SEP$addCp".replace(s"$CP_SEP$CP_SEP", CP_SEP))
prbArgs += "org.apache.nlpcraft.NCStart"
prbArgs += "-probe"
if (cfgPath != null)
prbArgs += s"-config=$cfgPath"
val prbPb = new ProcessBuilder(prbArgs.asJava)
if (mdls.nonEmpty)
// Combine multiple '--mdls' parameter into one comma-separate string.
prbPb.environment().put("CONFIG_FORCE_nlpcraft_probe_models", mdls)
prbPb.directory(new File(USR_WORK_DIR))
prbPb.redirectErrorStream(true)
val bleachPb = new ProcessBuilder(
JAVA,
"-ea",
"-cp",
JAVA_CP,
"org.apache.nlpcraft.model.tools.cmdline.NCCliAnsiBleach"
)
bleachPb.directory(new File(USR_WORK_DIR))
bleachPb.redirectOutput(Redirect.appendTo(output))
try {
logln(s"Probe:")
logln(s" ${y("|--")} Log: ${c(output.getAbsolutePath)}")
logln(s" ${y("|--")} Probe config: ${if (cfgPath == null) y("<default>") else c(cfgPath)}")
if (mdls.nonEmpty)
logln(s" ${y("|--")} Environment variables: \n ${c("CONFIG_FORCE_nlpcraft_probe_models=")}${c(mdls)}")
logln(s" ${y("+--")} Command: \n ${c(prbArgs.mkString("\n "))}")
// Start the 'probe | bleach > probe log output' process pipeline.
val procs = ProcessBuilder.startPipeline(Seq(prbPb, bleachPb).asJava)
val prbPid = procs.get(0).pid()
// Store mapping file between PID and timestamp (once we have probe PID).
// Note that the same timestamp is used in probe log file.
ignoring(classOf[IOException]) {
new File(SystemUtils.getUserHome, s".nlpcraft/.pid_${prbPid}_tstamp_$logTstamp").createNewFile()
}
/**
*
*/
def showTip(): Unit = {
val tbl = new NCAsciiTable()
tbl += (s"${g(STOP_PRB_CMD.name)}", "Stop the probe.")
tbl += (s"${g("restart-probe")}", "Restart the probe.")
tbl += (s"${g("test-model")}", "Auto test the model.")
tbl += (s"${g("tail-probe")}", "Tail the probe log.")
tbl += (s"${g("info")}", "Get server & probe information.")
logln(s"Handy commands:\n${tbl.toString}")
}
if (noWait) {
logln(s"Probe is starting...")
showTip()
}
else {
val progressBar = new NCAnsiProgressBar(
term.writer(),
NUM_PRB_SERVICES,
15,
true,
// ANSI is NOT disabled & we ARE NOT running from IDEA or Eclipse...
NCAnsi.isEnabled && IS_SCRIPT
)
log(s"Probe is starting ")
progressBar.start()
// Tick progress bar "almost" right away to indicate the progress start.
new Thread(() => {
Thread.sleep(1.secs)
progressBar.ticked()
})
.start()
val tailer = Tailer.create(
state.probeLog.get,
new TailerListenerAdapter {
override def handle(line: String): Unit = {
if (TAILER_PTRN.matcher(line).matches())
progressBar.ticked()
}
},
500.ms
)
var beacon: NCCliProbeBeacon = null
val endOfWait = U.now() + timeoutMins.mins
while (U.now() < endOfWait && beacon == null && ProcessHandle.of(prbPid).isPresent) {
if (progressBar.completed) {
// Load the beacon, if any.
if (beacon == null)
beacon = loadProbeBeacon().orNull
}
if (beacon == null)
Thread.sleep(2.secs) // Check every 2 secs.
}
tailer.stop()
progressBar.stop()
if (U.now() >= endOfWait)
ProcessHandle.of(prbPid).asScala match {
case Some(ph) =>
if (ph.destroy())
error(s"Timed out probe process terminated.")
if (beacon != null && beacon.beaconPath != null)
new File(beacon.beaconPath).delete()
case None => ()
}
if (beacon == null) {
logln(r(" [Error]"))
error(s"Probe start failed - check full log for errors: ${c(output.getAbsolutePath)}")
tailFile(output.getAbsolutePath, 20)
}
else {
logln(g(" [OK]"))
logProbeInfo(beacon)
// Reload server beacon to pick up new connected probe.
loadServerBeacon()
// Log all connected probes (including this one).
logConnectedProbes()
// Capture this probe start arguments (used in restart command).
state.lastStartProbeArgs = Some(args)
showTip()
}
}
}
catch {
case e: Exception =>
error(s"Probe failed to start: ${y(e.getLocalizedMessage)}")
state.lastStartProbeArgs = None
}
}
/**
*
* @return
*/
private def getRestEndpointFromBeacon: String =
loadServerBeacon() match {
case Some(beacon) => beacon.restUrl
case None => throw NoLocalServer()
}
/**
*
* @param path
* @param lines
*/
private def tailFile(path: String, lines: Int): Unit =
try
Using.resource(new ReversedLinesFileReader(new File(path), StandardCharsets.UTF_8)) { in =>
var tail = List.empty[String]
breakable {
for (_ <- 0 until lines)
in.readLine() match {
case null => break()
case line => tail ::= line
}
}
val cnt = tail.size
logln(bb(w(s"+----< ${K}Last $cnt log lines $W>---")))
tail.foreach(line => logln(s"${bb(w("| "))} $line"))
logln(bb(w(s"+----< ${K}Last $cnt log lines $W>---")))
}
catch {
case e: Exception => error(s"Failed to read log file: ${e.getLocalizedMessage}")
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdTailServer(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val lines = getIntParam(cmd, args, "lines", 20)
if (lines <= 0)
throw InvalidParameter(cmd, "lines")
loadServerBeacon() match {
case Some(beacon) => tailFile(beacon.logPath, lines)
case None => throw NoLocalServer()
}
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdTailProbe(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val lines = getIntParam(cmd, args, "lines", 20)
if (lines <= 0)
throw InvalidParameter(cmd, "lines")
loadProbeBeacon() match {
case Some(beacon) => tailFile(beacon.logPath, lines)
case None => throw NoLocalProbe()
}
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdPingServer(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val num = getIntParam(cmd, args, "number", 1)
var i = 0
val endpoint = getRestEndpointFromBeacon
while (i < num) {
log(s"(${i + 1} of $num) pinging server at ${b(endpoint)} ")
val spinner = new NCAnsiSpinner(
term.writer(),
// ANSI is NOT disabled & we ARE NOT running from IDEA or Eclipse...
NCAnsi.isEnabled && IS_SCRIPT
)
spinner.start()
val startMs = U.now()
try
restHealth(endpoint) match {
case 200 =>
spinner.stop()
logln(g("OK") + " " + c(s"[${U.now() - startMs}ms]"))
case code: Int =>
spinner.stop()
logln(r("FAIL") + s" [HTTP ${y(code.toString)}]")
}
catch {
case _: SSLException =>
spinner.stop()
logln(r("FAIL") + s" ${y("[SSL error]")}")
case _: IOException =>
spinner.stop()
logln(r("FAIL") + s" ${y("[I/O error]")}")
}
i += 1
if (i < num)
// Pause between pings.
Thread.sleep(500.ms)
}
}
/**
* Loads and returns server beacon file.
*
* @param autoSignIn
* @return
*/
private def loadServerBeacon(autoSignIn: Boolean = false): Option[NCCliServerBeacon] = {
val beaconOpt = try {
val beacon = Using.resource(
new ObjectInputStream(
new FileInputStream(
new File(SystemUtils.getUserHome, SRV_BEACON_PATH)
)
)
) {
_.readObject()
}
.asInstanceOf[NCCliServerBeacon]
ProcessHandle.of(beacon.pid).asScala match {
case Some(ph) =>
beacon.ph = ph
// See if we can detect server log if server was started by this script.
val files = new File(SystemUtils.getUserHome, NLPCRAFT_LOC_DIR).listFiles(new FilenameFilter {
override def accept(dir: File, name: String): Boolean =
name.startsWith(s".pid_$ph")
})
if (files != null && files.size == 1) {
val split = files(0).getName.split("_")
if (split.size == 4) {
val logFile = new File(SystemUtils.getUserHome, s".nlpcraft/server_log_${split(3)}.txt")
if (logFile.exists())
beacon.logPath = logFile.getAbsolutePath
}
}
Some(beacon)
case None =>
// Attempt to clean up stale beacon file.
new File(SystemUtils.getUserHome, SRV_BEACON_PATH).delete()
None
}
}
catch {
case _: Exception => None
}
beaconOpt match {
case Some(beacon) =>
state.isServerOnline = true
try {
// Attempt to sign in with the default account.
if (autoSignIn && state.accessToken.isEmpty)
httpPostResponseJson(
beacon.restUrl,
"signin",
s"""{"email": "$DFLT_USER_EMAIL", "passwd": "$DFLT_USER_PASSWD"}""") match {
case Some(json) =>
Option(Try(U.getJsonStringField(json, "acsTok"))) match {
case Some(tok) =>
state.userEmail = Some(DFLT_USER_EMAIL)
state.accessToken = Some(tok.get)
case None =>
state.userEmail = None
state.accessToken = None
}
case None => ()
}
// Attempt to get all connected probes if successfully signed in prior.
if (state.accessToken.isDefined)
httpPostResponseJson(
beacon.restUrl,
"probe/all",
"{\"acsTok\": \"" + state.accessToken.get + "\"}") match {
case Some(json) => state.probes =
Try(
U.jsonToObject[ProbeAllResponse](json, classOf[ProbeAllResponse]).probes.toList
).getOrElse(Nil)
case None => ()
}
}
catch {
case _: Exception =>
// Reset REPL state.
state.resetServer()
}
case None =>
// Reset REPL state.
state.resetServer()
}
beaconOpt
}
/**
* Loads and returns probe beacon file.
*
* @return
*/
private def loadProbeBeacon(): Option[NCCliProbeBeacon] = {
val beaconOpt = try {
val beacon = Using.resource(
new ObjectInputStream(
new FileInputStream(
new File(SystemUtils.getUserHome, PRB_BEACON_PATH)
)
)
) {
_.readObject()
}
.asInstanceOf[NCCliProbeBeacon]
ProcessHandle.of(beacon.pid).asScala match {
case Some(ph) =>
beacon.ph = ph
// See if we can detect probe log if server was started by this script.
val files = new File(SystemUtils.getUserHome, NLPCRAFT_LOC_DIR).listFiles(new FilenameFilter {
override def accept(dir: File, name: String): Boolean =
name.startsWith(s".pid_$ph")
})
if (files != null && files.size == 1) {
val split = files(0).getName.split("_")
if (split.size == 4) {
val logFile = new File(SystemUtils.getUserHome, s".nlpcraft/probe_log_${split(3)}.txt")
if (logFile.exists())
beacon.logPath = logFile.getAbsolutePath
}
}
Some(beacon)
case None =>
// Attempt to clean up stale beacon file.
new File(SystemUtils.getUserHome, PRB_BEACON_PATH).delete()
None
}
}
catch {
case _: Exception => None
}
beaconOpt match {
case Some(_) => state.isProbeOnline = true
case None => state.resetProbe()
}
beaconOpt
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdQuit(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
if (repl) {
loadServerBeacon() match {
case Some(b) => warn(s"Local server (pid ${c(b.pid)}) is still running.")
case None => ()
}
loadProbeBeacon() match {
case Some(b) => warn(s"Local probe (pid ${c(b.pid)}) is still running.")
case None => ()
}
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdStop(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
cmdStopServer(CMDS.find(_.name == STOP_SRV_CMD.name).get, Seq.empty[Argument], repl)
cmdStopProbe(CMDS.find(_.name == STOP_PRB_CMD.name).get, Seq.empty[Argument], repl)
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdStopServer(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
loadServerBeacon() match {
case Some(beacon) =>
val pid = beacon.pid
if (beacon.ph.destroy()) {
logln(s"Server (pid ${c(pid)}) has been stopped.")
// Attempt to delete beacon file right away.
new File(beacon.beaconPath).delete()
// Reset REPL state right away.
state.resetServer()
}
else
error(s"Failed to stop the local server (pid ${c(pid)}).")
case None => throw NoLocalServer()
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdStopProbe(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
loadProbeBeacon() match {
case Some(beacon) =>
val pid = beacon.pid
if (beacon.ph.destroy()) {
logln(s"Probe (pid ${c(pid)}) has been stopped.")
// Attempt to delete beacon file right away.
new File(beacon.beaconPath).delete()
// Reset REPL state right away.
state.resetProbe()
}
else
error(s"Failed to stop the local probe (pid ${c(pid)}).")
case None => throw NoLocalProbe()
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdNoLogo(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
warn("This command should be used together with other command in a command line mode.")
}
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdNoAnsi(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
NCAnsi.setEnabled(false)
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdAnsi(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
NCAnsi.setEnabled(true)
/**
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdHelp(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
/**
*
*/
def header(): Unit = logln(
s"""|${ansiBold("NAME")}
|$T___${y(s"'$SCRIPT_NAME'")} - command line interface to control NLPCraft.
|
|${ansiBold("USAGE")}
|$T___${y(s"'$SCRIPT_NAME'")} [COMMAND] [PARAMETERS]
|
|${T___}Without any arguments the script starts in REPL mode. The REPL mode supports all
|${T___}the same commands as command line mode. In REPL mode you need to put values that
|${T___}can have spaces (like JSON or file paths) inside of single or double quotes both
|${T___}of which can be escaped using '\\' character.
|
|${ansiBold("COMMANDS")}""".stripMargin
)
/**
*
* @param cmd
* @return
*/
def mkCmdLines(cmd: Command): Seq[String] = {
val lines = mutable.Buffer.empty[String]
if (cmd.desc.isDefined)
lines += cmd.synopsis + " " + cmd.desc.get
else
lines += cmd.synopsis
if (cmd.params.nonEmpty) {
lines += ""
lines += ansiBold("PARAMETERS")
for (param <- cmd.params) {
val line =
if (param.value.isDefined)
T___ + param.names.zip(LazyList.continually(param.value.get)).map(t => s"${t._1}=${t._2}").mkString(", ")
else
s"$T___${param.names.mkString(", ")}"
lines += c(line)
if (param.optional)
lines += s"$T___$T___${g("Optional.")}"
lines += s"$T___$T___${param.desc}"
lines += ""
}
lines.remove(lines.size - 1) // Remove last empty line.
}
if (cmd.examples.nonEmpty) {
lines += ""
lines += ansiBold("EXAMPLES")
for (ex <- cmd.examples) {
lines ++= ex.usage.map(s => y(s"$T___$s"))
lines += s"$T___$T___${ex.desc}"
}
}
lines.toSeq
}
def helpHelp(): Unit =
logln(s"" +
s"\n" +
s"Type ${c("help --cmd=xxx")} to get help for ${c("xxx")} command.\n" +
s"\n" +
s"You can also execute any OS specific command by prepending '${c("$")}' in front of it:\n" +
s" ${y("> $cmd /c dir")}\n" +
s" Runs Windows ${c("dir")} command in a separate shell.\n" +
s" ${y("> $ls -la")}\n" +
s" Runs Linux/Unix ${c("ls -la")} command.\n"
)
if (args.isEmpty) { // Default - show abbreviated help.
if (!repl)
header()
CMDS.groupBy(_.group).toSeq.sortBy(_._1).foreach(entry => {
val grp = entry._1
val grpCmds = entry._2
val tbl = NCAsciiTable().margin(left = if (repl) 0 else 4)
grpCmds.sortBy(_.name).foreach(cmd => tbl +/ (
"" -> s"${g(cmd.name)}",
"align:left, maxWidth:85" -> cmd.synopsis
))
logln(s"\n$B$grp:$RST\n${tbl.toString}")
})
helpHelp()
}
else if (args.size == 1 && args.head.parameter.id == "all") { // Show a full format help for all commands.
if (!repl)
header()
val tbl = NCAsciiTable().margin(left = if (repl) 0 else 4)
CMDS.foreach(cmd =>
tbl +/ (
"" -> s"${g(cmd.name)}",
"align:left, maxWidth:85" -> mkCmdLines(cmd)
)
)
logln(tbl.toString)
helpHelp()
}
else { // Help for individual commands.
var err = false
val seen = mutable.Buffer.empty[String]
val tbl = NCAsciiTable().margin(left = if (repl) 0 else 4)
for (arg <- args) {
val cmdName = arg.value.get
CMDS.find(_.name == cmdName) match {
case Some(c) =>
if (!seen.contains(c.name)) {
tbl +/ (
"" -> s"${g(c.name)}",
"align:left, maxWidth:85" -> mkCmdLines(c)
)
seen += c.name
}
case None =>
err = true
throw UnknownCommand(cmdName)
}
}
if (!err) {
if (!repl)
header()
logln(tbl.toString)
}
}
}
/**
*
* @param beacon
* @return
*/
private def logProbeInfo(beacon: NCCliProbeBeacon): Unit = {
val tbl = new NCAsciiTable
val logPath = if (beacon.logPath != null) g(beacon.logPath) else y("<not available>")
val jarsFolder = if (beacon.jarsFolder != null) g(beacon.jarsFolder) else y("<not set>")
val mdlSeq = beacon.modelsSeq.map(s => m(s.strip))
tbl += ("PID", s"${g(beacon.pid)}")
tbl += ("Version", s"${g(beacon.ver)} released on ${g(beacon.relDate)}")
tbl += ("Probe ID", s"${g(beacon.id)}")
tbl += ("Probe Up-Link", s"${g(beacon.upLink)}")
tbl += ("Probe Down-Link", s"${g(beacon.downLink)}")
tbl += ("JARs Folder", jarsFolder)
tbl += (s"Deployed Models (${mdlSeq.size})", mdlSeq)
tbl += ("Log file", logPath)
tbl += ("Started on", s"${g(DateFormat.getDateTimeInstance.format(new Date(beacon.startMs)))}")
logln(s"Local probe:\n${tbl.toString}")
}
/**
*
* @param beacon
* @return
*/
private def logServerInfo(beacon: NCCliServerBeacon): Unit = {
val tbl = new NCAsciiTable
val logPath = if (beacon.logPath != null) g(beacon.logPath) else y("<not available>")
tbl += ("PID", s"${g(beacon.pid)}")
tbl += ("Version", s"${g(beacon.ver)} released on ${g(beacon.relDate)}")
tbl += ("Database:", "")
tbl += (" URL", s"${g(beacon.dbUrl)}")
tbl += (" Driver", s"${g(beacon.dbDriver)}")
tbl += (" Pool min", s"${g(beacon.dbPoolMin)}")
tbl += (" Pool init", s"${g(beacon.dbPoolInit)}")
tbl += (" Pool max", s"${g(beacon.dbPoolMax)}")
tbl += (" Pool increment", s"${g(beacon.dbPoolInc)}")
tbl += (" Reset on start", s"${g(beacon.dbInit)}")
tbl += ("REST:", "")
tbl += (" Endpoint", s"${g(beacon.restUrl)}")
tbl += (" API provider", s"${g(beacon.restApi)}")
tbl += ("Probe:", "")
tbl += (" Uplink", s"${g(beacon.upLink)}")
tbl += (" Downlink", s"${g(beacon.downLink)}")
tbl += ("Token providers", s"${g(beacon.tokenProviders)}")
tbl += ("NLP engine", s"${g(beacon.nlpEngine)}")
tbl += ("Access tokens:", "")
tbl += (" Scan frequency", s"$G${beacon.acsToksScanMins} mins$RST")
tbl += (" Expiration timeout", s"$G${beacon.acsToksExpireMins} mins$RST")
tbl += ("External config:", "")
tbl += (" URL", s"${g(beacon.extConfigUrl)}")
tbl += (" Check MD5", s"${g(beacon.extConfigCheckMd5)}")
tbl += ("Log file", logPath)
tbl += ("Started on", s"${g(DateFormat.getDateTimeInstance.format(new Date(beacon.startMs)))}")
logln(s"Local server:\n${tbl.toString}")
logConnectedProbes()
logSignedInUser()
}
/**
*
*/
private def logSignedInUser(): Unit = {
if (state.accessToken.isDefined) {
val tbl = new NCAsciiTable()
tbl += (s"${g("Email")}", state.userEmail.get)
tbl += (s"${g("Access token")}", b(state.accessToken.get))
logln(s"Signed-in user account:\n$tbl")
}
}
/**
*
*/
private def logConnectedProbes(): Unit = {
val tbl = new NCAsciiTable
tbl #= (
"Probe ID",
"Uptime",
"Host / OS",
"Model IDs"
)
def addProbeToTable(tbl: NCAsciiTable, probe: Probe): NCAsciiTable = {
tbl += (
Seq(
probe.probeId,
s" ${c("guid")}: ${probe.probeGuid}",
s" ${c("tok")}: ${probe.probeToken}"
),
DurationFormatUtils.formatDurationHMS(U.now() - probe.startTstamp),
Seq(
s"${probe.hostName} (${probe.hostAddr})",
s"${probe.osName} ver. ${probe.osVersion}"
),
probe.models.toList.map(m => s"${b(m.id)}, v${m.version}")
)
tbl
}
state.probes.foreach(addProbeToTable(tbl, _))
logln(s"All server connected probes (${state.probes.size}):\n${tbl.toString}")
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdInfo(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
cmdInfoServer(CMDS.find(_.name == "info-server").get, Seq.empty[Argument], repl)
logln()
cmdInfoProbe(CMDS.find(_.name == "info-probe").get, Seq.empty[Argument], repl)
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdInfoServer(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
loadServerBeacon() match {
case Some(beacon) => logServerInfo(beacon)
case None => throw NoLocalServer()
}
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdInfoProbe(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
loadProbeBeacon() match {
case Some(beacon) => logProbeInfo(beacon)
case None => throw NoLocalProbe()
}
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdClear(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
term.puts(Capability.clear_screen)
/**
*
* @param body
* @return
*/
private def mkHttpHandler[T](body: HttpResponse => T): ResponseHandler[T] =
(resp: HttpResponse) => body(resp)
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdSignIn(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
state.accessToken match {
case None =>
val email = getParam(cmd, args, "email", DFLT_USER_EMAIL)
val passwd = getParam(cmd, args, "passwd", DFLT_USER_PASSWD)
httpRest(
cmd,
"signin",
s"""
|{
| "email": ${jsonQuote(email)},
| "passwd": ${jsonQuote(passwd)}
|}
|""".stripMargin
)
case Some(_) => warn(s"Already signed in. Use ${c("'signout'")} command to sign out first, if necessary.")
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdSignOut(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
state.accessToken match {
case Some(acsTok) =>
httpRest(
cmd,
"signout",
s"""
|{"acsTok": ${jsonQuote(acsTok)}}
|""".stripMargin
)
case None => throw NotSignedIn()
}
/**
* Quotes given string in double quotes unless it is already quoted as such.
*
* @param s
* @return
*/
private def jsonQuote(s: String): String = {
if (s == null)
null
else {
val ss = s.trim()
if (ss.startsWith("\"") && ss.endsWith("\""))
ss
else
s""""$ss""""
}
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdSqlGen(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
// Mandatory parameters check (unless --help is specified).
if (!isParam(cmd, args, "help")) {
getParam(cmd, args, "driver")
getParam(cmd, args, "schema")
getParam(cmd, args, "out")
getParam(cmd, args, "url")
}
val addCp = getCpParams(args)
val jvmOpts = getParamOpt(args, "jvmopts") match {
case Some(opts) => U.splitTrimFilter(U.trimQuotes(opts), " ")
case None => Seq("-ea", "-Xms1024m")
}
val jvmArgs = mutable.ArrayBuffer.empty[String]
jvmArgs += JAVA
JVM_OPTS_RT_WARNS.foreach(jvmArgs += _)
jvmArgs ++= jvmOpts
if (!NCAnsi.isEnabled)
jvmArgs += "-DNLPCRAFT_ANSI_COLOR_DISABLED=true"
jvmArgs += "-cp"
if (addCp.nonEmpty)
jvmArgs += s"$JAVA_CP$CP_SEP$addCp".replace(s"$CP_SEP$CP_SEP", CP_SEP)
else
jvmArgs += JAVA_CP
jvmArgs += "org.apache.nlpcraft.model.tools.sqlgen.NCSqlModelGenerator"
for (arg <- args)
if (arg.parameter.id != "cp" && arg.parameter.id != "jvmopts") {
val p = arg.parameter.names.head
arg.value match {
case None => jvmArgs += p
case Some(_) => jvmArgs += s"$p=${arg.value.get}"
}
}
val pb = new ProcessBuilder(jvmArgs.asJava)
pb.directory(new File(USR_WORK_DIR))
pb.inheritIO()
try {
pb.start().onExit().get()
}
catch {
case _: InterruptedException => () // Ignore.
case e: Exception => error(s"Failed to run SQL model generator: ${y(e.getMessage)}")
}
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdModelSugSyn(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
state.accessToken match {
case Some(acsTok) =>
val mdlId = getParamOpt(args, "mdlId") match {
case Some(id) => id
case None =>
if (state.probes.size == 1 && state.probes.head.models.length == 1)
state.probes.head.models.head.id
else
throw MissingOptionalParameter(cmd, "mdlId")
}
val minScore = getDoubleParam(cmd, args, "minScore", 0.5)
httpRest(
cmd,
"model/sugsyn",
s"""
|{
| "acsTok": ${jsonQuote(acsTok)},
| "mdlId": ${jsonQuote(mdlId)},
| "minScore": $minScore
|}
|""".stripMargin
)
case None => throw NotSignedIn()
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdModelSyns(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
state.accessToken match {
case Some(acsTok) =>
val mdlId = getParamOpt(args, "mdlId") match {
case Some(id) => id
case None =>
if (state.probes.size == 1 && state.probes.head.models.length == 1)
state.probes.head.models.head.id
else
throw MissingOptionalParameter(cmd, "mdlId")
}
val elmId = getParam(cmd, args, "elmId")
httpRest(
cmd,
"model/syns",
s"""
|{
| "acsTok": ${jsonQuote(acsTok)},
| "mdlId": ${jsonQuote(mdlId)},
| "elmId": ${jsonQuote(elmId)}
|}
|""".stripMargin
)
case None => throw NotSignedIn()
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdModelInfo(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
state.accessToken match {
case Some(acsTok) =>
val mdlId = getParamOpt(args, "mdlId") match {
case Some(id) => id
case None =>
if (state.probes.size == 1 && state.probes.head.models.length == 1)
state.probes.head.models.head.id
else
throw MissingOptionalParameter(cmd, "mdlId")
}
httpRest(
cmd,
"model/info",
s"""
|{
| "acsTok": ${jsonQuote(acsTok)},
| "mdlId": ${jsonQuote(mdlId)}
|}
|""".stripMargin
)
case None => throw NotSignedIn()
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdAsk(cmd: Command, args: Seq[Argument], repl: Boolean): Unit =
state.accessToken match {
case Some(acsTok) =>
val mdlId = getParamOpt(args, "mdlId") match {
case Some(id) => id
case None =>
if (state.probes.size == 1 && state.probes.head.models.length == 1)
state.probes.head.models.head.id
else
throw MissingOptionalParameter(cmd, "mdlId")
}
val txt = getParam(cmd, args, "txt")
val data = getParamOrNull(args, "data")
val enableLog = getFlagParam(args, "enableLog", dflt = false)
httpRest(
cmd,
"ask/sync",
s"""
|{
| "acsTok": ${jsonQuote(acsTok)},
| "mdlId": ${jsonQuote(mdlId)},
| "txt": ${jsonQuote(txt)},
| "data": ${jsonQuote(data)},
| "enableLog": $enableLog
|}
|""".stripMargin
)
case None => throw NotSignedIn()
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdCall(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val normArgs = args.filter(!_.parameter.synthetic)
val synthArgs = args.filter(_.parameter.synthetic)
val path = normArgs.find(_.parameter.id == "path").getOrElse(throw MissingParameter(cmd, "path")).value.get
var first = true
val buf = new StringBuilder()
val spec = REST_SPEC.find(_.path == path).getOrElse(throw InvalidParameter(cmd, "path"))
var mandatoryParams = spec.params.filter(!_.optional)
for (arg <- synthArgs) {
val jsName = arg.parameter.id
spec.params.find(_.name == jsName) match {
case Some(param) =>
mandatoryParams = mandatoryParams.filter(_.name != jsName)
if (!first)
buf ++= ","
first = false
buf ++= "\"" + jsName + "\":"
val value = arg.value.getOrElse(throw InvalidJsonParameter(cmd, arg.parameter.names.head))
param.kind match {
case STRING => buf ++= "\"" + U.escapeJson(U.trimQuotes(value)) + "\""
case OBJECT | ARRAY => buf ++= U.trimQuotes(value)
case BOOLEAN | NUMERIC => buf ++= value
}
case None => throw InvalidJsonParameter(cmd, jsName)
}
}
if (mandatoryParams.nonEmpty)
throw MissingMandatoryJsonParameters(cmd, mandatoryParams, path)
httpRest(cmd, path, s"{${buf.toString()}}")
}
/**
*
* @param cmd
* @param name
* @param value
* @param supported
*/
@throws[InvalidParameter]
private def checkSupported(cmd: Command, name: String, value: String, supported: String*): Unit =
if (!supported.contains(value))
throw InvalidParameter(cmd, name)
/**
*
* @param lines
* @param cmtBegin Comment begin sequence.
* @param cmtEnd Comment end sequence.
*/
private def extractHeader0(lines: Seq[String], cmtBegin: String, cmtEnd: String): (Int, Int) = {
var startIdx, endIdx = -1
for ((line, idx) <- lines.zipWithIndex if startIdx == -1 || endIdx == -1) {
val t = line.trim
if (t == cmtBegin) {
if (startIdx == -1)
startIdx = idx
}
else if (t == cmtEnd) {
if (startIdx != -1 && endIdx == -1)
endIdx = idx
}
}
if (startIdx == -1) (-1, -1) else (startIdx, endIdx)
}
/**
*
* @param lines
* @param cmtBegin One-line comment begin sequence.
*/
private def extractHeader0(lines: Seq[String], cmtBegin: String = "#"): (Int, Int) = {
var startIdx, endIdx = -1
for ((line, idx) <- lines.zipWithIndex if startIdx == -1 || endIdx == -1)
if (line.trim.startsWith(cmtBegin)) {
if (startIdx == -1)
startIdx = idx
}
else {
if (startIdx != -1 && endIdx == -1) {
require(idx > 0)
endIdx = idx - 1
}
}
if (startIdx == -1)
(-1, -1)
else if (endIdx == -1)
(startIdx, lines.size - 1)
else
(startIdx, endIdx)
}
def extractJavaHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines, "/*", "*/")
def extractJsonHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines, "/*", "*/")
def extractGradleHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines, "/*", "*/")
def extractSbtHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines, "/*", "*/")
def extractXmlHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines, "<!--", "-->")
def extractYamlHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines)
def extractPropertiesHeader(lines: Seq[String]): (Int, Int) = extractHeader0(lines)
/**
*
* @param zipInDir
* @param dst
* @param inEntry
* @param outEntry
* @param repls
*/
@throws[NCE]
private def copy(
zipInDir: String,
dst: File,
inEntry: String,
outEntry: String,
extractHeader: Option[Seq[String] => (Int, Int)],
repls: (String, String)*
): Unit = {
val key = s"$zipInDir/$inEntry"
require(PRJ_TEMPLATES.contains(key), s"Unexpected template entry for: $key")
var lines = PRJ_TEMPLATES(key)
val outFile = if (dst != null) new File(dst, outEntry) else new File(outEntry)
val parent = outFile.getAbsoluteFile.getParentFile
if (parent == null || !parent.exists() && !parent.mkdirs())
throw new NCE(s"Invalid folder: ${parent.getAbsolutePath}")
// Drops headers.
extractHeader match {
case Some(ext) =>
val (hdrFrom, hdrTo) = ext(lines)
lines = lines.zipWithIndex.flatMap {
case (line, idx) => if (idx < hdrFrom || idx > hdrTo) Some(line) else None
}
case None => // No-op.
}
// Drops empty line in begin and end of the file.
lines = lines.dropWhile(_.trim.isEmpty).reverse.dropWhile(_.trim.isEmpty).reverse
val buf = mutable.ArrayBuffer.empty[(String, String)]
for (line <- lines) {
val t = line.trim
// Drops duplicated empty lines, which can be appeared because header deleting.
if (buf.isEmpty || t.nonEmpty || t != buf.last._2)
buf += (line -> t)
}
var cont = buf.map(_._1).mkString("\n")
cont = repls.foldLeft(cont)((s, repl) => s.replaceAll(repl._1, repl._2))
try
Using.resource(new FileWriter(outFile)) { w =>
Using.resource(new BufferedWriter(w)) { bw =>
bw.write(cont)
}
}
catch {
case e: IOException => throw new NCE(s"Error writing $outEntry", e)
}
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdGenModel(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val filePath = replacePathTilda(getParam(cmd, args, "filePath"))
val overrideFlag = getFlagParam(args, "override", dflt = false)
val mdlId = getParam(cmd, args, "modelId")
val out = new File(filePath)
if (out.isDirectory)
throw new IllegalArgumentException(s"Invalid file path: ${c(out.getAbsolutePath)}")
if (out.exists()) {
if (overrideFlag) {
if (!out.delete())
throw new IllegalArgumentException(s"Couldn't delete file: ${c(out.getAbsolutePath)}")
}
else
throw new IllegalArgumentException(s"File already exists: ${c(out.getAbsolutePath)}")
}
val (fileExt, extractHdr) = {
val lc = filePath.toLowerCase
if (lc.endsWith(".yaml") || lc.endsWith(".yml"))
("yaml", extractYamlHeader _)
else if (lc.endsWith(".json") || lc.endsWith(".js"))
("json", extractJsonHeader _)
else
throw new IllegalArgumentException(s"Unsupported model file type (extension): ${c(filePath)}")
}
copy(
"nlpcraft-java-mvn",
out.getParentFile,
s"src/main/resources/template_model.$fileExt",
out.getName,
Some(extractHdr),
"templateModelId" -> mdlId
)
logln(s"Model file stub created: ${c(out.getCanonicalPath)}")
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdGenProject(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val outputDir = replacePathTilda(getParam(cmd, args, "outputDir", USR_WORK_DIR))
val baseName = getParam(cmd, args, "baseName")
val lang = getParam(cmd, args, "lang", "java").toLowerCase
val buildTool = getParam(cmd, args, "buildTool", "mvn").toLowerCase
val pkgName = getParam(cmd, args, "packageName", "org.apache.nlpcraft.demo").toLowerCase
val fileType = getParam(cmd, args, "modelType", "yaml").toLowerCase
val overrideFlag = getFlagParam(args, "override", dflt = false)
val dst = new File(outputDir, baseName)
val pkgDir = pkgName.replaceAll("\\.", "/")
var clsName = s"${baseName.head.toUpper}${baseName.tail}"
if (!clsName.toLowerCase.endsWith("model"))
clsName = s"${clsName}Model"
val variant = s"$lang-$buildTool"
val inFolder = s"nlpcraft-$variant"
val isJson = fileType == "json" || fileType == "js"
checkSupported(cmd, "lang", lang, "java", "scala", "kotlin")
checkSupported(cmd, "buildTool", buildTool, "mvn", "gradle", "sbt")
checkSupported(cmd, "fileType", fileType, "yaml", "yml", "json", "js")
def checkJavaName(v: String, name: String): Unit =
if (!SourceVersion.isName(v))
throw InvalidParameter(cmd, name)
checkJavaName(clsName, "baseName")
checkJavaName(pkgName, "packageName")
// Prepares output folder.
if (dst.isFile)
throw new IllegalArgumentException(s"Invalid output folder: ${c(dst.getAbsolutePath)}")
else {
if (!dst.exists()) {
if (!dst.mkdirs())
throw new IllegalArgumentException(s"Failed to create folder: ${c(dst.getAbsolutePath)}")
}
else {
if (overrideFlag)
U.clearFolder(dst.getAbsolutePath)
else
throw new IllegalArgumentException(s"Folder already exists (use ${c("'-o'")} to override): ${c(dst.getAbsolutePath)}")
}
}
@throws[NCE]
def cp(in: String, extractHeader: Option[Seq[String] => (Int, Int)], repls: (String, String)*): Unit =
copy(inFolder, dst, in, in, extractHeader, repls :_*)
@throws[NCE]
def cpAndRename(in: String, out: String, extractHdr: Option[Seq[String] => (Int, Int)], repls: (String, String)*): Unit =
copy(inFolder, dst, in, out, extractHdr, repls :_*)
@throws[NCE]
def cpCommon(langDir: String, langExt: String): Unit = {
cp(".gitignore", None)
val startClause =
langExt match {
case "java" => s"NCEmbeddedProbe.start($clsName.class);"
case "kt" => s"NCEmbeddedProbe.start($clsName::class.java)"
case "scala" => s"NCEmbeddedProbe.start(classOf[$clsName])"
case _ => throw new AssertionError(s"Unexpected language extension: $langExt")
}
cp(
"readme.txt",
None,
"com.company.nlp.TemplateModel" -> s"$pkgName.$clsName",
"NCEmbeddedProbe.start\\(TemplateModel.class\\);" -> startClause,
"templateModelId" -> baseName
)
cp(
s"src/main/resources/probe.conf",
None,
"com.company.nlp.TemplateModel" -> s"$pkgName.$clsName",
"templateModelId" -> baseName
)
val resFileName =
if (baseName.contains("_")) baseName else CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, baseName)
cpAndRename(
s"src/main/$langDir/com/company/nlp/TemplateModel.$langExt",
s"src/main/$langDir/$pkgDir/$clsName.$langExt",
// Suitable for all supported languages.
Some(extractJavaHeader),
"com.company.nlp" -> s"$pkgName",
"TemplateModel" -> clsName,
"template_model.yaml" -> s"$resFileName.$fileType"
)
cpAndRename(
s"src/main/resources/template_model.${if (isJson) "json" else "yaml"}",
s"src/main/resources/$resFileName.$fileType",
Some(if (isJson) extractJsonHeader else extractYamlHeader),
"templateModelId" -> baseName
)
}
@throws[NCE]
def cpPom(): Unit =
cp(
"pom.xml",
Some(extractXmlHeader),
"com.company.nlp" -> pkgName,
"myapplication" -> baseName,
"<nlpcraft.ver>(.*)</nlpcraft.ver>" -> s"<nlpcraft.ver>${VER.version}</nlpcraft.ver>"
)
@throws[NCE]
def cpGradle(): Unit = {
cp("build.gradle",
Some(extractGradleHeader),
"com.company.nlp" -> pkgName,
"myapplication" -> baseName,
"'org.apache.nlpcraft:nlpcraft:(.*)'" -> s"'org.apache.nlpcraft:nlpcraft:${VER.version}'"
)
cp(
"settings.gradle",
Some(extractGradleHeader),
"myapplication" -> baseName
)
}
@throws[NCE]
def cpSbt(): Unit = {
cp("build.sbt",
Some(extractSbtHeader),
"com.company.nlp" -> pkgName,
"myapplication" -> baseName,
(s"""libraryDependencies""" + " \\+= " + """"org.apache.nlpcraft" % "nlpcraft" % "(.*)"""") ->
(s"""libraryDependencies""" + " \\+= " + s""""org.apache.nlpcraft" % "nlpcraft" % "${VER.version}"""")
)
cp("project/build.properties", Some(extractPropertiesHeader))
}
def folder2String(dir: File): String = {
val sp = System.getProperty("line.separator")
def get(f: File): List[StringBuilder] = {
val name = if (f.isFile) s"${y(f.getName)}" else f.getName
val buf = mutable.ArrayBuffer.empty[StringBuilder] :+ new StringBuilder().append(name)
val children = {
val list = f.listFiles()
if (list == null) List.empty else list.sortBy(_.getName).toList
}
for {
child <- children
(v1, v2) = if (child != children.last) (s"${c("|")}-- ", s"${c("|")} ") else (s"${c("+")}-- ", " ")
sub = get(child)
} {
buf += sub.head.insert(0, v1)
sub.tail.foreach(p => buf += p.insert(0, v2))
}
buf.toList
}
get(dir).map(line => s"$line$sp").mkString
}
try {
variant match {
case "java-mvn" => cpCommon("java", "java"); cpPom()
case "java-gradle" => cpCommon("java", "java"); cpGradle()
case "kotlin-mvn" => cpCommon("kotlin", "kt"); cpPom()
case "kotlin-gradle" => cpCommon("kotlin", "kt"); cpGradle()
case "scala-mvn" => cpCommon("scala", "scala"); cpPom()
case "scala-gradle" => cpCommon("scala", "scala"); cpGradle()
case "scala-sbt" => cpCommon("scala", "scala"); cpSbt()
case _ => throw new IllegalArgumentException(s"Unsupported combination of '${c(lang)}' and '${c(buildTool)}'.")
}
logln(s"Project created in: ${c(dst.getCanonicalPath)}")
logln(folder2String(dst))
}
catch {
case e: Exception =>
try
U.clearFolder(dst.getAbsolutePath, delFolder = true)
catch {
case _: Exception => // No-op.
}
throw e
}
}
/**
*
* @param cmd
* @param path
* @param json
*/
private def httpRest(cmd: Command, path: String, json: String): Unit = {
if (!U.isValidJson(json))
throw MalformedJson()
if (!REST_SPEC.exists(_.path == path))
throw InvalidParameter(cmd, "path")
val spinner = new NCAnsiSpinner(
term.writer(),
// ANSI is NOT disabled & we ARE NOT running from IDEA or Eclipse...
NCAnsi.isEnabled && IS_SCRIPT
)
spinner.start()
val start = U.now()
// Make the REST call.
val resp =
try
httpPostResponse(getRestEndpointFromBeacon, path, json)
finally
spinner.stop()
val durMs = U.now() - start
// Ack HTTP response code.
logln(s"HTTP ${if (resp.code == 200) g("200") else r(resp.code)} [${durMs}ms]")
if (U.isValidJson(resp.data))
logln(U.colorJson(U.prettyJson(resp.data)))
else {
if (resp.code == 200)
logln(s"${g("HTTP response:")} ${resp.data}")
else
error(s"${r("HTTP error:")} ${resp.data}")
}
if (resp.code == 200) {
if (path == "signin") {
state.userEmail = Some(U.getJsonStringField(json, "email"))
state.accessToken = Some(U.getJsonStringField(resp.data, "acsTok"))
}
else if (path == "signout") {
state.userEmail = None
state.accessToken = None
}
}
}
/**
*
*/
private def doRepl(): Unit = {
loadServerBeacon(autoSignIn = true) match {
case Some(beacon) => logServerInfo(beacon)
case None => ()
}
loadProbeBeacon() match {
case Some(beacon) => logProbeInfo(beacon)
case None => ()
}
val parser = new DefaultParser()
parser.setEofOnUnclosedBracket(Bracket.CURLY, Bracket.ROUND, Bracket.SQUARE)
parser.setEofOnUnclosedQuote(true)
parser.regexCommand("")
parser.regexVariable("")
parser.setEscapeChars(null)
val completer: Completer = new Completer {
private val cmds = CMDS.map(c => (c.name, c.synopsis, c.group))
private val fsCompleter = new NCCliFileNameCompleter()
private val mdlClsCompleter = new NCCliModelClassCompleter()
// All '--cp' names.
private val CP_PARAM_NAMES = (
START_PRB_CMD.findParameterById("cp").names ++
TEST_MDL_CMD.findParameterById("cp").names
).toSet
/**
*
* @param disp
* @param grp
* @param desc
* @param completed
* @return
*/
private def mkCandidate(disp: String, grp: String, desc: String, completed: Boolean): Candidate =
new Candidate(disp, disp, grp, desc, null, null, completed)
/**
*
* @param param
* @return
*/
def splitEqParam(param: String): Option[(String/*Name*/, String/*Value*/)] = {
val eqIdx = param.indexOf('=')
if (eqIdx != -1) {
val paramName = param.substring(0, eqIdx)
val paramVal = param.substring(eqIdx + 1)
Some((paramName, paramVal))
}
else
None
}
/**
*
* @param cmdName
* @param param
* @return
*/
private def isFsPath(cmdName: String, param: String): Boolean =
CMDS.find(_.name == cmdName) match {
case Some(cmd) =>
splitEqParam(param) match {
case Some((name, _)) =>
cmd.params.find(_.names.contains(name)) match {
case Some(p) => p.fsPath
case None => false
}
case None => false
}
case None => false
}
override def complete(reader: LineReader, line: ParsedLine, candidates: java.util.List[Candidate]): Unit = {
val words = line.words().asScala
// Complete command names.
if (words.isEmpty || (words.size == 1 && !cmds.map(_._1).contains(words.head))) {
// Add all commands as a candidates.
candidates.addAll(cmds.map(n => {
val name = n._1
val desc = n._2.substring(0, n._2.length - 1) // Remove last '.'.
val grp = s"${n._3}:"
mkCandidate(
disp = name,
grp = grp,
desc = desc,
completed = true
)
}).asJava)
}
// Complete path for OS commands (starting with '$').
else if (words.nonEmpty && words.head.nonEmpty && words.head.head == '$' && words.last.contains(PATH_SEP_STR)) {
var path = words.last // Potential path.
var prefix = ""
var suffix = ""
if (path.nonEmpty) {
val first = path.head
val last = path.last
if (first == '"' || first == '\'') {
prefix = first.toString
path = path.drop(1)
}
if (last == '"' || last == '\'') {
suffix = last.toString
path = path.dropRight(1)
}
}
fsCompleter.fillCandidates(
reader,
null,
path,
prefix,
suffix,
candidates
)
}
// Complete paths for commands.
else if (words.size > 1 && isFsPath(words.head, words.last))
splitEqParam(words.last) match {
case Some((name, p)) =>
var path = p
var prefix = ""
var suffix = ""
if (path.nonEmpty) {
val first = path.head
val last = path.last
if (first == '"' || first == '\'') {
prefix = first.toString
path = path.drop(1)
}
if (last == '"' || last == '\'') {
suffix = last.toString
path = path.dropRight(1)
}
}
fsCompleter.fillCandidates(
reader,
name,
path,
prefix,
suffix,
candidates
)
case None => ()
}
else {
val cmd = words.head
val OPTIONAL_GRP = "Optional:"
val MANDATORY_GRP = "Mandatory:"
val DFTL_USER_GRP = "Default user:"
val CMDS_GRP = "Commands:"
candidates.addAll(CMDS.find(_.name == cmd) match {
case Some(c) =>
c.params.filter(!_.synthetic).flatMap(param => {
val hasVal = param.value.isDefined
val names = param.names.filter(_.startsWith("--")) // Skip shorthands from auto-completion.
names.map(name => mkCandidate(
disp = if (hasVal) name + "=" else name,
grp = if (param.optional) OPTIONAL_GRP else MANDATORY_GRP,
desc = null,
completed = !hasVal
))
})
.asJava
case None => Seq.empty[Candidate].asJava
})
// For 'start-probe' and 'test-model' provide model class name completion for '--mdls'
// if '--cp' parameter(s) with JAR file(s) is provided.
if (cmd == START_PRB_CMD.name || cmd == TEST_MDL_CMD.name) {
val cp = words.filter(w => CP_PARAM_NAMES.exists(x => w.startsWith(x))).flatMap(w => splitEqParam(w) match {
case Some((_, paramVal)) => Some(U.trimQuotes(paramVal.strip()))
case None => None
})
.mkString(CP_SEP)
try {
for (cls <- mdlClsCompleter.getModelClassNamesFromClasspath(cp.split(CP_SEP_CHAR).toSeq.asJava).asScala)
candidates.add(
mkCandidate(
disp = "--mdls=" + cls,
grp = OPTIONAL_GRP,
desc = null,
completed = true
)
)
}
catch {
// Just ignore.
case _: Exception => ()
}
}
// For 'help' - add additional auto-completion/suggestion candidates.
if (cmd == HELP_CMD.name)
candidates.addAll(CMDS.map(c => s"--cmd=${c.name}").map(s =>
mkCandidate(
disp = s,
grp = CMDS_GRP,
desc = null,
completed = true
))
.asJava
)
// For 'rest' or 'call' - add '--path' auto-completion/suggestion candidates.
if (cmd == REST_CMD.name || cmd == CALL_CMD.name) {
val pathParam = REST_CMD.findParameterById("path")
val hasPathAlready = words.exists(w => pathParam.names.exists(x => w.startsWith(x)))
if (!hasPathAlready)
candidates.addAll(
REST_SPEC.map(cmd => {
val name = s"--path=${cmd.path}"
mkCandidate(
disp = name,
grp = MANDATORY_GRP,
desc = cmd.desc,
completed = true
)
})
.asJava
)
}
// For 'ask', 'sugsyn', 'model-syn' and 'model-info' - add additional
// model IDs auto-completion/suggestion candidates.
if (cmd == ASK_CMD.name || cmd == MODEL_SUGSYN_CMD.name || cmd == MODEL_SYNS_CMD.name || cmd == MODEL_INFO_CMD.name)
candidates.addAll(
state.probes.flatMap(_.models.toList).map(mdl =>
mkCandidate(
disp = s"--mdlId=${mdl.id}",
grp = MANDATORY_GRP,
desc = null,
completed = true
)
)
.asJava
)
// For 'model-syns' add auto-completion for element IDs.
if (cmd == MODEL_SYNS_CMD.name) {
val mdlIdParam = MODEL_SYNS_CMD.findParameterById("mdlId")
words.find(w => mdlIdParam.names.exists(x => w.startsWith(x))) match {
case Some(p) =>
val mdlId = p.substring(p.indexOf('=') + 1)
state.probes.flatMap(_.models).find(_.id == mdlId) match {
case Some(mdl) =>
candidates.addAll(
mdl.elementIds.toList.map(id =>
mkCandidate(
disp = s"--elmId=$id",
grp = MANDATORY_GRP,
desc = null,
completed = true
)
)
.asJava
)
case None => ()
}
case None => ()
}
}
// For 'call' - add additional auto-completion/suggestion candidates.
if (cmd == CALL_CMD.name) {
val pathParam = CALL_CMD.findParameterById("path")
words.find(w => pathParam.names.exists(x => w.startsWith(x))) match {
case Some(p) =>
val path = p.substring(p.indexOf('=') + 1)
REST_SPEC.find(_.path == path) match {
case Some(spec) =>
candidates.addAll(
spec.params.map(param => {
mkCandidate(
disp = s"--${param.name}",
grp = if (param.optional) OPTIONAL_GRP else MANDATORY_GRP,
desc = null,
completed = false
)
})
.asJava
)
// Add 'acsTok' auto-suggestion.
if (spec.params.exists(_.name == "acsTok") && state.accessToken.isDefined)
candidates.add(
mkCandidate(
disp = s"--acsTok=${state.accessToken.get}",
grp = MANDATORY_GRP,
desc = null,
completed = true
)
)
// Add 'mdlId' auto-suggestion.
if (spec.params.exists(_.name == "mdlId") && state.probes.nonEmpty)
candidates.addAll(
state.probes.flatMap(_.models.toList).map(mdl => {
mkCandidate(
disp = s"--mdlId=${mdl.id}",
grp = MANDATORY_GRP,
desc = null,
completed = true
)
})
.asJava
)
// Add default 'email' and 'passwd' auto-suggestion for 'signin' path.
if (path == "signin") {
candidates.add(
mkCandidate(
disp = s"--email=$DFLT_USER_EMAIL",
grp = DFTL_USER_GRP,
desc = null,
completed = true
)
)
candidates.add(
mkCandidate(
disp = s"--passwd=$DFLT_USER_PASSWD",
grp = DFTL_USER_GRP,
desc = null,
completed = true
)
)
}
case None => ()
}
case None => ()
}
}
}
}
}
class ReplHighlighter extends Highlighter {
override def highlight(reader: LineReader, buffer: String): AttributedString =
AttributedString.fromAnsi(
CMD_NAME.matcher(
CMD_PARAM.matcher(
buffer
)
.replaceAll("$1" + c("$2"))
)
.replaceAll(bo(g("$1")) + "$2")
)
override def setErrorPattern(errorPattern: Pattern): Unit = ()
override def setErrorIndex(errorIndex: Int): Unit = ()
}
val reader = LineReaderBuilder
.builder
.appName("NLPCraft")
.terminal(term)
.completer(completer)
.parser(parser)
.highlighter(new ReplHighlighter())
.history(new DefaultHistory())
.variable(LineReader.SECONDARY_PROMPT_PATTERN, s"${g("...>")} ")
.variable(LineReader.INDENTATION, 2)
.build
reader.setOpt(LineReader.Option.AUTO_FRESH_LINE)
reader.unsetOpt(LineReader.Option.INSERT_TAB)
reader.unsetOpt(LineReader.Option.BRACKETED_PASTE)
reader.setOpt(LineReader.Option.DISABLE_EVENT_EXPANSION)
reader.setVariable(
LineReader.HISTORY_FILE,
new File(SystemUtils.getUserHome, HIST_PATH).getAbsolutePath
)
logln(s"${y("Tip:")} Hit ${rv(bo(" Tab "))} for commands, parameters and paths completion.")
logln(s" Type '${c("help")}' to get help and ${rv(bo(" ↑ "))} or ${rv(bo(" ↓ "))} to scroll through history.")
logln(s" Type '${c("quit")}' to exit.")
var exit = false
val pinger = U.mkThread("repl-server-pinger") { t =>
while (!t.isInterrupted) {
loadServerBeacon()
Thread.sleep(10.secs)
}
}
pinger.start()
var wasLastLineEmpty = false
while (!exit) {
val rawLine = try {
val acsTokStr = bo(s"${state.accessToken.getOrElse("N/A")} ")
val prompt1 = if (state.isServerOnline) gb(k(s" server: ${BO}ON$RST$GB ")) else rb(w(s" server: ${BO}OFF$RST$RB "))
val prompt2 = if (state.isProbeOnline) gb(k(s" probe: ${BO}ON$RST$GB ")) else rb(w(s" probe: ${BO}OFF$RST$RB "))
val prompt3 = wb(k(s" acsTok: $acsTokStr")) // Access token, if any.
val prompt4 = kb(g(s" $USR_WORK_DIR ")) // Current working directory.
if (!wasLastLineEmpty)
reader.printAbove("\n" + prompt1 + ":" + prompt2 + ":" + prompt3 + ":" + prompt4)
reader.readLine(s"${g(bo(">"))} ")
}
catch {
case _: UserInterruptException => "" // Ignore.
case _: EndOfFileException => null
case _: Exception => "" // Guard against JLine hiccups.
}
if (rawLine == null)
exit = true
else {
val line = rawLine
.trim()
.replace("\n", "")
.replace("\t", " ")
.trim()
if (line.nonEmpty) {
wasLastLineEmpty = false
try
doCommand(splitAndClean(line), repl = true)
catch {
case e: SplitError =>
val idx = e.index
val lineX = line.substring(0, idx) + r(line.substring(idx, idx + 1) ) + line.substring(idx + 1)
val dashX = c("-" * idx) + r("^") + c("-" * (line.length - idx - 1))
error(s"Uneven quotes or brackets:")
error(s" ${r("+--")} $lineX")
error(s" ${r("+--")} $dashX")
}
if (line == QUIT_CMD.name)
exit = true
}
else
wasLastLineEmpty = true
}
}
U.stopThread(pinger)
// Save command history.
ignoring(classOf[IOException]) {
reader.getHistory.save()
}
}
/**
*
* @param cmd Command descriptor.
* @param args Arguments, if any, for this command.
* @param repl Whether or not executing from REPL.
*/
private [cmdline] def cmdVersion(cmd: Command, args: Seq[Argument], repl: Boolean): Unit = {
val isS = getFlagParam(args, "semver", dflt = false)
val isD = getFlagParam(args, "reldate", dflt = false)
if (!isS && !isD)
logln((
new NCAsciiTable
+= ("Version:", c(VER.version))
+= ("Release date:", c(VER.date.toString))
).toString
)
else if (isS)
logln(s"${VER.version}")
else
logln(s"${VER.date}")
}
/**
*
* @param msg
*/
private def error(msg: String = ""): Unit = {
// Make sure we exit with non-zero status.
exitStatus = 1
if (msg != null && msg.nonEmpty)
logln(s"${r("X")} ${U.capitalize(msg)}")
}
/**
*
* @param msg
*/
private def warn(msg: String = ""): Unit =
if (msg != null && msg.nonEmpty)
logln(s"${y("!")} ${U.capitalize(msg)}")
/**
*
* @param msg
*/
private def logln(msg: String = ""): Unit = {
term.writer().println(msg)
term.flush()
}
/**
*
* @param msg
*/
private def log(msg: String = ""): Unit = {
term.writer().print(msg)
term.flush()
}
/**
* Prints out the version and copyright title header.
*/
private def title(): Unit = {
logln(U.asciiLogo8Bit())
logln(s"$NAME ver. ${VER.version}")
logln()
logln(s"Docs: ${g("nlpcraft.apache.org")}")
logln(s"GitHub: ${g("github.com/apache/incubator-nlpcraft")}")
logln()
}
/**
*
* @param baseUrl
* @param cmd
* @return
*/
private def prepRestUrl(baseUrl: String, cmd: String): String =
if (baseUrl.endsWith("/")) s"$baseUrl$cmd" else s"$baseUrl/$cmd"
/**
* Posts HTTP POST request.
*
* @param baseUrl Base endpoint URL.
* @param cmd REST call command.
* @param resp
* @param json JSON string.
* @return
* @throws IOException
*/
private def httpPost[T](baseUrl: String, cmd: String, resp: ResponseHandler[T], json: String): T = {
val post = new HttpPost(prepRestUrl(baseUrl, cmd))
post.setHeader("Content-Type", "application/json")
post.setEntity(new StringEntity(json, "UTF-8"))
try
HttpClients.createDefault().execute(post, resp)
finally
post.releaseConnection()
}
/**
*
* @param endpoint
* @param path
* @param json
* @return
*/
private def httpPostResponse(endpoint: String, path: String, json: String): HttpRestResponse =
httpPost(endpoint, path, mkHttpHandler(resp => {
val status = resp.getStatusLine
HttpRestResponse(
status.getStatusCode,
Option(EntityUtils.toString(resp.getEntity)).getOrElse(
throw new IllegalStateException(s"Unexpected REST error: ${status.getReasonPhrase}")
)
)
}), json)
/**
*
* @param endpoint
* @param path
* @param json
* @return
*/
private def httpPostResponseJson(endpoint: String, path: String, json: String): Option[String] =
httpPost(endpoint, path, mkHttpHandler(resp => {
val status = resp.getStatusLine
if (status.getStatusCode == 200)
Option(EntityUtils.toString(resp.getEntity))
else
None
}), json)
/**
* Posts HTTP GET request.
*
* @param endpoint Base endpoint URL.
* @param path REST call command.
* @param resp
* @param jsParams
* @return
* @throws IOException
*/
private def httpGet[T](endpoint: String, path: String, resp: ResponseHandler[T], jsParams: (String, AnyRef)*): T = {
val bldr = new URIBuilder(prepRestUrl(endpoint, path))
jsParams.foreach(p => bldr.setParameter(p._1, p._2.toString))
val get = new HttpGet(bldr.build())
try
HttpClients.createDefault().execute(get, resp)
finally
get.releaseConnection()
}
/**
* Splits given string by spaces taking into an account double and single quotes,
* '\' escaping as well as checking for uneven <>, {}, [], () pairs.
*
* @param line
* @return
*/
@throws[SplitError]
private def splitAndClean(line: String): Seq[String] = {
val lines = mutable.Buffer.empty[String]
val buf = new StringBuilder
var stack = List.empty[Char]
var escape = false
var index = 0
def stackHead: Char = stack.headOption.getOrElse(Char.MinValue)
for (ch <- line) {
if (ch.isWhitespace && !stack.contains('"') && !stack.contains('\'') && !escape) {
if (buf.nonEmpty) {
lines += buf.toString()
buf.clear()
}
}
else if (ch == '\\') {
if (escape)
buf += ch
else
// SKip '\'.
escape = true
}
else if (ch == '"' || ch == '\'') {
if (!escape) {
if (!stack.contains(ch))
stack ::= ch // Push.
else if (stackHead == ch)
stack = stack.tail // Pop.
else
throw SplitError(index)
}
buf += ch
}
else if (OPEN_BRK.contains(ch)) {
stack ::= ch // Push.
buf += ch
}
else if (CLOSE_BRK.contains(ch)) {
if (stackHead != BRK_PAIR(ch))
throw SplitError(index)
stack = stack.tail // Pop.
buf += ch
}
else {
if (escape)
buf += '\\' // Put back '\'.
buf += ch
}
// Drop escape flag.
if (escape && ch != '\\')
escape = false
index += 1
}
if (stack.nonEmpty)
throw SplitError(index - 1)
if (buf.nonEmpty)
lines += buf.toString()
lines.map(_.strip).toSeq
}
/**
*
* @param cmd
* @param args
* @return
*/
private def processParameters(cmd: Command, args: Seq[String]): Seq[Argument] =
args.map { arg =>
val parts = arg.split("=", 2)
def mkError() = new IllegalArgumentException(s"Invalid parameter: ${c(arg)}, type $C'help --cmd=${cmd.name}'$RST to get help.")
if (parts.size > 2)
throw mkError()
val name = if (parts.size == 1) arg.strip else parts(0).trim
val value = if (parts.size == 1) None else Some(U.trimQuotes(parts(1).strip))
val hasSynth = cmd.params.exists(_.synthetic)
if (name.endsWith("=")) // Missing value or extra '='.
throw mkError()
cmd.findParameterByNameOpt(name) match {
case None =>
if (hasSynth)
Argument(Parameter(
id = name.substring(2), // Remove single '--' from the beginning.
names = Seq(name),
value = value,
synthetic = true,
desc = null
), value) // Synthetic argument.
else
throw mkError()
case Some(param) =>
if ((param.value.isDefined && value.isEmpty) || (param.value.isEmpty && value.isDefined))
throw mkError()
Argument(param, value)
}
}
/**
*
* @param args
* @param repl
*/
private def processAnsi(args: Seq[String], repl: Boolean): Unit = {
args.find(_ == NO_ANSI_CMD.name) match {
case Some(_) => NO_ANSI_CMD.body(NO_ANSI_CMD, Seq.empty, repl)
case None => ()
}
args.find(_ == ANSI_CMD.name) match {
case Some(_) => ANSI_CMD.body(ANSI_CMD, Seq.empty, repl)
case None => ()
}
}
/**
*
* @param args
*/
private def execOsCmd(args: Seq[String]): Unit = {
val pb = new ProcessBuilder(args.asJava).inheritIO()
val proc = pb.start()
try
proc.waitFor()
catch {
case _: InterruptedException => () // Exit.
}
}
/**
* Processes a single command defined by the given arguments.
*
* @param args
* @param repl Whether or not called from 'repl' mode.
*/
@throws[Exception]
private def doCommand(args: Seq[String], repl: Boolean): Unit = {
if (args.nonEmpty) {
try
if (args.head.head == '$') {
val head = args.head.tail.strip // Remove '$' from 1st argument.
val tail = args.tail.toList
execOsCmd(if (head.isEmpty) tail else head :: tail)
}
else if (args.head.head == '@') {
if (!repl)
throw new IllegalStateException("This syntax is only available in REPL mode.")
// A shortcut for the 'ask' command.
ASK_CMD.body(
ASK_CMD,
Seq(
Argument(ASK_CMD.params.find(_.id == "txt").get,
Some(args.head.tail.strip + " " + args.tail.mkString(" ")))
),
repl
)
} else {
// Process 'no-ansi' and 'ansi' commands first.
processAnsi(args, repl)
// Remove 'no-ansi' and 'ansi' commands from the argument list, if any.
val xargs = args.filter(arg => arg != NO_ANSI_CMD.name && arg != ANSI_CMD.name)
if (xargs.nonEmpty) {
val cmd = xargs.head
// Reset error code.
exitStatus = 0
CMDS.find(_.name == cmd) match {
case Some(cmd) => cmd.body(cmd, processParameters(cmd, xargs.tail), repl)
case None => throw UnknownCommand(cmd)
}
}
}
catch {
case e: Exception => error(e.getLocalizedMessage)
}
}
}
/**
*
* @param args
*/
private def boot(args: Array[String]): Unit = {
new Thread() {
override def run(): Unit = {
U.gaScreenView("cli")
}
}
.start()
// Initialize OS-aware terminal.
term = TerminalBuilder.builder()
.name(NAME)
.system(true)
.nativeSignals(true)
.signalHandler(Terminal.SignalHandler.SIG_IGN)
.jansi(SystemUtils.IS_OS_UNIX)
.jna(SystemUtils.IS_OS_WINDOWS)
.build()
// Process 'no-ansi' and 'ansi' commands first (before ASCII title is shown).
processAnsi(args.toSeq, repl = false)
if (!args.contains(NO_LOGO_CMD.name))
title() // Show logo unless we have 'no-logo' command.
// Remove all auxiliary commands that are combined with other commands, if any.
val xargs = args.filter(arg =>
arg != NO_ANSI_CMD.name &&
arg != ANSI_CMD.name &&
arg != NO_LOGO_CMD.name
)
if (xargs.isEmpty)
doRepl()
else
doCommand(xargs.toSeq, repl = false)
sys.exit(exitStatus)
}
cleanUpTempFiles()
// Boot up.
boot(args)
}