2026-10-01 20:48:38 +08:00
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
|
|
|
|
|
|
module Main (main) where
|
|
|
|
|
|
2026-10-11 17:32:12 +08:00
|
|
|
import ChuSQL.CLI.Normalization (runNormalizationOnce)
|
2026-10-01 20:48:38 +08:00
|
|
|
import ChuSQL.CLI.Format (OutputFormat (..), formatName, parseFormat)
|
2026-10-05 00:15:01 +08:00
|
|
|
import ChuSQL.CLI.Password (readPasswordInput)
|
2026-10-01 20:48:38 +08:00
|
|
|
import ChuSQL.CLI.Script (stripTerminator)
|
2026-10-05 00:15:01 +08:00
|
|
|
import ChuSQL.Interface.Session (
|
2026-10-01 20:48:38 +08:00
|
|
|
Session,
|
|
|
|
|
authenticateSession,
|
2026-10-05 22:22:23 +08:00
|
|
|
authenticateSudoSession,
|
2026-10-01 20:48:38 +08:00
|
|
|
isPlainIdentifier,
|
|
|
|
|
newSession,
|
|
|
|
|
switchDatabase,
|
|
|
|
|
)
|
2026-10-03 02:17:46 +08:00
|
|
|
import ChuSQL.Interface.Config (ServerConfig (..), loadServerConfigAt, loadWebConfigAt, rootUserName)
|
2026-10-02 14:53:02 +08:00
|
|
|
import ChuSQL.Interface.Link (Client, closeClient, connectClient)
|
2026-10-05 22:22:23 +08:00
|
|
|
import ChuSQL.Interface.Sudo (SudoCredential, readSudoCredential)
|
2026-10-02 14:53:02 +08:00
|
|
|
import ChuSQL.Interface.TOML (resolveConfigPath)
|
2026-10-01 20:48:38 +08:00
|
|
|
import Control.Exception (finally)
|
|
|
|
|
import Control.Monad (unless)
|
2026-10-11 17:32:12 +08:00
|
|
|
import Data.IORef (IORef, newIORef)
|
2026-10-01 20:48:38 +08:00
|
|
|
import Data.Maybe (fromMaybe)
|
|
|
|
|
import Data.Text (Text)
|
|
|
|
|
import qualified Data.Text as T
|
|
|
|
|
import Options.Applicative
|
|
|
|
|
import Repl (reportError, runRepl, runStatementOnce)
|
|
|
|
|
import System.Console.Haskeline (defaultSettings, getPassword, runInputT)
|
|
|
|
|
import System.Directory (getHomeDirectory)
|
|
|
|
|
import System.Exit (exitFailure)
|
|
|
|
|
import System.FilePath ((</>))
|
2026-10-05 00:15:01 +08:00
|
|
|
import System.IO (hFlush, hIsTerminalDevice, hPutStr, hSetEncoding, stderr, stdin, stdout, utf8)
|
2026-10-01 20:48:38 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- chusql-cli:与 Web 管理端并列的前端,接到拥有存储的 server 上跑 SQL,账号与 Web 共用。
|
2026-10-01 20:48:38 +08:00
|
|
|
|
|
|
|
|
data Options = Options
|
|
|
|
|
{ optUser :: Maybe Text
|
|
|
|
|
, optDatabase :: Text
|
|
|
|
|
, optFormat :: OutputFormat
|
|
|
|
|
, optExecute :: Maybe Text
|
|
|
|
|
, optHistory :: Maybe FilePath
|
|
|
|
|
, optConfig :: Maybe FilePath
|
2026-10-11 17:32:12 +08:00
|
|
|
, optNormalize :: Maybe Text
|
|
|
|
|
, optDeclaration :: Maybe FilePath
|
2026-10-05 22:22:23 +08:00
|
|
|
, optSudo :: Bool
|
2026-10-01 20:48:38 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 命令行参数解析器
|
2026-10-01 20:48:38 +08:00
|
|
|
optionsParser :: Parser Options
|
|
|
|
|
optionsParser =
|
|
|
|
|
Options
|
2026-10-05 00:15:01 +08:00
|
|
|
<$> optional (T.pack <$> strOption (long "user" <> short 'u' <> metavar "USER" <> help "Sign in as this account (default: root)"))
|
2026-10-01 20:48:38 +08:00
|
|
|
<*> ( T.pack
|
|
|
|
|
<$> strOption
|
|
|
|
|
( long "database"
|
|
|
|
|
<> short 'd'
|
|
|
|
|
<> metavar "DB"
|
|
|
|
|
<> value ""
|
|
|
|
|
<> help "Database to use (default: none, select one with USE)"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
<*> option
|
|
|
|
|
(eitherReader formatReader)
|
|
|
|
|
( long "format"
|
|
|
|
|
<> short 'f'
|
|
|
|
|
<> metavar "FORMAT"
|
|
|
|
|
<> value FormatTable
|
|
|
|
|
<> showDefaultWith (T.unpack . formatName)
|
2026-10-11 17:32:12 +08:00
|
|
|
<> help "Output format: table (or text), json or csv"
|
2026-10-01 20:48:38 +08:00
|
|
|
)
|
|
|
|
|
<*> optional (T.pack <$> strOption (long "execute" <> short 'e' <> metavar "SQL" <> help "Run one statement and exit"))
|
|
|
|
|
<*> optional (strOption (long "history" <> metavar "FILE" <> help "SQL history file (default ~/.chusql_history)"))
|
2026-10-05 00:15:01 +08:00
|
|
|
<*> optional (strOption (long "config" <> metavar "FILE" <> help "Config file to read (default: the fixed settings.toml under the system config directory)"))
|
2026-10-11 17:32:12 +08:00
|
|
|
<*> optional (T.pack <$> strOption (long "normalize" <> metavar "TABLE" <> help "Analyze normalization using explicit declarations"))
|
|
|
|
|
<*> optional (strOption (long "declaration" <> metavar "FILE" <> help "UTF-8 JSON normalization declaration"))
|
2026-10-05 22:22:23 +08:00
|
|
|
<*> switch (long "sudo" <> help "Authenticate using the local root or elevated administrator identity")
|
2026-10-01 20:48:38 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 把 --format 的取值解析成格式
|
2026-10-01 20:48:38 +08:00
|
|
|
formatReader :: String -> Either String OutputFormat
|
2026-10-11 17:32:12 +08:00
|
|
|
formatReader "text" = Right FormatTable
|
|
|
|
|
formatReader raw = maybe (Left "format must be table, text, json or csv") Right (parseFormat (T.pack raw))
|
2026-10-01 20:48:38 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 命令行界面与帮助
|
2026-10-01 20:48:38 +08:00
|
|
|
parserInfo :: ParserInfo Options
|
|
|
|
|
parserInfo =
|
|
|
|
|
info
|
|
|
|
|
(optionsParser <**> helper)
|
|
|
|
|
( fullDesc
|
|
|
|
|
<> progDesc "Run SQL against the ChuSQL storage engine"
|
|
|
|
|
<> header "csql - the ChuSQL command line client"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 解析参数、登录 server 后进入会话
|
2026-10-01 20:48:38 +08:00
|
|
|
main :: IO ()
|
|
|
|
|
main = do
|
|
|
|
|
hSetEncoding stdout utf8
|
|
|
|
|
hSetEncoding stderr utf8
|
|
|
|
|
options <- execParser parserInfo
|
2026-10-11 17:32:12 +08:00
|
|
|
case (optNormalize options, optDeclaration options, optExecute options, optFormat options) of
|
|
|
|
|
(Nothing, Nothing, _, _) -> pure ()
|
|
|
|
|
(Just _, Just _, Nothing, format) | format /= FormatCsv -> pure ()
|
|
|
|
|
_ -> reportError "--normalize requires --declaration, text/json format and cannot be combined with --execute" >> exitFailure
|
2026-10-01 20:48:38 +08:00
|
|
|
configPath <- resolveConfigPath (optConfig options)
|
|
|
|
|
base <- loadWebConfigAt configPath
|
2026-10-02 14:53:02 +08:00
|
|
|
config <- loadServerConfigAt configPath
|
2026-10-05 00:15:01 +08:00
|
|
|
let rootName = rootUserName base
|
2026-10-05 22:22:23 +08:00
|
|
|
userName = fromMaybe (if optSudo options then scSudoUser config else rootName) (optUser options)
|
|
|
|
|
credential <- if optSudo options then do
|
|
|
|
|
if T.null userName then reportError "sudo_auth_user is not configured" >> exitFailure else pure ()
|
|
|
|
|
local <- readSudoCredential (scPort config)
|
|
|
|
|
case local of
|
|
|
|
|
Left message -> reportError message >> exitFailure
|
|
|
|
|
Right proof -> pure (Just proof)
|
|
|
|
|
else pure Nothing
|
|
|
|
|
password <- if optSudo options then pure "" else promptPassword userName
|
2026-10-02 14:53:02 +08:00
|
|
|
connected <- connectClient (T.pack (scHost config)) (scPort config)
|
|
|
|
|
case connected of
|
2026-10-01 20:48:38 +08:00
|
|
|
Left message -> reportError message >> exitFailure
|
2026-10-02 14:53:02 +08:00
|
|
|
Right client ->
|
2026-10-05 22:22:23 +08:00
|
|
|
connect options client userName password credential
|
2026-10-02 14:53:02 +08:00
|
|
|
`finally` closeClient client
|
2026-10-01 20:48:38 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 连 server、登录,之后转入会话
|
2026-10-05 22:22:23 +08:00
|
|
|
connect :: Options -> Client -> Text -> Text -> Maybe SudoCredential -> IO ()
|
|
|
|
|
connect options client userName password credential = do
|
2026-10-02 14:53:02 +08:00
|
|
|
session <- newSession client
|
2026-10-05 22:22:23 +08:00
|
|
|
signedIn <- case credential of
|
|
|
|
|
Just proof -> authenticateSudoSession session proof userName
|
|
|
|
|
Nothing -> authenticateSession session userName password
|
2026-10-01 20:48:38 +08:00
|
|
|
case signedIn of
|
|
|
|
|
Left message -> reportError message >> exitFailure
|
|
|
|
|
Right () -> runSession options session
|
|
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 执行 -e 的单条语句,否则进交互循环
|
2026-10-01 20:48:38 +08:00
|
|
|
runSession :: Options -> Session -> IO ()
|
|
|
|
|
runSession options session = do
|
|
|
|
|
formatRef <- newIORef (optFormat options)
|
|
|
|
|
ready <- selectDatabase session (T.strip (optDatabase options))
|
|
|
|
|
if not ready
|
|
|
|
|
then exitFailure
|
2026-10-11 17:32:12 +08:00
|
|
|
else case (optNormalize options, optDeclaration options) of
|
|
|
|
|
(Just table, Just file) -> runNormalizationOnce session table file (optFormat options == FormatJson) >>= \ok -> unless ok exitFailure
|
|
|
|
|
_ -> runSqlSession options session formatRef
|
|
|
|
|
|
|
|
|
|
-- 执行 SQL 或进入交互循环。
|
|
|
|
|
runSqlSession :: Options -> Session -> IORef OutputFormat -> IO ()
|
|
|
|
|
runSqlSession options session formatRef = case optExecute options of
|
|
|
|
|
Just sql -> do
|
|
|
|
|
ok <- runStatementOnce session (optFormat options) (stripTerminator sql)
|
|
|
|
|
unless ok exitFailure
|
|
|
|
|
Nothing -> do
|
|
|
|
|
historyFile <- resolveHistory (optHistory options)
|
|
|
|
|
runRepl session formatRef historyFile
|
2026-10-01 20:48:38 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | -d 指定了库才切,否则保持未选库
|
2026-10-01 20:48:38 +08:00
|
|
|
selectDatabase :: Session -> Text -> IO Bool
|
|
|
|
|
selectDatabase _ wanted | T.null wanted = pure True
|
|
|
|
|
selectDatabase session wanted
|
|
|
|
|
| not (isPlainIdentifier wanted) = reportError ("not a plain database name: " <> wanted) >> pure False
|
|
|
|
|
| otherwise = do
|
|
|
|
|
switched <- switchDatabase session wanted
|
|
|
|
|
case switched of
|
|
|
|
|
Left message -> reportError message >> pure False
|
|
|
|
|
Right () -> pure True
|
|
|
|
|
|
2026-10-03 02:17:46 +08:00
|
|
|
-- | 交互式读取口令;直接回车表示空口令
|
2026-10-01 20:48:38 +08:00
|
|
|
promptPassword :: Text -> IO Text
|
|
|
|
|
promptPassword user = do
|
2026-10-05 00:15:01 +08:00
|
|
|
terminal <- hIsTerminalDevice stdin
|
|
|
|
|
if terminal
|
|
|
|
|
then do
|
|
|
|
|
entered <- runInputT defaultSettings (getPassword (Just '*') (T.unpack user <> "'s password: "))
|
|
|
|
|
pure (maybe "" T.pack entered)
|
|
|
|
|
else do
|
|
|
|
|
hPutStr stderr (T.unpack user <> "'s password: ")
|
|
|
|
|
hFlush stderr
|
|
|
|
|
readPasswordInput stdin
|
2026-10-01 20:48:38 +08:00
|
|
|
|
2026-10-02 14:53:02 +08:00
|
|
|
-- | 取历史文件路径,没给就用默认
|
2026-10-01 20:48:38 +08:00
|
|
|
resolveHistory :: Maybe FilePath -> IO (Maybe FilePath)
|
|
|
|
|
resolveHistory (Just path) = pure (Just path)
|
|
|
|
|
resolveHistory Nothing = do
|
|
|
|
|
home <- getHomeDirectory
|
|
|
|
|
pure (Just (home </> ".chusql_history"))
|