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 | 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] | 12 | subcommands: [Init.self, Add.self, Show.self] |
| 13 | ) | 13 | ) |
| 14 | 14 | ||
| 15 | @OptionGroup var global: GlobalOptions | 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 | exists so tests and scripts run unattended. Otherwise the CLI prompts on | 183 | exists so tests and scripts run unattended. Otherwise the CLI prompts on |
| 184 | the terminal with echo off. No TTY and no variable is exit 2. | 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 | Exit codes: | 190 | Exit codes: |
| 187 | 191 | ||
| 188 | | Code | Meaning | | 192 | | Code | Meaning | |