changeset 21:ea65ab890f66

More work to support interactive feature.
author David Barts <n5jrn@me.com>
date Tue, 02 Jul 2024 11:27:39 -0700
parents 4391afcf6bd0
children 07406c4af4a5
files src/main/kotlin/name/blackcap/passman/Arguments.kt src/main/kotlin/name/blackcap/passman/Console.kt src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt src/main/kotlin/name/blackcap/passman/Database.kt src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt src/main/kotlin/name/blackcap/passman/ExportSubcommand.kt src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt src/main/kotlin/name/blackcap/passman/ListSubcommand.kt src/main/kotlin/name/blackcap/passman/Main.kt src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt src/main/kotlin/name/blackcap/passman/MessagedException.kt src/main/kotlin/name/blackcap/passman/PasswordSubcommand.kt src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt src/main/kotlin/name/blackcap/passman/RenameSubcommand.kt src/main/kotlin/name/blackcap/passman/Shplitter.kt src/main/kotlin/name/blackcap/passman/Subcommand.kt src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt
diffstat 17 files changed, 128 insertions(+), 131 deletions(-) [+]
line wrap: on
line diff
--- a/src/main/kotlin/name/blackcap/passman/Arguments.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/Arguments.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -59,12 +59,11 @@
         commandLine = try {
             DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
-            throw RuntimeException("this will never happen")
+            throw SubcommandException(message = e.message ?: "syntax error", status = 2)
         }
         if (commandLine.hasOption("help")) {
             HelpFormatter().printHelp("$SHORTNAME $name [options] csv_file", options)
-            exitProcess(0)
+            throw SubcommandException(status = 0)
         }
     }
 
@@ -92,11 +91,10 @@
     private fun CommandLine.getCharOptionValue(name: String): Char {
         val optionValue = getOptionValue(name)
         when (optionValue.length) {
-            0 -> die("--$name value must not be empty")
+            0 -> throw SubcommandException(message = "--$name value must not be empty")
             1 -> return optionValue[0]
-            else -> die("--$name value must be a single character")
+            else -> throw SubcommandException(message = "--$name value must be a single character")
         }
-        throw RuntimeException("this will never happen")
     }
 }
 
--- a/src/main/kotlin/name/blackcap/passman/Console.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/Console.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -33,7 +33,7 @@
 
 private fun <T> must(getter: () -> T, checker: (T) -> Boolean): T {
     while (true) {
-        var got = getter()
+        val got = getter()
         if (checker(got)) {
             return got
         }
@@ -42,9 +42,8 @@
 }
 
 private fun <T> doConsoleIo(getter: () -> T?, message: String): T {
-    val ret = getter()
-    if (ret == null) {
-        die(message)
-    }
-    return ret!!
+    val ret = getter() ?: throw ConsoleException(message)
+    return ret
 }
+
+class ConsoleException(message: String, cause: Throwable? = null) : MessagedException(message, cause)
--- a/src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/CreateSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -1,7 +1,6 @@
 package name.blackcap.passman
 
 import org.apache.commons.cli.*
-import kotlin.system.exitProcess
 
 class CreateSubcommand(): Subcommand() {
     private companion object {
@@ -13,7 +12,7 @@
     }
     private lateinit var commandLine: CommandLine
 
-    override fun run(args: Array<String>) {
+    override fun run(args: Array<String>): {
         val options = Options().apply {
             addOption("g", GENERATE, false, "Use password generator.")
             addOption("h", HELP, false, "Print this help message.")
@@ -24,14 +23,14 @@
         try {
             commandLine = DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
+            throw SubcommandException(message = e.message ?: "syntax error", status = 2, cause = e)
         }
         if (commandLine.hasOption(HELP)) {
             HelpFormatter().printHelp("$SHORTNAME create [options]", options)
-            exitProcess(0)
+            return
         }
         checkArguments()
-        val db = Database.open()
+        val db = Database.default
 
         val entry = if (commandLine.hasOption(GENERATE)) {
             val rawLength = commandLine.getOptionValue(LENGTH)
@@ -41,7 +40,7 @@
                 -1
             }
             if (length < MIN_GENERATED_LENGTH) {
-                die("${see(rawLength)} - invalid length")
+                throw SubcommandException(message = "${see(rawLength)} - invalid length")
             }
             Entry.withGeneratedPassword(length,
                 commandLine.hasOption(SYMBOLS),
@@ -57,7 +56,7 @@
             result.next()
             val count = result.getInt(1)
             if (count > 0) {
-                die("record matching ${see(entry.name)} already exists")
+                throw SubcommandException(message = "record matching ${see(entry.name)} already exists")
             }
         }
 
@@ -100,10 +99,10 @@
             }
         }
         if (bad) {
-            exitProcess(2);
+            throw SubcommandException(status = 2)
         }
         if (commandLine.args.isNotEmpty()) {
-            die("unexpected trailing arguments", 2)
+            throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
     }
 }
--- a/src/main/kotlin/name/blackcap/passman/Database.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/Database.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -12,6 +12,7 @@
         private const val PLAINTEXT = "This is a test."
         private const val SALT_LENGTH = 16
         private const val DEFAULT_PROMPT = "Decryption key: "
+        lateinit var default: Database
 
         fun open(passwordPrompt: String = DEFAULT_PROMPT, fileName: String = DB_FILE,
                  create: Boolean = true): Database {
@@ -20,10 +21,14 @@
                 if (create) {
                     error("initializing database ${see(fileName)}")
                 } else {
-                    die("${see(fileName)} not found")
+                    throw DatabaseException("${see(fileName)} not found")
                 }
             }
-            val masterPassword = getPassword(passwordPrompt, !exists)
+            val masterPassword = try {
+                getPassword(passwordPrompt, !exists)
+            } catch (e: ConsoleException) {
+                throw DatabaseException(e.message, cause = e)
+            }
             val conn = DriverManager.getConnection("jdbc:sqlite:$fileName")
             val enc = if (exists) { reuse(conn, masterPassword) } else { init(conn, masterPassword) }
             val ret = Database(conn, enc)
@@ -37,15 +42,14 @@
                     it.setString(1, "salt")
                     val result = it.executeQuery()
                     if (!result.next()) {
-                        die("corrupt database, missing salt parameter")
+                        throw DatabaseException("corrupt database, missing salt parameter")
                     }
                     val salt = result.getBytes(1)
                     return Encryption(masterPassword, salt)
                 }
             } catch (e: SQLException) {
                 e.printStackTrace()
-                die("unable to reopen database")
-                throw RuntimeException("this will never happen")
+                throw DatabaseException("unable to reopen database", e)
             }
         }
 
@@ -83,8 +87,7 @@
                 return encryption
             } catch (e: SQLException) {
                 e.printStackTrace()
-                die("unable to initialize database")
-                throw RuntimeException("this will never happen")
+                throw DatabaseException("unable to initialize database", e)
             }
         }
 
@@ -94,7 +97,7 @@
                     stmt.setString(1, "test")
                     val result = stmt.executeQuery()
                     if (!result.next()) {
-                        die("corrupt database, missing test parameter")
+                        throw DatabaseException("corrupt database, missing test parameter")
                     }
                     val readFromDb = result.getDecryptedString(1, database.encryption)
                     if (readFromDb != PLAINTEXT) {
@@ -104,9 +107,9 @@
                 }
             } catch (e: SQLException) {
                 e.printStackTrace()
-                die("unable to verify decryption key")
+                throw DatabaseException("unable to verify decryption key", e)
             } catch (e: GeneralSecurityException) {
-                die("invalid decryption key")
+                throw DatabaseException("invalid decryption key", e)
             }
         }
     }
@@ -114,6 +117,8 @@
     fun makeKey(name: String): Long = Hashing.hash(encryption.encryptFromString0(name.lowercase()))
 }
 
+class DatabaseException(message: String, cause: Throwable? = null) : MessagedException(message, cause)
+
 fun ResultSet.getDecryptedString(columnIndex: Int, encryption: Encryption): String? {
     return encryption.decryptToString(getBytes(columnIndex) ?: return null)
 }
--- a/src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/DeleteSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -1,17 +1,15 @@
 package name.blackcap.passman
 
-import kotlin.system.exitProcess
-
 class DeleteSubcommand(): Subcommand() {
-    override fun run(args: Array<String>) {
+    override fun run(args: Array<String>): {
         if (args.isEmpty()) {
-            die("expecting a site name", 2)
+            throw SubcommandException(message = "expecting a site name", status = 2)
         }
         if (args[0] == "-h" || args[0].startsWith("--h")) {
             println("usage: passman delete name [...]")
-            exitProcess(0)
+            return 0
         }
-        val db = Database.open()
+        val db = Database.default
         var errors = 0
         for (nameIn in args) {
             db.connection.prepareStatement("delete from passwords where id = ?").use {
@@ -22,6 +20,8 @@
                 }
             }
         }
-        exitProcess(if (errors > 0) 1 else 0)
+        if (errors > 0) {
+            throw SubcommandException()
+        }
     }
 }
--- a/src/main/kotlin/name/blackcap/passman/ExportSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/ExportSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -18,22 +18,22 @@
     private val options = ImportExportArguments()
     private lateinit var csvFile: String
 
-    override fun run(args: Array<String>) {
+    override fun run(args: Array<String>): {
         parseArguments(args)
-        db = Database.open()
+        db = Database.default
         try {
             doExport()
         } catch (e: IOException) {
-            die(e.message ?: "I/O error")
+            throw SubcommandException(message = e.message ?: "I/O error", cause = e)
         }
     }
 
     private fun parseArguments(args: Array<String>) {
         val params = parseInto("export", args, options)
         when (params.size) {
-            0 -> die("expecting CSV file name", 2)
+            0 -> throw SubcommandException(message = "expecting CSV file name", status = 2)
             1 -> csvFile = params[0]
-            else -> die("unexpected trailing arguments", 2)
+            else -> throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
         csvDateFormat = SimpleDateFormat(options.format).apply {
             timeZone = TimeZone.getTimeZone(options.zone)
@@ -74,4 +74,4 @@
         val value = getDate(columnIndex)
         return if (value == null) NULL else csvDateFormat.format(value)
     }
-}
\ No newline at end of file
+}
--- a/src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -22,23 +22,23 @@
 
     override fun run(args: Array<String>) {
         parseArguments(args)
-        db = Database.open()
+        db = Database.default
         try {
             doImport()
         } catch (e: IOException) {
-            die(e.message ?: "I/O error")
+            throw SubcommandException(message = e.message ?: "I/O error", cause = e)
         } catch (e: CsvException) {
-            val message = e.message ?: "CSV error"
-            die("line $line, $message")
+            val baseMessage = e.message ?: "CSV error"
+            throw SubcommandException(message = "line $line, $baseMessage", cause = e)
         }
     }
 
     private fun parseArguments(args: Array<String>) {
         val params = parseInto("import", args, options)
         when (params.size) {
-            0 -> die("expecting CSV file name", 2)
+            0 -> throw SubcommandException(message = "expecting CSV file name", status = 2)
             1 -> csvFile = params[0]
-            else -> die("unexpected trailing arguments", 2)
+            else -> throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
         csvDateFormat = SimpleDateFormat(options.format).apply {
             timeZone = TimeZone.getTimeZone(options.zone)
@@ -85,7 +85,7 @@
 
     private fun fromCsv(fields: Array<String>): Entry {
         if (fields.size != NFIELDS) {
-            die("line $line, expected $NFIELDS fields but got ${fields.size}")
+            throw SubcommandException(message = "line $line, expected $NFIELDS fields but got ${fields.size}")
         }
         return Entry(
             name = fields[0],
@@ -105,8 +105,7 @@
         try {
             return csvDateFormat.parse(unparsed)
         } catch (e: ParseException) {
-            die("${see(unparsed)} - invalid date/time string")
-            throw e /* kotlin is too stupid to realize this never happens */
+            throw SubcommandException(message = "${see(unparsed)} - invalid date/time string", cause = e)
         }
     }
 
--- a/src/main/kotlin/name/blackcap/passman/ListSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/ListSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -2,7 +2,6 @@
 
 import org.apache.commons.cli.*
 import java.util.regex.PatternSyntaxException
-import kotlin.system.exitProcess
 
 class ListSubcommand(): Subcommand() {
     private companion object {
@@ -57,14 +56,14 @@
         try {
             commandLine = DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
+            throw SubcommandException(message = e.message ?: "syntax error", cause = e, status = 2)
         }
         if (commandLine.hasOption(HELP)) {
             HelpFormatter().printHelp("$SHORTNAME list [options]", options)
-            exitProcess(0)
+            return
         }
         if (commandLine.args.isNotEmpty()) {
-            die("unexpected trailing arguments", 2)
+            throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
 
         STRING_OPTIONS.forEach {
@@ -82,7 +81,7 @@
                     }
                     matchers[it.name]!! += { x -> x is String && x.contains(Regex(value, regexOptions)) }
                 } catch (e: PatternSyntaxException) {
-                    die("${see(value)} - invalid regular expression")
+                    throw SubcommandException(message = "${see(value)} - invalid regular expression", cause = e, status = 2)
                 }
             }
         }
@@ -90,7 +89,7 @@
         TIME_OPTIONS.forEach {
             commandLine.getOptionValues(it.name)?.forEach { rawValue ->
                 if (rawValue.isEmpty()) {
-                    die("empty string is not a valid time expression")
+                    throw SubcommandException(message = "empty string is not a valid time expression")
                 }
                 val (op, exp) = when(rawValue.first()) {
                     '+', '>' -> Pair<Char, String>('>', rawValue.substring(1))
@@ -100,8 +99,7 @@
                 }
                 val value = parseDateTime(exp)
                 if (value == null) {
-                    die("${see(rawValue)} - invalid time expression")
-                    throw RuntimeException("will never happen")
+                    throw SubcommandException(message = "${see(rawValue)} - invalid time expression")
                 }
                 if (it.name !in matchers) {
                     matchers[it.name] = mutableListOf<(Any?) -> Boolean>()
@@ -117,7 +115,7 @@
     }
 
     private fun runQuery(): Unit {
-        val db = Database.open()
+        val db = Database.default
 
         db.connection.prepareStatement("select $NAME, $USERNAME, $NOTES, $CREATED, $MODIFIED, $ACCESSED from passwords").use {
             val results = it.executeQuery()
--- a/src/main/kotlin/name/blackcap/passman/Main.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/Main.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -15,6 +15,7 @@
     }
     val subcommand = args[0];
     val scArgs = args.sliceArray(1 until args.size)
+    Database.default = Database.open()
     runSubcommand(subcommand, scArgs)
 }
 
--- a/src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/MergeSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -2,7 +2,6 @@
 
 import org.apache.commons.cli.*
 import java.sql.ResultSet
-import kotlin.system.exitProcess
 
 class MergeSubcommand(): Subcommand() {
     private companion object {
@@ -15,7 +14,10 @@
 
     override fun run(args: Array<String>) {
         parseArguments(args)
-        db = Database.open()
+        if (commandLine.hasOption(MergeSubcommand.HELP)) {
+            return
+        }
+        db = Database.default
         doMerge()
     }
 
@@ -28,17 +30,17 @@
         try {
             commandLine = DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
+            throw SubcommandException(message = e.message ?: "syntax error", status = 2, cause = e)
         }
         if (commandLine.hasOption(MergeSubcommand.HELP)) {
             HelpFormatter().printHelp("$SHORTNAME merge [options] other_database", options)
-            exitProcess(0)
+            return
         }
         if (commandLine.args.isEmpty()) {
-            die("expecting other database name", 2)
+            throw SubcommandException(message = "expecting other database name", status = 2)
         }
         if (commandLine.args.size > 1) {
-            die("unexpected trailing arguments", 2)
+            throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
     }
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/name/blackcap/passman/MessagedException.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -0,0 +1,6 @@
+package name.blackcap.passman
+
+// Exception that always has a non-null message.
+open class MessagedException(_message: String, _cause: Throwable? = null) : Exception(_message, _cause) {
+    override val message = _message
+}
--- a/src/main/kotlin/name/blackcap/passman/PasswordSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/PasswordSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -3,23 +3,22 @@
 import java.nio.file.Files
 import java.nio.file.Path
 import java.nio.file.StandardCopyOption
-import kotlin.system.exitProcess
 
 class PasswordSubcommand : Subcommand() {
     override fun run(args: Array<String>) {
         // Parse arguments
         if (args.size > 0 && (args[0] == "-h" || args[0].startsWith("--h"))) {
             println("usage: passman password")
-            exitProcess(0)
+            return
         }
         if (!args.isEmpty()) {
-            die("unexpected arguments", 2)
+            throw SubcommandException(message = "unexpected arguments", status = 2)
         }
 
         // Open databases
         println("Changing database encryption key...")
         val oldPath = Path.of(DB_FILE)
-        val oldDb = Database.open(fileName = DB_FILE, passwordPrompt = "Old database key: ")
+        val oldDb = Database.default
         val newPath = Path.of(NEW_DB_FILE)
         if (Files.exists(newPath)) {
             println("WARNING: deleting ${see(NEW_DB_FILE)}")
@@ -31,7 +30,9 @@
         println("WARNING: do not interrupt this process or data may be lost!")
         copyRecords(oldDb, newDb)
 
-        // Wrap up
+        // Wrap up. XXX - this closes Database.default, so this subcommand may
+        // only be invoked directly from the shell prompt, never in interactive
+        // mode.
         oldDb.connection.close()
         newDb.connection.close()
         Files.move(newPath, oldPath, StandardCopyOption.REPLACE_EXISTING)
--- a/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/ReadSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -1,7 +1,6 @@
 package name.blackcap.passman
 
 import org.apache.commons.cli.*
-import kotlin.system.exitProcess
 
 class ReadSubcommand(): Subcommand() {
     private companion object {
@@ -23,24 +22,23 @@
         try {
             commandLine = DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
+            throw SubcommandException(message = e.message ?: "syntax error", status = 2, cause = e)
         }
         if (commandLine.hasOption(HELP)) {
             HelpFormatter().printHelp("$SHORTNAME read [options] name", options)
-            exitProcess(0)
+            return
         }
         if (commandLine.args.isEmpty()) {
-            die("expecting site name", 2)
+            throw SubcommandException(message = "expecting site name", status = 2)
         }
         if (commandLine.args.size > 1) {
-            die("unexpected trailing arguments", 2)
+            throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
         val nameIn = commandLine.args[0];
-        val db = Database.open()
+        val db = Database.default
         val entry = Entry.fromDatabase(db, nameIn)
         if (entry == null) {
-            die("no record matches ${see(nameIn)}")
-            return // Kotlin is too stupid to realize we never get here
+            throw SubcommandException(message = "no record matches ${see(nameIn)}")
         }
         try {
             print(ALT_SB + CLEAR)
--- a/src/main/kotlin/name/blackcap/passman/RenameSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/RenameSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -1,9 +1,6 @@
 package name.blackcap.passman
 
 import org.apache.commons.cli.*
-import java.sql.PreparedStatement
-import java.sql.Types
-import kotlin.system.exitProcess
 
 class RenameSubcommand(): Subcommand() {
     private companion object {
@@ -17,7 +14,10 @@
 
     override fun run(args: Array<String>) {
         parseArguments(args)
-        db = Database.open()
+        if (commandLine.hasOption(HELP)) {
+            return
+        }
+        db = Database.default
         renameIt()
     }
 
@@ -29,17 +29,17 @@
         try {
             commandLine = DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
+            throw SubcommandException(message = e.message ?: "syntax error", status = 2, cause = e)
         }
         if (commandLine.hasOption(HELP)) {
             HelpFormatter().printHelp("$SHORTNAME rename [options] source destination", options)
-            exitProcess(0)
+            return
         }
         if (commandLine.args.size < 2) {
-            die("expecting source and destination", 2)
+            throw SubcommandException(message = "expecting source and destination", status = 2)
         }
         if (commandLine.args.size > 2) {
-            die("unexpected trailing arguments", 2)
+            throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
         source = commandLine.args[0]
         destination = commandLine.args[1]
@@ -50,13 +50,13 @@
         val did = db.makeKey(destination)
 
         if(!recordExists(sid)) {
-            die("no record matches ${see(source)}")
+            throw SubcommandException(message = "no record matches ${see(source)}")
         }
         if (recordExists(did)) {
             if (commandLine.hasOption(FORCE)) {
                 deleteRecord(did)
             } else {
-                die("record matching ${see(destination)} already exists")
+                throw SubcommandException(message = "record matching ${see(destination)} already exists")
             }
         }
 
--- a/src/main/kotlin/name/blackcap/passman/Shplitter.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/Shplitter.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -108,22 +108,13 @@
             else -> { current.append(ch); false }
         }
 
+    // XXX - multiline commands achieved with backslash-newline
+    // sequences not supported, so this only works with single-
+    // line commands.
     private fun backslash(ch: Char): Boolean {
-        val last = lastState()
-        if (ch == '\n' && last !in QUOTING) {
-            // if not quoting, \\n makes a normal whitespace out of command terminator
-            popState()
-            return true
-        } else if (last == ::space) {
-            // start a new unquoted string no matter what
-            current.append(ch)
-            state = ::nonspace
-            return false
-        } else {
-            // continue existing string no matter what
-            current.append(ch)
-            popState()
-            return false
-        }
+        // continue existing string no matter what
+        current.append(ch)
+        popState()
+        return false
     }
 }
--- a/src/main/kotlin/name/blackcap/passman/Subcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/Subcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -3,3 +3,10 @@
 abstract class Subcommand() {
     abstract fun run(args: Array<String>): Unit
 }
+
+// Replaces fatal errors that used to exit the process.
+class SubcommandException(message: String? = null, cause: Throwable? = null, status: Int = 1) : Exception(message, cause) {
+    private val _status = status
+    val status: Int
+        get() = _status
+}
--- a/src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt	Sun Jun 30 22:28:52 2024 -0700
+++ b/src/main/kotlin/name/blackcap/passman/UpdateSubcommand.kt	Tue Jul 02 11:27:39 2024 -0700
@@ -4,7 +4,6 @@
 import java.sql.Types
 import java.util.*
 import kotlin.properties.Delegates
-import kotlin.system.exitProcess
 
 class UpdateSubcommand(): Subcommand() {
     private lateinit var commandLine: CommandLine
@@ -28,6 +27,9 @@
 
     override fun run(args: Array<String>) {
         parseArguments(args)
+        if (commandLine.hasOption(HELP)) {
+            return
+        }
         checkDatabase()
         try {
             update()
@@ -47,14 +49,14 @@
         try {
             commandLine = DefaultParser().parse(options, args)
         } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
+            throw SubcommandException(message = e.message ?: "syntax error", status = 2, cause = e)
         }
         if (commandLine.hasOption(HELP)) {
             HelpFormatter().printHelp("$SHORTNAME update [options] name", options)
-            exitProcess(0)
+            return
         }
         checkArguments()
-        db = Database.open()
+        db = Database.default
         nameIn = commandLine.args[0]
         id = db.makeKey(nameIn)
         val rawLength = commandLine.getOptionValue(LENGTH)
@@ -64,7 +66,7 @@
             -1
         }
         if (length < MIN_GENERATED_LENGTH) {
-            die("${see(rawLength)} - invalid length")
+            throw SubcommandException(message = "${see(rawLength)} - invalid length")
         }
     }
 
@@ -79,13 +81,13 @@
             }
         }
         if (bad) {
-            exitProcess(2);
+            throw SubcommandException(status = 2)
         }
         if (commandLine.args.isEmpty()) {
-            die("expecting site name", 2)
+            throw SubcommandException(message = "expecting site name", status = 2)
         }
         if (commandLine.args.size > 1) {
-            die("unexpected trailing arguments", 2)
+            throw SubcommandException(message = "unexpected trailing arguments", status = 2)
         }
     }
 
@@ -96,7 +98,7 @@
             result.next()
             val count = result.getInt(1)
             if (count < 1) {
-                die("no record matches " + see(nameIn))
+                throw SubcommandException(message = "no record matches " + see(nameIn))
             }
         }
     }
@@ -140,9 +142,13 @@
     }
 
     private fun updateOne(name: String): Unit {
-        val prompt = name.replaceFirstChar { it.titlecase(Locale.getDefault()) } + ": "
+        val prompt = name.replaceFirstChar { it.uppercase(Locale.getDefault()) } + ": "
         val value: Any? = if (name in SENSITIVE_FIELDS) {
-            updatePassword()
+            try {
+                getPassword(prompt)
+            } catch (e: ConsoleException) {
+                throw SubcommandException(message = e.message, cause = e)
+            }
         } else {
             val rawValue = readLine(prompt)
             if (name in NULLABLE_FIELDS && rawValue == NULL_SPECIFIED) {
@@ -181,17 +187,4 @@
         addOne("password", newPassword)
     }
 
-    private fun updatePassword(): CharArray {
-        while (true) {
-            val pw1 = getPassword("Password: ")
-            if (pw1.isEmpty()) {
-                return pw1
-            }
-            val pw2 = getPassword("Verification: ")
-            if (pw1 contentEquals pw2) {
-                return pw1
-            }
-            error("mismatch, try again")
-        }
-    }
 }