CLI: init, add, show, ls, find, edit, rm, generate, clip !8

merged merged by cmc on 2026-09-18 01:35 UTC · krz/keycask:cli into main

29 files changed, +1526 −9

Layout: unified · split

README.md +47 −1
@@ -3,11 +3,57 @@
33Command-line password manager. One passphrase-encrypted vault file.
44Swift, runs on macOS, Linux, and Windows.
55
6Work in progress. Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`.
6## install
7
8```sh
9swift build -c release
10cp .build/release/keycask ~/.local/bin/
11```
12
13## use
14
15```sh
16keycask init
17keycask add github -u cmc --url https://github.com --tag dev --generate
18keycask add mail --words 6
19keycask add bank # prompts for the password
20keycask show github # password masked
21keycask show github --reveal
22keycask show github --field password # raw value, for scripts
23keycask clip github # clipboard, clears after 45s
24keycask ls --tag dev
25keycask find example
26keycask edit github --tag work --untag dev
27keycask rm github --yes
28keycask generate --words 5 --copy
29```
30
31Every read command takes `--json`. Passwords are masked unless `--reveal`.
32
33Names are labels and may repeat. Every command that takes a name also
34takes the entry's 8-character id, which `ls` and `add` print. An
35ambiguous name lists the candidates.
36
37## files
38
39| what | default | override |
40|---|---|---|
41| vault | `$XDG_DATA_HOME/keycask/vault.kc`, else `~/.local/share/keycask/vault.kc` (`%LOCALAPPDATA%\keycask\vault.kc` on Windows) | `KEYCASK_VAULT`, `--vault` |
42| passphrase | prompted | `KEYCASK_PASSPHRASE` |
43
44The vault is a JSON envelope: PBKDF2-HMAC-SHA256 (600000 rounds) over
45the passphrase, ChaCha20-Poly1305 over the entries. Writes are atomic.
46
47## exit codes
48
490 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous name.
750
851## develop
952
1053```sh
1154swift build
1255swift test
56swift format lint --strict --recursive Sources Tests
1357```
58
59Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`.
Sources/keycask/AtomicFile.swift added +69
@@ -0,0 +1,69 @@
1import Foundation
2import KeycaskCore
3
4#if canImport(Darwin)
5 import Darwin
6#elseif canImport(Glibc)
7 import Glibc
8#elseif canImport(Musl)
9 import Musl
10#elseif os(Windows)
11 import WinSDK
12#endif
13
14enum AtomicFile {
15 static func write(_ data: Data, to url: URL) throws {
16 let directory = url.deletingLastPathComponent()
17 let temp = url.appendingPathExtension("tmp")
18 do {
19 try FileManager.default.createDirectory(
20 at: directory, withIntermediateDirectories: true)
21 try writePrivate(data, to: temp)
22 try replace(url, with: temp)
23 } catch let error as KeycaskError {
24 try? FileManager.default.removeItem(at: temp)
25 throw error
26 } catch {
27 try? FileManager.default.removeItem(at: temp)
28 throw KeycaskError.io("write \(url.path): \(error)")
29 }
30 }
31
32 #if os(Windows)
33 private static func writePrivate(_ data: Data, to url: URL) throws {
34 try data.write(to: url)
35 let handle = try FileHandle(forWritingTo: url)
36 try handle.synchronize()
37 try handle.close()
38 }
39
40 private static func replace(_ target: URL, with temp: URL) throws {
41 let ok = temp.path.withCString(encodedAs: UTF16.self) { src in
42 target.path.withCString(encodedAs: UTF16.self) { dst in
43 MoveFileExW(src, dst, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
44 }
45 }
46 guard ok.boolValue else {
47 throw KeycaskError.io("rename \(temp.path): error \(GetLastError())")
48 }
49 }
50 #else
51 private static func writePrivate(_ data: Data, to url: URL) throws {
52 _ = unlink(url.path)
53 let fd = open(url.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600)
54 guard fd >= 0 else {
55 throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))")
56 }
57 let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
58 try handle.write(contentsOf: data)
59 try handle.synchronize()
60 try handle.close()
61 }
62
63 private static func replace(_ target: URL, with temp: URL) throws {
64 guard rename(temp.path, target.path) == 0 else {
65 throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))")
66 }
67 }
68 #endif
69}
Sources/keycask/Clipboard.swift added +152
@@ -0,0 +1,152 @@
1import Foundation
2import KeycaskCore
3
4enum Clipboard {
5 struct Handoff: Codable, Equatable {
6 var secret: String
7 }
8
9 struct Tool: Equatable {
10 let copy: [String]
11 let paste: [String]
12 }
13
14 static let timeoutSeconds = 45
15
16 static func shouldClear(secret: String, current: String?) -> Bool {
17 current == secret
18 }
19
20 static func findTool(
21 path: String = ProcessInfo.processInfo.environment["PATH"] ?? "",
22 fileManager: FileManager = .default
23 ) -> Tool? {
24 #if os(Windows)
25 let separator: Character = ";"
26 let candidates: [(copy: [String], paste: [String])] = [
27 (["clip.exe"], ["powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"])
28 ]
29 #elseif os(macOS)
30 let separator: Character = ":"
31 let candidates: [(copy: [String], paste: [String])] = [(["pbcopy"], ["pbpaste"])]
32 #else
33 let separator: Character = ":"
34 let candidates: [(copy: [String], paste: [String])] = [
35 (["wl-copy"], ["wl-paste", "--no-newline"]),
36 (
37 ["xclip", "-selection", "clipboard"],
38 ["xclip", "-selection", "clipboard", "-o"]
39 ),
40 ]
41 #endif
42 let dirs = path.split(separator: separator).map(String.init)
43 func locate(_ name: String) -> String? {
44 for dir in dirs {
45 let full = URL(fileURLWithPath: dir).appendingPathComponent(name).path
46 if fileManager.isExecutableFile(atPath: full) { return full }
47 }
48 return nil
49 }
50 for candidate in candidates {
51 guard let copy = locate(candidate.copy[0]), let paste = locate(candidate.paste[0])
52 else {
53 continue
54 }
55 return Tool(
56 copy: [copy] + candidate.copy.dropFirst(),
57 paste: [paste] + candidate.paste.dropFirst())
58 }
59 return nil
60 }
61
62 /// Removes exactly one trailing line break, which some paste tools add.
63 static func stripTrailingNewline(_ s: String) -> String {
64 var scalars = s.unicodeScalars
65 guard scalars.last == "\n" else { return s }
66 scalars.removeLast()
67 if scalars.last == "\r" { scalars.removeLast() }
68 return String(scalars)
69 }
70
71 static func read() throws -> String {
72 let tool = try requireTool()
73 let (status, output) = try runTool(tool.paste, input: nil)
74 guard status == 0 else { return "" }
75 return stripTrailingNewline(output)
76 }
77
78 static func write(_ text: String) throws {
79 let tool = try requireTool()
80 let (status, _) = try runTool(tool.copy, input: text)
81 guard status == 0 else { throw KeycaskError.failure("clipboard tool failed") }
82 }
83
84 static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws {
85 _ = try requireTool()
86 let handoff = Handoff(secret: secret)
87 try write(secret)
88 let process = Process()
89 process.executableURL = Bundle.main.executableURL
90 process.arguments = ["clipboard-daemon", String(seconds)]
91 process.standardOutput = FileHandle.nullDevice
92 process.standardError = FileHandle.nullDevice
93 let input = Pipe()
94 process.standardInput = input
95 do {
96 try process.run()
97 input.fileHandleForWriting.write(try JSONEncoder().encode(handoff))
98 try input.fileHandleForWriting.close()
99 } catch {
100 throw KeycaskError.failure("start clipboard daemon: \(error)")
101 }
102 }
103
104 static func runDaemon(seconds: Int) throws {
105 let data = FileHandle.standardInput.readDataToEndOfFile()
106 let handoff: Handoff
107 do {
108 handoff = try JSONDecoder().decode(Handoff.self, from: data)
109 } catch {
110 throw KeycaskError.failure("bad handoff")
111 }
112 Thread.sleep(forTimeInterval: TimeInterval(seconds))
113 let current = try? read()
114 guard shouldClear(secret: handoff.secret, current: current) else { return }
115 try write("")
116 }
117
118 private static func requireTool() throws -> Tool {
119 guard let tool = findTool() else {
120 #if os(Windows)
121 let hint = "clip.exe and powershell.exe"
122 #elseif os(macOS)
123 let hint = "pbcopy and pbpaste"
124 #else
125 let hint = "wl-clipboard or xclip"
126 #endif
127 throw KeycaskError.failure("no clipboard tool found: install \(hint)")
128 }
129 return tool
130 }
131
132 private static func runTool(_ argv: [String], input: String?) throws -> (Int32, String) {
133 let process = Process()
134 process.executableURL = URL(fileURLWithPath: argv[0])
135 process.arguments = Array(argv.dropFirst())
136 let out = Pipe()
137 process.standardOutput = out
138 process.standardError = FileHandle.nullDevice
139 let inPipe = Pipe()
140 process.standardInput = inPipe
141 do {
142 try process.run()
143 } catch {
144 throw KeycaskError.failure("run \(argv[0]): \(error)")
145 }
146 if let input { inPipe.fileHandleForWriting.write(Data(input.utf8)) }
147 try? inPipe.fileHandleForWriting.close()
148 let data = out.fileHandleForReading.readDataToEndOfFile()
149 process.waitUntilExit()
150 return (process.terminationStatus, String(decoding: data, as: UTF8.self))
151 }
152}
Sources/keycask/Commands/Add.swift added +75
@@ -0,0 +1,75 @@
1import ArgumentParser
2import Foundation
3import KeycaskCore
4
5enum 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
17struct 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 words != nil, length != nil {
32 throw ValidationError("--length and --words are mutually exclusive")
33 }
34 if let length, length < 1 { throw ValidationError("--length must be at least 1") }
35 if let words, words < 1 { throw ValidationError("--words must be at least 1") }
36 if length != nil, !generate, words == nil {
37 throw ValidationError("--length requires --generate")
38 }
39 }
40
41 /// nil means the caller must prompt.
42 func newPassword() -> String? {
43 if let words { return Generator.passphrase(words: words) }
44 if generate { return Generator.password(length: length ?? Generator.defaultLength) }
45 return nil
46 }
47}
48
49struct Add: ParsableCommand {
50 static let configuration = CommandConfiguration(abstract: "Add an entry.")
51
52 @OptionGroup var global: GlobalOptions
53 @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String
54 @Option(name: [.short, .customLong("username")], help: "Username.") var username: String?
55 @Option(name: .long, help: "URL.") var url: String?
56 @Option(name: .long, help: "Notes.") var notes: String?
57 @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = []
58 @OptionGroup var password: PasswordOptions
59
60 func run() throws {
61 var open = try OpenVault.load(global)
62 let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ")
63 var entry = Entry(
64 name: name, username: username, password: secret, url: url,
65 notes: notes, tags: tag)
66 while open.vault.entry(id: entry.id) != nil {
67 entry = Entry(
68 name: name, username: username, password: secret, url: url,
69 notes: notes, tags: tag)
70 }
71 try open.vault.add(entry)
72 try open.save()
73 print(entry.id.rawValue)
74 }
75}
Sources/keycask/Commands/Clip.swift added +20
@@ -0,0 +1,20 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Clip: ParsableCommand {
5 static let configuration = CommandConfiguration(
6 abstract: "Copy a field to the clipboard. Clears after \(Clipboard.timeoutSeconds) seconds."
7 )
8
9 @OptionGroup var global: GlobalOptions
10 @Argument(help: "Entry id or name.") var ref: String
11 @Option(name: .long, help: "Field to copy (default password).") var field = "password"
12
13 func run() throws {
14 let open = try OpenVault.load(global)
15 let entry = try open.vault.resolve(ref)
16 let value = try Output.field(entry, named: field)
17 try Clipboard.copyWithTimeout(value)
18 print("copied \(field) of \(entry.name); clears in \(Clipboard.timeoutSeconds)s")
19 }
20}
Sources/keycask/Commands/ClipboardDaemon.swift added +12
@@ -0,0 +1,12 @@
1import ArgumentParser
2
3struct ClipboardDaemon: ParsableCommand {
4 static let configuration = CommandConfiguration(
5 commandName: "clipboard-daemon", shouldDisplay: false)
6
7 @Argument var seconds: Int
8
9 func run() throws {
10 try Clipboard.runDaemon(seconds: seconds)
11 }
12}
Sources/keycask/Commands/Edit.swift added +45
@@ -0,0 +1,45 @@
1import ArgumentParser
2import KeycaskCore
3
4struct 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/Find.swift added +20
@@ -0,0 +1,20 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Find: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Search entries.")
6
7 @OptionGroup var global: GlobalOptions
8 @Argument(help: "Case-insensitive substring.") var query: String
9 @Flag(name: .long, help: "JSON output.") var json = false
10
11 func run() throws {
12 let open = try OpenVault.load(global)
13 let entries = open.vault.search(query)
14 if json {
15 print(try Output.json(entries, reveal: false), terminator: "")
16 } else {
17 print(Output.table(entries), terminator: "")
18 }
19 }
20}
Sources/keycask/Commands/Generate.swift added +30
@@ -0,0 +1,30 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Generate: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "Generate a password.")
6
7 @Option(name: .long, help: "Password length (default 24).") var length: Int?
8 @Option(name: .long, help: "Passphrase of this many words instead.") var words: Int?
9 @Flag(name: .long, help: "Copy to the clipboard instead of printing.") var copy = false
10
11 mutating func validate() throws {
12 if length != nil, words != nil {
13 throw ValidationError("--length and --words are mutually exclusive")
14 }
15 if let length, length < 1 { throw ValidationError("--length must be at least 1") }
16 if let words, words < 1 { throw ValidationError("--words must be at least 1") }
17 }
18
19 func run() throws {
20 let secret =
21 words.map { Generator.passphrase(words: $0) }
22 ?? Generator.password(length: length ?? Generator.defaultLength)
23 if copy {
24 try Clipboard.copyWithTimeout(secret)
25 print("copied; clears in \(Clipboard.timeoutSeconds)s")
26 return
27 }
28 print(secret)
29 }
30}
Sources/keycask/Commands/Init.swift added +12
@@ -0,0 +1,12 @@
1import ArgumentParser
2
3struct Init: ParsableCommand {
4 static let configuration = CommandConfiguration(abstract: "Create an empty vault.")
5
6 @OptionGroup var global: GlobalOptions
7
8 func run() throws {
9 let url = try OpenVault.create(global)
10 print("created \(url.path)")
11 }
12}
Sources/keycask/Commands/Ls.swift added +20
@@ -0,0 +1,20 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Ls: ParsableCommand {
5 static let configuration = CommandConfiguration(abstract: "List entries.")
6
7 @OptionGroup var global: GlobalOptions
8 @Option(name: .long, help: "Only entries with this tag.") var tag: String?
9 @Flag(name: .long, help: "JSON output.") var json = false
10
11 func run() throws {
12 let open = try OpenVault.load(global)
13 let entries = tag.map { open.vault.filter(tag: $0) } ?? open.vault.sortedEntries
14 if json {
15 print(try Output.json(entries, reveal: false), terminator: "")
16 } else {
17 print(Output.table(entries), terminator: "")
18 }
19 }
20}
Sources/keycask/Commands/Rm.swift added +25
@@ -0,0 +1,25 @@
1import ArgumentParser
2import KeycaskCore
3
4struct 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/Commands/Show.swift added +24
@@ -0,0 +1,24 @@
1import ArgumentParser
2import KeycaskCore
3
4struct 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 added +19
@@ -0,0 +1,19 @@
1import ArgumentParser
2
3struct GlobalOptions: ParsableArguments {
4 @Option(name: .long, help: "Path to the vault file.")
5 var vault: String?
6}
7
8struct Keycask: ParsableCommand {
9 static let configuration = CommandConfiguration(
10 commandName: "keycask",
11 abstract: "Command-line password manager. One passphrase-encrypted vault file.",
12 subcommands: [
13 Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self,
14 Clip.self, ClipboardDaemon.self,
15 ]
16 )
17
18 @OptionGroup var global: GlobalOptions
19}
Sources/keycask/OpenVault.swift added +46
@@ -0,0 +1,46 @@
1import Foundation
2import KeycaskCore
3
4struct OpenVault {
5 var vault: Vault
6 let kdf: Envelope.KDFParams
7 let url: URL
8 let passphrase: String
9
10 static func load(_ options: GlobalOptions) throws -> OpenVault {
11 let url = Paths.vaultURL(override: options.vault)
12 let data: Data
13 do {
14 data = try Data(contentsOf: url)
15 } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
16 throw KeycaskError.noVault(url.path)
17 } catch {
18 if !FileManager.default.fileExists(atPath: url.path) {
19 throw KeycaskError.noVault(url.path)
20 }
21 throw KeycaskError.io("read \(url.path): \(error)")
22 }
23 let envelope = try Envelope(parsing: data)
24 let passphrase = try Passphrase.obtain(confirm: false)
25 let plaintext = try envelope.open(passphrase: passphrase)
26 let vault = try VaultCodec.decode(plaintext)
27 return OpenVault(vault: vault, kdf: envelope.kdf, url: url, passphrase: passphrase)
28 }
29
30 static func create(_ options: GlobalOptions) throws -> URL {
31 let url = Paths.vaultURL(override: options.vault)
32 guard !FileManager.default.fileExists(atPath: url.path) else {
33 throw KeycaskError.vaultExists(url.path)
34 }
35 let passphrase = try Passphrase.obtain(confirm: true)
36 let fresh = OpenVault(vault: Vault(), kdf: .fresh(), url: url, passphrase: passphrase)
37 try fresh.save()
38 return url
39 }
40
41 func save() throws {
42 let plaintext = try VaultCodec.encode(vault)
43 let envelope = try Envelope.seal(plaintext, passphrase: passphrase, kdf: kdf)
44 try AtomicFile.write(try envelope.encoded(), to: url)
45 }
46}
Sources/keycask/Output.swift added +77
@@ -0,0 +1,77 @@
1import Foundation
2import KeycaskCore
3
4enum 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 guard !entries.isEmpty else { return "[]\n" }
42 return try encode(entries.map { masked($0, reveal: reveal) })
43 }
44
45 static func json(_ entry: Entry, reveal: Bool) throws -> String {
46 try encode(masked(entry, reveal: reveal))
47 }
48
49 static func field(_ entry: Entry, named name: String) throws -> String {
50 switch name {
51 case "id": entry.id.rawValue
52 case "name": entry.name
53 case "username": entry.username ?? ""
54 case "password": entry.password
55 case "url": entry.url ?? ""
56 case "notes": entry.notes ?? ""
57 case "tags": entry.tags.joined(separator: ",")
58 case "created": iso(entry.created)
59 case "updated": iso(entry.updated)
60 default: throw KeycaskError.usage("unknown field \(name)")
61 }
62 }
63
64 private static func encode(_ value: some Encodable) throws -> String {
65 let encoder = VaultCodec.makeEncoder()
66 encoder.outputFormatting.insert(.prettyPrinted)
67 do {
68 return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n"
69 } catch {
70 throw KeycaskError.io("encode json: \(error)")
71 }
72 }
73
74 private static func iso(_ date: Date) -> String {
75 date.formatted(.iso8601)
76 }
77}
Sources/keycask/Passphrase.swift added +28
@@ -0,0 +1,28 @@
1import Foundation
2import KeycaskCore
3
4enum Passphrase {
5 static let variable = "KEYCASK_PASSPHRASE"
6
7 static func obtain(
8 confirm: Bool, environment: [String: String] = ProcessInfo.processInfo.environment
9 ) throws -> String {
10 if let fromEnv = environment[variable] {
11 return try validated(fromEnv)
12 }
13 guard Terminal.stdinIsTTY else {
14 throw KeycaskError.usage("no passphrase: set \(variable) or run on a terminal")
15 }
16 let first = try Terminal.readSecretLine(prompt: "Passphrase: ")
17 if confirm {
18 let second = try Terminal.readSecretLine(prompt: "Confirm passphrase: ")
19 guard first == second else { throw KeycaskError.failure("passphrases do not match") }
20 }
21 return try validated(first)
22 }
23
24 private static func validated(_ passphrase: String) throws -> String {
25 guard !passphrase.isEmpty else { throw KeycaskError.failure("passphrase is empty") }
26 return passphrase
27 }
28}
Sources/keycask/Paths.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2
3enum Paths {
4 static let variable = "KEYCASK_VAULT"
5
6 static func vaultURL(
7 override: String?, environment: [String: String] = ProcessInfo.processInfo.environment
8 ) -> URL {
9 if let override { return URL(fileURLWithPath: override) }
10 if let env = environment[variable], !env.isEmpty { return URL(fileURLWithPath: env) }
11 return defaultDirectory(environment: environment)
12 .appendingPathComponent("keycask").appendingPathComponent("vault.kc")
13 }
14
15 private static func defaultDirectory(environment: [String: String]) -> URL {
16 #if os(Windows)
17 let base = environment["LOCALAPPDATA"] ?? environment["USERPROFILE"] ?? "."
18 return URL(fileURLWithPath: base)
19 #else
20 if let xdg = environment["XDG_DATA_HOME"], !xdg.isEmpty {
21 return URL(fileURLWithPath: xdg)
22 }
23 let home = environment["HOME"] ?? "."
24 return URL(fileURLWithPath: home).appendingPathComponent(".local/share")
25 #endif
26 }
27}
Sources/keycask/Terminal.swift added +68
@@ -0,0 +1,68 @@
1import Foundation
2import KeycaskCore
3
4#if canImport(Darwin)
5 import Darwin
6#elseif canImport(Glibc)
7 import Glibc
8#elseif canImport(Musl)
9 import Musl
10#elseif os(Windows)
11 import CRT
12 import WinSDK
13#endif
14
15enum Terminal {
16 static var stdinIsTTY: Bool {
17 #if os(Windows)
18 return _isatty(_fileno(stdin)) != 0
19 #else
20 return isatty(STDIN_FILENO) != 0
21 #endif
22 }
23
24 static func write(_ text: String) {
25 FileHandle.standardError.write(Data(text.utf8))
26 }
27
28 static func readLine(prompt: String) -> String? {
29 write(prompt)
30 return Swift.readLine(strippingNewline: true)
31 }
32
33 static func confirm(_ question: String) -> Bool {
34 guard let answer = readLine(prompt: question + " [y/N] ") else { return false }
35 return answer.lowercased().hasPrefix("y")
36 }
37
38 static func readSecretLine(prompt: String) throws -> String {
39 write(prompt)
40 defer { write("\n") }
41 return try withEchoDisabled { Swift.readLine(strippingNewline: true) ?? "" }
42 }
43
44 #if os(Windows)
45 private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
46 let handle = GetStdHandle(DWORD(bitPattern: -10))
47 var mode: DWORD = 0
48 guard GetConsoleMode(handle, &mode).boolValue else {
49 throw KeycaskError.io("GetConsoleMode failed")
50 }
51 SetConsoleMode(handle, mode & ~DWORD(ENABLE_ECHO_INPUT))
52 defer { SetConsoleMode(handle, mode) }
53 return try body()
54 }
55 #else
56 private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
57 var original = termios()
58 guard tcgetattr(STDIN_FILENO, &original) == 0 else {
59 throw KeycaskError.io("tcgetattr failed")
60 }
61 var quiet = original
62 quiet.c_lflag &= ~tcflag_t(ECHO)
63 tcsetattr(STDIN_FILENO, TCSANOW, &quiet)
64 defer { tcsetattr(STDIN_FILENO, TCSANOW, &original) }
65 return try body()
66 }
67 #endif
68}
Sources/keycask/main.swift +21 −1
@@ -1,3 +1,23 @@
1import ArgumentParser
2import Foundation
13import KeycaskCore
24
3print("keycask")
5func fail(_ text: String, code: Int32) -> Never {
6 FileHandle.standardError.write(Data((text + "\n").utf8))
7 exit(code)
8}
9
10do {
11 var command = try Keycask.parseAsRoot()
12 try command.run()
13} catch let error as KeycaskError {
14 fail(error.message, code: error.exitCode)
15} catch {
16 let text = Keycask.fullMessage(for: error)
17 let code = Keycask.exitCode(for: error)
18 if code.isSuccess {
19 print(text)
20 exit(0)
21 }
22 fail(text, code: code.rawValue == 64 ? 2 : 1)
23}
Tests/KeycaskCLITests/AddShowTests.swift added +124
@@ -0,0 +1,124 @@
1import Foundation
2import 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 lengthWithWordsIsUsageError() throws {
105 let cli = try CLI.initialized()
106 let r = try cli.run(["add", "a", "--words", "4", "--length", "30"])
107 #expect(r.status == 2)
108 }
109
110 @Test func addWithoutPasswordSourceIsUsageError() throws {
111 let cli = try CLI.initialized()
112 let r = try cli.run(["add", "x"], stdin: "")
113 #expect(r.status == 2)
114 #expect(r.stderr.contains("no password"))
115 }
116
117 @Test func corruptVaultIsFailure() throws {
118 let cli = try CLI.initialized()
119 try Data("{}".utf8).write(to: cli.vault)
120 let r = try cli.run(["show", "x"])
121 #expect(r.status == 1)
122 #expect(r.stderr.hasPrefix("vault is corrupt"))
123 }
124}
Tests/KeycaskCLITests/CLI.swift +64 −2
@@ -27,6 +27,68 @@ enum Binary {
2727 }()
2828}
2929
30@Test func binaryIsBuilt() {
31 #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path))
30struct CLI {
31 struct Result {
32 let status: Int32
33 let stdout: String
34 let stderr: String
35 var lines: [String] { stdout.split(separator: "\n").map(String.init) }
36 }
37
38 static let passphrase = "correct horse battery"
39
40 let dir: URL
41 let vault: URL
42
43 init() throws {
44 dir = FileManager.default.temporaryDirectory
45 .appendingPathComponent("keycask-tests-\(UUID().uuidString)")
46 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
47 vault = dir.appendingPathComponent("vault.kc")
48 }
49
50 @discardableResult
51 func run(
52 _ args: [String],
53 stdin: String? = nil,
54 passphrase: String? = CLI.passphrase,
55 extraEnvironment: [String: String] = [:]
56 ) throws -> Result {
57 let process = Process()
58 process.executableURL = Binary.url
59 process.arguments = args
60 var env = ProcessInfo.processInfo.environment
61 env["KEYCASK_VAULT"] = vault.path
62 env.removeValue(forKey: "KEYCASK_PASSPHRASE")
63 if let passphrase { env["KEYCASK_PASSPHRASE"] = passphrase }
64 for (k, v) in extraEnvironment { env[k] = v }
65 process.environment = env
66
67 let out = Pipe()
68 let err = Pipe()
69 let input = Pipe()
70 process.standardOutput = out
71 process.standardError = err
72 process.standardInput = input
73 try process.run()
74 if let stdin {
75 input.fileHandleForWriting.write(Data(stdin.utf8))
76 }
77 try input.fileHandleForWriting.close()
78 let outData = out.fileHandleForReading.readDataToEndOfFile()
79 let errData = err.fileHandleForReading.readDataToEndOfFile()
80 process.waitUntilExit()
81 return Result(
82 status: process.terminationStatus,
83 stdout: String(decoding: outData, as: UTF8.self),
84 stderr: String(decoding: errData, as: UTF8.self))
85 }
86
87 /// Runs `init` and returns the harness, for tests that need a vault.
88 static func initialized() throws -> CLI {
89 let cli = try CLI()
90 let r = try cli.run(["init"])
91 precondition(r.status == 0, "init failed: \(r.stderr)")
92 return cli
93 }
3294}
Tests/KeycaskCLITests/ClipboardTests.swift added +168
@@ -0,0 +1,168 @@
1import Foundation
2import Testing
3
4@testable import keycask
5
6@Suite struct ClipboardTests {
7 @Test func clearsOnlyWhenClipboardStillHoldsTheSecret() {
8 #expect(Clipboard.shouldClear(secret: "s", current: "s"))
9 #expect(!Clipboard.shouldClear(secret: "s", current: "user pasted"))
10 #expect(!Clipboard.shouldClear(secret: "s", current: nil))
11 }
12
13 @Test func handoffRoundTrips() throws {
14 let h = Clipboard.Handoff(secret: "s3cret")
15 let data = try JSONEncoder().encode(h)
16 #expect(try JSONDecoder().decode(Clipboard.Handoff.self, from: data) == h)
17 }
18
19 @Test func stripsOneTrailingNewline() {
20 #expect(Clipboard.stripTrailingNewline("s\r\n") == "s")
21 #expect(Clipboard.stripTrailingNewline("s\n") == "s")
22 #expect(Clipboard.stripTrailingNewline("s") == "s")
23 #expect(Clipboard.stripTrailingNewline("s\n\n") == "s\n")
24 }
25
26 @Test func findToolScansPathInOrder() throws {
27 let dir = FileManager.default.temporaryDirectory
28 .appendingPathComponent("keycask-clip-\(UUID().uuidString)")
29 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
30 #expect(Clipboard.findTool(path: dir.path) == nil)
31
32 #if os(macOS)
33 let names = ["pbcopy", "pbpaste"]
34 #elseif os(Windows)
35 let names = ["clip.exe", "powershell.exe"]
36 #else
37 let names = ["xclip"]
38 #endif
39 for n in names {
40 let f = dir.appendingPathComponent(n)
41 try Data("#!/bin/sh\n".utf8).write(to: f)
42 try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path)
43 }
44 let tool = Clipboard.findTool(path: dir.path)
45 #expect(tool != nil)
46 #expect(tool?.copy.first?.hasPrefix(dir.path) == true)
47 }
48
49 @Test func daemonWithoutHandoffFails() throws {
50 let cli = try CLI()
51 let r = try cli.run(["clipboard-daemon", "1"], stdin: "not json", passphrase: nil)
52 #expect(r.status == 1)
53 }
54
55 @Test func daemonIsHiddenFromHelp() throws {
56 let cli = try CLI()
57 let r = try cli.run(["--help"], passphrase: nil)
58 #expect(!r.stdout.contains("clipboard-daemon"))
59 #expect(r.stdout.contains("clip"))
60 }
61
62 @Test func clipOfMissingEntryIsNotFoundBeforeTouchingClipboard() throws {
63 let cli = try CLI.initialized()
64 let r = try cli.run(["clip", "nope"])
65 #expect(r.status == 3)
66 }
67
68 #if !os(Windows)
69 /// A temp directory of clipboard tools backed by a file, so tests never
70 /// touch the real clipboard.
71 struct Stub {
72 let dir: URL
73
74 var file: URL { dir.appendingPathComponent("clip.txt") }
75 var environment: [String: String] { ["PATH": "\(dir.path):/bin:/usr/bin"] }
76
77 init() throws {
78 dir = FileManager.default.temporaryDirectory
79 .appendingPathComponent("keycask-clip-stub-\(UUID().uuidString)")
80 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
81 #if os(macOS)
82 try install("pbcopy", "#!/bin/sh\ncat > \"$(dirname \"$0\")/clip.txt\"\n")
83 try install(
84 "pbpaste", "#!/bin/sh\ncat \"$(dirname \"$0\")/clip.txt\" 2>/dev/null\n")
85 #else
86 try install(
87 "xclip",
88 """
89 #!/bin/sh
90 for a in "$@"; do
91 if [ "$a" = "-o" ]; then
92 cat "$(dirname "$0")/clip.txt" 2>/dev/null
93 exit 0
94 fi
95 done
96 cat > "$(dirname "$0")/clip.txt"
97
98 """)
99 #endif
100 }
101
102 func contents() -> String {
103 (try? String(contentsOf: file, encoding: .utf8)) ?? ""
104 }
105
106 func set(_ text: String) throws {
107 try Data(text.utf8).write(to: file)
108 }
109
110 private func install(_ name: String, _ script: String) throws {
111 let url = dir.appendingPathComponent(name)
112 try Data(script.utf8).write(to: url)
113 try FileManager.default.setAttributes(
114 [.posixPermissions: 0o755], ofItemAtPath: url.path)
115 }
116 }
117
118 @Test func clipCopiesTheFieldThroughTheTool() throws {
119 let cli = try CLI.initialized()
120 let stub = try Stub()
121 try cli.run(["add", "t", "--generate"])
122 let expected = try cli.run(["show", "t", "--field", "password"]).stdout
123 .trimmingCharacters(in: .whitespacesAndNewlines)
124
125 let start = Date()
126 let r = try cli.run(["clip", "t"], extraEnvironment: stub.environment)
127 let elapsed = Date().timeIntervalSince(start)
128
129 #expect(r.status == 0)
130 #expect(r.stdout.contains("copied password of t"))
131 #expect(stub.contents() == expected)
132 #expect(elapsed < 5)
133 }
134
135 @Test func generateCopyWritesTheTool() throws {
136 let cli = try CLI()
137 let stub = try Stub()
138 let r = try cli.run(
139 ["generate", "--copy"], passphrase: nil, extraEnvironment: stub.environment)
140 #expect(r.status == 0)
141 let copied = stub.contents()
142 #expect(copied.count == 24)
143 #expect(!r.stdout.contains(copied))
144 }
145
146 @Test func daemonClearsWhenClipboardStillHoldsSecret() throws {
147 let cli = try CLI()
148 let stub = try Stub()
149 try stub.set("probe")
150 let r = try cli.run(
151 ["clipboard-daemon", "1"], stdin: #"{"secret":"probe"}"#, passphrase: nil,
152 extraEnvironment: stub.environment)
153 #expect(r.status == 0)
154 #expect(stub.contents() == "")
155 }
156
157 @Test func daemonLeavesForeignContentAlone() throws {
158 let cli = try CLI()
159 let stub = try Stub()
160 try stub.set("other")
161 let r = try cli.run(
162 ["clipboard-daemon", "1"], stdin: #"{"secret":"probe"}"#, passphrase: nil,
163 extraEnvironment: stub.environment)
164 #expect(r.status == 0)
165 #expect(stub.contents() == "other")
166 }
167 #endif
168}
Tests/KeycaskCLITests/EditRmTests.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import 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}
Tests/KeycaskCLITests/GenerateTests.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2import Testing
3
4@Suite struct GenerateTests {
5 @Test func defaultIs24Characters() throws {
6 let cli = try CLI()
7 let r = try cli.run(["generate"], passphrase: nil)
8 #expect(r.status == 0)
9 #expect(r.stdout.count == 25)
10 }
11
12 @Test func lengthAndWords() throws {
13 let cli = try CLI()
14 let lenResult = try cli.run(["generate", "--length", "12"], passphrase: nil)
15 #expect(lenResult.stdout.count == 13)
16 let wordsOutput = try cli.run(["generate", "--words", "6"], passphrase: nil).stdout
17 let w = wordsOutput.trimmingCharacters(in: .newlines).split(separator: "-")
18 #expect(w.count == 6)
19 }
20
21 @Test func doesNotNeedAVault() throws {
22 let cli = try CLI()
23 #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
24 #expect(try cli.run(["generate"], passphrase: nil).status == 0)
25 }
26
27 @Test func lengthAndWordsTogetherIsUsageError() throws {
28 let cli = try CLI()
29 #expect(
30 try cli.run(["generate", "--length", "3", "--words", "3"], passphrase: nil).status == 2)
31 }
32}
Tests/KeycaskCLITests/InitTests.swift added +105
@@ -0,0 +1,105 @@
1import Foundation
2import Testing
3
4@Suite struct InitTests {
5 @Test func initCreatesVaultAndPrintsPath() throws {
6 let cli = try CLI()
7 let r = try cli.run(["init"])
8 #expect(r.status == 0)
9 #expect(r.stdout.contains(cli.vault.path))
10 #expect(FileManager.default.fileExists(atPath: cli.vault.path))
11 let text = try String(contentsOf: cli.vault, encoding: .utf8)
12 #expect(text.contains("\"format\" : 1"))
13 #expect(text.contains("pbkdf2-hmac-sha256"))
14 #expect(!text.contains("entries"))
15 }
16
17 @Test func initRefusesExistingVault() throws {
18 let cli = try CLI.initialized()
19 let r = try cli.run(["init"])
20 #expect(r.status == 1)
21 #expect(r.stderr.contains("already exists"))
22 }
23
24 @Test func initWithoutPassphraseOrTTYIsUsageError() throws {
25 let cli = try CLI()
26 let r = try cli.run(["init"], passphrase: nil)
27 #expect(r.status == 2)
28 #expect(r.stderr.contains("KEYCASK_PASSPHRASE"))
29 }
30
31 @Test func emptyPassphraseIsRejected() throws {
32 let cli = try CLI()
33 let r = try cli.run(["init"], passphrase: "")
34 #expect(r.status == 1)
35 #expect(r.stderr.contains("empty"))
36 }
37
38 @Test func vaultFlagBeatsEnvironment() throws {
39 let cli = try CLI()
40 let other = cli.dir.appendingPathComponent("elsewhere.kc")
41 let r = try cli.run(["--vault", other.path, "init"])
42 #expect(r.status == 0)
43 #expect(FileManager.default.fileExists(atPath: other.path))
44 #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
45 }
46
47 @Test func unknownSubcommandIsUsageError() throws {
48 let cli = try CLI()
49 let r = try cli.run(["frobnicate"])
50 #expect(r.status == 2)
51 #expect(r.stderr.contains("Usage"))
52 }
53
54 @Test func helpExitsZero() throws {
55 let cli = try CLI()
56 let r = try cli.run(["--help"])
57 #expect(r.status == 0)
58 #expect(r.stdout.contains("init"))
59 }
60
61 #if !os(Windows)
62 @Test func vaultIsPrivateOnUnix() throws {
63 let cli = try CLI.initialized()
64 let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path)
65 let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777
66 #expect(mode == 0o600)
67 }
68
69 @Test func preExistingTempFileDoesNotWeakenPermissions() throws {
70 let cli = try CLI()
71 let temp = cli.dir.appendingPathComponent("vault.kc.tmp")
72 FileManager.default.createFile(
73 atPath: temp.path, contents: Data(), attributes: [.posixPermissions: 0o644])
74
75 let r = try cli.run(["init"])
76 #expect(r.status == 0)
77 let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path)
78 let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777
79 #expect(mode == 0o600)
80 #expect(!FileManager.default.fileExists(atPath: temp.path))
81 }
82
83 @Test func symlinkedTempFileIsNotFollowed() throws {
84 let cli = try CLI()
85 let victim = cli.dir.appendingPathComponent("victim.txt")
86 FileManager.default.createFile(atPath: victim.path, contents: Data())
87 let temp = cli.dir.appendingPathComponent("vault.kc.tmp")
88 try FileManager.default.createSymbolicLink(at: temp, withDestinationURL: victim)
89
90 let r = try cli.run(["init"])
91 #expect(r.status == 0)
92 let victimAttrs = try FileManager.default.attributesOfItem(atPath: victim.path)
93 #expect((victimAttrs[.size] as! NSNumber).intValue == 0)
94 let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path)
95 #expect(attrs[.type] as? FileAttributeType == .typeRegular)
96 #expect((attrs[.posixPermissions] as! NSNumber).intValue & 0o777 == 0o600)
97 }
98 #endif
99
100 @Test func noTempFileLeftBehind() throws {
101 let cli = try CLI.initialized()
102 let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path)
103 #expect(names == ["vault.kc"])
104 }
105}
Tests/KeycaskCLITests/LsFindTests.swift added +72
@@ -0,0 +1,72 @@
1import Foundation
2import Testing
3
4@Suite struct LsFindTests {
5 func seeded() throws -> CLI {
6 let cli = try CLI.initialized()
7 try cli.run([
8 "add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "Dev",
9 "--generate",
10 ])
11 try cli.run([
12 "add", "bank", "--url", "https://bank.example", "--notes", "downtown branch",
13 "--generate",
14 ])
15 try cli.run(["add", "Alpha", "--tag", "dev", "--generate"])
16 return cli
17 }
18
19 @Test func lsSortsByNameAndShowsColumns() throws {
20 let cli = try seeded()
21 let r = try cli.run(["ls"])
22 #expect(r.status == 0)
23 let names = r.lines.map {
24 String($0.split(separator: " ", omittingEmptySubsequences: true)[1])
25 }
26 #expect(names == ["Alpha", "bank", "github"])
27 #expect(r.stdout.contains("cmc"))
28 #expect(r.stdout.contains("https://github.com"))
29 }
30
31 @Test func lsTagFilterIsCaseInsensitive() throws {
32 let cli = try seeded()
33 let r = try cli.run(["ls", "--tag", "DEV"])
34 #expect(r.lines.count == 2)
35 #expect(!r.stdout.contains("bank"))
36 }
37
38 @Test func lsJsonIsAnArrayWithMaskedPasswords() throws {
39 let cli = try seeded()
40 let r = try cli.run(["ls", "--json"])
41 let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
42 #expect(arr.count == 3)
43 #expect(arr.allSatisfy { $0["password"] as? String == "********" })
44 }
45
46 @Test func emptyVaultListsNothing() throws {
47 let cli = try CLI.initialized()
48 let r = try cli.run(["ls"])
49 #expect(r.status == 0)
50 #expect(r.stdout == "")
51 let j = try cli.run(["ls", "--json"])
52 #expect(j.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "[]")
53 }
54
55 @Test func findMatchesNotesURLTagsCaseInsensitively() throws {
56 let cli = try seeded()
57 #expect(try cli.run(["find", "DOWNTOWN"]).lines.count == 1)
58 #expect(try cli.run(["find", "github.com"]).lines.count == 1)
59 #expect(try cli.run(["find", "dev"]).lines.count == 2)
60 let none = try cli.run(["find", "zzz"])
61 #expect(none.status == 0)
62 #expect(none.stdout == "")
63 }
64
65 @Test func findJson() throws {
66 let cli = try seeded()
67 let r = try cli.run(["find", "bank", "--json"])
68 let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]]
69 #expect(arr.count == 1)
70 #expect(arr[0]["name"] as? String == "bank")
71 }
72}
Tests/KeycaskCLITests/PathsTests.swift added +38
@@ -0,0 +1,38 @@
1import Foundation
2import Testing
3
4@testable import keycask
5
6@Suite struct PathsTests {
7 @Test func overrideWinsOverEverything() {
8 let url = Paths.vaultURL(
9 override: "/x/v.kc", environment: ["KEYCASK_VAULT": "/y", "HOME": "/h"])
10 #expect(url.path == "/x/v.kc")
11 }
12
13 @Test func environmentVariableWinsOverDefaults() {
14 let url = Paths.vaultURL(
15 override: nil, environment: ["KEYCASK_VAULT": "/y/v.kc", "HOME": "/h"])
16 #expect(url.path == "/y/v.kc")
17 }
18
19 #if os(Windows)
20 @Test func windowsUsesLocalAppData() {
21 let url = Paths.vaultURL(
22 override: nil, environment: ["LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"])
23 #expect(
24 url.path.hasSuffix("keycask/vault.kc") || url.path.hasSuffix("keycask\\vault.kc"))
25 }
26 #else
27 @Test func xdgDataHomeIsUsedWhenSet() {
28 let url = Paths.vaultURL(
29 override: nil, environment: ["XDG_DATA_HOME": "/d", "HOME": "/h"])
30 #expect(url.path == "/d/keycask/vault.kc")
31 }
32
33 @Test func homeFallback() {
34 let url = Paths.vaultURL(override: nil, environment: ["HOME": "/h"])
35 #expect(url.path == "/h/.local/share/keycask/vault.kc")
36 }
37 #endif
38}
docs/superpowers/specs/2026-09-17-keycask-design.md +11 −5
@@ -183,6 +183,10 @@ Passphrase input: if `KEYCASK_PASSPHRASE` is set, its value is used. It
183183exists so tests and scripts run unattended. Otherwise the CLI prompts on
184184the terminal with echo off. No TTY and no variable is exit 2.
185185
186Password input for `add` and `edit --password`: on a terminal, a hidden
187prompt. Without a terminal, the first line of stdin. Neither available is
188exit 2.
189
186190Exit codes:
187191
188192| Code | Meaning |
@@ -220,10 +224,12 @@ and a Windows body where they differ.
220224 Linux; `clip.exe` to write and `powershell -command Get-Clipboard` to
221225 read on Windows. No tool found is exit 1 with a message naming the
222226 tools. `clip` spawns `keycask clipboard-daemon` detached with stdout
223 and stderr to null, writes `{"secret": ..., "previous": ...}` to its
224 stdin, and exits without waiting. The daemon sets the clipboard,
225 sleeps 45 seconds, reads the clipboard, and if it still equals the
226 secret restores `previous` or clears when `previous` is empty.
227 and stderr to null and exits without waiting. `clip` writes the
228 clipboard itself, then spawns the daemon with `{"secret": ...}` on its
229 stdin. The daemon sleeps 45 seconds, reads the clipboard, and clears
230 it if it still equals the secret. It never restores earlier contents,
231 so a second `clip` inside the window cannot bring an earlier secret
232 back.
227233
228234## Errors
229235
@@ -267,7 +273,7 @@ CLI, black box:
267273 masking versus `--reveal`, `--field` raw output, the ambiguous-name
268274 listing, `rm` without `--yes` and without a TTY, `edit` with no flags,
269275 `--generate` with `--words`, and `init` on an existing vault.
270- The clipboard daemon's restore decision is unit-tested in process;
276- The clipboard daemon's clear decision is unit-tested in process;
271277 the tests do not touch the real clipboard.
272278
273279The CLI suite is the conformance suite. When the app exists, its