Commit a89f22df65
Verified · cmc
Layout: unified · split
Sources/keycask/Commands/Edit.swift added +45
| @@ -0,0 +1,45 @@ | |||
| 1 | import ArgumentParser | ||
| 2 | import KeycaskCore | ||
| 3 | |||
| 4 | struct Edit: ParsableCommand { | ||
| 5 | static let configuration = CommandConfiguration(abstract: "Change an entry.") | ||
| 6 | |||
| 7 | @OptionGroup var global: GlobalOptions | ||
| 8 | @Argument(help: "Entry id or name.") var ref: String | ||
| 9 | @Option(name: .long, help: "New name.") var name: String? | ||
| 10 | @Option(name: [.short, .customLong("username")], help: "New username.") var username: String? | ||
| 11 | @Option(name: .long, help: "New URL.") var url: String? | ||
| 12 | @Option(name: .long, help: "New notes.") var notes: String? | ||
| 13 | @Option(name: .long, help: "Add a tag. Repeatable.") var tag: [String] = [] | ||
| 14 | @Option(name: .long, help: "Remove a tag. Repeatable.") var untag: [String] = [] | ||
| 15 | @Flag(name: .long, help: "Prompt for a new password.") var password = false | ||
| 16 | @OptionGroup var generated: PasswordOptions | ||
| 17 | |||
| 18 | mutating func validate() throws { | ||
| 19 | let changes = | ||
| 20 | [name, username, url, notes].contains { $0 != nil } | ||
| 21 | || !tag.isEmpty || !untag.isEmpty || password || generated.generate | ||
| 22 | || generated.words != nil | ||
| 23 | guard changes else { throw ValidationError("nothing to change") } | ||
| 24 | if password, generated.newPassword() != nil { | ||
| 25 | throw ValidationError("--password cannot be combined with --generate or --words") | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | func run() throws { | ||
| 30 | var open = try OpenVault.load(global) | ||
| 31 | let target = try open.vault.resolve(ref) | ||
| 32 | let newSecret: String? = | ||
| 33 | password ? try PasswordInput.read(prompt: "New password: ") : generated.newPassword() | ||
| 34 | try open.vault.update(id: target.id) { e in | ||
| 35 | if let name { e.name = name } | ||
| 36 | if let username { e.username = username } | ||
| 37 | if let url { e.url = url } | ||
| 38 | if let notes { e.notes = notes } | ||
| 39 | if let newSecret { e.password = newSecret } | ||
| 40 | let drop = Set(untag.map { $0.lowercased() }) | ||
| 41 | e.tags = e.tags.filter { !drop.contains($0.lowercased()) } + tag | ||
| 42 | } | ||
| 43 | try open.save() | ||
| 44 | } | ||
| 45 | } | ||
Sources/keycask/Commands/Rm.swift added +25
| @@ -0,0 +1,25 @@ | |||
| 1 | import ArgumentParser | ||
| 2 | import KeycaskCore | ||
| 3 | |||
| 4 | struct Rm: ParsableCommand { | ||
| 5 | static let configuration = CommandConfiguration(abstract: "Remove an entry.") | ||
| 6 | |||
| 7 | @OptionGroup var global: GlobalOptions | ||
| 8 | @Argument(help: "Entry id or name.") var ref: String | ||
| 9 | @Flag(name: .long, help: "Do not ask for confirmation.") var yes = false | ||
| 10 | |||
| 11 | func run() throws { | ||
| 12 | var open = try OpenVault.load(global) | ||
| 13 | let target = try open.vault.resolve(ref) | ||
| 14 | if !yes { | ||
| 15 | guard Terminal.stdinIsTTY else { | ||
| 16 | throw KeycaskError.usage("refusing to remove without --yes when not on a terminal") | ||
| 17 | } | ||
| 18 | guard Terminal.confirm("remove \(target.name) (\(target.id.rawValue))?") else { | ||
| 19 | throw KeycaskError.failure("aborted") | ||
| 20 | } | ||
| 21 | } | ||
| 22 | try open.vault.remove(id: target.id) | ||
| 23 | try open.save() | ||
| 24 | } | ||
| 25 | } | ||
Sources/keycask/Keycask.swift +1 −1
| @@ -9,7 +9,7 @@ struct Keycask: ParsableCommand { | |||
| 9 | static let configuration = CommandConfiguration( | 9 | static let configuration = CommandConfiguration( |
| 10 | commandName: "keycask", | 10 | commandName: "keycask", |
| 11 | abstract: "Command-line password manager. One passphrase-encrypted vault file.", | 11 | abstract: "Command-line password manager. One passphrase-encrypted vault file.", |
| 12 | subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self] | 12 | subcommands: [Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self] |
| 13 | ) | 13 | ) |
| 14 | 14 | ||
| 15 | @OptionGroup var global: GlobalOptions | 15 | @OptionGroup var global: GlobalOptions |
Tests/KeycaskCLITests/EditRmTests.swift added +75
| @@ -0,0 +1,75 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | |||
| 4 | @Suite struct EditRmTests { | ||
| 5 | @Test func editChangesFieldsAndBumpsUpdated() throws { | ||
| 6 | let cli = try CLI.initialized() | ||
| 7 | try cli.run(["add", "gh", "--tag", "a", "--generate"]) | ||
| 8 | let before = | ||
| 9 | try JSONSerialization.jsonObject( | ||
| 10 | with: Data(try cli.run(["show", "gh", "--json"]).stdout.utf8)) as! [String: Any] | ||
| 11 | let r = try cli.run([ | ||
| 12 | "edit", "gh", "--name", "github", "-u", "cmc", "--url", "https://x", "--notes", "n", | ||
| 13 | "--tag", "b", "--untag", "a", | ||
| 14 | ]) | ||
| 15 | #expect(r.status == 0) | ||
| 16 | let after = | ||
| 17 | try JSONSerialization.jsonObject( | ||
| 18 | with: Data(try cli.run(["show", "github", "--json"]).stdout.utf8)) as! [String: Any] | ||
| 19 | #expect(after["name"] as? String == "github") | ||
| 20 | #expect(after["username"] as? String == "cmc") | ||
| 21 | #expect(after["url"] as? String == "https://x") | ||
| 22 | #expect(after["notes"] as? String == "n") | ||
| 23 | #expect(after["tags"] as? [String] == ["b"]) | ||
| 24 | #expect(after["created"] as? String == before["created"] as? String) | ||
| 25 | #expect(after["id"] as? String == before["id"] as? String) | ||
| 26 | } | ||
| 27 | |||
| 28 | @Test func editPasswordFromStdinAndGenerate() throws { | ||
| 29 | let cli = try CLI.initialized() | ||
| 30 | try cli.run(["add", "gh", "--generate"]) | ||
| 31 | try cli.run(["edit", "gh", "--password"], stdin: "newpass\n") | ||
| 32 | #expect(try cli.run(["show", "gh", "--field", "password"]).stdout == "newpass\n") | ||
| 33 | try cli.run(["edit", "gh", "--generate", "--length", "30"]) | ||
| 34 | #expect(try cli.run(["show", "gh", "--field", "password"]).stdout.count == 31) | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test func editWithNoChangesIsUsageError() throws { | ||
| 38 | let cli = try CLI.initialized() | ||
| 39 | try cli.run(["add", "gh", "--generate"]) | ||
| 40 | let r = try cli.run(["edit", "gh"]) | ||
| 41 | #expect(r.status == 2) | ||
| 42 | } | ||
| 43 | |||
| 44 | @Test func editUnknownIsNotFound() throws { | ||
| 45 | let cli = try CLI.initialized() | ||
| 46 | #expect(try cli.run(["edit", "nope", "--url", "x"]).status == 3) | ||
| 47 | } | ||
| 48 | |||
| 49 | @Test func rmWithYesRemoves() throws { | ||
| 50 | let cli = try CLI.initialized() | ||
| 51 | let id = try cli.run(["add", "gh", "--generate"]).stdout.trimmingCharacters(in: .newlines) | ||
| 52 | let r = try cli.run(["rm", id, "--yes"]) | ||
| 53 | #expect(r.status == 0) | ||
| 54 | #expect(try cli.run(["show", id]).status == 3) | ||
| 55 | #expect(try cli.run(["ls"]).stdout == "") | ||
| 56 | } | ||
| 57 | |||
| 58 | @Test func rmWithoutYesAndWithoutTTYIsUsageError() throws { | ||
| 59 | let cli = try CLI.initialized() | ||
| 60 | try cli.run(["add", "gh", "--generate"]) | ||
| 61 | let r = try cli.run(["rm", "gh"], stdin: "y\n") | ||
| 62 | #expect(r.status == 2) | ||
| 63 | #expect(r.stderr.contains("--yes")) | ||
| 64 | #expect(try cli.run(["ls"]).lines.count == 1) | ||
| 65 | } | ||
| 66 | |||
| 67 | @Test func rmAmbiguousNameLists() throws { | ||
| 68 | let cli = try CLI.initialized() | ||
| 69 | try cli.run(["add", "gh", "--generate"]) | ||
| 70 | try cli.run(["add", "gh", "--generate"]) | ||
| 71 | let r = try cli.run(["rm", "gh", "--yes"]) | ||
| 72 | #expect(r.status == 5) | ||
| 73 | #expect(try cli.run(["ls"]).lines.count == 2) | ||
| 74 | } | ||
| 75 | } | ||