gitbay/Repos/RepoCredentials.swift
145 lines · 4660 bytes
1import Foundation
2import Observation
3
4/// `repo secret list`, `set` and `remove`. Names only: a value goes to
5/// the server on stdin and never comes back.
6@Observable
7@MainActor
8final class BuildSecretsViewModel {
9
10 private(set) var state: LoadState<[String]> = .loading
11 private(set) var actionError: String?
12 private(set) var working = false
13
14 private let client: GitbayClient
15 let repoPath: String
16
17 init(client: GitbayClient, repoPath: String) {
18 self.client = client
19 self.repoPath = repoPath
20 }
21
22 func load() async {
23 do {
24 let names = try await client.readList(["repo", "secret", "list", repoPath], of: String.self)
25 state = names.isEmpty ? .empty("No build secrets yet.") : .loaded(names)
26 } catch {
27 state = .from(error)
28 }
29 }
30
31 /// Sets or replaces a secret. The name is the user's, so a refusal
32 /// of it is shown as the server words it.
33 func set(name: String, value: String) async -> Bool {
34 let name = name.trimmingCharacters(in: .whitespaces)
35 guard !name.isEmpty, !value.isEmpty else { return false }
36 return await perform(["repo", "secret", "set", repoPath, name], stdin: value)
37 }
38
39 func remove(_ name: String) async {
40 await perform(["repo", "secret", "remove", repoPath, name])
41 }
42
43 @discardableResult
44 private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
45 working = true
46 actionError = nil
47 defer { working = false }
48 do {
49 try await client.run(argv, stdin: stdin)
50 await load()
51 return true
52 } catch let error as GitbayError {
53 actionError = error.messageForTypedInput
54 } catch {
55 actionError = GitbayError.transport(error).userFacingMessage
56 }
57 // A refusal may mean the list changed elsewhere, e.g. a row
58 // removed from another device.
59 await load()
60 return false
61 }
62}
63
64/// One row of `repo deploy-key list`.
65nonisolated struct DeployKey: Decodable, Sendable, Hashable, Identifiable {
66 let fingerprint: String
67 let algo: String
68 /// `ro` or `rw`.
69 let mode: String
70 /// The key's comment; empty when it had none.
71 let label: String
72 var lastUsedAt: Date? = nil
73 var expiresAt: Date? = nil
74 var id: String { fingerprint }
75
76 var canPush: Bool { mode == "rw" }
77
78 enum CodingKeys: String, CodingKey {
79 case fingerprint, algo, mode, label
80 case lastUsedAt = "last_used_at"
81 case expiresAt = "expires_at"
82 }
83}
84
85/// `repo deploy-key list`, `add` and `remove`: keys bound to this one
86/// repository, read-only unless added with `--rw`.
87@Observable
88@MainActor
89final class DeployKeysViewModel {
90
91 private(set) var state: LoadState<[DeployKey]> = .loading
92 private(set) var actionError: String?
93 private(set) var working = false
94
95 private let client: GitbayClient
96 let repoPath: String
97
98 init(client: GitbayClient, repoPath: String) {
99 self.client = client
100 self.repoPath = repoPath
101 }
102
103 func load() async {
104 do {
105 let keys = try await client.readList(["repo", "deploy-key", "list", repoPath], of: DeployKey.self)
106 state = keys.isEmpty ? .empty("No deploy keys yet.") : .loaded(keys)
107 } catch {
108 state = .from(error)
109 }
110 }
111
112 /// `repo deploy-key add <repo> [--rw] [--ttl <d>] < key.pub`.
113 func add(publicKey: String, canPush: Bool, ttl: String?) async -> Bool {
114 let key = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
115 guard !key.isEmpty else { return false }
116 var argv = ["repo", "deploy-key", "add", repoPath]
117 if canPush { argv.append("--rw") }
118 if let ttl { argv += ["--ttl", ttl] }
119 return await perform(argv, stdin: key)
120 }
121
122 func remove(_ key: DeployKey) async {
123 await perform(["repo", "deploy-key", "remove", repoPath, key.fingerprint])
124 }
125
126 @discardableResult
127 private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
128 working = true
129 actionError = nil
130 defer { working = false }
131 do {
132 try await client.run(argv, stdin: stdin)
133 await load()
134 return true
135 } catch let error as GitbayError {
136 actionError = error.messageForTypedInput
137 } catch {
138 actionError = GitbayError.transport(error).userFacingMessage
139 }
140 // A refusal may mean the list changed elsewhere, e.g. a row
141 // removed from another device.
142 await load()
143 return false
144 }
145}