Commit 9069ae00a0
Verified · cmc
Layout: unified · split
Sources/keycask/Commands/Add.swift added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import Foundation | |
| 3 | import KeycaskCore | |
| 4 | ||
| 5 | enum PasswordInput { | |
| 6 | static func read(prompt: String) throws -> String { | |
| 7 | if Terminal.stdinIsTTY { | |
| 8 | return try Terminal.readSecretLine(prompt: prompt) | |
| 9 | } | |
| 10 | guard let line = Swift.readLine(strippingNewline: true) else { | |
| 11 | throw KeycaskError.usage("no password: pass one on stdin or run on a terminal") | |
| 12 | } | |
| 13 | return line | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | struct PasswordOptions: ParsableArguments { | |
| 18 | @Flag(name: .long, help: "Generate a random password.") | |
| 19 | var generate = false | |
| 20 | ||
| 21 | @Option(name: .long, help: "Length of the generated password (default 24).") | |
| 22 | var length: Int? | |
| 23 | ||
| 24 | @Option(name: .long, help: "Generate a passphrase of this many words instead.") | |
| 25 | var words: Int? | |
| 26 | ||
| 27 | mutating func validate() throws { | |
| 28 | if generate, words != nil { | |
| 29 | throw ValidationError("--generate and --words are mutually exclusive") | |
| 30 | } | |
| 31 | if let length, length < 1 { throw ValidationError("--length must be at least 1") } | |
| 32 | if let words, words < 1 { throw ValidationError("--words must be at least 1") } | |
| 33 | if length != nil, !generate, words == nil { | |
| 34 | throw ValidationError("--length requires --generate") | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /// nil means the caller must prompt. | |
| 39 | func newPassword() -> String? { | |
| 40 | if let words { return Generator.passphrase(words: words) } | |
| 41 | if generate { return Generator.password(length: length ?? Generator.defaultLength) } | |
| 42 | return nil | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | struct Add: ParsableCommand { | |
| 47 | static let configuration = CommandConfiguration(abstract: "Add an entry.") | |
| 48 | ||
| 49 | @OptionGroup var global: GlobalOptions | |
| 50 | @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String | |
| 51 | @Option(name: [.short, .customLong("username")], help: "Username.") var username: String? | |
| 52 | @Option(name: .long, help: "URL.") var url: String? | |
| 53 | @Option(name: .long, help: "Notes.") var notes: String? | |
| 54 | @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = [] | |
| 55 | @OptionGroup var password: PasswordOptions | |
| 56 | ||
| 57 | func run() throws { | |
| 58 | var open = try OpenVault.load(global) | |
| 59 | let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ") | |
| 60 | var entry = Entry( | |
| 61 | name: name, username: username, password: secret, url: url, | |
| 62 | notes: notes, tags: tag) | |
| 63 | while open.vault.entry(id: entry.id) != nil { | |
| 64 | entry = Entry( | |
| 65 | name: name, username: username, password: secret, url: url, | |
| 66 | notes: notes, tags: tag) | |
| 67 | } | |
| 68 | try open.vault.add(entry) | |
| 69 | try open.save() | |
| 70 | print(entry.id.rawValue) | |
| 71 | } | |
| 72 | } | |
Sources/keycask/Commands/Show.swift added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Show: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "Show an entry.") | |
| 6 | ||
| 7 | @OptionGroup var global: GlobalOptions | |
| 8 | @Argument(help: "Entry id or name.") var ref: String | |
| 9 | @Flag(name: .long, help: "Show the password.") var reveal = false | |
| 10 | @Option(name: .long, help: "Print one field, unmasked.") var field: String? | |
| 11 | @Flag(name: .long, help: "JSON output.") var json = false | |
| 12 | ||
| 13 | func run() throws { | |
| 14 | let open = try OpenVault.load(global) | |
| 15 | let entry = try open.vault.resolve(ref) | |
| 16 | if let field { | |
| 17 | print(try Output.field(entry, named: field)) | |
| 18 | } else if json { | |
| 19 | print(try Output.json(entry, reveal: reveal), terminator: "") | |
| 20 | } else { | |
| 21 | print(Output.text(entry, reveal: reveal), terminator: "") | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
Sources/keycask/Keycask.swift +1 −1
| @@ -9,7 +9,7 @@ struct Keycask: ParsableCommand { | ||
| 9 | 9 | static let configuration = CommandConfiguration( |
| 10 | 10 | commandName: "keycask", |
| 11 | 11 | abstract: "Command-line password manager. One passphrase-encrypted vault file.", |
| 12 | subcommands: [Init.self] | |
| 12 | subcommands: [Init.self, Add.self, Show.self] | |
| 13 | 13 | ) |
| 14 | 14 | |
| 15 | 15 | @OptionGroup var global: GlobalOptions |
Sources/keycask/Output.swift added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import Foundation | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | enum Output { | |
| 5 | static let mask = "********" | |
| 6 | ||
| 7 | static func masked(_ entry: Entry, reveal: Bool) -> Entry { | |
| 8 | guard !reveal else { return entry } | |
| 9 | var copy = entry | |
| 10 | copy.password = mask | |
| 11 | return copy | |
| 12 | } | |
| 13 | ||
| 14 | static func text(_ entry: Entry, reveal: Bool) -> String { | |
| 15 | let e = masked(entry, reveal: reveal) | |
| 16 | var lines = ["id: \(e.id.rawValue)", "name: \(e.name)"] | |
| 17 | if let u = e.username { lines.append("username: \(u)") } | |
| 18 | lines.append("password: \(e.password)") | |
| 19 | if let u = e.url { lines.append("url: \(u)") } | |
| 20 | if !e.tags.isEmpty { lines.append("tags: \(e.tags.joined(separator: ", "))") } | |
| 21 | if let n = e.notes { lines.append("notes: \(n)") } | |
| 22 | lines.append("created: \(iso(e.created))") | |
| 23 | lines.append("updated: \(iso(e.updated))") | |
| 24 | return lines.joined(separator: "\n") + "\n" | |
| 25 | } | |
| 26 | ||
| 27 | static func table(_ entries: [Entry]) -> String { | |
| 28 | guard !entries.isEmpty else { return "" } | |
| 29 | let rows = entries.map { [$0.id.rawValue, $0.name, $0.username ?? "", $0.url ?? ""] } | |
| 30 | let widths = (0..<3).map { col in rows.map { $0[col].count }.max() ?? 0 } | |
| 31 | return rows.map { row in | |
| 32 | let padded = (0..<3).map { | |
| 33 | row[$0].padding(toLength: widths[$0], withPad: " ", startingAt: 0) | |
| 34 | } | |
| 35 | return (padded + [row[3]]).joined(separator: " ") | |
| 36 | .trimmingCharacters(in: .whitespaces) | |
| 37 | }.joined(separator: "\n") + "\n" | |
| 38 | } | |
| 39 | ||
| 40 | static func json(_ entries: [Entry], reveal: Bool) throws -> String { | |
| 41 | try encode(entries.map { masked($0, reveal: reveal) }) | |
| 42 | } | |
| 43 | ||
| 44 | static func json(_ entry: Entry, reveal: Bool) throws -> String { | |
| 45 | try encode(masked(entry, reveal: reveal)) | |
| 46 | } | |
| 47 | ||
| 48 | static func field(_ entry: Entry, named name: String) throws -> String { | |
| 49 | switch name { | |
| 50 | case "id": entry.id.rawValue | |
| 51 | case "name": entry.name | |
| 52 | case "username": entry.username ?? "" | |
| 53 | case "password": entry.password | |
| 54 | case "url": entry.url ?? "" | |
| 55 | case "notes": entry.notes ?? "" | |
| 56 | case "tags": entry.tags.joined(separator: ",") | |
| 57 | case "created": iso(entry.created) | |
| 58 | case "updated": iso(entry.updated) | |
| 59 | default: throw KeycaskError.usage("unknown field \(name)") | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | private static func encode(_ value: some Encodable) throws -> String { | |
| 64 | let encoder = VaultCodec.makeEncoder() | |
| 65 | encoder.outputFormatting.insert(.prettyPrinted) | |
| 66 | do { | |
| 67 | return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n" | |
| 68 | } catch { | |
| 69 | throw KeycaskError.io("encode json: \(error)") | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | private static func iso(_ date: Date) -> String { | |
| 74 | date.formatted(.iso8601) | |
| 75 | } | |
| 76 | } | |
Tests/KeycaskCLITests/AddShowTests.swift added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | ||
| 4 | @Suite struct AddShowTests { | |
| 5 | @Test func addReadsPasswordFromStdinAndPrintsID() throws { | |
| 6 | let cli = try CLI.initialized() | |
| 7 | let r = try cli.run( | |
| 8 | ["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "dev"], | |
| 9 | stdin: "hunter2\n") | |
| 10 | #expect(r.status == 0) | |
| 11 | let id = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 12 | #expect(id.count == 8) | |
| 13 | ||
| 14 | let shown = try cli.run(["show", id]) | |
| 15 | #expect(shown.status == 0) | |
| 16 | #expect(shown.stdout.contains("name: github")) | |
| 17 | #expect(shown.stdout.contains("username: cmc")) | |
| 18 | #expect(shown.stdout.contains("password: ********")) | |
| 19 | #expect(shown.stdout.contains("tags: dev")) | |
| 20 | #expect(!shown.stdout.contains("hunter2")) | |
| 21 | } | |
| 22 | ||
| 23 | @Test func showByNameRevealAndField() throws { | |
| 24 | let cli = try CLI.initialized() | |
| 25 | try cli.run(["add", "github"], stdin: "hunter2\n") | |
| 26 | let revealed = try cli.run(["show", "github", "--reveal"]) | |
| 27 | #expect(revealed.stdout.contains("password: hunter2")) | |
| 28 | let field = try cli.run(["show", "github", "--field", "password"]) | |
| 29 | #expect(field.stdout == "hunter2\n") | |
| 30 | let missing = try cli.run(["show", "github", "--field", "url"]) | |
| 31 | #expect(missing.status == 0) | |
| 32 | #expect(missing.stdout == "\n") | |
| 33 | let unknown = try cli.run(["show", "github", "--field", "nope"]) | |
| 34 | #expect(unknown.status == 2) | |
| 35 | } | |
| 36 | ||
| 37 | @Test func jsonMasksUnlessReveal() throws { | |
| 38 | let cli = try CLI.initialized() | |
| 39 | try cli.run(["add", "github", "-u", "cmc"], stdin: "hunter2\n") | |
| 40 | let masked = try cli.run(["show", "github", "--json"]) | |
| 41 | let obj = try JSONSerialization.jsonObject(with: Data(masked.stdout.utf8)) as! [String: Any] | |
| 42 | #expect(obj["name"] as? String == "github") | |
| 43 | #expect(obj["username"] as? String == "cmc") | |
| 44 | #expect(obj["password"] as? String == "********") | |
| 45 | #expect((obj["id"] as? String)?.count == 8) | |
| 46 | #expect((obj["created"] as? String)?.hasSuffix("Z") == true) | |
| 47 | let revealed = try cli.run(["show", "github", "--json", "--reveal"]) | |
| 48 | let obj2 = | |
| 49 | try JSONSerialization.jsonObject(with: Data(revealed.stdout.utf8)) as! [String: Any] | |
| 50 | #expect(obj2["password"] as? String == "hunter2") | |
| 51 | } | |
| 52 | ||
| 53 | @Test func addGenerateAndWords() throws { | |
| 54 | let cli = try CLI.initialized() | |
| 55 | try cli.run(["add", "a", "--generate"]) | |
| 56 | try cli.run(["add", "b", "--generate", "--length", "40"]) | |
| 57 | try cli.run(["add", "c", "--words", "4"]) | |
| 58 | #expect(try cli.run(["show", "a", "--field", "password"]).stdout.count == 25) | |
| 59 | #expect(try cli.run(["show", "b", "--field", "password"]).stdout.count == 41) | |
| 60 | let words = try cli.run(["show", "c", "--field", "password"]).stdout | |
| 61 | .trimmingCharacters(in: .newlines).split(separator: "-") | |
| 62 | #expect(words.count == 4) | |
| 63 | } | |
| 64 | ||
| 65 | @Test func generateAndWordsTogetherIsUsageError() throws { | |
| 66 | let cli = try CLI.initialized() | |
| 67 | let r = try cli.run(["add", "a", "--generate", "--words", "3"]) | |
| 68 | #expect(r.status == 2) | |
| 69 | } | |
| 70 | ||
| 71 | @Test func duplicateNamesAreAllowedAndAmbiguousOnShow() throws { | |
| 72 | let cli = try CLI.initialized() | |
| 73 | let a = try cli.run(["add", "gh", "-u", "one", "--generate"]).stdout.trimmingCharacters( | |
| 74 | in: .newlines) | |
| 75 | let b = try cli.run(["add", "gh", "-u", "two", "--generate"]).stdout.trimmingCharacters( | |
| 76 | in: .newlines) | |
| 77 | let r = try cli.run(["show", "gh"]) | |
| 78 | #expect(r.status == 5) | |
| 79 | #expect(r.stderr.contains(a) && r.stderr.contains(b)) | |
| 80 | #expect(try cli.run(["show", a]).stdout.contains("username: one")) | |
| 81 | } | |
| 82 | ||
| 83 | @Test func missingEntryIsNotFound() throws { | |
| 84 | let cli = try CLI.initialized() | |
| 85 | let r = try cli.run(["show", "nope"]) | |
| 86 | #expect(r.status == 3) | |
| 87 | #expect(r.stderr == "nope: not found\n") | |
| 88 | } | |
| 89 | ||
| 90 | @Test func wrongPassphraseCannotDecrypt() throws { | |
| 91 | let cli = try CLI.initialized() | |
| 92 | let r = try cli.run(["show", "x"], passphrase: "wrong") | |
| 93 | #expect(r.status == 4) | |
| 94 | #expect(r.stderr.contains("cannot decrypt")) | |
| 95 | } | |
| 96 | ||
| 97 | @Test func missingVaultIsNotFound() throws { | |
| 98 | let cli = try CLI() | |
| 99 | let r = try cli.run(["show", "x"]) | |
| 100 | #expect(r.status == 3) | |
| 101 | #expect(r.stderr.contains("keycask init")) | |
| 102 | } | |
| 103 | ||
| 104 | @Test func corruptVaultIsFailure() throws { | |
| 105 | let cli = try CLI.initialized() | |
| 106 | try Data("{}".utf8).write(to: cli.vault) | |
| 107 | let r = try cli.run(["show", "x"]) | |
| 108 | #expect(r.status == 1) | |
| 109 | #expect(r.stderr.hasPrefix("vault is corrupt")) | |
| 110 | } | |
| 111 | } | |
docs/superpowers/specs/2026-09-17-keycask-design.md +4
| @@ -183,6 +183,10 @@ Passphrase input: if `KEYCASK_PASSPHRASE` is set, its value is used. It | ||
| 183 | 183 | exists so tests and scripts run unattended. Otherwise the CLI prompts on |
| 184 | 184 | the terminal with echo off. No TTY and no variable is exit 2. |
| 185 | 185 | |
| 186 | Password input for `add` and `edit --password`: on a terminal, a hidden | |
| 187 | prompt. Without a terminal, the first line of stdin. Neither available is | |
| 188 | exit 2. | |
| 189 | ||
| 186 | 190 | Exit codes: |
| 187 | 191 | |
| 188 | 192 | | Code | Meaning | |